May 25, 2021

「将 Vue SFC 编译为 ESM 」之路探索

#Vue.js

最近想解决个场景,在给 ve-charts 编写文档的时候,想做一个代码示例演示功能,在改动代码后可以直观的看到组件的变化。之前版本中文档是用的 docsify ,docsify 中自带了一个 vuep。vuep 就是解决我需要的场景的。不过 vuep 版本比较老了。目前还不支持 vue3 组件。所以想独立开发一个运行代码示例的组件。

# ES Modules 规范

ES modules(ESM) 是 JavaScript 官方的标准化模块系统

# 演进

在 ES6 之前,社区内已经有我们熟悉的模块加载方案 CommonJSAMD,前者用于服务器 即 Node.js,而后者借助第三方库实现浏览器加载模块。

在前端工程里,应用范围比较广的还是 CommonJS,从三个方面我们可以看出:

  • 我们依赖的发布在 NPM 上的第三方模块,大部分都打包默认支持 CommonJS
  • 通过 Webpack 构建的前端资源是兼容 Node.js 环境的 CommonJS
  • 我们编写的 ESM 代码 需要通过 Babel 转换为 CommonJS

# 趋势

好消息是,浏览器已经开始原生支持模块功能了,并且 Node.js 也在持续推进支持 ES Modules 模块功能

ESM 标准化还在道路上

# 客户端与服务端的实现区别

# 在 Node.js 中使用 ES Modules

Node.js v13.2.0 开始,有两种方式可以正确解析 ESM 标准的模块,在此之间还需要加上 --experimental-modules 才可以使用 ESM 模块。

  • 以后缀名为 .mjs 结尾的文件
  • 以后缀名为 .js 结尾的文件,且在 package.json 中声明字段 typemodule
// esmA/index.mjs
export default esmA

// or
// esmB/index.js
export default esmB

// esmB/package.json
{
  "type": "module"
}
1
2
3
4
5
6
7
8
9
10
11
  • 以后缀名为 .cjs 结尾的文件,将继续解析为 CommonJS 模块

# 在浏览器中使用 ES Modules

现代浏览器已经原生支持加载 ES Modules 需要将 type="module" 放到 <script> 标签中,声明这个脚本是一个模块。

这样就可以在脚本中使用 importexport 语句了

<script type="module">
  // include script here
</script>
1
2
3

caniuse-esm

# 在 Node.js 中处理依赖关系

现代前端工程开发环境中,会根据 package.json 来描述模块之间的依赖关系,安装模块后,所有模块会放在 node_modules 文件夹下。例如 package.json 中描述依赖了 lodash

{
  "name": "test",
  "version": "0.0.1",
  "dependencies": {
    "lodash": "^4.17.21"
  }
}
1
2
3
4
5
6
7

# 在浏览器中处理依赖关系

类似的,在浏览器中处理模块之间的依赖关系,目前有一个新的提案 import-maps

通过声明 <script> 标签的属性 typeimportmap,来定义模块的名称和模块地址之间的映射关系

例如:

<script type="importmap">
{
  "imports": {
    "lodash": "https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js"
  }
}
</script>
1
2
3
4
5
6
7

# 在浏览器中处理依赖、使用模块

importmap 仍然处于提案阶段,目前浏览器兼容情况还很缓慢,但是未来会持续兼容。我们可以使用 es-module-shims 使浏览器兼容。

<!-- UNPKG -->
<script async src="https://unpkg.com/es-module-shims@0.10.1/dist/es-module-shims.js"></script>

<!-- 声明依赖 -->
<script type="importmap">
{
  "imports": {
    "app": "./src/app.js"
  }
}
</script>

<!-- 使用模块 -->
<script type="module">
import 'app'
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# Vue SFC 简介

# 什么是 Vue SFC?

Vue 生态里 SFC 是 single-file components (单文件组件) 的缩写

通过扩展名 .vue 来描述了一个 Vue 组件

功能特性:

代码示例:

sfc

# 如何编译 Vue SFC?

Vue 工程需要借助 vue-loader 或者 rollup-plugin-vue 来将 SFC 文件编译转化为可执行的 JS

Vue 2

vue-loader 依赖:

  • @vue/component-compiler-utils
  • vue-style-loader

Vue 3

vue-loader@next 依赖:

  • @vue/compiler-core

Vite 2

@vitejs/plugin-vue 依赖:

  • @vue/compiler-sfc

# @vue/compiler-sfc 的工作原理

编译一个 Vue SFC 组件,需要分别编译组件的 templatescriptstyle

API

                                  +--------------------+
                                  |                    |
                                  |  script transform  |
                           +----->+                    |
                           |      +--------------------+
                           |
+--------------------+     |      +--------------------+
|                    |     |      |                    |
|  facade transform  +----------->+ template transform |
|                    |     |      |                    |
+--------------------+     |      +--------------------+
                           |
                           |      +--------------------+
                           +----->+                    |
                                  |  style transform   |
                                  |                    |
                                  +--------------------+
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

facade module,最终会编译为如下结构有 render 方法的组件伪代码

// main script
import script from '/project/foo.vue?vue&type=script'
// template compiled to render function
import { render } from '/project/foo.vue?vue&type=template&id=xxxxxx'
// css
import '/project/foo.vue?vue&type=style&index=0&id=xxxxxx'

// attach render function to script
script.render = render

// attach additional metadata
// some of these should be dev only
script.__file = 'example.vue'
script.__scopeId = 'xxxxxx'

// additional tooling-specific HMR handling code
// using __VUE_HMR_API__ global

export default script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# Vite & Vue SFC Playground

基于 @vue/compiler-sfc 构建的官方应用有 ViteVue SFC Playground,前者运行在服务端,后者运行在浏览器端。

# Vite 的依赖

  • vite 2 通过插件 @vitejs/plugin-vue 提供 Vue 3 单文件组件支持
  • 底层依赖 @vue/compiler-sfc

# Vue SFC Playground 的依赖

# 两者编译 SFC 的过程之间的区别?

SFC Playground 中模块的编译源自 Vite 中对 SSR 的支持

Vite

SFC Playground

# 两者编译 HelloWorld.vue 组件的区别?

Vite

// /components/HelloWorld.vue
import {defineComponent} from "/node_modules/.vite/vue.js?v=49d3ccd8";
const _sfc_main = defineComponent({
  name: "HelloWorld",
  props: {
    msg: {
      type: String,
      required: true
    }
  }
});

import { toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "/node_modules/.vite/vue.js?v=49d3ccd8"

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock("h1", null, _toDisplayString(_ctx.msg), 1 /* TEXT */))
}

_sfc_main.render = _sfc_render
_sfc_main.__file = "/Users/xiaoyunwei/GitHub/private/slides-vite-demo/src/components/HelloWorld.vue"
export default _sfc_main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

SFC Playground

// ./HelloWorld.vue
const __sfc__ = {
  name: "HelloWorld",
  props: {
    msg: {
      type: String,
      required: true
    }
  }
}

import { toDisplayString as _toDisplayString, openBlock as _openBlock, createBlock as _createBlock } from "vue"

function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock("h1", null, _toDisplayString($props.msg), 1 /* TEXT */))
}
__sfc__.render = render
__sfc__.__file = "HelloWorld.vue"
export default __sfc__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

# 两者编译 App.vue 组件的区别?

Vite

// ./App.vue
import {defineComponent} from "/node_modules/.vite/vue.js?v=49d3ccd8";
import HelloWorld from "/src/components/HelloWorld.vue";
const _sfc_main = defineComponent({
  name: "App",
  components: {
    HelloWorld
  }
});

import { resolveComponent as _resolveComponent, openBlock as _openBlock, createBlock as _createBlock } from "/node_modules/.vite/vue.js?v=49d3ccd8"

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(), _createBlock(_component_HelloWorld, { msg: "Hello Vue 3 + TypeScript + Vite" }))
}

_sfc_main.render = _sfc_render
_sfc_main.__file = "/Users/xiaoyunwei/GitHub/private/slides-vite-demo/src/App.vue"
export default _sfc_main
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

SFC Playground

// ./App.vue
import HelloWorld from './HelloWorld.vue'

const __sfc__ = {
  name: 'App',
  components: {
    HelloWorld
  }
}

import { resolveComponent as _resolveComponent, openBlock as _openBlock, createBlock as _createBlock } from "vue"
function render(_ctx, _cache, $props, $setup, $data, $options) {
  const _component_HelloWorld = _resolveComponent("HelloWorld")

  return (_openBlock(), _createBlock(_component_HelloWorld, { msg: "Hello Vue SFC Playground" }))
}
__sfc__.render = render
__sfc__.__file = "App.vue"
export default __sfc__
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

可以看出在编译 SFC 时,底层逻辑基本是一致的。

# 抽象将 SFC 编译为 ES Modules 的能力

借鉴 Vue SFC Playground ,造了两个轮子 🎡

  • vue-sfc2esm: https://github.com/xiaoluoboding/vue-sfc2esm
  • vue-sfc-sandbox: https://github.com/xiaoluoboding/vue-sfc-sandbox

感兴趣可以点击去 GitHub 关注

# vue-sfc2esm

将 Vue SFC 编译为 ES modules.

sfc2esm

# 功能

  • 💪 基于 TypeScript 编写
  • 🌳 TreeShakable & SideEffects Free
  • 📁 虚拟文件系统 (支持编译 .vue/.js 文件).
  • 👬 友好的错误提示

# 核心逻辑

  • vue-sfc2esm 内部实现了一个虚拟的 📁 文件系统,用来记录文件和代码的关系。
  • vue-sfc2esm 会基于 @vue/compiler-sfc 将 SFC 代码编译成 ES Modules
  • 编译好的 ES Modules 代码可以直接应用于现代浏览器中。

编译 App.vue 示例代码:

<script type="module">
import { createApp as _createApp } from "vue"

if (window.__app__) {
  window.__app__.unmount()
  document.getElementById('app').innerHTML = ''
}

document.getElementById('__sfc-styles').innerHTML = window.__css__
const app = window.__app__ = _createApp(__modules__["DefaultDemo.vue"].default)
app.config.errorHandler = e => console.error(e)
app.mount('#app')
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13

💡 使用 ES Modules 模块前,需要提前引入 Vue

<script type="importmap">
  {
    "imports": { "vue": "https://cdn.jsdelivr.net/npm/vue@next/dist/vue.esm-browser.js" }
  }
</script>
1
2
3
4
5

# vue-sfc-sandbox

vue-sfc-sandboxvue-sfc2esm 的上层应用,同时也基于 @vue/compiler-sfc 开发,提供实时编辑 & 预览 SFC 的沙盒组件。

vue-sfc-sandbox

# 功能

🗳️ SFC 沙盒

  • 💪 基于 TypeScript 编写
  • 🌳 TreeShakable & SideEffects Free
  • 📁 虚拟文件系统 (支持编译 .vue/.js 文件)
  • 👬 友好的错误提示,基于 vue-sfc2esm
  • 🧪 将 Vue SFC 文件转换为 ES Modules
  • 🔌 支持外部 CDN, 比如 unpkgjsdelivr 等.
  • 🧩 加载 Import Maps.

✏️ 编辑器面板

  • 🎨 基于 codemirror 6 的代码编辑器。
  • 🧑‍💻 对开发者友好, 内建高亮代码, 可交互的面板呈现 REPL 沙盒环境。

👓 预览面板

  • ⚡️ 实时编译 SFC 文件
  • 🔍 全屏查看

# 未来与现状

✨ 功能

  • 在线实时编译 & 预览 SFC 文件 / Vue 3 组件
  • 支持传入外部 CDN
  • 支持传入 Import Maps,传入 URL 需要为 ESM

💠 未来

  • 导出 SFC 组件
  • 支持实时编译 React 组件
  • 编辑器智能提示

💉 痛点

  • 无法直接使用打包成 CommonJSUMD 格式的包
  • 第三方依赖请求过多,有明显的等待时长

🖖 破局

# 相似工程

类似 sfc-sandbox,基于 Vue 技术栈可以在线提供编辑器 + 演示的工具

  • vuep - 🎡 A component for rendering Vue components with live editor and preview.
  • demosify - Create a playground to show the demos of your projects.
  • codepan - Like codepen and jsbin but works offline (Archived).

# 未来前端工程构建

虽然浏览器目前可以加载使用 ES Modules 了,但是它还是存在着一些上述提到的痛点中的问题的。

不过 2021 年的今天,已经涌现出了一批新的,可以称之为下一代的前端构建工具,例如 esbuildsnowpackvitewmr 等等。

可以看看这篇文章《Comparing the New Generation of Build Tools》,从工具配置、开发服务、生产构建、构建SSR等方面分析比较了前端下一代的构建工具。

future-build-tools

# 参考资料

  • JavaScript modules 模块: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Guide/Modules
  • ES modules: A cartoon deep-dive: https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/
  • import-maps: https://github.com/WICG/import-maps
  • es-module-shims: https://github.com/guybedford/es-module-shims
  • Vue 3 Template Explorer: https://vue-next-template-explorer.netlify.app/
  • Vue SFC Playground: https://sfc.vuejs.org/
  • 《Comparing the New Generation of Build Tools》: https://css-tricks.com/comparing-the-new-generation-of-build-tools/