React Hooks 核心原理与实战笔记

Hooks 的出现

在 React 中, Hooks 就是把某个目标结果钩到某个可能会变化的数据源或者事件源上, 那么当被钩到的数据或事件发生变化时, 产生这个目标结果的代码会重新执行, 产生更新后的结果.

所有的 Hooks 的最终结果都是导致 UI 的变化.

Hooks 的最大好处: 逻辑复用

Hooks 的另一大好处: 有助于关注分离

参考:

useState 保存组件状态和使用生命周期

useState 是用来管理 state, 让函数组件具有维持状态的能力

在一个函数组件的多次渲染之间, 这个 state 是共享的

// 定义一个年龄的 state, 初始值是 42
const [age, setAge] = useState(42);
// 定义一个水果的 state, 初始值是 banana
const [fruit, setFruit] = useState("banana");
// 定一个一个数组 state, 初始值是包含一个 todo 的数组
const [todos, setTodos] = useState([{ text: "Learn Hooks" }]);

useState 是一个非常简单的 Hook, 它让你很方便地去创建一个状态, 并提供一个特定的方法 (比如 setAge) 来设置这个状态

state 中永远不要保存可以通过计算得到的值

useEffect

useEffect 用于执行一段副作用

副作用是指一段和当前执行结果无关的代码, 比如说要修改函数外部的某个变量, 要发起一个请求, 等等

在函数组件的当次执行过程中, useEffect 中代码的执行不影响渲染出来的 UI

useEffect 是每次组件 render 完后判断依赖并执行

useEffect(callback, dependencies);

callback: 要执行的函数

dependencies: 可选的依赖项数组

依赖项是可选的

  • 没有依赖项, 则每次 render 后都会重新执行

    useEffect(() => {
      // 每次 render 完一定执行
      console.log("re-rendered");
    });
    
  • 有依赖项, 且依赖项不为空数组

    import React, { useState, useEffect } from "react";
    
    function BlogView({ id }) {
      // 设置一个本地 state 用于保存 blog 内容
      const [blogContent, setBlogContent] = useState(null);
    
      useEffect(() => {
        // useEffect 的 callback 要避免直接的 async 函数, 需要封装一下
        const doAsync = async () => {
          // 当 id 发生变化时, 将当前内容清楚以保持一致性
          setBlogContent(null);
          // 发起请求获取数据
          const res = await fetch(`/blog-content/${id}`);
          // 将获取的数据放入 state
          setBlogContent(await res.text());
        };
        doAsync();
      }, [id]); // 使用 id 作为依赖项, 变化时则执行副作用
    
      // 如果没有 blogContent 则认为是在 loading 状态
      const isLoading = !blogContent;
      return <div>{isLoading ? "Loading..." : blogContent}</div>;
    }
    
  • 空数组作为依赖项, 则只在首次执行时触发

    useEffect(() => {
      // 组件首次渲染时执行,等价于 class 组件中的 componentDidMount
      console.log("did mount");
    }, []);
    

useEffect 还允许返回一个函数, 用于在组件销毁的时候做一些清理的操作, 机制就几乎等价于类组件中的 componentWillUnmount

// 设置一个 size 的 state 用于保存当前窗口尺寸
const [size, setSize] = useState({});
useEffect(() => {
  // 窗口大小变化事件处理函数
  const handler = () => {
    setSize(getSize());
  };
  // 监听 resize 事件
  window.addEventListener("resize", handler);

  // 返回一个 callback 在组件销毁时调用
  return () => {
    // 移除 resize 事件
    window.removeEventListener("resize", handler);
  };
}, []);

总结:

  • useEffect(() => {}) 每次 render 后执行
  • useEffect(() => {}, []) 仅第一次 render 后执行
  • useEffect(() => {}, [deps]) 第一次 render 后及依赖项发生变化后执行
  • useEffect(() => { return () => {} }, []) 组件 unmount 后执行

参考:

useCallback 缓存回调函数

useCallback(fn, deps);

fn: 定义的回调函数 deps: 依赖的变量数组

只有当某个依赖变量发生变化时, 才会重新声明 fn 这个回调函数

// 修改前
// 每次组件状态发生变化的时候, 函数组件都会重新执行一遍
// 在每次执行的时候, 都会创建一个新的事件处理函数 handleIncrement
function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = () => setCount(count + 1);
  // ...
  return <button onClick={handleIncrement}>+</button>;
}

// 修改后
// 只有 count 发生变化的时候, 才需要重新创建一个回调函数,
// 这样就保证了组件不会创建重复的回调函数.
// 而接收这个回调函数作为属性的组件, 也不会频繁地需要重新渲染
function Counter() {
  const [count, setCount] = useState(0);
  const handleIncrement = useCallback(
    () => setCount(count + 1),
    [count] // 只有当 count 发生变化时才会重新创建回调函数
  );
  // ...
  return <button onClick={handleIncrement}>+</button>;
}

