この記事は 第2のドワンゴ Advent Calendar 2017 7日目の記事です。
昨日の記事は@yue82さんでRTL設計スタイルガイドのアンチパターンをやってみたでした :clap:

こんにちは。ニコニコ静画でフロントエンド開発を行っているnagisioです。 今年も冬コミに落ちてしまいました :innocent:
去年の記事はRe:ゼロから始めるElectron開発生活でした。Electronに関して、残念ながら最近はあまり書いてないのですが、Reactは既に趣味でも仕事でも必須なフレームワークとなりました。
本記事ではそんなReactを用いたプロダクト開発について、どのように開発を進めていくのかを追っていきます。

はじめに(宣伝枠)

ニコニコ静画チームにおいて、フロントエンドでの新規開発は基本的に少人数(1〜2人程度)で行っています。比較的小規模な開発が多く、例えば直近でリリースしたのがニコニコ漫画@C93です。

c93.png

これはコミックマーケットでのサークル情報や新刊情報を投稿できるサービスで、毎回(8月と12月)新機能をリリースするなど改良を繰り返しています。これまでのリリースをまとめると、

  • ニコニコ漫画@C91(2016/12)
    • 初めてのサービスリリース。基本的な投稿機能および共有機能など
  • ニコニコ漫画@C92(2017/08)
    • 画像の好きな部分にスタンプを押せるスタンプ機能をリリース 1
  • ニコニコ漫画@C93(2017/12)
    • 新刊情報をまとめた、サークル情報を提供するサークル機能をリリース

といった具合になります。このプロジェクトでは一部ページにReactおよびSPAを採用しており、毎回サービスの改良や機能追加をしながら、新しいフレームワーク等を積極的に採用しているプロジェクトとなっています。

さて、開発期間や開発人数が限られている中で、新しいものを取り入れていくためには、開発を円滑に進めるための土台作りが必要不可欠です。以下では、Reactとの組み合わせで最近採用している(ニコニコ漫画@C93でも採用しました)MobXフレームワークについての解説を中心に、クイックな開発を実現させるための方法について紹介していきます。

安心してコーディングができる環境を作っておく

環境構築はある程度のテンプレを確立しておき、それを基に構築していきます。Yarnを使います :cat2:
クイックな開発を進めるためにも、ここは毎回気合を入れて最新版に追従し、必要に応じてマイグレートを行ないます。

Webpack

私は基本webpackおよびwebpack-dev-serverを使っています。2
また、webpackは今年はじめに v2.2.0 がリリースされましたが、最新版は v3.10.0 となっています(2017/12/7現在)。
特に v1.x から v2.x においては大きな変更が行われているので、公式のマイグレーションガイドを参考に、早めに移行しておきましょう。

ESLint

正しく綺麗:sparkles:なコーディングを行うために、ESLintは導入していきます。
eslint-config-airbnbをベースとし、eslint-config-prettierおよびeslint-plugin-flowtypeを含めています。少人数開発のため、割と厳しいルールにしていますが、この辺は慣れだと思います。
.eslintrc ファイルは大抵以下のようになります。秘伝のタレ感があります。

.eslintrc
{
  "extends": [
    "airbnb",
    "plugin:flowtype/recommended",
    "prettier",
    "prettier/flowtype"
  ],
  "env": {
    "browser": true,
    "node": true,
    "es6": true,
    "jest": true
  },
  "parser": "babel-eslint",
  "parserOptions": {
    "ecmaFeatures": {
      "jsx": true
    }
  },
  "plugins": [
    "flowtype",
    "prettier"
  ],
  "settings": {
    "import/resolver": {
      "webpack": {
        "config": "webpack.config.js"
      }
    }
  },
  "rules": {
    "prettier/prettier": ["error", {
      "printWidth": 100,
      "singleQuote": true
    }],
    "comma-dangle": ["error", "never"],
    "import/no-extraneous-dependencies": ["error", {
      "devDependencies": [
        "test/**",
        "**/*.config.js",
      ],
      "optionalDependencies": false
    }]
  }
}

