深入 useEffect

深入 useEffect

我们在之前的章节当中已经整体的梳理一遍 React 当中的几种状态逻辑复用的方式,从 Mixin 到 HOC,再到最后的 Hook,虽然 Hook 算是比较新的内容,但是它也已经渐渐开始变得普及起来

所以在本章当中,我们就来深入的了解一下 Hook 当中的 useEffect,主要参考的是官方 FAQ 当中的 如果我的 effect 的依赖频繁变化,我该怎么办?,更多详细内容可以参考文档

遇到的问题

首先先来看看我们在使用 useEffect 过程当中会遇到的一些问题,不过不用担心,这里涉及的问题我们都会在后面详细来进行介绍,涉及到的问题主要有以下几点

  • 如何用 useEffect 模拟 componentDidMount?
  • 如何在 useEffect 里请求数据?[] 又是什么?
  • 是否应该把函数当做 effect 的依赖?
  • 为什么有时候会出现无限重复请求的问题?
  • 为什么有时候在 effect 里拿到的是旧的 state 或 prop?

如何用 useEffect 模拟 componentDidMount?

如果我们想要模拟生命周期,虽然可以使用 useEffect(fn, []),但它们并不完全相等,和 componentDidMount 不一样,useEffect 会捕获 props 和 state,所以即便在回调函数里,我们拿到的还是初始的 props 和 state,如果我们想要得到最新的值,可以使用 ref 来进行操作,不过通常会有更简单的实现方式,所以并不一定要用 ref,关于这一点我们会在下面详细来进行介绍

但是我们需要注意的是 effect 的运行方式和 componentDidMount 以及其他生命周期是不同的,effect 更倾向于数据同步,而不是响应生命周期事件

如何在 useEffect 里请求数据?[] 又是什么?

如何请求数据相关内容可以参考 How to fetch data with React Hooks 这篇文章,十分详细,而 [] 表示 effect 没有使用任何 React 数据流里的值,因此该 effect 仅被调用一次是安全的

但是 [] 同样也是一类常见问题的来源,就是我们以为没使用数据流里的值但其实使用了,所以我们需要采用一些策略(useReducer/useCallback 等)来移除这些 effect 依赖,而不是错误地忽略它们

是否应该把函数当做 effect 的依赖?

一般建议把不依赖 props 和 state 的函数提到相关组件外面,并且把那些仅被 effect 使用的函数放到 effect 里面,这样做了以后,如果我们的 effect 还是需要用到组件内的函数(包括通过 props 传进来的函数),可以在定义它们的地方用 useCallback 包一层,为什么要这样做呢?

因为这些函数可以访问到 props 和 state,因此它们会参与到数据流中,更多相关内容可以参考官方提供的 FAQ

为什么有时候会出现无限重复请求的问题?

这个通常发生于我们在 effect 里做数据请求并且没有设置 effect 依赖参数的情况,如果没有设置依赖,effect 会在每次渲染后执行一次,然后在 effect 中更新了状态引起渲染并再次触发 effect,这样就形成了无限循环,但是也可能是因为我们设置的依赖总是在改变

我们可以通过一个一个移除的方式排查出哪个依赖导致了问题,但是移除所使用的依赖(或者盲目地使用 [])通常是一种错误的解决方式,举个例子,比如某个函数可能会导致这个问题,我们可以把它们放到 effect 里,或者提到组件外面,或者用 useCallback 包一层,或者使用 useMemo 等方式都可以避免重复生成对象

为什么有时候在 effect 里拿到的是旧的 state 或 prop?

effect 拿到的总是定义它的那次渲染中的 props 和 state(这个我们会在下面看到),之所以这样涉及是能够避免 一些问题,对于某些场景来说,我们可以明确地使用可变的 ref 来保存一些值,更多相关内容可以参考官方的 FAQ

我们在上面简单的介绍了在使用 useEffect 过程当中会遇到的一些问题,以及简单的处理方式,下面我们就来深入的了解一下 useEffect,看看这些问题出现的根本原因以及如何更为优雅的来解决它们,让我们先从渲染开始看起

渲染

我们先从一个简单是示例开始看起,也就是官方文档当中那个计时器的示例,我们稍微的调整一下,如下

