该系列是本人准备面试的笔记,或许有描述不当的地方,请在评论区指出,感激不尽。
其他篇章:
- Promise.try 和 Promise.withResolvers,你了解多少呢?
- 从 babel 编译看 async/await
- 挑战ChatGPT提供的全网最复杂“事件循环”面试题
- Vue.nextTick 从v3.5.13追溯到v0.7.0
- Vue 怎么监听 Set,WeakSet,Map,WeakMap 变化?
前言
刚刚兴起的念头,打算开源一个跟 Vue 全局组件相关的 plugin。所以今天来简单了解一下,Vue 组件标签是怎么一步一步解析成 HTML 标签的。
调试准备
- 先克隆源码:
git clone https://github.com/vuejs/core.git
; - 安装依赖:
pnpm install
; - 启动开发环境:
pnpm dev
; - 在
packages/vue/examples
目录下创建测试文件;
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vue</title>
<!-- 根据相对路径引入 -->
<script src="../dist/vue.global.js"></script>
<script type="module">
const { createApp, defineComponent, h } = Vue;
// 定义子组件
const HelloWorld = defineComponent({
props: {
message: {
type: String,
default: 'Hello World!',
},
},
setup(props) {
return () => {
return h('div', { class: 'hello-world' }, `Message: ${props.message}`);
};
},
});
// 定义父组件
const App = defineComponent({
components: { HelloWorld },
template: `
<div>
<HelloWorld message="Hello Vue!"></HelloWorld>
</div>
`,
});
// 创建 Vue 应用
const app = createApp(App);
app.mount('#app');
</script>
</head>
<body>
<div id="app"></div>
</body>
</html>
- 启动
Live Server
; - 从
createApp
开始断点调试。
compile 过程
baseParse(source, resolvedOptions)
baseParse
将 <div><HelloWorld message="Hello Vue!"></HelloWorld></div>
解析为 AST 树。
-
初始化解析上下文
- 调用
createRoot()
创建根节点(RootNode
),并设置输入字符串和一些默认配置。 - 初始化
tokenizer
的模式(HTML、SFC 或 BASE)和属性(如decodeEntities
)。
- 调用
-
调用
tokenizer.parse
tokenizer.parse(currentInput)
开始解析输入的模板字符串。tokenizer
是解析的核心,负责将输入字符串逐步分解成一系列的 token(词法单元)。
-
扫描输入字符
- 在
tokenizer.parse
函数中,this.buffer
持有输入字符串,解析过程逐字符扫描。 - 每个字符的处理基于
state
(状态机),不同的状态决定如何解析字符。
- 在
-
根据当前状态处理字符
-
state
状态机控制当前解析的上下文,比如:- Text: 处理普通文本节点。
- InTagName: 解析开始标签(如
<div>
和<HelloWorld>
)。 - InAttrName: 解析属性名(如
message
)。 - InAttrValueDoubleQuotes: 解析属性值(如
"Hello Vue!"
)。 - InClosingTagName: 解析结束标签(如
</HelloWorld>
和</div>
)。
这些状态不断转换,直到所有字符都被处理完。
-
-
返回 AST
- 解析完成后,
tokenizer
会生成一个 AST 树结构。 - 调用
condenseWhitespace
清理空白字符,将 AST 树的children
作为最终结果。
- 解析完成后,
-
返回根节点
- 最终,
baseParse
返回根节点(RootNode
),包含div
元素和它的子节点(HelloWorld
元素)。
- 最终,
transform(ast, ...)
对已经解析成 AST 的模板进行转换和优化,生成可以用于渲染的最终结构。
-
创建转换上下文 (
createTransformContext
)- 调用
createTransformContext
函数,初始化转换上下文,这个上下文包含了许多与转换相关的状态和数据,比如当前正在处理的节点、帮助函数、导入项等。
- 调用
-
遍历节点 (
traverseNode
)- 调用
traverseNode
函数,递归遍历整个 AST 树中的每一个节点。遍历过程中,应用所有的节点转换(nodeTransforms
)插件,这些插件对每个节点进行修改或处理。
- 调用
-
应用节点转换
- 每个节点都会被
nodeTransforms
中的转换函数处理。 - 如果转换函数返回一个退出回调(
onExit
),则会在节点处理完成后执行。 - 节点的类型决定了如何继续遍历它的子节点。例如,
<div>
会继续递归遍历它的子元素,而插值表达式和注释节点则可能直接进行转换。 - 其中,transformElement 方法的作用是将模板中的元素节点(普通元素或组件)转换为用于生成渲染代码的
VNodeCall
表示,并处理其属性、子节点、指令和优化标志(如patchFlag
等)。
- 每个节点都会被
-
生成代码 (
createRootCodegen
)- 调用
createRootCodegen
为模板生成渲染代码,就是createVNode
生成虚拟 DOM 相关函数的代码。
- 调用
-
收集元数据
- 在转换过程中,收集 AST 节点相关的信息,最终将这些信息添加到
root
节点。 - 收集的内容包括:
helpers
(辅助函数)、components
(组件)、directives
(指令)、imports
(导入项)、hoists
(提升的静态节点)、temps
(临时变量)等。
- 在转换过程中,收集 AST 节点相关的信息,最终将这些信息添加到
-
完成转换
- 最后,标记
root.transformed
为true
,表示 AST 已经完成转换并可以进一步使用。
- 最后,标记
generate(ast, resolvedOptions)
将转换后的 AST 进一步处理,生成可执行的渲染函数代码(即最终的 render
代码),以便 Vue 的运行时能够正确渲染组件或模板。
-
创建上下文(
createCodegenContext
):- 初始化生成代码所需的上下文,包括辅助方法(如
push
)、缩进控制和选项配置。
- 初始化生成代码所需的上下文,包括辅助方法(如
-
生成前置代码(Preambles):
-
为不同模式(如
module
模式)生成模块导入代码、函数头部代码等前置内容:- 模块模式: 使用
genModulePreamble
生成模块依赖和作用域 ID 处理代码。 - 函数模式: 使用
genFunctionPreamble
创建函数声明和作用域初始化。
- 模块模式: 使用
-
-
处理辅助函数(Helpers):
- 如果需要,解构 Vue 内置辅助函数(如
createVNode
、resolveComponent
等)到局部作用域中,避免全局查找。
- 如果需要,解构 Vue 内置辅助函数(如
-
生成组件和指令的解析代码:
- 使用
genAssets
方法,生成组件和指令的运行时解析代码(如resolveComponent
或resolveDirective
调用)。
- 使用
-
定义临时变量:
- 根据 AST 的临时变量需求(
temps
属性),定义必要的局部变量以供后续使用。
- 根据 AST 的临时变量需求(
-
生成 VNode 树表达式:
- 对
codegenNode
节点进行递归处理,生成 VNode 树的 JavaScript 表达式。如果没有 VNode,则返回null
。
- 对
-
封闭代码块并完成代码生成:
- 根据上下文,补充必要的闭合大括号、缩进和返回语句,完成渲染函数定义。
-
返回生成结果:
- 输出最终的渲染函数代码、AST 和其他元信息(如
source map
)。
- 输出最终的渲染函数代码、AST 和其他元信息(如
APP render
函数最终的模样:
const _Vue = Vue
return function render(_ctx, _cache) {
with (_ctx) {
const { resolveComponent: _resolveComponent, createVNode: _createVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = _Vue
const _component_HelloWorld = _resolveComponent("HelloWorld")
return (_openBlock(), _createElementBlock("div", null, [
_createVNode(_component_HelloWorld, { message: "Hello Vue!" })
]))
}
}
从以上代码可以看出,resolveComponent
就是被用来寻找 HelloWorld
的组件代码,是我这次的行动目的。这也是 Vue 暴露出来的 api,文档在此。
挂载
-
Vue 调用
mount
方法从根组件开始,进入渲染过程:- VNode 渲染: 递归遍历 VNode 树,调用渲染器的
patch
函数。
- VNode 渲染: 递归遍历 VNode 树,调用渲染器的
-
Vue 递归比较(或直接创建)当前 VNode 和 DOM:
- 初次渲染: 如果没有旧 VNode,则直接调用
createElement
等方法创建真实 DOM。 - 属性绑定: 将 VNode 的
props
、events
等绑定到对应的 DOM 节点。 - 处理子节点: 递归渲染子 VNode,完成整个树的渲染。
- 初次渲染: 如果没有旧 VNode,则直接调用
-
挂载到真实 DOM
- 渲染器将生成的真实 DOM 树插入到根容器(
#app
)中,完成挂载。
- 渲染器将生成的真实 DOM 树插入到根容器(
resolveComponent
export function resolveComponent(
name: string,
maybeSelfReference?: boolean,
): ConcreteComponent | string {
return resolveAsset(COMPONENTS, name, true, maybeSelfReference) || name
}
function resolveAsset(
type: AssetTypes,
name: string,
warnMissing = true,
maybeSelfReference = false,
) {
const instance = currentRenderingInstance || currentInstance
if (instance) {
const Component = instance.type
// explicit self name has highest priority
if (type === COMPONENTS) {
const selfName = getComponentName(
Component,
false /* do not include inferred name to avoid breaking existing code */,
)
if (
selfName &&
(selfName === name ||
selfName === camelize(name) ||
selfName === capitalize(camelize(name)))
) {
return Component
}
}
const res =
// local registration
// check instance[type] first which is resolved for options API
resolve(instance[type] || (Component as ComponentOptions)[type], name) ||
// global registration
resolve(instance.appContext[type], name)
if (!res && maybeSelfReference) {
// fallback to implicit self-reference
return Component
}
if (__DEV__ && warnMissing && !res) {
const extra =
type === COMPONENTS
? `\nIf this is a native custom element, make sure to exclude it from ` +
`component resolution via compilerOptions.isCustomElement.`
: ``
warn(`Failed to resolve ${type.slice(0, -1)}: ${name}${extra}`)
}
return res
} else if (__DEV__) {
warn(
`resolve${capitalize(type.slice(0, -1))} ` +
`can only be used in render() or setup().`,
)
}
}
function resolve(registry: Record<string, any> | undefined, name: string) {
return (
registry &&
(registry[name] ||
registry[camelize(name)] ||
registry[capitalize(camelize(name))])
)
}
resolveComponent
的逻辑十分简单,主要目的就是在 Vue 实例和全局上下文中解析指定的组件、指令或过滤器等资源,以便在运行时找到相应的定义。
-
优先解析组件自身:
- 如果是组件类型 (
type === COMPONENTS
),优先检查当前组件的名称是否与目标名称匹配(包括原始名称、驼峰格式和首字母大写格式)。
- 如果是组件类型 (
-
局部解析:
- 在当前组件实例的注册表(如
instance[type]
或组件的options
中)尝试找到匹配的资源。
- 在当前组件实例的注册表(如
-
全局解析:
- 在应用程序上下文的全局注册表(
instance.appContext[type]
)中查找,即通过app.component()
注册的全局组件。
- 在应用程序上下文的全局注册表(
-
隐式自引用:
- 如果
maybeSelfReference
为true
,且未找到匹配资源,则返回组件本身作为默认结果,通常出现在递归组件或动态组件的上下文中。
- 如果
以上便是局部组件和全局组件的解析过程,那 <component is='HelloWorld'>
动态组件呢?
<component is='HelloWorld'>
我们改写一下 App 的 template:
const App = defineComponent({
components: { HelloWorld },
template: `
<div>
<component is="HelloWorld" message="Hello Vue!"></component>
</div>
`,
});
整体逻辑大差不差,在 transformElement
方法会对动态组件进行处理:通过 resolveComponentType
解析 :is
属性值,生成动态组件的类型。构建动态组件的属性、子节点以及运行时更新标志。将动态组件标记为块节点,便于运行时优化。
生成的 render
函数如下:
const _Vue = Vue
return function render(_ctx, _cache) {
with (_ctx) {
const { resolveDynamicComponent: _resolveDynamicComponent, openBlock: _openBlock, createBlock: _createBlock, createElementBlock: _createElementBlock } = _Vue
return (_openBlock(), _createElementBlock("div", null, [
(_openBlock(), _createBlock(_resolveDynamicComponent("HelloWorld"), { message: "Hello Vue!" }))
]))
}
}
_resolveComponent("HelloWorld")
变成了 _resolveDynamicComponent("HelloWorld")
。
export function resolveDynamicComponent(component: unknown): VNodeTypes {
if (isString(component)) {
return resolveAsset(COMPONENTS, component, false) || component
} else {
// invalid types will fallthrough to createVNode and raise warning
return (component || NULL_DYNAMIC_COMPONENT) as any
}
}
如果 component
是字符串,表示可能是一个组件的名称。还是调用 resolveAsset
尝试从当前上下文中解析该名称对应的组件(例如在局部或全局注册的组件)。如果找不到组件,则直接返回字符串,这可能是一个原生 HTML 标签。
如果 component
是非字符串(如一个组件对象或其他值),直接返回。如果 component
是 null
或 undefined
,返回 NULL_DYNAMIC_COMPONENT
作为占位符。
结语
虽然了解了小蝌蚪找妈妈的整个过程,但是我好像走进了死胡同,没有出路,不能通过 Vue plugin 方式实现。
最后,喜欢这篇文章的朋友不要忘了点赞收藏评论!