三命

三命 查看完整档案

填写现居城市  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

三命 赞了文章 · 9月8日

图说 ES Modules

原文:ES modules: A cartoon deep-dive, Lin Clark

ES modules(ESM) 是 JavaScript 官方的标准化模块系统。
然而,它在标准化的道路上已经花费了近 10 年的时间。

可喜的是,标准化之路马上就要完成了。等到 2018 年 5 月 Firefox 60 发布之后,所有的主流浏览器就都支持 ESM 了。同时,Node 模块工作小组也正在为 Node.js 添加 ESM 支持。为 WebAssembly 提供 ESM 集成的工作也正在如火如荼的进行。

许多 JS 开发者都知道,对 ESM 的讨论从开始至今一直都没停过。但是很少有人真正理解 ESM 的工作原理。

今天,让我们来梳理梳理 ESM 到底解决了什么问题,以及它跟其他模块系统之间有什么区别。

为何要模块化

说到 JS 编程,其实说的就是如何管理变量
编程的过程都是关于如何给变量赋值,要么直接赋值给变量,要么是把两个变量结合起来然后再把结果赋值给另一个变量。

01_variables.png

因为大部分代码都是关于改变变量的,所以如何组织这些变量就直接影响了编码质量,以及维护它们的成本。

如果代码中仅有少量的变量,那么组织起来其实是很简单的。
JS 本身就提供了一种方式帮你组织变量,称为函数作用域。因为函数作用域的缘故,一个函数无法访问另一个函数中定义的变量。

02_module_scope_01.png

这种方式是很有效的。它使得我们在写一个函数的时候,只需要考虑当前函数,而不必担心其它函数可能会改变当前函数的变量。
不过,它也有不好的地方。它会让我们很难在不同函数之间共享变量

如果我们想跟当前函数以外的函数共享变量要怎么办呢?一种通用的做法是把要共享的变量提升到上一层作用域,比如全局作用域。

在 jQuery 时代这种提升做法相当普遍。在我们加载任何 jQuery 插件之前,我们必须确保 jQuery 已经存在于全局作用域。

02_module_scope_02.png

这种做法也确实行之有效,但是也带来了令人烦恼的影响。
首先,所有的 <script> 必须以正确的顺序排列,开发者必须非常谨慎地确保没有任何一个脚本排列错误。

如果排列错了,那么在运行过程中,应用将会抛出错误。当函数在全局作用域寻找 jQuery 变量时,如果没有找到,那么它将会抛出异常错误,并且停止继续运行。

02_module_scope_03.png

这同时也使得代码的后期维护变得困难。
它会使得移除旧代码或者脚本标签变得充满不确定性。你根本不知道移除它会带来什么影响。代码之间的依赖是不透明的。任何函数都可能依赖全局作用域中的任何变量,以至于你也不知道哪个函数依赖哪个脚本。

其次,由于变量存在于全局作用域,所以任何代码都可以改变它
恶意的代码可能会故意改变全局变量,从而让你的代码做出危险行为。又或者,代码可能不是恶意的,但是却无意地改变了你期望的变量。

模块化的作用

模块化为你提供了一种更好的方式来组织变量和函数。你可以把相关的变量和函数放在一起组成一个模块。

这种组织方式会把函数和变量放在模块作用域中。模块中的函数可以通过模块作用域来共享变量。

不过,与函数作用域不同的是,模块作用域还提供了一种暴露变量给其他模块使用的方式。模块可以明确地指定哪些变量、类或函数对外暴露。

对外暴露的过程称为导出。一旦导出,其他模块就可以明确地声称它们依赖这些导出的变量、类或者函数。

02_module_scope_04.png

因为这是一种明确的关系,所以你可以很简单地辨别哪些代码能移除,哪些不能移除。

拥有了在模块之间导出和导入变量的能力之后,你就可以把代码分割成更小的、可以独立运行地代码块了。然后,你就可以像搭乐高积木一样,基于这些代码块,创建所有不同类型的应用。

由于模块化是非常有用的,所以历史上曾经多次尝试为 JS 添加模块化的功能。不过截止到目前,真正得到广泛使用的只有两个模块系统。
一个是 Node.js 使用的 CommonJS (CJS);另一个是 JS 规范的新模块系统 EcmaScript modules(ESM),Node.js 也正在添加对 ESM 的支持。

下面就让我们来深入理解下这个新的模块系统是如何工作的。

ESM 原理

当你在使用模块进行开发时,其实是在构建一张依赖关系图。不同模块之间的连线就代表了代码中的导入语句。

正是这些导入语句告诉浏览器或者 Node 该去加载哪些代码。
我们要做的是为依赖关系图指定一个入口文件。从这个入口文件开始,浏览器或者 Node 就会顺着导入语句找出所依赖的其他代码文件。

04_import_graph.png

但是呢,浏览器并不能直接使用这些代码文件。它需要解析所有的文件,并把它们变成一种称为模块记录(Module Record)的数据结构。只有这样,它才知道代码文件中到底发生了什么。

05_module_record.png

解析之后,还需要把模块记录变成一个模块实例。模块实例会把代码和状态结合起来。

所谓代码,基本上是一组指令集合。它就像是制作某样东西的配方,指导你该如何制作。
但是它本身并不能让你完成制作。你还需要一些原料,这样才可以按照这些指令完成制作。

所谓状态,它就是原料。具体点,状态是变量在任何时候的真实值。
当然,变量实际上就是内存地址的别名,内存才是正在存储值的地方。

所以,可以看出,模块实例中代码和状态的结合,就是指令集和变量值的结合

06_module_instance.png

对于模块而言,我们真正需要的是模块实例。
模块加载会从入口文件开始,最终生成完整的模块实例关系图。

对于 ESM ,这个过程包含三个阶段:

  1. 构建:查找,下载,然后把所有文件解析成模块记录。
  2. 实例化:为所有模块分配内存空间(此刻还没有填充值),然后依照导出、导入语句把模块指向对应的内存地址。这个过程称为链接(Linking)。
  3. 运行:运行代码,从而把内存空间填充为真实值。

07_3_phases.png

大家都说 ESM 是异步的。
因为它把整个过程分为了三个不同的阶段:加载、实例化和运行,并且这三个阶段是可以独立进行的。

这意味着,ESM 规范确实引入了一种异步方式,且这种异步方式在 CJS 中是没有的。
后面我们会详细说到为什么,然而在 CJS 中,一个模块及其依赖的加载、实例化和运行是一起顺序执行的,中间没有任何间断。

不过,这三个阶段本身是没必要异步化。它们可以同步执行,这取决于它是由谁来加载的。因为 ESM 标准并没有明确规范所有相关内容。实际上,这些工作分为两部分,并且分别是由不同的标准所规范的。

其中,ESM 标准 规范了如何把文件解析为模块记录,如何实例化和如何运行模块。但是它没有规范如何获取文件。

文件是由加载器来提取的,而加载器由另一个不同的标准所规范。对于浏览器来说,这个标准就是 HTML。但是你还可以根据所使用的平台使用不同的加载器。

07_loader_vs_es.png

加载器也同时控制着如何加载模块。它会调用 ESM 的方法,包括 ParseModuleModule.InstantiateModule.Evaluate 。它就像是控制着 JS 引擎的木偶。

08_loader_as_puppeteer.png

下面我们将更加详细地说明每一步。

构建

对于每个模块,在构建阶段会做三个处理:

  1. 确定要从哪里下载包含该模块的文件,也称为模块定位(Module Resolution)
  2. 提取文件,通过从 URL 下载或者从文件系统加载
  3. 解析文件为模块记录

下载模块

加载器负责定位文件并且提取。首先,它需要找到入口文件。在 HTML 中,你可以通过 <script> 标签来告诉加载器。

08_script_entry.png

但是,加载器要如何定位 main.js 直接依赖的模块呢?
这个时候导入语句就派上用场了。导入语句中有一部分称为模块定位符(Module Specifier),它会告诉加载器去哪定位模块。

09_module_specifier.png

对于模块定位符,有一点要注意的是:它们在浏览器和 Node 中会有不同的处理。每个平台都有自己的一套方式来解析模块定位符。这些方式称为模块定位算法,不同的平台会使用不同的模块定位算法。
当前,一些在 Node 中能工作模块定位符并不能在浏览器中工作,但是已经有一项工作正在解决这个问题

在这个问题被解决之前,浏览器只接受 URL 作为模块定位符。
它们会从 URL 加载模块文件。但是,这并不是在整个关系图上同时发生的。因为在解析完这个模块之前,你根本不知道它依赖哪些模块。而且在它下载完成之前,你也无法解析它。

这就意味着,我们必须一层层遍历依赖树,先解析文件,然后找出依赖,最后又定位并加载这些依赖,如此往复。

10_construction.png

如果主线程正在等待这些模块文件下载完成,许多其他任务将会堆积在任务队列中,造成阻塞。这是因为在浏览器中,下载会耗费大量的时间。

11_latency-500x270.png

而阻塞主线程会使得应用变得卡顿,影响用户体验。这是 ESM 标准把算法分成多个阶段的原因之一。将构建划分为一个独立阶段后,浏览器可以在进入同步的实例化过程之前下载文件然后理解模块关系图。

ESM 和 CJS 之间最主要的区别之一就是,ESM 把算法化为为多个阶段。

CJS 使用不同的算法是因为它从文件系统加载文件,这耗费的时间远远小于从网络上下载。因此 Node 在加载文件的时候可以阻塞主线程,而不造成太大影响。而且既然文件已经加载完成了,那么它就可以直接进行实例化和运行。所以在 CJS 中实例化和运行并不是两个相互独立的阶段。
这也意味着,你可以在返回模块实例之前,顺着整颗依赖树去逐一加载、实例化和运行每一个依赖。

12_cjs_require.png

CJS 的方式对 ESM 也有一些启发,这个后面会解释。
其中一个就是,在 Node 的 CJS 中,你可以在模块定位符中使用变量。因为已经执行了 require 之前的代码,所以模块定位符中的变量此刻是有值的,这样就可以进行模块定位的处理了。

但是对于 ESM,在运行任何代码之前,你首先需要建立整个模块依赖的关系图。也就是说,建立关系图时变量是还没有值的,因为代码都还没运行。

13_static_import.png

不过呢,有时候我们确实需要在模块定位符中使用变量。比如,你可能需要根据当前的状况加载不同的依赖。

为了在 ESM 中实现这种方式,人们已经提出了一个动态导入提案。该提案允许你可以使用类似 import(\`${path}/foo.js`)的导入语句。

这种方式实际上是把使用 import() 加载的文件当成了一个入口文件。动态导入的模块会开启一个全新的独立依赖关系树。

14dynamic_import_graph.png

不过有一点要注意的是,这两棵依赖关系树共有的模块会共享同一个模块实例。这是因为加载器会缓存模块实例。在特定的全局作用域中,每个模块只会有一个与之对应的模块实例。

这种方式有助于提高 JS 引擎的性能。例如,一个模块文件只会被下载一次,即使有多个模块依赖它。这也是缓存模块的原因之一,后面说到运行的时候会介绍另一个原因。

加载器使用模块映射(Module Map)来管理缓存。每个全局作用域都在一个单独的模块映射中跟踪其模块。

当加载器要从一个 URL 加载文件时,它会把 URL 记录到模块映射中,并把它标记为正在下载的文件。然后它会发出这个文件请求并继续开始获取下一个文件。

15_module_map.png

当其他模块也依赖这个文件的时候会发生什么呢?加载器会查找模块映射中的每一个 URL 。如果发现 URL 的状态为正在下载,则会跳过该 URL ,然后开始下一个依赖的处理。

不过,模块映射的作用并不仅仅是记录哪些文件已经下载。下面我们将会看到,模块映射也可以作为模块的缓存。

解析模块

至此,我们已经拿到了模块文件,我们需要把它解析为模块记录。
这有助于浏览器理解模块的不同部分。

25_file_to_module_record.png

一旦模块记录创建完成,它就会被记录在模块映射中。所以,后续任何时候再次请求这个模块时,加载器就可以直接从模块映射中获取该模块。

25_module_map.png

解析过程中有一个看似微不足道的细节,但是实际造成的影响却很大。那就是所有的模块都按照严格模式来解析的。
也还有其他的小细节,比如,关键字 await 在模块的最顶层是保留字, this 的值为 undefinded

这种不同的解析方式称为解析目标(Parse Goal)。如果按照不同的解析目标来解析相同的文件,会得到不同的结果。因此,在解析文件之前,必须清楚地知道所解析的文件类型是什么,不管它是不是一个模块文件。

在浏览器中,知道文件类型是很简单的。只需要在 <script> 脚本中添加 type="module" 属性即可。这告诉浏览器这个文件需要被解析为一个模块。而且,因为只有模块才能被导入,所以浏览器以此推测所有的导入也都是模块文件。

26_parse_goal.png

不过在 Node 中,我们并不使用 HTML 标签,所以也没办法通过 type 属性来辨别。社区提出一种解决办法是使用 .mjs 拓展名。使用该拓展名会告诉 Node 说“这是个模块文件”。你会看到大家正在讨论把这个作为解析目标。不过讨论仍在继续,所以目前仍不明确 Node 社区最终会采用哪种方式。

无论最终使用哪种方式,加载器都会决定是否把一个文件作为模块来解析。如果是模块,而且包含导入语句,那它会重新开始处理直至所有的文件都已提取和解析。

到这里,构建阶段差不多就完成了。在加载过程处理完成后,你已经从最开始只有一个入口文件,到现在得到了一堆模块记录。

27_construction.png

下一步会实例化这些模块并且把所有的实例链接起来。

实例化

正如前文所述,一个模块实例结合了代码和状态。状态存储在内存中,所以实例化的过程就是把所有值写入内存的过程。

首先,JS 引擎会创建一个模块环境记录(Module Environment Record)。它管理着模块记录的所有变量。然后,引擎会找出多有导出在内存中的地址。模块环境记录会跟踪每个导出对应于哪个内存地址。

这些内存地址此时还没有值,只有等到运行后它们才会被填充上实际值。有一点要注意,所有导出的函数声明都在这个阶段初始化,这会使得后面的运行阶段变得更加简单。

为了实例化模块关系图,引擎会采用深度优先的后序遍历方式
即,它会顺着关系图到达最底端没有任何依赖的模块,然后设置它们的导出。

30_live_bindings_01.png

最终,引擎会把模块下的所有依赖导出链接到当前模块。然后回到上一层把模块的导入链接起来。

30_live_bindings_02.png

这个过程跟 CJS 是不同的。在 CJS 中,整个导出对象在导出时都是值拷贝
即,所有的导出值都是拷贝值,而不是引用。
所以,如果导出模块内导出的值改变了,导入模块中导入的值也不会改变。

31_cjs_variable.png

相反,ESM 则使用称为实时绑定(Live Binding)的方式。导出和导入的模块都指向相同的内存地址(即值引用)。所以,当导出模块内导出的值改变后,导入模块中的值也实时改变了。

模块导出的值在任何时候都可以能发生改变,但是导入模块却不能改变它所导入的值,因为它是只读的。
举例来说,如果一个模块导入了一个对象,那么它只能改变该对象的属性,而不能改变对象本身。

30_live_bindings_04.png

ESM 采用这种实时绑定的原因是,引擎可以在不运行任何模块代码的情况下完成链接。后面会解释到,这对解决运行阶段的循环依赖问题也是有帮助的。

实例化阶段完成后,我们得到了所有模块实例,以及已完成链接的导入、导出值。

现在我们可以开始运行代码并且往内存空间内填充值了。

运行

最后一步是往已申请好的内存空间中填入真实值。JS 引擎通过运行顶层代码(函数外的代码)来完成填充。

除了填充值以外,运行代码也会引发一些副作用(Side Effect)。例如,一个模块可能会向服务器发起请求。

40_top_level_code.png

因为这些潜在副作用的存在,所以模块代码只能运行一次
前面我们看到,实例化阶段中发生的链接可以多次进行,并且每次的结果都一样。但是,如果运行阶段进行多次的话,则可能会每次都得到不一样的结果。

这正是为什么会使用模块映射的原因之一。模块映射会以 URL 为索引来缓存模块,以确保每个模块只有一个模块记录。这保证了每个模块只会运行一次。跟实例化一样,运行阶段也采用深度优先的后序遍历方式。

那对于前面谈到的循环依赖会怎么处理呢?

循环依赖会使得依赖关系图中出现一个依赖环,即你依赖我,我也依赖你。通常来说,这个环会非常大。不过,为了解释好这个问题,这里我们举例一个简单的循环依赖。

41_cjs_cycle.png

首先来看下这种情况在 CJS 中会发生什么。
最开始时,main 模块会运行 require 语句。紧接着,会去加载 counter 模块。

41_cyclic_graph.png

counter 模块会试图去访问导出对象的 message 。不过,由于 main 模块中还没运行到 message 处,所以此时得到的 messageundefined。JS 引擎会为本地变量分配空间并把值设为 undefined

42_cjs_variable_2.png

运行阶段继续往下执行,直到 counter 模块顶层代码的末尾处。我们想知道,当 counter 模块运行结束后,message 是否会得到真实值,所以我们设置了一个超时定时器。之后运行阶段便返回到 main.js 中。

43_cjs_cycle.png

这时,message 将会被初始化并添加到内存中。但是这个 messagecounter 模块中的 message 之间并没有任何关联关系,所以 counter 模块中的 message 仍然为 undefined

44_cjs_variable_2.png

如果导出值采用的是实时绑定方式,那么 counter 模块最终会得到真实的 message 值。当超时定时器开始计时时,main.js 的运行就已经完成并设置了 message 值。

支持循环依赖是 ESM 设计之初就考虑到的一大原因。也正是这种分段设计使其成为可能。

ESM 的当前状态

