Skip to content

ChangeEventPlugin

ChangeEventPlugin 是 React 中处理 Change 事件的插件,其主要是处理 change 事件的相关逻辑。

虽然我们感觉 change 事件是一个非常简单的事件,但是因为原生事件中不同类型的情况下 change 事件的触发时机是不一样的,如其可以是 Input、Checkbox、Radio 、Textarea 等,所以 React 为了兼容这些不同的事件,将其单独抽离出来,单独处理。

ChangeEventPlugin 就是负责解决这些问题的,其不仅仅使用原生的 onChange 事件去处理事件,而是通过一系列事件的处理流程,去模拟原生的 onChange 事件的监听,从而实现了上述 DOM 元素的统一。

ChangeEventPlugin 事件的注册

js
function registerEvents() {
  registerTwoPhaseEvent("onChange", [
    "change",
    "click",
    "focusin",
    "focusout",
    "input",
    "keydown",
    "keyup",
    "selectionchange",
  ]);
}

从源码中可以看到, 为了模拟 onChange 事件,其监听了 "change", "click", "focusin", "focusout", "input", "keydown", "keyup", "selectionchange", 所以当我们在一个 DOM 元素上绑定了 onChange 事件时,其会增加了上述 8 个事件的监听。其中每一个事件的含义是什么?

  • change: 当元素的值发生改变时触发,如输入框的值发生改变时触发。
  • click: 当元素被点击时触发。
  • focusin: 当元素获得焦点时触发。
  • focusout: 当元素失去焦点时触发。
  • input: 当元素的值发生改变时触发,如输入框的值发生改变时触发。
  • keydown: 当键盘按键被按下时触发。
  • keyup: 当键盘按键被松开时触发。
  • selectionchange: 当元素的文本选择发生改变时触发。

那么其每一个事件的触发流程是什么?

  • 激活 Input 框 触发的事件: focusin => selectionchange => click
  • 输入内容触发的事件:keydown => input => selectionchange => keyup
  • 输入框失去焦点 触发事件: change -> blur -> selectionchange -> click

onChange 事件的处理流程

ChangeEventPlugin 插件提供了 extractEvents 方法,用于处理事件相关的逻辑。其通过 targetInst 事件触发目标对象,去将绑定 onChange 事件的 DOM 元素按照以下种类进行区分

1. SelectInput(type="file") 两类

特点: 这一类的元素其不需要用户输入内容,而是通过选择来改变其值, 所以其符合 Change 事件的规范

类型Textarea 或者 Input(type= 'number|date|text|...')

原生触发事件change

Target 对象

符合标准的 onChange 事件的元素节点,所以其 target 对象就是元素本身

js
function getTargetInstForChangeEvent(domEventName: DOMEventName, targetInst) {
  // 如果触发的事件为 change, 就返回 targetInst
  if (domEventName === "change") {
    return targetInst;
  }
}

2. 支持文本输入的元素

特点: 这一类的元素其需要用户输入内容,才能改变其值,所以其符合 Change 事件的规范,但是其本身存在一个问题,就是不同的输入法模式下,对于 Change 事件的触发时机不一致,所以 React 为了兼容这些不同的事件,将其单独抽离出来,单独处理。

类型input 且 type 为上述类型 或者 textarea

原生触发事件change|input 或者 selectionchange|keyup|keydown

Target 对象

对于这一类对象,其会根据浏览器的环境进行处理,其通过前置环境检测 isInputEventSupported 来判断当前浏览器是否支持 input 事件,从而进行不同的处理。

  • 支持 input 事件的时候

对于支持 input 事件的浏览器,其会在事件类型为 input 或者 change 的时候去判断 input.value 是否发生了变化,从而避免 onChange 回调函数重复调用, 只有 value 发生了变化,才会触发 change 事件(即返回当前 target 对象 ),否则就返回 undefined。

这里有一个比较重要的点,就是 React 是如何判断 value 是否发生了变化的呢?

其不是通过原生的 onchange input 等事件去判断,而是通过劫持的方式,在创建 Input 或者 Textarea 元素的时候通过 track 方法去劫持 value 属性的 setter 、 getter 方法,从而在合成事件中可以根据 node.value 和 tracker.getValue() 来判断是否有变化.

当 Input 输入的时候 先触发 event 事件,然后触发 updateValueIfChanged() 来判断是否有变化,如果有变化就会触发 tracker.setValue(nextValue) 来更新数据源

trackValueOnNode

