一个HOC向Hook迁移的示例

328 阅读5分钟
原文链接: zhuanlan.zhihu.com

背景

自在春节期间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>;
};

通过isDelayedisLoading这2个属性来表达3种状态(初始加载中、加载用时过长、已经加载完毕),使用条件分支展示不同的内容。

在以往,我们很容易判断出来isDelayed的获取可以通过HOC来实现,因此我们也判断它可以用Hook来实现复用。但是在面对如何实现这个Hook的时候,出现了不小的疑惑,甚至无从下手。

最后在一翻讨论后,我们发现一种方法,即先实现一个HOC版本,再“翻译”成对应的Hook,能快速完成对应的代码。

HOC版本

假设我们需要实现一个withDelayHint的HOC来实现这一逻辑,简单整理了一下它的功能:

  1. 知道组件当前是否在loading状态,如果不在的话就不用开定时器了。
  2. 如果处在loading状态,则打开一个定时器,指定时间后将isDelayedfalse改为true
  3. 如果loading状态发生了变化,则需要停掉定时器,并回到第1步重新判断是不是要开新的定时,用于组件状态更新的场合。

基于上面的整理,HOC需要至少2个参数:

  1. 如何获取loading状态。最简单地方法是提供一个属性的名称,直接从props[loadingPropName]拿,函数化一些可以提供一个函数来通过getLoading(props)获取。
  2. 定时器的延迟时长,以毫秒为单位。

因此,它的实现还是相对简单的:

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]
);

一些总结

  1. 如果觉得实现hook没有思路,可以先实现HOC再翻译过来。
  2. 组件的重要功能几乎都有hook的对应,主要的setState -> useState和生命周期转为useEffect
  3. useEffect一共有3部分,即本体、返回的清理函数、依赖数组,分别对应生命周期的主要部分、componentDidUpdatecomponentWillUnmount里的清理逻辑、componentDidUpdate里的if分支用到的属性。
  4. 可以把原来用于HOC的展示组件继续复用,以前是包一层HOC,现在是新加一个组件先调用hook再渲染组件。当然这样依旧会造出组件树上多一个节点,是否要合并可以自行权衡。
  5. Hook的一个特征是不访问props,因此通常调用HOC时传的propName之类的参数,在hook里会消失,变为直接将对应的属性值传过去。

除此这外,hook还提供了一系列和原有的概念对应的东西:

  1. useCallbackuseMemo对应以前reselect库提供的选择器,是react生态中非常重要的一环。
  2. useContext对应<Consumer>的使用以及诸如withRouterconnect等主流库的API。

从HOC转到hook有固定的模式,可以在熟悉hook的过程中有效降低开发的成本,是一种切实可行的模式。

以下是实际的应用示例:

Code Sandboxcodesandbox.io图标