背景
自在春节期间react 16.8.0
发布以来,我们第一时间跟进决定使用Hook来进行业务的开发,并计划逐渐将团队积累的HOC转移到Hook上,保持后续开发几乎不再使用HOC。
除去针对诸如“通过Hook使用Redux要不要继续把组件和容器分离”等的讨论外,我们很快地发现对Hook API的不熟悉成为了开发和使用Hook的一个障碍。
我们在实际的业务场景下,遇到一个需求:
对于一些加载比较慢的资源,组件最初展示标准的Loading效果,但在一定时间(比如2秒)后,变为“资源较大,正在积极加载,请稍候”这样的友好提示,资源加载完毕后再展示具体内容。
对于一个展示的组件来说,我们希望的逻辑就是这样的:
const PureDisplay = ({isLoading, isDelayed, data}) => {
if (isDelayed) {
return <div>'Please wait a little more...'</div>;
}
if (isLoading) {
return <div>'Loading...'</div>;
}
return <div>{data}</div>;
};
通过isDelayed
和isLoading
这2个属性来表达3种状态(初始加载中、加载用时过长、已经加载完毕),使用条件分支展示不同的内容。
在以往,我们很容易判断出来isDelayed
的获取可以通过HOC来实现,因此我们也判断它可以用Hook来实现复用。但是在面对如何实现这个Hook的时候,出现了不小的疑惑,甚至无从下手。
最后在一翻讨论后,我们发现一种方法,即先实现一个HOC版本,再“翻译”成对应的Hook,能快速完成对应的代码。
HOC版本
假设我们需要实现一个withDelayHint
的HOC来实现这一逻辑,简单整理了一下它的功能:
- 知道组件当前是否在
loading
状态,如果不在的话就不用开定时器了。 - 如果处在
loading
状态,则打开一个定时器,指定时间后将isDelayed
由false
改为true
。 - 如果
loading
状态发生了变化,则需要停掉定时器,并回到第1步重新判断是不是要开新的定时,用于组件状态更新的场合。
基于上面的整理,HOC需要至少2个参数:
- 如何获取
loading
状态。最简单地方法是提供一个属性的名称,直接从props[loadingPropName]
拿,函数化一些可以提供一个函数来通过getLoading(props)
获取。 - 定时器的延迟时长,以毫秒为单位。
因此,它的实现还是相对简单的:
import React, {Component} from 'react';
export default (loadingPropName, delay) => ComponentIn => {
const ComponentOut = class extends Component {
state = {
timer: null,
isDelayed: false
};
tryStartTimer = () => {
this.setState({isDelayed: false});
if (this.props[loadingPropName]) {
const timer = setTimeout(() => this.setState({isDelayed: true}), delay);
this.setState({timer});
}
};
componentDidMount() {
this.tryStartTimer();
}
compoenntWillUnmount() {
clearTimeout(this.state.timer);
}
componentDidUpdate(prevProps) {
if (this.props[loadingPropName] !== prevProps[loadingPropName]) {
clearTimeout(this.state.timer);
this.tryStartTimer();
}
}
render() {
const {isDelayed} = this.state;
return <ComponentIn {...this.props} isDelayed={isDelayed} />;
}
};
ComponentOut.displayName = `withDelayHint(${ComponentIn.displayName || ComponentIn.name})`;
return ComponentOut;
};
在使用上,将组件用HOC包装一次,即可以拿到isDelayed
属性:
const DisplayWithDelay = withDelayHint('isLoading', 2000)(PureDisplay);
<DisplayWithDelay isLoading={true} />
这样组件就会在2秒后显示提示信息。
翻译为Hook
在React官方提供的hook中,与组件中各种逻辑都有对应的版本,比如:
setState
对应useState
。- 生命周期对应
useEffect
。
因此,我们把上面的代码一一通过映射来实现。需要注意的是,因为hook本身并不是组件的实现,所以是获取不到props
的,因此hook不会有“从props
中获取isLoading
”这个逻辑,而是直接接收isLoading
的值就行:
import {useRef, useState, useEffect} from 'react';
export default (loading, delay) => {
// 和render无关的属性可以用useRef来保存
const timer = useRef(null);
// setState转到useState
const [delayed, setDelayed] = useState(false);
// 生命周期核心部分用useEffect
useEffect(
() => {
if (loading) {
timer.current = setTimeout(() => setDelayed(true), delay);
}
// 清理的逻辑在这里返回
return () => clearTimeout(timer.current);
},
// componentDidUpdate里的if对应的属性在这里传
[loading]
);
return delayed;
};
在使用上也很方便:
const HookDisplay = props => {
// 这里直接给isLoading,而不是loadingPropName
const isDelayed = useDelayHint(props.isLoading, 2000);
return <PureDisplay {...props} isDelayed={isDelayed} />;
};
可以看到,原本用于HOC的PureDisplay
组件在此处还能继续用,这让HOC迁移到Hook的成本非常的小。
事实上,考虑到useEffect
是用函数返回函数,这2个函数的作用域相互连接,所以timer
这个用于清理时调用clearTimeout
的东西,是不需要useRef
来实现的,完全可以作为一个局部变量搞定:
useEffect(
() => {
const timer = loading ? setTimeout(() => setDelayed(true), delay) : null;
return () => clearTimeout(timer);
},
[loading]
);
一些总结
- 如果觉得实现hook没有思路,可以先实现HOC再翻译过来。
- 组件的重要功能几乎都有hook的对应,主要的
setState -> useState
和生命周期转为useEffect
。 useEffect
一共有3部分,即本体、返回的清理函数、依赖数组,分别对应生命周期的主要部分、componentDidUpdate
和componentWillUnmount
里的清理逻辑、componentDidUpdate
里的if
分支用到的属性。- 可以把原来用于HOC的展示组件继续复用,以前是包一层HOC,现在是新加一个组件先调用hook再渲染组件。当然这样依旧会造出组件树上多一个节点,是否要合并可以自行权衡。
- Hook的一个特征是不访问
props
,因此通常调用HOC时传的propName
之类的参数,在hook里会消失,变为直接将对应的属性值传过去。
除此这外,hook还提供了一系列和原有的概念对应的东西:
useCallback
和useMemo
对应以前reselect库提供的选择器,是react生态中非常重要的一环。useContext
对应<Consumer>
的使用以及诸如withRouter
、connect
等主流库的API。
从HOC转到hook有固定的模式,可以在熟悉hook的过程中有效降低开发的成本,是一种切实可行的模式。
以下是实际的应用示例:
Code Sandboxcodesandbox.io