(1)项目结构和源码调试

一. 获取源码

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 项目顶层结构可以简概为:

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

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

  1. ├── compiler-core // 编译核心,抽象语法树和渲染桥接的实现(平台无关)
  2. ├── compiler-dom // 生成模板渲染函数
  3. ├── compiler-sfc // Vue 单文件组件(.vue)的编译实现
  4. ├── compiler-ssr // 服务端渲染编译实现
  5. ├── reactivity // 响应式的实现
  6. ├── ref-transform // Ref 语法糖
  7. ├── runtime-core // 运行时核心模块
  8. ├── runtime-dom // 运行时 DOM 相关 api/属性/事件处理
  9. ├── runtime-test // 运行时测试相关代码
  10. ├── server-renderer // 服务端渲染
  11. ├── sfc-playground // 单文件组件在线调试工具
  12. ├── shared // packages 之间共享的工具库
  13. ├── size-check // 简单应用,用来测试代码体积
  14. ├── template-explorer // 用于调试编译器输出的开发工具
  15. ├── vue // 面向公众的完整版本, 包含运行时和编译器,入口文件、编译后的文件都放这里
  16. └── 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 中并未配置入口字段,但其配置了不少开发/构建的预设脚本:
    1. /** package.json **/
    2. {
    3. // ...
    4. "scripts": {
    5. "dev": "node scripts/dev.js",
    6. "build": "node scripts/build.js",
    7. "size": "run-s size-global size-baseline",
    8. "size-global": "node scripts/build.js vue runtime-dom -f global -p",
    9. "size-baseline": "node scripts/build.js runtime-dom runtime-core reactivity shared -f esm-bundler && cd packages/size-check && vite build && node brotli",
    10. "lint": "eslint --ext .ts packages/*/src/**.ts",
    11. "format": "prettier --write --parser typescript "packages/**/*.ts?(x)"",
    12. "test": "run-s "test-unit -- {@}" "test-e2e -- {@}" --",
    13. // 略...
    14. },
    15. // ...
    16. }
    其中 scripts.dev 是用于开发调试的,scripts.build 用于构建出包。
    我们通过 scripts.build 对应的指令,检索到 scripts/build.js 文件来查阅更多构建内容,看看能否找到入口文件的相关线索: ```javascript / ./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 }))

  1. 可以看到,Vue 会通过 buildAll(allTargets) 方法来执行构建,其中 allTargets ./packages 目录下符合匹配规则的部分子目录(数组形式):
  2. ```javascript
  3. /** allTargets 参数值 **/
  4. [
  5. "compiler-core",
  6. "compiler-dom",
  7. "compiler-sfc",
  8. "compiler-ssr",
  9. "reactivity",
  10. "reactivity-transform",
  11. "runtime-core",
  12. "runtime-dom",
  13. "server-renderer",
  14. "shared",
  15. "template-explorer",
  16. "vue",
  17. "vue-compat",
  18. ]

allTargets 数组中的每个子目录,都是一个独立的模块的源码存放目录,最终它们会经由 Rollup 打包到各自目录下的 dist 子目录中。
p.s. VSCode 拥有很便捷的调试工具,我们可以通过先打断点,再执行 npm run build,来轻松获取 allTargets 的值。具体的调试技巧会在后文(第四节)介绍。
我们接着看 buildAll 方法,它调用了 runParallel 方法来遍历 allTargets 数组,传参给 build 方法去执行:

  1. /** buildAll 方法 **/
  2. async function buildAll(targets) {
  3. // 利用 CPU 多核能力来并发处理任务,提高构建效率
  4. await runParallel(require('os').cpus().length, targets, build)
  5. }
  6. async function runParallel(maxConcurrency, source, iteratorFn) {
  7. const ret = []
  8. const executing = []
  9. for (const item of source) {
  10. // 最终执行的 iteratorFn 即 buildAll 传入的 build 方法
  11. const p = Promise.resolve().then(() => iteratorFn(item, source))
  12. ret.push(p)
  13. // ...
  14. }
  15. return Promise.all(ret)
  16. }
  17. /** build 方法 **/
  18. const execa = require('execa')
  19. const formats = args.formats || args.f
  20. const devOnly = args.devOnly || args.d
  21. const prodOnly = !devOnly && (args.prodOnly || args.p)
  22. async function build(target) {
  23. const pkgDir = path.resolve(`packages/${target}`)
  24. const pkg = require(`${pkgDir}/package.json`)
  25. // ...
  26. const env =
  27. (pkg.buildOptions && pkg.buildOptions.env) ||
  28. (devOnly ? 'development' : 'production')
  29. // 执行指令
  30. await execa(
  31. 'rollup',
  32. [
  33. '-c', // 执行 Rollup 编译
  34. '--environment', // 表示其后面的字符串为传入 Rollup 的环境变量
  35. [
  36. `NODE_ENV:${env}`, // NODE_ENV:production
  37. `TARGET:${target}`, // TARGET:compiler-dom 等
  38. formats ? `FORMATS:${formats}` : ``, // 为空
  39. prodOnly ? `PROD_ONLY:true` : ``, // 为空
  40. // ...
  41. ]
  42. .filter(Boolean) // 过滤掉为空的参数
  43. .join(',') // 拼接为环境变量字符串
  44. ],
  45. )
  46. // ...
  47. }

由 build 方法可以知道,Vue 3 是通过 Rollup 来构建项目的。
它会获取传入目录下的 package.json 文件信息,配合指令参数(注意 npm run build 场景下并没有指定任何指令参数,故 args 为空数组),来编译和构建对应的包。
build 方法中通过 execa 执行了 Rollup 的命令行构建指令,如果你对这些 Rollup 的指令不了解,可以到这里查阅官方文档。
虽然目前我们还未找到项目的入口文件,但不知不觉中,把 Vue 3 的构建流程梳理了一小波,也是个不错的收获。