等到 2018 年 5 月 Firefox 60 发布后,所有的主流浏览器就都默认支持 ESM 了。Node 也正在添加 ESM 支持,为此还成立了工作小组来专门研究 CJS 和 ESM 之间的兼容性问题。

所以,在未来你可以直接在 <script> 标签中使用 type="module",并且在代码中使用 importexport
同时,更多的模块功能也正在研究中。
比如动态导入提案已经处于 Stage 3 状态;import.meta也被提出以便 Node.js 对 ESM 的支持;模块定位提案 也致力于解决浏览器和 Node.js 之间的差异。

相信在不久的未来,跟模块一起玩耍将会变成一件更加愉快的事!

查看原文

赞 119 收藏 228 评论 12

三命 赞了文章 · 9月8日

ES module工作原理

本文参考 https://hacks.mozilla.org/201...,建议大家读原文。

ES6发布了官方的,标准化的Module特性,这一特性花了整整10年的时间。但是,在这之前,大家也都在模块化地编写JS代码。比如在server端的NodeJS,它是对CommonJS的一个实现;Require.js则是可以在浏览器使用,它是对AMD的一个实现。

ES6官方化了模块,使得在浏览器端不再需要引入额外的库来实现模块化的编程(当然浏览器的支持与否,这里暂不讨论)。ES Module的使用也很简单,相关语法也很少,核心是import和export。但是,对于ES module到底是如何工作的,它又和之前的CommonJS和AMD有什么差别呢?这是接下来将要讨论的内容。

一:没有模块化的编程存在什么问题?

编写JS代码,主要是对于对变量的操作:给变量赋值或者变量之间进行各种运算。正因为大部分代码都是对变量的操作,所以如何组织代码里面的变量对于如何写好代码和代码维护就显得至关重要了。

当只有少量的变量需要考虑的时候,JavaScript提供了“scope(作用域)”来帮助你。因为在JavaScript里面,一个function不能访问定义在别的function里面的变量。

但是,这同时也带来一个问题,假如functionA想要使用functionB的变量怎么办呢?一个通用的办法就是把functionB的变量放到functionA的上一层作用域。典型的就是jQuery时代,如果要使用jQuery的API,先要保证jQuery在全局作用域。
但是这样做的问题也很多:

1: 所有的script标签必须保证正确的顺序,这使得代码的维护变得异常艰难。
2: 全局作用域被污染。

二:模块化编程如何解决上面提到的问题?

模块,把相关的变量和function组织到一起,形成一个所谓的module scope(模块作用域)。在这个作用域里面的变量和function之间彼此是可见的。

与function不同的是,一个模块可以决定自己内部的哪些变量,类,或者function可以被其他模块可见,这个决定我们叫做“export(导出)”。而其他的模块也就可以选择性地使用这个模块导出的内容,我们通过“import(导入)”来实现。

一旦有了导入和导出,我们就可以把我们的程序按照指责划分为一个个模块,大的模块可以继续划分为更小的模块,最终这些模块组合到一起,搭建起了我们整个程序,就像乐高一样。

三:ES Module的工作原理之Module Instances

当你在模块化编程的时候,你就会创建一棵依赖树。不同依赖之间的链接来源于你使用的每一条"import"语句。

就是通过这些"import"语句,浏览器和Node才知道它们到底要加载哪些代码。你给浏览器或者Node一个依赖树的入口文件,从这个入口文件开始,浏览器或者Node就沿着每一条"import"语句找到下面的代码。
图片描述

但是,浏览器却使用不了这些文件。所有的文件都必须要转变为一系列被叫做“Module Records(模块记录)的数据结构,这样浏览器才能明白这些文件的内容。
图片描述

在这之后,module record需要被转化为“module instance(模快实例)”。一个module instance包含2种东西:code和state。

code就是一系列的操作指令,就像菜单一样。但是,光有菜单,并不能作出菜,你还需要原材料。而state就是原材料。State就是变量在每一个特地时间点的值。当然,这些变量只是内存里面一个个保存着值的小盒子的小名而已。

而我们真正需要的就是每一个模块都有一个module instance。模块的加载就是从这个入口文件开始,最后得到包含所有module instance的完整图像。

四:Module Instances的产生步骤

对于,ES Module来说,这需要经历三个步骤:

1: Construction(构造)- 找到,下载所有的文件并且解析为module records。
2: Instantiation(实例化)- 在内存里找到所有的“盒子”,把所有导出的变量放进去(但是暂时还不求值)。然后,让导出和导入都指向内存里面的这些盒子。这叫做“linking(链接)”。
3: Evaluation(求值)- 执行代码,得到变量的值然后放到这些内存的“盒子”里。

图片描述

大家都说ES Module是异步的。你可以认为它是异步的,因为这些工作被分成了三个不同的步骤 - loading(下载),instantiating(实例化)和evaluating(求值) - 并且这些步骤可以单独完成。

这意味着ES Module规范采用了一种在CommonJS里面不存在的异步机制。在CommonJS里面,对于一个模块和它底下的依赖来说,下载,实例化,和求值都是一次性完成的,步骤相互之间没有任何停顿。

然而,这并不意味这这些步骤必须是异步的,它们也可以同步完成。这依赖于“loading(下载)”是由谁去做的。因为,并不是所有的东西都由ES module规范控制。事实上,确实有两部分的工作是由别的规范负责的。

ES module规范 陈述了你应该怎样把文件解析为module records,和怎样初始化模块以及求值。然而,它却没有说在最开始要怎样得到这些文件。

是loader(下载器)去获取到了文件。而loader对于不同的规范来说是特定的。对于浏览器来说,这个规范是HTML 规范。你可以根据你所使用的平台来得到不同的loader。

图片描述

loader也控制着模块如何加载。它会调用ES module的方法--ParseModule, Module.Instantiate,和Module.Evaluate。loader就像傀儡师,操纵着JS引擎的线。

现在让我们来具体聊一聊每一个步骤。
五:Module Instances的产生步骤之Construction

对于每一个模块来说,在这一步会经历以下几个步骤

1: 弄清楚去哪里下载包含模块的文件(又叫“ module resolution(模块识别)”)
2: 获取文件(通过从一个URL下载或者从文件系统加载)
3: 把文件解析为module record(模块记录)

step1: Finding the file and fetching it 找到文件并获取文件

loader会负责找到文件并下载。首先,需要找到入口文件,在HTML文件里,我们通过使用<script>标签告诉loader哪里去找到入口文件。

图片描述

但是,loader如何找到接下来的一系列模块 - 也就是main.js所直接依赖的哪些模块呢?这就轮到import语句登场了。import语句的某一部分又被叫做“模块说明符”。它告诉loader在哪儿可以找到下一个模块。
图片描述

关于“模块说明符”,有一点需要说明:某些时候,不同的浏览器和Node之间,需要不同的处理方式。每一个平台都有它们自己的方法去诠释“模块说明符”字符串。而这通过“模块识别算法”完成,不同的平台不一样。就目前来说,一些在Node环境工作的模块识别符在浏览器里面并不工作,但是这一情况正在被处理修复

而在修复之前,浏览器只接受URL作为模块标识符。浏览器会从那个URL下载模块文件。但是,对于整个依赖图来说,在同一时间是不可能的。因为直到解析了这个文件,你才知道这个模块需要哪些依赖。。。但是,你又不能解析这个文件除非你获取了它。

这意味着,要解析一个文件,我们必须一层一层地遍历这颗依赖树,理清楚他所有的依赖,然后找到并且下载这些依赖。但是,假如主线程一直在等待这些文件下载,那么大量的其他的任务就被卡在队列里面。这是因为,在浏览器里面进行下载工作,会耗费大量的时间。

像这样阻塞主线程,会导致使用了模块的app太慢了,这也是ES module规范把算法分割成多个步骤的其中一个原因。把construction(构建)单独划分到一个步骤,这就允许浏览器可以在进入到instantiating(实例化)的一系列同步工作之前可以先获取文件并且建立模块之间的依赖树。

把这个算法分割到不同的步骤--正是ES Module和CommonJS module之间的其中一个关键区别。

CommonJS可以做不同于ES Module的处理,是因为从文件系统里面加载文件比从网络上下载文件要花少得多的时间。这就意味着,Node可以在加载文件的时候阻塞主线程。又因为文件已经加载好了,那么实例化和求值(这两步在CommomJS里面是没有分开的)也显得很有道理。这意味着,在你返回这个模块之前,其依赖树上所有的依赖都完成了loading(加载),instantiating(实例化)和evaluating(求值)。
图片描述

CommonJS的方法会带来一些后果,后面会解释。但是,其中有一点是在Node里面的CommomJS module, 你可以在模块说明符里面使用变量。在你寻找下一个模块之前,你会执行完本模块的所有代码。这就意味着当你去做模块识别的时候,这个变量已经有值了。

但是,在ES Module里面,你是在任何求值之前先建立了完整的依赖树。这说明,你不能在模块说明符里面使用变量,因为这个变量目前还没有值。

图片描述

但是动态模块,在实际生产中又是有用的。所以有一个提议叫做动态导入,可以用来满足类似这样的需求:import(${path}/foo.js).

动态导入的工作原理是,任何使用import()来导入的文件,都会作为一个入口文件从而创建一棵单独的依赖树,被单独处理。

图片描述

但有一点需要注意的是 - 任何同时存在于两棵依赖树的模块都指向同一个模块实例。这是因为loader把模块实例缓存起来了。对于每一个模块来说,在一个特定的全局作用域内,只会有一个模版实例。

这对JS引擎来说,就意味着更少的工作量。举个例子,无论多少模块依赖着某一个模块,但是这个模块文件都只会被获取一次。loader使用module map来管理这些缓存,每一个全局作用域使用独立的module map来管理各自的缓存。

当loader通过一个URL去获取文件的时候,它会把这个URL放入module map并且做上“正在获取”的标志。然后它发出请求,进而继续下一个文件的获取工作。

当别的模块也依赖同一个文件的时候,会发生什么呢?Loader会查询module map里面的每一个URL,如果它看到这个URL有“正在获取“的标志,那它就不管了,继续下一个URL的处理。

module map不只是看哪个文件正在被下载,它同时也管理这模块的缓存,这就是下面的内容。

step2: Parsing

现在我们已经获取到了文件,我们需要把它解析为一个module record。这有助于浏览器理解模块的不同之处是什么。

图片描述

一旦module record创建完成,它就会被放到module map里面去。这意味着无论何时被请求,loader都可以从module map里面提取它。

图片描述

在解析的时候,有一个看起来琐碎但是却会产生巨大影响的细节:所有的模块都是在相当于在文件顶部使用了“use strict”(严格模式)下被解析的。除此之外,也还有其他的一些不同,例如:关键字await被保留在模块的最高层的代码里;this的值是undefined

不同的解析方法被称作“解析目标”。假如你用不同的解析目标解析同一个文件,你将会得到不同的解析结果。因为,在解析之前,你需要知道将要被解析的文件是否是模块。

在浏览器里面,这十分简单。你只需要给<script>标签加一个type="module"。这就告诉了浏览器这个文件需要被当成是一个模块来解析。因为只有模块才可以被导入,所以浏览器知道导入的文件也是模块。

但是Node不使用HTML相关的标签,所以无法使用type来表示。而在Node里面是通过文件的扩展名".mjs"来表明这是一个ES Module的。

不管是哪种方式,最终都是loader来决定这个文件是否当作一个模块来解析。假如它是一个module或者有import,那就会开始这个进程,直到所有的文件被下载和解析。

这一步骤就结束了。在加载进程结束之后,我们就从拥有一个入口文件到最后拥有一系列的module record。

图片描述

下一步就是实例化这些模块,并且把所有的实例链接起来。
六:Module Instances的产生步骤之Instantiation

如我之前提过的那样,一个实例结合了code和state。state存在于内存中,因此实例化这一步就是关于怎样把东西链接到内存里面的。

首先,JS引擎创建了一个“模块环境记录(module environment record)”。它管理着module record的变量,然后它在内存里面找到所有导出(export)的变量的“盒子”。module environment record会一直监控着内存里面的哪个盒子和哪个export是相关联的。

这些内存里面的盒子还没有获得它们的值,只有在求值这一步骤完成之后,真正的值才会被填充进去。但是这里有个小小的警告:任何导出的function定义,都是在这一步初始化的,这使得求值变得相对简单一些。

为了实例化模块图(module graph),JS引擎会做一个所谓的“深度优先后序遍历”的操作。意思就是说,JS引擎会先走到模块图的最底层--找到不依赖任何其他模块的那些模块,并且设置好它们的导出(export)。
图片描述

当JS引擎完成一个模块的所有导出的链接,它就会返回上一个层级去设置来自于这个模块的导入(import)。需要注意的是,导出和导入都是指向同一片内存地址。先链接导出保证了所有的导入都能找到对应的导出。
图片描述

这和CommonJS的模块不同。在CommonJS,导入的对象是基于导出拷贝的。这就意味着导出的任何的数值(例如数字)都是拷贝。这就意味着,如果导出模块在之后修改了一些值,导入的模块并不会被同步到这些修改。
图片描述

于此相反的是,ES module使用所谓的“实时绑定”,导出的模块和导入的模块都指向同一段内存地址。如果,导出模块修改了一个值,那么这个修改会在导入模块里面也得到体现。

导出值的模块可以在任何时间修改这些值,但是导入模块却不能修改它们导入的值。意思就是,如果一个模块导出了一个对象(object),那它可以修改这个对象的属性值。

“实时绑定”的好处是,不需要跑任何的代码,就可以链接起所有的模块。这有助于当存在循环依赖情况下的求值。

在这一步的最后,我们使得所有的模块实例导出/导入的变量的内存地址链接起来了。

接下来,我们就开始对代码求值,并且把得到的值填入对应的内存地址中。

七:Module Instances的产生步骤之Evaluation

最后一步是把值都填入内存地址中。JS引擎通过执行最上层的代码-也就是function以外的代码,来实现这一目的。
图片描述

除了往内存地址里面填值,对代码求值有可能也会触发副作用。举个例子,一个模块有可能会向server做请求。因为这个副作用,你只想求模块求值一次。和在实例化阶段的链接无论执行多少次都会得到同一个结果不同,求值会根据你进行了多少次求值操作而得到不同的结果。

这也是为什么需要module map。Module map根据URL来缓存模块,因为每一个模块都只有一个module record,这也保证了每一个模块只会被执行一次。和实例化一样,求值也是按照深度优先倒序的规则来的。

在一个循环依赖的情况下,最终会在依赖树里得到一个环,为了仅仅是说明问题,这里就用一个最简单的例子:
图片描述
我们先来看看CommonJS,它是怎么工作的。首先,main模块会执行到require语句,然后进入到counter模块。Counter模块尝试去从访问导出的对象里面的message变量。但是,因为这个变量还没有在main模块里面被求值,所以会返回undefined。JS引擎会在内存里面为这个本地变量开辟一段地址并把它的值设置为undefined。
图片描述

求值一直继续到counter模块的最底部。我们想知道最终是否能得到message的值(也就是main模块求值之后),于是我们设置了一个timeout。然后,同样的求值过程在main模块重新开始。

图片描述

message变量会被初始化并且放到内存中。但是,因为这两者之间已经没有任何链接,所以在counter模块里,message变量会保持为undefined。
图片描述

假如这个导出是用“实时绑定”处理的,counter模块最终就能得到正确的值。到timeout执行的时候,main模块的求值就完成了并且得到最终的值。

支持循环依赖,是ES module设计的一个重要基础。正是前面的“三个阶段”使得这一切成为可能。

查看原文

赞 20 收藏 12 评论 1

三命 赞了文章 · 8月25日

webpack构建和性能优化探索

前言

随着业务复杂度的不断的增加,工程模块的体积也会不断增加,构建后的模块通常要以M为单位计算。在构建过程中,基于nodejs的webpack在单进程的情况下loader表现变得越来越慢,在不做任何特殊处理的情况下,构建完后的多项目之间公用基础资源存在重复打包,基础库代码复用率也不高,这都慢慢暴露出webpack的问题。

原文地址

正文

针对存在的问题,社区涌出了各种解决方案,包括webpack自身也在不断优化。

构建优化

下面利用相关的方案对实际项目一步一步进行构建优化,提升我们的编译速度,本次优化相关属性如下:

  • 机器: Macbook Air 四核 8G内存
  • Webpack: v4.10.2
  • 项目:922个模块

构建优化方案如下:

  • 减少编译体积大小
  • 将大型库外链
  • 将库预先编译
  • 使用缓存
  • 并行编译

初始构建时间如下:

增量构建Development构建Production构建备注
3088ms43702ms89371ms

减少编译体积大小

初始构建时候,我们利用webpack-bundle-analyzer对编译结果进行分析,结果如下:

图片描述

可以看到,td-ui(类似于antd的ui组件库)、moment库的locale、BizCharts占了项目的大部分体积,而在没有全部使用这些库的全部内容的情况下,我们可以对齐进行按需加载。

针对td-ui和BizCharts,我们对齐添加按需加载babel-plugin-import,这个包可以在使用ES6模块导入的时候,对其进行分析,解析成引入相应文件夹下面的模块,如下:

图片描述

首先,我们先添加babel的配置,在plugins中加入babel-plugin-import:

{
    ...
    "plugins": [
        ...
        ["import", [
            { libraryName: 'td-ui', style: true },
            { libraryName: 'bizcharts', libraryDirectory: 'lib/components' },
        ]]
    ]
}

可以看到,我们给bizcharts也添加了按需加载,配置中添加了按需加载的指定文件夹,针对bizcharts,编译前后代码对比如下:

编译前:

图片描述

编译后:

图片描述

注意:bizcharts按需加载需要引入其核心代码bizcharts/lib/core;

