Optimizing Frontend Builds: Shared Module Evaluation Order Solutions
Related Material
Module execution order can be incorrect - Nov 30, 2020
Incorrect import order with code splitting and multiple entry points - Sep 21, 2020
摘要: 现代 javascript
构建工具(如 esbuild
、rollup
)在优化代码打包、提升应用性能方面扮演着至关重要的角色。然而,在处理共享代码块(shared chunks
)时,确保模块执行顺序符合 esm
规范的语义,同时实现高效打包,是一个复杂的技术挑战。本文深入探讨此问题的根源,并详细分析两种主流的技术解决方案及其内在权衡。
问题的根源:ESM
语义与打包优化的冲突
esm
规范定义了明确且严格的 模块加载、链接 和 求值 (evaluation
) 顺序。模块顶层代码的执行,特别是那些包含副作用 (side effects
)(例如,修改全局状态、执行 I/O
操作、依赖其他模块已完成初始化)的代码,那么就必须按照依赖关系精确地进行。
构建工具的核心目标之一是通过 代码分割 (code splitting
) 和 代码复用 (code reuse
) 来优化最终产物,减少包体大小和网络请求数量。当多个入口点 (entry points
) 依赖同一个共享模块时,为了最大化复用,bundlers
通常喜欢将这些共享模块提取为单独的 chunk
,并作为依赖方的依赖模块。然而,不同的入口点可能隐式地要求该共享模块在不同的时机或与其他模块的不同组合下完成初始化。
例如以下例子:
import './lib2';
import './lib1';
if (window.lib !== 'lib1') throw 'fail';
import './lib1';
import './lib2';
if (window.lib !== 'lib2') throw 'fail';
window.lib = 'lib1';
window.lib = 'lib2';
通过上述打包策略打包后的产物如下:
import './lib1-4ba6e17e.js';
if (window.lib !== 'lib1') throw 'fail';
import './lib1-4ba6e17e.js';
if (window.lib !== 'lib2') throw 'fail';
window.lib = 'lib2';
window.lib = 'lib1';
对于 main.js
和 main2.js
两个入口点来说,加载 lib1.js
和 lib2.js
的时机是不同的,lib1.js
和 lib2.js
的初始化时机也因此不同。
因此在 esm
规范下,shared chunks
的优化需求有时会与 esm
严格的、确定性的执行顺序要求产生冲突,导致潜在的运行时错误或非预期行为。
解决方案一:基于执行顺序的精细化代码分割 (Semantic-Preserving Code Splitting)
rollup
作者 Lukas Taegert-Atkinson
的想法:
I have an idea to fix this which would involve calculating the execution order for each entry point separately and then splitting into smaller chunks so that the relative execution order inside each chunk is correct for all entry points.
我的解决方案是:先独立算出各入口点的执行次序,再切分成小的 chunk,保证每个 chunk 内部的相对执行次序能满足所有入口点的要求。
此方案的核心思想是 严格遵循模块间的依赖和执行时序要求。bundlers
需要深入分析模块依赖图以及不同入口点对共享模块的初始化需求。
- 机制:
- 识别出由于不同入口点引入路径而导致具有不同执行顺序依赖的共享代码。
- 将这部分共享代码进行更细粒度的拆分,为不同的执行上下文生成独立的或适配的
chunk
版本,确保每个入口点的执行流都符合esm
语义。
- 优势:
- 高保真度: 最大程度地保留了
esm
的原始模块执行语义,提高了代码行为的可预测性和正确性。
- 高保真度: 最大程度地保留了
- 挑战:
chunk
数量增加: 可能导致生成大量细粒度的chunk
文件。- 网络开销: 显著增加
http
请求数量,虽然http/2
和http/3
的多路复用缓解了部分问题,但请求建立、传输头信息等开销依然存在,尤其在网络状况不佳时可能影响加载性能。 - 构建复杂度: 对
bundlers
的 依赖分析 和 代码分割 逻辑提出了更高的要求,增加了构建过程的复杂度和时间。
- 潜在缓解: 未来的
javascript
特性提案,如module declarations
(曾用名module fragments
) 或import attributes
(曾用名import assertions
) 等,可能提供更灵活的模块加载控制,理论上有助于优化这类场景下的资源组织,但这些提案目前尚未标准化或广泛实现,存在浏览器兼容性问题。
解决方案二:运行时延迟初始化 (Runtime-Delayed Initialization)
esbuild
作者 Evan Wallace
的想法:
FWIW the other approach that I'm currently considering more strongly is to make code in shared chunks lazily-evaluated.
其实,我现在更倾向于另一种方案:让共享代码块里的代码采用“惰性求值”(即延迟到真正需要时才执行计算)。
此方案采取一种不同的策略:在打包时维持较粗粒度的 chunk
划分,通过 代码转换 将共享模块中需要精确控制初始化时机的代码,从声明期 立即执行 改为在 运行时按需初始化。
机制:
- 区分模块成员的 声明 (
declaration
) 和 初始化 (initialization
)。 - 将原本在模块顶层立即执行的初始化逻辑(如 类实例化、函数调用赋值、依赖其他模块计算结果 等)封装到一个特定的初始化函数(例如
__init
)中。 bundlers
在生成的代码中插入运行时逻辑,确保在模块实际被需要执行其副作用或导出初始化后的值之前,调用相应的__init
函数。
javascript// 原始共享模块 (可能有副作用或依赖顺序) export function utilityFn() { /* ... */ } export class ComplexService {} export const configValue = expensiveCalculation(); // 立即执行 // 转换后的共享模块 export function utilityFn() { /* ... */ } export let ComplexService; // 声明 export let configValue; // 声明 let __initialized = false; function __init() { if (__initialized) return; __initialized = true; // eslint-disable-next-line no-class-assign ComplexService = class {}; // 延迟初始化 // eslint-disable-next-line no-const-assign configValue = expensiveCalculation(); // 延迟初始化 } // (Bundler 生成的运行时代码会在适当时候调用 __init())
- 区分模块成员的 声明 (
优势:
- 打包效率: 维持了较少的
chunk
数量,有效控制了http
请求总数,通常对初始加载性能更友好。 - 兼容性: 主要依赖现有的
javascript
运行时行为,不依赖实验性浏览器特性,兼容性更好。 - 模拟语义: 通过运行时机制巧妙地模拟了
esm
模块按需初始化的核心语义,解决了执行顺序问题。
- 打包效率: 维持了较少的
考量:
- 微小的运行时开销: 引入了额外的初始化函数调用和状态管理逻辑,尽管通常性能影响可忽略不计。
Tree Shaking
交互: 需要bundlers
足够智能,能够正确分析__init
函数内部代码的实际使用情况,以实现有效的Tree Shaking
。
其实这个方案与 webpack
有着异曲同工之妙,通过 webpack
打包上述用例的产物如下:
/******/ (() => {
// webpackBootstrap
/******/ 'use strict';
/******/ var __webpack_modules__ = {
/***/ 585: /***/ (
__unused_webpack_module,
__unused_webpack___webpack_exports__,
__webpack_require__
) => {
/* harmony import */ var _lib2__WEBPACK_IMPORTED_MODULE_0__ =
__webpack_require__(155);
/* harmony import */ var _lib2__WEBPACK_IMPORTED_MODULE_0___default =
/*#__PURE__*/ __webpack_require__.n(
_lib2__WEBPACK_IMPORTED_MODULE_0__
);
/* harmony import */ var _lib1__WEBPACK_IMPORTED_MODULE_1__ =
__webpack_require__(54);
/* harmony import */ var _lib1__WEBPACK_IMPORTED_MODULE_1___default =
/*#__PURE__*/ __webpack_require__.n(
_lib1__WEBPACK_IMPORTED_MODULE_1__
);
if (window.lib !== 'lib1') throw 'fail';
/***/
}
/******/
};
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/
}
/******/ // Create a new module (and put it into the cache)
/******/ var module = (__webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/
});
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__
);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/
}
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/chunk loaded */
/******/ (() => {
/******/ var deferred = [];
/******/ __webpack_require__.O = (result, chunkIds, fn, priority) => {
/******/ if (chunkIds) {
/******/ priority = priority || 0;
/******/ for (
var i = deferred.length;
i > 0 && deferred[i - 1][2] > priority;
i--
)
deferred[i] = deferred[i - 1];
/******/ deferred[i] = [chunkIds, fn, priority];
/******/ return;
/******/
}
/******/ var notFulfilled = Infinity;
/******/ for (var i = 0; i < deferred.length; i++) {
/******/ var [chunkIds, fn, priority] = deferred[i];
/******/ var fulfilled = true;
/******/ for (var j = 0; j < chunkIds.length; j++) {
/******/ if (
(priority & (1 === 0) || notFulfilled >= priority) &&
Object.keys(__webpack_require__.O).every(key =>
__webpack_require__.O[key](chunkIds[j])
)
) {
/******/ chunkIds.splice(j--, 1);
/******/
} else {
/******/ fulfilled = false;
/******/ if (priority < notFulfilled) notFulfilled = priority;
/******/
}
/******/
}
/******/ if (fulfilled) {
/******/ deferred.splice(i--, 1);
/******/ var r = fn();
/******/ if (r !== undefined) result = r;
/******/
}
/******/
}
/******/ return result;
/******/
};
/******/
})();
/******/
/******/ /* webpack/runtime/compat get default export */
/******/ (() => {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = module => {
/******/ var getter =
module && module.__esModule
? /******/ () => module['default']
: /******/ () => module;
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/
};
/******/
})();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for (var key in definition) {
/******/ if (
__webpack_require__.o(definition, key) &&
!__webpack_require__.o(exports, key)
) {
/******/ Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
/******/
}
/******/
}
/******/
};
/******/
})();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) =>
Object.prototype.hasOwnProperty.call(obj, prop);
/******/
})();
/******/
/******/ /* webpack/runtime/jsonp chunk loading */
/******/ (() => {
/******/ // no baseURI
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ 792: 0
/******/
};
/******/
/******/ // no chunk on demand loading
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ __webpack_require__.O.j = chunkId =>
installedChunks[chunkId] === 0;
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = (
parentChunkLoadingFunction,
data
) => {
/******/ var [chunkIds, moreModules, runtime] = data;
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId,
chunkId,
i = 0;
/******/ if (chunkIds.some(id => installedChunks[id] !== 0)) {
/******/ for (moduleId in moreModules) {
/******/ if (__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] =
moreModules[moduleId];
/******/
}
/******/
}
/******/ if (runtime) var result = runtime(__webpack_require__);
/******/
}
/******/ if (parentChunkLoadingFunction)
parentChunkLoadingFunction(data);
/******/ for (; i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if (
__webpack_require__.o(installedChunks, chunkId) &&
installedChunks[chunkId]
) {
/******/ installedChunks[chunkId][0]();
/******/
}
/******/ installedChunks[chunkId] = 0;
/******/
}
/******/ return __webpack_require__.O(result);
/******/
};
/******/
/******/ var chunkLoadingGlobal = (self['webpackChunkwebpack_project'] =
self['webpackChunkwebpack_project'] || []);
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(
null,
chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
);
/******/
})();
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module depends on other loaded chunks and execution need to be delayed
/******/ var __webpack_exports__ = __webpack_require__.O(
undefined,
[758],
() => __webpack_require__(585)
);
/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__);
/******/
/******/
})();
/******/ (() => {
// webpackBootstrap
/******/ 'use strict';
/******/ var __webpack_modules__ = {
/***/ 246: /***/ (
__unused_webpack_module,
__unused_webpack___webpack_exports__,
__webpack_require__
) => {
/* harmony import */ var _lib1__WEBPACK_IMPORTED_MODULE_0__ =
__webpack_require__(54);
/* harmony import */ var _lib1__WEBPACK_IMPORTED_MODULE_0___default =
/*#__PURE__*/ __webpack_require__.n(
_lib1__WEBPACK_IMPORTED_MODULE_0__
);
/* harmony import */ var _lib2__WEBPACK_IMPORTED_MODULE_1__ =
__webpack_require__(155);
/* harmony import */ var _lib2__WEBPACK_IMPORTED_MODULE_1___default =
/*#__PURE__*/ __webpack_require__.n(
_lib2__WEBPACK_IMPORTED_MODULE_1__
);
if (window.lib !== 'lib2') throw 'fail';
/***/
}
/******/
};
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/
}
/******/ // Create a new module (and put it into the cache)
/******/ var module = (__webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/
});
/******/
/******/ // Execute the module function
/******/ __webpack_modules__[moduleId](
module,
module.exports,
__webpack_require__
);
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/
}
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/chunk loaded */
/******/ (() => {
/******/ var deferred = [];
/******/ __webpack_require__.O = (result, chunkIds, fn, priority) => {
/******/ if (chunkIds) {
/******/ priority = priority || 0;
/******/ for (
var i = deferred.length;
i > 0 && deferred[i - 1][2] > priority;
i--
)
deferred[i] = deferred[i - 1];
/******/ deferred[i] = [chunkIds, fn, priority];
/******/ return;
/******/
}
/******/ var notFulfilled = Infinity;
/******/ for (var i = 0; i < deferred.length; i++) {
/******/ var [chunkIds, fn, priority] = deferred[i];
/******/ var fulfilled = true;
/******/ for (var j = 0; j < chunkIds.length; j++) {
/******/ if (
(priority & (1 === 0) || notFulfilled >= priority) &&
Object.keys(__webpack_require__.O).every(key =>
__webpack_require__.O[key](chunkIds[j])
)
) {
/******/ chunkIds.splice(j--, 1);
/******/
} else {
/******/ fulfilled = false;
/******/ if (priority < notFulfilled) notFulfilled = priority;
/******/
}
/******/
}
/******/ if (fulfilled) {
/******/ deferred.splice(i--, 1);
/******/ var r = fn();
/******/ if (r !== undefined) result = r;
/******/
}
/******/
}
/******/ return result;
/******/
};
/******/
})();
/******/
/******/ /* webpack/runtime/compat get default export */
/******/ (() => {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = module => {
/******/ var getter =
module && module.__esModule
? /******/ () => module['default']
: /******/ () => module;
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/
};
/******/
})();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for (var key in definition) {
/******/ if (
__webpack_require__.o(definition, key) &&
!__webpack_require__.o(exports, key)
) {
/******/ Object.defineProperty(exports, key, {
enumerable: true,
get: definition[key]
});
/******/
}
/******/
}
/******/
};
/******/
})();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) =>
Object.prototype.hasOwnProperty.call(obj, prop);
/******/
})();
/******/
/******/ /* webpack/runtime/jsonp chunk loading */
/******/ (() => {
/******/ // no baseURI
/******/
/******/ // object to store loaded and loading chunks
/******/ // undefined = chunk not loaded, null = chunk preloaded/prefetched
/******/ // [resolve, reject, Promise] = chunk loading, 0 = chunk loaded
/******/ var installedChunks = {
/******/ 889: 0
/******/
};
/******/
/******/ // no chunk on demand loading
/******/
/******/ // no prefetching
/******/
/******/ // no preloaded
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/
/******/ __webpack_require__.O.j = chunkId =>
installedChunks[chunkId] === 0;
/******/
/******/ // install a JSONP callback for chunk loading
/******/ var webpackJsonpCallback = (
parentChunkLoadingFunction,
data
) => {
/******/ var [chunkIds, moreModules, runtime] = data;
/******/ // add "moreModules" to the modules object,
/******/ // then flag all "chunkIds" as loaded and fire callback
/******/ var moduleId,
chunkId,
i = 0;
/******/ if (chunkIds.some(id => installedChunks[id] !== 0)) {
/******/ for (moduleId in moreModules) {
/******/ if (__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] =
moreModules[moduleId];
/******/
}
/******/
}
/******/ if (runtime) var result = runtime(__webpack_require__);
/******/
}
/******/ if (parentChunkLoadingFunction)
parentChunkLoadingFunction(data);
/******/ for (; i < chunkIds.length; i++) {
/******/ chunkId = chunkIds[i];
/******/ if (
__webpack_require__.o(installedChunks, chunkId) &&
installedChunks[chunkId]
) {
/******/ installedChunks[chunkId][0]();
/******/
}
/******/ installedChunks[chunkId] = 0;
/******/
}
/******/ return __webpack_require__.O(result);
/******/
};
/******/
/******/ var chunkLoadingGlobal = (self['webpackChunkwebpack_project'] =
self['webpackChunkwebpack_project'] || []);
/******/ chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
/******/ chunkLoadingGlobal.push = webpackJsonpCallback.bind(
null,
chunkLoadingGlobal.push.bind(chunkLoadingGlobal)
);
/******/
})();
/******/
/************************************************************************/
/******/
/******/ // startup
/******/ // Load entry module and return exports
/******/ // This entry module depends on other loaded chunks and execution need to be delayed
/******/ var __webpack_exports__ = __webpack_require__.O(
undefined,
[758],
() => __webpack_require__(246)
);
/******/ __webpack_exports__ = __webpack_require__.O(__webpack_exports__);
/******/
/******/
})();
(self['webpackChunkwebpack_project'] =
self['webpackChunkwebpack_project'] || []).push([
[758],
{
/***/ 54: /***/ () => {
window.lib = 'lib1';
/***/
},
/***/ 155: /***/ () => {
window.lib = 'lib2';
/***/
}
}
]);
可以发现 webpack
生成的 shared chunks
中就是采用 延迟执行初始化 方案,通过 webpack runtime
来管理 shared chunks
中各个模块初始化时机。
权衡与决策:准确性 vs. 效率
两种方案代表了在处理共享模块执行顺序问题上的不同工程哲学:
- 方案一 优先保证
ESM
语义的精确性,代价是可能牺牲部分打包效率和加载性能(尤其在请求数量方面)。 - 方案二 优先保证 打包效率和兼容性,通过运行时模拟来满足语义要求,可能引入微小的运行时开销。
Bundler 实现与开发者考量
不同的构建工具可能采用不同的默认策略或提供配置选项来让开发者影响此行为:
- rollup 及其生态(如
vite
)通常以其对esm
规范的严格遵循和精细的代码分割能力著称,其策略有时更接近方案一的思路。 - esbuild 以极高的构建速度为主要特点,其优化策略可能在某些默认配置下更倾向于方案二的效率,但也持续在改进对复杂
esm
场景的处理。
开发者在面对具体项目时,需要根据以下因素进行判断和配置:
- 项目对副作用和执行顺序的敏感度: 是否存在强依赖特定初始化顺序的共享模块?
- 性能瓶颈分析: 应用的主要性能瓶颈是网络请求数量,还是运行时计算?
- 目标环境兼容性: 是否需要兼容不支持最新
javascript
特性的旧环境? - 构建工具的配置能力: 了解并利用构建工具提供的相关配置项(如
rollup
/vite
的output.manualChunks
或特定插件)来微调分包和初始化策略。
结论
共享模块(shared modules
) 的执行顺序问题是现代前端构建优化中的一个典型挑战,反映了在 遵循标准语义 与 追求工程效率 之间的持续平衡,最终的选择往往是基于项目具体需求和约束的务实权衡。