小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
给定内容,如何统计内容字数?
这还不简单,str.length
不就行了。
好的,我们来试试。
function countByLength(str) {
return str.length;
}
console.log(countByLength("👋")); // 2
console.log(countByLength("𠇍")); // 2
console.log(countByLength("👨👩👧👦")); // 11
这不太对呀,明明是 1 个字符,怎么会返回这么多?
哦!这题我会,阮一峰的《ECMAScript 6 入门》里提到过,码点大于 0xFFFF
的字符,JavaScript 会认为他们是两个字符。ES5 里无法将码点大于 0xFFFF
的字符当做整体处理,ES6 里才有了 String.fromCodePoint
、String.codePointAt
等扩展。
码点? 字符怎么和码点扯上关系的?
是这样的,要让计算机能处理文字,必须把字符映射成二进制序列。字符 → 二进制序列。
像 ASCII 字符集,只需要表示拉丁字母、阿拉伯数字和基础的英文标点,字符量小并且组合简单。只需要把有限的字符编号为 0 - 127 的整数,在用 8 位二进制数一一对应编码即可。字符 → 编号 → 二进制序列。
Unicode 字符集可就复杂多了,它可是囊括了世界上大多数的文字系统。先将文字分解成线性字符集,再对字符集中的每一个字符编号(也就是码点),然后选择一种编码规范将码点转换成有限长度的二进制序列,常用的编码规范有 UTF-8、UTF-16、UTF-32。工作流程是:文字系统 → 字符集 → 码点 → 二进制序列。
经过上述流程,Unicode 得出的码点值范围从 U+0000 到 U+10FFFF,超过 110 万个字符。Unicode 将这些码点划分为 17 个基本平面来管理,一个平面包含 0xFFFF
个码点。其中
- 第一个平面(BMP)包括了大多数常用字符,
- 第二个平面(SMP)包括了一些较为不常见的字符, 如哥特体, 萧伯纳 (Shavian) 字母, 音乐符号, 古希腊文, 以及麻将, 扑克, 中国象棋, Emoji 等等;
- 第三个平面(SIP)包括了一些更加罕见的 CJK 字符;
那 Emoji 字符 👋 属于第二个平面,罕见的汉字 "𠇍" 属于第三个平面。
JavaScript 使用编码规范是 UTF-16,是一种能避免字节浪费的变长编码方式,它用 2 到 4 个字节表示一个 Unicode 码点,两个字节也称为一个编码单元(简称码元),也就是用 1 到 2 个码元表示一个码点。第一个平面 BMP 的码点优先分配,用一个码元来表示。后面的一个码元表示不下,转换为代理对,能通过 /[\uD800-\uDBFF][\uDC00-\uDFFF]/g
匹配到。
回到正题,String.length
返回的是字符串的编码单元(码元)个数,而 👋 和 "𠇍" 都要用两个码元表示,返回的结果自然是 2。
这样的话,要怎么获取字数呢?
如果要取长度的话,可以借助字符串遍历器能够识别大于 0xFFFF
字符的特点,使用 for...of
、Array.from
、展开语法来实现。对了,还可以用正则表达式的 u
修饰符,它能正确识别大于 \uFFFF
的 Unicode 字符。
function countByForOf(str) {
let count = 0;
for (const ch of str) {
count++;
}
return count;
}
function countByArrayFrom(str) {
return Array.from(str).length;
}
function countBySpread(str) {
return [...str].length;
}
function countByRegexp(str) {
return str.match(/./gu)?.length ?? 0;
}
countByForOf("𠇍"); // 1
countByArrayFrom("𠇍"); // 1
countBySpread("𠇍"); // 1
countByRegexp("𠇍"); // 1
countByForOf("👋"); // 1
countByArrayFrom("👋"); // 1
countBySpread("👋"); // 1
countByRegexp("👋"); // 1
看起来不错,但是,👨👩👧👦 Emoji 又该怎么解释?
countByForOf("👨👩👧👦"); // 7
countByArrayFrom("👨👩👧👦"); // 7
countBySpread("👨👩👧👦"); // 7
countByRegexp("👨👩👧👦"); // 7
等一等,我去查一查。找到了,原因是一些 Emoji 在 Unicode 字符集中并没有位置,而是由多个 Unicode 字符组合而成的。
例如上面这个全家福 "👨👩👧👦" 的 Unicode 表示是 U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
。是由零宽连接符(Zero-Width-Joiner, ZWJ) U+200D
将 4 个 Emoji 字符连接而成的。
ZWJ 一般用来修改人物的性别 🏃♀️、组合多个人物 👨👩👦 或是把人物和物品组合在一起 👨💻,更多好玩的组合成果可以看 emojipedia.org/emoji-zwj-s…。
除了 ZWJ 之外,Emoji 还有其他组合方式:
- 修饰符修出新的 Emoji,例如,👋🏻 的 Unicode 表示是
U+1F44B U+1F3FB
,后面的U+1F3FB
是一个肤色修饰符,它可以将前面的对象修饰为亮色,当然并不是所有 Emoji 都能够被修饰。这种通过特定修饰符组合的序列称为 Emoji Modifier Sequence; - 由区域指示字符组合生成新的 Emoji,例如,🇨🇳 是由
🇨 + 🇳
两个区域指示字符构成的,CN
是中国的简写代号,这种组合序列称为 Emoji Flag Sequence; - 由 Tag 字符组合生成新的 Emoji,例如,🏴 是由
🏴 + g + b + e + n + g
组成的,gbeng
是五个 Tag 字符,和苏格兰、威尔士区分,这种组合序列称为 Emoji Tag Sequence; - 由普通字符和 Emoji 字符组合,例如,#️⃣ 是由普通字符
#
和键位字符U+20E3
组合而成的,普通字符和 Emoji 字符之间通过U+FE0F
连接,这种组合序列称为 Emoji Keycap Sequence。
那么怎么将组合的 Emoji 字符识别为整体呢?
正则表达式里的 Unicode 属性匹配或许能帮助我们。
Unicode 中的每一个字符都属于唯一的类别(Unicode Category),包括 7 大类,30 小类。
同时每一个字符又满足一些属性(Unicode Property),也会被标记下来。
JavaScript 的正则表达式支持按 Unicode 类别或属性进行字符匹配,可以借助它来按匹配特定的语言或特定的分类。例如
const regexpCategory = /\p{Letter}/u; // 按照 Unicode 类别进行匹配,这里是匹配所有语言中的字母
"A".test(regexpCategory); // true
"𠇍".test(regexpCategory); // false
const regexpScript = /\p{Script=Han}/u; // 按照 Unicode Script 属性进行匹配,Script 记录的是字符所处的语言系统,这里是匹配汉字
"𠇍".test(regexpScript); // true
"A".test(regexpScript); // false
回到 Emoji,Unicode 规范将 Emoji 视为一种属性,和 Emoji 相关的属性有
- Emoji:所有能被标识为 Emoji 的字符,包括以下所有;
- Emoji_Presentation:可独立显示的 Emoji 字符;
- Emoji_Modifier:Emoji 修饰符;
- Emoji_Modifier_Base:上文有说过 “并不是所有 Emoji 都能够被修饰”,只有具备 Emoji_Modifier_Base 属性的才可以;
- Emoji_Component:不能作为独立 Emoji 显示的字符,比如区域指示符、Tag 字符;
- Extended_Pictographic:面向未来的 Emoji 字符
那直接用 Emoji 试一下
const regexpEmoji = /\p{Emoji}/u; // 按照 Unicode Emoji 属性匹配,匹配到所有标识有 Emoji 属性的字符。
console.log("👋".match(regexpEmoji)); // "👋"
console.log("🏃♀️".match(regexpEmoji)); // "🏃"
console.log("👨👩👧👦".match(regexpEmoji)); // "👨"
console.log("👨💻".match(regexpEmoji)); // "👨"
console.log("👋🏻".match(regexpEmoji)); // "👋"
console.log("🇨🇳".match(regexpEmoji)); // "🇨"
console.log("🏴".match(regexpEmoji)); // "🏴"
console.log("#️⃣".match(regexpEmoji)); // "#"
这里我们发现了问题,如果直接用 /\p{Emoji}/u
去匹配,遇到一个符合 Emoji 的字符就会返回,组合型的 Emoji 难以作为整体被识别。
于是就有了提案里的细分写法。
const regexp =
/\p{Emoji_Modifier_Base}\p{Emoji_Modifier}?|\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu;
console.log("👋".match(regexpEmoji)); // ["👋"]
console.log("🏃♀️".match(regexpEmoji)); // ["🏃", "♀️"]
console.log("👨👩👧👦".match(regexpEmoji)); // ["👨", "👩", "👧", "👦"]
console.log("👨💻".match(regexpEmoji)); // ["👨", "💻"]
console.log("👋🏻".match(regexpEmoji)); // ["👋🏻"]
console.log("🇨🇳".match(regexpEmoji)); // ["🇨", "🇳"]
console.log("🏴".match(regexpEmoji)); // ["🏴"]
console.log("#️⃣".match(regexpEmoji)); // ["#️"]
但这个正则同样无法全覆盖,他忽略了 ZWJ、Emoji Flag Sequence 等组合。Unicode Property 匹配方案似乎还不够成熟,我们只能暂时放弃 Unicode Property 了。
另一种方案是使用 emoji-regex ,这个库读取 Unicode 规范的 emoji-test.txt 文件自动生成全枚举 Emoji 正则表达式,可以说是目前最靠谱的方案。
然后,怎么实现字数统计呢?
首先是字符(Characters)统计,支持包含空格和不包含空格两种情况。
import emojiRegexp from "emoji-regex/es2015/RGI_Emoji";
const PatternString = {
emoji: emojiRegexp().source,
};
// 单独匹配 Emoji,除去空白符,使用 unicode 模式
const characterPattern = new RegExp(`${PatternString.emoji}|\\S`, "ug");
// 包含空白符
const characterPatternWithSpace = new RegExp(`${PatternString.emoji}|.`, "ug");
const countCharacters = (text: string, withSpace: boolean = false) => {
return (
text
.normalize()
.match(withSpace ? characterPatternWithSpace : characterPattern)
?.length ?? 0
);
};
console.log(countCharacters("👋🏃♀️👨👩👧👦👨💻👋🏻🇨🇳🏴#️⃣")); // 8
console.log(countCharacters("𠁆𠇖𠋦𠋥𠍵")); // 5
console.log(countCharacters("🙂 Hi.\n\n来探索充满创新的世界吧!")); // 16
console.log(countCharacters("🙂 Hi.\n\n来探索充满创新的世界吧!", true)); // 17
console.log(countCharacters("Hello, World", true)); // 12
接着是按词(Word)统计,参考 Apple Pages 的处理。CJK 一个字视为一个词,连续字母、整数和小数的组合视为一个词
import emojiRegexp from "emoji-regex/es2015/RGI_Emoji";
const PatternString = {
emoji: emojiRegexp().source,
cjk: "\\p{Script=Han}|\\p{Script=Kana}|\\p{Script=Hira}|\\p{Script=Hangul}",
word: "[\\p{L}|\\p{N}|._]+",
};
const wordPattern = new RegExp(
`${PatternString.emoji}|${PatternString.cjk}|${PatternString.word}`,
"gu"
);
export const countWords = (text: string) => {
return text.normalize().match(wordPattern)?.length ?? 0;
};
countWords("你好,世界。"); // 4
countWords("こんにちは"); // 5
countWords("안녕하십니까"); // 6
countWords("Hello, world"); // 10
countWords("10.11"); // 10
上述代码已封装为开源包 @homegrown/word-counter,可直接取用。
String.normalize()?
上面的代码里有用到 normalize()
,之前看文档其实并不了解它有什么作用,今天接触到才有所体会,顺便分享一下。
还是从例子出发
const str1 = "\u{0041}\u{030A}\u{0042}"; // "ÅB"
const str2 = "\u{00C5}\u{0042}"; // "ÅB"
const str3 = "\u{212B}\u{0042}"; // "ÅB"
const str4 = "ÅB";
console.log(((str1 === str2 === str3) === str4); // true
console.log([...str1].length); // 3
console.log([...str2].length); // 2
console.log([...str3].length); // 2
console.log([...str4].length); // 2
难道说 "Å"
可以有多种表示方式?是的,Unicode 码点表中 "Å"
有两个对应的码点。同时,由于文本分割为文本元素是非常复杂的过程,Unicode 允许灵活地组合字符,因此在 Unicode 中一些字符不仅有自身的编码,还可以由其他字符组合而成。
简而言之,ÅB
和 ÅB
看上去一样,但他们所携带的编码信息可能是有区别的。如果直接做遍历、取长、字符串反转等操作,组合字符会出现不符合预期的情况。
而 String.normalize()
可以将组合字符转换为字符的自身编码,避免传入字符串的不可控性。
相关文章和工具
文章
- 了解 Unicode | Unicode 字符集与字符编码
- 了解 JS 字符串特性 | 阮一峰 ES6 入门字符串的扩展、字符串新增方法、正则的扩展
- 学习 RegExp Unicode | MDN Unicode Property Escapes 使用入门
- 解疑 Unicode Property | 正则表达式——Unicode 属性列表
- Unicode 规范文档
工具
Package