0%

为什么使用Hooks

原本我以为React新增的React Hooks特性带来的利好是前端相关从业者的基本共识,但后来发现并不是这样的。React Hooks还很年轻,还没有被相当一部分开发人员了解和熟知。现在我打算通过这篇文章阐明React Hooks解决的主要问题,并安利大家尝试使用。 (2020.8.16 – 第二次改版,更好的增加、处理了比对,部分地方重新组织了语言)

提纲:

  • 背景
  • 类组件的问题及和使用React Hooks编写的函数组件的对比分析
    • 问题一. 巨大的复杂的组件、复杂的模式(像 高阶组件 和 render props等),使代码难以理解、重构、测试等等
    • 问题二. 跨组件复用包含状态的逻辑十分困难
    • 问题三. 人和机器对class都难以理解
  • 总结
  • Hooks对性能的影响
  • 结语

背景

我们知道React的整体思想是,通过将应用程序分解成可以组合在一起的独立组件,来更好地管理应用程序的复杂性。这个组件模型使React变得很是优雅,也是React精妙的地方。但是,问题不在于组件模型,而在于如何实现组件模型。 在React Hooks没有出来之前,上述组建模型主要是以类为载体实现的。在React 16.8新增React Hooks特性之后,我们可以在不编写类的情况下使用 state 以及其他的 React 特性,这为上述组件模型的实现提供了【全新的思维模型】(这是一种思维模型的转变)

类组件的问题及和使用React Hooks编写的函数组件的对比分析

下边列举了React Hooks之前用类组件写React项目存在的一些问题,及引入React Hooks之后实现上的对比。

问题一. 巨大的复杂的组件、复杂的模式(像 高阶组件 和 render props等),使代码难以理解、重构、测试等等

案例:我们先用类实现一个组件,然后用Hooks重写。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* 用类实现的组件
*/
import React from 'react';
import Profile from './Profile';
import { WithHover, WithTheme, WithAuth, WithRepos } from './HOC';
import { fetchRepos } from './WebAPI';

export default class RepoPanel extends React.Component {
constructor(props) {
super(props);
this.state = {
repos: [],
width: window.innerWidth,
}
this.updateRepos = this.updateRepos.bind(this);
this.handleResize = this.handleResize.bind(this);
}

componentDidMount() {
this.updateRepos(this.props.id)
window.addEventListener('resize', this.handleResize);
}

componentDidUpdate(prevProps) {
if (prevProps.id !== this.props.id) {
this.updateRepos(this.props.id)
}
}

componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}

handleResize() {
this.setState({
width: window.innerWidth
});
}

updateRepos (id) {
fetchRepos(id)
.then((repos) => this.setState({
repos,
}))
}

render() {
return (
<WithHover>
<WithTheme hovering={false}>
<WithAuth hovering={false} theme='dark'>
<WithRepos hovering={false} theme='dark' authed={true}>
<div>{this.state.width}</div>
<Profile
repos={this.state.repos}
hovering={false}
theme='dark'
authed={true}
/>
</WithRepos>
</WithAuth>
</WithTheme>
</WithHover>
);
}
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 用Hooks实现的组件
*/
import React, { useState, useEffect } from 'react';
import Profile from './Profile';
import { useHover, useTheme, useAuth, useRepos } from './CustomHooks';
import { fetchRepos } from './WebAPI';

export default function RepoPanel(props) {
const hovering = useHover();
const theme = useTheme();
const authed = useAuth();
useRepos()

const [repos, setRepos] = useState([]);
useEffect(() => {
fetchRepos(id)
.then((repos) => {
setRepos(repos)
})
}, [id])

const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
})

return (
<>
<div>{width}</div>
<Profile
repos={repos}
authed={authed}
theme={theme}
hovering={hovering}
/>
</>
);
}

可视化比对

通过上边简单对比,我们尝试着分析下产生问题一的原因:

  • 用类实现的组件生命周期众多。
  • 用类实现的组件代码逻辑会根据不同的触发时机分散到不同的生命周期中。随着业务的膨胀,不同生命周期中无关联的代码会越来越多,但同时不同生命周期中相关联的代码变更又需要保持同步,这些都会造成混乱。
    (eg:上边案例类实现组件的componentDidMount中的两句调用压根就是不相关的逻辑。componentDidMountcomponentDidUpdate中有相关联的代码逻辑,这里我们抽取公共部分到方法updateRepos中,尽管可以抽取,但是我们仍然需要在两个地方调用它,而且需要记得保持一致。分散在componentDidMountcomponentWillUnmount中对窗口大小监听的add和remove也是要保持同步。)
  • 复杂模式的嵌套会使得跟踪数据流变得困难。

通过对比分析我们能发现Hooks解决了上述问题,通过Hooks重写的组件,组织代码不再是基于生命周期函数进行了,而是基于这段代码要做什么进行,逻辑默认具有一致性。Hooks拥抱了函数,函数易于拆解,即便面对巨大的复杂的组件我们也可以轻易的拆分出适当大小的组件,上边复杂模式的嵌套在重写中就被消除了(复杂模式下文有更多介绍)。因为Hooks 可以使我们始终使用函数,我们也就不必在函数、类、高阶组件和 render 属性之间不断切换了,开发人员可以清理自己的思想,减少编写 React 应用时需要考虑的概念数量,编写更干净、更有声明性、更加模块化的代码,这样的代码本身产生错误的机会更少,更易编写、理解、重构、测试等等。

问题二. 跨组件复用包含状态的逻辑十分困难

原因:在类组件中重复的状态逻辑分散在不同的组件和生命周期函数之间。(上边案例中可以看出一些端倪)

类组件中有两种主要的模式来复用有状态的逻辑——高阶组件(HOC) & render props。但使用这两种模式复用状态逻辑要修改原有组件结构,这可能会很麻烦,并且会使数据流追踪变得困难,如果你使用React DevTools定位React 元素,可能会出现“包装地狱”(wrapper hell,如下图),这会使定位变得麻烦。而Hooks可以让我们在不修改组件结构的情况下轻松复用状态逻辑(提取出自定义Hook,比如上边案例中提取的useHover, useTheme, useAuth, useRepos),最终使问题二得以解决。

要进一步提取状态逻辑也很easy,如下进一步提取的自定义Hook useWindowWidth:

1
2
3
4
5
6
7
8
9
10
11
export default function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => setWidth(window.innerWidth);
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
})
return width;
}

提取后我们再对组件内容做个对比:(最右侧为最新的)

怎么样,这代码书写、组织起来它不香?它生产力它能不高?

问题三. 人和机器对class都难以理解

原因:

  • 从人的角度上讲,类组件需要关心this、继承、super等等概念,可能还要进行额外的bind等。另外在写无状态组件时,因为会担心早晚要将无状态组件转化为类组件,会对是否需要使用无状态组件产生困惑。
  • 从机器的角度上讲,类组件编译体积较大;热重载不稳定;压缩后的组件文件,方法名没有被压缩;如果有一个完全没有使用过的方法,也不会被踢除,因为编译的时候难以判断方法是否会被使用,编译器优化变得更困难。

之前为了能够使用 state 和生命周期,只能选择类组件,而现在我们可以使用Hooks方案编写的函数组件替代类组件了。
Hooks编写的函数组件更易于理解,因为函数组件的特殊性,React 底层还可以做 更多的性能优化

简单对比下不使用class的组件和使用class的组件 babel编译后的文件内容:

总结

首先,我们要知道,即使新编写的组件采用了 Hooks,原有的类编写的代码仍能照常运行,这很好的避免了使用新特性后对原有代码做较大重写的麻烦。然后,Hooks 是解决上边所有这些问题的最佳实践,Hooks 基本可以涵盖类的所有应用场景,同时在抽象,测试和复用代码方面提供更大的灵活性。 Hooks 让我们用更少的精力去构建组件,并提供更好的用户体验。Hooks可以有效的提升开发效率、降低维护成本。 Hooks 代表了 React 未来的愿景

Hooks对性能的影响

就部署大小而言,对 Hooks 的支持仅仅增加了 React 约 1.5kB(min + gzip)的大小。但由于少了class的兼容代码等,使用 Hooks 的函数组件代码通常可以比使用类的等效代码压缩得更小,再加上代码更扁平化后React本身可做更多的性能优化等等,最终会使相同情况下页面的加载速度、交互体验等有所提升,但性能不应成为使用Hooks的主要原因, Hooks主要目的是简单地将函数组件提升到与类组件相同的级别,从而通过更干净,更可维护的代码,更好地在组件之间重用方法,以及更简单、更具声明性的使用方式,获得更好的开发人员体验。

