mmlpx is an abbreviation of mobx model layer paradigm, inspired by CQRS and Android Architecture Components, aims to provide a mobx-based generic layered architecture for single page application.
npm i mmlpx -S
or
yarn add mmlpx
- MobX: ^3.2.1 || ^4.0.0 || ^5.0.0
Try to explore the possibilities for building a view-framework-free data layer based on mobx, summarize the generic model layer paradigm, and provide the relevant useful toolkits to make it easier and more intuitive.
import { inject, onSnapshot, getSnapshot, applySnapshot } from 'mmlpx'
import Store from './Store'
@observer
class App extends Component {
@inject() store: Store
stack: any[]
cursor = 0
disposer: IReactionDisposer
componentDidMount() {
this.stack.push(getSnapshot());
this.disposer = onSnapshot(snapshot => {
this.stack.push(snapshot)
this.cursor = this.stack.length - 1
this.store.saveSnapshot(snapshot)
})
}
componentWillUmount() {
this.disposer();
}
redo() {
applySnapshot(this.stack[++this.cursor])
}
undo() {
applySnapshot(this.stack[--this.cursor])
}
}
It is well known that MobX is an value-based reactive system which lean to oop paradigm, and we defined our states with a class facade usually. To avoid constructing the instance everytime we used and to enjoy the other benifit (unit test and so on), a di system is the spontaneous choice.
mmlpx DI system was deep inspired by spring ioc.
import { inject, ViewModel, Store } from 'mmlpx';
@Store
class UserStore {}
@ViewModel
class AppViewModel {
@inject() userStore: UserStore;
}
Due to we leverage the metadata description ability of typescript, you need to make sure that you had configured emitDecoratorMetadata: true
in your tsconfig.json
.
import { inject, ViewModel, Store } from 'mmlpx';
@Store
class UserStore {}
@ViewModel
class AppViewModel {
@inject(UserStore) userStore;
}
Sometimes you may need to intialize your dependencies dynamically, such as the constructor parameters came from router query string. Fortunately mmlpx
supported the ability via inject
.
import { inject, ViewModel } from 'mmlpx'
@ViewModel
class ViewModel {
@observable.ref
user = {};
constructor(projectId, userId) {
this.projectId = projectId;
this.userId = userId;
}
loadUser() {
this.user = this.http.get(`/projects/${projectId}/users/${userId}`);
}
}
class App extends Component {
@inject(ViewModel, app => [app.props.params.projectId, app.props.params.userId])
viewModel;
componentDidMount() {
this.viewModel.loadUser();
}
}
inject
decorator support four recipes initilizaztion:
inject() viewModel: ViewModel;
only for typescript.inject(ViewModel) viewModel;
generic usage.inject(ViewModel, 10, 'kuitos') viewModel;
initialized with static parameters forViewModel
constrcutor.inject(ViewModel, instance => instance.router.props) viewModel;
initialized with dynamic instance props forViewModel
constructor.
Notice that all the Store
decorated classes are singleton by default so that the dynamic initial params injection would be ignored by di system, if you wanna make your state live around the component lifecycle, always decorated them with ViewModel
decorator.
While you are limited to use decorator
in some scenario, you could use instantiate
to instead of @inject
.
@ViewModel
class UserViewModel {
constructor(name, age) {
this.name = name;
this.age = age;
}
}
const userVM = instantiate(UserViewModel, 'kuitos', 18);
mmlpx
di system also provided the mock method to support unit test.
function mock<T>(Clazz: IMmlpx<T>, mockInstance: T, name?: string) : recover
@Store
class InjectedStore {
name = 'kuitos';
}
class ViewModel {
@inject() store: InjectedStore;
}
// mock the InjectedStore
const recover = mock(InjectedStore, { name: 'mock'});
const vm = new ViewModel();
expect(vm.store.name).toBe('mock');
// recover the di system
recover();
const vm2 = new ViewModel();
expect(vm2.store.name).toBe('kuitos');
If you wanna strictly follow the CQRS paradigm to make your state changes more predictable, you could enable the strict mode by invoking useStrcit(true)
, then your actions in Store
or ViewModel
will throw an exception while you declaring a return statement.
import { useStrict } from 'mmlpx';
useStrict(true);
@Store
class UserStore {
@observable name = 'kuitos';
@action updateName(newName: string) {
this.name = newName;
// return statement will throw a exceptin when strict mode enabled
return this.name;
}
}
Benefit from the power of model management by di system, mmlpx supported time travelling out of box.
All you need are the three apis: getSnapshot
, applySnapshot
and onSnapshot
.
-
function getSnapshot(injector?: Injector): Snapshot;
function getSnapshot(modelName: string, injector?: Injector): Snapshot;
-
function applySnapshot(snapshot: Snapshot, injector?: Injector): void;
-
function onSnapshot(onChange: (snapshot: Snapshot) => void, injector?: Injector): IReactionDisposer;
function onSnapshot(modelName: string, onChange: (snapshot: Snapshot) => void, injector?: Injector): IReactionDisposer;
That's to say, mmlpx makes mobx do HMR and SSR possible as well!
As we need to serialize the stores to persistent object, and active stores with deserialized json, we should give a name to our Store
:
@Store('UserStore')
class UserStore {}
Fortunately mmlpx had provided ts-plugin-mmlpx to generate store name automatically, you don't need to name your stores manually.
You can check the mmlpx-todomvc redo/undo demo and the demo source code for details.
Business logic and rules definition, equate to the model in mvvm architecture, singleton in an application. Also known as domain object in DDD, always represent the single source of truth of the application.
import { observable, action, observe } from 'mobx';
import { Store, inject } from 'mmlpx';
import UserLoader from './UserLoader';
@Store
class UserStore {
@inject() loader: UserLoader;
@observable users: User[];
@action
async loadUsers() {
const users = await this.loader.getUsers();
this.users = users;
}
@postConstruct
onInit() {
observe(this, 'users', () => {})
}
}
Method decorated by postConstruct
will be invoked when Store
initialized by DI system.
Page interaction logic definition, live around the component lifecycle, ViewModel
instance can not be stored in ioc container.
The only direct consumer of Store
, besides the UI-domain/local states, others are derived from Store
via @computed
in ViewModel
.
The global states mutation are resulted by store command invocation in ViewModel
, and the separated queries are represented by transparent subscriptions with computed
decorator.
import { observable, action } from 'mobx';
import { postConstruct, ViewModel, inject } from 'mmlpx';
@ViewModel
class AppViewModel {
@inject() userStore: UserStore;
@observable loading = true;
@computed
get userNames() {
return this.userStore.users.map(user => user.name);
}
@action
setLoading(loading: boolean) {
this.loading = loading;
}
}
Data accessor for remote or local data fetching, converting the data structure to match definited models.
class UserLoader {
async getUsers() {
const users = await this.http.get<User[]>('/users');
return users.map(user => ({
name: user.userName,
age: user.userAge,
}))
}
}
export default App extends Component {
@inject()
vm: AppViewModel;
render() {
const { loading, userName } = this.vm;
return (
<div>
{loading ? <Loading/> : <p>{userName}</p>}
</div>
);
}
}