React的onClick和addEventListener有什么区别

2020年4月30日 ... ☕️ 5 min read

在React里添加事件处理一般直接添加属性

<button onClick={clickHandler}>
  button
</button>

然后把需要处理的内容放到处理函数里即可。

那么它和addEventListener添加的事件监听有什么区别呢?

我们添加可能的几个类型:函数局部变量、useRef存储、useState、custom hooks保存,通过两种方法的输出值查看一下二者区别。

两个例子

通过onClick添加事件处理

function App() {
  let counter = 0; // 局部变量
  const countRef = React.useRef(0); // ref
  let [count, setCount] = useState(0); // state变量
  const [ct, setCt] = useCount(0); // 自定义hooks
  const btnRef = React.useRef(null);

const clickHandler = () => {
  counter++;
  countRef.current++;
  setCount(() => count + 1);
  setCt(() => ct1 + 1);
  console.log(`click: counter ${counter}, countRef ${countRef.current}, count ${count}, ct ${ct}`);

  return (<div onClick={clickHandler}>button</div>);}
// custom hook
function useCount(init) {
  const [count, setCount] = React.useState(init);
  return [count, setCount];
};

输出:

click: counter 1, countRef 1, count 0, ct 0
click: counter 1, countRef 2, count 1, ct 1
click: counter 1, countRef 3, count 2, ct 2
click: counter 1, countRef 4, count 3, ct 3
click: counter 1, countRef 5, count 4, ct 4
click: counter 1, countRef 6, count 5, ct 5

直接给元素addEventListener添加事件处理

function App() {
  let counter = 0; // 局部变量
  const countRef = React.useRef(0); // ref
  let [count, setCount] = useState(0); // state变量
  const [ct, setCt] = useCount(0); // 自定义hooks
  const btnRef = React.useRef(null);

  const clickHandler = () => {
      counter++;
      countRef.current++;
      setCount(() => count + 1);
      setCt(() => ct1 + 1);
      console.log(`click: counter ${counter}, countRef ${countRef.current}, count ${count}, ct ${ct}`);
  };

  useEffect(() => {
    btnRef && btnRef.current.addEventListener('click', clickHandler);  }, []);
  return (<div ref={btnRef}>button</div>);
}

// custom hook
function useCount(init) {
  const [count, setCount] = React.useState(init);
  return [count, setCount];
};

输出:

click: counter 1, countRef 1, count 0, ct 0
click: counter 2, countRef 2, count 0, ct 0
click: counter 3, countRef 3, count 0, ct 0
click: counter 4, countRef 4, count 0, ct 0
click: counter 5, countRef 5, count 0, ct 0
click: counter 6, countRef 6, count 0, ct 0

两个例子有什么区别

首先,明确一下,函数内直接定义局部变量的方法严重不推荐,原因是状态无法跟踪,不可控;自定义hooks和useState意思类似,这里也不多讨论。

所以,关注的点主要是:useRef、useState在两种方法中的处理。

首先,可以看出来二者的调用栈不同

onclick 作用域和调用栈对比(左侧为onClick, 右侧为addEventListener)

第一次执行时,二者的作用域内容是一致的,由于是函数式组件,this为undefined,外层闭包为函数内容,再外侧是jsx,最外层是React环境。

所以不同点就是二者的执行机制。

之前说过React事件的注册和触发,我们知道事件的监听器其实全部是加在document上的,然后通过回调函数来统一处理。

先看onClick事件的触发

查找对应的fiber node还是在dispatchEvent

-dispatchDiscreteEvent
-dispatchEvent
-getClosestInstanceFromNode

每个DOM节点都保存了__reactInternalInstance和fiber node建立映射关系。通过这个函数可以反查到fiber node,从而可以获取到当前节点的状态。

之后继续执行的流程就和之前基本一致了。虚拟的dom节点(fakeNode),然后触发事件(fakeNode.dispatchEvent)。

由于调用了setCount,最后会update组件,所以再次执行函数组件(renderRoot)。定义的局部变量会初始化,而useRef、useState、custom hook的值由于在React环境内,状态得以保留。

第二次调用时,作用域内容已经不一样了。

onclick-twice-variables onclick第二次以后调用时作用域的值

可以想象到,为什么在调用到clickHandler之前,有那么长的调用栈,除了事件一层一层传递,更重要的是保证事件函数执行时,上下文环境是正确的。

如何保证呢?肯定要把函数脱离原始的作用域,放到React里维护,以同步更新作用域内的值。

可以看到,React为每个事件函数,都绑定了单独的作用域。以特殊标记__reactEventHandlers$XXX存放在fiber node的stateNode属性里。

stateNode-listener listener函数存储位置

每次事件执行时,都会同时更新该作用域内的各个值,以保证状态一致。

addEventListener

调用栈可以看到,clickHandler直接调用。

第一次执行,当调用到setCount时,会走dispatchAction,没问题,这是setState注册时绑定好的

-dispatchAction(fiber, queue, action)

其中fiber是App节点,是各个hooks存在的地方。

queue是记录上次更新的内容

// queue的结构
{
  dispatch:function () {}
  last:null
  lastRenderedReducer:function basicStateReducer(state, action) {}
  lastRenderedState:0
}

action是setCount方法的调用内容(参数),可以是值或者一个函数。

好了,明白上面三个内容,就开始执行了。

由于上次是0,这次执行之后是1,所以会触发一次组件更新。

第二次,顺序执行到setCount,dispatchAction时,取到上次是1,到这里都是意料之中的。

接着到执行action,可以看到此时的作用域内各个值

listener-twice-variables 第二次以后执行监听事件时作用域的值

就是说,fiber还是一直在更新,但是到这里的时候,setState还是按照闭包的初始值来计算的。

结果就是,state判断一致,直接跳过,而打印出来的,是闭包里的值。

有趣的是,如果放在render出来的内容,那么显示的结果是

click: counter 0, countRef 0, count 0, ct 0
click: counter 0, countRef 1, count 1, ct 1
click: counter 0, countRef 1, count 1, ct 1
click: counter 0, countRef 1, count 1, ct 1
click: counter 0, countRef 1, count 1, ct 1

为什么呢?因为setCount导致的更新只有“0到1”这一次,所以后面再触发,其实DOM元素根本没更新。

结论

在99%的情况下,请使用属性中的onClick(或同类事件),而不要自己通过addEventListener注册监听函数。特殊情况必须要使用的时候,也请尽量避免监听函数内的state更新操作。

如果非要更新,比如:使用了第三方的库,需要单独添加的监听函数,有没有解决办法呢?

有,需要额外的状态控制,使用uesReducer+useContext,或者redux。

#react

SideEffect is a blog for front-end web development.
Code by Axiu / rss