@pelagiccreatures/sargasso
Simple, Fast, Supervised Javascript Controller framework for Web Sites and Progressive Web Apps.
@author Michael Rhodes (except where noted)
@license MIT
Made in Barbados 🇧🇧 Copyright © 2020-2021 Michael Rhodes
Sargasso Makes HTML elements aware of events such as Document (DOM) insertions and deletions, HIJAX Page load, Scrolling, Resizing, Orientation and messages Managed Web Workers and elements allowing them to efficiently implement any behavior they need to perform.
One of the core features of this framework is to implement an asynchronous page loading scheme which supports deep linking and lightning fast page loads where only dynamic content areas are merged between page loads leaving css, js, web workers and wrapper elements intact. Sargasso controller instances are automatically created as needed when their element appears in the DOM and destroyed when their element is removed so everything is cleanly destroyed and all the trash is collected. Performance is further enhanced with shared event listening services which are fully debounced during updates. Services are also provided to schedule content changes using the browser's animation frame event loop and managed web workers for simplified offloading of computation heavy tasks to a dedicated thread resulting in highly performant pages.
Sargasso elements can also track changes to underlying data and re-render as needed using lit-html templates.
This is a very lightweight (27kb), pure ES6 framework (with only few dependencies) which aims to use the most advanced stable features of modern browsers to maximum effect leaving the historical cruft, kludges and code barnacles infesting older web frameworks behind. The result is lean, highly performant and clean library that simplifies the complex technologies behind modern progressive web apps and web sites.
Other Sargasso modules that build on this framework:
Status
API Stable
We are trying to keep this project as forward looking so as to not burden this framework with lots of obsolete junk and polyfills so while it will certainly not work on every ancient browser, it should work on any reasonably modern one. If you run into any problems, have questions, want to help or have any feedback let me know by opening a github issue.
Why?
Progressive Web Apps and modern websites need a HIJAX scheme to load pages that is integrated with and can manage element behavior. The big name frameworks out there at the moment are not a very good fit for the work I am doing so I decided to roll my own to investigate the current state of browser capabilities.
Usage Overview (Using CDN iife modules)
This simple example loads the framework using the CDN and defines a simple Sargasso element controller that says "Hi World!".
example/example1.html
<!DOCTYPE html>
<html>
<head>
<title>Example Sargasso Element</title>
</head>
<body>
<h3>First Sargasso Element</h3>
<sargasso-my-class id="custom">Using a custom element</sargasso-my-class>
<div data-sargasso-class="MyClass" id="data-attribute">Using data attribute</div>
<script src='https://cdn.jsdelivr.net/npm/@pelagiccreatures/sargasso/dist/sargasso.iife.js'></script>
<script defer>
window.onload = () => {
class MyClass extends SargassoModule.Sargasso {
start() {
this.queueFrame(() => {
this.element.innerHTML = 'Hello World! (' + this.element.getAttribute('id') + ')'
})
super.start()
}
}
SargassoModule.utils.registerSargassoClass('MyClass', MyClass)
SargassoModule.utils.bootSargasso()
}
</script>
</body>
</html>
Try It
Sargasso element controllers are javascript Objects that are subclasses of the framework's Sargasso class. Custom behavior is defined by overriding various methods of the base class.
Using data-sargasso-class to specify Sargasso classname
Alternately, Sargasso watches the DOM for any elements tagged with the data-sargasso-class
attribute which can be one classname or a list of classnames
<div data-sargasso-class="MyClass, MyOtherClass">This works in all browsers</div>
Custom Element tags to specify classname
Many browsers support custom elements (current compatibility so the preferred (faster and cleaner) syntax for sargasso elements is to use a custom element tag. The class name is the kebab-case of your subclass name so MyClass becomes sargasso-my-class:
<sargasso-my-class>This works in <em>most</em> browsers</sargasso-my-class>
You can also defer the instantiation using the lazy method by tagging it with data-lazy-sargasso-class
instead of data-sargasso-class
which will only start up the class when the element is visible in the viewport.
Sargasso Object Lifecycle
When a Sargasso element appears in the document, the framework supervisor will instantiate an object and call the start()
method of the object. When removed from the DOM, 'sleep()' will be called allowing you can cleanup any resources or handlers you set up in start (note that event listeners created with 'this.on' and 'this.once' are automatically cleaned up). Beyond responding to scrolling, resize and other responsive events, you will probably want to interact with your element in some way. You should use the start hook to set up any element events you need to respond to such as clicking a button, responding to touch events or key presses, etc.
Example with event handlers
example/example2.html
<!DOCTYPE html>
<html>
<head>
<title>Example Sargasso Element</title>
<style>
.container { margin-top: 150vh; margin-bottom: 33vh; }
.container span { border: thin solid #333; border-radius: 1em; padding: 1em; }
.clicked { color: red; }
.float { position: fixed; bottom: 0px; right: 0px; width: 50%; float: right; }
.float>pre { text-align: right; }
</style>
</head>
<body>
<h3>Sargasso Element Example</h3>
<p>Element is aware of when it is scrolled into the viewport, removed from the document and implements a delegated click event which removes the element when triggered.</p>
<p>Scroll down to see it in action</p>
<div class='container'>
<button data-sargasso-class="MyButtonClass">
Hello World! Click me!
</button>
</div>
<script src='https://cdn.jsdelivr.net/npm/@pelagiccreatures/sargasso/dist/sargasso.iife.min.js'></script>
<script defer>
window.onload = () => {
class MyButtonClass extends SargassoModule.Sargasso {
constructor(element, options = {}) {
options.watchViewport = true
super(element, options)
}
start() {
super.start()
this.on('click', (e) => {
e.preventDefault()
this.clicked()
})
this.debug('MyButtonClass starting')
}
sleep() {
this.debug('MyButtonClass sleep called')
super.sleep()
}
enterViewport() {
this.debug('MyButtonClass entered viewport!')
}
clicked() {
const frame = () => {
this.debug('clicked!')
this.addClass('clicked')
}
this.queueFrame(frame)
setTimeout(() => {
this.debug('removing MyButtonClass element')
this.element.remove()
}, 2000)
}
debug(message) {
document.getElementById('debug').append(message + '\n')
}
}
SargassoModule.utils.registerSargassoClass('MyButtonClass', MyButtonClass)
SargassoModule.utils.bootSargasso()
}
</script>
<div class="float">
<pre id="debug">
Sargasso element event log
------
ready
</pre>
</div>
</body>
</html>
Try It
Sargasso Base Class:
Properties
property | description |
---|
this.element | the element we are controlling |
Your Sargasso subclasses can subscribe to event feeds in order to be notified of events.
Methods to override as needed:
method | description |
---|
constructor(element, options = {}) | subscribe to services by setting appropriate options properties. All default to false so only set the ones you need to know about watchDOM , watchScroll , watchResize , watchOrientation , watchViewport eg. { watchScroll: true } |
start() | set up any interactions and event handlers |
sleep() | remove any event foreign handlers defined in start() and cleanup references - event handlers created with 'this.on' and 'this.once' are automatically removed by sargasso. |
don't forget to call super.xxx() in your subclass
Handlers for sargasso events, override as needed:
method | description |
---|
DOMChanged() | called when DOM changes if options 'watchDOM: true' was set in constructor |
didScroll() | called when scroll occurs if options 'watchScroll: true' was set in constructor |
didResize() | called when resize changes if options 'watchResize: true' was set in constructor |
enterViewport() | called when element is entering viewport if options 'watchViewport: true' was set in constructor |
exitViewport() | called when element is exiting viewport if options 'watchViewport: true' was set in constructor |
newPage(old, new) | on a new page |
didBreakpoint() | new screen width breakpoint |
workerOnMessage (id, data = {}) | id is the worker sending the message. Any payload from the worker postMessage is in data.xxx as defined by the worker |
observableChanged(id, property, value) | called on data change if watching an observable object |
enterFullscreen() | experimental called if options 'watchOrientation: true' when user rotates phone or if setFullscreen is called |
exitFullscreen() | experimental called on exit fullscreen |
CSS Methods
method | description |
---|
hasClass('classname') | returns true if this.element has cssclass |
addClass('classname') | add classname or array of classnames to this.element |
removeClass('classname') | remove classname or array of classnames to this.element |
setCSS({}) | set css pairs defined in object on this.element |
Register Event Methods
method | description |
---|
on(container,fn) | attach undelegated event handler to container scoped to a css selector |
once(container,fn) | attach undelegated event handler to container scoped to a css selector that executes only once (automatically removes event handler on first call) |
off(container) | remove undelegated event handler to container scoped to css selector |
on(container,selector,fn) | attach delegated event handler to container scoped to a css selector |
once(container,selector,fn) | attach delegated event handler to container scoped to a css selector that executes only once (automatically removes event handler on first call) |
off(container,selector) | remove delegated event handler to container scoped to css selector |
Utility Methods:
method | description |
---|
getMetaData(key) | return sargasso metadata associated with element (weak map) |
setMetaData(key,value) | set a sargasso metadata property |
isVisible() | true if element is visible |
Progressive Web App HIJAX Page Load
When HIJAX is enabled, Sargasso automatically captures <a href="..">
tags and calls the LoadPageHandler instead of allowing the browser load and replace entire pages natively. Usually a web site or app has a boilerplate html wrapper that is the same for every page and well defined content areas that change from page to page. When pages are loaded via HIJAX only the changed content is merged with the current page, replacing containers marked with data-hijax
leaving heavy weight wrapper elements, persistent javascript, css and sargasso elements intact. You can define as many dynamic elements in the wrapper as needed. Following this scheme allows for deep linking, and search engine discovery while also speeding page load for real browsers.
The Sargasso supervisor takes care of cleaning up any instantiated Sargasso element controllers in the old content by calling sleep() before the content is removed then sargasso elements in the new content are instantiated and start() is called. That way Sargasso element controllers can be cleanly managed on progressive web app pages without leaving dangling event handlers and memory leaks.
You can optionally make any link be ignored by hijax by setting the <a href=".." data-no-hijax>
. Offsite links and links with targets are automatically ignored.
<body>
<p>this is static content like header and navigation</p>
<div id="content" data-hijax>
<p>This is dynamic - it changes from page to page</p>
</div>
<p>this is also static content such as a site wide footer</p>
<script src='https://cdn.jsdelivr.net/npm/@pelagiccreatures/sargasso/dist/sargasso.iife.min.js'></script>
<script defer>
window.onload = () => {
let options = {
hijax: {
onError: (level, message) => {
alert('Something went wrong. ' + message)
}
}
}
SargassoModule.utils.bootSargasso(options)
}
</script>
</body>
Try It
Note: data-hijax
elements must have and ID and contain well formed child html elements.
<div id="nope" data-hijax>I'm just text. No child elements. Won't work.</div>
<div id="yup" data-hijax><p>I'm html. This works.</p></div>
Programatic Page Loading
SargassoModule.loadPageHandler(href)
is the utility function for programmatically loading a new page. EG. instead of location.href= '/home'
, use LoadPageHandler('/home')
This can be called to reload the page as well.
Content Merging Fine Control
Set the data-hijax-skip-unchanged
attribute on the hijax container and the content will remain static unless the markup is changed. This is useful if you have a Sargasso element that should remain instantiated and hold state when traversing several pages in a section.
<div id="test" data-hijax data-hijax-skip-unchanged>
<p>This content also sometimes changes from page to page, otherwise leave it alone.</p>
</div>
Set data-hijax-cache-key-selector
to a css selector of an element within the hijax container which has defined data-hijax-cache-key-selector
to leave the content intact across pages until the key changes.
<div id="test" data-hijax data-hijax-cache-key-selector="#sub-element">
<p id="sub-element" data-hijax-cache-key="some-key">This content uses a cache key to signal changes, otherwise leave it alone.</p>
</div>
Using Animation Frames
To avoid any chaotic repaints you should only make Content or DOM changes inside animation frames - don't do any long processes in the responsive callbacks or things might bog down the browser UI. Sargasso.queueFrame maintains a list of pending page updates which are executed in order using the animation frame loop.
method | description |
---|
queueFrame(function) | queue a function to execute that changes the DOM |
this.queueFrame(()=>{
this.removeClass('css-class,some-other-css-class')
this.addClass('css-class,some-other-css-class')
this.element.innerHTML = 'changed!'
})
ObservableObjects
Observable objects implement a notification scheme for data changes. (implementation is javascript Proxy and reflect) These objects can be shared across elements for real-time sharing and display of information.
method | description |
---|
observableStart (id, data) | start watching for changes in observable data. data is an optional JS data object |
observableStop (id) | stop watching for changes in observable data |
getObservable (id) | get underlying ObservableObject instance for id |
let args = {
name: 'World!',
cssClass: 'red'
}
let observable = new SargassoModule.ObservableObject('shared-data', args)
You can bind a function to the observable instance which is called on change:
let watch = (id, property, value) => {
console.log('id:' + id + ' property:' + property + ' is now:' + value)
}
observable.bind('uniqueID', watch)
observable.set('name','New Name')
observable.data.name = 'New Name'
Sargasso Elements can subscribe to notifications from ObservableObjects that are defined externally or owned by the element.
class MyClass extends SargassoModule.Sargasso {
start() {
super.start()
this.observableStart('shared-data');
}
observableChanged(id, property, value) {
}
}
Templates and Rendering
Complete example of an element that renders on data updates using an ObservableObject and li-html templates.
method | description |
---|
setRenderer (renderer) | set a rendering function for rendering (such as lit-html render) |
setTemplate (template) | set a template function for rendering (such as a lit-html template function) |
setTemplateArgs (args) | set template arguments |
render () | render template into element |
examples/example5.html
<!DOCTYPE html>
<html>
<head>
<title>Example Sargasso Element w/Data Observing & Template rendering</title>
<style>
.red { color: #f00; }
.green { color: #0f0; }
.blue { color: #00f; }
</style>
</head>
<body>
<h3>Example Sargasso Element w/Data Observing & Template rendering</h3>
<sargasso-my-class></sargasso-my-class>
<script src="https://cdn.jsdelivr.net/npm/@pelagiccreatures/sargasso/dist/sargasso.iife.min.js"></script>
<script defer type="module">
import {
html,render
} from 'https://unpkg.com/lit-html?module'
import {
repeat
} from 'https://unpkg.com/lit-html/directives/repeat.js?module'
window.onload = () => {
let args = {
name: 'World!',
cssClass: 'red',
list: [{id:1,name:'one'},{id:2,name:'two'},{id:3,name:'three'}]
}
let observed = new SargassoModule.ObservableObject('shared-data',args)
class MyClass extends SargassoModule.Sargasso {
start() {
super.start()
this.setRenderer(render)
this.setTemplate((args) => html`
<p class=${args.cssClass}>Hello ${args.name} (${args.cssClass})</p>
<strong>List</strong>
<ul>
${repeat(args.list, (item) => item.id, (item, index) => html`
<li>${index}: ${item.name}</li>
`)}
</ul>
`)
this.setTemplateArgs(this.observableStart('shared-data'))
}
}
SargassoModule.utils.registerSargassoClass('MyClass', MyClass)
SargassoModule.utils.bootSargasso()
let classes = ['red','green','blue']
let named = ['Bob','Carol','Ted','Alice']
setInterval(()=>{
observed.data.cssClass = classes[Math.floor(Math.random() * classes.length)]
observed.data.name = named[Math.floor(Math.random() * named.length)]
},1000)
}
</script>
</body>
</html>
Try It
Using managed Web Workers
You should offload compute heavy tasks to a new thread when possible.
method | description |
---|
workerStart(id, codeOrURL) | start a web worker with id. Ignored if worker id already installed. |
workerPostMessage(id, data {}) | send the worker tagged with id a message. the message must be an object which can have any structure you want to pass to the worker |
Sargasso controllers have built in managed Web Workers that can be defined in external scripts or inline code blobs simplifying the management of running workers.
The worker code runs when it receives an onmessage event.
A web worker, once installed, could be used by many instances so sargasso sets e.data.uid to the id on the instance that is invoking the worker which we need to pass back in the postMessage so we know who is who.
example/example4.html
<!DOCTYPE html>
<html>
<head>
<title>Example Sargasso Element</title>
</head>
<body>
<h3>First Sargasso Element</h3>
<sargasso-my-class data-name="custom" data-count-to="10">Will count to 10</sargasso-my-class>
<div data-sargasso-class="MyClass" data-name="div" data-count-to="20">Will count to 20</div>
<script src="https://cdn.jsdelivr.net/npm/@pelagiccreatures/sargasso/dist/sargasso.iife.min.js"></script>
<script defer>
window.onload = () => {
class MyClass extends SargassoModule.Sargasso {
start() {
super.start()
const task = `let counters= {}
onmessage = async (e) => {
if(!counters[e.data.uid]) { counters[e.data.uid] = e.data.count }
setInterval(()=>{
self.postMessage({ uid: e.data.uid, me:e.data.me, count: ++counters[e.data.uid] })
},1000)
}`
this.workerStart('mytask', task)
this.workerPostMessage('mytask', {
me: this.element.getAttribute('data-name'),
count: 0
})
}
workerOnMessage (id, data) {
if(id === 'mytask') {
if(data.count == this.element.getAttribute('data-count-to')) {
this.stopWorker('mytask')
}
this.queueFrame(()=>{
this.element.innerHTML = data.me + ' says ' + data.count
})
}
}
}
SargassoModule.utils.registerSargassoClass('MyClass', MyClass)
SargassoModule.utils.bootSargasso()
}
</script>
</body>
</html>
Try It
Serving modules from your project
npm install @pelagiccreatures/sargasso --save-dev
You can use the .iife.js bundles in the /dist directory of the @PelagicCreatures modules by copying them to a public directory on your server and referencing them in script tags in your html.
node_modules/@PelagicCreatures/Sargasso/dist/sargasso.iife.js
-or-
You can also bundle sargasso modules with your own es6 code using rollup.
npm install npx -g
npm install rollup --save-dev
npm install @rollup/plugin-json --save-dev
npm install @rollup/plugin-commonjs --save-dev
npm install @rollup/plugin-node-resolve --save-dev
npm install rollup-plugin-terser --save-dev
app.js root browser javascript app for bundle
import { Sargasso, utils, loadPageHandler } from '@pelagiccreatures/sargasso'
const boot = () => {
utils.bootSargasso({})
}
class MyClass extends Sargasso {
start() {
this.queueFrame(() => {
this.element.innerHTML += ' <strong>Started!</strong>'
})
super.start()
}
}
utils.registerSargassoClass('MyClass',MyClass)
export {
boot
}
html
<!DOCTYPE html>
<body>
<div data-sargasso-class="MyClass">Hello World</div>
<script src="public/dist/js/userapp.iife.js" defer></script>
<script defer>
window.onload= () => {
App.boot()
}
</script>
</body>
</html>
Create a rollup config file
Set input and output ass needed.
rollup.config.js
import commonjs from '@rollup/plugin-commonjs'
import nodeResolve from '@rollup/plugin-node-resolve'
import json from '@rollup/plugin-json'
import {
terser
}
from 'rollup-plugin-terser'
export default {
input: './app.js',
output: {
format: 'iife',
file: 'public/dist/js/userapp.iife.js',
name: 'App',
sourcemap: true,
compact: true
},
plugins: [
json(),
commonjs({}),
nodeResolve({
preferBuiltins: false,
dedupe: (dep) => {
return dep.match(/^(@pelagiccreatures|lodash|js-cookie)/)
}
}),
terser({
output: {
comments: false
}
})
]
}
Make the bundle
npx rollup --no-treeshake --no-freeze -c rollup.config.js
Tests
The hijax scheme does not work for file://xxx URIs so start a simple server on localhost:
python tests/localhost.py
Then run the tests:
npm test
-or-
point your browser to http://localhost:8000/tests/index.html to see it all in action