Vue 3 源码解析(1)项目结构和源码调试

1,953 阅读7分钟

Vue3 源码系列文章会持续更新,全部文章请查阅我的掘金专栏

本系列的文章 demo 存放于我的 Github 仓库,推荐大家下载和调试,进而加深理解。

一. 获取源码

Vue 3 的源代码存放在其 Github 官方仓库上,最新的主干版本可以点这里下载。
你可以在 Releases 页面获取其它的历史构建。本文将以 v3.2.33 作为解析 Vue 源码的范本。

下载源码到本地后,在根目录执行 pnpm 指令安装依赖模块:

pnpm install

从 v3.2.20 开始,Vue 就将其项目的包管理器从 yarn 迁移到了 pnpm。点击查看迁移记录
Vue 通过 package.json 中的 scripts.preinstall 设置了依赖模块安装的前置脚本,用于检查系统的环境变量,从而判断运行指令的电脑是否有安装 pnpm,若没有会抛出指引文案并结束进程。

二. 目录结构

Vue 3 项目顶层结构可以简概为:

├── packages     // [文件夹] 存放 Vue 源代码模块,是最重要的部分
├── scripts      // [文件夹] 存放各任务(例如 dev)的配置脚本
├── test-dts     // [文件夹] 存放 TypeScript 声明文件
├── package.json      // 项目配置清单
├── rollup.config.js  // rollup 配置文件
└── tsconfig.json     // TypeScript 配置文件

我们只关注 Vue 3 源码的实现,故整个项目只需要重点了解 packages 子目录中的内容即可,其内部结构如下:

├── compiler-core     // 编译核心,抽象语法树和渲染桥接的实现(平台无关)
├── compiler-dom      // 生成模板渲染函数
├── compiler-sfc      // Vue 单文件组件(.vue)的编译实现
├── compiler-ssr      // 服务端渲染编译实现
├── reactivity        // 响应式的实现
├── ref-transform     // Ref 语法糖
├── runtime-core      // 运行时核心模块
├── runtime-dom       // 运行时 DOM 相关 api/属性/事件处理
├── runtime-test      // 运行时测试相关代码
├── server-renderer   // 服务端渲染
├── sfc-playground    // 单文件组件在线调试工具
├── shared            // packages 之间共享的工具库
├── size-check        // 简单应用,用来测试代码体积
├── template-explorer // 用于调试编译器输出的开发工具
├── vue               // 面向公众的完整版本, 包含运行时和编译器,入口文件、编译后的文件都放这里
└── global.d.ts       // TypeScript 声明文件

上方的目录名中,存在部分专用术语:

  • compiler:程序编译器,是它把我们写好的代码,逐步编译为可执行文件(例如将 .vue 文件编译为浏览器可识别的 js 文件)。
  • runtime:程序运行时,指客户端从开始执行我们的 Vue 程序,到它执行结束的这个阶段(所做的处理)。
  • sfc:单文件组件(Single File Component),组件文件后缀名为 .vue
  • ssr:服务端渲染(Sever Side Render)。

三. 入口文件

3.1 从 package.json 切入寻找

寻找一个项目的入口文件,往往先从根目录的 package.json 找起,查看是否存在 main 字段或 module 字段,如果有,它们即项目的入口文件。
然而 Vue 3 在 package.json 中并未配置入口字段,但其配置了不少开发/构建的预设脚本:

/** package.json **/
{
  // ...
  "scripts": {
    "dev": "node scripts/dev.js",
    "build": "node scripts/build.js",
    "size": "run-s size-global size-baseline",
    "size-global": "node scripts/build.js vue runtime-dom -f global -p",
    "size-baseline": "node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler && cd packages/size-check && vite build && node brotli",
    "lint": "eslint --ext .ts packages/*/src/**.ts",
    "format": "prettier --write --parser typescript "packages/**/*.ts?(x)"",
    "test": "run-s "test-unit -- {@}" "test-e2e -- {@}" --",
    // 略...
  },
  // ...
}

其中 scripts.dev 是用于开发调试的,scripts.build 用于构建出包。
我们通过 scripts.build 对应的指令,检索到 scripts/build.js 文件来查阅更多构建内容,看看能否找到入口文件的相关线索:

/** ./scripts/build.js **/

const { targets: allTargets } = require('./utils')
const args = require('minimist')(process.argv.slice(2))
const targets = args._

