导入映射是什么
在 JavaScript 中,导入映射(Import Map)是一个 JSON 对象,用于控制模块导入路径。他允许你指定模块标识符(比如模块的名称)和模块实际位置(URL)之间的映射关系。
可以把导入映射(importmap)想象成一个自定义的模块路径解析器。在传统的 JavaScript 模块系统中,模块路径通常是相对于当前文件或者基于一些预定义的规则来解析的。而导入映射(importmap)提供了一种更加灵活的方式来告诉浏览器或者 JavaScript 运行时环境如何找到你想要导入的模块。
导入映射在 type 属性为 importmap 的 <script> 元素中定义:
<script type="importmap">
// 定义导入映射
</script>
注意,1 个 html 文件中只能有一个导入映射,例如下面的例子,定义了多个导入映射,然后页面报错了:
导入映射必须在导入模块的 <script> 元素之前声明,否则会报错,如下面的例子,在导入模块之后才声明导入映射,页面就报错了:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="module">
import { add } from 'calc'
import _ from 'lodash'
const total = add(1, 2)
const arr = _.concat(['a', 'b'], 'c', 'd')
console.log('res ', {
total,
arr,
_
})
</script>
<script type="importmap">
{
"imports": {
"calc": "./modules/calc.js",
"lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm"
}
}
</script>
</body>
</html>
在浏览器中,使用 import 语句或者动态 import() 方法导入 JavaScript 模块时,要指定一个模块标识符,指示要导入的模块。浏览器必须能够将此标识符解析为绝对路径,才能导入模块。
如下面的例子,模块标识符 ./modules/calc.js 是一个相对路径,浏览器在解析的时候会将其解析为绝对路径,而模块标识符 https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm 是一个绝对路径:
import { add } from './modules/calc.js'
import _ from 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm'
导入映射允许开发者在模块标识中指定(几乎)他们想要的任意文本;映射提供了一个相应的值,浏览器在解析模块标识符时会替换为该值。
检测浏览器是否支持 import map
可以使用 HTMLScriptElement.supports() 静态方法检查浏览器对导入映射(import map)的支持:
if (HTMLScriptElement.supports?.("importmap")) {
console.log("Browser supports import maps.");
}
裸模块
导入映射中,所谓裸模块,指的是那些没有路径信息(如相对路径或绝对路径),也没有协议(如 http://、https://)的模块标识符。例如,像 lodash、react 这样的模块引用就是裸模块,它们不是以./(相对路径)或/(绝对路径)开头,也没有包含完整的 URL。
如下面一个简单的 HTML 文件,其中包含了一个导入映射(importmap):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="importmap">
{
"imports": {
"calc": "./modules/calc.js",
"lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm"
}
}
</script>
<script type="module">
import { add } from 'calc'
import _ from 'lodash'
const total = add(1, 2)
const arr = _.concat(['a', 'b'], 'c', 'd')
console.log('res ', {
total,
arr,
_
})
</script>
</body>
</html>
在这个例子中,calc、lodash 都是裸模块,calc 和 lodash 通过 importmap 中的 imports 配置,分别映射到了 ./modules/calc.js 和 https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm 。其中 ./modules/calc.js 是相对路径,https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm 是实际的 URL 。importmap 使得浏览器能够正确地加载 calc 和 lodash 这两个模块。
映射路径前缀
模块标识符映射键也可用于重新映射模块标识符中的路径前缀。请注意,在这种情况下,属性和映射路径都要有一个尾随的正斜杠(/)。
如下面简单的例子:
<script type="importmap">
{
"imports": {
"util/": "./modules/util/",
"otherutil/": "https://example.com/modules/util/"
}
}
</script>
然后我们就可以这样导入 calc 模块:
import { minus } from 'util/calc.js'
模块标识符映射键中的路径
模块标识符键(Module specifier keys)不一定是要单个单词名称(”裸模块的名称“)。它们也可以包含路径分隔符或者以路径分隔符结尾,或者是绝对 URL,或者以 /、./ 或 ../ 开始的相对 URL。
<script type="importmap">
{
"imports": {
"shapes": "./shapes/square.js",
"shapes/square": "./modules/shapes/square.js",
"https://example.com/shapes/square.js": "./shapes/square.js",
"https://example.com/shapes/": "/shapes/square/",
"../shapes/square": "./shapes/square.js"
}
}
</script>
-
"shapes": "./shapes/square.js":将模块标识符 “shapes” 映射到相对路径 “./shapes/square.js”。这意味着当在代码中使用import something from 'shapes';时,实际上会加载 “./shapes/square.js” 这个文件。 -
"shapes/square": "./modules/shapes/square.js":将 “shapes/square” 这个更具体的模块标识符映射到另一个路径 “./modules/shapes/square.js”。这可以在需要更明确地指定特定模块时使用。 -
"https://example.com/shapes/square.js": "./shapes/square.js":即使是一个完整的 URL“example.com/shapes/squa… 也被映射到本地路径 “./shapes/square.js”。这可能用于处理从特定 URL 导入模块的情况,但实际上希望加载本地文件。 -
"https://example.com/shapes/": "/shapes/square/":将 URL“example.com/shapes/” 映射到路径 “/shapes/square/”。这个映射可能不太常见,具体用途取决于特定的应用场景,可能是为了处理以该 URL 开头的模块导入请求。 -
"../shapes/square": "./shapes/square.js":相对路径 “../shapes/square” 也被映射到相同的本地文件 “./shapes/square.js”。这在处理相对上级目录的模块导入时很有用。
这段代码通过定义各种模块标识符和实际文件路径之间的映射关系,使得在 JavaScript 模块系统中可以更灵活地导入不同的模块,并且可以根据不同的情况和需求进行定制化的模块路径解析。so good 👍
如果模块标识符映射中对应几个可能匹配的模块标识符键,则将选择最具体的键(即具有较长路径/值的键)。
如下面的例子,modules/util/calc.js 匹配的是 modules/util/ ,因为其更加具体,匹配的路径较长:
<script type="importmap">
{
"imports": {
"modules/util": "./modules/util/common/",
"modules/util/": "./modules/util/"
}
}
</script>
<script type="module">
import { minus } from 'modules/util/calc.js'
const res = minus()
console.log('res ' , {
res
})
</script>
如果在实际代码中使用的模块说明符最终的解析结果与导入映射(import map)中定义的模块标识符键是匹配的,那他们也是匹配的。也就是说,即使在实际代码中使用的模块标识符与导入映射(import map)中定义的模块标识符键不完全相同,但只要最终实际代码中使用的模块标识符解析后的结果与导入映射(import map)中定义的模块标识符键是匹配的,那他们就是匹配的。
如下面的例子,在导入映射(import map)中定义了模块标识符 ./js/app.js 解析为 lodash ,在实际的导入语句中使用的模块标识符是 ./foo/../js/app.js ,但是由于该模块标识符是一个相对路径,会经过路径解析为 ./js/app.js,这与导入映射(import map)中定义的模块标识符 ./js/app.js 是匹配的,尽管他们在字面上不完全一样,但经过路径解析后结果相同,那也会发生匹配:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="importmap">
{
"imports": {
"./js/app.js": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm"
}
}
</script>
<script type="module">
import app from './foo/../js/app.js';
const other = app.concat([], 2, 3, 4);
console.log(other);
</script>
</body>
</html>
上面的代码可以正确的导入 lodash :
作用域模块标识符映射
在导入映射(import map)中,scopes 用于定义特定路径范围内的模块标识符映射。这使得在不同的路径下,可以加载不同版本或者不同位置的模块。它提供了一种更精细的模块加载控制机制,以适应复杂的项目结构和模块依赖关系。
如下面的例子,定义了带有作用域模块标识符映射的导入映射,在不同的路径下创建了 calc 模块,模拟作用域模块标识符映射的场景:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<!-- 带有作用域模块标识符映射的导入映射 -->
<script type="importmap">
{
"imports": {
"calc": "./calc.js"
},
"scopes": {
"/special/": {
"calc": "./special/calc.js"
},
"/special/util/": {
"calc": "./special/util/calc.js"
}
}
}
</script>
<script type="module">
import { minus } from 'calc'
const res = minus()
console.log('demo1.html res ' , {
res
})
</script>
<script type="module" src="/special/index.js"></script>
<script type="module" src="/special/util/index.js"></script>
</body>
</html>
例子的文件目录结构如下:
calc.js 、index.js 文件的代码均比较简单,calc.js 仅做了 log 输出,index.js 仅是引入 calc 模块,并输出 log :
// special/util/calc.js
function minus() {
return "minus in special util scope";
}
export { minus };
// special/util/index.js
import { minus } from "calc";
const res = minus();
console.log("special/util/index.js res ", {
res,
});
// special/calc.js
function minus() {
return "minus in special scope";
}
export { minus };
// special/index.js
import { minus } from "calc";
const res = minus();
console.log("special/index.js res ", {
res,
});
根目录下的 calc.js 代码:
function minus() {
return "minus outside special scope";
}
export { minus };
该例子的运行结果如下:
可以看到,在作用域模块标识符映射的作用下,不同的路径,引入了不同的 calc 模块。
如果多个作用域与引用 URL 匹配,则使用最具体的作用域路径(具有最长名称的作用域键名称)。如果没有匹配的标识符,浏览器将会回落到第二具体的作用域路径,以此类推,最后会回落到 imports 键的模块标识符映射。
如下面的例子,由于没有定义 /special/util/ 路径的作用域模块标识符映射,在 /special/util/ 路径下引入 calc 模块,实际上引入的是 /special/ 路径下的 calc 模块:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="importmap">
{
"imports": {
"calc": "./calc.js"
},
"scopes": {
"/special/": {
"calc": "./special/calc.js"
}
}
}
</script>
<script type="module">
import { minus } from 'calc'
const res = minus()
console.log('demo1.html res ' , {
res
})
</script>
<script type="module" src="/special/index.js"></script>
<script type="module" src="/special/util/index.js"></script>
</body>
</html>
上面例子的执行结果:
在作用域模块标识符映射中,不仅能使用相对路径,也能使用线上的 url 前缀,比如:
<script type="importmap">
{
"imports": {
"lodash": "/libs/lodash.js",
"react": "/libs/react.js"
},
"scopes": {
"https://cdn.example.com/": {
"lodash": "https://cdn.example.com/lodash.min.js",
"react": "https://cdn.example.com/react.min.js"
}
}
}
</script>
当你的项目依赖了不同版本的模块时,作用域模块标识符映射可以帮助你指定特定路径下使用特定版本的模块,同时使用作用域模块标识符映射也可以根据环境切换模块的加载路径,开发时使用本地文件,生产环境使用 CDN 加速。
完整性元数据映射
导入映射(import map)中使用完整性元数据映射(Integrity Metadata Map)是为了确保模块加载时能够验证模块内容的完整性,防止文件被篡改。这是通过在导入映射中为每个模块提供一个 integrity 属性来实现的,integrity 属性指定了一个哈希值(如 sha256、sha384、sha512),用于校验加载的模块是否与原始资源匹配。
完整性数据映射的键(key)代表了模块的 url ,可以是绝对路径或相对路径(以 /、./、../ 开头或者线上 cdn 地址)。完整性数据映射的值(value)代表完整性元数据,与 integrity 属性值相同。
如下面的例子,为 lodash 模块随意设置了一个完整性元数据映射,指定的哈希值为 sha256-123,由于这个哈希值与浏览器计算出的哈希值不相等,浏览器会拒绝加载 lodash 模块:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="importmap">
{
"imports": {
"lodash": "./modules/util/common/lodash.js"
},
"integrity": {
"./modules/util/common/lodash.js": "sha256-123"
}
}
</script>
<script type="module">
import _ from 'lodash'
const res = _.fill([1, 2, 3], 6)
console.log('res ' , {
res
})
</script>
</body>
</html>
由于完整性数据映射中提供的哈希值与浏览器中计算出的哈希值不符,浏览器拒绝加载 lodash 模块:
有关 Digest 的解释:消息摘要(Digest),数字签名(Signature),数字证书(Certificate)是什么?
下面的例子给出了 lodash 模块正确的哈希值,浏览器正常加载 lodash 模块
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="importmap">
{
"imports": {
"lodash": "./modules/util/common/lodash.js"
},
"integrity": {
"./modules/util/common/lodash.js": "sha256-xaqfpHufg4S0pdo9tPUx0IhE0+Ym6D/UGiPRtSm+qB0="
}
}
</script>
<script type="module">
import _ from 'lodash'
const res = _.fill([1, 2, 3], 6)
console.log('res ' , {
res
})
</script>
</body>
</html>
我们还可以为没有通过完整性校验的模块提供回退机制,增强应用程序的稳定性,如下面的例子,当原 lodash 模块没有通过完整性校验时,则加载备份的 lodash 模块,保证应用程序的正常运行:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h2>import map demo</h2>
<script type="importmap">
{
"imports": {
"lodash": "./modules/util/common/lodash.js"
},
"integrity": {
"./modules/util/common/lodash.js": "sha256-123"
}
}
</script>
<script type="module">
async function loadModuleWithFallback(moduleSpecifier, fallbackPath) {
try {
const module = await import(moduleSpecifier)
console.log(`Using original ${moduleSpecifier}:`, module)
return module
} catch (err) {
if (err.message.includes('Failed to fetch dynamically imported module')) {
const fallbackModule = await import(fallbackPath)
console.log(`Using fallback for ${moduleSpecifier}:`, fallbackModule)
return fallbackModule
} else {
throw err
}
}
}
loadModuleWithFallback('lodash', './modules/fallback_lodash.js')
.then(_ => {
const res = _.default.fill(['a', 'b', 'c'], 6)
console.log('res ', {
res
})
}).catch(err => {
console.error('Error loading module:', err)
})
</script>
</body>
</html>
lodash.js 和 fallback_lodash.js 的代码很简单:
// lodash.js
function fill(arr, val) {
console.log("local lodash");
return arr.fill(val);
}
export default { fill };
// fallback_lodash.js
function fill(arr, val) {
console.log("fallback lodash");
return arr.fill(val);
}
export default { fill };
上述例子,加载了备份 lodash 模块的内容,执行结果如下:
通过映射散列文件名来改善缓存
网站使用的脚本文件通常具有哈希(散列)文件名以简化缓存。这个方法的缺点是,如果模块发生变化,任何使用其哈希文件名导入该模块的模块也需要同步更新,否则会导入该模块失败。这可能会导致一系列更新,从而浪费网络资源。
“simplify caching” 意思是 “简化缓存”。在计算机领域,特别是在网站开发和资源管理的上下文中,缓存(Caching)是一种用于存储数据副本的技术,目的是在后续请求相同数据时能够更快地响应。“简化缓存” 则是指通过一系列策略或技术使缓存的过程更加高效、易于管理和维护。
导入映射提供了简单的方法解决这个问题。使用导入映射可以让应用程序和脚本不依赖于特定的哈希文件名,而是依赖于模块名称(地址)的非哈希版本。然后,像下面这样的导入映射将提供到实际脚本文件的映射。
{
"imports": {
"main_script": "/node/srcs/application-fg7744e1b.js",
"dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
}
}
如果 dependency_script 发生更改,则文件名中包含的哈希值也会更改。在这种情况下,我们只需更新导入映射以反映模块的更改名称。我们不必更新依赖于它的任何 JavaScript 代码的源代码,因为 import 语句中的模块说明符不会更改。从而简化了文件缓存的管理。
总结
使用导入映射(import map),可以让浏览器导入模块时无需记住具体文件路径,提高了代码的可读性和可维护性。
而作用域模块标识符映射提供了更精细的模块加载控制机制,可用于定义特定路径范围内的模块标识符映射。这使得在不同的路径下,可以加载不同版本或者不同位置的模块,以适应复杂的项目结构和模块依赖关系。
在导入映射中也可以使用完整性元数据映射校验加载的模块的完整性,通过校验加载的模块是否与原始资源匹配,如果不匹配,浏览器会拒绝加载该模块,从而防止加载他人恶意篡改过的模块,提升应用程序的安全性。
我们也可以提供为应用程序提供回退机制,当加载的模块没有通过完整性校验时,加载备份的模块,提升应用程序的稳定性。
网站为了简化缓存,会将引入的脚本文件名带上哈希值,当脚本内容发生了变化后,会自动更新文件名带的哈希值,从而刷新缓存。当其他脚本文件通过带有哈希文件名的路径引入该模块时,则会导致其他脚本文件要跟着同步更新,从而引起了一连串的更新,浪费了网络资源。可以使用导入映射避免这个问题,在导入映射中将带有哈希文件名的路径与模块名称映射起来,然后在代码中直接通过模块名称引入相关的模块,这样当脚本内容发生变化时,只需修改导入映射,不需要修改其他脚本文件的代码,避免了一系列无关的更新。
{
"imports": {
"main_script": "/node/srcs/application-fg7744e1b.js",
"dependency_script": "/node/srcs/dependency-3qn7e4b1q.js"
}
}