由一次CI流水线失败引发的对各类构建工具的思考

起因

最近的一次需求中,用到了一个新特性顶层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中,所有文件都是模块。整个编译流程可以总结为几步:

  1. 从命令行和配置文件得到参数,初始化编译器compiler对象
  2. 根据配置的entry组织入口文件(当entry为对象即多入口,最后也会形成多个bundle,所以SPA和MPA都没有问题)
  3. 从每个入口出发,根据module中的规则匹配,调用不同loader处理。处理完后,递归的处理模块依赖的模块。重复直到处理所有依赖模块。这一步构建了整个项目的依赖图。
  4. 根据output和可能的plugin、optimazition中插件的分包策略产生chunk,最后组合成bundle
  5. 以上的每一步会广播出相应的事件,插件通过订阅插入自定义逻辑
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工具链。技术没有银弹,老项目的迁移和新项目的选型都需要具体取舍。

相关推荐
sen_shan32 分钟前
Vue3+Vite+TypeScript+Element Plus开发-04.静态菜单设计
前端·javascript·typescript·vue3·element·element plus·vue 动态菜单
旧识君1 小时前
移动端1px终极解决方案:Sass混合宏工程化实践
开发语言·前端·javascript·前端框架·less·sass·scss
吃没吃1 小时前
vue2.6-源码学习-Vue 核心入口文件分析
前端
Carlos_sam1 小时前
Openlayers:海量图形渲染之图片渲染
前端·javascript
XH2761 小时前
Android Retrofit用法详解
前端
鸭梨大大大2 小时前
Spring Web MVC入门
前端·spring·mvc
吃没吃2 小时前
vue2.6-源码学习-Vue 初始化流程分析 (src/core/instance/init.js)
前端
XH2762 小时前
Android Room用法详解
前端
木木黄木木2 小时前
css炫酷的3D水波纹文字效果实现详解
前端·css·3d
郁大锤3 小时前
Flask与 FastAPI 对比:哪个更适合你的 Web 开发?
前端·flask·fastapi