一、为什么需要函数组件?
开篇我们先来聊“Why”。React-Hooks 这个东西比较特别,它是 React 团队在真刀真枪的 React 组件开发实践中,逐渐认知到的一个改进点,这背后其实涉及对类组件和函数组件两种组件形式的思考和侧重。因此,你首先得知道,什么是类组件、什么是函数组件,并完成对这两种组件形式的辨析。
类组件(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 (
{this.state.text}
);
}
}
函数组件/无状态组件(Function Component/Stateless Component)
函数组件顾名思义,就是以函数的形态存在的 React 组件。早期并没有 React-Hooks 的加持,函数组件内部无法定义和维护 state,因此它还有一个别名叫“无状态组件”。以下是一个典型的函数组件:
function DemoFunction(props) {
const { text } = props
return (
{`function 组件所接收到的来自外界的文本内容是:[${text}]`}
);
}
两类组件的不同
函数组件与类组件的对比:无关“优劣”,只谈“不同”。你可以说,在 React-Hooks 出现之前的世界里,类组件的能力边界明显强于函数组件,但要进一步推导“类组件强于函数组件”,未免显得有些牵强。同理,一些文章中一味鼓吹函数组件轻量优雅上手迅速,不久的将来一定会把类组件干没(类组件:我做错了什么?)之类的,更是不可偏听偏信。
当我们讨论这两种组件形式时,不应怀揣“孰优孰劣”这样的成见,前端培训而应该更多地去关注两者的不同,进而把不同的特性与不同的场景做连接,这样才能求得一个全面的、辩证的认知。
类组件需要继承 class,函数组件不需要;
类组件可以访问生命周期方法,函数组件不能;
类组件中可以获取到实例化后的 this,并基于这个 this 做各种各样的事情,而函数组件不可以;
类组件中可以定义并维护 state(状态),而函数组件不可以;
函数组件更轻量,更呼应 React 设计思想的“轻巧快艇”,更加契合 React 框架的设计理念。同样逻辑的函数组件相比类组件而言,代码复杂度要低得多得多。
类组件更重,包裹在面向对象思想下的“重装战舰”
函数组件会捕获 render 内部的状态,这是两类组件最大的不同。类组件很难做到这一点。
不夸张地说,React 组件本身的定位就是函数,一个吃进数据、吐出 UI 的函数。作为开发者,我们编写的是声明式的代码,而 React 框架的主要工作,就是及时地把声明式的代码转换为命令式的 DOM 操作,把数据层面的描述映射到用户可见的 UI 变化中去。这就意味着从原则上来讲,React 的数据应该总是紧紧地和渲染绑定在一起的,而类组件做不到这一点。
重新理解类组件:包裹在面向对象思想下的“重装战舰”
React 类组件内部预置了相当多的“现成的东西”等着你去调度/定制,state 和生命周期就是这些“现成东西”中的典型。要想得到这些东西,难度也不大,你只需要轻轻地继承一个 React.Component 即可。
比较全面的组件带来的问题是
(1)很多问题不需要编写一个类组件来解决,过于复杂
(2)开发者编写的逻辑在封装后是和组件粘在一起的,这就使得类组件内部的逻辑难以实现拆分和复用。如果你想要打破这个僵局,则需要进一步学习更加复杂的设计模式(比如高阶组件、Render Props 等),用更高的学习成本来交换一点点编码的灵活度。
这一切的一切,光是想想就让人头秃。所以说,类组件固然强大, 但它绝非万能。
深入理解函数组件:呼应 React 设计思想的“轻巧快艇”
我们再来看这个函数组件的 case:
function DemoFunction(props) {
const { text } = props
return (
{`function 组件所接收到的来自外界的文本内容是:[${text}]`}
);
}
当然啦,要是你以为函数组件的简单是因为它只能承担渲染这一种任务,那可就太小瞧它了。它同样能够承接相对复杂的交互逻辑,像这样:
function DemoFunction(props) {
const { text } = props
const showAlert = () => {
alert(`我接收到的文本是${text}`)
}
return (
{`function 组件所接收到的来自外界的文本内容是:[${text}]`}
);
}
相比于类组件,函数组件肉眼可见的特质自然包括轻量、灵活、易于组织和维护、较低的学习成本等。这些要素毫无疑问是重要的,它们也确实驱动着 React 团队做出改变。但是除此之外,还有一个非常容易被大家忽视、也极少有人能真正理解到的知识点,我在这里要着重讲一下。这个知识点缘起于 React 作者 Dan 早期特意为类组件和函数组件写过的一篇非常棒的对比文章,这篇文章很长,但是通篇都在论证这一句话:
函数组件会捕获 render 内部的状态,这是两类组件最大的不同。
不夸张地说,React 组件本身的定位就是函数,一个吃进数据、吐出 UI 的函数。作为开发者,我们编写的是声明式的代码,而 React 框架的主要工作,就是及时地把声明式的代码转换为命令式的 DOM 操作,把数据层面的描述映射到用户可见的 UI 变化中去。这就意味着从原则上来讲,React 的数据应该总是紧紧地和渲染绑定在一起的,而类组件做不到这一点。
为什么类组件做不到?这里我摘出上述文章中的 Demo,站在一个新的视角来解读一下“函数组件会捕获 render 内部的状态,这是两类组件最大的不同”这个结论。
在线测试Demo
这个组件返回的是一个按钮,交互内容也很简单:点击按钮后,过 3s,界面上会弹出“Followed xxx”的文案。类似于我们在微博上点击“关注某人”之后弹出的“已关注”这样的提醒。
看起来好像没啥毛病,但是如果你在这个在线 Demo中尝试点击基于类组件形式编写的 ProfilePage 按钮后 3s 内把用户切换为 Sophie
明明我们是在 Dan 的主页点击的关注,结果却提示了“Followed Sophie”!
这个现象必然让许多人感到困惑:user 的内容是通过 props 下发的,props 作为不可变值,为什么会从 Dan 变成 Sophie 呢?
因为虽然 props 本身是不可变的,但 this 却是可变的,this 上的数据是可以被修改的,this.props 的调用每次都会获取最新的 props,而这正是 React 确保数据实时性的一个重要手段。
多数情况下,在 React 生命周期对执行顺序的调控下,this.props 和 this.state 的变化都能够和预期中的渲染动作保持一致。但在这个案例中,我们通过setTimeout 将预期中的渲染推迟了 3s,打破了 this.props 和渲染动作之间的这种时机上的关联,进而导致渲染时捕获到的是一个错误的、修改后的 this.props。这就是问题的所在。
props 会在 ProfilePage 函数执行的一瞬间就被捕获,而 props 本身又是一个不可变值,因此我们可以充分确保从现在开始,在任何时机下读取到的 props,都是最初捕获到的那个 props。深圳前端培训当父组件传入新的 props 来尝试重新渲染 ProfilePage 时,本质上是基于新的 props 入参发起了一次全新的函数调用,并不会影响上一次调用对上一个 props 的捕获。这样一来,我们便确保了渲染结果确实能够符合预期。
如果你认真阅读了我前面说过的那些话,相信你现在一定也不仅仅能够充分理解 Dan 所想要表达的“函数组件会捕获 render 内部的状态”这个结论,而是能够更进一步地意识到这样一件事情:函数组件真正地把数据和渲染绑定到了一起。
为什么需要Hooks?
正因为函数组件的优点,函数组件变得不可或缺。因此函数组件相较于类组件缺失的state、生命周期等功能,需要使用Hooks来完善。
Hooks 的本质:一套能够使函数组件更强大、更灵活的“钩子”
React-Hooks 是什么?它是一套能够使函数组件更强大、更灵活的“钩子”。
前面我们已经说过,函数组件比起类组件“少”了很多东西,比如生命周期、对 state 的管理等。这就给函数组件的使用带来了非常多的局限性,导致我们并不能使用函数这种形式,写出一个真正的全功能的组件。
React-Hooks 的出现,就是为了帮助函数组件补齐这些(相对于类组件来说)缺失的能力。
如果说函数组件是一台轻巧的快艇,那么 React-Hooks 就是一个内容丰富的零部件箱。“重装战舰”所预置的那些设备,这个箱子里基本全都有,同时它还不强制你全都要,而是允许你自由地选择和使用你需要的那些能力,然后将这些能力以 Hook(钩子)的形式“钩”进你的组件里,从而定制出一个最适合你的“专属战舰”。
二、How 怎么使用 react-hooks?
编码实例来帮助你认识 useState、useEffect 这两个有代表性的 Hook。请你摒弃“认识的 API 名字越多就越牛”这种错误的学习理念。如果你希望掌握尽可能多的 Hook 的用法,点击这里可以一键进入 React-Hooks API 文档的海洋。对本课时来说,所有涉及对 API 用法的介绍都是 “教具”,仅仅是为后续更深层次的知识讲解作铺垫。
useState 给函数定义一个state
(1)定义函数组件的一个状态
const [name, setName] = useState('initialState');
(2)使用属性
console.log(state)
(3)修改属性
setName('fang')
useState 返回的是一个数组,数组的第一个元素对应的是我们想要的那个 state 变量,第二个元素对应的是能够修改这个变量的 API。
我们可以通过数组解构的语法,将这两个元素取出来,并且按照我们自己的想法命名。状态和修改状态的 API 名都是可以自定义的。
后续我们可以通过调用 setState(''),来修改 state 的值。
状态更新后会触发渲染层面的更新,这点和类组件是一致的。
当我们在函数组件中调用 React.useState 的时候,实际上是给这个组件关联了一个状态——注意,是“一个状态”而不是“一批状态”。这一点是相对于类组件中的 state 来说的。在类组件中,我们定义的 state 通常是一个如下所示的对象。而在 useState 这个钩子的使用背景下,state 就是单独的一个状态,它可以是任何你需要的 JS 类型。
this.state {
text: "初始文本",
length: 10000,
author: ["xiuyan", "cuicui", "yisi"]
}
useEffect() 用于为函数组件引入副作用的钩子
函数组件相比于类组件来说,最显著的差异就是 state 和生命周期的缺失。useState 为函数组件引入了 state,而 useEffect 则在一定程度上弥补了生命周期的缺席。副作用就是说函数组件的render在执行前后会执行一些其他函数。类似于Java中的AOP。useEffect 能够为函数组件引入副作用。过去我们习惯放在 componentDidMount、componentDidUpdate 和 componentWillUnmount 三个生命周期里来做的事,现在可以放在 useEffect 里来做,比如操作 DOM、订阅事件、调用外部 API 获取数据等。
useEffect(callBack, []) useEffect 可以接收两个参数,分别是回调函数与依赖数组 。
(1)每一次渲染后都执行的副作用:传入回调函数,不传依赖数组。调用形式如下所示:
useEffect(callBack)
(2)仅在挂载阶段执行一次的副作用:传入回调函数,且这个函数的返回值不是一个函数,同时传入一个空数组。调用形式如下所示:
useEffect(()=>{
// 这里是业务逻辑
}, [])
(3)仅在挂载阶段和卸载阶段执行的副作用:传入回调函数,且这个函数的返回值是一个函数,同时传入一个空数组。假如回调函数本身记为 A, 返回的函数记为 B,那么将在挂载阶段执行 A,卸载阶段执行 B。调用形式如下所示:
useEffect(()=>{
// 这里是 A 的业务逻辑
// 返回一个函数记为 B
return ()=>{
}
}, [])
(4)每一次渲染都触发,且卸载阶段也会被触发的副作用:传入回调函数,且这个函数的返回值是一个函数,同时不传第二个参数。如下所示:
useEffect(()=>{
// 这里是 A 的业务逻辑
// 返回一个函数记为 B
return ()=>{
}
})
(5)根据一定的依赖条件来触发的副作用:传入回调函数,同时传入一个非空的数组,如下所示:
useEffect(()=>{
// 这是回调函数的业务逻辑
// 若 xxx 是一个函数,则 xxx 会在组件每次因 num1、num2、num3 的改变而重新渲染时被触发
return xxx
}, [num1, num2, num3])
第(3)中情况需要注意,这种调用方式之所以会在卸载阶段去触发 B 函数的逻辑,是由 useEffect 的执行规则决定的:useEffect 回调中返回的函数被称为“清除函数”,当 React 识别到清除函数时,会在调用新的 effect 逻辑之前执行清除函数内部的逻辑。这个规律不会受第二个参数或者其他因素的影响,只要你在 useEffect 回调中返回了一个函数,它就会被作为清除函数来处理。
第(4)中情况,其实你只要记住,如果你有一段 effect 逻辑,需要在每次调用它之前对上一次的 effect 进行清理,那么把对应的清理逻辑写进 useEffect 回调的返回函数(上面示例中的 B 函数)里就行了。
第(5)中情况,这里我给出的一个示意数组是 [num1, num2, num3]。首先需要说明,数组中的变量一般都是来源于组件本身的数据(props 或者 state)。若数组不为空,那么 React 就会在新的一次渲染后去对比前后两次的渲染,查看数组内是否有变量发生了更新(只要有一个数组元素变了,就会被认为更新发生了),并在有更新的前提下去触发 useEffect 中定义的副作用逻辑。
三、Why React-Hooks?
为什么使用React-Hooks?
告别难以理解的 Class;
解决业务逻辑难以拆分的问题;
使状态逻辑复用变得简单可行;
函数组件从设计思想上来看,更加契合 React 的理念。
告别难以理解的 Class:把握 Class 的两大“痛点”
this。组件成员方法如果是普通function,里面的this不表示组件自身实例 。为了表示自身实例,之前通过用 bind、现在推崇箭头函数来实现。但不管什么招数,本质上都是在用实践层面的约束来解决设计层面的问题。好在现在有了 Hooks,一切都不一样了,我们可以在函数组件里放飞自我(毕竟函数组件是不用关心 this 的)哈哈,解放啦。
生命周期。
1)学习成本
2)不合理的逻辑规划方式。需要将逻辑打散,放到各个生命周期里。比如请求数据放在componentDidMount,分析数据放在componentDidUpdate。同一个数据处理逻辑分散。逻辑曾经一度与生命周期耦合在一起。
而在 Hooks 的帮助下,我们完全可以把这些繁杂的操作按照逻辑上的关联拆分进不同的函数组件里:我们可以有专门管理订阅的函数组件、专门处理 DOM 的函数组件、专门获取数据的函数组件等。Hooks 能够帮助我们实现业务逻辑的聚合,避免复杂的组件和冗余的代码。
状态复用:Hooks 将复杂的问题变简单
过去我们复用状态逻辑,靠的是 HOC(高阶组件)和 Render Props 这些组件设计模式,这是因为 React 在原生层面并没有为我们提供相关的途径。但这些设计模式并非万能,它们在实现逻辑复用的同时,也破坏着组件的结构,其中一个最常见的问题就是“嵌套地狱”现象。
Hooks 可以视作是 React 为解决状态逻辑复用这个问题所提供的一个原生途径。现在我们可以通过自定义 Hook,达到既不破坏组件结构、又能够实现逻辑复用的效果。
在认识到 Hooks 带来的利好的同时,还需要认识到它的局限性。
关于 Hooks 的局限性,目前社区鲜少有人讨论。这里我想结合团队开发过程当中遇到的一些瓶颈,和你分享实践中的几点感受:
Hooks 暂时还不能完全地为函数组件补齐类组件的能力:比如 getSnapshotBeforeUpdate、componentDidCatch 这些生命周期,目前都还是强依赖类组件的。官方虽然立了“会尽早把它们加进来”的 Flag,但是说真的,这个 Flag 真的立了蛮久了……(扶额)
“轻量”几乎是函数组件的基因,这可能会使它不能够很好地消化“复杂”:我们有时会在类组件中见到一些方法非常繁多的实例,如果用函数组件来解决相同的问题,业务逻辑的拆分和组织会是一个很大的挑战。我个人的感觉是,从头到尾都在“过于复杂”和“过度拆分”之间摇摆不定,哈哈。耦合和内聚的边界,有时候真的很难把握,函数组件给了我们一定程度的自由,却也对开发者的水平提出了更高的要求。
Hooks 在使用层面有着严格的规则约束:这也是我们下个课时要重点讲的内容。对于如今的 React 开发者来说,如果不能牢记并践行 Hooks 的使用原则,如果对 Hooks 的关键原理没有扎实的把握,很容易把自己的 React 项目搞成大型车祸现场。
四、React-Hooks使用原则
只在 React 函数中调用 Hook; 这个是说不要再普通函数中使用Hook。
不要在循环、条件或嵌套函数中调用 Hook。 原则 2 中强调的所有“不要”,都是在指向同一个目的,那就是要确保 Hooks 在每次渲染时都保持同样的执行顺序。
从源码调用流程看:Hooks 的正常运作,在底层依赖于顺序链表。初次渲染时,每定义一个Hook,就会将初始化后的hook对象放入链表。以后使用Hook时,会按照初次渲染的顺序将Hook和链表中的hook对象依次对应,不是按照hook变量名字使用。如果初次渲染和以后渲染数量或者位置不对应,数据就会错乱。
原则2例子
(1)以useState为例。如果初次渲染时使用到的useState为:
[name, setName] = useState("修言");
[age] = useState("99");
[career, setCareer] = useState("我是一个前端,爱吃小熊饼干");
(2)再次渲染时,因为if等判断条件使得只有一次调用:此时的useState会和上面初次渲染生成的链表中的第一个对应,从而导致数据错乱。
useState("我是一个前端,爱吃小熊饼干")