Hooks与事件绑定

React中,我们经常需要为组件添加事件处理函数,例如处理表单提交、处理点击事件等。通常情况下,我们需要在类组件中使用this关键字来绑定事件处理函数的上下文,以便在函数中使用组件的实例属性和方法。React HooksReact 16.8引入的一个新特性,其出现让React的函数组件也能够拥有状态和生命周期方法。Hooks的优势在于可以让我们在不编写类组件的情况下,复用状态逻辑和副作用代码,Hooks的一个常见用途是处理事件绑定。

描述

React中使用类组件时,我们可能会被大量的this所困扰,例如this.propsthis.state以及调用类中的函数等。此外,在定义事件处理函数时,通常需要使用bind方法来绑定函数的上下文,以确保在函数中可以正确地访问组件实例的属性和方法,虽然我们可以使用箭头函数来减少bind,但是还是使用this语法还是没跑了。

那么在使用Hooks的时候,可以避免使用类组件中的this关键字,因为Hooks是以函数的形式来组织组件逻辑的,我们通常只需要定义一个普通函数组件,并在函数组件中使用useStateuseEffectHooks来管理组件状态和副作用,在处理事件绑定的时候,我们也只需要将定义的事件处理函数传入JSX就好了,也不需要this也不需要bind

那么问题来了,这个问题真的这么简单吗,我们经常会听到类似于Hooks的心智负担很重的问题,从我们当前要讨论的事件绑定的角度上,那么心智负担就主要表现在useEffectuseCallback以及依赖数组上。其实类比来看,类组件类似于引入了thisbind的心智负担,而Hooks解决了类组件的心智负担,又引入了新的心智负担,但是其实换个角度来看,所谓的心智负担也只是需要接受的新知识而已,我们需要了解React推出新的设计,新的组件模型,当我们掌握了之后那就不会再被称为心智负担了,而应该叫做语法,当然其实叫做负担也不是没有道理的,因为很容易在不小心的情况下出现隐患。那么接下来我们就来讨论下Hooks与事件绑定的相关问题,所有示例代码都在https://codesandbox.io/s/react-ts-template-forked-z8o7sv

事件绑定

使用Hooks进行普通的合成事件绑定是一件很轻松的事情,在这个例子中,我们使用了普通的合成事件onClick来监听按钮的点击事件,并在点击时调用了add函数来更新count状态变量的值,这样每次点击按钮时,count就会加1

// https://codesandbox.io/s/hooks-event-z8o7sv
import { useState } from "react";

export const CounterNormal: React.FC = () => {
  const [count, setCount] = useState(0);
  const add = () => {
    setCount(count + 1);
  };
  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
      </div>
    </div>
  );
};

这个例子看起来非常简单,我们就不再过多解释了,其实从另一个角度想一下,这不是很类似于原生的DOM0事件流模型,每个对象只能绑定一个DOM事件的话,就不需要像DOM2事件流模型一样还得保持原来的处理函数引用才能进行卸载操作,否则是卸载不了的,如果不能保持引用的地址是相同的,那就会造成无限的绑定,进而造成内存泄漏,如果是DOM0的话,我们只需要覆盖即可,而不需要去保持之前的函数引用。实际上我们接下来要说的一些心智负担,就与引用地址息息相关。

另外有一点我们需要明确一下,当我们点击了这个count按钮,React帮我们做了什么。其实对于当前这个<CounterNormal />组件而言,当我们点击了按钮,那么肯定就是需要刷新视图,React的策略是会重新执行这个函数,由此来获得返回的JSX,然后就是常说的diff等流程,最后才会去渲染,只不过我们目前关注的重点就是这个函数组件的重新执行。Hooks实际上无非就是个函数,React通过内置的use为函数赋予了特殊的意义,使得其能够访问Fiber从而做到数据与节点相互绑定,那么既然是一个函数,并且在setState的时候还会重新执行,那么在重新执行的时候,点击按钮之前的add函数地址与点击按钮之后的add函数地址是不同的,因为这个函数实际上是被重新定义了一遍,只不过名字相同而已,从而其生成的静态作用域是不同的,那么这样便可能会造成所谓的闭包陷阱,接下来我们就来继续探讨相关的问题。

原生事件绑定

虽然React为我们提供了合成事件,但是在实际开发中因为各种各样的原因我们无法避免的会用到原生的事件绑定,例如ReactDOMPortal传送门,其是遵循合成事件的事件流而不是DOM的事件流,比如将这个组件直接挂在document.body下,那么事件可能并不符合看起来DOM结构应该遵循的事件流,这可能不符合我们的预期,此时可能就需要进行原生的事件绑定了。此外,很多库可能都会有类似addEventListener的事件绑定,那么同样的此时也需要在合适的时机去添加和解除事件的绑定。由此,我们来看下边这个原生事件绑定的例子:

// https://codesandbox.io/s/react-ts-template-forked-z8o7sv?file=/src/counter-native.tsx
import { useEffect, useRef, useState } from "react";

export const CounterNative: React.FC = () => {
  const ref1 = useRef<HTMLButtonElement>(null);
  const ref2 = useRef<HTMLButtonElement>(null);
  const [count, setCount] = useState(0);

  const add = () => {
    setCount(count + 1);
  };

  useEffect(() => {
    const el = ref1.current;
    const handler = () => console.log(count);
    el?.addEventListener("click", handler);
    return () => {
      el?.removeEventListener("click", handler);
    };
  }, []);

  useEffect(() => {
    const el = ref2.current;
    const handler = () => console.log(count);
    el?.addEventListener("click", handler);
    return () => {
      el?.removeEventListener("click", handler);
    };
  }, [count]);

  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
        <button ref={ref1}>log count 1</button>
        <button ref={ref2}>log count 2</button>
      </div>
    </div>
  );
};

在这个例子中,我们分别对ref1ref2两个button进行了原生事件绑定,其中ref1的事件绑定是在组件挂载的时候进行的,而ref2的事件绑定是在count发生变化的时候进行的,看起来代码上只有依赖数组[][count]的区别,但实际的效果上差别就很大了。在上边在线的CodeSandbox中我们首先点击三次count++这个按钮,然后分别点击log count 1按钮和log count 2按钮,那么输出会是如下的内容:

0 // log count 1
3 // log count 2

此时我们可以看出,页面上的count值明明是3,但是我们点击log count 1按钮的时候,输出的值却是0,只有点击log count 2按钮的时候,输出的值才是3,那么点击log count 1的输出肯定是不符合我们的预期的。那么为什么会出现这个情况呢,其实这就是所谓的React Hooks闭包陷阱了,其实我们上边也说了为什么会发生这个问题,我们再重新看一下,Hooks实际上无非就是个函数,React通过内置的use为函数赋予了特殊的意义,使得其能够访问Fiber从而做到数据与节点相互绑定,那么既然是一个函数,并且在setState的时候还会重新执行,那么在重新执行的时候,点击按钮之前的add函数地址与点击按钮之后的add函数地址是不同的,因为这个函数实际上是被重新定义了一遍,只不过名字相同而已,从而其生成的静态作用域是不同的,那么在新的函数执行时,假设我们不去更新新的函数,也就是不更新函数作用域的话,那么就会保持上次的count引用,就会导致打印了第一次绑定的数据。

那么同样的,useEffect也是一个函数,我们那么我们定义的事件绑定那个函数也其实就是useEffect的参数而已,在state发生改变的时候,这个函数虽然也被重新定义,但是由于我们的第二个参数即依赖数组的关系,其数组内的值在两次render之后是相同的,所以useEffect就不会去触发这个副作用的执行。那么实际上在log count 1中,因为依赖数组是空的[],两次render或者说两次执行依次比较数组内的值没有发生变化,那么便不会触发副作用函数的执行;那么在log count 2中,因为依赖的数组是[count],在两次render之后依次比较其值发现是发生了变化的,那么就会执行上次副作用函数的返回值,在这里就是清理副作用的函数removeEventListener,然后再执行传进来的新的副作用函数addEventListener。另外实际上也就是因为React需要返回一个清理副作用的函数,所以第一个函数不能直接用async装饰,否则执行副作用之后返回的就是一个Promise对象而不是直接可执行的副作用清理函数了。

useCallback

