你可能不知道的 JavaScript 与二进制原理

1,283 阅读10分钟

前言

在实际开发的过程中,二进制离我们越来越远,需要直接处理二进制的场景很少。最近恰好在研究 WebSocket 协议,里面涉及到很多二进制相关的操作,所以趁机把忘掉的东西再捡回来一些。话不多说,直接进入今天的正题。

二进制位运算

符号描述规则例子
&两个位都为1时,结果才为11100 & 1011 = 1000
|任意一位为1,结果就为11100 & 1011 = 1111
^异或两个位不同时结果才为1,相同结果为01100 & 1011 = 0111
~取反0变1,1变0~1100 = 0011
<<左移各二进制位全部左移若干位,高位丢弃,低位补01100 << 1 = 1000
>>右移各二进制位右移若干位(无符号数,高位补0;有符号数,有的编译器补符号位,有的编译器补0)1100 >> 1 = 0110

再来举几个计算过程的例子:

与 (&)

   1 1 0 0 1 1 0 0
 & 1 0 0 1 1 0 1 1
————————————————————
   1 0 0 0 1 0 0 0

或 (|)

   1 1 0 0 1 1 0 0
 | 1 0 0 1 1 0 1 1
————————————————————
   1 1 0 1 1 1 1 1

异或 (^)

   1 1 0 0 1 1 0 0
 ^ 1 0 0 1 1 0 1 1
————————————————————
   0 1 0 1 0 1 1 1

取反 (~)

 ~ 1 1 0 0 1 1 0 0
————————————————————
   0 0 1 1 0 0 1 1

左移 (<<)

   1 1 0 0 1 1 0 0 << 1
—————————————————————————
   1 0 0 1 1 0 0 0

Note:全部位向左移动一位,高位(左侧)的一个 1 被丢弃,低位(右侧)补充一个 0

右移 (>>)

   1 1 0 0 1 1 0 0 >> 1
—————————————————————————
   0 1 1 0 0 1 1 0

Note:全部位向右移动一位(无符号),高位(左侧)补充一个 0 ,低位(右侧)的一个 0 被丢弃

在 JavaScript 中验证

在 JavaScript 中,二进制的字面表达为:0b0B + 二进制位,示例:0b11001100 ,所以我们可以在 JavaScript 中试试上面学到的这些位运算操作。在控制台输入以下代码,看看效果先:

console.log( 0b11001100 & 0b10011011 ) // 0b10001000 → 136
console.log( 0b11001100 | 0b10011011 ) // 0b11011111 → 223
console.log( 0b11001100 ^ 0b10011011 ) // 0b01010111 → 87
console.log( ~0b11001100 ) // 0b00110011 → 51
console.log( 0b11001100 << 1 ) // 0b10011000 → 152
console.log( 0b11001100 >> 1 ) // 0b01100110 → 102

Chrome 控制台中运行效果如下:

这 TM 就尴尬了,有 2 处(取反 ~、左移 <<)运算结果和我们预想的不一样。先别慌,我们一个一个来剖析:

从简单的看起,先来分析左移运算 (<<) 为啥跟我们期望的不一样。首先,我们需要知道一个前提,在 v8 JS 引擎中,小整数(-2^30 至 2^30 -1)是占 4 个字节(32位)空间,而不是我们通常认知中的占用 64 位空间,这里主要是考虑到一些性能优化,想要了解更多详情可前去阅读官方博客文章,这里就不展开了。但是看下我们上面的计算是用 8 位来运算的,导致左移过程中高位的一个 1 被丢弃了,所以与实际运算结果出现了偏差。既然这样,那就用 32 位来计算试试:

  00000000 00000000 00000000 11001100 << 1
—————————————————————————————————————————————
  00000000 00000000 00000001 10011000

在控制台输入以下代码,看看结果(Tips: 数字中间的 _ 是 EcmaScript 的新语法,表示连字符,直接忽略就可以了):

console.log( 0b00000000_00000000_00000000_11001100 << 1 ) // 0b00000000_00000000_00000001_10011000 → 408

