Click here to Skip to main content
14,879,626 members
Articles / Programming Languages / Javascript
Article
Posted 22 Apr 2020

Stats

14.9K views
3 bookmarked

Introducing MVVM Architecture in JavaScript (TypeScript Edition)

Rate me:
Please Sign up or sign in to vote.
5.00/5 (3 votes)
27 Apr 2020MIT23 min read
The project explains MVVM design pattern implementation in JavaScript front-end application.
The small popularity of MVP and MVVM in favor of MVC architecture is caused by many factors. It is easy to start from any JavaScript and then evolve to MVC. More efforts would be required to build better MVP architecture. And much more practice and efforts would be required to build MVVM. Despite that fact, the MVVM architecture is gaining its popularity on mobile platforms as native applications. It is a rare subject for WEB applications.

Introduction

The detailed discussion about data binding is in my previous article, Two-Way Data Binding in Pure JavaScript.

When it comes to creating an application, the most important part comes with making a decision about developing language, tech stack, architecture patterns. When it comes to making a WEB application - the language choice is JavaScript or TypeScript. Back in time, JavaScript was treated as a weak programming language. That puts a mark on tech stack libraries. Mostly, all good libraries and utilities are around the MVC architecture. Less for MVP and much more less for MVVM. In practice, while actively gaining popularity on mobile platforms, MVVM for a WEB platform is completely ignored.

Data like particles and functions like waves, it is easy to extract data from waves and much harder to gather all particles into a function.

The small popularity of MVP and MVVM in favor of MVC architecture is caused by many factors. It is easy to start from any JavaScript and then evolve to MVC. More efforts would be required to build better MVP architecture. And much more practice and efforts would be required to build MVVM. Despite that fact, the MVVM architecture is gaining its popularity on mobile platforms as native applications. It is a rare subject for WEB applications.

MVVM is a Game-Changer

I believe MVVM architecture (it is just a design pattern) is capable of constructing middle and big applications. Especially when it comes to modern WEB technologies. Sooner or later, MVVM design will emerge on the WEB. With this article, I would like to start the fashion.

Let's tear MVVM design pattern into parts, and discuss every layer, its participants, and try to build a full working MVVM draft. Starting with the requirements, usually, it is about making ToDo application. Tech stack - vanilla JavaScript and WEB API. No more libraries. To accomplish many repeating tasks, the utility library will be created for this draft as well. In MVVM, a communication between Views and ViewModels goes over data binding technique. That will be covered as well. Communication with the backend will be simulated over an Adapter design pattern. Inversion of control (IoC) will be minimally mentioned.

Ultimate Layers in the Draft MVVM

The data transfer layer would keep everything that is related to a communication between a WEB application and a backend. Any ajax, fetch, XMLHttpRequest and anything other that is related to requesting and transfer data from/to the backend will be stored in this layer. Rules - Adapter design pattern. Data types, simple types that will work with JSON.stringify. In the draft, the communication with the backend will be simulated.

The Model layer would keep fetched data, will provide data to be pushed to a backend. In this layer, all that application can read or write to the backend will be stored in the form that is easy to use within the solution. All data that should be modified for backend communication interfaces will be adjusted in this layer as well. Rules - Model, the first letter of the MVVM design pattern. A very small part of the business requirements could be there. Just as draft version, with the excuse that it will be moved to a third layer latter.

The ViewModel layer would play an important role in the business logic of the application. Everything that would be related to implementing the requirements will be there. In this layer, communication between Model and View will be more intensive. Rules - ViewModel, the last two letters in MVVM design pattern. The layer would build an important set of core commands that would implement the logic of the application. For the small and middle applications, the separation between Models and ViewModels could be minimal, organized as it comes during development. For the big application, that could not be enough. It would be good to plan better separation between ViewModel and Model. Unit Of Work could help to build an extra layer that would allow to group core commands and provide more space to implement more requirements.

The View layer is the visible shape of the application. It is a precious UI that would allow clients to effectively use the application. That part is the ultimate target for many unexpected changes. Clients often would like to discuss the UI. Especially if clients will like the application, better UI they will require. It would be an entry point to all business workflows that are required and requested to implement. In this layer, all communication between ViewModels will be over data binding rules. That would allow us to produce a good separation from the ViewModel. Rules - View, the second letter in the MVVM design pattern, command - the command in MVVM design pattern. Partial requirement implementation that is related to the UI will be there.

There are two kinds of Views, passive and active views. Passive views are views that don't play an active role in picking up the right view model, just read state and update UI accordingly. Active views could pick up the right view models and read state. With the small and middle applications, it would be a great idea to keep passive views. That means View would never know anything about ViewModel. It would just bind and unbind to/from the provided ViewModel. The association of the View and ViewModel is configured over IoC setup. When clients will like the solution, there will come a day when a requirement could appear to build "impossible" UI/views. The requirement could be something like that: with the given core solution I (as the client) would like to build my own UI. The simple solution could be to replace all views with commands API, according to the Facade design pattern. That could be a tough period for the application with active Views. Every logic that is responsible to make a decision about the right view logically should be extracted and moved out of the Facade. If left, that could make a pain to maintain such a solution. In the case of passive Views, the commands API can be introduced as another View Layer with adjusting to the right ViewModels over IoC setup. Ideally... I think so... maybe... IMHO...

Utilities, Versatile Tools

Invisible to clients but very important to developers. The perfect tech stack, utilities that do all magic to exclude code repeating, long methods names, probably introducing new ways of development, that keep imaginative app developing person to never stop and look up for better libraries for work on the next "impossible" mission. The set that as minimal and effective, would help to resolve important tasks with less pain, sweat, and tears.

Commands in MVVM

Commands are defined in ViewModel. Commands are for anything that is out of fetch and display data. It is another small sub-layer in separation between Views and ViewModels. If the task is about to update state on the ViewModel, transfer value from input in UI to the backend, update or refresh UI with new data, a command is a good entry point to run such task. Besides exec method, the command can have more methods such as canExecute or inProgress. The first is to enable/disable buttons on UI. The second one is to implement an async loading progress overlay on UI.

Communication with Backend

It will be organized in a way to keep data transfer types as they come from remote service endpoints. With no conversion to types that will be transferred. Just one rule - convenience to receive and transfer data. Methods that just repeat backend service endpoints. With this approach, it would be easy to investigate issues that are backend related, locate all used endpoint path names, technologies that are used to transfer/receive data. Backend data contact can change with time. In this case, all data will be kept as it is, though, if something will be changed, it would be easy to determine the difference and adjust application.

In the draft version, the backend communication will be simulated by the following "Adapter":

JavaScript
export interface ITodoItem {
    id: number;
    title: string;
    complete: boolean;
}
const data = [{
}];
let index = data.length + 2;

class TodosAdapter {

    fetchTodos() {
        return new Promise<ITodoItem[]>((resolve) => {
            setTimeout(() => {
                const result = 
                   [...data.sort((l, r) => l.id > r.id ? -1 : l.id < r.id ? 1 : 0)];
                utils.text(utils.el('.console'), JSON.stringify(result, null, 2));
                resolve(result);
            }, 200);
        });
    }

    createTodo(title) {
        return new Promise<boolean>((resolve) => {
            setTimeout(() => {
                data.push({
                    id: index++,
                    title: title,
                    complete: false
                });
                resolve(true);
            }, 200);
        });
    }

    updateTodo(id, attrs) {
        return new Promise<boolean>((resolve, reject) => {
            setTimeout(() => {
                const item = utils.find(data, i => i.id === id);
                if (item) {
                    Object.assign(item, attrs);
                    resolve(true);
                } else {
                    reject(new Error
                          (`Can't update. "todo" task with id: ${id} was not found`));
                }
            });
        });
    }

    deleteTodo(id) {
        return new Promise<boolean>((resolve, reject) => {
            setTimeout(() => {
                const item = utils.find(data, i => i.id === id);
                const index = data.indexOf(item);
                if (item) {
                    data.splice(index, 1);
                    resolve(true);
                } else {
                    reject(new Error
                          (`Can't delete. "todo" task with id: ${id} was not found`))
                }
            });
        });
    }
}

It doesn't matter how well the Adapter will be programmed. It could be a well-defined solution with generalized fetch and convert methods or it could be just copy-pasted chunks of code that just do data transfer. Matters just public methods, their signatures, and the results they provide.

IoC - Inversion of Control

In MVVM, many Views can be bound to a one ViewModel. That smells like the singleton design pattern - current(inst). One useful approach is to separate Views from ViewModels. There, IoC setup could be a good solution. It would allow us to keep all composition between Views and ViewModels in a single place. The IoC setup could grow with the future. Though it would be scary to watch inside of it. However, it would be just one place, instead of spreading between many smart views.

The small IoC:

JavaScript
MainView.prototype.getViewModel = function () {
    return current(MainViewModel);
} 

There are more candidates for IoC setup. It is a relation between a TodosModel and Adapter. Relation between TodosModel and TodoViewModelItem. TodosModel and TodoMainViewModel.

Base Class for ViewModels and Maybe Views

It is expected to actively communicate between Views, Models, and ViewModels, let's think about a central point. It would be convenient to have one Base class that all those guys will inherit. From there, it would be possible to provide more functionalities to all participants without updating every class. With that approach, there will be a default public accessor for the internal state of the object. Although event delivering entry point - on and trigger methods. In the real world, there could be a Base class already defined. That's why I keep on and trigger methods separated from the base implementation. In regards to the base class, it is an adjustable solution. And since it is base class would be great to keep the events listed in a clear way and easily located. An object constructor can serve that purpose. I'm going to initialize all events from the parent class constructor. There is a prop('<propName>', <value>) method. In order to not produce many getter/setter methods, that may be not needed, it would be great to have the default public accessor for the internal objects state.

Something like that - declare events within child object constructor:

JavaScript
constructor() {
    super(
        'change:title',
        'change:items'
    );
    ...
}

Below is the final implementation of the Base class:

