cumqueoptio
Version:
The meta-framework suite designed from scratch for frontend-focused modern web development.
328 lines (272 loc) • 7.42 kB
text/typescript
import { AnyAction as ReduxAction } from 'redux';
import { memorize } from '@/utils/memoize';
import {
Context,
ModelDesc,
Action,
DispatchActions,
Model,
MountedModel,
OnMountHook,
UseModel,
} from '@/types';
import {
getComputedDepModels,
getModelInitializer,
getStateType,
isModel,
StateType,
} from '@/utils/misc';
const mountModel = (context: Context, model: Model) => {
if (context.apis.getModel(model)) {
return;
}
const { onMount, trigger: triggerOnMount } = createOnMount();
let modelDesc: ModelDesc = getModelInitializer(model)(context, {
use: context.apis.useModel,
onMount,
});
modelDesc.name = model._name;
modelDesc = context.pluginCore.invokePipeline('prepareModelDesc', modelDesc);
if (!checkModel(context, modelDesc)) {
return;
}
context.apis.mountingModel(model._name);
const flattenedActions = flattenActions(modelDesc);
const reducer = createReducer(
context,
flattenedActions,
modelDesc.state,
modelDesc.computed,
);
if (reducer) {
context.apis.addReducers({ [modelDesc.name]: reducer });
}
const [dispatchActions, setDispatchAction] = createDispatchActions(
context,
modelDesc,
);
let mountedModel = {
actions: dispatchActions,
state: modelDesc.state,
name: modelDesc.name,
modelDesc,
} as MountedModel;
({ mountedModel } = context.pluginCore.invokePipeline(
'modelMount',
{
modelDesc,
mountedModel,
},
{
setDispatchAction,
},
));
context.apis.addModel(model, mountedModel);
triggerOnMount();
};
const checkModel = (context: Context, modelDesc: ModelDesc) => {
// model's name should be a string, which length > 0
if (!modelDesc.name || typeof modelDesc.name !== 'string') {
console.error(
`model name expected is a valid string, but got ${modelDesc.name}`,
);
return false;
}
return true;
};
const generateComputedDescriptors = (
computed: any = {},
useModel: UseModel,
) => {
return Object.keys(computed).reduce(
(prev: PropertyDescriptorMap, name: string) => {
const selector = generateComputedSelector(name, computed[name], useModel);
prev[name] = {
get() {
// this refers to current modelState
return selector(this);
},
// MARK: not enumerable, avoid to get computed properties through rest(...) syntax. eg., reducer {...state}
enumerable: false,
configurable: true,
};
return prev;
},
{},
);
};
/**
* Create reducer from model
*/
const createReducer = <S = any>(
context: Context,
flattenedActions: Record<string, Action<S>>,
initialState: S,
computed?: any,
) => {
if (!flattenedActions) {
return null;
}
let computedDescriptors =
computed && generateComputedDescriptors(computed, context.apis.useModel);
const depModels = getComputedDepModels(computed);
const isDepModelAction = (actionType: string) => {
return depModels.some(
model => actionType.split('/')[0] === model._name.toUpperCase(),
);
};
return (state: S = initialState, reduxAction: ReduxAction) => {
const actionType = reduxAction.type;
const reducer = flattenedActions[actionType];
let newState = state;
// make sure state and computed reference change when computed properties' depending models change
if (isDepModelAction(actionType)) {
newState = { ...state };
computedDescriptors =
computed &&
generateComputedDescriptors(computed, context.apis.useModel);
}
if (reducer) {
newState = context.pluginCore.invokePipeline(
'beforeReducer',
flattenedActions[reduxAction.type],
{ name: reduxAction.type, computedDescriptors },
)(state, reduxAction.payload, ...(reduxAction.extraArgs || []));
}
if (computedDescriptors && getStateType(newState) !== StateType.Object) {
throw Error(`Only object type state can have computed properties.`);
}
return computedDescriptors
? Object.defineProperties(newState, computedDescriptors)
: newState;
};
};
const generateComputedSelector = (
name: string,
computed: any,
useModel: UseModel,
) => {
let selector: (...args: any) => any;
let depModels: Model[] | undefined;
const _selector = (fn, ...args) => {
const result = fn(...args);
if (typeof result === 'function') {
return memorize((...args: any[]) => {
return result(...args);
});
} else {
return result;
}
};
if (typeof computed === 'function') {
selector = (state: any) => {
return _selector(computed, state);
};
} else if (Array.isArray(computed)) {
depModels = computed.slice(0, -1);
const userSelector = computed.slice(-1)[0];
if (
!depModels.every(m => isModel(m)) ||
typeof userSelector !== 'function'
) {
throw new Error(
`The types of computed property parameters are not correct. Computed property name: ${name}`,
);
}
selector = (state: any) => {
return _selector(
userSelector,
state,
...depModels.map(model => useModel(model)[0]),
);
};
}
return memorize(selector);
};
/**
* Flatten nested actions into one layer.
*/
const flattenActions = (modelDesc: ModelDesc) => {
const flattenedActions: Record<string, Action<any>> = {};
forEachAction(modelDesc, (path, action) => {
flattenedActions[path.join('/').toUpperCase()] = action;
});
return flattenedActions;
};
const createDispatchActions = (
context: Context,
modelDesc: ModelDesc,
): [DispatchActions, (path: string[], action: any) => void] => {
const dispatchActions: DispatchActions = {};
const set = (path: string[], value: any) => {
let cur: any = dispatchActions;
const len = path.length;
for (let i = 1; i < len - 1; i++) {
if (!cur[path[i]]) {
cur[path[i]] = {};
}
cur = cur[path[i]];
}
if (!cur[path[len - 1]]) {
cur[path[len - 1]] = value;
} else {
cur[path[len - 1]] = Object.assign(value, cur[path[len - 1]]);
}
};
forEachAction(modelDesc, path =>
set(path, (payload: any, ...extraArgs: any[]) => {
return context.store.dispatch({
type: path.join('/').toUpperCase(),
payload,
extraArgs,
});
}),
);
return [dispatchActions, set];
};
/**
* Traverse action utils
*/
const forEachAction = (
modelDesc: ModelDesc,
callback: (path: string[], action: Action<any>) => void,
) => {
const path = [modelDesc.name];
const traverse = (action: ModelDesc['actions'] | Action<any>) => {
if (!action) {
return null;
}
if (typeof action === 'function') {
return callback(path.slice(), action);
}
Object.keys(action).forEach(key => {
path.push(key);
traverse(action[key]);
path.pop();
});
return null;
};
traverse(modelDesc.actions || {});
};
/**
* Create onMount hook
*/
const createOnMount = () => {
const handlers: Parameters<OnMountHook>[0][] = [];
const triggered = false;
const onMount = (handler: Parameters<OnMountHook>[0]) => {
handlers.push(handler);
};
const trigger = () => {
if (triggered) {
return;
}
handlers.forEach(handler => handler());
};
return {
onMount,
trigger,
};
};
export default mountModel;