每个系列一本前端好书,帮你轻松学重点。
本系列来自ZOOM前端架构师,前百度、滴滴资深技术专家黄轶 所编写的 《Vue.js技术内幕》
"算法在什么时候派上用场?"
这个问题,我请教过不少同行朋友,大家观点各异,难有确切答案。
因为我们似乎都只在面试时会碰到算法题,项目中很少。但其实不然,尼古拉斯·威茨(Niklaus Wirth)有句名言 ---"程序=算法+数据结构"。
所以,大家平时写的程序也都在应用算法,只是算法本身有"优劣"之分,更优的算法,要么节省内存,要么提升效率。
前面的文章,我们聊了Vue组件的创建和响应式原理,这些决定了Vue的运行机制,以及开发者的使用方式。
但隐藏在这些东西背后的,起重要作用的还有算法,它对用户和开发者都"不可见",却切实影响使用体验。
性能下限
在使用框架之前,修改页面元素通常需要开发者使用原生API修改DOM,而DOM操作是出了名的开销大,由此产生过不少性能优化原则,如:减少查询次数、批量更新DOM、避免主线程复杂操作等。
这个阶段,代码执行的效率如何,全靠程序员的编码水平和编码习惯。
当开始使用Vue、React这类框架,所提倡的第一件事,就是不直接操作DOM,只操作数据,由数据驱动视图,所以使用框架的时候并不代表不动DOM,只是这件事交由框架去做。
于是,就有了"框架为代码保证性能下限"的说法。
diff算法
Vue需要进行复杂计算的地方就是组件更新,要对比分析新旧虚拟DOM的差异,然后决定如何操作,更新有几种情况:从有到无、从无到有、从有到有。
前两种好办,只需删除或者添加,第三种"从有到有"就涉及到对比差异,也就是常说的"diff"。
diff算法的目标:在已知旧子节点DOM结构和vnode以及新子节点的vnode的情况下,低成本完成节点更新。
从一个简单的列表看:
css
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
假如变成这样:
xml
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="e">e</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
是在b节点的后面插入了一个e。
如果是这样:
css
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="d">d</li>
</ul>
也能一眼看出中间少了个c。
说是"一眼看出",只代表我们脑中处理的速度快,但处理的步骤一个也没少,我们是从头一个个看的,先对比第一个,再对比第二个...直到遇到一个差异点。
转换成更规范的语言,就是"同步节点",从头部进行叫同步头部节点,从尾部进行叫同步尾部节点。
当同步完成后,只会有三种情况:
- 新子节点有剩余,需要添加新节点
- 旧子节点有剩余,需要删除多余节点
- 存在未知子序列
单纯地添加和删除比较简单,但如果面对未知的子序列,diff算法会怎么做,才是重点。
索引图
当无法直接识别是否保留或者移动元素时,似乎只能进行遍历来对比差异,遍历旧的子序列,识别其节点在新的子序列中是否存在,这就需要双重循环,双重循环的时间复杂度是O (n2)。
为了优化,算法中有一种策略叫"空间换时间 ",即用更多的内存占用换取更快的速度,这就需要建立"索引图"。
大家应该都熟悉,开发过程会给v-for生成的列表项分配唯一的key,但可能不清楚这个key在diff过程中起到的作用,在进行新旧对比过程中,如果key相同,就认为它们是同一节点,直接执行更新,索引图也是根据key建立的。
核心代码如下:
ini
const patchKeyedChildren= (c1,c2)=>{
let i = 0;
const l2 = c2.length;
// 旧子节点尾部索引
let e1 = c1.length - 1
// 新子节点尾部索引
let e2 = l2 - 1
// 旧子序列开始索引
const s1 = i
// 新子序列开始索引
const s2 = i
// 根据key建立新子序列的索引图
const keyToNewIndexMap = new Map()
for(i = s2; i<= e2;i++){
const nextChild = c2[i]
keyToNewIndexMap.set(nextChild.key,i)
}
}
上面这段代码中c1代表旧子序列,c2代表新子序列,它做的事情就是遍历新子序列,并将其key和索引建立对应关系,存储在叫 keyToNewIndexMap 的 Map 结构中。
建立完就需要遍历旧子序列,通过这两次遍历,目的是找到节点操作(增、删、改)的依据。
先看遍历前的准备工作:
ini
let patched = 0,
const toBePatched = e2 - s2 + 1
let move = false
let maxNewIndexSoFar = 0
const newIndexToOldIndexMap = new Array(toBePatched)
for(i = 0;i < toBePatched;i++){
newIndexToOldIndexMap[i] = 0
}
以上这段代码:
- patched:已更新计数
- toBePatched:待更新计数
- maxNewIndexSoFar:存储上次newIndex
- move:标识是否有移动
- 建立了一个名为newIndexToOldIndexMap的数组,存储新子序列和旧子序列索引间的映射关系,用于确定最长递增子序列
准备做完,开始遍历:
ini
for(i = s1;i<= e1;i++){
const prevChild = c1[i]
if(patched == toBePatched){
// 所有子序列都已更新删除剩余节点
}
let newIndex = keyToNewIndexMap.get(prevChild.key)
if(newIndex == undefined){
// 旧子序列已不存在于新子序列中,删除节点
} else {
// 更新新子序列中的元素在旧子序列中的索引
newIndexToOldIndexMap[newIndex - s2] = i + 1
if(newIndex == maxNewIndexSoFar){
maxNewIndexSoFar = newIndex
} else {
moved = true
}
// 更新旧子序列中匹配的节点
patched++
}
}
这段代码:
- 根据前面建立的从key到索引的映射 keyToNewIndexMap,查找旧子序列节点在新子序列中的索引,如果找不到,说明应该删除;如果找到了,将它在旧子序列中的索引更新到 newIndexToOldIndexMap 中。
- 比较maxNewIndexSoFar 和 newIndex,一旦发现二者不相等,就说明有移动,设置 moved 标志位,并更新 patched。
当找到了需要移动的节点,接下来就需要进行移动,但我们先了解一样东西------最长递增子序列。
最长递增子序列
最长递增子序列(Longest Increasing Subsequence, LIS) 是一个经典的计算机科学问题,也是一道常见面试题。
定义如下:
给定一个序列(通常是数组或列表),找到其中最长的严格递增的子序列。子序列不要求连续,但必须保持元素的相对顺序。
比如:
输入序列:[10, 9, 2, 5, 3, 7, 101, 18]
最长递增子序列:[2, 3, 7, 101]
或 [2, 5, 7, 101]
为什么在这里提到它?聪明的你应该能想到,在前面建立的索引图,不正是一个数字序列吗?
为什么需要这个序列?想象一下,当新旧列表中不一致的情况较多时,如果从头到尾逐个排列,势必会浪费不少动作,因为它们中的一部分是不需要动的。
什么样的不需要动?本身就是从小到大排列的不需要动,这就是为什么需要寻找"递增序列",还要找"最长",就是为了减少移动,提高效率。
接下来就可以看移动的过程了。
移动
这个过程,Vue采用了倒序形式进行遍历新子序列,因为可以更方便地使用最后更新的节点作为锚点。
scss
// 当前节点移动时生成最长递增子序列
const increaseingNewIndexSequence = moved
? getSequence(newIndexToOldIndexMap)
:EMPTY_ARR
let j = increaseingNewIndexSequence.length - 1
// 倒序遍历,使用最后更新的节点作为锚点
for(i = toBePatched - 1; i>=0; i--){
const newIndex = s2 + i
const nextChild = c2[nextIndex]
// 锚点指向上一个更新的节点,如果nextIndex超过新子节点的长度,则指向parentAnchor
const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
if(newIndexToOldIndexMap[i] === 0){
// 挂载新的子节点
} else if(moved){
// 没有最长子序列,或者当前节点索引不在最长子序列中,执行移动
if(j = 0 || i!== increaseingNewIndexSequence[j]){
move(nextChild,container, anchor, 2)
} else {
// 倒序递增子序列
}
}
}
以下面两个序列为例:
旧子节点:c、d、e
新子节点:e、c、d、i
此时新子节点的长度为4,最长递增子序列就是c、d 索引组成的[1,2],倒序遍历时首先碰到 i,它在newIndexToOldIndexMap中的值是0,说明是新节点,直接挂载。
下一个遍历到的是d,走moved判断分支,发现为true,而且d的索引存在于递增子序列中,则执行j--倒序递增子序列,j变为了0。
接下来遇到c,而c的情况和d一样,j变为-1。
最后遇到e,j 为-1,且e的索引不在递增子序列中,就需要做移动,把e移动到上一个更新的节点,也就是c节点的前面。
至此完成了两件事:
- i 作为新节点直接挂载
- e 作为需要移动的节点,往前移动了两位,从 c、d、e、变为 e、c、d
至此,完成了这样两个简单列表的对比和元素变更。
简单的是这样,复杂的也是一样道理。
所以,整个流程总结一句话:建立新旧子序列的映射关系,并通过状态位的标识进行增删,或者生成最长递增子序列,对顺序不一致的做移动。
算法所体现的不仅仅是单次的任务成本,更是一种随着量变而来的趋势,量越大,"优劣"算法的差异就越明显。Vue,js 3.x就是做了这样的优化,它结合"贪心"和"二分查找"法,使得其复杂度由原本的O (n2),变成了 O(nl o g 2 n)。
假设处理 10,000 条数据,做个对比:
n2=100,000,000n2=100,000,000
n×log2n≈132,877n×log2n≈132,877
差距约为 99,867,123。优化效果很明显。
编译优化
除了算法,框架还会从其他层面着手进行优化,比如:Vue中从template编译生成render函数的过程。
Vuejs 2.x更新的粒度是组件级,但组件内或许只有很少的内容需要更新,这就导致成本的浪费,Vuejs 3.x通过对静态模板分析,生成了Block Tree,它基于动态节点切割嵌套区块,将vnode的更新性能由模板整体大小提升为动态内容数量相关,是非常大的性能突破。
小结
至此,我们把Vuejs中的一个关键点diff算法的核心思想讲完了,它结合了"深度遍历、递归、最长递增子序列"等多种方法技巧。当然,这不是全貌,也不是从开始就这样,每个事物都要经历不断改进才能变得更好。
借此,我们能够对它有一个大概的了解,或者说找到一些启发,知道解决类似的问题应该怎样思考。
本篇文,是基于《Vue.js技术内幕》源码分享的最后一篇,虽有不舍,但我们需要继续往前走。
下一本会是什么,我们拭目以待!~
更多好文第一时间接收,可关注公众号:前端说书匠