useMemo 缓存计算的结果

useCallback 缓存的是一个函数, 而 useMemo 缓存的是计算的结果

useMemo 两个好处:

  1. 避免重复计算
  2. 避免子组件的重复渲染
useMemo(fn, deps);

fn: 产生所需数据的计算函数, deps 依赖项

如果某个数据是通过其它数据计算得到的, 那么只有当用到的数据, 也就是依赖的数据发生变化的时候, 才应该需要重新计算

import React, { useState, useEffect } from "react";

export default function SearchUserList() {
  const [users, setUsers] = useState(null);
  const [searchKey, setSearchKey] = useState("");

  useEffect(() => {
    const doFetch = async () => {
      // 组件首次加载时发请求获取用户数据
      const res = await fetch("https://reqres.in/api/users/");
      setUsers(await res.json());
    };
    doFetch();
  }, []);
  let usersToShow = null;

  /* 不使用 useMemo
     无论组件为何刷新, 这里一定会对数组做一次过滤的操作
     重新计算 usersToShow 的值
  if (users) {
    usersToShow = users.data.filter((user) =>
      user.first_name.includes(searchKey)
    );
  }
  */

  // 使用 userMemo 缓存计算的结果 usersToShow
  // 只有在依赖项 users, searchKey 发生变化时
  // 才会重新计算 usersToShow
  const usersToShow = useMemo(() => {
    if (!users) return null;
    return users.data.filter((user) => {
      return user.first_name.includes(searchKey);
    });
  }, [users, searchKey]);

  return (
    <div>
      <input
        type="text"
        value={searchKey}
        onChange={(evt) => setSearchKey(evt.target.value)}
      />
      <ul>
        {usersToShow &&
          usersToShow.length > 0 &&
          usersToShow.map((user) => {
            return <li key={user.id}>{user.first_name}</li>;
          })}
      </ul>
    </div>
  );
}

useCallback 可以用 useMemo 实现, 它们都做了同一件事情: 建立了一个绑定某个结果到依赖数据的关系. 只有当依赖变了, 这个结果才需要被重新得到.

const myEventHandler = useMemo(() => {
  // 返回一个函数作为缓存结果
  return () => {
    // 在这里进行事件处理
  };
}, [dep1, dep2]);

useRef 在多次渲染之间共享数据

const myRefContainer = useRef(initialValue);

可以把 useRef 看作是在函数组件之外创建的一个容器空间, 在这个容器上可以通过唯一的 current 属设置一个值, 从而在函数组件的多次渲染之间共享这个值

// 使用了 useRef 来创建了一个保存 window.setInterval 返回句柄的空间
// 从而能够在用户点击暂停按钮时清除定时器
// 达到暂停计时的目的
import React, { useState, useCallback, useRef } from "react";

export default function Timer() {
  // 定义 time state 用于保存计时的累积时间
  const [time, setTime] = useState(0);

  // 定义 timer 这样一个容器用于在跨组件渲染之间保存一个变量
  const timer = useRef(null);

  // 开始计时的事件处理函数
  const handleStart = useCallback(() => {
    // 使用 current 属性设置 ref 的值
    timer.current = window.setInterval(() => {
      setTime((time) => time + 1);
    }, 100);
  }, []);

  // 暂停计时的事件处理函数
  const handlePause = useCallback(() => {
    // 使用 clearInterval 来停止计时
    window.clearInterval(timer.current);
    timer.current = null;
  }, []);

  return (
    <div>
      {time / 10} seconds.
      <br />
      <button onClick={handleStart}>Start</button>
      <button onClick={handlePause}>Pause</button>
    </div>
  );
}

useRef 保存的数据一般是和 UI 的渲染无关的, 因此当 ref 的值发生变化时, 是不会触发组件的重新渲染的, 这也是 useRef 区别于 useState 的地方

除了存储跨渲染的数据之外, useRef 还有一个重要的功能: 保存某个 DOM 节点的引用

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // current 属性指向了真实的 input 这个 DOM 节点
    // 从而可以调用 focus 方法
    inputEl.current.focus();
  };
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

useContext 定义全局状态

React 组件之间通过 props 传值, 且只能在父子组件之间传值

React 提供了 Context 这样一个机制, 能够让所有在某个组件开始的组件树上创建一个 Context. 这样这个组件树上的所有组件就都能访问和修改这个 Context

当这个 Context 的数据发生变化时, 使用这个数据的组件就能够自动刷新, 达到数据的绑定的目的

useContext:

const value = useContext(MyContext);

创建 Context

const MyContext = React.createContext(initialValue);

MyContext 具有一个 Provider 的属性, 一般是作为组件树的根组件

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee",
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222",
  },
};
// 创建一个 Theme 的 Context
const ThemeContext = React.createContext(themes.light);
function App() {
  // 整个应用使用 ThemeContext.Provider 作为根组件
  return (
    // 使用 themes.dark 作为当前 Context
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

// 在 Toolbar 组件中使用一个会使用 Theme 的 Button
function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

// 在 Theme Button 中使用 useContext 来获取当前的主题
function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button
      style={{
        background: theme.background,
        color: theme.foreground,
      }}
    >
      I am styled by theme context!
    </button>
  );
}

themes.dark 是作为一个属性值传给 Provider 这个组件, 如果要让它变得动态, 只要用一个 state 来保存, 通过修改 state 就能实现动态的切换 Context 的值. 而且这么做所有用到这个 Context 的地方都会自动刷新

function App() {
  // 使用 state 来保存 theme 从而可以动态修改
  const [theme, setTheme] = useState("light");

  // 切换 theme 的回调函数
  const toggleTheme = useCallback(() => {
    setTheme((theme) => (theme === "light" ? "dark" : "light"));
  }, []);

  return (
    // 使用 theme state 作为当前 Context
    <ThemeContext.Provider value={themes[theme]}>
      <button onClick={toggleTheme}>Toggle Theme</button>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

自定义 Hook

典型的四个使用场景

  1. 抽取业务逻辑

    计数器的例子可以改成

    import { useState, useCallback } from "react";
    
    function useCounter() {
      // 定义 count 这个 state 用于保存当前数值
      const [count, setCount] = useState(0);
      // 实现加 1 的操作
      const increment = useCallback(() => setCount(count + 1), [count]);
      // 实现减 1 的操作
      const decrement = useCallback(() => setCount(count - 1), [count]);
      // 重置计数器
      const reset = useCallback(() => setCount(0), []);
      // 将业务逻辑的操作 export 出去供调用者使用
      return { count, increment, decrement, reset };
    }
    
    import React from "react";
    
    function Counter() {
      // 调用自定义 Hook
      const { count, increment, decrement, reset } = useCounter();
    
      // 渲染 UI
      return (
        <div>
          <button onClick={decrement}> - </button>
          <p>{count}</p>
          <button onClick={increment}> + </button>
          <button onClick={reset}> reset </button>
        </div>
      );
    }
    
  2. 封装通用逻辑

    场景: 从服务端获取用户列表, 并显示在界面上

    在处理这类请求的时候, 模式都是类似的, 通常都会遵循下面步骤:

    1. 创建 data, loading, error 这 3 个 state
    2. 请求发出后, 设置 loading state 为 true
    3. 请求成功后, 将返回的数据放到某个 state 中, 并将 loading state 设为 false
    4. 请求失败后, 设置 error state 为 true, 并将 loading state 设为 false.
    5. 最后, 基于 data, loading, error 这 3 个 state 的数据, UI 就可以正确地显示数据, 或者 loading, error 这些反馈给用户

    通过创建自定义 Hook useAsync 可以将这样的逻辑提取出来成为一个可重用的模块

    import { useState } from "react";
    
    const useAsync = (asyncFunction) => {
      // 设置三个异步逻辑相关的 state
      const [data, setData] = useState(null);
      const [loading, setLoading] = useState(false);
      const [error, setError] = useState(null);
      // 定义一个 callback 用于执行异步逻辑
      const execute = useCallback(() => {
        // 请求开始时, 设置 loading 为 true, 清除已有数据和 error 状态
        setLoading(true);
        setData(null);
        setError(null);
        return asyncFunction()
          .then((response) => {
            // 请求成功时, 将数据写进 state, 设置 loading 为 false
            setData(response);
            setLoading(false);
          })
          .catch((error) => {
            // 请求失败时, 设置 loading 为 false, 并设置错误状态
            setError(error);
            setLoading(false);
          });
      }, [asyncFunction]);
    
      return { execute, loading, data, error };
    };
    
    import React from "react";
    import useAsync from './useAsync';
    
    export default function UserList() {
      // 通过 useAsync 这个函数, 只需要提供异步逻辑的实现
      const {
        execute: fetchUsers,
        data: users,
        loading,
        error,
      } = useAsync(async () => {
        const res = await fetch("https://reqres.in/api/users/");
        const json = await res.json();
        return json.data;
      });
    
      return (
        // 根据状态渲染 UI...
      );
    }
    
  3. 监听浏览器状态

    const useWindowWidth = () => {
      const [width, setWidth] = useState(window.innerWidth);
      useEffect(() => {
        const onResize = () => setWidth(window.innerWidth);
        window.addEventListener("resize", onResize);
        return () => window.removeEventListener("resize", onResize);
      }, []);
      return width;
    };
    
    const MyComponent = () => {
      const width = useWindowWidth();
      return <div>Window width is: {width}</div>;
    };
    
  4. 拆分复杂组件

参考: