乐趣区

关于vue.js:vue源码虚拟DOM与diff算法

一、什么是虚构 DOM?
虚构 DOM 是由渲染函数创立的轻量级 JavaScript 对象,蕴含三个参数:元素,具备数据、prop、attr 等的对象,以及一个 children 数组。数组能够传递子级,每个子级也都具备这些参数,并也能够具备子级,以此类推,直到咱们构建残缺的 DOM 树为止。

当须要列表更新时,先更改虚构 DOM,而后在新虚构 DOM 和旧虚构 DOM 之间执 diff,算出最大量更新,最初反映到真正的 DOM 上。
二、snabbdomh
snabbdom 是驰名的虚构 DOM 库, 是 diff 算法的鼻祖,vue 源码借鉴了 snabbdom;

环境搭建:

  1. 在我的项目文件夹下运行 npm init, 而后装置 snabbdom
npm i -S snabbdom
  1. webpack 依赖 5 版本以上
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
  1. 配置好 webpack.config.js 文件
const path = require('path')

module.exports = {
  entry:'./src/index.js',
  output:{
    // 虚构打包门路,不会生成真正的文件夹,而是在 8080 端口虚构生成
    publicPath: 'xuni',
    filename: 'bundle.js'
  },
  devServer:{
    port: 8080,
    // 动态资源文件夹
    contentBase: 'www'
  }
}
  1. 我的项目目录下创立 www 文件夹,www 下创立 index.html 文件,引入打包好的 js
<script src="xuni/bundle.js"></script>
  1. 我的项目目录下创立 src 文件夹,src 下创立 js 入口文件 index.js
  2. 配置我的项目 packge.json, 实现 npm run dev 开启一个 server
scripts: {"dev": "webpack-dev-server"}

在终端运行 npm run dev 能够开启一个 8080 端口的服务,环境就搭建实现啦。

三、虚构 DOM 如何被渲染函数生成?–h 函数
h 函数用来产生虚构节点 -vnode
1、h 函数的根本用法:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

// 创立一个 patch 函数
const patch = init([classModule,propsModule,styleModule,eventListenersModule])

// 应用 h 创立一个 vnode
let myVnode1 = h('a', {props:{href:'http://www.baidu.com'}}, '百度')
console.log(myVnode1) 

// 让虚构节点上树
const container = document.getElementById('container')
patch(container,myVnode1)

  • h 函数创立一个节点
  • 虚构 DOM(省略空属性)
  • 实在 DOM

应用 h 函数创立一个带子元素的 vnode

虚构 DOM(省略空属性)

实在 DOM

由例子可见,h 函数非常灵便,依据传入参数的不同,利用函数的重载生成不同的虚构 DOM

2、手写 h 函数的外围性能(不思考重载,只思考三个参数的状况)

  • h.js
import vnode from './vnode'

// 编写一个低配版的 h 函数,这个函数必须承受三个参数,缺一不可
// 相当于它的重载性能交弱
// 调用时的状态必须为上面的三种之一:// 状态 1:h('div',{},'文字')
// 状态 2:h('div',{},[])
// 状态 3:h('div',{},h())
export default function (sel, data, c) {
  // 查看参数
  if (arguments.length != 3)
    throw new Error('对不起,h 函数必须传入三个参数')
  // 查看第三个参数 c 的类型
  if (typeof c === 'string' || typeof c === 'number') {
    // 阐明当初调用 h 函数是状态 1
    return vnode(sel, data, undefined, c, undefined)
  } else if (Array.isArray(c)) {
    // 阐明当初调用 h 函数是状态 2
    let children = []
    for (let i = 0; i < c.length; i++) {// 查看 c[i]必须是一个对象,因为 h()返回值为对象
      if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) {throw new Error('数组元素必须为 h()函数')
      } else {children.push(c[i])
      }
    }
    return vnode(sel, data, children, undefined, undefined)
  } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
    // 阐明当初调用 h 函数为状态 3
    //children 数组只有一个元素
    return vnode(sel, data, , undefined, undefined)
  }
}

  • vnode.js
export default function (sel, data, children, text, elm) {
  const key = data.key
  return {sel, data, children, text, elm, key}
}
  • index.js(测试)
import h from './mysnabbdom/h'

let myVnode = h('ul', {}, [h('li', {}, h('span', {}, 'peach')),
  h('li', {}, h('span', {}, 'banana')),
  h('li', {}, [h('span', {}, 'apple'),
    h('ul', {}, [h('li', {}, 'price:8'),
      h('li', {}, 'color:red')
    ])
  ])
])

console.log(myVnode)

四、diff 算法原理
外围 –patch()
1、patch 根本应用:

import {
  init,
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
  h,
} from "snabbdom";

const patch = init([classModule,propsModule,styleModule, eventListenersModule,]);

