最近开发的一个新项目,构建工具从 Webpack 转换到了 Vite,开发体验有了惊人的提升:


  • 冷启动从 10+s 下降至 2s(有缓存时 1s 以内)

  • 热更新从 2s 下降至 1s 以内


Vite 为何如此神奇?官网对 Vite 的介绍很简单:Native-ESM powered web dev build tool. It's fast.


看来重点是 Native-ESM。那么究竟什么是 Native-ESM?为什么 Native-ESM 速度如此之快?让我们一探究竟。



图片



什么是 Native-ESM?

ESM 即 ES Module,全称 EcmaScript Modules,是 EcmaScript 组织制定的模块化规范。


Native 代表原生支持。至于被什么原生支持,这里简单介绍下 EcmaScript、JavaScript、浏览器/Node.js 之间的关系:

  • EcmaScript 是规范

  • JavaScript 是 EcmaScript 的编程语言实现

  • Node.js 和浏览器是 JavaScript 的两个运行时


Vite 是运行在浏览器上的 JavaScript 库,因此这里的 Native-ESM 指的是被浏览器原生支持、符合 EcmaScript Modules 标准的 JavaScript 模块。


在详细探索 ES Module 之前,我们先了解下 JavaScript 模块化的历史。

Javascript 模块化发展历程

编程语言中的模块通常是为了避免全局污染,同时更好地组织程序逻辑,方便代码复用。我们在讨论模块时一般考虑两个方面:

  • 模块定义:如何定义私有和公开的变量、属性、函数、方法

  • 模块加载:如何在运行环境中使用模块,模块之间如何相互引用


有些语言天然支持模块,例如 Java 中的 Class 就是天然的模块:















// import 引入模块import ClassA from 'ClassA';
// Class 类即模块Class B {// 私有属性private Int id;
// 公有方法public getId() {return this.id; }}


但 Javascript 可以理解为一个「一切皆为 function」的语言,并没有提供类似 Java Class 这种完善的模块化语法。为了实现模块化,JS 大致走过了以下历程:

命名空间

为了不污染全局变量,我们可以把想要访问的变量用一个「命名空间」包裹。实际上就是新建一个包含属性的对象,避免属性直接挂在在全局变量(Window/Global)上。但这个对象可任意修改,没有封闭的作用域,所以并非真正的模块。







var moduleA = {id: 1,name: 'test'}
console.log(moduleA.id)


立即执行函数表达式(IIFE)


利用立即执行函数表达式创建一个闭包。函数内的变量均为私有,对外暴露函数的返回值。这样这个闭包就成为了一个模块。代码示例如下:















var moduleA = (function () {var _id = 1var _getId = function () {console.log(id) }
// 对外暴露的是返回值return { getId: _getId }})()
moduleA._id // undefinedmoduleA._getId // undefinedmoduleA.getId // 1


这种方式除了能够完成完整的模块定义,还可以通过给匿名函数传参实现模块引用。在 jQuery 时代,这样的方式被广泛应用:















var moduleB = (function ($, _) { var _body = $('body') var _bodyCopy = _.clone(_body) var _getBodyCopy = function () { console.log(_bodyCopy) }
return { getBodyCopy: _getBodyCopy, }})(jQuery, lodash)
moduleB.getBodyCopy()


但是上面的代码中,jQuery 和 lodash 从哪里来呢?——还是全局变量。所以 IIFE 只在一定程度上解决了全局污染的问题,并没有根除。同时编写的方式也不太优雅。


CommonJS


JavaScript 的运行时起初只有浏览器,Node.js 的出现让 JavaScript 也可以在服务器上运行。似乎服务端的开发者更注重模块化,Node.js 社区推出了 CommonJS 规范。

CommonJS 中一个 js 文件就是一个模块,文件中的所有变量均为私有变量,通过 module.exports 导出一个对象,里面包含的则是公开变量;引用时通过 require 获取模块导出的对象。















// ModuleA.jsvar id = 1;var getId = function () {  console.log(id);};
module.exports = { getId };
// ModuleB.jsconst moduleA = require("ModuleA.js");
moduleA.id; // undefinedmoduleA.getId(); // 1


因为 CommonJS 是 Node.js 的默认模块规范,Node.js 中包含原生的 require 等 API,无需引用其它包获取。

AMD & CMD

CommonJS 的出现让 JavaScript 有了真正的模块规范。但由于其起源于 Node.js 社区,面向服务端开发场景,CommonJS 使用同步的方式加载模块,即等待所有模块加载完毕后再执行代码。


在服务端无需考虑网速的场景下是没问题的,但在浏览器上,同步加载的方式可能会导致较长的网络加载时间。由此诞生了面向浏览器,异步加载的模块方案:AMD、CMD


AMD 和 CMD 放在一起是因为这两个方案同为异步加载模块,只是在模块执行时间的设计理念有所差异。并且区别于 CommonJS,这两个方案均是社区推出,而非浏览器官方标准,因此需要引入各自的包来提供模块 API。

AMD

AMD 是 require.js 推出的模块规范。


在 html 中引入 require.js,并进行配置:















<!-- 引入 sea.js --><script src="sea.js"></script><script>// 配置 sea.js seajs.config({base: "js",alias: {jquery: "jquery/jquery/1.10.1/jquery.js", }, });
// 加载入口模块 seajs.use("js/main.js");</script>


通过 define API 定义和引用模块:


















// moduleA.jsdefine(function () {// 私有变量var id = 1;var getId = function () {console.log(id); };
// 暴露模块return { getId };});
// moduleB.jsdefine(/** 第一个参数是模块引用列表 */ ["moduleA"], function () {console.log(moduleA.getId());});


CMD

CMD 是 sea.js 推广的另一个异步加载模块规范。使用方式和 AMD 类似,都是在入口 html 文件中引入模块依赖包并进行配置,然后在 js 文件中根据各自规范的 API 定义和引用模块。














<!-- 引入 sea.js --><script src="sea.js"></script><script>// 配置 sea.js seajs.config({base: "js",alias: {jquery: "jquery/jquery/1.10.1/jquery.js", }, });
// 加载入口模块 seajs.use("js/main.js");</script>
















// moduleA.jsdefine(function (require, exports, module) {var id = 1;var getId = function () {console.log(id); };
module.exports = { getId };});
// moduleB.jsdefine(function (require, exports, module) {const moduleA = require("moduleA");console.log(moduleA.getId());});

