本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com
交代背景
一切起因皆是因为下面这段代码而起,大家可以先上个眼,后面会细说,线上地址戳 👉 codesandbox (https://codesandbox.io/s/useeffectzhi-nan-krlz2v?file=/src/App.js)
import React, { useState, useEffect } from 'react' function Article({ id }) { const [article, setArticle] = useState(null) useEffect(() => { let didCancel = false console.log('effect', didCancel) async function fetchData() { console.log('setArticle begin', didCancel) new Promise((resolve) => { setTimeout(() => { resolve(id) }, id); }).then(article => { // 快速点击 Add id 的 button,这里 didCancel 为什么会打印 true console.log('setArticle end', didCancel, article) // if (!didCancel) { // 把这一行代码注释就会出现错误覆盖状态值的情况 setArticle(article) // } }) } console.log('fetchData begin', didCancel) fetchData() console.log('fetchData end', didCancel) return () => { didCancel = true console.log('clear', didCancel) } }, [id]) return <div>{article}</div> } function App() { const [id, setId] = useState(5000) function handleClick() { setId(id-1000) } return ( <> <button onClick={handleClick}>add id</button> <Article id={id}/> </> ); } export default App;
关键代码是在 useEffect 中通过清除副作用函数来修改 didCancel 的值,再根据 didCancel 的值来判断是否立马执行 setState 的操作,其实就是为了解决 竞态 的情况。
竞态,就是在混合了 async/await 和自顶向下数据流的代码中(props 和 state 可能会在 async 函数调用过程中发生改变),出现错误覆盖状态值的情况
例如上面的例子,我们快速点击两次 button 后,在页面上我们会先看到 3000 ,再看到 4000 的结果,这就是因为状态为 4000 的先执行,但是更晚返回,所以会覆盖上一次的状态,所以我们最后看到的是 4000 。
接下来,我们先看两个前菜,纯函数和副作用
前菜一:纯函数
在程序设计中,若一个函数符合以下要求,则它可以被认为是纯函数:
此函数在相同的输入值时,需产生相同的输出。函数的
输出和输入值以外的其他隐藏信息或状态无关此函数不能有语义上可观察的函数
副作用
例如如下函数,接收两个入参,并且返回两个入参之和的值,并且没有使用外部的信息或状态
function add(a,b) { const total = a + b return total}console.log(add(1, 3))再看另外一个例子:
let a = 2function add(b) { const total = a + b return total}console.log(add(1, 3))上面这个函数也不是纯函数,因为a的值可能在外部被改变,从而导致add函数的返回值不一样。
前菜二:副作用
副作用指的是函数在执行过程中,除了返回可能的函数值之外,还对主调用函数产生附加的影响。
例如:修改了全局变量、修改了传入的参数、甚至是 console.log(), ajax 操作,直接修改 DOM,计时器函数,其他异步操作,其他会对外部产生影响的操作都是算作副作用。
let a = 2function add() { a = 3}console.log(add())我们运行上面的add函数,外部的变量a的值发生了改变,这就产生了副作用
Tips:console.log 也被称为副作用是因为它们会向控制台打印日志,控制台存在于函数外部
主菜:useEffect 清除副作用函数
什么时候执行清除函数
我们知道,如果在 useEffect 函数中返回一个函数,这个函数就是清除副作用函数,它会在组件销毁的时候执行,但是其实,它会在组件每次重新渲染时执行,并且先执行清除上一个 effect 的副作用。
思考下面的代码:
useEffect(() => { ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange); return () => { ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange); };});假如第一次渲染的时候 props 是 {id: 10},第二次渲染的时候是 { id: 20 }。你可能会认为发生了下面这些事:
React 清除了
{id: 10}的 effectReact 渲染
{id: 20}的 UIReact 运行
{id: 20}的 effect
(事实并不是这样)
React 只会在浏览器绘制后运行 effects。这使得你的应用更流畅因为大多数 effects 并不会阻塞屏幕的更新。Effect 的清除同样被延迟了,上一次的 effect 会在重新渲染后被清除:
React 渲染
{id: 20}的 UI浏览器绘制,在屏幕上看到
{id: 20}的 UIReact 清除
{id: 10}的 effectReact 运行
{id: 20}的 effect
这里就会出现让大家迷惑的点,如果清除上一次的 effect 发生在 props 变成{id: 20}之后,那它为什么还能拿到旧的{id: 10}
因为 React 组件内的每一个函数(包括事件处理函数,effects,定时器或者 API 调用等等)会捕获定义它们的那次渲染中的 props 和 state
所以,effect 的清除并不会读取最新的 props,它只能读取到定义它的那次渲染中的 props 值
什么时候需要使用清除函数
假如我们有一个 React 组件来获取和展示数据。如果我们的组件在我们的 Promise 解决之前卸载,useEffect 将尝试更新状态(在卸载的组件上)并发送如下所示的错误:
Warning Error
比如这个例子:
import React, { useState, useEffect } from 'react'function Child() { const [state, setState] = useState(null) const onClick = () => setState('foo') useEffect(() => { setTimeout(() => { setState('foo123') }, 5000); }, [state]) return ( <> { state } <button onClick={onClick}> child Change </button> </> );}function App() { const [status, setStatus] = useState(false) return ( <> <button onClick={() => setStatus(!status)}> toggle </button> { status && <Child /> } </> );}export default App;先点击toggle按钮让child组件显示,再点击child change按钮,然后立马点击toggle按钮让child组件销毁,等待几秒后就会报上述的错误了。为了修复这个错误,我们需要使用清理功能来解决它。
清除函数通常用于取消所有订阅以及取消获取请求。
回到最开始的 🌰
分析
回到我们最开始的例子,把注释掉的代码放开,就有了下面的分析。
第一次渲染后
function Article() { ... useEffect(() => { let didCancel = false async function fetchData() { new Promise((resolve) => { setTimeout(() => { resolve(id) }, id); }).then(article => { if (!didCancel) { setArticle(article) } }) } fetchData() }, [5000]) return () => { // 清除本次渲染副作用,给它编号 NO1,这里有个隐藏信息,此时这个函数内,还未执行前 didCancel = false didCancel = true }}// 等待 5s 后,页面显示 5000,可以在console.log('setArticle end', didCancel, article)这行代码上打上断点,我们可以更直观的分析接下来的操作 👉 快速点击两次button
/** 第一次点击,在页面绘制完成后,执行 useEffect 首先执行上一次的清除函数,即函数 NO1,NO1 将上一次 effect 闭包内的 didCancel 设置为了 true*/function Article() { ... useEffect(() => { let didCancel = false async function fetchData() { new Promise((resolve) => { setTimeout(() => { // setTimeout1 resolve(id) }, id); }).then(article => { if (!didCancel) { setArticle(article) } }) } fetchData() }, [4000]) return () => { // 清除本次渲染副作用,给它编号 NO2,这里有个隐藏信息,此时这个函数内作用域中的 didCancel = false didCancel = true }}从DevTools中可以看到:
image.png
/** 第二次点击,在页面绘制完成后,执行 useEffect 首先执行上一次的清除函数,即函数 NO2,NO2 将上一次 effect 闭包内的 didCancel 设置为了 true*/function Article() { ... useEffect(() => { let didCancel = false async function fetchData() { new Promise((resolve) => { setTimeout(() => { // setTimeout2 resolve(id) }, id); }).then(article => { if (!didCancel) { setArticle(article) } }) } fetchData() }, [3000]) return () => { // 清除本次渲染副作用,给它编号 NO3,这里有个隐藏信息,此时这个函数内作用域中的 didCancel = false didCancel = true }}从DevTools中可以看到:
image.png
结论
第二次点击后,setTimeout2 先执行完,此时 didCancel 值为 false,所以会执行setArticle的操作,页面展示3000,为什么这里的 didCancel 为 false 呢,因为此时 NO2 的清除函数没有执行,它会在组件下一次重新渲染,或者组件卸载时执行。
再等待差不多 1s 后,setTimeout2 执行完,此时 didCancel 的值被 NO2 的清除函数设置为了 true,所以它不会执行setArticle的操作。这样就不会出现,先看到4000然后再变成3000的情况。
解决竞态的方法
自定义 Hook
我们可以把上面通过维护一个布尔值来解决竞态的方式,写成一个自定义 Hook
// custom hookfunction useRaceConditions(fetchFn, deps) { useEffect(() => { let isCurrent = true const cleanEffect = fetchFn(() => isCurrent) return () => { isCurrent = !isCurrent // 如果 fetchFn 返回了函数,则在清除时执行 cleanEffect && cleanEffect() } }, deps)}// 上面的 Demo 代码就可以改成function Article({ id }) { const [article, setArticle] = useState(null) useRaceConditions((isCurrent) => { async function fetchData() { new Promise((resolve) => { setTimeout(() => { resolve(id) }, id); }).then(article => { if (isCurrent()) { setArticle(article) } }) } fetchData() }, [id]) return <div>{article}</div>}AbortController
AbortController接口表示一个控制器对象,允许你根据需求中止一个或多个 Web 请求。AbortController.abort()能够中止fetch请求及任何响应体的消费和流。
我们先使用AbortController构造函数创建一个控制器,然后使用AbortController.signal熟悉获取其关联AbortSignal对象的引用。
当一个fetch reuqest初始化,我们把AbortSignal作为一个选项传递到请求对象,这将signal和controller与这个fetch request相关联,然后允许我们通过调用AbortController.abort()中止请求
function Article({ id }) { const [article, setArticle] = useState(null) useEffect(() => { const controller = new AbortController() let signal = controller.signal async function fetchData() { try { const response = await fetch('https://autumnfish.cn/search?keywords=%E5%AD%A4%E5%8B%87%E8%80%85', {signal}) const newData = await response.json() setArticle(id) } catch (error) { if (error.name === 'AbortError') { console.log('Handling error thrown by aborting request') } } } fetchData() return () => { controller.abort() } }, [id]) return <div>{article}</div>}Axios CancelToken
axios 中使用 cancel token 取消请求。可以使用CancelToken.source工厂方法创建cancel token
function Article({ id }) { const [article, setArticle] = useState(null) useEffect(() => { const CancelToken = axios.CancelToken const source = CancelToken.source() async function fetchData() { try { await axios.get('https://autumnfish.cn/search?keywords=%E5%AD%A4%E5%8B%87%E8%80%85', { cancelToken: source.token }) setArticle(id) } catch (error) { if (axios.isCancel(error)) { console.log('Request canceled', error.message) } else { console.log('其他错误') } } } fetchData() return () => { source.cancel() } }, [id]) return <div>{article}</div>}甜品:useEffect 请求数据的方式
使用 async/await 获取数据
// 有同学想在组件挂在时请求初始化数据,可能就会用下面的写法function App() { const [data, setData] = useState() useEffect(async () => { const result = await axios('/api/getData') setData(result.data) })}但是我们会发现,在控制台中有警告信息:
image.png
意思就是在 useEffect 中不能直接使用 async,因为 async 函数声明定义一个异步函数,该函数默认会返回一个隐式 Promise,但是,在 effect hook 中我们应该不返回任何内容或者返回一个清除函数。所以我们可以改成下面这样
function App() { const [data, setData] = useState() useEffect(() => { const fetchData = async () => { const result = await axios( '/api/getData', ); setData(result.data); }; fetchData(); })}准确告诉 React 你的依赖项
function Greeting({ name }) { const [counter, setCounter] = useState(0); useEffect(() => { document.title = 'Hello, ' + name; }); return ( <h1 class> Hello, {name} <button onClick={() => setCounter(counter + 1)}>Increment</button> </h1> );}我们每次点击 button 使 counter+1 的时候,effect hook 都会执行,这是没必要的,我们可以将name加到 effect 的依赖数组中,相当于告诉 React,当我name的值变化时,你帮我执行 effect 中的函数。
如果我们在依赖中添加所有 effect 中用到的组件内的值,有时效果也不太理想。比如:
useEffect(() => { const id = setInterval(() => { setCount(count+1) }, 1000) return () => clearInterval(id)}, [count])虽然,每次 count 变化时会触发 effect 执行,但是每次执行时定时器会重新创建,效果不是最理想。我们添加count依赖,是因在setCount调用中用到了count,其他地方并没有用到count,所以我们可以将setCount的调用改成函数形式,让setCount在每次定时器更新时,自己就能拿到当前的count值。所以在 effect 依赖数组中,我们可以踢掉count
useEffect(() => { const id = setInterval(() => { setCount(count => count+1) }, 1000) return () => clearInterval(id)}, [])解耦来自 Actions 的更新
我们修改上面的例子让它包含两个状态:count和step
function Counter() { const [count, setCount] = useState(0); const [step, setStep] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + step); }, 1000); return () => clearInterval(id); }, [step]); return ( <> <h1>{count}</h1> <input value={step} onChange={e => setStep(Number(e.target.value))} /> </> );}此时,我们修改step又会重启定时器,因为它是依赖性之一。假如我们不想在step改变后重启定时器呢,该如何从 effect 中移除对step的依赖。
当你想更新一个状态,并且这个状态更新依赖于另一个状态的时候,在例子中就是count依赖step,我们可以用useReducer去替换它们
function Counter() { const [state, dispatch] = useReducer(reducer, initState) const { count, step } = state const initState = { count: 0, step: 1 } function reducer(state, action) { const { count, step } = state switch (action.type) { case 'tick': return { count: count + step, step } case 'step': return { count, step: action.step } default: throw new Error() } } useEffect(() => { const id = setInterval(() => { dispatch({ type: 'tick' }) }, 1000); return () => clearInterval(id); }, [dispatch]); return ( <> <h1>{count}</h1> <input value={step} onChange={e => setStep(Number(e.target.value))} /> </> );}上面代码中将dispatch作为 effect 依赖不会每次都触发 effect 的执行,因为 React 会保证dispatch在组件的声明周期内保持不变,所以不会重新创建定时器。
你可以从依赖中去除
dispatch,setState,useRef包裹的值,因为 React 会确保它们是静态的
相比于直接在 effect 里面读取状态,它dispatch了一个action来描述发生了什么,这使得我们的 effect 和 step 状态解耦。我们的 effect 不再关心怎么更新状态,它只负责告诉我们发生了什么。更新的逻辑全都交由reducer去统一处理
当你 dispatch 的时候,React 只是记住了 action,它会在下一次渲染中再次调用 reducer,所以 reducer 可以访问到组件中最新的
props
总结
本文从一段实例代码为切入点,引入useEffect清除函数,介绍了它的执行顺序,以及为什么需要清除函数,由此分析了实例代码中的解决竞态的方法,最后讨论useEffect种常见请求数据的方法。
主要是想帮助大家重新理解和认识useEffect,以及在useEffect中请求数据需要注意的地方,如上述内容有错误,请不吝指出。
参考链接
https://developer.mozilla.org/zh-CN/docs/Web/API/AbortController
https://zh.wikipedia.org/wiki/纯函数
https://zh.wikipedia.org/wiki/副作用_(计算机科学)
https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/