如何在前端实现字数统计?

2,632 阅读9分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

给定内容,如何统计内容字数?

这还不简单,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.fromCodePointString.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...ofArray.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 小类。

Screen Shot 2021-10-14 at 1.08.24 PM.png

同时每一个字符又满足一些属性(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 相关的属性有

b4b03a7cf3ffed3c10804f7dc532bed498dbe12234e02e237dc8828a6dc348c8.png

  • 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() 可以将组合字符转换为字符的自身编码,避免传入字符串的不可控性。

相关文章和工具

文章

工具

Package