gittech. site

for different kinds of informations and explorations.

Build SPA with Alpine.js

Published at
6 days ago

alpinejs-app

A Simple Single Page Application (SPA) library for Alpine.js.

The Motivation

  • to maintain a clear separation between HTML and JavaScript logic. This separation keeps presentation distinct from logic as much as possible. With Alpine.js, we now get efficient two-way data bindings and reactivity using even less code.

  • to have some kind of central registry of HTML templates and Javascript classes and use it to register components.

The registry is just 2 global Javascript objects, templates and components.

How the registry is delivered to the browser depends on bundling or application, for example:

  • bundle everything into a single file by converting HTML files to strings: see examples/build.js for a simple esbuild plugin
  • keep HTML templates in JSON files to load separately via fetch on demand
  • maintain HTML files on the server to load individually on demand similar to htmx

Features

  • Components: These are the primary building blocks of the UI. They can be either standalone HTML templates or HTML backed by JavaScript logic, capable of nesting other components.

  • Main Component: Displays the current main page within the #app-main HTML element. It manages the navigation and saves the view to the browser history using app.savePath.

  • History Navigation: Supports browser back/forward navigation by rendering the appropriate component on window popstate events, achieved using app.restorePath.

  • Direct Deep-Linking: For direct access, server-side routes must redirect to the main app HTML page, with the base path set as '/app/' by default.

Installation

npm

npm install @vseryakov/alpinejs-app

cdn

<script src="https://unpkg.com/[email protected]/dist/app.min.js"></script>

<script src="https://unpkg.com/[email protected]/dist/cdn.min.js" defer></script>

Getting Started

Here's a simple hello world example.

Live demo is available at demo.

index.html

<head>
<script src="bundle.js"></script>
</head>

<body>
    <div id="app-main"></div>
</body>

<template id="index">
    <h5>This is the index component</h5>

    <button x-render="'hello/hi?reason=World'">Say Hello</button>
</template>

<template id="hello">
    <h5>This is the <span x-text=$name></span> component</h5>
    Param: <span x-text="params.param1"></span><br>
    Reason: <span x-text="params.reason"></span><br>

    <div x-template.show="template"></div>

    <button @click="toggle">Toggle</button>
    <button x-render="'index'">Back</button>
</template>

index.js

import '../dist/app.js'
import './hello'
import './dropdown'
import "./dropdown.html"
import "./example.html"

app.debug = 1
app.start();

hello.js

app.components.hello = class extends app.AlpineComponent {
    template = ""

    toggle() {
        this.template = !this.template ? "example" : "";
    }
}

