问题 vue 的diff 算法和 react 的 diff 算法有什么区别?
最早的 diff 算法复杂度是O(m^3n^3) -> 2011 年降低到O(n^3) n 代表节点数 -> react O(n) 最好情况;最坏情况O(n^2)
复杂度:最好时间复杂度、最坏时间复杂度、平均时间复杂度、均摊时间复杂度
例子理解,如果遍历列表,长度是 n 假设最好情况:遍历第一个就找到,复杂度就是O(1) 最坏情况:最后一个才找到,复杂度就是O(n) 平均复杂度:总情况数 / 总操作数 情况数: 1 次找到、2 次、... n 次找到 1 + 2 + 3 + ... + n 操作数: n + 1 // 1 - 没找到 平均复杂度 = n(1 + n) / 2 / (n + 1) -> 不看系数 O(n)
均摊复杂度:最坏的情况平均分给每一个 in this case O(1)
- 为什么需要diff?
- 性能,虚拟DOM 的隔离,减少直接操作DOM
- 通过DSL,改变原来的开发模式(直接操作DOM,将数据加入),现在经过响应式方法,改变中间的数据,数据再映射到tree,最终tree 经过diff 再映射到视图。现代的前端 f(state) --> View
数据模型 -> virtual dom -> 视图(DOM)
DSL: { type: 'div', props: { class: '', id: '', children: [{type ...}, {}]}, ... } -> 这个对象来描述 DOM 结构
并不能说 虚拟DOM 比真实DOM 快,某些场景下会
DOM 为什么慢?主要是引起重绘和重排 -> 如何解决?
- 切片,渲染1w个,切成10个,并发 1000
- DocumentFragment, 不会每次操作DOM,最后一次再操作DOM
- 拼字符串
- 为什么传统的 diff 算法是O(n^3)?
树需要跨层级比较
计算机比较两颗树:最短编辑距离 假设:'hello' -> 'hallo' e -> a 需要几步?一眼望去就知道答案是一步,这个时候你脑子里的思考过程其实就是编辑距离算法
抽象一点,对字符串的操作不外乎三种,「替换」「插入」「删除」,执行这三种操作后到达目的的最小操作数,就是最短编辑距离,这里的复杂度就是我们需要考虑的
树的最短编辑距离算法复杂度是 O(n^2),其实就是实现的时候,拿 Levenshtein 举例,需要双层 for 循环去计算左,左上,右三个值,这里复杂度就是 O(n^2) 了
如果两个树同一层,type 类型不一致,type 不一样(一个div,一个 p),可以做的操作是删掉div,插入p -> 两个操作 O(n^2)
到了这个节点,然后,因为 diff 还要做一次 patch,(找到差异后还要计算最小转换方式)这个时候还要在之前遍历的基础上再遍历一次,要找到当时删除节点的位置,所以累计起来就是 O(n^3) 了
- 为什么react diff 是 O(n)? 不是严格意义上的O(n),而是O(nm) [a, b, c] vs [b, d, e, f] 比较的事实上是:同层比较,最优解是O(n),实际一般是O(mn) [a, b] 比 [b, d] 比 [c, e] 比 [null, f] 比
for (let i = 0, len = oldNodes.length; i < len; i++) {
if (oldNodes[i].type !== newNodes[i].type) {
replace()
}
else if (oldNodes[i].children && oldNodes[i].children.length) { // 如果没有这一层,假设 type 全不相同,那么就是 O(n),最坏复杂度 O(nm)
}
}
复杂度就为 O(m * n) m - 节点数 n - 子节点的数目 也可以理解成O(n^2) 极端情况下子节点遍历的数目和当前遍历的数目是一样的
- 如何做到 O(n)?
- react 是怎么设计将复杂度砍下来呢?其实就是在算法复杂度、虚拟 dom 渲染机制、性能中找了一个平衡,react 采用了启发式的算法,做了如下最优假设:
- a. 如果节点类型相同,那么以该节点为根节点的 tree 结构,大概率是相同的,所以如果类型不同,可以直接「删除」原节点,「插入」新节点
- b. 跨层级移动子 tree 结构的情况比较少见,或者可以培养用户使用习惯来规避这种情况,遇到这种情况同样是采用先「删除」再「插入」的方式,这样就避免了跨层级移动
- c. 同一层级的子元素,可以通过 key 来缓存实例,然后根据算法采取「插入」「删除」「移动」的操作,尽量复用,减少性能开销
- d. 完全相同的节点,其虚拟 dom 也是完全一致的
基于这些假设,可以将 diff 抽象成只需要做同层比较的算法,这样复杂度就直线降低了
面试题:为什么 v-for 要有key? key 的作用是复用,内部有一个映射表,在新旧 nodes 对比时辨识 VNodes
遍历 老的 tree,存了key和下标的映射
let prevMap = {}
let nextMap = {}
// old tree children
for (let i = 0; i < prev.length; i++) {
let { key = i + '' } = prev[i]
prevMap[key] = i
}
遍历新的children,取出 key 去老得里面找,如果没有说明是新增的,-> 找出新增的节点位置,mount(mount 方法两种方法,要么是insertBefore,一个是appendChild)
let lastIndex = 0
for (let n = 0; n < next.length; n++) {
let { key = n + '' } = next[n]
let j = prevMap[key]
let nextChild = next[n]
nextMap[key] = n
// {b: 0, a: 1}
// 原children 新 children
// [b, a] -> [c, d, a] ::[c, b, a] 👉 c
// [b, a] -> [c, d, a] ::[c, d, b, a] 👉 d
if (j == null) {
let refNode = n === 0 ? prev[0].el : next[n - 1].el.nextSibling
mount(nextChild, parent, refNode)
}
else {
// [b, a] -> [c, d, a] ::[c, d, a, b] 👉 a
patch(prev[j], nextChild, parent)
if (j < lastIndex) {
let refNode = next[n - 1].el.nextSibling;
parent.insertBefore(nextChild.el, refNode)
}
else {
lastIndex = j
}
}
}
遍历老 tree,老的有的新的没有,直接把老的 删除了
// [b, a] -> [c, d, a] ::[c, d, a] 👉 b
for (let i = 0; i < prev.length; i++) {
let { key = '' + i } = prev[i]
if (!nextMap.hasOwnProperty(key)) parent.removeChild(prev[i].el)
}
举个例子,假设原来有 [1, 2, 3] 三个子节点渲染了,假设我们这么操作了一波,将顺序打乱变成 [3, 1, 2],并且删除了最后一个,变成 [3, 1]
- 那,最优的 diff 思路应该是复用 3, 1组件,移动一下位置,去掉 2 组件,这样整体是开销最小的,如果有 key 的话,这波操作水到渠成,如果没有 key 的话,那么就要多一些操作了:
- a. 判断哪些可以复用,有 key 只需要从映射中康康 3, 1在不在,没有 key 的话,可能就执行替换了,肯定比「复用」「移动」开销大了
- b. 删除了哪一个?新增了哪一个?有 key 的话是不是很好判断嘛,之前的映射没有的 key,比如变成 [3, 1, 4]那这个 4 很容易判断出应该是新建的,删除也同理
- 但是没有 key 的话就麻烦一些了
追问:为什么不推荐用 下标做key? 我们来分析一下:
- [1, 2, 3] 这是原来的渲染节点,页面展示出 1, 2, 3,然后我们 splice(0, 1) 删除第一个元素后,理想情况是变成 [2, 3]
- 但是因为使用了下标为 key,对比前后两次 keys
- [0, 1, 2] -> [0, 1] 因为 vue 的机制,sameNode 判断一波后,误认为是 2 被删除了...害!
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
这个问题在 react 中表现上不会出现,会把第一个数组删掉,但是会把所有的li重新渲染一遍,性能上损失了
原理:默认删掉第一个之后,对应不上,执行的全部是新建
[a, b, c, d, e] [b, c, d, e]
虚拟 DOM
- 什么是 虚拟 DOM 本质 ->
{
type: 'div'.
props: {
children: [
]
},
el: xxx
}
嵌套对象 -> 形成一个 Tree
数据结构有了,需要babel中的方法, 将JSX中的关键词提取出并嵌套调用 createElement, 转成 DOM Tree
- 怎么创建虚拟 DOM
-> h() createElement...
function h(type, props) {
return { type, proprs }
}
- 使用
以 react 为例 jsx - js 和 html 混用,但是性能上这里比不上vue
<div>
<ul className='padding-20'>
<li key='li-01'>this is li 01</li>
</ul>
</div>
经过 babel 转一下:
createElement('div', {
children: [
createElement('ul', {
{ className: 'padding-20' }, createElement('li', {
key: 'li-01'
}, 'this is li 01')
})
]
})
- 虚拟 DOM 的数据结构有了,那么就是渲染 (mount/render) f(vnode) -> view
f(vnode) {
// 映射到真实DOM
document.createElement();
...
parent.insert();
...insertBefore()
}
//最终对外暴露一个render 方法
export const render = (vnode, parent) => { }
// 因此都会有个id='#app'
<div id='#app'></div>
- diff 相关 (patch) f(oldVnodeTree, newVnodeTree, parent) -> 调度(并不是数据一变更就渲染,每空闲的几毫秒进行渲染)$nextTick -> view
const normalize = (children = []) => children.map(child => typeof child === 'string' ? createText(child) : child)
// step1 定义虚拟DOM 数据结构
const createVnode = (type, props, key, $$) => {
return {
type, // div | ComponentA | '' 对应文本
props,
key, // 也可以放在props里
$$ // 内部使用的属性
}
}
// 特殊情况,创建文本方法
export const NODE_FLAG = {
EL: 1, //元素 element
TEXT: 1 << 1 // 位运算
}
// 2 & 1 = 1; 1 & 2 = 0
// * if (vnode.$$.flag & NODE_FLAG.TEXT)
// * // true 就是文本节点
const createText = (text) => {
return {
type: '',
props: {
nodeValue: text + ''
},
$$: { flag: NODE_FLAG.TEXT } // 标识虚拟节点为文本节点
}
}
// step2 定义生成虚拟DOM对象的方法
// h('div', { className: 'padding20' }, 'hello world!')
export const h = (type, props, ...kids) => {
props = props || {}
let key = props.key || void 0
kids = normalize(props.children || kids)
// props.children: 3 种情况
// void 0 没有
// { type: 'div', ... } 对象
// [{xx}, {xxx}] 数组中有很多对象
if (kids.length) props.children = kids.length === 1 ? kids[0] : kids
const $$ = {}
$$.el = null
$$.flag = type === '' ? NODE_FLAG.TEXT : NODE_FLAG.EL
return createVnode(type, props, key, $$)
// step3 渲染 f(vnode, parent)
export const render = (vnode, parent) => {
// parent 上已经有 vnode虚拟节点
let prev = parent._vnode
if (!prev) {
mount(vnode, parent)
parent._vnode = vnode
} else {
if (vnode) { // 新旧两个 vnodeTree 都存在
patch(prev, vnode, parent)
parent._vnode = vnode
}
else {
parent.removeChild(prev.$$.el)
}
}
}
// mount
export const mount = (vnode, parent, refNode) => {
if (!parent) throw new Error('你可能忘了点啥')
const $$ = vnode.$$
// if ($$.flag === NODE_FLAG.TEXT)
if ($$.flag & NODE_FLAG.TEXT) {
const el = document.createTextNode(vnode.props.nodeValue)
vnode.el = el
parent.appendChild(el)
}
else if ($$.flag & NODE_FLAG.EL) {
const { type, props } = vnode
// 先不考虑 type 是一个组件的情况 ⚠️
const el = document.createElement(type)
vnode.el = el
const { children, ...rest } = props
if (Object.keys(rest).length) {
for (let key of Object.keys(rest)) {
patchProps(key, null, rest[key], el)
}
}
if (children) {
const __children = Array.isArray(children) ? children : [children]
for (let child of __children) {
mount(child, el)
}
}
refNode ? parent.insertBefore(el, refNode) : parent.appendChild(el)
}
}
// patch
const patchChildren = (prev, next, parent) => {
// diff 比较耗性能,可以前置做一些处理,提升效率
if (!prev) {
if (!next) {
// do nothing
}
else {
next = Array.isArray(next) ? next : [next]
for (const c of next) {
mount(c, parent)
}
}
}
else if (prev && !Array.isArray(prev)) { // 只有一个 children
if (!next) parent.removeChild(prev.el)
else if (next && !Array.isArray(next)) {
patch(prev, next, parent)
}
else {
parent.removeChild(prev.el)
for (const c of next) {
mount(c, parent)
}
}
}
else odiff(prev, next, parent)
}
export const patch = (prev, next, parent) => {
// type: 'div' -> type: 'p'
// 同层比较
if (prev.type !== next.type) {
parent.removeChild(prev.el)
mount(next, parent)
return
}
// type 一样,diff props(先不看 children)
const { props: { children: prevChildren, ...prevProps } } = prev
const { props: { children: nextChildren, ...nextProps } } = next
// patchProps
const el = (next.el = prev.el)
for (let key of Object.keys(nextProps)) {
let prev = prevProps[key],
next = nextProps[key]
patchProps(key, prev, next, el)
}
for (let key of Object.keys(prevProps)) {
if (!nextProps.hasOwnProperty(key)) patchProps(key, prevProps[key], null, el)
}
// patch children ⚠️
patchChildren(
prevChildren,
nextChildren,
el
)
}
export const patchProps = (key, prev, next, el) => {
// style
if (key === 'style') {
// { style: { margin: '0px', padding: '10px' }}
if (next)
for (let k in next) {
el.style[k] = next[k]
}
// { style: { padding: '0px', color: 'red' } }
if (prev)
for (let k in prev) {
if (!next.hasOwnProperty(k)) {
el.style[k] = ''
}
}
}
// class
else if (key === 'className') {
if (!el.classList.contains(next)) {
el.classList.add(next)
}
}
// events
else if (key[0] === 'o' && key[1] === 'n') {
prev && el.removeEventListener(key.slice(2).toLowerCase(), prev)
next && el.addEventListener(key.slice(2).toLowerCase(), next)
}
else if (/\[A-Z]|^(?:value|checked|selected|muted)$/.test(key)) {
el[key] = next
}
else {
el.setAttribute && el.setAttribute(key, next)
}
}
diff
import { mount } from './mount.js'
import { patch } from './patch.js'
export const diff = (prev, next, parent) => {
let prevMap = {}
let nextMap = {}
// old tree children
for (let i = 0; i < prev.length; i++) {
let { key = i + '' } = prev[i]
prevMap[key] = i
}
let lastIndex = 0
for (let n = 0; n < next.length; n++) {
let { key = n + '' } = next[n]
let j = prevMap[key]
let nextChild = next[n]
nextMap[key] = n
// {b: 0, a: 1}
// 原children 新 children
// [b, a] -> [c, d, a] ::[c, b, a] 👉 c
// [b, a] -> [c, d, a] ::[c, d, b, a] 👉 d
if (j == null) {
let refNode = n === 0 ? prev[0].el : next[n - 1].el.nextSibling
mount(nextChild, parent, refNode)
}
else {
// [b, a] -> [c, d, a] ::[c, d, a, b] 👉 a
patch(prev[j], nextChild, parent)
if (j < lastIndex) {
let refNode = next[n - 1].el.nextSibling;
parent.insertBefore(nextChild.el, refNode)
}
else {
lastIndex = j
}
}
}
// [b, a] -> [c, d, a] ::[c, d, a] 👉 b
for (let i = 0; i < prev.length; i++) {
let { key = '' + i } = prev[i]
if (!nextMap.hasOwnProperty(key)) parent.removeChild(prev[i].el)
}
}
Vue3 中的核心diff 算法
// [a, b, c, d] // [d, a, b, c]
- 最快的办法是将 b 移动到第一个,abc 不要动 //最长上升子序列
- 不动的是 d,移动,a,移动b,移动c
双指针算法 解决对比后只需要增加或减少的情况 // [a, b, c, d] // [a, b, c, d, e]
export const odiff = (prevChildren, nextChildren, parent) => {
// 前指针
let j = 0
// 后指针
let prevEnd = prevChildren.length - 1
let nextEnd = nextChildren.length - 1
let prevNode = prevChildren[j]
let nextNode = nextChildren[j]
// [a, b, c, d] [a, b, c, d, e]
// j 👆 j 👆
// 前置优化部分,假设上述情况
// 👆:结束指针
// 双指针前面4个都相同,因此都不需要diff,只关注最后的e
outer: {
while(prevNode.key === nextNode.key) {
patch(prevNode, nextNode, parent)
j++
if (j > prevEnd || j > nextEnd) break outer
prevNode = prevChildren[j]
nextNode = nextChildren[j]
}
prevNode = prevChildren[prevEnd]
nextNode = nextChildren[nextEnd]
while (prevNode.key === nextNode.key) {
patch(prevNode, nextNode, parent)
prevEnd--
nextEnd--
if (j > prevEnd || j > nextEnd) break outer
prevNode = prevChildren[prevEnd]
nextNode = nextChildren[nextEnd]
}
}
// [a, b, c, h, d] [a, b, c, f, m, k, h, d]
// 👆 j j 👆
// 前指针大于老tree的后指针 && 前指针小于等于新tree的后指针,前指针和新tree的后指针之间正好是老tree没有的!!!直接插入既可
if (j > prevEnd && j <= nextEnd) {
let nextPos = nextEnd + 1
let refNode = nextPos >= nextChildren.length
? null
: nextChildren[nextPos].el
while (j <= nextEnd) {
mount(nextChildren[j++], parent, refNode)
}
return
}
// [a, b, c, f, m, k, h, d] [a, b, c, h, d]
// j 👆 👆 j
// 同理,前长后短,只需要删掉老tree的前后指针间的
else if (j > nextEnd) {
while (j <= prevEnd) {
parent.removeChild(prevChildren[j++].el)
}
return
}
// [a, b, c, d] [c, a, d, b]
// j 👆 j 👆
// 移动的例子
let nextStart = j,
prevStart = j,
nextLeft = nextEnd - j + 1,
nextIndexMap = {},
source = new Array(nextLeft).fill(-1),
patched = 0,
lastIndex = 0,
move = false
// { 'c': 0, 'a': 1, 'd': 2, 'b': 3 }
for (let i = nextStart; i <= nextEnd; i++) {
let key = nextChildren[i].key || i
nextIndexMap[key] = i
}
for (let i = prevStart; i <= prevEnd; i++) {
let prevChild = prevChildren[i],
prevKey = prevChild.key || i,
nextIndex = nextIndexMap[prevKey]
// [a, b, f, m, c] [c, a, d, b]
// a nextLeft = 4; patched = 1; nextIndex = 1; nextStart = 0; source = [-1, 0, -1, -1]; lastIndex = 1
// b nextLeft = 4; patched = 2; nextIndex = 3; nextStart = 0; source = [-1, 0, -1, 1]; lastIndex = 3
// f nextLeft = 4; patched = 2;
// m nextLeft = 4; patched = 2;
// c nextLeft = 4; patched = 3; nextIndex = 0; nextStart = 0; source = [4, 0, -1, 1]; lastIndex = 3; move = true
if (patched >= nextLeft || nextIndex === undefined) {
parent.removeChild(prevChild.el)
continue
}
patched++
let nextChild = nextChildren[nextIndex]
patch(prevChild, nextChild, parent)
source[nextIndex - nextStart] = i
if (nextIndex < lastIndex) {
move = true
} else {
lastIndex = nextIndex
}
}
if (move) {
const seq = lis(source); // seq = [1, 3]
let j = seq.length - 1;
for (let i = nextLeft - 1; i >= 0; i--) {
let pos = nextStart + i,
nextPos = pos + 1,
nextChild = nextChildren[pos],
refNode = nextPos >= nextLeft ? null : nextChildren[nextPos].el
// [4, 0, -1, 1]
if (source[i] === -1) {
mount(nextChild, parent, refNode)
} else if (i !== seq[j]) {
parent.insertBefore(nextChild.el, refNode)
} else {
j--
}
}
} else {
// no move
for (let i = nextLeft - 1; i >= 0; i--) {
if (source[i] === -1) {
let pos = nextStart + i,
nextPos = pos + 1,
nextChild = nextChildren[pos],
refNode = nextPos >= nextLeft ? null : nextChildren[nextPos].el
mount(nextChild, parent, refNode)
}
}
}
}
// 最长上升子序列算法: 就是在一个序列中,求长度最长且顺序是升序的子序列
// 1, 5, 2, 4, 6, 0, 7 -> 1, 2, 4, 6, 7
// 0 8 4 12 2 10 6 4 1 9 5 13
// 0
// 0 8
// 0 8 4 ❌
// 0 8 12
// 0 8 12 2 ❌
// ...
// 0 8 12 13 | 0 4 9 13 | ...
// 选出一个最长的序列
// 如果长度一样,选一个就可以
// 回到 [a, b, c, d]
// [d, a, b, c]
// 最长上升子序列 a b c ,保持不动 -> 将 d 移动到最前
function lis(arr) {
let len = arr.length,
result = [],
dp = new Array(len).fill(1);
for (let i = 0; i < len; i++) {
result.push([i])
}
for (let i = len - 1; i >= 0; i--) {
let cur = arr[i], nextIndex = undefined
if (cur === -1) continue
for (let j = i + 1; j < len; j++) {
let next = arr[j]
if (cur < next) {
let max = dp[j] + 1
if (max > dp[i]) {
nextIndex = j
dp[i] = max
}
}
}
if (nextIndex !== undefined) result[i] = [...result[i], ...result[nextIndex]]
}
let index = dp.reduce((prev, cur, i, arr) => cur > arr[prev] ? i : prev, dp.length - 1)
return result[index]
}