likes
comments
collection
share

来了解下 React 代码分割神器 redux-dynamic-modules 吗?

作者站长头像
站长
· 阅读数 27

如果你的项目正在使用 react 全家桶,且它有了一定的规模。

有一天,你的领导让你做性能优化,将某些不重要的、首屏不可见的部分拆成异步加载的模块。

搬砖已有几年的你,脑海闪过:

  • components 用 React(v16.6+) 自带的 lazy 与 Suspense 组合
  • reducer 用 redux 自带的 replaceReducer

突然你想到:“完蛋,我还用到 saga 的 watchers,怎么办 😣?”

可能你颇有经验,联想到:“好像 saga 注册都要 sagaMiddleware.run,还返回了一个 task,可以借助它们实现动态添加 watchers 吗?我不确定啊?😫”

如果我告诉你,有个第三方库帮我们做了这些事,你会不会谢谢我啊?


就是它 redux-dynamic-modules

该库引入了“Redux Module”的概念,它是一组应该动态加载的 Redux 部件(Reducer,middleware)。 它还暴露出一个 React 高阶组件用来在应用组件加载后加载“Module”。 此外,它还与诸如 redux-thunk 和redux-saga 之类的库集成,以使这些库可以动态加载他们的部件(thunk,sagas)。

它基于 react-redux 做了封装,基于此生成的 store 拥有了动态添加模块的能力。

1. 基本使用

1.1 生成 store

import { createStore } from 'redux-dynamic-modules'
import { getSagaExtension } from 'redux-dynamic-modules-saga'
import { sagas, advancedCombineReducers, reducers } from 'reducers'

/**
    enhances: for middlewares
    extensions: for saga, thunk...
    advancedCombineReducers: for combine module reducers
    advancedComposeEnhancers: default as devTools
**/
const moduleStoreSettings = {
  advancedCombineReducers,
  enhancers: [],
  extensions: [getSagaExtension()]
}

// initial module, sagas should be an Array
const initialModule = {
  id: 'root',
  reducerMap: reducers,
  sagas
}

// create a dynamic store with addModule method
// and there can be multiple initial modules, createSrore will receive them with ... as an Array
const store = createStore(moduleStoreSettings, initialModule)

我们不需要自己再注册 sagaMiddleware,维护 saga task,把它们都交给 redux-dynamic-modules 。

1.2 动态模块注册

import React from 'react'
import AsyncComponent from './component'
import { reducerMap, sagas } from './reducers'
import { DynamicModuleLoader } from 'redux-dynamic-modules-react'

/**
    id: unique, represents this module
    reducerMap: reducers map with an Object
    sagas: several wathcers as an Array
    retained: true means this module won't be removed once added
**/
export const getModule = (): any => ({
  id: 'AsyncComponent',
  reducerMap,
  sagas,
  retained: true
})

// props modules for store to add
export default function Dynamic (props: any) {
  return (
    <DynamicModuleLoader modules={[getModule()]}>
      <AsyncComponent {...props} />
    </DynamicModuleLoader>
  )
}

注册 module 的时候设置 retained 为 true,将不再有 remove 事件发生,表示 module 一旦加上就不会再被卸载。

2. 源码学习

这个库的代码十分简单,逻辑也很清晰,各位可以放心大胆食用。

2.1 包结构

它使用 lerna 管理多个包:

  • redux-dynamic-modules:主要就两行代码,导出核心包 redux-dynamic-modules-coreredux-dynamic-modules-react
  • redux-dynamic-modules-core:核心逻辑,包括 store 生成 与 module 的维护。
  • redux-dynamic-modules-react:实现 React 高阶组件 DynamicModuleLoader,用于注册异步 module
  • redux-dynamic-modules-saga:与 redux-saga 中间件集成
  • redux-dynamic-modules-thunk:与 redux-thunk 中间件集成
  • redux-dynamic-modules-observable:与 redux-observable 中间件集成
  • xx-example:demo

2.2 核心逻辑

走进 redux-dynamic-modules 源码,不得不提到整个库的核心 refCounter。

refCounter

redux-dynamic-modules 会自动执行 module 及其内容的引用计数 (reference counting)。

这意味着:

  • 如果已经添加过一个模块,再次添加它,就只是一个 NO-OP
  • 如果添加了两次同一个模块,则必须将其删除两次

