Skip to content

字符串与 Unicode:字符不总等于一个位置 ​

字符串最容易产生一种错觉:屏幕上看到一个字,程序中就占一个位置。英文例子让这种错觉活得很好,直到一个表情符号走进来。

方法产生新字符串 ​

js
const raw = '  JavaScript  ';
const clean = raw.trim();
console.log(clean, raw.length); // JavaScript 14
console.log(clean.toLowerCase()); // javascript
console.log(clean.startsWith('Java'), clean.endsWith('Script')); // true true
console.log(clean.includes('S'), clean.indexOf('S')); // true 4

字符串不可变,trim、大小写转换、替换等返回新值,不改 raw。includes 返回布尔值;indexOf/lastIndexOf 返回位置或 -1。不能直接 if (text.indexOf(word)):位置 0 为假,未找到的 -1 却为真。

trimStart/trimEnd 只处理一端;旧别名 trimLeft/trimRight 不如方向名称明确。repeat 重复文本,padStart/padEnd 补足目标长度,长度小于原长度时不截断。它们按码元计量,不等于屏幕显示宽度。

截取与分割必须说明单位 ​

js
const text = 'abcdef';
console.log(text.slice(1, 4)); // bcd
console.log(text.slice(-2)); // ef
console.log(text.substring(4, 1)); // bcd:会交换端点
console.log('a,b,c'.split(',', 2)); // ['a', 'b']
console.log('a-a'.replace('a', 'x')); // x-a
console.log('a-a'.replaceAll('a', 'x')); // x-x

slice 支持负位置并保持起止方向;substring 将负数当 0,且必要时交换两端。旧 substr(start, length) 属于遗留接口,阅读时认得,新增代码使用前两者。split 的 limit 限制返回片段数,不是“分割这么多次后保留剩余”。

replace 的字符串模式只替换第一处,replaceAll 替换全部。传正则给 replaceAll 必须有 g。替换字符串里的 $&、$1 等有特殊含义;要原样返回用户文本,可用返回该文本的替换函数。

split 先切边界,再决定保留多少片段 ​

js
console.log('a,,b,'.split(',')); // ['a', '', 'b', '']
console.log('a,,b,'.split(',', 2)); // ['a', '']
console.log('a,,b,'.split(',').filter(Boolean)); // ['a', 'b']

连续分隔符之间存在空片段,尾部的分隔符后也有空片段。filter(Boolean)把它们删掉了,但这可能改变列位置:如果逗号分隔的是字段,第二列空缺不是“没有第二列”。清理文本前先判断空片段在数据中代表什么。

分割与连接也未必恢复原文本。例如按多个空白分割再用单个空格join,会把原来的空白宽度和换行丢掉。正常化格式时可能正需要这种效果;保存用户原稿时则不应该。

替换字符串还有一层解释:

js
const replacement = '$&';
console.log('a-b'.replace('a', replacement)); // a-b
console.log('a-b'.replace('a', () => replacement)); // $&-b

第一行的 $& 是“完整匹配文本”的占位符,第二行函数返回的文本按原样使用。如果要把用户提供的替换内容原样放进去,函数返回值能避免它被当成替换语法。正则模式转义与替换文本转义是两个不同位置,不要混为一种“转义一次就安全”。

码元、码点与字素簇 ​

js
const face = '😀';
console.log(face.length); // 2,UTF-16 码元
console.log([...face].length); // 1,Unicode 码点
console.log(face.codePointAt(0).toString(16)); // 1f600
console.log(String.fromCodePoint(0x1f600)); // 😀
const combined = 'e\u0301';
console.log(combined.length, [...combined].length); // 2 2

JavaScript 字符串按 UTF-16 码元索引,一个码元 16 位。基本多文种平面之外的码点需要一对代理码元,所以表情符号可能占两个位置。charAt、方括号、at 和 charCodeAt 按码元工作;codePointAt 可识别从该位置开始的完整代理对;fromCharCode 接受码元,fromCodePoint 接受码点。

字符串迭代器按码点遍历,所以展开能避免把一对代理拆开。但组合字符、肤色修饰和家庭表情可以包含多个码点,它们仍可能是一个用户感知的字符,称为字素簇。

js
const segmenter = new Intl.Segmenter('zh-CN', { granularity: 'grapheme' });
const text = 'e\u0301😀';
const units = [...segmenter.segment(text)].map(part => part.segment);
console.log(units); // ['é', '😀']

