import()
语法,通常称为动态导入,是一种类似函数的表达式,允许将 ECMAScript 模块异步和动态地加载到可能的非模块环境中。
与声明式导入(静态导入)不同,动态导入仅在需要时才进行求值,并允许更高的语法灵活性。
在这里,“进行求值”(evaluate)是指在需要时才解析和执行动态导入的模块代码。动态导入不同于静态声明式导入,它不会在代码编译阶段就解析和执行模块,而是在运行时根据实际需求动态地加载和执行模块。
这种按需求加载和执行的方式可以带来更好的性能优化,例如减少应用程序的初始加载时间和资源消耗。同时,动态导入还提供了更高的语法灵活性,允许在运行时根据条件或用户交互来决定加载哪些模块。
动态导入是什么
动态导入(Dynamic Imports)是一种 JavaScript 模块加载机制,允许你在运行时按需加载模块。它是通过使用 import()
函数实现的,该函数返回一个 Promise 对象,该对象在模块加载完成后解析为模块的导出内容。动态导入与静态导入(使用 import something from "somewhere"
语句)不同,后者在编译时就确定了需要加载的模块。
动态导入的优点和缺点
优点:
缺点:
兼容性目前已经不是什么问题了,
import(moduleName)
import()
调用是一种与函数调用非常相似的语法,但 import
本身是一个关键字,而不是函数。您不能像 const myImport = import
这样为其设置别名,否则会抛出 SyntaxError
。
参数
moduleName
要导入的模块。模块标识符(specifier)的求值方式由宿主(host)指定,但始终遵循与静态导入声明相同的算法。
在这里,“宿主”(host)是指运行 JavaScript 代码的环境。JavaScript 可以在多种环境中运行,例如浏览器、Node.js 服务器、Web Workers 等。这些不同的运行环境被称为宿主环境。
在动态导入中,模块标识符的求值方式由宿主环境决定。这意味着不同的宿主环境可能会有不同的实现细节,但它们都遵循与静态导入声明相同的算法。这种设计使得动态导入能够在各种宿主环境中保持一致性和可移植性。
返回值
返回一个 Promise
对象,该对象在满足条件时解析为模块命名空间对象:一个包含来自 moduleName
的所有导出内容的对象。
import()
的求值过程永远不会同步地抛出错误。moduleName
会被强制转换为字符串,如果转换过程中抛出错误,则 Promise
会被拒绝,并返回抛出的错误。
静态导入声明语法(import something from "somewhere"
)是静态的,导入的模块会在加载完成后就立即进行求值。动态导入允许绕过静态导入的语法严格性,并根据条件或按需加载模块。以下是一些可能需要使用动态导入的原因:
eval
或脚本文件)。仅在必要时使用动态导入。静态形式更适用于加载初始依赖项,并且可以更容易地从静态分析工具和树摇(Tree shaking)中受益。
如果文件未作为模块运行(如果在 HTML 文件中引用,脚本标签必须具有 type="module"
),则无法使用静态导入声明,但始终可以使用异步动态导入语法,允许将模块导入到非模块环境中。
并非所有执行上下文都允许动态模块导入。例如,import()
可以在主线程、共享工作线程或专用工作线程中使用,但在 service worker 或 worklet 中调用时将会抛出错误。
模块命名空间对象是一个描述模块中所有导出内容的对象。它是在模块求值时创建的静态对象。有两种方法可以访问模块的模块命名空间对象:通过命名空间导入(import * as name from moduleName
)或通过动态导入的完成值。
模块命名空间对象是一个封闭的、无原型(prototype 为 null
)的对象。这意味着对象的所有字符串键都对应于模块的导出内容,且不会有额外的键。所有键按字典顺序(即 Array.prototype.sort()
的默认行为)可枚举,其中默认导出作为名为 default
的键可用。此外,模块命名空间对象具有一个名为@@toStringTag
的属性,其值为 "Module"
,用于 Object.prototype.toString()
。
如下示例是动态导入的 utils
模块,它对外暴露了 add
和 defalut
键,同时还有一个 Symbol.iterator
键。
当使用 Object.getOwnPropertyDescriptors()
获取其描述符时,可以看到,对象的属性是可写但不可配置的。
Object.getOwnPropertyDescriptors()
方法用于返回一个对象的所有自身属性(非继承属性)的描述符。描述符对象包含以下属性:
configurable
:表示属性是否可以被删除或者重新配置。如果为true
,则该属性可以被删除或者重新配置(例如,可以通过Object.defineProperty()
方法修改属性描述符)。如果为false
,则该属性不可被删除或重新配置。
enumerable
:表示属性是否可以被枚举。如果为true
,则该属性会出现在对象的枚举属性中(例如,可以通过for...in
循环或Object.keys()
方法访问到该属性)。如果为false
,则该属性不可枚举。
value
:表示属性的值。可以是任意类型的值,如数字、字符串、对象等。
writable
:表示属性值是否可以被修改。如果为true
,则该属性的值可以被修改。如果为false
,则该属性的值不可被修改。
然而,它们实际上是只读的,即使它们的 writable
描述符为 true
,原因在于 ES6 模块的规范要求导出的值是只读的。这是为了确保模块的引用和导出的值始终保持一致。这与静态导入是一致的,称为“实时绑定”或“动态绑定”——值可以由导出它们的模块重新分配,但不能由导入它们的模块重新分配。也就是说,当一个模块导出的值发生变化时,其他引用该模块的地方也会自动得到更新的值。为了实现这个特性,模块导出的值被设计为只读的。
每个模块标识符对应一个唯一的模块命名空间对象,因此以下通常是正确的:
import * as mod from "/my-module.js";
import("/my-module.js").then((mod2) => {
console.log(mod === mod2); // true
});
除了一个特殊情况:由于 Promise
永远不会满足一个 thenable
,如果 my-module.js
模块导出了一个名为 then()
的函数,那么当动态导入的 Promise
被实现时,该函数将作为 Promise
解析过程的一部分被自动调用。
// my-module.js
export function then(resolve) {
console.log("then() called");
resolve(1);
}
// main.js
import * as mod from "/my-module.js";
import("/my-module.js").then((mod2) => {
// Logs "then() called"
console.log(mod === mod2); // false
});
警告:不要从模块中导出名为 then()
的函数。这将导致模块在动态导入时与静态导入时的行为不同。
这里的"满足一个 thenable"是指
Promise
在解析过程中不会将其解析为一个具有then()
方法的对象。“thenable” 是一个术语,通常指代具有then()
方法的对象。这个方法允许对象参与异步操作,例如Promise
链式调用。在这里,是描述了一个特殊情况,即当模块导出一个名为
then()
的函数时,动态导入的Promise
会自动调用这个函数。这是因为Promise
规范要求在解析过程中检查解析值是否具有then()
方法。如果具有该方法,Promise
会将其视为 thenable 对象,并尝试调用该方法来获取最终值。然而,
Promise
本身永远不会将其解析为thenable
对象。这意味着,即使模块导出了一个名为then()
的函数,Promise
也不会将其视为thenable
对象。这是一个特殊情况,因为通常情况下,Promise 会尝试解析 thenable 对象。
仅为了其副作用而导入模块
(async () => {
if (somethingIsTrue) {
// import module for side effects
await import("/modules/my-module.js");
}
})();
如果你的项目使用了导出 ESM(ECMAScript 模块)的包,你也可以仅为了副作用而导入它们。这将仅运行包入口文件(以及它导入的任何文件)中的代码。
当我们谈论 “仅为了其副作用导入模块” 时,意味着我们导入一个模块,但不是为了使用它导出的值或函数,而是为了执行模块内的代码。这些代码可能会产生一些副作用,例如修改全局变量、设置环境配置或执行某些初始化操作。
在给出的示例中:
(async () => { if (somethingIsTrue) { // 仅为了副作用导入模块 await import("/modules/my-module.js"); } })();
使用动态导入语法来导入一个模块。这里,我们没有使用模块导出的任何值或函数。相反,我们仅仅是执行模块内的代码,这些代码可能会产生一些副作用。这就是为什么称之为 “仅为了其副作用导入模块”。
导入默认值
您需要从返回的对象中解构并重命名 default
键。
(async () => {
if (somethingIsTrue) {
const {
default: myDefault,
foo,
bar,
} = await import("/modules/my-module.js");
}
})();
按需导入以响应用户操作
下面示例演示了如何根据用户操作(在本例中是按钮点击)将功能加载到页面上,并调用该模块内的函数。当然这不是实现此功能的唯一方法。import()
函数还支持 await
。
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;
});
});
}
在这个示例中,我们为页面上的所有导航链接(nav > a
)添加了点击事件监听器。当用户点击链接时,我们阻止了链接的默认行为(e.preventDefault()
),然后使用动态导入(import()
)语法来按需导入模块(/modules/my-module.js
)。
导入成功后,我们调用模块中的 loadPageInto()
函数,并将页面的主要内容区域(main
)作为参数传递。这样,我们可以在用户点击链接时动态加载对应的页面内容。
如果导入过程中发生错误,我们会捕获错误(catch
),并将错误消息显示在页面的主要内容区域(main.textContent = err.message
)。
根据环境导入不同的模块
在诸如服务器端渲染等过程中,您可能需要在服务器或浏览器中加载不同的逻辑,因为它们与不同的全局变量或模块进行交互(例如,浏览器代码可以访问诸如 document
和 navigator
等 Web API
,而服务器代码可以访问服务器文件系统)。您可以通过条件动态导入来实现这一点。
let myModule;
if (typeof window === "undefined") {
myModule = await import("module-used-on-server");
} else {
myModule = await import("module-used-in-browser");
}
在这个示例中,我们使用了条件动态导入来根据当前的运行环境(服务器端或浏览器端)导入不同的模块。我们检查 window
对象是否存在(typeof window === "undefined"
)。如果不存在,说明我们正在服务器端环境中运行,因此我们导入服务器端使用的模块(module-used-on-server
)。如果存在,说明我们正在浏览器环境中运行,因此我们导入浏览器端使用的模块(module-used-in-browser
)。
通过这种方式,我们可以根据不同的运行环境选择性地导入适当的模块,从而确保正确的功能和逻辑在相应的环境中得到执行。
使用非字面量标识符导入模块
动态导入允许使用任何表达式作为模块标识符,不一定是字符串字面量。
在下面的示例中,我们同时加载10个模块,即 /modules/module-0.js、/modules/module-1.js
等,并调用每个模块导出的 load
函数。
Promise.all(
Array.from({ length: 10 }).map((_, index) =>
import(`/modules/module-${index}.js`),
),
).then((modules) => modules.forEach((module) => module.load()));
这段代码使用 Promise.all
和 Array.from
方法,创建了一个包含 10 个 Promise 的数组。每个 Promise 使用动态导入来加载对应的模块。当所有的模块都加载完成时,通过 then
方法获取到模块数组,并使用 forEach
遍历每个模块,并调用其 load
函数。
当使用动态导入进行按需加载时,浏览器会在需要的时候(例如点击事件触发)发起请求去加载对应的 JavaScript 模块。这个请求通常是一个 HTTP 请求,用于从服务器获取模块文件。当请求成功返回后,浏览器会执行加载的模块代码,并将模块中导出的内容提供给你的应用程序使用。
例如,假设你有一个名为 moduleA.js
的模块,当用户点击一个按钮时,你想要动态加载这个模块。你可以这样实现:
<button id="loadModule">加载模块button>
<script>
document.getElementById('loadModule').addEventListener('click', async () => {
const moduleA = await import('./moduleA.js');
// 使用moduleA中的导出内容
});
script>
浏览器是怎么执行这段 js 的呢?因为执行 js 要么用 script
标签,要么用 eval
,要么 new Function
浏览器在执行动态加载的 JavaScript 模块时,实际上内部也是使用了类似于 标签、
eval()
或 new Function()
的方式来执行代码。不过,这个过程是由浏览器自动完成的,开发者不需要直接操作这些底层细节。
当浏览器收到动态加载的 JavaScript 模块文件后,它会将文件内容解析为 JavaScript 代码。然后,浏览器会创建一个新的执行上下文(Execution Context),用于执行这段代码。在这个执行上下文中,浏览器会处理模块的导入、导出和其他逻辑。最后,浏览器会将模块中导出的内容提供给你的应用程序使用。
这个过程中,浏览器确保了模块的隔离性和安全性。与直接使用 标签、
eval()
或new Function()
相比,动态 import()
提供了更高级别的抽象,使得开发者可以更简单地实现按需加载、代码分割等功能,而无需关心底层的执行细节。
动态导入需要浏览器的支持。动态导入是 ECMAScript(JavaScript)规范中的一个特性,现代浏览器大多数已经支持了这个特性。然而,一些较旧的浏览器或者不支持最新特性的浏览器可能无法使用 import()
。
为了确保在不支持动态导入的浏览器上也能正常工作,可以使用一些工具和技术进行兼容处理:
Babel:Babe l是一个 JavaScript 编译器,可以将新的 JavaScript 特性转换为旧版本的浏览器可以理解的代码。通过配置 Babel 插件,可以将 import()
转换为类似于 require.ensure()
的语法,以实现类似的按需加载功能。
Webpack:Webpack 是一个前端构建工具,可以自动处理代码分割和动态加载。Webpack 支持将 import()
转换为兼容旧浏览器的代码,这样你就可以在不支持 import()
的浏览器上实现按需加载。
使用polyfill:polyfill 是一种用于填补浏览器功能缺失的技术。有一些 polyfill 库,例如 dynamic-import-polyfill
,可以在不支持动态 import()
的浏览器上提供类似的功能。引入这些 polyfill 库后,你可以在旧浏览器上使用 import()
,而不需要修改你的代码。
通过这些方法,可以确保动态导入在不同浏览器上都能正常工作,从而实现更好的兼容性和用户体验。