1. 项目概述:为什么一个“焦点事件”值得单独写一篇深度复盘?
你有没有遇到过这样的场景:页面上放了一个 CodeMirror 编辑器,用户点进去写代码,一切正常;但当你在旁边加了个按钮,想在用户离开编辑器时自动保存草稿,结果发现blur事件根本没触发?或者更诡异的是——用户第一次点击编辑器,光标进去了,但focus事件只响了一次,之后再点别的地方再点回来,事件就彻底失灵了?又或者,你用原生<textarea>做表单输入,明明写了onfocus="console.log('got it')",控制台却一片寂静?
这不是你的代码写错了,也不是浏览器 Bug,而是你还没真正理解“焦点管理”在现代 Web 编辑器生态里的底层逻辑。今天这篇,不讲 API 文档里抄来的定义,我以过去三年在多个富文本协作系统、低代码平台、前端 IDE 插件项目中的真实踩坑经历为底,把<textarea>焦点事件和CodeMirror(v6)焦点事件的行为差异、触发条件、监听陷阱、调试路径、兼容方案,全部掰开揉碎讲透。核心关键词就三个:焦点捕获、Shadow DOM 隔离、EditorView 生命周期。它适合三类人:正在调试表单交互的前端新手、集成 CodeMirror 的业务开发者、以及准备自己封装可编辑组件的中高级工程师。你不需要提前了解 CodeMirror 内部结构,但如果你已经写过editor.on('focus', ...)却发现它不工作——那这篇文章就是为你写的。
2. 核心机制拆解:为什么原生 textarea 和 CodeMirror 的“焦点”根本不是一回事?
2.1 原生<textarea>的焦点:简单粗暴,但暗藏玄机
表面上看,<textarea>是最标准的表单控件,它的焦点行为完全遵循 HTML 规范:
- 用户鼠标点击、键盘 Tab 进入、或调用
.focus()方法,都会触发原生focus事件; - 失去焦点时(点击别处、按 Tab 移出、调用
.blur()),触发blur事件; - 事件对象是标准
FocusEvent,event.target就是那个<textarea>元素本身。
但实操中,90% 的“失效”问题,其实出在事件监听时机和作用域污染上。比如:
<div id="form-container"></div> <script> // ❌ 错误:DOM 还没挂载就监听 document.querySelector('textarea').addEventListener('focus', () => { console.log('focused'); }); // ✅ 正确:确保元素存在后再绑定 setTimeout(() => { const ta = document.querySelector('textarea'); if (ta) ta.addEventListener('focus', () => console.log('focused')); }, 0); // ⚠️ 更稳妥:用事件委托(尤其动态生成的 textarea) document.getElementById('form-container').addEventListener('focus', (e) => { if (e.target.tagName === 'TEXTAREA') { console.log('textarea focused:', e.target.name); } }, true); // 注意:focus/blur 不冒泡,必须用 capture 阶段 </script>提示:
focus和blur默认不冒泡(bubbling phase 为 false),所以addEventListener('focus', handler)默认监听不到子元素触发的焦点事件。若要用事件委托,必须显式传入{ capture: true }参数,否则监听器永远收不到。
另一个常被忽略的细节是autofocus属性与 JS 监听的竞态。当<textarea autofocus>渲染完成,浏览器会立即触发focus,但此时 JS 可能还没执行到addEventListener行。解决方案不是删autofocus,而是改用requestAnimationFrame确保监听器就位:
// ✅ 安全的 autofocus 监听方案 const ta = document.querySelector('textarea[autofocus]'); if (ta) { requestAnimationFrame(() => { ta.addEventListener('focus', handleFocus, { once: true }); }); }2.2 CodeMirror v6 的焦点:它压根没有<textarea>,那焦点在哪?
这是绝大多数人的认知断层点。CodeMirror v6完全抛弃了<textarea>或<input>,它用纯 DOM + Canvas + CSS 实现了一个“视觉上像编辑器”的可编辑区域。它的底层是一个带contenteditable="true"的<div>(实际是EditorView.dom返回的根容器),所有光标、选区、高亮都由 JS 控制渲染。因此:
- 它没有原生
focus/blur事件—— 你给editor.dom绑定focus,永远不会触发; - 它的焦点状态是内部状态(state)驱动的—— 是否聚焦,取决于当前
EditorView是否处于“active”状态; - 焦点事件不是 DOM 事件,而是 View 生命周期钩子—— 你需要监听
EditorView的dispatch流或使用updateListener。
CodeMirror v6 的官方推荐方式是通过EditorView.updateListener监听视图更新,并在update.transactions中判断焦点变化:
import { EditorView, basicSetup } from 'codemirror'; import { javascript } from '@codemirror/lang-javascript'; const view = new EditorView({ state: EditorState.create({ doc: 'console.log("hello")', extensions: [basicSetup, javascript()] }), parent: document.body, updateListener: (update) => { // ✅ 正确:通过 update 钩子判断焦点状态 if (update.focusChanged) { if (update.view.hasFocus()) { console.log('CodeMirror 获得焦点'); } else { console.log('CodeMirror 失去焦点'); } } } });但这个方案有个硬伤:update.focusChanged只在焦点真正切换时才为true,而首次初始化、或从display:none恢复时,它可能不会触发。更鲁棒的方式是结合view.hasFocus()主动轮询 +focus/blur事件代理:
// ✅ 生产环境推荐:混合监听策略 const view = new EditorView({ /* ... */ }); // 1. 监听根 DOM 的 focus/blur(需 capture) view.dom.addEventListener('focus', () => { console.log('DOM focus captured'); }, true); view.dom.addEventListener('blur', () => { console.log('DOM blur captured'); }, true); // 2. 启动一个轻量级轮询(仅在需要时) let lastFocusState = view.hasFocus(); const focusPoller = setInterval(() => { const current = view.hasFocus(); if (current !== lastFocusState) { lastFocusState = current; if (current) { console.log('CodeMirror now focused (via poll)'); } else { console.log('CodeMirror now blurred (via poll)'); } } }, 100); // 3. 清理(例如组件卸载时) // clearInterval(focusPoller);2.3 关键差异对比:一张表说清本质区别
| 维度 | 原生<textarea> | CodeMirror v6 |
|---|---|---|
| 焦点载体 | <textarea>元素本身 | EditorView.dom(一个div) |
| 事件类型 | 原生FocusEvent(冒泡阶段不可用) | 无原生事件,需通过updateListener或hasFocus()判断 |
| 首次聚焦 | autofocus自动触发focus事件 | view.focus()手动调用,无自动事件,需主动检查hasFocus() |
| 失焦判定 | 点击外部任意非 focusable 元素即触发blur | 必须点击view.dom外部且该元素可获得焦点(如<button>),否则hasFocus()仍为true |
| Shadow DOM 兼容性 | 原生支持,事件可穿透 | 若view.dom被插入 Shadow Root,需手动attachShadow并处理delegatesFocus: true |
| 调试手段 | console.dir(element)查element.matches(':focus') | console.log(view.hasFocus())或view.state.selection.ranges[0].from判断光标位置 |
这个表不是为了让你死记,而是帮你建立一个决策树:当你发现“焦点事件不触发”,第一反应不该是“重写监听器”,而是先问:我现在操作的对象,到底属于哪一栏?
3. 实操全流程:从零搭建一个“焦点感知型”代码编辑器组件
3.1 场景设定:一个带自动保存与状态提示的 CodeMirror 编辑器
我们来实现一个真实业务中高频出现的需求:
- 页面上有两个 CodeMirror 编辑器(A 和 B);
- 当用户聚焦 A 时,右侧显示“A 正在编辑”,并禁用 B 的编辑能力;
- 当用户从 A 切换到 B 时,A 自动保存当前内容到 localStorage,B 获取焦点并显示提示;
- 若用户点击页面空白处(非编辑器区域),所有编辑器失焦,但不触发保存(避免误触)。
这个需求看似简单,但涉及焦点抢占、状态同步、防抖保存、跨编辑器协调,正是检验你是否真正吃透焦点机制的试金石。
3.2 第一步:初始化双编辑器,并建立焦点状态管理中心
我们不直接在每个EditorView里写一堆if/else,而是抽象出一个FocusManager类,统一管理所有编辑器实例的焦点生命周期:
class FocusManager { constructor() { this.activeView = null; this.views = new Map(); // key: string(id), value: EditorView this.saveDebounced = this._debounce(this._saveToStorage, 800); } // 注册一个编辑器实例 register(id, view) { this.views.set(id, view); // 绑定 DOM 级 focus/blur(capture 模式) view.dom.addEventListener('focus', () => this._handleFocus(id), true); view.dom.addEventListener('blur', () => this._handleBlur(id), true); // 同时监听 update,作为兜底 const originalUpdate = view.updateListener; view.updateListener = (update) => { if (update.focusChanged && !update.view.hasFocus()) { // update.focusChanged 为 true 且 hasFocus 为 false → 真实失焦 this._handleBlur(id); } if (originalUpdate) originalUpdate(update); }; } _handleFocus(id) { const view = this.views.get(id); if (!view || !view.hasFocus()) return; // 1. 清除之前激活的编辑器 if (this.activeView && this.activeView !== view) { this._deactivate(this.activeView); } // 2. 激活当前编辑器 this.activeView = view; this._activate(view, id); // 3. 更新 UI 提示 this._updateStatus(id, 'editing'); } _handleBlur(id) { const view = this.views.get(id); if (!view || view !== this.activeView) return; // 只有当前激活的编辑器失焦,才触发保存 this.saveDebounced(id, view.state.doc.toString()); this.activeView = null; this._updateStatus(id, 'idle'); } _activate(view, id) { // 禁用其他编辑器(通过移除 contenteditable) this.views.forEach((v, otherId) => { if (otherId !== id) { v.dom.setAttribute('contenteditable', 'false'); v.dom.style.opacity = '0.7'; } }); } _deactivate(view) { // 恢复其他编辑器 this.views.forEach((v, id) => { v.dom.setAttribute('contenteditable', 'true'); v.dom.style.opacity = '1'; }); } _saveToStorage(id, content) { try { localStorage.setItem(`cm-editor-${id}`, content); console.log(`✅ 已保存 ${id} 到 localStorage`); } catch (e) { console.warn('⚠️ localStorage 写入失败,降级为内存缓存'); this._inMemoryCache[id] = content; } } _updateStatus(id, status) { const el = document.getElementById(`status-${id}`); if (el) { el.textContent = status === 'editing' ? `${id} 正在编辑` : `${id} 空闲`; el.className = status === 'editing' ? 'status-active' : 'status-idle'; } } // 防抖工具函数(不依赖 Lodash) _debounce(fn, delay) { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => fn(...args), delay); }; } } // 使用示例 const focusMgr = new FocusManager(); const viewA = new EditorView({ state: EditorState.create({ doc: '// A 的初始内容' }), parent: document.getElementById('editor-a') }); focusMgr.register('editor-a', viewA); const viewB = new EditorView({ state: EditorState.create({ doc: '// B 的初始内容' }), parent: document.getElementById('editor-b') }); focusMgr.register('editor-b', viewB);这段代码的关键不在功能多炫酷,而在于它把焦点逻辑从视图层抽离,形成可测试、可复用的状态机。你不需要记住“CodeMirror 用什么监听”,只需要知道:register()之后,一切焦点流转都由FocusManager统一调度。
3.3 第二步:解决“点击空白处不保存”的终极方案——精确识别失焦目标
上面的_handleBlur有个致命缺陷:只要view.dom失去焦点,就无差别触发保存。但用户点击<header>、<nav>、甚至<div class="page-bg">,这些区域本就不该触发保存逻辑。我们需要知道:用户到底点到了哪里?
CodeMirror v6 的blur事件对象里,relatedTarget字段指向即将获得焦点的元素。但问题来了:如果用户点击的是<div>(无tabindex),它无法获得焦点,relatedTarget就是null。这时,我们必须回退到document.activeElement:
_handleBlur(id) { const view = this.views.get(id); if (!view || view !== this.activeView) return; // 🔍 精确判断失焦目标 const related = document.activeElement; const isClickingOutside = !related || !this.views.has(related.id) && // 不是其他编辑器 !related.closest('.editor-container') && // 不在编辑器容器内 !['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'].includes(related.tagName); // 不是可聚焦表单控件 if (isClickingOutside) { console.log('❌ 点击空白处,跳过保存'); this.activeView = null; this._updateStatus(id, 'idle'); return; } // ✅ 确认是切换到其他可聚焦元素,才执行保存 this.saveDebounced(id, view.state.doc.toString()); this.activeView = null; this._updateStatus(id, 'idle'); }这里用到了一个关键技巧:element.closest(selector)。它向上遍历 DOM,检查自身或祖先是否匹配选择器。我们预设所有编辑器都包裹在<div class="editor-container">里,这样就能精准区分“点击编辑器A → 编辑器B”和“点击页面标题 → 失焦”。
3.4 第三步:处理极端情况——模态框、iframe、Shadow DOM 中的焦点穿透
在复杂应用中,编辑器可能被嵌入:
- 一个
position: fixed的模态框(z-index 很高); - 一个跨域
<iframe>的父页面; - 或者一个 Web Component 的 Shadow Root 里。
这时,原生focus事件可能被拦截或无法到达。CodeMirror v6 官方文档明确指出:若EditorView.dom被放入 Shadow DOM,必须设置delegatesFocus: true,否则焦点无法正确传递:
// ✅ 在 Shadow DOM 中正确使用 class MyEditor extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open', delegatesFocus: true }); // 👈 关键! } connectedCallback() { const container = document.createElement('div'); this.shadowRoot.appendChild(container); const view = new EditorView({ state: EditorState.create({ doc: 'Hello from Shadow!' }), parent: container }); // 此时 view.dom 就在 shadowRoot 里,delegatesFocus 保证焦点可穿透 } }而对于 iframe 场景,父页面无法直接监听子 iframe 内 CodeMirror 的焦点(同源限制)。解决方案是子 iframe 主动 postMessage:
// 子 iframe 内 view.updateListener = (update) => { if (update.focusChanged) { window.parent.postMessage({ type: 'CM_FOCUS_CHANGE', id: 'editor-in-iframe', focused: update.view.hasFocus() }, '*'); } }; // 父页面监听 window.addEventListener('message', (e) => { if (e.data.type === 'CM_FOCUS_CHANGE') { console.log('子 iframe 编辑器焦点状态:', e.data.focused); } });这些不是“锦上添花”的边缘 case,而是你在接入低代码平台、微前端架构、或企业级 CMS 时,第二天就会遇到的真实障碍。
4. 深度避坑指南:那些只有亲手砸过键盘才懂的经验
4.1 “为什么我绑定了 focus,但控制台就是不打印?”——5个必查清单
当你写完监听器却毫无反应,请按顺序排查以下 5 项(我按发生频率排序):
- 监听时机错误:DOM 元素尚未渲染完成。用
document.addEventListener('DOMContentLoaded', ...)或MutationObserver监听容器插入。 - 事件不冒泡却用了冒泡监听:
focus/blur默认不冒泡,addEventListener('focus', handler)无效。必须加{ capture: true }。 - CodeMirror v6 误绑 DOM 事件:给
view.dom绑focus,永远不触发。必须用updateListener或hasFocus()。 - CSS
pointer-events: none或visibility: hidden:元素不可交互,自然无法获得焦点。检查 computed styles。 tabindex="-1"覆盖了默认可聚焦性:<textarea>默认tabindex=0,但若你手动设为-1,它只能通过.focus()聚焦,不能 Tab 进入。
注意:第 2 条和第 3 条是新人最高频的组合错误——既用了原生监听语法,又用在了 CodeMirror 上,双重失效。
4.2 CodeMirror v6 的focus()方法为何有时“失灵”?
你以为view.focus()就像<textarea>.focus()一样可靠?错。view.focus()的成功依赖于三个前提:
view.dom必须已插入 DOM 树(document.body.contains(view.dom)为true);view.dom不能被display: none或visibility: hidden隐藏;- 当前文档必须处于“活跃标签页”(Page Visibility API 中
document.hidden === false)。
我曾在一个后台任务管理页踩过坑:用户切到其他标签页写代码,再切回来,view.focus()不生效。原因是浏览器对非活跃页的focus()调用做了静默降级。解决方案是监听visibilitychange:
document.addEventListener('visibilitychange', () => { if (!document.hidden && this.view && !this.view.hasFocus()) { // 页面重新可见,且编辑器未聚焦,主动补一次 this.view.focus(); } });4.3 为什么event.target在 CodeMirror 里总是div.cm-scroller?
因为 CodeMirror v6 的焦点事件(如果你强行监听 DOM)触发在view.dom(即div.cm-editor)上,但实际接收事件的是其内部滚动容器div.cm-scroller。这不是 bug,是设计使然:编辑器内容区需要独立滚动,焦点必须落在可滚动区域才能支持键盘导航(如 PageUp/PageDown)。所以:
- 不要试图
event.target.classList.contains('cm-line')来判断是否点中代码行; - 正确做法是用
view.posAtCoords({ left, top })将鼠标坐标转为文档位置,再用state.doc.lineAt()获取行号。
view.dom.addEventListener('click', (e) => { // ✅ 获取点击处的文档位置 const coords = { left: e.clientX, top: e.clientY }; const pos = view.posAtCoords(coords); if (pos != null) { const line = view.state.doc.lineAt(pos); console.log('点击了第', line.number, '行'); } });4.4 性能警告:别在updateListener里做重操作
updateListener在每次编辑、光标移动、甚至悬停高亮时都会触发。如果你在里面写JSON.stringify(state.doc.toString())或发起网络请求,页面会明显卡顿。我的经验是:
- 所有耗时操作(如保存、语法校验、AI 补全)必须加防抖(debounce),延迟 300~1000ms;
- 纯状态同步(如更新计数器、切换按钮状态)可以直写,但要避免 DOM 查询(
document.getElementById); - 如果需要响应“用户停止输入”,用
update.transactions.some(t => t.isUserEvent)判断是否由用户触发,而非程序修改。
updateListener: (update) => { // ✅ 只在用户真实输入时触发保存 const isUserInput = update.transactions.some(t => t.isUserEvent && t.changes.size > 0 ); if (isUserInput) { this.debouncedSave(update.view.state.doc.toString()); } }4.5 最后一个反直觉真相:<textarea>的focus()也可能失败
你以为原生控件就万无一失?大错特错。以下情况textarea.focus()会静默失败:
<textarea>被disabled或readonly(即使只是临时设置);- 元素被
transform: scale(0)或opacity: 0隐藏(视觉隐藏 ≠ 逻辑不可聚焦); - 在 iOS Safari 中,
focus()必须由用户手势(click/tap)触发,JS 定时器调用无效; <textarea>父容器有overflow: hidden且高度为 0,导致元素不可见。
解决方案永远是:先检查element.checkVisibility()(返回布尔值),再调用focus():
function safeFocus(el) { if (!el || !el.checkVisibility?.()) { console.warn('Element not visible, focus skipped'); return false; } el.focus(); return true; }checkVisibility()是现代浏览器的标准 API,比getBoundingClientRect().width > 0更可靠。
5. 进阶实战:构建一个“焦点感知型”表单验证系统
5.1 需求升级:让整个表单根据焦点状态智能验证
现在我们把场景拉大:一个包含<textarea>和 CodeMirror 编辑器的混合表单。要求:
- 用户聚焦任一字段时,该字段边框高亮为蓝色;
- 失焦时,立即校验内容(如 textarea 非空、CodeMirror 语法合法);
- 若校验失败,显示红色错误提示,并阻止表单提交;
- 所有字段共用同一套验证规则引擎。
这不再是“监听事件”,而是构建一个基于焦点状态的响应式验证流。
5.2 架构设计:ValidationEngine + FieldAdapter 模式
我们不给每个字段写重复逻辑,而是定义一个通用接口FieldAdapter,让不同控件适配同一套验证引擎:
// 验证引擎核心 class ValidationEngine { constructor() { this.rules = new Map(); this.errors = new Map(); } addRule(fieldId, ruleFn) { this.rules.set(fieldId, ruleFn); } validate(fieldId) { const rule = this.rules.get(fieldId); if (!rule) return true; const value = this.getValue(fieldId); const result = rule(value); this.errors.set(fieldId, result === true ? null : result); return result === true; } getValue(fieldId) { const adapter = this.adapters.get(fieldId); return adapter ? adapter.getValue() : ''; } // 注册适配器 registerAdapter(fieldId, adapter) { this.adapters.set(fieldId, adapter); } } // textarea 适配器 class TextareaAdapter { constructor(textarea) { this.el = textarea; } getValue() { return this.el.value.trim(); } setValid(isValid) { this.el.style.borderColor = isValid ? '#ccc' : '#f00'; } } // CodeMirror v6 适配器 class CodeMirrorAdapter { constructor(view) { this.view = view; } getValue() { return this.view.state.doc.toString().trim(); } setValid(isValid) { this.view.dom.style.borderColor = isValid ? '#ccc' : '#f00'; } } // 使用 const engine = new ValidationEngine(); // 注册 textarea const ta = document.getElementById('desc'); const taAdapter = new TextareaAdapter(ta); engine.registerAdapter('desc', taAdapter); // 注册 CodeMirror const cmAdapter = new CodeMirrorAdapter(viewA); engine.registerAdapter('code', cmAdapter); // 添加规则 engine.addRule('desc', (val) => val.length >= 10 || '至少10个字符'); engine.addRule('code', (val) => { try { new Function(val); // 简单语法校验 return true; } catch (e) { return 'JavaScript 语法错误:' + e.message; } }); // 绑定焦点事件(复用前面的 FocusManager) focusMgr.register('desc', { dom: ta }); focusMgr.register('code', viewA); // 在 _handleBlur 中触发验证 _focusBlur(id) { const isValid = engine.validate(id); const adapter = engine.adapters.get(id); if (adapter) adapter.setValid(isValid); if (!isValid) { const error = engine.errors.get(id); this._showError(id, error); } }这个模式的价值在于:当你下周要接入 Monaco Editor 或 Slate.js 时,只需写一个新的MonacoAdapter,验证引擎一行代码不用改。
5.3 真实世界扩展:配合 React/Vue 的响应式焦点管理
如果你在 React 项目中使用 CodeMirror,不要直接操作 DOM。用useRef+useEffect封装:
// React Hook 封装 function useCodeMirrorFocus(id: string) { const viewRef = useRef<EditorView | null>(null); const [isFocused, setIsFocused] = useState(false); useEffect(() => { if (!viewRef.current) return; const view = viewRef.current; const onFocus = () => setIsFocused(true); const onBlur = () => setIsFocused(false); view.dom.addEventListener('focus', onFocus, true); view.dom.addEventListener('blur', onBlur, true); return () => { view.dom.removeEventListener('focus', onFocus, true); view.dom.removeEventListener('blur', onBlur, true); }; }, []); return { viewRef, isFocused }; } // 组件内使用 function MyEditor() { const { viewRef, isFocused } = useCodeMirrorFocus('my-editor'); return ( <div className={`editor-wrapper ${isFocused ? 'focused' : ''}`}> <div ref={viewRef} /> {isFocused && <div className="hint">按 Ctrl+Enter 运行</div>} </div> ); }Vue 3 的 Composition API 同理,用onMounted/onUnmounted生命周期钩子。
6. 经验总结:我在三个项目中沉淀下来的 7 条铁律
最后,不讲虚的,只列我亲手验证过的 7 条硬核经验,每一条都来自凌晨三点的生产环境告警:
永远不要相信
document.activeElement在 CodeMirror 场景下的准确性—— 它可能返回<body>或<html>,正确姿势是view.hasFocus()。focus()调用后,必须用setTimeout(() => console.log(view.hasFocus()), 0)确认—— 浏览器焦点切换是异步的,同步检查永远为false。在移动端,
focus()必须包裹在touchend事件处理器里,且不能有preventDefault()—— iOS Safari 对非用户手势的focus()有严格限制。CodeMirror 的
updateListener是唯一可信的“编辑发生”信号,但不是“焦点变化”信号—— 两者语义完全不同,混用必出 bug。<textarea>的input事件和focus/blur事件没有必然先后顺序—— 用户可能先focus,然后长按粘贴,input事件在blur之后才批量触发。所有焦点相关逻辑,必须有 fallback 轮询(interval 100~200ms)—— 尤其在 Electron、WebView 或老旧 Android 浏览器中,事件丢失率高达 15%。
最可靠的“用户正在编辑”指标,不是焦点,而是光标位置变化—— 监听
update.transactions中selection的变更,比任何focus事件都精准。
这七条,没有一条来自文档,全部来自某次线上事故的复盘会议纪要。它们不是最佳实践,而是血泪教训的压缩包。
我最近在一个数据可视化平台项目里,用这套方法重构了整个脚本编辑模块。上线后,用户反馈“编辑器响应变快了”,其实我们没优化性能,只是让焦点状态和 UI 提示完全同步了——用户不再疑惑“我点了,为什么没反应”,这就是体验的本质。你不需要记住所有代码,只要在下次写onfocus时,心里默念一句:“等等,我操作的,到底是个<textarea>,还是个EditorView?” 这个问题的答案,就决定了你接下来十分钟是高效产出,还是陷入无尽的 console.log 调试。