起因
最近的一次需求中,用到了一个新特性顶层await。开发本地测试完毕后提交代码到触发流水线,正在喝着茶摸鱼的时候就华丽丽的收到了CI失败的通知。进内网gitlab上看,镜像构建失败了,具体看日志vite打包失败,提示:Top-level await is not available in the configured target.
为什么要使用顶层await以及为什么会出现问题
Top-Level Await
我有一个入口函数main.js和工具类utils.js,需要导入的utils模块中存在一个异步依赖。如果不想引入promise的回调形式,需要用一个IIFE异步函数使得可以使用await。
js
// utils.js
let delayedValue = null;
const wait = (delayTime) => {
return new Promise((resolve) => setTimeout(() => {
resolve(delayTime);
}, delayTime));
};
(async () => {
delayedValue = await wait(1000);
})();
export { delayedValue };
这意味着,在promise被resolve前。我们没有办法拿到delayedValue的值。
js
// main.js
import { delayedValue } from "./utils";
console.log(delayedValue); // null
setTimeout(() => console.log(delayedValue), 1000); // 1000
这看似符合前端单线程异步IO事件驱动的设计,但有时候,我们确实需要串行地等待异步结果返回后再执行后续逻辑。一个曲线救国的做法是,导出这个IIFE函数,然后在使用的地方自行等待promise resolved。
js
// utils.js
export default (async () => {
delayedValue = await wait(1000);
})();
// main.js
import getDelayedValue, { delayedValue } from "./utils";
getDelayedValue.then(() => console.log(delayedValue));
这样做有一个隐含的问题,必须在每个用到delayedValue的地方将逻辑包裹在.then回调中,有可能大面积地破坏代码结构,增加复杂度。
有ES2022提供的顶层await之后,可以在要导入的模块最顶层使用await,这样在main.js中,就像使用一个普通同步变量一样使用它。减少了心智负担。需要注意的是,main.js中后续的代码都会被阻塞(而这也是你使用顶层await的原因):
js
// utils
await wait(1000);
delayedValue = 1000;
// main
import { delayedValue } from "./utils";
console.log(delayedValue);
console.log("------After");
生产和开发环境不一致?
那么为什么开发时可以正常使用的特性,到了生产却不能正常编译呢?
众所周知,vite在开发环境使用esbuild对依赖做预构建和做语法转译,而在生产环境,为了避免no bundle带来的爆炸式请求,用rollup打包。这样在开发和生产环境下不可避免的会出现表现不一致。为了避免或者说最大程度消除这点影响,vite在插件方面插件方面尽全力向rollup插件靠拢:大部分rollup插件是兼容vite插件的,可以直接作为vite插件使用。
在开发环境下,vite会启动一个插件容器pluginContainer,在不同环境下来调用几个最主要的rollup构建钩子(resolveId,load和最最常使用的transform):
js
function createPluginContainer(environments) {
return new PluginContainer(environments);
}
class PluginContainer {
constructor(environments) {
this.environments = environments;
}Info(id) { //。。。
});
}
get options() {
return this.environments.client.pluginContainer.options;
}
// For backward compatibility, buildStart and watchChange are called only for the client environment
// buildStart is called per environment for a plugin with the perEnvironmentStartEndDuring dev flag
async buildStart(_options) {
this.environments.client.pluginContainer.buildStart(
_options
);
}
async watchChange(id, change) {
this.environments.client.pluginContainer.watchChange(
id,
change
);
}
async resolveId(rawId, importer, options) {
return this._getPluginContainer(options).resolveId(rawId, importer, options);
}
async load(id, options) {
return this._getPluginContainer(options).load(id);
}
async transform(code, id, options) {
return this._getPluginContainer(options).transform(code, id, options);
}
}
我们知道,插件基于发布订阅模式达成与主流程的解耦,并将开发的控制权交给用户。所以当大部分钩子是兼容的并在开发和生产环境下都调用时(除了少部分,如moduleParsed),可以预期能够消除大部分不一致性。
不过,从结果上来看,不知道是故意的还是不小心(开玩笑的,尤大当然是故意的),开发和生产环境下调用同一插件的预设参数并不完全一致。具体到本文的问题,开发环境下是esbuild将typescript转译为javascript,有一个最重要的参数是target。我对target的理解是:转换结果的代码运行的浏览器环境,最低要支持这个版本。也就是说,转译的范围是你的target到源码之间的版本,低于target的版本不会被转译。
vite文档里有很明确的提到了开发和生产环境下的行为:
开发环境的默认值是esnext,也就是说vite默认你是在现代浏览器下开发。
但是构建过程中,虽然也是使用esbuild插件编译ts,但是考虑兼容低版本用户,需要明确转译代码到更低版本。对于esbuild,有些特性是不能安全地转译到低版本的,所以当包括这些特性,构建会直接退出:
其实我个人对于这个行为还是感到有些奇怪的。虽然在构建阶段不安全编译会直接失败是对用户的强拦截和强提醒,但是需要逐行查看构建终端的输出查明原因,最后在返回开发阶段抛弃对不安全特性的使用,重启整个开发流程还是令人沮丧的。能想到的解释只能是vite为了保持最快速的冷启动和HMR,选择始终对代码进行最小程度的转译减少esbuild的工作。就像vite对ts只做转译,而把类型检查完全交给编辑器插件进程一样。
别家构建工具怎么做?
gulp/grunt
在石器/白银时代,模块化和bundle的概念都还没明晰的时候,前端的文件数量和体积在增长,人们需要工具管理大量生产文件的构建:ts需要变成js,js需要转变为低版本浏览器可以兼容的的es3/5版本,less要变成css让浏览器认识,其他静态资源像图片也需要处理。没有人想一个一个文件去转换,于是gulp/grunt出现了。
使用gulp,我们通过编写gulpfile定义一系列任务,将整个转换过程看作一个流水线,将源文件对应到输出。利用series和parallel等方法,将各个任务(如编译js、css)组合成需要的顺序。将源文件src交给gulp,然后通过管道pipe一步步处理。
js
const { src, dest } = require('gulp');
const babel = require('gulp-babel');
const uglify = require('gulp-uglify');
const rename = require('gulp-rename');
exports.default = function() {
return src('src/*.js')
.pipe(babel())
.pipe(src('vendor/*.js'))
.pipe(dest('output/'))
.pipe(uglify())
.pipe(rename({ extname: '.min.js' }))
.pipe(dest('output/'));
}
那么是否会有生产和开发环境不一致的问题呢?理论上是不会的。经过以上总结,你应该也发现交给gulp执行后的输出依旧是一个个零散的文件,并没有像webpack一样生成一个bundle文件。最后散落的文件通常依靠我们自己组织在代码中。比如在html文件中直接硬编码编译后的css文件地址。甚至于对于js文件,因为没有模块化,为了避免全局变量污染,需要额外依靠seajs等模块化工具封装。
js
<link href="/path/after/gulp-compiled/css/laobanjiu.css" rel="stylesheet" />
<script src="./lib/seajs.js /> // 引入seajs库
<script>
seajs.use("path/after/compile/js/laobanjiu.js")
</script>
在开发阶段,我们依旧需要从源码仓库完整启动一次编译流程,得到输出结果后才能启动项目,因为我们硬编码了产出文件的地址。所以理论上,开发和生产的流程是完全一致的。唯一的区别是,开发环境下,gulp提供了watch这样的api监听文件系统变化,可以选择性的根据变化的文件信息启动某一具体任务,达到类似热更新的效果。
看起来很臃肿,构建结果关联环节也没有完全解耦,不过自定义的流程是清晰的,很难像webpack一整个黑盒一样,出了bug要么stackoverflow,要么自己去啃源码。让它逐步退出历史舞台的还是缺少自带模块化手段和分包等策略。
webpack
如前所述,webpack就像一个黑盒,好处是使用者只需要关心如何根据文档配置webpack.config.js,然后就可以期待得到一个生产环境可以使用的bundle.js。坏处也是,它是一个黑盒,当你发现输出不符合预期,你可能不得不打开这个盒子去看里面复杂繁琐的实现。
在webpack中,所有文件都是模块。整个编译流程可以总结为几步:
- 从命令行和配置文件得到参数,初始化编译器compiler对象
- 根据配置的entry组织入口文件(当entry为对象即多入口,最后也会形成多个bundle,所以SPA和MPA都没有问题)
- 从每个入口出发,根据module中的规则匹配,调用不同loader处理。处理完后,递归的处理模块依赖的模块。重复直到处理所有依赖模块。这一步构建了整个项目的依赖图。
- 根据output和可能的plugin、optimazition中插件的分包策略产生chunk,最后组合成bundle
- 以上的每一步会广播出相应的事件,插件通过订阅插入自定义逻辑
js
module.exports = {
entry: { //对象:几个entry就打包几个bundle;数组和单入口:统一打包成一个bundle
app: './src/app.js',
laobanjiu: './src/laobanjiu.js'
},
output: { //entry可以是对象或数组,output只能写一个(但是能生成多个)
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash]js',
},
module: {
rules: [{
test: /\.css$/,
use: ['style-loader', 'css-loader'] //调用顺序:从右到左,从下到上
}
]
},
plugins: [ //在编译、输出阶段广播的各种事件可以加入自定义逻辑
new htmlwebpack({
template: 'hellobanjiu.html'
}),
],
devServer: { //简单的express服务器,有热更新功能
proxy: { // 服务器代理 --> 解决开发环境跨域问题
'/api': {
target: 'http://localhost:3000',
}
},
}
}
开发环境下流程一样吗?是一样的。除非在插件中有根据环境变量特殊处理的额外逻辑,webpack-dev-server同样会走完一遍以上的流程,转译模块并构建整个依赖图。区别是构建结果仅保留在内存中,因为我们不需要写盘的结果,也方便在内存里只重新编译变化的模块(writeToDisk: true可以写入磁盘)。
为了在源代码变化后重新编译并通知浏览器,开发服务器在浏览器中注入了HMR运行时,充当客户端接收websocket通知。监听到文件系统变化后,在插件的done钩子中通知客户端,让浏览器通过express服务器请求新的编译结果。
可以直观的通过添加writeToDisk后观察打包产物发现,基本的结构不会变,胶水代码,一个巨大的IIFE函数,参数是每个模块。多出来一些代码就是HMR的运行时,在生产环境并没有热更新相关api实现。如果第三方和自定义的插件没有在不同环境以不同的逻辑改变输出,bundle结果就应该是一致的。
rollup
轮子哥Rich Harris(Svelte作者)的作品。和webpack在构建原理上没有本质的区别,同样是将项目中大量的小文件编译打包成少量的大文件bundle。同样它从入口文件出发,读取解析文件后生成AST,从AST中得到各类信息,包括从import节点分析导入的模块,再递归直到构建出整个依赖的Graph。同样通过插件来扩展功能并实现和主流程解耦。
它和webpack主要的差别在于:rollup更基于es6之后javascript这门语言本身带来的模块化能力,而非利用commonjs或amd等社区方案(当然,也可以输出为cjs或iife等)。这意味着最后的构建结果相比webpack更干净,没有__webpack_require__,因为浏览器自己可以认识type module的script。这也意味着,基于ES6的静态导入分析,可以更好地实现Tree Shaking。
js
export default {
input: 'src/main.js',
output: {
file: 'bundle.js',
format: 'es' // 'cjs', 'iife', 'umd', 'system'...
}
};
因为产物体积更小和对ES Module的天然支持,rollup现在是大部分我们耳熟能详的通过npm发布的第三方库的构建工具。如果是构建应用,webpack同样有强大的生态,也有成熟的代码风格和非js的图片、字体等资源处理的支持,根据项目具体情况取舍即可。
因为主要关注生产打包,rollup没有原生支持HMR。rollup-plugin-serve插件提供本地开发服务器,rollup-plugin-livereload插件提供本地开发热更新。其实现原理类似于webpack devserver。同样区别只在于内存和写盘。当然,因为本身是通过原生语言开发库,没有和框架高度集成的相关插件。
esbuild
如果把rollup和webpack类比,esbuild其实和gulp可以放在一起,因为相比于bundler,esbuild更多被视为编译流水线,同样是将一堆文件一一翻译成编译后的产物。但是用go写的esbuild,特性是很快,这是native语言相对于js的升级。
vite在生产环境依旧需要打包,开发环境因为直接从本地文件系统获取,无需海量的网络请求。esbuild也可以产生单一bundle:
js
import * as esbuild from 'esbuild'
await esbuild.build({
entryPoints: ['app.jsx'],
bundle: true,
outfile: 'output.js',
})
但是一方面,esbuild的生态不如rollup完善和繁荣,如代码分割功能差强人意;另一方面,rollup的插件api更容易兼容。目前通常认为esbuild不是那么适合生产环境。所以vite更像一个"在开发和生产环境基于各自工具的上层封装"。在开发环境,它构建了模块依赖图,给esbuild的转换流提供感知代码变化后定向热更新的能力,也通过.vite文件夹缓存依赖预构建的结构避免请求瀑布流;在生产环境,它根据项目框架类型给rollup配置了许多预设和插件,减少用户配置的同时暴露了底层api。在统一开发和生产行为方面做了不少努力。
rspack/rolldown
如果说这两年前端最火热的议题是什么,用rust重写整个工具链至少是其中之一。rust这几年的势头不必多说,在操作系统、图形渲染、游戏开发等等各项领域遍地开花。它确实够快,独特的所有权系统也保证能在编译阶段就发现C/++的内存安全问题(当然,c++祖师爷说管理不好内存是自己的问题),包管理器、工具链都足够完善。
rspack本周发布了1.3版本,去年8月是第一个稳定生产版本。作为字节内部用于代替webpack的产物,rust重构给巨石应用带来显著的冷启动、HMR性能提升,同时对大部分webpack loader和plugin做了完全兼容,降低了用户迁移的成本。rust相对于js,能compile to native code(虽然v8引擎的强劲已经让直接执行解释性语音的性能很不错),还有绝对的多线程并行优势。
如果你还是怀念开发环境的bundless(理论上不打包会比构建后提供文件更快,不过vite确实有可能会出现项目过大第一次冷启动请求文件过多导致的慢,毕竟访问首屏的时候启动前做的编译工作来到了启动后),也许可以等等rolldown在vite中的完全落地,它兼容rollup插件api。当它在vite中替代esbuild和rollup,我们也许终于能抹平开发和生产的差异,并体验rust带来的性能提升。像ts/jsx/css预处理器转译等都将成为内置功能。
js
import { defineConfig } from 'rolldown'
export default defineConfig({
input: 'src/main.js',
output: {
file: 'bundle.js',
},
})
熟悉吗,单独使用rolldown,大多数配置api都是与rollup兼容的,除了将一些插件转换流程内置,和类似于平台预设和兼容esm/cjs等新增的能力。
最后,all in rust后是否就能一劳永逸吗?试想当构建工具的底层语言和上层使用者的语言不匹配,是否会给调试带来困难?如前文所述,在webpack中开黑盒,我们尚可以用调试node程序的方法打断点进源码实现,变成rust这样的会变成二进制的native code,如何调试乃至可视化将会是个挑战。
结语
我们从一次新特性顶层await的使用出发,探索了在开发和生产环境下,vite的esbuild插件对语法转译的不同处理方式。带出了对市面上各类bundler(gulp,webpack,rollup,esbuild)在开发和生产下不同构建原理和用户特性的思考,也展望了面向未来的rust工具链。技术没有银弹,老项目的迁移和新项目的选型都需要具体取舍。