Mozel Component implements a hierarchical model-component architecture, which can optionally be setup as an MCV. In the basis, both a controller and a view are considered components taking care of a specific aspect of a model. That way, any number of component 'layers' can be added to a hierarchical model structure. For example, an architecture could be setup for each model to have a controller and a view (MCV), a controller and 2 views (MCVV) or even just a single view (MV).
How to distribute the responsibilities between the components is entirely up to you. MozelCC simply provides a mechanism for nested components to closely follow the structure of a nested model: for each model in the hierarchy, a corresponding component can be generated, and if a model's children change, so will each of its corresponding components' children.
The ground-truth of the architecture is the model. The model will define which components will be generated:
class ObjectModel extends Mozel {
@property(Vector3, {required})
position!: Vector3
// Hierarchy
@property(ObjectModel)
child?:ObjectModel;
@collection(ObjectModel)
children!: Collection<ObjectModel>;
}
Then we define a Component to be generated based on the model:
class ObjectComponent extends Component {
static Model = ObjectModel; // specify the model for which this component works
model!: ObjectModel; // just to tell Typescript the type of model
// Define the single child component based on the `child` property of the model, which is an ObjectModel.
// Therefore, we expect it to generate an ObjectComponent.
@component(schema(ObjectComponent.Model).child, ObjectComponent)
child!: ComponentSlot<ObjectComponent>;
// Define child components based on the `children` collection of the model, which is a collection of ObjectModels.
// Therefore, we expect it to generate ObjectComponents.
@components(schema(ObjectComponent.Model).children, ObjectComponent)
children!: ComponentList<ObjectComponent>;
}
At the core, we need something to generate our hierarchy, based on a set of available componennts. That is the ComponentFactory:
const componentFactory = new ComponentFactory();
// Register our available Components
componentFactory.register(ObjectComponent);
// This will register ObjectController as the component for ObjectModel (as specified in the ObjectController)
Finally, we can create a model and generate our component hierarchy:
// Create model
const modelFactory = ObjectModel.createFactory();
const model = modelFactory.createAndResolveReferences(ObjectModel, {
position: {x: 10, y: 0, z: 0},
child: modelFactory.create(ObjectModel),
children: [
modelFactory.create(ObjectModel)
]
});
// Generate components
const component = controllerFactory.createAndResolveReferences(model);
// Verify component hierarchy matches model hierarchy
const childModel = model.child;
const childComponent = component.child.current;
assert.equal(childModel, childComponent.model);
const childrenModel = model.children.get(0);
const childrenComponent = component.children.get(0);
assert.eequal(childrenModel, childrenComponent.model);
There are several ways to watch for changes in the model, as well as in the component hierarchy:
- Watch model changes.
- Child components being added/removed/switched.
- Current component being changed to a different parent.
class ObjectComponent extends Component {
static Model = ObjectModel; // specify the model for which this component works
model!: ObjectModel; // just to tell Typescript the type of model
@component(schema(ObjectComponent.Model).child, ObjectComponent)
child!: ComponentSlot<ObjectComponent>;
@components(schema(ObjectComponent.Model).children, ObjectComponent)
children!: ComponentList<ObjectComponent>;
onInit() {
super.onInit();
// 1. Watch position model (deeply)
this.watch(schema(ObjectComponent.Model).position, position => {
console.log("Position changed!");
}, {deep});
// 2. Watch child components
this.child.events.change.on(child => console.log("Child changed!"));
this.children.events.add.on(child => console.log("Child added!", child));
this.children.events.remove.on(child => console.log("Child removed!", child));
}
// 3. Current component being changed to a different parent.
onSetParent(parent:Component) {
super.onSetParent(parent);
console.log("Parent changed!");
}
}
The watch
method will start watching the model, but only when the component is enabled.
To keep watching even if the component is disabled, use watchAlways
.
Components have a lifecycle, each with their corresponding 'hooks', or methods available for override. Below are the lifecycle stages:
Called from the constructor. Is called for any Components that are created as child component. Hierarchy is created before any Component initialization stage, so note that the Component, as well as its parent, is not yet initialized.
Called from the constructor. Child component watchers are executed immediately.
After initialization, can be called by component.load()
. Starts initial asynchronous and recursive loading of all components
in the hierarchy. The onLoad
method can be overridden to do perform the initial asynchronous loading of the component.
Should return a Promise that resolves when the component is done loading.
If none of the components have any loading to do, this stage can be skipped.
After the component as loaded (or if loading is unneccesary), the component can be started with component.start()
.
This calls the onStart
method for all components in the hierarchy.
For a component to be enabled, it needs to be started, and set to be enabled. When this condition changes positively,
the onEnable
method is called on the component. If the component starts enabled
, the first call to onEnable
will
be at the end of the Start stage.
Components can be disabled, in which case they will stop watching and listening to events. When the 'enabled' state
changes negatively, the onDisable
method is called.
When a component will no longer be used, it should be destroyed (component.destroy()
). This will destroy the component
and all its child components. For each destroyed component, the onDestroy
method will be called.
Components can be enabled and disabled. Disabling a component will also disable its children. Re-enabling will only
re-enable those children that were enabled themselves. By default, enabling/disabling of components is managed by an
internal state of the component. If the model has an enabled
property, it will use that intead of the internal state,
making it easy for components in all layers to see (and change) the enabled state.
Another model property can also be chosen to represent the enabled state of the component, by setting the
this.enabledProperty
in the onInit
method. The enable
method will use this property to set the enabled state
(or use the default internal property if the given property name does not exist on the model).
Components can define and use events that can be listened to. Defining events is done as follows:
interface Vector3Interface {x:number, y:number, z:number;}
// Define a 'move' event, which payload is an object with the new position
class ObjectMovedEvent extends ComponentEvent<{position:Vector3Interface}> {}
// Define an Events class, bundling any defined events
class ObjectEvents extends ComponentEvents {
move = this.$event(ObjectMovedEvent);
}
// Place the events in the component class
class ObjectComponent extends Component {
//...
// Define the Events class this component uses
static Events = ObjectEvents;
// Override the type for the `events` property
events!:ObjectEvents;
}
You can listen to events directly:
// Use the events anywhere:
component.events.move.on(event => console.log("New position:", event.data.position));
component.events.move.fire(new ObjectMovedEvent(this, {x: 2, y: 10, z: 1}));
However, from a component, it is better to use listenTo
, as this will stop listening when the component is disabled
or destroyed:
this.listenTo(otherComponent.events.move, event => console.log("New position:", event.data.position));
Or listen to a dynamically defined event:
dynamicEvent:string = "...";
this.listentoEventName(otherComponent.events, dynamicEvent, event => console.log("Event fired!", event));
Using the Events
class, specific events of specific components can be listened to. Each component is also provided
with a shared EventBus (if created from the same ComponentFactory), allowing to listen to specific events from any
component:
class ObjectComponent extends Component {
// ...
onInit() {
super.onInit();
// Listen to move events from any other objects
this.eventBus.$on(ObjectMovedEvent, event => {
console.log("Object moved:", event.origin);
console.log("New position:", event.data.position);
});
}
move() {
// Fire the event into the eventBus, for anyone to hear
this.eventBus.$fire(new ObjectMovedEvent(this, {x: 1, y: 4, z: 2}));
}
}
To stop listening when disabled or destroyed, use listenTo
or listenToEventName
, e.g.:
this.listenTo(otherComponent.events.move, event => console.log("New position:", event.data.position));
this.listenToEventName(otherComponent.events, myEventName, event => console.log("Event fired!", event));
Components can define actions that can be called by other components. These are meant as handles for dynamically defined interaction between components. The definition of actions is similar to events. Example:
class ObjectModel extends Mozel {
@property(ObjectModel)
target?:ObjectModel; // Let's say our ObjectModel has another object targeted...
@property(String) // .. and a dynamically defined action to call on it
action?:string;
@property(Vector3, {required})
position?:Vector3;
}
// Define the actions:
class MoveAction extends ComponentAction<{position:Vector3Interface}> {}
class ObjectActions extends ComponentActions {
move = this.$action(MoveAction);
}
// Define the component:
class ObjectComponent extends Component {
static Model = ObjectModel;
model!:ObjectModel;
// Define ComponentSlot for target
@component(schema(ObjectComponent.Model).target, ObjectComponent)
target!:ComponentSlot<ObjectComponent>;
// Define actions
static Actions = ObjectActions;
actions!:ObjectActions;
onInit() {
super.onInit();
// When action is called, position should be changed
this.actions.move.on(action => {
// Move itself
this.model.position = this.model.create(Vector3, position);
})
}
callTargetAction(payload:any) {
const target = this.target.current;
if(!target) return;
// Call the currently set action on the current `target` child component
this.target.callAction(this.model.action, payload);
}
moveTarget() {
// The action to call is determined by the model, so we set the model to the move action
this.model.action = "MoveAction";
// Then we call the target action with the new position
this.callTargetAction({x: 10, y: 10, z: 10});
}
}
Sometimes, as in the case of an MCV, one component should be able to contact another component of the same model (e.g. the View can contact the Controller and/or vice-versa). One way to accomplish this is to provide the Registry of the component counterparts to each of the components, e.g. by dependency injection:
const controllerFactory = new ComponentFactory();
const viewFactory = new ViewFactory();
// Add the 'controllerRegistry' dependency and bind it to the registry of the controller factory
viewFactory.dependencies.bind("ControllerRegistry").toConstantValue(controllerFactory.registry);
// Note: `dependencies` is an [Inversify](https://inversify.io/) container.
class View extends Component {
// ...
controller!:Component;
onInit() {
super.onInit();
// Use the dependencies to get the controller registry
const controllerRegistry = this.dependencies.get("ControllerRegistry");
// Then find the controller matching the view. Since they are based on the same model, they should have the same `gid`.
this.controller = controllerRegistry.byGid(this.gid);
}
}
Although components themselves are view-agnostic and have no concept of HTML or rendering built-in, they are well-suited as the base of any type of View, allowing multiple View hierarchies to exist side-by-side.
Since components are view-agnostic, they will have to be setup to maintain the external view hierarchy. Fortunately, this can be easily achieved using hooks and watchers:
class ThreeObjectView extends Component {
static Model = ObjectModel;
model!:ObjectModel;
@components(schema(ThreeObjectView).children, ThreeObjectView)
children!:ComponentList<ThreeObjectView>;
threeObject?:THREE.Object3D;
onInit() {
super.onInit();
// Create a THREE Object3D for this view
this.threeObject = new THREE.Object3D();
// Watch the children to add/remove their THREE Object3Ds to/from this one
this.children.events.add.on(child => {
if(child.threeObject) this.threeObject.add(child.threeObject);
});
this.children.events.remove.on(child => {
if(child.threeObject) this.threeObject.remove(child.threeObject);
});
}
}
Any ThreeObjectView's threeObject
can then be used in a THREE rendering setup.
Already included in this library is a ReactView class, wrapping React Components in the View component class and recursively rendering the components in the hierarchy.
This is the basic setup (in TSX):
type Props = ReactViewComponentProps<MyView>;
type State = {};
class MyViewReact extends ReactViewComponent<Props, State> {
render() {
// We can use this.view to access the View, and `this.model` to access the model directly
return <div>
Name: {this.model.name} <br/>
Children: <br/>
{
// go through a ComponentList and render any ReactViews in the list
this.renderChildren(this.view.children)
}
</div>
}
}
export default class MyView extends ReactView {
static Model = MyModel;
model!:MyModel;
@components(schema(MyView.Model).children, ReactView)
children!:ComponentList<ReactView>;
getReactComponent():typeof React.Component {
return MyViewReact as typeof React.Component;
}
}
By default, ReactViewComponents will update when any of its model's direct properties change, as defined in onInitWatchers
:
this.watch('*', () => {
this.forceUpdate();
});
To change this behaviour, override the onInitWatchers
method to add watchers. Leave out super.onInitWatchers
to
prevent watching all direct properties for changes.