分享小技巧:实现在浏览器中import内联JS模块

avatar
掘金前首席打杂官

现代浏览器支持了ES Modules,也就是浏览器原生支持的JavaScript模块化方案。虽然考虑兼容性,我们还很少能够把ES Modules用于生产环境,但是在开发、测试、学习的场景中,ES Modules发挥了越来越大的作用,比如构建工具Vite,就利用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对象来实现,Blob对象有一些神奇的能力,我在前端冷知识系列中分享过一篇文章《超好用的Blob对象!》,有兴趣的同学可以去看一下。

言归正传,我们可以实现一个函数,将一段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…

有任何问题欢迎反馈~