关于前端:Vuejs源码学习完结虚拟DOM的patch算法

42次阅读

共计 6114 个字符,预计需要花费 16 分钟才能阅读完成。


残缺代码 (https://github.com/mfaying/si…

虚构 DOM

虚构 DOM 将虚构节点 vnode 和旧虚构节点 oldVnode 进行比照,得出真正须要更新的节点进行 DOM 操作。对两个虚构节点进行比照是虚构 DOM 中最外围的算法(即 patch)。

因为 Vue.js 的变动侦测粒度很细,肯定水平上能够晓得哪些状态产生了变动,所以能够通过细粒度的绑定来更新视图,Vue.js1.0 就是这样实现的。然而因为粒度太细,会有很多 watcher 同时察看某状态,会有一些内存开销以及一些依赖追踪的开销,所以 Vue.js 采纳了一个中等粒度的解决方案。状态侦测不再细化到某个具体节点,而是某个组件,组件外部通过虚构 DOM 来渲染视图,这能够大大缩减依赖数量和 watcher 数量。

VNode

在 Vue.js 中存在一个 VNode 类,应用它能够实例化不同类型的 vnode 实例,而不同类型的 vnode 实例各自示意不同类型的 DOM 元素。vnode 能够了解为 JavaScript 对象版本的 DOM 元素。

VNode 的作用

Vue.js 能够将上一次渲染视图时创立的 vnode 缓存起来,将新创建的 vnode 和缓存的 vnode 进行比照,找出不一样的办法并基于此去批改实在的 DOM。

VNode 的类型

  1. 正文节点
  2. 文本节点
  3. 元素节点
  4. 组件节点
  5. 函数式组件
  6. 克隆节点

正文节点

export const createEmptyVNode = text => {const node = new VNode();
  node.text = text;
  node.isComment = true;
  return node;
}

文本节点

export function createTextVNode (val) {return new VNode(undefined, undefined, undefined, String(val))
}

克隆节点

克隆节点是将现有节点的属性复制到新节点中,让新创建的节点和被克隆接节点的属性保持一致,从而实现克隆成果。它的作用是优化动态节点和插槽节点(slot node)。

以动态节点为例,动态节点因为它的内容不会扭转,除了首次渲染须要执行渲染函数获取 VNode 之外,后续更新不须要执行渲染函数从新生成 vnode。因而就会应用创立克隆节点的办法将 vnode 克隆一份,应用克隆节点进行渲染。

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.isCommet
  cloned.isCloned = true
  if (deep && vnode.children) {cloned.children = cloneVNodes(vnode.children)
  }
  return cloned;
}

克隆节点与被克隆节点之间的惟一区别是 isCloned 属性。

元素节点

元素节点通常会存在以下 4 种无效属性:

  1. tag: 节点名称,例如 p、ul、li 等
  2. data: 节点上的数据,比方 attrs、class 和 style 等。
  3. children: 以后节点的子节点列表
  4. context: 以后组件的 Vue.js 实例

组件节点

和元素节点相似,此外它还有以下两个独有的属性

  1. componentOptions: 组件节点的选项参数,蕴含 propsData、tag 和 children 等信息。
  2. componentInstance: 组件的实例,也是 Vue.js 的实例。

函数式组件

和元素节点相似,此外它还有以下两个独有的属性

  1. functionalOptions
  2. functionalContext

patch

虚构 DOM 最外围的局部是 patch,它能够将 vnode 渲染成实在的 DOM。patch 也能够叫作 patching 算法。

patch 不是暴力替换节点,而是在现有 DOM 上进行批改来达到渲染视图的目标。对现有 DOM 进行批改须要做三件事:

  1. 创立新增的节点
  2. 删除曾经废除的节点
  3. 批改须要更新的节点

新增节点

新增节点的一个很显著的场景就是,当 oldVnode 不存在而 vnode 存在时,就须要应用 vnode 生成实在的 DOM 元素并将其插入到视图中。

当 vnode 和 oldVnode 齐全不是同一个节点时(雷同的地位节点都存在),须要应用 vnode 生成实在的 DOM 元素并将其插入到视图中。

删除节点

当一个节点只在 oldVnode 中存在时,咱们须要把它从 DOM 中删除。

当 vnode 和 oldVnode 齐全不是同一个节点时,在 DOM 中须要应用 vnode 创立的新节点替换 oldVnode 所对应的旧节点,而代替过程中是将新创建的 DOM 节点插入旧节点的旁边,而后再将旧节点删除。

更新节点

当新旧两个节点是雷同的节点,咱们须要对这两个节点进行比拟粗疏的比对,而后对 oldVnode 在视图中所对应的实在节点进行更新。