AMD 和 CMD 的区别在于依赖引入的时机。AMD 会将全部依赖前置,只要加载完成就会执行代码;CMD 则推荐将依赖就进处理,需要时再引入。
AMD 和 CMD 的区别













// AMD 推荐define(["a", "b"], function (a, b) { a.doSomething(); // 依赖前置,提前执行 b.doSomething();});
// CMD 推荐define(function (require, exports, module) {var a = require("a"); a.doSomething();var b = require("b"); b.doSomething(); // 依赖就近,延迟执行});
这两种方式没有优劣之分,可能从节省网络开销的角度上 CMD 会更好些:在 AMD 的方案中,若 b.doSomething() 是在某个条件下执行,但没有被执行到,那么模块 b 的加载是浪费的——但不影响程序执行速度。
UMD
UMD 不是一个模块规范,而是一个对 AMD 和 CommonJS 的兼容方案,目的是为了让模块能够同时支持浏览器和 Node.js 两个运行时。实现方式很简单:















(function (window, factory) {// Node.js 环境if (typeof exports === "object") {module.exports = factory();// 浏览器环境,支持 AMD } else if (typeof define === "function" && define.amd) { define(factory);// 浏览器环境,不支持 AMD } else {window.eventUtil = factory(); }})(this, function () {//module ...});

很多工具库都支持同时运行在浏览器和 Node.js 中,所以 UMD 虽然不是单独的模块规范,但是目前主流的模块打包方式。
ES Module
看完了上面的几种模块规范,我们似乎能得到这几个结论:
  • CommonJS 看上去是实现方式最好的,但仅支持 Node.js(Node.js 亲儿子)
  • 浏览器端的方案,IIFE 已经过时了,AMD 和 CMD 支持异步加载适合浏览器,但缺乏浏览器原生 API 支持(不是亲儿子担心维护性),使用方式也没有 CommonJS 来得优雅


于是,浏览器的亲儿子、支持异步加载、使用起来比 CommonJS 更优雅的 ES Module 来了!

之前已经提过 EcmaScript、JavaScript、浏览器/Node.js 之间的关系,因此严格来讲,ES Module 不仅是浏览器的亲儿子,也是 Node.js 的亲儿子。
和 CommonJS 一样,ES Module 也是文件即模块,同时导出和引用的语法更加简单:

















// ModuleA.jsvar id = 1;
// 可以直接导出变量export const getId = function () {console.log(id);};
// 也可以整体导出模块export default { getId };
// ModuleB.jsimport moduleA from 'moduleA.js'
moduleA.id; // undefinedmoduleA.getId(); // 1

但模块的加载在 Node.js 和浏览器上有所不同:
  • Node.js: 模块文件的后缀名为 .mjs
  • 浏览器: script 标签
深入 ES Module
ES Module 的使用方法很简单,我们来看在浏览器运行时,它是如何工作的。
浏览器通过
Module Record 的数据结构。每个 Module Record 包含:

  • ECMAScript Code: AST 语法树
  • 需要请求的模块
    • moduleA.js
    • moduleB.js
  • 请求模块的入口
    • import CustomModule from 'moduleA.js': CustomModule 就是模块入口
  • 其它属性和方法

Module Record 再转换成为包含 code 和 state 的 Module Instance。转换的过程分三步:
  1. 构造(Construction)
  2. 实例化(Instantiation)
  3. 求值(Evaluation)

图片
图片来源:ES modules: A cartoon deep-dive
构造
每个 Module 在构造阶段要执行三个操作:
  • 找到包含模块的文件
  • 下载文件
  • 将文件解析为 Module Record

这两个步骤和运行时相关,例如浏览器中文件通过 script 标签引入的;而 Node.js 中文件从文件系统中引入。

浏览器会建立一个 Module Map,当请求一个 URL 时,浏览器把这个 URL 放入 Module Map,并将其打上标记来标识正在下载该文件。之后就发送请求获取该文件,然后继续获取下一个文件;同时 Module Map 也充当起模块缓存的作用,当请求的 URL 已存在时,会直接从 Map 中取得模块。

当文件下载完成后,浏览器会将文件转换为 Module Record,然后保存在 Module Map 中。
实例化
实例化是给各个 Module Record 中 export 的变量和函数分配内存地址,接着将其它模块中对应的 import 部分,指向对应的 export 内存地址。
JS 引擎会深度游先后遍历模块树,此时变量和函数只分配内存地址,没有值。赋值在最后一步进行。
图片
图片来源:ES modules: A cartoon deep-dive
求值
最后一步是在运行时将变量和函数的值填充到其内存地址中。JS引擎通过执行顶层代码(即函数外部的代码)来实现此目的。Module Map 中保存的 Module Record 里会存有当前模块的状态,可以避免同一个模块文件被多次执行。

图片
图片来源:ES modules: A cartoon deep-dive

Vite 中的 ES Module

回到文章开头的问题,为什么使用 Native-ESM 的 Vite 速度如此之快?
Vite 速度快并非全部是 Native-ESM 的功劳,ESBuild 和 dev-server 的缓存策略也起了巨大作用。这里只介绍 Native-ESM 在加速中起到的帮助。
冷启动
我们在 Webpack 项目里也使用 ES Module 编写代码,但在冷启动时,Webpack 会通过 Babel 把 ES Module 编译为 ES5/ES6 代码,再传给浏览器解析。编译过程耗时巨大;在 Vite 里,浏览器直接请求模块路径并通过原生能力解析,省去了最耗时的本地编译,因此冷启动速度有质的飞跃。

以 Webpack 为代表的「bundle based dev server」,本地编译 ES Module,浏览器解析 ES5 代码。如下图所示:

图片
图片来源:Vite & VitePress @ Vue Toronto 2020

以 Vite 为代表的「ESM based dev server」,本地无编译,浏览器解析 ES Module 代码。如下图所示:

图片
图片来源:Vite & VitePress @ Vue Toronto 2020
热更新
在 Vite 中,热更新是在原生 ESM上执行的。和冷启动一样的道理,当我们修改了一个文件后,不需要再重新编译,热更新耗时大大减少。
总结
Javascript 的模块化从 jQuery 时代的 IIFE 开始,在 Node.js 和浏览器上诞生了多个模块规范。Node.js 上的 CommonJS 设计良好,并且在服务端场景并无明显缺陷,所以自诞生起沿用至今;浏览器上随着 ES Module 规范的推出和 HTML 官方的跟进,ES Module 已成为模块化的最佳选择。
前度工程师普遍已习惯使用 ES Module 编程使代码更加优雅;同时浏览器的原生支持,也让社区产生了像 Vite 这样的 bundless 构建工具,大幅提高了本地开发体验。
参考
  • 用 Vite 加速你的生产力:https://segmentfault.com/a/1190000040422503
  • 【THE LAST TIME】深入浅出 JavaScript 模块化:https://github.com/Nealyang/PersonalBlog/issues/61
  • 前端模块化开发那点历史:https://github.com/seajs/seajs/issues/588
  • ES modules: A cartoon deep-dive:https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/


本文作者:杨子杰
本文编辑:刘桐烔