[关闭]
@frank-shaw 2019-11-30T02:16:49.000000Z 字数 10900 阅读 1433

vue源码阅读(六):数据更新算法--patch算法

vue 源码 patch算法


数据更新的过程回顾

在组件实例初始化的过程中,Watcher实例与updateComponent创建了关联。

  1. let updateComponent = () => {
  2. //更新 Component的定义,主要做了两个事情:render(生成vdom)、update(转换vdom为dom)
  3. vm._update(vm._render(), hydrating)
  4. }

重点关注vm._update()。查看/core/instance/lifecycle.js:

  1. Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  2. ...//省略
  3. if (!prevVnode) {
  4. //初始化渲染
  5. vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  6. } else {
  7. // updates
  8. vm.$el = vm.__patch__(prevVnode, vnode)
  9. }
  10. ...//省略
  11. }

此处的vm.__patch__()是在源码入口阶段的platforms/web/runtime/index.js中定义:

  1. import { patch } from './patch'
  2. //定义补丁函数,这是将虚拟DOM转换为真实DOM的操作,非常重要
  3. Vue.prototype.__patch__ = inBrowser ? patch : noop

进入到platforms/web/runtime/patch.js:

  1. import * as nodeOps from 'web/runtime/node-ops'
  2. import { createPatchFunction } from 'core/vdom/patch'
  3. import baseModules from 'core/vdom/modules/index'
  4. import platformModules from 'web/runtime/modules/index'
  5. const modules = platformModules.concat(baseModules)
  6. //引入平台特有代码,其中: nodeOps是节点操作,modules是属性操作
  7. export const patch: Function = createPatchFunction({ nodeOps, modules })

createPatchFunction({nodeOps, modules})中的两个参数nodeOps与modules都是和特定平台相关的代码。其中:

nodeOps是节点操作,定义各种原生dom基础操作方法。
modules 是属性操作,定义属性更新实现。

createPatchFunction在文件core/vdom/patch.js中。该文件是整个Vue项目中最大的文件,详细记录了patch的过程。下面我们来详细看看patch算法的原理与实现。

patch算法

patch算法通过新老VNode树的对比,根据比较结果进行最小单位地修改视图(打补丁的由来),而不是将整个视图根据新的VNode重绘。patch的核心在于diff算法。

此处输入图片的描述

diff算法使用同层的树节点进行比较(而非对整棵树进行逐层搜索遍历),时间复杂度只有O(n),是一种相当高效的算法。示意如下图:两棵树之间,相同颜色的方块代表互相进行比较的VNode节点。

此处输入图片的描述

patch()

看看patch算法的源码:

  1. return function patch (oldVnode, vnode, hydrating, removeOnly) {
  2. //新节点不存在,老节点存在,直接移除老节点
  3. if (isUndef(vnode)) {
  4. if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
  5. return
  6. }
  7. let isInitialPatch = false
  8. const insertedVnodeQueue = []
  9. if (isUndef(oldVnode)) {
  10. // empty mount (likely as component), create new root element
  11. isInitialPatch = true
  12. //新节点存在,老节点不存在,新增该节点
  13. createElm(vnode, insertedVnodeQueue)
  14. } else {
  15. //判断是否是真实DOM,用于区分是否是初始化逻辑
  16. const isRealElement = isDef(oldVnode.nodeType)
  17. //不是真实DOM,并且是相同的VNode元素
  18. if (!isRealElement && sameVnode(oldVnode, vnode)) {
  19. //更新操作
  20. patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
  21. } else {
  22. //真实DOM,即初始化逻辑
  23. if (isRealElement) {
  24. if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
  25. //当旧的VNode是服务端渲染的元素,hydrating记为true
  26. oldVnode.removeAttribute(SSR_ATTR)
  27. hydrating = true
  28. }
  29. if (isTrue(hydrating)) {
  30. //需要合并服务端渲染的页面到真实DOM上
  31. if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
  32. invokeInsertHook(vnode, insertedVnodeQueue, true)
  33. return oldVnode
  34. }
  35. }
  36. // 不是服务端渲染节点,或者服务端渲染合并到真实DOM失败,返回一个空节点
  37. oldVnode = emptyNodeAt(oldVnode)
  38. }
  39. // 取代现有元素
  40. const oldElm = oldVnode.elm
  41. const parentElm = nodeOps.parentNode(oldElm)
  42. //生成真实DOM
  43. createElm(
  44. vnode,
  45. insertedVnodeQueue,
  46. oldElm._leaveCb ? null : parentElm, //指定父节点
  47. nodeOps.nextSibling(oldElm) //指定插入位置:现有元素的旁边
  48. )
  49. ...//省略
  50. // 移除老节点
  51. if (isDef(parentElm)) {
  52. removeVnodes([oldVnode], 0, 0)
  53. } else if (isDef(oldVnode.tag)) {
  54. invokeDestroyHook(oldVnode)
  55. }
  56. }
  57. }
  58. invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
  59. return vnode.elm
  60. }

