您现在的位置是:首页 >技术杂谈 >vue2模板渲染更新详细流程网站首页技术杂谈

vue2模板渲染更新详细流程

dralexsanderl 2024-09-07 12:01:04
简介vue2模板渲染更新详细流程

vue2模板渲染更新详细流程

此文章基于vue2.6.10版本进行解析,在看文章最好结合源码一起看能帮助更快的理解。

vue中会将.vue文件或者template属性解析成一个render函数,在渲染(调用$mount方法)的时候通过执行这个render函数生成真实节点,再通过_update()方法将真实节点挂载到页面上实现最终的渲染。下面来看看具体的流程。

在初次渲染时,调用mountComponent方法组成一个updateComponent方法,并把这个方法传递到当前实例(vm)的watcher上,同时根据是否是懒加载来判断要不是直接先渲染一次。当当前实例中依赖的数据发生改变后就会通知到watcher并触发updateComponent方法实现重渲染。

相关代码:

// src/core/instance/lifecycle mountComponent
updateComponent = () => {
    vm._update(vm._render(), hydrating);
};

// 将updateComponent传递到Watcher实例中
new Watcher(
    vm,
    updateComponent,
    noop,
    {
      before() {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, "beforeUpdate");
        }
      },
    },
    true
);

_render函数是通过.vue文件里的模板解析生成的一个函数,我们只需要知道render()的返回值是一个虚拟节点(Vnode),我们现在暂时不关注render是怎么解析生成vnode

我们把重点放在_update方法上。在源码的src/core/instance/lifecycle.js中的lifecycleMixin方法中定义了一个Vue.prototype._update方法。

在这个方法中,调用了__patch__方法将vnode生成真实节点并挂载到页面上(或者在旧节点上进行更新)

// _update 逻辑
if (!prevVnode) {
  // 第一次初始化,没有旧虚拟节点
  vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
  } else {
  // 更新节点
  vm.$el = vm.__patch__(prevVnode, vnode);
}

这个__patch__方法是createPatchFunction方法的返回值,这是因为vue兼容了webweex两个平台(传入不同的渲染相关的方法,暴露一个相同的调用方法),需要使用不同的方法来处理不同平台的渲染(比如说创建一个文本节点,web是通过document.createTextNode,而weex的则是new TextNode)。
我们这里只看web端的:

// src/plaforms/web/runtime/patch.js
const patch = createPatchFunction({ nodeOps, modules })

createPatchFunction中传入了node的操作方法、标签中一些属性处理以及事件监听器的处理方法,通过闭包的方式将这些操作方法保存下来给返回值调用。

function createPatchFunction(backend) {
 // 通过闭包方法保存提供给其他方法使用。
 const { modules, nodeOps } = backend;
 ...
 return function patch (oldVnode, vnode, hydrating, removeOnly) {
   ...
 }
}

我们先把钩子相关的代码先忽略,先重点看看是怎么生成的真实节点并挂载到页面上以及是页面怎么进行更新的。

我们分以下几种情况来逐步剖析patch方法:

1. 首次渲染

// 在前面的_update方法里提到这是第一次初始化时的传参,
vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);

可以看到传入的是vm。$el,也就是我们在初始化vue需要挂载的那个元素。
为了之后的方法可以统一传参类型,因此特地为这个真实节点也创建了一个vnode

if(isRealElement) {
  oldVnode = emptyNodeAt(oldVnode);
}

在给真实节点创建了一个vnode后,可以也看成是下面更新渲染中非同一种vnode的类型,vue内部的处理逻辑也是这样的,所以首次渲染这个部分在单独为真实节点创建了一个vnode后,后续的讲解可以跳转到更新渲染中非同个vnode继续。

2. 更新渲染

这种情况下的更新也分两种,更新前后是否是同一种vnode

src/core/vdom/patch.js中的sameVnode方法判断是否是同一种vnode

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

从上面的函数可以看出:

  • key是前提,key不相同肯定就不算同一个vnode
  • 相同标签名,isComment属性相同,标签中必须同时存在属性或者同时不存在属性(styleclass这些),如果标签是input类型,input type可以有不同,不过必须都是文本类型的输入(textpasswordemail等)。
  • 对于异步占位符的vnode,就需要判断异步的工厂函数是否相同。

