5

Ant Design 4.0 的一些杂事儿 - checkbox 篇

 2 years ago
source link: https://zhuanlan.zhihu.com/p/431268270
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Ant Design 4.0 的一些杂事儿 - checkbox 篇

《豆酱》漫画作者

今天遇到了一个关于 Tree 中间添加 Checkbox 后表现异常的问题:

v2-074bb540f96e76c39f70a72192a5a3a4_720w.png

简单来说,就是 TreeNode 的点击事件设置了 preventDefault 。而在 TreeNode 里放置 Checkbox 时,点击只有第一次生效。重复点击不再生效,但是点击其他 Checkbox 又可以生效一次,然后又不生效:

v2-85e6e6777d4b9404967ef18504da2032_b.jpg

这个表现相当有趣。如果 preventDefault 不生效,那么点击应该总是触发 onChange 更新。反之,应该总是不触发更新。为何每个组件的第一次点击都能生效呢?在剥离成原生组件后,发现它是一个复杂的问题:

当然,本文不会冗长的介绍如何 debug 找到原因的过程。仅从结果,告诉大家这一有趣的现象背后的原因。

SyntheticEvent

我们知道,React 的事件是合成事件。它是浏览器的原生事件的跨浏览器包装器,从而开发者无需考虑兼容性问题。也因此,为了使得不同浏览器在事件触发上保持一致,会有一些黑科技在其中。比如这次的主角 checkbox。IE 8 中 checkbox 和 radio 的 onChange 事件不同于其他浏览器,仅会在 onBlur 时触发。所以在 React 的合成事件中,这两个组件是通过点击时对比前后 checked 状态来确认是否需要触发 onChange 事件。

(注:以下代码已被简化,可查阅 github 源码获取完成内容)

首先,它会检查是否是 checkbox 或 radio 来通过 click 模拟 change:

function shouldUseClickEvent(elem) {
  // Use the `click` event to detect changes to checkbox and radio inputs.
  // This approach works across all browsers, whereas `change` does not fire
  // until `blur` in IE8.
  const nodeName = elem.nodeName;
  return (
    nodeName &&
    nodeName.toLowerCase() === 'input' &&
    (elem.type === 'checkbox' || elem.type === 'radio')
  );
}

https://github.com/facebook/react/blob/2b77ab26adc2601dc223ede75ff4d19bc19c8684/packages/react-dom/src/events/plugins/ChangeEventPlugin.js#L222

接着,再访问 DOM 节点本身的 checked

function getValueFromNode(node: HTMLInputElement): string {
  if (isCheckable(node)) {
    return node.checked ? 'true' : 'false';
  }
  return node.value;
}

https://github.com/facebook/react/blob/2b77ab26adc2601dc223ede75ff4d19bc19c8684/packages/react-dom/src/client/inputValueTracking.js#L38

最后,对比一下缓存值和当前值是否相同,不同则说明需要触发 change 事件:

export function updateValueIfChanged(node: ElementWithValueTracker) {
  const lastValue = node._lastValue;
  const nextValue = getValueFromNode(node);

  if (nextValue !== lastValue) {
    node._lastValue = nextValue;
    return true;
  }
  return false;
}

嗯,似乎一切看起来都很正常。那么上面奇怪的行为出在哪儿呢?

dom spec

由上不难看出,React 通过检查 onClick 时,checkbox 的 checked 来作为依据。而这里就会引发一个问题,那就是如果 onClick 中调用了 e.preventDefault ,这个 checked 值是否应该被重置掉。经过实验,发现它是不会的:

<div id="holder">
  <input type="checkbox" id="input" />
</div>

holder.onClick = e => {
  e.preventDefault();
  console.log(input.checked); // true
}

input.onChange = console.log; // 不会触发

然而,有趣的是虽然点击时 checked 变了。但是由于 preventDefault 它并不会触发 onChange 事件。同时,我们异步检查 checked 属性。它又是 false 了。在查阅了相关 dom spec 后,找到了原因。

checkbox 元素内部存储 checkedness 以标识勾选状态,而在交互点击的时候会有一个 前置行为 和 取消行为:

https://html.spec.whatwg.org/multipage/input.html#the-input-element

简单来说,就是 前置行为 会将 checkedness 设置为相反的值(原本 true 就变 false,原本 false 就变 true)。而当 取消行为 存在时,则会将 checkedness 重置回 前置行为 之前的状态。

preventDefault 则是会设置 cancel flag 因而会触发 取消行为:

https://dom.spec.whatwg.org/#set-the-canceled-flag

结合上面的 spec,就可以得出这么一个流程:点击 -> 取反 -> 触发 click -> 调用 preventDefault -> 重置

而由于重置是一个后置行为,导致在 click 中无法获得其正确的 checked 值。

由于上述两者相结合,于是就出现了第一次点击时由于 React 在 dom 上的缓存和 checked 不一致,于是触发合成事件并且将 checked 状态存入缓存。而再次点击时,由于缓存和当前 checked 值相同,便不再触发。而当点击另一个 Checkbox 时,父节重新 render 导致前一个 Checkbox 的 checked 值被真的同步成了 true,有变得可以点击切换了。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK