GitHub README 里的表格,源码往往是每一列都手工对齐的。中文一进来这件事立刻变得不可救药:张三 在源码里只占 2 个字符,在等宽字体里却渲染成 4 列宽——用 .length 补空格,表格在编辑器里永远歪的。Markdown 表格格式化工具做的就是这件事:粘贴任意表格,按显示宽度对齐每一列。这篇文章拆它的核心——一个 30 行的宽度函数,和它背后“宽度”这个词的三层含义。
1. 为什么 .length 和码点数都不行
JavaScript 里“一个字符串多长”至少有三种答案:
| 口径 | '张三' |
'𝕊'(代理对) |
'👨👩👧'(ZWJ 序列) |
|---|---|---|---|
.length(UTF-16 码元) |
2 | 2 | 8 |
[...str](码点) |
2 | 1 | 5 |
| 显示宽度(等宽字体列数) | 4 | 1 | ≈2 |
表格对齐要的是最后一行。.length 在纯中文里恰好“看起来对”(汉字都是 BMP 单码元),但混进 emoji 或罕见字符就歪——这比纯英文表格还糟,因为它在最常用的场景里是对的,用户会以为工具可信。
实现里还有一个更早的坑:代码点迭代 for (const ch of str) 拿到的是码点不是码元,ch.codePointAt(0) 才是完整值。用 str[i] 下标取字符会把代理对劈成两半,codePointAt 返回的是半个代理项的码元值——落在下方宽度表的区间检查里恰好都不命中、按宽度 1 处理,结果就是 emoji 少算一列。
2. getVisualWidth:区间查表,不是查库
宽度函数是一个纯区间判断的循环:
function getVisualWidth(str: string): number {
let len = 0;
for (const ch of str) {
const code = ch.codePointAt(0) || 0;
if (
(code >= 0x1100 && code <= 0x115f) || // Hangul Jamo
(code >= 0x2e80 && code <= 0xa4cf) || // CJK 部首 ~ Yi 音节
(code >= 0xac00 && code <= 0xd7a3) || // 谚文音节
(code >= 0xf900 && code <= 0xfaff) || // CJK 兼容表意
(code >= 0xfe30 && code <= 0xfe6f) || // CJK 兼容形式
(code >= 0xff00 && code <= 0xff60) || // 全角形式
(code >= 0x20000 && code <= 0x323af) // CJK 扩展 B+
) {
len += 2;
} else {
len += 1;
}
}
return len;
}
这是 Unicode East Asian Width(东亚宽度)属性的粗粒化:W(Wide)和 F(Fullwidth)两类合并成“占 2 列”,其余按 1。为什么不用完整的 EAW 表(像 wcwidth 库那样逐码位查)?因为完整的表要带几千个码位的数据,而 Markdown 表格里实际出现的字符——汉字、全角标点、ASCII——九个区间已经全覆盖。A(Ambiguous)类字符(± × ÷ ° 这批在西方语境宽度 1、东亚字体里宽度 2 的字符)统一按 1 处理,这是一个“必须选边站”的决策:Ambiguous 的宽度取决于字体环境,任何静态选择都会错一半,选 1 至少与 GitHub 的渲染一致。
区间覆盖里有两个容易被漏的:0xff00-0xff60 是全角 ASCII(Alice 每个字符占 2 列),0x20000+ 是 CJK 扩展 B 以后的生僻字——后者在古籍、人名里不罕见,码点 > 0xFFFF 意味着必然是代理对,正好检验第 1 节说的码点迭代是否做对。
3. 对齐标记:解析与还原都不能丢
Markdown 表格的对齐方向写在分隔行里::--- 左对齐、:---: 居中、---: 右对齐。格式化器必须先解析、再还原:
const sepRow = parsedRows[1] || [];
const alignments: ('left' | 'right' | 'center')[] = [];
for (let c = 0; c < colCount; c++) {
const cell = sepRow[c] || '';
const starts = cell.startsWith(':');
const ends = cell.endsWith(':');
if (starts && ends) alignments.push('center');
else if (ends) alignments.push('right');
else alignments.push('left');
}
解析只看首尾有没有冒号。还原时不能照抄原文(::--- 这种写法 CommonMark 会拒收),而是从解析出的方向重新生成规范形式——compact 模式里能看到这条路径的独立出口:
const seps = alignments.map((align) => {
if (align === 'center') return ':---:';
if (align === 'right') return '---:';
return ':---';
});
补空格的 padString 按三个方向分派:右对齐先垫空格、居中对半分(Math.floor 给左半,余数归右半——两列奇偶差一格时固定偏一边,比“随机居中”可预测)。
4. 列数以分隔行为准,多余补空
数据行多写一个单元格并不会多出一列——CommonMark 的表以分隔行为准,多余部分被丢弃;少写的自动补空。格式化器的规整逻辑跟着规范走:
const colCount = Math.max(...parsedRows.map((r) => r.length));
const cells = Array.from({ length: colCount }, (_, c) => row[c] || '');
实现取了各行的最大列数而不是严格“以分隔行为准”——这是一个务实偏差:用户粘贴的表格如果数据行比分隔行多一格,按规范应截掉那一格,但截数据是破坏性操作,补一列空格保持全部信息更安全。工具做的是格式化(美化源码可读性)而不是校验(拒绝不规范输入),边界划在这里,规整行为就顺理成章。
另一个细节:宽度统计跳过分隔行(if (rIdx === 1) return)——分隔行是 --- 填充,它的长度不该参与列宽计算,否则 :---: 里的冒号会把列撑宽两格。
5. 工程收获
- “长度”有三个口径:码元、码点、显示宽度,表格对齐要的是第三个,且必须配合码点迭代才拿得到正确输入;
- 区间查表是数据的免费午餐:九个区间覆盖 Markdown 表格的实际字符集,完整 EAW 表的维护成本换不来可见的精度提升;
- Ambiguous 必须选边:静态工具里“取决于环境”等于“随便选一个并写进文档”,选与目标渲染环境一致的那边;
- 从解析结果重新生成,不照抄原文:对齐标记的规范化比保留原写法更可靠;
- 格式化与校验分离:补空格保留信息,截断丢弃信息,工具该选前者。
工具在此:Markdown 表格格式化,另有紧凑模式一键去多余空格。渲染结果与格式化前完全一致——美化只作用于源码可读性,全部本地运算。