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 并对其进行更新。








网友评论