别再被 emoji 搞懵了!JS 中 Unicode 编码万字扫盲?

B站影视 日本电影 2025-10-30 14:20 1

摘要:现代浏览器都提供了btoa 和 atob这两个方法用于 Base64编解码,但是其仅适用于包含 ASCII 字符或可以用单个字节表示的字符而非所有 Unicode。

起初是想深入了解下浏览器中的 Base64 编码,于是触及到了码点(Code Point)、码元(Code Unit)、不同码元字节占用的相关概念。

现代浏览器都提供了btoa 和 atob这两个方法用于 Base64编解码,但是其仅适用于包含 ASCII 字符或可以用单个字节表示的字符而非所有 Unicode。

为了安全的使用这两个方法,开发者可以通过 TextEncoder 获取 UTF-16 编码的 JavaScript 字符串,并使用TextEncoder.encode 将其转换为 UTF-8 编码的字节流 ,而 UTF-8 向后兼容 ASCII 且可以表示任何标准 Unicode 字符。同时,在 UTF-8 编码下每个码元占据 1 个字节。

换句话说,经过 TextEncoder.encode 进行 UTF-8 编码后会返回 Uint8Array 类型数组,数组中每个元素的值都在 0~255 之间,可以通过单字节表示,符合 btoa 和 atob 的使用条件。

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.function base64ToBytes(base64) {const binString = atob(base64);return Uint8Array.from(binString, (m) => m.codePointAt(0));}// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.function bytesToBase64(bytes) {const binString = String.fromCodePoint(...bytes);return btoa(binString);}// Sample string that represents a combination of small, medium, and large code points.// This sample string is valid UTF-16.// 'hello' has code points that are each below 128.// '⛳' is a single 16-bit code units.// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).// '' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.const validUTF16String = 'hello⛳❤️';// This will work. It will print:// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]const validUTF16StringEncoded = bytesToBase64(new TextEncoder.encode(validUTF16String));console.log(`Encoded string: [${validUTF16StringEncoded}]`);// This will work. It will print:// decoded string: [hello⛳❤️]const validUTF16StringDecoded = new TextDecoder.decode(base64ToBytes(validUTF16StringEncoded));console.log(`Decoded string: [${validUTF16StringDecoded}]`);

而至于解码操作,只需要将编码后的字符串[aGVsbG/im7PinaTvuI/wn6eA]先调用 atob 进行 Base64 解码,然后转化为 Uint8Array 类型数组。接着通过调用 TextDecoder.decode 将给定的类型数组解码为 JavaScript 中 UTF-16 的字符串并返回。

总之,其核心做法是将 UTF-8 下每个字节对应的码元进行单独 btoa 编码和 atob 解码。

Unicode 不是一种编码方式,而是一个字符集标准(character set)。其为每个字符分配一个唯一的数字,称为 码点(Code Point),格式如 U+0041(代表 'A')。下表展示了部分中文的文字码点数据:

由于 Unicode 本身只定义码点,但要在计算机中存储或传输,需用采用具体编码,典型的编码方式包括:

UTF-8:最常用于 Web、Linux、JSON 等,兼容 ASCII,变长(1–4 字节)UTF-16:JavaScript 内部使用,但对非 BMP(Basic Multilingual Plane) 字符需使用代理对,这一点下文会说到。UTF-32:定长,每个字符 4 字节,较少用

只要使用 UTF-8/UTF-16/UTF-32 编码,就能表示 Unicode 支持的所有字符。 但是值得注意的是,JavaScript 引擎会将字符串表示为 UTF-16 ,从而会破坏像 btoa 这类函数,此类函数实际上是基于每个字符都映射到单个字节的假设来运行的。

一句话,Unicode 只是定义了字符集,而下面说的编码方式则决定到底用几个码元、几个字节表示该字符。