乍一看,似乎有点前后矛盾,其实不然:虽然重复添加两次不会成功,但 refCounter 是 2,意味着有两个地方对它有依赖,所以需要删除它就需要满足它的两个引用都被删除。

这块的源码实现也很简单:

// RefCountedManager.ts
export function getRefCountedManager<IType extends IItemManager<T>, T>(
  manager: IType,
	equals: (a: T, b: T) => boolean,
	retained?: (a: T) => boolean // Decides if the item is retained even when the ref count reaches 0 
): IType {
	let refCounter = getObjectRefCounter<T>(equals, retained);
    const items = manager.getItems();
    // Set initial ref counting
    items.forEach(item => refCounter.add(item));

    const ret: IType = { ...(manager as object) } as IType;

    // Wrap add method
    ret.add = (items: T[]) => {
        if (!items) {
            return;
        }

        const nonNullItems = items.filter(i => i);
        const notAddedItems = nonNullItems.filter(
            i => refCounter.getCount(i) === 0
        );
        manager.add(notAddedItems);
        nonNullItems.forEach(refCounter.add);
    };

    // Wrap remove
    ret.remove = (items: T[]) => {...};
    ret.dispose = () => {...};

    return ret;
}

可以看到在 add 或 remove 时,它将代表 manager 执行,下文所有 manager (如 moduleManager,reducerManager 等)的 module 与其内容 (reducer, saga, middleware) 等都会记录引用。


开始之前,先对核心包 redux-dynamic-modules-core 的文件结构组织有个了解。

src
|-- Manager
   |-- MiddlewareManager.ts
   |-- ModuleManager.ts	
   |-- ReducerManager.ts	
   |-- RefCountedManager.ts	
|-- Utils
   |-- ComparableMap.ts
   |-- Flatten.ts
   |-- RefCounter.ts
|-- Contracts.ts
|-- index.ts
|-- ModuleStore.ts

ModuleStore

这里是整个库的入口,导出 createStore 供项目中使用。 它将接收并处理所有与 store 相关的逻辑,并暴露 addModules 到store,用于后续动态注册 module 。

// ModuleStore.ts
export function createStore<State>(
    moduleStoreSettings: ModuleStoreSettings<State>,
    ...initialModules: IModule<any>[]
): IModuleStore<State> {
    //	...

    // createReduxStore 即 redux 的 createStore
    const store: IModuleStore<State> = createReduxStore<State, any, {}, {}>(
      moduleManager.getReducer,
      initialState,
      enhancer as any
    ) as IModuleStore<State>;

    // 内部 dispatch 使用 store 的 dispatch,方便后续执行自定义 action
    moduleManager.setDispatch(store.dispatch);

    const addModules = (modulesToBeAdded: IModuleTuple) => {
      const flattenedModules = flatten(modulesToBeAdded);
      moduleManager.add(flattenedModules);
      return {
        remove: () => {
          moduleManager.remove(flattenedModules);
        },
      };
    };

    // ...

    store.addModules = addModules

    return store
}

真正核心逻辑在 ModuleManager 中,新增的 modules 在经过 refCounter filter 之后,进入 ModuleManager 中进行 reducer 与 saga (或 thunk) 的注册。

ModuleManager

ModuleManager 是调度中心,它控制新增/删除 module 需要处理的任务。

下文将以 add module 为例讲解。

来了解下 React 代码分割神器 redux-dynamic-modules 吗?

如上图,在 redux-dynamic-modules 中,将项目视为一个个 module 拼凑而成,module 的收集有两种途径:

  • 初始化时传给 createStore 的 initialModules(自由拆分,可以一个或多个)
  • 挂在 DynamicModuleLoader 上动态异步加载 modules,在 class 组件构建时通过 ReactReduxContext 拿到 store,再进行 addModules

DynamicModuleLoader 的实现在 redux-dynamic-modules-react 包中,主要逻辑是 props 接收异步注册的 module 内容,实现很简单,本文不做进一步分析,主要分析它的本质,调用 store.addModules

// ModuleManager.ts
const add = (modulesToAdd) => {
    if (!modulesToAdd || modulesToAdd.length === 0) {
        return;
    }
    modulesToAdd = modulesToAdd.filter(module => module);
    const justAddedModules = [];
    modulesToAdd.forEach(module => {
        if (!_moduleIds.has(module.id)) {
            _moduleIds.add(module.id);
            _modules.push(module);
            _addReducers(module.reducerMap);

            const middlewares = module.middlewares;
            if (middlewares) {
                _addMiddlewares(middlewares);
            }
            justAddedModules.push(module);
        }
    });

    // Fire an action so that the newly added reducers can seed their initial state
    _seedReducers();

    // add the sagas and dispatch actions at the end so all the reducers are registered
    justAddedModules.forEach(module => {
        // Let the extensions know we added a module
        extensions.forEach(p => {
            if (p.onModuleAdded) {
                p.onModuleAdded(module);
            }
        });

        // Dispatch the initial actions
        const moduleAddedAction = {
            type: "@@Internal/ModuleManager/ModuleAdded",
            payload: module.id,
        };
        _dispatchActions(
            module.initialActions
                ? [moduleAddedAction, ...module.initialActions]
                : [moduleAddedAction]
        );
    });
}

使用 justAddedModules 从接收的 modulesToAdd 中过滤出还未被注册的 module,并将其中的 reducer 与 middlewares 等一一注册。

reducerManager

添加 reducer 很简单,关键在将新加入的 reducer combine 到内部维护的 reducers 中即可。

// ReducerManager.ts
add: <ReducerState>(key: string, reducer: Reducer<ReducerState>) => {
    if (!key || reducers[key]) {
        return;
    }

    reducers[key] = reducer;
    combinedReducer = getCombinedReducer(reducers, reducerCombiner);
},

combinedReducer 将用在 dispatch 时处理 action。

_SeedReducers

注册完 reducers 之后我们需要借助 _seedReducers 调用 action 来初始化 state 。

const _seedReducers = () => {
  _dispatch({ type: "@@Internal/ModuleManager/SeedReducers" });
};

sagaManager

而 saga 的处理主要在 extension.onModuleAdded 中,它在 redux-dynamic-modules-saga 包中。

事实上正如前文提到的,使用了 redux-dynamic-modules 包之后,模块内的所有 sagas 基本都不需要手动处理,而是依靠内置的 sagaExtension 协助处理,因为它既然处理了你的 reducers,那么 sagas 就也该归它管。

如果添加的 module 中包含 sagas,则需要使用 sagaManager 处理。

怕大家忘了,再重申一次:redux-dynamic-modules 中一以贯之的是 refCounter 的概念,所有需要注册的模块都需要经过 refCounter 的过滤,避免重复添加,saga 这也不例外。

sagaManager 处理该项目内的所有 sagas,其基本逻辑也是通过 sagaMiddleware.run 每个 module 的 sagas 实现动态注册,并使用 tasks 维护,避免重复注册。

// sagaManager.ts
/**
 * Creates saga items which can be used to start and stop sagas dynamically
 */
export function getSagaManager(
    sagaMiddleware: SagaMiddleware<any>
): IItemManager<ISagaRegistration<any>> {
    const tasks = getMap<ISagaRegistration<any>, Task>(sagaEquals);

    return {
        getItems: (): ISagaRegistration<any>[] => [...tasks.keys],
        add: (sagas: ISagaRegistration<any>[]) => {
            if (!sagas) {
                return;
            }
            sagas.forEach(saga => {
                if (saga && !tasks.get(saga)) {
                    tasks.add(saga, runSaga(sagaMiddleware, saga));
                }
            });
        },
        remove: () => {...}
        dispose: () => {...}
    };
}

生命周期 Action

处理了 sagas 之后,所有任务完成,就该想办法通知主模块异步模块加载完成了,所以将触发 moduleAddedAction,payload 带上相应的 moduleId 方便后续处理。

在上节代码片段中,redux-dynamic-modules 除了会 dispatch 模块添加时用户自定义的 initialActions(当然还会在移除模块 dispatch 传入的 finalActions),还会有两个自动触发的 action:

  • 添加模块时,会 dispatch action “@@Internal/ModuleManager/ModuleAdded”,并带上 moduleId 做为 payload.
  • 移除模块时,会 dispatch action “@@Internal/ModuleManager/ModuleRemoved”,同样带上 moduleId.