run()

async function run() {
  if (!targets.length) {
    await buildAll(allTargets)
    // ...
  }
}


/** ./scripts/utils.js **/

const targets = (exports.targets = fs.readdirSync('packages').filter(f => {
  if (!fs.statSync(`packages/${f}`).isDirectory()) {
    return false
  }
  // 需要含有 package.json 的子目录才会被匹配到
  const pkg = require(`../packages/${f}/package.json`)
  // 更多匹配条件
  if (pkg.private && !pkg.buildOptions) {
    return false
  }
  return true
}))

可以看到,Vue 会通过 buildAll(allTargets) 方法来执行构建,其中 allTargets 即 ./packages 目录下符合匹配规则的部分子目录(数组形式):

/** allTargets 参数值 **/
[
  "compiler-core",
  "compiler-dom",
  "compiler-sfc",
  "compiler-ssr",
  "reactivity",
  "reactivity-transform",
  "runtime-core",
  "runtime-dom",
  "server-renderer",
  "shared",
  "template-explorer",
  "vue",
  "vue-compat",
]

allTargets 数组中的每个子目录,都是一个独立的模块的源码存放目录,最终它们会经由 Rollup 打包到各自目录下的 dist 子目录中。

p.s. VSCode 拥有很便捷的调试工具,我们可以通过先打断点,再执行 npm run build,来轻松获取 allTargets 的值。具体的调试技巧会在后文(第四节)介绍。

我们接着看 buildAll 方法,它调用了 runParallel 方法来遍历 allTargets 数组,传参给 build 方法去执行:

/** buildAll 方法 **/

async function buildAll(targets) {
  // 利用 CPU 多核能力来并发处理任务,提高构建效率
  await runParallel(require('os').cpus().length, targets, build)
}

async function runParallel(maxConcurrency, source, iteratorFn) {
  const ret = []
  const executing = []
  for (const item of source) {
    // 最终执行的 iteratorFn 即 buildAll 传入的 build 方法
    const p = Promise.resolve().then(() => iteratorFn(item, source))
    ret.push(p)

    // ...
  }
  return Promise.all(ret)
}


/** build 方法 **/

const execa = require('execa')
const formats = args.formats || args.f
const devOnly = args.devOnly || args.d
const prodOnly = !devOnly && (args.prodOnly || args.p)

async function build(target) {
  const pkgDir = path.resolve(`packages/${target}`)
  const pkg = require(`${pkgDir}/package.json`)
  // ...

  const env =
    (pkg.buildOptions && pkg.buildOptions.env) ||
    (devOnly ? 'development' : 'production')
  // 执行指令
  await execa(
    'rollup',
    [
      '-c',             // 执行 Rollup 编译
      '--environment',  // 表示其后面的字符串为传入 Rollup 的环境变量
      [
        `NODE_ENV:${env}`,    // NODE_ENV:production
        `TARGET:${target}`,   // TARGET:compiler-dom 等
        formats ? `FORMATS:${formats}` : ``,  // 为空
        prodOnly ? `PROD_ONLY:true` : ``,     // 为空
        // ...
      ]
        .filter(Boolean)  // 过滤掉为空的参数
        .join(',')        // 拼接为环境变量字符串
    ],
  )
  // ...
}

由 build 方法可以知道,Vue 3 是通过 Rollup 来构建项目的。

它会获取传入目录下的 package.json 文件信息,配合指令参数(注意 npm run build 场景下并没有指定任何指令参数,故 args 为空数组),来编译和构建对应的包。

build 方法中通过 execa 执行了 Rollup 的命令行构建指令,如果你对这些 Rollup 的指令不了解,可以到这里查阅官方文档。

虽然目前我们还未找到项目的入口文件,但不知不觉中,把 Vue 3 的构建流程梳理了一小波,也是个不错的收获。

3.2 从 rollup.config.js 切入寻找

上述 build 方法中的 execa 所执行的指令参考如下:

// 注意 build 所执行的只是并行任务拆分后的单个任务
// 而每条单任务指令的 TARGET 内容都是不同的,这里仅以 compiler-dom 的为例
rollup -c --environment COMMIT:56879e6,NODE_ENV:production,TARGET:compiler-dom

即执行 Rollup 构建任务并传入预设的环境变量。

