メインコンテンツへ移動

React 背後のツール化体系

無料2018-01-28#Tool#React工具链#React engineering architecture#前端工程化#前端工具链#前端工作流最佳实践

React チームのフロントエンドツール体系における探求

一.概観

React ツールチェーンタグクラウド:

Rollup    Prettier    Closure Compiler
Yarn workspace    [x]Haste    [x]Gulp/Grunt+Browserify
ES Module    [x]CommonJS Module
Flow    Jest    ES Lint    React DevTools
Error Code System    HUBOT(GitHub Bot)    npm

P.S. [x] 付きは以前使用していたが、最近(React 16)使用しなくなったもの

簡単な分類は以下の通り:

開発:ES Module, Flow, ES Lint, Prettier, Yarn workspace, HUBOT
構築:Rollup, Closure Compiler, Error Code System, React DevTools
テスト:Jest, Prettier
リリース:npm

ES モジュールメカニズムでソースコードを組織し、タイプチェックと Lint/フォーマットツールを補助とし、Yarn でモジュール依存を処理し、HUBOT が PR をチェック;Rollup + Closure Compiler で構築し、Error Code メカニズムで本番環境エラー追跡を実現し、DevTools がバンドルチェックを側面から補助;Jest が単体テストを駆動し、フォーマットされたバンドルで構築結果が十分にクリーンかを確認;最後に npm で新しい package をリリース

プロセス全体はそれほど複雑ではないが、いくつかの細部での考慮は非常に深く、例えば Error Code System、二重保険 envification(dev/prod 環境区別)、リリースフローのツール化

二.開発ツール

CommonJS Module + Haste -> ES Module

React 15 以前のバージョンはすべて CommonJS モジュールで定義、例えば:

var ReactChildren = require('ReactChildren');
module.exports = React;

現在は ES Module に切り替え。いくつかの理由:

  • モジュール導入/エクスポート問題を早期発見するのに役立つ

    CommonJS Module は存在しないメソッドを簡単に require でき、呼び出してエラーになるまで問題を発見できない。ES Module の静的モジュールメカニズムは importexport が名前で一致する必要がある。否则コンパイル構築でエラー

  • bundle size 上の優位性

    ES Module は tree shaking で bundle をよりクリーンにできる。根本的な理由は module.exports がオブジェクトレベルエクスポートで、export はより細粒度のアトミックレベルエクスポートをサポート。另一方面、名前で導入することで rollup などのツールがモジュールを平らに拼接でき、圧縮ツールがこれに基づいてより暴力的な変数名難読化を行い、さらに bundle size を減少

ソースコードのみを ES Module に切り替え、単体テスト用例は切り替えなかった。CommonJS Module が Jest のいくつかの特性(例えば resetModules)により友好的だから(ES Module に切り替えても、モジュール状態隔離が必要なシーンでは、依然として require を使用する必要 がある。そのため切り替えの意味は大きくない)

Haste については、React チームがカスタマイズしたモジュール処理ツールで、長い相対パスの問題を解決するために使用。例えば:

// ref: react-15.5.4
var ReactCurrentOwner = require('ReactCurrentOwner');
var warning = require('warning');
var canDefineProperty = require('canDefineProperty');
var hasOwnProperty = Object.prototype.hasOwnProperty;
var REACT_ELEMENT_TYPE = require('ReactElementSymbol');

Haste モジュールメカニズム下ではモジュール参照に明確な相対パスを示す必要がなく、プロジェクトレベルで一意のモジュール名で自動検索。例えば:

// 宣言
/**
 * @providesModule ReactClass
 */

// 引用
var ReactClass = require('ReactClass');

表面的には長いパス参照の問題を解決(プロジェクト構造の深いネストという根本問題を解決したわけではない)が、非標準モジュールメカニズムを使用するにはいくつかの典型的な欠点がある:

  • 標準と合わず、標準エコシステムのツールに接入する際に適応問題に直面

  • ソースコードが読み難く、モジュール依存関係を理解しにくい