到此为止,td-ui和bizcharts的按需加载已经处理完毕,接下来是针对moment的处理。moment的主要体积来源于locale国际化文件夹,由于项目中有中英文国际化的需求,我们这里使用webpack.ContextReplacementPugin对该文件夹的上下文进行匹配,只匹配中文和英文的语言包,plugin配置如下:

new webpack.ContextReplacementPugin(
    /moment[\/\\]locale$/, //匹配文件夹
    /zh-cn|en-us/  // 中英文语言包
)

如果没有国际化的需求,可以使用webpack.IgnorePlugin对整个locale文件夹进行忽略,配置如下:

new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)

减少编译体积大小完成之后得到如下构建对比结果:

增量构建Development 构建Production构建备注
3088ms43702ms89371ms
2561ms27864ms67441ms减少编译体积大小

将大型库外链 && 将库预先编译

为了避免一些已经编译好的大型库重新编译,我们需要将这些库放在编译意外的地方,或者预先编译这些库。

webpack也为我们提供了将模块外链的配置externals,比如我们把lodash外链,配置如下

module.exports = {
  //...
  externals : {
    lodash: 'window._'
  },

  // 或者

  externals : {
    lodash : {
      commonjs: 'lodash',
      amd: 'lodash',
      root: '_' // 指向全局变量
    }
  }
};

针对库预先编译,webpack也提供了相应的插件,那就是webpack.Dllplugin,这个插件可以预先编译制定好的库,最后在实际项目中使用webpack.DllReferencePlugin将预先编译好的库关联到当前的编译结果中,无需重新编译。

Dllplugin配置文件webpack.dll.config.js如下:

图片描述

dllReference配置文件webpack.dll.reference.config.js如下:

图片描述

最后使用webpack-mergewebpack.dll.reference.config.js合并到到webpack配置中。

注意:预先编译好的库文件需要在html中手动引入并且必须放在webpack的entry引入之前,否则会报错。

其实,将大型库外链和将库预先编译也属于减少编译体积的一种,最后得到编译时间结果如下:

增量构建Development构建Production构建备注
3088ms43702ms89371ms
2561ms27864ms67441ms减少编译体积大小
2246ms22870ms50601msDll优化后

使用缓存

首先,我们开启babel-loader自带的缓存功能(默认其实就是打开的)。

图片描述

另外,开启uglifyjs-webpack-plugin的缓存功能。

图片描述

添加缓存插件hard-source-webpack-plugin(当然也可以添加cache-loader)

const hardSourcePlugin = require('hard-source-webpack-plugin');

moudle.exports = {
    // ...
    plugins: [
        new hardSourcePlugin()
    ],
    // ...
}

添加缓存后编译结果如下:

增量构建Development构建Production构建备注
3088ms43702ms89371ms
2561ms27864ms67441ms减少编译体积大小
2246ms22870ms50601msDll优化后
1918ms10056ms17298ms使用缓存后

可以看到,编译效果极好。

并行编译

由于nodejs为单线程,为了更好利用好电脑多核的特性,我们可以将编译并行开始,这里我们使用happypack,当然也可以使用thread-loader,我们将babel-loader和样式的loader交给happypack接管。

babel-loader配置如下:

图片描述

less-loader配置如下:

图片描述

构建结果如下:

增量构建Development构建Production构建备注
3088ms43702ms89371ms
2561ms27864ms67441ms减少编译体积大小
2246ms22870ms50601msDll优化后
1918ms10056ms17298ms使用缓存后
2252ms11846ms18727ms开启happypack后

可以看到,添加happypack之后,编译时间有所增加,针对这个结果,我对webpack版本和项目大小进行了对比测试,如下:

  • Webpack:v2.7.0
  • 项目:1013个模块
  • 全量production构建:105395ms

添加happypack之后,全量production构建时间降低到58414ms

针对webpack版本:

  • Webpack:v4.23.0
  • 项目:1013个模块
  • 全量development构建 : 12352ms

添加happypack之后,全量development构建降低到11351ms。

得到结论:Webpack v4 之后,happypack已经力不从心,效果并不明显,而且在小型中并不适用。

所以针对并行加载方案要不要加,要具体项目具体分析。

性能优化

对于webpack编译出来的结果,也有相应的性能优化的措施。方案如下:

  • 减少模块数量及大小
  • 合理缓存
  • 合理拆包

减少模块数量及大小

针对减少模块数量及大小,我们在构建优化的章节中有提到很多,具体点如下:

  • 按需加载 babel-plugin-import(antd、iview、bizcharts)、babel-plugin-component(element-ui)
  • 减少无用模块webpack.ContextReplacementPlugin、webpack.IgnorePlugin
  • Tree-shaking:树摇功能,消除无用代码,无用模块。
  • Scope-Hoisting:作用域提升。
  • babel-plugin-transform-runtime,针对babel-polyfill清除不必要的polyfill。

前面两点我们就不具体描述,在构建优化章节中有说。

Tree-shaking

树摇功能,将树上没用的叶子摇下来,寓意将没有必要的代码删除。该功能在webapck V2中已被webpack默认开启,但是使用前提是,模块必须是ES6模块,因为ES6模块为静态分析,动态引入的特性,可以让webpack在构建模块的时候知道,那些模块内容在引入中被使用,那些模块没有被使用,然后将没有被引用的的模块在转为为AST后删除。

由于必须使用ES6模块,我们需要将babel的自动模块转化功能关闭,否则你的es6模块将自动转化为commonjs模块,配置如下:

{
    "presets": [
        "react",
        "stage-2",
        [
            "env",
            {
                "modlues": false // 关闭babel的自动转化模块功能,保留ES6模块语法
            }
        ]
    ]
}

Tree-shaking编译时候可以在命令后使用--display-used-exports可以在shell打印出关于代码剔除的提示。

Scope-Hoisting

作用域提升,尽可能的把打散的模块合并到一个函数中,前提是不能造成代码冗余。因此只有那些被引用了一次的模块才能被合并。

可能不好理解,下面demo对比一下有无Scope-Hoisting的编译结果。

首先定义一个util.js文件

export default 'Hello,Webpack';

然后定义入口文件main.js

import str from './util.js'
console.log(str);

下面是无Scope-Hoisting结果:

图片描述

然后是Scope-Hoisting后的结果:

图片描述

与Tree-Shaking类似,使用Scope-Hoisting的前提也是必须是ES6模块,除此之外,还需要加入webpack内置插件,位于webpack文件夹,webpack/lib/optimize/ModuleConcatenationPlugin,配置如下:

const ModuleConcatenationPlugin = require('webpack/lib/optimize/ModuleConcatenationPlugin');
module.exports = {
    //...
    plugins: [
        new ModuleConcatenationPlugin()
    ]
    //...
}

另外,为了更好的利用Scope-Hoisting,针对Npm的第三方模块,它们也可能提供了ES6模块,我们可以指定优先使用它们的ES6模块,而不是使用它们编译后的代码,webpack的配置如下:

module.exports = {
    //...
    resolve: {
        // 优先采用jsnext:main中指定的ES6模块文件
        mainFields: ['jsnext:main', 'module', 'browser', 'main']
    }
    //...
}

jsnext:main为业内大家约定好的存放ES6模块的文件夹,后续为了规范,更改为module文件夹。

babel-plugin-transform-runtime

在我们实际的项目中,为了兼容一些老式的浏览器,我们需要在项目加入babel-polyfill这个包。由于babel-polyfill太大,导致我们编译后的包体积增大,降低我们的加载性能,但是实际上,我们只需要加入我们使用到的不兼容的内容的polyfill就可以,这个时候babel-plugin-transform-runtime就可以帮我们去除那些我们没有使用到的polyfill,当然,你需要在babal-preset-env中配置你需要兼容的浏览器,否则会使用默认兼容浏览器。

添加babel-plugin-transform-runtime的.babelrc配置如下:

{
    "presets": [["env", {
        "targets": {
            "browsers": ["last 2 versions", "safari >= 7", "ie >= 9", "chrome >= 52"] // 配置兼容浏览器版本
        },
        "modules": false
    }], "stage-2"],
    "plugins": [
        "transform-class-properties",
        "transform-runtime", // 添加babel-plugin-transform-runtime
        "transform-decorators-legacy"
    ]
}

合理使用缓存

webpack对应的缓存方案为添加hash,那我们为什么要给静态资源添加hash呢?

  • 避免覆盖旧文件
  • 回滚方便,只需要回滚html
  • 由于文件名唯一,可开启服务器永远缓

然后,webpack对应的hash有两种,hashchunkhash

  • hash是跟整个项目的构建相关,只要项目里有文件更改,整个项目构建的hash值都会更改,并且全部文件都共用相同的hash值
  • chunkhash根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的哈希值。

细想我们期望的最理想的hash就是当我们的编译后的文件,不管是初始化文件,还是chunk文件或者样式文件,只要文件内容一修改,我们的hash就应该更改,然后刷新缓存。可惜,hash和chunkhash的最终效果都没有达到我们的预期。

另外,还有来自于的 extract-text-webpack-plugincontenthash,contenthash针对编译后的每个文件内容生成hash。只是extract-text-webpack-plugin在wbepack4中已经被弃用,而且这个插件只对css文件生效。

webpack-md5-hash

为了达到我们的预期效果,我们可以为webpack添加webpack-md5-hash插件,这个插件可以让webpack的chunkhash根据文件内容生成hash,相对稳定,这样就可以达到我们预期的效果了,配置如下:


var WebpackMd5Hash = require('webpack-md5-hash');
 
module.exports = {
    // ...
    output: {
        //...
        chunkFilename: "[chunkhash].[id].chunk.js"
    },
    plugins: [
        new WebpackMd5Hash()
    ]
};

合理拆包

为了减少首屏加载的时候,我们需要将包拆分成多个包,然后需要的时候在加载,拆包方案有:

  • 第三方包,DllPlugin、externals。
  • 动态拆包,利用import()、require.ensure()语法拆包
  • splitChunksPlugin

针对第一点第三方包,我们也在第一章节构建优化中有介绍,这里就不详细说了。

动态拆包

首先是import(),这是webpack提供的语法,webpack在解析到这样的语法时,会将指定的目录文件打包成一个chunk,当成异步加载文件输出到编译结果中,语法如下:

import(/* webpackChunkName: chunkName */ './chunkFile.js').then(_module => {
    // do something
});

import()遵循promise规范,可以在then的回调函数中处理模块。

注意:import()的参数不能完全是动态的,如果是动态的字符串,需要预先指定前缀文件夹,然后webpack会把整个文件夹编译到结果中,按需加载。

然后是require.ensure(),与import()类似,为webpack提供函数,也是用来生成异步加载模块,只是是使用callback的形式处理模块,语法如下:

// require.ensure(dependencies: String[], callback: function(require), chunkName: String)

require.ensure([], function(require){
    const _module = require('chunkFile.js');
}, 'chunkName');
splitChunksPlugin

webpack4中,将commonChunksPlugin废弃,引入splitChunksPlugin,两个plugin的作用都是用来切割chunk。

webpack 把 chunk 分为两种类型,initial和async。在webpack4的默认情况下,production构建会分析你的 entry、动态加载(import()、require.ensure)模块,找出这些模块之间共用的node_modules下的模块,并将这些模块提取到单独的chunk中,在需要的时候异步加载到页面当中。

默认配置如下:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      chunks: 'async', // 标记为异步加载的chunk
      minSize: 30000,
      minChunks: 1,
      maxAsyncRequests: 5,
      maxInitialRequests: 3,
      automaticNameDelimiter: '~', // 文件名中chunk的分隔符
      name: true,
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          priority: -10
        },
        default: {
          minChunks: 2, // 最小共享的chunk数
          priority: -20,
          reuseExistingChunk: true
        }
      }
    }
  }
};

splitChunksPlugin提供了灵活的配置,开发者可以根据自己的需求分割chunk,比如下面官方的例子1代码:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          name: 'commons',
          chunks: 'initial',
          minChunks: 2
        }
      }
    }
  }
};

意思是在所有的初始化模块中抽取公共部分,生成一个chunk,chunk名字为comons。

在如官方例子2代码:

module.exports = {
  //...
  optimization: {
    splitChunks: {
      cacheGroups: {
        commons: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all'
        }
      }
    }
  }
};

意思是从所有模块中抽离来自于node_modules下的所有模块,生成一个chunk。当然这只是一个例子,实际生产环境中并不推荐,因为会使我们首屏加载的包增大。

针对官方例子2,我们可以在开发环境中使用,因为在开发环境中,我们的node_modules下的所有文件是基本不会变动的,我们将其生产一个chunk之后,每次增量编译,webpack都不会去编译这个来自于node_modules的已经生产好的chunk,这样如果项目很大,来源于node_modules的模块非常多,这个时候可以大大降低我们的构建时间。

最后

现在大部分前端项目都是基于webpack进行构建的,面对这些项目,或多或少都有一些需要优化的地方,或许做优化不为完成KPI,仅为自己有更好的开发体验,也应该行动起来。

查看原文

赞 39 收藏 29 评论 2

三命 赞了文章 · 8月24日

从 JavaScript 到 TypeScript 4 - 装饰器和反射

随着应用的庞大,项目中 JavaScript 的代码也会越来越臃肿,这时候许多 JavaScript 的语言弊端就会愈发明显,而 TypeScript 的出现,就是着力于解决 JavaScript 语言天生的弱势:静态类型。

前端开发 QQ 群:377786580

这篇文章首发于我的个人博客 《听说》,系列目录:

在上一篇文章 《从 JavaScript 到 TypeScript 3 - 引入和编译》 我们简单介绍了 TypeScript 的引入和编译,在这篇文章中,我们会讨论 ECMAScript 的新特性,为后续的内容做点铺垫。

前言

在了解装饰器之前,我们先看一段代码:

class User {
  name: string
  id: number

  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }

  changeName (newName: string) {
    this.name = newName
  }
}

这段代码声明了一个 Class 为 UserUser 提供了一个实例方法 changeName() 用来修改字段 name 的值。

现在我们要在修改 name 之前,先对 newName 做校验,判断如果 newName 的值为空字符串,就抛出异常。

按照我们过去的做法,我们会修改 changeName() 函数,或者提供一个 validaName() 方法:

class User {
  name: string
  id: number
  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }
  // 验证 Name
  validateName (newName: string) {
    if (!newName){
      throw Error('name is invalid')
    }
  }
  changeName (newName: string) {
    // 如果 newName 为空字符串,则会抛出异常
    this.validateName(newName)
    this.name = newName
  }
}

可以看到,我们新编写的 validateName(),侵入到了 changeName() 的逻辑中。如此带来一个弊端:

  1. 我们不知道 changeName() 里面可能还包含了什么样的隐性逻辑
  2. changeName() 被扩展后逻辑不清晰

然后我们把调用时机从 changeName() 中抽出来,先调用 validateName(),再调用 changeName()

let user = new User('linkFly', 1)
if (user.validateName('tasaid')) {
  user.changeName('tasaid')
}

但是上面的问题 1 仍然没有被解决,调用方代码变的十分啰嗦。那么有没有更好的方式来表现这层逻辑呢?

装饰器就用来解决这个问题:"无侵入式" 的增强。

装饰器

顾名思义,"装饰器" (也叫 "注解")就是对一个 类/方法/属性/参数 的装饰。它是对这一系列代码的增强,并且通过自身描述了被装饰的代码可能存在的行为改变。

简单来说,装饰器就是对代码的描述。

由于装饰器是实验性特性,所以要在 tsconfig.json 里启用这个实验性特性:

{
    "compilerOptions": {
        // 支持装饰器
        "experimentalDecorators": true,
    }
}

钢铁侠托尼·史塔克只是一个有血有肉的人,而他的盔甲让他成为了钢铁侠,盔甲就是对托尼·史塔克的装饰(增强)。

我们使用装饰器修改一下上面的例子:

// 声明一个装饰器,第三个参数是 "成员的属性描述符",如果代码输出目标版本(target)小于 ES5 返回值会被忽略。
const validate = function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  // 保存原来的方法
  let method = descriptor.value
  // 重写原来的方法
  descriptor.value = (newValue: string) => {
    // 检查是否是空字符串
    if (!newValue) {
      throw Error('name is invalid')
    } else {
      // 否则调用原来的方法
      method()
    }
  }
}

class User {
  name: string
  id: number
  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }

  // 调用装饰器
  @validate
  changeName (newName: string) {
    this.name = newName
  }
}

这里我们可以看到,changeName 的逻辑没有任何改变,但其实它的行为已经通过装饰器 @validate 增强。

这就是装饰器的作用。装饰器可以用很直观的方式来描述代码:

class User {
  name: string

  @validateString
  set name (@required name: string) {
    this.name = name 
  }
}

装饰器工厂

装饰器的执行时机如下:

// 这是一个装饰器工厂,在外面使用 @god() 的时候就会调用这个工厂
function god(name: string) {
  console.log(`god(): evaluated ${name}`)
  // 这是装饰器,在 User 生成之后会执行
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
      console.log('god(): called')
  }
}

class User {
  @god('test')
  test () { }
}

以上代码输出结果

god(): evaluated test
god(): called

我们也可以直接声明一个装饰器来使用(要注意和装饰器工厂的区别):

function god(target, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log("god(): called")
}


class User {
  // 注意这里不是 @god(),没有 ()
  @god
  test () { }
}

装饰器全家族

装饰器家族有 4 种装饰形式,注意,装饰器能装饰在类、方法、属性和参数上,但不能只装饰在函数上!

类装饰器

类装饰器表达式会在运行时当作函数被调用,类的构造函数作为其唯一的参数。

function sealed(constructor: Function) {
  Object.seal(constructor)
  Object.seal(constructor.prototype)
}

@sealed
class User { }

方法装饰器