Explanation:

  • The script defines a template and a component, app.start calls restorePath when the page is ready, defaulting to render index since the static path doesn’t match. (running locally with file:// origin will not replace history)
  • The body includes a placeholder for the main app.
  • The index template is the default starting page, with a button to display the hello component with parameters.
  • Clicking 'Say Hello' switches the display to the hello component via x-render directive.
  • The hello component is a class extending app.AlpineComponent with a toggle function.
  • A x-template directive remains empty until the template variable is populated by clicking the 'Show' button, which triggers the component's toggle method to render the example template in the contained div. The .show modifier keeps the div hidden until template is set.
  • The example template is defined in the examples/example.html file and bundled into bundle.js.

Nothing much, all the work is done by Alpinejs actually.

Directive: x-template

Render a template or component inside the container from the expression which must return a template name or nothing to clear the container.

This can be an alternative to the x-if Alpine directive especially with multiple top elements because x-if only support one top element.

<div x-template="template"></div>

<div x-template="show ? 'index' : ''"></div>

Modifiers:

  • show - behave as x-show, i.e. hide if no template and display if it is set, x-template.show="..."
  • flex - set display to flex instead of block
  • inline - set display to inline-block instead of block
  • important - apply !important similar to x-show

Directive: x-render

Binds to click events to display components. Can set components via a syntax supporting names, paths, or URLs with parameters through parsePath.

Special options include:

  • $target - to define a specific container for rendering.
  • $history - to explicitly manage browser history.
<a x-render="'hello/hi?reason=World'">Say Hello</a>

<button x-render="'index?$target=#div'">Show</button>

Directive: x-scope-level

Reduce data scope depth for the given element, it basically cuts off data inheritance at the requested depth. Useful for sub-components not to interfere with parent's properties. In most cases declaring local properties would work but limiting scope for children might as well be useful.

<div x-data-depth></div>

<div x-data-depth=1></div>

Magic: $app

The $app object is an alias to the global app object to be called directly in the Alpine.js directives.

<a @click="$app.render('page/1/2?$target=#section')">Render Magic</a>

<a @click="$app.render({ name: 'page', params: { $target: '#section' }})">Render Magic</a>

Component Lifecycle and Event Handling

While Alpinejs has several ways how to reuse the data this app makes it more unified, this is opinionated of course.

Here is the life-cycle of a component:

  • on creation a component calls onCreate method if exists. it can be created in derived class to implement custom initialization logic and create properties. At this time the context is already initialized, the params property is set with parameters passed in the render call and event handler for component:event is registered on the app.

  • The component:create event is broadcasted with an object { name, element, params, component }

  • when a component is removed from the DOM onDelete method is called to cleanup resources like event handlers, timers...

  • app event component:event is sent to all live components, this can be used to broadcast important events happening and each component will decide what to do with it. This is uses app event emitter instead of DOM events to keep it separate and not overload browser with app specific messages.

In extending the example:

Add a new button to the index template:

<button x-render="'hello2'">Say Hello2</button>

Introduce another component:

hello.js

    app.templates.hello2 = "#hello"

    app.components.hello2 = class extends app.components.hello {
        onCreate() {
            this.params.reason = "Hello2 World"
            this._timer = setInterval(() => { this.params.param1 = Date() }, 1000);
        }

        onDelete() {
            clearInterval(this._timer)
        }

        onToggle(data) {
            console.log("received toggle event:", data)
        }

        toggle() {
            super.toggle();
            app.emit(app.event, "toggle", this.template)
        }
    }

Key additions include:

  • A hello2 template referencing existing hello for shared markup.
  • A new hello2 component extending from hello, showcasing class inheritance.

The hello2 component utilizes lifecycle methods:

  • onCreate sets up initialization like overriding reasons and running a timer.
  • onDelete manages cleanup by stopping timers.
  • toggle method reuses the toggling but adds broadcasting changes via events.

For complete interaction, access live demo at the index.html.

Custom Elements

Component classes are registered as Custom Elements with app- prefix,

using the example above hello component can be placed inside HTML as <app-hello></app-hello>.

See also how the dropdown component is implemented.

Examples

The examples/ folder contains more components to play around and a bundle.sh script to show a simple way of bundling components together.

Simple bundled example: index.html

An example to show very simple way to bundle .html and .js files into a single file.

It comes with pre-created bundle but to rebuild:

  • run npm run demo
  • it will generate examples/bundle.js file that includes all HTML and Javascript code
  • load it in the browser: open examples/index.html

Esbuild app plugin

The examples/build.js script is an esbuild plugin that bundles templates from .html files to be used by the app.

Running node build.js in the examples folder will generate the bundle.js which includes all .js and .html files used by the index.html

API

Global settings

  • base: "/app/"

    Defines the root path for the application, must be framed with slashes.

  • main: "#app-main"

    Central app container for rendering main components.

  • index: "index"

    Specifies a fallback component for unrecognized paths on initial load, it is used by restorePath.

  • templates: {}

    HTML templates, this is the central registry of HTML templates to be rendered on demand, this is an alternative to using <template> tags which are kept in the DOM all the time even if not used.

    This object can be populated in the bundle or loaded later as JSON, this all depends on the application environment.

  • components: {}

    Component classes, this is the registry of all components logic to be used with corresponding templates. Only classed derived from app.AlpineComponent will be used, internally they are registered with Alpine.data() to be reused by name.

Rendering

  • app.render(options, dflt)

    Show a component, options can be a string to be parsed by parsePath or an object { name, params }. if no params.$target provided a component will be shown inside the main element defined by app.main.

    It returns the resolved component as described in resolve method after rendering or nothing if nothing was shown.

    When showing main app the current component is asked to be deleted first by sending an event prepare:delete, a component that is not ready to be deleted yet must set the property event.stop in the event handler onPrepareDelete(event) in order to prevent rendering new component.

    To explicitly disable history pass options.nohistory or params.$nohistory otherwise main components are saved automatically by sending the path:save event. A component can globally disable history by creating a static property $nohistory in the class definition.

  • app.resolve(path, dflt)

    Returns an object with template and component properties: { name, params, template, component } Calls app.parsePath first to resolve component name and params.

    The template property is set as:

    • try app.templates[.name]
    • try an element with ID name and use innerHTML
    • if not found and dflt is given try the same with it
    • if template texts starts with # it means it is a reference to another element's innerHTML
    • if template text starts with $ it means it is a reference to another template in app.templates

    The component property is set as:

    • try app.components[.name]
    • try app.components[dflt]
    • if resolved to a function return
    • if resolved to a string it refers to another component, try app.templates[component]

    if the component property is empty then this component is HTML template.

Router

  • app.start

    Setup default handlers:

    • on path:restore event call restorePath to render a component from the history
    • on path:save call savePath to save the current component in the history
    • on page ready call restorePath to render the initial page

    If not called then no browser history will not be handled, up to the app to do it some other way.. One good reason is to create your own handlers to build different path and then save/restore.

  • app.restorePath(path)

    Show a component by path, it is called on path:restore event by default from app.start and is used to show first component on initial page load. If the path is not recognized or no component is found then the default app.index component is shown.

  • app.savePath(options)

    Saves the given component in the history as /name/param1/param2/param3/....

    It is called on every component:create event for main components as a microtask, meaning immediate callbacks have a chance to modify the behaviour.

  • app.parsePath(path, dflt)

    Parses component path and returns an object { name, params } ready for rendering. External urls are ignored.

    The path can be:

    All parts from the path and query parameters will be placed in the params object.

DOM utilities

  • app.$ready(callback)

    Run callback once the document is loaded and ready, it uses setTimeout to schedule callbacks

  • app.$(selector[, doc])

    An alias to document.querySelector, doc can be an Element

  • app.$on(element, event, callback)

    An alias for element.addEventListener

  • app.$elem(name, ...arg)

    Create a DOM element with attributes, -name means style.name, .name means a property name, all other are attributes, functions are event listeners

    app.$elem("div", "id", "123", "-display", "none", "._x-prop", "value", "click", () => {})

  • app.$elem(name, object [, options])

    Similar to above but all properties and attributes are taken from an object, in this form options can be passed, at the moment only options for addEventListener are supported.

    app.$elem("div", { id: "123", "-display": "none", "._x-prop": "value", click: () => {} }, { signal })

  • app.$parse(text, format)

    A shortcut to DOMParser, default is to return the .body.

    Second argument defined the result format:

    • list - the result will be an array with all body child nodes, i.e. simpler to feed it to Element.append()
    • doc - return the whole parsed document

    document.append(...app.$parse("<div>...</div>"), 'list'))

  • app.$data(element, level)

    Return component data instance for the given element or the main component if omitted. This is for debugging purposes or cases when calling some known method is required.

    if the level is not a number then the closest scope is returned otherwise only the requested scope at the level or undefined. This is useful for components to make sure they use only the parent's scope for example.

    This returns Proxy object, to get the actual object pass it to Alpine.raw(app.$data())

Event emitter

The app implements very simple event emitter to handle internal messages separate from the DOM events.

There are predefined system events:

  • path:restore - is sent by the window popstate event from app.start
  • path:save - is sent by app.render for main components only
  • path:push - is sent just before calling history.pushState with the path to be pushed
  • component:create - is sent when a new component is created, { type, name, element, params }
  • component:delete - is sent when a component is deleted, { type, name, element, params }
  • component:event - a generic event defined in app.event is received by every live component and is handled by onEvent(...) method if exist. Then convert the first string argument into camel format like onFirstArg(...) and call this method if exists

Methods:

  • app.on(event, callback)

    Listen on event, the callback is called synchronously

  • app.once(event, callback)

    Listen on event, the callback is called only once

  • app.only(event, callback)

    Remove all current listeners for the given event, if a callback is given make it the only listener.

  • app.off(event, callback)

    Remove event listener

  • app.emit(event, ...args)

    Send an event to all listeners at once, one by one.

    If the event ends with :* it means notify all listeners that match the beginning of the given pattern, for example:

    `app.emit("topic:*",....)` will notify `topic:event1`, `topic:event2`, ...
    

General utilities

  • app.call(obj, method, ...arg)

    Call a function safely with context and arguments:

    • app.call(func,..)
    • app.call(context, func, ...)
    • app.call(context, method, ...)
  • app.fetch(options, callback)

    Fetch remote content, wrapper around Fetch API, options are compatible to $.ajax:

    • type - GET,... POST is default
    • data - a body, can be a string, an object, FormData
    • dataType - explicit return type: text, blob, default is auto detected between text or json
    • headers - an object with additional headers to send

    The callback(err, data, info) - where info is an object { status, headers, type }

  • app.trace(...)

    if app.debug is set then it will log arguments in the console

Advanced

  • app.plugin(name, options)

    Register a render plugin, at least 2 functions must be defined in the options object:

    • render(element, options) - show a component, called by app.render
    • cleanup(element) - optional, run additional cleanups before destroying a component
    • data(element) - return the component class instance for the given element or the main
    • default - if not empty make this plugin default
    • Component - optional base component constructor, it will be registered as app.{Type}Component, like AlpineComponent, KoComponent,... to easy create custom components

    The reason for plugins is that while this is designed for Alpinejs, the idea originated by using Knockoutjs with this system, the plugin can be found at app.ko.js.

Author

Vlad Seryakov

License

Licensed under MIT