做移动端和前端这些年,被问得最多的一类问题不是列表怎么优化、不是动画怎么做,而是“这个输入框跟设计稿差了俩像素”“为什么我加的 label 一聚焦就跳一下”“这个 Material Design 文本输入字段在暗色模式下怎么灰成一片”。看起来最基础的组件,实际上是整套设计体系里状态最密、细节最多、最容易做歪的一块。Material Design 文本输入字段要处理的从来不只是“一个框加一行字”,它至少有六种状态、三套容器形态、两套标签行为、四类辅助信息位,还要同时照顾到键盘、读屏、自动填充和不同平台的渲染差异。
这篇内容我打算把它当成一个完整的组件工程来拆:从设计规范里的状态机讲起,把数值和颜色令牌推导一遍,再落地到 Android Compose、Flutter、Web 三套实现路径上,最后聊字符计数、密码切换、可访问性这些真正会在评审会上被挑出来的细节。适合已经能写页面、但对组件级打磨没底的初中级开发者,也适合做组件库、需要给团队定标准的人。看完你至少能做到两件事:拿到一张设计稿能反推出参数是怎么来的;遇到“输入框看起来不对”的时候,知道该往哪一层去查。
1. 为什么一个输入框值得单独开一章
1.1 输入框是全站复用率最高的交互单元
一个中后台系统里,输入框的数量通常是按钮的三到五倍。登录、搜索、筛选、表单、弹窗、设置页,几乎每个界面都有。这意味着它的任何一个细节缺陷都会被放大几十上百次:多出的一像素内边距,在一百个表单里就是一百次视觉错位;一个没处理好的错误状态,会让用户在三屏之外还不知道自己哪填错了。
我在一个数据录入类的项目里踩过一次典型的坑。当时为了赶进度,表单直接用原生 input 加一层描边糊上去,能填能提交,测试也过了。上线两周后客服反馈说“填到后面不知道哪个框没填”。原因很简单:错误提示只靠描边变红,而那个页面有二十多个字段,用户滚到中间时根本看不到是哪个字段出的问题,也没有辅助文本告诉他“这个字段要求什么格式”。后来我们把错误状态、辅助文本、字符计数这三件事一次补齐,同类工单直接掉了一大截。这件事让我彻底改变了对输入框的看法——它不是“画个框”,它是一套错误沟通机制。
1.2 表面简单,实际的复杂度在状态组合上
把输入框的状态列一遍你就知道为什么了。交互上它至少有默认、悬停、聚焦、已填充、错误、禁用、只读七种;容器形态上有填充式和描边式两种;标签有浮动和不浮动两种行为;辅助信息位上有辅助文本、错误文本、字符计数三个占位;再叠加图标前缀、图标后缀、清除按钮、密码可见切换。这些维度不是简单相加,而是交叉组合,每一种组合都要保证视觉层级不打架。
我一般的做法是先画一张状态矩阵表,把所有合法组合标出来,把“不允许同时出现”的组合明确写进组件契约里。比如错误文本和辅助文本在同一个位置,二者互斥,只能留一个;错误状态下前缀图标的颜色要不要跟着变红,这个必须提前定,否则不同人实现出来就是两个样子。这张表看着枯燥,但它是后面所有代码的分界线,不画这张表直接开写,返工概率极高。
1.3 三个平台的实现差异决定了选型
同一个组件在不同技术栈里的成熟度差别很大。Android 的原生 Material 组件对规范覆盖最完整,标签浮动、辅助文本、错误状态都内建;Flutter 的 InputDecoration 把几乎所有配置项都暴露出来了,灵活但容易配乱;Web 端最麻烦,浏览器原生没有浮动标签这个概念,要么引组件库,要么纯 CSS 自己搭。
我的判断口径很朴素:如果项目已经有一套自研设计系统,那么输入框应该按令牌驱动的方式实现,把颜色、圆角、间距全部抽成变量,不要硬编码;如果只是做业务页面、追求交付速度,那就优先用平台自带组件,只做轻量主题覆盖,别为了两个像素的差异去重写整个组件。这个判断在后面每一节都会反复用到,因为“该改哪一层”比“怎么改”更能决定你后面三个月的工作量。
2. 先把状态机理清:输入框到底有几种面孔
2.1 七种状态与它们的视觉映射
按规范的思路,输入框的视觉变化主要集中在三个载体上:容器(描边或填充背景)、标签、辅助信息。每种状态对应一套取值,我把常用的映射关系整理成下面这张表,实际落地时可以直接照着填令牌。
| 状态 | 容器描边 | 标签位置 | 标签颜色 | 辅助信息 | 说明 |
|---|---|---|---|---|---|
| 默认 | 1px 中性描边 | 居中(未输入)/ 浮动(已输入) | 中性 | 可显示辅助文本 | 常态 |
| 悬停 | 1px 加深描边 | 同上 | 中性 | 辅助文本 | 桌面端才有 |
| 聚焦 | 2px 主色描边 | 浮动 | 主色 | 辅助文本 | 光标进入 |
| 已填充 | 1px 中性描边 | 浮动 | 中性 | 辅助文本 | 有值未聚焦 |
| 错误 | 2px 错误色描边 | 浮动 | 错误色 | 错误文本(替换辅助文本) | 校验失败 |
| 禁用 | 1px 弱化描边 + 弱化背景 | 保持原位置 | 弱化 | 通常不显示 | 不可交互 |
| 只读 | 1px 中性描边 | 浮动 | 中性 | 可显示 | 可选中不可编辑 |
这张表有两个容易被忽略的点。第一,聚焦态描边从 1px 变 2px,如果不做补偿,容器内部的可视区域会缩 1px,文字会“抖”一下,这个抖动在大字号或者行高紧张的布局里非常明显。第二,错误态和聚焦态可能同时存在,用户聚焦到一个已经报错的字段上,这时候描边应该用错误色而不是主色,否则用户会以为错误已经消失了。规范里对这点有明确倾向,实现时如果两个状态用同一套判断分支,很容易漏掉。
2.2 标签浮动的三种时机与取舍
标签什么时候从“占位状态”浮到左上方,这是最容易吵架的地方。常见的有三种策略:一是聚焦即浮动,光标进去就飘起来;二是有值才浮动,聚焦但没输入时标签还留在原位;三是无条件始终浮动,标签固定在上方小字位置,输入区永远空着。
三种都合法,但适用场景完全不同。聚焦即浮动最符合直觉,用户点进去就知道“哦这里是要填东西的”,代价是聚焦但没输入时上方会出现一个空的小字标签,某些紧凑布局里显得多余。有值才浮动最省空间,但用户聚焦后看到标签还压在光标位置,会有一瞬间的困惑,尤其是标签字数多的时候。始终浮动最简单、最不容易出 bug,代价是固定占掉上方约 8 到 12 个逻辑像素的高度,字段密集的表单里会显得松散。
我在中后台表单里一般选第一种,原因是这类表单字段多、字段名长,标签压在输入区上会遮挡光标附近的输入内容;而在移动端的搜索框、单行短输入场景里会选第二种,能省一点纵向空间。这个选择必须在组件契约里写死,不能交给调用方随心情决定,否则同一个页面里出现两种浮动行为,视觉上就是乱的。
2.3 辅助文本、错误文本与计数器的站位冲突
这三个东西在视觉上都属于“容器下方的那一行小字”,位置完全重叠。所以它们必须互斥,且要有明确的优先级:错误文本优先级最高,出现错误时挤掉辅助文本;字符计数通常靠右,可以和前两者并存,但要注意长度,长错误提示和计数器挤在同一行时会被截断。
我处理这块的方式是给下方区域固定一个最小高度,比如 20 个逻辑像素,无论当前显示的是辅助文本、错误文本还是什么都没有,这块高度都保留。这样做的好处是字段之间有错误提示出现时,整个表单不会因为多出一行而整体下移,用户看到的是错误文本“浮现”而不是“页面跳一下”。代价是表单整体会高一些,但对于录入型界面,稳定性远比紧凑重要。
注意:错误文本出现导致的布局位移,是表单类页面里最常见的体验问题之一。宁可预留空白,也不要在校验时改变页面高度。
3. 视觉层拆解:数值是怎么算出来的
3.1 56 这个高度不是随便定的
文本输入字段的标准高度是 56 个逻辑像素,这个数字背后是一套自洽的推导。以填充式为例:顶部留 8 用来放浮动后的小字标签,中间文字行高按 24 计(正文 16 号字配 1.5 倍行高),底部再留 8 到 16 的呼吸空间,加起来落在 56 附近。描边式的结构略有不同,因为标签浮起后会“骑”在描边上,上半部分的盒模型计算要把描边宽度算进去。
我实际推导时的做法是:先定字号和行高,再定上下内边距,最后反推总高,而不是先定总高再往里塞。因为字号和行高决定了文字的可读性,这是不可让步的;总高可以为了适配具体场景微调,比如移动端紧凑表单用 48,桌面端宽松布局用 64,都是常见做法。反过来的顺序会逼着你去压缩行高,最后做出来的字看着就憋屈。
横向的内边距通常是 16。这个数字的意义是让文字不要紧贴容器边缘,形成视觉呼吸;带前缀图标的字段,图标本身占 24 宽,图标左侧距边缘 12,图标与文字之间留 16,这些数字加起来刚好落在 56 左右的可用宽度里,不会因为图标而把文字挤到右边去。
3.2 填充式和描边式的结构差异
填充式(Filled)的视觉重心在背景块,容器的顶部两个角是圆的,底部通常直角或者很小的圆角,给人一种“贴在页面上”的感觉。它的优势是在密集表单里边界清晰,一眼就能数出有多少个字段;劣势是需要一个和背景能区分开的填充色,在纯白背景上要选很浅的一层灰,对比度稍不注意就不够。
描边式(Outlined)只有一个 1 像素的边框,中间透明。它的优势是在卡片、弹窗这类本身有背景色的容器里不会显得突兀,视觉更轻;劣势是描边在低分辨率屏幕上容易糊,1 像素的线在缩放比例不是整数时会显得粗细不均。
我选型的经验是:表单字段少于八个、放在卡片里,用描边式;字段多、需要快速扫描定位,用填充式。同一个产品里不要两种混用,除非有明确的层级区分意图,比如主表单用描边、筛选区用填充,这种混用反而能帮用户区分功能区域。
3.3 圆角的取值与视觉补偿
圆角一般用 4。这个值不是审美偏好,而是一个折中:太小接近直角,在填充式容器上显得生硬;太大接近胶囊,会让输入框看起来像按钮。4 这个量级在 56 高的容器上,圆角占比不到十分之一,视觉上“有但几乎察觉不到”,这是它被广泛采用的原因。
有个细节值得单独说:描边式容器在圆角处,描边会沿着弧线走,如果用一个简单的 border 属性实现,圆角和直边的衔接处偶尔会出现 1 像素的错位,特别是在高 DPI 屏幕上。我遇到过最典型的表现是左上角看起来比右上角略粗。解决办法是把边框改成用内阴影或者伪元素叠一层,让描边自己绘制路径,或者干脆接受这个微小差异——它在正常观看距离下几乎不可见,为了它去重写整个边框渲染不值得。
3.4 颜色令牌的层级映射
颜色这块最容易做错的地方不是选色,而是“哪个元素用什么层级的颜色”。规范里对文本字段有一套颜色角色的约定:容器背景、描边、标签、输入文字、辅助文本、图标,各自对应不同的语义角色,而每个角色在亮色和暗色模式下有不同的映射值。
| 元素 | 亮色模式取色倾向 | 暗色模式取色倾向 | 备注 |
|---|---|---|---|
| 容器背景(填充式) | 极浅的中性色 | 略高于页面底色的中性色 | 需与页面背景有可辨差异 |
| 未聚焦描边 | 中性偏深 | 中性偏浅 | 保证 3:1 以上对比 |
| 聚焦描边 | 主色 | 主色(提亮版) | 状态强调 |
| 输入文字 | 高对比深色 | 高对比浅色 | 满足正文对比要求 |
| 标签(浮动后) | 中对比 | 中对比 | 不与输入文字抢 |
| 辅助文本 | 低对比 | 低对比 | 仍需可读 |
| 错误色 | 偏暗的红 | 提亮的红 | 亮暗模式需分别调 |
这里有个实操中很关键的点:暗色模式下不能简单地把亮色模式的颜色反相。反相之后,原本“深底浅字”的关系会变成“浅底深字”,但人眼在暗环境里对高亮度区域的敏感度更高,纯反相会让输入框比周围页面亮太多,形成一个刺眼的方块。正确做法是提高页面底色、降低容器相对亮度差,让输入框在暗色页面里是“稍微亮一点”而不是“亮一块”。
提示:暗色模式的验收不要只在编辑器里看,一定要在亮度调到 30% 左右的真实屏幕上过一遍,编辑器里的暗色预览普遍偏亮。
4. 三套实现路径的完整配置
4.1 Android Compose:优先用默认组件,只改令牌
Compose 里的输入框组件已经内建了标签浮动、辅助文本、错误状态这些能力,绝大多数情况下我不建议重写,只做主题覆盖就够了。关键是把颜色、形状、键盘行为三件事配好。
OutlinedTextField( value = email, onValueChange = onEmailChange, label = { Text("邮箱") }, placeholder = { Text("name@example.com") }, leadingIcon = { Icon(Icons.Outlined.MailOutline, contentDescription = null) }, trailingIcon = { if (email.isNotEmpty()) { IconButton(onClick = { onEmailChange("") }) { Icon(Icons.Filled.Close, contentDescription = "清空输入") } } }, supportingText = { Text(if (isError) errorText else "用于接收通知,不会公开") }, isError = isError, singleLine = true, shape = RoundedCornerShape(4.dp), colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary, unfocusedBorderColor = MaterialTheme.colorScheme.outline, errorBorderColor = MaterialTheme.colorScheme.error, focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent ), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, imeAction = ImeAction.Next ), keyboardActions = KeyboardActions( onNext = { focusManager.moveFocus(FocusDirection.Down) } ) )这段里有两个版本坑。第一,颜色配置的入口在不同版本的组件库里改过名字,早期是TextFieldDefaults.outlinedTextFieldColors这种形式,后来统一成OutlinedTextFieldDefaults.colors,参数名也从focusedBorderColor之外的旧命名做过调整。跨版本迁移时先看依赖版本再抄代码,直接粘贴网上示例经常编译不过。第二,supportingText这个参数是较新的能力,老版本要自己在下方额外排一个 Text,那时候就得手动处理最小高度,否则错误文本出现时整行会跳。
我个人习惯是把输入框再包一层自己的封装组件,把“必填标记、字数上限、错误文案、清空按钮、键盘动作”这些业务高频需求做成参数,内部统一处理。这样业务侧只需要传值和校验结果,不用每个页面重复写一遍图标和键盘逻辑。封装的时候注意别把TextFieldValue和String两种入参混着暴露,选一种,否则调用方会乱。
4.2 Flutter:InputDecoration 的配置优先级
Flutter 的输入框灵活度高,配置项多,也最容易配出四不像。我的原则是:只改必要的几个,其余走主题。下面是一份可以直接用的配置。
TextFormField( controller: controller, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, autovalidateMode: AutovalidateMode.onUserInteraction, decoration: InputDecoration( labelText: '邮箱', hintText: 'name@example.com', helperText: '用于接收通知', errorText: errorText, errorMaxLines: 2, counterText: '${controller.text.characters.length}/60', prefixIcon: const Icon(Icons.mail_outline, size: 20), suffixIcon: controller.text.isEmpty ? null : IconButton( icon: const Icon(Icons.close, size: 20), onPressed: () => controller.clear(), ), filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(4), borderSide: BorderSide( color: Theme.of(context).colorScheme.primary, width: 2, ), ), floatingLabelBehavior: FloatingLabelBehavior.auto, ), )几个必须知道的点。第一,border只是兜底,真正生效的是enabledBorder、focusedBorder、errorBorder、focusedErrorBorder这一组,只配border你会发现聚焦后样式没变。第二,填充色在不同主题版本里对应的角色名变过,早期叫surfaceVariant,后来拆成了更细的一组容器色,迁移时如果发现颜色不对,先查主题里这个角色现在映射到哪个值。第三,错误提示默认只显示一行,长文案会被截断成省略号,必须显式设置errorMaxLines,这是我在项目里被提过最多的一条修改意见。
还有一个和计数相关的坑:counterText里如果用controller.text.length去算,遇到中文或者组合字符会算成多个字符,用户会觉得“我明明只打了三个字怎么显示六”。要按用户感知的字符数算,得用支持字素簇的计数方式,或者干脆用输入格式限制器配合一个统一的计数工具函数。这类细节不做,中文场景下的字数统计基本都不可信。
4.3 Web:纯 CSS 实现浮动标签的完整方案
浏览器原生没有浮动标签,要自己做。核心思路是用:placeholder-shown伪类判断“是否为空”,配合:focus判断聚焦,两个条件任一成立就让标签浮起来。
.field { position: relative; margin-bottom: 20px; } .field input { box-sizing: border-box; width: 100%; height: 56px; padding: 24px 15px 7px; font-size: 16px; line-height: 24px; color: #1D1B20; background: transparent; border: 1px solid #79747E; border-radius: 4px; outline: none; transition: border-color 120ms ease; } .field input:focus { border-color: #6750A4; border-width: 2px; padding: 23px 14px 6px; } .field label { position: absolute; left: 16px; top: 28px; transform: translateY(-50%); font-size: 16px; line-height: 24px; color: #49454F; pointer-events: none; transition: top 120ms ease, font-size 120ms ease, color 120ms ease; } .field input:focus + label, .field input:not(:placeholder-shown) + label { top: 12px; font-size: 12px; color: #6750A4; } .field input[aria-invalid="true"] { border-color: #B3261E; } .field input[aria-invalid="true"] + label { color: #B3261E; }这份 CSS 有三个必须解释的地方。第一,input 上必须有一个 placeholder 属性,哪怕是单个空格,否则:placeholder-shown永远不匹配,浮动逻辑直接失效——这是纯 CSS 方案里最常见的翻车点,很多人调试半天以为是选择器写错,其实是 HTML 里少了那个属性。第二,聚焦时边框从 1 变 2,为了让文字位置不动,padding 要相应减 1,上面那段里我把纵向从24/7改成了23/6、横向从15改成了14,就是这么来的。不做这个补偿,聚焦瞬间文字会往右下跳一格,肉眼能看出来。第三,pointer-events: none不能漏,否则点标签区域点不到输入框,用户体验上会觉得“这个地方点不进去”。
如果想避免边框宽度变化带来的抖动,还有一个更稳的写法:边框始终保持 1 像素,聚焦时额外叠一层box-shadow: inset 0 0 0 1px currentColor或者用伪元素画第二层描边,这样 padding 完全不用动。我现在的项目里更倾向后者,代码多两行,但省掉了一整类对齐问题。
注意:浮动标签的过渡时间建议控制在 100 到 150 毫秒,太快看不见,太慢会拖累输入节奏。缓动曲线用接近匀速的,不要用弹性的,输入框抖动是非常廉价的感觉。
5. 高级定制:图标、计数与特殊输入
5.1 前缀后缀图标与点击热区
图标这件事分两种:装饰性的和可点击的。装饰性图标(比如邮箱前面的信封)只需要保证尺寸和颜色跟随状态,不要给它加语义标签,加了反而会让读屏多念一遍无意义的内容。可点击图标(清空、切换密码可见、扫码)必须保证点击热区足够大,视觉上 20 到 24 的大小,实际热区要做到 40 到 48,移动端尤其重要。
我遇到过的一个具体问题:清空按钮只在有输入时出现,这个“出现”会导致输入框右侧的内容突然位移。如果右侧还有别的元素(比如单位后缀),布局会跳。解决办法是清空按钮始终占位,只是没有输入时把透明度设为 0 并禁用它,视觉上不显示,布局上不变化。这个思路和前面辅助文本预留高度是同一套逻辑:状态变化只改视觉,不改布局。
5.2 字符计数的触发时机与截断策略
计数器什么时候出现,是个体验问题。一进页面就显示 “0/60”,会给人一种“这是个有约束的字段”的压迫感;但完全不给提示,用户打到第 61 个字符被硬截断会更恼火。
我的经验分场景:字数短、约束紧的字段(比如昵称、验证码)建议常驻显示,让用户一开始就有预期;字数宽松的长文本(比如备注、描述)建议在接近上限时(比如剩余 10 个字符)才浮现,平时不打扰。至于超过上限的处理,硬截断(直接不让输入)和软提示(允许输入但标红并禁止提交)各有利弊,我偏向软提示,因为硬截断会让用户不知道自己最后一个字为什么没打上去,尤其在有输入法的中文环境里,硬截断还可能打断组词过程。
5.3 密码、验证码与数字输入的特殊处理
密码字段的可见切换按钮不只是便利性问题,在移动端它几乎是必需的——小屏幕上输错一次的成本很高。切换时要注意光标位置,从隐藏切到可见,光标应该保持在原位不要跳到末尾,这个在不同实现里表现不一样,做完了要手动验证一下。
验证码字段通常有独立的视觉样式,比如分段格子、自动跳转下一格、粘贴自动分配。这类输入框我建议不要硬套标准的文本字段样式,因为它本质上是“多格数字输入”,用标准容器会显得很奇怪。但它仍然需要继承同一套颜色令牌、同一套错误状态表达方式,保证和页面其他字段是一致的语义。
数字类字段有个隐藏坑:手机号、金额、身份证号这些,用普通文本键盘会让用户多切一次键盘。务必根据字段语义设置键盘类型,金额用带小数点的数字键盘,纯数字用数字键盘。同时注意输入格式化的处理,比如金额每三位加一个分隔符,格式化必须在数据层保持原值、展示层单独处理,不能把带分隔符的字符串直接当数据存。
6. 可访问性与表单体验的实战细节
6.1 标签关联、错误播报与焦点顺序
用原生 input 的时候,label 的for属性一定要和 input 的 id 对应上,这是最基础的一条,但自研组件里经常漏。漏了之后,点击标签无法聚焦输入框,读屏软件也念不出字段名,用户只听到“编辑框”三个字,完全不知道要填什么。
错误提示的播报更微妙。错误文本渲染出来,读屏不一定会主动念。想让用户第一时间知道出错,需要把错误区域和输入框建立关联(用描述关系属性指向错误文本的 id),并且在错误出现时把焦点或者播报焦点引导过去。我一般只做关联,不主动抢焦点——抢焦点在长表单里会打断用户的填写节奏,用户正往下填,突然被拽回上面一个字段,体验很差。关联之后用户回到该字段时能听到原因,这就够了。
焦点顺序这块,一个原则:视觉顺序必须等于 Tab 顺序。用绝对定位或者 flex 的 order 调整过布局的页面,很容易出现视觉上第二个字段在 DOM 里排第五,Tab 键跳着走。这种问题在开发阶段自己用键盘走一遍就能发现,可惜很多人从来不用键盘测。
6.2 输入法与自动填充的兼容
中文输入法有个绕不开的问题:组词过程中会触发多次输入事件。如果每次事件都做实时校验,用户还没打完就被判错,体验极差。正确做法是判断输入法是否处于组合状态(isComposing),组合中不校验,组合结束再校验。
自动填充这块,浏览器和系统都依赖字段的语义标注。给输入框加上正确的 autocomplete 语义(邮箱、电话、姓名、一次性验证码各有对应值),用户点一下就能填好,这在移动端能省掉大量输入。这类标注成本极低,收益很高,但经常被忽略。另外一次性验证码建议开启系统的验证码自动读取能力,移动端上能省一次切应用的操作。
7. 常见问题与排查速查表
7.1 十个高频坑的定位路径
下面这张表是我这几年攒下来的,基本覆盖了八成以上的“输入框看起来不对”的问题。排查顺序建议从上往下,先排除最简单的配置错误。
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 聚焦后文字跳一下 | 边框宽度变化未补偿内边距 | 检查 focused 态的 padding 或改双层描边 |
| 标签不浮动 | 缺少 placeholder 或值为空判断失效 | 检查 HTML 与选择器链 |
| 错误文本出现页面下移 | 下方区域未预留固定高度 | 给辅助区域设最小高度 |
| 聚焦后样式没变化 | 只配了默认边框没配聚焦边框 | 补全状态边框配置 |
| 暗色模式下输入框突兀 | 颜色直接反相 | 重新映射容器与底色差值 |
| 清空按钮出现导致布局位移 | 按钮条件渲染而非常驻占位 | 改为常驻 + 透明度切换 |
| 中文计数不准 | 按码元而非字素计数 | 换用字素簇计数 |
| 长错误提示被截断 | 未设置多行 | 显式允许多行显示 |
| 点击标签无法聚焦 | label 未与输入控件关联 | 补 for 与 id |
| 输入时校验频繁闪烁 | 未判断输入法组合状态 | 组合期间跳过校验 |
举一个具体排查过程。有次一个表单里,只有金额字段在聚焦时明显“抖”,其他字段正常。我先怀疑是字体差异,换了字体没解决;量了一下发现这个字段的容器宽度是百分比,边框从 1 到 2 之后,box-sizing是 content-box,内部可用宽度少了 2 像素,导致同一个百分比下换行位置微变。改成box-sizing: border-box加内边距补偿,问题消失。这个案例说明:同样一个“抖”,在宽度固定的字段上表现为文字偏移,在宽度自适应的字段上可能表现为换行跳动,排查时要先确认这个字段的尺寸约束方式。
7.2 自研组件库的长期维护建议
如果你们打算把输入框做成组件库里的一个组件,有几条经验我觉得比代码本身更重要。
第一,把所有可变项收敛成令牌,不要在组件内部写死颜色和间距。颜色至少有容器、描边、标签、文字、辅助文本、错误、禁用七组,间距至少有内边距、图标间距、下方区域高度三组。令牌抽得越干净,后面主题切换、换肤、暗色适配的成本越低。
第二,组件契约要写清楚“哪些组合不允许”。比如错误态和禁用态同时出现时以谁为准,计数器和错误文本的宽度冲突怎么解,图标和清空按钮同时存在时谁靠右。这些不写清楚,不同人调用就是不同结果,最后组件库变成一个“看起来统一实际各写各的”的东西。
第三,一定留一个可以随时跑起来的状态预览页。把七种状态、两种容器形态、有无图标、有无辅助文本这些组合平铺出来,改任何一行样式都去这个页面看一眼。组件级的改动影响面很大,靠业务页面去验证太慢也太不可靠。
第四,版本升级时优先验证“视觉回归”而不是功能回归。输入框很少会“功能坏掉”,但极容易“看起来变了”——一个依赖内部实现改了颜色映射,或者某个状态边框默认值变了,功能测试完全测不出来,只有截图对比才能发现。有条件的话,给核心组件的几个关键状态做截图基线,每次升级跑一次对比,能省掉大量线上返工。
我在最近一个项目里就把这套流程跑通了:输入框组件收敛出 14 个令牌、9 种状态组合的预览页、每个状态的截图基线。后面换主题色的时候,改了两行令牌,跑了一遍对比,只发现一处错误态描边对比度不足,改完就发版了。整个过程不到一个小时,放在以前靠人工点页面,至少两天,还未必点得全。这大概就是“把一个小组件当回事”能带来的复利。