方法装饰器表达式会在运行时当作函数被调用,传入下列 3个参数

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. 成员的名字
  3. 成员的属性描述符 {value: any, writable: boolean, enumerable: boolean, configurable: boolean}
function god(name: string) {
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    // target: 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
    // propertyKey: 成员的名字
    // descriptor: 成员的属性描述符 {value: any, writable: boolean, enumerable: boolean, configurable: boolean}
  }
}

class User {
  @god('tasaid.com')
  sayHello () { }
}

访问器装饰器

和函数装饰器一样,只不过是装饰于访问器上的。

function god(name: string) {
  return function (target, propertyKey: string, descriptor: PropertyDescriptor) {
    // target: 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
    // propertyKey: 成员的名字
    // descriptor: 成员的属性描述符 {value: any, writable: boolean, enumerable: boolean, configurable: boolean}
  }
}

class User {
  private _name: string
  // 装饰在访问器上
  @god('tasaid.com')
  get name () {
    return this._name
  }
}

属性装饰器

属性装饰器表达式会在运行时当作函数被调用,传入下列 2个参数

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. 成员的名字
function god(target, propertyKey: string) {
  // target: 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  // propertyKey: 成员的名字
}

class User {
  @god
  name: string
}

参数装饰器

参数装饰器表达式会在运行时当作函数被调用,传入下列 3个参数:

  1. 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. 成员的名字
  3. 参数在函数参数列表中的索引
const required = function (target, propertyKey: string, parameterIndex: number) {
  // target: 对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  // propertyKey: 成员的名字
  // parameterIndex: 参数在函数参数列表中的索引
}

class User {
  private _name : string;
  set name(@required name : string) {
    this._name = name;
  }
}

例如上面 validate 的例子可以用在参数装饰器上

// 定义一个私有 key
const requiredMetadataKey = Symbol("required")

// 定义参数装饰器,大概思路就是把要校验的参数索引保存到成员中
const required = function (target, propertyKey: string, parameterIndex: number) {
  // 参数装饰器只能拿到参数的索引
  if (!target[propertyKey][requiredMetadataKey]) {
    target[propertyKey][requiredMetadataKey] = {}
  } 
  // 把这个索引挂到属性上
  target[propertyKey][requiredMetadataKey][parameterIndex] = true
}

// 定义一个方法装饰器,从成员中获取要校验的参数进行校验
const validateEmptyStr = function (target, propertyKey: string, descriptor: PropertyDescriptor) {
  // 保存原来的方法
  let method = descriptor.value
  // 重写原来的方法
  descriptor.value = function () {
    let args = arguments
    // 看看成员里面有没有存的私有的对象
    if (target[propertyKey][requiredMetadataKey]) {
      // 检查私有对象的 key
      Object.keys(target[propertyKey][requiredMetadataKey]).forEach(parameterIndex => {
        // 对应索引的参数进行校验
        if (!args[parameterIndex]) throw Error(`arguments${parameterIndex} is invalid`)
      })
    }
  }
}

class User {
  name: string
  id: number
  constructor(name:string, id: number) {
    this.name = name
    this.id = id
  }

  // 方法装饰器做校验
  @validateEmptyStr
  changeName (@required newName: string) { // 参数装饰器做描述
    this.name = newName
  }
}

clipboard.png

元数据反射

反射,就是在运行时动态获取一个对象的一切信息:方法/属性等等,特点在于动态类型反推导。在 TypeScript 中,反射的原理是通过设计阶段对对象注入元数据信息,在运行阶段读取注入的元数据,从而得到对象信息。

反射可以获取对象的:

  • 对象的类型
  • 成员/静态属性的信息(类型)
  • 方法的参数类型、返回类型
class User {
  name: string = 'linkFly'

  say (myName: string): string {
    return `hello, ${myName}`
  }
}

例如上面的例子,在 TypeScript 中可以获取到这些信息:

  • Class Name 为 User
  • User 有一个属性名为 name,有一个方法 say()
  • 属性 namestring 类型的,且值为 linkFly
  • 方法 say() 接受一个 string 类型的参数,在 TypeScript 中,参数名是获取不到的
  • 方法 say() 返回类型为 string

TypeScript 结合自身静态类型语言的特点,为使用了装饰器的代码声明注入了 3 组元数据:

  • design:type: 成员类型
  • design:paramtypes: 成员所有参数类型
  • design:returntype: 成员返回类型

由于元数据反射也是实验性 API,所以要在 tsconfig.json 里启用这个实验性特性:

{
    "compilerOptions": {
        "target": "ES5",
        // 支持装饰器
        "experimentalDecorators": true,
        // 装饰器元数据
        "emitDecoratorMetadata": true
    }
}

然后安装 reflect-metadata

npm i reflect-metadata --save

这样在装饰器中,就可以访问到由 TypeScript 注入的基本信息元数据:

import 'reflect-metadata'

let meta = function (target: any, propertyKey: string) {

  // 获取成员类型
  let type = Reflect.getMetadata('design:type', target, propertyKey)
  // 获取成员参数类型
  let paramtypes = Reflect.getMetadata('design:paramtypes', target, propertyKey)
  // 获取成员返回类型
  let returntype = Reflect.getMetadata('design:returntype', target, propertyKey)
  // 获取所有元数据 key (由 TypeScript 注入)
  let keys = Reflect.getMetadataKeys(target, propertyKey)


  console.log(keys) // [ 'design:returntype', 'design:paramtypes', 'design:type' ]
  // 成员类型
  console.log(type) // Function
  // 参数类型
  console.log(paramtypes) // [String]
  // 成员返回类型
  console.log(returntype) // String
}


class User {
  // 使用这个装饰器就可以反射出成员详细信息
  @meta
  say (myName: string): string {
    return `hello, ${myName}`
  }
}

结语

Java 和 C# 由于是强类型编译型语言,所以反射就成了它们动态反推导数据类型的一个重要特性。

目前来说,JavaScript 因为其动态性,所以本身就包含了一些反射的特点:

  • 遍历对象内所有属性
  • 判断数据类型

TypeScript 补充了基础的类型元数据,只不过还是有些地方不够完善:在 TypeScript 中,参数名通过反射是获取不到的。

为什么获取不到呢?因为 JavaScript 本质上还是解释型语言,还迎合 Web 有一大特色:编译和压缩...

  • 编译完了之后 Class Name 可能叫做 User_1
  • 压缩完了之后参数 myName 可能叫 m
  • 运行时可能传了 2 个,3 个,或者 N 个参数

angular 1.x 中使用的依赖注入,采用传字符串那么蹩脚的方式,也是对 JavaScript 反射机制的不完善做出的一种妥协。

在下一篇《从 JavaScript 到 TypeScript 5 - express 路由进化》 中,我们将在 express 上,使用装饰器和反射实现全新的路由表现。

 

TypeScript 中文网:https://tslang.cn/

TypeScript 视频教程:《TypeScript 精通指南

查看原文

赞 16 收藏 19 评论 1

三命 赞了文章 · 8月6日

JavaScript是如何工作的:深入类和继承内部原理+Babel和 TypeScript 之间转换

这是专门探索 JavaScript 及其所构建的组件的系列文章的第 15 篇。

想阅读更多优质文章请猛戳GitHub博客,一年百来篇优质文章等着你!

如果你错过了前面的章节,可以在这里找到它们:

  1. JavaScript 是如何工作的:引擎,运行时和调用堆栈的概述!
  2. JavaScript 是如何工作的:深入V8引擎&编写优化代码的5个技巧!
  3. JavaScript 是如何工作的:内存管理+如何处理4个常见的内存泄漏 !
  4. JavaScript 是如何工作的:事件循环和异步编程的崛起+ 5种使用 async/await 更好地编码方式!
  5. JavaScript 是如何工作的:深入探索 websocket 和HTTP/2与SSE +如何选择正确的路径!
  6. JavaScript 是如何工作的:与 WebAssembly比较 及其使用场景 !
  7. JavaScript 是如何工作的:Web Workers的构建块+ 5个使用他们的场景!
  8. JavaScript 是如何工作的:Service Worker 的生命周期及使用场景!
  9. JavaScript 是如何工作的:Web 推送通知的机制!
  10. JavaScript是如何工作的:使用 MutationObserver 跟踪 DOM 的变化!
  11. JavaScript是如何工作的:渲染引擎和优化其性能的技巧!
  12. JavaScript是如何工作的:深入网络层 + 如何优化性能和安全!
  13. JavaScript是如何工作的:CSS 和 JS 动画底层原理及如何优化它们的性能!
  14. JavaScript是如何工作的:解析、抽象语法树(AST)+ 提升编译速度5个技巧!

现在构建任何类型的软件项目最流行的方法这是使用类。在这篇文章中,探讨用 JavaScript 实现类的不同方法,以及如何构建类的结构。首先从深入研究原型工作原理,并分析在流行库中模拟基于类的继承的方法。 接下来是讲如何将新的语法转制为浏览器识别的语法,以及在 Babel 和 TypeScript 中使用它来引入ECMAScript 2015类的支持。最后,将以一些在 V8 中如何本机实现类的示例来结束本文。

概述

在 JavaScript 中,没有基本类型,创建的所有东西都是对象。例如,创建一个新字符串:

const name = "SessionStack";

接着在新创建的对象上调用不同的方法:

console.log(a.repeat(2)); // SessionStackSessionStack
console.log(a.toLowerCase()); // sessionstack

与其他语言不同,在 JavaScript 中,字符串或数字的声明会自动创建一个封装值的对象,并提供不同的方法,甚至可以在基本类型上执行这些方法。

另一个有趣的事实是,数组等复杂类型也是对象。如果检查数组实例的类型,你将看到它是一个对象。列表中每个元素的索引只是对象中的属性。当通过数组中的索引访问一个元素时,实际上是访问了数组对象的一个 key 值,并得到 key 对应的值。从数据的存储方式看时,这两个定义是相同的:

let names = [“SessionStack”];

let names = {
  “0”: “SessionStack”,
  “length”: 1
}

因此,访问数组中的元素和对象的属性耗时是相同的。我(本文作者)通过多次的努力才发现这一点的。就是不久,我(本文作者)不得不对项目中的一段关键代码进行大规模优化。在尝试了所有简单的可选项之后,最后用数组替换了项目中使用的所有对象。理论上,访问数组中的元素比访问哈希映射中的键要快且对性能没有任何影响。在 JavaScript中,这两种操作都是作为访问哈希映射中的键来实现的,并且花费相同的时间。

使用原型模拟类

一般的想到对象时,首先想到的是类。我们大都习惯于根据类及其之间的关系来构建应用程序。尽管 JavaScript 中的对象无处不在,但该语言并不使用传统的基于类的继承,相反,它依赖于原型来实现。

图片描述

在 JavaScript 中,每个对象通过原型连接着另一个对象。当尝试访问对象上的属性或方法时,首先从对象本身开始查找,如果没有找到任何内容,则在对象的原型中继续查找。

从一个简单的例子开始:

function Component(content) {
  this.content = content;
}

Component.prototype.render = function() {
    console.log(this.content);
}

Component 的原型上添加 render 方法,因为希望 Component 的每个实例都能有 render 方法。Component 任何实例调用此方法时,首先将在实例本身中执行查找,如果没有,接着从它的原型中执行查找。

图片描述

接着引入一个新的子类:

function InputField(value) {
    this.content = `<input type="text" value="${value}" />`;
}

如果想要 InputField 继承 Component 并能够调用它的 render 方法,就需要更改它的原型。当对子类的实例调用 render 方法时,不希望在它的空原型中查找,而应该从从 Component 上的原型查找:

InputField.prototype = Object.create(new Component());

通过这种方式,就可以在 Component 的原型中找到 render 方法。为了实现继承,需要将 InputField 的原型连接到 Component 的实例上,大多数库都使用 Object.setPrototypeOf 方法来实现这一点。

图片描述

然而,这不是唯一一件事要做的,每次继承一个类,需要:

  • 将子类的原型指向父类的实例。
  • 在子类构造函数中调用的父构造函数,完成父构造函数中的初始化逻辑。

如上所述,如果希望继承基类的的所有特性,那么每次都需要执行这个复杂的逻辑。当创建多个类时,将逻辑封装在可重用函数中是有意义的。这就是开发人员最初解决基于类继承的方法——通过使用不同的库来模拟它。

这些解决方案越来越流行,造成了 JS 中明显缺少了一些类型的现象。这就是为什么在 ECMAScript 2015 的第一个主要版本中引入了类,继承的新语法。

类的转换

当 ES6 或 ECMAScript 2015 中的新特性被提出时,JavaScript 开发人员不能等待所有引擎和浏览器都开始支持它们。为实现浏览器能够支持新的特性一个好方法是通过 转换 (Transpiling) ,它允许将 ECMAScript 2015 中编写的代码转换成任何浏览器都能理解的 JavaScript 代码,当然也包括使用基于类的继承编写类的转换功能。

图片描述

Babel

最流行的 JavaScript 编译器之一就是 Babel,宏观来说,它分3个阶段运行代码:解析(parsing),转译(transforming),生成(generation),来看看它是如何转换的:

class Component {
  constructor(content) {
    this.content = content;
  }

  render() {
      console.log(this.content)
  }
}

const component = new Component('SessionStack');
component.render();

以下是 Babel 转换后的样式:

var Component = function () {
  function Component(content) {
    _classCallCheck(this, Component);
    this.content = content;
  }

  _createClass(Component, [{
    key: 'render',
    value: function render() {
      console.log(this.content);
    }
  }]);

  return Component;
}();

如上所见,转换后的代码就可在任何浏览器执行了。 此外,还添加了一些功能, 这些是 Babel 标准库的一部分。

_classCallCheck_createClass 作为函数包含在编译文件中。

  • _classCallCheck 函数的作用在于确保构造方法永远不会作为函数被调用,它会评估函数的上下文是否为 Component 对象的实例,以此确定是否需要抛出异常。
  • _createClass 用于处理创建对象属性,函数支持传入构造函数与需定义的键值对属性数组。函数判断传入的参数(普通方法/静态方法)是否为空对应到不同的处理流程上。

为了探究继承的实现原理,分析继承的 ComponentInputField 类。。

class InputField extends Component {
    constructor(value) {
        const content = `<input type="text" value="${value}" />`;
        super(content);
    }
}

使用 Babel 处理上述代码,得到如下代码:


 var InputField = function (_Component) {
 _inherits(InputField, _Component);

 function InputField(value) {
    _classCallCheck(this, InputField);

    var content = '<input type="text" value="' + value + '" />';
    return _possibleConstructorReturn(this, (InputField.__proto__ || Object.getPrototypeOf(InputField)).call(this, content));
  }

  return InputField;
}(Component);

在本例中, Babel 创建了 _inherits 函数帮助实现继承。

以 ES6 转 ES5 为例,具体过程:

  1. 编写ES6代码
  2. babylon 进行解析
  3. 解析得到 AST
  4. plugin 用 babel-traverse 对 AST 树进行遍历转译
  5. 得到新的 AST树
  6. 用 babel-generator 通过 AST 树生成 ES5 代码

Babel 中的抽象语法树

AST 包含多个节点,且每个节点只有一个父节点。 在 Babel 中,每个形状树的节点包含可视化类型、位置、在树中的连接等信息。 有不同类型的节点,如 stringnumbersnull等,还有用于流控制(if)和循环(for,while)的语句节点。 并且还有一种特殊类型的节点用于类。它是基节点类的一个子节点,通过添加字段来扩展它,以存储对基类的引用和作为单独节点的类的主体。

把下面的代码片段转换成一个抽象语法树:

class Component {
  constructor(content) {
    this.content = content;
  }

  render() {
    console.log(this.content)
  }
}

下面是以下代码片段的抽象语法树:

图片描述

Babel 的三个主要处理步骤分别是: 解析(parse),转换 (transform),生成 (generate)。

解析

将代码解析成抽象语法树(AST),每个js引擎(比如Chrome浏览器中的V8引擎)都有自己的AST解析器,而Babel是通过 Babylon 实现的。在解析过程中有两个阶段: 词法分析 和 语法分析 ,词法分析阶段把字符串形式的代码转换为 令牌 (tokens)流,令牌类似于AST中节点;而语法分析阶段则会把一个令牌流转换成 AST的形式,同时这个阶段会把令牌中的信息转换成AST的表述结构。

转换

在这个阶段,Babel接受得到AST并通过babel-traverse对其进行 深度优先遍历,在此过程中对节点进行添加、更新及移除操作。这部分也是Babel插件介入工作的部分。

生成

将经过转换的AST通过babel-generator再转换成js代码,过程就是 深度优先遍历整个AST,然后构建可以表示转换后代码的字符串。

在上面的示例中,首先生成两个 MethodDefinition 节点的代码,然后生成类主体节点的代码,最后生成类声明节点的代码。

使用 TypeScript 进行转换

另一个利用转换的流行框架是 TypeScript。它引入了一种用于编写 JavaScript 应用程序的新语法,该语法被转换为任何浏览器或引擎都可以执行的 EMCAScript 5。下面是用 Typescript 实现 Component :

class Component {
    content: string;
    constructor(content: string) {
        this.content = content;
    }
    render() {
        console.log(this.content)
    }
}

转成抽象语法树如下:

图片描述

Typescript 还支持继承:

class InputField extends Component {
    constructor(value: string) {
        const content = `<input type="text" value="${value}" />`;
        super(content);
    }
}

以下是转换结果:

var InputField = /** @class */ (function (_super) {
    __extends(InputField, _super);
    function InputField(value) {
        var _this = this;
        var content = "<input type=\"text\" value=\"" + value + "\" />";
        _this = _super.call(this, content) || this;
        return _this;
    }
    return InputField;
}(Component));

最终的结果还是 ECMAScript 5 代码,其中包含 TypeScript 库中的一些函数。封 __extends 中的逻辑与在第一节中讨论的逻辑相同。

随着 Babel 和 TypeScript 被广泛采用,标准类和基于类的继承成为了构造 JavaScript 应用程序的标准方式,这推动了在浏览器中引入对类的原生支持。

类的原生支持

2014年,Chrome 引入了对 类的原生支持,这允许在不需要任何库或转换器的情况下执行类声明语法。

图片描述

本地实现类的过程就是我们所说的语法糖。这只是一种奇特的语法,它可以编译成语言中已经支持的相同的原语。可以使用新的易于使用的类定义,但是它仍然会创建构造函数和分配原型。

图片描述

V8的支持

撯着,看看在 V8 中对 ECMAScript 2015 类的本机支持的工作原理。正如在 前一篇文章 中所讨论的,首先必须将新语法解析为有效的 JavaScript 代码并添加到 AST 中,因此,作为类定义的结果,一个具有ClassLiteral 类型的新节点被添加到树中。

这个节点存储了一些信息。首先,它将构造函数作为一个单独的函数保存,还保存类属性的列表,这些属性包括 方法、getter、setter、公共字段或私有字段。该节点还存储对父类的引用,该类将继承父类,而父类将再次存储构造函数、属性列表和父类。

一旦这个新的类 ClassLiteral转换成代码,它又被转换成函数和原型。


原文:

https://blog.sessionstack.com...

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

你的点赞是我持续分享好东西的动力,欢迎点赞!

交流

干货系列文章汇总如下,觉得不错点个Star,欢迎 加群 互相学习。

https://github.com/qq44924588...

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复福利,即可看到福利,你懂的。

clipboard.png

查看原文

赞 70 收藏 50 评论 0

三命 收藏了文章 · 8月6日

探索类型系统的底层 - 自己实现一个 TypeScript

这篇文章包含两个部分:

A 部分:类型系统编译器概述(包括 TypeScript)

  • 语法 vs 语义
  • 什么是 AST?
  • 编译器的类型
  • 语言编译器是做什么的?
  • 语言编译器是如何工作的?
  • 类型系统编译器职责
  • 高级类型检查器的功能

B 部分:构建我们自己的类型系统编译器

  • 解析器
  • 检查器
  • 运行我们的编译器
  • 我们遗漏了什么?

A 部分:类型系统编译器概述

语法 vs 语义

语法和语义之间的区别对于早期的运行很重要。

语法 - Syntax

语法通常是指 JavaScript 本机代码。本质上是询问给定的 JavaScript 代码在运行时是否正确。

例如,下面的语法是正确的:

var foo: number = "not a number";

语义 - Semantics

这是特定于类型系统的代码。本质上是询问附加到代码中的给定类型是否正确。

例如,上面的代码在语法上是正确的,但在语义上是错误的(将变量定义为一个数字类型,但是值是一个字符串)。

接下来是 JavaScript 生态系统中的 AST 和编译器。

什么是 AST?

在进一步讨论之前,我们需要快速了解一下 JavaScript 编译器中的一个重要机制 AST。

关于 AST 详细介绍请看这篇文章

AST 的意思是抽象语法树 ,它是一个表示程序代码的节点树。Node 是最小单元,基本上是一个具有 typelocation 属性的 POJO(即普通 JavaScript 对象)。所有节点都有这两个属性,但根据类型,它们也可以具有其他各种属性。

在 AST 格式中,代码非常容易操作,因此可以执行添加、删除甚至替换等操作。

例如下面这段代码:

function add(number) {
  return number + 1;
}

将解析成以下 AST:

编译器类型

在 JavaScript 生态系统中有两种主要的编译器类型:

1. 原生编译器(Native compiler)

原生编译器将代码转换为可由服务器或计算机运行的代码格式(即机器代码)。类似于 Java 生态系统中的编译器 - 将代码转换为字节码,然后将字节码转换为本机代码。

2. 语言编译器

语言编译器扮演着不同的角色。TypeScript 和 Flow 的编译器在将代码输出到 JavaScript 时都算作语言编译器。

语言编译器与原生编译器的主要区别在于,前者的编译目的是 tooling-sake(例如优化代码性能或添加附加功能),而不是为了生成机器代码。

语言编译器是做什么的?

在类型系统编译器中,总结的两个最基本的核心职责是:

1. 执行类型检查

引入类型(通常是通过显式注解或隐式推理),以及检查一种类型是否匹配另一种类型的方法,例如 stringnumber

2. 运行语言服务器

对于一个在开发环境中工作的类型系统(type system)来说,最好能在 IDE 中运行任何类型检查,并为用户提供即时反馈。

语言服务器将类型系统连接到 IDE,它们可以在后台运行编译器,并在用户保存文件时重新运行。流行的语言,如 TypeScript 和 Flow 都包含一个语言服务器。

3. 代码转换

许多类型系统包含原生 JavaScript 不支持的代码(例如不支持类型注解) ,因此它们必须将不受支持的 JavaScript 转换为受支持的 JavaScript 代码。

关于代码转换更详细的介绍,可以参考原作者的这两篇文章 Web BundlerSource Maps

语言编译器是如何工作的?

对于大多数编译器来说,在某种形式上有三个共同的阶段。

1. 将源代码解析为 AST

  • 词法分析 -> 将代码字符串转换为令牌流(即数组)
  • 语法分析 -> 将令牌流转换为 AST 表示形式

解析器检查给定代码的语法。类型系统必须有自己的解析器,通常包含数千行代码。

Babel 解析器 中的 2200+ 行代码,仅用于处理 statement 语句(请参阅此处)。

Hegel 解析器将 typeAnnotation 属性设置为具有类型注解的代码(可以在这里看到)。

TypeScript 的解析器拥有 8900+ 行代码(这里是它开始遍历树的地方)。它包含了一个完整的 JavaScript 超集,所有这些都需要解析器来理解。

2. 在 AST 上转换节点

  • 操作 AST 节点

这里将执行应用于 AST 的任何转换。

3. 生成源代码

  • 将 AST 转换为 JavaScript 源代码字符串

类型系统必须将任何非 js 兼容的 AST 映射回原生 JavaScript。

类型系统如何处理这种情况呢?

类型系统编译器(compiler)职责

除了上述步骤之外,类型系统编译器通常还会在解析之后包括一个或两个额外步骤,其中包括特定于类型的工作。

顺便说一下,TypeScript 的编译器实际上有 5 个阶段,它们是:

  1. 语言服务预处理器 - Language server pre-processor
  2. 解析器 - Parser
  3. 结合器 - Binder
  4. 检查器 - Checker
  5. 发射器 - Emitter

正如上面看到的,语言服务器包含一个预处理器,它触发类型编译器只在已更改的文件上运行。这会监听任意的 import 语句,来确定还有哪些内容可能发生了更改,并且需要在下次重新运行时携带这些内容。

此外,编译器只能重新处理 AST 结构中已更改的分支。关于更多 lazy compilation,请参阅下文。

类型系统编译器有两个常见的职责:

1. 推导 - Inferring

对于没有注解的代码需要进行推断。关于这点,这里推荐一篇关于何时使用类型注解和何时让引擎使用推断的文章

使用预定义的算法,引擎将计算给定变量或者函数的类型。

TypeScript 在其 Binding 阶段(两次语义传递中的第一次)中使用最佳公共类型算法。它考虑每个候选类型并选择与所有其他候选类型兼容的类型。上下文类型在这里起作用,也会做为最佳通用类型的候选类型。在这里的 TypeScript 规范中有更多的帮助。

let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

TypeScript 实际上引入了 Symbolsinterface)的概念,这些命名声明将 AST 中的声明节点与其他声明进行连接,从而形成相同的实体。它们是 TypeScript 语义系统的基本构成。

2. 检查 - Checking

现在类型推断已经完成,类型已经分配,引擎可以运行它的类型检查。他们检查给定代码的 semantics。这些类型的检查有很多种,从类型错误匹配到类型不存在。

对于 TypeScript 来说,这是 Checker (第二个语义传递) ,它有 20000+ 行代码。

我觉得这给出了一个非常强大的 idea,即在如此多的不同场景中检查如此多的不同类型是多么的复杂和困难。

类型检查器不依赖于调用代码,即如果一个文件中的任何代码被执行(例如,在运行时)。类型检查器将处理给定文件中的每一行,并运行适当的检查。

高级类型检查器功能

由于这些概念的复杂性,我们今天不深入探讨以下几个概念:

懒编译 - Lazy compilation

现代编译的一个共同特征是延迟加载。他们不会重新计算或重新编译文件或 AST 分支,除非绝对需要。

TypeScript 预处理程序可以使用缓存在内存中的前一次运行的 AST 代码。这将大大提高性能,因为它只需要关注程序或节点树的一小部分已更改的内容。

TypeScript 使用不可变的只读数据结构,这些数据结构存储在它所称的 look aside tables 中。这样很容易知道什么已经改变,什么没有改变。

稳健性

在编译时,有些操作编译器不确定是安全的,必须等待运行时。每个编译器都必须做出困难的选择,以确定哪些内容将被包含,哪些不会被包含。TypeScript 有一些被称为不健全的区域(即需要运行时类型检查)。

我们不会在编译器中讨论上述特性,因为它们增加了额外的复杂性,对于我们的小 POC 来说不值得。

现在令人兴奋的是,我们自己也要实现一个编译器。

B 部分:构建我们自己的类型系统编译器

我们将构建一个编译器,它可以对三个不同的场景运行类型检查,并为每个场景抛出特定的信息。

我们将其限制在三个场景中的原因是,我们可以关注每一个场景中的具体机制,并希望到最后能够对如何引入更复杂的类型检查有一个更好的构思。

我们将在编译器中使用函数声明和表达式(调用该函数)。

这些场景包括:

1. 字符串与数字的类型匹配问题

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

2. 使用未定义的未知类型

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

3. 使用代码中未定义的属性名

interface Person {
  name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}

实现我们的编译器,需要两部分:解析器检查器

解析器 - Parser

前面提到,我们今天不会关注解析器。我们将遵循 Hegel 的解析方法,假设一个 typeAnnotation 对象已经附加到所有带注解的 AST 节点中。我已经硬编码了 AST 对象。

场景 1 将使用以下解析器:

字符串与数字的类型匹配问题
function parser(code) {
  // fn("craig-string");
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn"
      },
      arguments: [
        {
          type: "StringLiteral", // Parser "Inference" for type.
          value: "craig-string"
        }
      ]
    }
  };

  // function fn(a: number) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn"
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        // 参数标识
        typeAnnotation: {
          // our only type annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 数字类型
            type: "NumberTypeAnnotation"
          }
        }
      }
    ],
    body: {
      type: "BlockStatement",
      body: [] // "body" === block/line of code. Ours is empty
    }
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [expressionAst, declarationAst]
    }
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

可以看到场景 1 中,第一行 fn("craig-string") 语句的 AST 对应 expressionAst,第二行声明函数的 AST 对应 declarationAst。最后返回一个 programmast,它是一个包含两个 AST 块的程序。

在AST中,您可以看到参数标识符 a 上的 typeAnnotation,与它在代码中的位置相匹配。

场景 2 将使用以下解析器:

使用未定义的未知类型
function parser(code) {
  // fn("craig-string");
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn"
      },
      arguments: [
        {
          type: "StringLiteral", // Parser "Inference" for type.
          value: "craig-string"
        }
      ]
    }
  };

  // function fn(a: made_up_type) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn"
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        typeAnnotation: {
          // our only type annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 参数类型不同于场景 1
            type: "made_up_type" // BREAKS
          }
        }
      }
    ],
    body: {
      type: "BlockStatement",
      body: [] // "body" === block/line of code. Ours is empty
    }
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [expressionAst, declarationAst]
    }
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

场景 2 的解析器的表达式、声明和程序 AST 块非常类似于场景 1。然而,区别在于 params 内部的 typeAnnotationmade_up_type,而不是场景 1 中的 NumberTypeAnnotation

typeAnnotation: {
  type: "made_up_type" // BREAKS
}

场景 3 使用以下解析器:

使用代码中未定义的属性名
function parser(code) {
  // interface Person {
  //   name: string;
  // }
  const interfaceAst = {
    type: "InterfaceDeclaration",
    id: {
      type: "Identifier",
      name: "Person",
    },
    body: {
      type: "ObjectTypeAnnotation",
      properties: [
        {
          type: "ObjectTypeProperty",
          key: {
            type: "Identifier",
            name: "name",
          },
          kind: "init",
          method: false,
          value: {
            type: "StringTypeAnnotation",
          },
        },
      ],
    },
  };

  // fn({nam: "craig"});
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn",
      },
      arguments: [
        {
          type: "ObjectExpression",
          properties: [
            {
              type: "ObjectProperty",
              method: false,
              key: {
                type: "Identifier",
                name: "nam",
              },
              value: {
                type: "StringLiteral",
                value: "craig",
              },
            },
          ],
        },
      ],
    },
  };

  // function fn(a: Person) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn",
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        // 
        typeAnnotation: {
          type: "TypeAnnotation",
          typeAnnotation: {
            type: "GenericTypeAnnotation",
            id: {
              type: "Identifier",
              name: "Person",
            },
          },
        },
      },
    ],
    body: {
      type: "BlockStatement",
      body: [], // Empty function
    },
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [interfaceAst, expressionAst, declarationAst],
    },
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

除了表达式、声明和程序 AST 块之外,还有一个 interfaceAst 块,它负责保存 InterfaceDeclaration AST。

declarationAst 块的 typeAnnotation 节点上有一个 GenericType,因为它接受一个对象标识符,即 Person。在这个场景中,programAst 将返回这三个对象的数组。

解析器的相似性

从上面可以得知,这三种有共同点, 3 个场景中保存所有的类型注解的主要区域是 declaration

检查器

现在来看编译器的类型检查部分。

它需要遍历所有程序主体的 AST 对象,并根据节点类型进行适当的类型检查。我们将把所有错误添加到一个数组中,并返回给调用者以便打印。

在我们进一步讨论之前,对于每种类型,我们将使用的基本逻辑是:

  • 函数声明:检查参数的类型是否有效,然后检查函数体中的每个语句。
  • 表达式:找到被调用的函数声明,获取声明上的参数类型,然后获取函数调用表达式传入的参数类型,并进行比较。

代码

以下代码中包含 typeChecks 对象(和 errors 数组) ,它将用于表达式检查和基本的注解(annotation)检查。

const errors = [];

// 注解类型
const ANNOTATED_TYPES = {
  NumberTypeAnnotation: "number",
  GenericTypeAnnotation: true
};

// 类型检查的逻辑
const typeChecks = {
  // 比较形参和实参的类型
  expression: (declarationFullType, callerFullArg) => {
    switch (declarationFullType.typeAnnotation.type) {
      // 注解为 number 类型
      case "NumberTypeAnnotation":
        // 如果调用时传入的是数字,返回 true
        return callerFullArg.type === "NumericLiteral";
      // 注解为通用类型
      case "GenericTypeAnnotation": // non-native
        // 如果是对象,检查对象的属性
        if (callerFullArg.type === "ObjectExpression") {
          // 获取接口节点
          const interfaceNode = ast.program.body.find(
            node => node.type === "InterfaceDeclaration"
          );
          const properties = interfaceNode.body.properties;

          //遍历检查调用时的每个属性
          properties.map((prop, index) => {
            const name = prop.key.name;
            const associatedName = callerFullArg.properties[index].key.name;
            // 没有匹配,将错误信息存入 errors
            if (name !== associatedName) {
              errors.push(
                `Property "${associatedName}" does not exist on interface "${interfaceNode.id.name}". Did you mean Property "${name}"?`
              );
            }
          });
        }
        return true; // as already logged
    }
  },
  annotationCheck: arg => {
    return !!ANNOTATED_TYPES[arg];
  }
};

让我们来看一下代码,我们的 expression 有两种类型的检查:

  • 对于 NumberTypeAnnotation; 调用时类型应为 AnumericTeral(即,如果注解为数字,则调用时类型应为数字)。场景 1 将在此处失败,但未记录任何错误信息。
  • 对于 GenericTypeAnnotation; 如果是一个对象,我们将在 AST 中查找 InterfaceDeclaration 节点,然后检查该接口上调用者的每个属性。之后将所有错误信息都会被存到 errors 数组中,场景 3 将在这里失败并得到这个错误。
我们的处理仅限于这个文件中,大多数类型检查器都有作用域的概念,因此它们能够确定声明在运行时的准确位置。我们的工作更简单,因为它只是一个 POC

以下代码包含程序体中每个节点类型的处理。这就是上面调用类型检查逻辑的地方。

// Process program
ast.program.body.map(stnmt => {
  switch (stnmt.type) {
    case "FunctionDeclaration":
      stnmt.params.map(arg => {
        // Does arg has a type annotation?
        if (arg.typeAnnotation) {
          const argType = arg.typeAnnotation.typeAnnotation.type;
          // Is type annotation valid
          const isValid = typeChecks.annotationCheck(argType);
          if (!isValid) {
            errors.push(
              `Type "${argType}" for argument "${arg.name}" does not exist`
            );
          }
        }
      });

      // Process function "block" code here
      stnmt.body.body.map(line => {
        // Ours has none
      });

      return;
    case "ExpressionStatement":
      const functionCalled = stnmt.expression.callee.name;
      const declationForName = ast.program.body.find(
        node =>
          node.type === "FunctionDeclaration" &&
          node.id.name === functionCalled
      );

      // Get declaration
      if (!declationForName) {
        errors.push(`Function "${functionCalled}" does not exist`);
        return;
      }

      // Array of arg-to-type. e.g. 0 = NumberTypeAnnotation
      const argTypeMap = declationForName.params.map(param => {
        if (param.typeAnnotation) {
          return param.typeAnnotation;
        }
      });

      // Check exp caller "arg type" with declaration "arg type"
      stnmt.expression.arguments.map((arg, index) => {
        const declarationType = argTypeMap[index].typeAnnotation.type;
        const callerType = arg.type;
        const callerValue = arg.value;

        // Declaration annotation more important here
        const isValid = typeChecks.expression(
          argTypeMap[index], // declaration details
          arg // caller details
        );

        if (!isValid) {
          const annotatedType = ANNOTATED_TYPES[declarationType];
          // Show values to user, more explanatory than types
          errors.push(
            `Type "${callerValue}" is incompatible with "${annotatedType}"`
          );
        }
      });

      return;
  }
});

