浅谈 React Hook 中的一个坑,并且聊聊 useRef

1,998 阅读5分钟

正常情况下,使用 React 提供的事件处理能解决大部分需求,但难免会有使用自定义事件订阅的情况。

现在有如下需求:页面中有一个按钮,每次点击按钮之后,count 会加1,按下键盘的【Enter】按键之后,会在控制台打印当前的 count

我们使用 class 组件和函数组件可以写出如下两种代码:

// class component
const event = "keydown";
class App extends Component {
  state = {
    count: 0,
  };
  handlekKeydown = (e) => {
    if (e.code === "Enter") {
      console.log("current count: ", this.state.count);
    }
  };
  componentDidMount() {
    window.addEventListener(event, this.handlekKeydown);
  }

  componentWillUnmount() {
    window.removeEventListener(event, this.handlekKeydown);
  }

  render() {
    return (
      <div>
        <button onClick={() => this.setState({ count: this.state.count + 1 })}>
          Increase
        </button>
      </div>
    );
  }
}

// function component
const event = "keydown";
function App() {
  const [count, setCount] = useState(0);
  const handlekKeydown = (e) => {
    if (e.code === "Enter") {
      console.log("current count: ", count);
    }
  };
  useEffect(() => {
    window.addEventListener(event, handlekKeydown);
    return () => {
      window.removeEventListener(event, handlekKeydown);
    };
  }, []);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

class 组件能够按照预期执行,点击 n 次按钮之后按下【Enter】按键,打印出的 count 会是 n。

但是函数组件不管点击多少次按钮,控制台打印的结果都是0,执行的结果与 class 组件不符,是什么原因导致这两种写法差异如此巨大?

image.png

很多武侠小说中,主角想要练绝世武功之前得先忘掉之前所学的东西,使用 Hook 也是如此。

useEffect 虽然看起来是将componentDidMountcomponentWillUnmount结合了起来,但它们并不完全相等,我在《浅谈 React 常用的 Hooks》这篇文章提到过:

只要在 JavaScript/TypeScirpt 中执行或调用函数,会创建一个新的执行上下文并将其放在执行堆栈中。这样就创建了执行上下文,就像全局上下文一样。它将拥有自己的变量和函数空间。它将经历创建阶段,然后它将逐行执行函数中的代码。函数执行完毕之后,会弹出执行栈,如果发现没有变量再指向函数空间,也会在堆中销毁该函数空间。

函数组件就是通过反复执行一定的逻辑渲染出 JSX 的一个函数。如果useEffect里面的依赖为[],在其回调函数中捕获的是初始的函数上下文,也就是说 props 和 state 永远都是初始值。在任意一次渲染中,props 和 state 是始终保持不变的。 如果 props 和 state 在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数)。组件内的每一个函数(包括事件处理函数,effects,定时器或者API调用等等)会捕获定义它们的那次渲染中的 props 和 state。

此时我们改一下代码:

const event = "keydown";
function App() {
  const [count, setCount] = useState(0);
  const handlekKeydown = useCallback(
    (e: any) => {
      if (e.code === "Enter") {
        console.log("current count: ", count);
      }
    },
    [count]
  );
  useEffect(() => {
    window.addEventListener(event, handlekKeydown);

    return () => {
      window.removeEventListener(event, handlekKeydown);
    };
  }, [handlekKeydown]);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div> 
  );
}

使用用 useCallback 包装函数,并且将 count 放入其依赖中,随后将 handlekKeydown 放入useEffect 的依赖,当 count 改变之后,useCallback 会返回一个新的引用,useEffect 会感知到这个依赖项的变化,所以其回调里面可以拿到最新的 props 和 state。具体关于 useCallback 的用法可以看我之前写的这篇文章《浅谈 React 常用的 Hooks》

image.png

但这种写法会引来一个新的问题:每点击一次按钮之后,useEffect 会产生一个新的回调,会重新创建一个绑定函数,如此反复创建、移除,对性能上会产生影响。

我们可以回过头开开之前 class 组件的写法,让我们来仔细看看我们class组件中的 handlekKeydown 方法:

  handlekKeydown = (e) => {
    if (e.code === "Enter") {
      console.log("current count: ", this.state.count);
    }
  };

这个类方法从 this.state.count 中读取数据,此处的this指向了当前组件的实例。React 本身会随着时间的推移而改变,以便你可以在渲染方法以及生命周期方法中得到最新的实例。

所在点击多次【Increase】按下【Enter】之后,我们的组件进行了重新渲染,this.state将会改变。handlekKeydown方法从一个最新的的state中得到了count

在函数式组件中,你也可以拥有一个在所有的组件渲染帧中共享的可变变量,它被称为“ref”。在 class 组件中,Refs 通常被用于拿到子组件的 DOM:

Refs 提供了一种方式,允许我们访问 DOM 节点或在 render 方法中创建的 React 元素。

函数组件中,Refs 可以很方便地保存任何可变值,其类似于在 class 中使用实例字段的方式。

const ref = useRef(initialValue);

我们使用useRef改造一下代码:

const event = "keydown";
function App() {
  const [count, setCount] = useState(0);
  const ref = useRef(count);
  const handlekKeydown = (e) => {
    if (e.code === "Enter") {
      console.log("current count: ", ref.current);
    }
  };

  useEffect(() => {
    ref.current = count;
  });

  useEffect(() => {
    window.addEventListener(event, handlekKeydown);
    return () => {
      window.removeEventListener(event, handlekKeydown);
    };
  }, []);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

在这里,你可以把 ref 理解为函数组件中的实例字段。我们通过一个useEffect实现了自动赋值操作,这样handlekKeydown中就能永远拿到最新的 count。

 useEffect(() => {
    ref.current = count;
  });

但是这样写有一个问题,如果页面中涉及多个状态呢?难道每个状态都要重新定义一个 ref 用于存取值?,这样的话代码会又臭又长。

我们可以换种思路,可以用 ref 来储存方法:

const event = "keydown";
function App() {
  const [count, setCount] = useState(0);
  const handlekKeydown = (e) => {
    if (e.code === "Enter") {
      console.log("current count: ", count);
    }
  };
  const ref = useRef(handlekKeydown);
  useEffect(() => {
    ref.current = handlekKeydown;
  });
  useEffect(() => {
    const cb = (e) => ref.current(e); // 注意这里通过回调拿到最新值
    window.addEventListener(event, cb);
    return () => {
      window.removeEventListener(event, cb);
    };
  }, []);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increase</button>
    </div>
  );
}

这样完美的解决了上面提到的问题。

实际上 React 官方也注意到了这个问题,提出了一个新的 RFC (useEvent)用于解决类似的问题:

function Chat() {
  const [text, setText] = useState('');

  // 🟡 Always a different function
  const onClick = () => {
    sendMessage(text);
  };

  return <SendButton onClick={onClick} />;
}

但是害怕开发者将当前使用 useCallback 包裹的函数都替换,因为 useEvent 会”擦除“流入其值的响应性,所以暂时关闭了这个 RFC,发布了一个不同的、范围更小的 RFC 来取代这个 RFC,新的 RFC 为 useEffectEvent

总结

函数组件和 class 组件是有所不同的,函数式组件捕获了渲染当前帧所使用的值 。由于 class 组件有this, React 组件在随着时间推移而改变的过程中,你总能从this上获取最新的状态,因为 this就指向组件的实例。而useRef在函数组件中可以充当一个容器,返回的 ref 对象在组件的整个生命周期内持续存在,我们可以借助它在函数组件的每一帧传递数据。