最新在學習jspm,里面出現(xiàn)了很多關于import maps的概念,想去學習下,鑒于中文文章基本上沒有找到關于import maps的介紹,于是翻譯了import-maps。
如有不正確歡迎指出。
什么是Import maps?
這個提案允許控制 js的 import語句或者import()表達式獲取的庫的url,并允許在非導入上下文中重用這個映射。這就解決了非常多的問題,比如:
- 允許直接import標志符,就能在瀏覽器中運行,比如:
import moment from "moment。 - 提供兜底解決方案,比如
import $ from "jquery",他先會去嘗試去CDN引用這個庫,如果CDN掛了可以回退到引用本地版本。 - 開啟對一些內置模塊或者其他功能的polyfill。
- 共享import標識符在Javascript importing 上下文或者傳統(tǒng)的url上下文,比如
fetch()、<img src="">或者<link href="">。
他的主要機制是通過導入import map(模塊和對應url的映射),然后我們就可以在HTML或者CSS中接受使用url導入模塊的上下文替換成import: URL scheme來導入模塊。
來看下面這個例子:
import moment from "moment";
import { partition } from "lodash";
這樣寫純粹的標識符會拋出錯誤,見explicitly reserved。(簡單來說只能允許/ ./ ../開頭的標識符)。
但是如果有了import map:
<script type="importmap">
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
"lodash": "/node_modules/lodash-es/lodash.js"
}
}
</script>
那種純粹的寫法就能被解析為:
import moment from "/node_modules/moment/src/moment.js";
import { partition } from "/node_modules/lodash-es/lodash.js";
在import:URL Schema的場景下:
<link rel="modulepreload" href="import:lodash">
更多關于值為"importmap"的script標簽見: installation section.
具體功能
模塊標識符映射
模塊的純粹標識符
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
"lodash": "/node_modules/lodash-es/lodash.js"
}
}
在設置了這個import map之后,
import moment from "moment";
import("lodash").then(_ => ...);
以上這種寫法就能在js里直接支持。
需要注意的是映射的value,必須以/、./或者../開頭,或者是一個能夠識別的url。在這個示例中的是一個類相對路徑的地址,它會根據(jù)import map的基本路徑進行解析,比如:內聯(lián)的import map,它的基礎路徑就是頁面url,而如果是外部資源的import map(<script type="importmap" src="xxxxx" />)那么它的基礎路徑,就是這個script標簽的url。
package的斜杠語法
在nodejs或者打包環(huán)境中,我們經常會寫這樣的語句import localeData from "moment/locale/zh-cn.js"; ,他并非一個純粹的庫名,而是前綴固定,然后相對尋址去拿對應文件。而之前,我們都是說的支持純粹的庫名來map一個url,其實這樣的寫法也是可以支持的,只需要配置importmap:
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
"moment/": "/node_modules/moment/src/",
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
import map通過識別以/結尾的的key值,來完成這樣的功能,于是以下的寫法都能支持:
import localeData from "moment/locale/zh-cn.js";
import fp from "lodash/fp.js";
類URL的重映射
Import maps特別允許類URL標識符的重新映射,它的其中一個優(yōu)勢是做兜底url映射。旦我們先展示一些基礎用法來說明重映射這個概念:
{
"imports": {
"https://www.unpkg.com/vue/dist/vue.runtime.esm.js": "/node_modules/vue/dist/vue.runtime.esm.js"
}
}
可以用本地版本vue來替代全局任何從cdn獲取的vue。
{
"imports": {
"/app/helpers.mjs": "/app/helpers/index.mjs"
}
}
這個重映射可以解析任何到 /app/helpers.mjs的路徑,比如,在/app/路徑下 import "./helpers.mjs" 或者 在 /app/models下 /app/helpers/index.mjs。 最終都能指向"/app/helpers/index.mjs"。在實際代碼中,這樣做可能不太好,容易產生一些混淆,不過這也足以證明import map的能力。
Remapping也可以像上面講的那樣做前綴匹配,通過以/結尾:
{
"imports": {
"https://www.unpkg.com/vue/": "/node_modules/vue/"
}
}
這里主要表達對類url的解析和對純粹標識符的解析是一樣的。之前的例子是映射import "lodash"里的lodash,而這里變成了映射import "/app/helpers.mjs" 里的 /app/helpers.mjs。
無擴展名imports
在nodejs或者打包環(huán)境中,我們也經常import某個文件,省略其擴展名,import map沒有這樣高級的功能去一直尋址擴展名,但是我們能夠直接配置缺失的擴展名在import map上。
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/",
"lodash/fp": "/node_modules/lodash-es/fp.js",
}
}
這樣不僅我們能夠支持import fp from "lodash/fp.js" 而且還能支持import fp from "loadsh/fp"。
盡管這個例子展示了如何允許使用import map實現(xiàn)無擴展的導入,但并不一定需要這樣做。這樣做會使import map臃腫,并使包的接口對人對工具都變得不是那么簡單。
這種臃腫是非常有問題的,你必須為每一個文件都配置映射,它無法匹配整個入口問。比如:import "./fp" 你就必須寫 /node_modules/lodash-es/lodash.js,/node_modules/lodash-es/fp 你就必須寫/node_modules/lodash-es/fp.js。。想象這樣映射下去,非常臃腫,所以建議如果能不用缺失擴展名的寫法就不要用,這樣會使整個系統(tǒng)更加簡單。
映射腳本中的hash
腳本文件名中通常包含hash值,為了改善web的緩存能力。詳見:this general discussion of the technique, 或者 this more JavaScript- and webpack-focused discussion。
在模塊依賴的圖中,這會存在一個問題:
- 考慮一種沒有hash值場景,app.mjs依賴dep.mjs,dep.mjs依賴 sub-dep.mjs。當我們改了sub-dep.mjs,app.mjs和dep.mjs依然緩存著的,我們只需要替換sub-dep.mjs即可。
- 但是如果加了hash值,例如:
app-8e0d62a03.mjs,dep-16f9d819a.mjs, 和sub-dep-7be2aa47f.mjs,當我們改了sub-dep.mjs之后它的hash值會發(fā)生變化(如果不知道為什么的先去了解下加hash的原因),由于dep.mjs依賴的sub-dep.mjs hash值發(fā)生了變化,那么dep要更替依賴,那么dep的hash值也會發(fā)生變化,以此類推app的hash值也會發(fā)生變化,,那么這樣依賴瀏覽器的緩存效率全失。
Import maps 提供了一個途徑來解決這樣的窘境,通過解耦import語句中模塊的標識符,例如:
{
"imports": {
"app.mjs": "app-8e0d62a03.mjs",
"dep.mjs": "dep-16f9d819a.mjs",
"sub-dep.mjs": "sub-dep-7be2aa47f.mjs"
}
}
那我們就可以用 import "./sub-dep.mjs"替代import "./sub-dep-7be2aa47f.mjs",如果我們的sub-dep.mjs發(fā)生了變化,我們只需要更新我們的import maps:
{
"imports": {
"app.mjs": "app-8e0d62a03.mjs",
"dep.mjs": "dep-16f9d819a.mjs",
"sub-dep.mjs": "sub-dep-5f47101dc.mjs"
}
}
這樣即使sub-dep.mjs更新了,但是dep.mjs里的import "sub-dep.mjs"這句話也不會發(fā)生變化,那么它仍然可以繼續(xù)緩存在瀏覽器中,同樣app.mjs也如此。
兜底方案
第三方模塊
這就是我們之前說的如果CDN掛了,回退到訪問本地的庫的例子。我們通常使用這個方案 terrible document.write()-using sync-script-loading hacks來解決這個問題。現(xiàn)在import maps提供了一種能力來控制模塊的解析,這樣做能更好。
使用一個兜底數(shù)組來提供兜底鏈接:
{
"imports": {
"jquery": [
"https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js",
"/node_modules/jquery/dist/jquery.js"
]
}
}
先加載CDN的資源,如果掛了,就加載本地node_modules里的資源(回退策略只會生效一次,之后便會緩存所有的功能)。
"jquery": "/node_modules/jquery/dist/jquery.js" 其實就是"jquery": ["/node_modules/jquery/dist/jquery.js"] 的語法糖。
內建模塊且瀏覽器支持import maps
我們不僅可以對第三方模塊做兜底,這樣適用于內建模塊:
{
"imports": {
"std:kv-storage": [
"std:kv-storage",
"/node_modules/kvs-polyfill/index.mjs"
]
}
}
它首先回去嘗試使用瀏覽器的"std:kv-storage",但是如果瀏覽器并不支持這個功能,就可以加載它的polyfill - /node_modules/kvs-polyfill/index.mjs。
注意:std:在這里只是解說意圖,這個提案是通用的,可以使用任何內置模塊前綴。。
內建模塊,但瀏覽器不支持import maps
如果瀏覽器不支持import maps,import { StorageArea } from "std:kv-storage" 總會執(zhí)行失敗。有什么寫法即可以在老式瀏覽器生效,也可以在支持import maps的瀏覽器生效:
import { StorageArea } from "/node_modules/kvs-polyfill/index.mjs";
然后配置重映射到內建模塊上:
{
"imports": {
"/node_modules/kvs-polyfill/index.mjs": [
"std:kv-storage",
"/node_modules/kvs-polyfill/index.mjs"
]
}
}
- 這樣不支持import maps的瀏覽器,會使用polyfill。
- 支持import maps,但不支持KV storage的瀏覽器,會映射到URL上,于是使用polyfill。
- 都支持的瀏覽器,就會直接使用內建模塊。
但是這樣的方式不能工作在<script>里:
import "/node_modules/virtual-scroller-polyfill/index.mjs";
這樣寫之前講過了,是沒有問題的。但是:
<script type="module" src="/node_modules/virtual-scroller-polyfill/index.mjs"></script>
這樣就不能正常運行了,這樣會無條件的加載polyfill,而不會使用內建模塊。
如果還想上面的功能正常進行,應該這么寫:
<script type="module" src="import:/node_modules/virtual-scroller-polyfill/index.mjs"></script>
遺憾的是,這樣的寫法只能運行在支持import maps的瀏覽器中,但是在不支持import maps的瀏覽器中,我們只有這么寫:
<script type="module">import "/node_modules/virtual-scroller-polyfill/index.mjs";</script>
這樣就能像上述我們所說那種運行了。
作用域
相同的模塊多版本
這是一個常見的case,比如我們用了socksjs-client這個庫,他依賴querystringify@1.0,但是我們想使用querystringify@2.0的功能。一種解決方案是我們?yōu)槲覀兪褂玫?code>querystringify改個名字,但這并不是我們想要的解決方案。
Import maps 提供了scope 來解決這樣的情況:
{
"imports": {
"querystringify": "/node_modules/querystringify/index.js"
},
"scopes": {
"/node_modules/socksjs-client/": {
"querystringify": "/node_modules/socksjs-client/querystringify/index.js"
}
}
}
使用了這個import maps,任何以/node_modules/socksjs-client/開頭的庫都會去使用/node_modules/socksjs-client/querystringify/index.js,然而頂級的imports確保其他我們使用到的querystringify,都是"/node_modules/querystringify/index.js"這個版本。
作用域繼承
作用域以一種簡單的方式合并切覆蓋例如:
{
"imports": {
"a": "/a-1.mjs",
"b": "/b-1.mjs",
"c": "/c-1.mjs"
},
"scopes": {
"/scope2/": {
"a": "/a-2.mjs"
},
"/scope2/scope3/": {
"b": "/b-3.mjs"
}
}
}
將會按以下的這種方式解析:
| Specifier | Referrer | Resulting URL |
|---|---|---|
| a | /scope1/foo.mjs | /a-1.mjs |
| b | /scope1/foo.mjs | /b-1.mjs |
| c | /scope1/foo.mjs | /c-1.mjs |
| a | /scope2/foo.mjs | /a-2.mjs |
| b | /scope2/foo.mjs | /b-1.mjs |
| c | /scope2/foo.mjs | /c-1.mjs |
| a | /scope2/scope3/foo.mjs | /a-2.mjs |
| b | /scope2/scope3/foo.mjs | /b-3.mjs |
| c | /scope2/scope3/foo.mjs | /c-1.mjs |
虛擬化
有能包裝、擴展、刪除內建模塊的能力是非常重要的,以下舉例import maps如何做到這一點。
注意:這同樣對第三方模塊有效,只是以內建模塊為例子
刪除內建模塊
盡管這是非常極端及很少使用的,但是依然可能出現(xiàn)需要拒絕對某個模塊訪問的情況,如果是全局模塊,我們可以這么做:
delete self.WebSocket;
在import maps中你可以限制對一個內建模塊的訪問,通過設置其值為空數(shù)組:
{
"imports": {
"std:kv-storage": []
}
}
或者設置為null:
{
"imports": {
"std:kv-storage": null
}
}
這樣在代碼里:
import { Storage } from "std:kv-storage"; // throws
就會拋出錯誤。
選擇性拒絕
可以使用scope的特性,選擇性限制對某個內建模塊的訪問:
{
"imports": {
"std:kv-storage": null
},
"scopes": {
"/js/storage-code/": {
"std:kv-storage": "std:kv-storage"
}
}
}
在"/js/storage-code/"中就可以訪問到內建模塊,而在全局訪問都會拋出錯誤。
當然也可以讓模塊模塊訪問不到"std:kv-storage",而其他地方都能訪問:
{
"scopes": {
"/node_modules/untrusted-third-party/": {
"std:kv-storage": null
}
}
}
封裝內建模塊
有時候我們需要封裝一下內建模塊,這就需要,封裝的這個模塊能訪問原生的內建模塊,而其他地方得訪問封裝后的模塊,可以像以下這么寫:
{
"imports": {
"std:kv-storage": "/js/als-wrapper.mjs"
},
"scopes": {
"/js/als-wrapper.mjs": {
"std:kv-storage": "std:kv-storage"
}
}
}
其他地方import "std:kv-storage"時都會訪問到"/js/als-wrapper.mjs",而這個就是封裝后的模塊,但是在"/js/als-wrapper.mjs"這個文件自身內訪問的是原生的模塊:
import instrument from "/js/utils/instrumenter.mjs";
import { storage as orginalStorage, StorageArea as OriginalStorageArea } from "std:kv-storage";
export const storage = instrument(originalStorage);
export const StorageArea = instrument(OriginalStorageArea);
擴展內建模塊
這個封裝一個內建模塊非常相似,比如我們需要在內建模塊上再export一個class:SuperAwesomeStorageArea。我們只需要依然使用上例的import maps,然后修改"/js/als-wrapper.mjs"代碼:
export { storage, StorageArea } from "std:kv-storage";
export class SuperAwesomeStorageArea { ... };
如果我們是在想原聲模塊上添加方法,那么我們不需要import maps,而是直接引入polyfill即可,polyfill文件里給StorageArea.prototype添加方法。
import: URLs
作為import maps概念的補充,提供了import: URL scheme。 它使得在HTML、CSS或者一些其他接受URL地方來使用import map。
一個widget庫的例子
這個庫不僅僅包括js模塊,還報錯css主題和一些圖片,你可以配置import map:
{
"imports": {
"widget": "/node_modules/widget/index.mjs",
"widget/": "/node_modules/widget/"
}
}
然后就可以使用:
<link rel="stylesheet" href="import:widget/themes/light.css">
<script type="module" src="import:widget"></script>
或者:
.back-button {
background: url('import:widget/assets/back.svg');
}
這使得所有web資源都可以通過庫標識符來訪問。
數(shù)據(jù)文件的例子
比如the timezone database這樣的json文件:
{
"imports": {
"tzdata": "/node_modules/tzdata/timezone-data.json"
}
}
然后就可以這樣訪問:
const data = await (await fetch('import:tzdata')).json();
URL解析語義
如何精確的來解析import:仍然是有些模糊的,特別是以下兩種情況使用URL時:
- 解析相對路徑標識符,例如
import:./foo - 在不同地方決定使用哪個作用域時
第一種情況并不重要,因為相應的用例并不十分重要。但是第二種情況就會產生歧義,比如我們app用了v2的widget,但是有一個第三方庫使用了v1的widget,我們就需要配置:
{
"imports": {
"widget": "/node_modules/widget-v2/index.mjs",
"widget/": "/node_modules/widget-v2/"
},
"scopes": {
"/node_modules/gadget/": {
"widget": "/node_modules/widget-v1/index.mjs",
"widget/": "/node_modules/widget-v1/"
}
}
}
問題在于/node_modules/gadget/styles.css會怎么解析?
.back-button {
background: url(import:widget/back-button.svg);
}
這里其實和你預期時一樣的,使用的是v1相應的url。
當前我們關于import:所提案的url解析方案是使用請求所在的URL,意思是:
- 默認的,使用頁面的基礎URL(獲取客戶端API所基于的URL)
- 如果請求發(fā)生在css里,使用css文件的url( location見 Referrer Policy
) - 如果請求發(fā)生在 HTML module里,使用模塊的相對路徑。
但是默認這種選擇就會出現(xiàn)問題,假如在/node_modules/gadget/index.mjs里:
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'import:widget/themes/light.css';
document.head.append(link);
因為他最終會成為頁面上的一個link元素然后引用import:widget/themes/light.css,而不是js代碼里的引用,所以他最終會按照頁面的URL去解析,故得到的是v2的版本。但實際上這是在/node_modules/gadget/index.mjs里的代碼,我希望他獲取的是v1。
一個提案是使用import.meta.resolve():
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = import.meta.resolve('widget/themes/light.css');
document.head.append(link);
前一個版本的提案是解析import:相對于,當前執(zhí)行腳本,但是也會有問題,見:#75。
Import map 處理程序
安裝
<script type="importmap">
{
"imports": { ... },
"scopes": { ... }
}
</script>
或者:
<script type="importmap" src="import-map.importmap"></script>
但是當用src時,http的response的MIME必須是application/importmap+json(為什么不用application/json這將會使內容安全協(xié)議
失效),并且就像大多數(shù)cdn上的庫,都是開啟了跨域,且通常都是按UTF-8解析的。
由于import maps影響著所有的引入,所以import maps必須在所有其他模塊解析前加載成功。這就代表import maps會阻塞其他任何導入的加載。
這就意味著我們強烈推薦使用內聯(lián)的import maps,這會帶來更好的性能,就類似內聯(lián)樣式一樣。直到他們處理完前,他們都會一直阻塞瀏覽器的運行。如果非要使用外部資源,推薦使用HTTP/2 Push或者bundled HTTP exchanges這樣的功能來緩和阻塞造成的影響。
還有另外一種結果就是,如果在import或者import:之后才加載import maps,那么就會拋錯。import maps將會被忽略,且script元素也會觸發(fā)一個錯誤事件。
一個頁面允許多個import maps,它的加載規(guī)則和以下等效:
const result = {
imports: { ...a.imports, ...b.imports },
scopes: { ...a.scopes, ...b.scopes }
};
the proto-spec有更多關于這個的介紹。
動態(tài)生成import maps
你可以在執(zhí)行任何import之前,執(zhí)行以下腳本,動態(tài)生成import maps
<script>
const im = document.createElement('script');
im.type = 'importmap';
im.textContent = JSON.stringify({
imports: {
'my-library': Math.random() > 0.5 ? '/my-awesome-library.mjs' : '/my-rad-library.mjs';
}
});
document.currentScript.after(im);
</script>
<script type="module">
import 'my-library'; // will fetch the randomly-chosen URL
</script>
也可以動態(tài)覆蓋已經導入的import
<script type="importmap">
{
"imports": {
"lodash": "/lodash.mjs",
"moment": "/moment.mjs"
}
}
</script>
<script>
if (!someFeatureDetection()) {
const im = document.createElement('script');
im.type = 'importmap';
im.textContent = '{ "imports": { "lodash": "/lodash-legacy-browsers.js" } }';
document.currentScript.after(im);
}
</script>
<script type="module">
import _ from "lodash"; // will fetch the right URL for this browser
</script>
動態(tài)覆蓋的script在第二個,因為如果已經有人使用到了import maps再覆蓋,就沒有用了。
作用域
Import maps是應用級的東西,類似service workers(更確切地說,他是每一個模塊的映射,作用在不同的環(huán)境里)。所以它不應該被手動的組合,而是應該由控制整個app視角的人或工具生成。比如,一個庫里包含import map就是沒有意義的, 庫應該通過標識符簡單的引用,而讓整個應用去決定到底映射哪一個url。
也就是這樣促使了它是以<script type="importmap">的方式存在。
由于應用的import maps更改了每個模塊所在模塊映射中的解析算法,因此它們不受模塊的源碼是否最初是來自跨域URL的影響。如果你加載了一個來自CDN的模塊通過純粹的標示符,你需要提前知曉這個模塊會給你整個應用的帶來哪些其他的純粹標識符,且把這些標識符包含在應用的import map里。也就是說,您需要知道應用程序的所有傳遞依賴項是什么。對于庫來說,由作者來控制他們使用的包是哪一個版本非常重要。