减少DOM操作的性能开销
核心diff只关心新旧虚拟节点都存在一组子节点的情况. 首先遍历长度较短的一组, 尽可能调用patch函数进行更新. 如果新子节点更长, 说明需要挂载新子节点; 如果旧节点更长, 则有旧子节点需要卸载.
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
const oldC = n1.children, newC = n2.children,
oldL = oldC.length, newL = newC.length;
const len = Math.min(oldL, newL)
// 首先比较公共长度的部分
for(let i = 0; i < len; i++) {
patch(oldC[i], newC[i], container)
}
if(newL > oldL){
// 新子节点更长, 需要挂载多出的部分
for(let i = len; i < newL; i++) {
patch(null, newC[i], container)
}
} else if (newL < oldL){
// 旧子节点更长, 需要卸载多出的部分
for(let i = len; i < oldL; i++) {
unmount(oldC[i])
}
}
} else {
// ...
}
}
dom复用与key的作用
前面通过减少dom操作的次数(比较新旧子节点数, 进行patch, 然后根据情况删除或插入其余的子节点. 而不是将旧子节点全部卸载, 插入新的子节点), 减少性能开销.
但是对于相同的子节点, 可以通过dom的移动完成子节点的更新. 避免卸载和挂载的浪费. key属性就像虚拟节点的“身份证”号, 只要两个虚拟节点的type属性和key属性都相同, 就认为它们是相同的, 可以进行dom的复用.
// 可复用不代表不需要更新, 仍需要进行打补丁, 因为其子节点内容已发生变化
const oldVnode = { type: 'p', key: 1, children: 'text 1' }
const newVnode = { type: 'p', key: 1, children: 'text 2' }
// 对子节点进行patch
function patchChildren(n1, n2, container) {
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
const oldC = n1.children, newC = n2.children
// 遍历新旧节点,
for(let i = 0; i < newC.length; i++) {
const newVnode = newC[i]
for(let j = 0; j < oldC.length; j++) {
const oldVnode = oldC[i]
// 两个子节点可复用, 但仍需要调用patch函数更新
if(newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container)
break
}
}
}
}
}
找到旧子节点中所有可复用的节点, 调用patch函数进行更新. 保证可复用的节点都更新完毕.
找到要移动的元素
首先, 新旧子节点的节点顺序不变时, 就不需要额外的操作.
记录在旧节点中寻找具有相同key值节点的过程中, 遇到的最大索引值. 在后续寻找过程中, 如果存在索引值比当前遇到的最大索引值还小的节点, 则意味着该节点需要移动.
// newVnode oldVnode
// p-3 p-1
// p-1 p-2
// p-2 p-3
// 1. p-3在旧节点中相同节点的索引为 2.
// 2. p-1在旧节点索引为 0, 则 p-1需要移动
// 3. p-2在旧节点索引为 1, 也需要移动
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
const oldC = n1.children, newC = n2.children;
// 存储寻找过程遇到的最大索引
let lastIndex = 0
for(let i < 0; i < newC.length; i++){
const newVnode = newC[i]
for(let j = 0; j < oldC.length; j++) {
const oldVnode = oldC[i]
if(newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container)
if(j < lastIndex) {
// 当前节点在旧children中的索引小于最大索引值, 则该节点需要移动
} else {
// 如果过程中当前节点在旧children中的索引不小于最大索引值, 则更新 lastIndex
lastIndex = j
}
break
}
}
}
} else {
// ...
}
}
如何移动元素
移动节点指的是移动一个虚拟节点对应的真实dom节点, 而不是其本身.
当更新操作发生时, 渲染器会调用patchElement函数在新旧虚拟节点间打补丁. 在复用了元素之后, 新节点也将持有对真实dom的引用.
将当前节点对应的真实dom移动到前一个节点对应的真实dom后面完成移动.
// 打补丁操作
function patchElement(n1, n2){
// 新的vnode也引用了dom元素(dom元素的复用)
const el = n2.el = n1.el
// ...
}
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
const oldC = n1.children, newC = n2.children;
let lastIndex = 0
for(let i < 0; i < newC.length; i++){
const newVnode = newC[i]
let j = 0;
for(j; j < oldC.length; j++) {
const oldVnode = oldC[i]
if(newVnode.key === oldVnode.key) {
patch(oldVnode, newVnode, container)
// newVnode对应的真实dom需要移动
if(j < lastIndex) {
const prevVnode = newC[i - 1]
// 将新节点对应的真实dom插入到prevVnode对应的真实dom的后面
if(prevVnode){
const anchor = prevVnode.el.nextSibling
insert(newVnode.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
}
} else {
// ...
}
}
添加新元素
当新子节点中的节点, 在旧子节点中不存在时, 就是新增节点.
function patchChildren(){
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
const oldC = n1.children, newC = n2.children;
let lastIndex = 0
// 遍历新子节点, 定义find变量用于标识是否在旧子节点中找到此节点
for(let i = 0; i < newC.length; i++){
const newVonde = newC[i]
let j = 0, find = false
// 遍历旧子节点, 与新子节点中当前节点逐个比较
for(j; j < oldC.length; j++) {
const oldVnode = oldC[i]
// 节点可复用, 则找到了对应节点
if(newVonde.key === oldVnode.key){
find = true
patch(oldVnode, newVonde, container)
if(j < lastIndex){
const prevVnode = newC[i - 1]
if(prevVnode){
insert(newVonde.el, container, anchor)
}
} else {
lastIndex = j
}
break
}
}
// 没有找到可复用的节点, 则当前节点是新增节点
if(!find){
// 将新增节点挂载至合适位置, 需要先找到锚点元素
const prevVnode = newC[i - 1]
let anchor
// 前一个节点存在, 则锚点元素是它的下一个兄弟节点
if(prevVnode) {
anchor = prevVnode.el.nextSibling
// 前一个节点不存在, 则锚点元素是第一个子节点
} else {
anchor = container.firstChild
}
// 挂载newVonde
patch(null, newVonde, container, anchor)
}
}
}
}
// 新增入参 锚点元素
function patch(n1, n2, container, anchor){
// ...
if(typeof type === 'string') {
if(!n1) {
mountElement(n2, container, anchor)
} else {
patchElement(n1, n2)
}
} else if(typeof type === Text) {
// ...
} else if(typeof type === Fragment) {
// ...
}
}
// 锚点元素 记录插入位置
function mountElement(vnode, container, anchor){
// ...
insert(el, container, anchor)
}
移除不存在的元素
当更新结束时, 需要遍历旧的一组子节点, 然后去新的一组子节点中寻找具有相同key的节点. 如果找不到则删除该节点.
function patchChildren(n1, n2, container){
if(typeof n2.children === 'string'){
// ...
} else if (Array.isArray(n2.children)){
const oldC = n1.children, newC = n2.children;
let lastIndex = 0
for(let i < 0; i < newC.length; i++){
// ...
}
// 上一步的更新操作完成后, 遍历旧的子节点
for(let i < 0; i < oldC.length; i++){
const oldVnode = oldC[i]
const has = newC.find(vnode => vnode.key === oldVnode.key)
// 如果新子节地中不存在, 则卸载当前子节点
if(!has) {
unmount(oldVnode)
}
}
} else {
// ...
}
}
总结
- Diff算法用于计算两组子节点的差异, 并试图最大程度地复用dom元素.
- key属性是虚拟节点的“身份证号”. 在更新时渲染器通过key属性找到可复用的节点, 然后在更新时尽可能地通过dom移动操作来完成更新, 避免过多地对dom元素进行销毁和重建.
- 简单diff算法的核心逻辑是, 拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点. 如果找到了, 则记录该节点的索引(最大索引). 在整个更新过程中, 如果一个节点的索引小于最大索引, 则说明该节点对应的dom元素需要移动.