js
function trackValueOnNode(node: any): ?ValueTracker {
  // 根据节点类型(是否是可勾选的,如 checkbox 或 radio)确定要跟踪的字段是 'checked' 还是 'value'。
  const valueField = isCheckable(node) ? "checked" : "value";
  // 获取节点原型上对应字段的属性描述符,这通常是原生的 getter/setter。
  const descriptor = Object.getOwnPropertyDescriptor(
    node.constructor.prototype,
    valueField
  );
  // 在开发模式下,检查当前字段的值是否可以被强制转换为字符串,以确保类型安全。
  if (__DEV__) {
    checkFormFieldValueStringCoercion(node[valueField]);
  }
  // 将当前字段的值转换为字符串,并存储在 currentValue 变量中。
  // 这边保存的是 旧值 , tracker.getValue() 获取的数据源
  let currentValue = "" + node[valueField];

  // 如果节点自身已经定义了该属性(即不是继承自原型),或者属性描述符不存在,
  // 或者描述符没有 getter/setter 函数,则不进行值跟踪,直接返回。
  // 这种情况可能是因为某些浏览器(如 Safari)的行为,或者值已经被外部定义,
  // 此时强制跟踪可能会导致过度报告变更或硬性失败。
  if (
    node.hasOwnProperty(valueField) ||
    typeof descriptor === "undefined" ||
    typeof descriptor.get !== "function" ||
    typeof descriptor.set !== "function"
  ) {
    return;
  }
  const { get, set } = descriptor;
  Object.defineProperty(node, valueField, {
    configurable: true,
    get: function () {
      return get.call(this);
    },
    set: function (value) {
      // 在开发模式下,检查新设置的值是否可以被强制转换为字符串。
      if (__DEV__) {
        checkFormFieldValueStringCoercion(value);
      }
      // 更新内部存储的当前值。
      currentValue = "" + value;
      // 调用原生的 setter 来设置实际的 DOM 属性值。
      set.call(this, value);
    },
  });
  // 再次定义节点上 `valueField` 属性的描述符,这次是为了设置其可枚举性。
  // 第一次定义时没有设置 enumerable 是为了避免 IE11 和 Edge 14/15 中的一个 bug。
  // 再次调用 defineProperty 应该是等效的,并且可以解决该问题。
  Object.defineProperty(node, valueField, {
    enumerable: descriptor.enumerable,
  });
  // 创建一个 ValueTracker 对象,包含获取值、设置值和停止跟踪的方法。
  const tracker = {
    getValue() {
      return currentValue;
    },
    setValue(value) {
      if (__DEV__) {
        checkFormFieldValueStringCoercion(value);
      }
      currentValue = "" + value;
    },
    stopTracking() {
      detachTracker(node);
      delete node[valueField];
    },
  };
  return tracker;
}

updateValueIfChanged

根据 node 节点上的 node._valueTracker 实例对象 和 节点本身的 node.value 或者 node.checked 比较判断是否有变化

  • 旧值: tracker.getValue() => tracker 中的 currentValue
  • 新值: getValueFromNode(node) => node.value 或者 node.checked

当值发生变化时,会触发 tracker.setValue(nextValue) 来更新 tracker 中的 currentValue

js
export function updateValueIfChanged(node: ElementWithValueTracker) {
  // 获取节点的跟踪器。
  const tracker = getTracker(node);
  // 获取当前节点的旧值  tracker 中的 currentValue
  const lastValue = tracker.getValue();
  // 获取node节点上的新值  node.value 或者 node.checked
  const nextValue = getValueFromNode(node);
  // 如果新值和旧值不相等,则更新跟踪器的当前值,并返回 true,表示值已更改。
  if (nextValue !== lastValue) {
    tracker.setValue(nextValue);
    return true;
  }
  return false;
}
  • 不支持 input 事件的时候

对于不支持 input 事件的浏览器,其无法在用户输入的时候就知道可能需要触发 change 事件,所以其通过 selectionchangekeydownkeyup 这 3 个事件来模拟 input 事件的触发, 当用户按下键盘的时候、松开键盘、选择修改的等情况通过上述 track 的方式来判断是否有变化,从而触发 change 事件。

这样就可以在不支持 input 事件的浏览器中,模拟出用户输入就触发 onChange 事件的效果,同时通过新旧值的比较,从而避免了 3 个事件重复触发的问题。

3. 通过点击触发 Change 事件的元素

特点: 这一类的元素其不需要用户输入内容,而是通过点击来改变其值,这是 Input(type)带来的其他标准元素的变种

类型Input(type= 'checkbox|radio')

原生触发事件click

Target 对象

在 click 事件触发的时候判断 value 的值是否发生了变化,从而决定是否触发 onChange 事件

js
function getTargetInstForClickEvent(domEventName: DOMEventName, targetInst) {
  if (domEventName === "click") {
    return getInstIfValueChanged(targetInst);
  }
}

4. 其他

特点: 其他节点

类型其他

原生触发事件change

重点

  1. 在前面说过对于 Change 事件的处理,其会注册 8 个事件的监听,那么当元素上绑定了 onChange 事件时,不就是会触发 8 次 事件的回调么?

答案是不是的,这就是 React 合成事件的巧妙之处,其虽然对于 8 种原生事件都进行了注册,且其回调函数都是 dispatchEvent, 但是其在 extractEvents的种会根据其 domEventName去判断当前触发的事件类型,只有对于符合条件,且符合当前事件类型的情况下才返回 targetInst, 这样就可以根据是否存在 targetInst 来判断当前事件是否需要触发回调。

总结

ChangeEventPlugin 插件是 React 中处理 Change 事件的插件,其主要解决原生的 onChange 事件在不同的情况下的触发时机不一致的问题,特别是在 Input 输入框中,其不是用户输入的时候就触发 change(input 的时候),而是输入完成失去焦点的时候才触发 change 事件(blur 的时候),所以 React 为了兼容这些不同的事件,将其单独抽离出来,单独处理。

其通过 4 种不同的事件类型来模拟 onChange 事件的触发,从而实现了上述 DOM 元素的统一。

如 Textarea 或者 Input(type="file") 这类元素,这一类元素的 change 事件就是用户修改后触发,符合规范,所以直接使用的 原生 change 事件进行监听

如 Input(type="number|date|text|...") 这类元素,这一类元素的 change 事件就是用户修改完成失去焦点后才触发,所以其通过 input | change 两个事件去模拟 onChange 事件的触发,从而实现了上述 DOM 元素的统一。但是在某些时候其不支持 input 事件,所以其通过 selectionchange|keyup|keydown 这 3 个事件来模拟 input 事件的触发。

在这一步中最重要的是通过劫持的方式,通过 tracker.getValue() 和 node.value 的判断去实现了虽然监听了 8 个事件,但是只有在 value 的值发生了变化的时候才会触发 onChange 事件。

如 Input(type="checkbox|radio") 这类元素,这一类元素的 change 事件就是用户点击的时候触发,符合规范,所以直接使用的 原生 click 事件进行监听。

参考

你真的了解 onChange 事件吗