1.响应式原理
在 Vue 中,数据模型下的所有属性,会被 Vue 使用Object.defineProperty
(Vue3.0 使用 Proxy)进行数据劫持代理。响应式的核心机制是观察者模式,数据是被观察的一方,一旦发生变化,通知所有观察者,这样观察者可以做出响应,比如当观察者为视图时,视图可以做出视图的更新。
Vue.js 的响应式系统以来三个重要的概念,Observer
、Dep
、Watcher
。
发布者-Observer
Observe 扮演的角色是发布者,他的主要作用是在组件vm
初始化的时,调用defineReactive
函数,使用Object.defineProperty
方法对对象的每一个子属性进行数据劫持/监听,即为每个属性添加getter
和setter
,将对应的属性值变成响应式。
在组件初始化时,调用initState
函数,内部执行initState
、initProps
、initComputed
方法,分别对data
、prop
、computed
进行初始化,让其变成响应式。
初始化props
时,对所有props
进行遍历,调用defineReactive
函数,将每个 prop 属性值变成响应式,然后将其挂载到_props
中,然后通过代理,把vm.xxx
代理到vm._props.xxx
中。
同理,初始化data
时,与prop
相同,对所有data
进行遍历,调用defineReactive
函数,将每个 data 属性值变成响应式,然后将其挂载到_data
中,然后通过代理,把vm.xxx
代理到vm._data.xxx
中。
初始化computed
,首先创建一个观察者对象computed-watcher
,然后遍历computed
的每一个属性,对每一个属性值调用defineComputed
方法,使用Object.defineProperty
将其变成响应式的同时,将其代理到组件实例上,即可通过vm.xxx
访问到xxx
计算属性。
调度中心/订阅器-Dep
Dep 扮演的角色是调度中心/订阅器,在调用defineReactive
将属性值变成响应式的过程中,也为每个属性值实例化了一个Dep
,主要作用是对观察者(Watcher)进行管理,收集观察者和通知观察者目标更新,即当属性值数据发生改变时,会遍历观察者列表(dep.subs),通知所有的 watcher,让订阅者执行自己的update逻辑。
其dep
的任务是,在属性的getter
方法中,调用dep.depend()
方法,将观察者(即 Watcher,可能是组件的render function,可能是 computed,也可能是属性监听 watch)保存在内部,完成其依赖收集。在属性的setter
方法中,调用dep.notify()
方法,通知所有观察者执行更新,完成派发更新。
观察者-Watcher
Watcher 扮演的角色是订阅者/观察者,他的主要作用是为观察属性提供回调函数以及收集依赖,当被观察的值发生变化时,会接收到来自调度中心Dep
的通知,从而触发回调函数。
而Watcher
又分为三类,normal-watcher
、 computed-watcher
、 render-watcher
。
-
normal-watcher:在组件钩子函数
watch
中定义,即监听的属性改变了,都会触发定义好的回调函数。 -
computed-watcher:在组件钩子函数
computed
中定义的,每一个computed
属性,最后都会生成一个对应的Watcher
对象,但是这类Watcher
有个特点:当计算属性依赖于其他数据时,属性并不会立即重新计算,只有之后其他地方需要读取属性的时候,它才会真正计算,即具备lazy
(懒计算)特性。 -
render-watcher:每一个组件都会有一个
render-watcher
, 当data/computed
中的属性改变的时候,会调用该Watcher
来更新组件的视图。
这三种Watcher
也有固定的执行顺序,分别是:computed-render -> normal-watcher -> render-watcher。这样就能尽可能的保证,在更新组件视图的时候,computed 属性已经是最新值了,如果 render-watcher 排在 computed-render 前面,就会导致页面更新的时候 computed 值为旧数据。
小结
图片Observer 负责将数据进行拦截,Watcher 负责订阅,观察数据变化, Dep 负责接收订阅并通知 Observer 和接收发布并通知所有 Watcher。
2.Virtual DOM
在 Vue 中,template
被编译成浏览器可执行的render function
,然后配合响应式系统,将render function
挂载在render-watcher
中,当有数据更改的时候,调度中心Dep
通知该render-watcher
执行render function
,完成视图的渲染与更新。
整个流程看似通顺,但是当执行render function
时,如果每次都全量删除并重建 DOM,这对执行性能来说,无疑是一种巨大的损耗,因为我们知道,浏览器的DOM很“昂贵”的,当我们频繁的更新 DOM,会产生一定的性能问题。
为了解决这个问题,Vue 使用 JS 对象将浏览器的 DOM 进行的抽象,这个抽象被称为 Virtual DOM。Virtual DOM 的每个节点被定义为VNode
,当每次执行render function
时,Vue 对更新前后的VNode
进行Diff
对比,找出尽可能少的我们需要更新的真实 DOM 节点,然后只更新需要更新的节点,从而解决频繁更新 DOM 产生的性能问题。
VNode
VNode,全称virtual node
,即虚拟节点,对真实 DOM 节点的虚拟描述,在 Vue 的每一个组件实例中,会挂载一个$createElement
函数,所有的VNode
都是由这个函数创建的。
比如创建一个 div:
// 声明 render functionrender: function (createElement) { // 也可以使用 this.$createElement 创建 VNode return createElement('div', 'hellow world');}// 以上 render 方法返回html片段 <div>hellow world</div>
render 函数执行后,会根据VNode Tree
将 VNode 映射生成真实 DOM,从而完成视图的渲染。
Diff
Diff 将新老 VNode 节点进行比对,然后将根据两者的比较结果进行最小单位地修改视图,而不是将整个视图根据新的 VNode 重绘,进而达到提升性能的目的。
patch
Vue.js 内部的 diff 被称为patch
。其 diff 算法的是通过同层的树节点进行比较,而非对树进行逐层搜索遍历的方式,所以时间复杂度只有O(n),是一种相当高效的算法。
首先定义新老节点是否相同判定函数sameVnode
:满足键值key
和标签名tag
必须一致等条件,返回true
,否则false
。
在进行patch
之前,新老 VNode 是否满足条件sameVnode(oldVnode, newVnode)
,满足条件之后,进入流程patchVnode
,否则被判定为不相同节点,此时会移除老节点,创建新节点。
patchVnode
patchVnode 的主要作用是判定如何对子节点进行更新,
-
如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的 VNode 是 clone 或者是标记了 once(标记v-once属性,只渲染一次),那么只需要替换 DOM 以及 VNode 即可。
-
新老节点均有子节点,则对子节点进行 diff 操作,进行
updateChildren
,这个 updateChildren 也是 diff 的核心。 -
如果老节点没有子节点而新节点存在子节点,先清空老节点 DOM 的文本内容,然后为当前 DOM 节点加入子节点。
-
当新节点没有子节点而老节点有子节点的时候,则移除该 DOM 节点的所有子节点。
-
当新老节点都无子节点的时候,只是文本的替换。
updateChildren
Diff 的核心,对比新老子节点数据,判定如何对子节点进行操作,在对比过程中,由于老的子节点存在对当前真实 DOM 的引用,新的子节点只是一个 VNode 数组,所以在进行遍历的过程中,若发现需要更新真实 DOM 的地方,则会直接在老的子节点上进行真实 DOM 的操作,等到遍历结束,新老子节点则已同步结束。
updateChildren
内部定义了4个变量,分别是oldStartIdx
、oldEndIdx
、newStartIdx
、newEndIdx
,分别表示正在 Diff 对比的新老子节点的左右边界点索引,在老子节点数组中,索引在oldStartIdx
与oldEndIdx
中间的节点,表示老子节点中为被遍历处理的节点,所以小于oldStartIdx
或大于oldEndIdx
的表示未被遍历处理的节点。同理,在新的子节点数组中,索引在newStartIdx
与newEndIdx
中间的节点,表示老子节点中为被遍历处理的节点,所以小于newStartIdx
或大于newEndIdx
的表示未被遍历处理的节点。
每一次遍历,oldStartIdx
和oldEndIdx
与newStartIdx
和newEndIdx
之间的距离会向中间靠拢。当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx 时结束循环。
在遍历中,取出4索引对应的 Vnode节点:
- oldStartIdx:oldStartVnode
- oldEndIdx:oldEndVnode
- newStartIdx:newStartVnode
- newEndIdx:newEndVnode
diff 过程中,如果存在key
,并且满足sameVnode
,会将该 DOM 节点进行复用,否则则会创建一个新的 DOM 节点。
首先,oldStartVnode
、oldEndVnode
与newStartVnode
、newEndVnode
两两比较,一共有 2*2=4 种比较方法。
情况一:当oldStartVnode
与newStartVnode
满足 sameVnode,则oldStartVnode
与newStartVnode
进行 patchVnode,并且oldStartIdx
与newStartIdx
右移动。
情况二:与情况一类似,当oldEndVnode
与newEndVnode
满足 sameVnode,则oldEndVnode
与newEndVnode
进行 patchVnode,并且oldEndIdx
与newEndIdx
左移动。
情况三:当oldStartVnode
与newEndVnode
满足 sameVnode,则说明oldStartVnode
已经跑到了oldEndVnode
后面去了,此时oldStartVnode
与newEndVnode
进行 patchVnode 的同时,还需要将oldStartVnode
的真实 DOM 节点移动到oldEndVnode
的后面,并且oldStartIdx
右移,newEndIdx
左移。
情况四:与情况三类似,当oldEndVnode
与newStartVnode
满足 sameVnode,则说明oldEndVnode
已经跑到了oldStartVnode
前面去了,此时oldEndVnode
与newStartVnode
进行 patchVnode 的同时,还需要将oldEndVnode
的真实 DOM 节点移动到oldStartVnode
的前面,并且oldStartIdx
右移,newEndIdx
左移。
当这四种情况都不满足,则在oldStartIdx
与oldEndIdx
之间查找与newStartVnode
满足sameVnode
的节点,若存在,则将匹配的节点真实 DOM 移动到oldStartVnode
的前面。
若不存在,说明newStartVnode
为新节点,创建新节点放在oldStartVnode
前面即可。
当 oldStartIdx > oldEndIdx 或者 newStartIdx > newEndIdx,循环结束,这个时候我们需要处理那些未被遍历到的 VNode。
当 oldStartIdx > oldEndIdx 时,说明老的节点已经遍历完,而新的节点没遍历完,这个时候需要将新的节点创建之后放在oldEndVnode
后面。
当 newStartIdx > newEndIdx 时,说明新的节点已经遍历完,而老的节点没遍历完,这个时候要将没遍历的老的节点全都删除。
图片总结
借用官方的一幅图:
图片Vue.js 实现了一套声明式渲染引擎,并在runtime
或者预编译时将声明式的模板编译成渲染函数,挂载在观察者 Watcher 中,在渲染函数中(touch),响应式系统使用响应式数据的getter
方法对观察者进行依赖收集(Collect as Dependency),使用响应式数据的setter
方法通知(notify)所有观察者进行更新,此时观察者 Watcher 会触发组件的渲染函数(Trigger re-render),组件执行的 render 函数,生成一个新的 Virtual DOM Tree,此时 Vue 会对新老 Virtual DOM Tree 进行 Diff,查找出需要操作的真实 DOM 并对其进行更新。
网友评论