前言
前端发展到如今,社区生态已经非常丰富。
在无数开源大神的努力下,很多前端开发的痛点(比如「静态类型检查」、「浏览器兼容性」)早已有了事实上的标准解决方案(比如TS
、babel
)。
然而,在这繁荣之下,有一个日常开发不易感知的问题:
模块化规范的混乱
你可曾遇到过莫名其妙的bug
,在多方搜资源,反复验证,耗费数个小时终于发现:
原来是某个包导出的是CJS
,而项目使用ESM
导致。
比如这个例子:记一次打包压缩报错[1]
如果你觉得这是个很容易发现的问题,再考虑结合上node_modules
的层层依赖呢?
这个问题,揭开了模块化规范间斗争与博弈的冰山一角。
作为现代前端工程化的基石,模块化规范有太多值得深究的内容。
我会花几篇文章来讲解模块化规范。本文是第一篇,会围绕模块化规范的演进展开。
正文
如果问十年前的前端最头疼的是什么?一定是浏览器兼容性。
随着babel
等编译工具出现,兼容性逐渐被工程化方案解决(ES6 编译为ES5)。
不仅是「兼容性」问题,DSL
(如JSX
、VUE
的模版语法)、代码压缩
、代码静态检查
(TS)等日常开发的刚需都能在工程化方案中找到解决办法。
如果将当今繁荣的前端工程化生态比喻为一座大厦,那大厦的地基一定是「模块化规范」。
现代JS代码都是基于「模块化规范」组织起来,让我们从下往上来看看这座大厦:
规范的实现依赖于宿主环境,比如浏览器环境实现了EcmaScript Module
(后文简称ESM
)规范。
Node v12
之前支持CommonJS
(后文简称CJS
)规范,12之后同时支持CJS
与ESM
。
在「宿主环境」之上,是基于模块化规范实现的「工具集」,比如webpack
、vite
、VScode
生态。
再往上,基于「工具集」提供的API
,可以实现各种工程化工具。比如:
webpack loader
VScode plugin
babel plugin
再往上,就是开发者自己编写的业务代码。
开发者只需要在工具集中配置好工具,就能为业务代码提供服务。比如:
- 在
VScode
(工具集)中配置eslint
(工具),就能在开发时获得相应提示 - 在
webpack
(工具集)中配置babel loader
(工具),就能在开发时使用ES6
语法
可见,理想状态下,在开发者视角是不需要关注底层的「模块化规范」实现的。
规范之争
然而,事物是动态发展的,模块化规范也不是一蹴而就的,让我们回到09年。
美国程序员「Ryan Dahl」创造了node.js
项目,将JS
用于服务端开发。
node.js
使用CJS[2]标准作为模块化规范。
有了服务端模块规范(CJS),很自然的,JS
开发者们想为客户端(主要是浏览器)提供一种模块化规范。
然而CJS
是为服务端设计的。
在服务端,IO
操作通常能迅速完成,所以CJS
规范定义的:
模块加载 --> 模块解析 --> 模块执行
这个流程是作为一个整体同步执行的。
然而在浏览器环境,「模块加载」(即数据请求)通常很耗时。有人曾作出一个形象的比喻:
如果一个CPU周期花费1秒完成,那么文件的网络请求需要花费4年。
显然浏览器端需要一种「支持异步」的模块化规范。
AMD
(Asynchronous Module Definition 异步模块定义)规范,就是这样需求背景下的产物。
然而这些社区提出的规范终究只是为了解决一时的需求,随着历史的发展,新的模块化规范不断涌入、消亡。
直到ESM
规范被提出。
ESM
规范是ES
标准的模块化规范,他的早期讨论可以追溯到2009年。
你可以在这里看到
ESM
规范的历史es-module-history[3]
ESM
将模块规范分为三个阶段:
模块加载 --> 模块实例化 --> 模块执行
其中「模块加载」由宿主环境提供的loader
完成(比如在浏览器环境,loader
的行为由HTML规范[4]定义)。
「模块实例化」与「模块执行」由ESM
规范定义执行流程。
区别于CJS
规范的同步执行,ESM
规范将流程拆解为3个独立阶段。
「模块加载」同步、异步与否由宿主环境决定。
支持不同宿主环境,抹平多端差异、能力比其他规范都强大(后文会介绍)、再加上血统纯正(ES
官方提出),
使得ESM
规范一统前端「看似」指日可待。
然而,此时社区已经有大量基于CJS
规范产出的开源包、组件,他们无法立刻切换到ESM
规范。
所以,JS
生态的现状是:会处于、并将长期处于CJS
规范的库与ESM
规范的库共存的状态。
但是最终,ESM
规范一定会成为主导,毕竟他的优点太多(同样,后文会介绍)。
规范割裂带来的机会
当前模块化规范的混乱,对开源大佬们来说,就是机会。
为了让开发者将更多精力放在业务,而不是模块规范的适配上。
很多开源「工具集」都试图抹平模块化差异,比如:
- 在
babel
中使用babel-plugin-transform-commonjs
可以将CJS
规范的代码转换为ESM
规范 - 为了一刀切解决当前
ESM
、CJS
、浏览器script
标签导入这3种规范互相不兼容的情况,提出了兼容三者格式的UMD
(Universal Module Definition)规范
一些「工具集」利用模块化规范的不同与其他竞品形成差异化竞争,比如:
browserify
这款打包工具的卖点是:使用CJS
规范打包,使一份代码同时在Node
环境与浏览器环境(打包后)执行。
其中,在浏览器环境中,Node
的一些核心库(如events、stream、path...)会被打包成浏览器支持的版本。
Vite
在DEV
环境使用ESM
规范构建模块间的依赖关系。
依赖于大部分现代浏览器原生支持ESM
规范,省去了打包的过程,使其编译速度大大提升。
rollup
原生对ESM
提供更多支持。
严格支持ESM
规范,并提供更好的静态分析,使rollup
一度提供性能更优异的treeShaking
能力。
成为更多库
打包工具的首选。
与webpack
这样的的大而全方案形成差异竞争。
规范割裂带来的痛
可以看到,由于底层宿主环境对模块化规范支持的割裂,需要上层工具集来抹平模块规范的差异。
设想一个同时使用了webpack
、babel
、TS
的项目。
这3个工具集都对多种模块规范有兼容处理。比如:
单独使用babel
时,对于如下代码:
import a from 'lib';
console.log(a);
会被babel
编译为:
"use strict";
var _lib = _interopRequireDefault(require("lib"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
console.log(_lib.default);
ESM
的「默认导出」会被编译为包含default
属性的对象。
你可以打开babel playground[5]试试
当多个「工具集」在同一个项目中,为了各自目的做着同一件事(抹平模块化规范差异),
一旦工具链中某个插件配置有一丝丝不符合预期,或者引入了一个不符合预期的包,那么艰难的debug
就此开始了......
曙光
即使当前有诸多不便,历史的进程是无法阻止的,那些被历史巨轮甩下并碾碎的模块化规范,会逐渐消失在开发者的视野中。
而赢家注定会通吃。
为什么ESM
注定会成为最大赢家?他有什么无法比拟的优势?我们会在下篇文章揭晓。
参考资料
[1]
记一次打包压缩报错: https://cloud.tencent.com/developer/article/1650627
[2]
CJS: http://wiki.commonjs.org/wiki/Modules/1.1
[3]
es-module-history: https://gist.github.com/jkrems/769a8cd8806f7f57903b641c74b5f08a
[4]
HTML规范: https://html.spec.whatwg.org/#fetch-a-module-script-tree
[5]
babel playground: https://babeljs.io/repl