React 源码全方位剖析第一章- 前置知识

版本:v16.4.1

查看所有章节以及后续更新,请前往:个人博客,欢迎一起交流。

前言

当时在各种前端框架或库充斥市场的情况下,出现了大量优秀的框架,比如 Backbone、Angular、Knockout、Ember 这些框架大都采用了 MV* 的理念,把数据与视图分离。而就在这样纷繁复杂的时期,React 诞生于 Facebook 的内部项目,因为该公司对市场上所有 JavaScript MVC 框架,都不满意,就决定自己写一套,用来架设 Instagram 的网站。做出来以后,发现这套东西很好用,就在2013年5月开源了。所谓知其然还要知其所以然,加上 React 真是一天一改,如果现在不看,以后也真的很难看懂了。目前社区有很多 React 的源码剖析文章,趁着最近工作不忙,我打算分享一下 React 源码,并自形成一个系列,欢迎一起交流。在开始之前我们先做以下几点约定:

第一:目前分析的版本是 React 的最新版本 16.4.1。
第二:我尽可能站在我自己的角度去剖析,当然我会借鉴社区比较优秀的文章,同时面对大家的拍砖,我无条件接受,也很乐意与大家一起交换意见,努力写好该 React 源码系列。
第三:如果有幸您读到该 React 源码系列,感觉写得还行,还望收藏、分享或打赏。

前置知识

我们从这一章开始即将分析 React 的源码,在分析源码之前我们很有必要介绍一些前置知识如flow、Rollup等。除此之外,我们最好已经用过 React 做过实际项目,对 React 的思想有了一定的了解,对绝大部分的 API 都已经有使用,同时,我们应该有一定的HTML、CSS、JavaScript、ES6+、node & npm等功底,并对代码调试有一定的了解。

如果具备了以上条件,并且对 React 的实现原理很感兴趣,那么就可以开始 React 的底层学习了,对它的实现细节一探究竟。

Flow - JavaScript静态类型检查工具

Flow 是 facebook 出品的 JavaScript 静态类型检查工具,它与 Typescript 不同的是,它可以部分引入,不需要完全重构整个项目,所以对于一个已有一定规模的项目来说,迁移成本更小,也更加可行。除此之外,Flow 可以提供实时增量的反馈,通过运行 Flow server 不需要在每次更改项目的时候完全从头运行类型检查,提高运行效率。可以简单总结为:对于新项目,可以考虑使用 TypeScript 或者 Flow,对于已有一定规模的项目则建议使用 Flow 进行较小成本的逐步迁移来引入类型检查。React 的源码利用了 Flow 做了静态类型检查,所以了解 Flow 有助于我们阅读源码。

为什么用静态类型检查工具 Flow

JavaScript 是动态类型语言,它的灵活性有目共睹,但是过于灵活的副作用就是很容易就写出非常隐蔽的隐患代码,在编译期甚至运行时看上去都不会报错,但是可能会发生各种各样奇怪的和难以解决的bug。

类型检查是当前动态类型语言的发展趋势,所谓类型检查,就是在编译期尽早发现(由类型错误引起的)bug,又不影响代码运行(不需要运行时动态检查类型),使编写 JavaScript 具有和编写 Java 等强类型语言相近的体验。

项目越复杂就越需要通过工具的手段来保证项目的维护性和增强代码的可读性。React 源码在 ES2015 的基础上,除了用 ESLint 保证代码风格之外,也引入了 Flow 做静态类型检查。之所以选择 Flow,最根本原因应该和 Vue 一样,还是在于工程上成本和收益的考量。 大致体现在以下几点:

第一点:使用 Flow 可以一个一个文件地迁移,如果使用 TypeScript,则需要全部替换,成本极高,短期内并不现实;
第二点:Babel 和 ESLint 都有对应的 Flow 插件以支持语法,可以完全沿用现有的构建配置,非常小成本的改动就可以拥有静态类型检查的能力;
第三点:更贴近 ES 规范。除了 Flow 的类型声明之外,其他都是标准的 ES。万一哪天不想用 Flow 了,用babel-plugin-transform-flow-strip-types 转一下,就得到符合规范的 ES;
第四点:在需要的地方保留 ES 的灵活性,并且对于生成的代码尺寸有更好的控制力 (rollup / 自定义 babel 插件)。

如何用静态类型检查工具 Flow

在这里我们就简单说一说 Flow 的用法,其他用法可以参考Flow官网(可能需要 VPN,非常不稳定),有时间我会详细写一篇 Flow 使用指南。

Flow 仅仅是一个用于检查的工具,安装使用都很方便,使用时注意以下3点即可:

