【vue源码03】vue实例的构建过程梳理😀
Vue源码中有很多细节方面的处理,直接生啃的话很容易被这些代码分散注意,所以这篇博客先整理一下Vue实例构建的过程,让我们对Vue的源码执行关键流程、关键原理有个大概的掌握。
# 构建时
Vue框架有静态编译的部分、也有运行时部分。在构建时(就是项目打包),解析各vue文件,生成对应的js代码,然后运行时执行js代码创建vue实例。
# 编译模板
编译模板指的就是编译vue文件中<template>中这部分像html一样的模板代码,通过对模板的编译生成渲染函数。编译分为几个关键步骤:解析(Parsing)、优化(Optimization)、生成代码(Code Generation)。
# 解析
在解析阶段,Vue 会将模板字符串解析为一个抽象语法树(AST)。AST 是模板的结构化表示,它描述了模板的各个部分及其关系。
Lexer(词法分析): 词法分析器会扫描模板字符串,将其分解为一系列的令牌(tokens)。每个令牌代表模板中的一个基本单元,如标签、属性、文本等。
Parser(语法分析): 语法分析器会将这些令牌转换为 AST。AST 是一个树状结构,树的每个节点表示模板中的一个元素或指令。
例如,这样的模板:
<div id="app">
<p>{{ message }}</p>
</div>
解析成AST后就像这样:
{
"type": "Element",
"tag": "div",
"attrsList": [{ "name": "id", "value": "app" }],
"children": [
{
"type": "Element",
"tag": "p",
"attrsList": [],
"children": [
{
"type": "Expression",
"expression": "message"
}
]
}
]
}
解析的过程其实意外的单纯,就是将模板一个字符一个字符的解析,利用有限状态机来构建AST。
# 优化
在优化阶段,Vue 会对 AST 进行静态分析,以标记出静态节点和静态根节点。这有助于在后续的更新过程中跳过不必要的比较,提升性能。
- 静态节点: 不依赖于响应式数据的节点被标记为静态节点。静态节点在初次渲染后不会再改变,因此可以在更新时跳过它们。
- 静态根节点: 包含静态子树的节点被标记为静态根节点,进一步优化了整棵子树的比较
其实就是最大利用静态编译的特点,在运行前做好一些标记之类的准备,让运行时省去一些工作。比如标记一下静态的节点,比如一个div中的文本内容,这个内容是永远不会变化的,在后续更新执行diff算法比较差异时,就可以不用比较这个节点的内容,提高效率。
# 生成代码
在代码生成阶段,Vue 会将优化后的 AST 转换为渲染函数。渲染函数是一个 JavaScript 函数,它返回虚拟 DOM 树。
- 生成器(Code Generator): 代码生成器会遍历 AST,将每个节点转换为相应的代码片段,最后将这些片段拼接成完整的渲染函数。 例如,对于上述的 AST,生成的渲染函数可能像这样:
function render() {
return _c('div', { attrs: { id: 'app' } }, [
_c('p', [_v(_s(message))])
])
}
看着带下划线的方法心里会比较抵触哈哈,毕竟从命名看不出他是干嘛的,所谓恐惧来源于未知
- _c 是创建虚拟节点的方法。
- _v 是创建文本节点的方法。
- _s 是将表达式转换为字符串的方法。
# 编译总结
总结一下,Vue 的模板编译过程可以分为三个主要阶段:
- 解析(Parsing): 模板字符串被解析为 AST。包括词法分析和语法分析两个步骤。
- 优化(Optimization): 对 AST 进行静态分析,标记出静态节点和静态根节点,以优化渲染性能。
- 生成代码(Code Generation): 将优化后的 AST 转换为渲染函数。渲染函数在运行时被调用,生成虚拟 DOM 树。
# 解析< script setup >
< script setup >中通常会包括定义响应式数据、方法、计算属性和生命周期钩子等。
例如这样的代码:
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3');
</script>
编译器遇到后,就会提取其中的内容,将其转化成标准的js代码。编译器会进行以下操作:
解析模块导入:处理模块导入语句,例如 import { ref } from 'vue';。
识别响应式数据:识别 ref 或 reactive 等响应式 API,并将它们初始化的变量存储起来。
收集返回值:识别在 setup 函数中返回的变量和方法。
上面的代码会被转化成这样:
function setup() {
const message = ref('Hello Vue 3');
return {
message
};
}
也就是将解析后的内容封装到了setup函数里面。
如果遇到了有多个script标签的情况,比如这样:
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3');
</script>
<script>
export default {
data() {
return {
otherMessage: 'Hello from data option'
};
}
};
</script>
编译器会对选项进行合并,最终生成如下代码:
export default {
data() {
return {
otherMessage: 'Hello from data option'
};
},
setup() {
const message = ref('Hello Vue 3');
return {
message
};
},
}
如果文件中还带有模板,编译器就会处理模板,将其编译成渲染函数,也就是我们上面提到的模板编译过程~
这样的组件代码:
<template>
<div>{{ message }}</div>
</template>
<script setup>
import { ref } from 'vue';
const message = ref('Hello Vue 3');
</script>
<script>
export default {
data() {
return {
otherMessage: 'Hello from data option'
};
}
};
</script>
会被编译成:
export default {
data() {
return {
otherMessage: 'Hello from data option'
};
},
setup() {
const message = ref('Hello Vue 3');
return {
message
};
},
render() {
return h('div', null, message.value);
}
};
模板会被编译成渲染函数,装载到组件的render函数中。串起来了~
# 运行时
在编译时,我们已经解析了各.vue的文件,解析了模板和script标签,此时各个文件都是js暴露出来的、代表组件的对象,里面有像setup、render这些方法。运行时我们就是要动态的解析这些对象,初始化组件实例、构建响应式系统、挂载vue实例、生成虚拟dom以及渲染成真实dom。
# 创建Vue实例
就算使用vue的脚手架,我们在main.js文件里面也会创建一个vue实例吧?当vue实例开始创建时,首先会合并各个选项,包括编译生成的setup函数和渲染函数。vue实例的创建方法会整理成这样:
const app = Vue.createApp({
setup() {
const message = ref('Hello Vue 3');
return { message };
},
render() {
return h('div', null, message.value);
}
});
# 初始化Vue实例、构建响应式系统
接下来就会执行Vue实例的setup方法,将setup方法返回的数据和方法绑定到组件实例上,同时会在执行的各个时机执行对应的生命周期回调。
- 组件实例:组件实例是用来代表组件的一个抽象,其实也就是一个简单的对象,里面有一个组件中的属性、方法、以及各个生命周期回调和组件状态等等,每个组件都会有一个组件实例来代表组件状态。
在setup函数执行、初始化组件实例的过程中,就会遇到很多ref、reactive、computed等方法了,从而开始构建响应式系统,并且将这些数据绑定到组件实例上,让他在各个模板中可以被访问(也就是渲染函数render里面可以访问到),并且数据会响应式变化。
详细一点说,vue的响应式是基于数据劫持来实现的,ref或reactive执行的时候,会注册代理对象(基于proxy),并在getter和setter中执行依赖收集和依赖触发。
依赖收集指的是当代理对象的某个属性被访问时,会将访问这个属性的函数收集进一个容器中(触发getter),当代理对象属性被设置的时候(触发setter)进行依赖触发,遍历执行读取了该属性的每一个函数。
实际上,读取了这些代理对象的函数,就是各个组件通过模板编译而来的渲染函数render了(模板上使用响应式数据,被编译成渲染函数,这个渲染函数就是要被收集的副作用函数)。当数据有所改变的时候,会触发代理对象的setter,将读取了该属性的渲染函数重新执行,重新渲染页面视图。(当然还涉及diff算法等)
# 生成虚拟DOM
到这个阶段,就是执行实例的渲染函数render了。渲染函数执行后会生成虚拟DOM树,虚拟DOM就是一个普通的js对象,用来代表页面真实DOM的抽象结构。 比如这样的模板:
<template>
<div>{{ message }}</div>
</template>
会被编译成这样的渲染函数:
function render() {
return h('div', null, this.message);
}
渲染函数执行后,会生成这样的虚拟DOM:
const vnode = {
tag: 'div',
props: null,
children: 'Hello Vue 3'
};
# 渲染真实DOM
# patch函数
这部分就是渲染器的工作了。渲染器中会有各种的patch函数,负责真实DOM的渲染以及更新。
# 创建真实DOM节点
在初次渲染时,patch 函数会遍历虚拟 DOM 树,创建相应的真实 DOM 节点,并将它们插入到页面中。
例如:
const el = document.createElement(vnode.tag);
el.textContent = vnode.children;
document.querySelector('#app').appendChild(el);
# 更新真实DOM
之前有说过,当数据改变的时候,会触发响应式重新执行渲染函数,重新生成虚拟DOM并需要重新挂载到页面上,而如何使更新的成本最小、即只更新有变化的地方,这就是patch函数更新部分的工作了,此时就会执行Diff算法,对比新旧虚拟DOM的差异,然后根据差异更新真实DOM。
举个不恰当,但容易理解的例子,当message变成Hello Vue3 Updated时:
const newVnode = {
tag: 'div',
props: null,
children: 'Hello Vue 3 Updated'
};
// 比较 newVnode 和 oldVnode,更新真实 DOM
if (newVnode.children !== vnode.children) {
el.textContent = newVnode.children;
}
# 挂载完成
此时组件已经彻底挂载完成啦,当然就会执行组件的mounted生命周期回调。
# 总结
本篇博客梳理了vue从模板编译到vue实例创建、组件实例构建、响应式系统构建、虚拟DOM生成、渲染真实DOM、更新真实DOM的整个过程,理清的编译器、响应式系统、渲染器之间的关系,以及Vue构建的关键过程。我自认为先从宏部理解vue的工作流程是非常重要的,这样看源码或者相关书籍的时候心里也会有底,不容易产生“这里是干嘛的”疑问。
本篇博客还是只是简单的梳理流程,具体各部分在实现上还有非常多可以学习的细节,后续我们再对各部分进行更加深入的学习🤣