10

大道理小聪明系列 - Redux + Hooks 工程实践一则

 3 years ago
source link: https://segmentfault.com/a/1190000040053392
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

大道理小聪明系列 - Redux + Hooks 工程实践一则

“都 1202 年了怎么还有人在用 Redux”——这大概不少人看到这篇文章的第一反应。首先先表明一下,这篇文章并不讨论是不是应该使用 Redux,这是一个比较大的话题,应该单独水一篇。而且社区已经存在许许多多的讨论了,你总能从几篇高赞的文章中找到一些优缺点的对比图,然后结合你项目的场景最终作出决定。我们来随便举几个团队使用 Redux 的原因。首先是易懂,Redux 被人吐槽很多的可能是写法繁琐,但是在繁琐写法的背后就没有那么多黑科技了,非常容易排查问题。另外,Redux 本质是对逻辑处理方式提出了标准范式,并且搭配得给到了一组实践规范,有助于保持项目代码书写风格与组织方式的一致性,这点在多人合作开发的项目里面尤为重要。其他的优点就不在此赘述啦。

这时候就有同学可能要问了,你讲 Redux,那和 hooks 又有啥子关系呢。众所周知,在 React 团队推出 Hooks 这个概念后不久,Redux 也更新了对应的 API 来支持。Hooks 的本质是对逻辑的封装以及逻辑与 UI 代码的解耦。有了 Hooks 的加持能够让我们的 Redux React 项目更加简洁、易懂、扩展性更强。而且 Hooks API 在 Redux 的最佳实践建议中目前是 Level 2 的强烈推荐使用级别。他拥有更简洁的表达方式,更干净的 React 节点数,更友好的 typescript 支持。

具体 Redux 相关的 API 怎么用,这里不做介绍,可以直接跳转官方文档进行了解。下面我们会从一个应用场景来具体讲一讲,他们是怎么帮助我们更好地组织代码的。其中的部分工程级别代码来自于 react-boilerplate 的项目模版,它在动态加载问题上提供了不少帮助。

在开发大型 React 应用的时候,动态懒加载代码永远是我们项目架构中的必选项。代码的拆分、动态引用等,工程化工具都已经帮我们完成了。我们更需要关注的是,动态引入与解除挂载等操作时额外要做什么,以及这个工作如何尽量少的暴露给项目开发者。前面说过了,Hooks 最强大的能力在于逻辑的封装,这里当然也就要借助他的力量了。

这里我们以 Reducer 作为例子来讲,其他中间件,例如 Saga 等都可以类推,如果需要可以后续再把相应的代码一并贴出来。我们把整个封装分为三层:核心实现、可组合封装、对开发者暴露封装。下面我们按顺序一一讲解。(具体实现中我都会默认带上包含 connected router 的实现,方便需要抄代码的可以直接用)

这里的代码实现的是如何为一个 store 挂载与解除挂载拆分后的各个 Reducer 的逻辑。

// 本段代码完全来自于 react-boilerplate 项目
import { combineReducers } from 'redux';
import { connectRouter } from 'connected-react-router';
import invariant from 'invariant';
import { isEmpty, isFunction, isString } from 'lodash';

import history from '@/utils/history';
import checkStore from './checkStore'; // 做类型安全检测的,不用关心

function createReducer(injectedReducers = {}) {
  return history => combineReducers({
    router: connectRouter(history),
    ...injectedReducers,
  });
}

export function injectReducerFactory(store, isValid) {
  return function injectReducer(key, reducer) {
    if (!isValid) checkStore(store);

    invariant(
      isString(key) && !isEmpty(key) && isFunction(reducer),
      '(src/utils...) injectReducer: Expected `reducer` to be a reducer function',
    );

    if (
      Reflect.has(store.injectedReducers, key)
      && store.injectedReducers[key] === reducer
    ) return;

    store.injectedReducers[key] = reducer; // eslint-disable-line no-param-reassign
    store.replaceReducer(createReducer(store.injectedReducers)(history));
  };
}

export default function getInjectors(store) {
  checkStore(store);

  return {
    injectReducer: injectReducerFactory(store, true),
  };
}

这段有个点比较特殊,需要讲一下。你可能会发现,这里面根本没有解除挂载的部分。这是因为 reducer 比较特殊,他并不会产生副作用,并且因为目前提供的方法是通过整个替换的方式去挂载新的 Reducer,所以并没有什么必要去单独做解除挂载。在处理其他中间件的挂载时,特别是那些存在副作用的(例如 redux-saga),我们需要对应地实现一个解除挂载的 eject 方法。

OK,那么现在我们已经可以通过 getInjectors 方法为整个项目提供一个 injectReducer 注入 Reducer 的能力了(同时可能包含 eject 方法)。下一步就是怎么调度这个能力。

可组合的封装

这里,我们希望通过一个自定义的 hooks,可以允许开发者为一个组件声明某一个 命名空间 的 reducer 与其生命周期一致地进行挂载与解除挂载。开发者只需要传入 reducer 的命名空间与 reducer 实现,并将这个 hooks 放到相应的组件逻辑中即可。

import React from 'react';
import { ReactReduxContext } from 'react-redux';

// 这是我们在上一步实现的 injector 工厂,通过他来产出一个与固定 store 绑定的 injectReducer 函数
import getInjectors from './reducerInjectors';

const useInjectReducer = ({ key, reducer }) => {
  // 需要从 Redux 的 context 中获取到当前应用的全局 store 实例
  const context = React.useContext(ReactReduxContext);
  
  // 为了模拟 constructor 的运行时机
  const initFlagRef = React.useRef(false);
  if (!initFlagRef.current) {
    initFlagRef.current = true;
    getInjectors(context.store).injectReducer(key, reducer);
  }
  
  // 如果需要加入 eject 的逻辑,则可以使用这样的写法。类似于为当前组件增加一个 willUnmount 的生命周期逻辑。
  // React.useEffect(() => (() => {
  //   const injectors = getInjectors(context.store);
  //   injectors.ejectReducer(key);
  // }), []);
};

export { useInjectReducer };

useInjectReducer 这个 Hooks 帮助我们处理了何时去挂载,怎么挂载等问题,我们最终只需要告诉他 挂载什么 就可以了。通过这层封装,可以发现我们进一步收敛了关注点。到这一步为止,我们都是提供了一个项目级别的公共方法。在下一步中,我们会提供一个统一的写法,在具体的开发过程中去使用,进一步做封装收敛。

在进入下一步之前,我们先简单解释一下上面的逻辑。逻辑通过注释分为了三段(第三段在 reducer 场景下没用到),第一段我们通过当前组件所处的 redux 上下文,拿到了 store 的引用,第二段与第三段我们分别让组件在 初始化 和 销毁前 执行挂载与解除挂载的操作。通过一个 initFlagRef 为 functional 的组件模拟构造器的生命周期(如果有更好的实现方案欢迎指教),因为如果在挂载之后再 inject 的话,会在第一次渲染时取不到对应 store 的内容。

对开发者暴露封装

在完成公用方法的封装之后,我们下一步考虑的就是如何用更简单的方式,为我们的模块挂载 store 。按照下面的方式,开发者不用关心任何东西,只需一句话就可以完成挂载,也不用提供额外的参数。如果同时有 reducer、saga 或其他中间件内容,也可以一起打包搞定。

import { 
  useInjectReducer, 
  // useInjectSaga,
} from '@/utils/store';

import actions from './actions';
import constants from './constants';
import reducer from './reducer';
// import saga from './saga';

const namespace = constants.namespace;

const useSubStore = () => {
  useInjectReducer({ key: namespace, reducer });
  // useInjectSaga({ key: namespace, saga });
};

export {
  namespace,
  actions,
  constants,
  useSubStore,
};

实际使用范例:

import React from 'react';
import {
  useSubStore,
} from './store';

export default function Page() {
  useSubStore();
  
  return <div />;
};

具体的数据和逻辑我们也可以封装成几个 Hooks ,例如我们需要提供一个数组数据简单操作,我们只关心 添加 和 数量,就可以封装一个 Hooks,这样实际使用方只需要关心 添加 和 数量 这两个要素,不用关心 redux 的具体实现方式了。

import { useMemo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import {
  actions, constants, namespace,
} from './store';

export function useItemList() {
  const dispatch = useDispatch();
  const list = useSelector(state => state[namespace].itemList);
  // 这只是范例!
  const count = useMemo(() => list.length, [list]);
  const add = useCallback((item) => dispatch(actions.addItem(item)), []);
  
  return [count, add];
}

下面我们修改一下使用的地方:

import React from 'react';
import {
  useSubStore,
} from './store';
import { useItemList } from './useItemList';

export default function Page() {
  useSubStore();
  const [count, add] = useItemList();
  
  return <div onClick={() => add({})}>{count}</div>;
};

通过这样一种拆分方式,store 的定义,store 的使用逻辑,业务侧三者都只关注自己必须关注的部分,任何一方改动都可以尽量少地引起变更。

可复用的 Hooks

那我们进一步思考一下,以前我们可能一个页面对应一个 store。通过 Hooks 进行拆分后,我们更方便从功能层面去拆分 store,store 的逻辑也会更为清晰。与 store 的交互被封装成了 Hooks 之后也可以很快在多个展示层被使用。这在复杂 B 端工作台场景下会展现出很大的价值。案例会有点长,以后有时间可以再补上。

看完上面的例子,相信聪明的读者已经知道我想表达的问题了。通过结合 Redux + Hooks,标准化了定义代码,对逻辑、调用、定义三者一定程度上进行了解耦。通过简化的 API,减少了逻辑的理解成本,减少了后续维护的复杂度,一定程度上还可以达到复用。不管是相较于过去的 Redux 接入方案,还是相较于单纯使用 Hooks,都有着其独特的优势。特别适用于逻辑相对复杂的工作台场景。(而且我很喜欢 Saga 的设计思路,能用起来就很爽)。

OK,收。这次以一个简单的例子,稍稍展示了一下在 Hooks 大环境下 Redux 与其产生的化学反应。主要想展示的是依赖 Hooks 的逻辑可封装能力的一种设计思路,Redux 黑的同学们不要过多纠结与这个选型,萝卜青菜各有所爱。

希望这个系列能继续写下去 :)

作者:ES2049 / armslave00

文章可随意转载,但请保留此原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 [email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK