无需打包构建?ESM Bundleless 开发的探索与实践

写这篇文章的目的,是为了介绍与推广我自己的新项目 v-md,同时简单介绍该项目相关的前端知识,分享我对于 ESM Bundleless 开发的理解与实践。

v-md 是一款 ESM Bundleless 理念的在线编辑器,支持使用 markdownvuejstscss 等语言编写简单的 Web 网页。

Github 地址:github.com/v-md/v-md

欢迎 Star,欢迎 Issue,希望能得到大家的指点,共同交流学习。

接下来我们进入正文。

构建工具存在的意义

我们在日常的前端工作中,重复执行 npm run build,调试 webpack.config.jsvite.config.js 的时候,应该不止一次地抱怨过:明明一个 html 文件就具备了网页展示的基础,为什么会发展出越来越繁琐的构建工具与工程化,下一个项目,我一定要抛弃这些破玩意,原生一把梭!

事实上,JavaScript 的新标准,一直在朝着简化 Web 应用构建的方向努力。我们之所以一直摆脱不了构建工具,是因为无论是浏览器,还是 JavaScript 生态、现存的格式各样的 Web 应用,都有着沉重的历史包袱。

我们先来梳理一下构建工具存在的理由:

  1. JS 语言最早没有模块化标准,导致依赖管理难以维护,构建工具率先实现了模块化标准,如 CommonJS、ESM 等。它们把模块化的代码打包成浏览器能识别的代码,解决了模块依赖问题。
  2. 不同品牌、版本的浏览器中的 Web API 有着很大的差异,需要构建工具统一引入 polyfill,使构建后的应用能够在尽可能多的 Web 平台运行。
  3. React、Vue 等前端框架为了提升开发者的体验,引入了新的语法,构建工具需要提供对这些框架的支持,如 React 的 JSX 语法、Vue 的单文件组件等。当然,TypeScript 语言,SassLess 等 CSS 预处理器也同样如此。

ESM Bundleless 开发的可能性

所谓 ESM Bundleless 开发,是指使用 ESM 模块化标准,不经过构建工具打包,实现直接在浏览器中运行 Web 应用。我们在开头提出了构建工具存在的必要性,那么 ESM Bundleless 开发真的有实践的价值吗?

回到前面列举的构建工具存在的理由,其实从 ES modules 作为一种 JavaScript 标准的模块化规范首次在 ECMAScript 2015 中被提出时开始,前端领域的不断发展,使得上述理由的必要性在逐渐开始降低:

  1. 关于构建工具支持模块化: 现代浏览器已经支持了大部分必要的 ES modules 的特性:

原生 ESM 已在 >= 96.67% 的设备上兼容。

dynamic import 动态导入已在 >= 96.55% 的设备上兼容。

<script type="importmap"> 已在 >= 93.74% 的设备上兼容。

  1. 关于构建工具解决兼容性问题: 目前现代浏览器的普及率已经非常高,polyfill 的必要性在降低,如果你可以评估出自己面向的用户基本不存在老旧浏览器(公司内部应用,TOB 应用),或者应用的规模不会很大,那么完全可以尝试这种新的开发模式。当然,对于大规模的、用户群体庞大的应用,构建工具在兼容性方面的价值还是难以被替代的。

  2. 关于构建工具对现代前端框架的支持:

  • 可以将 VueReact 等框架的代码编译成 ESM 模块,以组件库的方式上传到 npm,即可支持浏览器原生 ESM 引入。
  • 诸如 babelvue/compiler-sfc 等编译工具,都支持在浏览器端运行。本文介绍的项目 v-md 就基于这一点,提供了浏览器端直接编写 JSXVue 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 编辑器,但实际上是一个网站编辑器。

前往体验地址(Github)

它的工作原理如下:

  • 在浏览器端实现了一套模拟的文件系统,模拟代码工作目录。
  • 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 的编译流程。
  • ...

这样的思路可以成立,不得不感谢 VueBabel、各路 CSS 预处理器都提供了完整的编译工具链,且都支持在浏览器环境下运行。v-mdvue-repl(Vue 在线演练场) 的基础上还实现了按需编译,刷新预览时只重新编译变动的内容,一定程度上提高了性能表现。

总结与展望

v-md 的实践表明:通过浏览器端编译、虚拟文件系统与 ESM 的深度结合,我们有可能在无需构建工具的情况下,实现复杂前端应用的实时开发体验。

当然,我的项目还处于非常早期的阶段,很多地方根本没有成熟,远远谈不上"探索全新的开发范式"。我只是希望它能作为一款产品,定位介于本地搭建环境与低代码开发之间,帮助有一定前端基础的开发者快速搭建小规模的网页。

如果你对这种开发模式感兴趣,欢迎前往 v-md 项目交流、学习、互动。

相关推荐
掘了9 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅9 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅9 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅9 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment9 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅10 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊10 小时前
jwt介绍
前端
爱敲代码的小鱼10 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax
Cobyte10 小时前
AI全栈实战:使用 Python+LangChain+Vue3 构建一个 LLM 聊天应用
前端·后端·aigc
NEXT0610 小时前
前端算法:从 O(n²) 到 O(n),列表转树的极致优化
前端·数据结构·算法