杂记 › 技术笔记 › 删右侧给空串、截右侧还整行:text-line-ops 的码元口径与越界不对称实测

删右侧给空串、截右侧还整行:text-line-ops 的码元口径与越界不对称实测

substring 数的是 UTF-16 码元,一个增平面汉字或 emoji 算 2 个,n=1 会切坏半个代理对;越界时删右侧回空串、截右侧却整行吐回,加上负数与尾换行的实测口径。

删掉每行开头 2 个字符,有一行开头变成方框

一份后台导出的名单,一行一项,要统一去掉行首的 2 个字符。多数行都干净,唯独夹了生僻字那一行,行首变成一个认不出的方框带着剩下的字;同一批里带 emoji 的那列也一样,删 1 个「字符」像删了半个。于是把那页的脚本逐字切出来在 Node 里原样跑了一遍:模板实测 8018 字节、175 行,唯一的脚本段 2708 个 UTF-16 码元、3176 字节,删除与截取两档的行为全压在同一句 substring 上。

它数的是 UTF-16 码元,不是字形

文本行操作的 process() 四个分支都走 String.prototype.substring,单位是 UTF-16 码元。样本账逐字量过:

  • "苹" 占 1 个码元、UTF-8 里 3 字节,预置样本那五种水果名全在这一类;
  • 增平面那个生僻字在 U+20000,是代理对 \uD840\uDC00,占 2 个码元、UTF-8 4 字节,十六进制 f0a08080;
  • 一枚 emoji 同样 2 个码元、4 字节;
  • 拼串:这个生僻字打头接「甲乙」、或接在「甲乙」之后,长度都是 4;emoji 拼 abc 两头都是 5。

n 填 1 会切坏半个代理对

删除档取左侧、n 填 1 打那串「生僻字加甲乙」,输出是 3 个码元的串,开头是个孤立低代理,探针原文写作 "\udc00甲乙";同一串给截取档左侧 n=1,只截到代理对的左半边,吐出 1 个码元的 \ud840。右侧同构:删右 1 留下行尾的孤立高代理,截右 1 得 \uDC00;emoji 那串删左 1 得到开头是 \ude00 的 4 码元串。n 填到 2 才正好吃掉整个代理对。孤立代理在 JS 里仍是合法码元,length 照算 1,可任何一次 UTF-8 编码都会把它换成 U+FFFD:实测 Buffer.from("\uD840", "utf8") 得 3 字节 efbfbd,低代理那半边同样。

越界时删右侧给空串、截右侧还给整行

四个分支里只有一处写了显式的越界判断:模板 L138 的删除右,形如 n >= line.length ? "" : line.substring(0, line.length - n)。其余三档(L136 删除左、L144 截取左、L146 截取右)全靠 substring 自己的钳位兜底,起点为负按 0、终点超长按行长。

同一份三行输入、同一个 n:删除清空,截取把短行原样吐回 输入行 删除左 删除右 截取左 截取右 ABCDE E A ABCD BCDE AB 空串 空串 AB AB 整行 A 空串 空串 A A 整行 差别只在源码有没有写显式判断,其余三档由 substring 钳位

图 1:三行长度不等的输入在同一个 n 下的四档实测

想「只留后几个字符」的人,对短行拿到的是全文,一个字符都没少;删除档在想删干净时反倒是安全的。

两个 n 的默认值都是 2,min 一次都没参与

模板 L34 与 L44 逐字对读:两个 number 输入框的 value 写的都是 2,差别只在 min,删除档 min="0"、截取档 min="1"——「截取档默认少删一个」是把 min 当成了默认值。整段脚本里 min、max、step、validity、checkValidity 这五个标识符出现次数合计 0,process() 从不查询校验状态。首屏就能验证:预置样本 5 行、每行恰好 2 个码元,方向取两个 select 的首个选项 left,substring(2) 把每行删空,输出实测是 4 个换行码元加 5 个空行,统计照报「处理行数:5」。

