實現在瀏覽器中 import 內聯 JS 模塊
現代瀏覽器支持了 ES Modules[1],也就是瀏覽器原生支持的 JavaScript 模塊化方案。雖然考慮兼容性,我們還很少能夠把 ES Modules 用於生產環境,但是在開發、測試、學習的場景中,ES Modules 發揮了越來越大的作用,比如構建工具 Vite[2],就利用 ES Modules 來快速提供開發調試環境。React 和 Vue 框架的學習中,也都可以利用 ES Modules 不用安裝本地構建工具,直接在瀏覽器上體驗這些現代框架。
不過 ES Modules 有個侷限性,就是它在瀏覽器裏能夠 import 指定 URL 的模塊化 JS 代碼,但是不能 import 自身 HTML 文件裏的模塊,比如:
<script type="module">
import {createApp} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
</script>
我們沒有辦法做到下面這種:
<script type="module" id="foo">
export default {foo: 'foo'};
</script>
<script type="module" id="bar">
import foo from '#foo'; // 我想在這裏引用上面的script標籤裏export的對象
</script>
但是如果能實現這種 inline-import,其實還挺有用的,這就意味着我們可以在像 CodePen 這樣簡單的 Playground 環境中使用多個 JavaScript 模塊,而不用把它們先發布成在線的 JS 文件再 import。
不過要實現 inline-import,也不是那麼容易。
思路上,我們可以藉助 Blob[3] 對象來實現,Blob 對象有一些神奇的能力,我在前端冷知識系列中分享過一篇文章《超好用的 Blob 對象!》[4],有興趣的同學可以去看一下。
言歸正傳,我們可以實現一個函數,將一段 JavaScript 文本創建成 Blob 對象,並返回 Blob 對象的 URL。
function getBlobURL(module) {
const jsCode = module.innerHTML;
const blob = new Blob([jsCode], {type: 'text/javascript'});
const blobURL = URL.createObjectURL(blob);
return blobURL;
}
接着我們實現一個 inlineImport 函數:
// https://github.com/WICG/import-maps
const map = {imports: {}, scopes: {}};
window.inlineImport = async (moduleID) => {
const {imports} = map;
let blobURL = null;
if(moduleID in imports) blobURL = imports[moduleID];
else {
const module = document.querySelector(`script[type="inline-module"]${moduleID}`);
if(module) {
blobURL = getBlobURL(module);
imports[moduleID] = blobURL;
}
}
if(blobURL) {
const result = await import(blobURL);
return result;
}
return null;
};
上面這段代碼不復雜,結合 getBlobURL,其核心就是從標籤<script type="inline-module">
中獲取 JavaScript 代碼字符串然後生成 blobURL,並且將它緩存在 map 對象裏,這樣下次如果再 import,就直接從 map 緩存中取。取出的 blobURL,通過 ES Modules 原生的動態 import 方法加載。有了 inlineImport 函數之後,我們就可以這樣用:
<script type="inline-module" id="foo">
const foo = 'bar';
export default {foo};
</script>
<script src="https://unpkg.com/inline-module/index.js"></script>
<script type="module">
const foo = (await inlineImport('#foo')).default;
console.log(foo); // {foo: 'bar'}
</script>
這樣實現可以解決大部分問題,但是用起來還是不爽,因爲這樣只能動態 import。事實上,我們希望也能夠以靜態的方式 import,比如const foo = (await inlineImport('#foo')).default;
可以寫成import foo from '#foo';
實際上這個也是可以實現的,要用到現代瀏覽器的另一個特性,importmap。
importmap 本來是爲了解決 ES Modules 引入模塊的別名問題,比如我們覺得下面的代碼寫得不爽,因爲 import 的 URL 太長了。
<script type="module">
import {createApp} from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
</script>
可以改成:
<script type="importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="module">
import {createApp} from 'vue';
</script>
也就是在前面加一個<scirpt type="importmap">
給要 import 的模塊 URL 加一個別名就行了。
不過要注意,importmap 使用有限制,首先頁面上只能有一個type="importmap"
的 Script 標籤,多個是不支持的,另外 importmap 的位置要在所有<script type="module">
的元素出現之前。
那麼,我們接着就可以利用生成 importmap 的思路來實現靜態的 inline-import 了:
const currentScript = document.currentScript || document.querySelector('script');
function setup() {
const modules = document.querySelectorAll('script[type="inline-module"]');
const importMap = {};
[...modules].forEach((module) => {
const {id} = module;
if(id) {
importMap[`#${id}`] = getBlobURL(module);
}
});
const importMapEl = document.querySelector('script[type="importmap"]');
if(importMapEl) {
// map = JSON.parse(mapEl.innerHTML);
throw new Error('Cannot setup after importmap is set. Use <script type="inline-module-importmap"> instead.');
}
const externalMapEl = document.querySelector('script[type="inline-module-importmap"]');
if(externalMapEl) {
const externalMap = JSON.parse(externalMapEl.textContent);
Object.assign(map.imports, externalMap.imports);
Object.assign(map.scopes, externalMap.scopes);
}
Object.assign(map.imports, importMap);
const mapEl = document.createElement('script');
mapEl.setAttribute('type', 'importmap');
mapEl.textContent = JSON.stringify(map);
currentScript.after(mapEl);
}
if(currentScript.hasAttribute('setup')) {
setup();
}
這個函數的內容看起來稍微多一些,主要是處理 importmap 的規則,如果頁面上已經有 importmap 標籤,就不能再創建 importmap 了,要拋出異常,另外用戶確實需要自己創建 importmap,我們可以讓用戶用<script type="inline-module-import">
代替,然後我們自己合併 JSON 數據,也就是代碼邏輯裏 externalMapEl 的這部分。最後,最核心的部分就是前面得到模塊的 BlobURL,然後針對 id 和 BlobURL 生成 importMap,最終將 importMap 掛載到 HTML 文檔中。
有了這個 setup 方法之後,我們已經可以用靜態的 import 了,我在代碼的最後,如果 script 標籤上設置 setup 屬性,那麼就自動運行setup()
。
這樣我們就可以這麼寫:
<script type="inline-module" id="foo">
const foo = 'bar';
export default {foo};
</script>
<script src="https://unpkg.com/inline-module/index.js" setup></script>
<script type="module">
import foo from '#foo';
console.log(foo); // {foo: 'bar'}
</script>
或者要用到自定義的 importmap 的時候可以這麼寫:
<script type="inline-module-importmap">
{
"imports": {
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js"
}
}
</script>
<script type="inline-module" id="foo">
const foo = 'bar';
export default foo;
</script>
<script src="https://unpkg.com/inline-module/index.js" setup></script>
<script type="module">
import foo from '#foo'
console.log(foo);
import {createApp} from 'vue';
console.log(createApp);
</script>
只是需要注意的是,<script src="https://unpkg.com/inline-module/index.js" setup></script>
這段必須出現在所有的type="inline-module"
的 script 標籤之後,所有type="module"
的 script 標籤之前。這樣,我們就可以愉快地使用 inline-module 啦~ 有需要使用的同學,可以直接使用稀土掘金開源的 GitHub 倉庫代碼:github.com/xitu/inline…[5] 有任何問題歡迎反饋~
參考資料
[1]
ES Modules: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules
[2]
Vite: https://vitejs.dev/
[3]
Blob: https://developer.mozilla.org/zh-CN/docs/Web/API/Blob
[4]
《超好用的 Blob 對象!》: https://github.com/akira-cn/FE_You_dont_know/issues/12
[5]
github.com/xitu/inline…: https://github.com/xitu/inline-module
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/ghWlsNLu1JhnoiC7oouv8g