为了看懂 Vue3 diff算法,我学习了JS 位运算

3,659 阅读13分钟

「这是我参与2022首次更文挑战的第22天,活动详情查看:2022首次更文挑战」。

学习过 Vue3 源码的同学都知道,diff 算法用到了位运算,不把位运算搞清楚,你是看不懂 Vue3 源码的。

本文知识图谱如下:

image.png

本文将通过四个步骤带你熟悉前端中的位运算

  • 基础:JavaScript位运算符基本使用。
  • 上手:LeetCode 两道位运算相关算法题,感受写法。
  • 实战:用位运算实现一个系统的权限方案设计。
  • 熟练:Vue3 虚拟 DOM 中的位运算分析。

你掌握了吗?没掌握就一起来查漏补缺吧。

什么是位运算?

我们知道,计算机中计算用的是 二进制,不是日常生活的中的十进制

十进制是逢 10 进 1,二进制是逢 2 进 1,如下表所示:

十进制二进制十进制二进制
006110
117111
21081000
31191001
4100101010
5101111011

位运算就是计算机在二进制位置上的计算。

JavaScript 位运算符

我们介绍 5 种开发中用得多的,更多详细可参考 w3school 位运算

运算符名称描述
&AND如果两位都是 1 则设置每位为 1
|OR如果两位之一为 1 则设置每位为 1
XOR如果两位只有一位为 1 则设置每位为 1
<<零填充左位移通过从右推入零向左位移,并使最左边的位脱落。
>>有符号右位移通过从左推入最左位的拷贝来向右位移,并使最右边的位脱落。

& 运算符(和)

对一对数位执行位运算 & 时,如果数位均为 1 则返回 1。

单位示例:

运算结果
0 & 00
0 & 10
1 & 00
1 & 11

四位示例:

运算结果
1111 & 00000000
1111 & 00010001
1111 & 00100010
1111 & 01000100

| 运算符(或)

对一对数位执行位运算 | 时,如果其中一位是 1 则返回 1。

单位示例:

运算结果
0 | 00
0 | 11
1 | 01
1 | 11

四位示例:

运算结果
1111 | 00001111
1111 | 00011111
1111 | 00101111
1111 | 01001111

^ 运算符(异或)

对一对数位进行位运算 ^ 时,如果数位是不同的则返回 1,相同则返回 0

单位示例:

运算结果
0 ^ 00
0 ^ 11
1 ^ 01
1 ^ 10

四位示例:

运算结果
1111 ^ 00001111
1111 ^ 00011110
1111 ^ 00101101
1111 ^ 01001011

<< 运算符

<< 运算符,零填充左位移,通过从右推入零向左位移,并使最左边的位脱落。

计算过程是先转二进制,然后在最右边加个 0。

运算(十进制)结果等同于(二进制)结果
0 << 100 << 10
1 << 121 << 110
2 << 1410 << 1100
3 << 1611 << 1110
4 << 18100 << 11000

使用 << 运算符,相当于乘 2。

运算结果
5 << 110
5 << 220
5 << 340
5 << 480

5 << 2 相当于 (5 << 1) << 1 相当于 5 * 2 * 2。

5 << 3 相当于 ((5 << 1) << 1) << 1 相当于 5 * 2 * 2 * 2。

有点递归的感觉。

>> 运算符

>> 运算符,有符号(正或负)右移,通过从左推入最左位的拷贝来向右位移,并使最右边的位脱落。

计算过程是先转二进制,然后在最左边加个 0,再去掉最右边的一位。

运算(十进制)结果等同于(二进制)结果
0 >> 100 >> 10
1 >> 101 >> 10
2 >> 1110 >> 101
3 >> 1111 >> 101
4 >> 12100 >> 1010
5 >> 12101 >> 1010

使用 >> 运算符,相当于除以 2。

运算结果
256 >> 1128
256 >> 264
256 >> 332
256 >> 416
256 >> 58

256 >> 2 相当于 (256 >> 1) >> 1 相当于 256 / 2 / 2。

>> 运算符 和 >>> 运算符的区别

>> 运算符是有符号(正或负)右移

>>> 运算符是无符号右移

在二进制里,如果是正数,前面的值都是0,如果表示的是负数,就是正数的补码

计算机基础知识 | 负数的二进制表示

比如:

64  二进制表示为 00000000000000000000000000100000
-64 二进制表示为 11111111111111111111111111100000

所以左移的时候,有没有符号不影响。

64 << 5  相当于 00000000000000000000000000100000
         变为   00000000000000000000010000000000
         
-64 << 5 相当于 11111111111111111111111111100000
         变为   11111111111111111111110000000000            

image.png

右移的时候,影响就大了。

64 >> 5   相当于      00000000000000000000000001000000
          变为        00000000000000000000000000000010
          
-64 >> 5  相当于     11111111111111111111111111000000
          先转正数   00000000000000000000000001000000
          右移       00000000000000000000000000000010
          再转回负数  11111111111111111111111111111110
          
         
-64 >>> 5 相当于     11111111111111111111111111000000
          直接右移   00000111111111111111111111111110            

使用 >> 运算符 和 >>> 运算符,正数无影响

image.png

负数影响就很大了

image.png

所以平时右移一般都用 >> 运算符,不用 >>> 运算符,才能保证正负值二进制和十进制统一

Leetcode 练习

知道了位运算的基础知识,接下来通过 LeetCode 练习,逐渐熟悉位运算的写法。

231.2的幂

真题描述:给你一个整数 n,请你判断该整数是否是 2 的幂次方。如果是,返回 true ;否则,返回 false 。
如果存在一个整数 x 使得 n == 2x ,则认为 n 是 2 的幂次方。

输入: n = 1
输出: true
解释: 20 = 1
输入: n = 16
输出: true
解释: 24 = 16
输入: n = 3
输出: false

可以使用递归或者循环来解决这道题:

// 递归
const isPowerOfTwo = function (n) {
  if (n <= 0) {
    return false
  }
  if (n === 2 || n === 1) {
    return true
  }
  if (n % 2 !== 0) {
    return false
  }
  return isPowerOfTwo(n / 2)
}
// 循环
const isPowerOfTwo = function (n) {
  if (n <= 0) {
    return false
  }
  while (n > 2) {
    n = n / 2
    if (n % 2 !== 0) {
      return false
    }
  }
  if (n === 2 || n === 1) {
    return true
  }
}

但是如果使用位运算来解决,只需要一行代码,并且极大优化时间复杂度:

const isPowerOfTwo = function (n) {
  return n > 0 && (n & (n - 1)) === 0
}

时间复杂度: O(1)
空间复杂度: O(1)

是怎么实现的呢,我们来分析一下。

我们知道,2的幂次方转换为二进制,基本都是这个格式:

10      -> 2
100     -> 4
1000    -> 8
10000   -> 16

而 2^n - 1 转换为二进制,是这个样子:

10      -> 2
01      -> 1

100     -> 4
011     -> 3

1000    -> 8
0111    -> 7

10000   -> 16
01111   -> 15

让 2^n 和 2^n - 1 进行 & 运算

运算结果
10 & 0100
100 & 011000
1000 & 01110000
10000 & 0111100000

所以我们得出规律:

(2^n & 2^n - 1) === 0

所以只需要判断一下,n 是否大于0,并且 n & n - 1 是否等于 0,即可知道 n 是不是 2 的幂次方。

const isPowerOfTwo = function (n) {
  return n > 0 && (n & (n - 1)) === 0
}

说实话,你不告诉我可以这么解的话,人脑很难想出这种鬼东西,hh😂

136.只出现一次的数字

真题描述:给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
你的算法应该具有线性时间复杂度。 你可以不使用额外空间来实现吗?

输入: [2,2,1]
输出: 1
输入: [4,1,2,1,2]
输出: 4

可以使用一个对象来记录每个元素出现的次数,返回只出现一次的那个元素:

const singleNumber = function (nums) {
  const obj = {}
  for (let i = 0, len = nums.length; i < len; i++) {
    if (!obj[nums[i]]) {
      obj[nums[i]] = 1
    } else {
      obj[nums[i]]++
    }
  }
  for (const [key, value] of Object.entries(obj)) {
    if (value === 1) {
      return key
    }
  }
}

空间复杂度为 O(n),不满足题目要求。

这个时候,位运算出现了,用的是异或运算,先来看解答:

const singleNumber = function (nums) {
  let res = 0
  for (const num of nums) {
    res ^= num
  }
  return res
}

时间复杂度:O(n)
空间复杂度:O(1)

是怎么实现的呢,我们来分析一下。

当对一对数位进行异或运算时,如果数位是不同的则返回 1,相同则返回 0

一个数和 0 异或等于它本身:

a ^ 0 -> a

比如

100  ^ 000  ->     100
1000 ^ 0000  ->    1000

一个数和它本身异或等于 0 :

a ^ a -> 0

比如

100  ^ 100  ->     000
1000 ^ 1000  ->    0000

异或运算满足交换律:

a ^ b ^ a = (a ^ a) ^ b = 0 ^ b = b

在以上的基础条件上,将所有数字按照顺序做异或运算,最后剩下的结果即为唯一的数字。

