react hooks cover

React Hooks 在 [email protected] 版本中正式发布,之后在两个项目中尝鲜使用了一下,很大提升了开发效率和体验,尤其是在 WebRTC 直播项目里,简直是救我狗命的存在。在此来整理一下相关知识,用一个舒适的顺序剖析关于 React Hooks 的方方面面。

Motivation

They let you use state and other React features without writing a class.

React hooks 可以让你在 Class 之外使用 state 以及 React 的其它特性

React 的官方文档中明确说明了引入 React Hooks 是为了解决以下问题:

  1. 难以在组件间复用逻辑

    之前是通过 render props 和 高阶组件(HOC)来解决的,但这大大提升了程序的复杂度,多个高阶组件的嵌套也会形成嵌套地狱(Wrapper Hell)。

  2. 组件越来越复杂,难以理解难以维护

​ 复杂的生命周期机制,每个生命周期方法里都是几种不相干的逻辑代码和副作用。事件监听的代码在 componentDidMount里,解绑的清理代码在 componentWillUnmount 里,这让组件更难拆分了。

  1. Class 让人和计算机都难以理解

​ 光 this 指针这一项就已经有点难用了,除此之外,Class 对于代码压缩与热加载也并不友好,会有一些边界 Case。

因此,我们可以理解为 React Hooks 的设计目标就是:

  1. 免去编写 Class 的复杂性,解决生命周期的复杂性
  2. 解决逻辑复用难的问题

接下来,我们来看看是怎么实现的。

Implement

看到前面的动机,可能心里会有个念头“既然 Class 这么难用,当初为什么要这么设计非要使用 Class 来编写组件呢”。这当然有其历史必然性了。

React 中有两种组件形式,Class 类组件与 Function 函数组件:

Class Component:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class App extends React.Component{
constructor(props){
super(props);
this.state = {
//...
}
}
componentDidMount() {
this.fetchData()
}
//...
render() {
// ...
}
}

Function Component:

1
2
3
4
5
6
7
8
9
function App(links){
return (
<div>
<ul>
{links.map(({href, title})=> <li><a href={href}>{title}</a></li> )}
</ul>
</div>
)
}

类组件中会维护内部状态,函数组件则是无状态的。

这就是关键之处,函数组件无法保存状态,每次重新运行函数都会导致其作用域内所有函数被重置,也无法像类组件一样通过继承 React.Component 原型上的方法 setState 来更新自己的状态。所以,在过去,如果我们想让一个函数组件具有状态,就不得不将其转为类组件。

那么,如果我们想抛弃 Class,转向函数组件的话,必须解决的事便是让函数组件也能保留、修改、持久化自己的状态。

怎么能让函数状态持久化?

答案是 闭包

closure

惊不惊喜,意不意外?就是初学 JavaScript 时阴魂不散的闭包,简直闭包天天见。

Based on closures

所以,以 useState 为例,我们可以简单实现(以下均为简单实现,非源码)为:

1
2
3
4
5
6
7
8
9
let _state
const useState = (initialValue) => {
_state = _state || initialValue
const setState = (newValue) => {
_state = newValue
render()
}
return [_state, setState]
}

怎么支持组件内维护多个状态?最简单的方式当然是用数组:

1
2
3
4
5
6
7
8
9
10
let _hooks = []
let _cursor = 0
function useState(initialValue) {
_hooks[_cursor] = _hooks[_cursor] || initialValue
const setStateHookCursor = _cursor
const setState = (newVal) => {
_hooks[setStateHookCursor] = newVal
}
return [hooks[_cursor++], setState]
}

维护一个数组变量,一个锚点 cursor 就可以指哪打哪,准确读取、修改状态值了。

useEffect 也是类似的,只是状态数组里保存的是 deps,除此之外再增加一个浅比较 deps 是否有变化的逻辑即可:

1
2
3
4
5
6
7
8
9
10
11
function useEffect(callback, depArray) {
const hasNoDeps = !depArray
const deps = _hooks[_cursor]
const hasChangedDeps =
!deps || !depArray.every((el, i) => el === deps[i])
if (hasNoDeps || hasChangedDeps) {
callback()
hooks[_cursor] = depArray
}
_cursor++;
}

React 的源码实现当然比上面复杂的多,事实上,React Hooks 是在 Fiber 架构基础上实现的。

