光标重置的原因

在具体介绍前,得了解什么情况下会导致光标重置,其实很简单,当你通过 js 直接设置inputvalue时,光标就会重置,比如document.querySelector('input').value = '1234',就算要设置 value 和当前 value 值相同,同样会重置光标。

react 的 input 更新

先用同步更新的方式了解 react 怎么更新 input 元素的。用下面这段代码为例。

jsx
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 的入口。

为了查看具体流程,我们需要打个断点调试。

js
function commitMutationEffectsOnFiber() {
  // 省略
  switch (finishedWork.tag) {
    // 省略
    case HostComponent: {
      // 省略
      if (flags & Update) {
        // 这里打个断点查看
        debugger;
        // 省略
      }
    }
  }
}

这里打断点是因为 input 在 react 中属于HostComponent,可以直接看这个 case,并且我们只关心 input 标签的更新,所以断点位置直接设置在if (flags & Update)这个条件下。

准备完成后我们在页面输入a,然后就走到了我们断点的位置。下面的代码都会进行简化,取到不需要更新的代码。

js
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这个方法。

js
function commitUpdate() {
  // 更新 dom
  updateProperties(domElement, updatePayload, type, oldProps, newProps);
  // 更新 Fiber,后面会用到
  updateFiberProps(domElement, newProps);
}

这里主要是更新 dom 和更新 Fiber,我们现在只需要看更新 dom 的方法

js
function updateProperties(domElement, updatePayload, tag, lastRawProps, nextRawProps) {
  switch (tag) {
    case "input":
      ReactDOMInputUpdateWrapper(domElement, nextRawProps);
      break;
    case "textarea":
    // ...
    case "select":
    // ...
  }
}

可以看到 react 对于几个表单组件都是做了特殊处理的,继续往下看

js
// 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.valuevalue都是a,并不会走到任何条件下,也并没有设置 value,所以我们 input 标签的光标不会受到影响。

到这里,其实还是没法解释,因为就算异步更新,node.value是 input 的输入,value也是根据 input 输入回调设置的值,两者按道理应该是一样的。为什么会出现光标重置的问题呢?我们把代码改成异步更新的方式重新看下。

jsx
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'',但valueanode是页面的input元素,回去看下页面,果然input元素也是清空了的。所以这里会node.value = toString(value),会导致光标重置。

不过这里又有了新问题:为什么node.value''。我们知道input的 value 是我们输入的值,如果没有 js 设置 value,这里应该是a才对,而这里成为了'',肯定是 react 在这之前做了什么操作,那又是什么时候进行操作的呢?

react 的数据同步

验证

我们在推测 react 可能在 commit 阶段外对 input 标签做了处理,先简单验证

jsx
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做了层代理,所以可以直接加上断点。

image

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

image

dispatchDiscreteEvent这里就是 react 的合成事件,说明 react 在 dom 事件触发时是会更新一次 input 的值。

调用栈这里的updateWrapper是我们上面介绍过的方法,所以我们只需要关心下props参数值哪里取的,往上一直找到restoreStateOfTarget

js
// 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);
  }
}

image 这里props会赋值图上的__reactProps$n2pjsknr78s('reactProps'+随机数,后面称为__reactProps$)。而__reactProps$的更新是在updateFiberProps方法中,上面commitUpdate方法中可以看到,也就是说需要进入 commit 才会更新__reactProps$

流程分析

总结下上面的流程

同步更新

  1. 输入 a,事件触发,调用setText('a'),此时__reactProps$.value === ''input.value === 'a'
  2. 进入 commit 阶段,因为props.value === input.value,不会设置input.value,然后更新__reactProps$,此时__reactProps$.value === 'a'input.value === 'a'
  3. 进入restoreStateOfTarget, 因为__reactProps$.value === input.value,不会设置input.value

同步更新这里一直没有直接设置 input 的 value,所以光标不会重置。

异步更新

  1. 输入 a,事件触发,此时__reactProps$.value === ''input.value === 'a'
  2. 没有状态更新,跳过 commit 阶段,此时__reactProps$.value === ''input.value === 'a'
  3. 进入restoreStateOfTarget__reactProps$.value !== input.value,更新input.value'', 此时__reactProps$.value === ''input.value === ''
  4. setTimeout 回调执行,执行setText('a'),此时input.value === ''
  5. 进入 commit 阶段,此时props.value === 'a'input.value === '',所以更新input.value'a',同时更新__reactProps$
  6. 进入restoreStateOfTarget, 值相同跳过设置

可以看到异步更新的时候,input.value会被设置两次,所以光标会被重置。

至于为什么增加一个 restore 阶段。react 在finishEventHandler注释里讲了原因,感兴趣可以了解下。

image

方案

了解原因后,我们知道必须进进入 commit 阶段更新__reactProps$,所以能实现的方案不多。一种方案是增加一个同步更新的方法。

jsx
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。

jsx
function App() {
  const [text, setText] = useState("");
  function updateText(e) {
    const value = e.target.value;
    setTimeout(() => setText(value));
  }
  return <input defaultValue={text} onChange={updateText} />;
}