parseInt 后面挂个 ||0,拦不住负数也拦不住字母

两条 n 输入框的读取语句写成 parseInt(..., 10) || 0,而 || 0 只挡 NaN 与 0。下表左列是逐字喂进 value 的字符串,后面是归一化结果与单行输入 "ABCD"(4 个码元)在四档的实测:

填进去的归一化成删除左删除右截取左截取右
空串0ABCDABCD清空清空
"abc"0ABCDABCD清空清空
"2.7"2,截到小数点前CDABABCD
"1e3"1,遇字母 e 即停,不是 1000BCDABCAD
"-3"-3,负数原样通过ABCDABCD清空清空
20 个 9 连写1e20清空清空ABCDABCD

尾换行让统计多报一行,空行永远进结果

源码 L127 是 input.split("\n"),循环体对每个元素无差别执行分支再 push,没有一处跳过空字符串行,而 L167 那个「处理行数」直接取 lines.length。五组对账(除注明外都是删除左 n=1):

  • 五行样本不带尾换行:split 出 5 段,报 5,与内容一致;
  • 「苹果\n香蕉\n」末尾多 1 个换行:split 出 3 段,报 3,真实内容只有 2 行,差 1;
  • 空输入:split 得 1 段,报 1,内容 0 行;
  • 「x\ny\n\n」两行内容再拖 2 个空尾行:报 4,内容 2,差 2;
  • 「a\n\nb」的中间空行原样进结果,行位置一一对应;截取右 n=3 打「hello\n\nworld」也是 3 行、中间那行空着。

行数对不上并不说明有行被删空,先清掉空行再做行级处理更省事,思路见 清理文本空行与空白。

切档不重置参数,残留值还在自己的档里等

switchMode 只改写当前档变量、换按钮的 active 类、给四个参数区的 div 逐个写 style.display、刷新提示文案,最后调一次 process();全函数没有一行给输入控件赋值。探针给每个元素记 value 写入次数:连点 6 次绕完四个 tab 再切回来,前后缀与序号那两档的 5 个输入框写入次数全是 0——参数 DOM 常驻,只是被 display 藏起来。那两档各自怎么填,是 加前缀与加序号那篇 的主题,本篇不碰。

残留值归谁:输入 "ABCDEFGH"(8 个码元,方向显式设 left),删除档把 del-n 改成 5,输出 "FGH";切到截取档,它读的是自己的 cut-n 初值 2,输出 "AB",那个 5 并没有串档。

FAQ

Q:删 1 个字符为什么会弄坏没动的字?

substring 数的是 UTF-16 码元,增平面汉字与 emoji 各占 2 个,n 填 1 只切走半边;剩下的孤立代理再过一次 UTF-8 编码就变成 3 字节的 efbfbd,原字拼不回来。

Q:想只留后 n 个字符,短行为什么没被裁?

截取右侧在 n 大于行长时起点为负、按 0 处理,整行原样返回;同样长度打删除档则是空串。要「短行留不住就清空」,得自己先把短行挑出来。

Q:n 里填负数或字母,页面为什么不弹提示?

读取语句只有 parseInt(..., 10) || 0,空串与字母归成 0,负数原样通过;min 在脚本里出现 0 次,不参与任何判断。删除档对负数是无操作,截取档则是清空,两档都不弹提示。

📎 相关文章

替换次数报 5 还是报 1:批量查找替换的三个开关与零宽匹配实测 2026-11-07 空一个框就 0 张卡:分数通分页的静默失败、跳号的「分数N」与 990 行不约分 2026-11-07 120 个词槽、15 条整句、1 句写死的开头:lorem-ipsum 的词表清点与重复片段实测 2026-11-05 SVG 预览的尺寸听谁的:显示框恒等于容器,删掉 viewBox 后 r=1 实测 2 像素 2026-11-05 文本分列后空字段没丢:a,,b 照留、列号越界填空、填 0 或 abc 退回全部列实测 2026-11-05