创立节点

只有三种类型的节点会被创立并插入到 DOM 中:元素节点、正文节点和文本节点。

判断 vnode 是否是元素节点,只须要判断它是否具备 tag 属性即可。咱们能够调用以后环境下的 createElement 办法(在浏览器环境下是 document.createElement)来创立元素节点。

将元素渲染到视图,只须要调用以后环境下的 appendChild 办法(在浏览器环境下是 parendNode.appendChild)

元素通常都会有子节点(children),所以当一个元素节点被创立后,咱们须要将它的子节点也创立进去并插入到这个刚创立出的节点上面。这是一个递归的过程,只须要将 vnode 中的 children 属性循环一遍,将每个子虚构节点都执行一遍创立元素的逻辑。

如果 vnode 不存在 tag 属性,那么它可能是另外两个节点:正文节点和文本节点。当 isComment 属性为 true 时,vnode 是正文节点,否则为文本节点。如果是文本节点,调用以后环境下的 createTextNode 办法(在浏览器环境下是 document.createTextNode)来创立实在的文本节点并将其插入到指定的父节点中。如果是正文节点,则调用以后环境下的 createComment 办法(在浏览器环境下是 document.createComment)来创立实在的正文节点并将其插入到指定的父节点。

删除节点

function removeVnodes (vnodes, startIdx, endIdx) {for (; startIdx <= endIdx; ++startIdx) {const ch = vnodes[startIdx]
    if (isDef(ch)) {removeNode(ch.elm)
    }
  }
}

const nodeOps = {removeChild (node, child) {node.removeChild(child)
  }
}

function removeNode(el) {const parent = nodeOps.parentNode(el)
  if (isDef(parent)) {nodeOps.removeChild(parent, el)
  }
}

将节点操作封装成函数放在 nodeOps 里是为了预留跨平台渲染接口。

更新节点

1. 动态节点
如果新旧两个虚构节点都是动态节点,就不须要进行更新操作,能够间接跳过更新节点的过程。
2. 新虚构节点有文本属性
依据新节点(vnode)是否有 text 属性,更新节点能够分为两种不同的状况。如果有 text 属性,不管之前旧节点的子节点是什么,间接调用 setTextContent 办法(浏览器为 node.textContent)将视图中 DOM 节点的内容改为虚构节点的 text 属性所保留的文字。若新旧节点都是文本,且文本雷同,则不执行。
3. 新虚构节点无文本属性
如果新虚构节点没有 text 属性,那么它是一个元素节点。
3.1 有 children 的状况
若旧虚构节点也有 children 属性,那么须要对新旧两个虚构节点的 children 进行一个更具体的比照并更新。更新 children 可能会挪动某个子节点的地位,也可能会删除或新增某个节点,具体更新 children 前面会介绍。

若无 children 属性,阐明旧虚构节点要么是一个空标签,要么是一个文本节点。如果是文本节点,那么先把文本清空让它变成空标签,而后将新虚构节点中的 children 挨个创立成实在的 DOM 元素节点并将其插入到视图中的 DOM 节点上面。
3.1 无 children 的状况
阐明新创建的节点是一个空节点,旧虚构节点有子节点、有文本都须要删除,达到视图是空标签的目标。

更新子节点

后面探讨了当新节点的子节点和旧节点的子节点都存在并且不雷同,会进行子节点的更新操作。上面咱们具体探讨这种状况。

更新子节点大略能够分为 4 种操作:更新节点、新增节点、删除节点、挪动节点。

比照两个子节点列表(children), 首先须要做的事件是循环。循环 newChildren,每循环到一个新子节点,就去 oldChildren 找到和以后节点雷同的那个旧子节点。如果找不到,就新增,创立节点并插入视图;如果找到了,就做更新操作;如果地位不同,就挪动节点。

更新策略

1. 创立子节点
如果在 oldChildren 中没有找到与本次循环所指向的新子节点雷同的节点,咱们须要执行创立节点的操作,将新创建的节点插入到 oldChildren 中所有未解决节点(未解决就是没有任何更新操作的节点)的后面。不能插入已解决节点前面,这是因为旧虚构节点已解决节点不包含咱们新插入的节点,如果间断插入多个新节点,新节点的程序就会是反的。
2. 更新子节点
节点在 newChildren 和 oldChildren 中都存在且地位雷同,须要进行更新节点的操作。更新节点操作之前曾经介绍过。
3. 挪动子节点
节点在 newChildren 和 oldChildren 中都存在但地位不同,须要将这个节点的地位以新虚构节点的地位为基准进行挪动。

通过 Node.insertBefore()办法,咱们能够将一个已有节点挪动到一个指定的地位。

