触发更新
diff
- 响应式数据变更时,setter 触发 Dep.notify(),通知所有关联的 Watcher。
- Watcher 执行组件的渲染函数(render),生成新虚拟 DOM 树(vnode)。
虚拟节点(vnode)的核心属性
diff
- sel:标签选择器(如 div#app,含标签名、id、class)。
- data:包含节点属性(如 class、style,其中 key 是核心)。
- children:子节点数组(与 text 互斥)。
- text:文本内容(若存在,则无 children)。
- elm:对应的真实 DOM 元素。
patch
方法:判断节点复用性
markdown
- 是否为同一节点:通过 sel 和 key 判断(key 优先级更高)。
- 不同:暴力更新,创建并插入新节点
- 相同:进入 `patchVnode` 精细化对比。
patchVnode
:精细化对比 (这里我们要知道我们要尽量复用节点,也就是说所有的更新操作在旧节点上)
markdown
- 文本节点:
- 新节点有 text → 直接更新 旧节点 oldvnode.elm.innerText = text。
- 子节点处理:
- 旧无子,新有子:批量插入新子节点到父容器(oldVnode.elm)。
- 旧有子,新无子:清空旧节点的所有子节点。
- 新旧均有子 → 调用 updateChildren 进行双端对比(核心逻辑)。
updateChildren
:双端对比核心
整个流程图
h函数 是用来创建一个虚拟节点 这里我们做一个简易的h函数,实际上h函数参数不止这么多 这里我们只显示以下几种方式的调用
h('div', { }, 'hello')
h('div', { }, [ ])
h('div', { }, h( ))
js
h(sel,data,c){
if(arguments.length != 3){
throw new Error('参数必须为3个')
}
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函数')
//这里不需要执行c[i],因为c[i]已经是一个虚拟节点
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('传入的参数类型错误')
}
}
//vnode函数很简单用来返回虚拟节点
export default function vnode(sel,data,children,text,elm){
const key = data === undefined ? undefined : data.key
return {sel,data,children,text,elm,key}
}
创建一个 <div>
元素
js
h('div', { class: 'container' }, 'Hello World')
我们需要几个工具函数
js
import vnode from './vnode'
//判断是否为虚拟节点
export function isVnode(vnode){
return vnode.sel !== undefined
//将dom节点转换为虚拟节点
export function emptyNodeAt (elm) {
// tag其实就是就是标签元素比如div , span
return vnode(elm.tagName.toLowerCase() , {}, [], undefined, elm)
}
//判断是否为相同节点
export function isSameVnode(vnode1,vnode2){
return vnode1.sel == vnode2.sel && vnode1.key == vnode2.key
}
//根据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
}
准备工作完成
js
import h from './mysnabbdom/h'
import patch from './mysnabbdom/patch'
//创建虚拟节点
var myNode1 = h('h2',{},[
h('p',{key:"a"},"a"),
h('p',{key:"b"},"b"),
h('p',{key:"c"},"c"),
])
var vnode2 = h('h2',{},[
h('p',{key:"a"},"a"),
h('p',{key:"b"},"b"),
h('p',{key:"f"},"f"),
h('p',{key:"c"},"c"),
h('p',{key:"d"},"d"),
])
//patch函数,让虚拟节点上树
const container = document.getElementById('container')
patch(container,myNode1)
const btn = document.getElementById('btn')
btn.onclick = function(){
patch(myNode1,vnode2)
}
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<button id="btn">点击更改Dom</button>
<script src="/dist/bundle.js"></script>
</body>
</html>
patch
函数
js
export default function patch(oldVnode, newVnode){
//判断oldVnode是否 为虚拟节点
if(!isVnode(oldVnode) ){
oldVnode = emptyNodeAt(oldVnode)
}
if(isSameVnode(oldVnode,newVnode)){
console.log('是同一个节点')
patchVnode(oldVnode,newVnode)
}else{
console.log('不是同一个节点,暴力插入新的,删除旧的')
let newVnodeElm = createElement(newVnode)
if(oldVnode.elm.parentNode && newVnodeElm){
oldVnode.elm.parentNode.insertBefore( newVnodeElm, oldVnode.elm)
}
}
}
patchVnode
函数
按照我的理解这个函数其实就是给旧节点打补丁,保持与新节点一致
注意 :回想我开头的提到 children
与 text
相斥
js
export default function patchVnode(oldVnode,newVnode){
//判断新旧节点是否为同一个对象
if(oldVnode === newVnode){
return
}
// 判断新节点是否有text属性
if(newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)){
console.log('新节点有text属性')
if(newVnode.text !== oldVnode.text){
oldVnode.elm.innerText = newVnode.text
}
}else {
console.log('新节点没有text属性') // 没有text意味着有chidlren
//判断旧节点是否有children属性
if(oldVnode.children !== undefined && oldVnode.children.length > 0){
console.log('旧节点有children属性')
// 注意 这里是diff的核心。新旧节点都存在children
updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
}else{
console.log('旧节点没有children属性')
oldVnode.elm.innerHTML = ''
for(let i = 0; i < newVnode.children.length; i++){
let ch = newVnode.children[i]
let chDom = createElement(ch)
oldVnode.elm.appendChild(chDom)
}
}
}
//patch方法就是让 newVNode 及其所有的子元素,都能够正确的挂载上DOM元素。 如果 oldVNode 的有现成的DOM,就扔给 newVNode
newVnode.elm = oldVnode.elm
}
updateChildren
方法
diff算法的核心采用双端对比策略 因此我们需要 4个指针 分别指向旧节点头部,尾部 和新节点头部,尾部
新前 vs 旧前(头头相同):直接复用节点,指针后移。
新后 vs 旧后(尾尾相同):直接复用节点,指针前移。
新后 vs 旧前(尾头相同):将旧前节点移动到旧后之后,旧前指针后移,新后指针前移。
新前 vs 旧后(头尾相同):将旧后节点移动到旧前之前,旧后指针前移,新前指针后移。
按上述优先级依次判断,若均未命中,则通过key
查找可复用节点。
如何移动节点?
在DOM
中,如果我们插入一个已经在DOM
树中已经存在的元素,他就会被移动
js
let oldStartIdx = 0 //旧前指针
let newStartIdx = 0 // 新前指针
let oldEndIdx = oldCh.length - 1 //旧后指针
let newEndIdx = newCh.length - 1 //新后指针
let oldStartVnode = oldCh[oldStartIdx] //旧前节点
let newStartVnode = newCh[newStartIdx] //新前节点
let oldEndVnode = oldCh[oldEndIdx] //旧后节点
let newEndVnode = newCh[newEndIdx] //新后节点
我们需要在一个循环里依次进行匹配
头节点超过尾节点就代表循环结束
js
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(oldStartVnode == undefined){
oldStartVnode = oldCh[++oldStartIdx]
}
else if(oldEndVnode == undefined){
oldEndVnode = oldCh[--oldEndIdx]
}
else if(newStartVnode == undefined){
newStartVnode = newCh[++newStartIdx]
}
else if(newEndVnode == undefined){
newEndVnode = newCh[--newEndIdx]
}
else if(isSameVnode(oldStartVnode,newStartVnode)){
console.log('第一种情况---新前后前对比');
patchVnode(oldStartVnode,newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
else if(isSameVnode(oldEndVnode,newEndVnode)) {
console.log('第二种情况---新后旧后对比');
patchVnode(oldEndVnode,newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
else if(isSameVnode(oldStartVnode,newEndVnode)) {
console.log('第三种情况---新后旧前对比');
patchVnode(oldStartVnode,newEndVnode)
// 移动节点 ,把新前节点移动到旧后节点后面
//移动节点 只要你插入一个已经在Dom树上的节点,他就会被移动
parentElm.insertBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
else if(isSameVnode(oldEndVnode,newStartVnode)) {
console.log('第四种情况---新前旧后对比');
patchVnode(oldEndVnode,newStartVnode)
// 新前节点移动到旧前节点前面
parentElm.insertBefore(oldEndVnode.elm,oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
else {
console.log('四种情况都不匹配');
- 建立旧子节点的 `key` 到索引的映射表(`keyMap`)。
- 用新子节点的 `key` 查找可复用节点:
- 找到 → 移动节点到正确位置。
- 未找到 → 创建新节点插入。
if(!keyMap){
keyMap = {}
for(let i = oldStartIdx; i <= oldEndIdx; i++){
let key = oldCh[i].key
if(key){
keyMap[key] = i
}
}
}
console.log("🚀 ~ updateChildren ~ keyMap:", keyMap)
const idxInOld = keyMap[newStartVnode.key]
if(idxInOld == undefined){
// 如果idxInOld不存在,则说明新节点在旧节点中不存在
parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
} else {
/// 如果idxInOld存在,则说明新节点在旧节点中存在,则需要移动节点
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove,newStartVnode)
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm,oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
循环结束如果
-
旧子节点剩余 → 批量删除。 新节点有删除的情况,旧节点没有处理完,说明旧前跟旧后指针之间的节点都是多余的删除
-
新子节点剩余 → 批量插入。 就是新节点有新增的情况,新节点没有处理完,说明新前很新后之间的节点都是新增的需要插入
完整代码
js
export default function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let newEndIdx = newCh.length - 1
let oldStartVnode = oldCh[oldStartIdx]
let newStartVnode = newCh[newStartIdx]
let oldEndVnode = oldCh[oldEndIdx]
let newEndVnode = newCh[newEndIdx]
let keyMap = null
// 开始遍历
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(oldStartVnode == undefined){
oldStartVnode = oldCh[++oldStartIdx]
}
else if(oldEndVnode == undefined){
oldEndVnode = oldCh[--oldEndIdx]
}
else if(newStartVnode == undefined){
newStartVnode = newCh[++newStartIdx]
}
else if(newEndVnode == undefined){
newEndVnode = newCh[--newEndIdx]
}
else if(isSameVnode(oldStartVnode,newStartVnode)){
console.log('第一种情况---新前后前对比');
patchVnode(oldStartVnode,newStartVnode)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
}
else if(isSameVnode(oldEndVnode,newEndVnode)) {
console.log('第二种情况---新后旧后对比');
patchVnode(oldEndVnode,newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
}
else if(isSameVnode(oldStartVnode,newEndVnode)) {
console.log('第三种情况---新后旧前对比');
patchVnode(oldStartVnode,newEndVnode)
// 移动节点 ,把新前节点移动到旧后节点后面
//移动节点 只要你插入一个已经在Dom树上的节点,他就会被移动
parentElm.insertBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
}
else if(isSameVnode(oldEndVnode,newStartVnode)) {
console.log('第四种情况---新前旧后对比');
patchVnode(oldEndVnode,newStartVnode)
// 新前节点移动到旧前节点前面
parentElm.insertBefore(oldEndVnode.elm,oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
}
else {
console.log('四种情况都不匹配');
if(!keyMap){
keyMap = {}
for(let i = oldStartIdx; i <= oldEndIdx; i++){
let key = oldCh[i].key
if(key){
keyMap[key] = i
}
}
}
console.log("🚀 ~ updateChildren ~ keyMap:", keyMap)
const idxInOld = keyMap[newStartVnode.key]
if(idxInOld == undefined){
// 如果idxInOld不存在,则说明新节点在旧节点中不存在
parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
} else {
/// 如果idxInOld存在,则说明新节点在旧节点中存在,则需要移动节点
const elmToMove = oldCh[idxInOld]
patchVnode(elmToMove,newStartVnode)
oldCh[idxInOld] = undefined
parentElm.insertBefore(elmToMove.elm,oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
if(newStartIdx <= newEndIdx){
console.log('就是新节点有新增的情况,新节点没有处理完');
const before = newCh[ newEndIdx +1] == null? null : newCh[newEndIdx+1].elm
console.log("🚀 ~ updateChildren ~ before:", before)
for(let i = newStartIdx; i <= newEndIdx; i++){
//insertBefore 如果是null 则插入到父元素的最后一个子元素后面 与appendChild一样
parentElm.insertBefore( createElement(newCh[i]), before)
}
}
else if(oldStartIdx <= oldEndIdx){
console.log('新节点有删除的情况,旧节点没有处理完');
for(let i = oldStartIdx; i <= oldEndIdx; i++){
parentElm.removeChild(oldCh[i].elm)
}
}
}