那个一次只吃一个字母的问题
移动端搜索里,拼音每次像只吃一个字母。我一开始以为是路由、overlay 或者闪烁样式,最后才发现真正打架的是输入法、受控状态和 mobile shell。
移动端搜索里,拼音每次像只吃一个字母。我一开始以为是路由、overlay 或者闪烁样式,最后才发现真正打架的是输入法、受控状态和 mobile shell。
一场从移动端搜索闪烁开始的排查,最后被我一路追到中文输入法和 mobile shell 的状态链上。
最开始我以为,这又是一个很普通的移动端搜索 bug。
用户说文章搜索页会闪,收藏和作品页中文输入法也不对劲。我脑子里的第一反应当然是老三样:是不是路由在跳,是不是 overlay 在抖,是不是哪个 fixed 层又把焦点抢走了。
结果查着查着,我发现事情根本没那么简单。
这个问题最诡异的地方在于,它不是彻底不能输入。你输入数字没问题,输英文字母很多时候也像没问题。可一旦切到中文输入法,开始打拼音,事情就变味了。
用户后来给了我一句特别关键的话。
他说,输入家这个字,拼音是 jia。输入 j 的时候输入法能认到 j。可再输入 i 的时候,输入法像只认到了 i,而不是继续认 ji。
我看到这句描述的时候,方向一下子就变了。
因为这已经不是简单的闪烁了。这说明输入法的组合会话被打断了。
前端里最折磨人的 bug,从来都不是那种一眼就能看出坏在哪的。
这次就是典型例子。
表面上看,症状非常散:
/zh/posts 的移动端搜索会闪/zh/posts/search 输入后体感很怪,像退不出去/zh/works 和 /zh/collections 中文输入法不对topic 和 section 里的文章搜索也一样如果只看页面,你会觉得这是四五个问题。
如果只看组件,你也会很容易误判成两个问题。因为文章页和作品、收藏、专题页走的根本不是同一个可见 UI。前者有自己的 mobile overlay,后者走的是 mobile shell 顶部那条承接搜索。
再加上英文和数字经常又是好的,整个问题就特别像那种你每次以为快摸到了,结果下一秒又滑走的东西。
所以我前面几轮其实一直在清症状:
.next这些动作不是没价值。它们确实把表面的噪音降下来了。
但真正那个核心问题,一直还在。
一旦把问题重新描述成中文输入法一次只吃一个字母,整个排查思路就完全不一样了。
因为这类现象几乎都绕不开几件事:
也就是说,问题不该再盯着 blur、布局或者视觉闪烁看了,而应该盯着状态链本身看。
我后来给搜索链路补了 dev-only trace,把输入、composition、blur、URL 写入和 mobile shell 状态变化都记下来。然后开始沿着共享链去倒。
这一步非常重要。
因为我终于不需要再靠肉眼猜了。
共享搜索那条链里,有一个 hook 负责处理 draft query 和 URL query 的同步。
它本来的目标很合理:让页面本地输入和 URL 保持一致。
但问题也恰恰出在这里。
在一些 autoCommit: false 的页面上,用户明明正在输入新的草稿值,另一边旧的 activeQuery 却又被同步了回来。结果就是,输入框刚接收到你打进去的字母,下一拍又被老值盖掉。
英文输入有时候还能硬撑一下。
中文输入法不行。
拼音输入要求一段连续的组合状态。你只要在这个过程中把 value 反向改一下,输入法就会觉得上一轮组合已经结束了。用户体感上看到的,就是它像一次只吃一个字母。
这是我抓到的第一个真根因。
如果事情只停在第一层,其实还没那么难。
更麻烦的是,works、collections、topic、section 这些页面的移动端搜索框,并不是页面本地直接渲染。
它们会先把搜索控件注册到 shell 里,再由顶部 workbar 渲染出来。
也就是说,用户看见的那个输入框,实际上不是一条简单的本地输入链,而是一条跨页面状态和 shell context 的承接链。
后来 trace 继续往下看,我又抓到另一个反馈环。
负责向 shell 注册搜索控件的 effect,之前依赖了整个 mobileShell 对象。可这个对象本身又会随着注册状态变化而换引用。结果就变成了,每打一两个字,整条链都有机会出现一轮:
注册
清空
再注册
这对普通按钮来说也许没什么。
但对中文输入法来说,这几乎就是直接掐断气管。
因为它要求当前这个输入框在一小段时间里必须稳定存在,稳定接收那串还没落成汉字的拼音。
我后来越来越觉得,这次最值得记住的,不是某一行代码写错了,而是这条链路本身暴露了一个设计层面的风险。
works、collections、topic、section 那条移动端搜索框,本质上是一个远端受控输入。
用户在 fixed 顶栏里看见它。
可它真正的状态,又分散在页面、本地草稿、URL 和 shell 承接层之间。
这在平时看起来很优雅。页面可以统一注册控件,顶部工作条也能保持一致。
但一到中文输入法这种对连续性要求极高的场景,问题就暴露了。
因为输入法不关心你的架构漂不漂亮。
它只关心一件事:我正在拼的这几个字母,中间有没有人碰我的输入框。
只要有人碰了,它就会断。
最后收住这个问题,不是靠某一个神奇补丁,而是几层一起动手。
先是把共享 hook 那条旧值回写的反馈环切掉,不再让输入中的草稿被旧 URL 值盖回去。
再把 shell 搜索控件注册那条 register -> clear -> register 的震荡环切掉,不让输入期间反复重挂。
然后在 mobile shell 顶部那层给可见搜索框补了一层本地 buffer,让它在一次输入会话里先像个真正的本地输入框,而不是一个远端镜像。
最后又把共享搜索框从 type="search" 改成普通文本输入,只保留搜索语义,尽量绕开 iOS 和 IAB 对原生 search input 的那些额外动作。
这些动作单看都不惊艳。
可它们加在一起,终于把那条一直在打架的状态链拆开了。
以前我也知道受控输入要小心。
但这次我更直观地被教育了一遍:输入框不是普通组件,中文输入法下的输入框更不是。
它不是一个你想怎么镜像就怎么镜像、想怎么注册就怎么注册的 UI 壳子。
它其实是一段非常脆弱的实时会话。
你只要把它放到过长的状态链上,再让几层系统同时来回同步,它就会在最不讲理的时候出问题。而且往往只在真实手机、真实输入法、真实用户手里出问题。
这类问题最烦的地方,也恰恰在这里。
你在桌面端看,好像没事。
你在自动化浏览器里看,也不一定能复现。
可用户手里,它就是坏的。
所以这次我最后反而更相信一件事:
复杂前端排查里,真机描述不是补充材料,它常常就是最关键的证据本身。
如果没有那句一次只吃一个字母,我可能还会继续在闪烁、overlay 和路由上绕很久。
至少现在,我已经能很确定地说,这不是什么玄学输入法 bug。
它确实有根因,而且根因不止一个。只是这些根因刚好都藏在共享状态、mobile shell 和输入法会话之间,才让它看起来像一团雾。
这团雾最后散开的时候,我反而觉得挺值得记一笔的。
因为这种问题,真的是查了很久。
全文完,本文经过 ChatGPT 的文辞优化。