Vuejs技术内幕:用算法优雅解决复杂问题

每个系列一本前端好书,帮你轻松学重点。

本系列来自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×log⁡2n≈132,877n×log2n≈132,877

差距约为 99,867,123。优化效果很明显。

编译优化

除了算法,框架还会从其他层面着手进行优化,比如:Vue中从template编译生成render函数的过程。

Vuejs 2.x更新的粒度是组件级,但组件内或许只有很少的内容需要更新,这就导致成本的浪费,Vuejs 3.x通过对静态模板分析,生成了Block Tree,它基于动态节点切割嵌套区块,将vnode的更新性能由模板整体大小提升为动态内容数量相关,是非常大的性能突破。

小结

至此,我们把Vuejs中的一个关键点diff算法的核心思想讲完了,它结合了"深度遍历、递归、最长递增子序列"等多种方法技巧。当然,这不是全貌,也不是从开始就这样,每个事物都要经历不断改进才能变得更好。

借此,我们能够对它有一个大概的了解,或者说找到一些启发,知道解决类似的问题应该怎样思考。

本篇文,是基于《Vue.js技术内幕》源码分享的最后一篇,虽有不舍,但我们需要继续往前走。

下一本会是什么,我们拭目以待!~

更多好文第一时间接收,可关注公众号:前端说书匠

相关推荐
冴羽2 分钟前
SvelteKit 最新中文文档教程(10)—— 部署 Cloudflare Pages 和 Cloudflare Workers
前端·javascript·svelte
fridayCodeFly5 分钟前
v-form标签里的:rules有什么作用。如何定义。
前端·javascript·vue.js
xixixin_15 分钟前
【uniapp】内容瀑布流
java·前端·uni-app
计算机毕设定制辅导-无忧学长21 分钟前
响应式 Web 设计:HTML 与 CSS 协同学习的进度(二)
前端·css·html
yzp011222 分钟前
html方法收集
前端·javascript·html
paradoxaaa_25 分钟前
VUE2导出el-table数据为excel并且按字段分多个sheet
javascript·vue.js·excel
lbh1 小时前
前端处理 .xlsx 文件流并触发下载指南
前端·javascript
xixixin_1 小时前
【uniapp】各端获取路由路径的方法
前端·javascript·uni-app·vue
Epicurus1 小时前
如何编写高质量的TypeScript应用程序
前端
萧寂1731 小时前
Grid布局示例代码
前端·javascript·css