實現在瀏覽器中 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