你可能不知道的流式 React Hooks(关于组织代码的最佳实践)

avatar
前端工程师 @公众号:ELab团队

这次的分享结合我在项目中使用 full hooks-based React Components 的一些经验,给大家介绍一些我所认为的 React Hooks 最佳实践。

文中的很多 term 是为了阐明一些概念所设,并非专有名词,不需要当真。

回顾一下 React Hooks

首先还是简单回顾一下 React Hooks。

先看传统的 React Class-based Component。一个组件由四部分构成:

  • 状态 state:一个统一的集中的 state
  • 生命周期回调 lifecycle methods:一些需要诵记的生命周期相关的回调函数(WillMount / Mounted / WillReceiveProps / Updated / WillUnmount 等等)
  • 回调函数 handlers:一些回调方法,在 view 层被调用,作用在 state 层上
  • 渲染函数 render:即组件的 view 层,负责产出组件的 VirtualDOM 节点,挂载回调函数等

React Hooks 组件其实可以简单地理解成一个 render 函数。这个 render 函数本身即组件。他通过 useState 和 useEffect 两个函数来实现函数的“状态化”,即获得对 state 和生命周期的注册和访问能力。

Hooks 是一套新的框架

相比类组件,Hooks 组件有以下特点

  • 自上而下:相比类组件的方法之间相互调用,作为函数的 Hooks 组件的具有更单纯的逻辑流,即自上而下
  • 弱化 handlers:相比类组件的方法注册,函数式组件虽然也可以实现在函数上下文中声明回调,但这对不如类方法来得自然。
  • 简化生命周期:Hooks 通过一个单纯的 useEffect 来注册基于依赖变更的生命周期函数,把类组件中的生命周期都混合在一起。事实上,我们完全可以彻底抛弃对原先 React 组件生命周期的理解,直接来理解 useEffect,把他单纯地当成在 render 过程中注册的函数副作用。
  • 分散的 state 和 effect 注册和访问:Hooks 不再像类组件一般要求在统一的地方注册组件用到的所有状态以及生命周期方法,这使得更「模型内聚」的逻辑组织成为可能。
  • 依赖驱动:多个基础 Hooks 在设计上都有 deps 的概念,用以实现基于依赖项的变更来执行所声明的函数的能力

基于上述迥异的语法和完全平行的 API,基于 Hooks 的组件书写可以被当作一门独立于基于类组件的全新框架。我们应尽量避免以模仿类组件的风格去书写 Hooks 组件的逻辑,而应当重新审视这种新的语法。

由于上述的语法特点,Hooks 适合通过「基于变更」的声明风格来书写,而非「基于回调」的命令式方式来书写。这会让一个组件更易于拆分和复用逻辑并拥有更清晰的逻辑依赖关系。大家将逐步看到「基于变更」的风格的优势,下面小举两个例子来对比一下「基于变更」和「基于回调」的写法:

例一:通过 useEffect 声明请求

需求场景:更改一个 keyword state 并发起查询的请求

基于回调的写法(仿类写法)

const Demo: React.FC = () => {
    const [state, setState] = useState({
        keyword: '',
    });
    const query = useCallback((queryState: typeof state) => {
        // ...
    }, []);
    const handleKeywordChange = useCallback((e: React.InputEvent) => {
        const latestState = { ...state, keyword: e.target.value };
        setState(latestState);
        query(latestState);
    }, [state, query]);
    return // view
}

这种写法有几个问题:

  • handleKeywordChange 若在两次渲染中被多次调用,会出现 state 过旧的问题,从而得到的 latestState 将不是最新的,会产生bug。(这个问题类组件也会存在)
  • query 方法每次都需要在 handler 中被命令式地调用,如果需要调用它的 handler 变多,则依赖关系语法复杂,且容易疏忽忘记手动调用。
  • query 使用的 queryState 就是最新的 state,却每次需要由 handler 将 state 计算好交给 query 函数,方法间职责分割得不明确。

基于变更的写法

const Demo: React.FC = () => {
    const [state, setState] = useState({
        keyword: '',
    });
    const handleKeywordChange = useCallback((e: React.InputEvent) => 
        {
            const nextKeyword = e.target.value;
            setState(prev => ({ ...prev, keyword: nextKeyword }))
        }, []);
    useEffect(() => {
        // query
    }, [state]);
    return // view
}

上面的写法解决了「基于回调」写法的所有问题。它把 state 作为了 query 的依赖,只要 state 发生变更,query 就会自动执行,且执行时机一定是在 state 变更以后。我们没有命令式地调用 query,而是声明了在什么情况下它应当被调用。