正如预期结果一样,SO 这个问题就这么轻松的解了🤷‍♂️,没有难度嘛。

再来看看取反(~)这个问题,我们先用 32 位来模拟演算一下计算机的运算逻辑:

 ~ 00000000 00000000 00000000 11001100 
—————————————————————————————————————————
   11111111 11111111 11111111 00110011

先拿掉第 1位符号位(负数),算出来的结果是:-2147483443。额,这和实际计算的结果 -205 差距有点大呀。。。到底是哪儿出了问题呢?几经周折,终于弄清楚了这里面的蹊跷:

先来了解几个概念:原码、反码、补码,不知道这些知识还给大学老师了没?

原码

符号位加上真值的绝对值, 即用第一位表示符号(正数为 0,负数为 1), 其余位表示值,我们以上提到的二进制都是用原码表示。为了方便,以下都使用 8 位来示例:

[+1] → [0000 0001]

[-1] → [1000 0001]

反码

  • 正数的反码是本身
  • 负数的反码是:符号位不变,其他各位取反

[+1] → [0000 0001] → [0000 0001]

[-1] → [1000 0001] → [1111 1110]

补码

  • 正数的补码是本身
  • 负数的补码是:在 反码 的基础上 + 1

[+1] → [0000 0001] → [0000 0001]反 → [0000 0001]

[-1] → [1000 0001] → [1111 1110] → [1111 1111]

补码求原码

计算方式跟 原码转补码 方式一样

  • 如果是正数,补码的原码是本身
  • 如果是负数,先求反码,再 + 1

[0000 0001] → [0000 0001] → [0000 0001] → [+1]

[1111 1111] → [1000 0000] → [1000 0001] → [-1]

了解这些概念后,我们需要注意的是:计算机系统中,所有的数值都是以补码的形式存储的。大概的原因就是,使用补码后可以让符号位一起参与进来运算,这样计算机的数字运算设计就比考虑符号位要简易很多。那我们再回过头来看这个问题:

   00000000 00000000 00000000 11001100   原码
   00000000 00000000 00000000 11001100   原码的补码 
 ~ 
   11111111 11111111 11111111 00110100   取反后的补码

经过上述的模拟运算,计算机内存里存的 32 位值应该是:11111111 11111111 11111111 00110100(补码),但是给人类看的是可读的原码,需要将补码转为原码(第一位是 1 表示负数,需要 先转反码后再 + 1):

    11111111 11111111 11111111 00110100   补码
    10000000 00000000 00000000 11001011   转反码
 +1
————————————————————————————————————————————————
    10000000 00000000 00000000 11001100   原码

把原码拿到控制台转 10 进制看下,果然,这是我们想要的结果(Note:第 1 位是符号位,表示负数,剩下的再拿去转换)。

此外,在 JavaScript 中,还需注意以下问题:

  1. >> 表示有符号右移(符号位不会被移动),例如:-0b1100 >> 1 == -0b0110
  2. >>> 表示无符号右移,例如:-0b1100 >>> 1 == 2147483642(符号位已经在右移的过程中被补充的 0 替换了)
  3. JavaScript 将数字存储为 64 位浮点数,但是所有的位运算都以 32 位的二进制执行。在执行位运算之前,JavaScript 将数字转换成 32 位有符号整数,执行位运算后,再将结果转为 64 位浮点数,所以不难解释以下现象:
    • (2 ** 32) | 0 = 0 : 高位被丢弃,后面 32 位(包括符号位)都是 0 ,所以结果是 +0
    • 5.5 | 0 = 5 : 位运算会先转成整数,小数被忽略,可以用这一特性用于向下取整

在 JavaScript 中创建二进制数据

