Appearance
前言
在Vue中虚拟DOM和diff算法时面试的常考题,为了面试你可能粗浅了解过一些虚拟DOM和diff算法的相关知识或者一些公众号的文章,至少见过下面的图片
但为了面试而去应付的了解,真能能让我们能够认识到虚拟DOM和diff算法嘛?
本章的目的主要是为了更好的了解虚拟DOM和diff算法?如何弄懂?手动将底层代码敲出来
先简单介绍一下虚拟DOM和diff算法
图中显示,我们有三个改动的地方,那我们要如何改造呢?
这样代价太高了,这时候就可以利用diff算法。
这就好比我们DOM上的操作.
真实DOM变为虚拟DOM,标签是sel、标签上的参数是data、子元素是children、文本是text。
diff算法是发生在虚拟dom上的,通过在虚拟dom上的比较。算出如何最小量更新,最后反映在真实DOM上。
snabbdom
snabbdom是著名的虚拟DOM库,是diff算法的鼻祖,Vue源码借鉴了snabbdom;
官方git:https://github.com/snabbdom/snabbdom
项目搭建
• snabbdom库是DOM库,当然不能在nodejs环境运行,所以我们需要搭建webpack和webpack-dev-server开发环境,好消息是不需要安装任何loader
• 这里需要注意,必须安装最新版webpack@5,不能安装webpack@4,这是因为webpack4没有读取身份证中exports的能力,建议大家使用这样的版本:
npm i -D webpack@5.11.0 webpack-cli@3.3.12 webpack-dev-server@3.11.0
• 参考webpack官网,书写好webpack.config.js文件
js
const path = require("path")
module.exports ={
entry:"./src/index.js",
output:{
publicPath:"zxx",
filename:"bundle.js"
},
devServer:{
port:8080,
contentBase:"www"
}
}
const path = require("path")
module.exports ={
entry:"./src/index.js",
output:{
publicPath:"zxx",
filename:"bundle.js"
},
devServer:{
port:8080,
contentBase:"www"
}
}
snabbdom的测试环境就搭建好了,下面我们来用一下snabbdom的用力吧,可以直接去他的官网看一下
体验h函数
js
import { h } from "snabbdom";
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D')
]);
console.log(vnode1)
import { h } from "snabbdom";
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D')
]);
console.log(vnode1)
打印结果:
体验diff算法
js
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');
// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D')
]);
patch(container, vnode1);
const vnode2 = h('ul', {}, [
h('li', { key: 'D' }, 'D'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'B' }, 'B')
]);
// 点击按钮时,将vnode1变为vnode2
btn.onclick = function () {
patch(vnode1, vnode2);
};
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 得到盒子和按钮
const container = document.getElementById('container');
const btn = document.getElementById('btn');
// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
const vnode1 = h('ul', {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D')
]);
patch(container, vnode1);
const vnode2 = h('ul', {}, [
h('li', { key: 'D' }, 'D'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'B' }, 'B')
]);
// 点击按钮时,将vnode1变为vnode2
btn.onclick = function () {
patch(vnode1, vnode2);
};
下面我们先手写H函数吧,一个个来。
h函数
js
import vnode from "./vnode"
// 编写一个低配版本的h函数,这个函数必须接受3个参数,缺一不可
// 相当于它的重载功能较弱。
// 也就是说,调用的时候形态必须是下面的三种之一:
// 形态① h('div', {}, '文字')
// 形态② h('div', {}, [])
// 形态③ h('div', {}, h())
export default function (sel, data, c) {
if (arguments.length !== 3) {
throw new Error("对不起,h函数必须传入3个函数,我们是低配版的h函数")
}
//检查参数c的类型
if (typeof c == 'string' || typeof c == 'number') {
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
let children = []
for (let i = 0; i < c.length; i++) {
if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
throw new Error("传入的数组参数中有项不是h函数")
children.push(c[i])
}
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c == "object" && c.hasOwnProperty("sel")) {
let children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error("传入的第三个参数类型不对")
}
}
import vnode from "./vnode"
// 编写一个低配版本的h函数,这个函数必须接受3个参数,缺一不可
// 相当于它的重载功能较弱。
// 也就是说,调用的时候形态必须是下面的三种之一:
// 形态① h('div', {}, '文字')
// 形态② h('div', {}, [])
// 形态③ h('div', {}, h())
export default function (sel, data, c) {
if (arguments.length !== 3) {
throw new Error("对不起,h函数必须传入3个函数,我们是低配版的h函数")
}
//检查参数c的类型
if (typeof c == 'string' || typeof c == 'number') {
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
let children = []
for (let i = 0; i < c.length; i++) {
if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel')))
throw new Error("传入的数组参数中有项不是h函数")
children.push(c[i])
}
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c == "object" && c.hasOwnProperty("sel")) {
let children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error("传入的第三个参数类型不对")
}
}
这样函数主要是用来模板的转为我们所需要的虚拟DOM对象参数
vnode函数
js
export default function (sel, data, children, text, elm) {
const key = data.key;
return {
sel,
data,
children,
text,
elm,
key
}
}
export default function (sel, data, children, text, elm) {
const key = data.key;
return {
sel,
data,
children,
text,
elm,
key
}
}
返回虚拟DOM结构对象
patch函数
js
import vnode from "./vnode";
import createElement from "./createElement";
import patchVnode from "./patchVnode";
export default function patch(oldVnode, newVnode) {
//判断第一个传入参数,是DOM节点还是虚拟节点?
if (oldVnode.sel == '' || oldVnode.sel == undefined) {
//传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, {}, undefined, oldVnode)
}
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
console.log("是同一个节点")
patchVnode(oldVnode,newVnode)
} else {
console.log("不是同一个节点。暴力插入新的,删除旧的")
let newVnodeElm = createElement(newVnode)
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
// console.log("旧节点打印",oldVnode.elm)
oldVnode.elm.parentNode && oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
import vnode from "./vnode";
import createElement from "./createElement";
import patchVnode from "./patchVnode";
export default function patch(oldVnode, newVnode) {
//判断第一个传入参数,是DOM节点还是虚拟节点?
if (oldVnode.sel == '' || oldVnode.sel == undefined) {
//传入的第一个参数是DOM节点,此时要包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, {}, undefined, oldVnode)
}
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
console.log("是同一个节点")
patchVnode(oldVnode,newVnode)
} else {
console.log("不是同一个节点。暴力插入新的,删除旧的")
let newVnodeElm = createElement(newVnode)
if (oldVnode.elm.parentNode && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
// console.log("旧节点打印",oldVnode.elm)
oldVnode.elm.parentNode && oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
diff算法入口;渲染虚拟DOM上树,通过去比较,实现最小量更新,流程如下图:
createElement函数
js
//将vnode创建为真实dom
export default function createElement(vnode) {
let domNode = document.createElement(vnode.sel)
//判断是有子节点还是纯文本
if (vnode.text !== '' && (vnode.children == undefined || vnode.children.length == 0)) {
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
for (let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
let chDom = createElement(ch)
domNode.appendChild(chDom)
}
}
vnode.elm = domNode
return vnode.elm
}
//将vnode创建为真实dom
export default function createElement(vnode) {
let domNode = document.createElement(vnode.sel)
//判断是有子节点还是纯文本
if (vnode.text !== '' && (vnode.children == undefined || vnode.children.length == 0)) {
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
for (let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i]
let chDom = createElement(ch)
domNode.appendChild(chDom)
}
}
vnode.elm = domNode
return vnode.elm
}
根据虚拟DOM创建真实DOM
patchVnode函数
js
import createElement from "./createElement"
import updateChildren from "./updateChildren"
//对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode){
if(oldVnode === newVnode ) return
console.log("我捡来了",oldVnode,newVnode)
if(newVnode.text !== undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
console.log("新vnode有text属性")
if(newVnode.text !== oldVnode.text){
oldVnode.elm.innerText = newVnode.text
}
}else{
console.log("新vnode没有text属性")
if(oldVnode.children !== undefined && oldVnode.children.length > 0){
console.log("新老vnode都有children")
updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
}else{
//老的没有children 新的有children
oldVnode.elm.innerHTML = ''
for(let i =0;i<newVnode.children.length;i++){
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
import createElement from "./createElement"
import updateChildren from "./updateChildren"
//对比同一个虚拟节点
export default function patchVnode(oldVnode, newVnode){
if(oldVnode === newVnode ) return
console.log("我捡来了",oldVnode,newVnode)
if(newVnode.text !== undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
console.log("新vnode有text属性")
if(newVnode.text !== oldVnode.text){
oldVnode.elm.innerText = newVnode.text
}
}else{
console.log("新vnode没有text属性")
if(oldVnode.children !== undefined && oldVnode.children.length > 0){
console.log("新老vnode都有children")
updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
}else{
//老的没有children 新的有children
oldVnode.elm.innerHTML = ''
for(let i =0;i<newVnode.children.length;i++){
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
用于对比同一节点的是否有变化
updateChildren函数
js
import patchVnode from "./patchVnode"
import createElement from "./createElement"
//判断是否为同一个虚拟节点
function checkSameVnode(a, b) {
return a.sel == b.sel && a.key == b.key
}
export default function updateChildren(parentElm, oldCh, newCh) {
console.log(parentElm, oldCh, newCh)
//旧前
let oldStartIdx = 0
//新前
let newStartIdx = 0
//旧后
let oldEndIdx = oldCh.length - 1
//新后
let newEndIdx = newCh.length - 1
//旧前节点
let oldStartVnode = oldCh[0]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新前节点
let newStartVnode = newCh[0]
//新后节点
let newEndVnode = newCh[newEndIdx]
let keyMap = null
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// console.log("☆")
console.log("首先不是判断是否命中的,而是略过已经加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)) {
console.log("①新前与旧前命中", oldStartVnode, newStartVnode)
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log("②新后与旧后命中")
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log("③新后与旧前命中")
patchVnode(oldStartVnode, newEndVnode)
// 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
//为什么是移到老老节点的旧后(此旧后非老节点中最后一个)的后面呢,而不是直接老节点中最后一个节点的的后面?
//因为如果说前面已经命中过了新后与旧后,你的新后和旧后的下标是不是移动了,如果你还直接老节点的最后一个,顺序就不是正常的效果了
//譬如A、B、C、D和A、C、B、D 。A和B分别命中新前与旧前 、新后与新后。当B的时候只能命中到新后与旧前。这是如果插到老节点的最后一个节点的后面,输出顺序就会变为A、C、D、B了
// 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//四种都没有命中
//制作keyMap一个映射对象,这样就不用每次都遍历老对象了
if (!keyMap) {
keyMap = {}
for (let i = oldStartIdx; i < oldEndIdx; i++) {
const key = oldCh[i]
if (key !== undefined) {
keyMap[key] = i
}
}
}
console.log(keyMap)
//寻找当前这项(newStartIdx)在keyMap中的映射的位置序号
const idxInOld = keyMap[newStartVnode.key]
if (idxInOld == undefined) {
// 判断,如果idxInOld是undefined表示它是全新的项
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
//如果不是全新项,则需要进行移动
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
//把当前这项设置为undefined,表示我已经处理过这个了
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
//继续看还有没有剩余的节点,循环结束了start 比old小
if(newStartIdx <= newEndIdx){
console.log("new还有剩余节点没有处理,要加项,要把所有的剩余节点放到oldStartIdx之前")
for(let i =newStartIdx;i<newEndIdx;i++){
parentElm.insertBefore(createElement(newCh[i]),oldCh[oldStartIdx].elm)
}
}else if(oldStartIdx <= oldEndIdx){
console.log("old还有剩余节点没有处理,要删除项")
for(let i =oldStartIdx;i<oldEndIdx;i++){
if(oldCh[i]){
parentElm.removeChild(oldCh[i].elm)
}
}
}
}
import patchVnode from "./patchVnode"
import createElement from "./createElement"
//判断是否为同一个虚拟节点
function checkSameVnode(a, b) {
return a.sel == b.sel && a.key == b.key
}
export default function updateChildren(parentElm, oldCh, newCh) {
console.log(parentElm, oldCh, newCh)
//旧前
let oldStartIdx = 0
//新前
let newStartIdx = 0
//旧后
let oldEndIdx = oldCh.length - 1
//新后
let newEndIdx = newCh.length - 1
//旧前节点
let oldStartVnode = oldCh[0]
//旧后节点
let oldEndVnode = oldCh[oldEndIdx]
//新前节点
let newStartVnode = newCh[0]
//新后节点
let newEndVnode = newCh[newEndIdx]
let keyMap = null
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// console.log("☆")
console.log("首先不是判断是否命中的,而是略过已经加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)) {
console.log("①新前与旧前命中", oldStartVnode, newStartVnode)
patchVnode(oldStartVnode, newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
console.log("②新后与旧后命中")
patchVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {
console.log("③新后与旧前命中")
patchVnode(oldStartVnode, newEndVnode)
// 当③新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
//为什么是移到老老节点的旧后(此旧后非老节点中最后一个)的后面呢,而不是直接老节点中最后一个节点的的后面?
//因为如果说前面已经命中过了新后与旧后,你的新后和旧后的下标是不是移动了,如果你还直接老节点的最后一个,顺序就不是正常的效果了
//譬如A、B、C、D和A、C、B、D 。A和B分别命中新前与旧前 、新后与新后。当B的时候只能命中到新后与旧前。这是如果插到老节点的最后一个节点的后面,输出顺序就会变为A、C、D、B了
// 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {
patchVnode(oldEndVnode, newStartVnode)
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//四种都没有命中
//制作keyMap一个映射对象,这样就不用每次都遍历老对象了
if (!keyMap) {
keyMap = {}
for (let i = oldStartIdx; i < oldEndIdx; i++) {
const key = oldCh[i]
if (key !== undefined) {
keyMap[key] = i
}
}
}
console.log(keyMap)
//寻找当前这项(newStartIdx)在keyMap中的映射的位置序号
const idxInOld = keyMap[newStartVnode.key]
if (idxInOld == undefined) {
// 判断,如果idxInOld是undefined表示它是全新的项
// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
} else {
//如果不是全新项,则需要进行移动
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove, newStartVnode)
//把当前这项设置为undefined,表示我已经处理过这个了
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
//继续看还有没有剩余的节点,循环结束了start 比old小
if(newStartIdx <= newEndIdx){
console.log("new还有剩余节点没有处理,要加项,要把所有的剩余节点放到oldStartIdx之前")
for(let i =newStartIdx;i<newEndIdx;i++){
parentElm.insertBefore(createElement(newCh[i]),oldCh[oldStartIdx].elm)
}
}else if(oldStartIdx <= oldEndIdx){
console.log("old还有剩余节点没有处理,要删除项")
for(let i =oldStartIdx;i<oldEndIdx;i++){
if(oldCh[i]){
parentElm.removeChild(oldCh[i].elm)
}
}
}
}
这个就是整个diff 算法中最为核心的部分,传说中的新前与旧前、新后与旧后、新后与旧前、新前与旧后的过程都在这里了。
总结
这样简易版的snabbdom就完成了。