写这篇文章的目的,是为了介绍与推广我自己的新项目 v-md,同时简单介绍该项目相关的前端知识,分享我对于 ESM Bundleless 开发的理解与实践。
v-md
是一款 ESM Bundleless 理念的在线编辑器,支持使用 markdown
、vue
、js
、ts
、css
等语言编写简单的 Web 网页。
Github 地址:github.com/v-md/v-md
欢迎 Star,欢迎 Issue,希望能得到大家的指点,共同交流学习。
接下来我们进入正文。
构建工具存在的意义
我们在日常的前端工作中,重复执行 npm run build
,调试 webpack.config.js
、vite.config.js
的时候,应该不止一次地抱怨过:明明一个 html
文件就具备了网页展示的基础,为什么会发展出越来越繁琐的构建工具与工程化,下一个项目,我一定要抛弃这些破玩意,原生一把梭!
事实上,JavaScript 的新标准,一直在朝着简化 Web 应用构建的方向努力。我们之所以一直摆脱不了构建工具,是因为无论是浏览器,还是 JavaScript 生态、现存的格式各样的 Web 应用,都有着沉重的历史包袱。
我们先来梳理一下构建工具存在的理由:
- JS 语言最早没有模块化标准,导致依赖管理难以维护,构建工具率先实现了模块化标准,如 CommonJS、ESM 等。它们把模块化的代码打包成浏览器能识别的代码,解决了模块依赖问题。
- 不同品牌、版本的浏览器中的 Web API 有着很大的差异,需要构建工具统一引入 polyfill,使构建后的应用能够在尽可能多的 Web 平台运行。
- React、Vue 等前端框架为了提升开发者的体验,引入了新的语法,构建工具需要提供对这些框架的支持,如 React 的 JSX 语法、Vue 的单文件组件等。当然,
TypeScript
语言,Sass
、Less
等 CSS 预处理器也同样如此。
ESM Bundleless 开发的可能性
所谓 ESM Bundleless 开发,是指使用 ESM 模块化标准,不经过构建工具打包,实现直接在浏览器中运行 Web 应用。我们在开头提出了构建工具存在的必要性,那么 ESM Bundleless 开发真的有实践的价值吗?
回到前面列举的构建工具存在的理由,其实从 ES modules 作为一种 JavaScript 标准的模块化规范首次在 ECMAScript 2015 中被提出时开始,前端领域的不断发展,使得上述理由的必要性在逐渐开始降低:
- 关于构建工具支持模块化: 现代浏览器已经支持了大部分必要的 ES modules 的特性:
原生 ESM 已在 >= 96.67% 的设备上兼容。
dynamic import
动态导入已在 >= 96.55% 的设备上兼容。
<script type="importmap">
已在 >= 93.74% 的设备上兼容。
-
关于构建工具解决兼容性问题: 目前现代浏览器的普及率已经非常高,polyfill 的必要性在降低,如果你可以评估出自己面向的用户基本不存在老旧浏览器(公司内部应用,TOB 应用),或者应用的规模不会很大,那么完全可以尝试这种新的开发模式。当然,对于大规模的、用户群体庞大的应用,构建工具在兼容性方面的价值还是难以被替代的。
-
关于构建工具对现代前端框架的支持:
- 可以将
Vue
、React
等框架的代码编译成 ESM 模块,以组件库的方式上传到npm
,即可支持浏览器原生 ESM 引入。 - 诸如
babel
、vue/compiler-sfc
等编译工具,都支持在浏览器端运行。本文介绍的项目 v-md 就基于这一点,提供了浏览器端直接编写JSX
、Vue SFC
的可能性。
也许是时候探索一下 ESM Bundleless 的开发方式了。后面我将向大家简单介绍一下浏览器 ES Module 的用法,再与大家分享我自己实践这种开发方式的个人项目 v-md。
浏览器端 ES Module 的用法
基本使用
<script type="module">
允许浏览器直接解析和执行符合 ES6 标准的模块化代码。在下面的例子,我们用 <script type="module">
声明 ESM 脚本,该脚本从同路径下的另一个 ESM 脚本 b.js
中引入其导出的变量。
js
// b.js
export const a = 1
html
<script type="module">
import { b } from './b.js'
console.log(b) // 1
</script>
<script type="module">
同样支持 src
属性声明脚本地址。
html
<script type="module" src="./a.js"></script>
js
// a.js
import { b } from './b.js'
console.log(b) // 1
引入远程资源
浏览器 ES Module 最大的亮点就是支持引入远程资源,只需要在 import
语句中声明远程资源的 URL 即可。这使得 npm
仓库中相当多的模块,只要提供了 ESM
格式的产物,都可以直接被使用。
html
<script type="module">
// 这是 vue 提供的 ESM 产物的路径
import { ref, watch } from 'https://cdn.jsdelivr.net/npm/vue@latest/dist/vue.runtime.esm-browser.js'
const a = ref(1)
watch(a, (val) => {
console.log(val) // 每秒钟打印加 1 后的数值
})
setInterval(() => {
a.value++
}, 1000)
</script>
importmap 路径映射
当然,直接使用远程资源的 URL 显得过于繁琐了,我们希望能够像使用本地资源一样,使用模块名来引入远程资源。例如:
js
import { ref, watch } from 'vue'
但问题是,浏览器无法识别 vue
这个模块名,这就需要借助 <script type="importmap">
标签了。importmap
的作用就是将模块名映射到远程资源的 URL 上,告诉浏览器"从哪里寻找指定的资源"。
有了 <script type="importmap">
,我们就可以告诉浏览器 vue
在哪里了:
html
<script type="importmap">
{
"imports": {
"vue": "https://cdn.jsdelivr.net/npm/vue@latest/dist/vue.runtime.esm-browser.js"
}
}
</script>
<script type="module">
// 这是 vue 提供的 ESM 产物的路径
import { ref, watch } from 'https://cdn.jsdelivr.net/npm/vue@latest/dist/vue.runtime.esm-browser.js'
const a = ref(1)
watch(a, (val) => {
console.log(val) // 每秒钟打印加 1 后的数值
})
setInterval(() => {
a.value++
}, 1000)
</script>
当然,如果一个项目提供了多个产物入口。以 vue
为例,生产版本的 URL 在 /dist/vue.runtime.esm-browser.prod.js
路径,而 SFC
编译模块在 /compiler-sfc/index.browser.mjs
,如果我们要在脚本中引入不同的模块,就需要分别指定不同的 URL,这无疑增加了维护成本。
于是,我们可以利用 importmap
映射路径前缀的特性,定义一个共同的路径前缀 vue/
:
html
<script type="importmap">
{
"imports": {
"vue/": "https://cdn.jsdelivr.net/npm/vue@latest/",
"@vue/compiler-sfc": "https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@latest/dist/compiler-sfc.esm-browser.js"
}
}
</script>
<script type="module">
import * as vue from 'vue/dist/vue.runtime.esm-browser.js'
import * as compiler from 'vue/compiler-sfc/index.browser.mjs'
console.log('vue', vue)
console.log('compiler', compiler)
</script>
由于 vue/compiler-sfc/index.browser.mjs
的文件内容涉及了另一个模块 @vue/compiler-sfc
,我们在 importmap
中也补充了这个模块的路径。最终正确打印出两个不同的模块:
动态模块加载
dynamic import
是在前端开发中非常常见的用法,它允许我们在运行时动态地加载模块,而不是预先加载所有模块。我们通常用这一特性按需加载模块,以减少应用的初始加载时间。
浏览器 ES Module 也同样支持动态导入,上面的例子改写成动态加载后如下:
html
<script type="importmap">
{
"imports": {
"vue/": "https://cdn.jsdelivr.net/npm/vue@latest/",
"@vue/compiler-sfc": "https://cdn.jsdelivr.net/npm/@vue/compiler-sfc@latest/dist/compiler-sfc.esm-browser.js"
}
}
</script>
<script type="module">
import('vue/dist/vue.runtime.esm-browser.js').then((vue) => {
console.log('vue', vue)
})
import('vue/compiler-sfc/index.browser.mjs').then((compiler) => {
console.log('compiler', compiler)
})
</script>
低版本浏览器的兼容
在几乎所有主流浏览器都支持原生 ESM 的情况下,如果还要支持不足 5% 的老旧浏览器,我们可以还可以引入 Polyfill:es-module-shims。
html
<script
async
src="https://cdn.jsdelivr.net/npm/es-module-shims@1.5.18/dist/es-module-shims.wasm.js"
></script>
<script type="importmap">
// ...
</script>
<script type="module">
// ...
</script>
更多关于 ESM 的内容,可以参考 MDN 的 JavaScript 模块。
v-md 编辑器与 ESM Bundleless 开发
受到 vue-repl(Vue 代码演练场) 与 Vitepress(Vue 静态站点生成器) 的启发,我启动了自己的 v-md 项目。它"伪装"成一款 Markdown 编辑器,但实际上是一个网站编辑器。
它的工作原理如下:
- 在浏览器端实现了一套模拟的文件系统,模拟代码工作目录。
- 用 Monaco Editor 呈现、编辑各个文件的代码。
- 对每个文件,按照文件后缀名的不同,采用不同的编译流程,编译为浏览器原生支持的
ESM
模块。 - 使用
iframe
创建隔离沙箱环境,导入编译后的 ESM 产物,呈现出最终效果。
v-md 如何使用 ESM
使用控制台调试 v-md Demo 的预览文档,我们观察到文件目录中的入口文件 main.ts
被转化为了 ESM 模块,通过 <script type="module">
引入到了 iframe
的文档中。
这里的 blob URL 是通过 URL.createObjectURL 方法创建的,这样做使得虚拟文件系统中的内容可以拥有 http 网络地址,从而得以被浏览器识别为可执行的 ESM 脚本。
在这段脚本中,外部依赖 import { createApp } from 'vue'
是通过 importmap
机制支持的:文件系统中的 import-map.json
负责定义外部依赖的映射路径,其内容会被注入到 iframe
文档中的 <script type="importmap">
中。
另一个本地依赖 index.md
则与 main.ts
相同,在 ESM 脚本中被替换为对应的 blob URL
。
v-md 如何实现 Bundleless
上面的案例中,index.md
并不是一个 javascript
文件,按理说不应该被浏览器识别为 ESM 模块。那么 v-md
是如何脱离本地构建,实现 Bundleless 的呢?
打开 index.md
对应的 blob URL
,我们看到其内容如下:
js
/* Analyzed bindings: {
"ref": "setup-const",
"msg": "setup-ref"
} */
import {
toDisplayString as _toDisplayString,
createElementVNode as _createElementVNode,
createTextVNode as _createTextVNode,
vModelText as _vModelText,
withDirectives as _withDirectives,
createStaticVNode as _createStaticVNode,
Fragment as _Fragment,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
const _hoisted_1 = {
id: "msg",
tabindex: "-1",
};
import { ref } from "vue";
const __sfc__ = {
__name: "index",
setup(__props) {
const msg = ref("Hello World!");
return (_ctx, _cache) => {
return (
_openBlock(),
_createElementBlock(
_Fragment,
null,
[
_createElementVNode("h1", _hoisted_1, [
_createTextVNode(_toDisplayString(msg.value) + " ", 1 /* TEXT */),
_cache[1] ||
(_cache[1] = _createElementVNode(
"a",
{
class: "header-anchor",
href: "#msg",
"aria-label": 'Permalink to "{{ msg }}"',
},
"鈥�",
-1 /* HOISTED */
)),
]),
_withDirectives(
_createElementVNode(
"input",
{
"onUpdate:modelValue":
_cache[0] || (_cache[0] = ($event) => (msg.value = $event)),
},
null,
512 /* NEED_PATCH */
),
[[_vModelText, msg.value]]
),
_cache[2] ||
(_cache[2] = _createStaticVNode(
'<p><a href="https://www.baidu.com">鐧惧害涓€涓�</a></p><pre class="shiki shiki-themes light-plus language-ts" style="--shiki-highlight:#000000;--shiki-highlight-bg:#FFFFFF;" tabindex="0"><button title="Copy code" class="copy"></button><span class="lang">ts</span><code><span class="line"><span style="--shiki-highlight:#0000FF;">const</span><span style="--shiki-highlight:#0070C1;"> a</span><span style="--shiki-highlight:#000000;"> = </span><span style="--shiki-highlight:#098658;">1</span></span></code></pre>',
2
)),
],
64 /* STABLE_FRAGMENT */
)
);
};
},
};
__sfc__.__file = "/index.md";
export default __sfc__;
(() => {
const id = "style_/index.md";
let stylesheet = document.getElementById(id);
if (!stylesheet) {
stylesheet = document.createElement("style");
stylesheet.setAttribute("id", id);
stylesheet.setAttribute("css", "");
document.head.appendChild(stylesheet);
}
const styles = document.createTextNode("h1 {\n color: red;\n}");
stylesheet.innerHTML = "";
stylesheet.appendChild(styles);
})();
显然,这是 vue SFC 模板编译后的产物。本质上,v-md
还是做了与构建工具相同的事情,只不过它将编译产物的过程移到了浏览器端 。前文提到,v-md
在编译时会按照文件后缀名的不同,采用不同的流程,将各种类型的文件编译为浏览器原生支持的 ESM
模块。这个过程可以进一步拆分如下:
.js
文件默认被支持。- 样式文件改为创建
<style>
标签,并注入样式内容的脚本。 .json
文件改为export default
导出整个 JSON 对象的脚本。.vue
文件通过@vue/compiler-sfc
编译为ESM
模块,并注入到页面中。.md
文件通过VitePress
的编译流程先编译为vue
,再走vue
的编译流程。- ...
这样的思路可以成立,不得不感谢 Vue
、Babel
、各路 CSS 预处理器都提供了完整的编译工具链,且都支持在浏览器环境下运行。v-md
在 vue-repl
(Vue 在线演练场) 的基础上还实现了按需编译,刷新预览时只重新编译变动的内容,一定程度上提高了性能表现。
总结与展望
v-md 的实践表明:通过浏览器端编译、虚拟文件系统与 ESM 的深度结合,我们有可能在无需构建工具的情况下,实现复杂前端应用的实时开发体验。
当然,我的项目还处于非常早期的阶段,很多地方根本没有成熟,远远谈不上"探索全新的开发范式"。我只是希望它能作为一款产品,定位介于本地搭建环境与低代码开发之间,帮助有一定前端基础的开发者快速搭建小规模的网页。
如果你对这种开发模式感兴趣,欢迎前往 v-md 项目交流、学习、互动。