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. 
上一篇:类加载及执行子系统的案例与实战


下一篇:Vue服务端渲染