Based on the structure above, I’ve divided Rematch into the following components:
Recalling the Rematch import statement import { init } from '@rematch/core', the core represents the core source code of Rematch, which I categorize into two files:
/src/rematch.ts contains the Rematch class, including the initialization method of Rematch and methods for model construction, etc.
/src/redux.ts involves the transformation of code logic from Rematch to Redux, mainly the generation of reducers.
Next, let’s discuss these two parts.
1 Rematch Class
1.1 The global init method
Before understanding the Rematch class, let’s look at Rematch’s export method init, which is the only place to instantiate the Rematch class:
exportinterfaceInitConfig<Mextendsobject =Models>{name?: string;models?: M;plugins?: Plugin[];redux?: InitConfigRedux;}// incrementer used to provide a store name if none exists
letcount=0;/**
* init
*
* generates a Rematch store
* with a set configuration
* @param config
*/exportconstinit=(initConfig: R.InitConfig={}):R.RematchStore=>{constname=initConfig.name||count.toString();count+=1;constconfig: R.Config=mergeConfig({...initConfig,name});returnnewRematch(config).init();};
The init() function accepts a configuration object as a parameter and returns a created Rematch store. Inside the function, since initConfig supports property defaults, the mergeConfig() function is first called to fill in the missing parameters. Also, the plugins attribute represents some exported plugin configurations (which will be discussed later in Rematch plugins). These initialization configurations in the plugins also need to be merged:
/**
* Rematch class
*
* an instance of Rematch generated by "init"
*/exportdefaultclassRematch{protectedconfig: R.Config;protectedmodels: R.Model[];privateplugins: R.Plugin[]=[];privatepluginFactory: R.PluginFactory;constructor(config: R.Config){this.config=config;this.pluginFactory=pluginFactory(config);for(constpluginofcorePlugins.concat(this.config.plugins)){this.plugins.push(this.pluginFactory.create(plugin));}// preStore: middleware, model hooks
this.forEachPlugin("middleware",(middleware)=>{this.config.redux.middlewares.push(middleware);});}publicforEachPlugin(method: string,fn:(content: any)=>void){for(constpluginofthis.plugins){if(plugin[method]){fn(plugin[method]);}}}// ...
}
In the constructor, the main task is to bind config, pluginFactory, and plugins to the Rematch instance. We’ll skip the plugin part for now.
Here’s a brief explanation of the forEachPlugin() method, which is used to traverse all plugins. If a corresponding hook exists on the plugin (matched through the first parameter method), it calls the second parameter (a callback function) and passes the hook as a parameter back. For example, in the constructor, all plugins’ middleware hooks are collected and merged into the redux.middleware configuration.
1.3 The init method on Rematch instance
After instantiating the Rematch class, its init() method is called:
exportdefaultclassRematch{// ...
protectedconfig: R.Config;protectedmodels: R.Model[];// ...
publicgetModels(models: R.Models):R.Model[]{returnObject.keys(models).map((name: string)=>({name,...models[name],reducers: models[name].reducers||{},}));}publicaddModel(model: R.Model){// ... some validation
// run plugin model subscriptions
this.forEachPlugin("onModel",(onModel)=>onModel(model));}publicinit() {// collect all models
this.models=this.getModels(this.config.models);for(constmodelofthis.models){this.addModel(model);}// create a redux store with initialState
// merge in additional extra reducers
constredux=createRedux.call(this,{redux: this.config.redux,models: this.models,});constrematchStore={name: this.config.name,...redux.store,// dynamic loading of models with `replaceReducer`
model:(model: R.Model)=>{this.addModel(model);redux.mergeReducers(redux.createModelReducer(model));redux.store.replaceReducer(redux.createRootReducer(this.config.redux.rootReducers));redux.store.dispatch({type:"@@redux/REPLACE "});},};this.forEachPlugin("onStoreCreated",(onStoreCreated)=>{constreturned=onStoreCreated(rematchStore);// if onStoreCreated returns an object value
// merge its returned value onto the store
if(returned){Object.keys(returned||{}).forEach((key)=>{rematchStore[key]=returned[key];});}});returnrematchStore;}}
In init(), models are first initialized and bound. If a plugin has registered an onModel hook, it is called. Then, a native Redux store is generated and wrapped in a Rematch store. Finally, if a plugin has registered an onStoreCreated hook, it is called. If the hook returns an object, the object’s properties are merged into the Rematch store.
The main part of this process is to explain how to use the createRedux() function to generate a native Redux store, which is the second part of the Rematch core about Redux.
2 Create Redux Store
The Redux part mainly transforms the models configured in Rematch into Redux reducers, eventually generating a Redux store.
The file is located at /src/redux.ts, containing only one default export function:
exportdefaultfunction({redux,models,}:{redux: R.ConfigRedux;models: R.Model[];}){constcombineReducers=redux.combineReducers||Redux.combineReducers;constcreateStore: Redux.StoreCreator=redux.createStore||Redux.createStore;constinitialState: any=typeofredux.initialState!=="undefined"?redux.initialState:{};this.reducers=redux.reducers;// combine models to generate reducers
this.mergeReducers=(nextReducers: R.ModelReducers={})=>{// merge new reducers with existing reducers
this.reducers={...this.reducers,...nextReducers};if(!Object.keys(this.reducers).length){// no reducers, just return state
return(state: any)=>state;}returncombineReducers(this.reducers);};this.createModelReducer=(model: R.Model)=>{constmodelBaseReducer=model.baseReducer;constmodelReducers={};for(constmodelReducerofObject.keys(model.reducers||{})){constaction=isListener(modelReducer)?modelReducer:`${model.name}/${modelReducer}`;modelReducers[action]=model.reducers[modelReducer];}constcombinedReducer=(state: any=model.state,action: R.Action)=>{// handle effects
if(typeofmodelReducers[action.type]==="function"){returnmodelReducers[action.type](state,action.payload,action.meta);}returnstate;};this.reducers[model.name]=!modelBaseReducer?combinedReducer:(state: any,action: R.Action)=>combinedReducer(modelBaseReducer(state,action),action);};// initialize model reducers
for(constmodelofmodels){this.createModelReducer(model);}this.createRootReducer=(rootReducers: R.RootReducers={}):Redux.Reducer<any,R.Action>=>{constmergedReducers: Redux.Reducer<any>=this.mergeReducers();if(Object.keys(rootReducers).length){return(state,action)=>{constrootReducerAction=rootReducers[action.type];if(rootReducers[action.type]){returnmergedReducers(rootReducerAction(state,action),action);}returnmergedReducers(state,action);};}returnmergedReducers;};constrootReducer=this.createRootReducer(redux.rootReducers);constmiddlewares=Redux.applyMiddleware(...redux.middlewares);constenhancers=composeEnhancersWithDevtools(redux.devtoolOptions)(...redux.enhancers,middlewares);this.store=createStore(rootReducer,initialState,enhancers);returnthis;}
It might look a bit messy, so let me break it down and explain step by step.
2.1 Create Model Reducers
First, create a unified reducer for each Model. Since each Model contains multiple reducers, this step creates a unified one, then dispatches to the corresponding individual reducers.
/**
* isListener
*
* determines if an action is a listener on another model
*/constisListener=(reducer: string):boolean=>reducer.indexOf("/")>-1;exportdefaultfunction({redux,models,}:{redux: R.ConfigRedux;models: R.Model[];}){constcombineReducers=redux.combineReducers||Redux.combineReducers;constcreateStore: Redux.StoreCreator=redux.createStore||Redux.createStore;constinitialState: any=typeofredux.initialState!=="undefined"?redux.initialState:{};this.reducers=redux.reducers;this.createModelReducer=(model: R.Model)=>{constmodelBaseReducer=model.baseReducer;constmodelReducers={};for(constmodelReducerofObject.keys(model.reducers||{})){constaction=isListener(modelReducer)?modelReducer:`${model.name}/${modelReducer}`;modelReducers[action]=model.reducers[modelReducer];}constcombinedReducer=(state: any=model.state,action: R.Action)=>{// handle effects
if(typeofmodelReducers[action.type]==="function"){returnmodelReducers[action.type](state,action.payload,action.meta);}returnstate;};this.reducers[model.name]=!modelBaseReducer?combinedReducer:(state: any,action: R.Action)=>combinedReducer(modelBaseReducer(state,action),action);};// initialize model reducers
for(constmodelofmodels){this.createModelReducer(model);}// ...
}
First, some default values are filled in, including createStore, combineReducers methods, initial initialState, and reducers. Then, each model is traversed to generate a model-level reducer, with model.name as the key, filling the this.reducers object.
There are 2 main points to note here:
The generation of action.type. If the reducer method name contains /, then Rematch considers it a reducer for listening (e.g., if there are two Models, modelA and modelB, and modelA has a reducer named foo, then a listening reducer modelA/foo can be defined in modelB. So when the dispatched action is modelA/foo, both reducers will execute). The corresponding action type is the name of that reducer; otherwise, it is ${model.name}/${modelReducerName}.
Each model can define a baseReducer. If there is a baseReducer, each model will go through its corresponding baseReducer before reaching the specific reducer.
After creating each Model’s reducer, the next step is to create a rootReducer.
2.2 Create Root Reducer
After the Root reducer is created, a Redux store can be generated. Let’s look at the following code:
exportdefaultfunction({redux,models,}:{redux: R.ConfigRedux;models: R.Model[];}){// ...
constcombineReducers=redux.combineReducers||Redux.combineReducers;this.reducers=redux.reducers;// ...
// combine models to generate reducers
this.mergeReducers=(nextReducers: R.ModelReducers={})=>{// merge new reducers with existing reducers
this.reducers={...this.reducers,...nextReducers};if(!Object.keys(this.reducers).length){// no reducers, just return state
return(state: any)=>state;}returncombineReducers(this.reducers);};this.createRootReducer=(rootReducers: R.RootReducers={}):Redux.Reducer<any,R.Action>=>{constmergedReducers: Redux.Reducer<any>=this.mergeReducers();if(Object.keys(rootReducers).length){return(state,action)=>{constrootReducerAction=rootReducers[action.type];if(rootReducers[action.type]){returnmergedReducers(rootReducerAction(state,action),action);}returnmergedReducers(state,action);};}returnmergedReducers;};constrootReducer=this.createRootReducer(redux.rootReducers);// ...
}
Now, let’s look at the this.createRootReducer() function. It first calls this.mergeReducers() to combine reducers. After the above steps, each model’s reducers have been collected into a reducerMapObject (this.reducers), and in this.mergeReducers(), combineReducers is called to merge them into the final rootReducer.
It’s important to note that, similar to configuring a baseReducer for each Model, Rematch also allows you to configure a global redux.rootReducers. When dispatching an action, it first checks if there is a matching reducer in redux.rootReducers. If so, it processes through this reducer first, then the final rootReducer dispatches to the specific reducers.
constcomposeEnhancersWithDevtools=(devtoolOptions: R.DevtoolOptions={}):any=>{const{disabled,...options}=devtoolOptions;/* istanbul ignore next */return!disabled&&typeofwindow==="object"&&window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__?window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(options):Redux.compose;};exportdefaultfunction({redux,models,}:{redux: R.ConfigRedux;models: R.Model[];}){// ...
constrootReducer=this.createRootReducer(redux.rootReducers);constmiddlewares=Redux.applyMiddleware(...redux.middlewares);constenhancers=composeEnhancersWithDevtools(redux.devtoolOptions)(...redux.enhancers,middlewares);this.store=createStore(rootReducer,initialState,enhancers);returnthis;}
This part is similar to Redux, but Rematch completely encapsulates it, so I won’t go into detail.
3 Craete Rematch Store
Lastly, let’s look at the creation of the Rematch store, recalling the previous code:
exportdefaultclassRematch{// ...
publicinit() {// ...
// create a redux store with initialState
// merge in additional extra reducers
constredux=createRedux.call(this,{redux: this.config.redux,models: this.models,});constrematchStore={name: this.config.name,...redux.store,// dynamic loading of models with `replaceReducer`
model:(model: R.Model)=>{this.addModel(model);redux.mergeReducers(redux.createModelReducer(model));redux.store.replaceReducer(redux.createRootReducer(this.config.redux.rootReducers));redux.store.dispatch({type:"@@redux/REPLACE "});},};// ...
returnrematchStore;}}
As can be seen, the Rematch store adds a name property and a model method on top of the Redux store. The former, I guess, is used to distinguish between multiple Rematch stores, while the latter is used to dynamically add models. Let’s focus on how to dynamically add models:
First, associate the model with the Rematch instance.
Then create a reducer for the model.
Finally, recreate a rootReducer and use Redux’s replaceReducer() method for replacement. A special action is also dispatched to indicate this.
It should be noted that in the code redux.mergeReducers(redux.createModelReducer(model)), the call to redux.mergeReducers is actually redundant and ineffective since its return value is not used. Moreover, in the redux.createRootReducer(this.config.redux.rootReducers) method below, redux.mergeReducers is called again, where it actually takes effect.
In Rematch v1, due to the use of the Rematch instance, many variable objects are directly associated with this and are updated by direct mutation, leading to some redundant or “strange” aspects. In v2, the Rematch instance is replaced by functional programming. However, some variable bindings to this have not been completely removed, so there are still some issues like this in the code, which I will explain separately when writing about the upgrade from v1 to v2.
This concludes the introduction to Rematch core. Next, I will explain the plugin mechanism of Rematch in two parts: the first part will introduce the pluginFactory and Rematch’s two core plugins, and the second part will discuss several third-party plugins developed by the Rematch team to understand this mechanism more deeply.
Finally, I will write two articles, one on the changes in upgrading from Rematch v1 to v2 and the other on the Rematch type system (the biggest change brought by the upgrade to v2) as well as some remaining issues and difficulties of this type system, which I will discuss with you.