Vue3源码解析
准备工作至项目结构为翻译官方贡献者指南内容,若翻译有误,尽情谅解。从入口开始以后内容为笔者阅读源码与有关博客时的心得与理解,因为小编能力有限,不会具体讲解各个指令与vue特性的实现方式。而是主要讲述vue源码的整体流程以及patch算法,若有理解不到位的地方,请联系晓蟲进行理性探讨。
- 准备工作
- 脚本
- 项目结构
- 从入口开始
- 初探编译-解析
- 再探编译-变换
- 编译终点-生成代码字符串
- 初探运行时
- patch算法
准备工作
需要Node.js Version 16+和PNPM,同时建议下载ni,ni
提供的nr
命令可以使npm脚本运行更简单。
$ pnpm i # 下载项目依赖包
使用了以下高阶工具:
- TypeScript作为开发语言
- Rollup用于打包
- Jest用于单元测试
- Prettier用于代码格式化
脚本
以下所有命令都使用ni
包中的nr
命令。当然也可以使用npm run
,但是需要在命令后面添加额外的参数--
,例如nr build runtime --all
等价于npm run build -- runtime --all
。
nr build
bulid
脚本可以构建所有公共包(对应包中的package.json
没有private: true
配置)
可以使用模糊匹配你进行包的构建
# 单独构建runtime-core
nr build runtime-core
# 构建所有能够匹配"runtime"的包
nr build runtime --all
构建格式
默认情况下,每个包将以它的package.json
文件下的buildOptions.formats
指定的格式构建多版本的发行包。这些格式可以通过-f
参数复写,其中支持以下格式:
global
esm-bundler
esm-browser
cjs
以下额外的格式只能应用于vue
主包:
global-runtime
esm-bundler-runtime
esm-browser-runtime
更多关于格式的细节可以阅读这两个文件进行了解vue
的README和rollup的配置文件 //TODO。
例如,使用只使用global格式构建runtime-core
:
nr build runtime-core -f global
可以用逗号分隔的列表指定多种格式:
nr build runtime-core -f esm-browser,cjs
生成源映射的构建
使用--sourcemap
或-s
参数可以带源映射构建。
PS:这会导致构建速度变慢。
带类型声明的构建
使用--types
或-t
参数会在构建时生成类型声明
- 每个包将类型声明集中到一个单独的
.d.ts
文件中。 - 在
中生成API报告。/temp/ .api.md - 在
中生成一个API模型json,这个文件可以用来生成导出api的Markdown版本。/temp/ .api.json
nr dev
dev
脚本在dev模式下以指定的格式(默认:global)捆绑一个目标包(默认:vue),并监视其变化。
$ nr dev
> watching: packages/vue/dist/vue.global.js
-
dev
脚本不支持模糊匹配-你必须指定完整的包名,例如nr dev runtime-core
。 -
dev
脚本支持通过-f
参数指定构建格式,就像build
脚本一样。 -
dev
脚本还支持-s
参数来生成源映射,但它会使重构变慢。 -
dev
脚本支持-i
参数来内联所有deps。这在调试默认将deps外部化的esm-bundler
构建时非常有用。//TODO
nr dev-compiler
dev-compiler
脚本构建、监听和在http://localhost:5000
为Template Explorer文件提供服务,这在调试编译器时非常有用。
nr test
test
脚本只是简单地调用jest
,所以几乎所有的Jest CLI Options都可以被使用。
# 运行所有测试用例
$ nr test
# 运行runtime-core包下的所有测试用例
$ nr test runtime-core
# 运行指定文件的测试用例
$ nr test fileName
# 运行指定文件的指定测试用例
$ nr test fileName -t 'test name'
默认的test
脚本包括--runInBand
jest标志,以提高测试的稳定性,特别是CSS转换相关的测试。在测试特定的测试时,也可以直接运行带有标志的npx jest
来加速测试(jest默认是并行运行的)。
项目结构
vue3项目是用monorepo创建的,它能够在packages
目录里关联多个包,在一个项目里管理多个代码库
-
reactivity
: 响应式API,例如toRef
、reactive
、Effect
、computed
、watch
等,可作为与框架无关的包,独立构建。 -
runtime-core
: 平台无关的运行时核心代码。包括虚拟dom渲染、组件实现和JavaScript API。可以使用这个包针对特定平台构建高价运行时(即定制渲染器)。 -
runtime-dom
: 针对浏览器的运行时。包括对原生DOM API、属性(attributes)、特性(properties)、事件回调的处理。 -
runtime-test
: 用于测试的轻量级运行时。可以在任何JavaScript环境使用,因为它最终只会呈现JavaScript对象形式的渲染树,其可以用来断言正确的渲染输出。另外还提供用于序列化树、触发事件和记录更新期间执行的实际节点操作的实用工具。 -
server-renderer
: 服务端渲染相关。 -
compiler-core
: 平台无关的编译器核心代码。包括编译器可扩展基础以及与所有平台无关的插件。 -
compiler-dom
: 添加了针对浏览器的附加插件的编译器。 -
compiler-sfc
: 用于编译Vue单文件组件的低阶工具。 -
compiler-ssr
: 为服务端提供优化后的渲染函数的编译器。 -
template-explorer
: 用于调试编译器输出的开发者工具。运行nr dev template-explorer
命令后打开它的index.html
文件,获取基于当前源代码的模板的编译结果。也可以使用在线版本live version -
shared
: 多个包共享的内部工具(特别是运行时包和编译器包所使用的与环境无关的工具)。 -
vue
: 用于面向公众的完整构建,其中包含编译器和运行时。
导包
各个包可以直接使用包名导入其他包。
PS:导包时应该使用package.json
下所列的包名,大多数情况下需要使用@vue/
前缀:
import { h } from '@vue/runtime-core'
主要是通过一下几种方式实现导入前缀:
- 针对TypeScript,通过
tsconfig.json
的compilerOptions.paths
。 - 针对Jest,通过
jest.config.js
的moduleNameMapper
。 - 针对普通的Node.js,使用PNPM工作空间进行链接。
包依赖关系
+---------------------+
| |
| @vue/compiler-sfc |
| |
+-----+--------+------+
| |
v v
+---------------------+ +----------------------+
| | | |
+------------>| @vue/compiler-dom +--->| @vue/compiler-core |
| | | | |
+----+----+ +---------------------+ +----------------------+
| |
| vue |
| |
+----+----+ +---------------------+ +----------------------+ +-------------------+
| | | | | | |
+------------>| @vue/runtime-dom +--->| @vue/runtime-core +--->| @vue/reactivity |
| | | | | |
+---------------------+ +----------------------+ +-------------------+
在跨包边界导入时遵循的一些规则:
- 当从另一个包导入时,不要使用直接相对路径。应在源包进行导出并通过包层级进行导入。
- 编译包不应该导入运行时包,反之亦然。如果需要在编译器端和运行时端共享某些内容,应该将其提取到
@vue/shared
中。 - 如果A包有一个非类型导入,或者从另一个B包重新导出一个类型。那么B包应该作为A包
package.json
的依赖项。这是因为当一个包使用ESM/-bundler/CJS格式构建和类型声明文件会被外部化。所以当从包注册中心使用依赖包时,必须将依赖包实际安装为依赖包。
从入口开始
在源码阅读阶段,将以贴出关键性源码(所有忽略代码将以/* <功能> */方式替换),辅以注释与流程图的方式进行讲解。
阅读任何源码都应从代码的入口开始,让我们将目光落入vue/src/index.ts
文件。入口文件相对简单只声明了一个编译缓存compileCache
和compileToFunction
编译器函数,并只运行了来自runtime-dom
的函数registerRuntimeCompiler(compileToFunction)
将compileToFunction
注册为运行时编译器,并最终导出运行时和编译器。在看源码之前,我们先看一张图来理解这个函数体的作用。
// Vue入口文件 packages/vue/src/index.ts
//声明编译缓存key为HTML字符串,value为渲染函数
const compileCache: Record = Object.create(null)
function compileToFunction(
template: string | HTMLElement,
options?: CompilerOptions
): RenderFunction {
//如果模板不是字符串,判断是否为dom的node节点,是的话取其innerHTML作为模板
if (!isString(template)) {
if (template.nodeType) {
template = template.innerHTML
} else {
/* 错误处理 */
}
}
//如果有缓存渲染函数,返回缓存
const key = template
const cached = compileCache[key]
if (cached) {
return cached
}
//如果模板以'#'开头,表明要找对应id元素
if (template[0] === '#') {
const el = document.querySelector(template)
/* 找不到el,报错 */
//不安全,因为在in-DOM模板中可能执行JS表达式。用户必须确保in-DOM模板可信。
//如果模板来自服务器,那么必须保证模板中不包含任何用户数数据
template = el ? el.innerHTML : ``
}
const { code } = compile(
template,
extend(
{
hoistStatic: true, //是否静态提升
/* 报错处理 */
} as CompilerOptions,
options //用户添加的可选项
)
)
/* 报错处理函数 */
//将code作为参数构建匿名函数并调用,返回结果为渲染函数
const render = (
__GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
) as RenderFunction
// 将函数标记为运行时编译
;(render as InternalRenderFunction)._rc = true
//返回渲染函数并缓存
return (compileCache[key] = render)
}
//将编译函数注册到运行时
registerRuntimeCompiler(compileToFunction)
//导出编译器函数与运行时
export { compileToFunction as compile }
export * from '@vue/runtime-dom'
这段代码不难,主要难点是不清楚code变量的值。我们可以打开在项目结构中提到的live version,我们可以看到左边为源码,右边为编译结果,打开console,可以看到抽象语法树AST。
Hello World
//code变量
const code = 'const _Vue = Vue return function render(_ctx, _cache) { with (_ctx) { const { openBlock: _openBlock, createBlock: _createBlock } = _Vue return (_openBlock(), _createBlock("div", null, "Hello World")) } }'
//至于render就是执行了code构建的匿名函数return的结果
初探编译-解析
在上一章中,我们不难发现入口文件的核心是调用@vue/compiler-dom
的compile
,在这一章我们将初探编译,看看compile
是如何解析源码,生成AST。
AST
在剖析源码之前,我们需要先了解什么是AST。Abstract Syntax Tree,即抽象语法树,是对源代码的结构抽象。因此我们对该树进行语义分析,通过变换该抽象结构,而不改变原来的语义,达到优化的目的等等。在前端领域,如果写一个底层框架,AST是不可或缺的技术之一,比如TypeScript、Webpack、babel、ESlint等等。
比如这样一段JS表达式function add(a, b) { return a + b}
,我们可以将其拆分成如下的语法树:
解析后的对象,则如下所示:
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "add",
"loc": {/*关于位置的信息*/}
},
"params":[
{
"type": "Identifier",
"name": "a",
"loc": {/*关于位置的信息*/}
},
{
"type": "Identifier",
"name": "b",
"loc": {/*关于位置的信息*/}
}
],
"body":{
"type": "BlockStatement",
"body": [
{
"type": "ReturnStatement",
"argument":{
"type": "BinaryExpression",
"operator": "+",
"left": {
"type": "Identifier",
"name": "a",
"loc": {/*关于位置的信息*/}
},
"right": {
"type": "Identifier",
"name": "b",
"loc": {/*关于位置的信息*/}
},
"loc": {/*关于位置的信息*/}
},
"loc": {/*关于位置的信息*/}
}
],
"loc": {/*关于位置的信息*/}
},
"generator": false,
"expression": false,
"async": false,
"loc": {/*关于位置的信息*/}
}
如果我们将该AST结构进行改变,就能将原来的普通函数声明,变换为匿名函数赋值。使用recast包进行以下操作:
const recast = require("recast");
const code =`function add(a, b) {return a +b}`
const ast = recast.parse(code);
const add = ast.program.body[0]
const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders
ast.program.body[0] = variableDeclaration("const", [
variableDeclarator(add.id, functionExpression(
null,
add.params,
add.body
))
]);
//将AST对象重新转回可以阅读的代码
const output = recast.prettyPrint(ast, { tabWidth: 2 }).code
console.log(output)
/*
const add = function(a, b) {
return a + b;
};
*/
compile
好的,在了解AST之后,让我们将目光重新聚焦到Vue的compile
函数。compile只是简单地调用了@vue/compiler-core
的baseCompile
,传入符合浏览器的CompilerOptions
,并将结果返回。
export function compile(
template: string,
options: CompilerOptions = {}
): CodegenResult {
return baseCompile(
template,
// parserOptions包含适用于浏览器的辅助函数,options为用户传入的选项
extend({}, parserOptions, options, {
//nodeTransforms列表会对抽象语法树的node节点进行特定变换
nodeTransforms: [
// 忽略