目录

以 useEffect 为圆心

以 useEffect 为圆心,其他 Hooks 为半径,构建 React Hooks 的知识圆环。为什么会想出这样一个标题呢?Hooks 的知识点过于分散,很多朋友在读过 React 官方文档后,还是不知道 Hooks 如何在实际项目中使用。本文希望从 useEffect 的具体用法中引出其他 Hooks,从而构建出完整的 React Hooks 知识体系。

React 的核心原理:当数据发生变化时,UI 随之更新,就是所谓的数据驱动。

函数式编程

函数式编程 中,函数是 头等对象头等函数 ,这意味着一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。λ 演算 是这种范型最重要的基础,λ 演算的函数可以接受函数作为输入参数和输出返回值。

顺带提一句,由邱奇创造的 λ 演算 (λ-calculus) 是世界上最小的程序设计语言。λ 演算中没有数(number),字符串(string),布尔型(boolean) 或任何非函数的数据类型,它只用匿名单参函数就能模拟图灵机,具体实现过程可阅读我的这篇 文章

比起指令式编程 ,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程。

心智模型

学习 Hooks 的使用,重点是心智模型的转变。useEffect 的心智模型是实现状态同步,而不是响应生命周期事件。每次触发时 useEffect,它都会捕获本次调用时组件中的数据,也就是所谓的 Capture Value 特性:组件每次渲染都有自己的数据,组件内的函数(包括 effects,事件处理函数,定时器或者 API 调用等)会捕获该次渲染的组件数据。

状态同步

首先看这段代码,请判断最终计时器中的 count 和 组件中的 count 分别是多少?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function Counter() {
  const [count, setCount] = useState(0);
  console.log("组件中的count", count);
  useEffect(() => {
    console.log("触发useEffect");
    const id = setInterval(() => {
      console.log("计时器中的count", count);
      setCount(count + 1);
    }, 1000);
    return () => {
      console.log("销毁了定时器");
      clearInterval(id);
    };
  }, []);

  return <h1>{count}</h1>;
}

答案分别是 0 和 1,多少有点基础的人都能想通或者猜对。现在更改需求,让组件中的 count 每秒加 1。我们有两种写法:

  • 不对依赖数组撒谎
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 每次因 count 变化触发的重渲染都会触发 useEffect
function Counter() {
  const [count, setCount] = useState(0);
  console.log("组件中的count", count);
  useEffect(() => {
    console.log("触发useEffect");
    const id = setInterval(() => {
      console.log("计时器中的count", count);
      setCount(count + 1);
    }, 1000);
    return () => {
      console.log("销毁了定时器");
      clearInterval(id);
    };
  }, [count]);

  return <h1>{count}</h1>;
}

每次重渲染创建 Counter 组件时都会触发 useEffect,销毁上一次的计数器,并创建新的计数器。因此组件中的 count 和计时器中的 count 是同步的。

  • 让 Effect 自给自足
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 移除 useEffect 的非必需的依赖,减少不必要的触发。
function Counter() {
  const [count, setCount] = useState(0);
  console.log("组件中的count", count);
  useEffect(() => {
    console.log("触发useEffect");
    const id = setInterval(() => {
      console.log("计时器中的count", count);
      // 这里接收的函数描述 count 如何变化(action)
      setCount((c) => c + 1);
    }, 1000);
    return () => {
      console.log("销毁了定时器");
      clearInterval(id);
    };
  }, []);

  return <h1>{count}</h1>;
}

通过控制台我们发现,计时器中的 count 始终为 0,怎么解释?useEffect 只在组件初次渲染后触发一次,它创建了计时器,计时器记住了当时的 count。既然计时器中的 count 始终为 0,那么 setCount 是怎样让组件状态同步的呢?想搞清楚这一点就不得不提 useReducer 了。

useReducer

事实上,useState 是预置了如下 reducer 的 useReducer,相关 源码

1
2
3
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === "function" ? action(state) : action;
}

也就是说,setCount 接收的 action 函数是在下一次函数组件渲染时,在 useState 中调用的。描述动作和执行动作分开进行,感觉有 Redux 的内味了?没错,React 还为 useReducer 提供了配套的 dispatch 方法:

1
const [state, dispatch] = useReducer(reducer, initialArg, init);

再次更改需求,我们不让计时器每秒加 1,而是由输入的 step 控制。还是对比两种写法:

  • 不对依赖数组撒谎
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(0);
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count => count + step);
    }, 1000);
    return () => clearInterval(id);
  }, [step]);
  ...
}
  • 让 Effect 自给自足
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const initialState = {
  count: 0,
  step: 0,
};

