TextEncoder.encodeInto 的容量边界:还剩三个字节,为什么表情一个字节也没写入

前天 3阅读

把字符串写进固定容量的Uint8Array时,缓冲区还剩空间,并不表示编码器会把下一个字符切开塞进去。encodeInto要为一个码点写入完整UTF-8序列;如果剩余容量不足,就在该码点之前停止,并报告本次读了多少输入、写了多少字节。

下面输入是A😀B。A需要一个UTF-8字节,表情需要四个,B再需一个。第一块只有四字节,写入A以后剩三字节,容不下整个表情。我们预先把目标填为127,便于确认没写到的尾部是否仍为旧值。

保存为demo.mjs,用node demo.mjs运行。程序在Node.js v24.19.0实跑,只操作内存中的文本与字节数组。两次编码后按实际写入长度拼接,再用严格解码检查是否完整还原原文。

TextEncoder.encodeInto 的容量边界:还剩三个字节,为什么表情一个字节也没写入

AI模型生成的概念插图:同一完整色块需要足够宽的入口,空间不足时留在外面;是容量关系的比喻,不表示精确字节格数。

完整可运行程序

import assert from 'node:assert/strict';

const encoder = new TextEncoder();
const source = 'A😀B';
const target = new Uint8Array(4).fill(0x7f);
const first = encoder.encodeInto(source, target);
assert.deepEqual(first, {read: 1, written: 1});
assert.deepEqual([...target], [65, 127, 127, 127]);
console.log('first:', JSON.stringify(first), [...target].join(','));

const remaining = source.slice(first.read);
const next = new Uint8Array(5);
const second = encoder.encodeInto(remaining, next);
assert.deepEqual(second, {read: 3, written: 5});
assert.deepEqual([...next], [240, 159, 152, 128, 66]);
console.log('second:', JSON.stringify(second), [...next].join(','));

const joined = new Uint8Array(first.written + second.written);
joined.set(target.subarray(0, first.written));
joined.set(next.subarray(0, second.written), first.written);
assert.equal(new TextDecoder('utf-8', {fatal: true}).decode(joined), source);
console.log('reconstructed:', new TextDecoder().decode(joined));

const tooSmall = encoder.encodeInto('😀', new Uint8Array(3));
assert.deepEqual(tooSmall, {read: 0, written: 0});
console.log('too small:', JSON.stringify(tooSmall));

本次实际输出

first: {"read":1,"written":1} 65,127,127,127
second: {"read":3,"written":5} 240,159,152,128,66
reconstructed: A😀B
too small: {"read":0,"written":0}

read和written使用两种单位

first显示read为一、written为一,数组为65,127,127,127。65是A的字节,后三个127保持原样。这既证明表情没有被部分写入,也说明不能把整个目标数组都当作本轮有效输出,尾部仍可能包含预置数据。

第二次从source.slice(first.read)继续,得到表情加B。表情在JavaScript字符串中占两个UTF-16码元,再加B共三个,因此read是三;它们编码后分别占四个和一个字节,所以written是五。这里的两个计数不应该相减来推导剩余空间。

输入游标与输出游标分别前进

源字符串的继续位置使用read,因为slice接收码元索引;目标缓冲区的继续位置使用written,因为Uint8Array按字节索引。多个调用累积时,应分别维护输入偏移和输出偏移,不能为了省一个变量把二者当成同一个进度。

joined只取target的前first.written个字节,再接上next的有效部分。严格解码成功且等于原文,最后打印A😀B。若误把第一块四字节全拼进去,三个127也会进入结果;仅仅确认没有解码异常,并不能发现所有内容污染。

零进展需要改变容量或结束

最后给单个表情分配三字节,返回read为零、written为零。输入并非空串,调用也没有报错,只是连第一个码点都放不下。如果循环一直用同样的小缓冲区重试,又不检查进度,就可能永远停在同一位置。

每轮结束后,先按written处理有效字节,再判断剩余输入。仍有输入而read为零时,应扩大目标空间或按接口约定报错;不要擅自把输入偏移加一,因为那可能切开代理对,把完整文本变成孤立代理项。

本例讨论的是有效字符串的UTF-8编码,不是界面字素截断。多个码点可以组成一个可见符号,即便每个UTF-8序列完整,视觉单位也未必完整。写协议缓冲区与限制昵称显示长度应采用各自的验收条件,并始终明确计数单位。

官方资料核验日期:2026年10月2日。上述输出来自本文完整程序的本地执行,全部断言通过,退出状态为零。

参考资料

WHATWG Encoding:TextEncoder.encodeInto逐码点写入规则

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。