课否 · 回复功能开发实践(二): React函数式组件与Hooks

最近大概阅读了一下课否的代码,项目使用了Taro框架。这个框架主要特点是可以实现小程序跨平台多端统一开发。这里对于框架不多赘述。

另外一个特点则是这次项目中组件的形态发生了较大的变化:从类组件变成了函数式组件。因此在开始开发之前,我决定先了解一下React函数式组件。

1、函数式组件概述

拿React官网上的函数式组件的Demo来举例

harmony
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

// 相当于 componentDidMount 和 componentDidUpdate:
useEffect(() => {
// 使用浏览器的 API 更新页面标题
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

Example 是一个函数,它return一个React Dom。这种返回React Dom节点的函数就是函数式组件。这种函数式组件直观上要比类组件的代码简洁的多。

函数式组件与类组件最大的区别就是,类组件有生命周期,并且state和props都作为类的属性。而函数式组件则不存在这样的生命周期。因此我们要学习的主要内容就是如何在函数式组件中使用state和进行生命周期的管理。

Hooks 的作用正是如此

2、Hook 概览

同样参考上面的代码, count 变量很像我们类组件中的state。事实上useState正是帮助我们使用state的hook。而useEffect则会在渲染的时候更新一下页面的标题。

Hook 是一些可以让你在函数组件里“钩入” React state 及生命周期等特性的函数。

React内置了一些Hook,我们也可以创建自己的Hook来服用不同组件之间的状态逻辑。

接下来我们主要学习一下两个常用的Hook: State和Effect Hook

3、State Hooks

我们使用类组件来实现相同的点击功能,如下

harmony
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}

render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}

construtor中初始化state.count为0,使用this.state.count来获取count的值,使用this.setState()方法来更改state

调用useState钩子函数来新建一个state及修改其的方法。该函数的参数是state的初始值,以数组的形式返回state和修改state的方法。在上面的例子中,我们使用数组的解构赋值来获取到了它们。

等号左边的名字是由我们自己决定的。另外如果有多个state的话,我们可以依次声明并且使用它们。

state也不必须是基本的变量,它可以是数组或者对象。

4、 Effect Hooks

在介绍Effect Hooks之前,我们需要先了解一个新的概念: 函数副作用(side effects)

在计算机科学中,函数副作用指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量),修改参数或改变外部存储。

与函数副作用的几个相关概念有:

  • 纯函数 输入输出的数据全是显式的,函数与外界交换数据只有唯一的渠道——参数与返回值。
  • 非纯函数 函数通过隐式的方式同外界交换数据。隐式即函数除参数与返回值之外,用其他方式同外界交换方式。比如读取全局变量、修改全局变量、IO等等
  • 参照透明性 无副作用是参照透明性的必要非充分条件。参照透明意味着一个表达式(例如一次函数调用)可以被替换为它的值。这需要该表达式是纯的,也就是说该表达式必须是完全确定的(相同的输入总是导致相同的输出)而且没有副作用。

另外如果函数参数是个引用,而函数修改了引用内的内容,则该函数同样具有副作用。比如js中某个函数的参数是一个对象,而这个函数讲这个对象的某个属性进行了修改。

而在React的函数式组件中,副作用函数需要使用Effect Hooks来进行操控。

如之前提过的例子

harmony
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React, { useState, useEffect } from 'react';