那么如何得悉新虚构节点的地位在哪里呢?其实并不难。

比照两个子节点列表是通过从左到右循环 newChildren 这个列表,而后每循环一个节点,就去 oldChildren 中寻找与这个节点雷同的节点进行解决。也就是说,newChildren 中以后被循环到的这个节点的右边是被解决过的。那就不难发现,这个节点的地位是所有未解决节点的第一个节点。所以,只有把须要挪动的节点挪动到所有未解决节点的最后面就能够了。

对于怎么分辨哪些节点是解决过的,哪些节点是未解决的,前面会介绍。

4. 删除子节点
当 newChildren 中所有节点都被循环了一遍后,如果 oldChildren 中还有没有被解决的节点,那么这些节点就是须要被删除的。

优化策略

通过状况下,并不是所有子节点的地位都会产生挪动,一个列表中总有几个节点的地位是不变的。针对这些地位不变的或者说地位能够预测的节点,咱们不须要循环来查找,咱们有 4 种快捷查找形式:

  1. 新前与旧前
  2. 新后与旧后
  3. 新后与旧前
  4. 新前与旧后

新前:newChildren 中所有未解决的第一个节点
新后:newChildren 中所有未解决的最初一个节点
旧前:oldChildren 中所有未解决的第一个节点
旧后:oldChildren 中所有未解决的最初一个节点

新前与旧前

如果新前与旧前是同一个节点,因为地位雷同,所以更新节点即可。如果不是,换下一个快捷查找形式。

新后与旧后

如果新后与旧后是同一个节点,因为地位雷同,所以更新节点即可。

新后与旧前

如果新后与旧前是同一个节点,因为地位不同,除了更新节点以外,还须要执行挪动节点的操作,将节点挪动到 oldChildren 中所有未解决节点的最初面。

新前与旧后

如果新前与旧后是同一个节点,因为地位不同,除了更新节点以外,还须要执行挪动节点的操作,将节点挪动到 oldChildren 中所有未解决节点的最后面。

如果后面这 4 种形式比照之后都没有找到雷同的节点,这时再通过循环的形式去 oldChildren 中具体找一圈。

哪些节点是未解决过的

因为咱们的逻辑都是在循环体内解决的,所以只有让循环条件保障只有未解决过的节点能力进入循环体内即可。

一个失常的循环都能实现这个成果,然而因为咱们的优化策略,节点是有可能从前面比照的,比照胜利就会进行更新解决。也就是说,循环不再是只解决所以未解决过的节点的第一个,有可能会解决最初一个,这种状况下就不能从前往后循环,而应该是从两边向两头循环。

那么,怎么实现从两边向两头循环呢?

首先,咱们先筹备 4 个变量:oldStartIdx、oldEndIdx、newStartIdx、newEndIdx。在循环体内,每解决一个节点,就将下标向指定的方向挪动一个地位。通常状况下是对新旧两个节点进行更新操作,就相当于一次性解决两个节点,将新旧两个节点的下标都向指定方向挪动一个地位。

开始地位所示意的节点被解决后,就向后挪动一个地位;完结地位的节点被解决后,则向前挪动一个地位。也就是说,oldStartIdx 和 newStartIdx 只能向后挪动,而 oldEndIdx 和 newEndIdx 只能向前挪动。

当开始地位大于等于完结地位,阐明所以节点都遍历过了,则完结循环。

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {// 做点什么}

你可能会发现,无论 newChildren 或者 oldChildren,只有有一个循环结束,就会退出循环。那么,当新子节点和旧子节点的节点数量不统一时,会导致循环完结后依然有未解决的节点,也就是说这个循环不须要笼罩所有节点。

因为如果 oldChildren 先循环结束,这个时候如果 newChildren 中还有残余节点,那么阐明这些节点都是新增的节点,间接把这些节点插入 DOM 中就行了。

如果 newChildren 先循环结束,如果 oldChildren 还有残余的节点,阐明这些节点都是被废除的节点,将这些节点从 DOM 中移除即可。

在 patch 中,还有一部分逻辑是建设 key 和 index 索引的对应关系。在 Vue.js 的模板中,渲染列表时能够为节点设置一个属性 key, 这个属性能够标示一个节点的惟一 ID。在更新子节点时,须要在 oldChildren 中循环去找一个节点。如果咱们为子节点设置了属性 key,建设了 key 和 index 索引的对应关系,就生成了一个 key 对应着一个节点下标这样一个对象。那么在 oldChildren 中找雷同节点时,能够间接通过 key 拿到下标,从而获取节点,基本不须要通过循环来查找节点。

残缺代码 (https://github.com/mfaying/si…

参考

《深入浅出 Vue.js》

正文完
 0