ESModule 系列 :构建下一代基础设施 PDN 雨点打透心脏的1/2处 2022-09-09 11:57 225阅读 0赞 > 借助包的分发服务,我们甚至能将本地安装依赖的速度提升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 语法的转化,是属于语法的升级,升级过程中势必会遇到很多语法兼容问题。 1. 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' 1. 循环引入,动态引入语法在 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\] ![c07b930ffc1392ff9ff2d46a9de10163.png][] 1. 共享 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。 1. 其他问题... ### 解决方案 ### 1. 通过 AST 等方案,直接动态解析出所有 `exports.xxx` 和 `Object.definedProperty(exports, 'xxx')` 等语句,手动将其编译成具名导出语法 `export { xxx }` 2. 通过在 `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) {} } 1. 通过动态白名单的方式,针对有动态引入的 NPM 包,在转化成 ESM 包之前,首先用 Webpack 将其 bundle 一次,然后在进行 ESM 转化。 2. 通过动态白名单的方式,针对有共享 Context 的 NPM 包,不再打包所有源码 3. 其他解决方案... 在漫长的踩坑与实践中,我们内部已经基本实现了 NPM 包转化 ESM 的分发服务(相比较市面上的分发服务,该服务将转化过程中遇到的问题进一步抽象,实现了一层修复层,可以支持动态修复)。 ## 下一代开发工具 ## > 引用:[渐进式 Unbundled 开发工具探索之路][Unbundled] 前几期我们已经有同学介绍了如何开发一个 unbudnled 开发工具;在这里,「下一代」开发工具指的就是「unbundle」开发工具,下面要讲的,就是围绕「unbundle」这个词。 ### 原理 ### 目前市面上流行的 unbundle 开发工具,比如 Vite,Snowpack,它们的底层核心架构基本都是一致的,即将源码与第三方依赖分开单独做处理。 在 dev server 启动前,开发工具首先会遍历源码目录,解析每个源码文件 AST 中所有的 `ImportDeclaration`,拿到所有的第三方依赖路径;然后将解析出的第三方依赖路径作为 `entryPoints` 传入传统的构建工具(Webpack,Rollup,Esbuild 等),打出一个多入口的另类 node\_modules,在这个 node\_modules 中,除了传入的 `entryPoints` 继续作为目标文件存在外,其他的公共依赖部分都会被打成一个大的 chunks。 ![d59106c393afcdc16268729008d22696.png][] \[原始node\_modules\] ![633623b527ae26d020976a0749900356.png][] \[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 ,也会有数倍的提升,尤其是在有锁文件的情况下,安装速度提升十倍也不是不可能。 ![175d947d580f28f655dae2aaafc56ad8.png][] **\[没有锁文件的情况下,通过** **yarn** **安装依赖的速度\]** ![5eb3d3e30dcce624938cc51cc0faba9c.png][] **\[没有锁文件的情况下,通过上述方案安装依赖的速度\]** ![91cbb0153bf00eb3b8fdd17d11935ad8.png][] **\[有锁文件的情况下,通过通过** **yarn** **安装依赖的速度\]** ![3ddf0511d8860cf6c66b0563367d3ad8.png][] **\[有锁文件的情况下,通过上述方案安装依赖的速度\]** 目前,新一代依赖管理工具和新一代开发工具的工作还处于初期,整个工程还有巨大的优化空间,包括安装速度的进一步提升,对本地缓存的进一步利用,对 monorepo 的支持等... 后续的进展我们会持续与大家进行分享;当然,如果屏幕前的你对这些工作有兴趣,欢迎扫描下方的二维码加入我们一起建设。 ### 参考资料 ### \[1\] 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* \- END - [![544233434f23521746aaea199542aa2b.png][]][544233434f23521746aaea199542aa2b.png 1] ## 关于奇舞团 ## 奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。 ![b81be951fff7dc58edbf84cae18d2bce.png][] [c07b930ffc1392ff9ff2d46a9de10163.png]: /images/20220829/12478b812aa14b1fb054b015897db6f7.png [Unbundled]: https://mp.weixin.qq.com/s?__biz=MzkxNDIzNTg4MA%3D%3D&idx=1&mid=2247484153&scene=21&sn=8c66e2061b8b9df486ad2f84973e987b#wechat_redirect [d59106c393afcdc16268729008d22696.png]: /images/20220829/beb00cd160d6473dae1ce734b8efc9a3.png [633623b527ae26d020976a0749900356.png]: /images/20220829/0be3777ce43b4a73b3e50e928675c407.png [175d947d580f28f655dae2aaafc56ad8.png]: /images/20220829/952e58cce4f64d7c9cdf9173233d2f85.png [5eb3d3e30dcce624938cc51cc0faba9c.png]: /images/20220829/5a6747b2f1d1425f871d3e5151431976.png [91cbb0153bf00eb3b8fdd17d11935ad8.png]: /images/20220829/edcadf3844414d37aec35ed6f632170d.png [3ddf0511d8860cf6c66b0563367d3ad8.png]: /images/20220829/9368a15a32b343fa94e6cec63ef6d39e.png [544233434f23521746aaea199542aa2b.png]: /images/20220829/cca4c3d47cd5406b9e8a8d1e18b19815.png [544233434f23521746aaea199542aa2b.png 1]: http://mp.weixin.qq.com/s?__biz=Mzg4MTYwMzY1Mw%3D%3D&chksm=cf61d698f8165f8e2b7f6cf4a638347c3b55b035e6c2095e477b217723cce34acbbe943ad537&idx=1&mid=2247496626&scene=21&sn=699dc2b117d43674b9e80a616199d5b6#wechat_redirect [b81be951fff7dc58edbf84cae18d2bce.png]: /images/20220829/541e7289393c449382e0d40765e8b5df.png
还没有评论,来说两句吧...