/pacto

A lightweight framework for non SPA websites.

Primary LanguageJavaScriptMIT LicenseMIT

Pacto

CI Status Coverage Status on Codecov Known Vulnerabilities Minified gzipped size

A lightweight framework for non SPA websites.

Why?

There are a lot of great frameworks out there which are supposed to be used for single page applications (SPA). When using them on regular websites it's hard to apply those frameworks to an already server-side rendered DOM or to enhance certain sections with some interaction candy. On the other hand, the network payload to ship an SPA framework is quite huge when using for example only the state management or virtual DOM of that framework. This may results in a higher time to interactive due to network traffic, parse, interpret and execution time.

Pacto tries to reduce those problems by shipping small features which are using latest browser features like IntersectionObserver and WeakMap.

The Concepts

In contrast to other libraries, pacto follows the traditional approach of an MVC framework. The core of pacto is a context instance. This is based on a typical event bus where events can be added, removed and triggered (pubsub pattern). The context instance also allows adding actions to certain events. An action is designed to hold a part of the application logic. Each time a relevant event occurs, an added action will run to execute a logic like to update a state, to fetch or to recalculate data.

These actions allow creating modules. Each module should contain at least one initialize action but can be composed of multiple actions, stores, states, services, views etc. This initialize action is meant to be the entry point of each module. It setups and executes its module:

pacto app module structure

The state management is not solved by pacto, but there is small backbone/mobx inspired model and collection extension for pacto called pacto.model.

Installation

Pacto is available on NPM:

npm install pacto --save

Requirements

Pacto is dependency free, but it requires latest browser features. So you may need to add a polyfill for WeakMap. When using InitializeLazy you can also add a polyfill for IntersectionObserver.

Using dynamic imports, this boilerplate can be used to load all required polyfills before loading and running the app:

(function(src){
	Promise.all([
		(!!window.WeakMap || import('weakmap-polyfill')),
		(!!window.IntersectionObserver || import('intersection-observer')),
	]).then(() => {
		var script = document.createElement('script');
		script.type = 'text/javascript';
		script.charset = 'utf-8';
		script.async = true;
		script.defer = true;
		script.src = src;
		document.body.appendChild(script);
	});
})('/path/to/app-using-pacto.js');

Support NodeList.prototype.forEach

Pacto expects the support of NodeList.prototype.forEach. For older Browsers such as IE11 you need to add another polyfill.

Documentation

Context

An instance of pacto's Context has typical known properties of an EventEmitter like .on(), .off() and .trigger(). It also allows to handle Actions and store/receive Values in each instance.

import {Context} from 'pacto';

const context = new Context();
context
	.on('event:type', (event) => console.log('The event occurred.', event))
	.trigger('event:type', {foo: 'bar'})
	.off('event:type');

The context can store the event-histroy. This allows modules to load lazy and react on previous events from history, if required. The history is disabled by default. To enable this feature pass {history: true} into the constructor. The history can be flushed.

import {Context} from 'pacto';

const context = new Context({history: true});
context.trigger('event:type');
context.trigger('event:type', {foo: 'bar'});
context.histroy; // logs: [{type: 'event:type', data: null}, {type: 'event:type', data {foo: 'bar'}}]
context.flushHistory();
context.histroy; // logs: []

Actions

An Action is a class which can bound to a specific event. Each action class needs to contain at least a .run() method. When an action relevant event is dispatched through the context, an instance of the action class will be created and executed. The instance of each action has access to the context and the passed event data which triggered the execution of that action.

Action management is done by the .actions property of the context instance.

import {Context} from 'pacto';

class Action {
	run() {
		console.log('I am an action', this.context, this.event);
	}
}

const context = new Context();
context.actions.add('event:type', Action);
context.trigger('event:type', {foo: 'bar'}); // logs: 'I am an action', {context}, {event}

Read more about the actions API.

Build-in actions

Pacto offers build-in actions that help to define modules by using a configuration.

Initialize

The initialize action setups a module and wires a view to a DOM element. Each initialize action is described by its settings: selector, view, namespace. The selector is a CSS valid selector to define which elements to use for each view instance. The created view instance is grouped in a list of views by the initialize action. This list is stored inside the context values using a given namespace (take a look at Values).

// Initialize.js
import {Initialize} from 'pacto';
import {View} from 'mymodule/views/View';