もしCSSを書く予定があるならstylelintも導入すると良いでしょう。

Flow

安心感:tea:を持ってJavaScriptを書くために型は必須になりました(個人の感想です)。
型を導入するためにはAltJSであるTypeScriptがありますが、ES2015+で書ける点と導入の敷居が低い点からFlowを採用しています。
ちなみにflow-typedは導入しなくなりました(入れなくても特に問題なく動いています)。
.flowconfig ファイルに記述が必要なのは基本的には name_mapper ぐらいです。外部モジュールで何か怒られたら [ignore] に追記しましょう。
また、v0.53.0 からReactのサポートが手厚くなりました。公式ドキュメントが参考になります。

.flowconfig
[ignore]

[include]
./src/js/

[libs]

[options]
esproposal.decorators=ignore
module.name_mapper='^\(components\|stores\|types\|utils\)\/\(.*\)$' -> '<PROJECT_ROOT>/src/js/\1/\2'

Prettier

Prettierはコードフォーマッタです。雑に書いても大丈夫です、勝手に直してくれます。素晴らしいツール :beer:

/* input */

const foo=[{ a: 1} , {b: 2} ]

/* output */

const foo = [{ a: 1 }, { b: 2 }];

Reduxの諸々をやめてMobxを使う

Reduxとの戦い

私はこれまでReactと組み合わせるFluxフレームワークとしてReduxを採用してきました。しかしながらReact+Reduxでは非同期処理が書き辛いため、ミドルウェアを導入することが一般的です。
それは例えばredux-thunkであったり、redux-sagaであったりします。また、immutable.jsを組み合わせる場合もあります。
redux-sagaを利用すると、非同期処理はおおよそ以下のようなAction/Saga/Reducerになるでしょう。

action.js
// requestを投げるだけのaction
export const fetchUserRequest = () => ({
  type: 'FETCH_USER_REQUESTED'
});
saga.js
import { call, put, takeEvery } from 'redux-saga/effects';

function* fetchUser(id: number) {
  try {
    const user = yield call(Api.fetchUser, id);
    // fetchに成功したらsucceededのActionを投げる
    yield put({ type: 'FETCH_USER_SUCCEEDED', user });
  } catch (error) {
    // fetchに失敗したらfailedのActionを投げる
    yield put({ type: 'FETCH_USER_FAILED', error });
  }
}

export default function* watchFetchUser() {
  // requestのActionが飛んでくるまで待機する
  yield takeEvery('FETCH_USER_REQUESTED', fetchUser);
}
reducer.js
// stateの型定義
type State = {
  isFetching: boolean,
  user: ?User, // ?UserはMaybeTypeでUser, null, undefinedのいずれかを許容する
  error: ?Error
};

const initialState: State = {
  isFetching: false,
  user: null,
  error: null
};

export default function (action: Action, state: State = initialState) {
  switch (action.type) {
    case 'FETCH_USER_REQUESTED':
      return Object.assign({}, state, { isFetching: true });
    case 'FETCH_USER_SUCCEEDED':
      return Object.assign({}, state, { isFetching: false, user: action.user });
    case 'FETCH_USER_FAILED':
      return Object.assign({}, state, { isFetching: false, error: action.error });
    default:
      return state;
  }
}

Fluxフレームワーク単体で見れば非常に明瞭なフローであり、設計の指針が決まりさえすれば、コーディングにおいて迷うことは少ないように思います。
しかしながら、 とにかく記述量が多い ことがネックでした。

MobXとの出会い

そこで注目したのがMobXです。先に言っておくとMobXは Fluxフレームワークではない です。
とりあえずコードを見てみましょう。やることは上述した非同期処理と同じです。
ちなみにES2017の async/await 構文とESNextのDecorators構文を使用します。3

UsetStore.js
export default class UserStore {
  @observable isFetching: boolean;
  @observable user: ?User;
  @observable error: ?Error;

