手撕vite(1)-单文件组件的解析

555 阅读5分钟

Vue 的单文件组件 (即 *.vue 文件,英文 Single-File Component,简称 SFC) 是一种特殊的文件格式,使我们能够将一个 Vue 组件的模板、逻辑与样式封装在单个文件中。

然而浏览器对于这种文件是不能够识别的。因此就需要解析成js文件。解析的过程是复杂的,幸运的是Vue 已经提供了 SFC 的编译能力。

compiler-core:和平台无关的编译器
compiler-dom:浏览器平台下的编译器,依赖于 compiler-core
compiler-sfc:单文件组件编译器,依赖于 compiler-core 和 compiler-dom
compiler-ssr:服务端渲染的编译器,依赖于 compiler-dom
reactivity:数据响应式系统,可以独立使用
runtime-core:和平台无关的运行时
runtime-dom:针对浏览器的运行时,处理原生 DOM API 和 事件等
runtime-test:为测试所编写的轻量级运行时,由于它渲染出来的 DOM 树其实是一个 js 对象,所以 这个运行时可以运行在所有的 js 环境里,可以用它来测试渲染是否正确,还可以用于序列化 DOM,触发 DOM 事件,以及记录更新中的某次 DOM 操作
server-renderer:用于服务端渲染
shared:vue 内部使用的公共 api
size-check:私有的包,不会发布到 npm,作用是在 tree-sharking 后检查包的大小
template-explorer:浏览器里运行的实时编译组件,它会输出 render 函数
vue 用来构建完整版的 vue,依赖于 compiler 和 runtime

下面来通过一个示例子。来看如何解析一个Vue文件。

首先我们创建App.Vue文件,内容如下:

<template>
  <div class="title" >{{ title }}</div>
  {{count}}
  <button @click="count++">+++</button>
</template>

<script>
import { ref } from "vue";

export default {
  name: "Main",
  setup() {
    const title = ref("Hello Vue");
    const count = ref(1);
    return {
      title,
      count
    };
  },
};
</script>

<style scoped>
.title {
  font-size: 60px;
  font-weight: 900;
  color: red;
}
</style>

可以看到一个Vue文件通常包括3部分,分别是template,script和style。要解析vue文件。就需要对template,script和style分别解析。

首先我们将代码读取进内存。

const {readFileSync,writeFileSync} = require('fs-extra')
const {parse, compileScript, compileTemplate, rewriteDefault, compileStyle} = require("@vue/compiler-sfc");
const file = readFileSync("./App.vue", "utf8");

const {descriptor} = parse(file);
console.log(descriptor);

打印parse方法读取的字符串:

{
  filename: 'anonymous.vue',
  source: '<template>\n' +
    '  <div class="title" >{{ message }}</div>\n' +
    '  {{count}}\n' +
    '  <button @click="count++">+++</button>\n' +
    '</template>\n' +
    '\n' +
    '<script>\n' +
    'import { ref } from "vue";\n' +
    '\n' +
    'export default {\n' +
    '  name: "Main",\n' +
    '  setup() {\n' +
    '    const message = ref("Hello Vue");\n' +
    '    const count = ref(1);\n' +
    '    return {\n' +
    '      message,\n' +
    '      count\n' +
    '    };\n' +
    '  },\n' +
    '};\n' +
    '</script>\n' +
    '\n' +
    '<style scoped>\n' +
    '.title {\n' +
    '  font-size: 60px;\n' +
    '  font-weight: 900;\n' +
    '  color: red;\n' +
    '}\n' +
    '</style>\n',
  template: {
    type: 'template',
    content: '\n' +
      '  <div class="title" >{{ message }}</div>\n' +
      '  {{count}}\n' +
      '  <button @click="count++">+++</button>\n',
    loc: {
      source: '\n' +
        '  <div class="title" >{{ message }}</div>\n' +
        '  {{count}}\n' +
        '  <button @click="count++">+++</button>\n',
      start: [Object],
      end: [Object]
    },
    attrs: {},
    ast: {
      type: 1,
      ns: 0,
      tag: 'template',
      tagType: 0,
      props: [],
      isSelfClosing: false,
      children: [Array],
      loc: [Object],
      codegenNode: undefined
    },
    map: {
      version: 3,
      sources: [Array],
      names: [],
      mappings: ';EACE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EACtC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EACR,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC',
      file: 'anonymous.vue',
      sourceRoot: '',
      sourcesContent: [Array]
    }
  },
  script: {
    type: 'script',
    content: '\n' +
      'import { ref } from "vue";\n' +
      '\n' +
      'export default {\n' +
      '  name: "Main",\n' +
      '  setup() {\n' +
      '    const message = ref("Hello Vue");\n' +
      '    const count = ref(1);\n' +
      '    return {\n' +
      '      message,\n' +
      '      count\n' +
      '    };\n' +
      '  },\n' +
      '};\n',
    loc: {
      source: '\n' +
        'import { ref } from "vue";\n' +
        '\n' +
        'export default {\n' +
        '  name: "Main",\n' +
        '  setup() {\n' +
        '    const message = ref("Hello Vue");\n' +
        '    const count = ref(1);\n' +
        '    return {\n' +
        '      message,\n' +
        '      count\n' +
        '    };\n' +
        '  },\n' +
        '};\n',
      start: [Object],
      end: [Object]
    },
    attrs: {},
    map: {
      version: 3,
      sources: [Array],
      names: [],
      mappings: ';AAOA,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;;AAEzB,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;EACb,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;EACZ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;IACN,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAChC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACpB,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE;MACL,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;MACP,CAAC,CAAC,CAAC,CAAC;IACN,CAAC;EACH,CAAC;AACH,CAAC',
      file: 'anonymous.vue',
      sourceRoot: '',
      sourcesContent: [Array]
    }
  },
  scriptSetup: null,
  styles: [
    {
      type: 'style',
      content: '\n.title {\n  font-size: 60px;\n  font-weight: 900;\n  color: red;\n}\n',
      loc: [Object],
      attrs: [Object],
      scoped: true,
      map: [Object]
    }
  ],
  customBlocks: [],
  cssVars: [],
  slotted: false,
  shouldForceReload: [Function: shouldForceReload]
}