上面代码的逻辑不算难,主要进行的是同层的树节点进行比较。其中包含的操作分别是:增加新节点、删除旧节点、更新节点。

查看源码发现,只有当判定新旧节点是同一个节点的时候,才会执行patchVnode()更新操作。怎样才算同一个节点呢?我们来看sameVnode()

sameVnode()

  1. /*
  2. 判断两个VNode节点是否是同一个节点,需要满足以下条件:
  3. key相同
  4. tag(当前节点的标签名)相同
  5. isComment(是否为注释节点)相同
  6. 是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)都有定义
  7. 当标签是<input>的时候,type必须相同
  8. */
  9. function sameVnode (a, b) {
  10. return (
  11. a.key === b.key && (
  12. (
  13. a.tag === b.tag &&
  14. a.isComment === b.isComment &&
  15. isDef(a.data) === isDef(b.data) &&
  16. sameInputType(a, b)
  17. ) || (
  18. isTrue(a.isAsyncPlaceholder) &&
  19. a.asyncFactory === b.asyncFactory &&
  20. isUndef(b.asyncFactory.error)
  21. )
  22. )
  23. )
  24. }
  25. /*
  26. 判断当标签是<input>的时候,type是否相同
  27. 某些浏览器不支持动态修改<input>类型,所以他们被视为不同类型
  28. */
  29. function sameInputType (a, b) {
  30. if (a.tag !== 'input') return true
  31. let i
  32. const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  33. const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  34. return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
  35. }

当两个VNode的tag、key、isComment都相同,并且同时定义或未定义data的时候,且如果标签为input则type必须相同。这时候这两个VNode则算sameVnode,可以直接进行patchVnode操作。

patchVnode()

  1. function patchVnode (
  2. oldVnode,
  3. vnode,
  4. insertedVnodeQueue,
  5. ownerArray,
  6. index,
  7. removeOnly
  8. ) {
  9. //两个VNode节点相同则直接返回
  10. if (oldVnode === vnode) {
  11. return
  12. }
  13. const elm = vnode.elm = oldVnode.elm
  14. ...//省略
  15. /*
  16. 如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),
  17. 并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),
  18. 那么只需要替换componentInstance即可。
  19. */
  20. if (isTrue(vnode.isStatic) &&
  21. isTrue(oldVnode.isStatic) &&
  22. vnode.key === oldVnode.key &&
  23. (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  24. ) {
  25. vnode.componentInstance = oldVnode.componentInstance
  26. return
  27. }
  28. let i
  29. const data = vnode.data
  30. if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
  31. //i = data.hook.prepatch,如果存在,见"./create-component componentVNodeHooks"
  32. i(oldVnode, vnode)
  33. }
  34. const oldCh = oldVnode.children
  35. const ch = vnode.children
  36. if (isDef(data) && isPatchable(vnode)) {
  37. //调用update回调以及update钩子
  38. for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
  39. if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  40. }
  41. //vnode的text属性与children属性是互斥关系,若没有text属性,必有children
  42. if (isUndef(vnode.text)) {
  43. if (isDef(oldCh) && isDef(ch)) {
  44. //老的有子节点,新的也有子节点,对子节点进行diff操作
  45. if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  46. } else if (isDef(ch)) {
  47. //新的有子节点,老的没有子节点,先清空老节点的text内容
  48. if (process.env.NODE_ENV !== 'production') {
  49. checkDuplicateKeys(ch)
  50. }
  51. if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
  52. //再增加新节点
  53. addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  54. } else if (isDef(oldCh)) {
  55. //老的有子节点,新的没有子节点,移除老节点
  56. removeVnodes(oldCh, 0, oldCh.length - 1)
  57. } else if (isDef(oldVnode.text)) {
  58. nodeOps.setTextContent(elm, '')
  59. }
  60. } else if (oldVnode.text !== vnode.text) {
  61. //两个都没有子节点,那么替换文本内容
  62. nodeOps.setTextContent(elm, vnode.text)
  63. }
  64. if (isDef(data)) {
  65. //i = data.hook.postpatch,如果存在,见"./create-component componentVNodeHooks"
  66. if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  67. }
  68. }