让我们再次遍历代码,按类型对其进行分解。

FunctionDeclaration (即 function hello(){})

首先处理 arguments/params。如果找到类型注解,就检查给定参数的类型 argType 是否存在。如果不进行错误处理,场景 2 会在这里报错误。

之后处理函数体,但是我们知道没有函数体需要处理,所以我把它留空了。

stnmt.body.body.map(line => {
  // Ours has none
});

ExpressionStatement (即 hello())

首先检查程序中函数的声明。这就是作用域将应用于实际类型检查器的地方。如果找不到声明,就将错误信息添加到 errors 数组中。

接下来,我们针对调用时传入的参数类型(实参类型)检查每个已定义的参数类型。如果发现类型不匹配,则向 errors 数组中添加一个错误。场景 1 和场景 2 在这里都会报错。

运行我们的编译器

源码存放在这里,该文件一次性处理所有三个 AST 节点对象并记录错误。

运行它时,我得到以下信息:

总而言之:

场景 1:

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

我们定义参数为 number 的类型,然后用字符串调用它。

场景 2:

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

我们在函数参数上定义了一个不存在的类型,然后调用我们的函数,所以我们得到了两个错误(一个是定义的错误类型,另一个是类型不匹配的错误)。

场景 3:

interface Person {
  name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}

我们定义了一个接口,但是使用了一个名为 nam 的属性,这个属性不在对象上,错误提示我们是否要使用 name

我们遗漏了什么?

如前所述,类型编译器还有许多其他部分,我们在编译器中省略了这些部分。其中包括:

  • 解析器:我们是手动编写的 AST 代码,它们实际上是在类型的编译器上解析生成。
  • 预处理/语言编译器: 一个真正的编译器具有插入 IDE 并在适当的时候重新运行的机制。
  • 懒编译:没有关于更改或内存使用的信息。
  • 转换:我们跳过了编译器的最后一部分,也就是生成本机 JavaScript 代码的地方。
  • 作用域:因为我们的 POC 是一个单一的文件,它不需要理解作用域的概念,但是真正的编译器必须始终知道上下文。

非常感谢您的阅读和观看,我从这项研究中了解了大量关于类型系统的知识,希望对您有所帮助。以上完整代码您可以在这里找到。(给原作者 start)

备注:

原作者在源码中使用的 Node 模块方式为 ESM(ES Module),在将源码克隆到本地后,如果运行不成功,需要修改 start 指令,添加启动参数 --experimental-modules

"start": "node --experimental-modules src/index.mjs",

原文:https://indepth.dev/under-the...

查看原文

三命 赞了文章 · 8月6日

探索类型系统的底层 - 自己实现一个 TypeScript

这篇文章包含两个部分:

A 部分:类型系统编译器概述(包括 TypeScript)

  • 语法 vs 语义
  • 什么是 AST?
  • 编译器的类型
  • 语言编译器是做什么的?
  • 语言编译器是如何工作的?
  • 类型系统编译器职责
  • 高级类型检查器的功能

B 部分:构建我们自己的类型系统编译器

  • 解析器
  • 检查器
  • 运行我们的编译器
  • 我们遗漏了什么?

A 部分:类型系统编译器概述

语法 vs 语义

语法和语义之间的区别对于早期的运行很重要。

语法 - Syntax

语法通常是指 JavaScript 本机代码。本质上是询问给定的 JavaScript 代码在运行时是否正确。

例如,下面的语法是正确的:

var foo: number = "not a number";

语义 - Semantics

这是特定于类型系统的代码。本质上是询问附加到代码中的给定类型是否正确。

例如,上面的代码在语法上是正确的,但在语义上是错误的(将变量定义为一个数字类型,但是值是一个字符串)。

接下来是 JavaScript 生态系统中的 AST 和编译器。

什么是 AST?

在进一步讨论之前,我们需要快速了解一下 JavaScript 编译器中的一个重要机制 AST。

关于 AST 详细介绍请看这篇文章

AST 的意思是抽象语法树 ,它是一个表示程序代码的节点树。Node 是最小单元,基本上是一个具有 typelocation 属性的 POJO(即普通 JavaScript 对象)。所有节点都有这两个属性,但根据类型,它们也可以具有其他各种属性。

在 AST 格式中,代码非常容易操作,因此可以执行添加、删除甚至替换等操作。

例如下面这段代码:

function add(number) {
  return number + 1;
}

将解析成以下 AST:

编译器类型

在 JavaScript 生态系统中有两种主要的编译器类型:

1. 原生编译器(Native compiler)

原生编译器将代码转换为可由服务器或计算机运行的代码格式(即机器代码)。类似于 Java 生态系统中的编译器 - 将代码转换为字节码,然后将字节码转换为本机代码。

2. 语言编译器

语言编译器扮演着不同的角色。TypeScript 和 Flow 的编译器在将代码输出到 JavaScript 时都算作语言编译器。

语言编译器与原生编译器的主要区别在于,前者的编译目的是 tooling-sake(例如优化代码性能或添加附加功能),而不是为了生成机器代码。

语言编译器是做什么的?

在类型系统编译器中,总结的两个最基本的核心职责是:

1. 执行类型检查

引入类型(通常是通过显式注解或隐式推理),以及检查一种类型是否匹配另一种类型的方法,例如 stringnumber

2. 运行语言服务器

对于一个在开发环境中工作的类型系统(type system)来说,最好能在 IDE 中运行任何类型检查,并为用户提供即时反馈。

语言服务器将类型系统连接到 IDE,它们可以在后台运行编译器,并在用户保存文件时重新运行。流行的语言,如 TypeScript 和 Flow 都包含一个语言服务器。

3. 代码转换

许多类型系统包含原生 JavaScript 不支持的代码(例如不支持类型注解) ,因此它们必须将不受支持的 JavaScript 转换为受支持的 JavaScript 代码。

关于代码转换更详细的介绍,可以参考原作者的这两篇文章 Web BundlerSource Maps

语言编译器是如何工作的?

对于大多数编译器来说,在某种形式上有三个共同的阶段。

1. 将源代码解析为 AST

  • 词法分析 -> 将代码字符串转换为令牌流(即数组)
  • 语法分析 -> 将令牌流转换为 AST 表示形式

解析器检查给定代码的语法。类型系统必须有自己的解析器,通常包含数千行代码。

Babel 解析器 中的 2200+ 行代码,仅用于处理 statement 语句(请参阅此处)。

Hegel 解析器将 typeAnnotation 属性设置为具有类型注解的代码(可以在这里看到)。

TypeScript 的解析器拥有 8900+ 行代码(这里是它开始遍历树的地方)。它包含了一个完整的 JavaScript 超集,所有这些都需要解析器来理解。

2. 在 AST 上转换节点

  • 操作 AST 节点

这里将执行应用于 AST 的任何转换。

3. 生成源代码

  • 将 AST 转换为 JavaScript 源代码字符串

类型系统必须将任何非 js 兼容的 AST 映射回原生 JavaScript。

类型系统如何处理这种情况呢?

类型系统编译器(compiler)职责

除了上述步骤之外,类型系统编译器通常还会在解析之后包括一个或两个额外步骤,其中包括特定于类型的工作。

顺便说一下,TypeScript 的编译器实际上有 5 个阶段,它们是:

  1. 语言服务预处理器 - Language server pre-processor
  2. 解析器 - Parser
  3. 结合器 - Binder
  4. 检查器 - Checker
  5. 发射器 - Emitter

正如上面看到的,语言服务器包含一个预处理器,它触发类型编译器只在已更改的文件上运行。这会监听任意的 import 语句,来确定还有哪些内容可能发生了更改,并且需要在下次重新运行时携带这些内容。

此外,编译器只能重新处理 AST 结构中已更改的分支。关于更多 lazy compilation,请参阅下文。

类型系统编译器有两个常见的职责:

1. 推导 - Inferring

对于没有注解的代码需要进行推断。关于这点,这里推荐一篇关于何时使用类型注解和何时让引擎使用推断的文章

使用预定义的算法,引擎将计算给定变量或者函数的类型。

TypeScript 在其 Binding 阶段(两次语义传递中的第一次)中使用最佳公共类型算法。它考虑每个候选类型并选择与所有其他候选类型兼容的类型。上下文类型在这里起作用,也会做为最佳通用类型的候选类型。在这里的 TypeScript 规范中有更多的帮助。

let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

TypeScript 实际上引入了 Symbolsinterface)的概念,这些命名声明将 AST 中的声明节点与其他声明进行连接,从而形成相同的实体。它们是 TypeScript 语义系统的基本构成。

2. 检查 - Checking

现在类型推断已经完成,类型已经分配,引擎可以运行它的类型检查。他们检查给定代码的 semantics。这些类型的检查有很多种,从类型错误匹配到类型不存在。

对于 TypeScript 来说,这是 Checker (第二个语义传递) ,它有 20000+ 行代码。

我觉得这给出了一个非常强大的 idea,即在如此多的不同场景中检查如此多的不同类型是多么的复杂和困难。

类型检查器不依赖于调用代码,即如果一个文件中的任何代码被执行(例如,在运行时)。类型检查器将处理给定文件中的每一行,并运行适当的检查。

高级类型检查器功能

由于这些概念的复杂性,我们今天不深入探讨以下几个概念:

懒编译 - Lazy compilation

现代编译的一个共同特征是延迟加载。他们不会重新计算或重新编译文件或 AST 分支,除非绝对需要。

TypeScript 预处理程序可以使用缓存在内存中的前一次运行的 AST 代码。这将大大提高性能,因为它只需要关注程序或节点树的一小部分已更改的内容。

TypeScript 使用不可变的只读数据结构,这些数据结构存储在它所称的 look aside tables 中。这样很容易知道什么已经改变,什么没有改变。

稳健性

在编译时,有些操作编译器不确定是安全的,必须等待运行时。每个编译器都必须做出困难的选择,以确定哪些内容将被包含,哪些不会被包含。TypeScript 有一些被称为不健全的区域(即需要运行时类型检查)。

我们不会在编译器中讨论上述特性,因为它们增加了额外的复杂性,对于我们的小 POC 来说不值得。

现在令人兴奋的是,我们自己也要实现一个编译器。

B 部分:构建我们自己的类型系统编译器

我们将构建一个编译器,它可以对三个不同的场景运行类型检查,并为每个场景抛出特定的信息。

我们将其限制在三个场景中的原因是,我们可以关注每一个场景中的具体机制,并希望到最后能够对如何引入更复杂的类型检查有一个更好的构思。

我们将在编译器中使用函数声明和表达式(调用该函数)。

这些场景包括:

1. 字符串与数字的类型匹配问题

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

2. 使用未定义的未知类型

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

3. 使用代码中未定义的属性名

interface Person {
  name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}

实现我们的编译器,需要两部分:解析器检查器

解析器 - Parser

前面提到,我们今天不会关注解析器。我们将遵循 Hegel 的解析方法,假设一个 typeAnnotation 对象已经附加到所有带注解的 AST 节点中。我已经硬编码了 AST 对象。

场景 1 将使用以下解析器:

字符串与数字的类型匹配问题
function parser(code) {
  // fn("craig-string");
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn"
      },
      arguments: [
        {
          type: "StringLiteral", // Parser "Inference" for type.
          value: "craig-string"
        }
      ]
    }
  };

  // function fn(a: number) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn"
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        // 参数标识
        typeAnnotation: {
          // our only type annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 数字类型
            type: "NumberTypeAnnotation"
          }
        }
      }
    ],
    body: {
      type: "BlockStatement",
      body: [] // "body" === block/line of code. Ours is empty
    }
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [expressionAst, declarationAst]
    }
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

可以看到场景 1 中,第一行 fn("craig-string") 语句的 AST 对应 expressionAst,第二行声明函数的 AST 对应 declarationAst。最后返回一个 programmast,它是一个包含两个 AST 块的程序。

在AST中,您可以看到参数标识符 a 上的 typeAnnotation,与它在代码中的位置相匹配。

场景 2 将使用以下解析器:

使用未定义的未知类型
function parser(code) {
  // fn("craig-string");
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn"
      },
      arguments: [
        {
          type: "StringLiteral", // Parser "Inference" for type.
          value: "craig-string"
        }
      ]
    }
  };

  // function fn(a: made_up_type) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn"
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        typeAnnotation: {
          // our only type annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 参数类型不同于场景 1
            type: "made_up_type" // BREAKS
          }
        }
      }
    ],
    body: {
      type: "BlockStatement",
      body: [] // "body" === block/line of code. Ours is empty
    }
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [expressionAst, declarationAst]
    }
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

场景 2 的解析器的表达式、声明和程序 AST 块非常类似于场景 1。然而,区别在于 params 内部的 typeAnnotationmade_up_type,而不是场景 1 中的 NumberTypeAnnotation

typeAnnotation: {
  type: "made_up_type" // BREAKS
}

场景 3 使用以下解析器:

使用代码中未定义的属性名
function parser(code) {
  // interface Person {
  //   name: string;
  // }
  const interfaceAst = {
    type: "InterfaceDeclaration",
    id: {
      type: "Identifier",
      name: "Person",
    },
    body: {
      type: "ObjectTypeAnnotation",
      properties: [
        {
          type: "ObjectTypeProperty",
          key: {
            type: "Identifier",
            name: "name",
          },
          kind: "init",
          method: false,
          value: {
            type: "StringTypeAnnotation",
          },
        },
      ],
    },
  };

  // fn({nam: "craig"});
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn",
      },
      arguments: [
        {
          type: "ObjectExpression",
          properties: [
            {
              type: "ObjectProperty",
              method: false,
              key: {
                type: "Identifier",
                name: "nam",
              },
              value: {
                type: "StringLiteral",
                value: "craig",
              },
            },
          ],
        },
      ],
    },
  };

  // function fn(a: Person) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn",
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        // 
        typeAnnotation: {
          type: "TypeAnnotation",
          typeAnnotation: {
            type: "GenericTypeAnnotation",
            id: {
              type: "Identifier",
              name: "Person",
            },
          },
        },
      },
    ],
    body: {
      type: "BlockStatement",
      body: [], // Empty function
    },
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [interfaceAst, expressionAst, declarationAst],
    },
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

除了表达式、声明和程序 AST 块之外,还有一个 interfaceAst 块,它负责保存 InterfaceDeclaration AST。

declarationAst 块的 typeAnnotation 节点上有一个 GenericType,因为它接受一个对象标识符,即 Person。在这个场景中,programAst 将返回这三个对象的数组。

解析器的相似性

从上面可以得知,这三种有共同点, 3 个场景中保存所有的类型注解的主要区域是 declaration

检查器

现在来看编译器的类型检查部分。

它需要遍历所有程序主体的 AST 对象,并根据节点类型进行适当的类型检查。我们将把所有错误添加到一个数组中,并返回给调用者以便打印。

在我们进一步讨论之前,对于每种类型,我们将使用的基本逻辑是:

  • 函数声明:检查参数的类型是否有效,然后检查函数体中的每个语句。
  • 表达式:找到被调用的函数声明,获取声明上的参数类型,然后获取函数调用表达式传入的参数类型,并进行比较。

代码

以下代码中包含 typeChecks 对象(和 errors 数组) ,它将用于表达式检查和基本的注解(annotation)检查。

const errors = [];

// 注解类型
const ANNOTATED_TYPES = {
  NumberTypeAnnotation: "number",
  GenericTypeAnnotation: true
};

// 类型检查的逻辑
const typeChecks = {
  // 比较形参和实参的类型
  expression: (declarationFullType, callerFullArg) => {
    switch (declarationFullType.typeAnnotation.type) {
      // 注解为 number 类型
      case "NumberTypeAnnotation":
        // 如果调用时传入的是数字,返回 true
        return callerFullArg.type === "NumericLiteral";
      // 注解为通用类型
      case "GenericTypeAnnotation": // non-native
        // 如果是对象,检查对象的属性
        if (callerFullArg.type === "ObjectExpression") {
          // 获取接口节点
          const interfaceNode = ast.program.body.find(
            node => node.type === "InterfaceDeclaration"
          );
          const properties = interfaceNode.body.properties;

          //遍历检查调用时的每个属性
          properties.map((prop, index) => {
            const name = prop.key.name;
            const associatedName = callerFullArg.properties[index].key.name;
            // 没有匹配,将错误信息存入 errors
            if (name !== associatedName) {
              errors.push(
                `Property "${associatedName}" does not exist on interface "${interfaceNode.id.name}". Did you mean Property "${name}"?`
              );
            }
          });
        }
        return true; // as already logged
    }
  },
  annotationCheck: arg => {
    return !!ANNOTATED_TYPES[arg];
  }
};

让我们来看一下代码,我们的 expression 有两种类型的检查:

  • 对于 NumberTypeAnnotation; 调用时类型应为 AnumericTeral(即,如果注解为数字,则调用时类型应为数字)。场景 1 将在此处失败,但未记录任何错误信息。
  • 对于 GenericTypeAnnotation; 如果是一个对象,我们将在 AST 中查找 InterfaceDeclaration 节点,然后检查该接口上调用者的每个属性。之后将所有错误信息都会被存到 errors 数组中,场景 3 将在这里失败并得到这个错误。
