React Hooks 的一些使用经验
Feb 18, 2020
React Hooks 最佳实践:更换 Vue 3.0 (大雾)
最近在使用 React Hooks 的过程中,有一些经验和踩的一些坑,在此做个总结。
Hook 的使用准则
只在最顶层使用 Hook,千万不能在条件判断和循环里使用
React hooks:not magic,just arrays
let firstRender = true
function RenderFunctionComponent() {
let initName
if (firstRender) {
;[initName] = useState("Rudi")
firstRender = false
}
const [firstName, setFirstName] = useState(initName)
const [lastName, setLastName] = useState("Yardley")
return <Button onClick={() => setFirstName("Fred")}>Fred</Button>
}
React 内部记录 Hook 的状态取决于调用的顺序,当你在条件判断里调用,会打乱 Hook 的调用顺序,记录下来的状态和调用的 Hook 就匹配不上了,可能就会出现异常。循环或者遍历的话,由于不能保证每次渲染的时候 hook 调用次数是一致的,因此也会造成顺序不一致。如果你这样使用了,React 会直接给你报错。
下面这种情况不注意可能就出错了
function App(props) {
const [posts] = usePosts(props.tag)
if (!posts.length) {
return <div>No Posts!</div>
}
const hotPosts = useMemo(() => {
return posts.filter(p => p.read_num > 10000)
}, [posts])
return <Hot posts={hotPosts} />
}
useState
useState
是 useReducer
的语法糖。可以通过 useReducer
来实现 useState
function useState(initialState) {
return useReducer(
(state, action) => {
if (typeof action === "function") {
return action(state)
}
return action
},
typeof initialState === "function" ? initialState() : initialState
)
}
实际上,useState
源码上也是使用 useReducer
实现的。
相比较于 class 组件的 setState
,useState
返回的 dispatch
有些不同:
- dispatch 不会替你做对象的合并操作,你传进去什么,state 就是什么。
- dispatch 不像
this.setState(state, callback)
那样有第二个参数回调来给你执行一些更新后的副作用
除此之外,class 组件我们所有状态都是声明在 this.state
这个对象里,useState
我们更建议将不相关的状态单独声明。
// good
const [count, setCount] = useState(0)
const [step, setStep] = useState(1)
// bad
const [state, setState] = useState({ count: 0, step: 1 })
// good
const person = useState({ name: "xxx", age: 18 })
// bad
const [name, setName] = useState("xxx")
const [age, setAge] = useState(18)
上文提到,useState
不会做对象的合并操作。所以你传入的如果是一个对象,你要修改状态的话会有些麻烦。你可能需要通过 setState ({...state})
这样的方式来更新,如果对象层级比较深就更麻烦了。所以还是建议以 immutable 的方式封装一个自定义 Hook 来替代 useState
。例如 immer
import produce from "immer"
function useImmer(initialState) {
const [state, setState] = React.useState(initialState)
const update = React.useCallback(updater => {
setState(produce(updater))
}, [])
return [state, update]
}
useReducer
useReducer
来源于 Redux 的 reducer,可以认为是一个组件粒度的 Redux。对于组件内的业务逻辑,我们应该尽量使用 useReducer
将相关状态组织到一起,而不是分散使用 useState
。
type State = {
loading: boolean;
error: boolean;
data: any;
};
type ActionTypes = 'Success' || 'Error' || 'Pending'
type Action = {
payload: any;
type: ActionTypes
}
function reducer(state: State, action: Action):State {
switch (action.type) {
case 'Pending':
return {...state, loading: true, error: false }
case 'Error':
return {...state, error: true, loading: false }
case 'Success': {
return {error: false, loading: false, data: action.payload}
}
}
}
function useModel(initialState: State) {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchArticle = (tag: 'vue' | 'react' | 'angular') => {
// 可以在这里面做更多的业务逻辑处理, 比如竞态, debounce 等等
dispatch({type: 'Pending'})
fetch(`/api/article?tag=${tag}`).then(res => {
dispatch({type: 'Success', payload: res})
}).catch(() => {
dispatch({type: 'Error'})
})
}
const fetchVue = () => fetchArticle('vue')
const fetchReact = () => fetchArticle('react')
const fetchAngular = () => fetchArticle('angular')
return {
state,
fetchVue,
fetchReact,
fetchAngular
}
}
我们利用 useReducer
封装了一个自定义 Hook useModel
,而我们的组件,只需要负责调用 useModel
返回的方法,组件就可以重点关注交互逻辑,而不是具体的业务逻辑。
除此以外,关于 reducer 的写法,推荐一篇文章用状态机来替代 isLoading
React.memo
父组件传进来的 props,只要是对象,就要注意两点:
- immutable,当对象更新时,一定要确保引用更新,否则子组件不会更新,
- 对象没有更新时,要确保引用不变,否则性能优化就全白费了。
比如下面的 React.memo
是无效的,因为每次渲染时 b
的引用都不同。
const Child = React.memo(props => {
return <div></div>
})
const a = {}
export default function App() {
const forceUpdate = useForceUpdate()
const b = {}
return (
<>
<button onClick={forceUpdate}>force update</button>
<Child a={a} b={b} />
</>
)
}
如果使用了 useContext
,当 Provider
的值更新了,那么 React.memo
也是无效并且重渲染的。所以我发现了一个有趣的 Demo。
const CounterContext = React.createContext()
const Counter = React.memo(
props => {
const value = React.useContext(CounterContext)
return (
<div>
<div>Prop: {props.value}</div>
<div>Provider: {value}</div>
</div>
)
},
() => true
)
export default function App() {
const [value, setValue] = React.useState(1)
const handleClick = () => {
setValue(v => v + 1)
}
return (
<div>
<CounterContext.Provider value={value}>
<Counter value={value} />
</CounterContext.Provider>
<button onClick={handleClick}>Add</button>
</div>
)
}
点击按钮,<Counter value={value}/>
组件会重新渲染,但是,props.value
是不会更新的,来自 Provider 的 value 是会更新的。所以在没有看过 React 源码的情况下,我只能猜测:当 React.memo
的第二个参数 areEqual
函数返回 true
时,并不是简简单单的直接返回上次渲染的 vdom。
除此之外,还发现 React 可以做到上游组件重渲染,下游组件重渲染,而自己不重渲染的....
const CounterContext = React.createContext()
const Child = () => {
console.log("Child render")
const value = React.useContext(CounterContext)
return <div>Child: {value}</div>
}
const Counter = React.memo(
props => {
console.log("Counter render")
return (
<div>
<div>Prop: {props.value}</div>
<Child />
</div>
)
},
() => true
)
export default function App() {
const [value, setValue] = React.useState(1)
const handleClick = () => {
setValue(v => v + 1)
}
console.log("App render")
return (
<div>
<CounterContext.Provider value={value}>
<Counter value={value} />
</CounterContext.Provider>
<button onClick={handleClick}>Add</button>
</div>
)
}
这个 demo,点击按钮,控制台输出 App render
和 Child render
,不会输出 Counter render
。
useMemo
我们常常把 useMemo
当作性能优化的手段。实际上,有些情况下,效果可能会适得其反。
写过 Vue 的人应该都能想到 useMemo
和 Vue 的 computed
很像,两者都是对一个值做缓存,当依赖不变时直接使用缓存的结果,当依赖变化时才会自动的重新计算。但由于框架机制的不同,Vue 依赖侦测的机制,可以在依赖变动时自动通知 computed
更新重新计算。而 React 是在组件重渲染时,虽然 useMemo
不一定每次都会重新计算,但是 React 去浅比较 (shallowEqual) 依赖数组里的每一项是否变化这一步是必不可少的。这个比较显然导致了一部分的开销,另外一部分的开销是调用 hook 时本身的开销。从这两点我们可以得出一个结论,如果计算的开销 < React 比较依赖的开销 + hook 本身的开销,那么使用 useMemo
其实是负优化。在简单的计算场景下,计算开销往往都非常小,使用 useMemo
是得不偿失的。因为当你使用了之后,就需要维护依赖 (我不认为 eslint-plugin-react-hooks 是个完美的解决方案),如果哪天需求变更,忘记把依赖加到数组里,Bug 就产生了。
useMemo
带来的问题:
- 要缓存数据,势必会带来更大的内存开销。
- 除非计算逻辑复杂,否则
useMemo
的调用开销更大。 useMemo
主要是防止重渲染,需要配合React.memo
使用,但除此之外,还有很多地方需要注意,否则很可能加了也没用。如果性能不敏感,全部使用useMemo
和React.memo
收益很小。
因此,当你使用 useMemo
时,请明确是否真的需要 useMemo
。如果一个状态,它的依赖每次渲染都会变动,或者本身没有开销大的计算,也不是作为一个对象当作 prop 传入子组件,那么直接声明成变量就好了。
总结了下,使用 useMemo
时至少需要满足的以下任一条件:
- 这个计算的操作确实比较昂贵,比如数组遍历,循环等等。
- 这个计算操作的返回值是个对象,并且会作为其他 hook (包括子组件 hook) 或 React.memo 组件的依赖。
- 这是在自定义 hook 中使用。
总的来说,在明确会有性能问题的场景下,我们才需要使用 useMemo
。
除了这个,其实我们还可以使用 useMemo
来实现 useCallback
和 useRef
。
function useCallback(cb, deps) {
return useMemo(() => (...args) => cb(...args), deps)
}
const useRef = value => {
return useMemo(() => ({ current: value }), [])
}
可以看到,useCallback
其实就相当于 useMemo
返回一个函数。useRef
则是通过传一个空的依赖数组,那么 useMemo
返回值就不会更新,这样返回的就是一个引用不变的对象了,并且你可以直接修改这个对象上的 current 属性,这个 useRef
也是可以用在 DOM,React.forwardRef
和 useImperativeHandle
上的。
我们也可以用 useRef
实现 useMemo
(禁止套娃)
function useMemo(getter, deps = []) {
const depsRef = useRef(deps)
const valueRef = useRef(getter())
if (deps && shallowEqualDeps(depsRef.current, deps)) {
return valueRef.current
}
depsRef.current = deps
return (valueRef.current = getter())
}
能实现 useMemo
,肯定也就能实现 useCallBack
。下文有另一个版本 useCallback
,也是通过 useRef
实现,那个版本不需要管理依赖也能达到 useCallback
的效果。
useCallback
useCallback
是 useMemo
的语法糖,看上面那个代码应该就能明白。
但是 useCallback 问题更严重一些。React Hooks 是基于闭包的。
一个经典的例子。
class TickC extends React.PureComponent {
handleClick = () => {
setInterval(() => {
const count = this.props.count
console.log("class:", count)
}, 500)
}
render() {
return <button onClick={this.handleClick}>Class</button>
}
}
function TickF(props) {
const handleClick = useCallback(() => {
setInterval(() => {
console.log("function:", props.count)
}, 500)
}, [])
return <button onClick={handleClick}>Function</button>
}
export default function Demo() {
const [count, setCount] = React.useState(0)
const add = () => setCount(c => ++c)
return (
<div>
<TickF count={count} />
<TickC count={count} />
<button onClick={add}>Add</button>
</div>
)
}
点击两次按钮,在 class 版本中,定时器会输出当前最新的 count
的值,而 Hooks 版本的定时器中,只会输出 0。原因是 useEffect
只会在我们的组件中调用一次,也就是首次渲染完之后。而我们传给 useEffect
的函数,引用了 count
变量,形成了一个闭包。这就把此时 (首次渲染) 的 count
的给记住了。
利用闭包,class 组件也能做到函数组件一样的行为。
class TickC extends React.PureComponent {
handleClick = () => {
const count = this.props.count
setInterval(() => {
console.log("class:", count)
}, 500)
}
}
实际上,如果这个函数不会作为其他 hook 的依赖,就不需要 useCallback
,否则反而影响性能。尤其注意 <div onClick={handleClick}></div>
这种用法,根本就没有任何效果。
思考一下,我们希望的 useCallback 是怎样的?
- 引用不变
- 永远获取到最新值
我们有什么办法可以解决这两个问题?
- 如果是一个纯函数,直接提取到组件外面,虽然这个函数每次渲染都会不断的创建,销毁。但是这点性能开销几乎可以忽略不计。
const filterOdd = arr => arr.filter(num => num % 2 === 0)
function App() {
return <List filter={filterOdd} />
}
- 利用
useRef
的引用不变封装一个useCallback
function useCallback(cb) {
const ref = useRef(cb)
ref.current = cb
return useRef((...args) => {
ref.current.apply(this, args)
}).current
}
补充阅读:
useEffect
const doSomethingA = () => {
/* ... */
}
const doSomethingB = () => {
/* ... */
}
useEffect(() => {
doSomethingA()
doSomethingB()
}, [...deps, doSomethingA, doSomethingB])
如果一个函数只在 useEffect 里使用,可以直接在 useEffect 内部声明,这样你就不用处理他的依赖。
useEffect(() => {
const doSomethingA = () => {
/* ... */
}
const doSomethingB = () => {
/* ... */
}
doSomethingA()
doSomethingB()
}, deps)
日后你如果需要添加一个 doSomethingC 的逻辑,就不用漏掉依赖了。
我觉得最好的使用方式是,一个 useEffect 做一件事
useEffect(() => {
const doSomethingA = () => {
/* ... */
}
doSomethingA()
}, [...deps])
useEffect(() => {
const doSomethingB = () => {
/* ... */
}
doSomethingB()
}, [...deps])
内部分享的时候有同事问:
Q:如果 doSomethingA
依赖了某个变量 a
,需要把这个 a
添加到 deps
里头吗?
A:实际上,这里没有使用 useCallback
,每次渲染传给 useEffect
的都是一个新的匿名箭头函数。当 useEffect 被调用,声明的 doSomethingA
也是新的,因此使用的 a 变量也是最新的。只要你的 useEffect
调用时机不依赖变量 a
,就可以不添加。否则就会造成,虽然 doSomethingA
能访问到的 a
是最新的,但是 useEffect
没有被调用。
useEffect
会在组件渲染完成之后调用。因此我们常常会封装一个 useMount
。
function useMount(effect) {
useEffect(effect, [])
}
useMount 里的 hook 只会在组件挂载完后调用一次。然而这里的 mount,和 class 组件的 componentDidMount 生命周期的调用时机其实是不一样的。
与 componentDidMount、componentDidUpdate 不同的是,在浏览器完成布局与绘制之后,传给 useEffect 的函数会延迟调用
这在一些场景下会造成行为不一致 useMount Demo1useMount Demo2
useLayoutEffect
虽然上面说的那种场景不太常见,假如真遇到了的话,那可以使用 useLayoutEffect
,useLayoutEffect
是在修改 DOM 后立即调用,行为和 componentDidMount
相似。
class 和 hook 的心智模型不同,不要把 useEffect 当作生命周期。我们更关注的是,当某个依赖变化了,我应该做些什么。而不是在某个 timing,我应该做些什么。hook 让你更关注于状态随依赖变化的关系,而不是组件的生命周期。
useRef
在 class 组件中,我们有时需要一些无关渲染的状态,比如存储一些定时器之类的,我们往往都是挂载在 this
上面。useRef
类似于 class 组件上的实例属性,它是一个 mutable 的对象。改变 ref.current
值不会触发重渲染。由于对象的引用传递,即使是在闭包里,我们也可以通过 ref.current
拿到最新值。还记得上面 class 组件的 Counter
组件的例子吗?这就像你通过 this.state
拿到最新值一样。
useImperativeHandle
useImperativeHandle
需要配合 useRef
和 forwardRef
一起食用。
之前,给 class 组件添加 ref
时拿到的是整个组件的实例,这样很容易被滥用。而你是不能给一个函数组件添加 ref
拿到组件状态的 (因为函数组件没有实例),你只能通过 React.forwardRef
将其转发到对应的 DOM 节点上。
现在,useImperativeHandle
可以让函数组件暴露一些接口给父组件。
class Count extends React.Component {
state = {
count: 0,
}
add = () => {
this.setState(state => ({ count: state.count + 1 }))
}
render() {
return (
<div>
count: {this.state.count}
<button onClick={this.add}>Add</button>
</div>
)
}
}
export default function App() {
const ref = React.useRef(null)
const handleClick = () => {
ref.current.add()
}
return (
<div className="App" onClick={handleClick}>
<Counter ref={ref} />
</div>
)
}
App
组件里是可以访问到整个子组件 Counter
的实例的,所以你可以调用 Counter
实例上的 add
方法。实际上,作为子组件,我们可能不希望把 add
方法暴露出去。
将 Counter
改写成 function
组件
const Counter = React.forwardRef((props, ref) => {
const [count, setCount] = React.useState(0)
const add = () => {
setCount(c => c + 1)
}
React.useImperativeHandle(ref, () => ({
count,
}))
return (
<div>
count: {count}
<button onClick={add}>Add</button>
</div>
)
})
现在,App
组件可以访问到 Counter
组件的 count
这个值,却没办法调用 add
方法。这比 class 组件下的行为要好。因为 useImperativeHandle
可以既可以让父组件访问函数子组件暴露出来的一些接口,又可以让子组件严格控制应该暴露出哪些东西。
useContext
尽量不要使用,使用第三方基于 Hook 的状态管理。constate
或 unstated-next
等。
分享时有同事对不要使用 useContext 的疑议。可能是因为我用词不当造成了一些误解。
其实我本意是想强调,比较会用到 Context 的场景,一些状态管理都已经替我们解决了,我们应该优先考虑状态管理而不是 Context。当然,Context 和 Redux 之类的状态管理库还是有区别,Redux 强调单一数据源 (the single source of truth),而 Context 是可以在树上的不同节点间多次独立使用的,只不过我们这种场景比较少,不过我认为即使遇到这种场景,也应该抽象一层出来,不要裸用 useContext
。
自定义 Hooks
这其实是 React Hooks 最大的亮点,它比 HOC 和 render props 更好的解决了逻辑复用的问题,并且不会有层级嵌套深,props 命名冲突等问题,通过一个函数来封装逻辑也比他们简单的多。
有一些激进的团队,他们认为组件里不应该裸用 useState
,useMemo
等 hook,而是从这些基础 hook 上按层次封装出一系列功能独立的自定义 hook,通过组合这些自定义来实现业务逻辑.....当然我认为这样写 hook 可能就太累了…(用最基础的 hook 心智负担就已经比较重了,更何况包装了三四层的 hook?)
不过,如果不做那么彻底的话,我们简单的包装一两层,也还是比较容易做到的。一个好的 hook,你可以通过名字知道这个 hook 干嘛用的,很多时候,当他人接手代码的时候,实际上是可以跳过阅读这段代码的,这样可以专注在组件的逻辑上,而不是一些复杂的数据处理流程等等。
下面是一个在业务代码里看到的验证码倒计时组件:
const MAX = 60 * 1000
function CountDown() {
const [isBlock, setBlock] = useState(false)
const [count, setCount] = useState(MAX)
const handleClick = () => {
getAuthCode().then(() => {
setBlock(true)
})
}
useEffect(() => {
let timer
if (isBlock) {
timer = countdownBlock()
}
return () => {
clearInterval(timer)
}
}, [isBlock])
const countdownBlock = () => {
const timer = window.setInterval(() => {
setCount(c => {
const _c = c - 1
if (_c < 0) {
clearInterval(timer)
}
return _c
})
}, 1000)
return timer
}
useEffect(() => {
if (count < 0) {
setBlock(false)
setCount(MAX)
}
}, [count])
return (
<div>
<div>
验证码: <input />
</div>
<button disabled={isBlock} onClick={handleClick} disabled={isBlock}>
{isBlock ? `${count}秒后重新发送` : "发送验证码"}
</button>
</div>
)
}
其实这个写的太复杂了,可能是不熟悉 hook 的心智模型,下面是优化后的版本
function useCountDown(max) {
const [count, setCount] = useState(-1)
const [run, cancel] = useInterval(() => setCount(c => c - 1), 1000)
useEffect(() => {
if (count < 0) {
cancel()
}
}, [count])
const countdown = () => {
setCount(max)
run()
}
return [count, countdown]
}
function CountDown() {
const [count, countdown] = useCountDown(60)
const isBlock = count >= 0
const handleClick = () => {
getAuthCode().then(() => {
countdown()
})
}
return (
<div>
<div>
验证码: <input />
</div>
<button disabled={isBlock} onClick={handleClick} disabled={isBlock}>
{isBlock ? `${count}秒后重新发送` : "发送验证码"}
</button>
</div>
)
}
从调用顺序来看,逻辑清晰了许多,代码量也减少了。
遇到的一个坑
开发环境下,根组件外面包裹上 <React.StrictMode>
,会导致组件渲染两次。
原因是 React 团队认为组件渲染时不应该含有副作用。包裹上 <React.StrictMode>
能够帮助你更容易发现这一点,参见