代码不难理解。整个patchVnode的规则是这样的:

1.如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换elm以及componentInstance即可。

2.新老节点均有children子节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。

3.如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。

4.当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。

5.当新老节点都无子节点的时候,只是文本的替换。

updateChildren()

  1. function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  2. let oldStartIdx = 0
  3. let newStartIdx = 0
  4. let oldEndIdx = oldCh.length - 1
  5. let oldStartVnode = oldCh[0]
  6. let oldEndVnode = oldCh[oldEndIdx]
  7. let newEndIdx = newCh.length - 1
  8. let newStartVnode = newCh[0]
  9. let newEndVnode = newCh[newEndIdx]
  10. let oldKeyToIdx, idxInOld, vnodeToMove, refElm
  11. // removeOnly is a special flag used only by <transition-group>
  12. // to ensure removed elements stay in correct relative positions
  13. // during leaving transitions
  14. const canMove = !removeOnly
  15. if (process.env.NODE_ENV !== 'production') {
  16. checkDuplicateKeys(newCh)
  17. }
  18. while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  19. if (isUndef(oldStartVnode)) {
  20. oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
  21. } else if (isUndef(oldEndVnode)) {
  22. oldEndVnode = oldCh[--oldEndIdx]
  23. /*
  24. 前四种情况其实是指定key的时候,判定为同一个VNode,则直接patchVnode即可
  25. 分别比较oldCh以及newCh的两头节点2*2=4种情况
  26. */
  27. } else if (sameVnode(oldStartVnode, newStartVnode)) { //头头相同
  28. patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  29. oldStartVnode = oldCh[++oldStartIdx]
  30. newStartVnode = newCh[++newStartIdx]
  31. } else if (sameVnode(oldEndVnode, newEndVnode)) { //尾尾相同
  32. patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  33. oldEndVnode = oldCh[--oldEndIdx]
  34. newEndVnode = newCh[--newEndIdx]
  35. } else if (sameVnode(oldStartVnode, newEndVnode)) { //头尾相同
  36. patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  37. //挪动oldStartVnode到oldEndVnode前面
  38. canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
  39. oldStartVnode = oldCh[++oldStartIdx]
  40. newEndVnode = newCh[--newEndIdx]
  41. } else if (sameVnode(oldEndVnode, newStartVnode)) { //尾头相同
  42. patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  43. //挪动oldEndVnode到oldStartVnode前面
  44. canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
  45. oldEndVnode = oldCh[--oldEndIdx]
  46. newStartVnode = newCh[++newStartIdx]
  47. } else {
  48. //在OldCh中,创建一张key<==>idx对应的map表,可加快查找newStartVnode是否在OldCh中
  49. if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
  50. idxInOld = isDef(newStartVnode.key)
  51. ? oldKeyToIdx[newStartVnode.key]
  52. : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
  53. if (isUndef(idxInOld)) { //如果不存在OldCh中,那么认定newStartVnode是新元素,创建
  54. createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
  55. } else {
  56. vnodeToMove = oldCh[idxInOld]
  57. /*
  58. 对比OldCh中找到的【相同key】的Vnode与newStartVnode,是否是相同的节点
  59. 判断是否是相同节点,除了key相同外,还有:
  60. tag(当前节点的标签名)相同
  61. isComment(是否为注释节点)相同
  62. 是否data都有定义
  63. 当标签是<input>的时候,type必须相同
  64. */
  65. if (sameVnode(vnodeToMove, newStartVnode)) {
  66. patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  67. oldCh[idxInOld] = undefined
  68. canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
  69. } else {
  70. // 如果key相同,但是其他不同,那么认为这是新元素,创建
  71. createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
  72. }
  73. }
  74. newStartVnode = newCh[++newStartIdx]
  75. }
  76. }
  77. if (oldStartIdx > oldEndIdx) {
  78. //如果oldCh数组已经遍历完,newCh数组还有元素,那么将剩余元素都创建
  79. refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
  80. addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
  81. } else if (newStartIdx > newEndIdx) {
  82. //如果newCh数组已经遍历完,oldCh数组还有元素,那么将剩余元素都删除
  83. removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  84. }
  85. }