Based on Fiber

按照官方的说法React Fiber对核心算法的一次重新实现,也可以说它是一个新的 Reconciler。所以, Reconciler 是什么呢?

React 的源码可以分为三个主要部分:

React Core

这一部分只涵盖了与定义组件相关的顶层 API

Renderers

渲染器模块负责管理 React 树如何被具体底层平台所调用,Web 则为 DOM API,React Native 的话则是安卓 iOS 的视图 API。

除此之外还有 React Test Renderer 渲染器,可以把 React 组件转化为 JSON 树,供 Jest 这类测试框架做快照测试使用

Reconcilers

Reconciler 是一种 diff 算法用以确定在状态改变时需要更新那些 DOM 元素。它没有公开的 API,因此也没被独立打包,它只被 React DOM 和 React Native 这类渲染器使用

在推出 v16 的 Fiber 架构后,新的 Reconciler 被称为 Fiber Reconciler,v15 及之前的实现则被称为 Stack Reconciler

react codebase

了解到这里, Fiber Reconciler 是为了解决 React v15 的DOM元素多,频繁刷新场景下的主线程阻塞问题,直观显示,则是“掉帧”问题。v15 是一次同步处理整个组件树,通过递归的方式进行渲染,使用 JavaScript 引擎自身的函数调用栈,它会一直执行到栈空位置,一旦工作量大就会阻塞整个主线程(就像前面说的用 HOC 方式形成 Wrapper Hell 的话不仅 debug 难,对性能也会产生严重影响)。然而我们的更新工作可能并不需要一次性同步完成,其中是可以按照优先级调整工作,把整个过程分片处理的,这就是 Fiber 想做的事。

Fiber Reconciler链表的形式遍历组件数,可以灵活的暂停、继续、放弃当前任务。通过 Scheduler 调度器来进行任务分配,每次只做一个小任务,通过 requestIdleCallback 回到主线程看看有没有更高优先级的任务需要处理,如果有就暂停当前任务,去做优先级更高的,否则就继续执行。

Fiber

关于 Fiber 的内容可以了解的还有更多,包括它是怎么划分优先级的,对现有代码的影响等等。但是还是先回到关于 Hooks 的实现。

Fiber 的类型定义在源码的 react-reconciler/src/ReactFiber.js 文件里,我们抽取一下需要了解的字段:

1
2
3
4
5
6
7
export type Fiber = {
tag: WorkTag,
key: null | string,
type: any,
// ...
memoizedState: any
}

memoizedState 就是用来储存当前渲染节点的最终状态值。

我们再看一下 Hook 的类型定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export type Hook = {
memoizedState: any, // 上一次更新之后的最终状态值
queue: UpdateQueue | null, // 更新队列,存储多次更新操作
next: Hook | null, // 指向链表的下一个 Hook
};
type Update {
action: any,
next: Update,
};
type UpdateQueue {
last: Update,
dispatch: any,
lastRenderedState: any,
};

不难看到,在实际的实现中,React Hooks 并没有采用数组,而是通过单向链表的方式来存储多个 Hooks。

除此之外,可以看到 Queue 有个 last 字段,我们可以调用 dispatchAction(即更新 state 的方法) 多次,也只有最后那次会生效,生效为 last 存储的最后一次 update 的 state 值。

因此,在每个组件内,都会有个 Fiber 对象以这样的形式来存储:

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
const [catName, setCatName] = useState('Tom');
const [mouseName, setMouseName] = useState('Jerry');
setCatName('Tommy')
// FiberNode
const fiber = {
//...
memoizedState: {
memoizedState: 'Tom',
queue: {
last: {
action: 'Tommy'
},
dispatch: dispatch,
lastRenderedState: 'Tommy'
},
next: {
memoizedState: 'Jerry',
queue: {
// ...
},
next: null
}
},
//...
}

调用 Hook API 实际上就是新增一个 Hook 实例并将其追加到 Hooks 链表上,返回给组件的是这个 Hook 的 state 和对应的 setter,链表的结构决定了 re-render 时 React 并不会知道这个 setter 对应的是哪个 hooks,因此它会从链表的头开始一一执行(这是采用了链表结构的弊端,但是如果通过 HashMap 来存储的话,每次调用 Hook API 都需要显示地传入一个 Key 值来区分不同 Hook,更复杂了)。