如果需要对 module 添加移除做监听并针对性实现一些业务逻辑的话,这里是一个非常好的切入点


当然,以上只讲了 add 的逻辑,其实还有 remove 或者 dispose 的逻辑,不过逻辑大同小异。

3. 实现分析

以上我们简单学习了 redux-dynamic-modules 的核心逻辑,现在回到开文,看看它的实现与搬砖几年的你内心构思的方案有没有差异。

3.1 components splitting

虽然 redux-dynamic-modules 实现了一个高阶组件 DynamicModuleLoader,但它主要目的是用来注册 module 的,真正做到 code splitting 还是靠 lazy 与 Suspense 组合。

或者换句话说,借助 lazy 与 Suspense,再结合 redux-dynamic-modules 动态注册reducer 与 saga 等的能力,实现了完整的 module 代码分割。

import React, {lazy, Suspense} from 'react'
import { ErrorBoundary, Loading } from '@/components'

const Profile = lazy(() => import(/* webpackChunkName:'profile',webpackPrefetch:true */'modules/profile'))

const App = () => {
    return (
        <ErrorBoundary>
            <div>abc</div>
            <Suspense fallback={<Loading />}>
                <Profile />
            </Suspense>
        </ErrorBoundary>
    )
}

3.2 reducer splitting

正如 redux 官网提到的对 reducer 实现 code splitting 的几个方法,除了我们熟知 replaceReducer

function replaceReducer (nextReducer) {
	currentReducer = nextReducer
	return store
}

其实就是简单替换了 currentReducer 而已,因为

function dispatch (action) {
	currentState = currentReducer(currentState, action)
	// ...
}

因为每个 action 被 dispatch 时,都需要用当前 store 中的 reducers 与 state 去消费它,我们保证reduers 与 state 对应即可。

redux-dynamic-modules 采用的是另一个贴合 module 主旨的思路,建立一个 reducerManager 来动态管理所有的 reducer,但原理无二。

3.3 saga splitting

redux-dynamic-modules 内部维护了所有 saga tasks,并借助 sagaMiddleware.run(saga)实现了saga watchers 的动态注册。

但不得不提迁移过程遇到的两个坑

额外注册 sagaMiddleware,维护 saga task

如前文提到,我们可以在 initialModule 里将所有 sagas 托管给 redux-dynamic-modules,那如果没这么干呢?

const sagaMiddleware = createSagaMiddleware()
// mount it on the Store
const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

// then run the saga
sagaMiddleware.run(mySaga)

如上代码片段,接入 redux-dynamic-modules 时,我们仍使用旧的 sagaMiddleware.run(mySaga)

注册方式,项目能正常运行,但你会发现动态注册的 module 中的 sagas watch 不了,即所有 watchers 都没被注册

通过以上阅读源码,可以发现这是因为项目内维护的 sagaMiddleware 与 redux-dynamic-modules 中维护的不在一个频道。

所以切记别再额外注册 sagaMiddleware,维护 saga task。

sagas task error catch

按上章推荐的把一切交给 redux-dynamic-modules 后,一些朋友可能会发现头疼了,因为:

let tasks = sagaMiddleware.run(sagas)

const onTasksException = () => {
  tasks.toPromise().catch(e => {
    tasks.cancel()
    tasks = sagaMiddleware.run(sagas)
    onTasksException()
  })
}

onTasksException()

因为很多朋友没有自信捕获了所有 saga 的 error ,所以往往会依赖 sagas task 实现异常捕获,保证sagas 运行正常。

现在一切都交出去了,没了控制权,遇到 saga 执行 error ,所有 task 就都罢工了,点解?

  var _this = this
  sagas.forEach(function (saga) {
    if (saga && !tasks.get(saga)) {
-     tasks.add(saga, runSaga(sagaMiddleware, saga));
+     let task = runSaga(sagaMiddleware, saga)
+     tasks.add(saga, task);
+     task.toPromise().catch(function(error) {
+       _this.remove([saga])
+       task = sagaMiddleware.run(saga)
+       _this.add([saga])
+     })
    }
  });

提供一个我的解决方案,patch redux-dynamic-modules-saga 中的 sagaManager.ts 文件 add 方法,添加 catch,如果某一 saga 执行失败,则移除再重新注册,达到目的。

所以朋友们,事在人为啊!