当然这种写法也不是没有问题:

  • 万一需求场景要求我们在 state 的某些特定字段变更的时候不触发 query,上面的写法就失效了

事实上,这个问题恰恰要求我们在写 Hooks 时花更多的精力专注于「变」与「不变」的管理,而不是「调」与「不调」的管理上。

例二:注册对 window size 的监听

需求场景:在 window resize 时触发 callback 函数

基于回调的写法(仿类写法)

const Demo: FC = () => {
    const callback = // ...
    useEffect(() => {
        window.addEventListener('resize', callback);
        return () => window.removeEventListener('resize', callback);
    }, []);
    return // view
}

在「componentDidMount」的时候注册这个监听,在「componentWillUnmount」的时候注销它。很单纯啊是不是?

但是问题来了,在类组件中,callback 可以是一个类方法(method),它的引用在整个组件生命周期中都不会发生改变。但是函数式组件中的 callback 是在每次执行的上下文中生成的,它极有可能每次都不一样!这样 window 对象上挂载的监听将会是组件第一次执行产生的 callback,之后所有执行轮次中产生的 callback 都将不会被挂载到 window 的订阅者中,bug 就出现了。

那改一下?

基于回调的写法2
const Demo: FC = () => {
    const callback = // ...
    useEffect(() => {
        window.addEventListener('resize', callback);
        return () => window.removeEventListener('resize', callback);
    }, [callback]);
    return // view
}

这样把 callback 放到注册监听的 effect 的依赖中看起来似乎能 work,但是也太不优雅了。在组件的执行过程中,我们将疯狂地在 window 对象上注册注销注册注销,听起来就不太合理。下面看看基于变更的写法:

基于变更的写法
const Demo: FC = () => {
    const [windowSize, setWindowSize] = useState([
        window.innerWidth,
        window.innerHeight
    ] as const);
    useEffect(() => {
        const handleResize = () => {
            setWindowSize([window.innerWidth, window.innerHeight]);
        }
        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
    }, []);
    const callback = // ...
    useEffect(callback, [windowSize]);
    return // view
};

这里我们通过一个 useState 和一个 useEffect 首先把 window resize 从一个回调的注册注销过程转换成了一个表示 window size 的 state。之后依赖这个 state 的变更实现了对 callback 的调用。这个调用同样是声明式的,而不是直接手动命令式的调用的,而声明式往往意味着更好的可测性。

上面的代码看似更复杂了,但事实上,只要我们把 2-10 行的代码抽离出来,很快就得到了一个跨组件可复用的自定义 Hooks:useWindowSize。使得在别的组件中使用基于 window resize 的回调变得非常方便:

const useWindowSize = () => {
    const [windowSize, setWindowSize] = useState([window.innerWidth, window.innerHeight] as const);
    useEffect(() => {
        const handleResize = () => {
            setWindowSize([window.innerWidth, window.innerHeight]);
        }
        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
    }, []);
    return windowSize
}

基于变更的写法的关键在于把「 动作」转换成「 状态

Marble Diagrams

通过上面的论述和例子我们可以看到在 Hooks-based 组件中合理地使用基于变更的代码可以带来一定的好处。为了更好地理解「基于变更」这件事。这里引入流式编程中常用于辅助理解的 Marble 图。你将很快发现,我们一直在说的「基于变更」于流式编程中的「流」没有两样:

RxMarble图例

流式编程中,一个珠子_(marble)_就代表一个推送过来的数据,一串横向的珠子就代表一个数据流(ObservableSubject)在时间上的一系列推送数据。流式编程通过一系列操作符,对数据流实现加工整合映射等操作来实现编程逻辑。上图的 merge 操作,是非常常用的合并两个数据源的操作符。

不可变数据流与「执行帧」

基于变更的 Hooks coding 其实是与 stream coding 相当同构的概念。两者都弱化 callback,把 callback 包装起来成为流或操作符。

Hooks 组件中的一个 state 就是流式编程中的流,即一串珠子

而一个 state 的每一次变更,便是一颗珠子

不可变数据流 immutable dataflow

为了完全地体现「变更」,所有的状态更新都要做到 immutable 简而言之:让引用的变化与值的变化完全一致

为了实现这一点,你可以:

  1. 每次 setState 的时候注意
  2. 自己实现一些 immutable utils
  3. 借助第三方的数据结构库,如 facebook的 ImmutableJS

(个人推荐 1 或 2,可以尽可能减少引入不必要的概念)

执行帧

在 Hooks-based 编程中,我们还要有所谓「执行帧」的概念。这种概念在其他框架如 vue / Angular 中很被弱化,而对 React 尤其是函数式组件中却很有助于思考 在组件上下文中的 state 或 props 一旦发生变更,就会触发组件的执行。每次执行就相当于一帧渲染的绘制。所有的 marble 就串在执行帧与状态构成的网格中

变更的源头

对一个组件来说,能触发它重新渲染的变更称为「源」source。 一个组件的变更源一般有以下几种:

  • props 变更:即父组件传递给组件的 props 发生变更
  • 事件 event:如点击,如上文的 window resize 事件。对事件,需要将事件回调包装成 state
  • 调度器:即 animationFrame / interval / timeout

上述源头,有些已经被「marble化」了,如 props。有些还没有,需要我们包装的方式把他们「marble 化」

例一:对事件的包装

const useClickEvent = () => {
    const [clickEvent, setClickEvent] = useState<{ x: number; y: number; }>(null);
    const dispatch = useCallback((e: React.MouseEvent) => {
        setClickEvent({ x: e.clientX, y: e.clientY });
    }, []);
    return [clickEvent, dispatch] as const;
}

例二:对调度器的包装(以 interval 为例)

const useInterval = (interval: number) => {
    const [intervalCount, setIntervalCount] = useState();
    useEffect(() => {
        const intervalId = setInterval(() => {
            setIntervalCount(count => count + 1)
        });
        return () => clearInterval(intervalId);
    }, []);
    return intervalCount;
};

流式操作符

从源变更到最终 view 层需要的数据状态,一个组件的数据组织可以抽象成下图: 中间的 operators 就是组件处理数据的核心逻辑。在流式编程中的 operator 几乎都可以在 Hooks 中通过自定义 Hooks 写出同构的表示。

这些「流式 Hook」是由基本 Hooks 复合而成的更高阶的 Hooks,可以具有高度的复用性,使得代码逻辑更简练。

映射(map)

通过 useMemo 就可以直接实现把一些变更整合到一起得到一个「computed」状态

对应 ReactiveX 概念:map / combine / latestFrom

const [state1, setState1] = useState(initalState1);
const [state2, setState2] = useState(initialState2);
const computedState = useMemo(() => {
    return Array(state2).fill(state1).join('');
}, [state1, state2]);

跳过前几次(skip) / 只在前几次响应(take)

有时候我们不想在第一次的时候执行 effect 里的函数,或进行 computed 映射。可以实现自己实现的 useCountEffect / useCountMemo 来实现

对应 ReactiveX 概念:take / skip

const useCountMemo = <T>(callback: (count: number) => T, deps: any[]): T => {
    const countRef = useRef(0);
    return useMemo(() => {
        const returnValue = callback(countRef.current);
        countRef.current++;
        return returnValue;
    }, deps);
};
export const useCountEffect = (cb: (index: number) => any, deps?: any[]) => {
    const countRef = useRef(0);
    useEffect(() => {
        const returnValue = cb(countRef.current);
        currentRef.current++;
        return returnValue;        
    }, deps);
};

流程与调度(debounce / throttle / delay)

在基于变更的 Hooks 组件中,debounce / throttle / delay 等操作变得非常简单。debounce / throttle / delay 的对象将不再是 callback 函数本身,而是变更的状态

对应 ReactiveX 的概念:debounce / delay / throttle

const useDebounce = <T>(value: T, time = 250) => {
    const [debouncedState, setDebouncedState] = useState(null);
    useEffect(() => {
        const timer = setTimeout(() => {
            setDebouncedState(value);
        }, time);
        return () => clearTimeout(timer);
    }, [value]);
    return debouncedState;
};
const useThrottle = <T>(value: T, time = 250) => {
    const [throttledState, setThrottledState] = useState(null);
    const lastStamp = useRef(0);
    useEffect(() => {
        const currentStamp = Date.now();
        if (currentStamp - lastStamp > time) {
            setThrottledState(value);
            lastStamp.current = currentStamp;
        }
    }, [value]);
    return throttledState
}

action / reducer 模式的异步流程

Redux 的核心架构 action / reducer 模式在 Hooks 中的实现非常简单,React 甚至专门提供了一个经过封装的语法糖钩子 useReducer 来实现这种模式。

对于异步流程,我们同样可以采用 action / reducer 的模式来实现一个 useAsync 钩子来帮助我们处理异步流程。

这里示意的是一个最简单的基于 promise 的函数模式,类似 redux 中使用 redux-thunk 中间件。

同时,我们伴随请求的数据状态维护一组 loading / error / ready 字段,用来标示当前数据的状态。

useAsync 钩子还可以内置对多个异步流程的 竞争 / 保序 / 自动取消 等机制的控制逻辑。

