「这是我参与2022首次更文挑战的第22天,活动详情查看:2022首次更文挑战」。
学习过 Vue3 源码的同学都知道,diff 算法用到了位运算,不把位运算搞清楚,你是看不懂 Vue3 源码的。
本文知识图谱如下:
本文将通过四个步骤带你熟悉前端中的位运算
- 基础:JavaScript位运算符基本使用。
- 上手:LeetCode 两道位运算相关算法题,感受写法。
- 实战:用位运算实现一个系统的权限方案设计。
- 熟练:Vue3 虚拟 DOM 中的位运算分析。
你掌握了吗?没掌握就一起来查漏补缺吧。
什么是位运算?
我们知道,计算机中计算用的是 二进制
,不是日常生活的中的十进制
。
十进制是逢 10 进 1,二进制是逢 2 进 1,如下表所示:
十进制 | 二进制 | 十进制 | 二进制 |
---|---|---|---|
0 | 0 | 6 | 110 |
1 | 1 | 7 | 111 |
2 | 10 | 8 | 1000 |
3 | 11 | 9 | 1001 |
4 | 100 | 10 | 1010 |
5 | 101 | 11 | 1011 |
位运算
就是计算机在二进制位置上的计算。
JavaScript 位运算符
我们介绍 5 种开发中用得多的,更多详细可参考 w3school 位运算
运算符 | 名称 | 描述 |
---|---|---|
& | AND | 如果两位都是 1 则设置每位为 1 |
| | OR | 如果两位之一为 1 则设置每位为 1 |
XOR | 如果两位只有一位为 1 则设置每位为 1 | |
<< | 零填充左位移 | 通过从右推入零向左位移,并使最左边的位脱落。 |
>> | 有符号右位移 | 通过从左推入最左位的拷贝来向右位移,并使最右边的位脱落。 |
& 运算符(和)
对一对数位执行位运算 & 时,如果数位均为 1 则返回 1。
单位示例:
运算 | 结果 |
---|---|
0 & 0 | 0 |
0 & 1 | 0 |
1 & 0 | 0 |
1 & 1 | 1 |
四位示例:
运算 | 结果 |
---|---|
1111 & 0000 | 0000 |
1111 & 0001 | 0001 |
1111 & 0010 | 0010 |
1111 & 0100 | 0100 |
| 运算符(或)
对一对数位执行位运算 | 时,如果其中一位是 1 则返回 1。
单位示例:
运算 | 结果 |
---|---|
0 | 0 | 0 |
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 1 |
四位示例:
运算 | 结果 |
---|---|
1111 | 0000 | 1111 |
1111 | 0001 | 1111 |
1111 | 0010 | 1111 |
1111 | 0100 | 1111 |
^ 运算符(异或)
对一对数位进行位运算 ^ 时,如果数位是不同的则返回 1,相同则返回 0
单位示例:
运算 | 结果 |
---|---|
0 ^ 0 | 0 |
0 ^ 1 | 1 |
1 ^ 0 | 1 |
1 ^ 1 | 0 |
四位示例:
运算 | 结果 |
---|---|
1111 ^ 0000 | 1111 |
1111 ^ 0001 | 1110 |
1111 ^ 0010 | 1101 |
1111 ^ 0100 | 1011 |
<< 运算符
<<
运算符,零填充左位移,通过从右推入零向左位移,并使最左边的位脱落。
计算过程是先转二进制,然后在最右边加个 0。
运算(十进制) | 结果 | 等同于(二进制) | 结果 |
---|---|---|---|
0 << 1 | 0 | 0 << 1 | 0 |
1 << 1 | 2 | 1 << 1 | 10 |
2 << 1 | 4 | 10 << 1 | 100 |
3 << 1 | 6 | 11 << 1 | 110 |
4 << 1 | 8 | 100 << 1 | 1000 |
使用 << 运算符,相当于乘 2。
运算 | 结果 |
---|---|
5 << 1 | 10 |
5 << 2 | 20 |
5 << 3 | 40 |
5 << 4 | 80 |
5 << 2 相当于 (5 << 1) << 1 相当于 5 * 2 * 2。
5 << 3 相当于 ((5 << 1) << 1) << 1 相当于 5 * 2 * 2 * 2。
有点递归的感觉。
>> 运算符
>>
运算符,有符号(正或负)右移,通过从左推入最左位的拷贝来向右位移,并使最右边的位脱落。
计算过程是先转二进制,然后在最左边加个 0,再去掉最右边的一位。
运算(十进制) | 结果 | 等同于(二进制) | 结果 |
---|---|---|---|
0 >> 1 | 0 | 0 >> 1 | 0 |
1 >> 1 | 0 | 1 >> 1 | 0 |
2 >> 1 | 1 | 10 >> 1 | 01 |
3 >> 1 | 1 | 11 >> 1 | 01 |
4 >> 1 | 2 | 100 >> 1 | 010 |
5 >> 1 | 2 | 101 >> 1 | 010 |
使用 >> 运算符,相当于除以 2。
运算 | 结果 |
---|---|
256 >> 1 | 128 |
256 >> 2 | 64 |
256 >> 3 | 32 |
256 >> 4 | 16 |
256 >> 5 | 8 |
256 >> 2 相当于 (256 >> 1) >> 1 相当于 256 / 2 / 2。
>> 运算符 和 >>> 运算符的区别
>>
运算符是有符号(正或负)右移
>>>
运算符是无符号右移
在二进制里,如果是正数,前面的值都是0,如果表示的是负数,就是正数的补码
比如:
64 二进制表示为 00000000000000000000000000100000
-64 二进制表示为 11111111111111111111111111100000
所以左移的时候,有没有符号不影响。
64 << 5 相当于 00000000000000000000000000100000
变为 00000000000000000000010000000000
-64 << 5 相当于 11111111111111111111111111100000
变为 11111111111111111111110000000000
右移的时候,影响就大了。
64 >> 5 相当于 00000000000000000000000001000000
变为 00000000000000000000000000000010
-64 >> 5 相当于 11111111111111111111111111000000
先转正数 00000000000000000000000001000000
右移 00000000000000000000000000000010
再转回负数 11111111111111111111111111111110
-64 >>> 5 相当于 11111111111111111111111111000000
直接右移 00000111111111111111111111111110
使用 >> 运算符 和 >>> 运算符,正数无影响
负数影响就很大了
所以平时右移一般都用 >> 运算符,不用 >>> 运算符,才能保证正负值二进制和十进制统一
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 & 01 | 00 |
100 & 011 | 000 |
1000 & 0111 | 0000 |
10000 & 01111 | 00000 |
所以我们得出规律:
(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
在以上的基础条件上,将所有数字按照顺序做异或运算,最后剩下的结果即为唯一的数字。
不告诉我可以用位运算,我绝对不可能想得到,感觉像回到了大学学习《离散数学》一样😂
实战!权限方案设计
我们使用位运算来做点有意思的事,管理一个系统的权限。
比如,一个小程序,我们分为 管理员
、运营者
、开发者
和数据分析者
这四个角色
一共有这些权限:
管理员:全部
运营者:管理权限、推广权限、设置权限、使用体验版小程序
开发者:开发权限、使用体验版小程序、使用开发者工具
数据分析者:统计模块查看权限、使用体验版小程序
每个成员又可以兼顾多个角色,比如某一个人,既可以是运营者,也可以是开发者。
定义权限
首先,我们用二进制的方式把所有的权限定义出来:
- 每种权限码都是唯一的
- 所有权限码的二进制数形式,有且只有一位值为 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))
删除权限
使用 ^
运算符来删除权限,假设要删除运营者的设置权限,就可以这么做:
运营者权限 0001111
^
设置权限 0000100
得到运营者新权限 0001011
先删除,再查看,测试一下:
operator = operator ^ SETTING
console.log('运营者有设置权限 :>> ', !!(operator & SETTING))
成员兼顾多个角色
比如某个成员,既是开发者,又是数据分析者,要合并他的权限。
非常简单,还是使用 |
运算符。
const user1Auth = developor | analyst
console.log('user1有开发权限 :>> ', !!(user1Auth & DEVELOP))
console.log('user1有查看统计模块权限 :>> ', !!(user1Auth & VIEWSTATISTICS))
整体代码
// 定义权限
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 上的属性或类型。
很显然, Vue3 中位运算的用法和权限设计是一样的,理解了上文的权限设计,你也就理解了 Vue3 中的位运算,只是 Vue3 的 VNode 上的属性和类型比起之前权限设计的要复杂一些,但原理都是一样的。
Vue3 为什么要用位运算呢?
主要是为了提升性能,使用位运算,不仅提升了标记的速度,也节省了运行内存。
毕竟只需要 number 就能存储,比使用数组或者对象存储,空间复杂度就是 O(n) 节省到 O(1)。
且直接计算二进制,速度要快很多。
看到这里,Vue3 的 diff 算法比 Vue2 性能好得多的其中一个原因,不知不觉中就明白了。
小结
位运算看似很难,实则很简单,就是离散数学里最基础的东西,学会了之后,我们对于前端的权限设计、多选逻辑设计,都可以用。
使用位运算,性能肯定是提升了,至于代码可读性,就见仁见智了。
对不懂位运算的人来说,就是看天书。
对懂位运算的人来说,代码变得更精简,可读性更强。
最关键的一点,不理解位运算,你是看不懂 Vue3 的 diff 算法的,hh🙈
看到 Vue3 关于位运算的设计,不得不再次感叹,算法真的太重要了。
所以说啊,以前上学的时候没好好学,现在才来补,真想给过去的自己一巴掌,你咋没好好学计算机基础和算法呢?
如果我的文章对你有帮助,你的赞👍就是对我的最大支持!
传送门