跳到正文

别再用 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。项目接入方式见规则文档和插件的使用指南;下面说明它如何判断用户文本、处理误报和组织实现。

ESLint 规则从代码现场、类型信息和语义名单判断一次字符串操作该报告还是放过

先抓真正危险的字符串操作

规则只保护用户可见文本,不把所有字符串 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 文本处理的验证分层:工具层、Lint 规则层和业务组件层分别守住不同风险

工具层测试要覆盖 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 处理。