UTF-8 和 UTF-16 是 Unicode 标准的两种不同编码方式(encoding forms),它们同时存在的原因主要与历史兼容性、存储效率、处理性能和平台习惯等因素有关。下面从几个关键角度解释为什么会有这两种编码方式。

UTF-8 的最大优势是向后兼容 ASCII,所有 ASCII 字符(U+0000 到 U+007F)在 UTF-8 中都用单字节表示,且编码与 ASCII 完全相同。这意味着:

旧的 ASCII 文本无需转换即可被 UTF-8 解析网络协议、文件系统、编程语言(如 C 的字符串)可以平滑过渡到 Unicode在以英文为主的场景下,UTF-8 非常节省空间

针对不同语言,UTF-8 和 UTF-16 都占据不同的字节数,例如:

UTF-8:拉丁字母(如英文):1 字节;常见汉字(如中文、日文):3 字节;少数生僻字符(如 emoji):4 字节UTF-16:基本多文种平面(BMP,U+0000–U+FFFF)中的字符(包括绝大多数常用汉字):2 字节/字符辅助平面字符(如部分 emoji、古文字):4 字节/字符(使用代理对)

总之,关于不同编码的体积占用可以总结为以下几点:

除了兼容性、存储效率外,不同编码的性能和内存占用也有较大的区别:

UTF-16 在某些系统(如 Windows、Java、JavaScript 引擎)中被广泛采用固定 2 字节(对 BMP 字符)便于随机访问(如 str[100] 可快速定位)早期 Unicode 设计时以为 65536 个码位足够,所以用 16 位表示虽然后来扩展到 21 位(需要代理对),但系统已深度依赖 UTF-16UTF-8变长编码(1–4 字节),随机访问需从头扫描,但是也有优点

不同编码方式的选择有时候也依赖于平台类型,典型的例如:

Web 和 Unix/Linux:几乎全部使用 UTF-8(HTML5 默认、Linux 文件系统、Git 等)Windows API / Java / JavaScript:内部使用 UTF-16JavaScript 的 String 基于 UTF-16 单元数据库/文件格式:可能根据场景选择(如 SQLite 支持 UTF-8,而某些旧系统用 UTF-16)

总之,不管最终选择 UTF-8 还是 UTF-16 ,两者之间都支持无损转换。不过,对于基本多文种平面(BMP, U+0000–U+FFFF)以外的字符(即 U+10000 到 U+10FFFF),UTF-16 需要使用代理对来编码。

如前文所述,码点是 Unicode 标准中用来唯一标识一个字符的抽象数字编号,其是字符在 Unicode 字符集中的“身份证号”,与具体如何存储或传输无关。

码元是特定字符编码方案中用于表示文本的最小单位,不同编码下码元大小不同,例如:UTF-8 下是8位,而 UTF-16 下是 16位,而 UTF-32 下是 32位

所以每个常用汉字 → 1 个码点(Code Point)→ 3 个 UTF-8 码元 → 3 个字节。

[228, 189, 160, 229, 165, 189]↑ ↑“你” “好”

因此最终“你”转化为了 E4 BD A0,而“好”转化为了E5 A5 BD,最终每个字节分别对应于一个码元

需要注意的是,在 UTF-16 编码中,一个码元通常固定为 2 字节。BMP 中的码点用一个码元表示,但是辅助平面中的码点需要用两个码元(即一个代理对)表示,此时一个生僻汉字通常会占用4个字节。

以字符 “” 为例,其并非一个单一的 Unicode 码点,而是由多个码点组成的 ZWJ 序列(Zero Width Joiner Sequence)。具体来说,其由 (U+1F468)、零宽连接符(U+200D, ZWJ)和 (U+1F466)依次拼接而成。

Unicode 之所以采用这种组合机制,而非为每一种家庭组合(如 )分配独立的码点,是为了避免字符集的爆炸式增长,这种设计被称为 Emoji ZWJ Sequence。当渲染引擎识别到这类序列时,会将其合成并显示为一个连贯的复合 emoji,例如 。

