多字节字符
在实现字符串脱敏需求时,遇到一个很有意思的问题:当我用 String.prototype.subStr 来获取字符串切片时,JavaScript 并不能正确的识别 emoji 的长度。于是 debug 出以下规律。
1 | var s = "a"; |
好家伙,完全没想到还能有这种问题。。
找了时间搜索一番,总结出以下知识点
本文的所有问题几乎都能在阮一峰的《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 | var s = "12abcd中文😀0"; |
阮一峰老师给了更好的办法。
如果是双字节字符,则第一个字节必须是0xD800
和0xDBFF
之间
1 | var s = "12abcd中文😀0"; |
原因后面可以补以下
实战记录
更新于 2023 年 3 月 28 日
最近开发遇到一个需求,是用的 antd 的 textarea 组件输入短信内容。短信平台 luosimao 是会识别多字节字符的,如,输入 emoji😉
,则识别的长度为 2。
而 antd 的 textarea 组件的 maxLength 字段是把多字节字符的长度识别为 1。
所以需要定制。
首先修改右下角的字数提示。这个好改,原生支持,给 showCount 传递 formatter 即可:
1 | <TextArea |
另一个是拦截 maxLength,因为是个表单,用的<Form.Item>包裹,自动捕获了 value 与 onChange 事件。则需要创建一个高阶组件来拦截 onChange 事件。
1 | export default React.forwardRef((props: TextAreaProps, ref: any) => { |
裁剪就是e.target.value.slice
。这个会产生一个问题。
假如刚好裁剪到多字节字符,那么会生成一个乱码:
1 | var s = "😉😉😉😉😉"; |
如果直接把裁剪的字符串 ss
传递给 onChange,渲染到页面上,那么页面上就会看到一个乱码😉😉�
这时需要封装一个方法来删除不合法的字符串:
1 | /** |
主要原理是,用 Array.from 把字符串转为数组后,数组的每一个元素则是完整的多字节字符,用下标访问,则能拿到完整的多字节字符。而字符串用下标
访问则拿到的是单字节的码点
。
如果是多字节,则数组下标与字符串下标不同
。如果是裁剪掉的多字节,则两者都会展示码点
。利用这一点能判断出来字符是码点还是多字节。
还有一个问题。单字节字符在数组下标与字符串下标也是一样的。这时可以利用判断多字节字符的基本平面字符
在0xdbff
与0xd800
的范围内来判断是否是多字节字符。
因为我的使用场景是,清理裁剪字符串,所以裁剪剩下来的肯定是双字节字符串的前面部分,也就是字符串基本平面字符
,所以我只需要判断是否是基本平面字符
即可
1 | const isDoubleChar = (char) => char <= 0xdbff && char >= 0xd800; |
但是,即使这样,,也法没处理Emoji Sequence
,也就是超过双字节的多字节字符串。。Emoji Sequence
在转换为数组时,拆分字符为多个字符。比较特殊。暂不考虑。实际场景中也不太会遇到。