- No transpilation – It's all plain vanilla JavaScript
- Small size – Weighing in at
3kb
, you'll barely notice it - Minimal API – Only a handfull functions to learn
- No magic – Prototypal state and events
- It's fast – Both on server and client
import { html, mount, use, Component } from 'https://cdn.skypack.dev/yeet@next'
mount('body', Component(App))
function App (state, emit) {
use(store)
return function () {
return html`
<body>
<p>Clicked ${state.count} times</p>
<button onclick=${() => emit('increment'))}>Click me</button>
</body>
`
}
}
function store (state, emitter) {
state.count = 0
emitter.on('increment', function () {
state.count++
emitter.emit('render')
})
}
Building interactive and performant websites shouldn't require a whole lot of dependencies, a bundler, or even Node.js for that matter. The JavaScript language has all the capabilities required built right in, without sacrificing either developer or user experience.
Frameworks are tools and tools should be interchangeable and easy to replace. That's why yeet rely on the lowest common denominator – the DOM. There are no unneccessary abstractions such as virtual DOM, synthetic events or template syntax to learn. Only functions and prototypes.
If you know JavaScript you already know most there is to know about yeet. And anything new you learn from using yeet is directly benefitial to anything else you might want to use JavaScript for.
The state object in yeet is shared between components using prototypes. You can think of the state object as a shared context which components can use to read from and write to.
However, a component can only ever mutate its own state, it can only read from the parent state, yet they are the same object – what?! This is achieved using prototypes. The prototype of a component's state object is the parent component's state object.
About prototypal inheritance
JavaScript prototypes are the mechanism for inheriting properties and behavior from one object to another. What is facinating about prototypes is that they are live – meaning that any change made to an object is immediately made available to all other objects whose prototype chain includes said object.
const parent = {}
const child = Object.create(parent)
parent.name = 'world'
console.log(`Hello ${parent.name}`) // Hello world
console.log(`Hello ${child.name}`) // Hello world
child.name = 'planet'
console.log(`Hello ${parent.name}`) // Hello world
console.log(`Hello ${child.name}`) // Hello planet
Read more about Object prototypes.
To modify a parent state object, one can use events to communicate up the component tree (or prototype chain, if you will).
Events are the core mechanism for communication up the component tree. Yeet adhers to the dogma "data down, events up", which is to say that data should be passed down the component tree, either with state or as arguments. When something happens, e.g. the user clicks a button, an event should be emitted which bubbles up the component tree, notifying components which may then mutate their state and issue a re-render.
Components can be usefull in situations when you need a locally contained state, want to use some third party library or want to know when components mount or unmout in the DOM.
Components in yeet use generator functions to control the
component lifecycle. By using generators yeet can step through your component
and pause execution until the appropiate time, e.g. when the component has
updated or is removed from the DOM. This allows you to retain local variables
which persist throughout the component lifespan without meddling with this
or
learning new state management techinques, they're just regular ol' variables.
import { html, ref, mount, Component } from 'https://cdn.skypack.dev/yeet@next'
import mapboxgl from 'https://cdn.skypack.dev/mapbox-gl'
const state = { center: [18.0704503, 59.3244897] }
mount('#app', Component(Map), state)
function * Map (state, emit) {
const container = ref()
let map
yield function * () {
yield html`<div id="app" ref=${container}></div>`
map = map || new mapboxgl.Map({
container: container.current,
center: state.center
})
}
map.destroy()
}
Using generators allows you to keep local variables accessible throughout the component lifecycle. If you are already familiar with generators there's not really that much to learn.
If you are new to generators, learning yeet will only further build your JavaScript toolset, there is nothing here which you cannot use in other contexts.
A generator function is a special kind of function which can pause execution
midway and allows us to inspect intermediate values before procceding with
execution. A generator function has two caracteristics which set it appart from
regular functions, and asterics (*
) after the function
keyword and the
yield
keyword.
The anatomy of a generator function
// ↓ This thing makes it a generator function
function * createGenerator (list) {
for (const num of list) {
yield num // ← Pause here
}
return 'finished!'
}
// ↓ Call it like any other function
const generator = createGenerator([1, 2, 3])
// We can now step through the generator
generator.next() // { value: 1, done: false }
generator.next() // { value: 2, done: false }
generator.next() // { value: 3, done: false }
generator.next() // { value: 'finished!', done: true }
By yielding in a yeet component you are telling yeet to halt execution and save the rest of the function for later, e.g. when the component has updated or when it is removed from the DOM. A yeet component's lifecycle is thereby clearly laid out in chronological order, from top to bottom.
Generators are used to declare the lifecycle of yeet components. Only functions,
html partials (returned by the html
and svg
tags) and promises carry any
special meaning when using yield
. When a yeet component yields a function,
that is the function which will be used for any consecutive re-renders. Anything
that comes after yield
will be executed once the components is removed from
the DOM (e.g. replaced by another element).
function * MyComponent () {
// Happens only once, during setup
yield function () {
// Happens every time the component updates
}
// Happens only once, when the component is removed/replaced
}
They yielded function may also be a generator function. This can be used to perform side effects such as setting up subscriptions, manually manipulating the DOM or initializing some third party library. This is handled asynchrounously, meaning the DOM will have updated and the changes may have been made visible to the user before the generator finishes.
function MyComponent () {
return function * () {
// Happens before every update
yield html`<h1>Hello planet!</h1>`
// Happens after every update
}
}
If you require immediate access to the rendered element, e.g. to synchronously mutate or inspect the rendered element before the page updates, you may yield yet another function.
Note: Use with causion, this may have a negative impact on performance.
function MyComponent () {
return function () {
return function * () {
// Happens before every update
yield html`<h1>Hello planet!</h1>`
// Happens SYNCHRONOUSLY after every update
}
}
}
Even though all components have access to the shared state, you'll probably need
to supply your components with some arguments to configure behavior or forward
particular properties. You can either provide extra arguments to the Component
function or you can call the function returned by Component
with any number of
arguments.
function Reaction (state, emit) {
// ↓ Arguments are provided to the inner function
return function ({ emoji }) {
return html`<button onclick=${() => emit('reaction', emoji)}>${emoji}</button>`
}
}
// ↓ Declare component on beforehand
const ReactionComponent = Component(Reaction)
// ↓ Declare component and arguments on beforehand
const SadReaction = Component(Reaction, { emoji: '😢' })
html`
<form>
${Component(Reaction, { emoji: '😀' })} <!-- ← Declare component inline -->
${ReactionComponent({ emoji: '😐' })}
${SadReaction}
</form>
`
Components can yield any value but if you yield a Promise yeet will await the promise before it continues to render. On the server, rendering is asynchronous by design, this means that all promises are resolved as the component renders. Rendering in the browser behaves a little differently. While awaiting a promise nothing will be rendered in place of the component. Once all yielded promises have resolved (or rejected) the component will finish rendering and the element will appear on the page.
Yeet does not make any difference between promises which resolve or reject, you will have to catch and handle rejections accordingly, yeet will just forward the resolved or rejected value.
import fetch from 'cross-fetch'
import { html, use } from 'yeet'
function User (state, emit) {
const get = use(api) // ← Register api store with component
return function () {
// ↓ Expose the promise to yeet
const user = yield get(`/users/${state.user.id}`)
return html`
<body>
<h1>${user.name}</h1>
</body>
`
}
}
function api (state, emit) {
if (!state.cache) state.cache = {} // ← Use existing cache if available
// ↓ Return a function for lazily reading from the cache
return function (url) {
if (url in state.cache) return state.cache[url] // ← Read from cache
return fetch(url).then(async function (res) {
const data = await data.json()
state.cache[url] = data // ← Store response in cache
return data // ← Return repsonse
})
}
}
In most situations yeet does an excellent job at keeping track of which
component goes where. This is in part handled by identifying which template tags
(the html
and svg
tag functions) are used. In JavaScript, template
literals are unique and yeet leverages this to keep track of which template tag
goes where.
When it comes to components, yeet uses your component function as a unique key to keep track of which component is tied to which element in the DOM.
When it comes to lists of identical components, this becomes difficult and yeet
needs a helping hand in keeping track. In these situations, you can provide a
unique key
to each component which will be used to make sure that everything
keeps running smoothly.
function Exponential (state, emit) {
let exponent = 1
function increment () {
exponent++
emit('render')
}
return function ({ num }) {
return html`
<li>
<button onclick=${increment}>${Math.pow(num, exponent)}</button>
</li>
`
}
}
const numbers = [1, 2, 3, 4, 5]
return html`
<ol>
${numbers.map((num) => Component(Exponential, { num, key: num }))}
</ol>
`
Stores are the mechanism for sharing behavior between components, or even apps. A store can subscribe to events, mutate the local state and issue re-renders.
import { html, use, Component } from 'https://cdn.skypack.dev/yeet@next'
function Parent (state, emit) {
use(counter) // ← Use the counter store with this component
return function () {
return html`
${Component(Increment)}
<output>${state.count}</output>
${Component(Decrement)}
`
}
}
function Increment (state, emit) {
return html`<button onclick=${() => emit('increment')}>+</button>`
}
function Decrement (state, emit) {
return html`<button onclick=${() => emit('decrement')}>-</button>`
}
function counter (state, emitter) {
state.count = 0 // ← Define some initial state
emitter.on('increment', function () {
state.count++
emitter.emit('render')
})
emitter.on('decrement', function () {
state.count--
emitter.emit('render')
})
}
How you choose to name your events is entirely up to you. There's only one
exception: the render
event has special meaning and will re-render the closest
component in the component tree. The render
event does not bubble.
Yeet has first-class support for server rendering. There are plans to support server-rendered templates, meaning any backend could render the actual HTML and yeet would wire up functionality using the pre-existing markup.
Rendering on the server supports fully asynchronous components. If a component yields promises, yeet will wait for these promises to resolve while rendering.
Coming soon…
The API is intentionally small.
Create html partials which can be rendered to DOM nodes (or strings in Node.js).
import { html } from 'https://cdn.skypack.dev/yeet@next'
const name = 'planet'
html`<h1>Hello ${name}!</h1>`
Both literal attributes as well as dynamically "spread" attributes work. Arrays
will be joined with an empty space (
) to make it easier to work with many
space separated attributes, e.g. class
.
import { html } from 'https://cdn.skypack.dev/yeet@next'
const attrs = { disabled: true, hidden: false, placeholder: null }
html`<input type="text" class="${['foo', 'bar']}" ${attrs}>`
// → <input type="text" class="foo bar" disabled>
Events can be attached to elements using the standard on
-prefix.
import { html } from 'https://cdn.skypack.dev/yeet@next'
html`<button onclick=${() => alert('You clicked me!')}>Click me!</button>`
If you have lists of things you want to render as elements, interpolating arrays works just like you'd expect.
import { html } from 'https://cdn.skypack.dev/yeet@next'
const list = [1, 2, 3]
html`<ol>${list.map((num) => html`<li>${num}</li>`)}</ol>`
It's not always that you can or need to have an outer containing element. Rendering fragments works just like single container elements.
import { html } from 'https://cdn.skypack.dev/yeet@next'
html`
<h1>Hello world!</h1>
<p>Lorem ipsum dolor sit amet…</p>
`
The svg
tag is required for rendering all kinds of SVG elements, such as
<svg>
, <path>
, <circle>
etc. All the same kinds of behaviors as described in
html
apply to svg
.
import { svg } from 'https://cdn.skypack.dev/yeet@next'
svg`
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="50"/>
</svg>
`
If you have preformatted html that you wish to render, just interpolating them in the template won't work. Text that is interpolated in templates is automatically escaped to avoid common XXS attacks, e.g. injecting script tags.
import { html, raw } from 'https://cdn.skypack.dev/yeet@next'
const content = '<strong>Hello world!</strong>'
html`<div>${content}</div>`
// → <div><strong>Hello world!</strong></div>
html`<div>${raw(content)}</div>`
// → <div><strong>Hello world!</strong></div>
It's common to want to access elements in the DOM to mutate or read properties.
For this there is the ref
helper which, when called, will return an object
with the property current
which will be the currently mounted DOM node it was
attached to.
Note: This only works in the client, current
will never be available while
server rendering.
import { html, ref, render } from 'https://cdn.skypack.dev/yeet@next'
const div = ref()
render(html`<div ref=${div}>Hello planet!</div>`)
div.current // ← Reference to the rendered div element
Register a store to use with component. Accepts a function which will be called
with state
and emitter
(an instance of EventEmitter
).
Whatever is returned by the supplied function is returned by use
. You should
refrain from using use
anywhere but during the component setup stage.
Stores are great for sharing functionality between components. A shared store can be used to handle common operations on the shared state object or just to avoid duplicating code between components.
import { html, use, ref } from 'https://cdn.skypack.dev/yeet@next'
function * Video (state, emit) {
const video = ref()
const detach = use(pauser(video))
yield ({ src }) => html`<video src="${src}" ref=${video}></video>`
detach()
}
function pauser (video) {
return function (state, emitter) {
function onvisibilitychange () {
if (document.visibilityState === 'visible') {
video.current.play()
} else {
video.current.pause()
}
}
document.addEventListener('visibilitychange', onvisibilitychange)
return function () {
document.removeEventListener('visibilitychange', onvisibilitychange)
}
}
}
Mount a given html partial on a DOM node. Accepts a html partial, a DOM node or selector and optionally a root state object.
import { html, mount } from 'https://cdn.skypack.dev/yeet@next'
mount('body', html`
<body>
<h1>Hello planet!</h1>
</body>
`)
import { html, mount, Component } from 'https://cdn.skypack.dev/yeet@next'
mount(document.getElementById('app'), Component(Main), { name: 'world' })
function Main (state, emit) {
return html`
<main id="app">
<h1>Hello ${state.name}!</h1>
</main>
`
}
Render a partial to element (browser) or string (server). On the client, render
is synchronous and the resulting DOM node is returned. On the server render
always returns a promise which resolves to a string. Accepts an optional root
state object.
import { html, render } from 'https://cdn.skypack.dev/yeet@next'
const h1 = render(html`<h1>Hello planet!</h1>`))
document.body.appendChild(h1)
import { html, render } from 'yeet'
import { createServer } from 'http'
createServer(async function (req, res) {
const body = await render(html`<body>Hello world!</body>`)
res.end(body)
}).listen(8080)
import { Readable } from 'stream'
import { html, render } from 'yeet'
import { createServer } from 'http'
createServer(async function (req, res) {
Readable.from(html`<body>Hello world!</body>`).pipe(res)
}).listen(8080)
The Component function accepts a function as its first argument and any number of additional arguments. The additional arguments will be forwarded to the inner render function. The Component function returns a function which may be called with any number of arguments, these arguments will override whichever arguments were supplied prior.
It is best practice to provide an object as the first render argument since the
optional key
property is extracted from the first render argument.
import { html, render, Component } from 'https://cdn.skypack.dev/yeet@next'
function Greeting () {
return function (props, name = 'world') {
return html`<p>${props?.phrase || 'Hello'} ${name}!</p>`
}
}
render(Component(Greeting))
// → <p>Hello world!</p>
render(Component(Greeting, { phrase: 'Hi' }))
// → <p>Hi world!</p>
render(Component(Greeting, { phrase: 'Howdy' }, 'planet'))
// → <p>Howdy planet!</p>
const Greeter = Component(Greeting)
render(Greeter({ phrase: 'Nice to meet you,' }))
// → <p>Nice to meet you, world!</p>
Stores are called with state and an event emitter. The event emitter can be used
to act on events submitted from e.g. user actions. All events except the
render
event bubbles up the component tree.
You can register a catch-all event listener by attaching a listener for the *
event. The first argument to catch-all listeners is the event name followed by
the event arguments.
emitter.on('*', function (event, ...args) {
console.log(`Emitted event "${event}" with arguments:`, ...args)
})
Attach listener for the specified event name.
Remove the event listener for the specified event name.
Emit an event of the specified name accompanied by any number of arguments.
There wouldn't be a yeet if there hadn't been a choo. Yeet borrows a lot of the core concepts such as a shared state and event emitter from choo. The idea of performant DOM updates based on template literals was born from proof of concept work done by Renée Kooi.
[ ] Server-rendered templates (non-Node.js)