无需打包构建?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 项目交流、学习、互动。

相关推荐
Slow菜鸟3 分钟前
JavaScript与UniApp、Vue、React的关系
javascript·vue.js·uni-app
VT.馒头12 分钟前
【力扣】2629. 复合函数——函数组合
前端·javascript·算法·leetcode
程序猿--豪12 分钟前
前端技术百宝箱
javascript·vue.js·react.js·webpack·gitee·css3·html5
程序员buddha12 分钟前
ThinkPHP8.0+MySQL8.0搭建简单实用电子证书查询系统
javascript·css·mysql·php·layui·jquery·html5
╰つ゛木槿14 分钟前
NPM安装与配置全流程详解(2025最新版)
前端·npm·node.js
每天吃饭的羊31 分钟前
React 性能优化
前端·javascript·react.js
hzw05101 小时前
使用pnpm管理前端项目依赖
前端
小柚净静1 小时前
npm install vue-router 无法解析
javascript·vue.js·npm
风清扬雨1 小时前
Vue3中v-model的超详细教程
前端·javascript·vue.js
高志小鹏鹏1 小时前
掘金是不懂技术吗,为什么一直用轮询调接口?
前端·websocket·rocketmq