JavaScript 提供了以下几个类来创建二进制数据:

  • Int8Array : 8位有符号整型的二进制数组
  • Uint8Array : 8位无符号整型的二进制数组
  • Uint8ClampedArray : 8位无符号整型固定数组,与 Uint8Array 的用法差异详见 stackoverflow
  • Int16Array : 16位有符号整型的二进制数组
  • Uint16Array : 16位无符号整型的二进制数组
  • Int32Array : 32位有符号整型的二进制数组
  • Uint32Array : 32位无符号整型的二进制数组
  • Float32Array : 32位有符号浮点数的二进制数组
  • Float64Array : 64位有符号浮点数的二进制数组
  • BigInt64Array : 64位有符号大整型的二进制数组,用法示例:new BigInt64Array([100n])
  • BigUint64Array : 64位无符号大整型的二进制数组,用法示例:new BigUint64Array([100n])

以上所有的类都继承于 TypedArray 类,可以使用 TypedArray 原型上的属性和方法。

举个栗子,把一串数据以 8 位有符号整形的方式写入内存中:

var arr = new Int8Array([10, -20, 30, 40, 50])

// 或者
var arr = Int8Array.from([10, -20, 30, 40, 50])

arr.buffer // -> 返回 ArrayBuffer 对象
arr[0] // 10
arr[1] // -20
arr[2] // 30
arr[3] // 40
arr[4] // 50

实例属性 instance.buffer 返回 ArrayBuffer 对象,这是一个原始二进制数据缓冲区。因为一个字节有 8 位(1 byte = 8 bit),所以 Int8ArrayUint8Array 是比较常用的二进制数据创建类。其他的创建类用法与之类似,可以参考相关 MDN 文档。

ArrayBuffer 与 Blob

  • ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区
  • Blob 对象表示一个不可变、原始数据的类文件对象,它的数据可以按文本或二进制的格式进行读取,也可以转换成 ReadableStream 来用于数据操作

以上是 MDN 上对于两者的名词解释。我是这样理解的,ArrayBuffer 是固定大小的,创建的时候必须提前分配好空间;而 Blob 则更侧重于以某种方式对二进制数据进行消费,这两者的区别有一丢丢像 node 里 Bufferstream 的区别。

创建 ArrayBuffer

new ArrayBuffer(10); // 创建 10 个字节长度的 ArrayBuffer 对象

new Uint8Array([10, 20, 30, 40, 50]).buffer; // 通过 Uint8Array 类创建 ArrayBuffer 对象,并写入数据

创建 Blob

通过传入 ArrayBuffer 对象作为构造参数创建:

const buffer = new Uint8Array([10, 20, 30, 40, 50]).buffer; 
const blob = new Blob(buffer);

还可以指定合适的 MIME 类型:

const blob = new Blob([
  JSON.stringify({ foo: 123, bar: 'hello' }, null, 2)
], {type : 'application/json'});

Note: File 类继承与 Blob 类

一些技巧

1. 将二进制数据转为图片 URL

const blob = new Blob([buffer]); // blob 或者 buffer 一般来源于本地文件上传 或 HTTP 响应二进制文件数据等
const url = URL.createObjectURL(blob); // 返回值 url 可直接用于 img 标签的 src 属性上

2. 获取/处理二进制数据

一般情况下不会遇到这种场景,但是在一些依赖二进制的传输协议里,处理二进制是非常常见的操作。通常头部(二进制前 n 位)存当前数据的元信息(一些标志、数据长度等),后面存真实需要传输的数据。

例如:在 WebSocket 协议里,第 1 位是 FIN 标识(消息可分片传输,FIN 为 1 表示当前的消息已经传输完了),那么在实战中该如何取二进制的第 1 位呢?其实,我们可以简化一下操作,只需要取第 1 个字节的第 1 位就可以了:

// nodejs
const buffer = Buffer.from('...') // buffer 来自 WebSocket 消息

// Tips: `&` 运算符,只有2个位都为 1 ,结果才为 1
// `0b10000000` 只有第 1 位是 `1`,所以与之相与,只有2种结果:
//   - 0b10000000: 第一位是 `1`
//   - 0b00000000: 第一位是 `0`
const FIN = buffer[0] & 0b10000000 === 0b10000000; // 如果第 1 位是 `1`,结果为 true

结语

以上就是笔者在学习探索过程中的一些总结,个人能力有限,难免有错误或者不恰当的地方,敬请指正~

扩展阅读

参考