在上边的场景中,我们通过为useEffect添加依赖数组的方式似乎解决了这个问题,但是设想一个场景,如果一个函数需要被多个地方引入,也就是说类似于我们上一个示例中的handler函数,如果我们需要在多个位置引用这个函数,那么我们就不能像上一个例子一样直接定义在useEffect的第一个参数中。那么如果定义在外部,这个函数每次re-render就会被重新定义,那么就会导致useEffect的依赖数组发生变化,进而就会导致副作用函数的重新执行,显然这样也是不符合我们的预期的。此时就需要将这个函数的地址保持为唯一的,那么就需要useCallback这个Hook了,当使用React中的useCallback Hook时,其将返回一个memoized记忆化的回调函数,这个回调函数只有在其依赖项发生变化时才会重新创建,否则就会被缓存以便在后续的渲染中复用。通过这种方式可以帮助我们在React组件中优化性能,因为其可以防止不必要的重渲染,当将这个memoized回调函数传递给子组件时,就可以避免在每次渲染时重新创它,这样可以提高性能并减少内存的使用。由此,我们来看下边这个使用useCallback进行事件绑定的例子:

// https://codesandbox.io/s/react-ts-template-forked-z8o7sv?file=/src/counter-callback.tsx
import { useCallback, useEffect, useRef, useState } from "react";

export const CounterCallback: React.FC = () => {
  const ref1 = useRef<HTMLButtonElement>(null);
  const ref2 = useRef<HTMLButtonElement>(null);
  const [count, setCount] = useState(0);

  const add = () => {
    setCount(count + 1);
  };

  const logCount1 = () => console.log(count);

  useEffect(() => {
    const el = ref1.current;
    el?.addEventListener("click", logCount1);
    return () => {
      el?.removeEventListener("click", logCount1);
    };
  }, []);

  const logCount2 = useCallback(() => {
    console.log(count);
  }, [count]);

  useEffect(() => {
    const el = ref2.current;
    el?.addEventListener("click", logCount2);
    return () => {
      el?.removeEventListener("click", logCount2);
    };
  }, [logCount2]);

  return (
    <div>
      {count}
      <div>
        <button onClick={add}>count++</button>
        <button ref={ref1}>log count 1</button>
        <button ref={ref2}>log count 2</button>
      </div>
    </div>
  );
};

在这个例子中我们的logCount1没有useCallback包裹,每次re-render都会重新定义,此时useEffect也没有定义数组,所以在re-render时并没有再去执行新的事件绑定。那么对于logCount2而言,我们使用了useCallback包裹,那么每次re-render时,由于依赖数组是[count]的存在,因为count发生了变化useCallback返回的函数的地址也改变了,在这里如果有很多的状态的话,其他的状态改变了,count不变的话,那么这里的logCount2便不会改变,当然在这里我们只有count这一个状态,所以在re-render时,useEffect的依赖数组发生了变化,所以会重新执行事件绑定。在上边在线的CodeSandbox中我们首先点击三次count++这个按钮,然后分别点击log count 1按钮和log count 2按钮,那么输出会是如下的内容:

0 // log count 1
3 // log count 2

那么实际上我们可以看出来,在这里如果的log count 1与原生事件绑定例子中的log count 1一样,都因为没有及时更新而保持了上一次render的静态作用域,导致了输出0,而由于log count 2及时更新了作用域,所以正确输出了3,实际上这个例子并不全,我们可以很明显的发现实际上应该有其他种情况的,我们同样先点击count++三次,然后再分情况看输出:

  • logCount函数不用useCallback包装。
    • useEffect依赖数组为[]: 输出0
    • useEffect依赖数组为[count]: 输出3
    • useEffect依赖数组为[logCount]: 输出3
  • logCount函数使用useCallback包装,依赖为[]
    • useEffect依赖数组为[]: 输出0
    • useEffect依赖数组为[count]: 输出0
    • useEffect依赖数组为[logCount]: 输出0
  • logCount函数使用useCallback包装,依赖为[count]
    • useEffect依赖数组为[]: 输出0
    • useEffect依赖数组为[count]: 输出3
    • useEffect依赖数组为[logCount]: 输出3

虽然看起来情况这么多,但是实际上如果接入了react-hooks/exhaustive-deps规则的话,发现其实际上是会建议我们使用3.3这个方法来处理依赖的,这也是最标准的解决方案,其他的方案要不就是存在不必要的函数重定义,要不就是存在应该重定义但是依然存在旧的函数作用域引用的情况,其实由此看来React的心智负担确实是有些重的,而且useCallback能够完全解决问题吗,实际上并没有,我们可以接着往下聊聊useCallback的缺陷。