所以,“” = U+1F468 U+200D U+1F466。

字形簇(Grapheme clusters)是 Unicode 标准中用于表示“用户感知的一个字符”的基本单位,其解决了一个关键问题:在屏幕上看起来像“一个字符”的东西,在底层可能由多个 Unicode 码点组成

即 Unicode 中的“字符”并不总是一对一对应用户眼中的“字符”。例如:

带重音符号的字母:é 可以是一个预组合字符(U+00E9),也可以是 e(U+0065) + 组合重音符号(U+0301)表情符号序列:(“女程序员”)由 (U+1F469) + 零宽连接符(U+200D) + (U+1F4BB)组成带旗帜的地区:某些国旗使用“区域指示符”对,如 = U+1F1FA + U+1F1F8。

在这些例子中,虽然底层是多个码点,但用户认为它们是“一个字符”。Grapheme cluster 就是用来界定这种“用户感知字符”边界的规则。

在 JS 中,字符串内部使用 UTF-16 表示,其中:

基本多文种平面(BMP) 的字符(U+0000 ~ U+FFFF)用 1 个 16 位码元(code unit) 表示超出 BMP 的字符(如 emoji 、U+1F469)需用 2 个 16 位码元 表示,称为代理对(surrogate pair),占用4个字节高位代理(leading surrogate):范围 0xD800–0xDBFF低位代理(trailing surrogate):范围 0xDC00–0xDFFF"".length;// 2(因为 = U+1F469,需要代理对)"".charCodeAt(0);// 0xD83D(高位代理)"".charCodeAt(1);// 0xDC69(低位代理)

为避免歧义,代理对的两个组成部分必须介于 0xD800 和 0xDFFF 之间,并且这些码元也不用于编码单码元字符。更准确地说,高位代理的值介于 0xD800 和 0xDBFF 之间(含),而低位代理的值介于 0xDC00 和 0xDFFF 之间(含)。

单独代理(lone surrogate)是指满足以下描述之一的 16 位码元:

位于 0xD800–0xDBFF 范围内(含该范围,即高位代理),但其是字符串中的最后一个代码单元,或者下一个代码单元不是低位代理位于 0xDC00–0xDFFF 范围内(含该范围,即低位代理),但其是字符串中的第一个码元或者前一个码元不是高位代理

单独代理不代表任何 Unicode 字符,可以简单理解为:一个本应成对出现的代理码元(高位或低位),在字符串中却“孤身一人”,无法还原为有效 Unicode 字符。它们是UTF-16 编码错误或数据损坏的标志,在健壮的程序中应被检测、拒绝或替换。

虽然大多数 JavaScript 内置方法都能正确处理它们,因为其都基于 UTF-16 码元工作,但在与其他系统交互时,单独代理通常不是有效值。例如, encodeURI 会针对单独代理抛出 URIError,因为 URI 编码使用的是 UTF-8 编码,而 UTF-8 编码不支持单独代理的编码。

