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技术内幕》源码分享的最后一篇,虽有不舍,但我们需要继续往前走。

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

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

相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__2 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.3 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~4 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华4 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子7 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅7 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学8 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端