在具体介绍前,得了解什么情况下会导致光标重置,其实很简单,当你通过 js 直接设置input的value时,光标就会重置,比如document.querySelector('input').value = '1234',就算要设置 value 和当前 value 值相同,同样会重置光标。
react 的 input 更新
先用同步更新的方式了解 react 怎么更新 input 元素的。用下面这段代码为例。
function App() {
const [text, setText] = useState("");
function updateText(e) {
const value = e.target.value;
setText(value);
}
return <input value={text} onChange={updateText} />;
}
这里不介绍 react 前面的流程了,直接到 commit 阶段查看,想了解前面流程的可以可以参考图解 react。
找到commitMutationEffectsOnFiber这个方法,这里是 commit 阶段更新 dom 的入口。
为了查看具体流程,我们需要打个断点调试。
function commitMutationEffectsOnFiber() {
// 省略
switch (finishedWork.tag) {
// 省略
case HostComponent: {
// 省略
if (flags & Update) {
// 这里打个断点查看
debugger;
// 省略
}
}
}
}
这里打断点是因为 input 在 react 中属于HostComponent,可以直接看这个 case,并且我们只关心 input 标签的更新,所以断点位置直接设置在if (flags & Update)这个条件下。
准备完成后我们在页面输入a,然后就走到了我们断点的位置。下面的代码都会进行简化,取到不需要更新的代码。
if (flags & Update) {
const instance = finishedWork.stateNode;
// {value: 'a', onChange: ƒ}
const newProps = finishedWork.memoizedProps;
// {value: '', onChange: ƒ}
const oldProps = current.memoizedProps;
commitUpdate(instance, updatePayload, type, oldProps, newProps, finishedWork);
}
这里获取Props,newProps 的 value 是a,oldProps 的 value 是'',和我们更新的状态一样,继续看commitUpdate这个方法。
function commitUpdate() {
// 更新 dom
updateProperties(domElement, updatePayload, type, oldProps, newProps);
// 更新 Fiber,后面会用到
updateFiberProps(domElement, newProps);
}
这里主要是更新 dom 和更新 Fiber,我们现在只需要看更新 dom 的方法
function updateProperties(domElement, updatePayload, tag, lastRawProps, nextRawProps) {
switch (tag) {
case "input":
ReactDOMInputUpdateWrapper(domElement, nextRawProps);
break;
case "textarea":
// ...
case "select":
// ...
}
}
可以看到 react 对于几个表单组件都是做了特殊处理的,继续往下看
// ReactDOMInputUpdateWrapper 就是这个方法,上面引入时设置了别名
function updateWrapper(element, props) {
// node 是 inupt 元素
const node = element;
// value === 'a' node.value === 'a'
const value = getToStringValue(props.value);
const type = props.type;
if (value != null) {
if (type === "number") {
// 省略
} else if (node.value !== toString(value)) {
node.value = toString(value);
}
}
}
此时我们node.value和value都是a,并不会走到任何条件下,也并没有设置 value,所以我们 input 标签的光标不会受到影响。
到这里,其实还是没法解释,因为就算异步更新,node.value是 input 的输入,value也是根据 input 输入回调设置的值,两者按道理应该是一样的。为什么会出现光标重置的问题呢?我们把代码改成异步更新的方式重新看下。
function App() {
const [text, setText] = useState("");
function updateText(e) {
const value = e.target.value;
setTimeout(() => setText(value));
}
return <input value={text} onChange={updateText} />;
}
同样输入a调试一下,调试一直走到updateWrapper这里,发现node.value为'',但value为a。node是页面的input元素,回去看下页面,果然input元素也是清空了的。所以这里会node.value = toString(value),会导致光标重置。
不过这里又有了新问题:为什么node.value是''。我们知道input的 value 是我们输入的值,如果没有 js 设置 value,这里应该是a才对,而这里成为了'',肯定是 react 在这之前做了什么操作,那又是什么时候进行操作的呢?
react 的数据同步
验证
我们在推测 react 可能在 commit 阶段外对 input 标签做了处理,先简单验证
function App() {
const [text, setText] = useState("");
function updateText() {}
return <input value={text} onChange={updateText} />;
}
这里我们隐藏了updateText里面的逻辑,结果发现无论怎么输入,input的值空的,说明 react 确实会对 input 做了处理。
dom 状态重置
这里可以在 updateText 方法打断点然后一点点看在哪里进行了 input 的修改。不过我在调试的时候发现 react 在trackValueOnNode方法内对input.value做了层代理,所以可以直接加上断点。

