【手写vue】v0.0.3

150 阅读4分钟

接着上一版

如果你不知道上一版在哪?电梯直达

mini-vue 仓库源码 点击关注不迷路,哈哈。

v0.0.3 虚拟DOM与diff算法

要求:

  • 使用vnode进行更新比较,最后再操作真实DOM渲染到页面上去
  • 最小更新,只修改有变化的DOM节点,没有变化的不更新
  • 探究diff算法,更高效的更新(适合大部分小范围更新场景,不适用于数据量大的更新)
  • 明白v-for中key属性的意义

每个版本的要求我都会写在最前面,这里推荐大家先不要着急往下看,在上一个版本的基础上,试着先自己写写看。

在上一个版本中我们以及实现了一个低配版的响应式更新功能,这个版本我们再完善下虚拟DOM的实现。

  1. 首先我们先新增一个patch.js模块文件,用来编写vnode的处理逻辑,向外暴露一个patch方法
/**
 * 更新DOM
 * @param {vnode} n1 旧vnode
 * @param {vnode} n2 新vnode
 * @param {Element} container 父节点
 * @param {Object} vm 当前组件实例对象
 */
export function patch (n1, n2, container, vm) {
  n2.vm = vm
  if (n1 == null) {
    initPatch(n2, container)
  } else {
    updatePatch(n1, n2, container)
  }
  // vm是全局不变的对象,因此用_vnode属性来存储本次的虚拟DOM,用于下次更新时比较
  vm._vnode = n2
}
  1. 接下来我们应该在什么地方调用这个patch方法呢? 看看vue.js文件中,之前操作DOM的地方,改成如下写法
function updateComponent () {
  const container = vm.container
  // 调用render函数,获得虚拟dom节点
  const vnode = app.render ? app.render.call(vm) : ''
  patch(vm._vnode, vnode, container, vm)
}
  1. 同时注意到,之前render函数返回的是真实DOM,现在变成vnode了,因此我们还得再修改下render.js,执行后返回一个vnode对象
export function createElement (vm, tag, prop, children) {
  const vnode = { vm, tag }

  if (Array.isArray(prop)) {
    children = prop
  } else if (prop) {
    let events;
    for (let attr of Object.getOwnPropertyNames(prop)) {
      if (attr === 'on') {
        // 如果存在on属性,则进行事件绑定操作
        events = {}
        for (let ev of Object.getOwnPropertyNames(prop.on)) {
          events[ev] = prop.on[ev].bind(vm)
        }
      } else {
        vm[attr] = prop[attr]
      }
    }
  }

  events && (vnode.events = events)

  if (children && children.length > 0) {
    const arr = []
    children.forEach(child => {
      if (typeof child === 'string') {
        child = { vm, tag: 'text', text: child }
      }
      arr.push(child)
    })
    vnode.children = arr
  }
  return vnode
}
  1. 接下来,回到我们刚开始创建的patch.js文件中,起先我们只写了一个patch函数,里面分为第一次渲染和更新渲染两种情况,为啥要区分呢?因为这两种操作差别很大,而且第一次渲染页面时,初始化了好些配置,以及依赖收集等等,也为我们后续更新操作减少了复杂性。不然共用一个函数,要注意很大边界条件的判定。

先看initPatch,很简单,清空,再重新渲染,很快,注意这里我们用到了vnodeToDom函数,将虚拟DOM转为真实DOM

/**
 * 初始化DOM,不用多余的判断,直接渲染
 */
function initPatch (vnode, container) {
  let el = vnodeToDom(vnode)
  container.innerHTML = ''
  container.append(el)
}

vnodeToDom函数

/**
 * 将虚拟DOM转换为真实DOM
 * @param {*} vnode 
 * @returns el
 */
function vnodeToDom (vnode) {
  const { tag, events, attrs, children, text } = vnode
  
  // 注意下这里, 为了方便处理,我们把单纯的文本字符串,也视为一个DOM节点
  if (tag === 'text') {
    vnode.el = document.createTextNode(text)
    return vnode.el
  }

  const el = vnode.el || (vnode.el = document.createElement(tag))
  if (events) {
    Object.getOwnPropertyNames(events).forEach(ev => {
      el.addEventListener(ev, events[ev])
    })
  }

  if (attrs) {
    Object.getOwnPropertyNames(attrs).forEach(name => {
      el.setAttribute(name, attrs[name])
    })
  }

  if (children) {
    children.forEach(child => {
      el.append(vnodeToDom(child))
    })
  }

  return el
}

接下来就是重头戏了,diff算法更新DOM

/**
 * 更新节点
 * @param {vnode} n1 旧vnode
 * @param {vnode} n2 新vnode
 * @param {Element} container 父节点
 * @param {boolean} isSame 判断n1,n2是否已经比较过了, 在updateChildren函数中有使用 
 */
function updatePatch (n1, n2, container, isSame) {
  let el = n1.el
  if (!isSame && !sameVnode(n1, n2)) {
    el = vnodeToDom(n2)
  }
  n2.el = el
  if (n2.children && n1.children) {
    updateChildren(n1.children, n2.children, el)
  } else if (isUndef(n2.children)) {
    el.innerHTML = ''
  } else if (isUndef(n1.children)) {
    n2.children.forEach(child => {
      el.appendChild(vnodeToDom(child))
    })
  }

  if (el !== n1.el) { // 当新的节点不是原来旧的节点时,执行替换操作
    container.replaceChild(el, n1.el)
  }
}
/**
 * diff算法,更新DOM子节点们
 * @param {Array} oldCh 旧vnode子节点集合
 * @param {Array} newCh 新vnode子节点集合
 * @param {Element} container 父节点
 */
function updateChildren (oldCh, newCh, container) {
  let oldStartIdx = 0
  let oldStart = oldCh[oldStartIdx]
  let oldEndIdx = oldCh.length - 1
  let oldEnd = oldCh[oldEndIdx]
  let newStartIdx = 0
  let newStart = newCh[newStartIdx]
  let newEndIdx = newCh.length - 1
  let newEnd = newCh[newEndIdx]

  let oldKeyMap, idxInOld // 定义旧节点的KeyMap, 要新插入的DOM, 要移除的DOM

  // 新旧数组,分别使用双指针进行遍历
  // 旧前-新前, 旧后-新后, 旧前-新后, 旧后-新前
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStart)) { // 此处使用isUndef, 避免把0,''等一些合法值过滤了
      oldStart = oldCh[++oldStartIdx]
    } else if (isUndef(oldEnd)) {
      oldEnd = oldCh[--oldEndIdx]
    } else if (sameVnode(oldStart, newStart)) { // 旧前-新前
      // 当前节点相同时,进一步比较他们的子节点是否也相同
      updatePatch(oldStart, newStart, oldStart.el, true)
      oldStart = oldCh[++oldStartIdx]
      newStart = newCh[++newStartIdx]
    } else if (sameVnode(oldEnd, newEnd)) { // 旧后-新后
      updatePatch(oldEnd, newEnd, oldEnd.el, true)
      oldEnd = oldCh[--oldEndIdx]
      newEnd = newCh[--newEndIdx]
    } else if (sameVnode(oldStart, newEnd)) { // 旧前-新后
      updatePatch(oldStart, newEnd, oldStart.el, true)
      oldStart = oldCh[++oldStartIdx]
      newEnd = newCh[--newEndIdx]
    } else if (sameVnode(oldEnd, newStart)) { // 旧后-新前
      updatePatch(oldEnd, newStart, oldEnd.el, true)
      oldEnd = oldCh[--oldEndIdx]
      newStart = newCh[++newStartIdx]
    } else { // 当前后两端都没匹配到时,通过key来查找,顺序匹配newCh, 这也就是newStart = [++newStartIdx]的原因
      // 初始化old的keyMap
      oldKeyMap = oldKeyMap || getKeyMap(oldCh, oldStartIdx, oldEndIdx)
      // 判断newStart是否在oldCh中, 优先使用key获取,其次遍历oldCh全部进行查找
      // 因此在使用v-for时,最好定义唯一的key值
      idxInOld = isDef(newStart.key) ? oldKeyMap[newStart.key] : findNewInOld(newStart, oldCh, oldStartIdx, oldEndIdx)

      if (isUndef(idxInOld)) { // 如果不存在,则newStart为新增的节点
        insertBefore(container, vnodeToDom(newStart), oldStart.el)
      } else {
        vnodeToMove = oldCh[idxInOld]
        if (sameVnode(vnodeToMove, newStart)) { // 说明该节点在此次更新中移动了位置
          updatePatch(vnodeToMove, newStart, vnodeToMove.el, true)
          insertBefore(container, vnodeToMove.el, oldStart.el)
          oldCh[idxInOld] = undefined
        } else {
          // key值相同,但却不是同一个节点,因此还是按照新增节点处理
          insertBefore(container, vnodeToDom(newStart), oldStart.el)
        }
      }

      newStart = [++newStartIdx]
    }
  }

  // 循环匹配结束后,只存在两种情况
  if (oldStartIdx > oldEndIdx) { // 旧的队列提前遍历完了,说明新的比旧的多,还需要进行插入操作
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      container.appendChild(vnodeToDom(newCh[i]))
    }
  } else if (newStartIdx > newEndIdx) { // 新的队列提前遍历完了,说明新的比旧的少,还需要把旧的里面剩余节点移除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      container.removeChild(oldCh[i].el)
    }
  }
}

慢慢的代码就比较多了,完整代码看源码仓库

代码中难理解的地方都写了注释,如果看不明白,可以debugger看执行过程。我还是推荐自己先动手试着写写,然后在对比,再改,再调试。在不断摸索中才能成长的更快。

突然间,你就悟了,哈哈,真的有可能。

下一版v0.0.4生命周期 施工中...