这个过程不算复杂。通过画图的方式会更加清晰一些。
此处输入图片的描述

4种简单比较

在新老两个VNode节点的左右头尾两侧都有一个变量标记,在遍历过程中这几个变量都会向中间靠拢。当oldStartIdx <= oldEndIdx或者newStartIdx <= newEndIdx时结束循环。
首先,oldStartVnode、oldEndVnode与newStartVnode、newEndVnode两两比较一共有2*2=4种比较方法。

头头相等或者尾尾相等

当新老VNode节点的start或者end满足sameVnode()时,直接将对应VNode节点进行patchVnode即可。
此处输入图片的描述

头尾相等

如果oldStartVnode与newEndVnode满足sameVnode(),即sameVnode(oldStartVnode, newEndVnode)。这时候说明oldStartVnode已经跑到oldEndVnode后面了。进行patchVnode的同时,还需要将oldStartVnode移动到oldEndVnode的后面。
此处输入图片的描述

尾头相等

如果oldEndVnode与newStartVnode满足sameVnode(),即sameVnode(oldEndVnode, newStartVnode)。这说明oldEndVnode跑到了oldStartVnode的前面。进行patchVnode()的同时,还需要将oldEndVnode移动到oldStartVnode的前面。
此处输入图片的描述

非简单比较

如果不符合以上4种简单情况,那么在OldCh中,创建一张key<==>idx对应的map表,可加快查找newStartVnode是否在OldCh中。从这个map表中可以找到是否有与newStartVnode一致key的旧的VNode节点。如果满足sameVnode()patchVnode()的同时会将这个真实DOM(elmToMove)移动到oldStartVnode对应的真实DOM的前面。
此处输入图片的描述

当然,满足相同节点的情况是美好的,更多的情况是不满足相同节点的时候:key不相同,或者key相同但依然不满足sameVnode()。这时,需要创建新节点:
此处输入图片的描述

当然,还有一些终止条件的判断。

如果oldCh数组已经遍历完,newCh数组还有元素,那么将剩余元素都创建。
此处输入图片的描述
如果newCh数组已经遍历完,oldCh数组还有元素,那么将剩余元素都删除
此处输入图片的描述

patch算法回顾

整个patch算法很长。整体一遍理下来,会对其实现过程有一个更加深刻的理解。

接下来想要问一个问题:updateChildren()的时候,为什么会产生头尾交叉的4种比较的方式呢?它有什么好处么?

结合实际日常网页操作的习惯:我们可能会是只拖拖拽拽一个DOM元素,其他元素都不改变;或者点击一个让展示元素逆向排序的按钮;或者清除了一个DOM元素。

这些操作,都只是简单的改变DOM树的同层结构而已。头尾交叉的比较方式,完全能够高效应对这些日常操作。

这也可以作为一个面向工程化的算法优化案例了~

MORE

关于DOM的操作更新,patch算法已经向我们展示了全部过程。不过依然还有细节没有处理完:patch的过程只是将虚拟DOM映射成了真实的DOM。那如何给这些DOM加入attr、class、style等DOM属性呢?

实际上,源码中已经有答案,只是还不够明细。它的实现依赖于VDOM的生命周期函数。在createPatchFunction()中,有这么一段:

  1. //VDOM生命周期钩子函数
  2. const hooks = ['create', 'activate', 'update', 'remove', 'destroy']
  3. //引入DOM属性相关的生命周期钩子函数
  4. for (i = 0; i < hooks.length; ++i) {
  5. cbs[hooks[i]] = []
  6. //modules包含[attrs,klass,events,domProps,style,transition,ref,directives]等DOM属性
  7. for (j = 0; j < modules.length; ++j) {
  8. if (isDef(modules[j][hooks[i]])) {
  9. cbs[hooks[i]].push(modules[j][hooks[i]])
  10. }
  11. }
  12. }

这个cbs数组中的函数在哪里被调用了呢?来看patchVNode()中容易被忽视的一行代码:

  1. if (isDef(data) && isPatchable(vnode)) {
  2. //调用各个DOM属性相关的update钩子函数
  3. for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
  4. if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  5. }

此处正式更新对应DOM属性的所在。实际上,也就意味着,在patchVNode()过程中,DOM属性对应的更新也已经悄悄做完了。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注