我们的处理仅限于这个文件中,大多数类型检查器都有作用域的概念,因此它们能够确定声明在运行时的准确位置。我们的工作更简单,因为它只是一个 POC

以下代码包含程序体中每个节点类型的处理。这就是上面调用类型检查逻辑的地方。

// Process program
ast.program.body.map(stnmt => {
  switch (stnmt.type) {
    case "FunctionDeclaration":
      stnmt.params.map(arg => {
        // Does arg has a type annotation?
        if (arg.typeAnnotation) {
          const argType = arg.typeAnnotation.typeAnnotation.type;
          // Is type annotation valid
          const isValid = typeChecks.annotationCheck(argType);
          if (!isValid) {
            errors.push(
              `Type "${argType}" for argument "${arg.name}" does not exist`
            );
          }
        }
      });

      // Process function "block" code here
      stnmt.body.body.map(line => {
        // Ours has none
      });

      return;
    case "ExpressionStatement":
      const functionCalled = stnmt.expression.callee.name;
      const declationForName = ast.program.body.find(
        node =>
          node.type === "FunctionDeclaration" &&
          node.id.name === functionCalled
      );

      // Get declaration
      if (!declationForName) {
        errors.push(`Function "${functionCalled}" does not exist`);
        return;
      }

      // Array of arg-to-type. e.g. 0 = NumberTypeAnnotation
      const argTypeMap = declationForName.params.map(param => {
        if (param.typeAnnotation) {
          return param.typeAnnotation;
        }
      });

      // Check exp caller "arg type" with declaration "arg type"
      stnmt.expression.arguments.map((arg, index) => {
        const declarationType = argTypeMap[index].typeAnnotation.type;
        const callerType = arg.type;
        const callerValue = arg.value;

        // Declaration annotation more important here
        const isValid = typeChecks.expression(
          argTypeMap[index], // declaration details
          arg // caller details
        );

        if (!isValid) {
          const annotatedType = ANNOTATED_TYPES[declarationType];
          // Show values to user, more explanatory than types
          errors.push(
            `Type "${callerValue}" is incompatible with "${annotatedType}"`
          );
        }
      });

      return;
  }
});

让我们再次遍历代码,按类型对其进行分解。

FunctionDeclaration (即 function hello(){})

首先处理 arguments/params。如果找到类型注解,就检查给定参数的类型 argType 是否存在。如果不进行错误处理,场景 2 会在这里报错误。

之后处理函数体,但是我们知道没有函数体需要处理,所以我把它留空了。

stnmt.body.body.map(line => {
  // Ours has none
});

ExpressionStatement (即 hello())

首先检查程序中函数的声明。这就是作用域将应用于实际类型检查器的地方。如果找不到声明,就将错误信息添加到 errors 数组中。

接下来,我们针对调用时传入的参数类型(实参类型)检查每个已定义的参数类型。如果发现类型不匹配,则向 errors 数组中添加一个错误。场景 1 和场景 2 在这里都会报错。

运行我们的编译器

源码存放在这里,该文件一次性处理所有三个 AST 节点对象并记录错误。

运行它时,我得到以下信息:

总而言之:

场景 1:

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

我们定义参数为 number 的类型,然后用字符串调用它。

场景 2:

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

我们在函数参数上定义了一个不存在的类型,然后调用我们的函数,所以我们得到了两个错误(一个是定义的错误类型,另一个是类型不匹配的错误)。

场景 3:

interface Person {
  name: string;
}
fn({ nam: "craig" }); // throw with "nam" vs "name"
function fn(a: Person) {}

我们定义了一个接口,但是使用了一个名为 nam 的属性,这个属性不在对象上,错误提示我们是否要使用 name

我们遗漏了什么?

如前所述,类型编译器还有许多其他部分,我们在编译器中省略了这些部分。其中包括:

  • 解析器:我们是手动编写的 AST 代码,它们实际上是在类型的编译器上解析生成。
  • 预处理/语言编译器: 一个真正的编译器具有插入 IDE 并在适当的时候重新运行的机制。
  • 懒编译:没有关于更改或内存使用的信息。
  • 转换:我们跳过了编译器的最后一部分,也就是生成本机 JavaScript 代码的地方。
  • 作用域:因为我们的 POC 是一个单一的文件,它不需要理解作用域的概念,但是真正的编译器必须始终知道上下文。

非常感谢您的阅读和观看,我从这项研究中了解了大量关于类型系统的知识,希望对您有所帮助。以上完整代码您可以在这里找到。(给原作者 start)

备注:

原作者在源码中使用的 Node 模块方式为 ESM(ES Module),在将源码克隆到本地后,如果运行不成功,需要修改 start 指令,添加启动参数 --experimental-modules

"start": "node --experimental-modules src/index.mjs",

原文:https://indepth.dev/under-the...

查看原文

赞 20 收藏 15 评论 0

三命 赞了文章 · 7月20日

巧用 TypeScript(五)---- infer

介绍

infer 最早出现在此 PR 中,表示在 extends 条件语句中待推断的类型变量。

简单示例如下:

type ParamType<T> = T extends (param: infer P) => any ? P : T;

在这个条件语句 T extends (param: infer P) => any ? P : T 中,infer P 表示待推断的函数参数。

整句表示为:如果 T 能赋值给 (param: infer P) => any,则结果是 (param: infer P) => any 类型中的参数 P,否则返回为 T

interface User {
  name: string;
  age: number;
}

type Func = (user: User) => void

type Param = ParamType<Func>;   // Param = User
type AA = ParamType<string>;    // string

内置类型

在 2.8 版本中,TypeScript 内置了一些与 infer 有关的映射类型:

  • 用于提取函数类型的返回值类型:

    type ReturnType<T> = T extends (...args: any[]) => infer P ? P : any;

    相比于文章开始给出的示例,ReturnType<T> 只是将 infer P 从参数位置移动到返回值位置,因此此时 P 即是表示待推断的返回值类型。

    type Func = () => User;
    type Test = ReturnType<Func>;   // Test = User
  • 用于提取构造函数中参数(实例)类型:

    一个构造函数可以使用 new 来实例化,因此它的类型通常表示如下:

    type Constructor = new (...args: any[]) => any;

    infer 用于构造函数类型中,可用于参数位置 new (...args: infer P) => any; 和返回值位置 new (...args: any[]) => infer P;

    因此就内置如下两个映射类型:

    // 获取参数类型
    type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any ? P : never;
    
    // 获取实例类型
    type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;
    
    class TestClass {
    
      constructor(
        public name: string,
        public string: number
      ) {}
    }
    
    type Params = ConstructorParameters<typeof TestClass>;  // [string, numbder]
    
    type Instance = InstanceType<typeof TestClass>;         // TestClass

一些用例

至此,相信你已经对 infer 已有基本了解,我们来看看一些使用它的「骚操作」:

  • tupleunion ,如:[string, number] -> string | number

    解答之前,我们需要了解 tuple 类型在一定条件下,是可以赋值给数组类型:

    type TTuple = [string, number];
    type TArray = Array<string | number>;
    
    type Res = TTuple extends TArray ? true : false;    // true
    type ResO = TArray extends TTuple ? true : false;   // false

    因此,在配合 infer 时,这很容做到:

    type ElementOf<T> = T extends Array<infer E> ? E : never
    
    type TTuple = [string, number];
    
    type ToUnion = ElementOf<ATuple>; // string | number

    stackoverflow 上看到另一种解法,比较简(牛)单(逼):

    type TTuple = [string, number];
    type Res = TTuple[number];  // string | number
  • unionintersection,如:string | number -> string & number

    这个可能要稍微麻烦一点,需要 infer 配合「 Distributive conditional types 」使用。

    相关链接中,我们可以了解到「Distributive conditional types」是由「naked type parameter」构成的条件类型。而「naked type parameter」表示没有被 Wrapped 的类型(如:Array<T>[T]Promise<T> 等都是不是「naked type parameter」)。「Distributive conditional types」主要用于拆分 extends 左边部分的联合类型,举个例子:在条件类型 T extends U ? X : Y 中,当 TA | B 时,会拆分成 A extends U ? X : Y | B extends U ? X : Y

    有了这个前提,再利用在逆变位置上,同一类型变量的多个候选类型将会被推断为交叉类型的特性,即

    type Bar<T> = T extends { a: (x: infer U) => void, b: (x: infer U) => void } ? U : never;
    type T20 = Bar<{ a: (x: string) => void, b: (x: string) => void }>;  // string
    type T21 = Bar<{ a: (x: string) => void, b: (x: number) => void }>;  // string & number

    因此,综合以上几点,我们可以得到在 stackoverflow 上的一个答案:

    type UnionToIntersection<U> =
      (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
    
    type Result = UnionToIntersection<string | number>; // string & number

    当传入 string | number 时:

    • 第一步:(U extends any ? (k: U) => void : never) 会把 union 拆分成 (string extends any ? (k: string) => void : never) | (number extends any ? (k: number)=> void : never),即是得到 (k: string) => void | (k: number) => void
    • 第二步:(k: string) => void | (k: number) => void extends ((k: infer I)) => void ? I : never,根据上文,可以推断出 Istring & number

当然,你可以玩出更多花样,比如 uniontuple

LeetCode 的一道 TypeScript 面试题

前段时间,在 GitHub 上,发现一道来自 LeetCode TypeScript 的面试题,比较有意思,题目的大致意思是:

假设有一个这样的类型(原题中给出的是类,这里简化为 interface):

interface Module {
  count: number;
  message: string;
  asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>;
  syncMethod<T, U>(action: Action<T>): Action<U>;
}

在经过 Connect 函数之后,返回值类型为

type Result {
  asyncMethod<T, U>(input: T): Action<U>;
  syncMethod<T, U>(action: T): Action<U>;
}

其中 Action<T> 的定义为:

interface Action<T> {
  payload?: T
  type: string
}

这里主要考察两点

  • 挑选出函数
  • 此篇文章所提及的 infer

挑选函数的方法,已经在 handbook 中已经给出,只需判断 value 能赋值给 Function 就行了:

type FuncName<T>  = {
  [P in keyof T]: T[P] extends Function ? P : never;
}[keyof T];

type Connect = (module: Module) => { [T in FuncName<Module>]: Module[T] }
/*
 * type Connect = (module: Module) => {
 *   asyncMethod: <T, U>(input: Promise<T>) => Promise<Action<U>>;
 *   syncMethod: <T, U>(action: Action<T>) => Action<U>;
 * }
*/

接下来就比较简单了,主要是利用条件类型 + infer,如果函数可以赋值给 asyncMethod<T, U>(input: Promise<T>): Promise<Action<U>>,则取值为 asyncMethod<T, U>(input: T): Action<U>。具体答案就不给出了,感兴趣的小伙伴可以尝试一下。

更多

参考

更多文章,请关注我们的公众号:

微信服务号

查看原文

赞 31 收藏 21 评论 4

三命 赞了文章 · 7月16日

彻底搞懂并实现webpack热更新原理

目录

HMR是什么

HMRHot Module Replacement是指当你对代码修改并保存后,webpack将会对代码进行重新打包,并将改动的模块发送到浏览器端,浏览器用新的模块替换掉旧的模块,去实现局部更新页面而非整体刷新页面。接下来将从使用到实现一版简易功能带领大家深入浅出HMR

文章首发于@careteen/webpack-hmr,转载请注明来源即可。

使用场景

scenario

如上图所示,一个注册页面包含用户名密码邮箱三个必填输入框,以及一个提交按钮,当你在调试邮箱模块改动了代码时,没做任何处理情况下是会刷新整个页面,频繁的改动代码会浪费你大量时间去重新填写内容。预期是保留用户名密码的输入内容,而只替换邮箱这一模块。这一诉求就需要借助webpack-dev-server的热模块更新功能。

相对于live reload整体刷新页面的方案,HMR的优点在于可以保存应用的状态,提高开发效率。

配置使用HMR

配置webpack

首先借助webpack搭建项目

  • 初识化项目并导入依赖
mkdir webpack-hmr && cd webpack-hmr
npm i -y
npm i -S webpack webpack-cli webpack-dev-server html-webpack-plugin
  • 配置文件webpack.config.js
const path = require('path')
const webpack = require('webpack')
const htmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {
  mode: 'development', // 开发模式不压缩代码,方便调试
  entry: './src/index.js', // 入口文件
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'main.js'
  },
  devServer: {
    contentBase: path.join(__dirname, 'dist')
  },
  plugins: [
    new htmlWebpackPlugin({
      template: './src/index.html',
      filename: 'index.html'
    })
  ]
}
  • 新建src/index.html模板文件
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Webpack Hot Module Replacement</title>
</head>
<body>
  <div id="root"></div>
</body>
</html>
  • 新建src/index.js入口文件编写简单逻辑
var root = document.getElementById('root')
function render () {
  root.innerHTML = require('./content.js')
}
render()
  • 新建依赖文件src/content.js导出字符供index渲染页面
var ret = 'Hello Webpack Hot Module Replacement'
module.exports = ret
// export default ret
  • 配置package.json
  "scripts": {
    "dev": "webpack-dev-server",
    "build": "webpack"
  }
  • 然后npm run dev即可启动项目
  • 通过npm run build打包生成静态资源到dist目录

接下来先分析下dist目录中的文件

解析webpack打包后的文件内容

  • webpack自己实现的一套commonjs规范讲解
  • 区分commonjs和esmodule

dist目录结构

.
├── index.html
└── main.js

其中index.html内容如下

<!-- ... -->
<div id="root"></div>
<script type="text/javascript" data-original="main.js"></script></body>
<!-- ... -->

使用html-webpack-plugin插件将入口文件及其依赖通过script标签引入

先对main.js内容去掉注释和无关内容进行分析

(function (modules) { // webpackBootstrap
  // ...
})
({
  "./src/content.js":
    (function (module, exports) {
      eval("var ret = 'Hello Webpack Hot Module Replacement'\n\nmodule.exports = ret\n// export default ret\n\n");
    }),
  "./src/index.js": (function (module, exports, __webpack_require__) {
    eval("var root = document.getElementById('root')\nfunction render () {\n  root.innerHTML = __webpack_require__(/*! ./content.js */ \"./src/content.js\")\n}\nrender()\n\n\n");
  })
});

可见webpack打包后会产出一个自执行函数,其参数为一个对象

"./src/content.js": (function (module, exports) {
  eval("...")
}

键为入口文件或依赖文件相对于根目录的相对路径,值则是一个函数,其中使用eval执行文件的内容字符。

  • 再进入自执行函数体内,可见webpack自己实现了一套commonjs规范
(function (modules) {
  // 模块缓存
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // 判断是否有缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 没有缓存则创建一个模块对象并将其放入缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加载
      exports: {}
    };
    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 将状态置为已加载
    module.l = true;
    // 返回模块对象
    return module.exports;
  }
  // ...
  // 加载入口文件
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})
如果对上面commonjs规范感兴趣可以前往我的另一篇文章手摸手带你实现commonjs规范

给出上面代码主要是先对webpack的产出文件混个眼熟,不要惧怕。其实任何一个不管多复杂的事物都是由更小更简单的东西组成,剖开它认识它爱上它。

配置HMR

接下来配置并感受一下热更新带来的便捷开发

webpack.config.js配置

  // ...
  devServer: {
    hot: true
  }
  // ...

./src/index.js配置

// ...
if (module.hot) {
  module.hot.accept(['./content.js'], () => {
    render()
  })
}

当更改./content.js的内容并保存时,可以看到页面没有刷新,但是内容已经被替换了。

这对提高开发效率意义重大。接下来将一层层剖开它,认识它的实现原理。

HMR原理

core

如上图所示,右侧Server端使用webpack-dev-server去启动本地服务,内部实现主要使用了webpackexpresswebsocket

  • 使用express启动本地服务,当浏览器访问资源时对此做响应。
  • 服务端和客户端使用websocket实现长连接
  • webpack监听源文件的变化,即当开发者保存文件时触发webpack的重新编译。

    • 每次编译都会生成hash值已改动模块的json文件已改动模块代码的js文件
    • 编译完成后通过socket向客户端推送当前编译的hash戳
  • 客户端的websocket监听到有文件改动推送过来的hash戳,会和上一次对比

    • 一致则走缓存
    • 不一致则通过ajaxjsonp向服务端获取最新资源
  • 使用内存文件系统去替换有修改的内容实现局部刷新

上图先只看个大概,下面将从服务端和客户端两个方面进行详细分析

debug服务端源码

core

现在也只需要关注上图的右侧服务端部分,左侧可以暂时忽略。下面步骤主要是debug服务端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。

  1. 启动webpack-dev-server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L173
  2. 创建webpack实例,源代码地址@webpack-dev-server/webpack-dev-server.js#L89
  3. 创建Server服务器,源代码地址@webpack-dev-server/webpack-dev-server.js#L107
  4. 添加webpack的done事件回调,源代码地址@webpack-dev-server/Server.js#L122

    1. 编译完成向客户端发送消息,源代码地址@webpack-dev-server/Server.js#L184
  5. 创建express应用app,源代码地址@webpack-dev-server/Server.js#L123
  6. 设置文件系统为内存文件系统,源代码地址@webpack-dev-middleware/fs.js#L115
  7. 添加webpack-dev-middleware中间件,源代码地址@webpack-dev-server/Server.js#L125

    1. 中间件负责返回生成的文件,源代码地址@webpack-dev-middleware/middleware.js#L20
  8. 启动webpack编译,源代码地址@webpack-dev-middleware/index.js#L51
  9. 创建http服务器并启动服务,源代码地址@webpack-dev-server/Server.js#L135
  10. 使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接,源代码地址@webpack-dev-server/Server.js#L745

    1. 创建socket服务器,源代码地址@webpack-dev-server/SockJSServer.js#L34

