banner
sora

sora

编程心得 & 人文感悟

为什么直接设置React输入框的value没有用

背景#

最近图省事写个油猴脚本做一些自动填写表单的任务
直接找到该节点,修改对应的 value 是不生效的,再次 focus 后原来的值又会设置回去,这是为什么呢

图文无关
我有个朋友汗流浃背了

过程#

考虑到 React 框架使用范围很广,input 组件又很基础
说不定这个问题已经被解决过了,所以可以先搜一遍

image

首先去翻了博客园和某臭名昭著的四字母盗库网站
基本没有靠谱的办法,发什么事件都不行
除了一位网页称 React 开发的网页调用底下的_valueTracker 属性就生效了

我调了,不管事啊

但这么个问题,总不至于去模拟键盘输入吧

虽然没解决问题,但好歹知道网页是用 React 框架做的了
除了这种碰巧的办法,还可以使用 wappalyzer 插件,不过是要收费才能解锁全部实际能力
但我觉得实际上手动翻 <head> 也不是不行

这点基础问题,感觉不至于解决不了吧
碰碰运气去翻 React 的源码,发现解决方法竟然就写在 input 组件的单元测试里
不得不说大型开源项目,注释比代码多,文件名清晰易懂,真是又规范又好看

// https://github.com/facebook/react/blob/main/packages/react-dom/src/events/plugins/__tests__/ChangeEventPlugin-test.js#L273
const setUntrackedValue = Object.getOwnPropertyDescriptor(
  HTMLInputElement.prototype,
  'value',
).set;

...

    // Set it programmatically.
    input.value = 'bar';
    // Even if a DOM input event fires, React sees that the real input value now
    // ('bar') is the same as the "current" one we already recorded.
    input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
    expect(input.value).toBe('bar');
    // In this case we don't expect to get a React event.
    expect(called).toBe(0);

    // However, we can simulate user typing by calling the underlying setter.
    setUntrackedValue.call(input, 'foo');
    // Now, when the event fires, the real input value ('foo') differs from the
    // "current" one we previously recorded ('bar').
    input.dispatchEvent(new Event('input', {bubbles: true, cancelable: true}));
    expect(input.value).toBe('foo');
    // In this case React should fire an event for it.
    expect(called).toBe(1);

原因#

那么,为什么我直接设置 value 属性不能生效呢?
我们知道,JavaScript 备受诟病不是没有原因的
我们现代 lisp 怎么你了
里面可以实现各种骚操作
各种变量混在一块,在 ES6 之前几乎没有作用域的限制
可以轻而易举地修改其他代码的属性,甚至换掉 undefined
著名的一个 evil 仓库就是利用这点来做供应链投毒各种搞事

React 封装 ReactDOMInput 组件时
替换掉了浏览器原生 input 组件的 value 对应的 descriptor

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/inputValueTracking.js#L79
  Object.defineProperty(node, valueField, {
    configurable: true,
    // $FlowFixMe[missing-this-annot]
    get: function () {
      return get.call(this);
    },
    // $FlowFixMe[missing-local-annot]
    // $FlowFixMe[missing-this-annot]
    set: function (value) {
      if (__DEV__) {
        checkFormFieldValueStringCoercion(value);
      }
      currentValue = '' + value;
      set.call(this, value);
    },
  });
  // We could've passed this the first time
  // but it triggers a bug in IE11 and Edge 14/15.
  // Calling defineProperty() again should be equivalent.
  // https://github.com/facebook/react/issues/11768
  Object.defineProperty(node, valueField, {
    enumerable: descriptor.enumerable,
  });

  const tracker = {
    getValue() {
      return currentValue;
    },
    setValue(value: string) {
      if (__DEV__) {
        checkFormFieldValueStringCoercion(value);
      }
      currentValue = '' + value;
    },
    stopTracking() {
      detachTracker(node);
      delete node[valueField];
    },
  };
  return tracker;

其中 currentValue 负责跟踪 input 组件值的变化,是 tracker getValue的返回值
如果直接设置value属性,会走到set方法里,同时设置currentValue和调用原生的set方法

因为 tracker 跟踪的currentValue和原生 input 组件里面的value值是一样的
这样,即使在触发 input 事件后,外部强行写入的值并不会更改 react 内部的值

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/inputValueTracking.js
export function updateValueIfChanged(node: ElementWithValueTracker): boolean {
  if (!node) {
    return false;
  }

  const tracker = getTracker(node);
  // if there is no tracker at this point it's unlikely
  // that trying again will succeed
  if (!tracker) {
    return true;
  }

  const lastValue = tracker.getValue(); // 注:currentValue
  const nextValue = getValueFromNode(node); // 注:input.value
  if (nextValue !== lastValue) {
    tracker.setValue(nextValue);
    return true;
  }
  return false;
}

在 input 重新获得焦点时,ReactDOMInputrestoreControlledInputState被调用,原生组件强行写入的值被 React 中原本记录的值覆盖掉

// https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactDOMInput.js#L340
export function restoreControlledInputState(element: Element, props: Object) {
  const rootNode: HTMLInputElement = (element: any);
  updateInput(
    rootNode,
    props.value,
    props.defaultValue,
    props.defaultValue,
    props.checked,
    props.defaultChecked,
    props.type,
    props.name,
  );

    ...

}

那么,为什么要加入一个currentValue的机制呢?难道是因为 React 开发者们不喜欢油猴脚本?

通过git blame 翻到packages\react-dom\src\events\plugins\__tests__\ChangeEventPlugin-test.js的最后一次提交,是一次目录结构整理,可以看到原本这里是有一段非常详细的注释,介绍了为什么要加入这个机制

// https://github.com/facebook/react/commit/a67757e11540936677b0a5d89d623b3e38b5fe69#diff-76a986d539bc7c94d63e46233a9537e03d8994f69ac4e4cd332631bc911a1c09R81

    // We try to avoid firing "duplicate" React change events.
    // However, to tell which events are duplicates and should be ignored,
    // we are tracking the "current" input value, and only respect events
    // that occur after it changes. In this test, we verify that we can
    // keep track of the "current" value even if it is set programatically.

    // Set it programmatically.
    input.value = 'bar';

在 2016 年,这个机制只针对设置了 onChange 属性的 Input 组件生效
从这段注释中,可以得出这样的结论:
加入这个机制一开始在当时是为了去重 change 事件,所以在input.value之外加了一个currentValue,避免多次触发 onChange 绑定的函数

感想#

开源对行业和个人成长的影响真的都是非常正面的
遇到问题可以自己去查去学习
而不是从抄来抄去的三手资料和写的模糊不清的闭源软件文档里收获花费再多时间也是拳头打棉花的无力感

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。