const vnode = h('ul',[h('li',{key:'A'},'A')
  h('li',{key:'B'},'B')
  h('li',{key:'C'},'C')
])
const newVnode = h('ul',[h('li',{key:'D'},'D')
  h('li',{key:'A'},'A')
  h('li',{key:'B'},'B')
  h('li',{key:'C'},'C')
])
// 调用 patch, 两个虚构 DOM 间进行 diff 算法,计算最大量更新
patch(vnode, newVnode);
  • key 是节点的惟一标识,能够通知 diff 算法,在更改前后它们是否是同一个 DOM 节点
// 判断是否为同一个节点:
oleVnode.key === newVnode.key && oldVnode.sel === newVnode.sel
  • 只有是同一个虚构节点,才进行精细化比拟,否则就暴力删除旧的,插入新的。
  • 只进行同层比拟,不会进行跨层比拟,即便是同一片虚构节点,只有是跨层了,则不进行 diff

2、模仿实现 patch()

首先解决新旧节点不是同一个节点的状况:

  • 新建 patch.js 文件
import vnode from "./vnode";
import createElement from './createElement'

export default function (oldVnode, newVnode) {
  // 判断 vnode1 是虚构节点还是 DOM 节点
  if (oldVnode.sel == undefined || oldVnode.sel == '') {
    //vnode1 是 DOM 节点,需转成虚构节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, undefined, undefined, oldVnode)
  }

  // 判断是否为同一节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    // 是同一个,进行精细化比拟
    //********diff 算法 *******
  } else {
    // 不是同一个节点,创立新节点
    let newVnodeElm = createElement(newVnode)
    if (oldVnode.elm.parentNode && newVnodeElm) {
      // 将新节点上树,让标杆节点的父元素调用 insertBefore 办法,将创立的节点,插入到标杆节点之前
      oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
    }
    // 删除旧节点
    oldVnode.elm.parentNode.removeChild(oldVnode.elm)
  }
}
  • 新建 createElement.js 文件,作用为依据 vnode 生成对应的 DOM
//vnode:需创立 DOM 的虚构节点
export default function createElement(vnode) {const domNode = document.createElement(vnode.sel)
  // 判断 vnode 的 inner 是否只有文本
  if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
    // 将文本插入到 domeNode
    domNode.innerText = vnode.text
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    //children 为数组,具备子元素
    // 递归创立节点
    for (let i = 0; i < vnode.children.length; i++) {let ch = vnode.children[i]
      // 一旦调用 createElement()办法,就创立了一个 DOM,并且将该 vnode 的 elm 属性指向了这个创立的 DOM
      let chDOM = createElement(ch)
      domNode.appendChild(chDOM)
    }
  }
  // 给 vnode 补充 elm 属性
  vnode.elm = domNode
 
  // 返回 elm,elm 是一个纯 DOM 对象
  return vnode.elm
}
  • index.js 测试



点按钮能实现 DOM 替换

新旧节点为同一个节点时,流程图如下:

解决新旧节点的子节点含 text 的状况:

  • patch.js 新增代码如下
  // 判断是否为同一节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    // 是同一个,进行精细化比拟
    // 判断新旧 vnode 是否是同一个对象
    if (oldVnode === newVnode) return
    // 判断 newVnode 有没有 text 属性
    if (newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)) {
      //newVnode 有 text 属性,判断 newVnode 的 text 和 oldVnode 是否雷同
      if (newVnode.text != oldVnode.text) {
        //text 不同,将 oldVnode.elm 中的 innerText 扭转为 newVnode.text
        oldVnode.elm.innerText = newVnode.text
      }
    } else {
      //newVnode 没有 text 属性
      // 判断 oldVnode 有没有 children
      if (oldVnode.children != undefined && oldVnode.children.length > 0) {
        //oldVnode 和 newVnode 都有 children
        //*****diff 更新子节点 ******
      } else {
        //oldVnode 没有,newVnode 有,并且 oldVnode 有 text, 清空 oldVnode 中的 text,并将 newVnode 中的 children 增加给 oldVnode 的 children
        oldVnode.elm.innerHTML = ''
        // 创立新的 vnode 子节点,创立 DOM,上树
        for (let i = 0; i < newVnode.children.length; i++) {let dom = createElement(newVnode.children[i])
          oldVnode.elm.appendChild(dom)
        }
      }
    }
  }

减少 key 属性,标识节点
在 vnode.js 中配置

五、diff 算法外围
在解决子节点时,咱们发现要思考的状况十分多,经典的 diff 算法优化策略提出四个指针,采纳四种命中查找:

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

