别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘(3):用 ESLint 发现用户文本的危险操作
别再用 length 统计用户文本:一次 Unicode 字符数和 ESLint 护栏复盘 · 1 · 2 · 第 3 篇(当前)
统一的文本工具可以按字素计数与截断,新增表单却仍可能写回 nickname.length、slice() 或静态 maxlength。这里用 ESLint 根据字符串类型与变量语义发现可疑调用,同时保留技术格式字符串的合法用法。
这条规则保护的是用户可见文本。字素簇可能包含多个码点或 UTF-16 单元,用户感知字符的计数因此不能直接等同于原生 length;具体分段和输入处理见计数、截断与输入框必须共用边界。
统一工具和迁移现有调用点之后,新增表单仍可能写回原生操作:
if (nickname.trim().length === 0) return
const preview = nickname.slice(0, 10)
const initial = nickname.charAt(0)
ESLint 可以根据代码中的线索,对疑似用于用户可见文本的原生字符串操作发出提醒,尽早暴露切坏 Unicode 组合字符的风险。这是静态检查的启发式判断,不能代替对完整业务的理解。
这条检查已经加入 @sheng/eslint-plugin,规则名是 @sheng/ui-no-native-string-user-text-ops。项目接入方式见规则文档和插件的使用指南;下面说明它如何判断用户文本、处理误报和组织实现。
先抓真正危险的字符串操作
规则只保护用户可见文本,不把所有字符串 API 都当成风险。它要抓住这些写法:
nickname.length
remark.trim().length
description.slice(0, max)
signature.substring(0, 20)
avatarName.charAt(0)
也要抓住模板里的静态限制:
<input maxlength="20" />
<textarea maxlength="200" />
<input inputmode="search" maxlength="200" />
但它必须放过这些写法:
items.length
Object.keys(payload).length
new Uint8Array(bytes).length
uid.length
token.slice(0, 8)
password.length >= 6
phone.length === 11
path.slice(0, -1)
hex.length % 2 === 0
这一步最容易走偏。规则写得太弱,扫不出真实问题;写得太强,项目里到处都是 warning,最后大家只会把规则关掉。
浏览器端诊断使用规则页里的可编辑示例,不另维护一份独立 demo。可以改一改变量名或字符串操作,看看哪些地方会被打上 ESLint 波浪线。
语义判断比粗暴禁用更重要
最后的判断模型大概是:
- 有 TypeScript 类型信息时,先确认目标确实是 string。
- 没有类型信息时,用字符串字面量、模板字符串、
String(value)、ref<string>()、computed<string>()和明确返回 string 的方法链辅助判断。 - 收集变量名、属性名、函数名、调用参数和 Vue template 属性里的语义词。
- 命中用户文案语义时报告,例如
nickname、remark、description、signature、title、content、search。 - 命中技术字符串语义时放过,例如
uid、token、cookie、path、url、hex、password、phone、i18n key。 - 如果同时命中用户文案和技术语义,用户文案优先。
codeTitle.length仍然应该报告,因为它是标题,不是验证码。
语义匹配前要先归一化名字。didFromCookie、did_from_cookie、did-from-cookie 都应该能变成类似 did from cookie 的词串;否则名单会越写越散。
Vue template 里的 class 也会制造噪声。text-ink-primary、content-center 这类 Tailwind class 不能当成用户文案证据;class 可以帮助识别 password-input 这种技术输入,但不能因为里面有 text 就误报。
常量名单和规则实现分开维护
如果 AST 遍历、错误文案、方法名单、技术字段名单、用户文案字段名单和正则表达式都写在同一个文件里,调整字段语义时就要反复进入规则实现,增加维护成本。
这类规则有两种变化频率:
- AST 遍历、类型判断、report 逻辑相对稳定。
- 方法名单、黑白名单、替代文案、项目特殊字段会频繁调整。
所以最后拆成了:
eslint/rules/ui-no-native-string-user-text-ops/
constants.js
index.mjs
constants.js 只放经常变化的名单和文案,而且尽量用数组:
export const NATIVE_STRING_TEXT_METHOD_NAMES = [
'charAt',
'slice',
'substring',
'substr',
]
export const DEFAULT_TECHNICAL_NAME_WORDS = [
'uid',
'token',
'cookie',
'path',
'url',
'hex',
'password',
'phone',
'i18n',
'key',
'keys',
]
export const DEFAULT_USER_TEXT_NAME_WORDS = [
'nickname',
'remark',
'description',
'signature',
'title',
'content',
'search',
]
入口文件再把数组转成 Set、Map 或 RegExp:
const NATIVE_STRING_TEXT_METHODS = new Set(NATIVE_STRING_TEXT_METHOD_NAMES)
function toWordPattern(words, patternFragments = []) {
const parts = [
...words.map(escapeRegExp),
...patternFragments,
]
return parts.length ? `\\b(?:${parts.join('|')})\\b` : null
}
这份规则的方法和语义名单采用数组维护,入口再转换成查询结构。它们主要是词项列表;如果某份数据本来就在表达键值映射,仍然应该按映射组织,不必为了形式统一拆成数组。
长正则的问题是 review 成本高。追加一个词、删除一个词、调整排序、解释例外,都藏在一行很长的字符串里。Set / Map 对运行时好,但对维护者没有数组直观。规则加载时再转换成高效结构就够了,源文件应该优先服务 review。
规则源码入口在 src/rules/ui-no-native-string-user-text-ops/index.ts,常量入口在 src/rules/ui-no-native-string-user-text-ops/constants.ts。核心实现分三步:先判断目标表达式是否像 string 或 Vue string ref,再从变量名、属性名、函数名、模板属性和调用链里收集语义词,最后用用户文本名单和技术字符串名单决定是否报告。完整规则、单测和配置示例继续以插件仓库为准。
黑白名单支持项目追加
默认名单只能覆盖跨项目稳定语义。每个业务都会有自己的技术字符串和用户文案字段,例如 sku、serial、checksum、caption、headline。如果每次都改规则源码,规则很快会变成某个项目的字段大全。
更好的方式是默认值加项目级追加配置:
// eslint.config.mjs
'@sheng/ui-no-native-string-user-text-ops': [
'warn',
{
// 默认名单在规则内部维护;这里的配置只做项目级追加。
// 新增技术字符串语义时加到 technicalNamePatterns,例如 sku / serial / checksum;
// 新增用户可见文本语义时加到 userTextNamePatterns,例如 caption / headline。
technicalNamePatterns: [],
userTextNamePatterns: [],
},
]
这里用了追加,不用覆盖。默认名单是安全基线,项目配置只是补自己的语义。否则某个项目一旦传了自定义名单,就可能把 uid / token / password / phone 这类通用技术字符串全部丢掉。
规则单测也要同时覆盖默认名单和项目配置:
// 默认技术字符串
"const i18nKey = 'string_confirm'; i18nKey.trim().length > 0"
// 项目级技术字段
{
code: "const sku = 'ABC'; sku.length",
options: [{ technicalNamePatterns: ['\\bsku\\b'] }],
}
// 项目级用户文本字段
{
code: "const caption = 'SUGO'; caption.slice(0, 2)",
options: [{ userTextNamePatterns: ['\\bcaption\\b'] }],
errors: [{ messageId: 'noNativeStringMethod' }],
}
Warning 起步更适合团队迁移
这类规则第一次进项目时,我更倾向于先开 warning。
原因很现实:它一定会扫出一批历史问题,也一定会扫出少数需要判断的边界。直接开 error,容易让大家为了过 CI 写一堆 eslint-disable;开 warning 可以先把问题暴露出来,再按模块逐步处理。
处理顺序也不要一刀切。可以按风险分层:
- 昵称、签名、备注、举报描述、弹窗 prompt 这类保存型文本,优先改。
- 搜索词、评论、输入框本地截断,第二批改。
- 只读展示预览里的
slice()、charAt(),按影响范围改。 - chatroom、message 等已有其他人重构的模块,先留 warning,避免同时改动同一批调用点。
- 明确是技术字符串的误报,优先改变量名或补默认 / 项目名单;不要随手 disable。
只有两类地方适合局部 disable:
- 当前字符串确实是协议、编码、路径、token、手机号、验证码等技术格式。
- 规则短期无法理解上下文,但人能确认原生长度就是业务需要。
disable 也要写理由:
// eslint-disable-next-line @sheng/ui-no-native-string-user-text-ops -- 这里校验的是 6 位短信验证码,不是用户可见自然语言文本。
if (code.length !== 6) return false
如果解释写不出来,通常说明这行不该 disable。
验证分别覆盖 Unicode 工具、Lint 规则与真实输入组件,还要单独检查码点预算和用户感知字符预算的差别。完整内容见字符边界与护栏的验证范围弹窗。
总结
静态检查先判断操作对象是否像字符串,再收集变量、属性、函数、参数和模板中的语义线索,区分用户文本与技术字符串。类型信息和名字只能帮助发现风险,不能代替对接口协议和实际输入行为的判断。
规则实现与经常调整的名单分开维护,项目配置追加业务词项,迁移时先暴露 warning,再逐项处理历史调用和误报。工具边界、规则诊断与真实输入组件各自验证,才能让护栏持续发现退化,而不是把所有原生字符串操作一律禁掉。
上一篇:计数、截断与输入框必须共用边界。
字符边界与护栏的验证范围
验证同时覆盖工具、规则和业务组件
验证需要覆盖三个层次:工具层检查 Unicode 分段和截断边界,规则层检查该报告与该放过的写法,业务组件层检查真实输入是否闪回、截断半个字符或 emit 出超长值。
工具层测试要覆盖 Unicode 样例:
expect(truncateUnicodeCharacters('A😀🇯🇵𠮷B', 3)).toBe('A😀🇯🇵')
expect(truncateUnicodeCharacters('🇯🇵🇺🇸🇹🇷', 2)).toBe('🇯🇵🇺🇸')
expect(truncateUnicodeCharacters('👍🏽👍🏿B', 2)).toBe('👍🏽👍🏿')
expect(truncateUnicodeCharacters('👨👩👧B', 1)).toBe('👨👩👧')
expect(truncateUnicodeCharacters('e\u0301e\u0301B', 2)).toBe('e\u0301e\u0301')
码点协议还要单独验证两种截断策略:预算为 2 时,A🇯🇵B 的码点前缀是 A🇯,保留完整字素的结果是 A。测试除了检查结果不超限,还要检查它是原文前缀;只断言码点数不超过 2,会漏掉错误的 AB。再覆盖 0、非法预算、组合音标、非 BMP 字符和 ZWJ 序列,并确认完整字素策略在额度不足时允许返回空字符串。
规则层测试要覆盖该报和该放过:
// 应该报告
nickname.trim().length === 0
remark.slice(0, max)
description.substring(0, 20)
avatarName.charAt(0)
<input inputmode="search" maxlength="200" />
// 应该放过
items.length
Object.keys(payload).length
new Uint8Array(bytes).length
uid.length
password.length >= 6
phone.length === 11
i18nKey.trim().length > 0
业务组件测试要覆盖真实输入行为。比如搜索框以 200 个字素为额度,输入 199 个 A 再加 🇯🇵B,最后应该保留 199 个 A 和一个完整国旗,而不是截成半个 flag,也不是把 B 放进去;如果额度改成 200 个码点且保留完整字素,结果就只能留下 199 个 A。
const value = `${'A'.repeat(199)}🇯🇵B`
await wrapper.get('input').setValue(value)
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
`${'A'.repeat(199)}🇯🇵`,
])
expect((wrapper.get('input').element as HTMLInputElement).value).toBe(
`${'A'.repeat(199)}🇯🇵`,
)
最后再跑一次项目 lint,逐项确认 warning 的处理状态:哪些历史调用仍待迁移,哪些模块正在并行重构、暂缓处理,哪些规则误报已经修正。
把源码工作区嵌进 Hexo:Sandpack、Monaco 和本地预览的取舍另行记录了源码工作区的 Hexo 嵌入机制、Sandpack / Monaco 选型、本地预览和 WarehouseError 处理。