Hooks 是 React 很出色的一个功能更新。它极大的简化了之前在类组件中必须拆分到各个声明周期中的逻辑。

但是,Hooks 需要一种新的思维模式,尤其是对初学者来讲。

防抖和节流

网络上有太多太多关于防抖和节流的文章了,所以我不打算再深入讨论如何编写自己的防抖和节流功能。方便起见,我们考虑直接使用 Lodash 中提供的 debouncethrottle

这里我们带大家快速复习一下,防抖和节流两个函数都接收两个参数,一个回调函数以及一个以毫秒为单位的延迟(暂时称为 x),而且这两个函数都返回另外一个具有特定功能的函数:

  • debounce: 返回一个可以调用任意次的函数(一般是快速的连续调用),但是这个函数实际只会在最后一次调用完 x 毫秒后,调用回调函数。
  • throttle: 返回一个可以调用任意次的函数(一般是快速的连续调用),但是每 x 毫秒间隔内最多只会调用一次回调函数。

案例

我们有一个迷你博客编辑器(Github仓库地址),在这个编辑器中,我们需要在用户每次输入停止1秒后将博客内容存储到数据库内。

如果你想看最终版本的代码,直接访问 Codesandbox 就可以 我们的编辑器最小版的代码应该是这样:

import React, { useState } from 'react';
import debounce from 'lodash.debounce';

function App() {
  const [value, setValue] = useState("");
  const [dbValue, saveToDb] = useState(""); // would be an API call normally

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  return (
    <main>
      <h1>Blog</h1>
      <textarea value={value} onChange={handleChange} rows={5} cols={50} />
      <section className="panels">
        <div>
          <h2>这是编辑器端的内容 (Client)</h2>
          {value}
        </div>
        <div>
          <h2>这是存储好了的内容 (DB)</h2>
          {dbValue}
        </div>
      </section>
    </main>
  );
}

上面这段代码里,saveToDb 实际上应该是对后端 API 的调用,但是这里为了让代码保持简洁,我把数据存储在 state 中,且为了方便大家观看,我直接将其渲染到了页面上。

因为我们只想在用户停止输入 1s 后执行这个存储操作,所以这里应该使用防抖。

大家可以在这里查看起始代码。

创建一个防抖函数

首先,我们需要一个防抖函数来封装对 saveToDb 函数的调用:

import React, { useState } from 'react';
import debounce from 'lodash.debounce';

function App() {
  const [value, setValue] = useState("");
  const [dbValue, saveToDb] = useState(""); // would be an API call normally

  const handleChange = (event) => {
    const { value: nextValue } = event.target;
    setValue(nextValue);
    const debouncedSave = debounce(() => saveToDb(nextValue), 1000);    debouncedSave();  };

  return <main>{/* Same as before */}</main>;
}

但是,这样其实是不能正常工作的,大家自习观察就会发现,我们是在 handleChange 函数中创建的 debouncedSave 函数,那这就意味着,每次按键触发 handleChange 事件都会重新创建一个 debouncedSave 函数,引用不一致就会导致防抖功能失效了。

useCallback

在我们给子组件传递回调函数的时候,useCallback 可以用来优化性能。但是我们可以利用他的另外一个特性,就是会对回调函数进行缓存,在依赖不发生任何变更的情况下,能保证每次调用的都是同一个。这样就能保证我们每次调用的 debounceSave 都是同一个了。

这样就跟我们预想的一样了:

import React, { useState, useCallback } from 'react';
import debounce from 'lodash.debounce';

function App() {
  const [value, setValue] = useState("");
  const [dbValue, saveToDb] = useState(""); // would be an API call normally

  const debouncedSave = useCallback(    debounce((nextValue) => saveToDb(nextValue), 1000),    [] // will be created only once initially  );
  const handleChange = (event) => {
    const { value: nextValue } = event.target;
    setValue(nextValue);
    // Even though handleChange is created on each render and executed
    // it references the same debouncedSave that was created initially
    debouncedSave(nextValue);
  };

  return <main>{/* Same as before */}</main>;
}

useRef

useRef 可以用来创建一个可修改的对象,我们传递给 useRef的参数会作为初始值赋值给这个对象的 .current 属性。最关键的是,如果我们不去手动的更改,那么这个值会组件的生命周期内持续存在。

同样,这样也能和我们预期的一样:

import React, { useState, useRef } from 'react';
import debounce from 'lodash.debounce';

function App() {
  const [value, setValue] = useState("");
  const [dbValue, saveToDb] = useState(""); // would be an API call normally

  // This remains same across renders
  const debouncedSave = useRef(    debounce((nextValue) => saveToDb(nextValue), 1000)  ).current;
  const handleChange = (event) => {
    const { value: nextValue } = event.target;
    setValue(nextValue);
    // Even though handleChange is created on each render and executed
    // it references the same debouncedSave that was created initially
    debouncedSave(nextValue);
  };

  return <main>{/* Same as before */}</main>;
}

封装一个自定义 Hook

上面两个方法中,我们用到了 useCallbackuseRef,而且都能很好的帮我们实现需求。对于一次性案例来讲,这样挺好,但是如果写法能变得更简洁岂不是更棒?如果我们不使用 useCallbackuseRef 的话,我们的代码会变得可读性更高。我们当然可以把这个逻辑抽象到一个 useDebounce Hook 中。

下面的代码是我们使用 useCallback 来实现我们的构思:

import React, { useState, useCallback } from "react";
import debounce from "lodash.debounce";

function useDebounce(callback, delay) {
  const debouncedFn = useCallback(
    debounce((...args) => callback(...args), delay),
    [delay] // will recreate if delay changes
  );
  return debouncedFn;
}

function App() {
  const [value, setValue] = useState("");
  const [dbValue, saveToDb] = useState(""); // would be an API call normally

  const debouncedSave = useDebounce((nextValue) => saveToDb(nextValue), 1000);

  const handleChange = (event) => {
    const { value: nextValue } = event.target;
    setValue(nextValue);
    debouncedSave(nextValue);
  };

  return <main>{/* Same as before */}</main>;
}

这个代码确实能正常运行也能完成我们要的功能,但是很奇怪的,我的 TypeScript Linter 报了一个错误:

React Hook useCallback received a function whose dependencies are unknown.
Pass an inline function instead. eslint(react-hooks/exhaustive-deps)

但是这个代码能在 JavaScript 环境下正常运行,并且没有任何错误(使用的是 create-react-app 模板)。不管怎么样,下边给大家提供一个替代方案,使用 useRef 来实现的 useDebounce Hook:

function useDebounce(callback, delay) {
  // Memoizing the callback because if it's an arrow function
  // it would be different on each render
  const memoizedCallback = useCallback(callback, []);
  const debouncedFn = useRef(debounce(memoizedCallback, delay));

  useEffect(() => {
    debouncedFn.current = debounce(memoizedCallback, delay);
  }, [memoizedCallback, debouncedFn, delay]);

  return debouncedFn.current;
}

这个代码没有上面使用 useCallback 实现的简洁,很有可能我那个 Linter 的错误是一个 Gug, 说不定过不了多久就能修复了。

在这篇文章里,我只是简单的介绍了防抖,但是节流也可以用同样的方式来实现。一样的,你也可以做一个自己的 useThrottle Hook。