1
2
3
4
5
6
7
8
9
function App() {
const [count, setCount] = useState(0)
return (
<div>
<p>点击了 {count} 次</p>
<button onClick={_ => setCount(count + 1)}>点击</button>
</div>
)
}

这里我们注意来看 {count} 当中的内容,这里的 count 并不是是 React 帮我们监听状态的变化并而自动更新的,它仅仅只是一个用于显示的数字,我们的组件在第一次渲染的时候,从 useState() 拿到 count 的初始值 0,当我们调用 setCount(1) 的时候,React 会再次渲染组件,这一次 count 是 1

当我们更新状态的时候,React 会重新渲染组件,每一次渲染都能拿到独立的 count 状态,这个状态值是函数中的一个常量,所以下面的这行代码没有做任何特殊的数据绑定

1
<p>点击了 {count} 次</p>

它仅仅只是在渲染输出中插入了 count 这个数字,这个数字由 React 提供,当我们调用 setCount 的时候,React 会带着一个不同的 count 值再次调用组件,然后 React 会更新 DOM 以保持和渲染输出一致

这里关键的点在于任意一次渲染中的 count 常量都不会随着时间改变,渲染输出会变是因为我们的组件被一次次调用,而每一次调用引起的渲染中,它包含的 count 值独立于其他渲染(关于这个过程更深入的探讨可以参考 React as a UI Runtime)

所以在这里我们可以发现,其实 React 当中的每一次渲染都有它自己的 props 和 state,有了这个了解以后我们接着往下看,那么事件处理函数呢?是否也是一样的呢?我们将上面的示例稍微调整一下,我们添加一个显示点击数的事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
const [count, setCount] = useState(0)

function handleAlertClick() {
setTimeout(() => {
alert(count)
}, 3000)
}

return (
<div>
<p>点击了 {count} 次</p>
<button onClick={_ => setCount(count + 1)}>点击</button>
<button onClick={handleAlertClick}>显示点击数</button>
</div>
)
}

如果我们首先将 count 增加到 3,然后在点击显示点击数的按钮,等待 3 秒以后可以发现结果是我们所设想的 3,但是如果我们先将 count 增加到 3,然后点击一下显示点击数的按钮,并且在定时器回调触发之前将 count 点击增加到 5,那么此时弹出的结果会是多少呢?是 5 还是 3 呢?

答案是 3,也就是这个值是我们点击时候的状态,所以在这里我们也可以发现,除开 props 和 state,每一次渲染也都有它自己的事件处理函数(How Are Function Components Different from Classes 这篇文章当中探讨了具体原因),但它究竟是如何工作的呢?

我们发现 count 在每一次函数调用中都是一个常量值,也就是说我们的组件函数每次渲染都会被调用,但是每一次调用中 count 值都是常量,并且它被赋予了当前渲染中的状态值,但是这并不是 React 所特有的,普通的函数也有类似的行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function sayHi(person) {
const name = person.name
setTimeout(() => {
alert(`Hi, ${name}`)
}, 3000)
}

let someone = { name: 'zhangsan' }
sayHi(someone)

someone = { name: 'lisi' }
sayHi(someone)

someone = { name: 'wangwu' }
sayHi(someone)

在上面这个例子中,外层的 someone 会被赋值很多次(就像在 React 中,当前的组件状态会改变一样),然后在 sayHi 函数中,局部常量 name 会和某次调用中的 person 关联,因为这个常量是局部的,所以每一次调用都是相互独立的,结果就是当定时器回调触发的时候,每一个 alert 都会弹出它拥有的 name

这就解释了我们的事件处理函数如何捕获了点击时候的 count 值,这是因为每一次渲染都有一个新版本的 handleAlertClick,每一个版本的 handleAlertClick 都记住它自己的 count,我们用伪代码表示如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function App() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert(0)
}, 3000)
}
<button onClick={handleAlertClick} /> // The one with 0 inside
}

function App() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert(1)
}, 3000)
}
<button onClick={handleAlertClick} /> // The one with 1 inside
}

function App() {
// ...
function handleAlertClick() {
setTimeout(() => {
alert(2)
}, 3000)
}
<button onClick={handleAlertClick} /> // The one with 2 inside
}

在任意一次渲染中,props 和 state 是始终保持不变的,如果 props 和 state 在不同的渲染中是相互独立的,那么使用到它们的任何值也是独立的(包括事件处理函数),它们都属于一次特定的渲染,即便是事件处理中的异步函数调用所得到的的也是这次渲染中的 count 值

Effect

我们在上面探讨了 props/state 以及事件处理函数,那么 effect 呢?其实 effect 并没有什么两样,我们还是以上面的示例为例,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function App() {
const [count, setCount] = useState(0)

useEffect(() => {
document.title = `${count}`
})

return (
<div>
<p>点击了 {count} 次</p>
<button onClick={_ => setCount(count + 1)}>点击</button>
</div>
)
}

结果可以发现,两处的 count 的值是同步修改的,那么我们可能会有些疑惑,那就是 effect 是如何读取到最新的 count 状态值的呢?其实这并不是 count 的值每次在 effect 当中发生了改变,而是 effect 函数本身在每一次渲染中都不相同,每一个 effect 版本当中的 count 值都来自于它属于的那次渲染,我们用伪代码表示如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
useEffect(() => {
document.title = `${0}`
})
}

function App() {
useEffect(() => {
document.title = `${1}`
})
}

function App() {
useEffect(() => {
document.title = `${2}`
})
}

React 会记住提供的 effect 函数,并且会在每次更改作用于 DOM 并让浏览器绘制屏幕后去调用它,所以虽然我们说的是一个 effect(这里指更新 document 的 title 的操作),但其实每次渲染都是一个不同的函数,并且每个 effect 函数获取到的 props 和 state 都来自于它属于的那次特定渲染,概念上我们可以想象 effect 是渲染结果的一部分,它属于某个特定的渲染,就像事件处理函数一样

所以在这里我们也可以发现,即每次渲染也都有它自己的 effect,但是如果我们将上面的示例调整为下面这样,结果又会是如何呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
const [count, setCount] = useState(0)

useEffect(() => {
setTimeout(() => {
console.log(`${count}`)
}, 3000)
})

return (
<div>
<p>点击了 {count} 次</p>
<button onClick={_ => setCount(count + 1)}>点击</button>
</div>
)
}

如果我们点击了很多次并且在 effect 里设置了延时,那么输出的结果会是什么呢?结果可能会出乎我们的意料,我们可能认为是某一个固定的值,但是运行后却可以发现,它会按顺序的输出每一次点击所对应的值,至于原因,也正是我们之前提及过的,因为每一个 effect 都属于某次特定的渲染,这里我们就不得不和 Class 当中的 this.state 来进行简单的对比了,如下

1
2
3
4
5
componentDidUpdate() {
setTimeout(() => {
console.log(`${this.state.count}`)
}, 3000)
}

如果调整成生命周期的模式,运行以后可以发现,this.state.count 总是指向最新的 count 值,而不是属于某次特定渲染的值,如果我们在定时器回调结束之前点击了 5 次,那么我们可以看到 5 次输出结果,并且每次输出打印的结果都是 5

到目前为止,综合我们上面所有的发现,可以明确地得出,即每一个组件内的函数(包括事件处理函数,effect,定时器或者 API 调用等等)都会捕获某次渲染中定义的 props 和 state,所以下面的两个例子是相等的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App(props) {
useEffect(() => {
setTimeout(() => {
console.log(props.counter)
}, 1000)
})
}

function App(props) {
const counter = props.counter
useEffect(() => {
setTimeout(() => {
console.log(counter)
}, 1000)
})
}

也就是说,在组件内什么时候去读取 props 或者 state 是无关紧要的,因为它们不会改变,在单次渲染的范围内,props 和 state 始终保持不变,当然有时候我们可能想在 effect 的回调函数里读取最新的值而不是捕获的值,最简单的实现方法是使用 refs(可以参考 How Are Function Components Different from Classes)

不过不推荐去进行这样的操作,因为需要从过去渲染中的函数里读取未来的 props 和 state,但是有时候可能也需要这样做

