虚拟DOM
Vue.js2.0引入了虚拟dom,比1.0的初始渲染速度提升了2-4倍,并大大降低了内存消耗。
dom操作越来越频繁,代码中多数都在操作dom,状态难以管理 => 代码应该集中于状态管理,dom操作交给框架去实现 => 状态改变,重新渲染,只更新与这个状态相关的dom节点,angular脏检查-react虚拟dom-vue1.0细粒度绑定。
虚拟dom的解决方式是通过状态生成一个虚拟节点树, 渲染前会用新的虚拟dom树和上一次的进行比对,只渲染不同的部分。
vue1.0通过细粒度绑定来更新,每一个绑定都有一个对应的watcher来观察状态的变化,产生内存开销和依赖追踪的开销,对大型项目来说,这个开销是非常大的。
vue2.0开始选择中等粒度的解决方案,引入虚拟dom,组件级别是一个watcher实例。即便一个组件中有10个节点使用了某个状态,但其实也只有一个watcher,状态发生变化时,通知到组件,然后组件内部再通过虚拟dom去进行对比和渲染。** vue能够随意调整绑定的粒度,本质上还要归功于变化侦测。**
模板 -> 渲染函数 -> vnode -> patch -> 视图
patch为新旧节点树比对,判断出哪些节点发生了变化,从而只对变化的节点进行更新操作。
VNode
VNode类可以实例化不同类型的vnode实例,表示不同类型的dom元素。使用vnode创建真实dom,再插入到页面中渲染视图。
export default class VNode {
constructor (tag, data, children, text, elm, context, componentOptions, asynFactory) {
this.tag = tag
this.data = data
this.children = children
this.text = text
this.elm = elm
this.ns = undefined
this.context = context
...
}
}
作用在于,可以将上一次渲染视图时的vnode缓存起来,之后每当需要重新渲染视图时,将新创建的vnode和上一次缓存的vnode进行比对。
注释节点
export const createEmptyVNode = text => {
const node = new VNode()
node.text = text
node.isComment = true
// 其余属性全是默认的undefined或者false
}
文本节点
export function createTextVNode (val) {
return new VNode(undefined, undefined, undefined, String(val))
}
克隆节点
将现有节点的属性复制到新节点中,优化静态节点和插槽节点。
eg: 组件因状态变化需要重新渲染时,静态节点不会改变,所以除了首次渲染需要执行渲染函数获取vnode之外,后续都创建克隆节点进行渲染,不用重新执行渲染函数生成新的vnode。
克隆节点的isCloned属性值为true,被克隆的原始节点isCloned为false。
export function cloneVNode (vnode, deep) {
const cloned = new VNode(
vnode.tag,
vnode.data,
vnode.children,
vnode.text,
vnode.elm,
vnode.context,
vnode.componentOptions,
vnode.asyncFactory
)
cloned.ns = vnode.ns
cloned.isStatic = vnode.isStatic
cloned.key = vnode.key
cloned.isComment = vnode.isComment
cloned.isCloned = true
if (deep && vnode.children) {
cloned.children = cloneVNodes(vnode.children)
}
return cloned
}
元素节点
{
// 通常存在4种有效属性
children: [VNode, VNode],
context: {...},
data: {...},
tag: "p",
...
}
组件节点
{
componentInstance: {...},
componentOptions: {...},
context: {...},
data: {...},
tag: "vue-component-1-child"
}
函数式组件
{
functionalContext: {...},
functionalOptions: {...},
context: {...},
data: {...},
tag: "div"
}
patch
虚拟dom最核心的部分是patch,可以将vnode渲染成真实的dom。渲染时,不是直接暴力覆盖原有dom,而是通过比对新旧虚拟节点树,找出需要更新的节点操作dom更新。用js运算成本来替换dom操作的成本。
patch的目的是修改dom节点渲染视图,对比新旧vnode差异只是手段。
oldNode是否存在? -> 不存在,使用vnode创建节点并插入视图
-> 存在 -> oldNode和vnode是否是同一个节点
-> 是,使用patchVnode进行详细的对比和更新操作
否,使用vnode创建真实节点并插入到视图中旧节点旁边,删除视图中旧节点
创建节点
对于每一个节点,先判断节点类型,如果有tag属性,则是元素节点,调用当前环境下的createElement方法创建;如果isComment为true,则是注释节点,调用createComment方法创建;如果为false,则为文本节点,调用createTextNode创建。如果其有子节点,则需要递归创建子节点,等到所有子节点都创建完,再将其用appendChild方法插入到其父节点中。
删除节点
// 删除一组指定节点
function removeVnodes (vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
}
// 删除单个节点
function removeNode(el) {
const parent = nodeOps.parentNode(el)
if (isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
// 把removeChild封在nodeOps中,是为了把框架更新dom节点的操作封装,实现**跨平台渲染**,不用平台下调用节点操作。
const nodeOps = {
removeChild(node, child) {
node.removeChild(child)
}
}
更新节点
以vnode为准来更新视图
静态节点
如果新旧节点都为静态节点,无论状态怎么变化都不会该改变,则不需要重新渲染,跳过更新节点的过程。
vnode有文本属性
如果oldVnode也有文本属性,且与vnode一致,则不要改动。其余情况,无论oldVNode怎样,都调用setTextContent方法,更新视图中dom内容为text属性值。
vnode无文本属性
- 有children
如果oldVNode有children,则需要进一步详细比较其区别;
如果oldVNode没有children,那不是空节点就是文本节点,如果是文本节点,就清空文本,然后遍历vnode的children创建真实子dom,并插入到视图中。 - 无children
说明vnode是一个空节点,所以oldVNode中有什么就删什么,使真实dom变成一个空节点。
更新子节点
当vnode和oldVNode的子节点都存在且不同时,需要更新子节点。大概分为4种操作: 更新节点,新增节点,删除节点和移动节点。对比两个子节点列表,需要遍历newChildren,对于每一个child,遍历oldChildren查找,如果找不到,说明是新增节点,如果找到了,就更新节点,如果找到的oldChild和newChild位置不同,则需要移动节点。
更新策略
1.新增节点:创建新节点,并插入到oldChildren中所有未处理节点的前面。
为啥是所有未处理节点的前面?
因为我们是用新旧节点树进行对比,而不是真实dom树。如果,新旧节点树都已经处理了两个节点,新树的第三个节点,旧树中没有,此时需要在真实dom树中新增这个节点,而旧树中只有两个已处理和两个未处理。当比对第四个节点也需要新增时,如果添加在所有已处理的后面,将添加在第3位,位置错误。
2.更新子节点:当一个节点同时存在于newChildren和oldChildren中,且位置相同,只需要更新children和文本即可。
3.移动子节点:当一个节点同时存在于newChildren和oldChildren中,但位置不同,需要在视图中将此节点移动到所有未处理节点的最前面。节点更新并且完成移动后,开始进行下一轮循环,处理下一个未处理的节点。
4.删除子节点:当遍历完newChildren中所有节点时,oldChildren中剩余的未处理节点就是被废弃需要删除的节点。
优化策略
利用四种快捷查找方式,如果找到则直接更新和移动位置,如果没有找到,再进行遍历查找。
新前: newChildren中所有未处理的第一个节点
新后: newChildren中所有未处理的最后一个节点
旧前: oldChildren中所有未处理的第一个节点
旧后: oldChildren中所有未处理的最后一个节点
- 新前与旧前
比对新前与旧前两个节点,如果是同一个节点,则直接更新节点并更新视图;如果不是,则尝试比对新后与旧后。 - 新后与旧后
比对新后与旧后两个节点,如果是同一个节点,则直接更新节点并更新视图;如果不是,则尝试比对新后与旧前。 - 新后与旧前
比对新后与旧前两个节点,如果是同一个节点,则更新节点,然后将此节点移动到oldChildren中所有未处理节点的最后面;如果不是,则尝试比对新前与旧后。
图 - 新前与旧后
比对新前与旧后两个节点,如果是同一个节点,则更新节点,然后将此节点移动到oldChildren中所有未处理节点的最前面;如果不是,则遍历oldChildren查找。
图
未处理节点
被遍历过的都是处理过的节点,还没遍历到的就是未处理节点。但是由于优化策略,节点可能会有从后面比对的,所以不再只是处理所有未处理节点的第一个也可能是最后一个,所以遍历应该从两头向中间。
oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。start被处理过,就后移一位;end被处理过,就前移一位;old和new一起移动节点。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 4种快速查找
// 遍历查找
}
while (oldStartIdx <= oldEndIdx) {
// oldChildren中还有剩余节点,是需要被废弃的,可以遍历删除
}
while (newStartIdx <= newEndIdx) {
// newChildren中还有剩余节点,是新增节点,需要遍历添加
}