编辑输入框的按钮也使其失焦,破坏了焦点守卫
当按钮向输入框写入数据时,基于焦点的脏状态守卫会静默失败。本文将解释为什么 blur 事件会先触发,以及在实时更新的表单中应该使用什么替代方案。
如果一个表单会从服务器接收实时更新,你需要某种方法来避免覆盖用户正在输入的内容。通常的答案是使用一个焦点标志:在 focus 事件时设置 editing = true,在 blur 事件时清除它,并在它为 true 时跳过同步。这对于打字输入是有效的。但当你添加一个会写入同一输入的按钮时,这种方法就失效了。
原因很小,且容易被忽略。点击一个已获焦输入框旁边的按钮,会首先使该输入框失焦。你的保护机制恰好在最需要它的时候被关闭了,并且如果失焦处理程序还会刷新延迟的工作,你就为刷新操作亮了绿灯。
我在为一个管理面板中的到期日字段添加“+31 days”按钮时遇到了这个问题。该按钮会增加输入框中的数字,并将保存操作留给现有的“保存”按钮。在测试中,它看起来没问题。但在实时数据流下,它几乎每次都会丢失编辑内容。
这个失败是确定性的,而非竞争所致
以下是浏览器运行它的顺序:
- 在按钮上触发
mousedown事件,将焦点从输入框移开。 - 输入框触发
blur事件。其处理程序将editing设置为false,并且在许多代码库中,会刷新(flush)任何在编辑时被延迟的刷新操作。 click事件触发。你的处理程序将新值写入输入框。- 几百毫秒后,一个服务器推送到达。此时
editing为false,因此同步操作会运行,并用存储的值替换输入框的内容。
这里没有任何事情取决于时机上的运气。第 2 步总是先于第 3 步,因此当按钮写入时,该标志(flag)总是处于关闭状态。唯一的变量是用户按下“保存”之前是否会有更新到达,而这在一个繁忙的管理后台界面上几乎是肯定的。
还有第二种路径,甚至更简单。如果用户根本没有聚焦过输入框,只是选择了一行并点击了按钮,那么 editing 从一开始就不是 true。
Two details in there matter more than they look.
Set the flag after the write, not before. If you set it at the top of the handler, a rejected click still marks the form dirty. The user sees an "unsaved" badge for a value that never changed, and the next sync is blocked for no reason.
Reject instead of clamping. An earlier version capped the result at the maximum. Press the button at 3640 with a cap of 3650 and the value moves to 3650. Press again and nothing happens, with no message. The button looks broken. Refusing with a reason is shorter to write and easier to trust.
Do not put the dirty flag in the refresh gate
The obvious next step is to reuse the flag everywhere you already check the focus flag. Resist that for the scheduler that decides whether to refresh at all.
A focus flag clears itself. The user clicks somewhere else and it is gone. A dirty flag is sticky by design: it stays until the value is saved or discarded. If you put a sticky flag in the "should I refresh" condition, then a user who types a value and walks away freezes the whole screen behind a "changes available" banner, indefinitely.
Keep the two scopes separate:
- The refresh scheduler may use focus flags, so a refresh is deferred for a second or two while someone types.
- The field sync inside the refresh uses the dirty flag, so the one input with unsaved work is skipped while everything else updates.
The list, the badges, and the rest of the panel keep moving. Only the field the user is editing holds still.
Let the stale value stay visible next to the new one
Once the input stops syncing, it will disagree with the server. Do not hide that. Show both.
In the panel I was working on, the input showed the pending value while a line under it kept showing the stored one:
[ 84 ] [保存] [永久] [+31 天]
当前 53 天,于 2026-08-30 到期 * 未保存,请按“保存”
Two numbers on screen sounds like a bug report waiting to happen, and the first instinct is to freeze the caption too. That instinct is wrong. While the field is dirty, that line is the only channel telling the user what the server actually holds. If another operator extends the same license to 90 days, the caption moves to 90 and the input stays at 84. The user can see that something changed underneath and decide whether to overwrite it.
Freeze the caption and you get the worst version: a screen that looks internally consistent while being wrong.
Sticky state needs a lifecycle
A flag that clears itself needs no cleanup. A sticky one does, and the cleanup is where the second round of bugs lives.
The one that bit me: the panel is rendered conditionally on a selected record. Delete the record and the subtree is removed from the DOM. Removal does not fire blur. So an input that was focused when its panel disappeared leaves its focus flag true forever, and every later update gets deferred into a banner that nobody clicks.
The fix is a single function that owns clearing the selection, so no caller sets it to null directly:
clearSelection() {
this.selected = null
this.durationInput = ''; this.durationDirty = false; this.editingDuration = false
this.tierInput = ''; this.editingTier = false; this.editingNote = false
}
Call it from delete, and from the refresh path when the selected record is no longer in the returned list. Two more rules worth writing down:
- On failure, keep the flag. If a save returns 400 or 502, the value in the field is still the only copy of the user's intent. Clearing dirty there invites the next sync to erase it.
- On success, order the writes. Assign the server response to the field, then clear dirty, then refetch. Clearing first opens a window where the refetch can touch the field before you have put the confirmed value in it.
What about preventing the blur?
There is a tempting one-liner: block the default action of mousedown on the button so focus never leaves the input.
<button onmousedown="event.preventDefault()" onclick="addDays(31)">+31 天</button>
It does keep focus, and for a user who was already typing it does keep the focus flag up. It is still the wrong fix, for two reasons.
It only preserves focus that already exists. It does not create it. The user who selects a row and clicks the button without ever touching the input is still unguarded, and that is the exact path that started this whole investigation.
And when it does work, it works by leaving the focus flag up for a long time. If your refresh scheduler defers on that flag, you have traded a lost edit for a screen that stops updating while a user stares at a form. That is the sticky-flag problem from the previous section, arriving through a side door.
Fix the model, not the event.
What this does not fix
A dirty flag protects the edit from your own refresh. It does nothing about a second person editing the same record.
The scenario is the mirror image of the one we started with. You raise the value to 84 and step away. Someone else extends the same record to 90 and saves. Your caption updates to 90, your input still reads 84, and if you come back and press Save without reading the caption, you overwrite their change with no warning. Before the dirty flag, the refresh would have quietly replaced your 84 and the conflict would never have existed, because your edit would already be gone.
That is not a regression so much as a trade. You moved from "the system silently discards your work" to "the system silently discards someone else's, if you ignore what is on screen." The second is better, because at least the information is visible, but it is not the same as being safe.
The real fix is a compare-and-set: capture the stored value when the edit begins, send it with the save, and let the server reject the write if the record moved. That is a change to the API contract, not a front-end tweak, so it deserves its own decision rather than being smuggled in. What is worth doing cheaply is deciding, in advance, which of the two failures you would rather have, and making the visible one visible enough that a careful person can catch it.
Two smaller traps in the same neighborhood
While wiring this up, two unrelated-looking things came from the same root of assuming the framework does more than it does.
Number inputs give you strings. <input type="number"> constrains what the user can enter. It does not change the type of the bound value. Several frameworks only cast when you ask them to, with an explicit modifier. So the value you send is "1", not 1, and a server that strictly parses integers rejects it. The irony in my case was sharp: "1" was on my own list of inputs the API should refuse, and the front end was the thing sending it.
Text-setting directives erase children. A directive that sets an element's text content replaces everything inside it on every evaluation. Put a conditional badge inside that element and it vanishes the first time the text is recomputed. Split the container: one child for the bound text, a sibling for the badge.
Neither is deep. Both cost a debugging round, because the symptom appears at runtime in a path you were not looking at.
Questions this usually raises
Does this apply outside a specific framework?
Yes. The order of mousedown, blur, and click is defined by the browser, not by your view layer. Anything that clears an editing flag on blur inherits the problem.
What if I have several fields with buttons? One flag per field. A single shared flag means an unsaved edit in one place blocks syncing in another, and finding that later is unpleasant.
Is losing one edit worth this much machinery? The machinery is one boolean, one condition, and one cleanup function. The failure it prevents is silent: the value reverts, the save succeeds, and the confirmation message reports the old number as if it were the new one. The user has no signal that anything went wrong. Silent wrong is expensive to find later.
Should the button just save immediately instead? That is a real option and it removes the problem entirely. It is the wrong trade when the button sits next to other buttons with different effects, because a misclick becomes a committed change with an audit entry. Editing the field and requiring a separate confirm keeps a misclick recoverable.
How do I test it? Turn the flag off and watch the failure appear. If disabling the guard does not reproduce the loss, the guard is not the thing protecting you and something else is masking it. That check took me a minute and was worth more than the rest of the testing, because it proved the code was earning its place.
A useful second check is the refresh path you did not think about. Most live screens have more than one way to reload: the push handler, a polling fallback when the socket drops, and usually a "changes available, click to refresh" banner. In my case that banner called the reload function directly, skipping the scheduler entirely. Because the guard lives inside the sync rather than in the scheduler, it covered that path too. But I only learned that by clicking the banner while an edit was pending, not by reading the code. List every entry point into your reload function and try each one with a dirty field.
Does the guard belong in the component or in the store? Wherever the sync happens. The point of the pattern is that the field-level skip sits at the exact place where server data is written into form state. If that write lives in a store action, the flag goes there. Putting it in the component and hoping the store cooperates gives you two sources of truth about who owns the input.