截昵称、倒序显示、限制“字数”前,先定义产品要求:码元数、码点数、字素簇数、字节数还是显示宽度?它们不是同一个量。Intl.Segmenter 也可按 word 或 sentence 分段;后面的日期与国际化一篇进一步介绍 Intl。

相同外观可以有不同编码 ​

js
const composed = '\u00e9';
const decomposed = 'e\u0301';
console.log(composed === decomposed); // false
console.log(composed.normalize('NFC') === decomposed.normalize('NFC')); // true

normalize 支持 NFC、NFD、NFKC、NFKD。前两者处理规范等价,后两者还处理兼容等价,可能改变全角等形式。是否应该把这些视为相同,要由字段语义决定,不能悄悄对密码等精确输入统一转换。

字符串也可能含孤立代理码元。isWellFormed 检查是否有这类不完整序列,toWellFormed 用替换字符修复。它们属于现代方法,目标环境要核对支持;它们不等于 HTML 转义、URL 编码或文本合法性校验。

三种截断器为什么会给出不同结果 ​

js
const text = 'e\u0301😀!';
const byUnits = text.slice(0, 1);
const byPoints = [...text].slice(0, 1).join('');
const segmenter = new Intl.Segmenter('zh-CN', { granularity: 'grapheme' });
const byGraphemes = [...segmenter.segment(text)].slice(0, 1)
  .map(part => part.segment).join('');
console.log(byUnits, byPoints, byGraphemes); // e e é

第一个可见字符由e与组合尖音符两个码点组成。按一个码元或一个码点截,都只留下e;按一个字素簇截才保留完整外观。如果文本换成😀开头,码元截断还会拆开代理对,码点截断则已足够保留这个单码点表情。方法并没有互相矛盾,只是单位不同。

normalize('NFC')能把某些组合序列合成规范组合形式,但它不是字素压缩器:家庭表情、零宽连接符和修饰符组合不会普遍变成一个码点。因此“先normalize,再用length当字数”仍不可靠。

若字段限制的是网络或文件字节数,又要依据具体编码测量。JavaScript字符串的length不是UTF-8字节数;不要因为中文通常看起来占两个显示格,就把一个中文字符固定当作两个字节。显示宽度更与字体、排版和终端规则有关,也不由Unicode码点数量独自决定。

模板标签是一次特殊函数调用 ​

js
function inspect(parts, value) {
  return `${parts[0]}<${value}>${parts[1]}`;
}
console.log(inspect`主题:${'Unicode'}。`); // 主题:<Unicode>。
console.log(String.raw`第一行\n第二行`); // 保留反斜线与 n

标签函数接收静态字符串片段数组和各插值,能返回任意类型,不必返回字符串。parts.raw 提供原始转义文本,String.raw 利用它保留书写形式。标签不会自动让 SQL、HTML 或正则安全;安全性取决于标签的具体实现。

方法索引与遗留接口 ​

目的方法
索引与编码at/charAt/charCodeAt/codePointAt、String.fromCharCode/fromCodePoint
查找includes/indexOf/lastIndexOf/startsWith/endsWith/search
截取连接slice/substring/concat/split/repeat/padStart/padEnd
转换trim/trimStart/trimEnd/toLowerCase/toUpperCase/toLocaleLowerCase/toLocaleUpperCase/normalize
模式处理match/matchAll/replace/replaceAll
表示与协议toString/valueOf/Symbol.iterator、String.raw、isWellFormed/toWellFormed

localeCompare 按地区比较,返回负、零、正,不保证恰好 -1 或 1。遗留的 anchor/link/bold/fontcolor 等 HTML 包装方法不适合生成现代页面,应使用 DOM 或模板系统的明确接口。

练习 ​

  1. 为什么 '😀'.slice(0, 1) 不能得到完整表情?怎样按码点和字素簇分别截取?

  2. 'a,b,c'.split(',', 2) 为什么不会返回 ['a', 'b,c']?写出实际结果。

  3. 以e加组合尖音符开头的文本,按码点截一个和按字素截一个有什么不同?为什么filter(Boolean)可能破坏逗号分隔字段的位置?

  4. 写 shorten(text, limit),按字素簇最多保留limit个,只在确实截掉内容时添加 '…';limit约定为非负整数。测试 'e\u0301😀!' 在limit为0、1、2、3时的结果,并测试空字符串。省略号不计入limit,不得切断组合字符。

查看答案

用心记录每一步成长