下面示例了 useAsync 钩子的用法,采用了 generator 来实现一个异步流程对状态的多步修改。甚至可以实现类似 redux-saga 的复杂异步流程管理。

const responseState = useAsync(responseInitialState, actionState, function * (action, prevState) {
    switch (action?.type) {
        case 'clear':
            return null;
        case 'request': {
            const { data } = yield apiService.request(action.payload);
            return data;
        }
        default:
            return prevState;
    }
})

下面的代码例举了一个通过类「action/ reducer」模式的异步钩子来维护一个字典类型的数据状态的场景:

// 来自 props 或 state 的 actions
// fetch action: 获取
let fetchAction: {
  type: 'query',
  id: number;
};

let clearAction: {
  type: 'clear',
  ids: number[]; // 需要保留的 ids 
}

let updateAction: {
  type: 'update',
  id: number;
}

// 通过一个自定义的 merge 钩子来保留上述三个状态中最新变更的一个状态
const actions = useMerge(fetchAction, clearAction, updateAction);

// reducer
const dataState = useQuery(
    {} as Record<number, DataType>,
    actions,
    async (action, prev) => {
        switch (action?.type) {
            case 'update':
            case 'query': {
                const { id } = action;
                // 已经存在子列表的情况下,不对数据作变更,返回一个 identity 函数
                if (action.type === 'query' && prev[id]) return prevState => prevState;
                // 拉取指定 id 下的列表数据
                const { data } = await httpService.fetchListData({ id });
                // 返回一个插入数据的状态映射函数
                return prev => ({
                    ...prev,
                    [id]: data,
                });
            }
            case 'clear': {
                // 返回一个保留特定 id 数据的状态映射函数
                return prev =>
                    pick( // pick 是一个从对象里获取一部分 key value 对组成新对象的方法
                        prev,
                        action.ids,
                    );
            }
            default:
                return prev;
        }
    },
    { mode: 'multi', immediate: false }
);

单例的 Hooks——全局状态管理

通过 Hooks 管理全局状态可以与传统方式一样,例如借助 context 配合 redux 通过 Provider 来下发全局状态。这里推荐更 Hooks 更方便的一种方式——单例 Hooks:Hox

通过第三方库 Hox 提供的 createModel 方法可以产生一个挂载在虚拟组件中的全局单例的 Hooks。这个虚拟组件的实例一经创建将在 app 的整个生命周期中存活,等于是产生了一个全局的「marble 源」,从而任何的组件都可以使用这个 Hooks 来获取这个源来处理自己的逻辑。

hox 的具体实现涉及自定义 React Reconciler,感兴趣的同学可以去看一下它源码的实现。

流式 Hooks 局限性

「基于变更」的 Hooks 组件书写由于与流式编程非常相似,我也把他称作「流式 Hooks」。

上面介绍了很多流式 Hooks 的好处。通过合适的逻辑拆分和复用,流式 Hooks 可以实现非常细粒度且高内聚的代码逻辑。在长期实践中也证明了它是比较易于维护的。那么这种风格 Hooks 存在什么局限性呢?

「过频繁」的变更

在 React 中,存在三种不同「帧率」或「频繁度」的东西:

  • 调和 reconcile:把 virtualDOM 的变更同步到真实的 DOM 上去
  • 执行帧 rendering:即 React 组件的执行频率
  • 事件 event:即事件 dispatch 的频率

这三者的触发频率是从上至下越来越高的

由于 React Hooks 的变更传播的最小粒度是「执行帧」粒度,故一旦事件的发生频率高过它(一般来说只会是同步的多次事件的触发),这种风格的 Hooks 就需要一些较为 Hack 的逻辑来兜底处理。

避免「为了流而流」

流式编程如 RxJS 大量被用于消息通讯(如在 Angular 中),被用于处理复杂的事件流程。但其本身一直没有成为主流的应用架构。导致这个状况的一个瓶颈就在于它几乎没有办法写一星半点命令式的代码,从而会出现把一些通过命令式/回调式很好实现的代码写得非常冗长难懂的情况。

React Hooks 虽然可以与 RxJS 的语法产生很大成都的同构,但其本质仍然是命令式为底层的编程,故它可以是多范式的。在编码中,我们在绝大部分场景下可以通过流式的风格实现,但也应当避免为了流而流。如 Redux 下的一个关于哪些状态应该放到全局哪些应该放到组件内的 Issue 下评论的:选择看起来更不奇怪 (less weird) 的那个

愿景

目前我正在规划和产出一套基础的流式 Hooks,便于业务逻辑引用来书写具有流式风格的 Hooks 代码 Marble Hooks

❤️ 谢谢支持

  1. 喜欢的话别忘了 分享、点赞、在看 三连哦~。