开篇概览
通过学习《React Hooks 核心原理与实战》课程,从以下3个部分来介绍 React Hooks
- React Hooks 的由来
- Hooks 的基本用法
- 自定义 Hooks 应用
往期 React 相关回顾
初学React,需要了解哪些知识点?
1. React Hooks 的由来
在 Hooks 出现之前,组件有 Class组件 及 函数式组件 两种形式,
Class 作为 React 组件时,有两方面特性没有很好的发挥出来:
- React 组件之间很少会用到
继承,比如说我们创建一个 Button 组件,然后在创建一个 DropdownButton 去继承它这样使用 - 所有 UI 都是由状态驱动,我们很少会在外部去调用一个
组件实例,因为组件的方法通常在内部调用,或者生命周期方法是被自动调用等
函数式组件没有得到大规模使用的原因:
- 必须是纯函数,自身
无状态 - 无法提供
生命周期机制
结合以往的经验,React 提供给我们一个更理想的机制,那就是 Hooks:
- 可以把外部数据绑定到函数的执行(函数组件有自己的状态)
- 数据变化时,函数可自动重新执行(类似生命周期)
graph LR
node1(State) --> Execution --> node((Result))
node2(URL) --> Execution
node3(Window-Size) --> Execution
Hooks(钩子),把
目标结果钩到某个可能发生变化的依赖(数据源或事件源)上,被钩的依赖发生变化,产生这个目标结果的代码会重新执行,得到更新后的结果
上图中可以看出,绑定了 State 、URL 以及 Window Size 的函数,当这些依赖项发生改变时会重新执行某个函数,然后的到新的结果
值得注意的是,Hooks 中绑定的依赖项还可以是其他 Hook 执行的结果,这样可以更好的进行逻辑复用
Class组件 实现监听窗口大小
以 Class 结合高阶组件的形式来完成逻辑复用,缺点:
- 每一个高阶组件都会多一层额外节点,调试麻烦
- 代码不直观,难以维护
const withWindowSize = Component => {
// 产生一个高阶组件 WrappedComponent,只包含监听窗口大小的逻辑
class WrappedComponent extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
size: this.getSize()
};
}
componentDidMount() {
window.addEventListener("resize", this.handleResize);
}
componentWillUnmount() {
window.removeEventListener("resize", this.handleResize);
}
getSize() {
return window.innerWidth > 1000 ? "large" :"small";
}
handleResize = ()=> {
const currentSize = this.getSize();
this.setState({
size: this.getSize()
});
}
render() {
// 将窗口大小传递给真正的业务逻辑组件
return <Component size={this.state.size} />;
}
}
return WrappedComponent;
};
class MyComponent extends React.Component{
render() {
const { size } = this.props;
if (size === "small") return <SmallComponent />;
else return <LargeComponent />;
}
}
// 使用 withWindowSize 产生高阶组件,用于产生 size 属性传递给真正的业务组件
export default withWindowSize(MyComponent);
Hooks 实现监听窗口大小
相对比 Class 高阶组件,将一个外部数状态使用 Hooks 进行封装,变成可绑定的数据源,窗口发生变化时 Hook 的组件就会重新渲染,且代码相对简洁、不会产生新的节点
const getSize = () => {
return window.innerWidth > 1000 ? "large" : "small";
}
const useWindowSize = () => {
const [size, setSize] = useState(getSize());
useEffect(() => {
const handler = () => {
setSize(getSize())
};
window.addEventListener('resize', handler);
return () => {
window.removeEventListener('resize', handler);
};
}, []);
return size;
};
const MyComponent = () => {
const size = useWindowSize();
if (size === "small") return <SmallComponent />;
else return <LargeComponent />;
};
Hooks 有助于关注分离
Hooks 能够让同一个业务逻辑的代码尽可能聚合在一起,Class 组件的处理方式会相对分散,下图左侧为 Class 组件实现,右图为 Hooks 实现
总结
- 相对比 Class组件 和 旧的函数式组件,Hooks 能够更好的体现从 State => View 的函数式映射
- Hooks 更加简洁,逻辑复用方便
2. Hooks 的基本用法
Hooks 本身作为纯粹的 JavaScript 函数,不是通过某个特殊的 API 去创建的,而是直接定义一个函数,它需要在降低学习和使用成本的同时,还需要遵循一定的规则才能正常工作:
- 只能在函数组件的顶级作用域使用(Hooks 不能在循环、条件判断或者嵌套函数内执行)
- 只能在函数组件或者其他 Hooks 中使用
Hooks 只能在函数组件的顶级作用域使用
Hooks 在组件的多次渲染之间,必须按顺序被执行;因为在 React 组件内部,其实是维护了一个对应组件的固定 Hooks 执行列表,以便在多次渲染之间保持 Hooks 的状态,并做对比。
function MyComp() {
const [count, setCount] = useState(0);
if (count > 10) {
// 错误:不能将 Hook 用在条件判断里
useEffect(() => {
// ...
}, [count])
}
// 这里可能提前返回组件渲染结果,后面就不能再用 Hooks 了
if (count === 0) {
return 'No content';
}
// 错误:不能将 Hook 放在可能的 return 之后
const [loading, setLoading] = useState(false);
//...
return <div>{count}</div>
}
所以 Hooks 的这个规则可以总结为两点:
- 所有 Hook 必须要被执行到
- 必须按顺序执行
Hooks 只能在函数组件或者其它 Hooks 中使用
Hooks 作为专门为函数组件设计的机制,使用的情况只有两种:
- 在函数组件内
- 在自定义的 Hooks 里面
如果一定要在 Class 组件中使用,那应该如何做呢?
答:利用高阶组件的模式,将 Hooks 封装成高阶组件,从而让类组件使用
import React from 'react';
import { useWindowSize } from '../hooks/useWindowSize';
export const withWindowSize = (Comp) => {
return props => {
const windowSize = useWindowSize();
return <Comp windowSize={windowSize} {...props} />;
};
};
通过 withWindowSize 高阶组件模式,可以把 useWindowSize 的结果作为属性,传递给需要使用窗口大小的类组件,这样就可以实现在 Class 组件中复用 Hooks 的逻辑了
import React from 'react';
import { withWindowSize } from './withWindowSize';
class MyComp {
render() {
const { windowSize } = this.props;
// ...
}
}
// 通过 withWindowSize 高阶组件给 MyComp 添加 windowSize 属性
export default withWindowSize(MyComp);
useState:让函数组件具有维持状态的能力
- useState(initialState) 创建初始 state,initialState 可以是任意类型(数字、字符串、对象、数组...)
- useState() 返回值为拥有两个元素的数组,第一个数组元素用来读取 state,第二个数组元素用来设置 state
Class 中的状态 state 只有一个,且为对象,通过对象中的不同属性来表示各种状态
Hooks 形式可创建多个 state ,对比 Class 的对象形式更加语义化
import React, { useState } from 'react';
function Example() {
// 创建一个保存 count 的 state,并给初始值 0
// setCount 用来设置 count 的值
const [count, setCount] = useState(0);
// 定义一个新状态,年龄的 state,初始值是 42
const [age, setAge] = useState(42);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>
+
</button>
<p>{age}</p>
</div>
);
}
useEffect:执行副作用
- useEffect(callback, dependencies)
- callback 要执行的函数
- dependencies 依赖数组
- 不传此项,每次执行该函数组件时都会执行 callback
- 指定依赖项,只有对应依赖发生改变才会执行 callback
- 传空数组,函数组件首次渲染时执行,等价于 class组件中的 componentDidMount
- useEffect() 返回一个函数,可用于组件销毁时的处理操作
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(() => {
// 每次 render 完一定执行
console.log('re-rendered');
});
useEffect(() => {
// 组件首次渲染时执行,等价于 class 组件中的 componentDidMount
console.log('did mount');
}, [])
useEffect(() => {
// componentDidMount + componentDidUpdate
console.log('这里基本等价于 componentDidMount + componentDidUpdate');
return () => {
// componentWillUnmount
console.log('这里基本等价于 componentWillUnmount');
}
}, [deps])
useCallback:缓存回调函数
缓存函数组件内部函数 handleIncrement,避免每次重新渲染时都要重新创建 handleIncrement 函数
- useCallback(fn, deps) 只有当某个依赖变量发生变化时,才会重新声明 fn 这个回调函数
import React, { useState, useCallback } from 'react';
function Counter() {
const [count, setCount] = useState(0);
const handleIncrement = useCallback(
() => setCount(count + 1),
[count], // 只有当 count 发生变化时,才会重新创建回调函数
);
// ...
return <button onClick={handleIncrement}>+</button>
}
useMemo:缓存计算的结果
避免需要用到的数据没发生变化时进行重复计算
- useMemo(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;
// if (users) {
// // 无论组件为何刷新,这里一定会对数组做一次过滤的操作
// usersToShow = users.data.filter((user) =>
// user.first_name.includes(searchKey),
// );
// }
// 使用 userMemo 缓存计算的结果
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>
);
}
useRef:在多次渲染之间共享数据
可以把 useRef 看作是在函数组件之外创建的一个容器空间,在这个容器上,我们可以通过唯一的 current 属设置一个值,从而在函数组件的多次渲染之间共享这个值
使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,这也是 useRef 区别于 useState 的地方
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 还有一个重要的功能,就是保存某个 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:定义全局状态
区别于普通的全局变量,useContext 能够进行数据的绑定,定义全局的响应式数据,当这个 Context 的数据发生变化时,使用这个数据的组件就能够自动刷新
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>
);
}
3. 自定义 hooks 应用
自定义 Hooks 的两个特点:
- 名字一定是以 use 开头的函数,这样 React 才能够知道这个函数是一个 Hook;
- 函数内部一定调用了其它的 Hooks,可以是内置的 Hooks,也可以是其它自定义 Hooks,这样才能够让组件刷新,或者去产生副作用。
封装通用逻辑:useAsync
在组件的开发过程中,有一些常用的通用逻辑,最常见的需求:发起异步请求获取数据并显示在界面上
在这个过程中,我们不仅要关心请求正确返回时,UI 会如何展现数据
还需要处理请求出错,以及关注 Loading 状态在 UI 上如何显示
通常都会遵循下面步骤:
- 创建 data,loading,error 这 3 个 state;
- 请求发出后,设置 loading state 为 true;
- 请求成功后,将返回的数据放到某个 state 中,并将 loading state 设为 false;
- 请求失败后,设置 error state 为 true,并将 loading state 设为 false。
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 };
};
在其他组件中使用 useAsync 时就只需关心与业务逻辑:
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...
);
}
为什么不直接封装普通的工具类,而是用 Hooks?
答:因为在 Hooks 中,你可以管理当前组件的 state,从而将更多的逻辑写在可重用的 Hooks 中;在普通的工具类中是无法直接修改组件 state 的,那么也就无法在数据改变的时候触发组件的重新渲染。
监听浏览器状态:useScroll
虽然 React 组件基本上不需要关心太多的浏览器 API,但是有时候却是必须的:界面需要根据在窗口大小变化重新布局;在页面滚动时,需要根据滚动条位置,来决定是否显示一个“返回顶部”的按钮。
import { useState, useEffect } from 'react';
// 获取横向,纵向滚动条位置
const getPosition = () => {
return {
x: document.body.scrollLeft,
y: document.body.scrollTop,
};
};
const useScroll = () => {
// 定一个 position 这个 state 保存滚动条位置
const [position, setPosition] = useState(getPosition());
useEffect(() => {
const handler = () => {
setPosition(getPosition(document));
};
// 监听 scroll 事件,更新滚动条位置
document.addEventListener("scroll", handler);
return () => {
// 组件销毁时,取消事件监听
document.removeEventListener("scroll", handler);
};
}, []);
return position;
};
有了这个 Hook,你就可以非常方便地监听当前浏览器窗口的滚动条位置了,比如 返回顶部 功能的实现
import React, { useCallback } from 'react';
import useScroll from './useScroll';
function ScrollTop() {
const { y } = useScroll();
const goTop = useCallback(() => {
document.body.scrollTop = 0;
}, []);
const style = {
position: "fixed",
right: "10px",
bottom: "10px",
};
// 当滚动条位置纵向超过 300 时,显示返回顶部按钮
if (y > 300) {
return (
<button onClick={goTop} style={style}>
Back to Top
</button>
);
}
// 否则不 render 任何 UI
return null;
}
拆分复杂组件
尽量将相关的逻辑做成独立的 Hooks,然后在函数组中使用这些 Hooks,通过参数传递和返回值让 Hooks 之间完成交互
拆分逻辑的目的不一定是为了重用,而可以是为了业务逻辑的隔离;所以我们不一定要把 Hooks 放到独立的文件中,而是可以和函数组件写在一个文件中。这么做的原因就在于,这些 Hooks 是和当前函数组件紧密相关的,所以写到一起,反而更容易阅读和理解
看一个示例,展示一个博客文章的列表,有一列要显示文章的分类,还需要提供表格过滤功能,以便能够只显示某个分类的文章
后端提供了两个 API: 一个用于获取文章的列表,另一个用于获取所有的分类
这就需要我们在前端将文章列表返回的数据分类 ID 映射到分类的名字,以便显示在列表里
如果按照直观的思路去实现,通常都会把逻辑都写在一个组件里,比如类似下面的代码:
function BlogList() {
// 获取文章列表...
// 获取分类列表...
// 组合文章数据和分类数据...
// 根据选择的分类过滤文章...
// 渲染 UI ...
}
针对这样一个功能,我们甚至可以将其拆分成 4 个 Hooks,每一个 Hook 都尽量小,代码如下:
import React, { useEffect, useCallback, useMemo, useState } from "react";
import { Select, Table } from "antd";
import _ from "lodash";
import useAsync from "./useAsync";
const endpoint = "https://myserver.com/api/";
const useArticles = () => {
// 使用上面创建的 useAsync 获取文章列表
const { execute, data, loading, error } = useAsync(
useCallback(async () => {
const res = await fetch(`${endpoint}/posts`);
return await res.json();
}, []),
);
// 执行异步调用
useEffect(() => execute(), [execute]);
// 返回语义化的数据结构
return {
articles: data,
articlesLoading: loading,
articlesError: error,
};
};
const useCategories = () => {
// 使用上面创建的 useAsync 获取分类列表
const { execute, data, loading, error } = useAsync(
useCallback(async () => {
const res = await fetch(`${endpoint}/categories`);
return await res.json();
}, []),
);
// 执行异步调用
useEffect(() => execute(), [execute]);
// 返回语义化的数据结构
return {
categories: data,
categoriesLoading: loading,
categoriesError: error,
};
};
const useCombinedArticles = (articles, categories) => {
// 将文章数据和分类数据组合到一起
return useMemo(() => {
// 如果没有文章或者分类数据则返回 null
if (!articles || !categories) return null;
return articles.map((article) => {
return {
...article,
category: categories.find(
(c) => String(c.id) === String(article.categoryId),
),
};
});
}, [articles, categories]);
};
const useFilteredArticles = (articles, selectedCategory) => {
// 实现按照分类过滤
return useMemo(() => {
if (!articles) return null;
if (!selectedCategory) return articles;
return articles.filter((article) => {
console.log("filter: ", article.categoryId, selectedCategory);
return String(article?.category?.name) === String(selectedCategory);
});
}, [articles, selectedCategory]);
};
const columns = [
{ dataIndex: "title", title: "Title" },
{ dataIndex: ["category", "name"], title: "Category" },
];
export default function BlogList() {
const [selectedCategory, setSelectedCategory] = useState(null);
// 获取文章列表
const { articles, articlesError } = useArticles();
// 获取分类列表
const { categories, categoriesError } = useCategories();
// 组合数据
const combined = useCombinedArticles(articles, categories);
// 实现过滤
const result = useFilteredArticles(combined, selectedCategory);
// 分类下拉框选项用于过滤
const options = useMemo(() => {
const arr = _.uniqBy(categories, (c) => c.name).map((c) => ({
value: c.name,
label: c.name,
}));
arr.unshift({ value: null, label: "All" });
return arr;
}, [categories]);
// 如果出错,简单返回 Failed
if (articlesError || categoriesError) return "Failed";
// 如果没有结果,说明正在加载
if (!result) return "Loading...";
return (
<div>
<Select
value={selectedCategory}
onChange={(value) => setSelectedCategory(value)}
options={options}
style={{ width: "200px" }}
placeholder="Select a category"
/>
<Table dataSource={result} columns={columns} />
</div>
);
}