由于工作的原因我已经很长时间没接触过 React 了。前段时间圈子里都在讨论 React Hooks,出于好奇也学习了一番,特此整理以加深理解。
缘由
在 web 应用无所不能的 9012 年,组成应用的 Components 也越来越复杂,冗长而难以复用的代码给开发者们造成了很多麻烦。比如:
难以复用 stateful 的代码,render props 及 HOC 虽然解决了问题,但对组件的包裹改变了组件树的层级,存在冗余;
在 ComponentDidMount、ComponentDidUpdate、ComponentWillUnmount 等生命周期中做获取数据,订阅 / 取消事件,操作 ref 等相互之间无关联的操作,而把订阅 / 取消这种相关联的操作分开,降低了代码的可读性;
与其他语言中的 class 概念差异较大,需要对事件处理函数做 bind 操作,令人困扰。另外 class 也不利于组件的 AOT compile,minify 及 hot loading。
在这种背景下,React 在 16.8.0 引入了 React Hooks。
特性
主要介绍 state hook,effect hook 及 custom hook
State Hook
最基本的应用如下:
import React, {useState} from ‘react’
function counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>You have clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click
</button>
</div>
)
}
调用 useState,传入初始值,通过数组的结构赋值得到独立的 local state count,及 setCount。count 可以理解为 class component 中的 state,可见这里的 state 不局限于对象,可以为 number,string,当然也可以是一个对象。而 setCount 可以理解为 class component 中的 setState,不同的是 setState 会 merge 新老 state,而 hook 中的 set 函数会直接替换,这就意味着如果 state 是对象时,每次 set 应该传入所有属性,而不能像 class component 那样仅传入变化的值。所以在使用 useState 时,尽量将相关联的,会共同变化的值放入一个 object。
再看看有多个“local state”的情况:
import React, {useState} from ‘react’
function person() {
const [name, setName] = useState(‘simon’)
const [age, setAge] = useState(24)
return (
<div>
<p>name: {name}</p>
<p>age: {age}</p>
</div>
)
}
我们知道当函数执行完毕,函数作用域内的变量都会销毁,hooks 中的 state 在 component 首次 render 后被 React 保留下来了。那么在下一次 render 时,React 如何将这些保留的 state 与 component 中的 local state 对应起来呢。这里给出一个简单版本的实现:
const stateArr = []
const setterArr = []
let cursor = 0
let isFirstRender = true
function createStateSetter(cursor) {
return state => {
stateArr[cursor] = state
}
}
function useState(initState) {
if (isFirstRender) {
stateArr.push(initState)
setterArr.push(createStateSetter(cursor))
isFirstRender = false
}
const state = stateArr[cursor]
const setter = setterArr[cursor]
cursor++
return [state, setter]
}
可以看出 React 需要保证多个 hooks 在 component 每次 render 的时候的执行顺序都保持一致,否则就会出现错误。这也是 React hooks rule 中必须在 top level 使用 hooks 的由来——条件,遍历等语句都有可能会改变 hooks 执行的顺序。
Effect Hook
import React, {useState, useEffect} from ‘react’
function FriendStatus(props) {
const [isOnline, setIsOnline] = useState(null)
function handleStatusChange(status) {
setIsOnline(status.isOnline)
}
// 基本写法
useEffect(() => {
document.title = ‘Dom is ready’
})
// 需要取消操作的写法
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
return function cleanup() {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange)
}
})
if (isOnline === null) {
return ‘Loading…’
}
return isOnline ? ‘Online’ : ‘Offline’
}
可以看到上面的代码在传入 useEffect 的函数 (effect) 中做了一些 ”side effect”,在 class component 中我们通常会在 componentDidMount,componentDidUpdate 中去做这些事情。另外在 class component 中,需要在 componentDidMount 中订阅,在 componentWillUnmount 中取消订阅,这样将一件事拆成两件事做,不仅可读性低,还容易产生 bug:
class FriendStatus extends React.Component {
constructor(props) {
super(props);
this.state = {isOnline: null};
this.handleStatusChange = this.handleStatusChange.bind(this);
}
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}
handleStatusChange(status) {
this.setState({
isOnline: status.isOnline
});
}
render() {
if (this.state.isOnline === null) {
return ‘Loading…’;
}
return this.state.isOnline ? ‘Online’ : ‘Offline’;
}
}
如上代码,如果 props 中的 friend.id 发生变化,则会导致订阅和取消的 id 不一致,如需解决需要在 componentDidUpdate 中先取消订阅旧的再订阅新的,代码非常冗余。而 useEffect hook 在这一点上是浑然天成的。另外 effect 函数在每次 render 时都是新创建的,这其实是有意而为之,因为这样才能取得最新的 state 值。
有同学可能会想,每次 render 后都会执行 effect,这样会不会对性能造成影响。其实 effect 是在页面渲染完成之后执行的,不会阻塞,而在 effect 中执行的操作往往不要求同步完成,除了少数如要获取宽度或高度,这种情况需要使用其他的 hook(useLayoutEffect),此处不做详解。即使这样,React 也提供了控制的方法,及 useEffect 的第二个参数————一个数组,如果数组中的值不发生变化的话就跳过 effect 的执行:
useEffect(() => {
ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange)
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
}
}, [props.friend.id])
Custom Hook
A custom Hook is a JavaScript function whose name starts with”use”and that may call other Hooks.
Custom Hook 的使命是解决 stateful logic 复用的问题,如上面例子中的 FriendStatus,在一个聊天应用中可能多个组件都需要知道好友的在线状态,将 FriendStatus 抽象成这样的 hook:
function useFriendStatus(friendID) {
const [isOnline, setIsOnline] = useState(null);
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}
useEffect(() => {
ChatAPI.subscribeToFriendStatus(friendID, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(friendID, handleStatusChange);
};
});
return isOnline;
}
function FriendStatus(props) {
const isOnline = useFriendStatus(props.friend.id)
if (isOnline === null) {
return ‘Loading…’
}
return isOnline ? ‘Online’ : ‘Offline’
}
function FriendListItem(props) {
const isOnline = useFriendStatus(props.friend.id)
return (
<li style={{color: isOnline ? ‘green’ : ‘black’}}>
{props.friend.name}
</li>
)
}
FriendStatus 和 FriendListItem 中的 isOnline 是独立的,因 custom hook 复用的是 stateful logic,而不是 state 本身。另外 custom hook 必须以 use 开头来命名,这样 linter 工具才能正确检测其是否符合规范。
除了以上三种 hook,React 还提供了 useContext, useReducer, useCallback, useMemo, useRef, useImperativeHandle, useLayoutEffect, useDebugValue 内置 hook,它们的用途可以参考官方文档,这里我想单独讲讲 useRef。顾名思义,这个 hook 应该跟 ref 相关的:
function TextInputWithFocusButton() {
const inputEl = useRef(null)
const onButtonClick = () => {
inputEl.current.focus()
}
return (
<>
<input ref={inputEl} type=”text” />
<button onClick={onButtonClick}>Focus the input</button>
</>
)
}
来看看官方文档上的说明:
useRef returns a mutable ref object whose .current property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.
这句话告诉我们在组件的整个生命周期里,inputEl.current 都是存在的,这扩展了 useRef 本身的用途,可以使用 useRef 维护类似于 class component 中实例属性的变量:
function Timer() {
const intervalRef = useRef()
useEffect(() => {
const id = setInterval(() => {
// …
})
intervalRef.current = id
return () => {
clearInterval(intervalRef.current)
}
})
// …
}
这在 class component 中是理所当然的,但不要忘记 Timer 仅仅是一个函数,函数执行完毕后函数作用域内的变量将会销毁,所以这里需要使用 useRef 来保持这个 timerId。类似的 useRef 还可以用来获取 preState:
function Counter() {
const [count, setCount] = useState(0)
const prevCountRef = useRef()
useEffect(() => {
prevCountRef.current = count // 由于 useEffect 中的函数是在 render 完成之后异步执行的,所以在每次 render 时 prevCountRef.current 的值为上一次的 count 值
})
const prevCount = prevCountRef.current
return <h1>Now: {count}, before: {prevCount}</h1>
}
参考文章 & 拓展阅读
React Hooks 官方文档
React hooks: not magic, just arrays
Why Isn’t X a Hook?
Making Sense of React Hooks