查看原文
其他

ESModule 系列 (二):构建下一代基础设施 PDN

郑凡恺 ByteDance Web Infra 2022-09-08

借助包的分发服务,我们甚至能将本地安装依赖的速度提升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 = {
    Modulerequire('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]
  1. 共享 Context 重复打包的问题

由 CMD 转化为 ESM 的过程中,分发网络通常会使用 Rollup 等工具,将依赖包的源代码全部打包到一起,最后提供一个 ESM 单文件,这样可以显著的减少网络请求量(比如,请求 antd 包,如果不打包源码,可能需要递归引入 antd/es/** 下的所有文件,这样网络请求数量可能达到数百级别)。

import * as Module from 'antd.mjs'

同样的,如果引用 ESM 包的不同路径文件时,比如 swiper@6.7.0/es/index.jsswiper@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.xxxObject.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 开发工具探索之路

前几期我们已经有同学介绍了如何开发一个 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 的支持等...

后续的进展我们会持续与大家进行分享;当然,如果屏幕前的你对这些工作有兴趣,欢迎扫描下方的二维码加入我们一起建设。

参考资料

[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 -


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存