一、初始化项目
安装webpack webpack-cli webpack-dev
配置webpack.config.js
const path = require('path');
module.exports = {
// 入口
entry: './src/index.js',
// 出口
output: {
// 虚拟打包路径,就是说文件夹不会真正生成,而是在8080端口虚拟生成
publicPath: 'xuni',
// 打包出来的文件名,不会真正的物理生成
filename: 'bundle.js'
},
devServer: {
// 端口号
port: 8080,
// 静态资源文件夹
contentBase: 'public'
}
}
二、创建h函数和Vnode函数
h函数的目的是创建虚拟节点vnode
这里简化h函数的形式,h函数接收三个参数,第一个参数是标签,第二个参数是标签的属性,第三个参数是文本内容或者子节点(字符串或者数组)
//h.js
import vnode from './vnode'
export function h(sel,data,params){
//h函数的第三个是字符串类型,意味没有子元素
if(typeof params === 'string'){
return vnode(sel,data,undefined,params,undefined)
}else if(Array.isArray(params)){ //h函数的第三个参数是数组,意味有子元素
let children = []
for(let item of params){
children.push(item)
}
return vnode(sel,data,children,undefined,undefined)
}
}
h函数传递参数到vnode函数,vnode函数目的是创建虚拟DOM对应的对象结构
//vnode.js
//接受参数,sel对应元素标签,data对应元素标签的属性,text对应元素的文本,elm对应真实DOM节点,children对应的是子元素
export default function vnode(sel,data,children,text,elm){
return {
sel,
data,
children,
text,
elm
}
}
三、patch函数/createElement函数/patchVnode函数
patch函数的目的是对比新旧的虚拟节点,从而把虚拟节点生成为DOM节点放到页面中
接受两个参数,第一个参数是旧的虚拟节点,第二个参数是新的虚拟节点
新老节点的替换规则:
-
规则1:只能同层比较,不能跨层比较
-
规则2:如果新老节点不是同一个节点,则暴力删除旧节点,插入创建新的节点
//patch.js
import vnode from "./vnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
//如果oldVnode没有sel,证明不是虚拟节点(就让他变成虚拟节点)
if (oldVnode.sel === undefined) {
oldVnode = vnode(
oldVnode.tagName.toLowerCase(),//sel
{},//data
[],//children
undefined,//text
oldVnode //elm
)
}
//判断 旧的虚拟节点和 新的虚拟节点 是否是同一个节点
if (oldVnode.sel === newVnode.sel) {
//如果是同一个节点,判断条件会复杂,用patchVnode函数来处理,后面有提及
patchVnode(oldVnode,newVnode)
} else {
//如果不是同一个节点就暴力删除旧节点,创建插入新的节点
//把新的虚拟节点创建为真实DOM节点
let newVnodeElemnt = createElement(newVnode)
//获取旧的虚拟节点所对应的真实DOM
let oldVnodeElement = oldVnode.elm
//删除并替换旧的DOM
if(newVnodeElemnt){
oldVnodeElement.parentNode.insertBefore(newVnodeElemnt,oldVnodeElement)
}
oldVnodeElement.parentNode.removeChild(oldVnodeElement)
}
}
这时候就要创建createElement函数,这个函数是根据虚拟节点创造真实DOM
//createElement.js
export default function createElement(vnode){
//根据虚拟节点的标签sel创建DOM节点
let domNode = document.createElement(vnode.sel)
//判断有没有子节点 children是不是undefined
if(vnode.children === undefined){
domNode.innerText = vnode.text
}else if(Array.isArray(vnode.children)){
//说明内部有子节点,需要递归创建子节点
for(let child of vnode.children){
let childDom = createElement(child)
domNode.appendChild(childDom)
}
}
//补充elm属性
vnode.elm = domNode
return domNode
}
-
规则3:如果是相同节点,又分为很多情况
通过patchVnode函数来处理,第一个参数是旧的虚拟节点,第二个参数是新的虚拟节点
-
情况1:新节点没有children,说明新节点是文本,不管旧的节点有没有children,直接替换真实DOM的文本内容
-
情况2:新节点有children,旧的节点也有children,最复杂的情况,匹配策略有六种,在第四节详细讲
- 旧前和新前
- 旧后和新后
- 旧前和新后
- 旧后和新前
- 以上都不满足,查找
- 创建或者删除
-
情况3:新节点有children,旧的节点没有children,把旧的内容删除清空,添加新的
-
//patchVnode.js
import createElement from "./createElement"
export default function patchVnode(oldVnode,newVnode){
//判断新的虚拟节点有没有children
if(newVnode.children === undefined){ //新的虚拟节点没有子节点
//新的虚拟节点的文本和旧的虚拟节点的文本是否一样
if(newVnode.text !== oldVnode.text ){//如果不一样,替换真实DOM的文本内容,对应情况1
oldVnode.elm.innerText = newVnode.text
}
}else{ //新的虚拟节点有子节点
//对应情况2:新的虚拟节点有子节点,旧的虚拟节点有子节点
if(oldVnode.children !== undefined && oldVnode.children.length > 0){
//比较复杂待会解释
}else{ //对应情况3:新的虚拟节点有子节点,旧的虚拟节点没有子节点
//清空旧节点的HTML内容
oldVnode.elm.innerHTML = ''
//遍历子节点创造DOM元素,添加到页面之中
for(let child of newVnode.children){
let childDom = createElement(child)
oldVnode.elm.appendChild(childDom)
}
}
}
}
四、新旧节点是同一个虚拟节点,且都有子节点(最复杂的情况,DIFF算法核心)
如果要提升性能,一定要添加key,key是虚拟DOM的唯一表示表示,在更改前后确定是否是同一个节点
6种策略顺序:
匹配策略:key相同,选择器相同说明匹配上了
- 旧前和新前
匹配:说明是同一个节点,同时旧前的指针++,新前的指针++
- 旧后和新后
匹配:旧后的指针--,新后的指针--
- 旧前和新后
匹配:旧前的指针++,新后的指针--
- 旧后和新前
匹配:旧后的指针--,新前的指针++
- 以上都不满足,通过循环来查找
循环旧节点,查找到有相同节点的话,标记为undefined
- 创建或者删除
首先先添加key,返回的vnode中要添加key
//vnode.js
export default function vnode(sel,data,children,text,elm){
let key = data.key
return {
sel,
data,
children,
text,
elm,
key
}
}
然后创建updateChildren函数,接受三个参数,第一个参数是旧节点对应的真实DOM,第二个参数是旧节点的子节点,第三个参数是新节点的子节点
1.前五种匹配策略
//updateChildren.js
import patchVnode from './patchVnode'
import createElement from './createElement'
//判断是否是同一个节点
function isSameNode(vnode1, vnode2) {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}
export default function updateChildren(parentElm, oldChildren, newChildren) {
//声明旧前旧后、新前新后四种指针
let oldStartIndex = 0,
oldEndIndex = oldChildren.length - 1,
newStartIndex = 0,
newEndIndex = newChildren.length - 1
//声明指针对应的节点
let oldStartVnode = oldChildren[0],
oldEndVnode = oldChildren[oldEndIndex],
newStartVnode = newChildren[0],
newEndVnode = newChildren[newEndIndex]
while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
// 首先应该不是判断四种命中,而是略过已经加了undefined标记的项
if (oldStartVnode === null || oldChildren[oldStartIndex] === undefined) {
oldStartVnode = oldChildren[++oldStartIndex];
} else if (oldEndVnode === null || oldChildren[oldEndIndex] === undefined) {
oldEndVnode = oldChildren[--oldEndIndex];
} else if (isSameNode(oldStartVnode, newStartVnode)) {
//第一种情况:旧前和新前
patchVnode(oldStartVnode, newStartVnode)
//配置elm
if (newStartVnode) { newStartVnode.elm = oldStartVnode?.elm }
//指针++
oldStartVnode = oldChildren[++oldStartIndex]
newStartVnode = newChildren[++newStartIndex]
} else if (isSameNode(oldEndVnode, newEndVnode)) {
//第二种情况:旧后和新后
patchVnode(oldEndVnode, newEndVnode)
//配置elm
if (newEndVnode) { newEndVnode.elm = oldEndVnode?.elm }
//指针--
oldEndVnode = oldChildren[--oldEndIndex]
newEndVnode = newChildren[--newEndIndex]
} else if (isSameNode(oldStartVnode, newEndVnode)) {
//第三种情况:旧前和新后,需要移动位置
patchVnode(oldStartVnode, newEndVnode)
//配置elm
if (newEndVnode) { newEndVnode.elm = oldStartVnode?.elm }
//把旧前指向的节点移动到旧后指向的节点的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
//指针
oldStartVnode = oldChildren[++oldStartIndex]
newEndVnode = newChildren[--newEndIndex]
} else if (isSameNode(oldEndVnode, newStartVnode)) {
//第四种情况:旧后和新前,需要移动位置
patchVnode(oldEndVnode, newStartVnode)
//配置elm
if (newStartVnode) { newStartVnode.elm = oldEndVnode?.elm }
//把旧后指向的节点移动到旧前指向的节点的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
//指针
oldEndVnode = oldChildren[--oldEndIndex]
newStartVnode = newChildren[++newStartIndex]
} else {
//以上都不满足,查找
//创建一个对象keymap,存放旧的虚拟节点,判断新旧有没有相同的节点
//遍历旧节点,将key和index形成一一映射关系存放到对象keymap中
const keyMap = {}
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
const key = oldChildren[i]?.key
if (key) keyMap[key] = i
}
//在旧节点中查找新前指针指向的节点,根据key来找
let indexInOld = keyMap[newStartVnode.key]
if (indexInOld) {
//如果有,说明该数据在新旧虚拟节点都存在
const elmMove = oldChildren[indexInOld]
patchVnode(elmMove, newStartVnode)
//处理过的节点,在旧的虚拟节点中设置为undefined,并且移动位置到旧前指针指向的节点的前面
oldChildren[indexInOld] = undefined
parentElm.insertBefore(elmMove.elm, oldStartVnode.elm)
} else {
//如果没有找到,说明是一个新节点,需要创建DOM元素,放在旧节点的最前面
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
}
//让新数据(指针)++
newStartVnode = newChildren[++newStartIndex]
}
}
}
2.添加与删除
这里的逻辑还需要完善下,但是总体已经把DIFF算法核心总结了一波
//update
//结束循环只有两种情况,新增和删除
if (oldStartIndex > oldEndIndex) {
// 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
/ 插入的标杆
const before = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1].elm : null
for (let i = newStartIndex; i <= newEndIndex; i++) {
// insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
parentElm.insertBefore(createElement(newChildren[i]), before);
}
} else if (oldStartIndex <= oldEndIndex) {
// 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
for (let i = oldStartIndex; i <= oldEndIndex; i++) {
if (oldChildren[i]) {
parentElm.removeChild(oldChildren[i].elm);
}
}
}
五、简单测试一波
//index.js
import h from './dom/h'
import patch from './dom/patch'
let vnode1 = h('ul',{},[
h('li',{key:'a'},'a'),
h('li',{key:'b'},'b'),
h('li',{key:'c'},'c')
])
let vnode2 = h('ul',{},[
h('li',{key:'cd'},'cd'),
h('li',{key:'a'},'a'),
])
//在页面中创建一个空的div,id为container,以及button按钮
let container = document.getElementById('container')
let button = document.querySelector('button')
patch(container,vnode1)
button.addEventListener('click',()=>{
patch(vnode1,vnode2)
})
运行yarn dev
点击Button