1.将 Flow 安装到我们的项目中。
2.确保编译之后的代码移除了 Flow 相关的语法。
3.在需要检查的地方增加了 Flow 相关的类型注解。

第一点:将Flow增加到我们的项目中

安装最新版本的 Flow:

$ npm install --save-dev flow-bin

安装完成之后在 package.json 文件中增加执行脚本:

{
  // ...
  "scripts": {
    "your-script-name": "flow",
    // ...
  },
  // ...
}

然后初始化 Flow:

$ npm run flow init

执行完成后,Flow 会在终端输出以下内容:

> [email protected] flow /yourProjectPath
> flow "init"

然后在根目录下生成一个名为 .flowconfig 的文件,打开之后是这样的:

[ignore]

[include]

[libs]

[lints]

[options]

[strict]

基本上,配置文件没有什么特殊需求是不用去配置的,Flow 默认涵盖了当前目录之后的所有文件。[include] 用于引入项目之外的文件。例如:

[include]

../otherProject/a.js

[libs]

它会将和当前项目平级的 otherProject/a.js 文件纳入进来。详细配置文件请看官网

第二点:编译之后的代码移除 Flow 相关的语法

Flow 在 JavaScript 语法的基础上使用了一些注解(annotation)进行了扩展。因此浏览器无法正确的解读这些 Flow 相关的语法,我们必须在编译之后的代码中(最终发布的代码)将增加的 Flow 注解移除掉。具体方法需要看我们使用了什么样的编译工具。下面将说明一些 React 开发常用的编译工具:

方式一:create-react-app

如果你的项目是使用create-react-app直接创建的,那么移除 Flow 语法的事项就不用操心了,create-react-app 已经帮你搞定了这个事。

方式二:Babel

如果使用 Babel 我们需要安装一个 Babel 对于 Flow 的 preset:

$ npm install --save-dev babel-preset-flow

然后,我们需要在项目根目录Babel 的配置文件 .babelrc中添加一个 Flow 相关的 preset:

{
  "presets": [
    "flow",
    //other config
  ]
}

方式三:flow-remove-types

如果你既没有使用 create-react-app 也没使用 Babel 作为语法糖编译器,那么可以使用 flow-remove-types 这个工具在发布之前移除 Flow 代码。

第三点:在需要检查的地方增加 Flow 相关的类型注解

如果你了解 C++/C# 的元编程或者 Java 的 Annotation,那么理解 Flow 的 Annotation 就会非常轻松。大概就是在文件、方法、代码块之前增加一个注解(Annotation)用来告知 Flow 的执行行为。

首先,Flow 只检查包含// @flow注解的文件,所以如果需要检查,我们需要这样编写我们的文件,首先我们写一个正确的示例:

/* @flow */

function add(x: number, y: number): number {
  return x + y
}

add(22, 11)

运行 Flow 终端会打印出以下内容:

> [email protected] flow /yourProjectPath
> flow "init"

Found 0 errors

承接上面代码,我们把代码修改成带有检查错误的例子:

/* @flow */

function add(x: number, y: number): number {
  return x + y
}

add("Hello", 11)

运行 Flow 终端会打印出以下内容:

> [email protected] flow /yourProjectPath
> flow "init"

Error ------------------------------------------------------------------------------------- src/platforms/web/mnr.js:8:5

Cannot call `add` with `"Hello"` bound to `x` because string [1] is incompatible with number [2].

   src/platforms/web/mnr.js:8:5
   8| add("Hello", 11)
          ^^^^^^^ [1]

