在 Vue3 官方文档中提到 Vue 编译器用来提高虚拟 DOM 运行时性能的优化手段,其中有一项是 更新类型标记,其中便提到了位运算;无独有偶,在 Svelte 中也是使用位掩码来记录脏数据。今天从基础的位运算开始聊一下位掩码
前置知识
计算机基础知识,大佬可以直接跳了
二进制
众所周知,输入电脑的任何信息最终都要转化为二进制才能处理。
二进制是由 1 和 0 两个数字组成的,它可以表示两种状态,即 开 和 关。
目前通用的是 ASCII
码。
最基本的单位为 bit。
二进制与十进制转换
十进制转二进制
通常使用 除 2 取余法
例:8 转二进制
ini
8➗2=4 ...... 0
4➗2=2 ...... 0
2➗2=1 ...... 0
1➗2=0 ...... 1
将余数从下往上排,得到 1000,即 8 的二进制数
二进制转十进制
从左往右,从 0 开始乘 2,再加余数,得到结果再乘 2,继续加下一位的余数。
例:1000 转十进制
ini
0 x 2 + 1 = 1
1 x 2 + 0 = 2
2 x 2 + 0 = 4
4 x 2 + 0 = 8
得到 8,也就是二进制 1000 的十进制数
位运算
位运算就是直接对整数在内存中的二进制位进行操作。
以下只列举部分常用位运算符
AND (&)
按位与运算,两位同时为 1,结果才为 1
举个栗子,8 & 11
8 的二进制是 1000,11 的二进制是 1011
yaml
1000
& 1011
------------------
1000
得到的结果是 1000,转换为十进制就是 8
OR (|)
按位或运算,只要有一位是 1,那么结果就为 1
再举个栗子,8 | 11
8 的二进制是 1000,11 的二进制是 1011
yaml
1000
| 1011
------------------
1011
得到的结果是 1011,转换为十进制就是 11
NOT (~)
按位非运算,将操作数的位反转
也即:
ini
~1 = 0
~0 = 1
在计算机中,正数用原码表示,负数使用补码存储。
有点小复杂了,这里又涉及到两个东西,反码
和 补码
。
- 正数的反码是其本身(等于原码),负数的反码是符号位保持不变,其余位取反。
- 正数的补码是其本身,负数的补码等于其反码 +1
再来个例子,~8
yaml
0000 1000
~
------------------------------------
1111 0111
反 1000 1000
补 1000 1001
首先看最高位,最高位 1 表示负数,0 表示正数。此二进制码为负数,最高位为符号位。
当按位取反为负数时,就直接取其补码,也就是 1000 1001
,转为十进制就是 -9
其实只要记住一句话就好:按位非的操作结果实际上是对数值进行取反并减 1
左移 (<<)
左移操作符会将运算对象的各二进制位全部左移若干位,左边的二进制位丢弃,右边补 0。
继续我们的小栗子,这次让老八左移 1 位,8 << 1
yaml
0000 1000
------------------------------------
0001 0000
得到的结果是:0001 0000
,转换为十进制就是 16
通常情况下,若左移时舍弃的高位中不包含 1,则每左移 1 位,相当于该数乘以 2。
有符号右移操作符 (>>)
有符号右移操作符会将数值的 32 位全部右移若干位(同时会保留正负号)。正数左补 0,负数左补 1,右边丢弃。
老八登场,8 >> 1
我这里就不画那么多 0 了(懒)
yaml
0000 1000
------------------------------------
0000 0100
得到的结果是:0000 0100
,转换为十进制就是 4
在有符号右移操作时,右移 1 位,相当于该数除以 2。
无符号右移操作符 (>>>)
无符号右移操作符会将数值的 32 位全部右移。
- 正数:无符号右移操作符和有符号右移操作符的结果是一样的
- 负数:无符号右移操作符将负数的二进制表示当成正数的二进制表示来处理
这次我们用负八,因为正数与有符号右移是一样的
-8 >>> 1
yaml
1000 0000 0000 0000 0000 0000 0000 1000
反 1111 1111 1111 1111 1111 1111 1111 0111
补 1111 1111 1111 1111 1111 1111 1111 1000
---------------------------------------------------------------------------------------------------------------------------------------
0111 1111 1111 1111 1111 1111 1111 1100
因为是负数,所以我们取的是它的补码,无符号右移时最高位的符号位补 0
得到的结果就是:0111 1111 1111 1111 1111 1111 1111 1100
,对应的十进制就是:2147483644
前端中的位运算
众所周知,前端程序主要是用 JavaScript 编写。
这时候我们就要想一想 JavaScript 中是怎么按位存储 Number 类型的?
我们都知道,JavaScript 中的 Number
是不区分 int
、long
、float
、double
类型的,所以 Number 的存储方式一定是要能满足浮点型的。
同时,为了不丢失精度,Number 类型实际上是一个基于 IEEE 754
标准的 64 位双精度浮点数。
但是浮点型又不能进行位运算(主要是没啥意义,且有安全隐患),所以 JavaScript 必须在 "不允许位运算" 和 "允许位运算但完全舍弃小数部分" 之间做选择。
然后 JavaScript 就果断地选择了后者,也就是位运算的时候直接把小数部分舍弃了。
这就导致了我们在 JavaScript 中可以通过位运算做一些特殊的数字处理;例如:8.8 >> 0
、 8 << 0
、~~8
都可以直接舍弃小数部分,不改变整数;以及在算法中常用的有符号位移操作(>>),经常用作代替除 2 的操作 9 >> 1 = 4
还需要注意的是,在 JavaScript 中进行整数运算时会自动转换为 32 位的有符号整数,例如位运算,有符号整数使用首位表示整数的符号,用 31 位表示整数的数值,数值范围是 −231-2^{31}−231 ~ 2312^{31}231
。
位掩码
位掩码(bitMask)又称位运算掩码,是一种用来对数据进行位操作的的掩码。它通常是一个二进制数,在计算机中用来与另一个数进行位运算,以达到特定的目的。
- 添加位:
a |= b
- 移除位:
a &= ~b
- 检测位:
a & b
举一个简单的小栗子
假设有一个 8 位二进制数:1011 1001
,现在我们想清除它的第三位,那我们只需要把它和 1111 0111
做 按位与 就能实现
yaml
1011 1001
& 1111 0111
------------------------------------
1011 0001
你可能会有疑问,这么做有啥实际意义?这玩意儿有啥用?
不急,继续往下看
老鼠试毒
来一道位运算相关的经典题目
题目
有 1000 瓶水,其中有一瓶有毒,小白鼠只要尝一点带毒的水,24 小时后就会死亡,问至少要多少只小白鼠才能在 24 小时内鉴别出哪瓶水有毒?
题解
先简化一下题目
假设只有 8 瓶水,我们把它们从 1 到 8 编好号码,其中一瓶是有毒的
因为有 8 瓶,所以至少需要用 4 位来表示,用二进制表示如下
一号水:0 0 0 1
二号水:0 0 1 0
三号水:0 0 1 1
四号水:0 1 0 0
五号水:0 1 0 1
六号水:0 1 1 0
七号水:0 1 1 1
八号水:1 0 0 0
其中 1 表示该瓶子需要给对应的小白鼠喝,0 表示不需要喝;数字 1 在第几位,那么该位置就对应第几只老鼠
一号水给第 4 只喝,二号水给第 3 只喝,三号水给第 3 和第 4 只喝,四号水给第 2 只喝,五号水给第 2 和第 4 只喝,六号水给第 2 和第 3 只喝,七号水给第 2、第 3、第 4 只喝,八号水只给第 1 只喝。我们只用到了四只小白鼠。
我们再以小白鼠的视角梳理一下
第 1 只:喝了八号水
第 2 只:喝了四、五、六、七号水
第 3 只:喝了二、三、六、七号水
第 4 只:喝了一、三、五、七号水
如果将八瓶水看作是二进制的 8 个二进制位(bit),那么四只小白鼠就可以使用二进制表示为:
yaml
第 1 只:0000 0001
第 2 只:0001 1110
第 3 只:0110 0110
第 4 只:1010 1010
(聪明的你可能发现了,小白鼠的二进制矩阵其实就是将 8 瓶水的二进制矩阵转置得到的)
假设只有第 2 只小白鼠挂了,那就说明 四、五、六、七 这四瓶水有问题。如果是五号有问题,那么第 4 只小白鼠也会挂掉;如果是六号有问题,那么第 3 只小白鼠也会挂掉;如果是七号有问题,那么第 3 只和第 4 只小白鼠都会挂掉;因此,有毒的就是四号水。
如果我们将四只小白鼠看作是二进制的 4 个二进制位(bit),1 表示小白鼠死亡,0 表示还活着。那么第 2 只小白鼠死亡,就可以表示为:0100
,对应的就是四号水有毒。
同理,如果是 1000 瓶水,那就需要 10 位的二进制来表示(2 的 10 次方=1024),也就是需要 10 只小白鼠。
应该差不多能感受到位运算的威力了吧
Svelte 中的位掩码
Svelte 是一个新兴热门的前端框架。
核心思想在于 『通过静态编译减少框架运行时的代码量』,不使用 Virtual Dom 而是在编译时直接封装原生的 javascript 操作 DOM 节点的方法;React 和 Vue 都是基于运行时的框架,当用户在你的页面进行各种操作改变组件的状态时,框架的运行时会根据新的组件状态(state)计算(diff)出哪些 DOM 节点需要被更新,从而更新视图。
因为没有 Virtual Dom,所以 Svelte 需要记录哪些数据更改了,这些被更改的数据也被叫做 "脏数据"。
Svelte 使用位掩码的技术来跟踪哪些值是脏的,即自组件最后一次更新以来,哪些数据发生了哪些更改。一个比特位存放一个数据是否变化,一般 1 表示脏数据,0 表示是干净数据。
假设你有四个值:A、B、C、D;那么二进制 0000 0001
就可以表示第一个值 A 有更新,0000 0010
就表示第二个值 B 有更新,同理,0000 0100
就表示第三个值 C 有更新;如果是二进制 0000 0101
就可以同时表示第一个值 A 和第三个值 C 都发生了改变,0000 1111
就可以表示四个值都发生了改变。
这种记录脏数据的方式,可以在最大程度上利用空间;上面例子中的 0000 1111
转换为十进制,不过才 15
,但却可以表示出四个值是脏数据。
上个栗子感受一下
html
<!-- App.svelte -->
<script>
let count = 0;
function handleClick() {
count += 1;
}
</script>
<main>
<button on:click="{handleClick}">点击</button>
<div>{count}</div>
</main>
经过编译器后生成如下 js 代码(这里我们只关注 p 函数,相当于 path)
js
p(ctx, [dirty]) {
/**
* 当 count 变化时
* dirty 会变成 1,& 1 后得到 1
* 对应的二进制 0001
*/
if (dirty & /*count*/ 1) set_data(t2, /*count*/ ctx[0]);
},
可以在 Svelte 提供的编程环境中自己尝试一下 REPL
JS 的限制
前面提到,在 Javascript 中进行位运算时会自动转换为 32 位的有符号整数,也就意味着 Svelte 使用二进制存储"脏数据"最大也只能存储 31 位(还有一个是符号位)
作为一个前端框架,31 位显然是不够用的
Svelte 的解决办法就是直接用 数组
来存放 ,数组中的每一项是二进制 31 位的比特位。假如超出 31 个数据了,超出的部分会被放到数组中的下一项。
这次我把 count 变量加到了 33 个,我们看下效果
当变量超过 31 个时,dirty 变成了数组,超过的部分移到了下一项;同时,我们可以看到 count31 与 1073741824 做按位与运算,1073741824 转换为二进制是 1000000000000000000000000000000
,正是 31 位
Vue3 中的位掩码
Vue3 使用的是 带编译时信息的虚拟 DOM;也就是在编译代码时,编译器会先静态分析模板,然后在生成的代码中打上标记;目的是为了运行时可以快速的确定生成的代码应该使用何种处理方式,用 Vue 官方的话说就是为了让运行时尽可能地走捷径。
更新类型标记
我们这里主要介绍其中的一项与位掩码相关的优化手段,即 更新类型标记
;对另外两个优化手段感兴趣的可以戳这里
在 Vue3 中定义了以下几种更新标记,元素在编译时会被打上标记
ts
export const enum PatchFlags {
// 动态文本节点
TEXT = 1,
// 动态的 class
CLASS = 1 << 1,
// 动态的 style 属性
STYLE = 1 << 2,
// 动态属性(除了 class 与 style)
PROPS = 1 << 3,
// 动态 key,当 key 变化时需要完整的 diff 算法做比较
FULL_PROPS = 1 << 4,
// 带有事件监听器的节点,合并事件
HYDRATE_EVENTS = 1 << 5,
// 一个不会改变子节点顺序的 Fragment
STABLE_FRAGMENT = 1 << 6,
// 带有 key 属性的 Fragment
KEYED_FRAGMENT = 1 << 7,
// 子节点没有 key 的 Fragment
UNKEYED_FRAGMENT = 1 << 8,
// 只有非props需要patch的,比如ref
NEED_PATCH = 1 << 9,
// 动态插槽
DYNAMIC_SLOTS = 1 << 10,
/**
* 仅因为用户在模板的根级别放置注释而创建的片段
* 这是一个仅用于开发的标志,因为注释在生产环境中会被剥离
*/
DEV_ROOT_FRAGMENT = 1 << 11,
/**
* 以下是特殊的flag
* 不会在优化中被用到
* 是内置的特殊flag
*/
/**
* 表示他是静态节点
* 他的内容永远不会改变
* 对于hydrate的过程中
* 不需要再对其子节点进行diff,更新时跳过整个子树
*/
HOISTED = -1,
// 用来表示一个节点的diff应该结束
BAIL = -2,
}
以下是官方的小栗子,三个动态绑定的元素
html
<div :class="{ active }"></div>
<input :id="id" :value="value" />
<div>{{ dynamic }}</div>
编译后得到如下代码
js
import {
normalizeClass as _normalizeClass,
createElementVNode as _createElementVNode,
toDisplayString as _toDisplayString,
Fragment as _Fragment,
openBlock as _openBlock,
createElementBlock as _createElementBlock,
} from "vue";
const _hoisted_1 = ["id", "value"];
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (
_openBlock(),
_createElementBlock(
_Fragment,
null,
[
_createElementVNode(
"div",
{
class: _normalizeClass({ active: _ctx.active }),
},
null,
2 /* CLASS */
),
_createElementVNode(
"input",
{
id: _ctx.id,
value: _ctx.value,
},
null,
8 /* PROPS */,
_hoisted_1
),
_createElementVNode(
"div",
null,
_toDisplayString(_ctx.dynamic),
1 /* TEXT */
),
],
64 /* STABLE_FRAGMENT */
)
);
}
可以明显看到,生成的渲染函数在最外层套了一层 Fragment 组件,当然这不是我们这次的重点
重点是通过 _createElementVNode 函数创建的三个元素,它们的第四个入参就是 更新类型标记(patchFlag)
,这个参数将会在渲染时发挥作用
在渲染时将调用 patchElement
函数
ts
// packages\runtime-core\src\renderer.ts
const patchElement = (...) => {
const el = (n2.el = n1.el!)
let { patchFlag, dynamicChildren, dirs } = n2
// 添加旧结点的 patchFlag
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
...
if (patchFlag > 0) {
/**
* 存在 patchFlag
* 说明当前元素是由编译生成的 render 代码是由编译器生成的
* 可以优化(走最快的路径完成 patch)
*/
if (patchFlag & PatchFlags.FULL_PROPS) {
// 元素的 props 中包含动态 key,需要全量 diff
patchProps(...)
} else {
// 元素存在动态类
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(...)
}
}
// 元素存在动态样式属性
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(...)
}
/**
* 元素绑定了动态属性
* 除了 类 和 样式
*/
if (patchFlag & PatchFlags.PROPS) {
...
hostPatchProp(...)
}
}
// 元素只有动态文本
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(...)
}
}
} else if (!optimized && dynamicChildren == null) {
/**
* 没办法优化的
* 需要全量 diff
*/
patchProps(...)
}
...
}
从源码中可以直观地看到,每个标记都会调用对应的处理函数;而判断元素含有哪一种标记,用的则是 按位与(&)
Vue2 更新方式
提到了 Vue3,不得不简单说一下 Vue2
相比 Vue3,Vue2 的更新方式简单粗暴,遍历所有属性,然后根据具体情况更新属性
ts
// src\core\vdom\patch.ts
function patchVnode(...) {
...
// update 数组中每一项都对应一种属性更新函数
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode)
}
...
}
以 类 为例
ts
// Dsrc\platforms\web\runtime\modules\class.ts
function updateClass(oldVnode: any, vnode: any) {
...
/**
* class 属性存在,且有变更
* 更新 class 属性
*/
if (cls !== el._prevClass) {
el.setAttribute('class', cls)
el._prevClass = cls
}
}
export default {
create: updateClass,
update: updateClass
}
权限系统
最后,简单说一下,一个常被提及的使用场景 ------------ 权限系统
假设我们有四种权限,通常写法如下
js
const read_code = 1;
const update_code = 2;
const delete_code = 3;
const execute_code = 4;
// 声明一个角色 guser
const roles1 = {
guser: [delete_code, execute_code],
};
// 添加权限
roles1.guser.push(read_code);
// 移除权限
roles1.guser.shift();
// 判断角色权限
function hasPermission1(userRole, action) {
/**
* 考虑到兼容性
* 这里不使用 includes 或 find 等函数
*/
const auths = roles1[userRole];
let flag = false;
for(let i = 0; i < auths.length; i++) {
if(auths[i] === action) {
flag = true;
break;
}
}
return flag;
}
如果使用位掩码,看上去稍微简洁了一点
js
const read_code = 1 << 0; // 0 0 0 1,1
const update_code = 1 << 1; // 0 0 1 0,2
const delete_code = 1 << 2; // 0 1 0 0,4
const execute_code = 1 << 3; // 1 0 0 0,8
const roles2 = {
guser: delete_code | execute_code,
};
// 添加权限
roles2.guser |= read_code
// 移除权限
roles2.guser &= ~delete_code
// 判断角色权限
function hasPermission2(userRole, action) {
return roles2[userRole] & action;
}
借助 JSBench 工具,我们看一下执行效率
hasPermission1 每秒执行 2.5 亿次,hasPermission2 每秒能执行 21 亿次,效率还是可以的
当然如果你换成 includes,效率也会更高一点
JSBench 工具的使用可以参考我这篇文章 js性能优化(实战篇)