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靜態的模塊機制要求import與export必須按名匹配,否則編譯構建就會報錯

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環境(***)

以不可再分的類庫姿態,把優化環節都收進來,擺脫bundle形式帶來的限制

Gulp/Grunt+Browserify -> Rollup

之前的構建系統是基於Gulp/Grunt+Browserify手搓的一套工具,後來在擴展方面受限於工具,例如:

Node環境下性能不好:頻繁的process.env.NODE_ENV訪問拖慢了***性能,但又沒辦法從類庫角度解決,因爲Uglify依靠這個去除無用代碼

所以React ***性能最佳實踐一般都有一條“重新打包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. 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章