ESModule 系列 :构建下一代基础设施 PDN
The following article is from ByteDance Web Infra Author 郑凡恺
借助包的分发服务,我们甚至能将本地安装依赖的速度提升10倍
ESM包的分发
什么是ESM包的分发?参考一下下面的几个网站
https://esm.sh/[1] https://cdn.skypack.dev/[2] https://jspm.org/[3]
简单来讲,这些站点都做了一件事情:将 npm 仓库上的包转化成支持 esmodule 的版本并通过 url 来进行分发。
为什么需要分发
为了迎合浏览器的发展浪潮。随着 ECMAScript 2015
提出ECMAScript Module 规范
以来,各个浏览器都在积极地推进着浏览器模块系统的实现。现今(2021年),各个主流浏览器已经基本全面实现并内置了ESModule
系统,为了更好的利用以往用CMD
或者AMD
规范开发的众多 NPM 包,ESM
包的分发网站应运而生。ESM
可以替换掉之前使用UMD加载组件库(或其他包)的场景随着 HTTP 2/3
的发展,5G 网络的普及,网络延时在 Web 交互中的权重会不断的降低,而上一代 Web 开发范式(即利用 bundle 工具如 webpack 等将源代码打成一个大的 bundle )会逐渐被浏览器原生的模块加载机制所取代借助 CDN ,可以对一个特定版本的 NPM 包 转化而来的 ESM 包做永久存储。因为对于 NPM 的每一个包都会有版本号控制,版本号不变内容就不会变。而一个 package@version 一旦转化成 ESM 包后就可以被永久化存储 可以配合 Esbuild 等新一代构建工具提升本地依赖的安装速度(定一个小目标:提速20倍)
原理
将一个 NPM 包转化为一个支持 ESM 规范的包,需要做的其实就是针对模块语法进行升级,将传统的 ADM/CMD/UMD 语法,通过 AST 的解析,将其转化为 ESModule 语法。
困境
模块语法的转化,不同于用 babel 将 ES6 转化为 ES5,从 ES6 到 ES5 是语法上的降级,而从 ADM/CMD/UMD 模块语法到 ESM 语法的转化,是属于语法的升级,升级过程中势必会遇到很多语法兼容问题。
CMD模块语法的动态导入导出问题
众所周知,Commonjs
模块语法是动态执行的,即 require()
执行之后拿到的模块有哪些属性,只有代码真正执行到 require 函数调用的那一行时才能知道,而 ESModule 模块语法规范中,模块的引入和导出在源代码执行之前就已经通过静态语法解析完成。
// exports.cjs
module.exports = {}
// require.cjs
console.info('start require')
const { keyA } = require('./exports.cjs')
console.info('require done')
// log
start require
require done
[CJS]
// exports.mjs
export default {
KeyA,
keyB,
}
// imports.mjs
console.info('start import')
import { keyA } from './exports.mjs'
console.info('import done')
// log
error, 'keyA' is not exported by './exports.mjs'
[ESM]
可以看到,ESM 模块语法在代码执行前就会通过静态语法检测,解析出子模块的具名导出变量和默认导出变量,然后会根据导入语法,在代码真正执行前先进行一次校验,如果引入了错误的变量,会直接抛出错误;而 CJS 模块语法不会预先进行语法检测,而是运行源代码,运行到 require 函数被调用时才会去处理子模块的导出。而 CJS 和 ESM 的模块导出机制也是不同的。在 CJS 中, module.exports 和 exports 对象其实是同一个引用,即,不论用户用什么语法来导出属性,最终导出的属性全是挂在了一个对象的引用上,而其他模块引用这个模块时,require 执行之后拿到的其实就是这个引用对象。而在 ESM 中,export default 和 export {} 属于两种完全不同的导出语法,通过默认导出语法 export default 导出的值,只能通过 import A 或者 import { default as A } 来导入,通过具名导出语法 export { A } 导出的值,只能通过 import { A } 导入。这两种导入导出方式不能混用,若错误使用,浏览器底层会直接抛出错误,而在 CJS 中,由于导出的值一直是一个对象,所以通过 require 引入模块时,是不会抛出语法错误的(除非模块不存在)。而目前生态最成熟的 ESM 转化工具比如 Rollup 和 Esbuild,他们对于 CJS 模块的转化支持也不是很友好。
// react.production.js
module.exports = {
createElement,
...React
}
// react.production.transpiled.mjs
const ReactLib = _commonjs(() => {
return {
createElement,
... React
}
})
export default ReactLib
[React的ESM转化]
可以看到,React 的 cjs 代码经过 Rollup 或者 Esbuild 转化之后,会直接被编译成只有一个默认导出的模块,通过这样的转化,在使用 React 时,会与我们常规的使用习惯有所冲突。
// Success
import React from 'react.production.traspiled.mjs'
React.createElement(xxx)
// Error: 'createElement' is not exported from 'react.production.traspiled.mjs'
import { createElement } from 'react.production.traspiled.mjs'
循环引入,动态引入语法在 ESM 中没有与 CMD 对等的语法转化
在 CJS 中,由于 require 本身就是动态的同步函数,所以 CJS 本身是支持动态引入的,而在 ESM 中,原生不支持同步的动态引入,想要在 ESM 中使用动态引入语法,只能通过 import().then()
的异步引入来模拟。但是这两者其实语法并不能做等价,其中,require 是同步执行的语法,返回结果是引入的对象;而 import()
是异步执行的语法,返回结果是一个 Promise
// cjs
module.exports = {
Module: require('Module')
}
// esm
import Module from 'Module'
export default {
Module
}
[非严格意义上的动态引入转化]
通过以上方案转化来的动态引入,原语义是希望在使用的时候再引用,而转化之后的 ESM 语法将其变为了,先引用,再使用,可能导致 'Module' 模块内部实例化未完成的情况下就已经被使用,导致出现 Module.xxx is not defined
的问题。
比如 protobufjs,参考 https://cdn.skypack.dev/-/protobufjs@v6.11.2-y1acFEe2eMgyc8qMlXUx/dist=es2020,mode=imports/optimized/protobufjs.js[4]
共享 Context 重复打包的问题
由 CMD 转化为 ESM 的过程中,分发网络通常会使用 Rollup 等工具,将依赖包的源代码全部打包到一起,最后提供一个 ESM 单文件,这样可以显著的减少网络请求量(比如,请求 antd 包,如果不打包源码,可能需要递归引入 antd/es/** 下的所有文件,这样网络请求数量可能达到数百级别)。
import * as Module from 'antd.mjs'
同样的,如果引用 ESM 包的不同路径文件时,比如 swiper@6.7.0/es/index.js
和 swiper@6.7.0/esm/components/core/update
, 若这两个路径的 ESM 单文件中引用了同样的 Context (比如 React Context),那么最终每个路径的文件里面都会包含一份 Context 的代码,这就导致最终的运行结果不符合预期。
// swiper@6.7.0/es/index.js
import Context from '/common/Context'
Context.setContext({ ... })
// swiper@6.7.0/esm/components/core/update
import Context from '/common/Context'
Context.setContext({ ... })
// ESM 转化结果
// swiper@6.7.0/es/index.js
Context = React.createContext()
Context.setContext()
//swiper@6.7.0/esm/components/core/update
Context = React.createContext()
Context.setContext()
可以看到,以上两个同 ESM 包的不同路径,但是打包了两份一样的 Context。
其他问题...
解决方案
通过 AST 等方案,直接动态解析出所有
exports.xxx
和Object.definedProperty(exports, 'xxx')
等语句,手动将其编译成具名导出语法export { xxx }
通过在
Node.js
中模拟一个Browser Context
,在 Context 中尝试调用require('Module')
,通过 CJS 加载方式拿到模块的导出对象,将其手动编译成具名导出和默认导出方案
with (BrowserContext) {
try {
const Module = require(ModuleName)
code += `\n export {`
Object.keys(Module).forEach(namedExport => {
code += `${namedExport}, `
})
code += '}'
} catch (e) {}
}
通过动态白名单的方式,针对有动态引入的 NPM 包,在转化成 ESM 包之前,首先用 Webpack 将其 bundle 一次,然后在进行 ESM 转化。
通过动态白名单的方式,针对有共享 Context 的 NPM 包,不再打包所有源码
其他解决方案...
在漫长的踩坑与实践中,我们内部已经基本实现了 NPM 包转化 ESM 的分发服务(相比较市面上的分发服务,该服务将转化过程中遇到的问题进一步抽象,实现了一层修复层,可以支持动态修复)。
下一代开发工具
前几期我们已经有同学介绍了如何开发一个 unbudnled 开发工具;在这里,「下一代」开发工具指的就是「unbundle」开发工具,下面要讲的,就是围绕「unbundle」这个词。
原理
目前市面上流行的 unbundle 开发工具,比如 Vite,Snowpack,它们的底层核心架构基本都是一致的,即将源码与第三方依赖分开单独做处理。
在 dev server 启动前,开发工具首先会遍历源码目录,解析每个源码文件 AST 中所有的 ImportDeclaration
,拿到所有的第三方依赖路径;然后将解析出的第三方依赖路径作为 entryPoints
传入传统的构建工具(Webpack,Rollup,Esbuild 等),打出一个多入口的另类 node_modules,在这个 node_modules 中,除了传入的 entryPoints
继续作为目标文件存在外,其他的公共依赖部分都会被打成一个大的 chunks。
[原始node_modules]
[bundle后的node_modules]
在 node_modules 处理完之后,接下来工具对源码不会做任何处理,直接启动 dev server,通常在 unbundle 开发工具中,默认的首页模板通常会包含下面这样的代码
<script type="module" src="/index.js" />
这样,在用户访问首页时,已经实现了 ECMAScript Module
机制的浏览器会自动去请求 /index.js
文件,请求会被 dev server 做拦截,同时代理到源代码中的 src/index.js
文件上。
优势
基于浏览器的 ESModule 加载机制,开发工具可以不用在每次启动 dev server 时都去打包源代码,基于这个思路,将第三方依赖和源代码区分开,对第三方依赖单独打包,而且由于第三方依赖是持久不变的,可以一次打包,次次使用(不新增新的依赖的情况下)。
在这种架构下,当第三方依赖已经被预处理之后的情况下,理论上每次启动 dev server 的时间可以达到秒级,对于传统的构建工具(Webpack,Rollup),开发服务器的启动速度可以说是提升了2个数量级。
思考
与分发服务结合,不安装依赖,快速开发
试想一下,在 Snowpack / Vite 的基础之上。我如果直接在源代码里面引用一个没有安装在本地的依赖,然后 dev server 直接连接到 ESM 分发服务,直接使用线上的包,同时检测一下这个依赖的版本,自动更新到 package.json 中,并在后台自动运行 install 进程。
在这个过程中,我没有安装新的依赖,但是可以直接在源代码中使用,所见即所得,无需等待。同时在开发过程中,这个依赖也会经由开发工具自动检测并安装到本地,在后续 dev server 重启的过程中会自动同步最新的本地依赖状况。
快速安装依赖
上一点说到,可以通过将 ESM 包分发服务与下一代开发工具结合,来实现本地开发体验的巨大飞跃。更激进一点,能不能通过 ESM 包的服务直接干掉 node_modules,或者说,换一个更精简,更快就能安装下来的 node_modules 呢?答案当然是肯定的。
通过分析 Vite 和 Snowpack 的源码,可以发现,这一类开发工具底层处理 node_modules 的方案,都是通过 Rollup / Esbuild,传入 entryPoints
的方式来对 node_moduels 进行预处理,从而构建出一个全 ESM 化的 node_modules。
那么我们可以直接在这一步的基础之上,通过开发 Rollup / Esbuild 插件,将读取本地文件的过程全部代理到 ESM 包的分发服务上去。而由于 ESM 包的分发服务对每个包的处理是将包的源码进行打包,因此在文件数量上会呈现数十倍的下降;而打包结果会永久存储到CDN上,等于一次安装,永久使用,相较于本地npm安装依赖时每次都需要下载依赖的整个 zip 包,网络 I/O 的耗时也会呈现数倍的下降。
基于这样一种思路实现的依赖安装工具,不仅可以完整还原 node_moduels 的目录结构,而且安装速度相较于 yarn/npm/pnpm ,也会有数倍的提升,尤其是在有锁文件的情况下,安装速度提升十倍也不是不可能。
[没有锁文件的情况下,通过 yarn 安装依赖的速度]
[没有锁文件的情况下,通过上述方案安装依赖的速度]
[有锁文件的情况下,通过通过 yarn 安装依赖的速度]
[有锁文件的情况下,通过上述方案安装依赖的速度]
目前,新一代依赖管理工具和新一代开发工具的工作还处于初期,整个工程还有巨大的优化空间,包括安装速度的进一步提升,对本地缓存的进一步利用,对 monorepo 的支持等...
后续的进展我们会持续与大家进行分享;当然,如果屏幕前的你对这些工作有兴趣,欢迎扫描下方的二维码加入我们一起建设。
参考资料
https://esm.sh/: https://esm.sh/
[2]https://cdn.skypack.dev/: https://cdn.skypack.dev/
[3]https://jspm.org/: https://jspm.org/
[4]https://cdn.skypack.dev/-/protobufjs@v6.11.2-y1acFEe2eMgyc8qMlXUx/dist=es2020,mode=imports/optimized/protobufjs.js: https://cdn.skypack.dev/-/protobufjs@v6.11.2-y1acFEe2eMgyc8qMlXUx/dist=es2020,mode=imports/optimized/protobufjs.js
关于奇舞团
奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。