3.2 从 rollup.config.js 切入寻找

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

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

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

  1. /** ./rollup.config.js **/
  2. const defaultFormats = ['esm-bundler', 'cjs']
  3. const packageConfigs = defaultFormats.map(
  4. format => createConfig(format, outputConfigs[format])
  5. )
  6. function createConfig(format, output, plugins = []) {
  7. let entryFile = /runtime$/.test(format) ? `src/runtime.ts` : `src/index.ts`
  8. // ...
  9. return {
  10. input: resolve(entryFile), // 入口文件配置项
  11. output, // 输出配置项
  12. // ...
  13. }
  14. }
  15. export default packageConfigs
  16. /** 相关变量 **/
  17. const packagesDir = path.resolve(__dirname, 'packages')
  18. const packageDir = path.resolve(packagesDir, process.env.TARGET)
  19. const resolve = p => path.resolve(packageDir, p)
  20. const pkg = require(resolve(`package.json`))
  21. const packageOptions = pkg.buildOptions || {}
  22. const name = packageOptions.filename || path.basename(packageDir)
  23. // 输出配置对象
  24. const outputConfigs = {
  25. 'esm-bundler': {
  26. file: resolve(`dist/${name}.esm-bundler.js`),
  27. format: `es`
  28. },
  29. cjs: {
  30. file: resolve(`dist/${name}.cjs.js`),
  31. format: `cjs`
  32. },
  33. global: {
  34. file: resolve(`dist/${name}.global.js`),
  35. format: `iife`
  36. },
  37. // ...
  38. }

综上可得,在执行 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 下的独立模块:

  1. import { compile, CompilerOptions, CompilerError } from '@vue/compiler-dom'
  2. import { registerRuntimeCompiler, RenderFunction, warn } from '@vue/runtime-dom'
  3. 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 来进行编译打包:

  1. /** ./package.json **/
  2. {
  3. "private": true,
  4. "version": "3.2.33",
  5. "scripts": {
  6. "dev": "node scripts/dev.js", // 开发模式脚本
  7. "build": "node scripts/build.js",
  8. // ...
  9. },
  10. // ...
  11. }
  12. /** ./scripts/dev.js 简化版 **/
  13. const { build } = require('esbuild')
  14. const { resolve, relative } = require('path')
  15. const args = require('minimist')(process.argv.slice(2))
  16. const target = args._[0] || 'vue'
  17. const format = args.f || 'global'
  18. const pkg = require(resolve(__dirname, `../packages/${target}/package.json`))
  19. const outfile = resolve(
  20. __dirname,
  21. `../packages/${target}/dist/${target}.${format}.js`
  22. )
  23. build({
  24. // 入口文件为 ./packages/vue/src/index.ts
  25. entryPoints: [resolve(__dirname, `../packages/${target}/src/index.ts`)],
  26. outfile,
  27. bundle: true,
  28. sourcemap: true,
  29. format: 'iife',
  30. platform: format === 'cjs' ? 'node' : 'browser',
  31. // ...
  32. })

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 界面直观地查阅各变量的当前值
另外,和浏览器的 debugger 调试类似,VSCode 也支持在多个断点中逐步执行

4.3 在浏览器上调试 Vue

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

  1. +---classic // 旧式写法示例页面
  2. | commits.html
  3. | grid.html
  4. | markdown.html
  5. | svg.html
  6. | todomvc.html
  7. | tree.html
  8. |
  9. +---composition // 组合式 API 示例页面
  10. | commits.html
  11. | grid.html
  12. | markdown.html
  13. | svg.html
  14. | todomvc.html
  15. | tree.html
  16. |
  17. +---transition // 动画示例页面
  18. list.html
  19. modal.html

每个页面都在开头引入了 Vue 构建后的文件 ./packages/vue/dist/vue.global.js。
这里以 ./packages/vue/examples/composition/markdown.html 为例,其内容如下:

  1. <!-- ./packages/vue/examples/composition/markdown.html -->
  2. <script src="../../../../node_modules/marked/marked.min.js"></script>
  3. <script src="../../../../node_modules/lodash/lodash.min.js"></script>
  4. <script src="../../dist/vue.global.js"></script>
  5. <div id="editor">
  6. <!-- 略 -->
  7. </div>
  8. <script>
  9. const { ref, computed } = Vue
  10. Vue.createApp({
  11. setup() {
  12. // ...
  13. }
  14. }).mount('#editor')
  15. </script>

在执行了上一节提及的 dev 任务后,我们可以直接在浏览器中打开这个 markdown.html 页面,然后修改 ./packages/[module]/src 下的源文件内容,esbuild 会监听到文件改动,快速地重新构建出新的 ./packages/vue/dist/vue.global.js,此时手动刷新页面即可看到变更:
该方式常用来快速地调试运行在客户端的代码。因为 esbuild 在构建过程还生成了 sourcemap 文件,我们在浏览器调试时可以直接定位到对应 typescript 源码,非常方便。
我们可以在 VSCode 上安装 Live Server 插件,通过它来打开 markdown.html,这样每次 esbuild 重新构建出新包后,页面都能自动刷新。
💡 Live Server 是通过往页面 标签内注入脚本来实现自动刷新的能力的,但 Vue 项目上提供的示例页都缺乏 标签,会导致 Live Server 插件失效。解决办法很简单,给 markdown.html 加上 即可。