这里增加在 set 方法这里打断点,可以看到调用栈。

dispatchDiscreteEvent这里就是 react 的合成事件,说明 react 在 dom 事件触发时是会更新一次 input 的值。
调用栈这里的updateWrapper是我们上面介绍过的方法,所以我们只需要关心下props参数值哪里取的,往上一直找到restoreStateOfTarget
// target 是 input 元素
function restoreStateOfTarget(target) {
// dom 对应的 fiber
const internalInstance = getInstanceFromNode(target);
// stateNode 是 dom 元素,因为没有感谢,所以还是当前的 input 元素
const stateNode = internalInstance.stateNode;
if (stateNode) {
// 获取图下的__reactProps$n2pjsknr78s
const props = getFiberCurrentPropsFromNode(stateNode);
// 这个方法最后会调用到 updateWrapper
restoreImpl(internalInstance.stateNode, internalInstance.type, props);
}
}
这里props会赋值图上的__reactProps$n2pjsknr78s('reactProps'+随机数,后面称为__reactProps$)。而__reactProps$的更新是在updateFiberProps方法中,上面commitUpdate方法中可以看到,也就是说需要进入 commit 才会更新__reactProps$。
流程分析
总结下上面的流程
同步更新
- 输入 a,事件触发,调用
setText('a'),此时__reactProps$.value === '',input.value === 'a' - 进入 commit 阶段,因为
props.value === input.value,不会设置input.value,然后更新__reactProps$,此时__reactProps$.value === 'a',input.value === 'a' - 进入
restoreStateOfTarget, 因为__reactProps$.value === input.value,不会设置input.value
同步更新这里一直没有直接设置 input 的 value,所以光标不会重置。
异步更新
- 输入 a,事件触发,此时
__reactProps$.value === '',input.value === 'a' - 没有状态更新,跳过 commit 阶段,此时
__reactProps$.value === '',input.value === 'a' - 进入
restoreStateOfTarget,__reactProps$.value !== input.value,更新input.value为'', 此时__reactProps$.value === '',input.value === '' - setTimeout 回调执行,执行
setText('a'),此时input.value === '' - 进入 commit 阶段,此时
props.value === 'a',input.value === '',所以更新input.value为'a',同时更新__reactProps$ - 进入
restoreStateOfTarget, 值相同跳过设置
可以看到异步更新的时候,input.value会被设置两次,所以光标会被重置。
至于为什么增加一个 restore 阶段。react 在finishEventHandler注释里讲了原因,感兴趣可以了解下。

方案
了解原因后,我们知道必须进进入 commit 阶段更新__reactProps$,所以能实现的方案不多。一种方案是增加一个同步更新的方法。
let outText = "";
function App() {
const [, setText] = useState("");
const [, forceUpdate] = useState([]);
function updateText(e) {
const value = e.target.value;
outText = value;
forceUpdate([]);
setTimeout(() => setText(value));
}
return <input value={outText} onChange={updateText} />;
}
另一种是不设置 value 值,改设置 defaultValue。
function App() {
const [text, setText] = useState("");
function updateText(e) {
const value = e.target.value;
setTimeout(() => setText(value));
}
return <input defaultValue={text} onChange={updateText} />;
}