同一种vnode

如果是同一种vnode,直接调用patchVnode进行新旧节点的比对更新(不需要处理当前节点,直接处理子节点即可)。

patchVnode中的关于DOM更新的算法是基于Snabbdom的。
来看看具体是怎么处理的:

// patchVnode
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) {
  // 新旧子节点都存在
  if (isDef(oldCh) && isDef(ch)) {
  // 新旧子节点不相同
    if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
  } else if (isDef(ch)) {
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '') 
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  } else if (isDef(oldCh)) {
    removeVnodes(elm, oldCh, 0, oldCh.length - 1)
  } else if (isDef(oldVnode.text)) {
    nodeOps.setTextContent(elm, '')
  }
// 如果要比对的新节点是文本节点,直接通过setTextContent替换文本内容即可(文本节点不存在子节点)。
} else if (oldVnode.text !== vnode.text) {
  nodeOps.setTextContent(elm, vnode.text)
}

如果新节点是非文本节点,则可能具有以下几种可能:

  1. 新旧节点都是存在子节点。
  2. 只有新节点存在子节点。使用createElm创建了对应的DOM后挂载到父元素上。
  3. 只有旧节点存在子节点。使用removeNode方法移除旧节点的子节点。
  4. 新节点是空节点,旧节点是文本节点。使用setTextContent把旧节点的值改为空值
  5. 新旧节点都是空节点。这个不需要处理,因为没有什么影响

重点需要看新旧节点都存在子节点的情况(也就是updateChildren方法):

patchVnode到现在要讲解的updateChildren方法都是在同一层级进行比较的,不会跨层级比较。这样只比较同层级的方式时间复杂度可以降低到O(n)

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      ...
    } else if (isUndef(oldEndVnode)) {
      ...
    } else if (sameVnode(oldStartVnode, newStartVnode)) {
      ...
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      ...
    } else if (sameVnode(oldStartVnode, newEndVnode)) {
      ...
    } else if (sameVnode(oldEndVnode, newStartVnode)) {
    } else {
      ...
    }
  }
}

上面只把核心的条件代码展示出来,我们通过下面例子来更好的理解以上条件处理逻辑:
在这里插入图片描述

假设图中的每个字母代表一个节点(没有子节点),相同的字母代表是相同节点。

当第一次循环时,前面的条件都不满足,但是都存在节点B,执行else里的逻辑

// else 里的逻辑
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
  idxInOld = isDef(newStartVnode.key)
    ? oldKeyToIdx[newStartVnode.key]
    : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

先看看这几行代码:

将旧子节点按照节点内的key值与索引建立映射并把这个map保存下来,如果当前循环中新子节点的存在key值,那么就可以直接映射到旧子节点列表中的索引值,直接找到旧的节点。

如果没有key值,那么每次循环都需要遍历旧子节点(oldStartIdx - oldEndIdx)去与当前循环中的新子节点进行比对判断是否是相同节点(非常影响性能,这也就是为什么v-for指令都需要带key进行标识

那么根据上面的对比结果,我们可以非常容易猜到下面肯定就是对有无索引值两种情况(旧子节点列表中存不存在与当前循环中的新子节点相同的节点)进行处理:

// else 里的逻辑

// 没有索引值
  if (isUndef(idxInOld)) {
    // 说明这个节点是新的,需要调用createElm生成一个DOM
    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
  } else {
  // 有
    vnodeToMove = oldCh[idxInOld];
    // 存在相同key不代表一定是相同节点,同样需要判断一下
    if (sameVnode(vnodeToMove, newStartVnode)) {
      // 继续对比这俩子节点
      patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // 移除索引值对应的节点(只是移除vnode中的,真实DOM仍存在)
      oldCh[idxInOld] = undefined
      // 把比对结束后的子节点插入到旧子节点的起始指针对应节点的前一位(位置要按照新子节点列表的顺序排列)
      canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
    } else {
      // 生成一个DOM
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    }
    // 移动指针
    newStartVnode = newCh[++newStartIdx]
  }

在这里插入图片描述

使用insertBefore方法会将节点从一个位置移动插入到新位置,原位置上节点相关的关系会重新建立

第二次循环,满足sameVnode(oldStartVnode, newStartVnode)条件,都是A

// sameVnode(oldStartVnode, newStartVnode) 条件逻辑
// 继续对比节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  // 新旧起始指针右移
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]