不告诉我可以用位运算,我绝对不可能想得到,感觉像回到了大学学习《离散数学》一样😂

实战!权限方案设计

我们使用位运算来做点有意思的事,管理一个系统的权限。

比如,一个小程序,我们分为 管理员运营者开发者数据分析者这四个角色

image.png

一共有这些权限:

管理员:全部
运营者:管理权限、推广权限、设置权限、使用体验版小程序
开发者:开发权限、使用体验版小程序、使用开发者工具
数据分析者:统计模块查看权限、使用体验版小程序

每个成员又可以兼顾多个角色,比如某一个人,既可以是运营者,也可以是开发者。

定义权限

首先,我们用二进制的方式把所有的权限定义出来:

  • 每种权限码都是唯一的
  • 所有权限码的二进制数形式,有且只有一位值为 1,其余全部为 0
管理权限           0000001
推广权限           0000010
设置权限           0000100
使用体验版小程序    0001000
开发权限           0010000
使用开发者工具      0100000
查看统计模块       1000000

为什么要这么做呢,因为这样我们就可以:

  • 使用 | 运算符来添加权限
  • 使用 & 运算符来校验权限
  • 使用 ^ 运算符来删除权限

使用左移运算符来实现权限的定义:

const MANAGE = 1
const SPREAD = 1 << 1
const SETTING = 1 << 2
const USETESTAPP = 1 << 3
const DEVELOP = 1 << 4
const USEDEVELOPAPP = 1 << 5
const VIEWSTATISTICS = 1 << 6

const ALLAUTH = 1 << 7 - 1

添加权限

使用 | 运算符来添加权限。

比如,运营者需要有管理权限、推广权限、设置权限、使用体验版小程序,对这四个权限使用 | 操作符,就变成这样:

管理权限 | 推广权限 |  设置权限 |使用体验版小程序 

管理权限           0000001
推广权限           0000010
设置权限           0000100
使用体验版小程序    0001000
运营者权限         0001111

代码实现:

let operator = MANAGE | SPREAD | SETTING | USETESTAPP
let developor = DEVELOP | USEDEVELOPAPP | USETESTAPP
let analyst = VIEWSTATISTICS | USETESTAPP
let admin = ALLAUTH

校验权限

使用 & 运算符来校验权限,校验运营者是否有推广权限:

运营者权限         0001111
                    &
推广权限           0000010

得到              0000010

转为布尔值           true

校验运营者是否有开发权限:

运营者权限         0001111
                    &
开发权限           0010000

得到              0000000

转为布尔值           false

代码实现:

console.log('运营者有管理权限 :>> ', !!(operator & MANAGE))
console.log('运营者有开发权限 :>> ', !!(operator & DEVELOP))

image.png

删除权限

使用 ^ 运算符来删除权限,假设要删除运营者的设置权限,就可以这么做:

运营者权限          0001111
                      ^
设置权限            0000100

得到运营者新权限     0001011

先删除,再查看,测试一下:

operator = operator ^ SETTING
console.log('运营者有设置权限 :>> ', !!(operator & SETTING))

image.png

成员兼顾多个角色

比如某个成员,既是开发者,又是数据分析者,要合并他的权限。

非常简单,还是使用 | 运算符。

const user1Auth = developor | analyst

console.log('user1有开发权限 :>> ', !!(user1Auth & DEVELOP))
console.log('user1有查看统计模块权限 :>> ', !!(user1Auth & VIEWSTATISTICS))

image.png

整体代码

// 定义权限
const MANAGE = 1
const SPREAD = 1 << 1
const SETTING = 1 << 2
const USETESTAPP = 1 << 3
const DEVELOP = 1 << 4
const USEDEVELOPAPP = 1 << 5
const VIEWSTATISTICS = 1 << 6

const ALLAUTH = 1 << 7 - 1

// 添加权限
let operator = MANAGE | SPREAD | SETTING | USETESTAPP
const developor = DEVELOP | USEDEVELOPAPP | USETESTAPP
const analyst = VIEWSTATISTICS | USETESTAPP
const admin = ALLAUTH

// 校验权限
console.log('运营者有管理权限 :>> ', !!(operator & MANAGE))
console.log('运营者有开发权限 :>> ', !!(operator & DEVELOP))

// 删除权限
operator = operator ^ SETTING
console.log('运营者有设置权限 :>> ', !!(operator & SETTING))

// 成员兼顾多个角色
const user1Auth = developor | analyst
console.log('user1有开发权限 :>> ', !!(user1Auth & DEVELOP))
console.log('user1有查看统计模块权限 :>> ', !!(user1Auth & VIEWSTATISTICS))

