likes
comments
collection
share

手写简化版Vue(五) _update的实现解析

作者站长头像
站长
· 阅读数 29

本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;

首次渲染

得到VNode之后, 下一步就是要将其转换为真实dom了, 也就是我们编译的最后一步! 这一部分中, 将调vm._update用方法, 来实现vdom到dom的转换

// 源码路径 /src/core/instance/lifecycle.ts
export function lifecycleMixin (Vue) {
  // 将虚拟dom转为真实节点
  Vue.prototype._update = function (vnode) {
    const vm = this
    let prevNode = vm._vnode
    let prevEl = vm.$el
    vm._vnode = vnode
    // 首次渲染
    if (!prevNode) {
      vm.$el = vm.__patch__(vm.$el, vnode)
    // 更新渲染
    } else {
      vm.$el = vm.__patch__(prevNode, vnode)
    }
    if (prevEl) {
      prevEl.__vue__ = null
    }
  }
}

整体上也很清晰明了; 重点就在于这里的__patch__方法, 而__patch__方法也是分了2种情况: 首次渲染以及更新渲染, 两者的区别在于, 首次渲染, 第一个参数为真实节点vm.$el, 更新渲染, 第一个参数为虚拟节点prevNode, 下面我们围绕patch方法展开讲解, 先看看运行时入口文件引入部分:

// 源码路径 /src/platforms/web/runtime/index.ts

import { patch } from './patch'
Vue.prototype.__patch__ = patch

export default Vue

patch方法由createPatchFunction方法创建:

// patch入口文件:
// 源码路径 /src/platforms/web/runtime/patch.ts
import { createPatchFunction } from "../../../core/vdom/patch"
// 对原生节点的操作方法
import * as nodeOps from './node-ops'
// 对属性/事件等模块的操作方法
import baseModules from './modules/index'

const modules = baseModules
// 创建patch方法
export const patch = createPatchFunction({nodeOps, modules})


// nodeOps上的方法定义:
// 源码路径: /src/platforms/web/runtime/node-opts.ts
export function tagName (node) {
  return node.tagName
}

export function parentNode (node) {
  return node.parentNode
}

export function createElement (tag) {
  return document.createElement(tag)
}

export function appendChild (node, child) {
  node.appendChild(child)
}

export function createTextNode (text) {
  return document.createTextNode(text)
}

export function nextSibling (node) {
  return node.nextSibling
}

export function insertBefore (parent, child, ref) {
  parent.insertBefore(child, ref)
}

export function setTextContent (node, textContent) {
  node.textContent = textContent
}

我们可以看到, 又是执行一个方法(createPatchFunction), 返回另一个可执行方法(patch), 就跟我们前面说的createCompiler方法的逻辑类似, 即一个偏函数, 而node-ops, modules/index中其实都是具体的属于web这个平台的操作逻辑, 这么做有利于代码的解耦, 提高可维护性; 这些操作逻辑会在createPatchFunction方法中用到; 由于createPatchFunction方法颇为复杂, 所以我们先重点关注return的patch方法部分:

// 源码地址: /src/core/vdom/patch.ts
import { isDef, isArray, isPrimitive, isUndef } from "../../shared/util"
import VNode from "./vnode"
// 空的虚拟dom
const emptyVnode = new VNode('', {}, [])
export function createPatchFunction (backend) {
  const {nodeOps, modules} = backend
	// ...其他逻辑暂时省略

 	// 实际返回的patch方法
  return function patch (oldVnode, vnode) {
    // 只有真实的dom节点才存在nodeType属性
    const isRealElement = isDef(oldVnode.nodeType)
    const insertedVNodeQueue = []
    if (isDef(oldVnode)) {
      // 更新渲染
      if (!isRealElement && sameVnode(oldVnode, vnode)) {
        // 更新节点
        patchVnode(oldVnode, vnode)
      // oldVnode是真实节点 || oldVnode和vnode不同, 此时vnode都要重新渲染
      } else {
        // 首次渲染
        if (isRealElement) {
          // 转为Vnode
          oldVnode = emptyNodeAt(oldVnode)
        }
        // 这个oldElm是一个真实的节点, 此处一般为#app
        let oldElm = oldVnode.elm
        // nodeOps, modules这些, 都是针对本平台(web platform)特定的操作方法, nodeOps里的其实就是一堆操作dom的原生方法:
        let parentElm = nodeOps.parentNode(oldElm)
        // 创建节点
        createElem(vnode, insertedVNodeQueue, parentElm, nodeOps.nextSibling(oldElm))
      }
    }
  }
  // 真实节点转为VNode
  function emptyNodeAt (elm) {
    return new VNode(nodeOps.tagName(elm).toLowerCase(), {}, [], undefined, elm)
  }
  // ...其他逻辑暂时省略
}