1
2
3
4
5
6
7
8
9
10
11
12
13
function App() {
const [count, setCount] = useState(0)
const latestCount = useRef(count)

useEffect(() => {
// Set the mutable latest value
latestCount.current = count
setTimeout(() => {
// Read the mutable latest value
console.log(`${latestCount.current}`)
}, 3000)
})
}

这样操作以后,我们可以发现点击 5 次以后,会输出 5 次最后操作的值,与我们上面 this.state.count 的计算结果是一致的,所以可以发现,在 Class 组件中 React 正是这样去修改 this.state 的,不像捕获的 props 和 state,我们没法保证在任意一个回调函数中读取的 latestCount.current 是不变的

Effect 中的清理

在 官方文档 当中曾经提及,有些 effect 可能需要有一个清理步骤,本质上它的目的是消除副作用,比如取消订阅,我们在之前也提到过,每一个组件内的函数(包括事件处理函数,effect,定时器或者 API 调用等等)都会捕获某次渲染中定义的 props 和 state

有了这些概念以后,我们来思考下面的代码

1
2
3
4
5
6
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.id, handleStatusChange)
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.id, handleStatusChange)
}
})

我们假设第一次渲染的时候 props 是 {id: 10},第二次渲染的时候是 {id: 20},我们可能会认为清理操作也是按顺序执行的,也就是首先清除了 {id: 10} 的 effect,然后渲染了 {id: 20} 的 UI,最后再次执行了 {id: 20} 的 effect,但是事实并不是这样

React 只会在 浏览器绘制 后运行 effect,这会使我们的应用更为流畅,因为大多数 effect 并不会阻塞屏幕的更新,effect 的清除同样被延迟了,上一次的 effect 会在重新渲染后被清除,也就是说首先会渲染 {id: 20} 的 UI,然后浏览器进行绘制,所以我们在屏幕上可以看到 {id: 20} 的 UI,最后才会按顺序依次去执行清除 {id: 10} 的 effect 和 {id: 20} 的 effect

这里可能有一点会让人疑惑的地方,那就是如果清除上一次的 effect 是发生在 props 变成 {id: 20} 之后,那它为什么还能获取到旧的 {id: 10} 呢?答案显而易见,effect 的清除并不会读取最新的 props,它只能读取到定义它的那次渲染中的 props 值,也就是我们之前提到过的,它的伪代码会是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// First render, props are {id: 10}
function App() {
// Effect from first render
useEffect(() => {
ChatAPI.subscribeToFriendStatus(10, handleStatusChange)
// Cleanup for effect from first render
return () => {
ChatAPI.unsubscribeFromFriendStatus(10, handleStatusChange)
}
})
// ...
}

// Next render, props are {id: 20}
function App() {
// Effect from second render
useEffect(() => {
ChatAPI.subscribeToFriendStatus(20, handleStatusChange)
// Cleanup for effect from second render
return () => {
ChatAPI.unsubscribeFromFriendStatus(20, handleStatusChange)
}
})
// ...
}

也就是说,第一次渲染中 effect 的清除函数其实只能看到 {id: 10} 这个 props,这也正是为什么 React 能做到在绘制后立即处理 effect,并且在默认情况下会使我们的应用运行更为流畅

同步

比如我们有下面这样一个组件

1
2
3
function App({ name }) {
return <h1 className="Greeting">Hello, {name}</h1>
}

我们首先渲染 <Greeting name="zhangsan" />,然后渲染 <Greeting name="lisi" />,和我们直接渲染 <Greeting name="lisi" /> 其实并没有什么区别,因为在这两种情况中,我们最后看到的结果都是 Hello, lisi

React 会根据我们当前的 props 和 state 同步到 DOM,所以我们应该以相同的方式去思考 effect,只不过 useEffect 可以使我们能够根据 props 和 state 同步 React tree 之外的东西而已

1
2
3
4
5
6
function App({ name }) {
useEffect(() => {
document.title = 'Hello, ' + name
})
return <h1 className="Greeting">Hello, {name}</h1>
}

这与我们所熟知的 mount/update/unmount 等生命周期是有一定区别的,不过如果在每一次渲染后都去运行所有的 effect 可能并不高效(并且在某些场景下,它可能会导致无限循环),所以我们该怎么解决这个问题呢?在这种情况下,我们就需要告诉 React 去比对我们的 Effect

