删掉每行开头 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、终点超长按行长。
图 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 个码元)在四档的实测:
| 填进去的 | 归一化成 | 删除左 | 删除右 | 截取左 | 截取右 |
|---|---|---|---|---|---|
| 空串 | 0 | ABCD | ABCD | 清空 | 清空 |
"abc" | 0 | ABCD | ABCD | 清空 | 清空 |
"2.7" | 2,截到小数点前 | CD | AB | AB | CD |
"1e3" | 1,遇字母 e 即停,不是 1000 | BCD | ABC | A | D |
"-3" | -3,负数原样通过 | ABCD | ABCD | 清空 | 清空 |
| 20 个 9 连写 | 1e20 | 清空 | 清空 | ABCD | ABCD |
尾换行让统计多报一行,空行永远进结果
源码 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 次,不参与任何判断。删除档对负数是无操作,截取档则是清空,两档都不弹提示。