这个 Hooks 链表是在 mount 阶段时构造的,所以声明 Hook 时的顺序很重要,这也是为什么我们只能在函数组件顶部作用域调用Hook API,不能在条件语句、循环、子函数里调用 Hooks。

Notice: Capture Value

这是一个新手常见坑,首先来看一段代码(在线地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Example() {
const [count, setCount] = useState(0);
const handleAlertClick = useCallback(
() => {
setTimeout(() => {
alert("You clicked on: " + count);
}, 3000);
},
[count]
);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>增加 count</button>
<button onClick={handleAlertClick}>显示 count</button>
</div>
);
}

先点击 “显示 count” 按钮,然后快速点击 “增加 count” 两下,会发现 alert 弹窗上显示的是 0,然而 p 标签里已经是2了。这就是 React Hook 的 Capture Value 快照特性。

image-20200106180246276

Each Render Has Its Own Props and State

记住,每次 Render 都有自己的 Props 和 State

每次 Render 的内容都会形成一个快照并保存下来,因此当状态变更而 re-render 时,就有了 N 个快照,每个都拥有自己独立的,固定不变的 Props 和 State。在每个快照之间(在这段代码里即每次点击之间),count 只是一个常量,不存在数据绑定,watcher 或者 proxy 之类的东西,它只是一个常量数字。因此点击“显示 count” 按钮时,当前快照内 count 值为 0,alert 弹窗为 0,后面无论点击多少次“增加 count” 按钮,都是新的快照,与它无关了。

Capture Value 特性存在于除 useRef 之外的所有 Hook API 中(因为非 useRef 相关的 Hook API,本质上都形成了闭包,闭包有自己独立的状态,这就是 Capture Value 的本质)。所以如果想避免上述例子中取不到 state 最新值的情况,可以通过 useRef 把所需的 state 值保存下来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function Example() {
const [count, setCount] = useState(0);
const countRef = useRef(null)
const handleAlertClick = useCallback(
() => {
setTimeout(() => {
alert("You clicked on: " + countRef.current);
}, 3000);
},
[count]
);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => {
countRef.current = count + 1
setCount(count + 1)
}}>增加 count</button>
<button onClick={handleAlertClick}>显示 count</button>
</div>
);
}

Hooks API

接下来简单介绍一下官方的几个有意思的 Hooks 的常规用法和注意事项。

useRef

前面在 Capture Value 也介绍了,它是唯一返回 mutable 数据的 Hook,它不仅可以 DOM 引用,还可以存储任意 JavaScript 值。

修改 useRef 的值必须改其 current 属性,否则不会触发 re-render

useCallback

useCallback 可以保证在 re-render 之间返回的始终是同一回调引用:

1
2
3
4
// 只要 a 或 b 不变,这个值就不会变化
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);

需要注意的是,用 useCallback 包裹的函数所用参数,也必须在 hook 的 deps 数组里。

使用场景:

1
2
3
4
5
6
7
8
9
10
11
function Counter() {
const [count, setCount] = useState(0)
const handleIncrement = useCallback(() => {
setCount(count + 1)
}, [count])
return (<div>
{count}:
<ComplexButton onClick={handleIncrement}>increment</ComplexButton>
</div>)
}

如果不使用 useCallback 来包住回调函数的话,那么每次点击按钮修改 count 值时触发 re-render 生成新的回调函数,传入 ComplexButton 的 props 发生变化,导致了 ComplexButton 重新渲染。

useMemo

1
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

仅当依赖项发生改变时,才会重新计算 memoizedValue,常被用于缓存昂贵计算函数的返回值。

useCallback(fn, deps)== useMemo(() => fn, deps)

React 提供了一个与类组件的 PureComponent 相同功能的 API React.memo,会在自身 re-render 时,对每一个 props 项进行浅比较,如果引用没有变化,就不会触发重渲染。

useReducer

1
const [state, dispatch] = useReducer(reducer, initialArg, init);

useState 用于相对扁平结构的状态,useReducer 则用于复杂结构的状态。而其返回的 dispatch 方法可以放心传递给子组件,而不会造成子组件的 re-render

具体可参考 Dan Abramov 的示例代码

先写到这里,后面希望可以再学习整理一下 React Hooks 的逻辑复用实践~

Reference