当前位置: 首页 > news >正文

React 受控输入框光标跳到末尾:格式化输入时的 selection 丢失 bug 与修复

React 受控输入框光标跳到末尾:格式化输入时的 selection 丢失 bug 与修复

做过手机号、银行卡号、金额千分位这类输入框的人多半遇到过:你在字符串中间改一个字,光标「啪」地跳到了最后。用户想在第 3 位后面插个数字,结果每敲一下光标就弹到末尾,体验极差。这不是浏览器的锅,是受控组件在格式化时把光标位置弄丢了。这篇讲清楚原因和几种修法。

先复现这个 bug

一个给数字加千分位逗号的输入框,朴素写法长这样:

function AmountInput() { const [value, setValue] = useState(""); const handleChange = (e) => { // 去掉旧逗号,重新按三位加逗号 const raw = e.target.value.replace(/,/g, ""); const formatted = raw.replace(/\B(?=(\d{3})+(?!\d))/g, ","); setValue(formatted); }; return <input value={value} onChange={handleChange} />; }

功能是对的:输入1234567会显示1,234,567。但只要你把光标放在中间编辑——比如在1,2342后面插入9——光标立刻跳到字符串末尾。在长输入里这几乎没法用。

为什么光标会跳

关键在于受控组件的渲染机制。当你在中间输入时,发生了这样一串事:

  1. 你在位置 3 敲了一个键,浏览器先把值更新、光标停在位置 4。
  2. onChange触发,你在里面setValue(formatted),而且formatted因为重新加逗号,长度可能和 DOM 里当前的值不一样
  3. React 重新渲染,把input.value整个替换成新的formatted字符串。
  4. 当 React 用 setter 重写input.value时,浏览器会把光标默认放到字符串末尾——因为它不知道你原本想停在哪。

所以根因是:React 重写了整个 value,但没有恢复光标位置(selectionStart/selectionEnd)。只要新旧字符串长度一致,你可能感觉不到;一旦格式化改变了长度(加/删逗号),光标就明显乱跳。

核心:受控 input 每次 setState 都会整体替换 value,而替换 value 会重置光标到末尾。想让光标待在原处,必须自己记录并恢复 selection。

修法一:记录并恢复光标位置

在改值前记下光标位置,渲染后再用useLayoutEffect把它设回去。用useLayoutEffect而不是useEffect,是为了在浏览器绘制前就恢复,避免用户看到光标闪一下:

import { useState, useRef, useLayoutEffect } from "react"; function AmountInput() { const [value, setValue] = useState(""); const inputRef = useRef(null); const cursorRef = useRef(null); // 存下要恢复的光标位置 const handleChange = (e) => { const el = e.target; const oldValue = el.value; const oldPos = el.selectionStart; // 格式化前的光标位置 const raw = oldValue.replace(/,/g, ""); const formatted = raw.replace(/\B(?=(\d{3})+(?!\d))/g, ","); // 关键:算出格式化后光标该在哪。 // 用「光标左边有几个数字」来定位,不受逗号增删影响 const digitsBeforeCursor = oldValue.slice(0, oldPos).replace(/,/g, "").length; let newPos = 0, seen = 0; for (; newPos < formatted.length; newPos++) { if (/\d/.test(formatted[newPos])) seen++; if (seen === digitsBeforeCursor) { newPos++; break; } } cursorRef.current = newPos; setValue(formatted); }; // 渲染后、绘制前恢复光标,避免闪烁 useLayoutEffect(() => { if (cursorRef.current != null && inputRef.current) { inputRef.current.setSelectionRange(cursorRef.current, cursorRef.current); cursorRef.current = null; } }); return <input ref={inputRef} value={value} onChange={handleChange} />; }

思路是用「光标左边有多少个数字」作为锚点,而不是原始字符下标。因为逗号会增删,字符下标不可靠;但「左边有几个数字」在格式化前后是稳定的。渲染后遍历新字符串,找到「第 N 个数字之后」的位置,把光标设回去。

修法二:非受控 + 只在失焦时格式化

如果不想处理光标逻辑,一个务实的取巧办法是:输入时不格式化,只在失焦(blur)时格式化一次。输入过程中光标行为完全交给浏览器,自然不会跳:

function AmountInput() { const [value, setValue] = useState(""); return ( <input value={value} onChange={(e) => setValue(e.target.value.replace(/,/g, ""))} // 输入时只存纯数字 onBlur={(e) => { // 离开输入框时才加逗号,此时光标跳末尾无所谓 const raw = e.target.value.replace(/,/g, ""); setValue(raw.replace(/\B(?=(\d{3})+(?!\d))/g, ",")); }} /> ); }

代价是输入过程中看不到实时的千分位。但对很多表单来说,这个体验完全可接受,而且代码简单、零光标 bug。能接受「失焦才格式化」时,优先用这个方案,别为了实时格式化背上一堆光标恢复逻辑。

修法三:直接用成熟库

金额、手机号、日期这类掩码输入是通用需求,已经有把光标问题处理干净的库,别自己造轮子。比如react-number-format:

import { NumericFormat } from "react-number-format"; function AmountInput() { const [value, setValue] = useState(""); return ( <NumericFormat value={value} thousandSeparator="," // 千分位 onValueChange={(v) => setValue(v.value)} // v.value 是纯数字,v.formattedValue 带逗号 /> ); }

这类库内部已经做了 selection 保存与恢复,还处理了粘贴、删除、前导零等一堆边界。生产项目里除非有特殊定制需求,直接上库最稳。

一个容易忽略的坑:type 得是 text

想给数字加逗号,<input>type必须是text,不能是type="number"。因为type="number"的输入框里塞非数字字符(逗号)会被浏览器判为非法值,selectionStart还会返回null,上面的光标逻辑直接崩。需要唤起数字键盘就用inputMode="numeric":

<input type="text" inputMode="numeric" value={value} onChange={handleChange} />

小结

  • 光标跳末尾的根因:受控组件 setState 会整体替换input.value,而替换 value 会让浏览器把光标默认重置到末尾。
  • 修法一(要实时格式化):改值前记selectionStart,用「光标左边的数字个数」当锚点算出新位置,useLayoutEffectsetSelectionRange恢复。
  • 修法二(可接受失焦格式化):输入时存纯值,onBlur才格式化,零光标逻辑,首选。
  • 修法三(生产项目):直接用react-number-format等成熟库,边界都替你处理好了。
  • 格式化输入框type一定用text+inputMode="numeric",别用type="number"

记忆点:受控 input 一旦在 onChange 里改变 value 长度,就必须自己保存并恢复光标——否则它永远跳到末尾。

http://www.jsqmd.com/news/1300459/

相关文章:

  • oracle,安装oracle时,最后提示: 监听程序未启动或数据库服务未注册到该监听程序,如何解决
  • CodeCombat终极指南:5步开启免费游戏化编程学习之旅
  • 2026装修行业怎么做GEO推广?把握AI搜索趋势 解锁精准获客新通路
  • 高端瓜子品牌优选 洽洽全链品质铸就高端口碑 - 盈达新发现
  • 《聪明的投资者》系列②:什么是安全边际?为什么它至今仍是价值投资的核心原则?
  • 易货商城小程序系统(现成案例)
  • 蒸馏战争——当最好用的编程工具变成最危险的污染源
  • 抖音小店未来运营趋势分析!自动化发货是否会全面取代传统人工手动操作 - 抖掌柜
  • Agent 能完成一个任务,但它能持续追一个三个月的目标吗?
  • 分子与蛋白互作伯远生物分子与蛋白互作
  • 从Java开发到大模型工程师:零基础转型实战指南
  • Python字符串查找:find()方法原理、应用与性能优化全解析
  • Bilibili-Old:终极翻页评论区修复与旧版体验完整指南 [特殊字符]
  • 回溯法解子集问题:从原理到Python工程实践
  • TDR时域反射检测定位内层走线阻抗类故障
  • CST远场后处理模板:提升天线仿真效率的关键工具
  • 突破BIOS限制:FanControl如何实现Windows电脑散热控制的终极自由
  • 亲身实测 TOP9 MBTI 测评平台,完整八维免费看,无广告捆绑套路 - 时讯资讯
  • KMS_VL_ALL_AIO:智能激活Windows与Office的终极解决方案
  • 2026黄金回收行情震荡,个人闲置黄金变现避坑指南 - 大鱼奢侈品
  • macOS与Windows截图自动加水印:快捷指令与Python脚本实战指南
  • Source Sans 3字体完整指南:如何为你的项目选择最佳开源UI字体
  • TCP协议核心机制与网络工程师实战指南
  • 2026年贵阳阳台漏水检测机构推荐榜单:精准定位免砸砖,防水堵漏维修口碑优选! - 优企名品
  • 激光测距模块M01——激光测距仪方案
  • 181、噪声与动态范围评价:SNR、DR与视觉噪声模型的工程应用
  • C#单文件发布:将DLL嵌入EXE的原理与实战方法
  • 宝妈轻创业优选!绍兴私房蛋糕裱花培训班全域招生 - 烘焙行业测评
  • 多层板批量开短路-搭建电气类故障检测体系覆盖
  • STM32高级定时器互补PWM配置与死区时间设置详解