前言
作者简介:Quixn,专注于 Node.js 技术栈分享,前端从 JavaScript 到 Node.js,再到后端数据库,优质文章推荐,【小Q全栈指南】作者,Github 博客开源项目 github.com/Quixn…
大家好,我是Quixn。今天简单聊聊 Vue 的整个渲染渲染过程。嫌文章太长者,可直接到文末看精简版总结哦。
Vue 的整个渲染过程
Vue的渲染引擎是基于Virtual DOM实现的。下面是Vue的整个渲染过程的简要概述:
1、解析模板:Vue首先会解析模板,并生成一个抽象语法树(AST)。
2、生成渲染函数:Vue根据AST生成一个渲染函数,该函数用于生成Virtual DOM树。
3、执行渲染函数:当组件的状态发生变化时,Vue会重新执行渲染函数,生成一个新的Virtual DOM树。
4、对比新旧Virtual DOM树:Vue会对比新旧Virtual DOM树的差异,找出需要更新的部分。
5、更新DOM:Vue会根据差异更新真实的DOM树。
如果你是Vue的初学者,以下提供一份简单的流程图和一则小故事方便你记忆和理解,也可直接跳过哦。
小故事:
当你使用Vue开发应用时,就好比是你成为了一名餐厅的厨师,你需要将菜单(Vue的模板)转化为美味的佳肴(渲染出的页面)。
首先,你需要将菜单交给服务员(Vue编译器)来翻译,然后把翻译后的菜单(AST)给到你。翻译完成后,你需要根据这份菜单开始烹制食材(Vue的渲染函数)。
你使用菜单上的指令和属性来选择要使用的食材(渲染函数中的数据)和烹饪方法(Vue的指令)。这些指令和属性告诉你哪些食材需要加工,哪些需要展示给客人,哪些不需要展示等等。
接着,你开始烹饪。你遵循菜单上的步骤来处理食材(执行渲染函数),最终你烹制出了一份美味佳肴(生成了Virtual DOM树)。
最后,你需要将这份美味佳肴上菜(渲染到页面上),让客人可以品尝到你的作品(浏览器展示渲染结果)。如果客人有一些不喜欢的菜品(需要更新的部分),你需要回到厨房再烹制一份,然后再把新菜品端给客人(更新Virtual DOM树和重新渲染页面)。
这就是Vue的整个渲染过程,将模板转化为AST,然后生成渲染函数,最后渲染到页面上,一次又一次地循环,直到呈现出完整的页面。
Vue的渲染过程中,最关键的部分是生成Virtual DOM树和对比新旧Virtual DOM树的差异。Vue通过一种叫做Virtual DOM Diff算法的算法来实现这一过程。该算法会对比新旧Virtual DOM树的结构和属性,找出差异,并将差异应用到真实的DOM树上,从而实现高效的更新。
1、解析模板
当Vue应用启动时,会将模板传入Vue的编译器进行解析和编译。Vue的编译器将模板解析成一个抽象语法树(AST),并将AST转换为渲染函数。
下面是Vue模板解析的基本流程:
首先,Vue会将模板字符串转换为AST树的形式。在这个过程中,Vue使用了一个叫做htmlparser2的库来解析模板。
Vue将模板中的指令和属性转换为对应的AST节点。这些节点被称为指令节点或属性节点。指令节点包含指令名称和表达式,属性节点包含属性名称和属性值。
在模板解析的过程中,Vue会将模板中的文本内容转换为文本节点。文本节点中包含文本内容。
2、生成渲染函数
解析完模板后,Vue会对AST进行静态分析(至于这个过程是如何进行静态分析的,后面会专门出一篇文章来讲,此处不做分析),以便优化渲染函数的生成。静态分析可以检测到不需要更新的节点,从而避免不必要的更新。
最后,Vue将AST转换为渲染函数。渲染函数是一个JavaScript函数,用于生成Virtual DOM树并将其渲染到真实的DOM树上。
下面是一个简单的Vue模板和生成的AST节点的示例:
<template>
<div class="container">
<h1>{{ title }}</h1>
<p v-if="showText">{{ text }}</p>
</div>
</template>
解析后的生成的AST节点:
{
type: 1,
tag: 'div',
attrsList: [{ name: 'class', value: 'container' }],
attrsMap: { class: 'container' },
children: [
{
type: 1,
tag: 'h1',
children: [
{
type: 2,
expression: '_s(title)',
text: '{{ title }}'
}
]
},
{
type: 1,
tag: 'p',
if: 'showText',
ifConditions: [
{
exp: 'showText',
block: {
type: 1,
tag: 'p',
children: [
{
type: 2,
expression: '_s(text)',
text: '{{ text }}'
}
]
}
}
]
}
]
}
在这个示例中,解析后的AST树中包含一个div节点和两个子节点,一个h1节点和一个p节点。h1节点中包含一个文本节点,它的文本内容是模板中的{{ title }} 。p节点有一个v-if指令,Vue会将这个指令转换为一个if属性,并在ifConditions数组中保存条件和对应的节点。在实际的渲染过程中,Vue会根据if属性的值来判断是否需要渲染这个节点。
上面我们已经将模板转换为AST树了,接下来就是将AST树转换生成渲染函数。在 Vue 中,将 AST 转换为渲染函数的过程,实际上是在编译阶段完成的。编译器将模板转换为一个包含渲染函数的函数,这个函数接收一个参数 render,执行后会返回一个 VNode 节点。
具体来说,编译器将模板解析为 AST,然后对 AST 进行静态分析,进行一系列的优化,最后通过遍历 AST 生成渲染函数。生成渲染函数的过程中,会用到源码里的 codegen.js 文件中定义的一些函数来生成代码。
下面是一个简单的示例,假设我们有这样一个 Vue 模板:
<template>
<div>
<p>{{ message }}</p>
<button @click="handleClick">Click Me</button>
</div>
</template>
编译器将会把这个模板解析为 AST,然后遍历 AST 生成一个渲染函数:
function render(_ctx, _cache) {
return (_openBlock(), _createBlock("div", null, [
_createVNode("p", null, _toDisplayString(_ctx.message), 1 /* TEXT */),
_createVNode("button", { onClick: _cache[1] || (_cache[1] = $event => _ctx.handleClick($event)) }, "Click Me")
]))
}
上面的渲染函数中,_ctx 是组件实例的上下文对象,_cache 是一个缓存对象,存储了事件处理函数等。
渲染函数返回的是一个 VNode 节点,表示要渲染的内容。在渲染过程中,Vue 会根据这个 VNode 节点构建真实的 DOM 节点并插入到页面中。
3、执行渲染函数
经过上面两个步骤我们已经将模板解析并生成了渲染函数了,接下来Vue又是如何执行渲染函数的呢?
在 Vue 中,执行渲染函数的过程是通过调用虚拟 DOM 的 patch 方法来实现的。patch 方法接收两个参数,一个是旧 VNode,另一个是新 VNode。它会比较这两个 VNode 的差异,并将差异应用到真实的 DOM 上。
在组件初始化或数据更新时,Vue 会生成新的VNode并与旧的VNode进行比较,找出两者之间的差异,并将这些差异应用到真实的 DOM 上。
下面是一个简单的示例,它演示了如何使用渲染函数来创建一个包含一个按钮和一个计数器的组件:
<!-- App.vue -->
<template>
<div>
<button @click="increment">{{ count }}</button>
</div>
</template>
<script>
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
},
render() {
return h('div', [
h('button', { on: { click: this.increment } }, this.count)
])
}
}
</script>
在上面的代码中,我们使用render函数来创建VNode树,并返回一个根节点。在这个例子中, 函数render里的h函数是Vue提供的用于创建VNode的辅助函数。
在这个例子中,如果用户点击了按钮,Vue 将会重新调用render函数,并返回一个更新后的VNode树。然后,Vue 将会将这个 VNode 树与之前的 VNode 树进行比较,并计算出需要对 DOM 进行的更改。这些更改将被传递给虚拟 DOM 的patch方法,该方法将这些更改应用到真实的 DOM 上,从而实现更新视图的效果。
这里只是一个简单的示例,实际上,渲染函数可能更加复杂,可以使用计算属性、循环等功能来实现更加强大的动态 UI。但是无论如何,渲染函数的基本原理都是通过比较新旧VNode的差异,并将这些差异应用到真实的 DOM 上来实现更新视图的效果。
这个示例的过程就像我们在玩扑克牌时,每个玩家手中拥有一些牌,需要不断地调整牌的位置,直到得到最优的牌型。
假设我们有两个玩家A和B,他们各自手中有5张牌,每次玩家A会换掉一张手牌,然后将手牌展示给所有玩家看。其他玩家可以观察A的换牌过程,然后根据A的牌型和其他玩家手中的牌来决定自己是否换牌。
在这个过程中,A换掉牌相当于更新了数据,其他玩家观察A换牌的过程相当于生成新的虚拟DOM树,然后根据新旧虚拟DOM树的变化来决定是否更新视图。每个玩家都可以通过观察A换牌的过程来得到最新的牌型和最优的决策。
类比到Vue中,每个组件都有自己的数据,当数据发生变化时,Vue会根据渲染函数生成新的虚拟DOM树,然后通过比对新旧虚拟DOM树来判断哪些节点需要更新。最终,Vue会将更新的节点通过DOM操作更新到视图上,让用户看到最新的内容。
4、对比新旧Virtual DOM树
上面我们说到Vue会根据渲染函数生成新的虚拟DOM树,然后通过比对新旧虚拟DOM树来判断哪些节点需要更新。那Vue又是通过什么来做这个判断的呢?答案就是Virtual DOM Diff算法。
Vue的Virtual DOM Diff算法是一个用于比较新旧Virtual DOM树之间差异的算法。它通过对比两棵树的节点,找到需要更新、添加和删除的节点,然后对真实DOM进行最小化的操作。
Vue的Diff算法可以大致分为以下三个步骤:
首先比较新旧Virtual DOM的根节点,如果它们的标签名、key和属性都相同,则认为这两个节点是相同的,可以直接比较它们的子节点。如果根节点不同,则直接将新的Virtual DOM替换旧的Virtual DOM。
对比子节点,通过遍历新旧Virtual DOM树的子节点列表,按顺序进行比较。Vue使用了一些启发式的策略来尽可能地减少比较次数,例如只对同级节点进行比较,通过节点的key来快速定位到相同的节点等。在比较过程中,如果有子节点不同,就将新的子节点插入到旧的子节点的位置上,然后递归对新旧子节点进行比较。
对比完成后,得到了需要更新、添加和删除的节点,Vue会将这些操作打包成一个补丁(patch),然后将补丁应用到真实DOM上,从而完成更新。
下面是一个简单的Vue组件的代码示例,以说明Diff算法的实现方式:
<template>
<div>
<h1>{{ title }}</h1>
<ul>
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
</div>
</template>
<script>
export default {
data() {
return {
title: 'Todo List',
items: [
{ id: 1, text: 'Learn Vue' },
{ id: 2, text: 'Build an app' },
{ id: 3, text: 'Deploy to production' },
],
};
},
methods: {
addItem() {
this.items.push({ id: 4, text: 'Test' });
},
},
};
</script>
在这个例子中,当调用addItem方法添加新的item时,Vue会重新执行渲染函数,生成一个新的Virtual DOM树。Vue会对比新旧Virtual DOM树的差异,并将差异应用到真实的DOM树上,从而实现高效的更新。
Vue2和Vue3的Virtual DOM Diff算法有一些不同之处,Vue3对Diff算法进行了一些优化,以提高渲染性能和减少内存占用。下面分别介绍Vue2和Vue3的Diff算法。
(1)、Vue2的Diff算法:
Vue2的Diff算法使用了一种称为双端指针的策略,通过同时遍历新旧Virtual DOM树,从而减少比较次数,提高性能。具体来说,算法的过程如下:
-
1、如果新旧节点是
同一个节点,直接比较它们的子节点。 -
2、如果新旧节点
不是同一个节点,且它们都有子节点,则将旧节点的子节点按照key映射到一个对象上,然后遍历新节点的子节点列表,通过key从旧节点的映射对象中找到对应的节点进行比较,找不到则创建新节点。 -
3、如果新旧节点
不是同一个节点,且旧节点没有子节点,直接用新节点替换旧节点。 -
4、如果新旧节点
不是同一个节点,且新节点没有子节点,删除旧节点。
(2)、Vue3的Diff算法:
Vue3的Diff算法采用了一种称为“动态规划”的策略,通过寻找最长递增子序列,从而减少比较次数,提高性能。具体来说,算法的过程如下:
-
1、如果新旧节点是同一个节点,直接比较它们的子节点。
-
2、如果新旧节点不是同一个节点,且它们都有子节点,则使用“最长递增子序列”算法对子节点进行匹配,将不需要更新的节点复用,需要更新的节点打上标记。
-
3、如果新旧节点不是同一个节点,且旧节点没有子节点,直接用新节点替换旧节点。
-
4、如果新旧节点不是同一个节点,且新节点没有子节点,删除旧节点。
下面是一个简单的Vue3组件的代码示例,以说明Diff算法的实现方式:
<template>
<div>
<h1>{{ title }}</h1>
<ul>
<li v-for="item in items" :key="item.id">{{ item.text }}</li>
</ul>
</div>
</template>
<script>
import { reactive } from 'vue';
export default {
setup() {
const state = reactive({
title: 'Todo List',
items: [
{ id: 1, text: 'Learn Vue' },
{ id: 2, text: 'Build an app' },
{ id: 3, text: 'Deploy to production' },
],
});
const addItem = () => {
state.items.push({ id: 4, text: 'Test' });
};
return { state, addItem };
},
};
</script>
在这个例子中,我们使用了Vue3的新特性setup()函数来定义组件。当调用addItem方法添加新的item时,Vue会重新执行setup()函数,生成一个新的Virtual DOM树。Vue会对比新旧Virtual DOM树的差异,并将差异应用到真实的DOM树上,从而实现高效的更新。
5、更新DOM
在Vue执行完渲染函数并且对比新旧的vnode树后,如果发现需要更新DOM,那么就会执行patch函数来完成DOM更新。
patch函数定义在src/core/vdom/patch.js中,其主要作用是比较新旧节点的差异,然后进行最小化的DOM操作,从而尽可能减少DOM操作的次数,提高渲染效率。
下面是patch函数的简化代码示例:
function patch(oldVnode, vnode) {
if (sameVnode(oldVnode, vnode)) {
// 如果新旧节点相同,执行updateChildren函数更新子节点
updateChildren(oldVnode, vnode)
} else {
// 如果新旧节点不同,直接替换旧节点
const parent = api.parentNode(oldVnode.elm)
api.insertBefore(parent, createElm(vnode), api.nextSibling(oldVnode.elm))
api.removeChild(parent, oldVnode.elm)
}
}
在代码示例中,updateChildren函数用于更新新旧节点的子节点,它的实现过程就是通过比较新旧节点的子节点,找出它们之间的差异,然后进行最小化的DOM操作。如果新旧节点本身不同,则直接通过DOM操作替换旧节点。
总之,patch函数的主要作用就是通过最小化的DOM操作,将新的vnode树渲染成真实的DOM树,并将其插入到HTML文档中。
总结
本篇文章简要的聊了聊Vue的整个渲染过程,一共经历了5个步骤:
1、模板解析,通过htmlparser2的库来解析模板生成包含指令节点、属性节点和文本节点的AST树。
2、生成渲染函数,经历两个小的步骤,先静态分析AST,再转换为渲染函数。
3、执行渲染函数,通过调用虚拟 DOM 的 patch 方法来实现。
4、对比新旧虚拟 DOM,介绍了Vue2和Vue3 diff 算法的异同。
5、最后通过path函数更新DOM。
欢迎关注,公众号回复【vue的渲染过程】获取文章的全部脑图资源。
关于我 & Node交流群
大家好,我是 Quixn,专注于 Node.js 技术栈分享,前端从 JavaScript 到 Node.js,再到后端数据库,优质文章推荐。如果你对 Node.js 学习感兴趣的话(后续有计划也可以),可以关注我,加我微信【 Quixn1314 】,拉你进交流群一起交流、学习、共建,或者关注我的公众号【 小Q全栈指南 】。Github 博客开源项目 github.com/Quixn…
欢迎加我微信【 Quixn1314 】,拉你 进 Node.js 高级进阶群,一起学Node,长期交流学习...