  constructor() {
    this.isFetching = false;
    this.user = null;
    this.error = null;
  }

  @action
  async fetchUser(id: number) {
    try {
      this.isFetching = true;
      const user = await Api.fetchUser(id);
      this.user = user;
    } catch (error) {
      this.error = error;
    } finally {
      this.isFetching = false;
    }
  }
}

基本的には @observable で監視対象の変数をマークして、 @action に処理を書くだけです。これだけで先程のAction/Reducer/Sagaに当たる部分が全て作成できてしまいました。
MobXでのState管理はたった1つStore層をつくるだけです。ここに記述量の差が大きく生まれると思います。

MobXは新しい切り口

MobXはStore層を作るだけというシンプルさから素直に書ける一方で、 自由度が非常に高い ために破綻しやすいという欠点があります。アプリケーション全体のストアの設計が難しく、他のストアと密に依存したストアが簡単に生まれ、結果無駄なレンダリングがたくさん発生していた―ということがよくあります :cry:
しかしながら、その驚くほどの簡単さと圧倒的な書き味の良さの体験は、目を見張るものがあります。特に、小規模なプロダクトでは大きな恩恵を受けることが出来ると思います。

テストはちゃんと書く。だけど最小限に書く。

Flow+ESLintの効果は絶大で、圧倒的にエラーが減ります。ですが、エラーは間違いなくどこかで発生するものと考え、テストもきっちりと書きます。ここで嬉しいお知らせなのは、MobXを使うとテストコードも減るという点です(:bangbang:)。

基本はJest

テスティングフレームワークの選定は紆余曲折があり、Mocha :point_right: AVA :point_right: Jestと色々と試した結果、現在はJestに落ち着いています。ただし、Jestの機能を補うためにEnzymeSinonを組み合わせています。
Jestを採用している理由ですが、その1つが セットアップの容易さ です。これまでテスト環境の構築にはjsdomを導入したりES2015+のコードを変換するなどの手順が必要でしたが、Jestは基本的にconfigファイルを用意してあげればすぐに開始できます(以下)。

jest.config.js
module.exports = {
  testRegex: '/test/.*\\.test\\.(js|jsx)$',
  coveragePathIgnorePatterns: ['<rootDir>/test/helpers/'],
  moduleFileExtensions: ['js', 'jsx'],
  modulePaths: ['<rootDir>/src/js/'],
  setupFiles: ['<rootDir>/test/helpers/shim.js']
};

また、Jestはコードカバレッジレポートの機能が内蔵されているので、簡単にコードカバレッジを出力できます(下図)。

cov.png

スナップショットテストを活用する

Jestを採用しているもう一つの理由がスナップショットテストです。これまでReactコンポーネントのレンダリング結果が正しいか確認するためには、Enzymeを主に用いてきました。しかしながら、これは コンポーネントの規模が大きくなるほど大量のアサーションが必要 になり、やや煩雑な作業でした。そこでスナップショットテストの出番です。
以下はMaterialIconsのアイコンを表示するIconコンポーネントの実装です。

Icon.jsx
// @flow
import React from 'react';
import type { Node } from 'react';

// propsの型定義
type Props = {|
  name: string,
  style?: Object
|};

export default class Icon extends React.Component<Props> {
  static defaultProps = {
    style: {}
  };

  shouldComponentUpdate(): boolean {
    return false;
  }

  render(): Node {
    return (
      <i className="material-icons" style={this.props.style}>
        {this.props.name}
      </i>
    );
  }
}

これに対し、以下のようなテストコードを用意します。

Icon.test.jsx
import React from 'react';
import renderer from 'react-test-renderer';
import Icon from 'components/Icon/Icon';

describe('<Icon />', () => {
  it('renders correctly', () => {
    const props = {
      name: 'foo',
      style: { color: '#fff' }
    };
    const tree = renderer.create(<Icon {...props} />).toJSON();

    expect(tree).toMatchSnapshot();
  });
});

