Esbuild Bundler HMR

語言: CN / TW / HK

Esbuild 雖然 bundler 非常快,但是其沒有提供 HMR 的能力,在開發過程中只能採用 live-reload 的方案,一有程式碼改動,頁面就需要全量 reload ,這極大降低開發體驗。為此新增 HMR 功能至關重要。

經過調研,社群內目前存在兩種 HMR 方案,分別是 Webpack/ Parcel 為代表的 Bundler HMR 和 Vite 為代表的 Bundlerless HMR。經過考量,我們決定實現 Bundler HMR,在實現過程中遇到一些問題,做了一些記錄,希望大家有所瞭解。

ModuleLoader 模組載入器

Esbuild 本身具有 Scope hosting 的功能,這是生產模式經常會開啟的優化,會提高程式碼的執行速度,但是這模糊了模組的邊界,無法區分程式碼具體來自於哪個模組,針對模組的 HMR 更無法談起,為此需要先禁用掉 Scope hosting 功能。由於 Esbuild 未提供開關,我們只能捨棄其 Bundler 結果,自行 Bundler。

受 Webpack 啟發,我們將模組內的程式碼轉換為 Common JS,再 wrapper 到我們自己的 Moduler loader 執行時,其中迴圈依賴的情況需要提前匯出 module.exports 需要注意一下。

轉換為 Common JS 目前是使用 Esbuild 自帶的 transform,但需要注意幾個問題。

  • Esbuild dynamic import 遵循 瀏覽器 target 無法直接轉換 require,目前是通過正則替換 hack。

  • Esbuild 轉出的程式碼包含一些執行時程式碼,不是很乾淨。

  • 程式碼內的巨集(process.env.NODE_ENV 等)需要注意進行替換。

比如下面的模組程式碼的轉換結果:

// a.ts
import { value } from 'b'

// transformed to
moduleLoader.registerLoader('a'/* /path/to/a */, (require, module, exports) => {
const { value } = require('b');

});
  • Cjs 動態匯出模組的特性。

export function name(a) {
return a + 1
}

const a = name(2)
export default a

如上模組轉換後結果如下:

var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var entry_exports = {};
// 注意這裡
__export(entry_exports, {
default: () => entry_default,
name: () => name
});
module.exports = __toCommonJS(entry_exports);
function name(a2) {
return a2 + 1;
}
var a = name(2);
var entry_default = a;

注意兩部分:

  1. 第 7 行程式碼可以看到, ESMCJS 後會給模組加上 __esModule 標記。

  2. 第 10 行程式碼中可以看到,CJS 的匯出是 computed 的, module.exports 賦值時需要保留 computed 匯出。

ModuleLoader 的實現注意相容此行為,虛擬碼如下:

class Module {
_exports = {}
get exports() {
return this._exports
}
set exports(value) {
if(typeof value === 'object' && value) {
if (value.__esModule) {
this._exports.__esModule = true;
}
for (const key in value) {
Object.defineProperty(this._exports, key, {
get: () => value[key],
enumerable: true,
});
}
}
}
}

由於 Scope Hosting 的禁用,在 bundler 期間無法對模組的匯入匯出進行檢查,只能得到在執行期間的程式碼報錯,Webpack 也存在此問題。



Module Resolver

雖然對模組進行了轉換,但無法識別 alias,node_modules 等模組。

如下面例子, node 模組 b 無法被執行,因為其註冊時是 /path/to/b

// a.ts
import { value } from 'b'

另外,由於 HMR API 接受子模組更新也需要識別模組。

module.hot.accpet('b', () => {})

有兩種方案來解決:

  1. Module URL Rewrite

Webpack/Vite 等都採用的是此方案,對模組匯入路徑進行改寫。

  1. 註冊對映表

由於 Module Rerewrite 需要對 import 模組需要分析,會有一部分開銷和工作量,為此採用註冊對映表,在執行時進行對映。如下:

moduleLoader.registerLoader('a'/* /path/to/a */, (require, module, exports) => {
const { value } = require('b');
expect(value).equal(1);
});
moduleLoader.registerResolver('a'/* /path/to/a */, {
'b': '/path/to/b'
});

當某個模組發生變化時,不用重新整理頁面就可以更新對應的模組。

首先看個 HMR API 使用的例子:

// bar.js
import foo from './foo.js'

foo()

if (module.hot) {
module.hot.accept('./foo.js' ,(newFoo) => {
newFoo.foo()
})
}

在上面例子中, bar.js./foo.js 的 HMR Boundary ,即接受更新的模組。如果 ./foo.js 發生更新,只要重新執行 ./foo.js 並且執行第七行的 callback 即可完成更新。

具體的實現如下:

  1. 構建模組依賴圖。

