Vue3源码解析
准备工作至项目结构为翻译官方贡献者指南内容,若翻译有误,尽情谅解。从入口开始以后内容为笔者阅读源码与有关博客时的心得与理解,因为小编能力有限,不会具体讲解各个指令与vue特性的实现方式。而是主要讲述vue源码的整体流程以及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
文件中。 - 在
<projectRoot>/temp/<packageName>.api.md
中生成API报告。 - 在
<projectRoot>/temp/<packageName>.api.json
中生成一个API模型json,这个文件可以用来生成导出api的Markdown版本。
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<string, RenderFunction> = 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。
<!--源码-->
<div>Hello World</div>
//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: [
// 忽略 <script> 和 <tag>标签
//它没有放在DOMNodeTransforms中,因为compiler-ssr使用该列表生成vnode回退分支
ignoreSideEffectTags,
...DOMNodeTransforms,
...(options.nodeTransforms || [])
],
//关于指令的变换函数
directiveTransforms: extend(
{},
DOMDirectiveTransforms,//包括v-html、v-text、v-model、v-on、v-show
options.directiveTransforms || {}
),
transformHoist: __BROWSER__ ? null : stringifyStatic //静态提升
})
)
}
baseCompile
让我们深入baseCompile
,它属于@vue/compiler-core
,说明我们可以根据它定制符合任意平台的编译器。在前文中提到@vue/compiler-dom
的compile
就是符合浏览器平台的定制编译器,所谓的定制,就是传入特定的options可选项。baseCompile
同样简单、清晰明了。
export function baseCompile(
template: string | RootNode,
options: CompilerOptions = {}
): CodegenResult {
/* 错误处理 */
// 前缀标识,用于决定使用module模式还是function模式生成代码
const prefixIdentifiers = !__BROWSER__ && (options.prefixIdentifiers === true || isModuleMode)
/* 错误处理 */
//生成ast抽象语法树
const ast = isString(template) ? baseParse(template, options) : template
//根据前缀标识,获取预设转换函数
const [nodeTransforms, directiveTransforms] = getBaseTransformPreset(prefixIdentifiers)
/* 非浏览器且使用了TS,则添加TS插件 */
//对ast进行变换
transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // 用户的转换函数
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {} // 用户的转换函数
)
})
)
// 根据ast生成vue入口需要的编译代码code
return generate(
ast,
extend({}, options, {
prefixIdentifiers
})
)
}
剖析baseParse
从baseCompile
得知,ast是由baseParse
生成的。那我们继续深入剖析,碍于篇幅所限,以及涉及标签解析、属性解析、指令解析、插槽解析等等,我们只挑选一个例子进行剖析,其他有兴趣的可以自行阅读源代码。baseParse
同样简单,就短短三条逻辑,让我们来看看。
export function baseParse(
content: string,
options: ParserOptions = {}
): RootNode {
/*根据内容与选项生成context上下文
{
options, //解析选项
column: 1, //列
line: 1, //行
offset: 0, //原始源码的偏移量
originalSource: content, //原始源码
source: content, //源码,随着解析的进行,不断替换
inPre: false, // 是否<pre>标签,在<pre>标签内的内容,会保留空格和换行,通常用于源代码展示。
inVPre: false, 是否有v-pre指令,该元素及其子元素不参与编译,用于跳过编译过程,用于纯原生dom提高编译速度。
onWarn: options.onWarn //用户的错误处理函数
}
*/
const context = createParserContext(content, options)
/*根据上下文获取游标,简单理解为编辑进行到的位置。里面就这样两行代码:
const { column, line, offset } = context
return { column, line, offset }
*/
const start = getCursor(context)
//生成ast根节点,节点的数据结构将在之后进行解读
return createRoot(
parseChildren(context, TextModes.DATA, []), //解析子节点
/* 根据上下文和游标开始位置获取解析的源码片段。同样只有以下两条代码
end = end || getCursor(context)
return {
start,
end,
source: context.originalSource.slice(start.offset, end.offset)
}
*/
getSelection(context, start)
)
}
上面的代码中,唯一难理解的是TextModes.DATA
参数。因此在继续往下剖析之前,我们需要先了解TextModes
枚举,及其各个值得意义。以下的源码及注释:
export const enum TextModes {
// | Elements | Entities | End sign | Inside of
DATA, // | ✔ | ✔ | End tags of ancestors |
RCDATA, // | ✘ | ✔ | End tag of the parent | <textarea>
RAWTEXT, // | ✘ | ✘ | End tag of the parent | <style>,<script>
CDATA,
ATTRIBUTE_VALUE
}
TextModes即源码的类型。根据官方的注释,我们不难理解DATA
类型即为元素(包括组件);RCDATA
是在<textarea>
标签中的文本,在该标签中的空格和换行不会被浓缩;RAWTEXT
类型为<style>,<script>
为中的代码,即JS与CSS;CDATA
是普通前端比较少接触的<![CDATA[cdata]]>
代码,这是使用于XML与XHTML中的注释,在该注释中的cdata代码将不会被解析器解析,而会当做普通文本处理;ATTRIBUTE_VALUE
顾名思义,即是各个标签的属性。
parseChildren
从这一小节开始,将正式进入解析阶段,代码量也因此增加了不少。让我们稍作休息,调整精神状态,开启正式的解析之旅。
我们先忽略循环递归解析部分代码,看看parseChildren
整体的代码流程。
function parseChildren(
context: ParserContext,
mode: TextModes,
ancestors: ElementNode[]
): TemplateChildNode[] {
//获取最后一个祖先节点,即父节点,在上一小节中传入空数组,即没有父节点
const parent = last(ancestors)
//父节点的命名空间,父节点不存在就默认取HTML命名空间,即解析时,对应节点会被当做HTML节点处理
const ns = parent ? parent.ns : Namespaces.HTML
//当前父节点的子节点数组
const nodes: TemplateChildNode[] = []
while (!isEnd(context, mode, ancestors)) {
/* 递归解析 */
}
//标记是否移除空格
let removedWhitespace = false
if (mode !== TextModes.RAWTEXT && mode !== TextModes.RCDATA) {
/* 去除空格 */
}
//Arrary.filter(Boolean)能够移除数组中的0、''、null、undefined
return removedWhitespace ? nodes.filter(Boolean) : nodes
}
移除空白符的逻辑相对简单、且不是我们的重点。我们只需了解RAWTEXT
和RCDATA
类型的节点,以及前文提到的<pre>
标签中的文本(包括标签本身)和注释不会被去除空白符。其中RAWTEXT
类型是JS和CSS代码,它们的空白符,是在进行生产构建时,由构建工具处理。
接下来,看看解析部分:
//根据上下文、节点类型和祖先节点判断是否到达结尾
while (!isEnd(context, mode, ancestors)) {
const s = context.source //获取需要解析的源码
let node: TemplateChildNode | TemplateChildNode[] | undefined = undefined //声明子节点
if (mode === TextModes.DATA || mode === TextModes.RCDATA) { //只对元素(组件)和<textarea>标签内文本解析
if (!context.inVPre && startsWith(s, context.options.delimiters[0])) {
// 如果没有使用v-pre指令,且源码以的delimiters[0]选项存在,默认为'{{',则当做插值表达式进行解析
node = parseInterpolation(context, mode)
} else if (mode === TextModes.DATA && s[0] === '<') {
// 如果是dom标签,按照HTML官网规范解析,以下是HTML官方“开始标签”解析算法
// https://html.spec.whatwg.org/multipage/parsing.html#tag-open-state
if (s.length === 1) {
//如果是源码的最后一个字符,报边界错误
emitError(context, ErrorCodes.EOF_BEFORE_TAG_NAME, 1)
} else if (s[1] === '!') {
//'<'后接'!'则,当做注释进行解析,以HTML官方算法解析注释。
//注释类型包括'<!--'、 '<!DOCTYPE'、'<![CDATA['三种
// https://html.spec.whatwg.org/multipage/parsing.html#markup-declaration-open-state
/* HTML官方算法解析注释 */
} else if (s[1] === '/') {
//如果是'</'当做结束标签进行解析,依然使用HTML官方算法
// https://html.spec.whatwg.org/multipage/parsing.html#end-tag-open-state
/* 解析结束标签 */
} else if (/[a-z]/i.test(s[1])) {
//如果是以[a-z]开头的标签,当做元素进行解析(包括组件),我们的重点在这,后面将对parseElement进行讲解
node = parseElement(context, ancestors)
/* 对2.x中<template>的兼容。在3.x中,若没有vue的官方指令,会被当做原生的dom标签 */
} else if (s[1] === '?') {
//如果是'<?'报不支持该类型的标签,且当做注释进行解析
emitError(
context,
ErrorCodes.UNEXPECTED_QUESTION_MARK_INSTEAD_OF_TAG_NAME,
1
)
node = parseBogusComment(context)
} else {
//报非法字符串错误
emitError(context, ErrorCodes.INVALID_FIRST_CHARACTER_OF_TAG_NAME, 1)
}
}
}
//经过一段解析后,还是undefined,当做普通文本解析
if (!node) {
node = parseText(context, mode)
}
//将节点推入节点数组
if (isArray(node)) {
for (let i = 0; i < node.length; i++) {
pushNode(nodes, node[i])
}
} else {
pushNode(nodes, node)
}
}
以上代码大多数是以HTML官方解析HTML的算法进行解析,在此列出官方链接以供参考:
我们需要关注插值的解析与元素的解析部分。在判断是否插值的表达式中,我们可以注意到这样一个变量context.options.delimiters[0]
。不知还有没印象,在上下文context中的options是我们在入口文件传入的自选项options,其中包括用户自定义选项。这说明,使用vue的开发者可以通过传入delimiters
数组达到自定义定界符的目的,该参数的默认值是["{{","}}"]
。
接着,我们继续剖析parseInterpolation
解析函数,我们只讲解这一个解析案例,其他的解析函数都大同小异,若无特殊情况,都不再剖析其他解析函数。
function parseInterpolation(
context: ParserContext,
mode: TextModes
): InterpolationNode | undefined {
//获取开始与结束定界符
const [open, close] = context.options.delimiters
//从开始定界符之后开始寻找结束定界符的索引
const closeIndex = context.source.indexOf(close, open.length)
//如果找不到则报错
if (closeIndex === -1) {
emitError(context, ErrorCodes.X_MISSING_INTERPOLATION_END)
return undefined
}
const start = getCursor(context) //获取开始游标
advanceBy(context, open.length) //解析位置前进open.length长度,修改上下文context的source、offset、line、column
const innerStart = getCursor(context) //插值表达式开始位置游标,初始化,之后修改
const innerEnd = getCursor(context) //插值表达式结束位置游标,初始化,之后修改
const rawContentLength = closeIndex - open.length //计算原生插值表达式长度
const rawContent = context.source.slice(0, rawContentLength) //获取原生插值表达式
//DATA、RCDATA、ATTRIBUTE_VALUE类型且包含'&',由自选项提供的decodeEntities函数进行解码,其他情况返回原文本
const preTrimContent = parseTextData(context, rawContentLength, mode)
const content = preTrimContent.trim() //获得去除前后空白符的表达式,用于之后计算原生表达式的开始与结束索引
const startOffset = preTrimContent.indexOf(content) //获取前面空白符的最后索引作为偏移量
if (startOffset > 0) { //如果偏移量大于零,根据原生插值与偏移量修改innerStart的位置描述
advancePositionWithMutation(innerStart, rawContent, startOffset)
}
//获取原生插值表达式的结束偏移量
const endOffset = rawContentLength - (preTrimContent.length - content.length - startOffset)
advancePositionWithMutation(innerEnd, rawContent, endOffset) //修改innerEnd位置描述
advanceBy(context, close.length) //context位置前进到结束定界符之后,结束解析
//返回AST节点描述对象
return {
type: NodeTypes.INTERPOLATION, //类型为插值表达式
content: {
type: NodeTypes.SIMPLE_EXPRESSION, //内容为简单表达式
isStatic: false, //不可静态提升
constType: ConstantTypes.NOT_CONSTANT,//不是常量
content, //去除了前后空格的表达式文本
loc: getSelection(context, innerStart, innerEnd) //范围位置信息,包括开始位置、结束位置、以及相应源码
},
loc: getSelection(context, start) //从定界符开始到定界符结束位置的范围及源码
}
}
小编,已尽量每一行都添加了详尽注释,应该不存在阅读障碍。通过这一小案列的解析,我们可以总结其他解析函数的工作流程,即抽取源码,改变上下文context的source以及位置信息,最后构建AST节点返回。AST节点除了type与loc属性必不可少之外,其他属性根据特定节点赋值。比如之后的标签解析,需要添加命名空间、标签名称、标签类型、标签属性、子AST节点数组、是否自闭合标签。其中type具有以下类型:
export const enum NodeTypes {
ROOT,
ELEMENT,
TEXT,
COMMENT,
SIMPLE_EXPRESSION,
INTERPOLATION,
ATTRIBUTE,
DIRECTIVE,
// containers
COMPOUND_EXPRESSION,
IF,
IF_BRANCH,
FOR,
TEXT_CALL,
// codegen
VNODE_CALL,
JS_CALL_EXPRESSION,
JS_OBJECT_EXPRESSION,
JS_PROPERTY,
JS_ARRAY_EXPRESSION,
JS_FUNCTION_EXPRESSION,
JS_CONDITIONAL_EXPRESSION,
JS_CACHE_EXPRESSION,
// ssr codegen
JS_BLOCK_STATEMENT,
JS_TEMPLATE_LITERAL,
JS_IF_STATEMENT,
JS_ASSIGNMENT_EXPRESSION,
JS_SEQUENCE_EXPRESSION,
JS_RETURN_STATEMENT
}
回归parseElement
。该解析函数笼统来看分为简单的三步,解析开始标签 -> 递归解析子节点 -> 解析结束标签。并始终在上下文context中维护inPre
、inVPre
字段,以此判断子节点是否处于pre
标签内,或者具有v-pre
指令的标签内。同时兼容2.x的inline-template
属性,但是需要在自选项的compatConfig
配置中显示配置COMPILER_INLINE_TEMPLATE
为true
或者声明MODE
为非3
版本(编译器的行为默认为3
版本),兼容属性才会被解析。由于inline-template
在Vue3不再支持,小编也从未使用过inline-template
,对其不是很了解,顾有兴趣的小伙伴可以到官网自行了解内联模板。由于该函数流程清晰,代码量也不是很多,小编就不在此贴出代码,让我们赶快进入整个解析的重点parseTag
。
parseTag
parseTag
函数可以用来解析开始标签、自闭合标签与结束标签,不过我们只关注其开始标签的部分。
function parseTag(
context: ParserContext,
type: TagType.Start,
parent: ElementNode | undefined
): ElementNode
/* 以及另外两个TypeScript类型声明 */
{
/* 解析开启标签即'<标签名'
获取开始游标start、标签名tag、命名空间ns、context前进标签名长度和跳过空白符*/
// 保存当前的游标位置(位于第一个属性之前)与该位置之后的源码。用于之后重新解析具有v-pre指令标签的属性
const cursor = getCursor(context)
const currentSource = context.source
// 检测是否为<pre>标签
if (context.options.isPreTag(tag)) {
context.inPre = true
}
// 解析属性、特性、指令
let props = parseAttributes(context, type)
/* 检查是否有v-pre指令,如果有context.inVPre = true。
并使用上面获取的cursor和currentSource重置上下文,重新解析属性并过滤v-pre指令 */
/* 解析标签关闭(非结束标签),如'>'、'/>' */
/* 兼容v2.x,发出警告: 在Vue3中,如果v-if和v-for位于同一标签,v-if具有更高的优先级,且不再访问v-for的变量 */
/* 如果没有v-pre指令,检测元素类型是普通的dom元素、solt、template(具有Vue官方指令的才是Vue的template标签,
否则为元素dom标签)还是组件,并赋值tagType */
return {
type: NodeTypes.ELEMENT,
ns,
tag,
tagType,
props,
isSelfClosing,
children: [],
loc: getSelection(context, start),
codegenNode: undefined // 将在变换阶段生成
}
}
isComponent
来看看vue是如何辨别组件与dom元素的
function isComponent(
tag: string,
props: (AttributeNode | DirectiveNode)[],
context: ParserContext
) {
const options = context.options
if (options.isCustomElement(tag)) { //isCustomElement默认返回false,除非用户配置改方法
return false
}
if (
tag === 'component' || //标签名是否component
/^[A-Z]/.test(tag) || //标签是否大写字母开头
isCoreComponent(tag) || //是否核心组件Teleport、Suspense、KeepAlive、BaseTransition
(options.isBuiltInComponent && options.isBuiltInComponent(tag)) || //特定平台内置组件,比如浏览器平台的Transition
(options.isNativeTag && !options.isNativeTag(tag)) //非原生标签
) {
return true
}
/* 代码走到这,代表为原生元素,但是还需要检查是否有'is'属性、v-is和:is指令
兼容vue2,vue3中原生dom添加is属性不再被认为是组件,除非添加前缀'vue:'
v-is指令依然正常、:is指令同样只能使用在兼容模式下 */
}
parseAttribute
解析阶段一切的核心,解析开始标签的属性。包括一切属性、特性、指令、组件props。在parseAttribute上一层有parseAttributes函数、其主要作用是循环调用parseAttribute,并对解析出来的class空白符浓缩为一个空格,去除前后空白符。
function parseAttribute(
context: ParserContext,
nameSet: Set<string>
): AttributeNode | DirectiveNode {
/* 获取属性名,并对不合语法的属性名进行报错,包含="'< 字符的属性名不合法 */
/* 获取属性值value,允许=前后包含多个空白符 */
//不在v-pre指令内,以V-、:、.、@、#开头的被认为vue指令、props与事件
if (!context.inVPre && /^(v-[A-Za-z0-9-]|:|\.|@|#)/.test(name)) {
const match =
/(?:^v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i.exec(
name
)! //分段匹配属性
let isPropShorthand = startsWith(name, '.')
let dirName = //取V-后的指令名,但当使用简写时match[1]不存在、则根据简写,判别bind、on、slot指令
match[1] ||
(isPropShorthand || startsWith(name, ':')
? 'bind'
: startsWith(name, '@')
? 'on'
: 'slot')
let arg: ExpressionNode | undefined
if (match[2]) { //指令参数,比如@click中的click、:props中的pros、v-slot:footer中的footer
const isSlot = dirName === 'slot' //是否slot,slot需要特殊处理
const startOffset = name.lastIndexOf(match[2])
const loc = getSelection(
context,
getNewPosition(context, start, startOffset),
getNewPosition(
context,
start,
startOffset + match[2].length + ((isSlot && match[3]) || '').length
)
)
let content = match[2]
let isStatic = true
if (content.startsWith('[')) {
isStatic = false //如果参数是动态的,即@[myEventHandler],则不可进行静态提升
if (!content.endsWith(']')) {
emitError(
context,
ErrorCodes.X_MISSING_DYNAMIC_DIRECTIVE_ARGUMENT_END
)
content = content.slice(1)
} else {
content = content.slice(1, content.length - 1) //
}
} else if (isSlot) {
// 由于在v-slot没有修饰符、且vuetify广泛使用包含.的插槽名,所以如果是插槽指令点dots,不被认为是修饰符,而是插槽名的一部分
content += match[3] || ''
}
arg = {
type: NodeTypes.SIMPLE_EXPRESSION, //因为是指令,所以类型为简单表达式
content, //指令参数
isStatic, //是否可静态提升
constType: isStatic //是否常量
? ConstantTypes.CAN_STRINGIFY
: ConstantTypes.NOT_CONSTANT,
loc
}
}
/* 修改属性值的位置信息 */
const modifiers = match[3] ? match[3].slice(1).split('.') : [] //获取修饰符数组
if (isPropShorthand) modifiers.push('prop') //如果v-bind指令的缩写不是:,而是点dot. ,则把添加修饰符prop
// 兼容 vue3中不再支持v-bind:foo.sync,而是使用v-model:foo方式进行替代
if (__COMPAT__ && dirName === 'bind' && arg) {
if (
modifiers.includes('sync') &&
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_BIND_SYNC,
context,
loc,
arg.loc.source
)
) {
dirName = 'model'
modifiers.splice(modifiers.indexOf('sync'), 1)
}
//vue3中不再兼容vue2中v-bind的.prop修饰符,而是在适当时机将v-bind属性设置为dom的prop
if (__DEV__ && modifiers.includes('prop')) {
checkCompatEnabled(
CompilerDeprecationTypes.COMPILER_V_BIND_PROP,
context,
loc
)
}
}
return {
type: NodeTypes.DIRECTIVE, // 属性类型为指令
name: dirName, //指令名
exp: value && {
type: NodeTypes.SIMPLE_EXPRESSION, //指令表达式
content: value.content,
isStatic: false, //不可静态提升
constType: ConstantTypes.NOT_CONSTANT,
loc: value.loc
},
arg,
modifiers, //修饰符
loc
}
}
// 如果没有指令名或非法指令名,报错
if (!context.inVPre && startsWith(name, 'v-')) {
emitError(context, ErrorCodes.X_MISSING_DIRECTIVE_NAME)
}
return {
type: NodeTypes.ATTRIBUTE, //不是指令,则是普通的dom属性
name,
value: value && {
type: NodeTypes.TEXT,
content: value.content,
loc: value.loc
},
loc
}
}
至此,编译篇的解析部分,已全部讲解完毕。相信大家还没有忘记,我们进行这么复杂的解析的目的是为了获得一棵关于源码的AST树。接下来是的阶段是,我们曾在讲解AST时,提及过的变换,变换AST的结构,使它在不失去原本的语义的情况下,对源码进行优化。
再探编译-变换
由于是对AST的变换,所以不会有返回值,所以在baseCompile
的transform
函数,只会传入ast抽象语法树和相应的变换选项,顾不再过多解释。另由于变换上下文,不像解析上下文一样简单,其包含非常多状态、选项以及辅助函数,所以不采取一次性讲解,而是在使用都相关成员时进行讲解。
transform(
ast,
extend({}, options, {
prefixIdentifiers,
nodeTransforms: [
...nodeTransforms,
...(options.nodeTransforms || []) // 用户的变换
],
directiveTransforms: extend(
{},
directiveTransforms,
options.directiveTransforms || {} // 用户的变换
)
})
)
export function transform(root: RootNode, options: TransformOptions) {
//获取上下文
const context = createTransformContext(root, options)
//变换AST
traverseNode(root, context)
//静态提升,vue3新特性
if (options.hoistStatic) {
hoistStatic(root, context)
}
//非服务端渲染,创建codegen
if (!options.ssr) {
createRootCodegen(root, context)
}
// 变换后的AST完成元数据赋值
root.helpers = [...context.helpers.keys()]
root.components = [...context.components]
root.directives = [...context.directives]
root.imports = context.imports
root.hoists = context.hoists
root.temps = context.temps
root.cached = context.cached
if (__COMPAT__) {
root.filters = [...context.filters!]
}
}
正如前文所说,我们直接跳过上下文的讲解,直接剖析traverseNode
函数
export function traverseNode(
node: RootNode | TemplateChildNode,
context: TransformContext
) {
context.currentNode = node //正在变换的ast节点
const { nodeTransforms } = context
const exitFns = [] //用来存储变换函数的退出函数
//应用所有节点变换插件
for (let i = 0; i < nodeTransforms.length; i++) {
const onExit = nodeTransforms[i](node, context)
if (onExit) {
if (isArray(onExit)) {
exitFns.push(...onExit)
} else {
exitFns.push(onExit)
}
}
if (!context.currentNode) {
// 变换函数可能移除原有的ast节点,则直接返回
return
} else {
// 经过变换后,ast节点可能被替换
node = context.currentNode
}
}
switch (node.type) {
case NodeTypes.COMMENT:
if (!context.ssr) {
//注入Comment symbol,用户生成代码阶段,生成需要的导入代码
context.helper(CREATE_COMMENT)
}
break
case NodeTypes.INTERPOLATION:
// {{express}} 插值表达式不需要变化,但是需要注入toString helper
if (!context.ssr) {
context.helper(TO_DISPLAY_STRING)
}
break
// 对于容器类型的,需要进一步向下遍历
case NodeTypes.IF:
//对v-if的所有分支进行变换
for (let i = 0; i < node.branches.length; i++) {
traverseNode(node.branches[i], context)
}
break
case NodeTypes.IF_BRANCH:
case NodeTypes.FOR:
case NodeTypes.ELEMENT:
case NodeTypes.ROOT:
traverseChildren(node, context)
break
}
// 退出变换函数
context.currentNode = node
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
vue先进行一次遍历变换,更改具有v-if
、v-else
、v-else-if
、v-for
指令节点及其子节点的结构。之后再重新遍历变换v-if
的所有分支节点,以及递归变换其他类型节点。最后以出栈的方式逐一退出变换的函数。由于变换函数众多且相当复杂,虽然用户也可以传入自己的变换函数,但99.99%的情况下并没有这种需求,我们只需了解到变换会更改解析出来的ast就行了,因此我们只取其中较为简单的v-once
变换函数进行剖析,v-once
可以使相关的表达式只渲染一次,而不会双向绑定。
export const transformOnce: NodeTransform = (node, context) => {
if (node.type === NodeTypes.ELEMENT && findDir(node, 'once', true)) { //元素节点上是否存在"v-once"指令
if (seen.has(node) || context.inVOnce) { //本节点是否已执行或者处于`v-once`的子元素中
return
}
seen.add(node) //缓存v-once节点
context.inVOnce = true //上下文修改为是在`v-once`节点当中
context.helper(SET_BLOCK_TRACKING) //添加辅助类型
return () => { //前文提到的`exitFn`,退出变换函数,用于修改上下文环境,更改codegenNode
context.inVOnce = false
const cur = context.currentNode as ElementNode | IfNode | ForNode
if (cur.codegenNode) {
cur.codegenNode = context.cache(cur.codegenNode, true /* isVNode */)
}
}
}
}
其中codegenNode
是用于后文baseCompile
的最后一步generate
,生成代码字符串用的节点描述对象。
静态提升
我们还是将更多的精力投入到vue3的新特性——静态提升上,即transform
中的hoistStatic
,其会递归ast并发现一些不会变的节点与属性,给他们打上可以静态提升的标记。在生成代码字符串阶段,将其序列化成字符串、以减少编译和渲染成本。在live version中,我们可以尝试在开启与关闭静态提升选项后,以下代码的编译结果的区别:
<div>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
<span>Hello World</span>
</div>
hoistStatic
只调用了一个walk
函数,这个函数是对其子节点的遍历检查,而非本节点。
export function hoistStatic(root: RootNode, context: TransformContext) {
walk(
root, //ast
context, //变换上下文
// 很不幸,根节点不可静态提升
isSingleElementRoot(root, root.children[0]) //是否为单子元素且子元素非插槽
)
}
walk
函数很长,在阅读walk
之前,我们需要先铺垫一些规则。
/**
* 静态类型有几种级别。
* 高级别兼容低级别.例如 如果一个几点可以被序列化成字符串,
* 那也一定可以被静态提升和跳过补丁。
*/
export const enum ConstantTypes {
NOT_CONSTANT = 0,
CAN_SKIP_PATCH,
CAN_HOIST,
CAN_STRINGIFY
}
因为只有静态的内容才可以被静态提升,所以只有原生DOM元素和纯文本可以被静态提升。
function walk(
node: ParentNode,
context: TransformContext,
doNotHoistNode: boolean = false //由外部提供是否可静态提升
) {
const { children } = node
const originalCount = children.length //用于记录该子元素的数量
let hoistedCount = 0 //被静态提升的子元素数量
//遍历整个直接子元素
for (let i = 0; i < children.length; i++) {
const child = children[i]
// 只有纯元素与纯文本可以被静态提升
if ( //对元素进行提升
child.type === NodeTypes.ELEMENT && //是否为元素
child.tagType === ElementTypes.ELEMENT //是否为原生元素
) {
const constantType = doNotHoistNode
? ConstantTypes.NOT_CONSTANT
: getConstantType(child, context) //根据上下判断该节点的常量类型
if (constantType > ConstantTypes.NOT_CONSTANT) {
if (constantType >= ConstantTypes.CAN_HOIST) {
;(child.codegenNode as VNodeCall).patchFlag =
PatchFlags.HOISTED + (__DEV__ ? ` /* HOISTED */` : ``) //打上变量提升标记
child.codegenNode = context.hoist(child.codegenNode!) //修改成简单表达式,并在上下文中保存该表达式
hoistedCount++ // 被静态提升的子元素+1
continue
}
} else {
/* 虽然整个元素不可以被静态提升,但他的prop可能可以被静态提升。
找出纯文本属性或者经过判断可静态提升的属性进行提升 */
}
} else if ( //对文本进行提升
child.type === NodeTypes.TEXT_CALL &&
getConstantType(child.content, context) >= ConstantTypes.CAN_HOIST
) {
child.codegenNode = context.hoist(child.codegenNode)
hoistedCount++
}
// 递归子元素
if (child.type === NodeTypes.ELEMENT) {
const isComponent = child.tagType === ElementTypes.COMPONENT
if (isComponent) { //如果是一个组价,增加插槽作用域层数
context.scopes.vSlot++
}
walk(child, context)
if (isComponent) {
context.scopes.vSlot--
}
} else if (child.type === NodeTypes.FOR) {
// 不能挂载只有一个元素的v-for节点,因为其必须是一个block
walk(child, context, child.children.length === 1)
} else if (child.type === NodeTypes.IF) {
for (let i = 0; i < child.branches.length; i++) {
// 同样不能提升只有一个子元素的v-if分支,其必须是一个block
walk(
child.branches[i],
context,
child.branches[i].children.length === 1
)
}
}
}
//对静态节点进行变换,变换为字符串
if (hoistedCount && context.transformHoist) {
context.transformHoist(children, context, node)
}
// 如果静态提升的子元素个数等于原本子元素个数,则直接提升整个children数组
if (
hoistedCount &&
hoistedCount === originalCount &&
node.type === NodeTypes.ELEMENT &&
node.tagType === ElementTypes.ELEMENT &&
node.codegenNode &&
node.codegenNode.type === NodeTypes.VNODE_CALL &&
isArray(node.codegenNode.children)
) {
node.codegenNode.children = context.hoist(
createArrayExpression(node.codegenNode.children)
)
}
}
至于getConstantType
,主要是通过节点类型来判断是否可被提升,除了元素、文本、表达式以外其他都不是静态类型,而这三种还要似具体情况辨别其静态的类型。比如元素类型,需要检查其属性,子节点以及bind指令表达式是否静态,元素类型需要将其静态类型降到最低的属性、子节点、表达式的静态类型。
创建根生成描述对象
变换还剩下最后的createRootCodegen
。vue3先支持多个根节点,这个函数的作用是,判断根节点的数量是否大于1,若大于1,则在其外层再包一层节点,就如此简单。
编译终点-生成代码字符串
代码生成阶段、会根据解析以及变换添加相应标记后的ast以及使用vue的环境,生成对应的用户生成虚拟节点的代码字符串。
generate
export function generate(
ast: RootNode,
options: CodegenOptions & {
onContextCreated?: (context: CodegenContext) => void
} = {}
): CodegenResult {
const context = createCodegenContext(ast, options) //获取代码生成器上下文
if (options.onContextCreated) options.onContextCreated(context) //生命周期回调,如果
/* 解构获取取上下文用于生成代码的函数 */
const hasHelpers = ast.helpers.length > 0 //是否有在转换阶段存入helper
/* 根据环境声明useWithBlock、genScopeId、isSetupInlined以决定生成代码的格式 */
// 在setup()内联模式中,前文在子上下文中生成并分别返回。
const preambleContext = isSetupInlined
? createCodegenContext(ast, options)
: context
if (!__BROWSER__ && mode === 'module') { //nodejs环境,将preambleContext修改为module模式上下文
genModulePreamble(ast, preambleContext, genScopeId, isSetupInlined)
} else { //浏览器环境,修改成function模式的上下文
genFunctionPreamble(ast, preambleContext)
}
// 决定渲染函数名及参数
const functionName = ssr ? `ssrRender` : `render`
const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache']
if (!__BROWSER__ && options.bindingMetadata && !options.inline) {
// 非浏览器、非内联模式,绑定优化参数
args.push('$props', '$setup', '$data', '$options')
}
const signature = //根据是否使用ts,决定使用何种签名
!__BROWSER__ && options.isTS
? args.map(arg => `${arg}: any`).join(',')
: args.join(', ')
if (isSetupInlined) { //根据是否内联模式,使用function还是箭头函数
push(`(${signature}) => {`)
} else {
push(`function ${functionName}(${signature}) {`)
}
indent() //换行并添加缩进
if (useWithBlock) {
push(`with (_ctx) {`)
indent()
// function模式的const声明应该在with块中,它们也应该被重命名,以避免与用户属性冲突
if (hasHelpers) {
//使用hepler引入需要用到的函数,并重命名
push(
`const { ${ast.helpers
.map(s => `${helperNameMap[s]}: _${helperNameMap[s]}`)
.join(', ')} } = _Vue`
)
/* 换行 */
}
}
/* 生成资源(ast中声明的所有组件、指令、filters、临时变量)导入语句 */
/* 非ssr,添加return */
if (ast.codegenNode) {
// 生成虚拟节点树表达式
genNode(ast.codegenNode, context)
}
/* 一些完善语法的代码:缩进、添加'}'' */
return {
ast,
code: context.code,
preamble: isSetupInlined ? preambleContext.code : ``, //是内联模式则使用,前文上下文的前文
map: context.map ? (context.map as any).toJSON() : undefined // 源码映射
}
}
generate
虽略长,但不复杂。主要是根据不同的环境,nodejs、浏览器、ssr生成对应的代码格式。genNode更是简单,switch判别不同的ast节点类型,根据不同类型插入相应的运行时用于创建虚拟节点的函数的代码字符串。
// 映射的运行时函数,包括创建虚拟节点,组件、指令与过滤函数的解析等等
export const helperNameMap: any = {
[FRAGMENT]: `Fragment`,
[TELEPORT]: `Teleport`,
[SUSPENSE]: `Suspense`,
[KEEP_ALIVE]: `KeepAlive`,
[BASE_TRANSITION]: `BaseTransition`,
[OPEN_BLOCK]: `openBlock`,
[CREATE_BLOCK]: `createBlock`,
[CREATE_ELEMENT_BLOCK]: `createElementBlock`,
[CREATE_VNODE]: `createVNode`,
[CREATE_ELEMENT_VNODE]: `createElementVNode`,
[CREATE_COMMENT]: `createCommentVNode`,
[CREATE_TEXT]: `createTextVNode`,
[CREATE_STATIC]: `createStaticVNode`,
[RESOLVE_COMPONENT]: `resolveComponent`,
[RESOLVE_DYNAMIC_COMPONENT]: `resolveDynamicComponent`,
[RESOLVE_DIRECTIVE]: `resolveDirective`,
[RESOLVE_FILTER]: `resolveFilter`,
[WITH_DIRECTIVES]: `withDirectives`,
[RENDER_LIST]: `renderList`,
[RENDER_SLOT]: `renderSlot`,
[CREATE_SLOTS]: `createSlots`,
[TO_DISPLAY_STRING]: `toDisplayString`,
[MERGE_PROPS]: `mergeProps`,
[NORMALIZE_CLASS]: `normalizeClass`,
[NORMALIZE_STYLE]: `normalizeStyle`,
[NORMALIZE_PROPS]: `normalizeProps`,
[GUARD_REACTIVE_PROPS]: `guardReactiveProps`,
[TO_HANDLERS]: `toHandlers`,
[CAMELIZE]: `camelize`,
[CAPITALIZE]: `capitalize`,
[TO_HANDLER_KEY]: `toHandlerKey`,
[SET_BLOCK_TRACKING]: `setBlockTracking`,
[PUSH_SCOPE_ID]: `pushScopeId`,
[POP_SCOPE_ID]: `popScopeId`,
[WITH_CTX]: `withCtx`,
[UNREF]: `unref`,
[IS_REF]: `isRef`,
[WITH_MEMO]: `withMemo`,
[IS_MEMO_SAME]: `isMemoSame`
}
至此,有关于编译的部分已大概剖析。碍于能力有限,只讲了大概的编译流程。其中的重点,比如v-if、v-for、slot等节点的变换,较为晦涩难懂,如若读者有兴趣可自行阅读。
初探运行时
我们在从入口开始中提到过,vue的入口文件导出compile
编译函数和整个runtime-dom
运行时包。我们在平时开发,使用的便是运行时包中的函数。让我们从vue应用的构建函数createApp
开始。
export const createApp = ((...args) => {
//获取匿名单例的createApp函数,其中匿名单例可以返回render、hydrate、createApp三种渲染函数
const app = ensureRenderer().createApp(...args)
/* 注入原生标签以及CompilerOptions编译选项检查,其中CompilerOptions在webpack、vite或vue-cli中进行配置 */
//对原有的mount方法进行包装
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
/* 将containerOrSelector参数转换为dom节点对象,string类型使用document.querySelector查找,其他原样返回 */
/* 检查传进来的参数是否是函数式组件,或是否有render或template。
若都没有则使用container.innerHTML作为模板。但需要注意,可能执行里面的js代码,所以ssr时,
模板中最好不要包含任何用户数据。警告:在vue3中,模板容器不再被视为模板的一部分,其上的指令不会被执行*/
// 在挂载之前清空内容
container.innerHTML = ''
//挂载并获得代理对象
const proxy = mount(container, false, container instanceof SVGElement)
if (container instanceof Element) {
container.removeAttribute('v-cloak') //容器清除v-cloak指令
container.setAttribute('data-v-app', '') //容器增加data-v-app属性
}
return proxy
}
return app
}) as CreateAppFunction<Element>
ensureRenderer
函数返回包含render
、createApp
和hydrate
三个函数的单例对象,其中hydrate
水合函数与ssr有关,createApp
需要使用到render
、createApp
。所以在解析render
之前,我们先简单看下createAppAPI
。
export function createAppAPI<HostElement>(
render: RootRenderFunction,
hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
/* rootProps必须是Object */
//创建app上下文,该上下文将存在于整个生命周期
const context = createAppContext()
const installedPlugins = new Set() //已安装的插件
//是否已挂载
let isMounted = false
const app: App = (context.app = {
_uid: uid++, //一个页面可能存在多个vue实例,需使用id标识
_component: rootComponent as ConcreteComponent, //根组件
_props: rootProps, //传递给根组件的props
_container: null, //dom容器
_context: context, //app上下文
_instance: null, //虚拟节点实例
version, //vue的版本
//app.config不允许整个对象替换,必须每个选项单独修改
get config() {
return context.config
},
set config(v) {
if (__DEV__) {
warn(
`app.config cannot be replaced. Modify individual options instead.`
)
}
},
//以下函数都是在app上下文中进行数组或者map存储,顾不展示具体源码
use(plugin: Plugin, ...options: any[]) {
/* 安装插件 */
},
mixin(mixin: ComponentOptions) {
/* 全局混入组件代码,将影响到每一个组件 */
},
component(name: string, component?: Component): any {
/* 全局注册组件 */
},
directive(name: string, directive?: Directive) {
/* 全局注册指令 */
},
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
//未挂载执行
if (!isMounted) {
//创建根虚拟节点
const vnode = createVNode(
rootComponent as ConcreteComponent,
rootProps
)
//将app的上下文存储在根虚拟节点
vnode.appContext = context
// 热更新根节点
if (__DEV__) {
context.reload = () => {
render(cloneVNode(vnode), rootContainer, isSVG)
}
}
//水合或者渲染虚拟节点
if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, isSVG)
}
isMounted = true
app._container = rootContainer //设置app容器
/* 为开发者工具或其他探测工具设置 */
//挂载完毕,返回根组件实例的代理对象
return getExposeProxy(vnode.component!) || vnode.component!.proxy
} else if (__DEV__) {
/* 警告,该vue app已经挂载过 */
}
},
unmount() {
/* 卸载,对容器元素render一个null对象, 删除开发者工具辅助 */
},
provide(key, value) {
/* 全局注入属性 */
}
})
/* 若开启兼容,安装vue2的API */
return app
}
}
createApp
的重点的是mount
挂载函数。我们可以看到,在挂载时期主要做了三件事:
- 基于
createApp
的参数创建虚拟节点。 - 基于虚拟节点和容器元素进行进行渲染。
- 最后返回虚拟节点
component
属性的代理对象,主要使根实例可以取得所有refs等,顾不具体讲解。
创建虚拟节点
在创建虚拟节点时,会进行一些类型检查、正规化、克隆、块树节点追踪、兼容Vue2等操作。这些不是我们的重点,略过这些辅助操作后,我们会发现最后只是单纯地返回了一个虚拟节点对象。
const vnode = {
__v_isVNode: true,
__v_skip: true,
type, //传入的组件对象
props, //传递给组件对象的参数
key: props && normalizeKey(props), //取出所有传入的key
ref: props && normalizeRef(props), //对props进行ref正规化
scopeId: currentScopeId, //现在的作用域id
slotScopeIds: null,
children, //子节点
component: null,
suspense: null,
ssContent: null,
ssFallback: null,
dirs: null,
transition: null,
el: null,
anchor: null,
target: null,
targetAnchor: null,
staticCount: 0,
shapeFlag, // 虚拟节点类型标记
patchFlag, // patch算法标记
dynamicProps, //动态Props
dynamicChildren: null,
appContext: null
} as VNode
执行render函数
render
函数可以说是vue重点中的重点,因为vue
的patch
算法便是在这里执行,通过patch
比较新旧虚拟节点的不同,有针对性的更新相关dom节点。
const render: RootRenderFunction = (vnode, container, isSVG) => {
if (vnode == null) {
if (container._vnode) { //没有传入新的虚拟节点,当存在旧虚拟节点,则卸载旧虚拟节点
unmount(container._vnode, null, null, true)
}
} else {//存在新虚拟节点,则执行patch算法,比较新旧虚拟节点
patch(container._vnode || null, vnode, container, null, null, null, isSVG)
}
//卸载或者patch都会向任务调度器push任务,flushPostFlushCbs冲刷任务调度器。
flushPostFlushCbs()
container._vnode = vnode //容器指向新的虚拟的节点
}
不展开讲unmount
,其主要工作为清除ref,卸载组件、子节点、调用节点和指令的生命周期回调以及将副作用函数推入任务队列(节点内为调用beforeUnmount
回调,任务为在卸载完所有子节点后,执行flushPostFlushCbs
冲刷任务队列,执行unmounted
回调)。
我们的重点是弄清Vue的关键算法patch
。
patch算法
主要参考这篇文章的解析
patch 的过程中主要完成以下几件事情:
- 创建需要新增的节点
- 移除已经废弃的节点
- 移动或修改需要更新的节点
const patch: PatchFn = (
n1, //旧节点
n2, //新节点
container, //容器
anchor = null, //锚点,算法过程的参照节点
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren //优化模式标识
) => {
if (n1 === n2) { //新旧节点是同一个对象,直接返回
return
}
// 不是相同类型的节点,直接卸载旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
//被打过BAIL类型标记的节点退出优化模式。
//比如非编译器生成,而是手动编写的渲染函数,认为总是新的,无法进行优化
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}
const { type, ref, shapeFlag } = n2
switch (type) { //根据vNode类型,执行不同的算法
case Text: //文本类型
processText(n1, n2, container, anchor)
break
case Comment: //注释类型
processCommentNode(n1, n2, container, anchor)
break
case Static: //静态节点类型
if (n1 == null) {
mountStaticNode(n2, container, anchor, isSVG)
} else if (__DEV__) {
patchStaticNode(n1, n2, container, isSVG)
}
break
case Fragment: //Fragment类型
processFragment(/* 忽略参数 */)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) { // 元素类型
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.COMPONENT) { // 组件类型
processComponent(/* 忽略参数 */)
} else if (shapeFlag & ShapeFlags.TELEPORT) { // TELEPORT 类型
;(type as typeof TeleportImpl).process(/* 忽略参数 */)
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) { //SUSPENSE类型
;(type as typeof SuspenseImpl).process(/* 忽略参数 */)
} else if (__DEV__) { //警告
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
// 设置ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}
n1
为旧节点、n2
为新节点
- 当新旧节点为同一个节点时,直接退出patch。
- 当新旧节点不是同一个类型时直接卸载旧节点,
isSameVNodeType
的代码很简单,就只是n1.type === n2.type && n1.key === n2.key
,即除了类型以外,还要判断key是否相同。 - 当新节点被打上
BAIL
标记,则退出优化模式。 - 根据节点的不同类型,执行不同的处理算法。
由于节点类型众多,所以我们只从较为重点的COMPONENT
和ELEMENT
类型入手,有兴趣的读者可自行去看其他类型的patch过程。又由于COMPONENT
是由ELEMENT
组成的,根节点是COMPONENT
,我们先从processComponent
开始。
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
n2.slotScopeIds = slotScopeIds //新节点获取作用域栈
if (n1 == null) { //旧节点不存在
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { //如果是keep-alive节点,则唤醒
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
isSVG,
optimized
)
} else { //旧节点不存在,且未keep-alive,挂载组件
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
} else { //存在旧节点,比较新旧虚拟节点更新组件实例
updateComponent(n1, n2, optimized)
}
}
processComponent
较为简单,考虑三种情况进行处理。分别是组件激活,全新组件挂载、变更组件更新。由于activate
函数由keep-alive
组件定义,非patch
算法的关键,在此不对组件缓存激活进行剖析。因此我们从组件首次挂载的情况开始。
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
) => {
// 兼容2.x 在实际挂载之前创建实例的情况
const compatMountInstance =
__COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance( //创建组件实例,大多数属性初始化为null或空对象
initialVNode,
parentComponent,
parentSuspense
))
/* 开发模式,注册热更新 */
/* 开发模式将虚拟节点推入警告上下文,为开发者工具开启mount记时 */
/* 为keep-alive组件提供内部render函数 */
// 初始化props和slots
if (!(__COMPAT__ && compatMountInstance)) {
if (__DEV__) {
startMeasure(instance, `init`)
}
setupComponent(instance)
if (__DEV__) {
endMeasure(instance, `init`)
}
}
// setup() is async. This component relies on async logic to be resolved
// before proceeding
if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
parentSuspense && parentSuspense.registerDep(instance, setupRenderEffect)
// Give it a placeholder if this is not hydration
// TODO handle self-defined fallback
if (!initialVNode.el) {
const placeholder = (instance.subTree = createVNode(Comment))
processCommentNode(null, placeholder, container!, anchor)
}
return
}
//建立渲染函数副作用:依赖收集
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
)
/* 结束mount计时 */
}
写不动了,有生之年更新。其实,按照前面的剖析思路,读者想必已掌握阅读源码的技巧,剩下的可自行探索