这是只需要比对这两个节点,然后移动新旧节点的起始指针:

在这里插入图片描述

第三次循环,满足isUndef(oldStartVnode)的条件:

// isUndef(oldStartVnode) 条件逻辑:
oldStartVnode = oldCh[++oldStartIdx]

就只有一个移动起始指针的逻辑:

在这里插入图片描述

第四次循环,满足sameVnode(oldStartVnode, newEndVnode)的条件,都是C

// sameVnode(oldStartVnode, newEndVnode)  条件逻辑
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 移动指针
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]

同样对比节点后插入到DOM中,然后移动指针:

这里把节点C插入到E

在这里插入图片描述

第五次循环,满足sameVnode(oldEndVnode, newStartVnode)条件,都是E

patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]

同样对比节点后插入到DOM中,然后移动指针:

在这里把节点E插到G前面

在这里插入图片描述

第六次循环,满足sameVnode(oldEndVnode, newEndVnode)的条件,都是D

// sameVnode(oldEndVnode, newEndVnode) 条件逻辑
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
// 移动指针
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]

同样对比节点后插入到DOM中,然后移动指针:

在这里插入图片描述

当前渲染的节点为BAEGDC,比我们的新子节点要多一个节点。

此时newEndIdx < newStartIdx,中止循环,执行updateChildren方法最后一段代码:

// 新子节点比旧子节点多
if (oldStartIdx > oldEndIdx) {
  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
// 旧子节点比新子节点多
} else if (newStartIdx > newEndIdx) {
  removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}

移除G节点,所以最终生成的节点为 BAEDC

其实updateChildren方法看似复杂,不过是if判断多了几个,核心都是比较两个节点,然后在遍历对比子节点进行更新。

非同一种vnode

不是同一种的话就要通过createElm生成真实节点,在新节点挂载后把旧节点移除掉。

const oldElm = oldVnode.elm      
const parentElm = nodeOps.parentNode(oldElm)
// 生成新节点
createElm(
  vnode,
  insertedVnodeQueue,
  oldElm._leaveCb ? null : parentElm,
  nodeOps.nextSibling(oldElm)
)

// 移除旧节点
if (isDef(parentElm)) {
  removeVnodes(parentElm, [oldVnode], 0, 0)
}

createElm方法中可以看到就是通过document.createElement来创建新元素,而后通过xxx.appendChild或者xxx.insertBefore方法把节点挂载到页面上。

function createElm( 
  vnode,
  insertedVnodeQueue,
  parentElm,
  // 旧节点的下一个兄弟节点
  refElm,
  nested,
  ownerArray,
  index
) {
  // 当是组件的情况下,createComponent就会在根据传入的参数生成组件实例,经过$mount方法选渲染成组件后返回true
  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }
  ...
  // 生成真实节点
  vnode.elm = vnode.ns
    ? nodeOps.createElementNS(vnode.ns, tag)
    : nodeOps.createElement(tag, vnode)
  // 创建子元素
  createChildren(vnode, children, insertedVnodeQueue)
  // 挂载
  insert(parentElm, vnode.elm, refElm)
}

对于普通标签而言就是通过createElement创建,但是组件则是需要使用createComponent方法解析后才能插入文档。

createComponent方法通过调用组件钩子init方法创建一个组件实例并通过$mount方法创建了真实节点后通过xxx.appendChild或者xxx.insertBefore方法插入到文档中。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  ...
  // 组件实例相关的生成看下面渲染相关钩子部分
  if (isDef(vnode.componentInstance)) {
    // 把组件实例上的真实节点赋值到vnode.elm中
    initComponent(vnode, insertedVnodeQueue)
    // 挂载
    insert(parentElm, vnode.elm, refElm)
    if (isTrue(isReactivated)) {
      reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
    }
    return true
  }
}

至此,渲染相关的部分其实已经执行完了,也就是页面上已经渲染好节点了。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。