编写一个管理异步的React Hook

631 阅读11分钟
原文链接: zhuanlan.zhihu.com

背景

我们当前完成了对hook的初步探索,并且将一个小型的系统重构为了基于hook的实现,得到的效果还算理想。

首先是这个系统的规模:

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
JavaScript                     184           1171            694           8445
LESS                            63            339             12           1662
Markdown                         1             46              0            109
-------------------------------------------------------------------------------
SUM:                           248           1556            706          10216
-------------------------------------------------------------------------------

在重构之后,得到的效果:

54 files changed, 1404 insertions(+), 1833 deletions(-)

可以看到,代码有下降的趋势,随之而来的可读性也有所提高。

对于一个带有前后端交互的系统,管理好异步请求的数据始终是我们的一个目标。在最初的阶段,依赖于redux的全局状态,我们研发了standard-redux-shape库在redux的数据流约束下管理异步的请求和响应;在随后我们希望这些并没有多模块共享的内容可以更简单地管理,研发了react-kiss的query region在任意层级上管理数据。

在实际的使用中,我们发现有不少类似这样的代码:

const enhance = compose(
    connect(mapStateToProps),
    establishRepoListScoreDetailQuery('RepoListScoreDetailQuery'),
    joinRepoListScoreDetailQuery(mapRegionToProps)
);
export default enhance(RepoListScoreDetailTable);

可以看到,一个数据集的建立(establish)和使用(join)是在同一个组件上的,也就是说这些数据实际并没有在组件树上跨层级地被使用,这相当于使用组件内部状态管理数据

基于这一考虑,我们计划在组件内部状态之上也学习之前的两个库,使用同一种数据结构,基于自研的qurey-shape来管理异步状态,适逢hook的推出,随即将之封装为一个hook。

目标

在第一步,我们设计了用于管理异步请求和响应的hook的签名,称之为useFetch。虽然并非所有的异步都与fetch相关,但这个名称能更好地表现出它的用意。

对于我们的技术栈而言,一个请求服务器的函数一定有以下特征:

  1. 它是异步的,无论是否有使用本地缓存,总之一定是返回一个Promise
  2. 只有一个参数。我们并不推荐一个请求函数有多个参数,通常会将所有参数放在一起称为params
  3. 调用方需要管理“请求中”、“成功”、“失败”至少3种状态。
  4. 参考qurey-shape,请求使用不同的“策略”来管理。
  5. 有很大一部分请求是完全幂等的,即在任意场景下请求都只会返回相同的内容,无需重复请求。

基于此,我们的最基本的签名如下:

const {pending, data, error} = useFetch(
    apiFunction,
    params,
    strategy,
    {idempotent: false}
);

其中参数如下:

  • apiFunction:一个异步函数,调用时将接收params作为参数。
  • params:调用异步时的参数。
  • strategy:来自query-shape的一个策略。
  • options:相关的配置,其中idempotent表示是否幂等,如果一个请求是幂等的,那么只要它被请求过一次,后续就无需重复请求,可直接复用第一次的响应。

请求的触发

为了理顺基本的思路,我们需要回答一个问题:

在react应用中,我们是因为“用户的行为”发起请求,还是因为“状态的变化”发起请求?

对应更早的版本的react,如果我们使用“用户的行为”发起请求,那么可能代码是这样的(此处用最简单地表达,不处理异步,不处理多请求同时发起的场景):

class UserList extends Component {
    handleSearch = () => this.fetchUserList();

    componentDidMount() {
        this.fetchUserLit({keyword: ''});
    }

    fetchUserList() {
        const params = {keyword: this.state.keyword};
        this.setState({pending: true});
        const {data} = await getUserList(params);
        this.setState({data, pending: false});
    }

    render() {
        // ...
    }
}

而如果我们认为是“状态的变化”发起的请求,那么它可能是这样的:

class UserList extends Component {
    handleSearch = async () => {
        const params = {keyword: this.state.keyword};
        this.setState({params});
    }

    componentDidMount() {
        this.fetchUserList();
    }

    componentDidUpdate(prevProps, prevState) {
        if (prevState.params !== this.state.params) {
            this.fetchUserList();
        }
    }

    fetchUserList() {
        this.setState({pending: true});
        const {data} = await getUserList(this.state.params);
        this.setState({data, pending: false});
    }

    render() {
        // ...
    }
}

可以看到两者的区别主要在于handleSearch是直接调用请求,还是通过“将params更新以触发componentDidUpdate来发起请求”。在更早版本的react中,大部分开发者潜意识中接受了第一种方法,并且从未觉得一个组件生命周期不配对(有componentDidMount却没有componentDidUpdate)有什么问题,进一步还认为在componentDidUpdate中直接使用setState更新pending的值是不好的,eslint-plugin-react也有相应的规则阻止我们这么做。

但是在hook诞生以后,我强烈地从useEffect的设计中感受到react团队希望传达给我们的想法:

不要区分mount和update,仅控制外部依赖的注册(请求)与销毁。

实际上,第一种代码改为hook的实现也确实比较困难,要特地使用useEffect加上一个标记变量来模拟出useDidMount的效果,使得代码整体上并不符合react的哲学。而第二种代码则更为顺畅:

const UserList = () => {
    const [keyword, setKeyword] = userState('');
    const [params, setParams] = useState({keyword});
    const [pending, setPending] = useState(false);
    const [data, setData] = useState([]);
    const handleClick = useCallback(
        () => setParams({keyword}),
        [keyword]
    );
    useEffect(
        () => {
            async (() => {
                setPending(true);
                const {data} = await getUserList(params);
                setPending(false);
                setData(data);
            })();
        },
        [params]
    );

    return <div />;
}

useEffect可以很好地表达“当params变化时执行整个过程”这样的流程逻辑,使得整体代码的表达能力也上升了一个台阶。

基本思路

上文已经使用官方提供的各类hook实现了最基本的异步请求逻辑,不难看出其中是有很多可以抽象复秀的部分的:

  1. pending的管理。
  2. useEffect的调用。
  3. 进一步的,异常的处理,多请求并发的控制等。

所以,我们可以基于query-shape提供的能力,实现一个非常基础的架子:

import {useReducer, useEffect} from 'react';
import {findQueery} from 'query-shape';

const useFetch = (task, params, strategy, {idempotent = false} = {}) => {
    const {initialize, fetch, receive, error, accept} = strategy;
    // 把一个异步调用的各个过程,以状态机的形式变成reducer
    const reducer = useCallback(
        (state, {type, payload}) => {
            switch (type) {
                case 'fetch':
                    return fetch(state, payload);
                case 'receive':
                    return receive(state, payload.key, payload.data);
                case 'error':
                    return error(state, payload.key, payload.error);
                case 'accept':
                    return accept(state, payload);
                default:
                    return state;
            }
        },
        [strategy]
    );
    const [querySet, dispatch] = useReducer(reducer, null, initialize);
    // 在生命周期中处理异步
    const runFetch = async () => {
        const previousQuery = find(params);
        // 如果是幂等的,且已经有了一个之前的结论了,那就不用请求了
        if (idempotent && previousQuery) {
            return;
        }
        dispatch({type: 'fetch', payload: {key: params}});
        try {
            const data = await task(params);
            dispatch({type: 'receive', payload: {data}});
        }
        catch (error) {
            dispatch({typpe: 'error', payload: {error}});
        }
    };
    useEffect(
        () => runFetch(),
        [params] // 当params变化时更新请求
    );
    const query = findQuery(querySet, params);

    if (query) {
        return {
            query,
            accept() {
                dispatch({type: 'accept', payload: {key: params}});
            },
            pendingMutex: query.pendingMutex,
            pending: query.pendingMutex > 0,
            response: query.response,
            nextResponse: query.nextResponse,
            data: query.response ? query.response.data : undefined,
            error: query.response ? query.response.error : undefined,
        };
    }

    // 空的时候返回一个请求中的状态,方便外部调用
    return {
        pending: true,
        pendingMutex: Infinity,
        accept() {
            dispatch({type: 'accept', payload: {key: params}});
        },
    };
};

优化

上面的这个函数基本可用,但也确实存在一些问题。

粒度拆解

首要的问题就是,这个函数太大了,一点也不符合审美。这个函数事实上混杂了2个职责:

  1. 管理好一个query集合,即使用query-shape的各种功能通过状态机更新集合。
  2. 处理异步的请求,即在生命周期中发起请求。

根据单一职责的原则,这2个能力就当拆解为2个hook,也能同时用在不同抽象层次的场合:

const useQuerySet = strategy => {
    const {initialize, fetch, receive, error, accept} = strategy;
    // 把一个异步调用的各个过程,以状态机的形式变成reducer
    const reducer = useCallback(
        (state, {type, payload}) => {
            switch (type) {
                case 'fetch':
                    return fetch(state, payload);
                case 'receive':
                    return receive(state, payload.key, payload.data);
                case 'error':
                    return error(state, payload.key, payload.error);
                case 'accept':
                    return accept(state, payload);
                default:
                    return state;
            }
        },
        [strategy]
    );
    const [querySet, dispatch] = useReducer(reducer, null, initialize);

    // 提供各种操作
    return {
        fetch(key) {
            dispatch({type: 'fetch', payload: key});
        },
        receive(key, data) {
            dispatch({type: 'receive', payload: {key, data}});
        },
        error(key, error) {
            dispatch({type: 'error', payload: {key, error}});
        },
        accept(key) {
            dispatch({type: 'accept', payload: key});
        },
        find(key) {
            return findQuery(querySet, key);
        },
    };
};

这是一个简单地模式,将useReducer转变为多个函数,以更强类型、更语义明确地形式供外部调用,在这里我们其实总结了一个很好地实践,就是不要让业务代码直接使用useReducer。在useQuerySet的基础上,我们的useFetch会变得更为简洁:

const useFetch = (task, params, strategy, {idempotent: false} = {}) => {
    const {fetch, receive, error, accept, find} = useQuerySet(strategy);
    const runFetch = async () => {
        const previousQuery = find(params);
        if (idempotent && previousQuery) {
            return;
        }
        fetch(params);
        try {
            const data = await task(params);
            receive(params, data);
        }
        catch (ex) {
            error(params, ex);
        }
    };
    useEffect(
        () => runFetch(),
        [params]
    );
    const query = find(params);

    return {
        // 返回各种子属性
    };
};

参数的判等

在重构hook的粒度之余,我们面对着一个更为严重的功能性问题:在实际的使用中,params容易遇到“引用变化内容不变”的场景,如以下的代码:

useFetch(getUser, {keyword}, strategy);

此处的{keyword}会每一次都是一个新的对象,但它所代表的请求的参数其实是不变的。根据useEffect的浅比较原理,这样调用会让组件每一次更新都发起请求,无论keyword是否有变化。

我们或许可以使用useMemo来解决这一问题:

const params = useMemo(() => ({keyword}), [keyword]);
useFetch(getUser, params, strategy);

看上去,使用useMemokeyword的实际值与params建立缓存关系,是可以解决问题的。但是在react的官方文档中,对useMemo有着这样的补充描述:

You may rely on useMemo as a performance optimization, not as a semantic guarantee.
In the future, React may choose to “forget” some previously memoized values and recalculate them on next render, e.g. to free memory for offscreen components. Write your code so that it still works without useMemo — and then add it to optimize performance.

也就是说,useMemo在功能上是不保证缓存的有效的,它只能作为性能优化来使用。而“是否发起请求”并不是一个性能上的点,对于非幂等的请求,在不合适的场景下多余的调用是会导致整个应用的状态错误的。因此我们无法在此处使用useMemo保证其正确性。

为了确保这一部分的正确性,我们重新理一下需要:

  1. 我们需要在params对象内容相同的时候,useEffect不被重复执行。
  2. 也就是说,我们需要一种机制,仅在params对象内容变化时,才会让useEffect的依赖变化。
  3. 所谓“内容的变化”,就代表着深比较返回false
  4. 因此我们需要一种能力,来获取一个对象与“组件上一次更新时”这个对象的深比较结果。
  5. 为了能够深比较,我们需要有办法获取“上一次更新时”的这个对象的值。
  6. 同时我们还需要将深比较的结果变为一个变化的值,而不仅仅是truefalse

总结一下,同时考虑职责单一原则,我们现在需要:

  1. 一个hook获取一个对象“上一次更新时的值”,我们称为usePreviousValue
  2. 一个hook,用于将对象与上一次的值进行比较,我们称为useDeepEqual
  3. 基于useDeepEqual的结果,在对象内容变化时返回唯一的值,我们称为useDeepEqualIdentifier

在这个步骤的引导下,我们首先实现usePreviousValue

const usePreviousValue = (value, initialValue = null) => {
    const cache = useRef(initialValue);
    const previousValue = cache.current;
    cache.current = value;
    return previousValue;
};

如果深入地理解了hook的构成,这个函数是不难写的。不理解内部原理的话要搞出这东西还真比较麻烦,我们走过一些弯路,其中非常重要的一点是千万不能用useState管理前一个值,不然就可以享受无限递归的快感了。useRef在此处相当于更早的版本时react的class组件上的一个实例属性,这算是官方都公认的useRef的“奇葩”用法。

在这基础上,useDeepEequal就非常容易,无非拿出前一个值比一比就返回,事实上这东西都不应该称为hook(它没有使用任何官方的hook):

const useDeepEequal = value => {
    // 使用value当初始值,因此第一次调用会返回true
    const previousValue = usePreviousValue(value, value);
    return deepEquals(value, previousValue);
};

进一步,我们将这个结果转化为一个可变的值,使用每一次都递增的数字是最简单的:

const useDeepEqualIdentifier = value => {
    const identifier = useRef(0);
    const isEqual = useDeepEqual(value);

    if (!isEqual) {
        identifier.current = identifier.current + 1;
    }

    return identifier.current;
};

再次注意,没有useState什么事。这种是典型的“需要一个持久化的计算结果”的场景,本身只是一种计算,并不触发更新,因此使用useRef才是正确的。

总结

最后,我们的useFetch中的useEffect的调用被转化为:

const identifier = useDeepEqualIdentifier(params);
useEffect(
    () => runFetch(),
    [identifier]
);

这样就实现了仅在params内容变化时才重新调用请求。

通过对异步请求响应的管理模式的探索、对hook的学习以及基于useRef的各种揉捏,我们最终实现了一个稳定可靠的useFetch函数,在组件中直接调用就可以获得一个请求的状态与响应,快速实现视图与业务逻辑。

事实上,上面的useDeepEqualuseDeepEqualIdentifier在我们的实际实现中更为灵活,毕竟也可能需要shallowEqual的场景,甚至更复杂的自定义的比较逻辑,因此我们对应的TS实现是:

export const useCustomEqual = <T>(value: T, equals: (x: T | null, y: T) => boolean) => {
    const previousValue = usePreviousValue(value, value);
    return equals(previousValue, value);
};
export const useCustomEqualIdentifier = <T>(value: T, equals: (x: T | null, y: T) => boolean) => {
    const identifier = useRef(0);
    const isEqual = useCustomEqual(value, equals);
    if (!isEqual) {
        identifier.current = identifier.current + 1;
    }
    return identifier.current;
};
export const useDeepEqual = <T>(value: T) => useCustomEqual(value, deepEquals);
export const useShallowEqual = <T>(value: T) => useCustomEqual(value, shallowEquals);
export const useDeepEqualIdentifier = <T>(value: T) => useCustomEqualIdentifier(value, deepEquals);
export const useShallowEqualIdentifier = <T>(value: T) => useCustomEqualIdentifier(value, shallowEquals);

可见基于hook,还是能有很多相应的封装的,上面几个hook在社区中还未见到特别好的实现,在此与大家分享。