大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!
1. 通用框架或者库的代码体积危机
前端开源框架或者库为了提升开发者的 DX 而采用的一种常见模式是使用单一入口文件来重新导出所有公共 API。然而,该方式会产生一个潜在的问题,即导致大量未使用的代码被包含在模块图谱 (Module Graph) 中。
// 统一入口文件 lodash.js
export {default as add} from "./add.js";
export {default as divide} from "./divide.js";
export {default as debounce} from "./debounce.js";
export {default as map} from "./map.js";
虽然可以使用称为 “tree-shaking” 的技术来解决此类问题,即跟踪模块导出的各个绑定的依赖关系,并移除那些未使用的重新导出。
// tree-shaking 会保证只导入并使用 add 和 multiply 函数
import {add, multiply} from './math';
console.log('Add:', add(2, 3));
// 输出: Add: 5
console.log('Multiply:', multiply(4, 5));
// 输出: Multiply: 20
然而,由于模块加载可能带来副作用,该技术并非总是可行,例如:不同的工具在 代码大小和正确性之间会做出权衡,从而导致 Web 应用程序优化不足,或者由于非纯模块 (non-pure module) 未按预期执行而导致难以调试。
// 非纯模块,其在加载时直接修改了全局状态
console.log('Non-pure module loaded!');
// 修改全局变量
window.someGlobalVariable = 'Modified by nonPureModule';
// 导出一个函数
export function doSomething() {
console.log('Doing something...');
}
同时,当直接在浏览器中运行 ESM 时,tree-shaking 技术也存在不足,因为其默认需要全程序分析 (whole-program analysis)。而浏览器不会对整个程序进行静态分析 ,因为其只负责加载和执行模块,而不关心模块之间的依赖关系或未使用的代码。
2. 为什么需要延迟重新导出 (Deferred re-exports)
实际上,Web 应用通常包含大量 JavaScript 代码,从而对启动时间产生重大影响。一种可行的方法是加载尽可能少的必要代码,并预加载将来可能需要的代码。然而,该策略在实践中很难实现,常常导致 Web 应用程序优化不足。
导入延迟提案 (import defer proposal) 解决了部分问题,其允许以最小的代价延迟执行应用程序启动期间不需要的代码。例如:
// 该提案会加载./helpers.js 和其依赖,但是不会立即执行
import defer * as helpers from "./helpers.js";
function fn() {
// helpers.js 仅在此时会执行
helpers.doSomething();
}
延迟重新导出提案通过允许库将重新导出 (reexport) 标记为 “未使用则忽略” ,最终解决了通用前端框架或者库的体积危机问题。其实现了以下核心目的:
- 遵循清晰的语义而非依赖工具定义的启发式方法
- 原生 JS 平台也可以实现这些语义以避免加载未使用的代码
- 与 import defer 提案集成,使重新导出 (reexport) 能够受益于相同的 “加载后,仅在实际需要时执行” 语义
延迟重新导出可以与 import defer 提案结合使用:
// 模块 math.js
export defer {add} from "./math/add.js";
export defer {sub} from "./math/sub.js";
当模块使用 import {add} from "./math.js"; 导入时,其会加载并执行./math.js 和 ./math/add.js,同时跳过 ./math/sub.js 及其所有依赖项。
3. 延迟重新导出模块的执行顺序
延迟重新导出的模块会在重新导出它们的模块之后执行,且按照重新导出的顺序执行,比如下面的示例:
// 重新导出模块 barrel.js
export defer {a} from "./a.js";
export {b} from "./b.js";
export defer {c} from "./c.js";
export {d} from "./d.js";
export defer {e} from "./e.js";
// 这里是入口导入文件 entrypoint.js
import {e, a, d} from "./barrel.js";
此时模块执行顺序为:
- b.js
- d.js
- barrel.js
- a.js
- c.js
- e.js
- entrypoint.js
与按源代码顺序执行所需内容相比,始终在重新导出它们的模块之后执行延迟导出的模块 ,可以提高不同类型的模块图谱之间的一致性。
4. 延迟重新导出与 import defer 集成
import defer 提案规定,使用命名空间导入时,defer 关键字表示 “仅在实际需要时执行此模块”。对于模块命名空间对象,export defer 也遵循类似的语义:
// 模块 math.js
export defer {add} from "./math/add.js";
export defer {sub} from "./math/sub.js";
export {mul} from "./math/mul.js";
// 模块 index.js
import * as math from "./math.js";
// 所有方法都加载, 会执行 ./math.js 和 ./math/mul.js
math.add;
// 执行 ./math/add.js
math.sub;
// 执行 ./math/sub.js
在将 export defer 与 import defer 配对使用时,可以提供更多的控制:
// 模块 math2.js
export defer {add} from "./math/add.js";
export defer {sub} from "./math/sub.js";
// 模块 index2.js
import defer * as math from "./math2.js";
// 所有方法加载,但是任何方法都不会执行
math.add;
// 执行 ./math.js ./math/add.js
math.sub;
// 执行 ./math/sub.js
参考资料
https://github.com/tc39/proposal-deferred-reexports
https://github.com/tc39/proposal-defer-import-eval/
https://www.youtube.com/watch?v=0t-Le4kdaMg