多字节字符

多字节字符

二月 10, 2022

在实现字符串脱敏需求时,遇到一个很有意思的问题:当我用 String.prototype.subStr 来获取字符串切片时,JavaScript 并不能正确的识别 emoji 的长度。于是 debug 出以下规律。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var s = "a";
console.log(s.length); // 1
console.log(s.subStr(0, 1)); // 'a'
console.log(s[0]);

var s1 = "😀";
console.log(s1.length); // 2
console.log(s1.subStr(0, 1)); // '\uD83D'
console.log(s1[0]); // '\uD83D'

var s2 = "ผู้";
console.log(s2.length); // 3
console.log(s2.subStr(0, 1)); // 'ผ'
console.log(s2[0]); // 'ผ'

好家伙,完全没想到还能有这种问题。。

找了时间搜索一番,总结出以下知识点

本文的所有问题几乎都能在阮一峰的《Unicode 与 JavaScript 详解》里找到答案

什么是 Unicode、UTF-x

现在 Unicode 指的是一套字符集,UTF-8 是 Unicode 字符集的一种实现方式。Unicode 字符集规定了每个字符的码点(如
U+597D = 好),但到底用什么样的字节序表示这个码点,就是 UTF-x 干的事。

如 UTF-32 则完全对应 Unicode 编码。固定占用四个字节。四个字节一个码点。

UTF-8 则是变长的编码方法,字符长度从1个字节到4个字节不等。越是常用的字符,字节越短(哈夫曼编码?)

UTF-16 则是介于两者之间。Unicode 中的”基本平面“占用 2 个字符。”辅助平面“占用 4 个字节。

基本平面、辅助平面的概念去阮老师博客里看。。

JavaScript 用的什么编码

JavaScript 用的叫 UCS-2 编码。因为历史原因。JavaScript 诞生时,Unicode 还未出现。但好在 UCS-2 后来合并到 UTF-16 里。可以简单的理解,JavaScript用的是UTF-16编码

所以可以简单的理解

很常见的中文、英文是写在基本平面,占用 2 个字节。而 emoji 是辅助平面内。占用 4 个字节。。

那么’ผู้’.length 为啥为 3?

这个应该要扯到Emoji Sequence,这篇文章扯到展开操作符:一家人就这么被拆散了
暂时不了解。暂时不展开讲。

2023 年 3 月 27 更新

遇到比较有意思的是,Emoji Sequence转换为数组,则会拆分成三个元素,也就是三个独立的字符。与双字节字符表现不一致。

而且,还有个更有意思的是。复制粘贴Emoji Sequence到 input 中后,需要按三次 Backspace 键才能完全删除。。。

可见,这类字符本质上是三个字符。

打印出来的\uD83D 代表什么

这是是码点表示法,写法是”反斜杠+u+码点“

在这里的”码点“是十六进制,与 JavaScript 中十六进制数字0xD83D一样。所以容易误解

几个常用的字符操作方法

String.fromCharCode

返回由指定的 UTF-16 代码单元序列创建的字符串

如果是双字节字符的第一个字符的画,则直接返回码点。否则可以返回对应的字符

String.fromCharCode('0xD83D') // '\uD83D'
String.fromCharCode(22909)    // '好'
String.fromCharCode('0x597D')    // '好'

可以接收多个参数

String.fromCharCode(22909, 22909); // ‘好好’

String.fromCodePoint

ES6 加上的,为了解决双字节问题。基本签名同 String.fromCharCode,多的功能是支持大于 0xFFFF 的码点。也就是直接输入双字节字符的码点。

String.fromCodePoint('0x1f601')    // '😁'
String.fromCharCode('0x1f601')    // ''
String.fromCharCode('0xf601') // ''

String.prototype.charAt

只是返回字符而已。效果同直接下标访问

var s1 = '😀'
s1.charAt(0) === s1[0]

String.prototype.CharCodeAt

返回一个字符串指定位置的码点(数字形式)

var s1 = '😀'
s1.charCodeAt(0)    //  55357

String.prototyoe.at

返回一个字符串指定字符的码点(码点表示法)

var s1 = '😀'
s1.at()    //    '\uD83D'
s1.at(1)    //    '\uDE00'
'\uD83D' + '\uDE00'    // '😀'
s1.at() + s1.at(1)    //     '😀'

如何判断是否是双字节字符

开始笨办法

先 Array.from()把字符串转成数组。

然后判断第二个字符是不是双字节字符:

1
2
3
4
5
var s = "12abcd中文😀0";

var arr = Array.from(s);

var isDoubleChar = arr[1] == s[1];

