React Hooks 是 React 16.8 的新功能,能够在不编写 class 的状况下使用状态等功能,从而使得函数式组件从无状态的变化为有状态的。 React 的类型包 @types/react 中也同步把 React.SFC (Stateless Functional Component) 改成了 React.FC (Functional Component)。html
经过这一升级,原先 class 写法的组件也就彻底能够被函数式组件替代。虽然是否要把老项目中全部类组件所有改成函数式组件因人而异,但新写的组件仍是值得尝试的,由于代码量的确减小了不少,尤为是重复的代码(例如 componentDidMount + componentDidUpdate + componentWillUnmount = useEffect)。react
从 16.8 发布(今年2月)至今也有大半年了,但本人水平有限,尤为在 useEffect 和异步任务搭配使用的时候常常踩到一些坑。特做本文,权当记录,供遇到一样问题的同僚借鉴参考。我会讲到三个项目中很是常见的问题:ios
useEffect
发起异步任务,第二个参数使用空数组可实现组件加载时执行方法体,返回值函数在组件卸载时执行一次,用来清理一些东西,例如计时器。axios.CancelToken
) 来控制停止请求,更加优雅地退出。useEffect
返回值中清理时,使用局部变量或者 useRef
来记录这个 timer
。不要使用 useState
。setTimeout
等闭包时,尽可能在闭包内部引用 ref 而不是 state,不然容易出现读取到旧值的状况。useState
返回的更新状态方法是异步的,要在下次重绘才能获取新值。不要试图在更改状态以后立马获取状态。这类需求很是常见,典型的例子是在列表组件加载时发送请求到后端,获取列表后展示。git
发送请求也属于 React 定义的反作用之一,所以应当使用 useEffect
来编写。基本语法我就再也不过多说明,代码以下:程序员
import React, { useState, useEffect } from 'react';
const SOME_API = '/api/get/value';
export const MyComponent: React.FC<{}> = () => {
const [loading, setLoading] = useState(true);
const [value, setValue] = useState(0);
useEffect(() => {
(async () => { const res = await fetch(SOME_API); const data = await res.json(); setValue(data.value); setLoading(false); })(); }, []); return ( <> {loading ? ( <h2>Loading...</h2> ) : ( <h2>value is {value}</h2> )} </> ); } 复制代码
如上是一个基础的带 Loading 功能的组件,会发送异步请求到后端获取一个值并显示到页面上。若是以示例的标准来讲已经足够,但要实际运用到项目中,还不得不考虑几个问题。github
React 会报一个 Warningtypescript
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.in Notificationjson
大意是说在一个组件卸载了以后不该该再修改它的状态。虽然不影响运行,但做为完美主义者表明的程序员群体是没法容忍这种状况发生的,那么如何解决呢?redux
问题的核心在于,在组件卸载后依然调用了 setValue(data.value)
和 setLoading(false)
来更改状态。所以一个简单的办法是标记一下组件有没有被卸载,能够利用 useEffect
的返回值。axios
// 省略组件其余内容,只列出 diff
useEffect(() => {
let isUnmounted = false;
(async () => { const res = await fetch(SOME_API); const data = await res.json(); if (!isUnmounted) { setValue(data.value); setLoading(false); } })(); return () => {
isUnmounted = true;
}
}, []);
复制代码
这样能够顺利避免这个 Warning。
上述作法是在收到响应时进行判断,即不管如何须要等响应完成,略显被动。一个更加主动的方式是探知到卸载时直接中断请求,天然也没必要再等待响应了。这种主动方案须要用到 AbortController。
AbortController 是一个浏览器的实验接口,它能够返回一个信号量(singal),从而停止发送的请求。这个接口的兼容性不错,除了 IE 以外全都兼容(如 Chrome, Edge, FF 和绝大部分移动浏览器,包括 Safari)。
useEffect(() => {
let isUnmounted = false;
const abortController = new AbortController(); // 建立
(async () => {
const res = await fetch(SOME_API, {
singal: abortController.singal, // 当作信号量传入
});
const data = await res.json();
if (!isUnmounted) {
setValue(data.value);
setLoading(false);
}
})();
return () => {
isUnmounted = true;
abortController.abort(); // 在组件卸载时中断
}
}, []);
复制代码
singal 的实现依赖于实际发送请求使用的方法,如上述例子的 fetch
方法接受 singal
属性。若是使用的是 axios,它的内部已经包含了 axios.CancelToken
,能够直接使用,例子在这里。
另外一种常见的需求是要在组件交互(好比点击某个按钮)时发送请求或者开启计时器,待收到响应后修改数据进而影响页面。这里和上面一节(组件加载时)最大的差别在于 React Hooks 只能在组件级别编写,不能在方法(dealClick
)或者控制逻辑(if
, for
等)内部编写,因此不能在点击的响应函数中再去调用 useEffect
。但咱们依然要利用 useEffect
的返回函数来作清理工做。
以计时器为例,假设咱们想作一个组件,点击按钮后开启一个计时器(5s),计时器结束后修改状态。但若是在计时未到就销毁组件时,咱们想中止这个计时器,避免内存泄露。用代码实现的话,会发现开启计时器和清理计时器会在不一样的地方,所以就必须记录这个 timer。看以下的例子:
import React, { useState, useEffect } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [value, setValue] = useState(0);
let timer: number;
useEffect(() => {
// timer 须要在点击时创建,所以这里只作清理使用
return () => {
console.log('in useEffect return', timer); // <- 正确的值
window.clearTimeout(timer);
}
}, []);
function dealClick() {
timer = window.setTimeout(() => {
setValue(100);
}, 5000);
}
return (
<>
<span>Value is {value}</span>
<button onClick={dealClick}>Click Me!</button>
</>
);
}
复制代码
既然要记录 timer,天然是用一个内部变量来存储便可(暂不考虑连续点击按钮致使多个 timer 出现,假设只点一次。由于实际状况下点了按钮还会触发其余状态变化,继而界面变化,也就点不到了)。
这里须要注意的是,若是把 timer
升级为状态(state),则代码反而会出现问题。考虑以下代码:
import React, { useState, useEffect } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [value, setValue] = useState(0);
const [timer, setTimer] = useState(0); // 把 timer 升级为状态
useEffect(() => {
// timer 须要在点击时创建,所以这里只作清理使用
return () => {
console.log('in useEffect return', timer); // <- 0
window.clearTimeout(timer);
}
}, []);
function dealClick() {
let tmp = window.setTimeout(() => {
setValue(100);
}, 5000);
setTimer(tmp);
}
return (
<>
<span>Value is {value}</span>
<button onClick={dealClick}>Click Me!</button>
</>
);
}
复制代码
有关语义上 timer
到底算不算做组件的状态咱们先抛开不谈,仅就代码层面来看。利用 useState
来记住 timer
状态,利用 setTimer
去更改状态,看似合理。但实际运行下来,在 useEffect
返回的清理函数中,获得的 timer
倒是初始值,即 0
。
为何两种写法会有差别呢?
其核心在于写入的变量和读取的变量是不是同一个变量。
第一种写法代码是把 timer
做为组件内的局部变量使用。在初次渲染组件时,useEffect
返回的闭包函数中指向了这个局部变量 timer
。在 dealClick
中设置计时器时返回值依旧写给了这个局部变量(即读和写都是同一个变量),所以在后续卸载时,虽然组件从新运行致使出现一个新的局部变量 timer
,但这不影响闭包内老的 timer
,因此结果是正确的。
第二种写法,timer
是一个 useState
的返回值,并非一个简单的变量。从 React Hooks 的源码来看,它返回的是 [hook.memorizedState, dispatch]
,对应咱们接的值和变动方法。当调用 setTimer
和 setValue
时,分别触发两次重绘,使得 hook.memorizedState
指向了 newState
(注意:不是修改,而是从新指向)。但 useEffect
返回闭包中的 timer
依然指向旧的状态,从而得不到新的值。(即读的是旧值,但写的是新值,不是同一个)
若是以为阅读 Hooks 源码有困难,能够从另外一个角度去理解:虽然 React 在 16.8 推出了 Hooks,但实际上只是增强了函数式组件的写法,使之拥有状态,用来做为类组件的一种替代,但 React 状态的内部机制没有变化。在 React 中 setState
内部是经过 merge 操做将新状态和老状态合并后,从新返回一个新的状态对象。不论 Hooks 写法如何,这条原理没有变化。如今闭包内指向了旧的状态对象,而 setTimer
和 setValue
从新生成并指向了新的状态对象,并不影响闭包,致使了闭包读不到新的状态。
咱们注意到 React 还提供给咱们一个 useRef
, 它的定义是
useRef 返回一个可变的 ref 对象,其
current
属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。
ref 对象能够确保在整个生命周期中值不变,且同步更新,是由于 ref 的返回值始终只有一个实例,全部读写都指向它本身。因此也能够用来解决这里的问题。
import React, { useState, useEffect, useRef } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [value, setValue] = useState(0);
const timer = useRef(0);
useEffect(() => {
// timer 须要在点击时创建,所以这里只作清理使用
return () => {
window.clearTimeout(timer.current);
}
}, []);
function dealClick() {
timer.current = window.setTimeout(() => {
setValue(100);
}, 5000);
}
return (
<>
<span>Value is {value}</span>
<button onClick={dealClick}>Click Me!</button>
</>
);
}
复制代码
事实上咱们后面会看到,useRef
和异步任务配合更加安全稳妥。
这个其实比较基础了。
import React, { useState } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [value, setValue] = useState(0);
function dealClick() {
setValue(100);
console.log(value); // <- 0
}
return (
<span>Value is {value}, AnotherValue is {anotherValue}</span>
);
}
复制代码
useState
返回的修改函数是异步的,调用后并不会直接生效,所以立马读取 value
获取到的是旧值(0
)。
React 这样设计的目的是为了性能考虑,争取把全部状态改变后只重绘一次就能解决更新问题,而不是改一次重绘一次,也是很容易理解的。
import React, { useState, useEffect } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [value, setValue] = useState(0);
const [anotherValue, setAnotherValue] = useState(0);
useEffect(() => {
window.setTimeout(() => {
console.log('setAnotherValue', value) // <- 0
setAnotherValue(value);
}, 1000);
setValue(100);
}, []);
return (
<span>Value is {value}, AnotherValue is {anotherValue}</span>
);
}
复制代码
这个问题和上面使用 useState
去记录 timer
相似,在生成 timeout 闭包时,value 的值是 0。虽然以后经过 setValue
修改了状态,但 React 内部已经指向了新的变量,而旧的变量仍被闭包引用,因此闭包拿到的依然是旧的初始值,也就是 0。
要修正这个问题,也依然是使用 useRef
,以下:
import React, { useState, useEffect, useRef } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [value, setValue] = useState(0);
const [anotherValue, setAnotherValue] = useState(0);
const valueRef = useRef(value);
valueRef.current = value;
useEffect(() => {
window.setTimeout(() => {
console.log('setAnotherValue', valueRef.current) // <- 100
setAnotherValue(valueRef.current);
}, 1000);
setValue(100);
}, []);
return (
<span>Value is {value}, AnotherValue is {anotherValue}</span>
);
}
复制代码
假设咱们要实现一个按钮,默认显示 false。当点击后更改成 true,但两秒后变回 false( true 和 false 能够互换)。考虑以下代码:
import React, { useState } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [flag, setFlag] = useState(false);
function dealClick() {
setFlag(!flag);
setTimeout(() => {
setFlag(!flag);
}, 2000);
}
return (
<button onClick={dealClick}>{flag ? "true" : "false"}</button>
);
}
复制代码
咱们会发现点击时可以正常切换,可是两秒后并不会变回来。究其缘由,依然在于 useState
的更新是从新指向新值,但 timeout 的闭包依然指向了旧值。因此在例子中,flag
一直是 false
,虽而后续 setFlag(!flag)
,但依然没有影响到 timeout 里面的 flag
。
解决方法有二。
第一个仍是利用 useRef
import React, { useState, useRef } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [flag, setFlag] = useState(false);
const flagRef = useRef(flag);
flagRef.current = flag;
function dealClick() {
setFlag(!flagRef.current);
setTimeout(() => {
setFlag(!flagRef.current);
}, 2000);
}
return (
<button onClick={dealClick}>{flag ? "true" : "false"}</button>
);
}
复制代码
第二个是利用 setFlag
能够接收函数做为参数,并利用闭包和参数来实现
import React, { useState } from 'react';
export const MyComponent: React.FC<{}> = () => {
const [flag, setFlag] = useState(false);
function dealClick() {
setFlag(!flag);
setTimeout(() => {
setFlag(flag => !flag);
}, 2000);
}
return (
<button onClick={dealClick}>{flag ? "true" : "false"}</button>
);
}
复制代码
当 setFlag
参数为函数类型时,这个函数的意义是告诉 React 如何从当前状态产生出新的状态(相似于 redux 的 reducer,不过是只针对一个状态的子 reducer)。既然是当前状态,所以返回值取反,就可以实现效果。
在 Hook 中出现异步任务尤为是 timeout 的时候,咱们要格外注意。useState
只能保证屡次重绘之间的状态值是同样的,但不保证它们就是同一个对象,所以出现闭包引用的时候,尽可能使用 useRef
而不是直接使用 state 自己,不然就容易踩坑。反之若是的确碰到了设置了新值但读取到旧值的状况,也能够往这个方向想一想,可能就是这个缘由所致。