前言
Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
早期的写法以 Class 类组件为主,附带一些纯用于展示的函数组件,但是函数组件是不能控制自身的状态的。Hooks 写法引入之后,函数组件的写法开始流行起来。
函数组件引入了多种钩子函数如 useEffect、useState、useRef、useCallback、useMemo、useReducer 等等,通过这些钩子函数来管理函数组件的各自状态。
下面就通过一些例子来简单了解部分 hook。
1、新建 hooks-demo 工程项目
我的 node 版本是 v12.13.0
,npm 版本是 6.12.0
。
执行下面命令:
npm init @vitejs/app hooks-demo --template react
执行完之后,安装依赖运行
npm install npm run dev
2、基础 Hook
useState
函数内声明变量,可以通过 useState
方法,它接受一个参数,可以为默认值,也可以为一个函数。
修改 App.jsx
,改成下面代码:
import React, { useState } from 'react' function App() { const [data, setData] = useState([1, 2, 3, 4, 5]) return ( <div className="kaimo-app"> { data.map((item, index) => <div key={index}>kaimo:{item}</div>) } </div> ) } export default App
页面显示结果如下:
useEffect
useEffect() 的作用就是指定一个副效应函数,组件每渲染一次,该函数就自动执行一次。组件首次在网页 DOM 加载后,副效应函数也会执行。
无需清除的 effect
在 React 更新 DOM 之后运行一些额外的代码。比如发送网络请求,手动变更 DOM,记录日志,这些都是常见的无需清除的操作。
需要清除的 effect
还有一些副作用是需要清除的。例如订阅外部数据源。这种情况下,清除工作是非常重要的,可以防止引起内存泄露!
useEffect 的用法
我们先通过 useEffect 副作用,模拟请求一个接口数据。
import React, { useState, useEffect } from 'react' // 模拟数据接口,2 秒后返回数据 const getDataList = () => { return new Promise((resolve, reject) => { setTimeout(() => { resolve([6, 7, 8, 9, 10]) }, 2000) }) } function App() { const [data, setData] = useState([1, 2, 3, 4, 5]) useEffect(() => { (async() => { const kaimo = await getDataList() console.log('useEffect:kaimo', kaimo) setData(kaimo) })() }) return ( <div className="kaimo-app"> { data.map((item, index) => <div key={index}>kaimo:{item}</div>) } </div> ) } export default App
页面显示结果:
我们发现函数组件默认进来之后,会执行 useEffect 中的回调函数,但是当 setData 执行之后,App 组件再次刷新,刷新之后会再次执行 useEffect 的回调函数,变成一个死循环了。
useEffect 的第二个参数
有时候,我们不希望 useEffect() 每次渲染都执行,这时可以使用它的第二个参数,使用一个数组指定副效应函数的依赖项,只有依赖项发生变化,才会重新渲染。
function Welcome(props) { useEffect(() => { document.title = `Hello, ${props.name}`; }, [props.name]); return <h1>Hello, {props.name}</h1>; }
上面例子中,useEffect() 的第二个参数是一个数组,指定了第一个参数(副效应函数)的依赖项(props.name)。只有该变量发生变化时,副效应函数才会执行。
如果第二个参数是一个空数组,就表明副效应参数没有任何依赖项。
因此,副效应函数这时只会在组件加载进入 DOM 后执行一次,后面组件重新渲染,就不会再次执行。这很合理,由于副效应不依赖任何变量,所以那些变量无论怎么变,副效应函数的执行结果都不会改变,所以运行一次就够了。
我们把副作用的部分加上空数组试试:
useEffect(() => { (async() => { const kaimo = await getDataList() console.log('useEffect:kaimo', kaimo) setData(kaimo) })() }, [])
我们发现只会执行一次,不会再出现死循环了。
给请求一个 query 参数
下面我们给请求加一个 query 参数,代码如下:
import React, { useState, useEffect } from 'react' // 模拟数据接口,2 秒后返回数据 const getDataList = (query) => { return new Promise((resolve, reject) => { setTimeout(() => { console.log('kaimo-query', query); resolve([6, 7, 8, 9, 10]) }, 2000) }) } function App() { const [data, setData] = useState([1, 2, 3, 4, 5]) const [query, setQuery] = useState('') useEffect(() => { (async() => { const kaimo = await getDataList(query) console.log('useEffect:kaimo', kaimo) setData(kaimo) })() }, [query]) return ( <div className="kaimo-app"> <input type="text" placeholder='请输入查询参数' onChange={e => setQuery(e.target.value)} /> { data.map((item, index) => <div key={index}>kaimo:{item}</div>) } </div> ) } export default App
然后我输入 1 改变 query 的值,副作用函数便会被执行,结果如下:
如果你的接口有查询参数,可以将参数设置在 useEffect 的第二个参数的数组值中,这样改变查询变量的时候,副作用便会再次触发执行,相应的函数也会重新带着最新的参数,获取接口数据。
useContext
const value = useContext(MyContext);
接收一个 context 对象(React.createContext 的返回值)并返回该 context 的当前值。当前的 context 值由上层组件中距离当前组件最近的 <MyContext.Provider> 的 value prop 决定。
当组件上层最近的 <MyContext.Provider> 更新时,该 Hook 会触发重渲染,并使用最新传递给 MyContext provider 的 context value 值。即使祖先使用 React.memo 或 shouldComponentUpdate,也会在组件本身使用 useContext 时重新渲染。
下面修改 App.jsx 代码为:
import React, { useContext } from 'react' const themes = { light: { foreground: "#fff", background: "orange" }, dark: { foreground: "#000", background: "green" } }; const ThemeContext = React.createContext(themes.light); function ThemedButton() { const theme = useContext(ThemeContext); console.log('ThemedButton', theme) return ( <button style={{ background: theme.background, color: theme.foreground }}> Kaimo Theme Button </button> ); } function Toolbar() { return ( <div> <ThemedButton /> </div> ); } function App() { return ( <ThemeContext.Provider value={themes.dark}> <Toolbar /> </ThemeContext.Provider> ); } export default App
我们发现按钮用的是 dark 的主题:
3、自定义 Hook
接下来,我们把上面的模拟接口请求抽离成一个自定义 hook
,新建 useKaimoApi.jsx
,在里面添加
import { useState, useEffect } from 'react' // 模拟数据接口,2 秒后返回数据 const getDataList = (query) => { return new Promise((resolve, reject) => { setTimeout(() => { console.log('kaimo-query', query); resolve([6, 7, 8, 9, 10]) }, 2000) }) } // 自定义 hook const useKaimoApi = () => { const [data, setData] = useState([1, 2, 3, 4, 5]) const [query, setQuery] = useState('') useEffect(() => { (async() => { const kaimo = await getDataList(query) console.log('useEffect:kaimo', kaimo) setData(kaimo) })() }, [query]) return [data, setQuery] } export default useKaimoApi
App.jsx
中也修改一下:
import React from 'react' import useKaimoApi from './useKaimoApi' function App() { const [data, setQuery] = useKaimoApi() return ( <div className="kaimo-app"> <input type="text" placeholder='请输入查询参数' onChange={e => setQuery(e.target.value)} /> { data.map((item, index) => <div key={index}>kaimo:{item}</div>) } </div> ) } export default App
结果是一样的:
我们可通过自定义 Hook 的形式,把公共逻辑抽离出来复用,这也是之前 Class 类组件不能做到的。
4、额外的 Hook
useMemo
返回一个 memoized
值。
把“创建”函数和依赖项数组作为参数传入 useMemo
,它仅会在某个依赖项改变时才重新计算 memoized
值。这种优化有助于避免在每次渲染时都进行高开销的计算。
下面我们尝试修改一下 App.jsx
里的代码:
import React, { useState, useEffect } from 'react' // 在内部新增一个子组件,子组件接收父组件传进来的一个对象,作为子组件的 useEffect 的第二个依赖参数。 function Child({data}) { useEffect(() => { console.log('kaimo-child:查询条件', data) }, [data]) return <div className='kaimo-child'>子组件</div> } function App() { const [name, setName] = useState('') const [phone, setPhone] = useState('') const [kw, setKw] = useState('') const kaimoData = { name, phone } return ( <div className="kaimo-app"> <input type="text" placeholder='请输入姓名' onChange={e => setName(e.target.value)} /> <input type="text" placeholder='请输入电话' onChange={e => setPhone(e.target.value)} /> <input type="text" placeholder='请输入关键词' onChange={e => setKw(e.target.value)} /> <Child data={kaimoData} /> </div> ) } export default App
下面我们依次在输入框里输入1,2,3,发现输入 kw 为 3 的时候,发现也执行了 useEffect 内的回调函数,而子组件并没有监听 kw 的变化。
这个时候我们可以通过 useMemo
将 data 包装一下,告诉 data 它需要监听的值。
import React, { useState, useEffect, useMemo } from 'react' ... const kaimoData = useMemo(() => ({ name, phone }), [name, phone]) ...
添加 useMemo 之后,我们发现 kw 输入 3 的时候,不会在触发。它相当于把父组件需要传递的参数做了一个标记,无论父组件其他状态更新任何值,都不会影响要传递给子组件的对象。
useCallback
const memoizedCallback = useCallback( () => { doSomething(a, b); }, [a, b], );
返回一个 memoized 回调函数。
把内联回调函数及依赖项数组作为参数传入 useCallback,它将返回该回调函数的 memoized 版本,该回调函数仅在某个依赖项改变时才会更新。
useCallback 也是和 useMemo 有类似的功能。
下面我们传递一个函数给子组件,修改 App.jsx 如下所示:
import React, { useState, useEffect } from 'react' // 在内部新增一个子组件,子组件接收父组件传进来的一个对象,作为子组件的 useEffect 的第二个依赖参数。 function Child({callback}) { useEffect(() => { callback(); }, [callback]) return <div className='kaimo-child'>子组件</div> } function App() { const [name, setName] = useState('') const [phone, setPhone] = useState('') const [kw, setKw] = useState('') const kaimoCallback = () => { console.log('kaimo-child:查询条件', { name, phone }) } return ( <div className="kaimo-app"> <input type="text" placeholder='请输入姓名' onChange={e => setName(e.target.value)} /> <input type="text" placeholder='请输入电话' onChange={e => setPhone(e.target.value)} /> <input type="text" placeholder='请输入关键词' onChange={e => setKw(e.target.value)} /> <Child callback={kaimoCallback} /> </div> ) } export default App
当我们修改任何状态值,都会触发子组件的回调函数执行。
我们给要传递的函数,包裹一层 useCallback,如下所示:
import React, { useState, useEffect, useCallback } from 'react' ... const kaimoCallback = useCallback(() => { console.log('kaimo-child:查询条件', { name, phone }) }, []) ...
无论修改其他任何属性,都不会触发子组件的副作用:
useCallback 的第二个参数同 useEffect 和 useMemo 的第二个参数,它是用于监听你需要监听的变量,如在数组内添加 name、phone、kw 等参数,当改变其中有个,都会触发子组件副作用的执行。
其他更多可以查看Hook API 索引
5、重新认识 useEffect
先来看一个例子:我们修改一下 App.jsx
import React, { useEffect, useState } from 'react' function App() { const [count, setCount] = useState(0) const handleClick = () => { setTimeout(() => { console.log('kaimo 点击次数: ' + count); }, 2000); } return ( <div className="kaimo-app"> <button onClick={() => setCount(count + 1)}>点击{count}次</button> <button onClick={handleClick}>展示点击次数</button> </div> ) } export default App
我们先按下面的步骤操作:
- 点击增加按钮两次,将 count 增加到 2。
- 然后点击展示点击次数按钮。
- 在
console.log
执行之前,再次点击新增按钮 2 次,将 count 增加到 4。
按照正常的思路,浏览器应该打印出 kaimo 点击次数: 4
。
我们发现打印出来的是 kaimo 点击次数: 2
。
这是因为函数组件 App,在每一次渲染都会被调用,而每一次调用都会形成一个独立的上下文,可以理解成一个快照。每一次渲染形成的快照,都是互相独立的。
用一份伪代码来解释,大致如下:
// 默认初始化 function App() { const count = 0; // useState 返回默认值 // ... function handleClick() { setTimeout(() => { console.log('kaimo 点击次数: ' + count); }, 2000); } // ... } // 第一次点击 function App() { const count = 1; // useState 返回值 // ... function handleClick() { setTimeout(() => { console.log('kaimo 点击次数: ' + count); }, 2000); } // ... } // 第二次点击 function App() { const count = 2; // useState 返回值 // ... function handleClick() { setTimeout(() => { console.log('kaimo 点击次数: ' + count); }, 2000); } // ... }
我们可以知道,每次渲染函数组件时,useEffect 都是新的,都是不一样的。
我们改造一下代码:
import React, { useEffect, useState } from 'react' function App() { const [count, setCount] = useState(0) useEffect(() => { setTimeout(() => { console.log('kaimo 点击次数: ' + count); }, 2000); }) return ( <div className="kaimo-app"> <button onClick={() => setCount(count + 1)}>点击{count}次</button> </div> ) } export default App
我们连续点击4次,每一次点击,都会重新执行 useEffect 内的回调,并且 count 值也是当时的快照的一个常量值。