跳到主要內容
黯羽輕揚每天積累一點點

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 側面輔助 bundle 檢查;Jest 驅動單測,還通過格式化 bundle 來確認構建結果足夠乾淨;最後通過 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 文件經常衝突,要麼需要浪費精力 merge 衝突,要麼就懶得提交這個自動生成的麻煩文件,導致版本滯後,所以計劃通過 GitHub Bot 把這個麻煩抽離出去

三、構建工具

bundle 形式

之前提供兩種 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 種:

  • 運行時動態依賴(注入):把兩份都放進 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 結束都看一眼是否發生死循環,防止 guardthrow 的錯誤被外層 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. 為了便於檢查發布工具自身,還提供了模擬發布選項,可以跳過發版的實際操作,空跑流程

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論