React 16 では大部分のカスタムモジュールメカニズムを削除(ReactNative にはまだ一小部分残っている)。Node 標準の相対パス参照を採用。長いパスの問題はプロジェクト構造をリファクタリングして徹底的に解決。フラットなディレクトリ構造を採用(同 package 下で最深 2 レベル参照、跨 package は Yarn 処理を経てトップレベル絶対パス参照)

Flow + ES Lint

Flow はタイプエラーをチェックし、タイプ不一致の潜在的な問題を早期発見。例えば:

export type ReactElement = {
  $$typeof: any,
  type: any,
  key: any,
  ref: any,
  props: any,
  _owner: any, // ReactInstance or ReactFiber

  // __DEV__
  _store: {
    validated: boolean,
  },
  _self: React$Element<any>,
  _shadowChildren: any,
  _source: Source,
};

静的タイプ宣言及びチェックの他に、Flow の最大の特徴はReact コンポーネント及び JSX への深度サポート

type Props = {
  foo: number,
};
type State = {
  bar: number,
};
class MyComponent extends React.Component<Props, State> {
  state = {
    bar: 42,
  };

  render() {
    return this.props.foo + this.state.bar;
  }
}

P.S. Flow の React サポートに関する詳細情報は、Even Better Support for React in Flow を参照

またエクスポートタイプチェックの Flow「魔法」があり、mock モジュールのエクスポートタイプがソースモジュールと一致するかを検証:

type Check<_X, Y: _X, X: Y = _X> = null;
(null: Check<FeatureFlagsShimType, FeatureFlagsType>);

ES Lint は構文エラー及び約定コーディングスタイルエラーをチェック。例えば:

rules: {
  'no-unused-expressions': ERROR,
  'no-unused-vars': [ERROR, {args: 'none'}],
  // React & JSX
  // Our transforms set this automatically
  'react/jsx-boolean-value': [ERROR, 'always'],
  'react/jsx-no-undef': ERROR,
}

Prettier

Prettier はコードを自動フォーマット。いくつかの用途:

  • 旧コードを統一スタイルにフォーマット

  • 提交前に変更がある部分をフォーマット

  • 継続的統合と連携し、PR コードスタイルが完全に一致することを保証(否则 build 失敗し、スタイル差異のある部分を出力)

  • IDE に統合、日常没事フォーマット

  • 構築結果をフォーマット。一方面 dev bundle の可読性を向上。另外有助於 prod bundle 中の冗長コードを発見

統一コードスタイルはもちろん協力に有利。另外、オープンソースプロジェクトでは、スタイルが様々な PR に頻繁に直面。厳格なフォーマットチェックを継続的統合の強制環節とすることでコードスタイル差異の問題を徹底的に解決でき、オープンソース作業の簡素化に役立つ

P.S. プロジェクト全体に強制統一フォーマットはやや極端に見えるが、大胆な試み。しかし効果はなかなか良いとのこと:

Our experience with Prettier has been fantastic, and we recommend it to any team that writes JavaScript.

Yarn workspace

Yarn の workspace 特性は monorepo の package 依存を解決(lerna bootstrap と類似の作用)。node_modules 下にソフトリンクを建立して Node モジュールメカニズムを「騙す」

Yarn Workspaces is a feature that allows users to install dependencies from multiple package.json files in subfolders of a single root package.json file, all in one go.

package.json/workspaces で Yarn workspaces を配置:

// ref: react-16.2.0/package.json
"workspaces": [
  "packages/*"
],

注意:Yarn の実際処理は Lerna と類似。どちらもソフトリンクで実現。ただしパッケージマネージャー这一層で monorepo package サポートを提供する方がより合理的。詳細な理由は Workspaces in Yarn | Yarn Blog を参照

そして yarn install 後に愉快地跨 package 引用可能:

import {enableUserTimingAPI} from 'shared/ReactFeatureFlags';
import getComponentName from 'shared/getComponentName';
import invariant from 'fbjs/lib/invariant';
import warning from 'fbjs/lib/warning';

P.S. 另外、Yarn と Lerna はシームレスに結合可能。useWorkspaces オプションで依存処理部分を Yarn に任せる。詳細は Integrating with Lerna を参照

HUBOT

HUBOT は GitHub ロボット。通常以下に使用:

  • 継続的統合に接入。PR が構築/チェックをトリガー

  • Issue を管理。非アクティブな討論ポストをクローズ

主に PR と Issue を中心に自動化を行う。例えば React チームは計画(現在まだ実施していない)ロボットが PR の bundle size への影響を返信。これにより bundle size の継続最適化を督促

現在毎回構築で bundle size 変化をファイルに出力し、Git に変化を追跡させる(提交)。例えば:

// ref: react-16.2.0/scripts/rollup/results.json
{
  "bundleSizes": {
    "react.development.js (UMD_DEV)": {
      "size": 54742,
      "gzip": 14879
    },
    "react.production.min.js (UMD_PROD)": {
      "size": 6617,
      "gzip": 2819
    }
  }
}

欠点は想像の通り。この json ファイルは頻繁にコンフリクト。要么マージコンフリクトに精力を浪費、要么この自動生成された面倒なファイルを提交するのを怠け、バージョンが滞后。そのため GitHub Bot でこの面倒を抽出する計画

三.構築ツール

bundle 形式

以前は 2 種類の bundle 形式を提供:

  • UMD 単一ファイル。外部依存として使用

  • CJS 散ファイル。自行構築 bundle をサポート(React をソースコード依存として)

いくつかの問題が存在:

  • 自行構築バージョンが不一致:異なる build 環境/配置で構築された bundle はすべて異なる

  • bundle パフォーマンスに最適化空間:打包 App の方式で類庫を構築するのはあまり適切でない。パフォーマンスに向上余地

  • 実験的最適化試行に不利:散ファイルモジュールに打包、圧縮などの最適化手段を適用できない

React 16 は bundle 形式を調整:

  • CJS 散ファイルを提供しなくなった。npm から取得するのは構築済みで、統一最適化された bundle

  • UMD 単一ファイルと CJS 単一ファイルを提供。それぞれ Web 環境と Node 環境(SSR)に使用

再分割できない類庫の姿で、最適化環節をすべて収め、bundle 形式による制限から脱却

Gulp/Grunt+Browserify -> Rollup

以前の構築システムは Gulp/Grunt+Browserify で手搓りした一組のツール。後に拡張面でツールに制限。例えば:

  • Node 環境でパフォーマンスが良くない:頻繁な process.env.NODE_ENV アクセスが SSR パフォーマンスを拖慢。しかし類庫角度から解決する方法がない。Uglify がこれに依存して无用コードを去除するため

そのため React SSR パフォーマンス最佳実践には一般に「React を重新打包し、構築時に process.env.NODE_ENV を去除」という一条がある(もちろん、React 16 はもうこのようにする必要がない。理由は上で言及した bundle 形式変化)

過度に複雑(overly-complicated)なカスタム構築ツールを丢弃し、より適切な Rollup に改用:

It solves one problem well: how to combine multiple modules into a flat file with minimal junk code in between.

P.S. Haste -> ES Module でも Gulp/Grunt+Browserify -> Rollup の切り替えでも、非標準のカスタマイズ方案から標準の開放的方案に切り替え。「手搓り」面で教訓を吸収すべき。なぜ業界規範のものが私たちのシーンで適用せず、自分で造る必要があるのか?

mock module

構築時に動的依存のシーンに直面する可能性:異なる bundle が機能は類似しているが実装に差異がある module に依存。例えば ReactNative のエラー提醒メカニズムは赤枠を表示。Web 環境は Console に出力

一般解法は 2 種類:

  • ランタイム動的依存(注入):2 つを bundle に入れ、ランタイムで配置または環境に応じて選択

  • 構築時に依存を処理:数份を多構築。異なる bundle に各自必要な依存モジュールを含める

明らかに構築時処理の方がよりクリーン。つまり mock module。開発中はこのような差異を気にせず、構築時に環境に応じて自動的に具体的な依存を選択。手書きの簡単な Rollup プラグインで実現:動的依存配置 + 構築時依存置換

Closure Compiler

google/closure-compiler は非常に強力な minifier。3 種類の最適化モード(compilation_level)がある:

  • WHITESPACE_ONLY:コメント、余分な句読点記号と空白文字を去除。論理機能上はソースコードと完全に等価

  • SIMPLE_OPTIMIZATIONS:デフォルトモード。WHITESPACE_ONLY の基础上でさらに変数名を短縮(局所変数と関数形参)。論理機能は基本的に等価。特殊情況(例えば eval('localVar') で名前で局所変数にアクセスし fn.toString() を解析)を除く

  • ADVANCED_OPTIMIZATIONS:SIMPLE_OPTIMIZATIONS の基础上でより強力なリネーム(全局変数名、関数名と属性)。无用コードを去除(到達できない、使用しない)。メソッド呼び出しと定数をインライン(划算なら、関数呼び出しを関数本体内容に、定数をその値に置換)

P.S. compilation_level の詳細情報は Closure Compiler Compilation Levels を参照

ADVANCED モードは過度に強力:

// 入力
function hello(name) {
  alert('Hello, ' + name);
}
hello('New user');

// 出力
alert("Hello, New user");

P.S. Closure Compiler Service でオンライン試玩可能

移行切り替えには一定のリスクがある。そのため React が使用しているのはまだ SIMPLE モード。しかし後続に ADVANCED モードを开启する計画がある可能性。Closure Compiler で bundle size を最適化を充分利用

Error Code System

In order to make debugging in production easier, we're introducing an Error Code System in 15.2.0. We developed a gulp script that collects all of our invariant error messages and folds them to a JSON file, and at build-time Babel uses the JSON to rewrite our invariant calls in production to reference the corresponding error IDs.

簡言之、prod bundle 中で詳細なエラーメッセージを対応するエラーコードに置換。本番環境でランタイムエラーをキャプチャするとエラーコードとコンテキスト情報を投げ出し、エラーコード変換サービスに投げて完全なエラー情報を還元。これにより prod bundle をできるだけクリーンに保証すると同時に、開発環境と同じ詳細エラー報告能力を保留

例えば本番環境下の非法 React Element エラー:

Minified React error #109; visit https://reactjs.org/docs/error-decoder.html?invariant=109&args[]=Foo for the full message or use the non-minified dev environment for full errors and additional helpful warnings.

非常に面白いテクニック。確かに開発体験向上に多くの心思をかけた

envification

いわゆるenvificationとは環境別に build。例えば:

// ref: react-16.2.0/build/packages/react/index.js
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./cjs/react.production.min.js');
} else {
  module.exports = require('./cjs/react.development.js');
}

常用手段。構築時に process.env.NODE_ENV を目標環境対応の文字列定数に置換。後続構築プロセス中(打包ツール/圧縮ツール)で余分なコードを剔除

package 入口ファイルの他に、里面でも同じ判断を二重保険として実施:

// ref: react-16.2.0/build/packages/react/cjs/react.development.js
if (process.env.NODE_ENV !== "production") {
  (function() {
    module.exports = react;
  })();
}

此外、開発者が誤って dev bundle を上线することを心配。そのため React DevTools にも一点提醒を追加:

This page is using the development build of React. 🚧

DCE check

DCE(dead code eliminated) check とは无用コードが正常に去除されたかを確認

一種の特殊情況を考慮:process.env.NODE_ENV がランタイムに設定されるのも不合理(もう一方の環境の余分なコードが存在する可能性)。そのため React DevTools で bundle 環境チェックも実施:

// ref: react-16.2.0/packages/react-dom/npm/index.js
function checkDCE() {
  if (process.env.NODE_ENV !== 'production') {
    throw new Error('^_^');
  }
  try {
    __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(checkDCE);
  } catch (err) {
    console.error(err);
  }
}
if (process.env.NODE_ENV === 'production') {
  checkDCE();
}

// DevTools 即ち__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE 宣言
checkDCE: function(fn) {
  try {
    var toString = Function.prototype.toString;
    var code = toString.call(fn);
    if (code.indexOf('^_^') > -1) {
      hasDetectedBadDCE = true;
      setTimeout(function() {
        throw new Error(
          'React is running in production mode, but dead code ' +
            'elimination has not been applied. Read how to correctly ' +
            'configure React for production: ' +
            'https://fb.me/react-perf-use-the-production-build'
        );
      });
    }
  } catch (err) { }
}

原理は Redux の [minified 検出](/articles/redux ソースコード解读/#articleHeader5) と類似。まず dev 環境判断を含むメソッドを宣言。判断中に一個の標識文字列(上例では ^_^)を含め、然后ランタイム(DevTools を通じて)で fn.toString() ソースコードをチェック。該標識文字列を含んでいれば DCE 失敗(无用コードが build プロセス中に去除されていない)と判断。非同期 throw

P.S. DCE check の詳細情報は、Detecting Misconfigured Dead Code Elimination を参照

四.テストツール

Jest

Jest は Facebook が推出したテストツール。亮点は以下の通り:

  • Snapshot Testing:DOM ツリースナップショットで React/React Native コンポーネントの UI テストを実施。コンポーネントレンダリング結果を以前のスナップショットと比較。差異がなければ通過

  • ゼロ配置:Mocha のように強大で柔軟だが配置が煩雑ではない。Jest は开箱即用。テスト駆動、アサーションライブラリ、mock メカニズム、テストカバレッジなどを自带

Snapshot Testing は UI 自動化テストの一般做法と類似。正しい結果をスクリーンショットして基準とする(この基準は継続的に更新する必要がある。そのためスナップショットファイルは一般にソースコードと一緒に提交)。後続毎回変更後に以前のスクリーンショットとピクセルレベルで比較。差異があれば問題があると判断

另外、React App テストに言及すると、もう一つより狠いものがある:Enzyme。Jest + Enzyme で React コンポーネントの深度テストを実施可能。詳細情報は Unit Testing React Components: Jest or Enzyme? を参照

P.S. フロントエンド UI 自動化テストの一般方法については、如何进行前端自动化测试? - 张云龙的回答 - 知乎 を参照

P.S. repl.it - try-jest by @amasad でオンライン試玩可能

preventing Infinite Loops

即ち死循環チェック。テストプロセスが死循環で阻塞されることを希望しない(React 16 で再帰をループに変更した後多くの while (true) がある。彼らはあまり放心していない)。処理方式は死再帰チェックと類似:最大深度(TTL)を制限。Babel プラグインで実施。テスト環境構築時にチェックを注入:

// ref: https://github.com/facebook/react/blob/master/scripts/jest/preprocessor.js#L38
require.resolve('../babel/transform-prevent-infinite-loops'),

// ref: https://github.com/facebook/react/blob/master/scripts/babel/transform-prevent-infinite-loops.js#L37
'WhileStatement|ForStatement|DoWhileStatement': (path, file) => {
  const guard = buildGuard({
    ITERATOR: iterator,
    MAX_ITERATIONS: t.numericLiteral(MAX_ITERATIONS),
  });
  if (!path.get('body').isBlockStatement()) {
    const statement = path.get('body').node;
    path.get('body').replaceWith(t.blockStatement([guard, statement]));
  } else {
    path.get('body').unshiftContainer('body', guard);
  }
}

防護に使用する buildGuard は以下の通り:

const buildGuard = template(`
  if (ITERATOR++ > MAX_ITERATIONS) {
    global.infiniteLoopError = new RangeError(
      'Potential infinite loop: exceeded ' +
      MAX_ITERATIONS +
      ' iterations.'
    );
    throw global.infiniteLoopError;
  }
`);

ここで一個の全局エラー変数 global.infiniteLoopError を使用。後続テストフローを中断:

// ref: https://github.com/facebook/react/blob/master/scripts/jest/setupTests.js#L56
 env.afterEach(() => {
  const error = global.infiniteLoopError;
  global.infiniteLoopError = null;
  if (error) {
    throw error;
  }
});

各 case 終了時に死循環が発生したかを確認。guard 中の throw エラーが外層 catch に捕捉された後、テストフローが依然として正常に進行するのを防止

manual test fixture

Node 環境エンジニアリングの単体テストの他に、ブラウザ環境人工テストの用例集も作成。以下を含む:

  • WebDriver に基づくアプリケーションテスト(Facebook では、このアプリケーションは主站を指す)

  • 人工テスト用例。必要な時に人工で DOM 関連の変更を検証

ブラウザ環境の自動化テストを実施しない主な理由は 3 つ:

  • ブラウザ環境のテストツールはそれほど信頼できない(flaky)。以往の経験から見ると、多くの問題を発見できない

  • 継続的統合を拖慢し、開発ワークフロー効率に影響。また継続的統合も相対的に脆弱になる

  • 自動化テストは常に DOM 問題を発見できるわけではない。例えばブラウザ表示の入力値は DOM 属性で取得したものと一致しない可能性

ブラウザ環境の自動化テストを実施したくないが、維持中に添加した一些境界 case 処理が更新変更で破壊されないことを確保したい。そのため最も有効な方式を採用:境界 case に対してテスト用例を書き、人工テスト検証

具体的な做法は Demo App に対して手動で React バージョンを切り替え、異なるバージョン/異なるブラウザ下で表現が一致するかを確認:

The fixture app lets you choose a version of React (local or one of the published versions) which is handy for comparing the behavior before and after the changes.

非常に愚かに見えるが、DOM 関連問題を発見するには確かに最も直接有効な方式。またこれらの用例が一定の程度に蓄積された時、品質保証に相当大きな作用を果たす(自信を持って DOM 関連変更を実施。後で誰も動かさない境地を避ける)。例えば:

the DOM attribute handling in React 16 was very hard to pull off with confidence at first. We kept discovering different edge cases, and almost gave up on doing it in time for the React 16 release.

価値ある人工テスト用例を蓄積するには多くの精力を投入する必要がある。エンジニアリング手段で可能な限り自動化する他に、GitHub Bot でコミュニティパートナーも簡単に参加できるようにする計画。そのためこのような「愚かなこと」も為すべからずではなく、為すべし。予測可能な利点は:大改不虚

五.リリースツール

npm publish

リリースフローを規範/簡素化するために、いくつかのことを実施:

  • master + feature flag のブランチ戦略を採用

  • リリースフローをツール化

以前は stable ブランチ戦略を採用。バージョンリリース時に手動で cherry-pick。バージョンをリリースすると一日を消費。後に master から直接リリースするように調整。望まない breaking change に対して、feature flag で構築時に去除。手動 cherry-pick の煩雑さを免除

リリースフローに全套ツールを実施。自動化できるものは自動で順序実行。人工操作に依存するものは提示して保存退出。人工処理完毕后進度を回復して先に進む。例えば:

自動
  test
  build
人工
  changelog
  smoke test
自動
  commit changelog
  publish npm package
人工
  GitHub release
  update site version
  test new release
  notify involved team

これによりツール化 checklist で人為的ミスを減少。規範一貫したリリースフローを保証

P.S. リリースツール自身をチェックしやすくするために、模擬リリースオプションも提供。バージョンリリースの実際操作をスキップし、フローを空走可能

参考資料

コメント

コメントはまだありません

コメントを書く