前面几节我们学习了解了 react
的渲染机制和生命周期,本节我们正式进入基本面试必考的核心地带 -- diff
算法,了解如何优化和复用 dom
操作的,还有我们常见的 key
的作用。
diff
算法使用在子都是数组的情况下,这点和vue
是一样的。如果元素是其他类型的话直接替换就好。
事例分析
按照之前的 diff
写法,如果元素不同我们是直接删了 a
再插入的:
按照上面图的结构,我们需要知道那个元素变化了,其实右边相对左边只是把 A
做了移动,没有 dom
元素的删除和新增。
diff
特点
- 同级对比
On
- 类型不一样销毁老的,创建新的
- 通过
key
标识
key
这里需要标识,主要是为了列表中有删除新增时有优化效果,如果纯静态列表,只是展示作用,key
意义不大。
diff 思路
- 使用
map
存储节点状态,格式如下:
let map = {
keyA: ADOM,
keyB: BDOM
}
- 定义 lastPlacedIndex 记录上一个不需要移动的老节点
默认 lastPlacedIndex = 0
,上一个不需要移动的节点,在循环新的子虚拟 dom
时,如果老节点的挂载索引小于当前值,则改变 lastPlacedIndex
。这里有点类似 vue
的最长递增子序列,最大的保证不变的 dom
元素,只是判断方式不同。
- 循环新数组
- 先出
A
,map
中如果有A
,表示可以复用- 判断
A
的老挂载索引和lastPlacedIndex
对比,如果索引值大,A
节点不需要移动,更新lastPlacedIndex
的值;否则循环到B
,挂载索引小,需要移动B
;循环到G
,map
中没有值,需要新增;新的数组节点循环完,未用到的老节点全部删除。
- 判断
实现 diff 算法
修改入口文件
代码语言:txt复制// src/index.js
class Counter extends React.Component {
constructor(props) {
super(props)
this.state = {list: ['A','B', 'C', 'D', 'E', 'F']}
}
handleClick = () => {
this.setState({
list: ['A', 'C', 'E', 'B', 'G']
})
}
render() {
// 使用空标签
return <React.Fragment>
<ul>
{this.state.list.map(item => {
// 这里使用 key 标识
return <li key={item}>{item}</li>
})}
</ul>
<button onClick={this.handleClick}>add 1</button>
</React.Fragment>
}
}
实现 React.Fragment
Fragment
就是代码片段,不占用dom
结构。简写<></>
,对应dom
操作为createDocumentFragment
。
- 是用原生库打印,看结构
可以发现就是一个简单的 Symbol
,所以需要定义新的类型:
代码语言:txt复制为什么一个简单的
Symbol
可以被渲染成片段呢?依赖于babel
解析。
// src/constants.js
export const REACT_FRAGMENT = Symbol("react.fragment") // React.Fragment 标签
// 备用,diff 时做 patch 的 type 定义
// 新的插入
export const PLACEMENT = 'PLACEMENT'
// 复用的移动
export const MOVE = 'MOVE'
在创建元素的时候进行类型判断,记得 react.js
中导出
// src/react-dom.js
// createDOM 方法
else if (type === REACT_FRAGMENT) {
// fragment 片段
dom = document.createDocumentFragment()
}
// updateElement 方法
else if (oldVdom.type === REACT_FRAGMENT) {
// fragment 不需要对比,直接对比 子 就可以了
const currentDOM = newVdom.dom = findDOM(oldVdom)
updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children)
}
我们需要修改 children 对比
之前逻辑:
代码语言:txt复制// src/react-dom.js
// diff 没有做复用,直接做的替换
function updateChildren(parentDOM, oldVChildren, newVChildren) {
// 拿到最长的
let maxLength = Math.max(oldVChildren.length, newVChildren.length);
for (let i = 0; i < maxLength; i ) {
// 不能直接 appendChild 进父,需要找到当前操作的节点的下一个节点。在其前面插入
const nextVdom = oldVChildren.find((item, index) => index > i && item && findDOM(item))
compareTwoVdom(parentDOM, oldVChildren[i], newVChildren[i], findDOM(nextVdom));
}
}
新的逻辑(参考上面的流程):
代码语言:txt复制// diff
function updateChildren(parentDOM, oldVChildren, newVChildren) {
oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : [oldVChildren];
newVChildren = Array.isArray(newVChildren) ? newVChildren : [newVChildren];
// 1.循环老结构, 构建map存储 key: dom
const keydOldMap = {}
let lastPlacedIndex = 0
oldVChildren.forEach((oldVChild, index) => {
let oldKey = oldVChild?.key || index // 写key 了就用key,没写默认 index
keydOldMap[oldKey] = oldVChild
})
// 2. 创建 dom 补丁包,收集 dom 操作
const patch = []
newVChildren.forEach((newVChild, index) => {
newVChild.mountIndex = index // 为新元素每个添加索引标识
const newKey = newVChild?.key || index
const oldVChild = keydOldMap[newKey] // 看有没有存
if(oldVChild) {
// 如果有老的,就去更新老节点 这里直接可以复用
updateElement(findDOM(oldVChild).parentNode, oldVChild, newVChild)
if(oldVChild.mountIndex < lastPlacedIndex) {
patch.push({
type: MOVE,
oldVChild,
newVChild,
mountIndex: index // 旧的移动到新的的位置
})
}
// 复用过了 删除掉
delete keydOldMap[newKey]
lastPlacedIndex = Math.max(lastPlacedIndex, oldVChild.mountIndex)// 取最大
} else {
// 新的
patch.push({
type: PLACEMENT,
newVChild,
mountIndex: index
})
}
})
// 找到需要移动的老节点
const moveVChildren = patch.filter(action => action.type === MOVE).map(action => action.oldVChild)
// 把要删除的节点 和 要移动的节点先全删除 (页面里没有了,但是内存中还存在 patch 中有存)
Object.values(keydOldMap).concat(moveVChildren).forEach(oldVdom => {
let currentDOM = findDOM(oldVdom)
currentDOM.remove()
})
patch.forEach(action => {
const {type, oldVChild, newVChild, mountIndex} = action
// 老的真实子节点
const childNodes = parentDOM.childNodes
// 新的插入
if (type === PLACEMENT) {
let newDOM = createDOM(newVChild)
let childNode = childNodes[mountIndex] // 老真实节点
if (childNode) {
// 往 老的父对应位置插入
parentDOM.insertBefore(newDOM, childNode)
} else {
parentDOM.appendChild(newDOM)
}
} else if (type === MOVE) {
// 移动不用创建 新 dom,复用
let oldDOM = findDOM(oldVChild)
let childNode = childNodes[mountIndex] // 老真实节点
if (childNode) {
// 往 老的父对应位置插入
parentDOM.insertBefore(oldDOM, childNode)
} else {
parentDOM.appendChild(oldDOM)
}
}
})
}
实现如下跟原生一致,可以看到,三个节点实现了复用,即 A, C, E
如果没有写 key
,我们在看效果:
可以看到只有第一个节点实现了复用,因为默认索引都使用的 0
。所以这也是为什么不建议我们使用索引当 key
的原因。动态列表 key
意义不大。
本节代码不是很多,主要是 diff
算法的思路和实现原理。如果了解了 vue
的 diff
算法,相信理解起来更好,也能更好的对比。下一小节我们学习下 react
新的生命周期。