钩子模式
React 16.8 引入了一项名为 钩子(Hooks) 的新功能。钩子在不使用类组件的情况下,也能使用 React 的状态和生命周期方法。
尽管钩子本身不是设计模式,但它在应用程序设计中扮演着非常重要的角色。许多传统的设计模式可以被钩子所取代。
类组件
在 React 引入钩子之前,我们必须使用类组件为组件添加状态和生命周期方法。
一个典型的 React 类组件如下所示:
class MyComponent extends React.Component {
/* 添加状态并绑定自定义方法 */
constructor() {
super()
this.state = { ... }
this.customMethodOne = this.customMethodOne.bind(this)
this.customMethodTwo = this.customMethodTwo.bind(this)
}
/* 生命周期方法 */
componentDidMount() { ...}
componentWillUnmount() { ... }
/* 自定义方法 */
customMethodOne() { ... }
customMethodTwo() { ... }
render() { return { ... }}
}
类组件在构造函数中包含状态,使用诸如 componentDidMount
和 componentWillUnmount
等生命周期方法,根据组件的生命周期执行副作用,以及添加自定义方法,为类添加额外逻辑。
尽管在引入 React 钩子之后我们仍然可以使用类组件,但使用类组件有一些缺点!让我们来看看使用类组件时最常见的几个问题。
ES6 中的类
在 React 钩子出现之前,类组件是唯一能够处理状态和生命周期方法的组件,我们不得不将函数重构为类组件,以添加额外的功能。
例如,我们有一个简单的 div
,它作为一个按钮。
function Button() {
return <div className="btn">disabled</div>;
}
我们希望在用户点击按钮时,将其从 disabled
更改为 enabled
,并在发生这种情况时为按钮添加一些额外的 CSS 样式。
为此,我们需要为组件添加状态,以便知道按钮的状态是 enabled
还是 disabled
。这意味着我们需要彻底重构函数,使其成为一个跟踪按钮状态的类组件。
export default class Button extends React.Component {
constructor() {
super();
this.state = { enabled: false };
}
render() {
const { enabled } = this.state;
const btnText = enabled ? "enabled" : "disabled";
return (
<div
className={`btn enabled-${enabled}`}
onClick={() => this.setState({ enabled: !enabled })}
>
{btnText}
</div>
);
}
}
最终,我们的按钮按照我们的预期工作了!
在这个例子中,组件非常小,重构并不是什么大问题。然而,你实际的组件可能包含更多的代码,这使得重构组件变得困难起来。
除了在重构组件时要确保不意外改变任何行为外,你还需要 理解 ES6 中类的工作原理。为什么我们需要 bind
自定义方法?构造函数的作用是什么?this
关键字从何而来?如果不小心改变了数据流,很难正确地重构组件。
重构
在多个组件之间共享代码的常见方法是使用高阶组件(Higher Order Component)或渲染属性(Render Props)模式。
除了随着组件变大而使重构变得棘手外,在更深层次嵌套的组件之间共享代码,拥有许多包装组件可能会导致所谓的 包装地狱(wrapper hell) 。在打开开发工具时,看到类似以下的结构并不罕见:
<WrapperOne>
<WrapperTwo>
<WrapperThree>
<WrapperFour>
<WrapperFive>
<Component>
<h1>终于在组件中了!</h1>
</Component>
</WrapperFive>
</WrapperFour>
</WrapperThree>
</WrapperTwo>
</WrapperOne>
包装地狱 可能使理解数据在应用程序中的流动变得更加困难,这可能会使确定意外行为发生的原因变得更加困难。
复杂性
随着我们在类组件中添加更多逻辑,组件的大小会迅速增加。组件内的逻辑可能会变得 纠缠不清且无序,这可能会使开发人员难以理解在类组件中使用了哪些逻辑。这可能会使调试和优化变得更加困难。
让我们来看一个使用 Counter
组件和 Width
组件的示例。
import React from "react";
import "./styles.css";
import { Count } from "./Count";
import { Width } from "./Width";
export default class Counter extends React.Component {
constructor() {
super();
this.state = {
count: 0,
width: 0
};
}
componentDidMount() {
this.handleResize();
window.addEventListener("resize", this.handleResize);
}
componentWillUnmount() {
window.removeEventListener("resize", this.handleResize);
}
increment = () => {
this.setState(({ count }) => ({ count: count + 1 }));
};
decrement = () => {
this.setState(({ count }) => ({ count: count - 1 }));
};
handleResize = () => {
this.setState({ width: window.innerWidth });
};
render() {
return (
<div className="App">
<Count
count={this.state.count}
increment={this.increment}
decrement={this.decrement}
/>
<div id="divider" />
<Width width={this.state.width} />
</div>
);
}
}
App
组件的结构为以下形式:
尽管这是一个小组件,但组件内的逻辑已经相当纠缠。某些部分指向 counter
逻辑,其他部分属于 width
逻辑。随着组件的增长,可能会越来越难以在组件内组织逻辑,找到相关逻辑。
除了纠缠的逻辑外,我们还在生命周期方法中 重复 了一些逻辑。
钩子
很明显,类组件并不是 React 中的一个好特性。为了解决使用类组件时 React 开发者可能遇到的常见问题,React 引入了 React 钩子。React 钩子是你可以用来管理组件状态和生命周期方法的函数。React 钩子使得以下成为可能:
- 为函数组件添加状态
- 在不使用诸如
componentDidMount
和componentWillUnmount
等生命周期方法的情况下,管理组件的生命周期 - 在整个应用程序中多个组件之间重用相同的状态逻辑
首先,让我们来看看如何使用 React 钩子为函数组件添加状态。
State钩子
React 提供了一个在函数组件中管理状态的钩子,称为 useState
。
让我们看看如何将类组件重构为使用 useState
钩子的函数组件。我们有一个名为 Input
的类组件,它简单地渲染一个输入字段。当用户在输入字段中输入任何内容时,input
的值在状态中更新。
class Input extends React.Component {
constructor() {
super();
this.state = { input: "" };
this.handleInput = this.handleInput.bind(this);
}
handleInput(e) {
this.setState({ input: e.target.value });
}
render() {
return <input onChange={this.handleInput} value={this.state.input} />;
}
}
useState
方法期望一个参数:这是状态的初始值,在这个例子中是一个空字符串。
我们可以从 useState
方法中解构出两个值:
- 状态的 当前值。
- 我们可以用来 更新状态 的方法。
const [value, setValue] = React.useState(initialValue);
第一个值可以类比于类组件中的 this.state.[value]
。第二个值可以类比于类组件中的 this.setState
方法。
由于我们处理的是输入的值,让我们将状态的当前值称为 input
,将更新状态的方法称为 setInput
。初始值应该是一个空字符串。
const [input, setInput] = React.useState("");
我们现在可以将 Input
类组件重构为有状态的函数组件。
function Input() {
const [input, setInput] = React.useState("");
return <input onChange={(e) => setInput(e.target.value)} value={input} />;
}
输入字段的值等于 input
状态的当前值,就像在类组件示例中一样。当用户在输入字段中输入内容时,input
状态的值会相应更新。
import React, { useState } from "react";
export default function Input() {
const [input, setInput] = useState("");
return (
<input
onChange={e => setInput(e.target.value)}
value={input}
placeholder="输入一些内容..."
/>
);
}
Effect钩子
我们已经看到可以使用 useState
组件在函数组件中处理状态,但类组件的另一个好处是可以添加生命周期方法。
使用 useEffect
钩子,我们可以 “钩入” 组件的生命周期。useEffect
钩子实际上结合了 componentDidMount
、componentDidUpdate
和 componentWillUnmount
生命周期方法。
componentDidMount() { ... }
useEffect(() => { ... }, [])
componentWillUnmount() { ... }
useEffect(() => { return () => { ... } }, [])
componentDidUpdate() { ... }
useEffect(() => { ... })
让当用户在输入框中输入内容时,我们还希望将该值记录到控制台。
我们需要使用一个 useEffect
钩子,该钩子 “监听” input
值。我们可以通过将 input
添加到 useEffect
的 第二个参数 中来实现这一点。
useEffect(() => {
console.log(`用户输入了 ${input}`);
}, [input]);
让我们试试看!
import React, { useState, useEffect } from "react";
export default function Input() {
const [input, setInput] = useState("");
useEffect(() => {
console.log(`用户输入了 ${input}`);
}, [input]);
return (
<input
onChange={e => setInput(e.target.value)}
value={input}
placeholder="输入一些内容..."
/>
);
}
现在,每当用户输入值时,输入的值就会被记录到控制台。
自定义钩子
除了 React 提供的内置钩子(useState
、useEffect
、useReducer
、useRef
、useContext
、useMemo
、useImperativeHandle
、useLayoutEffect
、useDebugValue
、useCallback
)外,我们还可以轻松创建自己的自定义钩子。
你可能已经注意到,所有钩子都以 use
开头。
假设我们想要跟踪用户在输入时可能按下的某些键。
function useKeyPress(targetKey) {}
我们希望按键添加 keydown
和 keyup
事件监听器。如果用户按下了该键,即触发了 keydown
事件,钩子内的状态切换为 true
。
function useKeyPress(targetKey) {
const [keyPressed, setKeyPressed] = React.useState(false);
function handleDown({ key }) {
if (key === targetKey) {
setKeyPressed(true);
}
}
function handleUp({ key }) {
if (key === targetKey) {
setKeyPressed(false);
}
}
React.useEffect(() => {
window.addEventListener("keydown", handleDown);
window.addEventListener("keyup", handleUp);
return () => {
window.removeEventListener("keydown", handleDown);
window.removeEventListener("keyup", handleUp);
};
}, []);
return keyPressed;
}
完美!我们可以在输入应用程序中使用这个自定义钩子。让我们在用户按下 q
、l
或 w
键时记录到控制台。
import React from "react";
import useKeyPress from "./useKeyPress";
export default function Input() {
const [input, setInput] = React.useState("");
const pressQ = useKeyPress("q");
const pressW = useKeyPress("w");
const pressL = useKeyPress("l");
React.useEffect(() => {
console.log(`用户按下了 Q!`);
}, [pressQ]);
React.useEffect(() => {
console.log(`用户按下了 W!`);
}, [pressW]);
React.useEffect(() => {
console.log(`用户按下了 L!`);
}, [pressL]);
return (
<input
onChange={e => setInput(e.target.value)}
value={input}
placeholder="输入一些内容..."
/>
);
}
现在,我们不再将按键逻辑保持在 Input
组件,而是可以在多个组件中重用 useKeyPress
钩子,无需一遍又一遍地重写相同的逻辑。
React 钩子的另一个巨大优势是,社区可以构建和共享钩子。我们刚刚自己编写了 useKeyPress
钩子,但实际上这根本没必要!这个钩子已经被其他人构建好了,如果我们在应用程序中安装它,就可以直接使用!
以下是一些列出社区构建的所有钩子并在你的应用程序中直接使用的网站:
通过使用 React 钩子而不是类组件,我们能够将逻辑分解为更小、可重用的部分,分离了逻辑。
使用 React 钩子使 分离组件逻辑 变得更加清晰。重用 相同的有状态逻辑变得更加容易,我们不再需要将函数组件重写为类组件以使组件具有状态。对 ES2015 类的深入了解也不再必要,可重用的有状态逻辑提高了组件的可测试性、灵活性和可读性。
优点
代码行数更少
钩子允许你按关注点和功能组织代码,而不是按生命周期。这不仅使代码更简洁、更清晰。以下是一个使用 React 的简单有状态组件的可搜索产品数据表,以及使用 useState
关键字后的钩子版本。
有状态组件
class TweetSearchResults extends React.Component {
constructor(props) {
super(props);
this.state = {
filterText: "",
inThisLocation: false,
};
this.handleFilterTextChange = this.handleFilterTextChange.bind(this);
this.handleInThisLocationChange =
this.handleInThisLocationChange.bind(this);
}
handleFilterTextChange(filterText) {
this.setState({
filterText: filterText,
});
}
handleInThisLocationChange(inThisLocation) {
this.setState({
inThisLocation: inThisLocation,
});
}
render() {
return (
<div>
<SearchBar
filterText={this.state.filterText}
inThisLocation={this.state.inThisLocation}
onFilterTextChange={this.handleFilterTextChange}
onInThisLocationChange={this.handleInThisLocationChange}
/>
<TweetList
tweets={this.props.tweets}
filterText={this.state.filterText}
inThisLocation={this.state.inThisLocation}
/>
</div>
);
}
}
使用钩子的相同组件
const TweetSearchResults = ({ tweets }) => {
const [filterText, setFilterText] = useState("");
const [inThisLocation, setInThisLocation] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inThisLocation={inThisLocation}
setFilterText={setFilterText}
setInThisLocation={setInThisLocation}
/>
<TweetList
tweets={tweets}
filterText={filterText}
inThisLocation={inThisLocation}
/>
</div>
);
};
简化组件
类难以管理,使用热重载时不太方便,并且不能很好地压缩。React 钩子解决了这些问题。
复用状态逻辑
类鼓励继承,这会迅速增加整体复杂性和出错的可能性。然而,钩子允许你在不编写类的情况下使用状态。允许使用纯函数进行组合。
共享逻辑
在钩子实现之前,React 没有办法共享操作逻辑。这最终导致了复杂。但钩子的引入解决了这个问题,因为它允许将有状态逻辑提取到一个简单的函数中。
当然,使用钩子也有一些潜在的缺点需要注意:
- 必须遵守其规则,没有 linter 插件的情况下,很难知道违反了哪条规则。
- 需要相当多的时间练习才能正确使用(例如:
useEffect
)。 - 注意是否使用错误(例如:
useCallback
、useMemo
)。
React 钩子与类
当 React 引入钩子时,它带来了新的问题:我们如何知道何时使用带有钩子的函数组件,何时使用类组件?借助钩子,即使在函数组件中也可以获得状态和生命周期钩子。钩子还允许你在不编写类的情况下使用状态和其他 React 特性。
以下是钩子和类之间的一些区别,以帮助你做出决定:
React 钩子 | 类 |
---|---|
它有助于避免多层嵌套,使代码更加清晰 | 通常,当你使用 HOC 或渲染属性时,当你尝试在 DevTools 中查看时,需要使用多层嵌套重新构建你的应用程序 |
它为 React 组件提供了统一性 | 类由于需要理解绑定和函数调用的上下文,对人类和机器都造成了困惑 |