No-build JavaScript framework for Single-page applications
npm install shablonShablon - No-build JavaScript frontend framework
======================================================================
> [!CAUTION]
> This is mostly an experiment created for the planned PocketBase UI rewrite to allow frontend plugins support.
>
> Don't use it yet - it hasn't been actually tested in real applications and it may change without notice!
Shablon _("template" in Bulgarian)_ is a ~6KB JS framework that comes with deeply reactive state management, plain JS extendable templates and hash-based router.
Shablon has very small learning curve (4 main exported functions) and it is suitable for building Single-page applications (SPA):
- State: store(obj) and watch(trackedFunc, optUntrackedFunc)
- Template: t.tag
- Router: router(routes, options)
There is no dedicated "component" structure. Everything is essentially plain DOM elements sprinkled with a little reactivity.
Below is an example _Todos list "component"_ to see how it looks:
``js
function todos() {
const data = store({
todos: [],
newTitle: "",
})
// external watcher
const w = watch(() => {
console.log("new title:", data.newTitle)
})
return t.div({ className: "todos-list", onunmount: () => w.unwatch() },
t.h1({ textContent: "Todos" }),
t.ul({ style: "margin: 20px 0" },
() => {
if (!data.todos.length) {
return t.li({ rid: "notodos", textContent: "No todos." })
}
return data.todos.map((todo) => {
return t.li({ rid: todo, textContent: () => todo.title })
})
}
),
t.hr(),
t.input({ type: "text", value: () => data.newTitle, oninput: (e) => data.newTitle = e.target.value }),
t.button({ textContent: "Add", onclick: () => data.todos.push({ title: data.newTitle }) })
)
}
document.getElementById("app").replaceChildren(todos());
`
Example Svelte 5 equivalent code for comparison
_Shablon is not as pretty as Svelte but it strives for similar developer experience._
`svelte
Installation
example folder for a showcase of a minimal SPA with 2 pages.#### Global via script tag (browsers)
The default IIFE bundle will load all exported Shablon functions in the global context.
You can find the bundle file at
dist/shablon.iife.js (or use a CDN pointing to it):`html
`#### ES module (browsers and npm)
Alternatively, you can load the package as ES module either by using the
dist/shablon.es.js file or
importing it from npm.- browsers:
`html
`- npm (
npm -i shablon):
`js
import { t, store, watch, router } from "shablon" const data = store({ count: 0 })
...
`API
store(obj)
store(obj) returns a reactive Proxy of the specified plain object.The keys of an
obj must be "stringifiable" because they are used internally to construct a path to the reactive value.The values can be any valid JS primitive value, including nested plain arrays and objects (aka. it is recursively reactive).
Getters are also supported and can be used as reactive computed properties.
The value of a reactive getter is "cached", meaning that even if one of the getter dependency changes, as long as the resulting value is the same there will be no unnecessary watch events fired.
Multiple changes from one or many stores are also automatically batched in a microtask. For example:
`js
const data = store({ age: 49, activity: "work" })watch(() => {
console.log("age", data.age)
console.log("activity", data.activity)
})
// changing both fields will trigger the watcher only once
data.age++
data.activity = "rest"
`> Note that only plain objects and arrays are wrapped in a nested
Proxy! Date, Set, Map, WeakRef, WeakSet, WeakMap or any custom object will be resolved as they are to avoid access errors.
> You can access the original object without the Proxy trap using the special __raw key, e.g. data.someObj.__raw.someKey.
watch(trackedFunc, optUntrackedFunc)
Watch registers a callback function that fires on initialization and
every time any of its evaluated
store reactive properties change.It returns a "watcher" object that could be used to
unwatch() the registered listener._Optionally also accepts a second callback function that is excluded from the evaluated
store props tracking and instead is invoked only when
trackedFunc is called
(could be used as a "track-only" watch pattern)._For example:
`js
const data = store({ count: 0 })const w = watch(() => console.log(data.count))
data.count++ // triggers watch update
w.unwatch()
data.count++ // doesn't trigger watch update
`"Track-only" pattern example:
`js
const data = store({
a: 0,
b: 0,
c: 0,
})// watch only "a" and "b" props
watch(() => [
data.a,
data.b,
], (_) => { // receive the return result of trackedFunc
console.log(data.a)
console.log(data.b)
console.log(data.c)
})
data.a++ // trigger watch update
data.b++ // trigger watch update
data.c++ // doesn't trigger watch update
`
t.tag
t.tag constructs and returns a new DOM element (aka. document.createElement(tag)).tag could be any valid HTML element name - div, span, hr, img, registered custom web component, etc.attrs is an object where the keys are:
- valid element's JS property
_(note that some HTML attribute names are different from their JS property equivalent, e.g. class vs className, for vs htmlFor, etc.)_
- regular or custom HTML attribute if it has html- prefix _(it is stripped from the final attribute)_, e.g. html-data-nameThe attributes value could be a plain JS value or reactive function that returns such value _(e.g.
() => data.count)_.children is an optional list of child elements that could be:
- plain text (inserted as TextNode)
- single tag
- array of tags
- reactive function that returns any of the aboveWhen a reactive function is set as attribute value or child, it is invoked only when the element is mounted and automatically "unwatched" on element removal _(with slight debounce to minimize render blocking)_.
Lifecycle attributes
Each constructed tag has 3 additional optional lifecycle attributes:
-
onmount: func(el) - optional callback called when the element is inserted in the DOM
- onunmount: func(el) - optional callback called when the element is removed from the DOM
- rid: any - "replacement id" is an identifier based on which we can decide whether to reuse the element or not during rerendering (e.g. on list change); the value could be anything comparable with ==
router(routes, options)
router(routes, options = { fallbackPath: "#/", transition: true }) initializes a hash-based client-side router by loading the provided routes configuration and listens for hash navigation changes.routes is a key-value object where:
- the key must be a string path such as #/a/b/{someParam}
- value is a route handler function that executes every time the page hash matches with the route's path
_(the route handler can return a "destroy" function that is invoked when navigating away from that route)_Note that by default the router expects to have at least one "#/" route that will be also used as fallback in case the user navigate to a missing page.
For example:
`js
router({
"#/": (route) => {
document.getElementById(app).replaceChildren(
t.div({ textContent: "Homepage!"})
)
},
"#/users/{id}": (route) => {
document.getElementById(app).replaceChildren(
t.div({ textContent: "User " + route.params.id })
)
return () => { console.log("cleanup...") }
},
})
`router returns an optional destroy function that could be used to remove the already registered listeners, allowing you to initialize a new router.
Performance and caveats
No extensive testing or benchmarks have been done yet but for the simple cases it should perform as fast as it could get because we update only the targeted DOM attribute when possible _(furthermore multiple store changes are auto batched per microtask to ensure that watchers are not invoked unnecessary)_.
For example, the expression
t.div({ textContent: () => data.title }) is roughly the same as the following pseudo-code:`js
const div = document.createElement("div")
div.textContent = data.titlefunction onTitleChange() {
div.textContent = data.title
}
`Conditional rendering tags as part of a reactive child function is a little bit more complicated though.
By default when such function runs due to a store dependency change, the old children will be removed and the new ones will be inserted on every call of that function which could be unnecessary if the tags hasn't really changed.
To avoid this you can specify the
rid attribute which instructs Shablon to reuse the same element if the old and new rid are the same minimizing the DOM operations. For example:`js
const data = store({ count: 0, list: ["a", "b", "c"] })// ALWAYS replace the child tags on every data.count or data.list change
t.div({ className: "bad"},
() => {
if (data.count < 2) {
return t.strong({}, "Not enough elements")
}
return data.list.map((item) => t.div({}, item))
}
)
// replace the child tags on data.count or data.list change
// ONLY if the tags "rid" attribute has changed
t.div({ className: "good"},
() => {
if (data.count < 2) {
return t.strong({ rid: "noelems" }, "Not enough elements")
}
return data.list.map((item) => t.div({ rid: item }, item))
}
)
`Other things that could be a performance bottleneck are the lifecycle attributes (
onmount, onunmount) because currently they rely on a global MutationObserver which could be potentially slow for deeply nested elements due to the nature of the current recursive implementation _(this will be further evaluated during the actual integration in PocketBase)_.
Security
Shablon DOES NOT perform any explicit escaping on its own and it relies on:
- modern browsers to perform TextNode _(when a child is a plain string)_ and attributes value escaping out of the box for us
- developers to use the appropriate safe JS properties (e.g.
textContent instead of innerHTML)There could be some gaps and edge cases so I strongly recommend registering a Content Security Policy (CSP) either as
meta tag or HTTP header to prevent XSS attacks.
Why Shablon?
If you are not sure why would you use Shablon instead of Svelte, Lit, Vue, etc., then I'd suggest to simply pick one of the latter because they usually have a lot more features, can offer better ergonomics and have abundance of tutorials.
Shablon was created for my own projects, and more specifically for PocketBase in order to allow writing dynamically loaded dashboard UI plugins without requiring a Node.js build step.
Since I didn't feel comfortable maintaining UI plugins system on top of another framework with dozens other dependencies that tend to change in a non-compatible way over time, I've decided to try building my own with minimal API surface and that can be safely "frozen".
Shablon exists because:
- it can be quickly learned (4 main exported functions)
- it has minimal "magic" and no unsafe-eval (aka. it is Content Security Policy friendly)
- no IDE plugin or custom syntax highlighter is needed (it is plain JavaScript)
- the templates return regular JS
Element` allowing direct mutationsShablon is free and open source project licensed under the Zero-Clause BSD License _(no attribution required)_.
Feel free to report bugs, but feature requests are not welcomed.
There are no plans to extend the project scope and once a stable PocketBase release is published it could be considered complete.