React17 Hook
Hook简介
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
1 | import React, { useState } from 'react'; |
没有破坏性改动
在我们继续之前,请记住 Hook 是:
- 完全可选的。 你无需重写任何已有代码就可以在一些组件中尝试 Hook。但是如果你不想,你不必现在就去学习或使用 Hook。
- 100% 向后兼容的。 Hook 不包含任何破坏性改动。
- 现在可用。 Hook 已发布于 v16.8.0。
没有计划从 React 中移除 class。 你可以在本页底部的章节读到更多关于 Hook 的渐进策略。
Hook 不会影响你对 React 概念的理解。 恰恰相反,Hook 为已知的 React 概念提供了更直接的 API:props, state,context,refs 以及生命周期。稍后我们将看到,Hook 还提供了一种更强大的方式来组合他们。
动机
Hook 解决了各种各样看起来不相关的问题。
在组件之间复用状态逻辑很难
React 没有提供将可复用性行为“附加”到组件的途径(例如,把组件连接到 store)。
如果你使用过 React 一段时间,你也许会熟悉一些解决此类问题的方案,比如 render props 和 高阶组件。但是这类方案需要重新组织你的组件结构,这可能会很麻烦,使你的代码难以理解。
如果你在 React DevTools 中观察过 React 应用,你会发现由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成“嵌套地狱”。
你可以使用 Hook 从组件中提取状态逻辑,使得这些逻辑可以单独测试并复用。Hook 使你在无需修改组件结构的情况下复用状态逻辑。 这使得在组件间或社区内共享 Hook 变得更便捷。
复杂组件变得难以理解
我们经常维护一些组件,组件起初很简单,但是逐渐会被状态逻辑和副作用充斥。每个生命周期常常包含一些不相关的逻辑。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。如此很容易产生 bug,并且导致逻辑不一致。
在多数情况下,不可能将组件拆分为更小的粒度,因为状态逻辑无处不在。同时,这也是很多人将 React 与状态管理库结合使用的原因之一。但是,这往往会引入了很多抽象概念,需要你在不同的文件之间来回切换,使得复用变得更加困难。
为了解决这个问题,Hook 将组件中相互关联的部分拆分成更小的函数(比如设置订阅或请求数据),而并非强制按照生命周期划分。你还可以使用 reducer 来管理组件的内部状态,使其更加可预测。
难以理解的 class
我们还发现 class 是学习 React 的一大屏障。你必须去理解 JavaScript 中 this
的工作方式,这与其他语言存在巨大差异。还不能忘记绑定事件处理器。
没有稳定的语法提案,这些代码非常冗余。大家可以很好地理解 props,state 和自顶向下的数据流,但对 class 却一筹莫展。即便在有经验的 React 开发者之间,对于函数组件与 class 组件的差异也存在分歧,甚至还要区分两种组件的使用场景。class 也给目前的工具带来了一些问题。例如,class 不能很好的压缩,并且会使热重载出现不稳定的情况。因此,我们想提供一个使代码更易于优化的 API。
为了解决这些问题,Hook 使你在非 class 的情况下可以使用更多的 React 特性。 从概念上讲,React 组件一直更像是函数。而 Hook 则拥抱了函数,同时也没有牺牲 React 的精神原则。Hook 提供了问题的解决方案,无需学习复杂的函数式或响应式编程技术。
渐进策略
没有计划从 React 中移除 class
Hook 和现有代码可以同时工作,你可以渐进式地使用他们。 不用急着迁移到 Hook。我们建议避免任何“大规模重写”,尤其是对于现有的、复杂的 class 组件。
我们准备让 Hook 覆盖所有 class 组件的使用场景,但是我们将继续为 class 组件提供支持。
Hook 概览
📌State Hook
这个例子用来显示一个计数器。当你点击按钮,计数器的值就会增加:
1 | import React, { useState } from 'react'; |
useState
就是一个Hook,通过在函数组件里调用它来给组件添加一些内部 state。React 会在重复渲染时保留这个 state。useState
会返回一对值:当前状态和一个让你更新它的函数,你可以在事件处理函数中或其他一些地方调用这个函数。
它类似 class 组件的 this.setState
,但是它不会把新的 state 和旧的 state 进行合并。
useState
唯一的参数就是初始 state。在上面的例子中,我们的计数器是从零开始的,所以初始 state 就是 0
。这个初始 state 参数只有在第一次渲染时会被用到。
声明多个 state 变量
你可以在一个组件中多次使用 State Hook:
1 | function ExampleWithManyStates() { |
数组解构的语法让我们在调用 useState
时可以给 state 变量取不同的名字。当然,这些名字并不是 useState
API 的一部分。React 假设当你多次调用 useState
的时候,你能保证每次渲染时它们的调用顺序是不变的。
什么是 Hook?
Hook 是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。
Hook 不能在 class 组件中使用 —— 这使得你不使用 class 也能使用 React。
React 内置了一些像 useState
这样的 Hook。你也可以创建你自己的 Hook 来复用不同组件之间的状态逻辑。
⚡Effect Hook
你之前可能已经在 React 组件中执行过数据获取、订阅或者手动修改过 DOM。我们统一把这些操作称为“副作用”,或者简称为“作用”。
useEffect
就是一个 Effect Hook,给函数组件增加了操作副作用的能力。它跟 class 组件中的 componentDidMount
、componentDidUpdate
和 componentWillUnmount
具有相同的用途,只不过被合并成了一个 API。
例如,下面这个组件在 React 更新 DOM 后会设置一个页面标题:
1 | import React, { useState, useEffect } from 'react'; |
当你调用 useEffect
时,就是在告诉 React 在完成对 DOM 的更改后运行你的“副作用”函数。由于副作用函数是在组件内声明的,所以它们可以访问到组件的 props 和 state。
默认情况下,React 会在每次渲染后调用副作用函数 —— 包括第一次渲染的时候。
副作用函数还可以通过返回一个函数来指定如何“清除”副作用。例如,在下面的组件中使用副作用函数来订阅好友的在线状态,并通过取消订阅来进行清除操作:
1 | import React, { useState, useEffect } from 'react'; |
在这个示例中,React 会在组件销毁时取消对 ChatAPI
的订阅,然后在后续渲染时重新执行副作用函数。
跟 useState
一样,你可以在组件中多次使用 useEffect
:
1 | function FriendStatusWithCounter(props) { |
✌️Hook 使用规则
Hook 就是 JavaScript 函数,但是使用它们会有两个额外的规则:
- 只能在函数最外层调用 Hook。不要在循环、条件判断或者子函数中调用。
- 只能在 React 的函数组件中调用 Hook。不要在其他 JavaScript 函数中调用。(还有一个地方可以调用 Hook —— 就是自定义的 Hook 中,我们稍后会学习到。)
💡自定义 Hook
有时候我们会想要在组件之间重用一些状态逻辑。目前为止,有两种主流方案来解决这个问题:高阶组件和 render props。自定义 Hook 可以让你在不增加组件的情况下达到同样的目的。
以前文提到的FriendStatus
组件为例,假设我们想在另一个组件里重用这个订阅逻辑。
首先,我们把这个逻辑抽取到一个叫做 useFriendStatus
的自定义 Hook 里:
1 | import React, { useState, useEffect } from 'react'; |
现在我们可以在两个组件中使用它:
1 | function FriendStatus(props) { |
1 | function FriendListItem(props) { |
每个组件间的 state 是完全独立的。Hook 是一种复用状态逻辑的方式,它不复用 state 本身。事实上 Hook 的每次调用都有一个完全独立的 state —— 因此你可以在单个组件中多次调用同一个自定义 Hook。
自定义 Hook 更像是一种约定而不是功能。如果函数的名字以 “use
” 开头并调用其他 Hook,我们就说这是一个自定义 Hook。 useSomething
的命名约定可以让我们的 linter 插件在使用 Hook 的代码中找到 bug。
🔌其他 Hook
除此之外,还有一些使用频率较低的但是很有用的 Hook。比如,useContext
让你不使用组件嵌套就可以订阅 React 的 Context。
1 | function Example() { |
另外 useReducer
可以让你通过 reducer 来管理组件本地的复杂 state。
1 | function Todos() { |
使用 State Hook
等价的 class 示例
如果你之前在 React 中使用过 class,这段代码看起来应该很熟悉:
1 | class Example extends React.Component { |
Hook 和函数组件
React 的函数组件是这样的:
1 | const Example = (props) => { |
你之前可能把它们叫做“无状态组件”。但现在我们为它们引入了使用 React state 的能力,所以我们更喜欢叫它”函数组件”。
Hook 在 class 内部是不起作用的。但你可以使用它们来取代 class 。
声明 State 变量
在 class 中,我们通过在构造函数中设置 this.state
为 { count: 0 }
来初始化 count
state 为 0
:
1 | class Example extends React.Component { |
在函数组件中,我们没有 this
,所以我们不能分配或读取 this.state
。我们直接在组件中调用 useState
Hook:
1 | import React, { useState } from 'react'; |
发生了什么?
我们声明了一个叫 count
的 state 变量,然后把它设为 0
。React 会在重复渲染时记住它当前的值,并且提供最新的值给我们的函数。我们可以通过调用 setCount
来更新当前的 count
。
调用 useState
方法的时候做了什么?
它定义一个 “state 变量”,这是一种在函数调用时保存变量的方式 —— useState
是一种新方法,它与 class 里面的 this.state
提供的功能完全相同。一般来说,在函数退出后变量就会”消失”,而 state 中的变量会被 React 保留。
useState
需要哪些参数?
useState()
方法里面唯一的参数就是初始 state。不同于 class 的是,我们可以按照需要使用数字或字符串对其进行赋值,而不一定是对象。
useState
方法的返回值是什么?
返回值为:当前 state 以及更新 state 的函数。
这就是我们写 const [count, setCount] = useState()
的原因。这与 class 里面 this.state.count
和 this.setState
类似,唯一区别就是你需要成对的获取它们。
读取 State
当我们想在 class 中显示当前的 count,我们读取 this.state.count
:
1 | <p>You clicked {this.state.count} times</p> |
在函数中,我们可以直接用 count
:
1 | <p>You clicked {count} times</p> |
更新 State
在 class 中,我们需要调用 this.setState()
来更新 count
值:
1 | <button onClick={() => this.setState({ count: this.state.count + 1 })}> |
在函数中,我们已经有了 setCount
和 count
变量,所以我们不需要 this
:
1 | <button onClick={() => setCount(count + 1)}> |
方括号有什么用?
1 | const [count, setCount] = useState(0); |
这种 JavaScript 语法叫数组解构。它意味着我们同时创建了 fruit
和 setFruit
两个变量,fruit
的值为 useState
返回的第一个值,setFruit
是返回的第二个值。
它等价于下面的代码:
1 | var fruitStateVariable = useState('banana'); // 返回一个有两个元素的数组 |
不必使用多个 state 变量
State 变量可以很好地存储对象和数组,因此,你仍然可以将相关数据分为一组。
然而,不像 class 中的 this.setState
,更新 state 变量总是替换它而不是合并它。
使用 Effect Hook
Effect Hook 可以让你在函数组件中执行副作用操作。
比如为计数器增加了一个小功能:将 document 的 title 设置为包含了点击次数的消息。
1 | import React, { useState, useEffect } from 'react'; |
TIP
数据获取,设置订阅以及手动更改 React 组件中的 DOM 都属于副作用。
TIP
如果你熟悉 React class 的生命周期函数,你可以把
useEffect
Hook 看做componentDidMount
,componentDidUpdate
和componentWillUnmount
这三个函数的组合。
在 React 组件中有两种常见副作用操作:需要清除的和不需要清除的。
无需清除的 effect
有时候,我们只想在 React 更新 DOM 之后运行一些额外的代码。比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。
使用 class 的示例
在 React 的 class 组件中,render
函数是不应该有任何副作用的。一般来说,在这里执行操作太早了,我们基本上都希望在 React 更新 DOM 之后才执行我们的操作。
这就是为什么在 React class 中,我们把副作用操作放到 componentDidMount
和 componentDidUpdate
函数中。
1 | componentDidMount() { |
在这个 class 中,我们需要在两个生命周期函数中编写重复的代码。
从概念上说,我们希望它在每次渲染之后执行 —— 但 React 的 class 组件没有提供这样的方法。即使我们提取出一个方法,我们还是要在两个地方调用它。
使用 Hook 的示例
1 | useEffect(() => { |
useEffect
做了什么?
通过使用这个 Hook,你可以告诉 React 组件需要在渲染后执行某些操作。React 会保存你传递的函数(我们将它称之为 “effect”),并且在执行 DOM 更新之后调用它。
为什么在组件内部调用 useEffect
?
将 useEffect
放在组件内部让我们可以在 effect 中直接访问 count
state 变量。
我们不需要特殊的 API 来读取它 —— 它已经保存在函数作用域中。Hook 使用了 JavaScript 的闭包机制,而不用在 JavaScript 已经提供了解决方案的情况下,还引入特定的 React API。
useEffect
会在每次渲染后都执行吗?
是的,默认情况下,它在第一次渲染之后和每次更新之后都会执行。React 保证了每次运行 effect 的同时,DOM 都已经更新完毕。
需要清除的 effect
还有一些副作用是需要清除的。例如订阅外部数据源。这种情况下,清除工作是非常重要的,可以防止引起内存泄露。
使用 Class 的示例
在 React class 中,你通常会在 componentDidMount
中设置订阅,并在 componentWillUnmount
中清除它。
1 | componentDidMount() { |
你会注意到 componentDidMount
和 componentWillUnmount
之间相互对应。使用生命周期函数迫使我们拆分这些逻辑代码,即使这两部分代码都作用于相同的副作用。
使用 Hook 的示例
由于添加和删除订阅的代码的紧密性,所以 useEffect
的设计是在同一个地方执行。如果你的 effect 返回一个函数,React 将会在执行清除操作时调用它:
1 | useEffect(() => { |
为什么要在 effect 中返回一个函数?
这是 effect 可选的清除机制。每个 effect 都可以返回一个清除函数。如此可以将添加和移除订阅的逻辑放在一起。它们都属于 effect 的一部分。
React 何时清除 effect?
React 会在组件卸载的时候执行清除操作。正如之前学到的,effect 在每次渲染的时候都会执行。这就是为什么 React 会在执行当前 effect 之前对上一个 effect 进行清除。
TIP
为 effect 中返回的函数命名不是必须的
使用 Effect 的提示
提示: 使用多个 Effect 实现关注点分离
使用 Hook 其中一个目的就是要解决 class 中生命周期函数经常包含不相关的逻辑,但又把相关逻辑分离到了几个不同方法中的问题。
就像你可以使用多个 state 的 Hook 一样,你也可以使用多个 effect。这会将不相关逻辑分离到不同的 effect 中:
1 | const [count, setCount] = useState(0); |
Hook 允许我们按照代码的用途分离他们, 而不是像生命周期函数那样。
React 将按照 effect 声明的顺序依次调用组件中的每一个 effect。
解释: 为什么每次更新的时候都要运行 Effect
忘记正确地处理 componentDidUpdate
是 React 应用中常见的 bug 来源。
effect并不需要特定的代码来处理更新逻辑,因为 useEffect
默认就会处理。
此默认行为保证了一致性,避免了在 class 组件中因为没有处理更新逻辑而导致常见的 bug。
提示: 通过跳过 Effect 进行性能优化
在某些情况下,每次渲染后都执行清理或者执行 effect 可能会导致性能问题。
在 class 组件中,我们可以通过在 componentDidUpdate
中添加对 prevProps
或 prevState
的比较逻辑解决:
1 | componentDidUpdate(prevProps, prevState) { |
这是很常见的需求,所以它被内置到了 useEffect
的 Hook API 中。如果某些特定值在两次重渲染之间没有发生变化,你可以通知 React 跳过对 effect 的调用,只要传递数组作为 useEffect
的第二个可选参数即可。
1 | useEffect(() => { |
当渲染时,如果 count
的值更新成了 6
,React 将会把前一次渲染时的数组 [5]
和这次渲染的数组 [6]
中的元素进行对比。这次因为 5 !== 6
,React 就会再次调用 effect。如果数组中有多个元素,即使只有一个元素发生变化,React 也会执行 effect。
对于有清除操作的 effect 同样适用。
Hook 规则
只在最顶层使用 Hook
不要在循环,条件或嵌套函数中调用 Hook, 确保总是在你的 React 函数的最顶层调用他们。遵守这条规则,你就能确保 Hook 在每一次渲染中都按照同样的顺序被调用。
只在 React 函数中调用 Hook
不要在普通的 JavaScript 函数中调用 Hook。你可以:
- ✅ 在 React 的函数组件中调用 Hook
- ✅ 在自定义 Hook 中调用其他 Hook
自定义 Hook
通过自定义 Hook,可以将组件逻辑提取到可重用的函数中。
目前为止,在 React 中有两种流行的方式来共享组件之间的状态逻辑: render props 和高阶组件,现在让我们来看看 Hook 是如何在让你不增加组件的情况下解决相同问题的。
提取自定义 Hook
当我们想在两个函数之间共享逻辑时,我们会把它提取到第三个函数中。而组件和 Hook 都是函数,所以也同样适用这种方式。
自定义 Hook 是一个函数,其名称以 “use
” 开头,函数内部可以调用其他的 Hook。
与 React 组件不同的是,自定义 Hook 不需要具有特殊的标识。我们可以自由的决定它的参数是什么,以及它应该返回什么。
换句话说,它就像一个正常的函数。但是它的名字应该始终以 use
开头,这样可以一眼看出其符合 Hook 的规则。
使用自定义 Hook
1 | function FriendStatus(props) { |
自定义 Hook 是一种自然遵循 Hook 设计的约定,而并不是 React 的特性。
自定义 Hook 必须以 “use
” 开头吗?
必须如此。这个约定非常重要。不遵循的话,由于无法判断某个函数是否包含对其内部 Hook 的调用,React 将无法自动检查你的 Hook 是否违反了 Hook 的规则。
在两个组件中使用相同的 Hook 会共享 state 吗?
不会。自定义 Hook 是一种重用状态逻辑的机制(例如设置为订阅并存储当前值),所以每次使用自定义 Hook 时,其中的所有 state 和副作用都是完全隔离的。
自定义 Hook 如何获取独立的 state?
每次调用 Hook,它都会获取独立的 state。由于我们直接调用了 useFriendStatus
,从 React 的角度来看,我们的组件只是调用了 useState
和 useEffect
。 正如我们在之前章节中了解到的一样,我们可以在一个组件中多次调用 useState
和 useEffect
,它们是完全独立的。
Hook API 索引
基础 Hook
useState
1 | const [state, setState] = useState(initialState); |
1 | setState(newState); |
useEffect
1 | useEffect(didUpdate); |
1 | useEffect(() => { |
useContext
1 | const MyContext = React.createContext(contextValue); |
useContext
的参数必须是 context 对象本身:
- 正确:
useContext(MyContext)
- 错误:
useContext(MyContext.Consumer)
- 错误:
useContext(MyContext.Provider)
额外的 Hook
useReducer
1 | const [state, dispatch] = useReducer(reducer, initialArg, init); |
useState
的替代方案。它接收一个形如 (state, action) => newState
的 reducer,并返回当前的 state 以及与其配套的 dispatch
方法。(参考Redux)
useCallback
1 | const memoizedCallback = useCallback( |
useMemo
1 | const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]); |
useRef
1 | const refContainer = useRef(initialValue); |
useImperativeHandle
1 | useImperativeHandle(ref, createHandle, [deps]) |
useLayoutEffect
useDebugValue
1 | useDebugValue(value) |
底层原理
React 是如何把对 Hook 的调用和组件联系起来的?
React 保持对当先渲染中的组件的追踪。多亏了 Hook 规范,我们得知 Hook 只会在 React 组件中被调用。
每个组件内部都有一个「记忆单元格」列表。它们只不过是我们用来存储一些数据的 JavaScript 对象。当你用 useState()
调用一个 Hook 的时候,它会读取当前的单元格(或在首次渲染时将其初始化),然后把指针移动到下一个。这就是多个 useState()
调用会得到各自独立的本地 state 的原因。
Hook 使用了哪些现有技术?
Hook 由不同的来源的多个想法构成:
- [react-future](https://github.com/reactjs/react-future/tree/master/07 - Returning State) 这个仓库中包含我们对函数式 API 的老旧实验。
- React 社区对 render prop API 的实验,其中包括 Ryan Florence 的 Reactions Component 。
- Dominic Gannaway 的用
adopt
关键字 作为 render props 的语法糖的提案。 - DisplayScript 中的 state 变量和 state 单元格。
- ReasonReact 中的 Reducer components。
- Rx 中的 Subscriptions。
- Multicore OCaml 提到的 Algebraic effects。