function Example() {
const [count, setCount] = useState(0);

// Similar to componentDidMount and componentDidUpdate:
useEffect(() => {
// Update the document title using the browser API
document.title = `You clicked ${count} times`;
});

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

提示: 如果你熟悉 React class 的生命周期函数,你可以把 useEffect Hook 看做 componentDidMount,componentDidUpdate 和 componentWillUnmount 这三个函数的组合。

按照这个提示,可以理解为useEffect在每次render之后都会执行。

无需清除的副作用

无需清除的副作用通常指那些我们只想在React更新DOM之后运行的代码。诸如网络请求,手动更改DOM(上面的例子)。这些例子的特点就是我们执行完之后就可以忽略它们了。无需清除的意思也可以理解为不需要再去考虑消除我们的副作用对于代码的影响。比如我们手动更改了DOM之后不需要把DOM恢复原状。

需要清除的副作用

还有一些副作用是需要清除的,最常见的一个例子就是订阅外部数据源。假如我们在渲染之后订阅了一个频道,这个行为很明显是一个副作用。而在组件卸载之后,如果我们不进行任何操作,依旧订阅这个频道,那么就会面临着内存泄漏等糟糕的问题。

而使用useEffect来清除副作用的方式很简单,那就是返回一个函数来清除

harmony
1
2
3
4
5
6
7
8
9
10
useEffect(() => {
function handleStatusChange(status) {
setIsOnline(status.isOnline);
}

ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
return () => {
ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
};
});

例如这个例子,我们在页面渲染之后,将Online状态置为true,表示在线。接着订阅Chat数据,这是会产生需要消除的副作用。最后我们返回了一个函数,该函数的作用是取消订阅。这样当组件卸载的时候就会执行该函数来取消订阅,从而消除副作用。

注意: 每次更新的时候都要运行Effect,包括其清除阶段。 这里举一个常见的bug来帮助我们理解。

harmony
1
2
3
4
5
6
7
8
9
10
11
12
13
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}

componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}

但是当组件已经显示在屏幕上时,friend prop 发生变化时会发生什么? 我们的组件将继续展示原来的好友状态。这是一个 bug。而且我们还会因为取消订阅时使用错误的好友 ID 导致内存泄露或崩溃的问题。

在class组件中,我们需要添加componentDidUpdate 来解决这个问题。

harmony
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
componentDidMount() {
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}

componentDidUpdate(prevProps) {
// 取消订阅之前的 friend.id
ChatAPI.unsubscribeFromFriendStatus(
prevProps.friend.id,
this.handleStatusChange
);
// 订阅新的 friend.id
ChatAPI.subscribeToFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}

componentWillUnmount() {
ChatAPI.unsubscribeFromFriendStatus(
this.props.friend.id,
this.handleStatusChange
);
}

忘记正确地处理 componentDidUpdate 是 React 应用中常见的 bug 来源。

那么我们的Hook在每次更新的时候都会进行effect的运行与清除,这样就可以避免上面的bug。

侠名按照时间列出一个可能会产生的订阅与取消订阅操作的调用序列,来帮助我们理解。

harmony
1
2
3
4
5
6
7
8
9
10
11
12
13
// Mount with { friend: { id: 100 } } props
ChatAPI.subscribeToFriendStatus(100, handleStatusChange); // 运行第一个 effect

// Update with { friend: { id: 200 } } props
ChatAPI.unsubscribeFromFriendStatus(100, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(200, handleStatusChange); // 运行下一个 effect

// Update with { friend: { id: 300 } } props
ChatAPI.unsubscribeFromFriendStatus(200, handleStatusChange); // 清除上一个 effect
ChatAPI.subscribeToFriendStatus(300, handleStatusChange); // 运行下一个 effect

// Unmount
ChatAPI.unsubscribeFromFriendStatus(300, handleStatusChange); // 清除最后一个 effect

useEffect的第二个参数表示只有在第二个参数发生变化的时候才执行副作用。

harmony
1
2
3
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]); // 仅在 count 更改时更新

传入一个空数组表示只在挂载的时候执行一次。

感悟

useEffect 这个hooks同类组件的生命周期相比有一个很大的优势就是可以将代码按照业务逻辑分类思考。每个Hook代表一个逻辑,我们只需要关注这个逻辑在每个周期需要做什么。而类组件的生命周期管理方式强迫我们将关注点放在一个一个阶段,这让我们对于业务的管理变得很混乱。

这也是为什么函数式组件的代码显得十分简洁和易于维护的一大原因。

总结

React 函数式组件的特性以及用法远不止这些。后面的文档我目前还不太能看懂。上面的这些部分已经足够帮助我胜任手头的业务需求了。我决定先进行实践。等未来自己有经验了,再去回看React的函数式组件。