前端光标控制实战:selectionStart、Range 与输入法兼容
1. 光标控制的本质先搞清楚你在操作哪套 API做前端这些年被问得最多的一类问题不是框架选型也不是打包优化而是 html 页面中文本框的光标控制到底该怎么做。场景都很具体输入手机号的时候每打三个数字自动加个空格结果光标一下跳到最前面点一下工具栏的表情按钮光标从输入框里消失了插进去的文字跑到末尾写富文本编辑器点个加粗按钮选区没了加粗了个寂寞。这些问题看似零散背后其实都指向同一件事——你没能准确地读到一个位置的坐标。浏览器对这个坐标给的东西很朴素就是一个数字光标在文本里排第几。听起来简单坑却不少因为这个“第几”在不同元素、不同浏览器、不同输入法状态下含义和可靠性都不一样。我一直觉得光标控制之所以让很多人觉得玄学是因为它牵扯到三套彼此不兼容的机制。第一套是 input 和 textarea 上的选择属性也就是那一组 selectionStart 系列简单直接但只在特定 type 上有效。第二套是 contenteditable 元素上的 Selection 和 Range功能强大但状态飘忽选区可能随时被浏览器收走。第三套是输入法IME的组合状态中文、日文、韩文输入过程中浏览器会进入一个临时的文本组合区这时候去读光标位置经常拿到的是旧值。把这三套机制分清绝大部分光标问题就不再是玄学了。这篇文章我按自己的实战梳理方式来写从最基础的属性读写讲起中间穿插格式化输入、光标处插入、输入法兼容这些天天要碰的场景最后讲 contenteditable 的 Range 操作、选区保存恢复以及我用镜像元素算光标像素坐标的那套方案。文中涉及到的参数取值、调用顺序、浏览器差异都是我自己在项目里反复验过的你可以直接拿去用。适合谁看呢写表单页的、写聊天输入框的、写富文本编辑器的、写代码编辑器的都能找到对应的段落。哪怕你只是想让某个输入框打开时自动聚焦并把光标放到末尾第一章就够用了。1.1 一个判断用哪套 API 的决策表很多人卡住不是因为不会写代码而是一开始就选错了工具。比如对input typenumber调 setSelectionRange控制台直接扔一个 InvalidStateError 出来再比如在 contenteditable 上找 selectionStart永远是 undefined。我整理了一张决策表遇到具体需求先对一下表能省掉大量试错时间。元素类型可用的光标 API能否设置选区典型用途inputtext/search/url/tel/passwordselectionStart、selectionEnd、setSelectionRange、setRangeText可以单行表单、手机号、验证码inputnumber/date/email 等无有效选择属性基本不行会报错或静默失败不要在这些类型上做光标逻辑textarea同 text 类 input全部可用可以多行评论、备注、消息输入contenteditable 容器window.getSelection、Range可以但状态易丢富文本、提及、代码编辑器这里有一条特别值得强调的实践如果你的业务需要做光标控制的格式化输入就不要用typenumber。这个类型在浏览器里被当作“数值控件”而不是“文本控件”规范层面就没有给它选择属性。想要数字键盘又想要光标控制正确做法是typetext加inputmodenumeric加pattern[0-9]*移动端一样能弹出数字键盘同时 selectionStart 全部可用。这个组合我用了好几年稳定得没什么可挑的。1.2 为什么“先 focus 再设选区”这个顺序不能反刚接触这块的人经常写出这样的代码先 setSelectionRange再 focus()。桌面 Chrome 上跑起来好像也没问题于是一直这么写直到上线后 iOS 用户反馈“点进来光标没在末尾”。原因是移动端的焦点和选区是强绑定的元素没有获得焦点的时候选区设置会被浏览器丢弃或者延后重置。iOS Safari 尤其明显它在元素获得焦点后的几毫秒内还会做一次自己的选区初始化把你刚设的位置覆盖掉。稳定的写法是先 focus再设选区而且设选区这一步最好放到下一帧。function focusToEnd(el) { el.focus({ preventScroll: true }); const len el.value.length; requestAnimationFrame(() { el.setSelectionRange(len, len); }); }preventScroll: true这个参数是顺手加上的如果你的输入框在页面底部聚焦时浏览器会把它滚动到视口中间页面会突然一跳体验很差。加上这个参数就不会自动滚动了滚动交给自己的逻辑控制比如 scrollIntoView 配合平滑滚动可控性更高。至于为什么用 requestAnimationFrame 而不是直接调用是因为 iOS 上 focus 引发的选区重置发生在当前任务之后同步代码跑完才轮到你放到下一帧就稳了。有些同学用 setTimeout 0 也有效但 rAF 更贴合渲染时机实测下来在低端安卓机上表现也更一致。2. 基础核心三个属性和一个方法吃透入门这块内容其实就四个东西selectionStart、selectionEnd、selectionDirection、setSelectionRange。听起来少但每个都有细节尤其是参数的含义和边界情况一旦理解错了后面所有场景都会跟着错。2.1 selectionStart 与 selectionEnd 的真实含义先说个容易误解的点这两个值不是“光标位置”而是“选区起点和终点的偏移量”。当没有选中任何文本时两者的值相等这时候那个相等的值才是我们平常说的光标位置。当用户拖选了一段文字selectionStart 是选区起点selectionEnd 是选区终点。单位是 UTF-16 码元不是字符数。这句话平时没感觉一旦你的文本里有 emoji 就会出问题。比如字符串ab它的 length 是 4 而不是 3因为 占两个码元。如果按字符数去截取再按码元去设光标位置就会偏。处理用户可见字符的时候稳妥做法是用Array.from(str)把字符串拆成码点数组做逻辑判断用码点数组最后换算回码元偏移再去设光标。读取顺序也有讲究。有些老浏览器主要是非常老的版本在读取 selectionStart 时会触发一次选区状态的同步如果你在一次事件里连续读多次理论上可能有性能开销。现代浏览器早就没这问题了正常读就行不用为了“省一次读取”把代码写得很难看。selectionDirection 是这三兄弟里存在感最低的但它在某些场景下有用。它表示选区的方向取值是 forward、backward、none。用户从左往右拖选是 forward从右往左拖选是 backward。什么时候需要这个做“扩展选区到下一个词”这类快捷键的时候你得知道用户当前是朝哪个方向选的才能决定扩哪边。注意这个属性在没有选区的时候行为不太一致有的浏览器返回 none有的返回 forward不要依赖它的默认值做判断。2.2 setSelectionRange 的第三个参数别忽略方法签名是setSelectionRange(start, end, direction)。前两个参数几乎所有教程都会讲第三个参数基本没人提。它的取值和 selectionDirection 一样用来显式指定选区方向。默认不传的时候浏览器按自己的规则处理通常是正向。方向不指定会有个可见后果选中一段文本之后如果用户按 Shift 加方向键扩展行为可能和你预期相反。做需要精确定位选区的场景比如“选中整个手机号让用户直接覆盖输入”建议显式传 forward行为更可预测。关于边界值规范里的处理是自动钳制。你传负数会被当作 0传超过长度的会被当作长度start 大于 end 会自动交换。这个宽容处理是好事但别依赖它。我见过因为 start 和 end 传反了导致选区错乱的案例排查了半天最后发现是自己在计算偏移时把两个变量搞混了。还有个高频坑对不支持的 type 调用它。我实测过typenumber和typedate现代 Chrome 上 setSelectionRange 不报错但也没效果部分版本会直接抛 InvalidStateError。所以判断逻辑应该是先看元素类型不要靠 try-catch 兜底那是掩盖问题而不是解决问题。2.3 setRangeText被严重低估的一个方法如果只能推荐一个光标相关的 API我会推 setRangeText。它的签名是setRangeText(replacement, start, end, selectionMode)作用是替换指定范围内的文本同时由浏览器帮你处理光标位置不需要你自己算偏移。selectionMode 有四个取值理解清楚这四个值能省掉一堆手写计算select表示替换后选中新插入的文本start表示光标落到插入内容之前end表示光标落到插入内容之后preserve是默认值尽量保持原有的选区相对关系。最典型的用法是在光标处插入一段文本function insertText(el, text) { el.focus(); const start el.selectionStart; const end el.selectionEnd; el.setRangeText(text, start, end, end); }这段代码的逻辑是把当前选区替换成新文本然后把光标放到插入内容之后。用户选中了一段文字再点表情选中的部分被替换掉光标跟在表情后面这正好符合直觉。如果用户没选中那 start 等于 end效果就是在光标处插入。我更推荐这个写法而不是手动拼字符串再赋值原因有两个。一是它不用你手动算新光标位置少一个出错点二是它对移动端的软键盘状态更友好直接改 value 有时候会触发键盘收起再弹出的抖动。还有一个很多人不知道的能力程序化赋值 value 不会清空撤销栈这件事其实是有条件的。直接el.value xxx会清掉撤销历史用户按 CtrlZ 就回不去了。但 setRangeText 和后面要讲的 execCommand(insertText) 都能保留撤销栈。做富交互输入框的时候这一点直接决定用户会不会骂你。3. 高频实战格式化、插入与输入法前三章讲的是零件这一章开始组装。这三个场景基本覆盖了日常开发里八成的光标需求我按自己项目里的真实代码来写每个方案都附带我当时为什么这么选的理由。3.1 格式化输入后光标乱跳的通用修复法银行卡每四位加一个空格、手机号三位四位分段、金额千分位这类需求的核心难点从来不是正则怎么写而是格式化之后光标往哪放。新手最常见的写法是拿到 value去掉所有非数字重新格式化赋回去然后什么都不管。结果是用户没输入一个字符光标就跳到末尾想改中间某一位只能重新开始输。正确思路是记录“光标前面有多少个有效字符”格式化完成后再让光标落回第 N 个有效字符之后。所谓有效字符就是参与格式化的那些字符比如数字。这样不管中间插了多少分隔符光标相对内容的位置都是稳定的。function reformatWithCaret(el, formatter) { const raw el.value; const caret el.selectionStart; // 1. 统计光标之前有多少个有效字符这里以数字为例 const validBefore raw.slice(0, caret).replace(/\D/g, ).length; // 2. 生成格式化结果 const formatted formatter(raw); // 3. 赋回去 el.value formatted; // 4. 从格式化结果里找第 validBefore 个有效字符之后的偏移 let pos 0; let count 0; while (pos formatted.length count validBefore) { if (/\d/.test(formatted[pos])) count; pos; } el.setSelectionRange(pos, pos); } function phoneFormatter(value) { const digits value.replace(/\D/g, ).slice(0, 11); const parts [digits.slice(0, 3), digits.slice(3, 7), digits.slice(7, 11)]; return parts.filter(Boolean).join( ); }这段代码可以直接用把它绑到 input 事件上就行。我特意把 formatter 拆成独立函数因为同一个输入框可能在不同场景下用不同格式比如输入框失焦时格式化成带空格的样子聚焦编辑时又切回纯数字拆开之后切换逻辑只是换个函数而已。有两个细节值得单独说。第一统计有效字符时只算“光标之前”的部分不要算全部否则光标位置永远在末尾。第二计算新位置时用 while 从头扫看起来不优雅但它对任何格式规则都成立不像有的实现需要知道分隔符具体在第几位才插的。扫描次数和字符串长度同阶输入框这点长度完全不用担心性能。再补一个真实踩过的坑在 input 事件里直接 setSelectionRange在安卓的部分输入法下光标会闪烁一下。原因是被赋值和设选区在同一帧内完成浏览器渲染时出现了中间态。解决办法是先把位置算好赋值和设选区之间不要有别的 DOM 读取操作实测下来闪烁会消失。如果还是闪那就把 setSelectionRange 放到微任务里用 queueMicrotask 包一层代价是可能和你自己的其他逻辑顺序冲突别乱用。3.2 在光标处插入内容三种方式与撤销栈的取舍聊天输入框的表情面板、评论区的 提及、模板工具里的变量插入都属于“在光标处插入一段内容”。这个需求我有三种实现方式各有取舍我按推荐程度从高到低说。第一种是 setRangeText前面已经写过。优点是简单、保留撤销栈、自动处理光标位置缺点是它只适用于 input 和 textarea不支持 contenteditable。第二种是 execCommand(insertText, false, text)。这个 API 已经从标准里划掉了但主流浏览器到今天仍然完整支持而且它有 setRangeText 没有的能力它可以在 contenteditable 元素里插入文本并且保留撤销栈用户按 CtrlZ 能撤销掉。做富文本输入框的时候如果只是插入纯文本这个是最省事的方案。function insertByCommand(text) { const ok document.execCommand(insertText, false, text); if (!ok) { // 浏览器不支持时的降级逻辑 insertByRange(text); } }降级逻辑一定要写因为 execCommand 的返回值在个别环境下是 true 但实际没插入所以判断条件不能只看返回值还要校验插入后内容是否变化。我的习惯是先记录插入前的文本调用后对比一次没变化就走 Range 方案。第三种就是手动拼字符串。前面两种都不适用的时候才用比如某些自绘的编辑器。写的时候注意两点一是新位置要算对二是赋值之后要考虑框架的响应式。如果你在 Vue 或 React 里直接改 input.value框架内部的 state 是感知不到的可能下一次渲染就把你插的内容冲掉了。React 里有个绕不开的坑受控组件的 value 是被 React 接管过的直接赋值不生效。这时候要拿到原生 setter 手动调用再派发一个 input 事件让 React 收到通知。function setNativeValue(el, value) { const proto el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter Object.getOwnPropertyDescriptor(proto, value).set; setter.call(el, value); el.dispatchEvent(new Event(input, { bubbles: true })); }这段代码在 React 项目里救过我不少次建议收藏。派发的事件一定要带 bubbles否则挂在父级的监听器收不到。至于 dispatch 一次还是多次通常一次就够多次会让 onChange 被重复调用如果你的处理函数里有副作用就会出问题。3.3 输入法组合期间光标数据是不可信的中文输入法是个分水岭。用户敲拼音的时候输入框里会先出现一串拼音字母浏览器进入组合状态composition这时候 input 事件会连发但选区属性在部分浏览器里是不更新的你读到的是组合开始之前的旧位置。如果你在这个阶段做格式化会看到拼音被拆开、光标乱飞用户完全没法打字。标准做法是用标志位把组合期间的事件挡掉只在组合结束时处理一次。let composing false; input.addEventListener(compositionstart, () { composing true; }); input.addEventListener(compositionend, (e) { composing false; handleFormat(e.target); // 组合结束正常走格式化 }); input.addEventListener(input, (e) { if (composing) return; // 组合期间一律跳过 handleFormat(e.target); });这段逻辑看着简单但有个细节容易漏compositionend 触发之后浏览器还会再发一次 input 事件。如果你在 compositionend 里处理了input 里又处理一次格式化就会跑两遍。两遍的后果通常不严重但如果你有节流或者异步逻辑就可能出现状态错位。我的处理方式是在 compositionend 里先记录一个时间戳input 事件如果发生在 50ms 内就直接跳过实测覆盖了主流浏览器。更干净的做法是用一个 consumed 标志位处理完置 true下一次 input 时检查并复位比时间戳可靠。还有个小分支不是所有输入法都发 compositionend某些第三方输入法在特殊情况下只发 input。所以标志位不能只靠 compositionstart 置位还要加个兜底如果在组合状态下超过一定时间没有 compositionend就主动复位标志位。这个超时值我用的是 1000ms暂时没遇到误判。4. 进阶contenteditable 与 Selection/Range到这里都是能“算得清”的部分从这一章开始进入状态管理的领域。contenteditable 里没有 selectionStart一切都通过 window.getSelection() 拿到的 Selection 对象和它包含的 Range 来操作。麻烦的地方在于这个 Selection 是全局的、随时会变的浏览器在失焦、点击别处、重渲染的时候都可能把它清空。4.1 Range 的三个关键动作一个 Range 有两个端点起点和终点各自由“容器节点 偏移量”组成。偏移量的含义取决于容器类型如果容器是文本节点偏移量是字符位置如果是元素节点偏移量是子节点的序号。这一点很多人没意识到导致设置位置时算错。日常用到的动作其实就三个collapse、setStart/setEnd、deleteContents。collapse 是把选区折叠成一个点也就是变成光标。参数传 true 折叠到起点传 false 折叠到终点。在光标处插入内容时通常先range.deleteContents()把选中的内容删掉再 insertNode最后 collapse 到插入节点之后。function insertNodeAtCaret(node) { const sel window.getSelection(); if (!sel.rangeCount) return; const range sel.getRangeAt(0); range.deleteContents(); range.insertNode(node); // 关键一步把光标移到插入节点之后 range.setStartAfter(node); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); }最后三行是很多人的盲区。insertNode 之后Range 的端点可能还指着旧位置如果不重新设置用户的下一次输入会跑到插入节点的前面或者里面去。setStartAfter 之后必须 removeAllRanges 再 addRange只调 addRange 不调 removeAllRanges在某些浏览器里会叠加出多个 Range表现为选区重复或者高亮异常。还有个坑是插入元素节点时的光标落点。假如你插入的是一个span contenteditablefalse的原子节点比如 某人 的标签光标落到它后面之后用户按 Backspace 会整块删掉体验是对的。但如果你的 span 没加 contenteditablefalse光标可能钻到 span 内部用户继续打字就写在标签里了数据全乱。做标签类插入务必给标签元素加上这个属性。4.2 选区保存与恢复让工具栏点击不丢光标富文本编辑器最典型的交互是用户选中一段文字然后去点工具栏的加粗按钮。问题在于点击按钮会让 contenteditable 失焦Selection 被清空等你执行加粗命令的时候已经不知道要加粗哪段了。标准解法是在失焦前把 Range 存下来执行完命令再恢复。存的时候要注意不能直接存 Range 对象因为 DOM 可能已经变了Range 的引用会失效。稳妥做法是存端点信息也就是容器节点和偏移量恢复时重新构造 Range。let savedRange null; function saveSelection() { const sel window.getSelection(); if (sel.rangeCount 0) { savedRange sel.getRangeAt(0).cloneRange(); } } function restoreSelection() { if (!savedRange) return; const sel window.getSelection(); sel.removeAllRanges(); sel.addRange(savedRange); } // 工具栏按钮用 mousedown 而不是 click boldBtn.addEventListener(mousedown, (e) { e.preventDefault(); // 阻止按钮抢焦点 saveSelection(); document.execCommand(bold); restoreSelection(); });这里有个非常关键的经验用 mousedown 而不是 click并且调用 preventDefault。原因是 mousedown 在失焦之前触发而 click 在失焦之后才触发顺序差了这一步选区就已经丢了。更进一步的优化是把工具栏容器的 mousedown 整体 preventDefault 掉这样按钮根本不会拿到焦点contenteditable 保持聚焦状态连保存恢复都省了。我在自己的编辑器里就是这么干的代码量少一半稳定性还更好。cloneRange 那一步也别省。有的场景下 Selection 里的 Range 是活的后续操作会改变它clone 一份相当于快照安全得多。4.3 镜像元素没有 Range 也要算出光标在哪有个需求在聊天输入框里很常见用户输入 的时候在光标正下方弹出一个候选人列表。textarea 没有提供任何获取光标像素坐标的接口怎么办我用的方案叫镜像元素。思路是造一个和 textarea 样式完全一样的隐藏 div把光标之前的文本塞进去末尾放一个 span 作为标记然后测量这个 span 的位置。因为样式一致文本换行位置也一致所以 span 的坐标就是光标的坐标。const MIRROR_PROPS [ boxSizing, fontFamily, fontSize, fontWeight, fontStyle, letterSpacing, lineHeight, textAlign, textTransform, textIndent, whiteSpace, wordSpacing, wordBreak, tabSize, paddingTop, paddingRight, paddingBottom, paddingLeft ]; function getCaretPosition(el, pos) { const style getComputedStyle(el); const mirror document.createElement(div); mirror.style.position absolute; mirror.style.top 0; mirror.style.left -9999px; mirror.style.visibility hidden; mirror.style.whiteSpace pre-wrap; mirror.style.wordWrap break-word; MIRROR_PROPS.forEach((k) { mirror.style[k] style[k]; }); // 宽度取 clientWidth避开滚动条带来的偏差 mirror.style.width el.clientWidth px; mirror.textContent el.value.slice(0, pos); const marker document.createElement(span); marker.textContent el.value.slice(pos) || .; mirror.appendChild(marker); document.body.appendChild(mirror); const result { left: marker.offsetLeft - el.scrollLeft, top: marker.offsetTop - el.scrollTop, height: parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2 }; document.body.removeChild(mirror); return result; }这段代码有几个地方是试错试出来的。宽度为什么要用 clientWidth 而不是复制 style.width因为 textarea 常见的width: 100%复制过去之后镜像 div 的 100% 是相对 body 算的宽度完全不对换行位置自然也就错了。padding 还要单独再设一次因为上面循环复制的是 padding 的四个分项外加一个 boxSizing组合起来才和原元素一致。marker 里面塞el.value.slice(pos) || .这一段是为了处理光标在末尾的情况此时 slice 出来是空串span 会塌陷成零宽度offsetLeft 测不准。放一个点号占位位置就正确了。这个点号不会被显示出来因为整个 mirror 都是 visibility hidden。再补两个实用点。滚动条要减掉滚动之后 textarea 内部的文本视觉位置会变光标的相对坐标要跟着减去 scrollLeft 和 scrollTop否则内容长了之后浮层会飘到框外。还有就是为了性能这个方法不要每次 input 都调用用 requestAnimationFrame 节流一次或者干脆只在检测到输入了 或者特定触发字符时才调。5. 兼容性、报错与排查实录前面讲的是怎么做这一节讲做不出来的怎么办。移动端的差异和一堆看起来莫名其妙的报错基本都在这一节里。5.1 iOS 与安卓的光标行为差异iOS Safari 在这块是最特立独行的。除了前面说的 focus 后选区会被重置还有两个行为值得记一下。第一个是聚焦时 iOS 会自动全选不具体行为取决于版本和元素类型。我实测过的现象是某些版本下对空输入框调用 focus 而不设选区iOS 会把光标放在开头安卓放在末尾。所以只要你对光标位置有要求就不要依赖默认行为永远显式调用 setSelectionRange。养成这个习惯之后跨端问题少了一大半。第二个是软键盘的弹出时机。iOS 上必须在用户手势的同步调用栈里调用 focus 才能弹出键盘哪怕是 setTimeout 里延迟一毫秒也会被拦。这一点对“打开页面自动聚焦搜索框”的需求影响很大iOS 根本不支持自动弹键盘只能聚焦但不能弹这是系统层面的限制做需求的时候要提前跟产品说清楚别到验收时才发现。安卓这边的差异主要集中在滚动。输入框在页面中部时聚焦后系统可能会把输入框顶到键盘上方配合preventScroll: true能控制一部分行为但不同厂商的浏览器内核表现不一致。我的稳妥做法是外层容器在聚焦时加个 class手动调整内边距把输入框顶起来同时禁用浏览器自己的滚动行为。这套方案写起来多一点代码但跨端一致性是最好的。另外提醒一句移动端不要用typenumber。除了前面说的选择属性不可用之外数字类型的输入框在部分安卓输入法里还有额外的加减按钮和自动格式化行为会让你的控件样式和你设计的完全不一样。5.2 常见问题速查表这张表是我这些年攒下来的基本都是实际被问过或者自己踩过的遇到问题先对表。现象大概率原因处理方式设置光标没反应元素未聚焦就开始设置先 focus再把设置放到 rAF 里控制台报 InvalidStateError对 number/date 类型调 setSelectionRange改成 typetext 加 inputmodenumeric输入一个字光标跳到开头格式化后没重算偏移用有效字符计数法重新定位点完按钮选区丢失click 触发时机晚于失焦改用 mousedown 加 preventDefault中文输入时乱码错位组合期间做了格式化用 composing 标志位屏蔽组合结束再处理空格光标位置不对字符串含 emoji码元与码点不一致用 Array.from 按码点处理再换算码元隐藏输入框设不了光标display:none 的元素无法聚焦先显示再聚焦再设选区Tab 切换后位置不变浏览器缓存了上次的选区主动 setSelectionRange 覆盖表里最后一条我要多解释一句。有些浏览器会记住元素上次的选区位置用户 Tab 回来之后光标还在原处这其实是好行为。但如果你自己做了“聚焦时定位到末尾”的逻辑就必须在 focus 之后立刻覆盖否则会和浏览器记住的位置打架表现时好时坏。5.3 我踩过的几个坑写在这里省你时间第一个坑setRangeText 和 maxlength 的关系。程序化赋值一般不受 maxlength 限制但 setRangeText 在超过长度时的行为各家实现不一致有的截断有的直接不管。涉及长度上限的场景我建议自己先判断一次再调用别把长度逻辑交给浏览器。第二个坑在 input 事件里改 value会再次触发 input 事件吗不会程序化赋值不触发事件这是好事你可以放心在 input 回调里改 value 而不会死循环。但如果你用的是 React 加原生 setter 那套再派发事件就会再次触发 onChange这时候要在处理函数里加个判断避免递归。第三个坑给输入框加了实时字数统计并截断用户粘贴超长文本时光标会跑到末尾。这个体验其实是对的但如果你还想在他粘贴完之后继续编辑中间部分就需要在截断时重新定位光标。判断逻辑和格式化那套是一样的先算用户原本的选区相对位置截断后再把位置按比例或者按有效字符数映射回去。第四个坑用document.activeElement判断当前聚焦元素在 iframe 或者 Shadow DOM 里会拿到不准确的宿主元素。Shadow DOM 里要用shadowRoot.activeElement取到的是内部真正的那个元素。做 Web Components 的时候不注意这点光标逻辑会莫名其妙失效。最后一个经验光标相关的问题排查效率最高的方式不是看代码而是在 console 里实时打印。绑定一个 selectionchange 监听把当前元素的 selectionStart、selectionEnd 和 value 打出来用户操作一路看下去绝大多数问题都能自己暴露出来。document.addEventListener(selectionchange, () { const el document.activeElement; if (el /INPUT|TEXTAREA/.test(el.tagName)) { console.log( JSON.stringify({ value: el.value, start: el.selectionStart, end: el.selectionEnd }) ); } });这个小工具我在调格式化输入的时候用得最多比打断点快得多因为光标问题是“流程性”的你得看连续的状态变化而不是某一个断点瞬间。把它挂上去操作一遍问题基本就自己跳出来了。另一个我常用的兜底手段是写一个 assert 函数在上线前的测试环境里校验光标位置是否符合预期比如格式化完必须满足selectionStart value.length一旦不满足就在控制台报警并上报。听起来有点小题大做但在光标这类容易被边缘情况击穿的逻辑里这层保护还真抓到过几次真问题尤其是在某些小众输入法环境下。