Jestを実行すると、以下のようなファイルが出力されます。

Icon.test.jsx.snap
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`renders correctly 1`] = `
<i
  className="material-icons"
  style={
    Object {
      "color": "#fff",
    }
  }
>
  foo
</i>
`;

この後、もしIconコンポーネントに変更が加わった場合、スナップショットテストが失敗します。
これにより、Reactコンポーネントのインタラクションのテストはこれまで通りEnzymeで行ないますが、レンダリング結果はスナップショットテストに任せることができるようになりました。

ところでCSSは

さて、ここまで一切触れてこなかったCSSですが、基本的に去年の記事でも触れたCSSModulesの仕組みを活用しています。また、PostCSSを採用しています。

AtomicDesignを完璧に実現するのは厳しい

CSSModulesではReactコンポーネント単位でファイルを分割するので、Reactコンポーネントの設計が肝になります。コンポーネント設計は基本的にはAtomicDesignの考え方を基にしています。AtomicDesignについては以下の記事が参考になります。

ただし、AtomicDesignを完璧に実現しようとすると、コンポーネントが膨大になっていきます :cold_sweat:
また、Store管理の兼ね合いで、どうしてもコンポーネントが大きくなってしまうこともあり、このあたりは妥協点を設ける必要があると思います。

サイズを決めるのは親コンポーネントである

CSSModulesを書いていく上で直面するのが、 width/height のサイズや margin の余白を誰が定義するのかという問題です。ここで重要なのは、最小単位(AtomicDesignでいうAtom)のコンポーネントは基本的に 具体的なサイズ/余白を決定してはいけない ということです。これは最小単位のコンポーネントは汎用的なコンポーネントであり、様々なコンポーネントで使用されることを前提としているためです。
ニコニコ漫画@C93での例を解説すると、 Buttonコンポーネントは以下のように複数のパターンが存在することがわかります。

button.png

配置される場所によって width の幅が異なることがわかります。
実際のButtonコンポーネントのPostCSSファイルは以下のような感じになっています(一部)。

Button.pcss
.button {
  display: inline-block;
  padding: 8px 15px;
  cursor: pointer;
  text-align: center;
  border-radius: 5px;
  outline: none;

  &[data-full-width="true"] {
    width: 100%;
  }

  &[data-button-type="default"] {
    color: #333;
    background-color: rgba(51, 51, 51, .18);
  }

  &[data-button-type="primary"] {
    color: #fff;
    background-color: #00bcd4;
  }

  &[data-button-type="disabled"] {
    color: #999;
    background-color: #ccc;
    cursor: default;
  }
}

ここで具体的な数値(=px)が使われているのは paddingborder-radius プロパティだけであることがわかります。 width: 100% がありますが、これはレンダリングする親に対してサイズを任せているので、Buttonコンポーネント自身は width の具体的な数値は持っていません。
このようにCSSを設計することで、どの場所にコンポーネントを置いても意図したスタイルで表示させることができます。また、FlexBoxと組み合わせることでレスポンシブに強くなり、どんな環境でもスタイルを崩さずに表示することが可能です。4

まとめ

本記事では少人数でのフロントエンド開発において、開発速度の向上と、生産性の向上の両立を実現させるための方法を紹介しました。

  • がっちりと環境構築をすることでコーディング速度は向上します :dancer:
  • 小規模開発ならMobXをオススメします。テストはJestで最小限に。スナップショットテストを活用しましょう :muscle:
  • CSSは汎用的なコンポーネントになるように組んでいきます。AtomicDesignはCSS設計の良い指針になるでしょう :yum:

明日の担当は@viviljpさんです :raised_hands:


  1. 所謂「見せられないよ」機能 

  2. 最近はrollupも気になっています 

  3. ES2015だけでも書けますが、こちらの方がさくっと書けます 

  4. 該当のページではグリッドデザインになっていますが、レスポンシブに変更しても崩れないように初めから設計しています