
一个由「焦点focus」引发的诡异 Bug节点刷新后再也点不动了关键词Angular、Web 无障碍Accessibility、focus事件、document.activeElement、防抖一句话背景在一个基于图Graph的可视化编辑器里用户点了某个节点、触发了一次接口刷新之后这个节点就再也选不中了——除非页面上有别的元素发生显示/隐藏的变化它才突然又能点了。一、现象产品是一个事件流Event Stream的可视化编辑器画布上有很多「节点node」源节点、处理节点、目标节点等。用户点击某个节点右侧会弹出对应的配置面板side pane。某次测试中发现一个稳定复现、但看起来毫无道理的 Bug点击节点 A —— 正常A 被选中右侧面板出现。点击另一个节点 B或触发了一次会刷新数据的操作比如get model接口。此时再去点节点 A点不动了。鼠标点上去毫无反应面板不切换。只有当页面上有别的元素发生「显示 / 隐藏」之类的 DOM 变化后A 才神奇地恢复可点击。最让人困惑的是第 4 点为什么「页面其他地方变一下」就能把它治好这通常意味着——问题不在点击逻辑本身而在某个「状态没被正确重置」。二、一个反直觉的前提我们用的是focus不是click大多数人写「选中一个节点」会很自然地用clickdivclassnode(click)onClick(node)/div但在这个项目里节点选中绑定的是focus事件!-- graph-node.component.html --divclassgraph-nodetabindex0(focus)onFocus($event, instance)(blur)onBlur($event, instance)/div为什么不用click偏要用focus答案是无障碍Accessibility简称 a11y。一个只响应click的元素键盘用户是操作不了的。依赖读屏软件NVDA / JAWS / VoiceOver或纯键盘导航的用户是用Tab键在可聚焦元素之间跳转的。要让节点能被键盘选中节点必须是可聚焦的tabindex0在「获得焦点」时就触发选中逻辑focus事件而不是等鼠标点击。所以这里把「选中」绑在focus上是正确且必要的设计——鼠标点击也会让元素获得焦点所以鼠标用户照样能用同时键盘用户也能用。一举两得。但也正是这个「为了无障碍而用 focus」的设计埋下了这个 Bug 的根因。三、根因浏览器的「焦点」和应用的「选中」是两套状态它们不同步了要理解这个 Bug得先分清两个不是一回事的概念概念谁维护含义浏览器焦点浏览器自己document.activeElement当前哪个 DOM 元素「拿着焦点」。同一时刻全文档只有一个应用选中态我们的应用store / 业务状态业务上「当前选中的是哪个节点」正常情况下这两者是绑在一起的点节点 A → A 获得浏览器焦点 → 触发focus→ 应用把「选中节点」设为 A。两套状态一致。问题出在那次「刷新接口」上。走查onFocus的实现可以看到它做的事情大致是// graph-node.component.ts简化asynconFocus(_:FocusEvent,instance:NodeInstance):Promisevoid{// ...防抖判断后面讲...if(/* 是个正常可选中的节点 */){constelementdocument.getElementById(this.id);element.click();// 触发选中相关副作用awaitthis.nodeService.selectNode(instance,true);// 真正更新应用选中态}this.updateTabIndex();}而当用户触发get model这类会刷新画布数据的操作时发生了下面这一连串事情接口返回画布数据被刷新节点们重新渲染或部分重排。应用层把「默认选中」/「激活态active」重新落到了某个节点上比如默认回到了 ES 节点。但是——浏览器层的document.activeElement并没有跟着改。浏览器仍然认为「焦点」还停在用户上一步点过的那个节点 A 上。于是关键的矛盾出现了应用觉得选中态变了浏览器却觉得焦点没变。浏览器记得「A 当前是 focused 的」所以当用户再次去点 A时浏览器判断「A 本来就是焦点焦点没发生变化」——于是根本不派发新的focus事件。focus事件的语义就是「焦点发生了转移」。如果一个元素已经是焦点了你再点它焦点没有「转移」浏览器自然不会再触发focus。而我们的选中逻辑全挂在focus上。focus不触发 →onFocus不执行 →selectNode不调用 →节点选不中。这就完美解释了第 4 个现象:「为什么页面其他元素变一下就好了」——因为那次变化某个元素显示/隐藏会让焦点被迫离开 A、再回到 A制造出一次真实的「焦点转移」于是focus又能触发了。本质是「被动地帮我们把焦点状态重置了一次」。四、用一张图说清楚正常流程 点击节点 A ──► 浏览器焦点 A ──► 派发 focus ──► onFocus ──► selectNode(A) ✅ 出 Bug 的流程 点击节点 B ──► 浏览器焦点 B 触发 get model 刷新 ──► 应用把「选中/激活」重置到 ES 节点 但 document.activeElement 仍然 B没人去改它 ❌ 现在「浏览器焦点」和「应用选中态」已经对不上了 用户再次点击 B ──► 浏览器「B 本来就是焦点焦点没变」──► 不派发 focus ──► onFocus 不执行 ──► 选不中 B 随便点一下别的、或某元素显隐──► 焦点被迫离开 B 再回来 ──► 焦点真的「转移」了 ──► focus 恢复触发 ✅五、修复思路刷新后主动把「浏览器焦点」拉回到「应用选中态」上既然根因是「刷新后浏览器焦点和应用选中态脱节」修复的核心就一句话在数据刷新、应用重新确定「当前激活节点」之后主动调用一次element.focus()强制让浏览器焦点和应用选中态重新对齐。为此我新增了一个专门干这件事的辅助函数注释里也点明了这个「焦点 ≠ 激活节点」的陷阱/** * description Sometimes the focus node is not same as activeNode, * so we need to focus the node by id. * param nodeId 要重新聚焦的节点 ID */exportasyncfunctionfocusActiveInstanceNode(nodeId:string){constnodeListdocument.getElementsByTagName(graph-node);constactiveElementArray.from(nodeList).find(nodenode.getAttribute(id)nodeId,);if(activeElement){(activeElementasHTMLElement).focus();// 主动把浏览器焦点拉回到真正激活的节点}}在「刷新接口完成、应用确定了当前激活节点」这个时机调用它就能保证浏览器的document.activeElement应用的「当前选中节点」两者重新指向同一个节点。这样用户下次点击任何节点都会产生真实的「焦点转移」focus正常派发选中逻辑正常工作。注意一个细节直接element.focus()是可行的因为节点元素本身有tabindex0是可聚焦的。如果元素不可聚焦focus()会是空操作。六、一个容易踩的副作用防抖debounce顺带提一个相关的坑。onFocus里有这样一段防抖逻辑private_onfocusDebounceEnabledfalse;asynconFocus(_:FocusEvent,instance:NodeInstance):Promisevoid{if(this._onfocusDebounceEnabled){return;// 300ms 内重复触发直接忽略}this._onfocusDebounceEnabledtrue;// ...选中逻辑...setTimeout(()(this._onfocusDebounceEnabledfalse),300);}为什么需要它因为focus/blur在某些交互下会短时间内连续触发多次鼠标点击同时会引发 focus键盘移动也会程序里element.click()又可能再引发一轮。如果不加防抖selectNode可能在 300ms 内被打几次造成重复请求、状态闪烁。但防抖也意味着如果你在修复里又手动focus()一次要小心别和这个 300ms 窗口打架——手动聚焦触发的focus事件可能正好落在防抖窗口里被吞掉。所以「主动重置焦点」的时机要选在防抖窗口之外或者让重置逻辑走单独的路径像focusActiveInstanceNode那样直接操作 DOM而不是依赖事件回调。七、复盘这个 Bug 教给我们什么「浏览器状态」和「应用状态」是两套东西别假设它们永远同步。document.activeElement焦点、:hover、滚动位置、表单的原生值……这些都是浏览器自己维护的状态。当你用框架Angular/React/Vue重新渲染、重置应用状态时浏览器的这些原生状态不会自动跟着变。一旦两者脱节就会出现「逻辑上明明该响应、实际却没反应」的诡异 Bug。focus事件的语义是「焦点转移」不是「我被操作了」。一个已经聚焦的元素再点它不会再触发focus。把核心交互逻辑挂在focus上时一定要考虑「焦点已经在我身上」这种边界情况。为无障碍做的设计往往会引入新的状态维度。用focus替代click是对的、是对键盘和读屏用户的尊重但它把「浏览器焦点」这个原本可以忽略的维度变成了业务逻辑强依赖的状态。做 a11y 不是加个属性那么简单它会改变你的状态模型。「页面变一下就好了」是个强烈的信号。只要 Bug 的恢复条件是「随便让 DOM 动一下」几乎可以断定问题是「某个状态该重置却没重置」而那次 DOM 变动恰好顺手帮你重置了。顺着这条线索找「谁应该负责重置、却没做」通常能直捣黄龙。八、一句话总结这个 Bug 的本质是为了无障碍我们把「选中」绑在了focus上但数据刷新时应用更新了「选中态」却没人去更新「浏览器焦点」导致用户再次点击同一节点时浏览器认为「焦点没变」而拒绝派发focus选中逻辑因此哑火。修复方法是刷新后主动element.focus()把浏览器焦点重新拉回到应用真正激活的节点上让两套状态重新对齐。