表单输入字数验证设计 - 不损害用户体验的限制实现
表单字数验证是一项需要在维护数据完整性和保持用户体验之间取得平衡的设计课题。仅设置 maxlength 属性是不够的,还需要考虑实时计数器、代理对处理、服务器端验证以及错误发生时的适当反馈等诸多要素。本文将结合数据库 VARCHAR 长度的一致性,讲解实务中可用的验证设计模式。请先了解字数限制的基本概念再继续阅读。
maxlength 属性的陷阱
HTML 的 maxlength 属性是最简便的字数限制手段。但它存在几个开发者容易忽视的问题。
最大的问题是 maxlength 按 UTF-16 代码单元进行限制。基本多语言平面 (BMP) 的字符占 1 个代码单元,但表情符号和代理对字符占 2 个代码单元。也就是说,设置 maxlength="10" 的字段中输入表情符号时,视觉上只能输入 5 个。
| 字符类型 | UTF-16 代码单元数 | maxlength 消耗 | 示例 |
|---|---|---|---|
| ASCII 字符 | 1 | 1 | A, 1, @ |
| 日语 (BMP) | 1 | 1 | あ, 漢, カ |
| 基本表情符号 | 2 | 2 | 😀, 🎉, ❤️ |
| ZWJ 序列表情符号 | 7-11 | 7-11 | 👨👩👧👦, 🏳️🌈 |
| 国旗表情符号 | 4 | 4 | 🇯🇵, 🇺🇸 |
| CJK 统一汉字扩展 B | 2 | 2 | 𠮷 (tsuchiyoshi) |
这个问题在姓名输入字段中尤为突出。日本户籍中登记的部分汉字属于 CJK 统一汉字扩展 B,在 maxlength 下消耗 2 个代码单元。姓氏"𠮷田"看起来是 2 个字符,但消耗 3 个代码单元。这与全角/半角字符计数问题密切相关。
另一个问题是 maxlength 会无声地拒绝输入。达到限制后无法再输入字符,但没有任何反馈说明原因。用户可能会怀疑键盘故障或浏览器出了问题。
使用 JavaScript 进行精确字符计数
要避免 maxlength 的 UTF-16 问题,需要在 JavaScript 中实现基于字素簇 (Grapheme Cluster) 的计数。字素簇是人类感知为"一个字符"的单位,能正确地将代理对、组合字符和 ZWJ 序列计为单个字符。
最可靠的方法是使用 Intl.Segmenter API。
// 使用 Intl.Segmenter 进行字素簇计数
function countGraphemes(text) {
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
return [...segmenter.segment(text)].length;
}
// 使用示例
countGraphemes('Hello'); // 5
countGraphemes('こんにちは'); // 5
countGraphemes('👨👩👧👦'); // 1
countGraphemes('🇯🇵'); // 1
countGraphemes('𠮷田太郎'); // 4
Intl.Segmenter 截至 2024 年已被主流浏览器支持 (Chrome 87+, Firefox 125+, Safari 15.4+)。如需支持旧版浏览器,可使用 grapheme-splitter 库作为后备方案。
但并非所有表单都需要字素簇计数。仅接受 ASCII 字符的字段 (如电子邮件地址或 URL) 使用 maxlength 即可。字素簇计数仅在用户自由输入文本的字段 (姓名、评论、自我介绍等) 中才需要。
实时字数计数器设计
实时字数计数器是向用户视觉反馈剩余字数的 UI 组件。X (原 Twitter) 的字数限制中采用的圆形进度指示器被广泛认为是该领域的最佳实践。
计数器设计需要考虑的要点如下:
| 设计要素 | 推荐模式 | 应避免的模式 | 原因 |
|---|---|---|---|
| 显示格式 | "剩余 42 字"或"158/200" | 仅显示"已输入 158 字" | 剩余字数更能促使用户行动 |
| 显示位置 | 输入字段右下方 | 字段上方或远离的位置 | 最小化视线移动 |
| 颜色变化 | 剩余 20% 时变黄,为 0 时变红 | 始终相同颜色 | 视觉传达紧迫感 |
| 超限行为 | 红色高亮超出部分 + 计数器显示负数 | 无声阻止输入 | 给用户编辑的余地 |
| 无障碍 | 用 aria-live="polite" 通知剩余字数 | 仅视觉显示 | 为屏幕阅读器用户提供信息 |
X 的设计之所以优秀,在于圆形指示器随着接近限制而变色,超出后以红色显示负数。它不阻止输入,而是允许继续编辑并禁用发布按钮,给用户时间考虑删减哪些内容。
服务器端验证的必要性
客户端验证是为了用户体验而存在的,不能保证安全性或数据完整性。maxlength 属性和 JavaScript 计数器都可以通过浏览器开发者工具轻松禁用。直接调用 API 则完全绕过前端验证。
服务器端字数验证与密码长度安全一样,是最后的防线。请务必实现以下几点:
- 字节数验证:由于数据库 VARCHAR 按字节数或字符数限制,服务器端也需验证 UTF-8 编码后的字节数
- 规范化:计数前应用 Unicode NFC 规范化。相同外观的字符,预组合字符和组合字符序列的字符数可能不同
- 控制字符移除:计数前移除 NULL 字符、退格符和其他控制字符
- 修剪:计数前移除首尾空白。不要让空白独占字数
# Python 服务器端验证示例
import unicodedata
def validate_text_length(text, max_chars=200, max_bytes=800):
# 移除控制字符
cleaned = ''.join(c for c in text if unicodedata.category(c) != 'Cc')
# NFC 规范化
normalized = unicodedata.normalize('NFC', cleaned.strip())
# 字符数检查
char_count = len(normalized)
# UTF-8 字节数检查
byte_count = len(normalized.encode('utf-8'))
if char_count > max_chars:
return False, f'字符数超过上限 ({char_count}/{max_chars})'
if byte_count > max_bytes:
return False, f'数据大小超过上限'
return True, None
各框架的实现模式
以下是主流前端框架中字数验证实现模式的比较。
| 框架 | 验证库 | 字数限制实现方式 | 实时计数器集成 |
|---|---|---|---|
| React | React Hook Form + Zod | 用 Zod 的 .max() 定义 schema | 用 watch() 监听输入值并显示计数 |
| Vue | VeeValidate + Yup | 用 Yup 的 .max() 定义 schema | 从 v-model 响应式值计数 |
| Angular | Reactive Forms | Validators.maxLength() | 通过 valueChanges Observable 计数 |
| Svelte | Superforms + Zod | 用 Zod 的 .max() 定义 schema | 用响应式声明 $: 计数 |
React Hook Form 与 Zod 的组合在类型安全和验证逻辑集中管理方面表现出色。将 Zod schema 与服务器端共享,可以防止前后端验证规则不一致。
// React Hook Form + Zod 示例
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
comment: z.string()
.min(1, '请输入评论')
.max(500, '评论请在 500 字以内'),
nickname: z.string()
.min(1, '请输入昵称')
.max(20, '昵称请在 20 字以内'),
});
function CommentForm() {
const { register, watch, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
const comment = watch('comment', '');
return (
<div>
<textarea {...register('comment')} />
<span aria-live="polite">
{comment.length}/500
</span>
{errors.comment && <p role="alert">{errors.comment.message}</p>}
</div>
);
}
字数限制的错误消息设计
字数超限时的错误消息应遵循错误消息设计原则,简洁传达问题内容和解决方法。
| 模式 | 消息示例 | 评价 |
|---|---|---|
| 仅说明问题 | "字数超过上限" | 一般 - 不清楚超了多少 |
| 问题 + 现状 | "523/500 字 - 超出 23 字" | 好 - 超出量明确 |
| 问题 + 解决方案 | "超出 23 字。请删除不必要的部分" | 好 - 下一步行动明确 |
| 渐进式警告 | 剩余 50 字时黄色显示,超出时红色显示 + 消息 | 优秀 - 提前警告,超出后具体引导 |
渐进式警告方式最为有效。在接近字数限制时提供视觉反馈,超出后展示具体的超出字数和解决方案。这样用户可以在输入时注意限制,超出后也能冷静应对。
表单设计的参考书籍也可以在 Amazon 的 UX 设计类书籍中找到。
文本域自动调整大小与字数限制的共存
文本域自动调整大小 (Auto-resize) 是根据输入内容自动扩展字段高度的 UI 模式。与字数限制结合使用时,需要做出一些设计决策。
如果不设置最大高度,长文本输入可能会破坏页面布局。对于 500 字限制的字段,设置约 400px (约 20 行日文) 的最大值比较实用。达到最大高度后显示滚动条,不阻止继续输入。
CSS 的 field-sizing: content 属性于 2024 年在 Chrome 123 中实现,可以无需 JavaScript 实现文本域自动调整大小。但由于 Firefox 和 Safari 尚未支持,作为渐进增强引入比较现实。
移动端表单字数限制 - 特有的挑战
移动设备上的表单输入存在与桌面端不同的字数相关挑战。
- IME 输入法编辑中文本:日语输入转换中 (composing 状态),
input事件会触发但包含未确定的文本。需要监听compositionstart/compositionend事件,在转换中暂停验证 - 预测输入的影响:在 iOS 或 Android 上选择预测输入候选词会一次性输入多个字符。可能一次性插入超过
maxlength的字符,需要 JavaScript 控制 - 屏幕尺寸限制:移动端字数计数器的显示空间有限,移动端使用"剩余 42"等简洁显示,桌面端使用"剩余 42 字 (458/500)"等详细显示
- 软键盘显示:软键盘显示后屏幕有效区域约减半。将字数计数器放在字段内或字段正下方,避免被键盘遮挡
// IME 转换中的验证控制
let isComposing = false;
textarea.addEventListener('compositionstart', () => {
isComposing = true;
});
textarea.addEventListener('compositionend', () => {
isComposing = false;
validateLength(textarea.value); // 转换确定后验证
});
textarea.addEventListener('input', () => {
if (!isComposing) {
validateLength(textarea.value);
}
// 转换中也更新计数器显示 (为了用户体验)
updateCounter(textarea.value);
});
与数据库的一致性设计
前端字数限制与数据库列定义不一致会导致生产环境中的数据截断或错误。如数据库 VARCHAR 长度设计中详述,MySQL 的 VARCHAR(255) 基于字符数,但实际存储消耗取决于编码。
| 数据库 | VARCHAR 单位 | 100 个日文字符的消耗 | 与前端限制的对应 |
|---|---|---|---|
| MySQL (utf8mb4) | 字符数 | VARCHAR(100) 可存储 | 容易与前端字数限制匹配 |
| PostgreSQL | 字符数 | VARCHAR(100) 可存储 | 容易与前端字数限制匹配 |
| SQL Server | 字符数 (NVARCHAR) | NVARCHAR(100) 可存储 | 容易与前端字数限制匹配 |
| Oracle | 字节数 (默认) | 需要 VARCHAR2(300) | 需要字节数转换 |
| DynamoDB | 项目大小 (400 KB) | 无单属性限制 | 在应用层设置限制 |
作为安全的设计方针,建议前端字数限制比数据库列定义更严格。例如,数据库为 VARCHAR(500) 时,前端限制设为 450 字左右,为 Unicode 规范化和修剪导致的字数变动留出缓冲。