服务端简易实现

上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合到一个文件中

启动webpack-dev-server服务器

先导入所有依赖

const path = require('path') // 解析文件路径
const express = require('express') // 启动本地服务
const mime = require('mime') // 获取文件类型 实现一个静态服务器
const webpack = require('webpack') // 读取配置文件进行打包
const MemoryFileSystem = require('memory-fs') // 使用内存文件系统更快,文件生成在内存中而非真实文件
const config = require('./webpack.config') // 获取webpack配置文件

创建webpack实例

const compiler = webpack(config)

compiler代表整个webpack编译任务,全局只有一个

创建Server服务器

class Server {
  constructor(compiler) {
    this.compiler = compiler
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }
}
let server = new Server(compiler)
server.listen(8000)

在后面是通过express来当启动服务的

添加webpack的done事件回调

  constructor(compiler) {
    let sockets = []
    let lasthash
    compiler.hooks.done.tap('webpack-dev-server', (stats) => {
      lasthash = stats.hash
      // 每当新一个编译完成后都会向客户端发送消息
      sockets.forEach(socket => {
        socket.emit('hash', stats.hash) // 先向客户端发送最新的hash值
        socket.emit('ok') // 再向客户端发送一个ok
      })
    })
  }

webpack编译后提供提供了一系列钩子函数,以供插件能访问到它的各个生命周期节点,并对其打包内容做修改。compiler.hooks.done则是插件能修改其内容的最后一个节点。

编译完成通过socket向客户端发送消息,推送每次编译产生的hash。另外如果是热更新的话,还会产出二个补丁文件,里面描述了从上一次结果到这一次结果都有哪些chunk和模块发生了变化。

使用let sockets = []数组去存放当打开了多个Tab时每个Tab的socket实例

创建express应用app

let app = new express()

设置文件系统为内存文件系统

let fs = new MemoryFileSystem()

使用MemoryFileSystemcompiler的产出文件打包到内存中。

添加webpack-dev-middleware中间件

  function middleware(req, res, next) {
    if (req.url === '/favicon.ico') {
      return res.sendStatus(404)
    }
    // /index.html   dist/index.html
    let filename = path.join(config.output.path, req.url.slice(1))
    let stat = fs.statSync(filename)
    if (stat.isFile()) { // 判断是否存在这个文件,如果在的话直接把这个读出来发给浏览器
      let content = fs.readFileSync(filename)
      let contentType = mime.getType(filename)
      res.setHeader('Content-Type', contentType)
      res.statusCode = res.statusCode || 200
      res.send(content)
    } else {
      return res.sendStatus(404)
    }
  }
  app.use(middleware)

使用expres启动了本地开发服务后,使用中间件去为其构造一个静态服务器,并使用了内存文件系统,使读取文件后存放到内存中,提高读写效率,最终返回生成的文件。

启动webpack编译

  compiler.watch({}, err => {
    console.log('又一次编译任务成功完成了')
  })

以监控的模式启动一次webpack编译,当编译成功之后执行回调

创建http服务器并启动服务

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    // ...
  }
  listen(port) {
    this.server.listen(port, () => {
      console.log(`服务器已经在${port}端口上启动了`)
    })
  }

使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接

  constructor(compiler) {
    // ...
    this.server = require('http').createServer(app)
    let io = require('socket.io')(this.server)
    io.on('connection', (socket) => {
      sockets.push(socket)
      socket.emit('hash', lastHash)
      socket.emit('ok')
    })
  }

启动一个 websocket服务器,然后等待连接来到,连接到来之后存进sockets池

当有文件改动,webpack重新编译时,向客户端推送hashok两个事件

服务端调试阶段

感兴趣的可以根据上面debug服务端源码所带的源码位置,并在浏览器的调试模式下设置断点查看每个阶段的值。

node dev-server.js

使用我们自己编译的dev-server.js启动服务,可看到页面可以正常展示,但还没有实现热更新。

下面将调式客户端的源代码分析其实现流程。

debug客户端源码

core

现在也只需要关注上图的左侧客户端部分,右侧可以暂时忽略。下面步骤主要是debug客户端源码分析其详细思路,也给出了代码所处的具体位置,感兴趣的可以先行定位到下面的代码处设置断点,然后观察数据的变化情况。也可以先跳过阅读此步骤。

debug客户端源码分析其详细思路

  1. webpack-dev-server/client端会监听到此hash消息,源代码地址@webpack-dev-server/index.js#L54
  2. 客户端收到ok的消息后会执行reloadApp方法进行更新,源代码地址index.js#L101
  3. 在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器,源代码地址reloadApp.js#L7
  4. 在webpack/hot/dev-server.js会监听webpackHotUpdate事件,源代码地址dev-server.js#L55
  5. 在check方法里会调用module.hot.check方法,源代码地址dev-server.js#L13
  6. HotModuleReplacement.runtime请求Manifest,源代码地址HotModuleReplacement.runtime.js#L180
  7. 它通过调用 JsonpMainTemplate.runtime的hotDownloadManifest方法,源代码地址JsonpMainTemplate.runtime.js#L23
  8. 调用JsonpMainTemplate.runtime的hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码,源代码地址JsonpMainTemplate.runtime.js#L14
  9. 补丁JS取回来后会调用JsonpMainTemplate.runtime.js的webpackHotUpdate方法,源代码地址JsonpMainTemplate.runtime.js#L8
  10. 然后会调用HotModuleReplacement.runtime.js的hotAddUpdateChunk方法动态更新模块代码,源代码地址HotModuleReplacement.runtime.js#L222
  11. 然后调用hotApply方法进行热更新,源代码地址HotModuleReplacement.runtime.js#L257HotModuleReplacement.runtime.js#L278

客户端简易实现

上面是我通过debug得出dev-server运行流程比较核心的几个点,下面将其抽象整合成一个文件

webpack-dev-server/client端会监听到此hash消息

在开发客户端功能之前,需要在src/index.html中引入socket.io

<script data-original="/socket.io/socket.io.js"></script>

下面连接socket并接受消息

let socket = io('/')
socket.on('connect', onConnected)
const onConnected = () => {
  console.log('客户端连接成功')
}
let hotCurrentHash // lastHash 上一次 hash值 
let currentHash // 这一次的hash值
socket.on('hash', (hash) => {
  currentHash = hash
})

将服务端webpack每次编译所产生hash进行缓存

客户端收到ok的消息后会执行reloadApp方法进行更新

socket.on('ok', () => {
  reloadApp(true)
})

reloadApp中判断是否支持热更新

// 当收到ok事件后,会重新刷新app
function reloadApp(hot) {
  if (hot) { // 如果hot为true 走热更新的逻辑
    hotEmitter.emit('webpackHotUpdate')
  } else { // 如果不支持热更新,则直接重新加载
    window.location.reload()
  }
}

在reloadApp中会进行判断,是否支持热更新,如果支持的话发射webpackHotUpdate事件,如果不支持则直接刷新浏览器。

在webpack/hot/dev-server.js会监听webpackHotUpdate事件

首先需要一个发布订阅去绑定事件并在合适的时机触发。

class Emitter {
  constructor() {
    this.listeners = {}
  }
  on(type, listener) {
    this.listeners[type] = listener
  }
  emit(type) {
    this.listeners[type] && this.listeners[type]()
  }
}
let hotEmitter = new Emitter()
hotEmitter.on('webpackHotUpdate', () => {
  if (!hotCurrentHash || hotCurrentHash == currentHash) {
    return hotCurrentHash = currentHash
  }
  hotCheck()
})

会判断是否为第一次进入页面和代码是否有更新。

上面的发布订阅较为简单,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。

在check方法里会调用module.hot.check方法

function hotCheck() {
  hotDownloadManifest().then(update => {
    let chunkIds = Object.keys(update.c)
    chunkIds.forEach(chunkId => {
      hotDownloadUpdateChunk(chunkId)
    })
  })
}

上面也提到过webpack每次编译都会产生hash值已改动模块的json文件已改动模块代码的js文件

此时先使用ajax请求Manifest即服务器这一次编译相对于上一次编译改变了哪些module和chunk。

然后再通过jsonp获取这些已改动的module和chunk的代码。

调用hotDownloadManifest方法

function hotDownloadManifest() {
  return new Promise(function (resolve) {
    let request = new XMLHttpRequest()
    //hot-update.json文件里存放着从上一次编译到这一次编译 取到差异
    let requestPath = '/' + hotCurrentHash + ".hot-update.json"
    request.open('GET', requestPath, true)
    request.onreadystatechange = function () {
      if (request.readyState === 4) {
        let update = JSON.parse(request.responseText)
        resolve(update)
      }
    }
    request.send()
  })
}

调用hotDownloadUpdateChunk方法通过JSONP请求获取到最新的模块代码

function hotDownloadUpdateChunk(chunkId) {
  let script = document.createElement('script')
  script.charset = 'utf-8'
  // /main.xxxx.hot-update.js
  script.src = '/' + chunkId + "." + hotCurrentHash + ".hot-update.js"
  document.head.appendChild(script)
}

这里解释下为什么使用JSONP获取而不直接利用socket获取最新代码?主要是因为JSONP获取的代码可以直接执行。

调用webpackHotUpdate方法

当客户端把最新的代码拉到浏览之后

window.webpackHotUpdate = function (chunkId, moreModules) {
  // 循环新拉来的模块
  for (let moduleId in moreModules) {
    // 从模块缓存中取到老的模块定义
    let oldModule = __webpack_require__.c[moduleId]
    // parents哪些模块引用这个模块 children这个模块引用了哪些模块
    // parents=['./src/index.js']
    let {
      parents,
      children
    } = oldModule
    // 更新缓存为最新代码 缓存进行更新
    let module = __webpack_require__.c[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
      parents,
      children,
      hot: window.hotCreateModule(moduleId)
    }
    moreModules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
    module.l = true // 状态变为加载就是给module.exports 赋值了
    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })
    hotCurrentHash = currentHash
  }
}

hotCreateModule的实现

实现我们可以在业务代码中定义需要热更新的模块以及回调函数,将其存放在hot._acceptedDependencies中。

window.hotCreateModule = function () {
  let hot = {
    _acceptedDependencies: {},
    dispose() {
      // 销毁老的元素
    },
    accept: function (deps, callback) {
      for (let i = 0; i < deps.length; i++) {
        // hot._acceptedDependencies={'./title': render}
        hot._acceptedDependencies[deps[i]] = callback
      }
    }
  }
  return hot
}

然后在webpackHotUpdate中进行调用

    parents.forEach(parent => {
      // parents=['./src/index.js']
      let parentModule = __webpack_require__.c[parent]
      // _acceptedDependencies={'./src/title.js',render}
      parentModule && parentModule.hot && parentModule.hot._acceptedDependencies[moduleId] && parentModule.hot._acceptedDependencies[moduleId]()
    })

最后调用hotApply方法进行热更新

客户端调试阶段

经过上述实现了一个基本版的HMR,可更改代码保存的同时查看浏览器并非整体刷新,而是局部更新代码进而更新视图。在涉及到大量表单的需求时大大提高了开发效率。

问题

  • 如何实现commonjs规范?
感兴趣的可前往debug CommonJs规范了解其实现原理。
  • webpack实现流程以及各个生命周期的作用是什么?
webpack主要借助了tapable这个库所提供的一系列同步/异步钩子函数贯穿整个生命周期。webpack生命周期基于此我实现了一版简易的webpack,源码100+行,食用时伴着注释很容易消化,感兴趣的可前往看个思路。
  • 发布订阅的使用和实现,并且如何实现一个可先订阅后发布的机制?
上面也提到需要使用到发布订阅模式,且只支持先发布后订阅功能。对于一些较为复杂的场景可能需要先订阅后发布,此时可以移步@careteen/event-emitter。其实现原理也挺简单,需要维护一个离线事件栈存放还没发布就订阅的事件,等到订阅时可以取出所有事件执行。
  • 为什么使用JSONP而不用socke通信获取更新过的代码?
因为通过socket通信获取的是一串字符串需要再做处理。而通过JSONP获取的代码可以直接执行。

引用

招聘

急缺前端,对搜狐焦点感兴趣的直接简历发我邮件<ketingwang213821@sohu-inc.com>或加我vx: Careteen

查看原文

赞 44 收藏 28 评论 6

三命 收藏了文章 · 7月13日

React Fiber 原理介绍

欢迎关注我的公众号睿Talk,获取我最新的文章:
clipboard.png

一、前言

在 React Fiber 架构面世一年多后,最近 React 又发布了最新版 16.8.0,又一激动人心的特性:React Hooks 正式上线,让我升级 React 的意愿越来越强烈了。在升级之前,不妨回到原点,了解下人才济济的 React 团队为什么要大费周章,重写 React 架构,而 Fiber 又是个什么概念。

二、React 15 的问题

在页面元素很多,且需要频繁刷新的场景下,React 15 会出现掉帧的现象。请看以下例子:
https://claudiopro.github.io/...

clipboard.png

其根本原因,是大量的同步计算任务阻塞了浏览器的 UI 渲染。默认情况下,JS 运算、页面布局和页面绘制都是运行在浏览器的主线程当中,他们之间是互斥的关系。如果 JS 运算持续占用主线程,页面就没法得到及时的更新。当我们调用setState更新页面的时候,React 会遍历应用的所有节点,计算出差异,然后再更新 UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过 16 毫秒,就容易出现掉帧的现象。

针对这一问题,React 团队从框架层面对 web 页面的运行机制做了优化,得到很好的效果。

clipboard.png

三、解题思路

解决主线程长时间被 JS 运算占用这一问题的基本思路,是将运算切割为多个步骤,分批完成。也就是说在完成一部分任务之后,将控制权交回给浏览器,让浏览器有时间进行页面的渲染。等浏览器忙完之后,再继续之前未完成的任务。

旧版 React 通过递归的方式进行渲染,使用的是 JS 引擎自身的函数调用栈,它会一直执行到栈空为止。而Fiber实现了自己的组件调用栈,它以链表的形式遍历组件树,可以灵活的暂停、继续和丢弃执行的任务。实现方式是使用了浏览器的requestIdleCallback这一 API。官方的解释是这样的:

window.requestIdleCallback()会在浏览器空闲时期依次调用函数,这就可以让开发者在主事件循环中执行后台或低优先级的任务,而且不会对像动画和用户交互这些延迟触发但关键的事件产生影响。函数一般会按先进先调用的顺序执行,除非函数在浏览器调用它之前就到了它的超时时间。

有了解题思路后,我们再来看看 React 具体是怎么做的。

四、React 的答卷

React 框架内部的运作可以分为 3 层:

  • Virtual DOM 层,描述页面长什么样。
  • Reconciler 层,负责调用组件生命周期方法,进行 Diff 运算等。
  • Renderer 层,根据不同的平台,渲染出相应的页面,比较常见的是 ReactDOM 和 ReactNative。

这次改动最大的当属 Reconciler 层了,React 团队也给它起了个新的名字,叫Fiber Reconciler。这就引入另一个关键词:Fiber。

Fiber 其实指的是一种数据结构,它可以用一个纯 JS 对象来表示:

const fiber = {
    stateNode,    // 节点实例
    child,        // 子节点
    sibling,      // 兄弟节点
    return,       // 父节点
}

为了加以区分,以前的 Reconciler 被命名为Stack Reconciler。Stack Reconciler 运作的过程是不能被打断的,必须一条道走到黑:

clipboard.png

而 Fiber Reconciler 每执行一段时间,都会将控制权交回给浏览器,可以分段执行:

clipboard.png

为了达到这种效果,就需要有一个调度器 (Scheduler) 来进行任务分配。任务的优先级有六种:

  • synchronous,与之前的Stack Reconciler操作一样,同步执行
  • task,在next tick之前执行
  • animation,下一帧之前执行
  • high,在不久的将来立即执行
  • low,稍微延迟执行也没关系
  • offscreen,下一次render时或scroll时才执行

优先级高的任务(如键盘输入)可以打断优先级低的任务(如Diff)的执行,从而更快的生效。

Fiber Reconciler 在执行过程中,会分为 2 个阶段。

clipboard.png

  • 阶段一,生成 Fiber 树,得出需要更新的节点信息。这一步是一个渐进的过程,可以被打断。
  • 阶段二,将需要更新的节点一次过批量更新,这个过程不能被打断。

阶段一可被打断的特性,让优先级更高的任务先执行,从框架层面大大降低了页面掉帧的概率。

五、Fiber 树

Fiber Reconciler 在阶段一进行 Diff 计算的时候,会生成一棵 Fiber 树。这棵树是在 Virtual DOM 树的基础上增加额外的信息来生成的,它本质来说是一个链表。

clipboard.png

Fiber 树在首次渲染的时候会一次过生成。在后续需要 Diff 的时候,会根据已有树和最新 Virtual DOM 的信息,生成一棵新的树。这颗新树每生成一个新的节点,都会将控制权交回给主线程,去检查有没有优先级更高的任务需要执行。如果没有,则继续构建树的过程:

clipboard.png

如果过程中有优先级更高的任务需要进行,则 Fiber Reconciler 会丢弃正在生成的树,在空闲的时候再重新执行一遍。

在构造 Fiber 树的过程中,Fiber Reconciler 会将需要更新的节点信息保存在Effect List当中,在阶段二执行的时候,会批量更新相应的节点。

六、总结

本文从 React 15 存在的问题出发,介绍 React Fiber 解决问题的思路,并介绍了 Fiber Reconciler 的工作流程。从Stack ReconcilerFiber Reconciler,源码层面其实就是干了一件递归改循环的事情,日后有机会的话,我再结合源码作进一步的介绍。

查看原文

认证与成就

  • 获得 579 次点赞
  • 获得 3 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 3 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-09-24
个人主页被 1.7k 人浏览