一 引言
Webpack 最初是为了解决前端模块化以及使用 Node.Js 生态的问题而出现,在过去的 8 年时间里,Webpack 的能力越来越强大。
但因为多了打包构建这一层,随着项目的增长,打包构建速度越来越慢,每次启动都要等待几十秒甚至几分钟,然后启动一轮构建优化,随着项目的进一步增大,构建速度又会降低,陷入不断优化的循环。
在项目达到一定的规模时,基于 Bundle 的构建优化的收益变得越来越有限,无法实现质的提升。我们从另一个角度思考,webpack 之所以慢,主要的原因还是在于他将各个资源打包整合在一起形成 bundle,如果我们不需要 bundle 打包的过程,直接让浏览器去加载对应的资源,我们将有可能可以跳出这个循环,实现质的提升。
在 Bundleless 的架构下,我们不再需要构建一个完整的 bundle,同时在修改文件时,浏览器也只需要重新加载单个文件即可。由于没有了构建这一层我们将能够实现以下的目标:
- 极快的本地启动速度,只需要启动本地服务。
- 极快的代码编译速度,每次只需要处理单个文件。
- 项目开发构建的时间复杂度始终为 O(1),使得项目能够持续保持高效的构建。
- 更加简单的调试体验,不再强依赖 sourcemaps 即可实现稳定的单文件的 debug。
基于以上的可能性 Bundleless 将重新定义前端的本地开发,让我们重新找回前端在 10 年前修改单个文件之后,只需要刷新即可即时生效的体验,同时叠加上前端的 HotModuleReplace 相关技术,我们可以把刷新也省去,最终实现保存即生效。
实现 Bundleless 一个很重要的基础能力是模块的动态加载能力,这一主要的思路会有两个:
- System.js 之类的 ES 模块加载器,好处是具有较高的兼容性。
- 直接利用 Web 标准的 ESModule,面向未来,同时整体架构也更加简单。
在本地开发过程中兼容性的影响不是特别大,同时 ESModule 已经覆盖了超过 90% 的浏览器,我们完全可以利用 ESModule 的能力让浏览器自主加载需要的模块,从而更加低成本同时面向未来实现 Bundleless。
社区中在近一两年也出现了很多基于 ESModule 的开发工具,如 Vite、Snowpack、es-dev-server 等。本文将主要分享基于浏览器的 ESModule 能力实现 Bundless 本地开发的相关思路、核心技术点以及 Vite 的相关实现和在供应链 POS 场景下的落地实践。
二 从资源加载看 Bundle 和 Bundleless 的不同
下面以大家最熟悉的 create-react-app 默认项目为例,从实际的页面渲染资源的加载过程对比 Bundle 和 Bundleless 的区别。
基于 Webpack 的 bundle 开发模式
上面的图具体的模块加载机制可以简化为下图:
在项目启动和有文件变化时重新进行打包,这使得项目的启动和二次构建都需要做较多的事情,相应的耗时也会增长。
基于 ESModule Bundleless 模式
从上图可以看到,已经不再有一个构建好的 bundle、chunk 之类的文件,而是直接加载本地对应的文件。
从上图可以看到,在 Bundleless 的机制下,项目的启动只需要启动一个服务器承接浏览器的请求即可,同时在文件变更时,也只需要额外处理变更的文件即可,其他文件可直接在缓存中读取。
对比总结
Bundleless 模式可以充分利用浏览器自主加载的特性,跳过打包的过程,使得我们能在项目启动时获取到极快的启动速度,在本地更新时只需要重新编译单个文件。下面将分享如何基于浏览器 ESModule 的能力实现 Bundleless 的开发。
三 如何实现 Bundleless
如何使用 ESModule 模块加载
实现 Bundleless 的第一步是要让浏览器自主加载对应的模块。
使用 type="module" 开启 ESModule
<div id="root"></div>
<script type="module">
// 直接在 script 标签中使用 type="module" 即可使用 ESModule 的方式
import React from 'https://cdn.pika.dev/react'
import ReactDOM from 'https://cdn.pika.dev/react-dom'
ReactDOM.render('Hello World', document.getElementById('root'))
</script>
利用 import-maps 支持 bare import
分享一个在 chrome 中已经实现了的 import-maps 的标准 ,可以让我们直接用 import React from 'react' 这样的写法,未来我们可以利用此能力实现线上的 Bundleless 部署。
<div id="root"></div>
<!-- 开启 chrome://flags/#enable-experimental-productivity-features -->
<script type="importmap">
{
"imports": {
"react": "https://cdn.pika.dev/react",
"react-dom": "https://cdn.pika.dev/react-dom"
}
}
</script>
<script type="module">
// 支持 bare import
import React from 'react'
import ReactDOM from 'react-dom'
ReactDOM.render('Hello World!', document.getElementById('root'))
</script>
以上我们介绍了浏览器中原生的 ESModule 是如何使用的。面向本地开发的场景,我们只需要启动一个本地的 devServer 承载浏览器的请求映射到对应的本地文件,同时动态地将项目中 import 的资源路径指向我们的本地地址,即可让浏览器直接加载本地的文件,比如可以使用下面的写法,将入口 JS 文件直接指向本地的路径,然后 devServer 再拦截相应的请求返回对应的文件。
<div id="root"></div>
<!-- 直接指向本地路径 -->
<script type="module" src="/src/main.jsx"></script>
如何加载非 JS 的文件资源
通过 ESModule 我们借助浏览器的能力实现了 JS 的自主加载,但实际的项目代码中我们不仅仅会 import JS 文件,也会有下面的写法:
// main.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import './index.css' // import css 文件
import App from './App' // import jsx 文件
// 使用 JSX 语法
ReactDOM.render(<App />, document.getElementById('root'))
而浏览器在处理文件时是依据 Content-Type 的,不关心具体的文件类型,所以我们需要在浏览器发起请求时,将对应的资源转化为 ESModule 格式,同时设置对应的 Content-Type 为 JS,返回给浏览器执行,浏览器就会按照 JS 的语法进行解析处理,整体的流程可见下图:
以下是 Vite 的相关实现,在请求返回的过程中,对不同的文件进行动态处理:
如何实现 HotModuleReplace
HotModuleReplace 能够在我们修改代码后,不需要刷新页面,直接在当前场景下生效,结合 Bundleless 极快的生效速度,我们能够实现几乎没有延迟的保存即生效的体验。对于 React,在 Webpack 场景下目前只能通过使用 react-hot-loader 来实现,但这一块受限于具体的实现,有一些场景会存在 bug,作者也建议迁移到 React 团队实现的 react-refresh,而这一块在 Webpack 中还没有相应的实现。在 Bundleless 场景下,因为我们的每个组件都是独立加载的,所以要集成 react-refresh,我们只需要在浏览器请求返回时在文件的顶部和底部加上相应的脚本即可完成集成。
要完整的实现 HotModuleReplace 会比上面画得更加复杂,还需要有一套依赖分析机制来判断当一个文件发生变更之后要替换哪些文件以及是否需要 reload。在 Bundleless 的场景下,因为不再需要打包为一个完整的 bundle,同时我们也能更加灵活地对单个文件进行修改,这一块相关的实现会更加容易。
以下是在 Vite 中的相关实现:
如何优化大量请求导致页面加载慢
Bundleless 的模式不再打包,提升了启动的速度,但对于一些有较多外部依赖或者自身文件数量较多的模块,需要发起大量请求才能获取到全部的资源,这个会降低开发过程中页面加载的时间。比如下面是直接在浏览器中 import lodash-es 会并发出大量的请求:
在这一块上我们可以做相应的优化,将外部的依赖提前打包成单个文件来减少在开发过程中由于外部依赖过多而发起过多的网络请求。
在 Vite 的启动流程中有一个 vite optimize 的过程会自动将 package.json 中的 depenencies 借助 Rollup 打包成 ES6 Module。
提前打包带来的好处除了能够提升页面的加载速度,借助 @rollup/plugin-commonjs 我们能够将 commonjs 的外部依赖打包为 ESModule 的形式引入,进一步扩大 Bundleless 的适用范围。
四 在供应链 POS 场景下落地实践
我们团队负责的供应链 POS 业务主要可分为面向建材家居的家装行业和线下小店的零售行业,在技术架构上采用了各个域 bundle 独立开发,然后最终借助底层的 sdk 合并为一个大的 SPA 的形式。由于项目的复杂性,在日常开发过程中,有以下的一些痛点:
- 项目的启动和耗时相对较长。
- 改动后二次编译时间长。
- 缺少稳定的 HMR 能力,开发过程中需要重复造场景。
- debug 依赖 sourcemaps 能力,有时会出现不稳定的情况。
基于以上的问题,借助 Vite 的相关实现,我们对本地开发环境进行了 Bundleless 的尝试和落地,在实验的一些项目中对于本地的开发体验有了很大的提升。
在启动以及修改生效的速度上带来极大的提升
目前已实现单 bundle 维度的开发,打包构建速度:
Webpack
Vite Bundleless
从上面的可以看出,在启动单个 bundle 时,Webpack 需要 10s 左右的时间,而基于 Bundleless 的 Vite 只需要 1s 左右,提升 10 倍。
整体的页面加载时间在 4s 左右,仍然比 Webpack 的打包构建时间要短,同时从上面的视频中也可以看到 HMR 的速度达到了毫秒级的响应,实现了基本无感的保存即生效。
不依赖 sourcemap 调试单个文件
落地过程中遇到的问题和解决
在实际落地过程中,遇到的问题主要是相关模块不符合 ESModule 规范以及一些写法上的标准化:
- 部分模块没有 ESModule 的打包。
- less 依赖 node_modules 的写法的规范。
- jsx 文件后缀规范。
- babel-runtime 的处理。
部分模块没有 ESModule 的打包
对于没有 ESModule 打包输出或者输出的错误的包,根据不同的类型使用不同的策略:
- 内部的包:通过升级脚手架,发布带有 ESModule 的包的新版本。
- 外部依赖:通过 issue、pull request 等形式,推动了 number-precision 等模块的升级。
- 同时有一些由于历史原因无法打出 ESModule 的包可以借助 @rollup/plugin-commonjs 打包为 ESModule。
less 依赖 node_modules 的写法的规范
@import '~@ali/pos-style-mixin/style/lst.less';
// ~ 只在 webpack 中 less-loader 的支持,在原生的 less 中不支持
// 统一迁移为下面的模式
@import '@ali/pos-style-mixin/style/lst.less';
// 同时在原先的 webpack 构建中的 less-laoder 中配置 lessOptions,用于最后的打包
/*
{
loader: 'less-loader',
options: {
lessOptions: {
javascriptEnabled: true,
paths: [path.resolve(cwd, 'node_modules')],
}
}
}
*/
JSX 文件后缀规范
Vite 在运行的过程中会依据文件不同的后缀名进行对应的编译处理,而在 Webpack 模式下我们通常会将 JSX、JS 等文件都丢给 babel-loader 进行处理,这使得有一些原本是 JSX 的文件没有写 JSX 后缀。Vite 只会对 /.(tsx?|jsx)$/ 的文件进行 esbuild 编译,对于纯 JS 会直接跳过 esbuild 的过程。对于这种情况我们是逐步将错误的原先没有写 JSX 的文件迁移为 JSX 文件。
babel-runtime 的处理
在使用了 babel-plugin-transform-runtime 之后,打包的输出结果会是下面这样:
上面所引用的 @babel/runtime/helpers/extends 是 commonjs 的格式无法直接使用,针对这个情况,有两种解法:
1)针对内部自己打包的模块,可以在进行 es6 打包时添加 useModules 配置,这样打包出来的代码就会是直接引用@babel/runtime/helpers/esm/extends
:
2)针对重新打包成本较高的模块,可以通过 Vite 的插件机制进行转换,将 @babel/runtime/helpers 在运行时替换为 @babel/runtime/helpers/esm 可以通过 alias 配置实现:
以上是在 Vite 开发环境的迁移过程中遇到的一些问题和处理的分享,这一块的更大范围的落地还在进行中。Bundleless 的落地不仅仅是为了适配 Vite 的开发模式,同时也是面向未来规范各个模块代码的过程,将我们的模块进行标准的 ESModule 化,在有新的工具和思想出现时可以用更低成本进行落地。
五 直接使用 Bundleless 进行部署的可行性
受限于网络请求和浏览器的解析速度,对于较大型的应用,bundle 在加载速度上还是能够带来较大的收益。V8 在 2018 年也给出了相关性能上的建议:在本地开发和小型的 Web 应用中使用。在今天的场景下,随着浏览器和网络性能的不断提升,结合 ServiceWorker 之类的缓存能力,网络加载的影响和越来越小,对于一些不需要考虑兼容性问题的场景可以进行内部的尝试,直接部署通过 ESModule 加载的代码。
六 总结
本文主要分享了 Bundleless 架构下,如何提升前端的研发效率、实现思路以及在具体业务场景下落地实践。Bundleless 本质上是将原先 Webpack 中模块依赖解析的工作交给浏览器去执行,使得在开发过程中代码的转换变少,极大地提升了开发过程中的构建速度,同时也可以更好地利用浏览器的相关开发工具。
站在当前的背景下,Web 各个领域 JavaScript/CSS/HTML 相关的标准都已成熟,同时浏览器内核也趋于统一,前端工程化的核心重点已逐步迁移到研发提效上,而 Bundleless 的模式能够带来长效的启动和 HMR 的速度,是未来的一大发展趋势。随着浏览器内核和 Web 标准的不断统一,前端的代码可以不再打包直接运行将成为可能,这将进一步提高整体的研发效率。
最后非常感谢 ESModule、Vite、Snowpack 等标准和工具的出现,让前端的开发体验往前跨了一大步。