References:
   src/platforms/web/mnr.js:4:17
   4| function add(x: number, y: number): number {
                      ^^^^^^ [2]



Found 1 error

到这里,Flow 已经算是安装成功了,接下来的事是要增加各种注解以加强类型限定或者参数检测。之后的内容将简要介绍 flow 的类型检查方式。

Flow 的类型检查方式

现在我们就说说 Flow 常用的2种类型检查方式:
类型推断:通过变量的执行上下文来推断出变量类型,然后根据这些推断来检查类型。
类型注释:事先注释好我们期望的类型,Flow 会基于这些注释来检查。

第一种方式:类型推断

此方式不需要编写任何代码即可进行类型检查,最小化开发者的工作量,它也不会强制你改变开发习惯,因为它会自动推断出变量的类型,这就是所谓的类型推断,Flow 最重要的特性之一。

通过一个简单例子说明一下:

/*@flow*/

function split(str) {
  return str.split(' ')
}

split(11)

Flow 检查上述代码后会报错,因为函数 split 期待的参数是字符串,而我们输入的是数字。

第二种方式:类型注释

如上所述,类型推断是 Flow 最有用的特性之一,不需要编写任何代码就能进行类型检查。但在某些特定的场景下,使用类型注释可以提供更好更明确的检查依据。

看看以下代码:

/*@flow*/

function add(x, y){
  return x + y
}

add('Hello', 11)

Flow 根据类型推断检查上述代码时检查不出任何错误,因为从语法层面考虑, + 既可以用在字符串上,也可以用在数字上,我们并没有明确指出 add() 的参数必须为数字。在这种情况下,我们可以借助类型注释来指明期望的类型。类型注释是以冒号 : 开头,可以在函数参数,返回值,变量声明中使用。如果我们在上段代码中使用类型注释,就会变成如下:

/*@flow*/

function add(x: number, y: number): number {
  return x + y
}

add('Hello', 11)

现在 Flow 就能检查出错误,因为函数参数的期待类型为数字,而我们提供了字符串。上面的例子是针对函数的类型注释。接下来我们来看看 Flow 能支持的一些常见的类型注释:

第一种:数组

/*@flow*/

var arr: Array<number> = [1, 2, 3]

arr.push('Hello')

数组类型注释的格式是 Array<T>,T 表示数组中每项的数据类型。在上述代码中,arr 是每项均为数字的数组。如果我们给这个数组添加了一个字符串,Flow 能检查出错误。

第二种:类和对象

/*@flow*/

class Bar {
  x: string;           // x 是字符串
  y: string | number;  // y 可以是字符串或者数字
  z: boolean;

  constructor(x: string, y: string | number) {
    this.x = x
    this.y = y
    this.z = false
  }
}

var bar: Bar = new Bar('hello', 4)

var obj: { a: string, b: number, c: Array<string>, d: Bar } = {
  a: 'hello',
  b: 11,
  c: ['hello', 'world'],
  d: new Bar('hello', 3)
}

类的类型注释格式如上,可以对类自身的属性做类型检查,也可以对构造函数的参数做类型检查。这里需要注意的是:属性 y 的类型中间用 | 做间隔,表示 y 的类型即可以是字符串也可以是数字。

对象的注释类型类似于类,需要指定对象属性的类型。

第三种:Null/undefined

Flow 会检查所有的 JavaScript 基础类型—— Boolean、String、Number、null、undefined(在Flow中用void代替)。除此之外还提供了一些操作符号,例如 text : ?string,它表示参数存在“没有值”的情况,除了传递 string 类型之外,还可以是 null 或 undefined。需要特别注意的是,这里的没有值和 JavaScript 的表达式的“非”是两个概念,Flow 的“没有值”只有 null、void(undefined),而 JavaScript 表达式的“非”包含:null、undefined、0、false。

如果想任意类型 T 可以为 null 或者 undefined,只需写成如下 ?T 的格式即可:

/*@flow*/

var foo: ?string = null

此时,foo 可以为字符串,也可以为 null。

Flow 在 React 源码中的应用

Flow 是 Facebook 开源的静态代码检查工具,它的作用就是在运行代码之前对 React 组件以及 Jsx 语法进行静态代码的检查以发现一些可能存在的问题。在 React v16 Fiber中的部分 TypeScript 代码只是类型声明文件和测试代码,也就是为了方便利用 TypeScript 写应用的开发者使用 React,给了接口定义和测试样例而已。

小结

通过对 Flow 的认识,有助于我们阅读 React 的源码,这种静态类型检查的方式非常有利于大型项目源码的开发和维护。此外,通过 React 重构,我们发现项目重构要么依赖规范,要么就得自己有绝对控制权,同时还要考量开发成本、项目收益以及整个团队的技术水平,并不是一味的什么火就用什么。

Rollup - 另一个前端模块化的打包工具

Rollup 是前端模块化的一个打包工具,可以将小块代码编译成大块复杂的代码,例如 library 或应用程序。简单地说,它可以从一个入口文件开始,将所有使用的模块根据命令或者根据 Rollup 配置文件打包成一个目标文件,并且 Rollup 会自动过滤掉那些没有被使用过的函数或变量,从而使代码最小化,如果想使用直接导入这一个目标文件即可,因此 Rollup 极其适合构建一个工具库。

这里提到 Rollup 的两个特别重要的特性,第一个就是它使用了 ES2015 的模板标准,这意味着你可以直接使用 import 和 export 而不需要引入 babel。另一个重要特性叫做 tree-shaking,这个特性可以帮助你将无用代码(即没有使用的代码)从最终的目标文件中过滤掉。举个简单的例子,我们在 foo.js 文件定义了 f1 和 f2 两个方法,然后在入口文件 index.js 只引入了 foo.js 文件中的 f1 方法,那么在最后打包 index.js 文件时,Rollup 就不会将 f2 方法打包到最终文件中(这个特性是基于 ES6 模块的静态分析的,也就是说,只有 export 而没有 import 的变量是不会被打包到最终代码中的)。

为什么用前端模块化的打包工具 Rollup

之前的构建系统是基于 Gulp/Grunt+Browserify 手搓的一套工具,后来在扩展方面受限于工具,例如:

Node 环境下性能不好:频繁的process.env.NODE_ENV访问拖慢了SSR 性能,但又没办法从类库角度解决,因为Uglify依靠这个去除无用代码,所以React SSR性能最佳实践一般都有一条“重新打包 React,在构建时去掉 process.env.NODE_ENV”.

丢弃了过于复杂(overly-complicated)的自定义构建工具,改用更合适的 Rollup:

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

无论 Haste -> ES Module 还是 Gulp/Grunt+Browserify -> Rollup 的切换都是从非标准的定制化方案切换到标准的开放的方案,应该在“手搓”方面吸取教训,为什么业界规范的东西在我们的场景不适用,非要自己造吗?

如何用前端模块化的打包工具 Rollup

关于如何使用前端模块化的打包工具 Rollup,这里就不做过多介绍了,可参考我之前写的一篇文章:Rollup使用指南,更详细的使用文档可参考:官网

Webpack 和 Rollup 有什么不同

2017年4月初,Facebook 将一个巨大的 pull 请求合并到了 React 主分支(master)中,将其现有的构建流程替换为基于 Rollup,这一举动促使一些人产生很大的疑惑“React 为什么选择 Rollup 而抛弃 webpack”,难道webpack要跌下神坛了?

Webpack 是目前使用最为火热的打包工具,没有之一,每月有数百万的下载量,为成千上万的网站和应用提供支持。相比之下,Rollup 并不起眼。但 React 并不孤单 – Vue,Ember,Preact,D3,Three.js,Moment 以及其他许多知名的库也使用 Rollup 。世界到底怎么了?为什么我们不能只有一个大众认可的 JavaScript 模块化打包工具?

Webpack 始于2012年,由 Tobias Koppers 发起,用于解决当时现有工具未解决的的一个难题:构建复杂的单页应用程序(SPA)。特别是 webpack 的两个特性改变了一切:

第一个特性:代码拆分(Code Splitting)

代码拆分也就是说你可以将应用程序分解成可管理的代码块,可以按需加载,这意味着用户可以快速获取网站内容,而不必等到整个应用程序下载和解析完成。

第二个特性:各式各样的加载器(loader)

不管是图像,css,还是 html ,在 Webpack 看来一切都可作为模块,然后通过不同的加载器 loader 来加载它们。

ES6 发布之后,其中引入的模块机制使得静态分析成为了可能,于是 Rollup 发布了:其中 Rollup 有两个特别重要的特性,第一个就是它利用 ES2015 巧妙的模块设计,尽可能高效的构建出能够直接被其他 Javascript 库的。另一个重要特性叫做 tree-shaking,这个特性可以帮助你将无用代码(即没有使用的代码)从最终的目标文件中过滤掉。

紧接着 Webpack2 发布,仿照 Rollup 增加了 tree-shaking。 在之后, Webpack3 发布,仿照 Rollup 又增加了 Scope Hoisting。在在之后, Parcel 发布了一个快速、零配置的打包工具。于是,Webpack4 仿照 Parcel 发布了。

说了这么多,工作中我们到底该用哪个工具?

对于应用使用 webpack,对于类库使用 Rollup。如果你需要代码拆分(Code Splitting),或者你有很多静态资源需要处理,再或者你构建的项目需要引入很多 CommonJS 模块的依赖,那么 webpack 是个很不错的选择。如果您的代码库是基于 ES2015 模块的,而且希望你写的代码能够被其他人直接使用,你需要的打包工具可能是 Rollup。

小结

无论 Haste -> ES Module 还是 Gulp/Grunt+Browserify -> Rollup 的切换都是从非标准的定制化方案切换到标准的开放的方案,可以看出 React 团队也在积极拥抱标准方案并非一味造轮子。其实 Vue.js 1.0.10 就已经使用 Rollup 了,而 React v16.0 改用 Rollup 肯定也有借鉴之意,因此,好技术都是在借鉴的大背景下诞生的(Vue 就是一个典型的例子)。在这里通过对 Rollup 的认识,有助于我们了解 React 的构建以及源码目录结构。

相关推荐