前言
# 前言
React
早期的写法以 Class
类组件为主,附带一些纯用于展示的函数组件,但是函数组件是不能控制自身的状态的。
直到 16.8 版本出来之后,引入了全新的 Hooks
写法,这让之前的类写法就显得 比较累赘,函数组件的写法开始流行起来。函数组件引入了多种钩子函数如 useEffect
、useState
、useRef
、useCallback
、useMemo
、useReducer
等等,通过这些钩子函数来管理函数组件的各自状态。
# 正文
本章节我会通过一个请求,带大家入门整个 React Hook
知识体系。首先我们需要创建一个空项目,由于本实验采用的是 Vite 2.0
作为脚手架工具,所以我们的 Node
版本必须要在 12.0.0
以上,目前我的版本是 12.6.0
。
我们通过指令新建一个联手项目,如下所示:
# npm 6.x
npm init @vitejs/app hooks-demo --template react
# npm 7+, 需要额外的双横线:
npm init @vitejs/app hooks-demo -- --template react
# yarn
yarn create @vitejs/app hooks-demo --template react
根据你的需求,选择上述三个其中一个。新建之后项目目录如下所示:
npm install
npm run dev
如下所示:
看到如上述所示代表项目已经启动成功了。
# useState
接下来我们清空 App.jsx
,添加如下代码:
import React, { useState } from 'react'
function App() {
const [data, setData] = useState([1, 2, 3, 4, 5])
return (
<div className="App">
{
data.map((item, index) => <div key={index}>{item}</div>)
}
</div>
)
}
export default App
函数内声明变量,可以通过 useState
方法,它接受一个参数,可以为默认值,也可以为一个函数。上述我们先分析默认值的情况,默认给一个数组 [1, 2, 3, 4, 5]
,data
参数便可以直接在 JSX
模板中使用。
# useEffect
此时,我们通过 useEffect
副作用,请求一个接口数据,如下所示:
import React, { useEffect, useState } from 'react'
// 模拟数据接口,3 秒钟返回数据。
const getList = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([6, 7, 8, 9, 10])
}, 3000)
})
}
function App() {
const [data, setData] = useState([1, 2, 3, 4, 5])
useEffect(() => {
(async () => {
const data = await getList()
console.log('data', data)
setData(data)
})()
})
return (
<div className="App">
{
data.map((item, index) => <span key={index}>{item}</span>)
}
</div>
)
}
export default App
函数组件默认进来之后,会执行 useEffect
中的回调函数,但是当 setData
执行之后,App
组件再次刷新,刷新之后会再次执行 useEffect
的回调函数,这便会形成一个可怕的死循环,回调函数会一直被这样执行下去。
所以这里引出 useEffect
的第二个参数。它是一个数组,数组内接收回调函数内使用到的状态参数,一旦在组件内改变了状态参数,则会触发副作用 useEffect
的回调函数执行。
所以我们如果传一个空数组 []
,则该副作用只会在组件渲染的时候,执行一次,如下所示:
useEffect(() => {
(async () => {
const data = await getList()
console.log('data', data)
setData(data)
})()
}, [])
执行一次之后,副作用不再被触发。
此时我们需要给请求一个 query
参数,如下所示:
import React, { useEffect, useState } from 'react'
const getList = (query) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('query', query)
resolve([6, 7, 8, 9, 10])
}, 3000)
})
}
function App() {
const [data, setData] = useState([1, 2, 3, 4, 5])
const [query, setQuery] = useState('')
useEffect(() => {
(async () => {
const data = await getList(query)
console.log('data', data)
setData(data)
})()
}, [query])
return (
<div className="App">
{
data.map((item, index) => <span key={index}>{item}</span>)
}
<input onChange={(e) => setQuery(e.target.value)} type="text" placeholder='请输入搜索值' />
</div>
)
}
export default App
此时我们改变 query
的值,副作用函数便会被执行,如下所示:
所以,如果你的接口有查询参数,可以将参数设置在 useEffect
的第二个参数的数组值中,这样改变查询变量的时候,副作用便会再次触发执行,相应的函数也会重新带着最新的参数,获取接口数据。
# 自定义 Hook
我们可以将上述的请求,抽离成一个自定义 hook
,方便在多个地方调用,新建 useApi.js
如下所示:
import React, { useEffect, useState } from 'react'
// 模拟请求
const getList = (query) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('query', query)
resolve([6, 7, 8, 9, 10])
}, 3000)
})
}
// 自定义 hook
const useApi = () => {
const [data, setData] = useState([1, 2, 3, 4, 5])
const [query, setQuery] = useState('')
useEffect(() => {
(async () => {
const data = await getList()
console.log('data', data)
setData(data)
})()
}, [query])
return [{ data }, setQuery];
}
export default useApi
如上述所示,最终将 data
数据,和设置请求参数的方法抛出,在 App.jsx
中做如下改动:
import React from 'react'
import useApi from './useApi'
function App() {
const [{ data }, setQuery] = useApi()
return (
<div className="App">
{
data.map((item, index) => <span key={index}>{item}</span>)
}
<input onChange={(e) => setQuery(e.target.value)} type="text" placeholder='请输入搜索值' />
</div>
)
}
export default App
我们查看浏览器展示结果:
上述这类自定义 Hook
的使用,在开发中也非常常见,比如有一个请求公共数据的接口,在多个页面中被重复使用,你便可通过自定义 Hook
的形式,将请求逻辑提取出来公用,这也是之前 Class
类组件所不能做到的。
# useMemo
我们修改 App.jsx
,在内部新增一个子组件,子组件接收父组件传进来的一个对象,作为子组件的 useEffect
的第二个依赖参数。
import React, { useEffect, useState } from 'react'
function Child({ data }) {
useEffect(() => {
console.log('查询条件:', data)
}, [data])
return <div>子组件</div>
}
function App() {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [kw, setKw] = useState('')
const data = {
name,
phone
}
return (
<div className="App">
<input onChange={(e) => setName(e.target.value)} type="text" placeholder='请输入姓名' />
<input onChange={(e) => setPhone(e.target.value)} type="text" placeholder='请输入电话' />
<input onChange={(e) => setKw(e.target.value)} type="text" placeholder='请输入关键词' />
<Child data={data} />
</div>
)
}
export default App
当我们修改姓名和电话的时候,观察子组件是否监听到依赖的变化,执行 useEffect
内的回调函数。
此时,上述的结果是我们预期的,我们只监听了 name
和 phone
两个参数,但是我们修改关键词输入框,会得到下面的结果。
子组件并没有监听 kw
的变化,但是结果却是子组件也被触发渲染了。原因其实是我们在父组件重新 setKw
之后,data
值和未作修改 kw
前的值已经不一样了。你可能会说,data
的值并没有变化,为什么说它已经不一样了呢?详细的分析我们放在后续部分,我们此时可以通过 useMemo
将 data
包装一下,告诉 data
它需要监听的值。
import React, { useEffect, useState, useMemo } from 'react'
function Child({ data }) {
useEffect(() => {
console.log('查询条件:', data)
}, [data])
return <div>子组件</div>
}
function App() {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [kw, setKw] = useState('')
const data = useMemo(() => ({
name,
phone
}), [name, phone])
return (
<div className="App">
<input onChange={(e) => setName(e.target.value)} type="text" placeholder='请输入姓名' />
<input onChange={(e) => setPhone(e.target.value)} type="text" placeholder='请输入电话' />
<input onChange={(e) => setKw(e.target.value)} type="text" placeholder='请输入关键词' />
<Child data={data} />
</div>
)
}
export default App
效果如下:
这便是 useMemo
的作用,它相当于把父组件需要传递的参数做了一个标记,无论父组件其他状态更新任何值,都不会影响要传递给子组件的对象。
# useCallback
同理,useCallback
也是和 useMemo
有类似的功能,比如我们传递一个函数给子组件,如下所示:
import React, { useEffect, useState, useCallback } from 'react'
function Child({ callback }) {
useEffect(() => {
callback()
}, [callback])
return <div>子组件</div>
}
function App() {
const [name, setName] = useState('')
const [phone, setPhone] = useState('')
const [kw, setKw] = useState('')
const callback = () => {
console.log('我是callback')
}
return (
<div className="App">
<input onChange={(e) => setName(e.target.value)} type="text" placeholder='请输入姓名' />
<input onChange={(e) => setPhone(e.target.value)} type="text" placeholder='请输入电话' />
<input onChange={(e) => setKw(e.target.value)} type="text" placeholder='请输入关键词' />
<Child callback={callback} />
</div>
)
}
export default App
当我们修改任何状态值,都会触发子组件的回调函数执行,但是 callback
没有作任何变化。
此时,我们给要传递的函数,包裹一层 useCallback
,如下所示:
const callback = useCallback(() => {
console.log('我是callback')
}, [])
无论修改其他任何属性,都不会触发子组件的副作用:
useCallback 的第二个参数同
useEffect
和useMemo
的第二个参数,它是用于监听你需要监听的变量,如在数组内添加name
、phone
、kw
等参数,当改变其中有个,都会触发子组件副作用的执行。
所以,useMemo
和 useCallback
,都能为「重复渲染」这个问题,提供很好的帮助。
# 重新认识 useEffect
上述很多现象,都是因为你没有很好地去理解 React Hooks
函数组件写法的渲染机制。通过一个小例子,我们来重新认识 useEffect
。
我们将上述 App.jsx
作如下修改:
import React, { useEffect, useState } from 'react'
function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
setTimeout(() => {
console.log('点击次数: ' + count);
}, 3000);
}
return (
<div className="App">
<button onClick={() => setCount(count + 1)}>点击{count}次</button>
<button onClick={handleClick}>展示点击次数</button>
</div>
)
}
export default App
我们作下列几个动作:
1、点击增加按钮两次,将 count
增加到 2。
2、点击「展示点击次数」。
3、在 console.log
执行之前,也就是 3 秒内,再次点击新增按钮 2 次,将 count
增加到 4。
按照正常的思路,浏览器应该打印出 点击次数: 4
,我们来查看浏览器的展示效果:
点击「展示点击次数」按钮,3 秒后,我们看到的结果是 点击次数: 2
,这与我们的预期有出入。
函数组件 App
,在每一次渲染都会被调用,而每一次调用都会形成一个独立的上下文,可以理解成一个快照。每一次渲染形成的快照,都是互相独立的。
默认进来的时候,形成一个快照,此时 count
为 0;当我们点击新增按钮第一次,执行 setCount
,函数组件被刷新一次,此时的快照中,count
为 1;再次点击按钮,再次生成快照,此时的 count
为 2,此时点击 「展示点击次数」按钮,在这份快照中,我们的 count
参数就是 2。所以我们后面无论怎么新增 count
,最终输出的结果 count
就是 2。
我们用一份伪代码来解释,大致如下:
// 默认初始化
function App() {
const count = 0; // useState 返回默认值
// ...
function handleClick() {
setTimeout(() => {
console.log('点击次数: ' + count);
}, 3000);
}
// ...
}
// 第一次点击
function App() {
const count = 1; // useState 返回值
// ...
function handleClick() {
setTimeout(() => {
console.log('点击次数: ' + count);
}, 3000);
}
// ...
}
// 第二次点击
function App() {
const count = 2; // useState 返回值
// ...
function handleAlertClick() {
setTimeout(() => {
console.log('点击次数: ' + count);
}, 3000);
}
// ...
}
上述代码中,第二次点击的快照中,console.log('点击次数: ' + count);
取的便是 const count = 2
。
同理,我们可以直到,每次渲染函数组件时,useEffect
都是新的,都是不一样的。我们对上面的写法稍作改动。
import React, { useEffect, useState } from 'react'
function App() {
const [count, setCount] = useState(0)
useEffect(() => {
setTimeout(() => {
console.log('点击次数: ' + count);
}, 3000);
})
return (
<div className="App">
<button onClick={() => setCount(count + 1)}>点击{count}次</button>
</div>
)
}
export default App
每一次点击,都会重新执行 useEffect
内的回调,并且 count
值也是当时的快照的一个常量值。
这和之前的类组件是不同的,我们改成类组件的实现形式如下:
import React from 'react'
export default class App extends React.Component {
constructor(props) {
super(props)
this.state = {
count: 0
}
}
componentDidUpdate() {
setTimeout(() => {
console.log('点击次数: ' + this.state.count);
}, 3000);
}
render() {
return <button onClick={() => this.setState({ count: this.state.count + 1 })}>点击{this.state.count}次</button>
}
}
类组件,声明之后,会在内部生成一个实例 instance
,所有的数据都会存在类的上下文中,所以 this.state.count
会一直指向最新的 count
值。
说到这里,大家应该对 React Hooks
的函数组件写法有了新的认识。
# 总结
行文至此,希望让同学们能好好地阅读和学习本章节的内容,以及课后对 React Hooks
的拓展。更好的理解它,有助于写出可维护、可拓展的代码,技术本身是服务于业务需求的,但是你不能很好的利用技术的特点,那业务也很难达到做满意的效果。