
import()语法,通常称为动态导入,是一种类似函数的表达式,允许将ECMAScript模块异步动态地加载到可能的非模块环境中。与声明式导入不同,动态导入仅在需要时执行,这为现代前端工程化提供了更大的语法灵活性。
下面示例展示了当用户点击按钮时异步加载指定模块的典型场景:
const main = document.querySelector("main");
for (const link of document.querySelectorAll("nav> a")) {
link.addEventListener("click", (e) => {
e.preventDefault();
import("/modules/my-module.js")
.then((module) => {
module.loadPageInto(main);
})
.catch((err) => {
main.textContent = err.message;
});
});
}
尽管动态导入非常强大,但在实际使用中存在一些容易被忽略的细节,需要开发者特别注意。
模块命名空间导出对象的 writable 属性为 true 意味着什么?
模块命名空间对象(Module Namespace Object)是一个 prototype 为 null 的 sealed 对象(不可添加或删除属性,但可修改已有可写属性的值)。同时,该对象的所有字符串键都与模块的导出对应,按字典顺序枚举,默认导出对应 default 键。
const obj = Object.create(null);
const obj2 = {__proto__: null};
此外,模块命名空间对象还有一个值为 "Module" 的 [Symbol.toStringTag] 属性。
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
// 使用
import * as math from "./math.js";
console.log(math[Symbol.toStringTag]);
// 输出: "Module"
console.log(Object.prototype.toString.call(math));
// 输出: "[object Module]"
const descriptors = Object.getOwnPropertyDescriptors(math);
console.log(descriptors);
当使用 Object.getOwnPropertyDescriptors() 获取模块命名空间的字符串属性描述符时,会发现它们是不可配置 (non-configurable) 但可写 (writable) 的,例如对于上述 math 模块:
{
"PI": {
"value": 3.14159,
"writable": true, // 可写
"configurable": false, // 不可删除或修改描述符(除了将 writable 设为 false)
"enumerable": true // 可枚举
},
"add": {
"writable": true,
"configurable": false,
"enumerable": true
}
}
然而,开发者实际上无法对这些导入的属性进行重新赋值。执行以下代码会直接抛出错误:
import * as math from "./math.js";
math.PI = "高级前端进阶";
// Uncaught TypeError: Failed to set the 'PI' property on 'Module': Cannot assign to read only property 'PI' of object '[object Module]'
尝试添加新属性同样会失败:
math.ai = "true";
// Uncaught TypeError: Cannot add property ai, object is not extensible
这种看似矛盾的行为(属性描述符 writable: true 但实际赋值报错)揭示了ES6模块实时绑定 (live bindings) 的特性:导出模块可以修改导出的值,但导入模块只能读取。属性的 writable: true 反映了值可能被更改(因为导出方可以改),而 configurable: false 确保了模块导出接口的稳定性。开发者确实可以在导出模块中重新赋值变量,并在导入的命名空间对象中观察到新值。
// counter.js
export let count = 0;
export function increment() {
count++;
}
export function decrement() {
count--;
}
// main.js
import * as counter from "./counter.js";
console.log("Initial count:", counter.count); // 0
counter.increment();
console.log("After increment:", counter.count); // 1
动态导入与静态导入的对象并非 100% 相等
在大多数情况下,静态导入和动态导入同一个模块,得到的命名空间对象是相等的。
import * as mod from "/my-module.js";
import("/my-module.js").then((mod2) => {
console.log(mod === mod2); // 通常为 true
});
但存在一个例外:如果被导入的模块导出了一个名为 then 的函数。由于 Promise 规范规定其永远不会用一个 thenable(即带有 then 方法的对象)来兑现自身,因此动态 import() 在解析时,如果发现模块导出了 then 函数,会自动调用它,并将其返回值作为 Promise 的兑现值。
// my-module.js
export function then(resolve) {
console.log("then() called");
resolve(1);
}
// main.js
import * as mod from "/my-module.js"; // mod 是正常的模块命名空间对象
import("/my-module.js").then((mod2) => {
// 会打印 "then() called"
console.log(mod === mod2); // false,因为 mod2 的值是数字 1,而非模块对象
});
因此,务必避免从模块中导出名为 .then() 的函数,这会导致动态导入的行为与预期严重不符。
滥用缓存绕过机制可能导致内存泄漏
模块缓存机制确保了同一模块在多次导入时,其代码只执行一次,避免了冗余的网络请求或磁盘访问,这是前端框架/工程化中性能优化的基础之一。
// counter.js
console.log('✅ counter.js is executing!'); // 这行只会打印一次
let initTime = Date.now();
export {initTime};
// main.js
import {initTime as staticTime} from './counter.js';
console.log('静态导入的 initTime:', staticTime);
import('./counter.js').then(module => {
console.log('动态导入的 initTime:', module.initTime);
console.log('两次值是否相等?', staticTime === module.initTime); // true
});
如果确实需要强制重新执行模块(例如热更新或获取最新数据),一种常见的技巧是在模块说明符后附加唯一的查询参数。这种方法在支持URL说明符的非浏览器运行时(如Node.js)也有效。
import(`/my-module.js?t=${Date.now()}`);
然而,这种做法的潜在风险是可能导致长时间运行的应用出现内存泄漏。因为每次带有不同查询参数的导入都被视为一个全新的模块,引擎无法安全地对旧的模块命名空间对象进行垃圾回收,并且没有标准API来手动清理这些缓存。
需要注意的是,模块命名空间对象缓存仅适用于成功加载、链接并执行的模块。模块导入分为三步:加载(Fetch)、链接(Link)、执行(Evaluate)。只有执行失败的模块才会被缓存。如果模块在加载或链接阶段失败,下一次导入可能会重试。浏览器对网络请求(fetch)的缓存遵循HTTP语义,因此处理这类失败应与处理普通的 fetch() 失败方式相同。