查找规定:

  • 四个指针挪动规定:新前 / 旧前 指针只往 下移 新后 / 旧后 指针只往 上移
  • 命中一种就不再往下进行命中判断了,挪动以后命中形式的指针;

    • 若未命中,则程序向下,查找下一种命中;
    • 若四种都未命中,则在所有未解决的旧节点中 循环查找 ,若找到,将 旧节点 中找到的节点变为 undefined,再 挪动新前 指针指向的节点挪动至 旧前 指针 之前
    • 若循环也未找到,则将该 新前 指针指向的节点插入到 旧前 指针 之前。再挪动指针,进行下一次命中查找。
  • 完结循环判断条件为:while(新前 <= 新后 && 旧前 <= 旧后)
  • 旧节点 先循环 结束,若新节点中还有未遍历的,则未遍历的节点为新增节点,直接插入到旧前指针节点之前即可。
  • 若新节点先循环结束,若旧节点有未遍历的节点,则阐明残余节点是要删除的节点
  • 当新前与旧前命中时,新前与旧前指针都往下移
  • 当新后与旧后命中时,新后与旧后指针都往上移
  • 当新后与旧前命中时,需先将新后指向的节点挪动到过后旧后指针的前面,再将新后指针上移,旧前指针下移
  • 当新前与旧后命中时,需先将新前指向的节点挪动到过后旧前指针的后面,再将新前指针下移,旧后指针上移

六、手写 diff 算法

  1. 将 patch.js 中是同一个节点, 进行精细化比拟的代码提取成单文件 patchVnode.js

  1. 新建 updateChildren.js
import patchVnode from './patchVnode'
import createElement from './createElement'

// 封装判断两个节点是否是同一个虚构节点的函数
function checkSameVnode(a, b) {return a.sel == b.sel && a.key == b.key}

// 参数 parentElm: 父元素 参数 oldCh: 旧子节点对象 参数 newCh: 新子节点对象
export default function (parentElm, oldCh, newCh) {
  let oldStartIdx = 0,             // 旧前
    newStartIdx = 0,               // 新前
    oldEndIdx = oldCh.length - 1,  // 旧后
    newEndIdx = newCh.length - 1,  // 新后
    oldStartVnode = oldCh[0],      // 旧前节点
    oldEndVnode = oldCh[oldEndIdx],// 旧后节点
    newStartVnode = newCh[0],      // 新前节点
    newEndVnode = newCh[newEndIdx],// 新后节点
    keyMap = null                  //keyMap 缓存未解决的节点
  // 循环条件
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 判断查看项是不是 undefined, 若是,需略过
    if (oldStartVnode == null || oldCh[oldStartIdx] == undefined) {oldStartVnode = oldCh[++oldStartIdx]
    } else if (oldEndVnode == null || oldCh[oldEndIdx] == undefined) {oldEndVnode = oldCh[--oldEndIdx]
    } else if (newStartVnode == null || newCh[newStartIdx] == undefined) {newStartVnode = newCh[++newStartIdx]
    } else if (newEndVnode == null || newCh[newEndIdx] == undefined) {newEndVnode = newCh[--newEndIdx]
    } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
      // 新前与旧前命中
      patchVnode(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
      // 新后与旧后命中
      patchVnode(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (checkSameVnode(newEndVnode, oldStartVnode)) {
      // 新后与旧前命中
      patchVnode(oldStartVnode, newEndVnode)
      // 在旧后指针之后插入旧前指向的节点
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      oldStartVnode = oldCh[++oldStartIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (checkSameVnode(newStartVnode, oldEndVnode)) {
      // 新前与旧后命中
      patchVnode(oldEndVnode, newStartVnode)
      // 在旧前指针之前插入旧后指针指向的节点
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      newStartVnode = newCh[++newStartIdx]
      oldEndVnode = oldCh[--oldEndIdx]
    } else {
      // 不满足四种命中,循环查找
      // 设置 keyMap,保留未查找过的节点
      if (!keyMap) {keyMap = {}
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {const key = oldCh[i].key
          key != undefined && (keyMap[key] = i)
        }
      }
      // 寻找以后项(newStartVnode)在 keyMap 中的映射地位 index
      const idxInOld = keyMap[newStartVnode.key]
      if (idxInOld == undefined) {
        // 以后项在 oldCh 中没有,是一个全新的项
        parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
      } else {
        // 以后项在 oldCh 中存在,需挪动地位
        const elmToMove = oldCh[idxInOld]
        patchVnode(elmToMove, newStartVnode)
        oldCh[idxInOld] = undefined
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
      }
      newStartVnode = newCh[++newStartIdx]
    }
  }
  // 查看是否有残余节点
  if (newStartIdx <= newEndIdx) {
    // 新节点有残余,在 oldStartVnode 之前插入残余新增的节点,若
    //insertBefore 第二个参数为 null 时,会将节点增加在结尾
    let before = oldStartVnode ? oldStartVnode.elm : null
    for (let i = newStartIdx; i <= newEndIdx; i++) {parentElm.insertBefore(createElement(newCh[i]), before)
    }
  } else if (oldStartIdx <= oldEndIdx) {
    // 旧节点有残余,删除节点
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {oldCh[i] && parentElm.removeChild(oldCh[i].elm)
    }
  }
}
  1. patchVnode.js 引入 updateChildren.js 并调用

退出移动版