一、从一个具体的问题说起
假设你在开发一个内容管理系统,需要控制用户对文章的操作权限。有的用户只能看,有的能看也能写,还有的管理员可以删除文章。这是每个后台系统都会遇到的基础需求。
按照常规思路,我们可能会用一个对象来记录用户的各项权限:
javascript
const userPermissions = {
canRead: true,
canWrite: false,
canDelete: false
};
这种做法很直观,但如果我们有十几个甚至几十个权限呢?对象会越来越臃肿,每次判断权限都要访问对象的属性,还要处理各种默认值。更麻烦的是,当我们需要把权限信息存到本地存储,或者通过网络传给后端时,这个对象的体积会比较大。
这时候,我们可以换一个角度思考:计算机最底层处理的是什么?是数字,是二进制的零和一。一个整数在内存中就是一串二进制位,我们能不能用这一串位来编码所有的权限信息呢?
这就是位运算权限系统的核心出发点。
二、用二进制位编码权限的基本思想
要理解位运算权限系统,首先得明白整数在计算机中是怎么存储的。我们知道,计算机内部用二进制表示所有数据。一个整数,比如数字五,用三十二位二进制表示,写出来是这样的:
00000000 00000000 00000000 00000101
从右往左看,第一位是1,第二位是0,第三位是1,其余位都是0。这串二进制数本质上就是一个由零和一组成的序列。
那么,如果我们做一个约定:让这个序列的每一位都对应一个权限,这一位是1就表示有这个权限,是0就表示没有,这样不就能用一个整数编码所有权限了吗?
具体怎么对应呢?我们可以从最右边开始编号:
- 第0位(最右边,值为1)表示"可读"
- 第1位(值为2)表示"可写"
- 第2位(值为4)表示"可删除"
按照这个规则,如果某个用户的权限值是1,二进制是001,表示只有读权限。如果是3,二进制是011,表示有读和写权限。如果是7,二进制是111,表示三个权限都有。
这样一来,原本需要用对象或数组存储的权限信息,现在被压缩到了一个整数里。这不仅节省了存储空间,更重要的是,我们为后续的高速运算创造了条件。因为位运算在CPU层面是直接支持的,执行速度极快。
三、如何计算每个权限对应的数值
明白了用二进制位编码权限的思想,接下来的问题是:在实际代码中,我们如何方便地定义每个权限对应的数值呢?
我们知道,每个权限对应一个特定的二进制位,这个位的位置决定了它的数值。第0位是1,第1位是2,第2位是4,第3位是8,以此类推。这些数值都是2的整数次幂。
在JavaScript中,有一种专门用来处理二进制位的运算符,叫做左移运算符,用两个小于号<<表示。它的作用是把一个数的二进制位整体向左移动指定的位数,右边空出的位置补零。
1 << 0 结果是1,二进制是1
1 << 1 结果是2,二进制是10
1 << 2 结果是4,二进制是100
1 << 3 结果是8,二进制是1000
可以看到,1 << n的结果就是2的n次方,其二进制形式恰好是在第n位(从0开始计数)上为1,其他位都是0。这正好就是我们需要的权限掩码。
利用这个特性,我们可以非常清晰地定义权限常量:
javascript
const Permission = {
READ: 1 << 0, // 值为1,二进制第0位是1
WRITE: 1 << 1, // 值为2,二进制第1位是1
DELETE: 1 << 2, // 值为4,二进制第2位是1
ADMIN: 1 << 3 // 值为8,二进制第3位是1
};
这种写法有几个好处。首先,意图非常明确,一眼就能看出每个权限对应哪一位。其次,方便调整,如果需要修改某个权限对应的位,只需要改一下左移的位数就行。最后,便于扩展,增加新的权限只需要继续往下写1 << 4、1 << 5即可。
需要注意的是,这种方案有一个隐含的限制:由于JavaScript的位运算在内部会把操作数转换为32位有符号整数,而且最高位是符号位,所以实际可用的只有31位。也就是说,这种方案最多支持31个不同的权限。对于大多数业务系统来说,31个权限已经足够。如果确实需要更多,可以考虑使用BigInt或者分块存储的方案,这里先不展开。
四、添加权限:按位或运算
定义好了权限常量,接下来的核心问题是:如何给用户添加权限?
假设我们有一个变量userPermission,用来存储用户的权限值,初始状态是0,表示没有任何权限。现在我们要给他添加"读"权限。
如果只是简单地赋值,会覆盖掉之前的状态。所以我们需要一种运算,能够在不影响其他位的前提下,把某一位设置成1。这个运算就是按位或,用竖线符号|表示。
按位或的规则是:两个位中只要有一个是1,结果就是1;只有两个都是0,结果才是0。
0101 (5)
| 0011 (3)
----
0111 (7)
把这个规则应用到权限添加上,原理是这样的:权限掩码(比如Permission.READ)只有它对应的那一位是1,其他位都是0。当它和用户的权限值进行或运算时,那位如果原来是0就会变成1,如果原来已经是1则保持1不变;而其他位因为和0进行或运算,所以保持原状。
具体到代码:
javascript
let userPermission = 0; // 初始没有任何权限
// 添加读权限
userPermission |= Permission.READ;
// 现在 userPermission 的值是 1 (二进制 001)
// 再添加写权限
userPermission |= Permission.WRITE;
// 现在 userPermission 的值是 3 (二进制 011)
无论用户之前有没有某个权限,用或运算添加都是安全的。如果之前没有,对应位原来是0,或上1之后变成1;如果之前已经有了,对应位已经是1,或上1之后还是1,不会发生变化。
如果要一次性添加多个权限,可以先把这些权限用或运算组合在一起,然后再和用户的权限值进行或运算:
javascript
// 一次性添加读、写、删除三个权限
userPermission |= Permission.READ | Permission.WRITE | Permission.DELETE;
这里的Permission.READ | Permission.WRITE | Permission.DELETE先把三个权限掩码通过或运算合并成一个值,然后再和userPermission进行或运算,实现批量添加。这种写法既简洁又高效。
五、检查权限:按位与运算
添加权限解决了,接下来的核心需求是如何检查用户有没有某个权限。这是权限系统中调用最频繁的操作,必须保证足够快。
按位与运算正好能满足这个需求。按位与用和号符号&表示,规则是:两个位都是1,结果才是1;只要有一个是0,结果就是0。
0101 (5)
& 0011 (3)
----
0001 (1)
检查权限的思路是这样的:把用户的权限值和要检查的权限掩码进行与运算。如果结果不为零,说明用户有这个权限;如果结果为零,说明没有。
原理在于,权限掩码只有它对应的那一位是1,其他位都是0。进行与运算后,如果用户权限的那一位是1,结果就是那个掩码值(非零);如果用户权限的那一位是0,结果就一定是0。
代码实现:
javascript
// 检查是否有读权限
const hasRead = (userPermission & Permission.READ) !== 0;
// 检查是否有写权限
const hasWrite = (userPermission & Permission.WRITE) !== 0;
这里要注意判断条件。因为与运算的结果可能是任何非零值,不一定是1,所以我们只需要判断结果是否不等于零即可。千万不要用=== Permission.READ这种判断,除非你很确定用户的权限值只有这一位是1。
举个例子说明。假设用户权限值是5(二进制101),表示有读和删除权限,没有写权限。
检查读权限:
101 (5,用户权限)
& 001 (1,读权限掩码)
---
001 (1,非零,说明有读权限)
检查写权限:
101 (5)
& 010 (2,写权限掩码)
---
000 (0,为零,说明没有写权限)
结果一目了然。这种检查方式的时间复杂度是O(1),无论系统有多少种权限,校验一次都只需要进行一次位运算,速度极快。
实际项目中,通常会封装成一个函数或方法:
javascript
function hasPermission(userPerm, checkPerm) {
return (userPerm & checkPerm) !== 0;
}
// 使用
if (hasPermission(userPermission, Permission.WRITE)) {
console.log('可以编辑文章');
}
检查多个权限中的任意一个,可以先把这些权限用或运算组合起来,再和用户的权限进行与运算:
javascript
// 检查是否有读或写的权限
const canAccess = (userPermission & (Permission.READ | Permission.WRITE)) !== 0;
检查是否同时拥有多个权限,要确保与运算的结果等于这些权限的组合值:
javascript
// 检查是否同时有读、写、删除三个权限
const required = Permission.READ | Permission.WRITE | Permission.DELETE;
const hasAll = (userPermission & required) === required;
这里的区别在于,检查"任意一个"用!== 0,检查"同时拥有"用=== required。前者只要有重合就行,后者要求完全覆盖。
六、移除权限:按位与和按位非的配合
说完了添加和检查,再来看最后一个核心操作:如何移除权限。
移除权限的需求很常见。比如管理员临时收回某个用户的某项操作权,或者用户自己关闭了某个功能模块。我们需要一种运算,能够把某一位从1变回0,同时不影响其他位。
单独使用按位与或按位或都无法直接实现这个需求。因为按位或只能把0变成1,不能把1变成0;按位与只能保留某些位,但不太好单独清除某一位。
这时候就需要按位非运算出场了。按位非用波浪线符号~表示,作用是把一个数的所有二进制位取反,0变成1,1变成0。
~ 0101 (5)
----
1010 (-6,补码表示)
按位非很少单独使用,它通常是和按位与配合起来,实现清除某一位的功能。具体做法是:先对要清除的权限掩码取反,这样原来那位是1的位置变成了0,其他位都变成了1;然后用这个结果和原权限值进行按位与运算。
代码实现:
javascript
// 移除写权限
userPermission &= ~Permission.WRITE;
原理分解一下。假设Permission.WRITE是2,二进制是010。对它取反,得到...101(前面还有很多1)。然后和userPermission进行与运算,那位0就会把userPermission对应位强制变成0,而那些1会让其他位保持原状。
举个例子。用户原本权限值是7(二进制111,拥有读、写、删除三个权限),现在我们要移除写权限(值是2,二进制010)。
首先,对写权限掩码取反:
~ 010 (2,写权限掩码)
---
...11111101 (所有位取反,低位是101)
然后和原权限值进行与运算:
111 (7,原权限)
& ...101 (取反后的掩码)
---
101 (5,只剩下读和删除权限)
结果变成了5(二进制101),写权限被成功移除了,其他权限不受影响。
如果要一次性移除多个权限,可以先把这些权限用或运算组合,然后取反,再进行与运算:
javascript
// 同时移除写和删除权限
userPermission &= ~(Permission.WRITE | Permission.DELETE);
这种写法先Permission.WRITE | Permission.DELETE把两个掩码合并,然后~取反,最后&=清除对应的位。简洁而高效。
七、总结
通过这篇文章,我们一步步构建起了这套高效、紧凑的权限管理方案。
回顾整个过程,核心要点可以归纳为以下几点:
第一,位运算权限系统的本质是用一个整数的二进制位来编码权限状态。每个权限对应一个特定的位,这一位是1就表示拥有该权限,是0则表示没有。这样,一个整数就能承载所有权限信息。
第二,定义权限时使用左移运算1 << n来生成掩码。这既保证了每个权限对应唯一的二进制位,又让代码清晰可读,便于维护和扩展。
第三,添加权限用按位或运算|=,检查权限用按位与运算&,移除权限则需要按位非和按位与的配合&= ~。这四种位运算组合起来,覆盖了权限管理的全部核心操作。
第四,这种方案的最大优势在于性能。添加、移除、检查权限的时间复杂度都是O(1),与权限总数无关。同时存储极其紧凑,序列化后就是一个数字,网络传输和本地存储都非常高效。
当然,位运算权限系统也有其适用边界。它最适合权限数量相对较少(几十个以内)、权限标识固定的场景。如果权限需要动态增删,或者数量可能爆发式增长,就需要考虑分块存储或BigInt等进阶方案。