结语

怎么样,心动了吗,快快尝试用Hooks开发你的项目吧。尝试前还可以翻阅我之前写的另一篇文章 Hooks方案的实践小结。
PS. 类组件的发展历程:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* (1). createClass -- 创建React组件的原始方式。最初使用createClass API的原因是,当时JavaScript没有内置的类系统。
*/
const RepoPanel = React.createClass({
getInitialState () {
return {
repos: [],
loading: true
}
},
componentDidMount () {
this.updateRepos(this.props.id)
},
componentDidUpdate (prevProps) {
if (prevProps.id !== this.props.id) {
this.updateRepos(this.props.id)
}
},
updateRepos (id) {
this.setState({ loading: true })

fetchRepos(id)
.then((repos) => this.setState({
repos,
loading: false
}))
},
render() {
const { loading, repos } = this.state

if (loading === true) {
return <Loading />
}

return (
<ul>
{repos.map(({ name, status, namespace, url }) => (
<li key={name}>
<ul>
<li><a href={url}>{name}</a></li>
<li>{status}</li>
<li>{namespace}</li>
</ul>
</li>
))}
</ul>
)
}
})
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* (2). React.Component
* 在ES6中,JavaScript引入了class关键字,并以一种原生方式在JavaScript中创建类。
* 如下我们通过class创建组件,使React更符合EcmaScript标准
*/
class RepoPanel extends React.Component {

// 在构造函数方法内部将组件的状态初始化为实例上的state属性
constructor (props) {
// 根据ECMAScript规范,如果要扩展子类,则必须先调用super,并传递prop。
super(props)

this.state = {
repos: [],
loading: true
}

// 使用createClass时,React会自动将所有方法绑定到组件的实例上。
// 使用React.Component时,则需要记住在类的构造函数中手动调用.bind方法,否则你会收到常见的“Cannot read property setState of undefined” 错误
this.updateRepos = this.updateRepos.bind(this)
}
componentDidMount () {
this.updateRepos(this.props.id)
}
componentDidUpdate (prevProps) {
if (prevProps.id !== this.props.id) {
this.updateRepos(this.props.id)
}
}

// 在新版本的 ES 中,有 Public Class Fields Syntax(https://babeljs.io/docs/en/babel-plugin-proposal-class-properties) 可以解决上边的手动bind问题
// updateRepos = (id) => {...}
updateRepos (id) {
this.setState({ loading: true })

fetchRepos(id)
.then((repos) => this.setState({
repos,
loading: false
}))
}
render() {
if (this.state.loading === true) {
return <Loading />
}

return (
<ul>
{repos.map(({ name, status, namespace, url }) => (
<li key={name}>
<ul>
<li><a href={url}>{name}</a></li>
<li>{status}</li>
<li>{namespace}</li>
</ul>
</li>
))}
</ul>
)
}
}
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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* (3). Class Fields -- 允许你直接将实例属性添加为类的属性,而不必使用构造函数。
* 解决了上边2个问题:
* 1. 不再需要使用构造函数来设置组件的初始状态
* 2. 不再需要在构造函数中使用.bind(因为我们可以将箭头函数用于我们的方法 ??class中不行吗?也可以不过需要语法支持,上边安装插件后就可以了)
*/
class RepoPanel extends React.Component {
state = {
repos: [],
loading: true
}
componentDidMount () {
this.updateRepos(this.props.id)
}
componentDidUpdate (prevProps) {
if (prevProps.id !== this.props.id) {
this.updateRepos(this.props.id)
}
}
updateRepos = (id) => {
this.setState({ loading: true })

fetchRepos(id)
.then((repos) => this.setState({
repos,
loading: false
}))
}
render() {
const { loading, repos } = this.state

if (loading === true) {
return <Loading />
}

return (
<ul>
{repos.map(({ name, status, namespace, url }) => (
<li key={name}>
<ul>
<li><a href={url}>{name}</a></li>
<li>{status}</li>
<li>{namespace}</li>
</ul>
</li>
))}
</ul>
)
}
}

参考:
React Today and Tomorrow and 90% Cleaner React With Hooks
Making Sense of React Hooks
React Hooks- Understanding the basics
Why React Hooks?