useMemoizedFn

同样的,我们继续来看一个例子,这个例子可能相对比较复杂,因为会有一个比较长的依赖传递,然后导致看起来比较麻烦。另外实际上这个例子也不能说useCallback是有问题的,只能说是会有相当重的心智负担。

const getTextInfo = useCallback(() => { // 获取一段数据
  return [text.length, dep.length];
}, [text, dep]);

const post = useCallback(() => { // 发送数据
  const [textLen, depLen] = getTextInfo();
  postEvent({ textLen, depLen });
}, [getTextInfo, postEvent]);

useEffect(() => {
  post();
}, [dep, post]);

在这个例子中,我们希望达到的目标是仅当dep发生改变的时候,触发post函数,从而将数据进行发送,在这里我们完全按照了react-hooks/exhaustive-deps的规则去定义了函数。那么看起来似乎并没有什么问题,但是当我们实际去应用的时候,会发现当text这个状态发生变化的时候,同样会触发这个post函数的执行,这是个并不明显的问题,如果text这个状态改变的频率很低的话,甚至在回归的过程中都可能无法发现这个问题。此外,可以看到这个依赖的链路已经很长了,如果函数在复杂一些,那复杂性越来越高,整个状态就会变的特别难以维护。

那么如何解决这个问题呢,一个可行的办法是我们可以将函数定义在useRef上,那么这样的话我们就可以一直拿到最新的函数定义了,实际效果与直接定义一个函数调用无异,只不过不会受到react-hooks/exhaustive-deps规则的困扰了。那么实际上我们并没有减缓复杂性,只是将复杂性转移到了useRef上,这样的话我们就需要去维护这个useRef的值,这样的话就会带来一些额外的心智负担。

const post = useRef(() => void 0);

post.current = () => {
  postEvent({ textLen, depLen });
}

useEffect(() => {
  post.current();
}, [dep]);

那么既然我们可以依靠useRef来解决这个问题,我们是不是可以将其封装为一个自定义的Hooks呢,然后因为实际上我们并没有办法阻止函数的创建,那么我们就使用两个ref,第一个ref保证永远是同一个引用,也就是说返回的函数永远指向同一个函数地址,第二个ref用来保存当前传入的函数,这样发生re-render的时候每次创建新的函数我们都将其更新,也就是说我们即将调用的永远都是最新的那个函数。这样通过两个ref我们就可以保证两点,第一点是无论发生多少次re-render,我们返回的都是同一个函数地址,第二点是无论发生了多少次re-render,我们即将调用的函数都是最新的。由此,我们就来看下ahooks是如何实现的useMemoizedFn

type noop = (this: any, ...args: any[]) => any;

type PickFunction<T extends noop> = (
  this: ThisParameterType<T>,
  ...args: Parameters<T>
) => ReturnType<T>;

function useMemoizedFn<T extends noop>(fn: T) {
  const fnRef = useRef<T>(fn);

  // why not write `fnRef.current = fn`?
  // https://github.com/alibaba/hooks/issues/728
  fnRef.current = useMemo(() => fn, [fn]);

  const memoizedFn = useRef<PickFunction<T>>();
  if (!memoizedFn.current) {
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }

  return memoizedFn.current as T;
}

那么使用的时候就很简单了,可以看到我们使用useMemoizedFn时是不需要依赖数组的,并且虽然我们在useEffect中定义了post函数的依赖,但是由于我们上边保证了第一点,那么这个在这个组件被完全卸载之前,这个依赖的函数地址是不会变的,由此我们就可以保证只可能由于dep发生的改变才会触发useEffect,而且我们保证的第二点,可以让我们在re-render之后拿到的都是最新的函数作用域,也就是textLendepLen是能够保证是最新的 ,不会存在拿到了旧的函数作用域里边值的问题。

const post = useMemoizedFn(() => {
  postEvent({ textLen, depLen });
});

useEffect(() => {
  post.current();
}, [dep, post]);

每日一题

https://github.com/WindrunnerMax/EveryDay

参考

https://juejin.cn/post/7194368992025247804 https://juejin.cn/post/7098137024204374030 https://react.dev/reference/react/useCallback