It just makes my life a little simpler
You'll find the following sections in this document:
- What is taproot
- Quick start
- Installation
- Setup
- Writing an Application
- Advanced concepts
- Components
- Application architecture
- EMPA
- SPA
- The View helper
- Storage
- IndexedDB
- LocalStorage
- SessionStorage
- Event sequences
- Advanced installation
- Use esbuild
- Other bundlers
taproot is a framework in the truest sense of the word.
taproot provides everything you need to get an application with sound architectural principles running, but doesn't provide any of the parts of that application.
Imagine that your application is a house you'd like to build; taproot pours the foundation and builds scaffolding. You bring the rest.
```
npm install -E @thomasrandolph/taproot
taproot ships native ES modules using bare module specifiers. If you don't already have a process that supports resolving bare module specifiers, you may want to read the Advanced Installation section.
Alternatively, see the Setup section for examples that use a version hosted by esm.sh.
taproot works in a minimal mode with zero configuration.
`html`
By default taproot attaches itself to the global scope as window.taproot.
This is obviously undesireable in most cases, so a namespace can be provided.
`js`
await setup( {
"namespace": "MyApp"
} );
Now your application runtime is accessible from window.MyApp. window.NAMESPACE
In either case, taproot assigns your namespace to , so it never needs to be hard-coded anywhere else.
`js`
console.log( window[ window.NAMESPACE ].startupOptions );
If you run the above code, you'll notice that db is false, but routing is true.
By default, taproot assumes that your application will have multiple routes.
For now, we can turn routing off to simplify our application.
`js`
await setup( {
"namespace": "MyApp",
"routing": false
} );
Now your application exists, but none of the useful features are available!
This is like pouring the foundation, but not setting up the scaffolding.
----
You might want to wait until you've completed other tasks before you start your application, so by default taproot is idle.
To start an idle taproot instance, you call .start().
`js
var MyApp = await setup( {
"namespace": "MyApp",
"routing": false
} );
MyApp.start();
`
To see the event-driven power of taproot in action, let's subscribe to an event and then start the application.
`js
async function start(){
var MyApp = await setup( {
"namespace": "MyApp",
"routing": false
} );
MyApp.subscribeWith( {
"TAPROOT|START": () => {
document.body.innerHTML = "My application started up!";
}
} );
MyApp.start();
}
start();
`
`js
import { setup } from "https://esm.sh/@thomasrandolph/taproot";
async function start(){
var MyApp = await setup( {
"namespace": "MyApp",
"routing": {
"routes": [
[
"/",
() => {
document.body.innerHTML = Hello, world! Go to /account/[your name]!;Hello, ${context.params.name}
}
],
[
"/account/:name",
( context ) => {
document.body.innerHTML = ;
}
]
]
}
} );
MyApp.start();
}
start();
`
Now you have a working application with routing!
But we should leverage the powerful message bus provided by taproot.
`js
import { setup } from "https://esm.sh/@thomasrandolph/taproot";
async function start(){
var MyApp = await setup( {
"namespace": "MyApp",
"routing": {
"routes": [
[ "/", () => MyApp.publish( { "name": "VIEW_CHANGE", "view": "home" } ) ],
[ "/account/:name", ( context ) => MyApp.publish( { "name": "VIEW_CHANGE", "view": "account", context } ) ]
]
}
} );
MyApp.subscribeWith( {
"VIEW_CHANGE": ( message ) => {
let views = {
"home": () => document.body.innerHTML = Hello, world!
Go to /account/[your name],Hello, ${msg.context.params.name}
"account": ( msg ) => document.body.innerHTML = ,
"error": () => document.body.innerHTML = "There was an error trying to route you."
};
if( views[ message.view ] ){
views message.view ;
}
else{
views.error();
}
}
} );
MyApp.start();
}
start();
`
Almost any site could use components to break up unrelated code into encapsulated chunks, or enable convenient reuse.
To that end, taproot provides a simple base Component class to construct these basic application building blocks.
Most of the syntax you'll see is just Lit, since the taproot Component class is a very small wrapper around Lit.
> Be careful that you're not over-componentizing. Ask yourself:
> - Is splitting this code into two separate components necessary?
> - Does componentizing this area of the code improve the application in the long run?
> - Will you ever reuse this component anywhere else?
>
> Keep in mind that the more components you have, the more JavaScript overhead there is in the browser.
> Keep your performance budget front-of-mind.
----
Imagine you want to build an application to view and rate pups.
You will almost certainly need a component that displays a pup to the screen, so let's make that now:
`js
import { Component, html } from "https://esm.sh/@thomasrandolph/taproot/Component";
export class Pup extends Component{
static properties = {
"pup": { "type": Object }
}
constructor(){
super();
this.pup = null;
}
render(){
return this.pup
? html
${this.pup.biography}
: "No pup. ☹️";
}
}
`This component is a native Web Component, so you could register it like:
`js
customElements.define( "pupsrater-pup", Pup );
`Now, when you render a pup, you can pass it a pup like:
`html
`> Note that this uses Lit's
html template expression syntax.
>
> The equivalent code without Lit is:
>
> `html
>
>
> `$3
taproot can be used in any application architecture, but the recommended approach is an Enhanced Multi-Page Application (EMPA).
#### EMPA
In an EMPA architecture, each main "page" is provided by a server and your JavaScript is loaded once the page has been parsed by the browser.
Both sides of that process should be fast, which is why taproot tries to stay as small and fast as possible without sacrificing features.
The server-side should also stay fast and light.
----
Let's go back to the pup rating app from the Component topic.
You might have three main pages for this application:
1. A user profile page where the user can manage their account and see all of the dog ratings they have submitted
2. A dog profile page where users can see dogs and rate them
3. An index of all dogs
Your folder structure could be something like:
`
public/
⎣ js/
⎣ profile/
⎣ pups/
⎣ pup/
⎣ index.html
⎣ index.js
`And each page file might look something like this example
pup/index.html file:
`html
`Of course, for other pages, the top-level component might be
or instead of .----
In the case of the Pup page, you're going to need to tell the browser what a
component is, so pup/index.js might look like: `js
import { Pup } from "../js/components/Pup/Pup.js";customElements.define( "pupsrater-pup", Pup );
`> In this case, we're assuming a very simple structure where the JavaScript for your application lives right next to the folders for your pages.
>
> A more structured application might store
pages within your source and compile (and bundle) each index.js out to a cloned directory in the public folder so that the source code isn't public, only compiled pages are.----
Notably, this Pup page can start our whole cascade of imports and behavior contained within the
pupsrater-pup component, but those are all UI concerns. There are two more concerns that we should definitely handle here at the "application startup" level: Routing and "Business Logic"."Business Logic" is used here broadly to encompass something roughly like "everything your app does that's not application infrastructure and not UI." You could think of it like "everything that happens automatically AFTER the application is running or manually after a user does something."
In our A-pup-lication, a single "Pup" page for all the many pups we want to rate isn't going to cut it, so we'll need a tiny bit of routing to figure out _which_ pup we're looking at. We'll expand the above
/pup/index.js file:`js
import { setup } from "https://esm.sh/@thomasrandolph/taproot";
import { Pup } from "../js/components/Pup/Pup.js";customElements.define( "pupsrater-pup", Pup );
var pupsrater = setup( {
"namespace": "pupsrater",
"routing": {
"routes": [
[
"/pup/:id",
( context ) => {
pupsrater.publish( "VIEW_PUP", { "pupId": context.params.id } );
}
],
[
"/pup", // Single pup page, but no pup id?
() => window.location = "/pups"; // Redirect to the main pups list
]
]
}
} );
pupsrater.start();
`Now we've got a bit of front end magic for a dynamic route, but nothing can handle that.
Let's update our demo
Pup component (js/components/Pup/Pup.js) from the Component topic to respond to VIEW_PUP events to show the right pup.`js
import { Component, html } from "https://esm.sh/@thomasrandolph/taproot/Component";export class Pup extends Component{
static properties = {
"pup": { "type": Object },
"retries": { "type": Number, "state": true },
"subscriptions": { "type": Array, "state": true }
}
constructor(){
super();
this.pup = null;
// Internal state
this.retries = 0;
this.subscriptions = [];
}
connectedCallback(){
super.connectedCallback();
this.subscriptions.push( window.pupsrater.subscribeWith( {
"VIEW_PUP": ( event ) => {
this.fetchPup( event.pupId );
}
} ) );
}
disconnectedCallback(){
this.subscriptions.forEach( ( subscription ) => {
subscription.unsubscribe();
} );
super.disconnectedCallback();
}
fetchPup( pupId ){
var pupUrl =
/api/pup/${pupId};
// Snipped for brevity, this is the same rest of the function as before
} render(){
// Snipped for brevity, same as before
}
}
`Note that the only changes here are the concept of "subscriptions" within the component - which are active when the component is in the DOM and removed when the component is removed from the DOM - and the
fetchPup function now takes the pup ID to fetch instead of having it hard coded. The only subscription here listens for our new
VIEW_PUP event and loads the correct pup internally.----
Finally, we should clean up "business logic" from within this UI component; it's not exactly appropriate that UI components might make a network request (or do any other "application" work other than render an interface and react to user interaction). Plus, fetching (and responding to errors while fetching) a pup might be something that other parts of the application might need to do.
taproot suggests extracting these kinds of behaviors out to a common directory like
actions, which borrows from Flux-style global variables to name and define all potential behaviors.`
public/
⎣ js/
❘ ⎣ components/
❘ ⎣ actions/
❘ ⎣ pups.js
⎣ profile/
⎣ pups/
⎣ pup/
⎣ index.html
⎣ index.js
`In
js/actions/pups.js, we'll define a common registration function and the potential actions we want to abstract:`js
export function registerPupsActions( { publish, subscribeWith } = window.pupsrater ){
return subscribeWith( {
"VIEW_PUP": ( event ) => {
let tries = event.previousTries || 0;
let sequenceId = event.tx;
let pupUrl = /api/pup/${event.pupId};
let pup; try{
pup = await fetch( pupUrl );
publish( {
"tx": sequenceId,
"name": "LOADED_PUP",
pup
} );
}
catch( error ){
publish( {
"tx": sequenceId,
"name": "LOAD_PUP_FAILURE",
error
} );
if( tries < 2 ){
++tries;
publish( {
...event,
"previousTries": tries
} );
}
}
}
} );
}
`Then, we'll pupdate our a-pup-lication entry module (
/pup/index.js) to load those actions and register them:`js
import { setup } from "https://esm.sh/@thomasrandolph/taproot";import { registerPupsActions } from "../js/actions/pups.js";
import { Pup } from "../js/components/Pup/Pup.js";
customElements.define( "pupsrater-pup", Pup );
var pupsrater = setup( {
"namespace": "pupsrater",
"routing": {
"routes": [
[
"/pup/:id",
( context ) => {
pupsrater.publish( "VIEW_PUP", { "pupId": context.params.id } );
}
],
[
"/pup", // Single pup page, but no pup id?
() => window.location = "/pups"; // Redirect to the main pups list
]
]
}
} );
registerPupsActions( pupsrater );
pupsrater.start();
`As a last step of cleaning up the business logic, let's pull the network fetching out of the UI component (
js/components/Pup/Pup.js) and rely on events:`js
import { Component, html } from "https://esm.sh/@thomasrandolph/taproot/Component";export class Pup extends Component{
static properties = {
"pup": { "type": Object },
// No more tracking of retries here!
"subscriptions": { "type": Array, "state": true }
}
constructor(){
super();
this.pup = null;
// Internal state
// No more setting the initial retries count here!
// Snipped for brevity, same as before
}
connectedCallback(){
super.connectedCallback();
this.subscriptions.push( window.pupsrater.subscribeWith( {
"VIEW_PUP": ( event ) => {
let transition = event.previousTries > 0 ? "RETRYING" : "LOADING";
this.currentFiniteState = transition;
},
"LOADED_PUP": ( { pup } ) => {
this.pup = pup;
this.currentFiniteState = "LOADED";
},
"LOAD_PUP_FAILURE": () => this.currentFiniteState = "ERROR"
} ) );
}
disconnectedCallback(){
// Snipped, same as before
}
// fetchPup method is removed!
render(){
// Snipped, same as before
}
}
`#### SPA
A Single Page Application architecture would look roughly like the EMPA architecture, with a couple key differences.
> Consider: Does your application require a single page? Ask yourself:
> - Could I make more of my content compiled ahead of time, so that a server round trip is fast?
> - Have I prepared for the potential of long-running memory leaks, and factored that into my performance and maintenance budget?
> - Would upcoming platform-native View Transitions smooth any potential rough edges instead?
For a single page, your application obviously wouldn't need any single-page directories.
`
public/
⎣ js/
❘ ⎣ myapp.js
⎣ index.html
`How you get
js/myapp.js is up to you, but - for production sites - it is recommended that you compile to that target, rather than shipping your source code.In this scenario,
index.html could simply be:`html
`> Note the
defer attribute.
> While the HTML parser will encounter your application script early, it will not block on loading it, and it won't execute it until the DOM has been fully parsed.Then, in
js/myapp.js you could determine what top-level component to render (instead of the HTML content having it already, like in the EMPA architecture):`js
import { setup } from "https://esm.sh/@thomasrandolph/taproot";
import { html, render } from "https://esm.sh/@thomasrandolph/taproot/Component";import { Profile } from "../js/components/Profile/Profile.js";
import { Pup } from "../js/components/Pup/Pup.js";
import { PupsList } from "../js/components/PupsList/PupsList.js";
customElements.define( "pupsrater-profile", Profile );
customElements.define( "pupsrater-pup", Pup );
customElements.define( "pupsrater-pups-list", PupsList );
function renderTopComponent( byName ){
var components = {
"profile": html
,
"pup": html,
"pups-list": html
} render( components[ byName ], document.body );
}
var pupsrater = setup( {
"namespace": "pupsrater",
"routing": {
"routes": [
[
"/pup/:id",
( context ) => {
renderTopComponent( "pup" );
pupsrater.publish( "VIEW_PUP", { "pupId": context.params.id } );
}
],
[
"/pup", // Single pup page, but no pup id?
() => window.location = "/pups"; // Redirect to the main pups list
],
[
"/pups",
() => renderTopComponent( "pups-list" );
],
[
"/profile",
() => renderTopComponent( "profile" );
]
]
}
} );
pupsrater.start();
`Keep in mind that there is much inefficiency here as a result of the single page nature of this application.
One simple improvement might be to expand the functionality of
renderTopComponent so that the component itself is imported dynamically and registered only when it is needed.`js
import { setup } from "https://esm.sh/@thomasrandolph/taproot";
import { html, render } from "https://esm.sh/@thomasrandolph/taproot/Component";// The components are not imported or defined here, like they were before!
async function renderTopComponent( byName ){
var components = {
"profile": [
"../js/components/Profile/Profile.js",
"Profile",
"pupsrater-profile",
html
,
],
"pup": [
"../js/components/Pup/Pup.js",
"Pup",
"pupsrater-pup",
html,
],
"pups-list": [
"../js/components/PupsList/PupsList.js",
"PupsList",
"pupsrater-pups-list",
html,
]
}
var [ registerPath, className, definitionName, output ] = components[ byName ]; var mod = await import( registerPath );
customElements.define( definitionName, mod[ className ] );
render( output, document.body );
}
// The rest remains as before
`> Keep in mind that much of this dynamicism could be achieved with an EMPA architecture.
> Most of this code "duplicates" what a browser with static HTML and a per-page JavaScript file could do implicitly.
##### The View helper
taproot provides a simple helper called
render to very slightly simplify rendering a new top-level component.`js
import { render } from "https://esm.sh/@thomasrandolph/taproot/View";render( "pupsrater-pup" );
`You could update
renderTopComponent again to use this instead of Lit's html and render. `js
import { render } from "https://esm.sh/@thomasrandolph/taproot/View";async function renderTopComponent( byName ){
var components = {
"profile": [
"../js/components/Profile/Profile.js",
"Profile",
"pupsrater-profile"
],
"pup": [
"../js/components/Pup/Pup.js",
"Pup",
"pupsrater-pup"
],
"pups-list": [
"../js/components/PupsList/PupsList.js",
"PupsList",
"pupsrater-pups-list"
]
}
var [ registerPath, className, definitionName ] = components[ byName ];
var mod = await import( registerPath );
customElements.define( definitionName, mod[ className ] );
render( definitionName );
}
`> In most cases, Lit's
render is more efficient than other DOM rendering utilities - like taproot's render.
>
> However, because the top-level component is the only child, a direct replacement like how taproot's render works isn't materially worse.taproot's
render skips modifying the DOM if the target element already contains the requested element name.
Regardless of whether the DOM is updated or not, the requested element is returned, which means you can perform actions on the component.`js
import { render } from "https://esm.sh/@thomasrandolph/taproot/View";var Pup = render( "pupsrater-pup" );
Pup.pup = {
"name": "Good Boi",
"profilePicture": "/images/pups/good-boi-1234.jpg",
"biography": "He's a very good boi."
};
`> If you directly modify a component this way, make sure your changes don't require statefulness.
>
> As a general rule, you should only set properties on the component, which is the standard API that would be used when rendering HTML normally.
$3
#### IndexedDBDocumentation coming soon.
#### LocalStorage
Documentation coming soon.
#### SessionStorage
Documentation coming soon.
$3
Documentation coming soon.
Advanced Installation
$3
1. Install esbuild:
npm install -E -D esbuild
2. Run a script for all your dependencies:
`js
import { build } from "esbuild"; async function run(){
await build( {
"entryPoints": [
"./node_modules/@thomasrandolph/taproot",
// Etc.
],
"allowOverwrite": true,
"bundle": true,
"splitting": true,
"chunkNames": "common/[hash]",
"format": "esm",
"outdir": "./web_modules/"
} );
}
run();
`
3. When you import the module, use web_modules/@thomasrandolph/taproot.js
4. Repeat step 2 whenever your dependencies change (this can be automated, for example as a postinstall hook)> Optional: Consider emptying the
./web_modules` directory entirely before building your dependencies to clean up vestigial packages and chunks.Any combination of bundlers and transpilers can resolve bare module specifiers into something that works on the web.
As long as your bundler understands ECMAScript Modules (the standard module specification for JavaScript), you can use taproot in your build pipeline.