我们都知道,React 的 diff 算法只会更新 DOM 真正发生改变的部分,而不是每次渲染都大动干戈,所以我们也可以使用这种类似的方式来处理 effect,比如下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App({ name }) {
const [counter, setCounter] = useState(0)

useEffect(() => {
document.title = 'Hello, ' + name
})

return (
<h1 className="Greeting">
Hello, {name}
<button onClick={() => setCounter(counter + 1)}>
Increment
</button>
</h1>
)
}

我们的 effect 并没有使用 counter 这个状态,所以我们的 effect 只会同步 name 属性给 document.title,但是 name 并没有变,所以在每一次 counter 改变后重新给 document.title 赋值并不是理想的做法,所以在这种情况下我们可以提供给 useEffect 一个依赖数组参数(deps)

1
2
3
useEffect(() => {
document.title = 'Hello, ' + name
}, [name])

如果当前渲染中的这些依赖项和上一次运行这个 effect 的时候值一样,因为没有什么需要同步,所以 React 会自动跳过这次 effect,但是需要注意,即使依赖数组中只有一个值在两次渲染中不一样,我们也不能跳过 effect 的运行,要同步所有

但是在平时我们可能经常会遇到下面这样的用法,也就是我们只是想在挂载的时候运行它一次而已,所以我们只传递一个 []

1
2
3
4
5
6
7
8
9
function App() {
async function fetchData() {
// ...
}

useEffect(() => {
fetchData()
}, [])
}

这样虽然在某些情况下可以解决问题,但是在官方的建议当中,如果我们设置了依赖项,effect 中用到的所有组件内的值都要包含在依赖中,这包括 props,state,函数,组件内的任何东西

有时候虽然我们也是这样操作的,但是却会遇到无限请求的问题,解决的方法当然不是移除依赖项,而是我们应该先尝试更好地理解这个问题,下面就让我们来深入的了解一下 effect 中的依赖项

依赖项

我们有没有考虑过,如果设置了错误的依赖会怎么样呢?我们都知道,如果依赖项包含了所有 effect 中使用到的值,React 就能知道何时需要运行它

1
2
3
useEffect(() => {
document.title = 'Hello, ' + name
}, [name])

但是如果我们将 [] 设为 effect 的依赖的话呢?可以发现,新的 effect 函数是不会运行的

1
2
3
useEffect(() => {
document.title = 'Hello, ' + name
}, [])

在这个例子当中,问题看起来显而易见,我们设置的依赖是 [],依赖没有变,所以不会再次运行 effect,也就是说运行了一次以后就停下来了,但是我们再来看看下面这个示例,情况就有些不太一样了

1
2
3
4
5
6
7
8
9
10
11
12
function App() {
const [count, setCount] = useState(0)

useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [])

return <h1>{count}</h1>
}

针对于这个示例,我们依然设置依赖为 [],因为直觉上我们就只想让其运行一次 effect,也就是帮助我们开启定时器即可,但是运行后的结果可能会出乎我们的意料,这个例子只会递增一次以后就停止了,因为在第一次渲染中,count 是 0,因此 setCount(count + 1) 在第一次渲染中等价于 setCount(0 + 1),但是同时我们设置了 [] 依赖,effect 不会再重新运行,所以其实它后面每一秒调用的都是 setCount(0 + 1)

其实仔细观察是可以发现我们的 effect 是依赖 count 的(但是我们仅仅只传递了一个 []),又因为 React 会对比依赖,而我们的依赖没有变,所以就不会再次运行 effect,也就是说会跳过后面的 effect,针对于上面这种情况,我们很容易的可以想到,我们直接在依赖中包含所有 effect 中用到的组件内的值不就可以了吗,我们来试试

1
2
3
4
5
6
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count])

问题的确是解决了,因为现在依赖数组正确了,但是我们仔细观察,却发现它可能不是太理想,因为现在每次 count 修改以后都会重新运行 effect,也就类似于下面这种情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// First render, state is 0
function App() {
// Effect from first render
useEffect(() => {
const id = setInterval(() => {
setCount(0 + 1)
}, 1000)
return () => clearInterval(id)
}, [0])
}