Rollup 在执行时,默认会读取项目根目录上的 rollup.config.js,来获取更多的构建配置信息(例如构建的输入、输出、模块类型)。
在 rollup.config.js 中搜索 input 字段,是最快的检索到构建入口的方式:

/** ./rollup.config.js **/

const defaultFormats = ['esm-bundler', 'cjs']
const packageConfigs = defaultFormats.map(
  format => createConfig(format, outputConfigs[format])
)

function createConfig(format, output, plugins = []) {
  let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`
  // ...
  return {
    input: resolve(entryFile),  // 入口文件配置项
    output,  // 输出配置项
    // ...
  }
}

export default packageConfigs


/** 相关变量 **/

const packagesDir = path.resolve(__dirname, 'packages')
const packageDir = path.resolve(packagesDir, process.env.TARGET)
const resolve = p => path.resolve(packageDir, p)
const pkg = require(resolve(`package.json`))
const packageOptions = pkg.buildOptions || {}
const name = packageOptions.filename || path.basename(packageDir)

// 输出配置对象
const outputConfigs = {
  'esm-bundler': {
    file: resolve(`dist/${name}.esm-bundler.js`),
    format: `es`
  },
  cjs: {
    file: resolve(`dist/${name}.cjs.js`),
    format: `cjs`
  },
  global: {
    file: resolve(`dist/${name}.global.js`),
    format: `iife`
  },
  // ...
}

综上可得,在执行 npm run build 构建指令时,Rollup 的入口文件为 ./packages/[dirname]/src/index.ts,输出为 ./packages/[dirname]/dist/[dirname].[type].js

那么我们最终可以获悉,Vue 项目下的入口文件,为 ./packages 下各模块目录中的 src/index.ts

Vue 3 的开发采用了 monorepo 模式,会把各种主要功能的模块都独立拆分开来,独立存放在 ./packages 中、独立构建、独立发布。
例如 ./packages/compiler-dom 是一个生成 DOM 模板渲染函数 的模块的目录,它有自己专属的 package.json,会被构建和发布为名为 @vue/runtime-dom 的 npm 包。这意味着你可以独立下载和使用它(即使你并不打算使用 Vue)。
Vue 整体框架的入口文件是 ./packages/vue/src/index.ts,它在源码的开头就引用了其它 ./packages 下的独立模块:

import { compile, CompilerOptions, CompilerError } from '@vue/compiler-dom'
import { registerRuntimeCompiler, RenderFunction, warn } from '@vue/runtime-dom'
import * as runtimeDom from '@vue/runtime-dom'

这种拆解耦合、提高复用率的开发模式是很有意义的。

四. 调试 Vue 源码

4.1 安装 VSCode 和 Volar 插件

VSCode 是一个免费的、可扩展的、多语言支持的 IDE,本文将介绍如何使用它来调试 Vue 3 的源码,从而让你更轻松地获悉 Vue 项目中某模块、某函数、某变量的取值。

有兴趣的同学可以点击链接到官网下载并安装 VSCode,如果你使用其它的主流 IDE,例如 Webstorm、HBuilder 等,它们也会有类似的调试能力,但本文不会做相关介绍。

接着推荐安装 Volar 插件,它可以帮助 VSCode 更好地识别 Vue 3 的语言特性,从而实现针对 Vue 3 文件的高亮、智能拼写和格式化等能力。

点击打开 Volar 插件页,再点击页面上方的 Install 按钮即可,它会唤起你的 VSCode 并安装该插件。

💡 如果之前你已在 VSCode 上开发过 Vue 2,并安装了 Vetur 插件,则还需要禁用 Vetur,因为该插件会和我们新装的 Volar 发生冲突。

4.2 调试 dev 任务脚本

在上文我们提到了,Vue 3 在项目根目录的 package.json 中,配置了开发模式下的预设脚本。
我们只需要执行 npm run dev 指令就能进入开发模式的构建程序,它会调用 scripts/dev.js,最终使用 esbuild 来进行编译打包:

/** ./package.json **/
{
  "private": true,
  "version": "3.2.33",
  "scripts": {
    "dev": "node scripts/dev.js",  // 开发模式脚本
    "build": "node scripts/build.js",
    // ...
  },
  // ...
}


/** ./scripts/dev.js 简化版 **/

const { build } = require('esbuild')
const { resolve, relative } = require('path')
const args = require('minimist')(process.argv.slice(2))
const target = args._[0] || 'vue'
const format = args.f || 'global'
const pkg = require(resolve(__dirname, `../packages/${target}/package.json`))

const outfile = resolve(
  __dirname,
  `../packages/${target}/dist/${target}.${format}.js`
)

build({
  // 入口文件为 ./packages/vue/src/index.ts
  entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
  outfile,
  bundle: true,
  sourcemap: true,
  format: 'iife',
  platform: format === 'cjs' ? 'node' : 'browser',
  // ...
})

esbuild 是使用 GO 语言开发的构建工具,它在构建时会新开一个进程,利用多线程能力并行执行任务。相比 Rollup 而言,esbuild 不使用 AST,且底层跑的是非 JIT 的纯机器码,所以构建效率会高很多(读者可运行 Vue 项目的 build 和 dev 任务做对比,二者的耗时差距非常明显)。
esbuild 也存在一些缺点,例如其它构建工具现有的插件难以扩展到它身上,例如无法将代码编译为 es5。这也是 Vue 3 / Vite 使用 esbuild 作为开发模式下的构建工具,但没有用于生产模式的原因。

在 VSCode 中查看 package.json,会发现 scripts 字段上会显示一个调试按钮,点击它会出来一个全部预设脚本的列表。
点击列表上的 dev 项,VSCode 会自动执行对应的指令:

VSCode 的该能力除了能帮我们省去了手动敲 npm run dev 的麻烦,还支持通过打断点的形式对 Node 侧的代码进行调试。
例如我们先在 ./scripts/dev.js 的 build 方法调用处打一个断点:

再通过上述的方式执行 dev 脚本任务,VSCode 会在断点处为我们停住任务进程,并可以在 VSCode 界面直观地查阅各变量的当前值:

vsc-debug-3.gif

另外,和浏览器的 debugger 调试类似,VSCode 也支持在多个断点中逐步执行:

vsc-debug-4.gif

你可以利用 VSCode 的该能力来调试执行在 Node 侧的其它脚本任务(例如 build 构建任务),从而更快地了解 Vue 项目各个构建任务的流程和逻辑。

4.3 在浏览器上调试 Vue

Vue 在 ./packages/vue/examples 目录下提供了多个示例页面:

+---classic  // 旧式写法示例页面
|       commits.html
|       grid.html
|       markdown.html
|       svg.html
|       todomvc.html
|       tree.html
|       
+---composition  // 组合式 API 示例页面
|       commits.html
|       grid.html
|       markdown.html
|       svg.html
|       todomvc.html
|       tree.html
|       
+---transition  // 动画示例页面
        list.html
        modal.html

每个页面都在开头引入了 Vue 构建后的文件 ./packages/vue/dist/vue.global.js

这里以 ./packages/vue/examples/composition/markdown.html 为例,其内容如下:

<!-- ./packages/vue/examples/composition/markdown.html -->

<script src="../../../../node_modules/marked/marked.min.js"></script>
<script src="../../../../node_modules/lodash/lodash.min.js"></script>
<script src="../../dist/vue.global.js"></script>

<div id="editor">
  <!-- 略 -->
</div>

<script>
const { ref, computed } = Vue

Vue.createApp({
  setup() {
    // ...
  }
}).mount('#editor')
</script>

在执行了上一节提及的 dev 任务后,我们可以直接在浏览器中打开这个 markdown.html 页面,然后修改 ./packages/[module]/src 下的源文件内容,esbuild 会监听到文件改动,快速地重新构建出新的 ./packages/vue/dist/vue.global.js,此时手动刷新页面即可看到变更:

vue-debug-1.gif

该方式常用来快速地调试运行在客户端的代码。因为 esbuild 在构建过程还生成了 sourcemap 文件,我们在浏览器调试时可以直接定位到对应 typescript 源码,非常方便。

我们可以在 VSCode 上安装 Live Server 插件,通过它来打开 markdown.html,这样每次 esbuild 重新构建出新包后,页面都能自动刷新。

💡 Live Server 是通过往页面 <body> 标签内注入脚本来实现自动刷新的能力的,但 Vue 项目上提供的示例页都缺乏 <body> 标签,会导致 Live Server 插件失效。解决办法很简单,给 markdown.html 加上 <body> 即可。