可以看到parse方法并没有做任何的解析工作。仅仅是将文件分成template,script和style。并添加对应的属性对象。另外注意点是styles是一个数组。这是因为一个单文件组件中可能不止一个style标签。

接下来就需要分别对template,script和style进行解析了。而Vue中也提供了对应的方法。

1 解析template

const {compileTemplate} = require("@vue/compiler-sfc");
const id = Date.now().toString();
// 编译模板,转换成 render 函数
const template = compileTemplate({
    source: descriptor.template.content,
    filename: "app.vue", 
    id: scopeId,
});

直接使用compileTemplate方法就可以将一个template转换成 render 函数。打印template内容如下:

console.log(template);

{
  code: 'import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"\n' +
    '\n' +
    'const _hoisted_1 = { class: "title" }\n' +
    '\n' +
    'export function render(_ctx, _cache) {\n' +
    '  return (_openBlock(), _createElementBlock(_Fragment, null, [\n' +
    '    _createElementVNode("div", _hoisted_1, _toDisplayString(_ctx.message), 1 /* TEXT */),\n' +
    '    _createTextVNode(" " + _toDisplayString(_ctx.count) + " ", 1 /* TEXT */),\n' +
    '    _createElementVNode("button", {\n' +
    '      onClick: _cache[0] || (_cache[0] = $event => (_ctx.count++))\n' +
    '    }, "+++")\n' +
    '  ], 64 /* STABLE_FRAGMENT */))\n' +
    '}',
    ....
}

2 解析Script

// 这个 id 是 scopeId,用于 css scope,保证唯一即可
const id = Date.now().toString();
const scopeId = `data-v-${id}`;

// 编译 script,因为可能有 script setup,还要进行 css 变量注入
const script = compileScript(descriptor, {id: scopeId});

3 解析Style

// 一个 Vue 文件,可能有多个 style 标签
for (const styleBlock of descriptor.styles) {
    const styleCode = compileStyle({
        source: styleBlock.content,
        id,       // style 的 scope id,
        filename: "app.vue",
        scoped: styleBlock.scoped,
    });
    const styleDOM = `
  var el = document.createElement('style')
  el.innerHTML =  `${styleCode.code}`
   document.body.append(el);
`;
}

我们分别解析了template,script和style文件。下面只需要将3个部分解析的代码合并下。然后返回就可以了。


// 用于存放代码,最后 join('\n') 合并成一份完整代码
const codeList = [];
const script = compileScript(...)
// script
codeList.push(rewriteDefault(script.content, "__sfc_main__"));
codeList.push(`__sfc_main__.__scopeId='${scopeId}'`);
// template
const template = compileTemplate(...)
codeList.push(template.code);
codeList.push(`__sfc_main__.render=render`);
codeList.push(`export default __sfc_main__`);
// style

for (const styleBlock of descriptor.styles) {
    const styleCode = compileStyle({
        source: styleBlock.content,
        id,       // style 的 scope id,
        filename: "app.vue",
        scoped: styleBlock.scoped,
    });
    const styleDOM = `
  var el = document.createElement('style')
  el.innerHTML =  `${styleCode.code}`
   document.body.append(el);
`;
    codeList.push(styleDOM)
}

整合之后codeList中存储的就是解析后的代码了。然而这个代码还是不能使用的。虽然浏览器支持module导入。但是识别以/,./,../这种路径开头。

所以此时我们就可以通过rollup或者esBuilder进行打包

const  {build}  = require("esbuild")
const  {externalGlobalPlugin}  =   require("esbuild-plugin-external-global")

/**
 * 将 vue 模块 external,即不参与打包(因为我们在 index.html 已经全局引入了 Vue,如果不全局引入 Vue,则需要将 vue 也打包到代码中)
 * 使用 externalGlobalPlugin 插件,让 external 的 Vue 模块从 window.Vue 中获取。
 */

 build({
    entryPoints: ["build.temp.js"],    // 入口文件
    format: "esm",       // 打包成 esm
    outfile: "bundle.js",     // 设置打包文件的名字
    bundle: true,           // bundle 为 true 才是打包模式
    external: ["vue"],
    plugins: [
        externalGlobalPlugin({
            vue: "window.Vue", // 将 import vue 模块,替换成 window.Vue
        }),
    ],
});

然后直接在index.html引入就行了

<!DOCTYPE html>
<html lang="en">
<head>
    <script src="https://unpkg.com/vue@next"></script>
</head>
<body>
<div id="app"></div>
</body>

<script type="module">
    // 引入刚刚打包好的代码
    import Cpm from './bundle.js'
    Vue.createApp(Cpm).mount('#app')
</script>

</html>