// Second render, state is 1
function App() {
// Effect from second render
useEffect(() => {
const id = setInterval(() => {
setCount(1 + 1)
}, 1000)
return () => clearInterval(id)
}, [1])
}

问题虽然解决了,但是可以发现我们的依赖是在一直发生着变化的,这就导致 effect 也会重新运行,也就是说我们的定时器会在每一次 count 改变后清除和重新设定,这明显这不是我们想要的结果,所以下面我们就来看看另外一种解决方案,也就是修改 effect 内部的代码以确保它包含的值只会在需要的时候发生变更,我们不再传递 [] 这种错误的依赖,而是修改 effect,让它的依赖变得更少

为了实现这个目的,我们需要问自己一个问题,那就是我们为什么要用 count 呢?可以看到我们只在 setCount 调用中用到了 count,在这个场景中,我们其实并不需要在 effect 中使用 count,之所以依赖它是因为我们在之前的 effect 中写了 setCount(count + 1),所以 count 成为了一个必需的依赖,但是其实我们真正想要的只是把 count 转换为 count + 1,然后返回给 React 而已,所以我们需要做的仅仅只是告知 React,让其去递增状态,而不用管它现在具体是什么值

在这种情况之下,我们就可以采用 Class 组件当中 setState() 的使用方式,因为在使用 setState() 的过程当中我们已经知晓,当我们想要根据前一个状态更新状态的时候,我们可以使用它的函数形式,这里当然也可以这么使用,如下

1
2
3
4
5
6
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1)
}, 1000)
return () => clearInterval(id)
}, [])

现在我们将更新值的方式调整成了 setCount(c => c + 1),同时也将依赖数组设置为了 [],再次运行后可以发现,程序可以正常运行,此时我们已经移除了依赖,我们的 effect 也就不再读取渲染中的 count 值了,而依赖为 [],它没有变化,所以不会再次运行 effect,尽管 effect 只运行了一次,但是在第一次渲染中的定时器回调函数可以完美地在每次触发的时候给 React 发送 c => c + 1 更新指令,它也不再需要知道当前的 count 值了,因为 React 已经知道了

然而,即使是 setCount(c => c + 1) 也并不完美,它看起来有点怪,并且非常受限于它能做的事,如果我们有两个互相依赖的状态,或者我们想基于一个 prop 来计算下一次的 state,它并不能做到,但是幸运的是,setCount(c => c + 1) 有一个更强大的模式,那就是 useReducer

useReducer

我们来修改上面的例子让它包含两个状态 count 和 step,我们的定时器会每次在 count 上增加一个 step 值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function App() {
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 后会重启定时器,因为它是依赖项之一,也就是清除上一次的 effect 然后重新运行新的 effect,但是如果我们不想在 step 改变后重启定时器,我们该如何从 effect 中移除对 step 的依赖呢?

当我们想更新一个状态的时候,并且这个状态更新依赖于另一个状态的值,这个时候我们就可以考虑使用 useReducer 去替换它们,reducer 可以让我们把组件内发生了什么(actions)和状态如何响应并更新分开表述,所以我们用一个 dispatch 依赖去替换 effect 的 step 依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const initialState = {
count: 0,
step: 1,
}

function reducer(state, action) {
const { count, step } = state
switch (action.type) {
case 'tick':
return {
...state,
count: count + step
}
case 'step':
return {
...state,
step: action.step
}
default:
return state
}
}

function App() {
const [state, dispatch] = useReducer(reducer, initialState)
const { count, step } = state

useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' })
}, 1000)
return () => clearInterval(id)
}, [dispatch])

return (
<>
<h1>{count}</h1>
<input value={step} onChange={e => {
dispatch({
type: 'step',
step: Number(e.target.value)
})
}} />
</>
)
}

因为 React 会保证 dispatch 在组件的声明周期内保持不变,所以在上面例子中我们也就不再需要重新订阅定时器

但是这里有一点需要注意的就是,我们可以从依赖中去除 dispatch,setState 和 useRef 包裹的值因为 React 会确保它们是静态的,不过我们设置了它们作为依赖也没什么问题(也就是说上面的 [dispatch] 可以简写为 [])