const illFormed = "https://example.com/search?q=\uD800";try {encodeURI(illFormed);} catch (e) {console.log(e); // URIError: URI malformed}if (illFormed.isWellFormed) {console.log(encodeURI(illFormed));} else {console.warn("Ill-formed strings encountered."); // Ill-formed strings encountered.}

不包含任何单独代理的字符串被称为格式良好的字符串,可以安全地用于不处理 UTF-16 的函数,例如 :encodeURI 或 TextEncoder。开发者可以使用 isWellFormed 方法检查字符串是否格式良好,或者使用 toWellFormed 方法对单独代理进行清理。

开发者必须谨慎对待当前迭代的字符串,例如:split("") 将按 UTF-16 码元拆分并分离代理对,且字符串索引也引用每个 UTF-16 码元的索引。但是,[Symbol.iterator] 支持按 Unicode 码点进行迭代。

console.log("".length) // 2"".split(""); // ['\ud83d', '\ude04']; // split 会分割为两个单独代理,所以要慎重// "Backhand Index Pointing Right: Dark Skin Tone"console.log("".length) // 4[...""]; // ['', '']// 拆分为基本的“反手食指指向右”表情符号和“深色肤色”表情符号// "Family: Man, Boy"console.log("".length) // 5[...""]; // [ '', '', '' ]// 拆分成“男人”和“男孩”表情符号,由 ZWJ 连接// The United Nations flagconsole.log(''.length) // 4[...""]; // [ '', '' ]// 拆分为两个“区域指示”字母“U”和“N”,所有旗帜表情符号均由两个区域指示字母连接而成

接下来看看为什么上面代码中的 "".length 会返回 5 呢?

const s = "";// 使用 for...of 按码点迭代,输出:1F468、200D、1F466// 因为索引基于 UTF-16 码元(.length === 5)console.log([...s])// ['', '', '']for (const char of s) {console.log(char.codePointAt(0).toString(16).toUpperCase);}

这是因为:虽然"" 只有 3 个 Unicode 码点,但其中 2 个是非 BMP 字符(emoji),每个在 UTF-16 中需要用 2 个码元(代理对) 表示,而中间的 ZWJ 是 BMP 字符,只需 1 个码元,总共5个码元。

JavaScript 使用 UTF-16 编码字符串,每个 Unicode 字符可能被编码为一个或两个码元(BMP 字符1个码元2个字节,而非BMP字符2个码元4个字节),因此 length 返回的值可能与字符串中实际的 Unicode 字符数不匹配。对于拉丁文、常见的 CJK 字符等不是问题。但如果使用的是某些特殊文字,例如:表情符号、数学符号或不常用的中文,则可能需要考虑码元和字符间的差异。

const str = ''console.log(''.length) // 2码元,4字节

语言规范要求字符串的最大长度为 2^53 -1 个元素,这是精确整数的上限。但是,具有此长度的字符串需要 16384TiB 的存储空间,这在任何合理的设备内存中都无法容纳,因此实现往往会降低阈值以便将字符串的长度方便地存储在 32 位整数中。

在 V8 中,最大长度为 2^29 - 24(~1GiB)。在 32 位系统上,最大长度为 2^28 - 16(~512MiB)在 Firefox 中,最大长度为 2^30 - 2(~2GiB),而在 65 之前的版本中为 2^28 - 1(~512MiB)在 Safari 中,最大长度为 2^31 - 1(~4GiB)

如果使用其他编码(例如: UTF-8 文件或 Blob)处理大型字符串时需要特别注意,将数据加载到 JS 字符串时,编码始终为 UTF-16,此时字符串的大小可能与源文件的大小不同。

上面说过, .length 计算的是码元而非字符数。而如果要获取字符数,可以先用迭代器拆分字符串,因为迭代器会按字符进行迭代

function getCharacterLength(str) {// The string iterator that is used here iterates over characters,// not mere code unitsreturn [...str].length;}console.log(getCharacterLength("A\uD87E\uDC04Z")); // 3console.log("A\uD87E\uDC04Z".length) // 4

更进一步,如果开发者要按字形簇(grapheme clusters)计数字符则可以使用 Intl.segmenter,即先把要拆分的字符串传递给 segment 方法,然后遍历返回的 Segments 对象以获取长度:

function getGraphemeCount(str) {const segmenter = new Intl.Segmenter("en-US", { granularity: "grapheme" });// The Segments object iterator that is used here iterates over characters in grapheme clusters,// which may consist of multiple Unicode charactersreturn [...segmenter.segment(str)].length;}console.log(getGraphemeCount("")); // 1console.log(getGraphemeCount("")); // 1

字符串的 codePointAt 方法返回一个非负整数,该整数表示从给定索引处开始的字符的 Unicode 码点,Unicode 码点的范围是 0 ~ 1114111 (0x10FFFF)。但是需要特别注意的是:索引是基于 UTF-16 码元而非 Unicode 码点,因为有些码点包含多个码元,所以通过下标访问可能不太准确。

''.length // 2''.codePointAt(0).toString(16) // "1f680"''.codePointAt(1).toString(16) // de80// 字符存在代理位,codePointAt 第一次获取完整的码点,第二次获取的是低代理位的无效码点(后续for循环中也会论述)''.charCodeAt(0).toString(16) // "d83d" → 高代理''.charCodeAt(1).toString(16) // "de80" → 低代理

在 UTF-16 中,每个字符串索引都是一个码元,其值为 0~65535。更高的码点由一对 16 位代理伪字符(surrogate pseudo-characters)表示,此时 codePointAt 返回的码点可能跨越两个字符串索引(下标访问异常的原因),即占用 2 个 UTF-16 码点,总共4个字节。

"ABC".codePointAt(0); // 65"ABC".codePointAt(0).toString(16); // 41"".length; // 2"".codePointAt(0); // 128525"".codePointAt(1); // 56845"".codePointAt(2); // undefined"\ud83d\ude0d".length // 2"\ud83d\ude0d".codePointAt(0); // 128525"\ud83d\ude0d".codePointAt(1); // 56845"\ud83d\ude0d".codePointAt(2); // undefined"\ud83d\ude0d".codePointAt(0).toString(16); // 1f60d

需要注意的是,使用字符串索引进行循环会导致相同的码点被访问两次,一次是高位代理(leading surrogate),一次是低位代理(trailing surrogate)。并且第二次 codePointAt 仅返回低位无效代理,所以最好避免通过索引循环。

假如有下面的代码示例,通过 for 循环读取字符串中每一个码点。

const str = "a";for (let i = 0; i

首先看字符串 "a" 的结构,其包含一个 emoji,Unicode 码点是 U+1F469(十进制 128105)。其超出了基本多文种平面(BMP),所以在 JavaScript 的 UTF-16 编码中,需要用两个 16 位码元(code units) 表示,即一个代理对(surrogate pair)。其中高位代理(leading surrogate)为0xD83D,而低位代理(trailing surrogate)为 0xDC69。而"a" 只是普通 ASCII 字符,码点 U+0061,只需一个码元。

所以,整个字符串在内存中的码元序列是:

此时,下面代码输出为3:

const str = "a";console.log(str.length===3) // 注意!不是 2!

接下来利用下标遍历进入 for 循环,调用 str.codePointAt(i):

第一次:i = 0,执行 str.codePointAt(0)。此时从索引 0 开始,看到 0xD83D高位代理

JavaScript 检查下一个码元是不是低位代理?发现是的,索引 1 是 0xDC69 低位代理,所以其正确组合成一个完整的码点,即 128105(即 U+1F469,)

第二次:i = 1,调用 str.codePointAt(1)。此时从索引 1 开始,当前码元是 0xDC69低位代理

JavaScript 检查是否是高位代理,发现不是,而是低位代理。根据规范,如果起始位置不是一个高位代理,codePointAt 就直接返回该位置的码元值作为 16 位整数,所以返回:0xDC69 → 十进制是 56425

此时问题来了:这个 56425不是一个合法的 Unicode 码点,其只是 UTF-16 编码中的低位代理,不能单独存在。此时误以为其是另一个字符,实际上它只是 的后半截,而此时的数字56425是毫无意义的。

同理,即使不是for循环,通过多次调用 codePointAt 也会存在该问题:

const str = '';console.log(str.length) // 2console.log(str.codePointAt(0).toString(16));// "1f680" → 字符 '' 的完整 Unicode 码点(U+1F680)console.log(str.codePointAt(1).toString(16));// "de80" → 索引 1 处的 UTF-16 码元值(即低位代理本身)console.log(str.charCodeAt(0).toString(16));// "d83d" → 高位代理(leading surrogate)console.log(str.charCodeAt(1).toString(16));// "de80" → 低位代理(trailing surrogate)

为了解决这个问题,开发者可以 for...of 语句或 spread 展开字符串,这两种方法都会调用字符串的 [Symbol.iterator],其会按码点进行迭代。然后,使用 codePointAt(0) 方法获取每个元素的码点。

const str = "a";for (const codePoint of str) {console.log(codePoint.codePointAt(0).toString(16));}[...str].map((cp) => cp.codePointAt(0).toString(16));// ['1f469', '61']

charCodeAt 方法返回一个介于 0 ~ 65535 的整数,表示给定索引处的 UTF-16 码元。

由于 charCodeAt 始终将字符串索引为 UTF-16 码元序列,因此其可能返回单独的代理项。而要获取给定索引处的完整 Unicode 码点,可以使用 String.prototype.codePointAt。

同时,由于 Unicode 码点的范围是 0~1114111 (0x10FFFF),因此 charCodeAt 始终返回小于 65536 的值,且较高的码点会由一对 16 位代理伪字符表示。因此,为了获取值大于 65535 的完整字符,不仅需要检索 charCodeAt(i),还需要检索 charCodeAt(i + 1),就像操作包含两个字符的字符串一样或者改用 codePointAt(i)。

const str = "";console.log(str.charCodeAt(0)); // 55362, or d842, which is not a valid Unicode characterconsole.log(str.charCodeAt(1)); // 57271, or dfb7, which is not a valid Unicode character

如前文所言,UTF-8UTF-16 都是 Unicode 的编码方式(encoding forms),只是用不同的字节序列来表示同一个 Unicode 码点。即,所有合法的 Unicode 字符(U+0000 到 U+10FFFF)都可以用 UTF-8 和 UTF-16 正确表示(尽管方式不同)。

因此,只要知道一段字节是 UTF-8 还是 UTF-16 编码的,就可以:

解码为统一的 Unicode 码点序列(如 U+4F60 U+597D)再用另一种编码方式重新编码为对应的字节序列

这个过程是标准的、可逆的、无信息丢失的。假设原始文本是中文 “你好”:

UTF-8 编码:“你” → 0xE4 0xBD 0xA0“好” → 0xE5 0xA5 0xBD完整字节:E4 BD A0 E5 A5 BDUTF-16LE 编码(小端,无 BOM):“你” → U+4F60 → 60 4F“好” → U+597D → 7D 59完整字节:60 4F 7D 59

转换步骤如下:

第一步:将 UTF-8 字节E4 BD A0 E5 A5 BD解码并得到 Unicode 码点序列[U+4F60, U+597D]// UTF-8 字节(十六进制): E4 BD A0 E5 A5 BDconst bytes = new Uint8Array([0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD]);// 使用 TextDecoder 解码为字符串,传入 Uint8Arrayconst decoder = new TextDecoder('utf-8');const str = decoder.decode(bytes);console.log(str); // 输出: "你好"

有了 UTF-16 字符串,开发者接下来可以调用 codePointAt 继续获取 UTF-16 的码点序列,因为 JavaScript 字符串本身就是以 UTF-16 编码存储的。

const str = "你好"const codePoints = Array.from(str).map(char => char.codePointAt(0));console.log(codePoints.map(cp => 'U+' + cp.toString(16).toUpperCase.padStart(4, '0')));// 输出 UTF-16 码点序列: ["U+4F60", "U+597D"]

在最终得到Unicode 码点序列为 [U+4F60, U+597D] 。

有了 UTF-16 码点序列,开发者甚至可以从码点序列获取 UTF-16 存储的码元信息,例如:

function codePointsToUtf16CodeUnits(codePoints) {const str = String.fromCodePoint(...codePoints);const codeUnits = ;// 上面说过,.length 获取的是码元长度// .charCodeAt 获取的就是指定下标的码元for (let i = 0; i '0x' + cu.toString(16).toUpperCase));// 输出: ["0x4F60", "0x597D"]

由于“你”和“好”都属于基本多文种平面(BMP), [U+4F60, U+597D] 两个码元拆开得到 60 4F 7D 59,总共4个字节。

接下来再看看如何将 UTF-16 转化为 UTF-8。

其实在 JavaScript 中,将 UTF-16 编码的字符串(即 JS 原生字符串)转换为 UTF-8 字节序列,最标准、可靠的方式是使用 TextEncoder 。

const str = "你好"; // 对应码点 U+4F60, U+597Dconst encoder = new TextEncoder; // 默认就是 'utf-8'const utf8Bytes = encoder.encode(str);console.log(utf8Bytes); // 输出: Uint8Array [228, 189, 160, 229, 165, 189]// 转为十六进制便于查看console.log([...utf8Bytes].map(b => b.toString(16).padStart(2, '0')).join(' '));// 输出: e4 bd a0 e5 a5 bd

由于 UTF-8 中一个码元用一个字节表示,所以从 e4 bd a0 e5 a5 bd可以看出一个中文占用三个字节。

ArrayBuffer 对象用于表示通用的原始二进制数据缓冲区,其是一个字节数组。

开发者无法直接操作 ArrayBuffer 的内容,相反需要创建一个类型化的数组对象(TypedArray)或 DataView 对象,两者都以特定格式表示缓冲区并支持读取和写入缓冲区内容。

ArrayBuffer 构造函数会创建一个 ArrayBuffer 对象,其长度以字节为单位,开发者也可以从现有数据,例如: Base64 字符串或本地文件获取数组缓冲区。下面是使用 Base64 字符串构造 ArrayBuffer 的示例:

function base64ToArrayBuffer(base64) {// 移除可能的 data URL 前缀(如 "data:...;base64,")const raw = base64.replace(/^data:.*?;base64,/, '');const bin = atob(raw); // 解码 Base64 为二进制字符串const len = bin.length;const bytes = new Uint8Array(len);for (let i = 0; i

如果要从本地文件创建 ArrayBuffer 也非常简单,例如:

document.getElementById('fileInput').addEventListener('change', async (event) => {const file = event.target.files[0];if (!file) return;// 方法 1:使用 FileReaderconst reader = new FileReader;reader.onload = => {const buffer = reader.result; // 这就是 ArrayBufferconsole.log('File size:', buffer.byteLength, 'bytes');console.log('ArrayBuffer:', buffer);};reader.readAsArrayBuffer(file);// 方法 2(现代浏览器):使用 Blob.arrayBuffer// const buffer = await file.arrayBuffer;// console.log('ArrayBuffer (via arrayBuffer):', buffer);});

Uint8Array、Int16Array、Uint32Array 等是不同方式查看 ArrayBuffer 内存的“镜头”,类似于比例尺。

Uint8Array:每次看 1 字节(8 位),值范围 0~255Int16Array:每次看 2 字节(16 位),解释为有符号整数(-32768~32767)Uint16Array:每次看 2 字节,解释为无符号整数(0~65535)

类型数组不关心内容含义,即不管是图片、音频、文本编码,还是传感器数据都可以用它们读写。

Unicode 编码和 TypedArray 本身没有直接的语义关系,但它们在实际应用中常协同工作。

Unicode 是一套字符编码标准,定义了每个字符对应的码点(如 U+0041 表示 'A')TypedArray(如 Uint8Array、Uint16Array 等)是 JavaScript 中用于操作原始二进制数据的视图,其不关心数据的语义含义

两者关系:当开发者需要在 JavaScript 中以特定编码(如 UTF-8/UTF-16)将 Unicode 字符串序列化为字节或从字节反序列化为字符串时,通常会借助 TypedArray 存储和处理字节。

UTF-16 编码的 JavaScript 字符串内部使用 16 位码元,可通过 Uint16Array 操作

const str = "A"; // 'A' 是 U+0041,'' 是 U+1F600(需要代理对)const uint16Arr = Uint16Array.from({ length: str.length },(_, i) => str.charCodeAt(i));console.log(uint16Arr); // 输出: Uint16Array [65, 55357, 56832],其中 65 = 0x0041 → 'A' ,55357 = 0xD83D, 56832 = 0xDE00 → 代理对,表示 U+1F600 // 2. 从 Uint16Array 重建字符串const reconstructed2 = String.fromCharCode(...uint16Arr);console.log(reconstructed2); // "A" ✅console.log(reconstructed2 === str); // true

需要注意:Uint16Array 表示的是 UTF-16 码元序列,因此必须用 String.fromCharCode 重建字符串。而 String.fromCodePoint 用于码点序列,与 Uint16Array 的语义不匹配。

那么为什么不能直接用 Uint16Array 来表示码点序列?主要是基于以下几个原因:

Uint16Array 每个元素是 16 位无符号整数(0~65535),但 Unicode 码点范围是 0 到 0x10FFFF(1114111),超过 65535 的码点(如 = U+1F600 = 128512)无法用单个 Uint16Array 元素表示。

因此,码点序列通常用普通 Array(或 Uint32Array,如果环境支持)存储,而不是 Uint16Array。

const str = "A";// 1. 获取码点序列(使用 Array,因为码点可能 > 65535)const codePoints = Array.from(str, c => c.codePointAt(0));// 或更严谨地处理代理对(但 Array.from(str) 已自动处理):// const codePoints = [...str].map(c => c.codePointAt(0));console.log(codePoints); // 输出: [65, 128512] 其中 'A' → U+0041 = 65,而 '' → U+1F600 = 128512 (完整码点,不是代理对)// 2. 用 fromCodePoint 重建字符串(接收码点)const reconstructed = String.fromCodePoint(...codePoints);console.log(reconstructed); // "A" ✅console.log(reconstructed === str); // true

最后,String.fromCharCode 能正确还原包含代理对的字符串,是因为其“原样”拼接传入的 16 位码元,而 JavaScript 字符串本身就是基于 UTF-16 编码的,其只要代理对的两个码元顺序正确,引擎会自动将其解释为一个完整的 Unicode 字符。

// 原始字符串(包含 ASCII、非 ASCII 和 emoji)const str = "Hello, 世界! ";// 1. 编码:字符串 → UTF-8 字节序列(Uint8Array)const utf8Bytes = new TextEncoder.encode(str);console.log(utf8Bytes); // 输出示例: Uint8Array(19) [72, 101, 108, 108, 111, 44, 32, ...]// 2. 解码:Uint8Array → 字符串const decoder = new TextDecoder('utf-8');const restored = decoder.decode(utf8Bytes);console.log(restored === str); // true ✅console.log(restored); // "Hello, 世界! "

需要注意的是,类型数组本身只是一个原始字节容器,其不理解字符边界。但当使用 TextDecoder('utf-8') 对 Uint8Array 解码时,解码器会严格按照 UTF-8 的编码规则解析字节序列,自动识别:

单字节字符(ASCII):0xxxxxxx双字节字符:110xxxxx 10xxxxxx三字节字符:1110xxxx 10xxxxxx 10xxxxxx四字节字符:11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

总之,只要字节序列完整且合法,无论一个字符占 1~4 字节,都能正确还原。

TypeArray 和 Unicode 的关系可以概括为:TypedArray 是实现 Unicode 编码/解码的底层工具之一,但本身并不理解 Unicode。两者属于不同抽象层次:Unicode 是语义层,TypedArray 是存储/操作层。

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String#utf-16_characters_unicode_code_points_and_grapheme_clusters

来源:高级前端进阶

相关推荐