在 ModuleLoader 過程中,執行模組的同時記錄了模組之間的依賴關係。

img

如果模組中含有 module.hot.accept 的 HMR API 呼叫則將模組標記成 boundary。

img
  1. 當模組發生變更時,會重新生成此模組相關的最小 HMR Bundle,並且將其通過 websocket 訊息告知瀏覽器此模組發生變更,瀏覽器端依據模組依賴圖尋找 boundaries,並且開始重新執行模組更新以及相應的 calllback。

img

注意 HMR API 分為 接受子模組的更新接受自更新 ,在查詢  HMR Boundray 的過程需要注意區分。

目前,只在 ModulerLoader 層面支援了 accpet dispose API。

由於模組轉換後沒有先後關係,我們可以直接把程式碼進行合併即可,但是這樣會缺少 sourcemap。

為此,進行了兩種方案的嘗試:

  1. Magic-string Bundle + remapping

虛擬碼如下:

import MagicString from 'magic-string';
import remapping from '@ampproject/remapping';

const module1 = new MagicString('code1')
const module1Map = {}
const module2 = new MagicString('code2')
const module2Map = {}

function bundle() {
const bundle = new MagicString.Bundle();
bundle.addSource({
filename: 'module1.js',
content: module1
});
bundle.addSource({
filename: 'module2.js',
content: module2
});
const map = bundle.generateMap({
file: 'bundle.js',
includeContent: true,
hires: true
});
remapping(map, (file) => {
if(file === 'module1.js') return module1Map
if(file === 'module2.js') return module2Map
return null
})
return {
code: bundle.toString(),
map:
}
}

實現過後發現二次構建存在顯著的效能瓶頸,remapping 沒有 cache 。

  1. Webpack-source

虛擬碼如下:

import { ConcatSource, CachedSource, SourceMapSource } from 'webpack-sources';

const module1Map = {}
const module1 = new CachedSource(new SourceMapSource('code1'), 'module1.js', module1Map)
const module2 = new CachedSource(new SourceMapSource('code2'), 'module2.js', module1Map)

function bundle(){
const concatSource = new ConcatSource();
concatSource.add(module1)
concatSource.add(module2)
const { source, map } = concatSource.sourceAndMap();
return {
code: source,
map,
};
}

CacheModule 有每個模組的 sourcemap cache,內部的 remapping 開銷很小,二次構建是方案一的數十倍效能提升。

另外,由於 esbuild 因為開啟了生產模式的優化, metafile.inputs 中並不是全部的模組,其中沒有可執行程式碼的模組會缺失,所以合併程式碼時需要從模組圖中查詢全部的模組。

Lazy Compiler(未實現)

頁面中經常會包含 dynamic import 的模組,這些模組不一定被頁面首屏使用,但是也被 Bundler,因此 Webpack 提出了 Lazy Compiler 。Vite 利用 ESM Loader 的 unbundler 天生避免了此問題。

React Refresh

What is React Refresh and how to integrate it .

和介紹的一樣,分為兩個過程。

  1. 將原始碼通過 react-refresh/babel 外掛進行轉換,如下:

function FunctionDefault() {
return <h1>Default Export Function</h1>;
}

export default FunctionDefault;

轉換結果如下:

var _jsxDevRuntime = require("node_modules/react/jsx-dev-runtime.js");
function FunctionDefault() {
return (0, _jsxDevRuntime).jsxDEV("h1", {
children: "Default Export Function"
}, void 0, false, {
fileName: "</Users/bytedance/bytedance/pack/examples/react-refresh/src/FunctionDefault.tsx>",
lineNumber: 2,
columnNumber: 10
}, this);
}
_c = FunctionDefault;
var _default = FunctionDefault;
exports.default = _default;
var _c;
$RefreshReg$(_c, "FunctionDefault");

依據 bundler hmr 實現加入一些 runtime。

var prevRefreshReg = window.$RefreshReg$;
var prevRefreshSig = window.$RefreshSig$;
var RefreshRuntime = require('react-refresh/runtime');
window.$RefreshReg$ = (type, id) => {
RefreshRuntime.register(type, fullId);
}
window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
// source code
window.$RefreshReg$ = prevRefreshReg;
window.$RefreshSig$ = prevRefreshSig;
// accept self update
module.hot.accept();
const runtime = require('react-refresh/runtime');
let enqueueUpdate = debounce(runtime.performReactRefresh, 30);
enqueueUpdate();
  1. Entry 加入下列程式碼。

 const runtime = require('react-refresh/runtime');
runtime.injectIntoGlobalHook(window);
window.$RefreshReg$ = () => {};
window.$RefreshSig$ = () => type => type;

注意這些程式碼需要執行在 react-dom 之前。

- END -