在上面的示例当中,相比于直接在 effect 里面读取状态,它 dispatch 了一个 action 来描述发生了什么,这使得我们的 effect 和 step 状态解耦,我们的 effect 不再关心怎么更新状态,它只负责告诉我们发生了什么,更新的逻辑全都交由 reducer 去统一处理

在上面我们已经知道如何移除 effect 的依赖,不管状态更新是依赖上一个状态还是依赖另一个状态,但假如我们需要依赖 props 去计算下一个状态,该如何处理呢?办法当然是有的,那就是把 reducer 函数放到组件内去读取 props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function Counter({ step }) {
const [count, dispatch] = useReducer(reducer, 0)

function reducer(state, action) {
switch (action.type) {
case 'tick':
return state + step
default:
return state
}
}

useEffect(() => {
const id = setInterval(() => {
dispatch({ type: 'tick' })
}, 1000)
return () => clearInterval(id)
}, [dispatch])

return <h1>{count}</h1>
}

function App() {
const [step, setStep] = useState(1)

return (
<>
<Counter step={step} />
<input value={step} onChange={e => setStep(Number(e.target.value))} />
</>
)
}

这种模式会使一些优化失效,所以我们应该避免滥用它,不过如果有需要完全可以在 reducer 里面访问 props,但是可能会疑惑,为什么在之前渲染中调用的 reducer 可以获取到最新的 props 呢?答案是当我们在 dispatch 的时候,React 只是记住了 action,它会在下一次渲染中再次调用 reducer,在那个时候,新的 props 就可以被访问到,而且 reducer 调用也不是在 effect 里的

函数

一个典型的误解是认为函数不应该成为依赖,比如下面这个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function App() {
const [data, setData] = useState({})

async function fetchData() {
const result = await axios('...')
setData(result.data)
}

useEffect(() => {
fetchData()
}, [])

// ...
}

上面的代码虽然可以正常工作,但这样做在组件日渐复杂的迭代过程中我们很难确保它在各种情况下还能正常运行,如果我们的代码做下面这样的分离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function App() {
function getFetchUrl() {
return '...'
}

async function fetchData() {
const result = await axios(getFetchUrl())
setData(result.data)
}

useEffect(() => {
fetchData()
}, [])

// ...
}

如果我们忘记去更新使用这些函数(很可能通过其他函数调用)的 effect 的依赖,我们的 effect 就不会同步 props 和 state 带来的变更,这当然不是我们想要的,幸运的是,对于这个问题有一个更为简单的解决方案,那就是如果某些函数仅在 effect 中调用,我们可以把它们的定义移到 effect 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {

// ...

useEffect(() => {
function getFetchUrl() {
return '...'
}
async function fetchData() {
const result = await axios(getFetchUrl())
setData(result.data)
}
fetchData()
}, [])

// ...
}

这样一来,我们不再需要去考虑这些间接依赖,因为在我们的 effect 中确实没有再使用组件范围内的任何东西,但是有时候我们可能不想把函数移入 effect 里,比如组件内有几个 effect 使用了相同的函数,我们不想在每个 effect 里复制黏贴一遍这个逻辑,也或许这个函数是一个 prop,比如下面这个示例,它就有两个 effect 会调用 getFetchUrl 的示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
function getFetchUrl(query) {
return '...' + query
}

useEffect(() => {
const url = getFetchUrl('react')
// ...
}, []) // 缺少依赖 getFetchUrl

useEffect(() => {
const url = getFetchUrl('redux')
// ...
}, []) // 缺少依赖 getFetchUrl

// ...
}

可以发现,我们的两个 effect 都依赖 getFetchUrl,但是如果我们将 getFetchUrl 添加到依赖数组当中,因为它每次渲染的内容都不同,所以我们的依赖数组会变得无用,一个可能的解决办法是把 getFetchUrl 从依赖中去掉,但是这并不是很好的解决方式,这会使我们后面对数据流的改变很难被发现从而忘记去处理,这会导致类似于我们之前的定时器不更新值的问题

相反的,我们有两个更简单的解决办法,第一个就是,如果一个函数没有使用组件内的任何值,我们应该把它提到组件外面去定义,然后就可以自由地在 effect 中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function getFetchUrl(query) {
return '...' + query
}