export class Action extends Initialize {
	get settings() {
		return {
			selector: '.mymodule',
			namespace: 'mymodule:views'
			view: View
		};
	}
}

// App.js
import {Context} from 'pacto';
import {Action as MyModule} from 'mymodule/actions/Initialize';

const context = new Context();
context.actions.add('app:start', [
	MyModule,
	// Add more modules here...
]);
context.trigger('app:start');

The initialize action of pacto ships some hooks which are called while executing and creating views. These hooks can be used by overwriting them:

  • beforeAll()
  • beforeEach(options, el, index)
  • afterEach(view, el, index)
  • afterAll(views)

beforeAll, beforeEach and afterEach can return false to skip the current execution phase.

InitializeLazy

Using an app bundler like webpack, parcel or rollup allows using code splitting by defining dynamic imports. Using them creates a smaller app build by separating them into chunks. The InitializeLazy action of pacto offers the possibility to simply use that feature and only load a certain module when its corresponding element exists inside the users DOM. If at least one of these elements is found and visible, the initialize action of that module will be imported, instantiated and executed. Once loaded the specific action will replace the lazy action.

pacto app module structure with lazy initialize action

// Initialize.js
import {Initialize} from 'pacto';
import {View} from 'mymodule/views/View';

export class Action extends Initialize {
	get settings() {
		return {
			selector: '.mymodule',
			namespace: 'mymodule:views'
			view: View
		};
	}
}

// InitializeLazy.js
import {InitializeLazy} from 'pacto';

export class Action extends InitializeLazy {
	get settings() {
		return {
			selector: '.mymodule',
		};
	}

	get import() {
		return import('mymodule/actions/Initialize');
	}
}

// App.js
import {Context} from 'pacto';
import {Action as MyLazyModule} from 'mymodule/actions/InitializeLazy';

const context = new Context();
context.actions.add('app:start', [
	MyLazyModule,
	// Add more modules here...
]);
context.trigger('app:start');
Define a root element

Both actions, Initialize and InitializeLazy takes care of an event property root. If not defined, they use the document body to look up for modules by the given selector. When passing a DOM element as root by triggering an event from the context, this element is used to look up child modules. This is useful when a specific section needs to be re-initialized.

const root = document.querySelector('.fetched-content');
this.context.trigger('app:start', { root });
Define a custom conditon

The InitializeLazy action has a getter condition that returns a promise. It allows customizing the load condition when executing the startup (lookup for matching elements) process. The default implementation waits for the DOM-ready state to be "complete" (using the DOMContentLoaded event to wait for).

export class Action extends InitializeLazy {
	get settings() {
		return {
			selector: '.mymodule',
		};
	}

	get import() {
		return import('mymodule/actions/Initialize');
	}

	get condition() {
		// Not a real world example...
		return new Promise((resolve) => setTimeout(resolve, 1000));
	}
}
Handling errors

Note that due to various reasons there could be an error during the execution of your actions. To be able to catch these you can listen for <action-id>:error to get notified when an error occurs.

So in our App.js we can add the app:start:error handler to get notified:

context.on('app:start:error', (event) => {
	const {error} = event.data;

	alert('An error while initializing the App occured.. ' + error.message);
});

Values

The .values property of a context instance is a key/value storage. Each type of value can be stored using a unique namespace (key).

import {Context} from 'pacto';

const context = new Context();
context.values.add('name:space', {foo: 'bar'});
console.log(context.values.has('name:space')); // logs: true
console.log(context.values.get('name:space')); // logs: {foo: 'bar'}

context.values.remove('name:space');
console.log(context.values.has('name:space')); // logs: false
console.log(context.values.get('name:space')); // logs: undefined

Read more about the values API.

View

A view is a simple wrapper class for DOM elements. It holds the references to its DOM element and context. An instance of this class is meant to be the communicator between user interactions and pacto framework actions. It is also the right place to do complex renderings using virtual DOM libraries by overwriting the render() function.

import {View} from 'pacto';

class ToggleButton extends View {
	render() {
		this.el.addEventListener('click', (event) => {
			this.el.classList.toggle('foo');
			this.context.trigger('togglebutton:toggle');
		});
	}
}

EventEmitter

All objects that emit events are instances of the EventEmitter class. These objects expose an .on() function that allows one or more functions to be attached to named events emitted by the object. To remove those attached functions the .off() function can be used. When a specific event is dispatched on an EventEmitter instance, all attached functions by that event are called synchronously.

License

LICENSE (MIT)