头图

一、什么是虚拟DOM?
虚拟DOM是由渲染函数创建的轻量级JavaScript对象,包含三个参数:元素,具有数据、prop、attr等的对象,以及一个children数组。数组可以传递子级,每个子级也都具有这些参数,并也可以具有子级,以此类推,直到我们构建完整的DOM树为止。
image.png
当需要列表更新时,先更改虚拟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)

image.png

  • h函数创建一个节点

image.png

  • 虚拟DOM(省略空属性)

image.png

  • 真实DOM

image.png

使用h函数创建一个带子元素的vnode
image.png
虚拟DOM(省略空属性)
image.png
真实DOM
image.png
由例子可见,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, [c], undefined, undefined)
  }
}

image.png

  • vnode.js
export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm }
}
  • 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)

image.png

四、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()
image.png
首先处理新旧节点不是同一个节点的情况:

  • 新建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测试

image.png
image.png
点按钮能实现DOM替换
image.png

新旧节点为同一个节点时,流程图如下:
image.png
处理新旧节点的子节点含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)
        }
      }
    }
  }

image.png

增加key属性,标识节点
在vnode.js中配置
image.png

五、diff算法核心
在处理子节点时,我们发现要考虑的情况非常多,经典的diff算法优化策略提出四个指针,采用四种命中查找:

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

查找规则:

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

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

六、手写diff算法

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

image.png

  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并调用

image.png

七、总结-项目结构
index.js
image.png
h.js
image.png
vnode.js
image.png
patch.js
image.png
createElement.js
image.png
patchVnode.js
image.png
updateChildren.js
image.png
image.png


Liane
16 声望2 粉丝