ミルク色の記録

やったこと、やってみたこと

2019年現時点の自分のWebフロントエンド開発環境

数年前、年末にその時点での自分のWebフロントエンド開発まわりの環境を記録用にまとめていたけど、 それからすっかり怠けてしまい、去年の暮れには久しぶりにやろうと思っていたものの、気がついたらすっかり年が明けていた。

最近色々ずっとアレな感じでメンタル低空飛行気味なんだけど、このまま朽ち果てていくのは嫌なので、気分転換兼ねてまとめてみた。

今回はサンプルとしてTodoアプリケーションを作りながらまとめた。

github.com

前置き

自分が普段開発しているWebフロントエンドのボリュームは2,3万行くらいのコード量のSingle Page Application。 一般公開されるWebサービスではなくて、ログインして使う業務アプリケーションみたいなのが多め。 Web API叩いて必要なデータをバックエンドとやりとりしながら、JavaScriptで動的にすべての画面を描いていく感じ。 初期描画のパフォーマンス要求とかは今のところそこまで気にしなくていい程度のもの。 それをフロントエンドチーム(自分一人)で開発・メンテしてる。

開発スタイルはLinuxデスクトップで、フルスクリーン表示したターミナルをtmuxで画面分割して、コマンド叩きつつvimでコード書いてる。 動作確認はChromeでDevTools開きながらやって、大枠できてから他のブラウザで確認する感じ。

そんな人間の環境なので、これだとできないことがあるとか、チームでやったらやりにくい、うまくいかないとかそういうのはあると思う。

偏りがあるのはさておき、とりあえずJavaScriptCSS、開発Webサーバ、テストあたりについて順にまとめていく。

JavaScriptまわり

「FlowからTypeScriptへ移行した」みたいな記事をよく見かけるようになった昨今で、 @babel/preset-typescriptが出てきたというのもあり、先のこと考えたらTypeScriptに行っとくべきかなーと思いつつ、 とりあえずBabel + Flowで型付けたECMAScriptで書き続けている。

babel.config.jsの設定はこんな感じ。

module.exports = {
  'presets': [
    '@babel/preset-env',
    '@babel/preset-react',
    '@babel/preset-flow'
  ],
  'env': {
    'development': {
      'presets': [
        'power-assert'
      ]
    },
    'production': {
      'plugins': [
        [
          'react-remove-properties',
          {
            'properties': ['data-test']
          }
        ]
      ]
    }
  }
}

ViewのライブラリにReactを使ってるので@babel/preset-reactを追加している。power-assertreact-remove-propertiesに関しては、後でテストの方で説明する。

Flowで外部ライブラリの型定義を扱うときに、flow-typedを真面目には使っていなくて、必要に応じてリポジトリから取ってきて定義ファイルを設置してみているけど、ほとんどはdeclare module.exports: any;で握りつぶしている。

バンドルはwebpackのbabel-loader頼み。

