当ES模块第一次在ECMAScript 2015中被引入,作为在JavaScript中标准化模块系统的一种方式时,它是通过在import语句中指定相对或绝对路径来实现的。
import dayjs from "https://cdn.skypack.dev/dayjs@1.10.7"; // ES modules
console.log(dayjs("2019-01-25").format("YYYY-MM-DDTHH:mm:ssZ[Z]"));
这与模块在其他通用模块系统中的工作方式略有不同,例如CommonJS,以及在使用webpack这样的模块捆绑器时,使用的是更简单的语法。
const dayjs = require('dayjs') // CommonJS
import dayjs from 'dayjs'; // webpack
在这些系统中,通过Node.js运行时或相关的构建工具,导入指定器被映射到一个特定(和版本)的文件。用户只需要在导入语句中应用裸露的模块指定符(通常是包名),围绕模块解析的问题就会被自动解决。
由于开发者已经熟悉了这种从npm导入包的方式,所以需要一个构建步骤来确保以这种方式编写的代码能够在浏览器中运行。这个问题由import maps解决了。从本质上讲,它允许将导入指定器映射到相对或绝对的URL上,这有助于控制模块的解析,而不需要应用构建步骤。
import maps 是怎么工作的
<script type="importmap">
{
"imports": {
"dayjs": "https://cdn.skypack.dev/dayjs@1.10.7",
}
}
</script>
<script type="module">
import dayjs from 'dayjs';
console.log(dayjs('2019-01-25').format('YYYY-MM-DDTHH:mm:ssZ[Z]'));
</script>
import map 是通过HTML document中的 <script type="importmap">标签指定的。这个script 标签必须放在 document 中的中第一个 <script type="module">标签之前(最好是在<head>中),以便在进行模块解析之前对它进行解析。此外,目前每个 document 只允许有一个 import map,未来可能会取消这一限制。
在 script 标签内,一个JSON对象被用来指定document中 script 所需的所有必要的模块映射。一个典型的 import map 的结构如下所示。
<script type="importmap">
{
"imports": {
"react": "https://cdn.skypack.dev/react@17.0.1",
"react-dom": "https://cdn.skypack.dev/react-dom",
"square": "./modules/square.js",
"lodash": "/node_modules/lodash-es/lodash.js"
}
}
</script>
在上面的 imports 对象中,每个属性都对应着一个映射。映射的左边是 import 指定器的名称,而右边是指定器应该映射到的相对或绝对URL。
当在映射中指定相对URL时,确保它们总是以/、./或./开头。请注意,在 import map 中出现包并不意味着它一定会被浏览器加载。任何没有被页面上的 script 使用的模块都不会被浏览器加载,即使它存在于import map中。
<script type="importmap" src="importmap.json"></script>
你也可以在一个外部文件中指定你的映射,然后使用src属性链接到该文件(如上所示)。如果决定使用这种方法,请确保在发送文件时将其Content-Type标头设置为application/importmap+json。
注意,出于性能方面的考虑,推荐使用内联方式,本文的其余部分的事例,也会使用内联方式。
一旦指定了映射,就可以在import语句中使用import说明符,如下所示:
<script type="module">
import { cloneDeep } from 'lodash';
const objects = [{ a: 1 }, { b: 2 }];
const deep = cloneDeep(objects);
console.log(deep[0] === objects[0]);
</script>
需要注意的是,导入映射中的映射不会影响诸如<script>标签的 src 属性之类的位置。因此,如你的使用<script src="/app.js">之类的内容,浏览器将试图在该路径上下载一个字面上的app.js文件,而不管 import map 中的内容如何。
将指定者映射到整个包中
除了将一个指定器映射到一个模块,你也可以将一个指定器映射到一个包含多个模块的包。这是通过使用指定器键和以尾部斜线结尾的路径来实现的。
<script type="importmap">
{
"imports": {
"lodash/": "/node_modules/lodash-es/"
}
}
</script>
这种方法允许我们导入指定路径中的任何模块,而不是整个主模块,这会导致所有组件模块由浏览器下载。
<script type="module">
import toUpper from 'lodash/toUpper.js';
import toLower from 'lodash/toLower.js';
console.log(toUpper('hello'));
console.log(toLower('HELLO'));
</script>
动态地构建 import map
映射也可以基于任意条件在 script 中动态构造,这种能力可以用来根据特征检测有条件地导入模块。下面的例子根据IntersectionObserver API是否被支持,在lazyload指定器下选择正确的文件进行导入。
<script>
const importMap = {
imports: {
lazyload: 'IntersectionObserver' in window
? './lazyload.js'
: './lazyload-fallback.js',
},
};
const im = document.createElement('script');
im.type = 'importmap';
im.textContent = JSON.stringify(importMap);
document.currentScript.after(im);
</script>
如果你想使用这种方法,请确保在创建和插入 import map 脚本标签之前进行(如上所述),因为修改一个已经存在的导入地图对象不会有任何效果。
通过对哈希值的映射来提高脚本的可缓存性
实现静态文件长期缓存的常见技术是在文件名中使用文件内容的哈希值,这样文件就会一直在浏览器的缓存中,直到文件内容发生变化。当这种情况发生时,文件将得到一个新的名字,以便最新的更新立即反映在应用程序中。
在传统的 bundling scripts,的方式下,如果一个被多个模块依赖的依赖关系被更新,这种技术就会出现问题。这将导致所有依赖该依赖的文件被更新,迫使浏览器重新下载它们,即使只有一个字符的代码被改变。
import map 为这个问题提供了一个解决方案,它允许通过重映射技术单独更新每个依赖关系。假设你需要从一个名为post.bundle.8cb615d12a121f6693aa.js的文件中导入一个方法:
<script type="importmap">
{
"imports": {
"post.js": "./static/dist/post.bundle.8cb615d12a121f6693aa.js",
}
}
</script>
而不是这样写:
import { something } from './static/dist/post.bundle.8cb615d12a121f6693aa.js'
可以这么写:
import { something } from 'post.js'
当更新文件的时候,只有 import map 需要更新。由于对其导出的引用没有更改,它们将保持在浏览器中的缓存,同时由于更新的哈希值,更新的脚本将再次被下载。
<script type="importmap">
{
"imports": {
"post.js": "./static/dist/post.bundle.6e2bf7368547b6a85160.js",
}
}
</script>
使用同一模块的多个版本
在 import map 中很容易实现一个包对应多个版本,所需要做的就是在映射中使用不同的导入指定符,如下图所示:
<script type="importmap">
{
"imports": {
"lodash@3/": "https://unpkg.com/lodash-es@3.10.1/",
"lodash@4/": "https://unpkg.com/lodash-es@4.17.21/"
}
}
</script>
通过使用作用域,也可以用同一个导入指定符来指代同一个包的不同版本。这允许我们在一个给定的作用域内改变导入指定符的含义。
<script type="importmap">
{
"imports": {
"lodash/": "https://unpkg.com/lodash-es@4.17.21/"
},
"scopes": {
"/static/js": {
"lodash/": "https://unpkg.com/lodash-es@3.10.1/"
}
}
}
</script>
有了这种映射,在/static/js路径下的任何模块,在导入语句中引用lodash/指定器时,将使用https://unpkg.com/lodash-es@3.10.1/,而其他模块将使用https://unpkg.com/lodash-es@4.17.21/。
使用带有 import map 的 NPM 包
正如在本文中所展示的,任何使用ES Modules的NPM包的生产版本都可以通过ESM、Unpkg和Skypack等CDN在 import map中使用。
即使NPM上的包不是为ES模块系统和本地浏览器导入行为设计的,像Skypack和ESM这样的服务也可以将它们转化为可在导入地图中使用的包。可以使用Skypack主页上的搜索栏来寻找浏览器优化的NPM包,这些包可以立即使用,而无需摆弄构建步骤。
检测 import map支持
只要支持HTMLScriptElement.supports()方法,就可以在浏览器中检测 import map的支持:
if (HTMLScriptElement.supports && HTMLScriptElement.supports('importmap')) {
// import maps is supported
}
支持旧的浏览器
Import map 使得在浏览器中使用裸模块指定器成为可能,而无需依赖目前在JavaScript生态系统中普遍存在的复杂的构建系统,但目前网络浏览器中并不广泛支持它。
在整理本文时,Chrome和Edge浏览器的89版及以后的版本提供了全面支持,但Firefox、Safari和一些移动浏览器不支持这项技术。为了在这些浏览器中保留对 import map的使用,必须采用一个合适的 polyfill 。
一个可以使用的polyfill的例子是ES Module Shims polyfill,它为任何支持ES模块基线的浏览器(约94%的浏览器)添加了 import map 和其他新模块特性的支持。我们所需要做的就是在 import map 脚本之前在HTML文件中包含es-module-shim脚本。
<script async src="https://unpkg.com/es-module-shims@1.3.0/dist/es-module-shims.js"></script>
在包括polyfill之后,可能会在你的控制台中得到一个JavaScript TypeError。这个错误可以被安全地忽略,因为它不会产生任何面向用户的后果。
总结
import map提供了一种更理智的方式来在浏览器中使用ES模块,而不局限于从相对或绝对的URL中导入。这使得我们可以很容易地移动代码,而不需要调整 import语句,并使个别模块的更新更加无缝,而不影响依赖这些模块的脚本的缓存能力。总的来说,import map为ES模块在服务器和浏览器中的使用方式带来了平等性。