oldskull
v2.0.0
Published
Front-end framework powered by TS, OOP and MVC/MVP
Downloads
6
Readme
Old Skull Framework
New front-end framework for old software developers. 🧙♂️
Features:
- Written in TypeScript (strict mode) using OOP
- Implements Model-View-Presenter architecture
- Relies on Observer design pattern for communication
- Smaller and faster than mainstream frameworks
- Fully covered by documentation
- Provides direct access to DOM
- Has no dependencies
Contents
Motivation
Modern front-end frameworks have a tendency to isolate developers from native platform and impose its own unique way of UI development that completely locks people in their isolated ecosystems.
For example they:
Provide new syntax not compatible with language specification
Hide high-level platform abstraction with even more high-level abstraction
Replace successful well-known paradigms with weird and unobvious one
As a result we have to deal with fat and slow SPA developed by people who don't know how their application platform works and who aren't exposed to common computer science knowledge.
Old Skull Framework is developed in a different way, it:
Hides nothing from a developer
Uses well-known terms, design patterns and paradigms
Open for learning, changing and extension
Overview
As was mentioned before oldskull
implements Model-View-Presenter architecture.
It means UI logic is always divided into three loosely coupled parts:
- Model stores and manages data
- View displays that data and handles user input
- Presenter acts as an intermediary between them
This architecture is implemented as a set of classes: OsfView
,
OsfModel
, OsfModelView
, OsfCollection
, OsfCollectionView
and
OsfPresenter
OsfView
creates and manages DOM structures:
import { OsfView } from 'oldskull';
class TaskView extends OsfView {
getHTML() {
return `
<div class="task">
<h2>Example task</h2>
<button class="btn btn-complete">
Complete
</button>
</div>
`;
}
domEvents = [
{
el: '.btn-complete',
on: 'click',
call: this.markAsCompleted.bind(this),
},
];
markAsCompleted() {
// ...
}
}
const taskView = new TaskView();
await taskView.init();
OsfModel
wraps data and implements business logic:
import { OsfModel } from 'oldskull';
interface ITask {
id: number;
name: string;
isCompleted: boolean;
}
class TaskModel extends OsfModel<ITask> {
switchStatus() {
// ...
}
}
const taskModel = new TaskModel({
id: 1,
name: 'Go for a walk',
isCompleted: false,
});
OsfModelView
is a OsfView
that's able to display a Model and handles its events:
import { OsfModelView, MODEL_CHANGED_EVENT } from 'oldskull';
class TaskView extends OsfModelView<TaskModel> {
getHTML() {
const task = this.model.attrs;
return `
<div class="task">
<h2>${ task.name }</h2>
</div>
`;
}
modelEvents = [
{
on: MODEL_CHANGED_EVENT,
call: this.handleModelChange.bind(this)
},
];
handleModelChange() {
// ...
}
}
const taskView = TaskView(taskModel);
await taskView.init();
OsfCollection
is just a set of Models that can be rendered by OsfCollectionView
:
import { OsfCollection, OsfCollectionView } from 'oldskull';
class TaskCollection extends OsfCollection<TaskModel> {
// Nothing's here for now
}
const tasks = new TaskCollection([
new TaskModel({ id: 1, name: 'Do this', isCompleted: false }),
new TaskModel({ id: 2, name: 'Do that', isCompleted: false }),
]);
class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
constructor(collection: OsfCollection<TaskModel>) {
super(collection, TaskView, NoTasksView);
}
getHTML() {
return '<div class="tasks"></div>';
}
}
const taskListView = new TaskListView(tasks);
await taskListView.init();
OsfPresenter
creates and manages a Model/View pair:
import { OsfPresenter } from 'oldskull';
class TaskPresenter extends OsfPresenter<TaskModel, TaskView> {
model = new TaskModel();
view = new TaskView(this.model);
viewEvents = [
{
on: 'completed',
call: this.handleViewCompleted.bind(this),
},
];
modelEvents = [
{
on: 'change isCompleted',
call: this.handleModelStatusChange.bind(this),
},
];
async beforeInit() {
// Model initialization here
}
handleViewCompleted() {
// Update a value in Model
}
handleModelStatusChange() {
// Call a View method that updates displayed status
}
}
const taskPresenter = new TaskPresenter();
await taskPresenter.init();
Below you will find detailed documentation on all mentioned classes and a few others that allow you to:
- Define application entry point:
OsfApplication
- Nest and switch Views:
OsfRegion
- Trigger and listen custom events:
OsfObservable
See also:
Installation
yarn: yarn add oldskull
npm: npm install oldskull --save
It's highly recommended to import all the oldskull
entities inside of
your project and re-export them for internal use:
// ./utils/framework/index.ts
import {
OsfRenderable,
IOsfRenderable,
OsfObservable,
IOsfObservable,
OsfView,
IOsfView,
OsfModel,
IOsfModel,
OsfModelView,
IOsfModelView,
OsfCollection,
IOsfCollection,
OsfCollectionView,
IOsfCollectionView,
OsfPresenter,
IOsfPresenter,
OsfApplication,
OsfRegion,
OsfReference,
OsfInsertPosition,
ALL_EVENTS,
MODEL_CHANGED_EVENT,
MODEL_ADDED_EVENT,
MODEL_REMOVED_EVENT,
COLLECTION_RESETED_EVENT,
} from 'oldskull';
export {
OsfRenderable as Renderable,
IOsfRenderable as IRenderable,
OsfObservable as Observable,
IOsfObservable as IObservable,
OsfView as View,
IOsfView as IView,
OsfModel as Model,
IOsfModel as IModel,
OsfModelView as ModelView,
IOsfModelView as IModelView,
OsfCollection as Collection,
IOsfCollection as ICollection,
OsfCollectionView as CollectionView,
IOsfCollectionView as ICollectionView,
OsfPresenter as Presenter,
IOsfPresenter as IPresenter,
OsfApplication as Application,
OsfRegion as Region,
OsfReference as Reference,
OsfInsertPosition as InsertPosition,
ALL_EVENTS,
MODEL_CHANGED_EVENT,
MODEL_ADDED_EVENT,
MODEL_REMOVED_EVENT,
COLLECTION_RESETED_EVENT,
};
This way you can always easily extend or override functionality of core classes afterwards without messing with each and every child class definition.
Classes
Observable
OsfObservable
class implements Observer design pattern.
Objects of this class can trigger events with optional payload, other objects can listen and handle those events.
import { OsfObservable } from 'oldskull';
const NEW_LETTER = 'NEW_LETTER';
class Mailbox extends OsfObservable {
add(letter: string) {
this.trigger(NEW_LETTER, letter);
}
}
const mailbox = new Mailbox();
mailbox.on(NEW_LETTER, (data: unknown) => {
const letter = <string>data;
console.log('New letter: ', letter);
});
Almost all oldskull
classes already extend OsfObservable
and able to trigger events.
An event can be triggered with or without a payload of any type:
this.trigger('eventName');
this.trigger('eventName', {
// ...
});
this.trigger('eventName', 'eventDescription');
To add an event handler use on
method:
function eventHandler(payload: unknown) {
// ...
}
somethingObservable.on('eventName', eventHandler);
To remove an event handler use off
method:
somethingObservable.off('eventName', eventHandler);
There is also a special method that simplifies the case when observable object needs to retrigger an event from other observable:
// in a method of custom observable class
somethingObservable.on(ALL_EVENTS, this.retrigger.bind(this));
Retriggering is often happens in OsfCollectionView
(described below)
to pass events from child Views to proper handler in a parent:
class ExampleCollectionView extends OsfCollectionView {
// ...
viewEvents = [
{ on: ALL_EVENTS, call: this.retrigger.bind(this) },
];
}
Renderable
OsfRenderable
is a base class for all renderable entities like
OsfView
, OsfModelView
, OsfCollectionView
, OsfPresenter
and
OsfApplication
.
Classes that extend it have:
el
property containing created HTML Elementasync init()
method that creates and initializesel
.Hook methods
beforeInit()
andafterInit()
that are called once on firstinit()
call.remove()
method that removesel
from DOM end performs finalization.Hook methods
beforeRemove()
andafterRemove()
that are called on eachremove()
call.
By default all hooks methods does nothing and meant to be overwritten by child classes:
class MyView extends View {
// ...
async beforeInit() {
// right before this.el creation and initialization
}
async afterInit() {
// right after this.el creation and initialization
}
addOpacity() {
this.el?.style.opacity = 0.5;
}
async beforeRemove() {
// right before this.el removed
}
async afterRemove() {
// right after this.el removed
}
}
// Create Element
const myView = new MyView();
await myView.init();
// Do something with it
document.body.append(myView.el);
// And remove it when it's no longer needed
myView.remove();
View
OsfView
class creates and manages DOM structures.
To define a desired DOM structure implement getHTML()
method returning HTML string:
import { OsfView } from 'oldskull';
class GreetingView extends OsfView {
getHTML() {
return `
<div class="greeting">
<h1>Hello</h1>
<p>How are you?</p>
</div>
`;
}
}
Any view has init()
/remove()
methods, el
property with created
Element and lifecycle hooks. See OsfRenderable
class for details.
By default View presumes it renders a general Element but if you render something different you can always specify a subtype using generics:
class ExampleInputView extends OsfView<HTMLInputElement> {
getHTML(): string {
return `<input type="text" value="hello">`;
}
}
const view = new ExampleInputView();
await view.init();
view.el?.value === 'hello' // true, no type error
Views can listen to DOM events and handle them:
class HeaderView extends OsfView {
getHTML() {
return `
<div class="header">
<button class="btn-logout">
Log out
</button>
</div>
`;
}
domEvents = [
{
// omit "el" to add event handler to the root element
el: '.btn-logout',
on: 'click',
call: this.handleLogout.bind(this),
},
];
handleLogout(event: Event) {
// ...
}
}
Views should be responsible only for UI displaying and user input handling. Data management and any other kind of business logic should happen outside.
Model
OsfModel
class encapsulates your data and provides common API for its management.
Preferable way of defining Models is class inheritance:
import { OsfModel } from 'oldskull';
interface ITask {
id: number;
name: string;
}
class TaskModel extends OsfModel<ITask> {
// Implement task specific methods here
// or leave it empty for now
}
const taskModel = new TaskModel({
id: 1,
name: 'Example task',
});
You can avoid class creation and rely on generics but this way you will lose an important ability to add common Model functionality without mass code refactoring:
const task = new OsfModel<ITask>({
id: 1,
name: 'Example task',
});
If you want to be able to create Model instances with default values you can overwrite Model constructor:
class TaskModel extends OsfModel<ITask> {
constructor(attrs?: ITask) {
super();
this.attrs = attrs || {
id: 0,
name: '',
};
}
}
const taskModel = new TaskModel();
Model attributes (this.attrs
) is our data managed by the Model.
You can read directly from it but must avoid writing to it:
const taskModel = new TaskModel();
console.log(`Task id is `, taskModel.attrs.id);
To modify attributes you should use set()
or setAttribute()
methods
that automatically trigger Model change events with model entity
as a payload:
const taskModel = new TaskModel();
// Handle all model changes
taskModel.on('change', (data: unknown) => {
const taskModel = <TaskModel>data;
console.log('The task was changed:', taskModel);
});
// Handle only "name" attribute changes
taskModel.on('change name', (data: unknown) => {
const taskModel = <TaskModel>data;
console.log('The task name was changed:', taskModel.attrs.name);
});
// Update all attribute values
taskModel.set({ id: 1, name: 'First task' });
// Update single attribute value
taskModel.setAttribute('name', 'foobar');
When you're manually adding event handlers
(not via modelEvents
/viewEvents
)
don't forget to remove them later, not doing so can cause memory leaks.
Instead of writing 'change'
string each time you can use
MODEL_CHANGED_EVENT
variable with the same value:
import { MODEL_CHANGED_EVENT } from 'oldskull';
taskModel.on(MODEL_CHANGED_EVENT, (data: unknown) => {
// ...
});
If you plan to use a Model inside a Collection you should implement
getId()
method that returns unique id of the entity:
class TaskModel extends OsfModel<ITask> {
getId(): number {
return this.attrs.id;
}
}
ModelView
OsfModelView
is an extended OsfView
that's able to display Model attributes:
import { OsfModelView } from 'oldskull';
class TaskView extends OsfModelView<TaskModel> {
getHTML() {
const task = this.model.attrs;
return `
<div class="task">
<h2>${ task.name }</h2>
</div>
`;
}
}
Make sure you perform XSS sanitization of untrusted fields using DOMPurify or similar tools.
ModelView also can listen and handle Model events:
import { OsfModelView, MODEL_CHANGED_EVENT } from 'oldskull';
class TaskView extends OsfModelView<TaskModel> {
getHTML() {
// ...
}
modelEvents = [
{
on: MODEL_CHANGED_EVENT,
call: this.handleModelChange.bind(this),
},
];
handleModelChange() {
// ...
}
}
This way of model event handling is fine but use of OsfPresenter
is preferable.
Collection
OsfCollection
class encapsulates a set of Models and provides an API
for its management.
import { OsfCollection } from 'oldskull';
class TaskCollection extends OsfCollection<TaskModel> {
// Nothing's here for now
}
const taskCollection = new TaskCollection([
new TaskModel({ id: 1, name: 'Do this'}),
new TaskModel({ id: 2, name: 'Do that'}),
]);
Collections as child classes may look unnecessary at first but it becomes very useful when you start to implement entity-specific logic like loading items from server.
You can add and remove Models from a Collection and handle those events:
const taskCollection = new OsfCollection<TaskModel>();
taskCollection.on(MODEL_ADDED_EVENT, (payload: unknown) => {
const task = <TaskModel>payload;
console.log('Task was added:', task);
});
taskCollection.on(MODEL_REMOVED_EVENT, (payload: unknown) => {
const task = <TaskModel>payload;
console.log('Task was removed:', task);
});
taskCollection.on(COLLECTION_RESETED_EVENT, () => {
console.log('All tasks was replaced');
});
// Add a model
taskCollection.add(taskModel0);
// Add a set of tasks
taskCollection.add([ taskModel1, taskModel2, taskModel3 ]);
// Remove a model by id
taskCollection.remove(1);
// Set array of models
// Already stored taskModel3 will be merged with provided
// Other models will be removed
taskCollection.set([taskModel3]);
// Reset array of models
// Skips merging and triggering model removal events
// Just overwrites Model array and triggers reset event
taskCollection.set([taskModel2]);
You can get specific Model from Collection by providing a Model id:
const taskModel = taskCollection.get(2);
It's possible to access Models directly by using models
property
but try to avoid it when possible:
const taskModel =
taskCollection.models.find(model => model.attrs.id === 2);
CollectionView
OsfCollectionView
class is a View that creates a container Element and
inside of it renders Models from a Collection using provided
OsfModelView
.
Default container element is <div></div>
but you can change it by
overwriting getHTML()
method:
import { OsfCollectionView } from 'oldskull';
class TaskListView extends OsfCollectionView<TaskModel, TaskView> {
constructor(collection: OsfCollection<TaskModel>) {
super(collection, TaskView);
}
getHTML(): string {
return '<div class="tasks"></div>';
}
}
const taskListView = new TaskListView(tasks);
await taskListView.init();
If a Collection has no Models then other View called "EmptyView" can be rendered instead:
import { OsfCollectionView } from 'oldskull';
class NoTasksView extends OsfView {
getHTML(): string {
return '<p>No tasks</p>';
}
}
class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
constructor(collection: OsfCollection<TaskModel>) {
super(collection, TaskView, NoTasksView);
}
}
ModelViews can be rendered inside of specified child element instead of
root element by defining childViewContainer
property with an OsfReference
:
class TaskListView extends OsfCollectionView<TaskModel, TaskView> {
// ...
getHTML(): string {
return `
<div class="tasks">
<h2>
Tasks
</h2>
<div class="task-list">
</div>
</div>
`;
}
childViewContainer = new OsfReference(this, '.task-list');
}
CollectionView can listen to events from a Collection and child Views:
class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
collectionEvents = [
{
on: MODEL_ADDED_EVENT,
call: this.addChildView.bind(this),
},
];
viewEvents = [
{
on: 'completed',
call: this.handleViewCompleted.bind(this),
},
];
handleViewCompleted() {
// ...
}
}
Set filterFunc
and sortFunc
properties on a CollectionView to filter
and sort models before initial rendering:
class TaskListView extends OsfCollectionView<TaskModel, TaskView, NoTasksView> {
filterFunc = (models) => models.filter((m) => m.attrs.name !== '');
sortFunc = (models) => models.reverse();
}
There are three methods for managing Views inside a CollectionView:
addChildView(modelOrArrayOfModels)
adds a ModelView that renders provided Model(s)removeChildView(modelId)
removes a ModelView that renders a model with specified idremoveAllChildViews()
removes all rendered ModelViews
You can specify a position where you want to append new ModelView using
optional parameters of addChildView
method:
import { OsfInsertPosition } from 'oldskull';
// Insert at the beginning of a list
await view.addChildView(modelTwo, OsfInsertPosition.Beginning);
// Insert at the begging
await view.addChildView(modelOne, OsfInsertPosition.Before, modelTwo);
// Insert at the begging
await view.addChildView(modelThree, OsfInsertPosition.After, modelTwo);
// Insert at the end of a list
await view.addChildView(modelFour, OsfInsertPosition.End);
Simple CollectionView can be instantiated without definition of child class but this approach in general should be avoided because it makes your code less maintainable:
const taskListView = new OsfCollectionView(collection, TaskView, NoTasksView);
Presenter
OsfPresenter
class is responsible for creation and initialization of a
Model/View pair and handling of their events:
import { OsfPresenter } from 'oldskull';
class TaskPresenter extends OsfPresenter<TaskModel, TaskView> {
model = new TaskModel();
view = new TaskView(this.model);
viewEvents = [
{
on: 'completed',
call: this.handleViewCompleted.bind(this),
},
];
modelEvents = [
{
on: 'change isCompleted',
call: this.handleModelStatusChange.bind(this),
},
];
async beforeInit() {
// Model initialization here
}
handleViewCompleted() {
// Update a value in Model
}
handleModelStatusChange() {
// Call a View method that updates displayed status
}
}
const taskPresenter = new TaskPresenter();
await taskPresenter.init();
Presenter class extends OsfRenderable
so you can make use of
lifecycle methods like beforeInit
/afterInit
to initialize
Model attributes and perform other necessary actions that are out
of Model/View scope.
Reference
OsfReference
is used in Views to get access to nested DOM Elements
that was rendered by them.
import { OsfReference } from 'oldskull';
class LayoutView extends OsfView {
getHTML() {
return `
<div class="root">
<div class="header"></div>
<div class="content"></div>
<div class="footer"></div>
</div>
`;
}
content = new OsfReference<HTMLElement>(this, '.content');
afterInit() {
const contentEl = this.content.get();
contentEl.classList.add('loaded');
}
}
To create a Reference just pass a View and a CSS selector of the needed element to the constructor and specify referenced element type in generic.
Actual Element can be obtained by get()
call.
Region
OsfRegion
allows to render Views and Presenters inside of specified
DOM Element that was rendered by the View:
import { OsfRegion } from 'oldskull';
class LayoutView extends OsfView {
getHTML() {
return `
<div class="page">
<div class="header"></div>
<div class="content"></div>
<div class="footer"></div>
</div>
`;
}
headerRegion = new OsfRegion(this, '.header');
contentRegion = new OsfRegion(this, '.content');
footerRegion = new OsfRegion(this, '.footer');
async afterInit() {
await this.headerRegion.show(new HeaderView());
await this.contentRegion.show(new ArticlesPresenter());
await this.footerRegion.show(new FooterView());
}
}
Region's constructor accepts a View and CSS selector of an Element where is should be attached.
Region itself provides only two methods:
show(viewOrPresenter)
to display a renderable itemempty()
to remove what's currently rendered in the region
Application
OsfApplication
is a skaffold for an application entry point.
import { OsfApplication } from 'oldskull';
export class MyApp extends OsfApplication {
async init() {
await this.mainRegion.show(new TaskListPresenter());
}
}
const app = new MyApp('#root');
app.init();
It creates a mainRegion
on the Element found by provided CSS selector
and expects you to implement init()
method that performs application start.
For more thorough example see index.ts from oldskull-realworld.
Usually initialization logic sets up:
- Router
- Global error handler
- Custom logger
- Page layout
FAQ
Can it be used in production?
Not yet.
Right now it's more like public beta so breaking changes still may appear based on initial feedback.
Does it support server-side rendering?
Not yet.
Draft SSR implementation showed that there is no way to implement it in a more or less appropriate manner without dirty hacks, performance penalties and code quality deterioration.
If you know how to make it possible without mentioned drawbacks feel free to tell us.
Are there UI kits for it?
Not yet.
For now you can use CSS frameworks that apply styles to common elements via global styles or class usage.
If you plan to implement your own UI kit we recommend to try to make it in a framework-agnostic way so it could be used without any framework or with any other framework that doesn't hide DOM access.
Isn't it just a Marionette.js clone?
Well, it is. But at least it doesn't depend on Backbone/Underscore/JQuery and written in TypeScript that's much more suitable for SPA development.