目前patch的整体逻辑很简单, 就是判断patch的首个参数是否是真实节点, 如果是, 则说明是首次渲染, 那就通过createElem来创建节点, 如不是, 则通过patchVnode方法进行节点的更新; 一个个来, 我们先来看下首次渲染的情况.

首次渲染中的核心方法就是createElem方法, 其功能就是根据vnode的信息, 创建出真实的节点

export function createPatchFunction (backend) {
  // ...其他部分暂时省略
  // 将虚拟dom转为真实dom
  function createElem (vnode, insertedVNodeQueue, parentElm, refElm, nested, owerArray, index) {
    const children = vnode.children
    const tag = vnode.tag
    const data = vnode.data //属性
    if (isDef(tag)) {
      // 根据表情名创建真实dom节点
      // 其实就是调用原生的document.createElement(tag)
      vnode.elm = nodeOps.createElement(tag)
      // 创建子节点
      createChildren(vnode, children, insertedVNodeQueue)
      // 如果存在属性
      if (isDef(data)) {
        // 执行开始的钩子方法
        invokeCreateHooks(emptyVnode, vnode)
      }
      // 插入父节点
      insert(parentElm, vnode.elm, refElm)
    } else {
      vnode.elm = nodeOps.createTextNode(String(vnode.text))
      insert(parentElm, vnode.elm, refElm)
    }
  }
 	// 创建子节点
  function createChildren (vnode, children, insertedVNodeQueue) {
    // 有多个子节点
    if (isArray(children)) {
      for (let i = 0; i<children.length; i++) {
        // 递归
        createElem(children[i], insertedVNodeQueue, vnode.elm, null)
      }
    // 如果只是string/number等基础类型, 则直接插入
    } else if (isPrimitive(children)) {
      nodeOps.appendChild(vnode.elm, child)
    }
  }
  // 插入节点操作
  function insert(parent, elm, ref) {
    if (isDef(parent)) {
      if (isDef(ref)) {
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        nodeOps.appendChild(parent, elm)
      }
    }
  }
  // 执行开始生命周期
  function invokeCreateHooks (emptyVnode, vnode) {
    for (let i = 0; i < cbs.create.length; ++i) {
      cbs.create[i](emptyVnode, vnode)
    }
  }
	// ...其他部分暂时省略
}

可以看出, 逻辑还是非常简单的, 就是根据Vnode的标签名, 调用一个createElement的原生方法, 创建一个节点, 再执行invokeCreateHooks更新属性, 然后调用appendChild插入就行了, 关于invokeCreateHooks方法, 下面会继续分解

事件/属性绑定

组合模式的妙用

现在想想, 我们除了需要搞出真实节点及其子节点, 还要搞啥? 还漏了啥? 没错, 事件/属性/class/指令等等需要添加! 而vue在这里使用了组合模式, 即给各个模块(事件/属性/class/指令), 定义了属于不同周期的钩子函数('create', 'update', 'destroy'等), 然后invokeCreateHooks方法通过统一执行各个模块的create钩子, 来实现事件/属性等的添加, 一起来看看具体怎么实现的吧:

// ...其他部分暂时省略
const hooks = ['create', 'update', 'destroy']
export function createPatchFunction (backend) {
  const {nodeOps, modules} = backend
	let cbs = {}
  // 将各个模块的钩子都组装到一个对象中去
  for (let i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (let j = 0; j < modules.length; ++j) {
      // 如果某个模块有对应的钩子函数, 则进行组装
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  // ...其他部分暂时省略
}

modules是一个由不同模块组成的数组, 每个模块是一个以hook为键的对象, 值就是这个hook需要执行的逻辑,

形如: [{create: () => {}, update: () => {}}, {...}, ...]

而循环过后cbs的大体结构就是:

cbs = {
  create: [() => {}, () => {}], // 该数组中包含了各个模块的create钩子函数
  update: [() => {}, () => {}],
}

所以, 现在回过头来看前面的invokeCreateHooks方法, 其实就是执行所有模块的create钩子函数! 在createElem的方法中, 就是执行每个属性模块的create钩子函数, 很明显, 一个模块在create的钩子上肯定都是各种添加操作, 说白了就是为节点添加该方法/属性;

 function invokeCreateHooks (emptyVnode, vnode) {
    for (let i = 0; i < cbs.create.length; ++i) {
      cbs.create[i](emptyVnode, vnode)
    }
  }

下面再来看看具体的模块逻辑吧

modules

在实际源码中, modules包含attrs, klass, events, domProps, style, transition等诸多内容, 这里应目前实际需要, 只展示events和domProps, 因为基本原理都是一样的,如果想更深入了解, 可以再去阅读源码;

  1. events

前面说了, modules在/src/platforms/web/runtime/patch.ts里被引入, 并以参数的形式传给上面的createPatchFunction方法的:

import modules from './modules/index'
export const patch = createPatchFunction({modules})

modules的入口文件index, 统一导出各个模块

// 源码路径: /src/platforms/web/runtime/modules/index.ts
import events from './events'
import domProps from './dom-props'
export default [events, domProps]

events模块, 负责所有事件的逻辑, 这里只列出了create部分

// 源码路径: /src/platforms/web/runtime/modules/events.ts
import { isUndef } from "../../../../shared/util"
import { updateListeners } from "../../../../core/vdom/helpers/update-listeners"
let target

// dom2级事件监听
function add (name, handler, capture, passive) {
  target.addEventListener(name, handler)
}

function updateDomListeners (oldVnode, vnode) {
  if (isUndef(oldVnode) && isUndef(vnode)){
    return
  }
  // 取出真实节点
  target = vnode.elm
  // on:xxx, 即代表事件
  const on = vnode.data.on
  updateListeners(on, add)
}

export default {
  create: updateDomListeners
}

// 源码路径: /src/core/vdom/helpers/update-listeners.ts
export function updateListeners (on, add) {
  for (const name in on) {
    add(name, on[name])
  }
}

我们由此可以得知, vue的事件绑定其实是dom2级事件, 使用了addEventListener这个原生方法进行绑定的!

  1. domProps

属性设置较为简单, 就只是单纯地一个遍历赋值操作

// 更新属性
function updateDomProps (oldVnode, vnode) {
  let props = vnode.data.domProps
  const elm = vnode.elm
  for (let key in props) {
    if (key === 'value') {
      elm[key] = props[key]
    }
  }
}

export default {
  create: updateDomProps,
  update: updateDomProps,
}

代码小节:

一. 设计模式上:

总体上, 事件/属性的绑定使用了组合模式, 即 每个模块先定义好各自的钩子函数, 通过遍历, 形成成为一个以钩子名为键, 以各个模块下, 属于这个钩子的方法组成的数组为值的对象; 这种写法的好处很明显:

  1. 符合了代码高内聚, 低耦合的设计原则, 各个模块相互分开, 互不影响;
  2. 代码可维护性高, 想要修改某个模块的行为, 直接修改对应的钩子即可;
  3. 统一调用, 由于使用了遍历, 所以不用担心某个方法没执行到; 前面的invokeCreateHooks方法, 也正是遍历执行cbs.update中的所有方法;

二. 虚拟dom转换:

  1. 所谓的VNode转为真实节点的基本逻辑, 其实很简单:
    1. 读取VNode中的tag, 再使用createElement原生dom方法创建原生同名节点, 并赋给VNode的elm属性
    2. 通过createChildren来遍历创建子节点, 如果子节点还有子节点, 则再递归调用createElm, 直到children为空;
    3. 将新创建的节点插入parentElm, 也就是父节点;
  1. 操作真实节点的方法均以参数形式传入nodeOps.xx, 这样极大提高了代码的可复用性; 要说VNode经过这么一波操作后, 有啥变化? 前面讲vm._render的时候, 说它们每个节点的elm属性, 都由undefined, 变为了实际存在的节点!

至此, 我们已经可以将页面渲染出来了!

new Vue({
  el: '#app',
  template: `
      <div>
        <div><span>姓名:</span>{{name}}</div>
        <div><span>年龄:</span>{{age}}</div>
      </div>
    `,
  data () {
    return {
      name: 'jack',
      age: 18
    }
  }
})

手写简化版Vue(五) _update的实现解析

更新渲染

完成了首次渲染, 我们来看看如果做到数据驱动页面更新, 回到刚才的patch方法, 如果非首次渲染, 那就会执行一个patchVnode方法, 接受oldVnode和VNode两个参数:

// 根据vnode改造oldvnode
  function patchVnode (oldVnode, vnode) {
    if (oldVnode === vnode) {
      return
    }
    const elm = (vnode.elm = oldVnode.elm)
    const ch = vnode.children
    const oldCh = oldVnode.children
    if (isDef(vnode.data)) {
      for (let i = 0; i < cbs.update.length; ++i) {
        (cbs.update[i])(oldVnode, vnode)
      }
    }
    // 如果新的节点没有文本内容
    if (isUndef(vnode.text)) {
      // 如果新老节点都存在
      if (ch && oldCh) {
        // 新老节点不同, 说明需要更新
        if (ch !== oldCh) {
          // 更新子节点
          updateChildren(elm, ch, oldCh)
        }
      // 原来没有子节点, 现在有, 则 增加子节点
      } else if (isDef(ch)) {
        if (oldVnode.text) {
          nodeOps.setTextContent(vnode, '')
        }
        // 增加操作
        addVnodes(elm, null, ch, 0, ch.length - 1)
      // 原来有, 现在没有, 删除子节点
      } else if (isDef(oldVnode)) {
        // 删除操作
        removeVnodes(oldCh, oldStartIdx, oldEndIdx)
      }
    // 如果新老文本都存在, 且不同, 则按照新的文本内容设置
    } else if (vnode.text !== oldVnode.text){
      nodeOps.setTextContent(elm, vnode.text)
    }
  }

updateChildren

updateChildren方法, 也就是更新子节点, 这里, 就是diff算法的核心所在, 用于对比两组同层的节点之间的不同, 并根据这些不同, 去更改/新增/删除真实的节点

 // 更新子节点, diff算法
  function updateChildren (parentElm, ch, oldCh, insertedVnodeQueue) {
    let oldStartIdx = 0
    let oldEndIdx = oldCh.length - 1

    let newStartIdx = 0
    let newEndIdx = ch.length - 1

    let oldStartVnode = oldCh[oldStartIdx]
    let oldEndVnode = oldCh[oldEndIdx]
    let newStartVnode = ch[newStartIdx]
    let newEndVnode = ch[newEndIdx]
    let keyToIndex, idxInOld
    while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      // 左侧推进
      } else if (sameVnode(oldStartVnode, newStartVnode)) {
        patchVnode(oldStartVnode, newStartVnode)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = ch[++newStartIdx]
      // 右侧推进
      } else if (sameVnode(oldEndVnode, newEndVnode)) {
        patchVnode(oldEndVnode, newEndVnode)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = ch[--newEndIdx]
      // 旧的开始节点和新的结束节点相同
      } else if (sameVnode(oldStartVnode, newEndVnode)) {
        patchVnode(oldStartVnode, newEndVnode)
        // 将新的开始的节点插到原最后一个节点的后面
        nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = ch[--newEndIdx]
      // 旧的结束节点和新的开始节点相同
      } else if (sameVnode(oldEndVnode, newStartVnode)) {
        patchVnode(oldEndVnode, newStartVnode)
        nodeOps.insertBefore(parentElm, oldEndVnode.elm, newStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = ch[++newStartIdx]
      } else {
        // 创建key到index的映射表
        keyToIndex = createKeyToIndex(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = newStartVnode.key ? keyToIndex[newStartVnode.key] : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        // 如果找不到对应的下标
        if (isUndef(idxInOld)) {
          createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)
        // 如果有下标
        } else {
          const vnodeToMove = oldCh[idxInOld]
          // 如果相同
          if (sameVnode(newStartVnode, vnodeToMove)) {
            patchVnode(vnodeToMove, newStartVnode)
            // 注意, 这个不处于边界元素, 必须设为undefined
            oldCh[idxInOld] = undefined
            nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          // 如果不同
          } else {
            createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)
          }
        }
        newStartVnode = [++newStartIdx]
      }
    }

    if (oldStartIdx > oldEndIdx) {
      // 新增
      const refElm = isUndef(ch[newEndIdx + 1]) ? null : ch[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, ch, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      // 删除
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }
  }

虽然代码洋洋洒洒很多, 我们仍然可以一步步解析:

  1. 先从左侧开始向右, 逐个对比新老节点, 如相同, 则patchVnode, 索引增加++oldStartIdx, ++newStartIdx, 否则就进入下一步;

手写简化版Vue(五) _update的实现解析

  1. 从右侧开始向左逐个对比, 确定是一个节点, 则patchVnode, 索引减少--oldStartIdx, --newStartIdx否则就进入下一步;

手写简化版Vue(五) _update的实现解析

  1. 比较oldStartVnode和newEndVnode, 确定是一个节点, 则同化(patchVnode), 并将其真实节点(即 elm属性的值)插入到老节点的最后后一位nodeOps.nextSibling(oldEndVnode.elm), 索引增减++oldStartIdx, --newEndIdx,否则就进入下一步;

手写简化版Vue(五) _update的实现解析

  1. 比较oldEndVnode和newStartVnode, 确定是一个节点, 则同化(patchVnode), 并将其真实节点(即 elm属性的值)插入到老节点的第一位oldStartVnode.elm 索引增减++newStartIdx, --oldEndIdx, 否则就进入下一步;

手写简化版Vue(五) _update的实现解析

  1. 以上均没找到, 则需要使用到key
    1. 如果newStartVnode有key, 就直接用oldKeyToIdx[newStartVnode.key]找出与之key相同的老节点的索引idxInOld

手写简化版Vue(五) _update的实现解析

    1. 如果newStartVnode没有key, 则遍历剩下的老节点(findIdxInOld), 找出和newStartVnode相同的老节点的索引idxInOld;

手写简化版Vue(五) _update的实现解析

    1. 如果前面的步骤根本没找到idxInOld, 说明这是一个新节点, 那就直接在oldStartVnode.elm前创建一个节点createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)

