位运算
如何使用位运算来实现 Vue 中的按需更新,让静态的节点可以越过虚拟 DOM 的计算逻辑,并且使用计算最长递增子序列的方式,来实现队伍的高效排序。
前面也复习了,在执行 diff 之前,要根据需要判断每个虚拟 DOM 节点有哪些属性需要计算,因为无论响应式数据怎么变化,静态的属性和节点都不会发生变化。所以我们看每个节点 diff 的时候会做什么,在 renderer.ts 代码文件中就可以看到代码,主要就是通过虚拟 DOM 节点的 patchFlag 树形判断是否需要更新节点。
方法就是使用 & 操作符来判断操作的类型,比如 patchFlag & PatchFlags.CLASS 来判断当前元素的 class 是否需要计算 diff;shapeFlag & ShapeFlags.ELEMENT 来判断当前虚拟 DOM 是 HTML 元素还是 Component 组件。这个“&”其实就是位运算的按位与。
// class
// this flag is matched when the element has dynamic class bindings.
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}
// style
// this flag is matched when the element has dynamic style bindings
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
上面的代码中 & 就是按位与的操作符,这其实是二进制上的计算符号,所以我们首先要了解一下什么是二进制。我们日常使用的数字都是十进制数字,比如数字 13 就是 1*10+3 的运算结果,每个位置都是代表 10 的 n 次方。13 也可以使用二进制表达,因为二进制每个位置只能是 0 和 1 两个数字,每个位置代表的是 2 的 n 次方,13 在二进制里是 1101,就是 1*8+1*4+0*2+1*1。而在 JavaScript 中我们可以很方便地使用 toString(2) 的方式,把十进制数字转换成二进制。运算的概念很简单,就是在二进制上的“与”和“或”运算:
(13).toString(2) // 1101
0 & 0 // 0
0 & 1 // 0
1 & 0 // 0
1 & 1 // 1
0 | 0 // 0
0 | 1 // 1
1 | 0 // 1
1 | 1 // 1
1 << 2 // 1左移动两位,就是100 就是1*2平方 = 4
二进制中,我们每个位置只能是 0 或者 1 这两个值,& 和 | 的概念和 JavaScript 中的 && 和 || 保持一致。两个二进制的 & 运算就是只有两个二进制位置都是 1 的时候,结果是 1,其余情况运算结果都是 0;| 是按位置进行“或”运算,只有两个二进制位置都是 0 的时候,结果是 0,其余情况运算结果都是 1;并且,还可以通过左移 << 和右移 >> 操作符,实现乘以 2 和除以 2 的效果。
由于这些都是在二进制上的计算,运算的性能通常会比字符串和数字的计算性能要好,这也是很多框架内部使用位运算的原因。
为什么位运算性能更好
我们来做一下 LeetCode231 题,题目描述很简单,判断数字 n 是不是 2 的幂次方,也就是说,判断数字 n 是不是 2 的整次方,比如 2、4、8。我们可以很轻松地写出 JavaScript 的解答,n 一直除以 2,如果有余数就是 false,否则就是 true:
var isPowerOfTwo = function(n) {
if(n === 1) return true
while( n > 2 ){
n = n / 2
if(n % 2 !== 0) return false
}
return n===2
};
不过上面的解答我们可以用位运算来优化。先来分析一下 2 的幂次方的特点。2 的幂次方就是数字 1 左移动若干次,其余位置全部都是 0,所以 n-1 就是最高位变成 0,其余位置都变成 1,就像十进制里的 10000-1 = 9999。这样,n 和 n-1 每个二进制位的数字都不一样,我们可以很轻松地用按位“与”来判断这个题的答案,如果 n&n-1 是 0 的话,数字 n 就符合 2 的整次幂的特点:
16
10000
16-1 = 15
01111
16&15 == 0
var isPowerOfTwo = function(n) {
return n>0 && (n & (n - 1)) === 0
};
所以我们使用位运算提高了代码的整体性能。
如何运用位运算
好,搞清楚为什么用位运算,我们回来看 diff 判断,如何根据位运算的特点,设计出权限的组合认证方案。
比如 Vue 中的动态属性,有文本、class、style、props 几个属性,我们可以使用二进制中的一个位置来表示权限,看下面的代码,我们使用左移的方式分别在四个二进制上标记了 1,代表四种不同的权限,使用按位或的方式去实现权限授予。比如,一个节点如果 TEXT 和 STYLE 都需要修改,我们只需要使用 | 运算符就可以得到 flag1 的权限表示,这就是为什么 Vue 3 中针对虚拟 DOM 类型以及虚拟 DOM 需要动态计算 diff 的树形都做了标记,你可以在Vue 3 的源码中看到下面的配置:
const PatchFlags = {
TEXT:1, // 0001
CLASS: 1<<1, // 0010
STYLE:1<<2, // 0100
PROPS:1<<3 // 1000
}
const flag1 = PatchFlags.TEXT | PatchFlags.STYLE // 0101
// 权限校验
flag1 & PatchFlags.TEXT // 有权限,结果大于1
flag1 & PatchFlags.CLASS //没有权限 是0
最长递增子系列
然后就到了今天的重点:我们虚拟 DOM 计算 diff 中的算法了。上一讲我们详细介绍了在虚拟 diff 计算中,如果新老子元素都是数组的时候,需要先做首尾的预判,如果新的子元素和老的子元素在预判完毕后,未处理的元素依然是数组,那么就需要对两个数组计算 diff,最终找到最短的操作路径,能够让老的子元素通过尽可能少的操作,更新成为新的子元素。
Vue 3 借鉴了 infero 的算法逻辑,就像操场上需要按照个头从低到高站好一样,我们采用的思路是先寻找一个现有队列中由低到高的队列,让这个队列尽可能的长,它们的相对位置不需要变化,而其他元素进行插入和移动位置,这样就可以做到尽可能少的操作 DOM。
所以如何寻找这个最长递增的序列呢?这就是今天的重点算法知识了,我们看LeetCode 第 300 题,题目描述如下, 需要在数组中找到最长底层的自序列长度:
给你一个整数数组 nums,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。
例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。
=
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
首先我们可以使用动态规划的思路,通过每一步的递推,使用 dp 数组,记录出每一步操作的最优解,最后得到全局最优解。
在这个例子中,我们可以把 dp[i]定义成 nums[0]到 nums[i]这个区间内,数组的最长递增子序列的长度,并且 dp 数组的初始值设为 1。
从左边向右递推,如果 nums[i+1]>nums[i],dp[i+1]就等于 dp[i]+1;如果 nums[i+1]<nums[i],就什么都不需要干,这样我们在遍历的过程中,就能根据数组当前位置之前的最长递增子序列长度推导出 i+1 位置的最长递增子序列长度。所以可以得到如下解法:
/**
* @param {number[]} nums
* @return {number}
*/
const lengthOfLIS = function(nums) {
let n = nums.length;
if (n == 0) {
return 0;
}
let dp = new Array(n).fill(1);
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
}
return Math.max(...dp)
}
由于我们需要两层循环,所以这个解法的时间复杂度是 n 的平方,这个解法其实已经不错了,但是还有更优秀的解法,也就是 Vue 3 中用到的算法:贪心 + 二分。
贪心 + 二分
我们再看一下这个题,贪心的思路就是在寻找最长递增的序列,所以,[1,3]要比[1,5]好,也就是说,在这个上升的序列中,我们要让上升速度尽可能变得慢,这样才有可能让后面的元素尽可能也递增。我们可以创建一个 arr 数组,用来保存这种策略下的最长递增子序列。如果当前遍历的 nums[i]大于 arr 的最后一个元素,也就是大于 arr 的最大值时,我们把 nums[i]追加到后面即可,否则我们就在 arr 中寻找一个第一个大于 num[i]的数字并替换它。因为是 arr 是递增的数列,所以在寻找插入位置的时候,我们可以使用二分查找的方式,把整个算法的复杂度变成 O(nlgn)。
下面的代码就是贪心 + 二分的解法,我们可以得到正确的最长递增子序列的长度:
/**
* @param {number[]} nums
* @return {number}
*/
const lengthOfLIS = function(nums) {
let len = nums.length
if (len <= 1) {
return len
}
let arr = [nums[0]]
for (let i = 0; i < len; i++) {
// nums[i] 大于 arr 尾元素时,直接追加到后面,递增序列长度+1
if (nums[i] > arr[arr.length - 1]) {
arr.push(nums[i])
} else {
// 否则,查找递增子序列中第一个大于numsp[i]的元素 替换它
// 递增序列,可以使用二分查找
let left = 0
let right = arr.length - 1
while (left < right) {
let mid = (left + right) >> 1
if (arr[mid] < nums[i]) {
left = mid + 1
} else {
right = mid
}
}
arr[left] = nums[i]
}
}
return arr.length
};
但是贪心 + 二分的这种解法,现在只能得到最长递增子序列的长度,但是最后得到的 arr 并不一定是最长递增子序列,因为我们移动的 num[i]位置可能会不正确,只是得到的数组长度是正确的,所以我们需要对这个算法改造一下,把整个数组复制一份之后,最后也能得到正确的最长递增子序列。具体代码怎么写呢?我们来到 Vue 3 的 renderer.ts 文件中,函数getSquenece就是用来生成最长递增子序列,看下面的代码:
// https://en.wikipedia.org/wiki/Longest_increasing_subsequence
function getSequence(arr: number[]): number[] {
const p = arr.slice() //赋值一份arr
const result = [0]
let i, j, u, v, c
const len = arr.length
for (i = 0; i < len; i++) {
const arrI = arr[i]
if (arrI !== 0) {
j = result[result.length - 1]
if (arr[j] < arrI) {
p[i] = j // 存储在result最后一个索引的值
result.push(i)
continue
}
u = 0
v = result.length - 1
// 二分查找,查找比arrI小的节点,更新result的值
while (u < v) {
c = (u + v) >> 1
if (arr[result[c]] < arrI) {
u = c + 1
} else {
v = c
}
}
if (arrI < arr[result[u]]) {
if (u > 0) {
p[i] = result[u - 1]
}
result[u] = i
}
}
}
u = result.length
v = result[u - 1]
// 查找数组p 找到最终的索引
while (u-- > 0) {
result[u] = v
v = p[v]
}
return result
}
这段代码就是 Vue 3 里的实现,result 存储的就是长度是 i 的递增子序列最小末位置的索引,最后计算出最长递增子序列。我们得到 increasingNewIndexSequence 队列后,再去遍历数组进行 patch 操作就可以实现完整的 diff 流程了:
for (i = toBePatched - 1; i >= 0; i--) {
const nextIndex = s2 + i
const nextChild = c2[nextIndex] as VNode
const anchor =
nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
if (newIndexToOldIndexMap[i] === 0) {
// mount new
patch(
null,
nextChild,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (moved) {
// move if:
// There is no stable subsequence (e.g. a reverse)
// OR current node is not among the stable sequence
if (j < 0 || i !== increasingNewIndexSequence[j]) {
move(nextChild, container, anchor, MoveType.REORDER)
} else {
j--
}
}
}
上面代码的思路,我们用下图演示。做完双端对比之后,a 和 g 已经计算出可以直接复用 DOM,剩下的队列中我们需要把 hbfdc 更新成 abdef。首先我们需要使用 keyToNewIndexMap 存储新节点中每个 key 对应的索引,比如下图中 key 是 c 的元素的索引就是 2;然后计算出 newIndexOldIndexMap 存储这个 key 在老的子元素中的位置,我们可以根据 c 的索引是 2,在 newIndexOldIndexMap 中查询到在老的子元素的位置是 6, 关于 newIndexOldIndexMap 的具体逻辑你可以在上面的代码中看到: