SPAC: Controller Self-Initialization & Object API
SPAC is a custom JavaScript framework for client-side, single-page web applications. It stands for "Stateful Pages, Actions and Components". Its design goal is to provide robust and simple entities that help you to structure apps. Pages and components provide the HTML, JavaScript functions and UI interactions. Actions govern external API calls. You define these entities in plain JavaScript, load up the central controller, and your app is ready to be served. Read the development journey of SPAC in my series: https://admantium.com/category/spac-framework/.
The core entity of SPAC is the controller: A self-initializing object that assembles your web application from its pages, actions and components. This article details the self-initialization phase, how it works, how it creates an internal objects API and its bindings to the browser.
Ongoing Example: ApiBlaze Index Page
To explain the concepts in detail, we will use the example of ApiBlaze, an ongoing development project that enables blazing-fast searches in API descriptions. You can read more about ApiBlaze in the project kickoff article.
ApiBlaze first screen consists of a search bar and a search result popup. When you execute a search, the appropriate action will be triggered. The directory layout for this screen is as follows:
src
└── actions
│ └── SearchApiSpecAction.js
└── components
├── ApiSearchComponent.js
└── ApiSearchResultsComponent.js
└── pages
│ ├── IndexPage.js
│ ├── SearchApiElementsPage.js
└── index.js
Before starting the app, you need to provide an inventory.json
file, which contains file links to all pages, actions and components. This file is generated by npm run bootstrap
. For this example, it looks as follows:
{
"pages": ["/src/pages/IndexPage.js", "/src/pages/SearchApiSpecAction.js"],
"components": [
"/src/components/ApiSearchComponent.js",
"/src/components/ApiSearchResultsComponent.js"
],
"actions": ["/src/actions/SearchApiSpecAction.js"]
}
Self-Initialization Process
The file index.js
contains code for importing the controller and starting the self-initialization. Typically, it looks like this:
import { Controller } from 'spac'
import inventory from './inventory.json'
const controller = new Controller({ inventory })
controller.init()
As you see, the controller is initialized by receiving the inventory, and then calling the async function init()
. During the initialization, the controller makes the following steps:
- For each file listed in the inventory, check...
- That the file name conforms with the naming pattern (/.*Page.js/, /.*Action.js/ or *Component.js/)
- That the file exports a class of the appropriate type
- Each of these classes is added to an internal
Map
object:pagesMap
: Define entries withroute
andobj
propertiesactionsMap
: Define entries withobj
propertiescomponentsMap
: Define entries withobj
properties
Files that do not conform to the naming patterns, or files for which the type check fails, are ignored.
Let’s see the details by following an example. The following excerpt shows init
method and how the /pages
directory will be traversed.
init () {
this._initMap(Page, 'pages', /Page.js/)
// ....
}
_initMap (parentClass, mapType, pattern) {
this.inventory[mapType].forEach(async filePath => {
try {
if (!filePath.match(pattern)) {
throw new Error()
}
const name = filePath.split('/').pop().replace(pattern, '')
const clazz = (await import(`${filePath}`)).default
if (clazz.prototype instanceof parentClass) {
if (parentClass === Page) {
const route = `/${name.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase()}`
this[mapType].set(name, { route, clazz })
} else {
this[mapType].set(name, { clazz })
}
}
} catch (e) {
// ...
}
})
}
In this method:
- Line 2: The
init
function calls an internal helper_initMap()
- Line 6: For each file inside the inventory...
- Line 8: ... check that it matches the given
pattern
- Line 11: ... attempt a dynamic import of the file
- Line 13: ... check the file exports a class of the given
parentClass
- Line 8: ... check that it matches the given
- Line 16/18: Store the name and an object containing the export in the given
mapType
Internal Objects API
When the initialization phase for the above mentioned example is completed, we obtain map objects that can be accessed and used in the application directly from the controller.
Pages
The page map object:
pages = {
Index: {
route: '/index',
clazz: IndexPage()
},
SearchApiElements: {
route: '/search_api_elements',
clazz: SearchApiElementsPage()
},
SearchApiSpec: {
route: '/search_api_spec',
clazz: SearchApiSpecPage()
}
}
Pages can be accessed with controller.page('PageName')
, and the method controller.display('PageName')
renders the page.
Components
The components map object:
components = {
ApiSearch: {
clazz: ApiSearchComponent()
},
ApiSearchResults: {
clazz: ApiSearchResultsComponent()
}
}
Components can be accessed with controller.component('componentName)
. This method is used by page
objects to fetch their components.
Actions
The actions map object:
actions = {
SearchApiSpec: {
clazz: SearchApiSpecAction()
}
}
Actions are accessed controller.action('ActionName')
.
Assembling Pages
When designing pages objects, you can choose to either manually import your components, or access the component via the controller.
The manual import looks like this:
import { Page } from 'spac'
import SearchBarComponent from '../components/SearchBarComponent.js'
import SearchResultsComponent from '../components/SearchResultsComponent.js'
export default class IndexPage extends Page {
render = () => {
return `
<h1>ApiBlaze Explorer</h1>
<section class='api-search-page'>
<div id='search-api-spec' class='search-api-spec'></div>
<div id="search-api-results" class="search-api-results"></div>
</section>
`
}
constructor (rootDom) {
super(rootDom)
this.addComponents(
new SearchBarComponent('#search-api-spec'),
new SearchResultsComponent('#search-api-results')
)
}
}
Alternatively, the objects API can be used to import components (and actions). For this, you need to add the special method _preloadComponents()
and pass an object with component names and their arguments, e.g. the querySelector
.
import { Page } from 'spac'
export default class IndexPage extends Page {
render = () => {
return `<h1>Hello</h1>`
}
_preloadComponents = () => {
return {
SearchBarComponent: { querySelector: '#search-api-spec' },
SearchResultsComponent: { querySelector: '#search-api-results' }
}
}
}
During initialization, the Page
class will check if this special method is defined, and if yes, use the Controllers component
method, to retrieve the class definition and create an instance of the particular component.
class Page extends PageInterface {
mount(querySelector) {
super.mount(querySelector)
// ...
if (this._preloadComponents) {
for (let [name, params] of this._preloadComponents()) {
const instance = this.controller.component(name, params)
this.components.set(name, instance)
}
}
}
}
Conclusion
This article explained how the controller's self-initialization phase works. First, we create an inventory of all pages, components and actions using the command npm run bootstrap
. Then, when the controllers’ instance is created, it will use the inventory to define internal map objects that point to all defined classes. The controller rigorously checks that each file is named correctly and that it exports a class which is of type Page, Component or Action. Then, these map objects can be used to dynamically load the entities for assembling the pages.