vue源码解析,Vue3源码解析 准备工作至项目结构为翻译官方贡献者指南[https:github.comvuejscoreblobmain.githubcontributi...
详情
Vue3源码解析
准备工作至项目结构为翻译官方内容,若翻译有误,尽情谅解。从入口开始以后内容为笔者阅读源码与有关博客时的心得与理解,因为小编能力有限,不会具体讲解各个指令与vue特性的实现方式。而是主要讲述vue源码的整体流程以及patch算法,若有理解不到位的地方,请联系进行理性探讨。
准备工作
需要Node.js Version 16+和,同时建议下载,ni
提供的nr
命令可以使npm脚本运行更简单。
$ pnpm i # 下载项目依赖包
使用了以下高阶工具:
- 作为开发语言
- 用于打包
- 用于单元测试
- 用于代码格式化
脚本
以下所有命令都使用ni
包中的nr
命令。当然也可以使用npm run
,但是需要在命令后面添加额外的参数--
,例如nr build runtime --all
等价于npm run build -- runtime --all
。
nr build
bulid
脚本可以构建所有公共包(对应包中的package.json
没有private: true
配置)
可以使用模糊匹配你进行包的构建
# 单独构建runtime-corenr 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
更多关于格式的细节可以阅读这两个文件进行了解和 //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
脚本构建、监听和在为文件提供服务,这在调试编译器时非常有用。
nr test
test
脚本只是简单地调用jest
,所以几乎所有的都可以被使用。
# 运行所有测试用例$ 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项目是用创建的,它能够在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
文件,获取基于当前源代码的模板的编译结果。也可以使用在线版本shared
: 多个包共享的内部工具(特别是运行时包和编译器包所使用的与环境无关的工具)。vue
: 用于面向公众的完整构建,其中包含编译器和运行时。
导包
各个包可以直接使用包名导入其他包。
PS:导包时应该使用package.json
下所列的包名,大多数情况下需要使用@vue/
前缀:
import { h } from '@vue/runtime-core'
主要是通过一下几种方式实现导入前缀:
- 针对TypeScript,通过
tsconfig.json
的compilerOptions.paths
。 - 针对Jest,通过
jest.config.js
的moduleNameMapper
。 - 针对普通的Node.js,使用进行链接。
包依赖关系
+---------------------+ | | | @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变量的值。我们可以打开在中提到的,我们可以看到左边为源码,右边为编译结果,打开console,可以看到抽象语法树。
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结构进行改变,就能将原来的普通函数声明,变换为匿名函数赋值。使用包进行以下操作:
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.buildersast.program.body[0] = variableDeclaration("const", [ variableDeclarator(add.id, functionExpression( null, add.params, add.body ))]);//将AST对象重新转回可以阅读的代码const output = recast.prettyPrint(ast, { tabWidth: 2 }).codeconsole.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: [ // 忽略