当你构建一个大型 JavaScript 应用时,有没有发现启动速度的瓶颈往往不在网络下载,而在代码执行?即使使用了代码分割和动态导入,模块的顶层代码依然会在加载后立即执行——动态 import 只是延迟了加载时机,但一旦加载完成,代码照跑不误。这意味着那些初始化开销大、但并非立即需要的模块(比如图表库、数据分析工具)仍然会拖慢首次渲染。CommonJS 的 require() 可以放在函数内部,实现“用到才执行”,但 ES Module 一直缺少对应的同步方案。TC39 的 stage 3 提案 import defer 正是来填补这个空白的。

import defer 的核心思想很简单:用 import defer * as ns from 'module' 导入一个模块时,浏览器会立即下载并解析该模块及其依赖,检查语法错误,但不会执行模块的顶层代码。模块的求值被推迟到第一次访问 ns 对象的属性时——那一刻,整个模块同步执行完毕,之后 ns 的行为与普通命名空间无异。这样,你既享受了预加载的网络优势,又避免了不必要的执行开销,而且 API 保持同步,不像动态 import 那样把调用链变成 Promise 链。

语法上,import defer 只支持命名空间形式(import defer * as ns),不支持单个命名导入。动态场景下可以使用 import.defer('module'),它返回一个 Promise,在模块下载和链接完成后 resolve,但同样不执行顶层代码。

一个关键限制是顶层 await(top-level await,即在模块顶层直接使用 await 关键字,这会使模块的求值变成异步)。如果被延迟的模块或其依赖图中存在顶层 await,那么该模块无法被延迟——因为属性访问必须保持同步,不能等待一个 Promise。实际上,import defer 会分析依赖图,将含有顶层 await 的模块及其依赖提前执行,只延迟那些纯同步的模块。例如,入口模块 a 用 import defer * as c from 'c',而 c 依赖 d(d 有顶层 await)和 f(纯同步),那么 d 和它的依赖 e 会立即执行,c 和 f 被延迟,直到首次访问 c.value 才执行。

错误处理也有差异。普通 import * as ns 在模块求值抛出异常时,命名空间会保留抛出前已导出的值,后续访问不会再次抛出。但 import defer 的命名空间在每次属性访问时都会重新抛出原始错误,确保行为不依赖于模块被谁、在何时首次求值。这是为了避免因其他模块提前用普通 import 求值而导致错误被吞掉,造成不一致。

目前 import defer 处于 TC39 stage 3,语法和语义基本定型,剩余工作主要是处理循环依赖和异步求值顺序等边界情况。引擎支持方面,V8 已在 Chrome 中通过 flag 开启,Deno 和 Bun 默认启用,Node.js 和 JavaScriptCore 正在跟进,SpiderMonkey 也在推进中。此外,Babel 插件和 webpack PR 已经可用,开发者现在就可以尝试。

对于追求启动性能的 JavaScript 应用,import defer 提供了一种比动态 import 更精细的控制:它让你在保持同步 API 的同时,将模块的初始化成本推迟到实际使用时刻。你可以把那些“重但少用”的模块(如报表渲染、PDF 生成、复杂计算库)用 import defer 导入,让首屏渲染更快。需要注意,被延迟的模块及其依赖必须没有顶层 await,否则会自动提前执行。随着 Deno 和 Bun 的默认支持,以及 Chrome 和 Node.js 的跟进,这个提案很可能成为未来 JavaScript 性能优化的标配。现在通过 Babel 插件尝鲜,提前熟悉这种模式,不失为明智之举。

阅读原文 → 返回 AI 技术文档

内容与图片版权归原作者所有 · 原文: https://nitayneeman.com/blog/introducing-import-defer-in-ecmascript