如此轻松地就实现了一个系统权限的管理,即使实际项目中权限要多很多,也可以通过多开辟一些空间来解决,且性能极好,代码简洁,可以用起来了。

Vue3 源码中虚拟DOM的位运算应用

在 Vue3 源码中,有很多使用位运算的例子,比如:

shapeFlags 针对 VNode 的 type 进行了更详细的分类,便于在 patch 阶段,根据不同的类型执行相应的逻辑。

// packages/shared/src/shapeFlags.ts
export const enum ShapeFlags {
  ELEMENT = 1,                   // HTML 或 SVG 标签 普通 DOM 元素
  FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件
  STATEFUL_COMPONENT = 1 << 2,   // 普通有状态组件
  TEXT_CHILDREN = 1 << 3,        // 子节点是纯文本
  ARRAY_CHILDREN = 1 << 4,       // 子节点是数组
  SLOTS_CHILDREN = 1 << 5,       // 子节点是插槽
  TELEPORT = 1 << 6,             // Teleport
  SUSPENSE = 1 << 7,             // Suspense
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 需要被 keep-alive 的有状态组件
  COMPONENT_KEPT_ALIVE = 1 << 9,        // 已经被 keep-alive 的有状态组件
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT // 有状态组件和函数组件都是组件,用 COMPONENT 表示
}

patchFlags 用于标识节点更新的属性,用于运行时优化。

// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
  TEXT = 1,                    // 动态文本节点
  CLASS = 1 << 1,              // 动态 class
  STYLE = 1 << 2,              // 动态 style
  PROPS = 1 << 3,              // 动态属性
  FULL_PROPS = 1 << 4,         // 具有动态 key 属性,当 key 改变时,需要进行完整的 diff 比较
  HYDRATE_EVENTS = 1 << 5,     // 具有监听事件的节点
  STABLE_FRAGMENT = 1 << 6,    // 子节点顺序不会被改变的 fragment
  KEYED_FRAGMENT = 1 << 7,     // 带有 key 属或部分子节点有 key 的 fragment
  UNKEYED_FRAGMENT = 1 << 8,   // 子节点没有 key 的 fragment
  NEED_PATCH = 1 << 9,         // 非 props 的比较,比如 ref 或指令
  DYNAMIC_SLOTS = 1 << 10,     // 动态插槽
  DEV_ROOT_FRAGMENT = 1 << 11, // 仅供开发时使用,表示将注释放在模板根级别的片段
  HOISTED = -1,                // 静态节点
  BAIL = -2                    // diff 算法要退出优化模式
}

看到上面的代码,是不是很熟悉,跟我们上文定义权限几乎一模一样,这里 PatchFlags 是定义 VNode 上的属性,shapeFlags 是定义 VNode 的类型。

  • 通过 | 运算 组合 VNode 的属性或类型
  • 通过 & 运算 校验 VNode 的属性或类型

随便全局搜一下,都有很多 |& 运算符的操作,其实这些就是在组合或者检验 VNode 上的属性或类型。

image.png

image.png

很显然, Vue3 中位运算的用法和权限设计是一样的,理解了上文的权限设计,你也就理解了 Vue3 中的位运算,只是 Vue3 的 VNode 上的属性和类型比起之前权限设计的要复杂一些,但原理都是一样的。

Vue3 为什么要用位运算呢?

主要是为了提升性能,使用位运算,不仅提升了标记的速度,也节省了运行内存

毕竟只需要 number 就能存储,比使用数组或者对象存储,空间复杂度就是 O(n) 节省到 O(1)。

且直接计算二进制,速度要快很多。

看到这里,Vue3 的 diff 算法比 Vue2 性能好得多的其中一个原因,不知不觉中就明白了。

小结

位运算看似很难,实则很简单,就是离散数学里最基础的东西,学会了之后,我们对于前端的权限设计、多选逻辑设计,都可以用。

使用位运算,性能肯定是提升了,至于代码可读性,就见仁见智了。

对不懂位运算的人来说,就是看天书。

对懂位运算的人来说,代码变得更精简,可读性更强。

最关键的一点,不理解位运算,你是看不懂 Vue3 的 diff 算法的,hh🙈

看到 Vue3 关于位运算的设计,不得不再次感叹,算法真的太重要了。

所以说啊,以前上学的时候没好好学,现在才来补,真想给过去的自己一巴掌,你咋没好好学计算机基础和算法呢?

如果我的文章对你有帮助,你的赞👍就是对我的最大支持!

传送门

从 keep-alive 源码掌握 LRU Cache

广度优先搜索

深度优先搜索

「1.9W字总结」一份通俗易懂的 TS 教程,入门 + 实战!