function reducer(state, action) {
  const { count, step } = state;
  if (action.type === 'tick') {
    return { count: count + step, step };
  } else if (action.type === 'step') {
    return { count, step: action.step };
  } else {
    throw new Error();
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  const { count, step } = state;
  useEffect(() => {
    const id = setInterval(() => {
      dispatch({ type: 'tick' });
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return (
    <div>
      <h1>{count}</h1>
      <input value={step} onChange={e => {
        dispatch({
          type: 'step',
          step: Number(e.target.value)
        });
      }} />
    <div/>
  );
}

移除 useEffect 的非必需的依赖,就能减少不必要的触发,是一种性能优化思路。还有一种思路是保持 useEffect 的依赖不变,也能减少不必要的触发,这会用到 useCallback 和 useMemo。

useCallback

React 判断组件中的数据是否发生改变时使用了 Object.is 进行比较。当 useEffect 的依赖数组的元素为引用数据类型时,每次的比较结果都是发生改变,这就失去了依赖数组本身的意义(条件式地触发 useEffect)。看这段代码,我们希望复用网络请求逻辑,这样可行吗?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function SearchResults() {
  const getFetchUrl = (query) => {
    return 'https://hn.algolia.com/api/v1/search?query=' + query;
  };

  useEffect(() => {
    const url = getFetchUrl('react');
  // Fetch data and do something
  ...
  }, [getFetchUrl]);

  useEffect(() => {
    const url = getFetchUrl('redux');
  // Fetch data and do something
  ...
  }, [getFetchUrl]);

  ...
}

当我们写这段代码时,我们发现网络请求将无限重复,因为函数调用会生成不同引用。一个可能的解决办法是把 getFetchUrl 从依赖中去掉,前提是你能确保它不受数据流变化的影响,否则就会出现意想不到的 bugs。

然而 useEffect 的设计意图就是要强迫你关注数据流的变化,然后去同步状态。当不能把函数从依赖中去掉时,我们可以使用 useCallback 来包装函数从而确保函数的引用相等

1
2
3
4
5
/** useCallback 在其依赖变化时,才生成新的函数
 * 现在依赖为空,getFetchUrl 永远调用同一个函数 */
const getFetchUrl = useCallback((query) => {
  return "https://hn.algolia.com/api/v1/search?query=" + query;
}, []);

更改 getFetchUrl 后就能避免网络请求重复的问题了。useCallback 本质上是对函数添加了一层依赖检查,让函数只在需要改变的时候才改变。

useCallback 的另一个使用场景:当父组件传递函数给子组件的时候,由于父组件的更新会导致该函数重新生成,从而传递给子组件的函数引用发生变化,这就会导致子组件也会更新,这时我们可以通过 useCallback 来缓存该函数,然后传递给子组件同一个函数避免子组件更新(子组件需用 memo 包装),看这篇 文章 的例子。

useMemo 扩展了 useCallback 的功能,useCallback 只能缓存函数,而 useMemo 可以缓存任何类型的值,同样是为了确保引用相等。除此之外,useMemo 还可以用于避免重复计算。

useRef

现在来看这个例子,我们连续点击 Add count 按钮时,3s 后会发生什么?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setTimeout(() => {
      console.log(count);
    }, 3000);
  });

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={()=>setCount(count+1)}>Add count</button>
    <div/>
  );
}

3s 后会在控制台依次打印 count 的值 1,2,3 …,然而在一些场景中,我们只想得到最新的 count 值,该怎么做?这就会用到 useRef,useRef 返回一个可变的 ref 对象,它的属性 current 被初始化为传入的参数,并且 useRef 始终返回同一个 ref 对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function Counter() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    latestCount.current = count;
    setTimeout(() => {
      console.log(latestCount.current);
    }, 3000);
  });

  return (
    <>
      <h1>{count}</h1>
      <button onClick={() => setCount(count + 1)}>Add count</button>
    </>
  );
}

现在我们连续点击会发现 3s 后控制台将多次打印最新的 count 值,这证明我们修改的是同一个 ref 对象。除了用来缓存变量,useRef 还能获得 DOM 元素,需要在元素上绑定 ref 属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function RefDemo() {
  const titleRef = useRef();

  function changeDOM() {
    titleRef.current.innerHTML = "hello world";
    titleRef.current.style.color = "red";
  }
  return (
    <div>
      <h2 ref={titleRef}>RefDemo</h2>
      <button onClick={changeDOM}>修改DOM</button>
    </div>
  );
}

思考

暂时就先这样吧,后面还会再补充。

参阅资料

推荐阅读