前言
React-Hooks 自 React 16.8 以来才真正被推而广之,它是 React 团队在真刀真枪的 React 组件开发实践中,逐渐认知到的一个改进点,这背后其实涉及对类组件和函数组件两种组件形式的思考和侧重。
一、React-Hooks 设计动机与工作模式
1.何谓类组件(Class Component)
所谓类组件,就是基于 ES6 Class 这种写法,通过继承 React.Component 得来的 React 组件。以下是一个典型的类组件:
class DemoClass extends React.Component {
// 初始化类组件的 state
state = {
text: ""
};
// 编写生命周期方法 didMount
componentDidMount() {
// 省略业务逻辑
}
// 编写自定义的实例方法
changeText = (newText) => {
// 更新 state
this.setState({
text: newText
});
};
// 编写生命周期方法 render
render() {
return (
<div className="demoClass">
<p>{this.state.text}</p>
<button onClick={this.changeText}>点我修改</button>
</div>
);
}
}
2.何谓函数组件/无状态组件(Function Component/Stateless Component)
函数组件顾名思义,就是以函数的形态存在的 React 组件。早期并没有 React-Hooks 的加持,函数组件内部无法定义和维护 state,因此它还有一个别名叫“无状态组件”。以下是一个典型的函数组件:
function DemoFunction(props) {
const { text } = props
return (
<div className="demoFunction">
<p>{`function 组件所接收到的来自外界的文本内容是:[${text}]`}</p>
</div>
);
}
3.函数组件与类组件的对比:无关“优劣”,只谈“不同”
基于上面的两个 Demo,从形态上对两种组件做区分。它们之间肉眼可见的区别就包括但不限于:
类组件需要继承 class,函数组件不需要;
类组件可以访问生命周期方法,函数组件不能;
类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
类组件中可以定义并维护 state(状态),而函数组件不可以;
单就我们列出的这几点里面,频繁出现了“类组件可以 xxx,函数组件不可以 xxx”,这是否就意味着类组件比函数组件更好呢?
答案是否定的
4.重新理解类组件和函数组件
类组件:包裹在面向对象思想下的“重装战舰”;
类组件是面向对象编程思想的一种表征。React 类组件内部预置了相当多的“现成的东西”等着我们去调度/定制,state 和生命周期就是这些“现成东西”中的典型。要想得到这些东西,只需轻轻地继承一个 React.Component 即可。这种感觉就好像是你不费吹灰之力,就拥有了一辆“重装战舰”,该有的枪炮导弹早已配备整齐,就等你操纵控制台上的一堆开关了。
毋庸置疑,类组件给到开发者的东西是足够多的,但“多”不一定就是“好”。把一个人塞进重装战舰里,他就一定能操纵这台战舰吗?如果他没有经过严格的训练,不清楚每一个操作点的内涵,那他极有可能会把炮弹打到友军的营地里去。
React 类组件,也有同样的问题——它提供了多少东西,你就需要学多少东西。假如背不住生命周期,你的组件逻辑顺序大概率会变成一团糟。“大而全”的背后,是不可忽视的学习成本。
类组件的问题就是太重了,假如我们只需要打死一只蚊子,也需要去开动这一整个战舰,带来的也是比较高昂的成本。
函数组件:呼应 React 设计思想的“轻巧快艇”;
相比于类组件,函数组件肉眼可见的特质自然包括轻量、灵活、易于组织和维护、较低的学习成本等。与类组件最大的不同,函数组件会捕获 render 内部的状态。
5.React-Hooks核心API
(1).useState():为函数组件引入状态。
早期的函数组件相比于类组件,其一大劣势是缺乏定义和维护 state 的能力,而 state(状态)作为 React 组件的灵魂,必然是不可省略的。因此 React-Hooks 在诞生之初,就优先考虑了对 state 的支持。useState 正是这样一个能够为函数组件引入状态的 API。
useState出现之前:
import React, { Component } from "react";
export default class TextButton extends Component {
constructor() {
super();
this.state = {
text: "初始文本"
};
}
changeText = () => {
this.setState(() => {
return {
text: "修改后的文本"
};
});
};
render() {
const { text } = this.state;
return (
<div className="textButton">
<p>{text}</p>
<button onClick={this.changeText}>点击修改文本</button>
</div>
);
}
}
useState出现之后:
import React, { useState } from "react";
export default function Button() {
const [text, setText] = useState("初始文本");
function changeText() {
return setText("修改后的文本");
}
return (
<div className="textButton">
<p>{text}</p>
<button onClick={changeText}>点击修改文本</button>
</div>
);
}
(2).useEffect():允许函数组件执行副作用操作。
函数组件相比于类组件来说,最显著的差异就是 state 和生命周期的缺失。useState 为函数组件引入了 state,而 useEffect 则在一定程度上弥补了生命周期的缺席。
useEffect 能够为函数组件引入副作用。过去我们习惯放在 componentDidMount、componentDidUpdate 和 componentWillUnmount 三个生命周期里来做的事,现在可以放在 useEffect 里来做,比如操作 DOM、订阅事件、调用外部 API 获取数据等。
// 注意 hook 在使用之前需要引入
import React, { useState, useEffect } from 'react';
// 定义函数组件
function IncreasingTodoList() {
// 创建 count 状态及其对应的状态修改函数
const [count, setCount] = useState(0);
// 此处的定位与 componentDidMount 和 componentDidUpdate 相似
useEffect(() => {
// 每次 count 增加时,都增加对应的待办项
const todoList = document.getElementById("todoList");
const newItem = document.createElement("li");
newItem.innerHTML = `我是第${count}个待办项`;
todoList.append(newItem);
});
// 编写 UI 逻辑
return (
<div>
<p>当前共计 {count} 个todo Item</p>
<ul id="todoList"></ul>
<button onClick={() => setCount(count + 1)}>点我增加一个待办项</button>
</div>
);
}
useEffect 快速上手:
useEffect 可以接收两个参数,分别是回调函数与依赖数组,如下面代码所示:
useEffect(callBack, [])
useEffect 用什么姿势来调用,本质上取决于你想用它来达成什么样的效果。下面我们就以效果为线索,简单介绍 useEffect 的调用规则。
(1).每一次渲染后都执行的副作用:传入回调函数,不传依赖数组。调用形式如下所示:
useEffect(callBack)
(2).仅在挂载阶段执行一次的副作用:传入回调函数,且这个函数的返回值不是一个函数,同时传入一个空数组。调用形式如下所示:
useEffect(()=>{
// 这里是业务逻辑
}, [])
(3).仅在挂载阶段和卸载阶段执行的副作用:传入回调函数,且这个函数的返回值是一个函数,同时传入一个空数组。假如回调函数本身记为 A, 返回的函数记为 B,那么将在挂载阶段执行 A,卸载阶段执行 B。调用形式如下所示:
useEffect(()=>{
// 这里是 A 的业务逻辑
// 返回一个函数记为 B
return ()=>{
}
}, [])
这里需要注意,这种调用方式之所以会在卸载阶段去触发 B 函数的逻辑,是由 useEffect 的执行规则决定的:useEffect 回调中返回的函数被称为“清除函数”,当 React 识别到清除函数时,会在卸载时执行清除函数内部的逻辑。这个规律不会受第二个参数或者其他因素的影响,只要你在 useEffect 回调中返回了一个函数,它就会被作为清除函数来处理。
(4).每一次渲染都触发,且卸载阶段也会被触发的副作用:传入回调函数,且这个函数的返回值是一个函数,同时不传第二个参数。如下所示:
useEffect(()=>{
// 这里是 A 的业务逻辑
// 返回一个函数记为 B
return ()=>{
}
})
上面这段代码就会使得 React 在每一次渲染都去触发 A 逻辑,并且在卸载阶段去触发 B 逻辑。
其实你只要记住,如果你有一段副作用逻辑需要在卸载阶段执行,那么把它写进 useEffect 回调的返回函数(上面示例中的 B 函数)里就行了。也可以认为,这个 B 函数的角色定位就类似于生命周期里 componentWillUnmount 方法里的逻辑
(5).根据一定的依赖条件来触发的副作用:传入回调函数(若返回值是一个函数,仍然仅影响卸载阶段对副作用的处理,此处不再赘述),同时传入一个非空的数组,如下所示:
useEffect(()=>{
// 这是回调函数的业务逻辑
// 若 xxx 是一个函数,则 xxx 会在组件卸载时被触发
return xxx
}, [num1, num2, num3])
这里给出的一个示意数组是 [num1, num2, num3]。首先需要说明,数组中的变量一般都是来源于组件本身的数据(props 或者 state)。若数组不为空,那么 React 就会在新的一次渲染后去对比前后两次的渲染,查看数组内是否有变量发生了更新(只要有一个数组元素变了,就会被认为更新发生了),并在有更新的前提下去触发 useEffect 中定义的副作用逻辑。
(3).useContext():嵌套组件传值。
import React, { useState ,,useContext, createContext} from 'react';
import './App.css';
// 创建一个 context
const Context = createContext(0)
// 组件一, useContext 写法
function Item () {
const count = useContext(Context);
return (
<div>{ count }</div>
)
}
function App () {
const [ count, setCount ] = useState(0)
return (
<div>
点击次数: { count }
<button onClick={() => { setCount(count + 1)}}>点我</button>
<Context.Provider value={count}>
<Item></Item>
</Context.Provider>
</div>
)
}
export default App;
6.Hooks 是如何帮助我们升级工作模式的
(1).告别难以理解的 Class:把握 Class 的两大“痛点”.
坊间总有传言说 Class 是“难以理解”的,这个说法的背后是 this 和生命周期这两个痛点。
先来说说 this,看看下面这段代码:
class Example extends Component {
state = {
name: '修言',
age: '99';
};
changeAge() {
// 这里会报错
this.setState({
age: '100'
});
}
render() {
return <button onClick={this.changeAge}>{this.state.name}的年龄是{this.state.age}</button>
}
}
changeAge 里并不能拿到组件实例的 this,所以会报错。为了解决 this 不符合预期的问题,前端也是各显神通,之前用 bind、现在推崇箭头函数。但不管什么招数,本质上都是在用实践层面的约束来解决设计层面的问题。
(2). Hooks 如何实现更好的逻辑拆分.
在过去,我们是怎么组织自己的业务逻辑的呢?多数情况下应该都是先想清楚业务的需要是什么样的,然后将对应的业务逻辑拆到不同的生命周期函数里去——没错,逻辑曾经一度与生命周期耦合在一起。
在这样的前提下,生命周期函数常常做一些奇奇怪怪的事情:比如在 componentDidMount 里获取数据,在 componentDidUpdate 里根据数据的变化去更新 DOM 等。如果说你只用一个生命周期做一件事,那好像也还可以接受,但是往往在一个稍微成规模的 React 项目中,一个生命周期不止做一件事情。下面这段伪代码就很好地诠释了这一点:
componentDidMount() {
// 1. 这里发起异步调用
// 2. 这里从 props 里获取某个数据,根据这个数据更新 DOM
// 3. 这里设置一个订阅
// 4. 这里随便干点别的什么
// ...
}
componentWillUnMount() {
// 在这里卸载订阅
}
componentDidUpdate() {
// 1. 在这里根据 DidMount 获取到的异步数据更新 DOM
// 2. 这里从 props 里获取某个数据,根据这个数据更新 DOM(和 DidMount 的第2步一样)
}
像这样的生命周期函数,它的体积过于庞大,做的事情过于复杂,会给阅读和维护者带来很多麻烦。最重要的是,这些事情之间看上去毫无关联,逻辑就像是被“打散”进生命周期里了一样。比如,设置订阅和卸载订阅的逻辑,虽然它们在逻辑上是有强关联的,但是却只能被分散到不同的生命周期函数里去处理,这不能算作是一个非常合理的设计。
而在 Hooks 的帮助下,我们完全可以把这些繁杂的操作按照逻辑上的关联拆分进不同的函数组件里:我们可以有专门管理订阅的函数组件、专门处理 DOM 的函数组件、专门获取数据的函数组件等。Hooks 能够帮助我们实现业务逻辑的聚合,避免复杂的组件和冗余的代码。
(3). 状态复用:Hooks 将复杂的问题变简单.
Hooks 可以视作是 React 为解决状态逻辑复用这个问题所提供的一个原生途径。我们可以通过自定义 Hook,达到既不破坏组件结构、又能够实现逻辑复用的效果。
7.Hooks并非万能
在认识到 Hooks 带来的利好的同时,还需要认识到它的局限性。
(1).Hooks 暂时还不能完全地为函数组件补齐类组件的能力:比如 getSnapshotBeforeUpdate、componentDidCatch 这些生命周期,目前都还是强依赖类组件的。官方虽然立了“会尽早把它们加进来”的 Flag,但是目前已经过去很久了……
(2).“轻量”几乎是函数组件的基因,这可能会使它不能够很好地消化“复杂” :我们有时会在类组件中见到一些方法非常繁多的实例,如果用函数组件来解决相同的问题,业务逻辑的拆分和组织会是一个很大的挑战。在“过于复杂”和“过度拆分”之间很难把控,耦合和内聚的边界,有时候很难把握,函数组件给了我们一定程度的自由,却也对开发者的水平提出了更高的要求。
(3).Hooks 在使用层面有着严格的规则约束:对于如今的 React 开发者来说,如果不能牢记并践行 Hooks 的使用原则,如果对 Hooks 的关键原理没有扎实的把握,很容易把自己的 React 项目搞成大型车祸现场。
React 团队面向开发者给出了两条 React-Hooks 的使用原则,原则的内容如下:
(1). 只在 React 函数中调用 Hook;
(2). 不要在循环、条件或嵌套函数中调用 Hook。
对于第一个原则很好理解,reack-Hooks本身就是基于react的,目前也没看到有人把它用在其它地方;
对于第二个原则,来看看如果不遵循会怎么样:
import React, { useState } from "react";
function PersonalInfoComponent() {
// 集中定义变量
let name, age, career, setName, setCareer;
// 获取姓名状态
[name, setName] = useState("修言");
// 获取年龄状态
[age] = useState("99");
// 获取职业状态
[career, setCareer] = useState("我是一个前端,爱吃小熊饼干");
// 输出职业信息
console.log("career", career);
// 编写 UI 逻辑
return (
<div className="personalInfo">
<p>姓名:{name}</p>
<p>年龄:{age}</p>
<p>职业:{career}</p>
<button
onClick={() => {
setName("秀妍");
}}
>
修改姓名
</button>
</div>
);
}
export default PersonalInfoComponent;
这个组件渲染后的页面是这样的:
我们来点击按钮之后,界面变成这样:
一切正常。接下来我们改造一下这个组件:把一部分的 useState 操作放进 if 语句里,事情就会变得大不一样。改动后的代码如下:
import React, { useState } from "react";
// isMounted 用于记录是否已挂载(是否是首次渲染)
let isMounted = false;
function PersonalInfoComponent() {
// 定义变量的逻辑不变
let name, age, career, setName, setCareer;
// 这里追加对 isMounted 的输出,这是一个 debug 性质的操作
console.log("isMounted is", isMounted);
// 这里追加 if 逻辑:只有在首次渲染(组件还未挂载)时,才获取 name、age 两个状态
if (!isMounted) {
// eslint-disable-next-line
[name, setName] = useState("修言");
// eslint-disable-next-line
[age] = useState("99");
// if 内部的逻辑执行一次后,就将 isMounted 置为 true(说明已挂载,后续都不再是首次渲染了)
isMounted = true;
}
// 对职业信息的获取逻辑不变
[career, setCareer] = useState("我是一个前端,爱吃小熊饼干");
// 这里追加对 career 的输出,这也是一个 debug 性质的操作
console.log("career", career);
// UI 逻辑的改动在于,name和age成了可选的展示项,若值为空,则不展示
return (
<div className="personalInfo">
{name ? <p>姓名:{name}</p> : null}
{age ? <p>年龄:{age}</p> : null}
<p>职业:{career}</p>
<button
onClick={() => {
setName("秀妍");
}}
>
修改姓名
</button>
</div>
);
}
export default PersonalInfoComponent;
修改后的页面,刚开始和原来一样:
修改后的组件在初始挂载的时候,实际执行的逻辑内容和上个版本是没有区别的,都涉及对 name、age、career 三个状态的获取和渲染。理论上来说,变化应该发生在我单击“修改姓名”之后触发的二次渲染里:二次渲染时,isMounted 已经被置为 true,if 内部的逻辑会被直接跳过。此时按照代码注释中给出的设计意图,这里我希望在二次渲染时,只获取并展示 career 这一个状态。那么事情是否会如我所愿呢?我们一起来看看单击“修改姓名”按钮后会发生什么:
组件直接报错了,报错信息提醒我们,这是因为“组件渲染的 Hooks 比期望中更少”。
确实,按照现有的逻辑,初始渲染调用了三次 useState,而二次渲染时只会调用一次。但仅仅因为这个,就要报错吗?
按道理来说,二次渲染的时候,只要获取到的 career 值没有问题,那么渲染就应该是没有问题的(因为二次渲染实际只会渲染 career 这一个状态),React 就没有理由阻止渲染动作。
我们每次渲染的时候都去输出一次 isMounted 和 career 这两个变量的值。下面就来看看这两个变量到底是什么情况。
首先将界面重置回初次挂载的状态,观察控制台的输出,如下图所示:
isMounted 值为 false,说明是初次渲染;career 值为“我是一个前端,爱吃小熊饼干”,这也是没有问题的。
接下来单击“修改姓名”按钮后,我们再来看一眼两个变量的内容,如下图所示:
二次渲染时,isMounted 为 true,这个没毛病。但是 career 竟然被修改为了“秀妍”,这是不是太诡异了?
我们调用的是 setName,那么它修改的状态也应该是 name,而不是 career。
原因一开始渲染时生成一个根据所有的useState对应的链表,后序对state的读写操作,都是根据索引来进行的,二次渲染时,只有一个useState,对应链表的第一个就变成了name,所以career 对应的值就变成了name 的值:秀妍。
二、在使用过程中遇到的问题
1.如何在局部css样式的写法下,修改第三方组件的样式:
可以使用 :global
2.input等输入框,使用事件对象获取e.target.value 为null:
如果想异步访问事件属性,需在事件上调用event.persist(),此方法会从池中移除合成事件,允许用户代码保留对事件的引用。
3.复用的方法:
可以提取到单独的ts文件中,像utils一样使用:
也可以封装和使用自定义Hooks:
export const useRequest = (fn, dependencies) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
// 请求的方法 这个方法会自动管理loading
const request = () => {
setLoading(true);
fn()
.then(setData)
.finally(() => {
setLoading(false);
});
};
// 根据传入的依赖项来执行请求
useEffect(() => {
request()
}, dependencies);
return {
// 请求获取的数据
data,
// loading状态
loading,
// 请求的方法封装
request
};
};
使用:
import { fetchTodos } from './api'
import { useRequest } from './hooks'
const TAB_ALL = "all";
const TAB_FINISHED = "finished";
const TAB_UNFINISHED = "unfinished";
const tabMap = {
[TAB_ALL]: "全部",
[TAB_FINISHED]: "已完成",
[TAB_UNFINISHED]: "待完成"
};
function App() {
const [activeTab, setActiveTab] = useState(TAB_ALL);
// 获取数据
const {loading, data: todos} = useRequest(() => {
return fetchTodos({ tab: activeTab });
}, [activeTab])
return (
<>
<Tabs activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab={tabMap[TAB_ALL]} key={TAB_ALL} />
<TabPane tab={tabMap[TAB_FINISHED]} key={TAB_FINISHED} />
<TabPane tab={tabMap[TAB_UNFINISHED]} key={TAB_UNFINISHED} />
</Tabs>
<div className="app-wrap">
<h1 className="app-title">Todo List</h1>
<Input />
<Spin spinning={loading} tip="稍等片刻~">
<!--把todos传递给组件-->
<TodoList todos={todos}/>
</Spin>
</div>
</>
);
}
4.可以在setState中传入一个有返回值的函数:
setList(() => {
// 新建一个数组
let res = [];
// 这里执行一些操作
...
// 最后返回这个数组
return res;
})
5.setState赋值的时候有时候需要更新引用才能赋值成功,更新视图:
// 这样写不行,也不能触发视图更新
const cpuChartData = chartData
// 为啥这样写就一切正常
const cpuChartData = [...chartData];