webpack.config.jsのbabel-loader部分はこんな感じ。

    module: {
      rules: [
        {
          test: /\.js$/,
          exclude: /node_modules/,
          loader: 'babel-loader'
        },

特に変わったことはしていない。

コードフォーマットをPrettierにまかせていて、ESlintは細かく設定していない。

.eslintrc.jsonはこんな感じ。

{
  "env": {
    "browser": true,
    "commonjs": true,
    "es6": true,
    "node": true,
    "mocha": true
  },
  "globals": {
    "ENABLE_SERVICE_WORKER": true
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended"
  ],
  "parser": "babel-eslint",
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    },
    "ecmaVersion": 2018,
    "sourceType": "module"
  },
  "plugins": [
    "flowtype",
    "react"
  ],
  "rules": {
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

ReactとFlow使ってるぶんの設定があるくらい。no-unused-varsの検出できれば良いくらいの感覚で使っている。

実はコードフォーマット系は苦手意識があったけど、去年Elmの勉強した時にelm-format使ったらすごい楽で、 フォーマットに頭使わなくていいことの良さを思い知って、JavaScriptでもやるようになった。

Prettierはシングルクォートについて以外、特に指定してなくてnpm scriptで使うためにpackage.jsonに次のように設定している。

"scripts": {
    "lint": "eslint src",
    "format-js": "prettier --single-quote --write 'src/**/*.js'",

ファイル保存時に自動整形はやってなくて、gitコミットする前にフォーマットして、テストとか通るの確認してからコミットしてる。

CSSまわり

LESSをやめて、Sassをやめて、PostCSSを使うようにしているけど、Bootstrap依存症なのでSassも入れることになり、結局Sassで書いている。 Bootstrapが次のバージョンからPostCSSにするとか言ってる記事を見た気がするので、次バージョンあげたらSassはやめるかもしれない。

バンドルはwebpack頼み。昔はwebpackはJavaScriptのバンドルのみに使って、CSSまわりはgulpと色々組み合わせてやっていたけど、後で書く開発Webサーバのこともあり、もうwebpackで全部やってる。

Reactを使っているので、styled-componentsとかやったほうがいいのかなーと思いつつも、 そもそもデザインやCSS得意じゃないので、デザイナーが作ってくれたCSSを取り込みながらやるスタイルを想定してCSSは別にしてる。 スコープ問題とかで苦しみたくないし、BEM記法は性に合わないので色々工夫してみていたけど、ひとまずCSS Modulesってやつでやってみている。

webpack.config.jsのその辺りの設定は次の通り。

    module: {
      rules: [
        {
          test: /src\/app\.scss$/,
          use: [
            { loader: MiniCssExtractPlugin.loader },
            {
              loader: 'css-loader'
            },
            {
              loader: 'postcss-loader',
              options: {
                plugins: function () {
                  return [
                    require('precss'),
                    require('autoprefixer')
                  ];
                }
              }
            },
            {
              loader: 'sass-loader'
            }
          ]
        },
        {
          test: /\.(css|scss)$/,
          include: /src\/presentation/,
          use: [
            { loader: MiniCssExtractPlugin.loader },
            {
              loader: 'css-loader',
              options: {
                modules: true,
                localIdentName: mode === 'production' ?
                  '[hash:base64]' : '[path][name]-[local]-[hash:base64:5]'
              }
            },
            {
              loader: 'postcss-loader',
              options: {
                plugins: function () {
                  return [
                    require('precss'),
                    require('autoprefixer')
                  ];
                }
              }
            },
            {
              loader: 'sass-loader'
            }
          ]
        }

src/app.scsssrc/presentation配下の.(css|scss)で分けてルールを設定している。

src/app.scssの方は次のようになっていて、BootstrapやFontAwesomeのスタイルはCSS Modulesの対象外にしたいので、こんな分け方になっている。

@import "./scss/_variables.scss";
@import "~bootstrap/scss/bootstrap";
$fa-font-path: "../node_modules/@fortawesome/fontawesome-free/webfonts";
@import "~@fortawesome/fontawesome-free/scss/fontawesome.scss";
@import "~@fortawesome/fontawesome-free/scss/solid.scss";
@import "~@fortawesome/fontawesome-free/scss/brands.scss";
@import "~react-toastify/scss/main.scss";

アプリケーションのViewコンポーネントのスタイルはsrc/presentation配下にコンポーネントごとにディレクトリを切って、対になるJavaScriptファイルと並べておいている。

Header
├── Header.js
└── Header.scss

こちらはCSS Modulesの対象にしている。

例えばHeader.scssの内容は次のようになっている。

@import "../../../scss/_variables.scss";

.head {
  background-color: $header-bg-color;
  .sub {
    color: $brand-title-color;
    &:hover {
      color: $brand-title-color;
    }
  }
}

これをHeader.js側で次のようにimport style from './Header.scss';して使っている。

/* @flow */
import React from 'react';
import style from './Header.scss';
import '../../../images/icon.svg';

export const Header = () => {
  return (
    <nav className={`navbar navbar-light ${style.head}`}>
      <span className={`navbar-brand ${style.sub}`}>
        <img
          src="images/icon.svg"
          width="30"
          height="30"
          className="d-inline-block align-top"
          alt=""
        />{' '}
        Todo
      </span>
    </nav>
  );
};

mini-css-extract-pluginを使って、ビルドしたらcssファイルとして出力されるようにしているけど、webpackのバージョンが上がる度にこのあたりは躓いているので、毎回ヒヤヒヤする。

開発Webサーバ

以前はgulpの中でconnectとかlive-reloadとか使ってやっていた。今ではwebpack-dev-serverでWeb APIのプロキシとか、 History APIのフォールバックとか全部任せてやっている。

これとCSSの件を合わせて、gulpを完全にやめるきっかけとなり、npm scriptとwebpackでだいたい何とかするようになった。

webpack.config.jsのwebpack-dev-server周りの設定は次の通り。

      devServer: {
        contentBase: './src',
        inline: true,
        host: '0.0.0.0',
        port: 8080,
        disableHostCheck: true,
        historyApiFallback: true,
        stats: {
          version: false,
          hash: false,
          chunkModules: false
        }
      },
      devtool: 'source-map'

これをnpm startで起動できるようにpackage.jsonに次の通り設定している。

  "scripts": {
    "start": "webpack-dev-server",

テストまわり

単体テスト

Jestの話題を見かけるようになったなーと思いつつ、あいかわらずmochaとpower-assertでやってる。spyやstubが必要になったらsinonも使っている。 そのためbabel.config.jspower-assertの設定がしてある。

以前はtestファイルの置き場はアプリケーションとは別にtestディレクトリを切って、そちらにアプリケーションと同じ階層で置くようにしていた。 しかし、importの都合とかで最終的に面倒になってアプリケーションのファイルと同列に*.spec.jsとして置くようになった。

domain
├── Todo.js
└── Todo.spec.js

*.spec.jsファイルはESLintの対象にしたくないので、.eslintignoreファイルに次のように設定している。

src/**/*.spec.js

テストの実行はnpm testで行うため、package.jsonに次のように設定してある。

"scripts": {
    "test": "mocha --require src/testSetup.js --recursive './src/**/*.spec.js'",

テストもECMAScriptで書きたいので、mochaに渡している--requireパラメータのsrc/testSetup.jsファイルで次のようにしている。

require('@babel/register')();
require('@babel/polyfill');

テストの実行はNode環境でのみにしていて、ブラウザ環境で実行するKarmaやtestemは使わなくなった。 ViewのテストはEnzymeとjsdomで頑張ってみたこともあるけど、極力しないようになった。

AjaxなどのWEB API叩く系は、以前はfetch-mockを使ってfetchを差し替えたりしていたが、 今はアプリケーションのコードでfetchを直接使わずに一枚かぶせたインターフェイスを定義して、テストの時だけ差し替えるようにしている。

例えば、今回のTodoアプリケーションだとlocalStorageを直接使わずにStorageInterfaceのオブジェクトとして、 テストの時はMapをベースにしたMemoryStorageに差し替えている。

src/infrastructure/LocalStorageRepository.jsの次のあたり。

export class LocalStorageRepository implements TodoRepository {
  _storage: StorageInterface;

  constructor(storage: StorageInterface) {
    this._storage = storage;
  }

  // 中略
}

interface StorageInterface {
  +length: number;
  getItem(key: string): ?string;
  setItem(key: string, data: string): void;
  clear(): void;
  removeItem(key: string): void;
}

export class MemoryStorage implements StorageInterface {
  _map: Map<string, string>;

  constructor() {
    this._map = new Map();
  }

  get length(): number {
    return this._map.size;
  }

  getItem(key: string): ?string {
    return this._map.get(key);
  }

  setItem(key: string, data: string): void {
    this._map.set(key, data);
  }

  clear(): void {
    this._map.clear();
  }

  removeItem(key: string): void {
    this._map.delete(key);
  }

  key(index: number): ?string {
    return Array.from(this._map.keys())[index];
  }
}

E2Eテスト

Seleniumを使ってE2Eテストをわりと書いている。 E2Eテストはコスト高いけど、手作業でする簡単な動作確認を自動で繰り返しできるようにしておく程度のモチベーションで書いている。

SeleniumJavaScriptで使うとAPIが非同期系多すぎて書きづらいので、pythonで書いている。テストにはpy.testを使っている。

環境はe2eディレクトリ配下にまとめている。

e2e
├── drivers
│   ├── chromedriver
│   └── geckodriver
├── requirements.txt
├── todo
│   ├── __init__.py
│   ├── datas
│   ├── page_objects
│   └── specs
└── venv

driversディレクトリにはWebドライバーをダウンロードしてきたものを置く。ブラウザがアップデートするとドライバーが合わなくなるので、 都度更新できるように中身はgitignore対象にしてある。

テストケースはe2e/todo/specs下に置いてある。E2Eテストを書くときにドライバーでのDOMの取得をテストにベタ書きすると 後々非常に辛くなるので、ページオブジェクトパターンを使ってテストケースではDOMを直接触らずに、 ページオブジェクト経由でシナリオに沿って画面操作して取得した結果をアサーションにかけている。

ページオブジェクトはe2e/todo/page_objects配下に置いてあり、例えばTodo一覧のページオブジェクト(todo_page.py)は次のようになっている。

from . import PageObject

class TodoListPage(PageObject):

    @property
    def el(self):
        return self.driver.find_element_by_css_selector('div[data-test="todo-list"]')

    def wait_show_todo_list(self):
        self._wait_show('div[data-test="todo-list"]')
        return self

    def wait_loading_complete(self):
        return self._wait_loading_complete(self.el)

    def get_todo_rows(self):
        rows = self.el.find_elements_by_css_selector('tr[data-test="todo-list-row"]')
        return list(map(lambda el: TodoListRow(self.driver, el), rows))

    def click_create_todo_button(self):
        b = self.el.find_element_by_css_selector('a[data-test="create-todo-button"]')
        b.click()
        return TodoFormPage(self.driver)

    def click_all_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="all-tab"]')
        t.click()
        return self

    def click_active_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="active-tab"]')
        t.click()
        return self

    def click_completed_tab(self):
        t = self.el.find_element_by_css_selector('a[data-test="completed-tab"]')
        t.click()
        return self

これを使ったTodo一覧の表示確認用のテスト(todos_test.py)は次の通り。IndexPageのopenでTodoListPageが取得できるようになっている。

class TodosTest(E2ETest):

    def test_show_todo_list_rows(self):
        p = IndexPage(self.driver, self.target_host)\
                .open()

        init_data(self.driver)

        p = p.reload()\
                .wait_show_todo_list()

        rows = p.get_todo_rows()
        assert len(rows) == 3
        r1, r2, r3 = rows;
        assert r1.get_title() == 't1'
        assert r2.get_title() == 't2'
        assert r3.get_title() == 't3'

画面のHTMLの構造が変わっても、機能自体が変わっていなければ、ページオブジェクトの修正だけでテストは変えずにすませられる。 それと、テストケース間でページオブジェクトを使いまわせるので重宝している。

CSS Modulesを使っている関係上、DOMへのアクセスにセレクタCSSクラスを使いにくい(テスト用の余計なCSSクラスとか付けたくない) のでDOMにはdata-test属性を付けてアクセスできるようにしている。

TodoListのViewだと次のようにdata-test="todo-list"とかしている。

/* @flow */

// 省略

export class TodoList extends React.Component<Props> {

  // 省略
  
  render() {
    const { visibleRecords, loading, filter } = this.props.todos;
    const rows = visibleRecords.map((t, i) => {
      return <TodoRow key={i} todo={t} />;
    });
    return (
      <div className={styles.todoList} data-test="todo-list">
        <div className="card">
          <div className="card-header">
            <ul className="nav nav-tabs card-header-tabs">

// 省略

babel.config.jsreact-remove-propertiesを利用していて、productionビルドの時はdata-test属性が取り除かれるようにしてある。

ビルド用のタスクはpackage.jsonに次のように設定している。

  "scripts": {
    "clean": "rm -rf build",
    "build": "npm run clean && NODE_ENV=production webpack",
    "build-testing": "npm run clean && NODE_ENV=testing webpack",

npm run buildでビルドするとproductionビルドをする。 npm run build-testingでビルドするとdata-test属性がついたままのビルドになるので、E2Eテストの時はこちらを使う。

今回のサンプルアプリケーションではWEB APIを叩くことも無いし、バックエンドにDBやredisがあったりするわけでもないので、 npmで立ち上げたWebサーバにアクセスするだけにしているけど、 業務ではバックエンドをDockerで動かせるようにしておいて、docker-composeでサーバ環境を立ち上げて、 テスト実行ごとにDB初期化とかデータ投入とか色々やって動かしている。

フロントエンドチーム外のメンバーへの提供

フロントエンドのgitリポジトリをクローンしてもらって各自にビルドしてもらうやり方だと、普段フロントエンドの開発をしてないメンバーの環境ではNodeのバージョンが古かったり、npmのバージョンが古かったりして、「ビルドできないんだけど...」と言われることが多かった。

そこでDocker内でビルドできるようにビルド用のDockerイメージを作成するスクリプト(create_docker_image.sh)と、 そのイメージを使ってビルドするスクリプト(build_on_docker.sh)を同梱するようにしている。

ビルド用のDockerイメージを作成するcreate_docker_image.shは次のようにしてある。

#!/bin/sh
SCRIPT_DIR=$(cd $(dirname ${0}) && pwd)
cd "$SCRIPT_DIR/.."

DOCKER_IMAGE_NAME="nodejs10-for-frontend-build"
IMAGE=`docker images | awk '{print $1}' | grep $DOCKER_IMAGE_NAME`
if [ "$IMAGE" != "$DOCKER_IMAGE_NAME" ]; then
    docker build -t $DOCKER_IMAGE_NAME .
fi

作成するDockerイメージのDockerfileは次の通り。

FROM ubuntu:18.04

RUN apt-get update && \
    apt-get install -y locales && \
    locale-gen en_US.UTF-8

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

RUN sed -i.bak -e "s%http://archive.ubuntu.com%http://jp.archive.ubuntu.com%g" /etc/apt/sources.list && \
    apt-get update && \
    apt-get install -y unzip wget git curl gnupg && \
    curl -sL https://deb.nodesource.com/setup_10.x | bash - && \
    apt-get install -y nodejs && \
    mkdir /.npm && \
    chmod 777 /.npm && \
    mkdir /.config && \
    chmod -R 777 /.config

node用の公式イメージでも良さそうだけど、とりあえず昔からの流れでubuntuベースで作ってる。

Docker内でフロントエンドのビルドをするbuild_on_docker.shは次の通り。

#!/bin/sh

SCRIPT_DIR=$(cd $(dirname ${0}) && pwd)
cd "$SCRIPT_DIR/.."
DOCKER_IMAGE_NAME="nodejs10-for-frontend-build"
PROJECT_ROOT_DIR=`pwd`
USER_ID=`id -u`
GROUP_ID=`id -g`

RUN_ACTION=`cat << EOS
    cd /tmp/frontend && \
    npm install && \
    npm run build
EOS`
docker run -u $USER_ID:$GROUP_ID \
    -v $PROJECT_ROOT_DIR:/tmp/frontend:rw \
    --rm $DOCKER_IMAGE_NAME /bin/sh -c "$RUN_ACTION"
rm -rf Todo Todo.zip Todo-*.zip
mv build Todo
REV=`git rev-parse HEAD`
echo $REV > Todo/GIT_REVISION
zip Todo.zip -r Todo
cp Todo.zip "Todo-$REV.zip"

ビルドのついでにzipで固めることと、gitのリビジョンつけたzipも作るようにしてある。

とりあえずこれらを使ってもらうようにしたが、そもそもビルド済みのを配れるようにしておけば良いのでは...と思い、 JenkinsなどのCIでこのスクリプトを使ってビルドし、zipをs3とかにアップロードして配布している。

まとめ

昔と比べるとgulpを捨ててwebpackベッタリになった。

こうして書き出してみると、偏った環境でやっているせいか、甘えてゆるふわになっている部分が結構ある気がする。 必要に応じてそのあたりを引き締めていくのが今後の課題となりそう。

とりあえず色んな動向をうかがいつつ、自分のペースで調整していくつもり。