实现在浏览器中 import 内联 JS 模块

发布于 2023-12-04 00:16:50 字数 7217 浏览 18 评论 0

现代浏览器支持了 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 了。

参考资料

  • [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

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

0 文章
0 评论
22 人气
更多

推荐作者

qq_E2Iff7

文章 0 评论 0

Archangel

文章 0 评论 0

freedog

文章 0 评论 0

Hunk

文章 0 评论 0

18819270189

文章 0 评论 0

wenkai

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文