阮一峰老师给了更好的办法。

如果是双字节字符,则第一个字节必须是0xD8000xDBFF之间

1
2
3
4
5
var s = "12abcd中文😀0";

var char = s.charCodeAt(1);

var isDoubleChar = char <= 0xdbff && char >= 0xd800;

原因后面可以补以下

实战记录

更新于 2023 年 3 月 28 日

最近开发遇到一个需求,是用的 antd 的 textarea 组件输入短信内容。短信平台 luosimao 是会识别多字节字符的,如,输入 emoji😉,则识别的长度为 2。

而 antd 的 textarea 组件的 maxLength 字段是把多字节字符的长度识别为 1。

所以需要定制。

首先修改右下角的字数提示。这个好改,原生支持,给 showCount 传递 formatter 即可:

1
2
3
4
5
6
7
8
9
<TextArea
showCount={{
formatter: (info) => `${info.value.length} / ${info.maxLength}`,
}}
ref={(input) => {
textAreaRef.current = input;
}}
maxLength={280}
/>

另一个是拦截 maxLength,因为是个表单,用的<Form.Item>包裹,自动捕获了 value 与 onChange 事件。则需要创建一个高阶组件来拦截 onChange 事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export default React.forwardRef((props: TextAreaProps, ref: any) => {
const newProps: TextAreaProps = {
...props,
onChange: (e: any) => {
if (props.maxLength) {
const { value } = e.target;
if (value.length > props.maxLength) {
e.target.value = fixString(e.target.value.slice(0, props.maxLength));
}
}

props.onChange && props.onChange(e);
},
};

return <TextArea {...newProps} ref={ref} />;
});

裁剪就是e.target.value.slice。这个会产生一个问题。

假如刚好裁剪到多字节字符,那么会生成一个乱码:

1
2
3
4
var s = "😉😉😉😉😉";
var ss = s.slice(0, 5);

console.log(ss); // '😉😉\uD83D'

如果直接把裁剪的字符串 ss 传递给 onChange,渲染到页面上,那么页面上就会看到一个乱码😉😉�

这时需要封装一个方法来删除不合法的字符串:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 从后往前,删除非法字符串,目的是删除因裁剪多字节字符产生的非法字符。
*
* var s = '😉😉😉😉😉'
* var arr = Araay.from(s)
* s[0] // '\uD83D'
* arr[0] // '😉'
* 利用以上特性,如果数组[索引]不是一个正常字符串(显示为码点),则说明不是一个合法字符串。需裁剪最后一个字符
*
* var ss = s.slice(0, 5); // '😉😉\uD83D'
* var arr = Array.from(ss); // ['😉', '😉', '\uD83D']
* arr[arr.length - 1] // '\uD83D'
* ss[ss.length - 1] // '\uD83D'
*
* @param {*} s
* @return {*}
*/
function fixString(s) {
const arr = Array.from(s);
let result = s.slice();

let arrLen = arr.length - 1;
let resultLen = result.length - 1;

while (arrLen >= 0 && resultLen >= 0) {
if (
arr[arrLen] === result[resultLen] && // 如果是裁剪剩下的非法字符,则两者都展示码点,两者相等
isDoubleChar(result.charCodeAt(resultLen - 1)) // 还需要判断当前字符是否在指定范围内,如果是,则是双字节字符的基本平面符,否则是单字节字符
) {
result = result.slice(0, resultLen);
arrLen -= 1;
resultLen -= 1;
} else {
break;
}
}

return result;
}

主要原理是,用 Array.from 把字符串转为数组后,数组的每一个元素则是完整的多字节字符,用下标访问,则能拿到完整的多字节字符。而字符串用下标访问则拿到的是单字节的码点

如果是多字节,则数组下标与字符串下标不同。如果是裁剪掉的多字节,则两者都会展示码点。利用这一点能判断出来字符是码点还是多字节。

还有一个问题。单字节字符在数组下标与字符串下标也是一样的。这时可以利用判断多字节字符的基本平面字符0xdbff0xd800的范围内来判断是否是多字节字符。

因为我的使用场景是,清理裁剪字符串,所以裁剪剩下来的肯定是双字节字符串的前面部分,也就是字符串基本平面字符,所以我只需要判断是否是基本平面字符即可

1
const isDoubleChar = (char) => char <= 0xdbff && char >= 0xd800;

但是,即使这样,,也法没处理Emoji Sequence,也就是超过双字节的多字节字符串。。Emoji Sequence在转换为数组时,拆分字符为多个字符。比较特殊。暂不考虑。实际场景中也不太会遇到。