如何在前端实现字数统计? | 您所在的位置:网站首页 › 计数器字数统计 › 如何在前端实现字数统计? |
小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。 给定内容,如何统计内容字数?这还不简单,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 试一下 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 规范文档工具 Emojiedia | Emoji 大字典 regexper | 正则表达式可视化 regexr | 正则表达式解释和测试工具Package 字数统计 | @homegrown/word-counter Emoji 全枚举正则表达式 | emoji-regex |
CopyRight 2018-2019 实验室设备网 版权所有 |