JavaScript
function dispatcher() {

    const handlers = [];

    return {
        add(handler) {
            if (!handler) {
                throw new Error('Can\'t attach to empty handler');
            }
            handlers.push(handler);

            return function () {
                const index = handlers.indexOf(handler);
                if (~index) {
                    return handlers.splice(index, 1);
                }
                throw new Error('Ohm! Something went wrong with 
                                 detaching unexisting event handler');
            };
        },

        notify() {
            const args = [].slice.call(arguments, 0);
            for (const handler of handlers) {
                handler.apply(null, args);
            }
        }
    }
}

function initEvents(...args) {
    const events = {};
    for (const key of args) {
        events[key] = dispatcher();
    }
    return {
        on(eventName, handler) {
            return events[eventName].add(handler);
        },
        trigger(eventName) {
            events[eventName].notify();
        }
    };
}

class Base<S = {}> {
    state: S;

    constructor(...args: string[]) {
        const events = initEvents(...args);

        this.on = events.on;
        this.trigger = events.trigger;
    }

    on(eventName, handler) {
        throw new Error('Not implemented');
    }

    trigger(eventName) {
        throw new Error('Not implemented');
    }

    prop<K extends keyof S>(propName: K, val?: S[K]): S[K] {
        if (arguments.length > 1 && val !== (this.state as any)[propName]) {
            (this.state as any)[propName] = val;
            this.trigger('change:' + propName);
        }

        return this.state[propName];
    }
}

Code that Shines (utils)

Let's not code but just give a thought to the shape of the library, methods and their signatures. To make life easier, the solution will be based on HTML templates with no transformations. html(html?) - A tool that would help to apply HTML from a template to DOM elements. After HTML is rendered, there should be a tool to pick up elements from the DOM - something like that: el('.selector', <document/element>). Since it is a todo list, the list would have items. There will be a task to change attributes and text of list item elements: attr('<name>', <value>) and text(<el>, '<text>'). Totally, it would require removing, appending items from the todo list. There is a good chance to have remove(<element>). The solution will contain data bindings, which means, HTML wouldn't be redrawn, the list items will be refreshed. All items in the list will just update its data when the list item will change. Each todo task would have states: active and complete. That will be a nice addition to see how MVVM will cope with filtering by active/completed tasks. To make such smart manipulations, there should be more tools: find(<items>, <fn>), filer(<items>, <fn>), map(<items>, <fn>), last(<items>, <from>), first(<items>, <n>). Last but not the least, listen to DOM events: on, trigger.

Here is the example of "utils" library:

JavaScript
const instances = new WeakMap();

export function current<T extends {}, O extends {}>(
    ctor: { new (...args): T },
    options?: O
): T {
    if (instances.has(ctor)) {
        return instances.get(ctor);
    }
    const inst = new ctor(options);
    instances.set(ctor, inst);

    return inst;
}

export function html(el, html?: string) {
    if (arguments.length > 1) {
        el.innerHTML = html;
    }
    return el.innerHTML;
}

export function el(selector, inst?) {
    inst = inst || document;
    if (!selector) {
        return null;
    }
    if ('string' === typeof selector) {
        return inst.querySelector(selector);
    }
    return selector;
}

export function attr(el, name, val?) {
    if (arguments.length > 2) {
        el.setAttribute(name, val);
    }
    return el.getAttribute(name);
}

export function text(el, text?) {
    if (arguments.length > 1) {
        el.innerText = text;
    }
    return el.innerText;
}

export function remove(el) {
    el.parentNode.removeChild(el);
}

export function on(inst, selector, eventName, fn) {
    const handler = function (evnt) {
        if (evnt.target.matches(selector)) {
            fn(evnt);
        }
    }
    inst.addEventListener(eventName, handler);
    return function () {
        inst.removeEventListener(eventName, handler);
    }
}

export function trigger(el, eventName) {
    el.dispatchEvent(new Event(eventName, { bubbles: true }));
}

export function getResult(inst, getFn) {
    const fnOrAny = getFn && getFn();
    if (typeof fnOrAny === 'function') {
        return fnOrAny.call(inst);
    }
    return fnOrAny;
}

export function find<T>(items: T[], fn: (item: T) => boolean) {
    for (const item of items) {
        if (fn(item)) {
            return item;
        }
    }
    return null;
}

export function filter<T>(items: T[], fn: (item: T) => boolean): T[] {
    const res = [] as T[]
    for (const item of items) {
        if (fn(item)) {
            res.push(item);
        }
    }
    return res;
}

export function map<T, Y>(items: T[], fn: (item: T) => Y): Y[] {
    const res = [] as Y[];
    for (const item of items) {
        res.push(fn(item));
    }
    return res;
}

export function last<T>(items: T[], from = 1): T[] {
    const length = items.length;
    return [].slice.call(items, from, length);
}

export function first<t>(items: T[], n = 1) {
    return [].slice.call(items, 0, n);
}

The shape of the library is adjusted.

Three Pillows of MVVM

Now we are opening Pandora's box...

The TodoModel (model) will reflect the behavior of the todo list. For that case, there should be a way to keep todo tasks, create new todo from a title, update todo and delete todo. The model will request and provide POKO objects to/from backend over the Adapter layer. It will inform participants about the changed state. Keep all fetched data from the backend in the internal state. It will just hold received data. Then data will be passed to ViewModel over events. It should be simple enough to not mess with every item. For the production version, it should deal with paged data, oauth access token, probably partially update state and inform about particular item change.

The tough question is should a model decide about updating its state (fetch data) when required or allow ViewModel to decide when needs? Let's assume that this.fetch() will be called from within the model when it is needed, e.g., when it was called createTodo method. The state of the backend is altered. One item added. And it would be logical to call this.fetch(). It would assure that data will be fresh and the model will inform about the state change. It is convenient and it is a restriction. It would be wise to give a second thought. It could lead to unexpected multiple requests to the backend. For instance, in this solution, the updateTodo will be called from two places. First place - it is when changing the title of the todo task item. The updateTodo will be called just once. It is safe to call this.fetch() from the model. And the second place is when to mark as complete all items. Sort of bulk update. The updateTodo will be evaluated for every item. That could lead to multiple calls of this.fetch() method. Not a pleasant thought, realizing that it will produce as many fetch requests to the backend as the number of items was updated. Now, let's assume the model will be less restrictive and just inform about internal state change. It would be expected that calling this.fetch() will be triggered out of the model when really needed. Then it would resolve the problem of the bulk update from one side. From another side, it would always require to supply every altering of the backend's state method to call this.fetch() method in the end. I like to be less restrictive, though, let's keep passive behavior and let ViewModels make the decision.

The implementation of the ToDo Model:

JavaScript
class TodosModel extends Base {
    static inst = null as TodosModel;
    static instance() {
        if (TodosModel.inst === null) {
            TodosModel.inst = new TodosModel();
            TodosModel.inst.fetch();
        }

        return TodosModel.inst;
    }
    adapter = new TodosAdapter();
    items = [] as ITodoItem[];

    constructor() {
        super('change:items');
    }

    getItems() {
        return this.items;
    }

    setItems(val) {
        if (this.items !== val) {
            this.items = val;
            this.trigger('change:items');
        }
    }

    async fetch() {
        const items = await this.adapter.fetchTodos();
        this.setItems(items);
    }

    createTodo(title) {
        return this.adapter.createTodo(title);
    }

    updateTodo(item: ITodoItem) {
        const { id, ...attrs } = item;
        return this.adapter.updateTodo(id, attrs);
    }

    deleteTodo(item: ITodoItem) {
        const { id } = item;
        return this.adapter.deleteTodo(id);
    }
}

For those who would like to give a more restrictive way, there is a solution. For the small modules in the application, modify this.fetch() method to work as a debouncing method. For medium modules - introduce bulk items update method. It would resolve the bulk update issue and still will be called once, e.g., this.fetch = _.debounce(this.fetch, 200); For the big modules, such restriction could harm network performance and it would be better to keep the model with passive fetch behavior. That would allow to spare some not needed extra requests to the backend. With the tradeoff - ViewModels would contain more code. By the way, implementing bulk updates will lead to more code as well. And it will keep the implementation more clear.

View Models

There are two view models in the solution. The first, MainViewModel - it is for the main view. The second one for every todo task - TodoViewModelItem. The main view model is to keep data in a convenient way to show in the main interface. The todo view model is to keep items data to be listed. The view models can be classified into two kinds: view models and view model items. The main difference between those kinds is in their creation and lifetime. The main view model will be created just once. It would be available for all views. The todo view model item can be created many times. It will be designed with the idea to be easily dropped from memory. Mostly, it will hold the logic to convert values to be presented on UI.

The item view model can be divided into four sections. View model Commands, constructor, getters section, command implementation section. Since it will be soon dropped from memory, there are no data binding commands. It refers to TodoModel singleton and calls updating the backend state logic. Please, take a note, it takes POKO item object from the model as the initial argument. And it has no setters. It can be simply dropped from memory with no memory leaks.

The todo view model item example:

JavaScript
class TodoViewModelItem {
    completeCommand = { exec: isComplete => this.complete(isComplete) };
    deleteCommand = { exec: () => this.remove() };
    updateTitleCommand = { exec: title => this.updateTitle(title)};

    constructor(public item: ITodoItem) {
    
    }

    getId() {
        return this.item.id;
    }

    getTitle() {
        return this.item.title;
    }

    getIsComplete() {
        return this.item.complete;
    }

    async updateTitle(title) {
        const todosModel = TodosModel.instance();
        await todosModel.updateTodo({
            ...this.item,
            title: title
        });
        todosModel.fetch();
    }

    async complete(isComplete) {
        const todosModel = TodosModel.instance();
        await todosModel.updateTodo({
            ...this.item,
            complete: isComplete
        });
        todosModel.fetch();
    }

    async remove() {
        const todosModel = TodosModel.instance();
        await todosModel.deleteTodo(this.item);
        todosModel.fetch();
    }
}

In general, the main view model is smarter than the todo view model item. It has getters/setters implemented within the Base class. It is prop('<propName>', <propValue>). It can be divided into five sections. The new state section, view model Commands, constructor, getters/setters, and command implementation section. Since it will be heavily used, it should contain a way to attach/detach listeners from the model. Creating and destroying the main view model could be a complex task. And it is expected to attach when create and detach when destroying event listeners. It has an empty constructor, all initialization logic is extracted into the initialize methods. This is adjustable, the initialize method can be called from the constructor or from the parent creating routines. It depends on the particular implementation.

The main view model will populate todo tasks to be displayed on UI into its internal state. Then it would inform all subscribed participants about the state change. That logic is hidden within the base class. Here in the main view model, it is adjusted by calling this.prop('<propName>', <propValue>).

JavaScript
class MainViewModel extends Base<MainViewModel['state']> {
    state = {
        title: '',
        items: [] as TodoViewModelItem[]
    }

    createNewItemCommand = { exec: () => this.createNewItem() };
    toggleAllCompleteCommand = {
        canExecute: () => !this.areAllComplete(),
        exec: () => this.toggleAllCompleteCommand.canExecute() && this.markAllComplete()
    };
    offChangeItems;

    constructor() {
        super(
            'change:title',
            'change:items'
        );
        this.initialize();
    }

    initialize() {
        const todos = TodosModel.instance();
        this.offChangeItems = todos.on('change:items', () => {
            this.populateItems();
        });
    }

    populateItems() {
        const todos = TodosModel.instance();
        this.prop('items', utils.map(todos.getItems(), item => new TodoViewModelItem(item)));
    }

    async createNewItem() {
        const model = TodosModel.instance();
        await model.createTodo(this.prop('title'));
        model.fetch();
        this.prop('title', '');
    }

    markAllComplete() {
        utils.map(this.prop('items'), m => m.complete(true));
    }
    
    areAllComplete() {
        if (!this.prop('items').length) {
            return false;
        }
        return !utils.find(this.prop('items'), i => !i.getIsComplete());
    }
}

To summarize: The view models can be of two types. Heavy - can have event listeners, contain complex logic related to implementing requirements, complex in constructing its instances. It creates light view models many times. And light - minimally implementing requirements, can be many times created and dropped from the memory. Could have data conversion for UI presentation.

UI Layer - Views

The views can be classified by several kinds as well. Pure views (controls), root views, and item views. Pure views although named as controls. They are responsible just to draw UI, keep child views. And never bound to view models. It will be in a separate folder from the rest of the views.

The perfect example is ListView:

JavaScript
class ListView<T extends IListItemView<VM>, 
      VM = ExtractViewModel<T>> extends Base<ListView<T, VM>['state']> {
    options = this.initOptions(this.config);
    el = utils.el(this.options.el);
    state = {
        items: [] as ExtractViewModel<T>[],
        children: [] as T[]
    };
    filter = null;
    offChangeItems;
    offChangeFilter;

    constructor(public config: ReturnType<ListView<T, VM>['initOptions']>) {
        super(
            'change:items',
            'change:children',
            'change:filter'
        );
        this.initialize();
    }

    getFilter() {
        return this.filter;
    }

    setFilter(fn: (i: ExtractViewModel<T>) => boolean) {
        if (this.filter !== fn) {
            this.filter = fn;
            this.trigger('change:filter');
        }
    }

    initOptions(options = {}) {
        const defOpts = {
            el: '',
            createItem(props?): T {
                return null;
            }
        };
    
        return {
            ...defOpts,
            ...options
        };
    }

    initialize() {
        this.offChangeItems = this.on('change:items', () => this.drawItems());
        this.offChangeFilter = this.on('change:filter', () => this.drawItems());
    }

    drawItem(viewModel: ExtractViewModel<T>, index: number) {
        const itemViews = this.prop('children');
        const currentView = itemViews[index];
        const itemView = currentView || this.options.createItem();
        if (!currentView) {
            this.prop('children', [...this.prop('children'), itemView]);
            this.el.append(itemView.el);
        }
        itemView.setViewModel(viewModel);
    }

    drawItems() {
        const items = this.filter ? 
              utils.filter(this.prop('items'), this.filter) : this.prop('items'),
            length = items.length,
            firstChildren = utils.first(this.prop('children'), length),
            restChildren = utils.last(this.prop('children'), length);

        this.prop('children', firstChildren);
        for (const itemView of restChildren) {
            itemView.remove();
        }

        for (let i = 0; i < length; i++) {
            const model = items[i];
            this.drawItem(model, i);
        }
    }

    remove() {
        utils.getResult(this, () => this.offChangeItems);
        utils.getResult(this, () => this.offChangeFilter);
        utils.remove(this.el);
    }
}

The list view is responsible just to create, destroy, and render children views. It would never communicate with the view models directly. It can be bound to view models over data binding commands from a parent view. Never contain data binding commands within. Acts as a self-sufficient view. The ListView is responsible to draw list items on UI. It can filter items by setting the special filtering function over the setFilter setter. In this example, the ListView is designed in a special way. It will refresh currently rendered child views with the new data. The idea here is to minimally manipulate DOM elements. The todo UI has an input box to edit the todo task title. In the case, DOM will be removed and then inserted (redraw), the currently editing input (focused) will lose its focus. That would impact UI experience. Fortunately, there is data binding. Every field is bound to its source. We can use that as an advantage. The switching view model in the view would lead to filling values that are changed and never replace the same values.

Please check this TodoListItemView - the setViewModel method:

JavaScript
function htmlToEl(html) {
    const el = document.createElement('div');
    utils.html(el, html);

    return el.firstElementChild;
}

class TodoListItemView<T extends TodoViewModelItem> {
    el = htmlToEl(template({}));
    vm: T;
    completeCommand;
    deleteCommand;
    updateTitleCommand;
    offTitleChange;
    offCompletedChange;
    offDeleteClick;

    getId() {
        return utils.attr(this.el, 'data-id');
    }

    setId(val) {
        if (this.getId() !== val) {
            utils.attr(this.el, 'data-id', val);
        }
        return val;
    }

    getTitle() {
        return utils.el('.title', this.el).value;
    }

    setTitle(val) {
        if (this.getTitle() !== val) {
            const title = utils.el('.title', this.el);
            title.value = val;
        }
        return val;
    }

    getCompleted() {
        const el = utils.el('.completed', this.el);
        return el.checked;
    }

    setCompleted(newValue) {
        const oldValue = this.getCompleted();
        if (oldValue !== newValue) {
            utils.el('.completed', this.el).checked = newValue;
        }
        return newValue;
    }

    bind() {
        this.unbind();
        this.completeCommand = this.vm.completeCommand;
        this.deleteCommand = this.vm.deleteCommand;
        this.updateTitleCommand = this.vm.updateTitleCommand;
        this.offCompletedChange = utils.on(this.el, '.completed', 
                'click', () => this.completeCommand.exec(this.getCompleted()));
        this.offDeleteClick = utils.on(this.el, '.delete', 
                'click', () => this.deleteCommand.exec());
        this.offTitleChange = utils.on(this.el, '.title', 
                'input', () => this.updateTitleCommand.exec(this.getTitle()));
    }

    unbind() {
        this.completeCommand = null;
        this.updateTitleCommand = null;
        this.deleteCommand = null;
        utils.getResult(this, () => this.offTitleChange);
        utils.getResult(this, () => this.offCompletedChange);
        utils.getResult(this, () => this.offDeleteClick);
    }

    setViewModel(item: T) {
        if (this.vm !== item) {
            this.vm = item;
            this.bind();
            this.setId(item.getId());
            this.setTitle(item.getTitle());
            this.setCompleted(item.getIsComplete());
        }
    }

    remove() {
        this.unbind();
        utils.remove(this.el);
    }
}

The setViewModel method is a special getter. I can't even call it getter. It is a setting of the view model method. It will be responsible for assigning the view model instance in the right way. Within the example, it has a lack of this.unbind call. This is because it is never used without an assigned view model. But it is expected that the todo item view will be removed. The remove method has this.unbind() call within its implementation. And it assigns values from the view model to UI over relevant setters.

The todo item view is designed in the way to keep its state on the DOM elements. That is reflected in getter/setter methods. Setter methods are designed in a special way. Before they set their value, they check for changes individually. This is a good rule to check if the old value is not the same as the new value. That would avoid extra UI refresh and improve UI experience (resetting caret position) when typing in text fields.

In the clean design, one of the main reasons to follow MVVM is because binding commands are ingested into the templating engine. And since the solution has a luck of such engine data binding commands are grouped into a pair of methods unbind/bind. Ideally, bind/unbind methods should be called before and after HTML rendering routines. I have cheated here. Since HTML rendering occurs just during initialization of the instance one time and view model is going to be changed more often, unbind/bind methods are called after every view model change. Technically, it still doesn't break rules - called after rendering HTML. Even more, it looks like a manual implementation of the complex binding command, e.g., 'title': 'vm.prop('title')'. Just a big gotcha with the MVVM explanation in this article.

The next is MainView:

JavaScript
interface MainView extends ReturnType<typeof initialize$MainView> {

}

function initialize$MainView<T>(inst: T, el) {
    return Object.assign(inst, {
        newTitle: utils.el('.new-title', el),
        allComplete: utils.el('.complete-all', el),
        filterAll: utils.el('.all', el),
        filterActive: utils.el('.active', el),
        filterCompleted: utils.el('.completed', el),
        todoList: new ListView({
            el: utils.el('.todo-list', el),
            createItem() {
                return new TodoListItemView<TodoViewModelItem>();
            }
        })
    });
}

class MainView extends Base {
    vm = this.getViewModel();
    options = this.initOptions(this.config);
    el = utils.el(this.options.el);
    offTitleToModel;
    offTitleFromModel;
    offItemsFromModel;

    offOnKeypress;
    offMarkAllComplete;
    offChangeItems;
    offFilter;

    createNewItemCommand = { exec() { return; } };
    toggleAllCompleteCommand = {
        canExecute() { return false; },
        exec() { return; }
    };

    constructor(public config: ReturnType<MainView['initOptions']>) {
        super('change:items');
    }

    getTitle() {
        return this.newTitle.value;
    }

    setTitle(val) {
        if (this.newTitle.value !== val) {
            this.newTitle.value = val;
            utils.trigger(this.newTitle, 'input');
        }
    }

    getAllComplete() {
        return this.allComplete.checked;
    }

    setAllComplete(val) {
        if (this.allComplete.checked !== val) {
            this.allComplete.checked = val;
            utils.trigger(this.allComplete, 'change');
        }
    }

    getFilter() {
        const el = utils.el('.filter input:checked', this.el);
        return el && el.value;
    }

    setFilter(newValue: 'all' | 'active' | 'completed') {
        const oldValue = this.getFilter();
        if (newValue !== oldValue) {
            const el = utils.el(`.filter input[value="${newValue}"]`);
            el.checked = true;
        }
    }

    getViewModel() { 
        return null as MainViewModel;
    }

    setViewModel() {
        this.unbind();
        this.setFilter('all');
        this.setAllComplete(this.toggleAllCompleteCommand.canExecute());
        this.bind();
    }

    initOptions(options = {}) {
        const defOpts = {
            el: 'body'
        };
        return {
            ...defOpts,
            ...options
        };
    }

    initialize() {
        const html = template({
            vid: 1
        });
        utils.html(this.el, html);
        initialize$MainView(this, this.el);

        this.offOnKeypress = utils.on(this.el, '.new-title', 
                             'keypress', evnt => this.onKeypress(evnt));
        this.offMarkAllComplete = utils.on(this.el, '.complete-all', 
                                  'change', () => this.getAllComplete() && 
                                  this.toggleAllCompleteCommand.exec());
        this.offChangeItems = this.todoList.on('change:items', 
                              () => this.setAllComplete
                              (!this.toggleAllCompleteCommand.canExecute()));
        this.offFilter = utils.on(this.el, '.filter input', 'click', 
                         () => this.filterItems(this.getFilter()));

        this.setViewModel();
    }

    bind() {
        this.unbind();
        this.createNewItemCommand = this.vm.createNewItemCommand;
        this.toggleAllCompleteCommand = this.vm.toggleAllCompleteCommand;
        this.offTitleToModel = utils.on(this.el, '.new-title', 
                               'input', () => this.vm.prop('title', this.getTitle()));
        this.offTitleFromModel = this.vm.on('change:title', 
                                 () => this.setTitle(this.vm.prop('title')));
        this.offItemsFromModel = this.vm.on('change:items', 
                                 () => this.todoList.prop('items', this.vm.prop('items')));
    }

    unbind() {
        this.createNewItemCommand = { exec() { return; } };
        this.toggleAllCompleteCommand = { canExecute() { return false; }, exec() { return; } };
        utils.getResult(this, () => this.offTitleToModel);
        utils.getResult(this, () => this.offTitleFromModel);
        utils.getResult(this, () => this.offItemsFromModel);
    }

    onKeypress(evnt) {
        if (evnt.which === ENTER_KEY && ('' + this.newTitle.value).trim()) {
            this.createNewItemCommand.exec();
        }
    }

    filterItems(filterName: 'all' | 'active' | 'completed') {
        switch (filterName) {
            case 'active':
                return this.todoList.setFilter(i => !i.getIsComplete());
            case 'completed':
                return this.todoList.setFilter(i => i.getIsComplete());
            default:
                return this.todoList.setFilter(null);
        }
    }

    remove() {
        utils.getResult(this, () => this.offOnKeypress);
        utils.getResult(this, () => this.offMarkAllComplete);
        utils.getResult(this, () => this.offChangeItems);
        utils.getResult(this, () => this.offFilter);

        utils.remove(this.el);
    }
}

The MainView is a heavy root view that contains the implementation of requirements. It is bound to the MainViewModel in unbind/bind methods. The main difference between TodoListItemView is a definition of DOM event listeners along with data binding commands. Because initialization of the root view is a complex part of the constructor, it is extracted within a separate initialize method. It is expected to call the initialize method after instantiating the instance of the MainView class.

Something like this. The App.run entry point which calls main.initialize();:

JavaScript
const template = data => `<div class="application">Loading...</div>`;

class App {
    static run() {
        utils.html(document.body, template({}));
        const main = new MainView({
            el: utils.el('.application')
        });
        main.initialize();
    }
}

setTimeout(() => {
    App.run();
});

The MainView, TodoListItemView, and ListView contain just UI related logic. They don't contain any manipulation with data. Anything that is about to manipulate with data (create/read/update/delete) is consolidated in view models.

The initialization of the DOM element properties is extracted into a separate initialize$MainView method. When will be the next iteration of development and the text in an HTML template be changed with renamed elements or CSS class names - initialize$MainView is the only place where I can look up and adjust these changes. The DOM event listeners are grouped there as well. Since they are a part of the UI, there is no point to unbind/bind them. They will exist as long as the instance of the MainView will exist. Then the this.setViewModel(); method is called to assign a view model. The unbind/bind methods are constructed to keep commands that are related to the binding view to a view model.

Commands - Gems in MVVM Design

There is something that mediates between views and view models. Small pieces of code that could be a part of both of them and still can be separated. MVVM commands. Anything that is signaled from UI, related to updating data, that is directed from UI towards view models can be treated as commands. Special parts of the application, help use the application in the way it is planned to be used. Always reviewed by customers, QA testers, developers in order to find the entry point to any complex logic that could be hidden by simple mouse click (or touch) on UI. They remind a lonely solitude with the black screen of a command-line console that was popular in the earlier times of computer evolution for average users. Now just developers and system administrators live there.

I will list them all. And let them speak for themselves:

JavaScript
createNewItemCommand = { exec: () => this.createNewItem() };
deleteCommand = { exec: () => this.remove() };
updateTitleCommand = { exec: title => this.updateTitle(title)};
completeCommand = { exec: isComplete => this.complete(isComplete) };
toggleAllCompleteCommand = {
    canExecute: () => !this.areAllComplete(),
    exec: () => this.toggleAllCompleteCommand.canExecute() && this.markAllComplete()
};

Potentially filer items could be in the commands list as well. Right now filter by all, active and completed UI action are just a part of the UI, since it filters already fetched from the backend items. In the case, backend could provide filtering requests they will become a part of that list as well.

For those who seek more answers

Totally, my precious reader has noticed some strange commands. Something like below:

JavaScript
utils.getResult(this, () => this.offMarkAllComplete);
utils.getResult(this, () => this.offChangeItems);
utils.getResult(this, () => this.offFilter);

In the Backbone JS, there is a command _.result(object, '<propName'). It extracts value from the method by a provided name from the object. I have decided to use something similar. That utils.getResults is meant for the same case.

The method initialize$MainView can be a part of the MainView. The main reason it is extracted from the class is that in this way, I can define custom properties and declare them on the MainView class at the same time.

The file structure of the project:

.
 |-viewModels
 | |-mainViewModel.ts
 | |-index.ts
 | |-todoViewModelItem.ts
 |-utils
 | |-index.ts
 |-models
 | |-todos.ts
 | |-index.ts
 |-controls
 | |-listView.ts
 |-adapters
 | |-todos.ts
 |-templates
 | |-mainView.ts
 | |-listItem.ts
 |-index.ts
 |-views
 | |-todoListItemView.ts
 | |-index.ts
 | |-mainView.ts
 |-base
 | |-base.ts

Conclusions about MVVM Design Pattern

MVVM design pattern is easy to follow in case all parts of the MVVM structure are familiar. There is a filling like I'm just worrying about resolving and implementing requirements instead of fighting with language constructions in order to find a nice place for the right requirement resolution. The best part is the ability to reprogram UI without altering the core implementations. Though, it can be adjusted for a particular client's demands. It is easy to locate every part of the application and start implementing new requirements.

It is hard to resolve and connect all MVVM ideas from the first try, because of the lack of documentation for the JavaScript language. I have myself dug into many resources that are from C# WPF.NET. The most confusing part was to resolve the implementation of the idea of a data binding concept. Despite a simplicity in understanding, it is hard to decouple participants and parts of the code that are for binding and part of views and models. It would be really awesome if one day templating language would appear with the meaning of data binding statements. Please, let me know if I've missed cool ideas for MVVM that could be a part of this article.

Positives

  • It is easy to setup UI with fewer updates/refreshes since every part of the UI is bound to a particular part of the data.
  • It can be introduced into the application at any stage of development.
  • Doesn't demand frameworks or utility libraries
  • With the rich tools and clear approach will be flexible, restrictions would act as one of the assisting tools
  • It provides more places to implement business requirements.
  • It would be easy to implement in small and medium applications.
  • The view layer can be replaced with less efforts.

Consequences

  • Since UI is bound to data, it could be hard to determine what part of the module is responsible for what refreshing place on UI.
  • This leads to more code writing.
  • The weak tools and not clear approach could be more restrictive than flexible.
  • Weak implementation could lead to confusion and less understanding of tasks of the MVVM layers.
  • It would demand a good effort to implement in a big application.
  • It could be problematic to implement for some specific framework libraries.

I dedicate this article to my wife, family, and my relatives.

Because, instead of giving my attention to those who seek it most, I have spent many weekends writing material for this article.

Articles to Look for Inspiration

History

  • 23rd April, 2020: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License

Share

About the Author

Volodymyr Kopytin
Software Developer at RebelMouse
Poland Poland
No Biography provided

Comments and Discussions

 
QuestionConfusing TS with Vanilla JavaScript Pin
Gerd Wagner27-Apr-20 0:32
professionalGerd Wagner27-Apr-20 0:32 
AnswerRe: Confusing TS with Vanilla JavaScript Pin
Volodymyr Kopytin27-Apr-20 3:00
professionalVolodymyr Kopytin27-Apr-20 3:00 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.