function App() {
useEffect(() => {
const url = getFetchUrl('react')
// ...
}, [])

useEffect(() => {
const url = getFetchUrl('redux')
// ...
}, [])

// ...
}

这样一来我们就不需要把它设为依赖,因为它们不在渲染范围内,因此不会被数据流影响,另外一种方式就是将其包装成 useCallback Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
const getFetchUrl = useCallback((query) => {
return '...' + query
}, [])

useEffect(() => {
const url = getFetchUrl('react')
// ...
}, [getFetchUrl])

useEffect(() => {
const url = getFetchUrl('redux')
// ...
}, [getFetchUrl])

// ...
}

useCallback 本质上是添加了一层依赖检查,它以另一种方式解决了问题,也就是我们使函数本身只在需要的时候才改变,而不是去掉对函数的依赖,这样一来如果 query 保持不变,getFetchUrl 也会保持不变,我们的 effect 也不会重新运行,但是如果 query 修改了,getFetchUrl 也会随之改变,因此会重新请求数据

同样的,对于通过属性从父组件传入的函数这个方法也适用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function Parent() {
const [query, setQuery] = useState('test')
const fetchData = useCallback(() => {
const url = '...' + query
// ...
}, [query])
return <Child fetchData={fetchData} />
}

function Child({ fetchData }) {
let [data, setData] = useState(null)
useEffect(() => {
fetchData().then(setData)
}, [fetchData])
// ...
}

因为 fetchData 只有在 Parent 的 query 状态变更时才会改变,所以我们的 Child 只会在需要的时候才去重新请求数据,使用 useCallback,函数完全可以参与到数据流中,我们可以说如果一个函数的输入改变了,这个函数就改变了,如果没有,函数也不会改变

类似的,useMemo 可以让我们对复杂对象做类似的事情

1
2
3
4
5
function App() {
const [color, setColor] = useState('pink')
const style = useMemo(() => ({ color }), [color])
return <Child style={style} />
}

但是到处使用 useCallback 是件挺笨拙的事,当我们需要将函数传递下去并且函数会在子组件的 effect 中被调用的时候,useCallback 是很好的技巧,但总的来说 Hook 本身能更好地避免 传递回调函数

竞态

下面是一个典型的在 Class 组件里发请求的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
class App extends Component {
state = {
article: null
}
componentDidMount() {
this.fetchData(this.props.id)
}
async fetchData(id) {
const article = await API.fetchArticle(id)
this.setState({ article })
}
// ...
}

仔细观察可能已经发现,上面的代码埋伏了一些问题,它并没有处理更新的情况,所以通常的解决方法是下面这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class App extends Component {
state = {
article: null
}
componentDidMount() {
this.fetchData(this.props.id)
}
componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.fetchData(this.props.id)
}
}
async fetchData(id) {
const article = await API.fetchArticle(id)
this.setState({ article })
}
// ...
}

这显然好多了!但依旧有问题,有问题的原因是请求结果返回的顺序不能保证一致,比如我们先请求 {id: 10},然后更新到 {id: 20},但 {id: 20} 的请求更先返回,请求更早但返回更晚的情况会错误地覆盖状态值,这被叫做竞态,这在混合了 async/await(假设在等待结果返回)和自顶向下数据流的代码中非常典型(props 和 state 可能会在 async 函数调用过程中发生改变)

effect 并没有神奇地解决这个问题,尽管它会警告我们让我们直接传了一个 async 函数给 effect,但是如果我们使用的异步方式支持取消,那我们就可以直接在清除函数中取消异步请求,或者最简单的权宜之计是用一个布尔值来跟踪它

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function App({ id }) {
const [article, setArticle] = useState(null)

useEffect(() => {
let didCancel = false

async function fetchData() {
const article = await API.fetchArticle(id)
if (!didCancel) {
setArticle(article)
}
}

fetchData()

return () => {
didCancel = true
}
}, [id])

// ...
}

关于如何处理错误和加载状态,以及抽离逻辑到自定义的 Hook 可以参考 在 React Hooks 中如何请求数据? 来了解更多

参考

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×