• 前端工程化精讲第十六课 无包构建:盘点那些 No-bundle 的构建方案


    上节课我们讨论了 Webpack 的最新版本 Webpack 5 所带来的提效新功能。思考题是 Webpack 5 中的持久化缓存究竟会影响哪些构建环节呢?

    通过对 compiler.cache.hook.get 的追踪不难发现:持久化缓存一共影响下面这些环节与内置的插件:

    • 编译模块:ResolverCachePlugin、Compilation/modules。

    • 优化模块:FlagDependencyExportsPlugin、ModuleConcatenationPlugin。

    • 生成代码:Compilation/codeGeneration、Compilation/assets。

    • 优化产物:TerserWebpackPlugin、RealContentHashPlugin。

    正是通过这样多环节的缓存读写控制,才打造出 Webpack 5 高效的持久化缓存功能。

    在之前的课程里我们详细分解了 Webpack 构建工具的效率优化方案,这节课我们来聊一聊今年比较火的另一种构建工具思路:无包构建(No-Bundle/Unbundle)。

    什么是无包构建

    什么是无包构建呢?这是一个与基于模块化打包的构建方案相对的概念。

    在“ 第 9 课时|构建总览:前端构建工具的演进”中谈到过,目前主流的构建工具,例如 Webpack、Rollup 等都是基于一个或多个入口点模块,通过依赖分析将有依赖关系的模块打包到一起,最后形成少数几个产物代码包,因此这些工具也被称为打包工具。只不过,这些工具的构建过程除了打包外,还包括了模块编译和代码优化等,因此称为打包式构建工具或许更恰当。

    无包构建是指这样一类构建方式:在构建时只需处理模块的编译而无须打包,把模块间的**依赖关系完全交给浏览器来处理。**浏览器会加载入口模块,分析依赖后,再通过网络请求加载被依赖的模块。通过这样的方式简化构建时的处理过程,提升构建效率。

    这种通过浏览器原生的模块进行解析的方式又称为 Native-ESM(Native ES Module)。下面我们就通过一个简单示例来展示这种基于浏览器的模块加载过程(16_nobundle/simple-esm),如下面的代码和图片所示:

    //./src/index.html
    ...
    
    ...
    //.src/modules/foo.js
    import { bar } from './bar.js'
    import { appendHTML } from './common.js'
    ...
    import('https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/slice.js').then((module) => {...})
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    Drawing 0.png

    从示例中可以看到,在没有任何构建工具处理的情况下,在页面中引入带有 type="module" 属性的 script,浏览器就会在加载入口模块时依次加载了所有被依赖的模块。下面我们就来深入了解一下这种基于浏览器加载 JS 模块的技术的细节。

    基于浏览器的 JS 模块加载功能

    从 caniuse 网站中可以看到,目前大部分主流的浏览器都已支持 JavaScript modules 这一特性,如下图所示:

    Drawing 1.png

    [图片来源:https://caniuse.com/es6-module]

    我们来总结这种加载方式的注意点。

    HTML 中的 Script 引用
    • 入口模块文件在页面中引用时需要带上**type="module"**属性。对应的,存在 type="nomodule",即支持 ES Module 的现代浏览器,它会忽略 type="nomodule" 属性的 script,因此可以用作旧浏览器中的降级方案。

    • 带有 type="module" 属性的 script在浏览器中通过 defer 的方式异步执行(异步下载,不阻塞 HTML,顺次执行),即使是行内的 script 代码也遵循这一原则(而普通的行内 script 代码则忽略 defer 属性)。

    • 带有 type="module" 属性且带有async属性的 script,在浏览器中通过 async 的方式异步执行(异步下载,不阻塞 HTML,按该模块和所依赖的模块下载完成的先后顺序执行,无视 DOM 中的加载顺序),即使是行内的 script 代码,也遵循这一原则(而普通的行内 script 代码则忽略 async 属性)。

    • 即使多次加载相同模块,也只会执行一次。

    模块内依赖的引用
    • 只能使用 import ... from '...' 的 ES6 风格的模块导入方式,或者使用 import(...).then(...) 的 ES6 动态导入方式,不支持其他模块化规范的引用方式(例如 require、define 等)。

    • 导入的模块只支持使用相对路径('/xxx', './xxx', '../xxx')和 URL 方式('https://xxx', 'http://xxx')进行引用,不支持直接使用包名开头的方式('xxxx', 'xxx/xxx')。

    • 只支持引用MIME Type为 text/javascript 方式的模块,不支持其他类型文件的加载(例如 CSS 等)。

    为什么需要构建工具

    从上面的技术细节中我们会发现,对于一个普通的项目而言,要使用这种加载方案仍然有几个主要问题:

    1. 许多其他类型的文件需要编译处理为 ES6 模块才能被浏览器正常加载(JSX、Vue、TS、CSS、Image 等)。

    2. 许多第三方依赖包在通过第三方 URL 引用时,不仅过程烦琐,而且往往难以进行灵活的版本控制与更新,因此需要合适的方式来解决引用路径的问题。

    3. 对于现实中的项目开发而言,一些便利的辅助开发技术,例如热更新等还是需要由构建工具来提供。

    下面,我们分析 Vite 和 Snowpack 这两个有代表性的构建工具是如何解决上面的问题的。

    Vite

    Vite 是 Vue 框架的作者尤雨溪最新推出的基于 Native-ESM 的 Web 构建工具。它在开发环境下基于 Native-ESM 处理构建过程,只编译不打包,在生产环境下则基于 Rollup 打包。我们还是先通过 Vite 的官方示例来观察它的使用效果,如下面的代码和图片所示(示例代码参见 example-vite):

    npm init vite-app example-vite
    cd example-vite
    npm install
    npm run dev
    
    • 1
    • 2
    • 3
    • 4
    • 1
    • 2
    • 3
    • 4

    Drawing 2.png

    可以看到,运行示例代码后,在浏览器中只引入了 src/main.js 这一个入口模块,但是在网络面板中却依次加载了若干依赖模块,包括外部模块 vue 和 css。依赖图如下:

    Drawing 4.png

    可以看到,经过 Vite 处理后,浏览器中加载的模块与源代码中导入的模块相比发生了变化,这些变化包括对外部依赖包的处理,对 vue 文件的处理,对 css 文件的处理等。下面我们就来逐个分析其中的变化。

    对导入模块的解析

    对 HTML 文件的预处理

    当启动 Vite 时,会通过 serverPluginHtml.ts 注入 /vite/client 运行时的依赖模块,该模块用于处理热更新,以及提供更新 CSS 的方法 updateStyle。

    对外部依赖包的解析

    首先是对不带路径前缀的外部依赖包(也称为Bare Modules)的解析,例如上图中在示例源代码中导入了 'vue' 模块,但是在浏览器的网络请求中变为了请求 /@module/vue。

    这个解析过程在 Vite 中主要通过三个文件来处理:

    • resolver.ts 负责找到对应在 node_modules 中的真实依赖包代码(Vite 会在启动服务时对项目 package.json 中的 dependencies 做预处理读取并存入缓存目录 node_modules/.vite_opt_cache 中)。

    • serverPluginModuleRewrite.ts 负责把源码中的 bare modules 加上 /@module/ 前缀。

    • serverPluginModuleResolve.ts 负责解析加上前缀后的模块。

    对 Vue文件的解析

    对 Vue 文件的解析是通过 serverPluginVue.ts 处理的,分离出 Vue 代码中的 script/template/style 代码片段,并分别转换为 JS 模块,然后将 template/style 模块的 import写到script 模块代码的头部。因此在浏览器访问时,一个 Vue 源代码文件会分裂为 2~3 的关联请求(例如上面的 /src/App.vue 和 /src/App.vue?type=template,如果 App.vue 中包含