手写简化版Vue(五) _update的实现解析

    1. 如果找到了idxInOld, 还要看对应的老节点是否和newStartVnode相同
      1. 相同, 则说明这个节点发生了位移, 并用这个老节点vnodeToMove, 插入到oldStartVnode.elm之前, 并将oldCh[idxInOld]设为undefined; 注意, 这里之所以要将其设为undefined, 而前面的几次插入都不用, 是因为, 前面几次都是在用一些边界节点(oldStartVnode.elm, oldEndVnode.elm)来插到新的位置上, 紧接着的索引加一或减一(++oldStartIdx, --oldEndIdx)操作, 又会使这个老节点直接离开了我们操作的区间(oldStartIdx到 oldEndIdx), 所以可以不管它, 但是, 此时, 我们的vnodeToMove, 肯定不在边界上, 所以后续索引增减, 又会轮到这个节点, 那就有问题了, 所以将其设为undefined, 这样, 当while再走到它时, 就会直接跳过;

手写简化版Vue(五) _update的实现解析

      1. 不同, 则说明用户已经完全更改了这个节点, 那就和前面一样, 在老oldStartVnode.elm前创建一个节点createElem(newStartVnode, insertedVNodeQueue, parentElm, oldStartVnode.elm)

手写简化版Vue(五) _update的实现解析

往期回顾

手写简化版Vue(一) 初始化

手写简化版Vue(二) 响应式原理

手写简化版Vue(三) 编译器原理

手写简化版Vue(四) render的实现