A simple, modern approach to Obvervables and DOM re-rendering and patching.
Writing Javascript for the browser used to be simple. You wrote your code, and that same code ran in the browser. Your code is what was running in your application. You spent your time writing Javascript, not configuring tools.
Things have changed. Modern Javascript development requires ridiculous amounts of tooling and setup. Webpack, JSX, Virtual DOM, Babel, CLI boilerplates, component loaders, Style extractors, tree-shaking and on and on. Have you looked in your node_modules
directory recently? Have you ever seen the file size of your built app and wondered WTF is all that? How long will that take to parse before your first meaningful paint?
The thing is, WE DON'T NEED THIS ANYMORE. Evergreen browsers support the features we want that we have been Babeling and polyfilling in order to use. ES6 brought us Promises, Modules, Classes, Template Literals, Arrow Functions, Let and Const, Default Parameters, Generators, Destructuring Assignment, Rest & Spread, Map/Set & WeakMap/WeakSet and many more. All the things we have been waiting for It's all there!
So why are we still using build steps and mangling our beautiful code back to the stone age?
ReBars is around 2.8k gzipped and has no dependancies other than Handlebars!
ReBars started with the idea of so what do I actually need from a Javascript framework?
- âś… a templating language (Handlebars)
- âś… re-render individual DOM elements on data change
- âś… manage your event handling and scope
ReBars re-renders tiny pieces of your application on change. You are in control of what re-renders and when. There is no...
- ❌ Virtual DOM
- ❌ JSX or others that need pre-built to JS
- ❌ DOM diffing and patching
- ❌ Single File Components
- ❌ CSS pre-processing and extracting
Your code simply runs on your app.
In fact the only time ReBars will compare any DOM is when an Array is being patched. All other times ReBars simply calls the Handlebars method again, and replaces the HTML.
ReBars keeps your DOM in sync with your data using Proxy, and gets out of your way. You can get back to just writing Javascript.
The reason ReBars is so simple, is that it is in fact just a Handlebars instance with helpers added. The main one being watch. This marks elements, and tells ReBars when to re-render them.
If you have used Handlebars, you already know ReBars
export default {
template: /*html*/ `
<strong>
Button have been clicked
{{#watch}}
{{ clicked }}
{{/watch}}
times
</strong>
<button {{ on click="step" }}>Click Me</button>
`,
data: { clicked: 0 },
methods: {
step() {
this.clicked++;
},
},
};
A ReBars application is a Handlebars template rendered to a specified DOM element. You can event have more than one app on a page if you desire.
You will need Handlebars in order to use ReBars. You can install it from NPM or use a CDN.
Using a CDN
<script src="//cdn.jsdelivr.net/npm/handlebars@latest/dist/handlebars.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/re-bars@latest/dist/index.umd.min.js"></script>
Or using NPM
npm i --save-dev handlebars re-bars
import Handlebars from "handlebars";
import ReBars from "re-bars";
Or using browser esm modules
<script type="module">
import Handlebars from "//unpkg.com/handlebars-esm";
import ReBars from "//unpkg.com/re-bars";
</script>
To create an app, invoke the Rebars.app
function with an Object describing your application. (We will talk more about thes items in a sec).
{
Handlebars // Optional, Handlebars source, defaults to window.Handlebars
template: ``, // The Handlebars template string
data: {}, // data passed to your template
helpers: {}, // Handlebars helpers to add
partials: {}, // Handlebars partials to register
trace: true, // If true logs changes and re-renders to the console
}
This will return an Object containing
Key | Type | Description |
---|---|---|
instance | Object | the Handlebars instance the app is using |
render | Function | the function to render the app |
You then call render
passing in the selector for a target element to render to.
const app = ReBars.app(...your app definition);
app.render("#my-app");
If you would like use Handlebars from a source other than window
, you can pass your instance of Handlebars to the ReBars.app
function. This can be helpful for test setup.
import Handlebars from "somewhere";
ReBars.app({
Handlebars,
...
});
The template is a String that is your Handlebars template your application will use. It will be rendered with the helpers and data that you include in your application.
It is helpful to use a template literal so that you can have multiple lines in your template String.
export default {
template: /*html*/ `
<h1>{{ myName }}</h1>
`,
data: {
myName: "Dave"
}
};
ReBars includes a function to load your templates from external files. This can be super handy for breaking up your application, or in working with proper syntax highlighting in your editor of choice.
ReBars will wait for all templates to resolve before mounting your application.
ReBars.load
can also be used for loading partials as external files.
const { ReBars } = window;
export default {
template: ReBars.load("./template.hbs"),
data: {
myName: "Dave"
}
};
The data object you provide to your ReBars application is the core of what makes ReBars great.
Your data object is what is passed to your Handlebars template on render, and what is watched for changes with the watch, and triggers re-renders.
{
...
data: {
name: {
first: "David",
last: "Morrow"
}
}
}
You don't have to do anything special for ReBars to observe all changes to your data Object. In fact ReBar's observer is native Proxy
You can also return a method as a value from your data. This is a simple yet powerful feature that lets you return calculations based off your data's state at that point in time. You can even define methods at runtime, or nest them deeply within your data Object.
export default {
template: /*html*/ `
{{#watch "friends.length" tag="h3" }}
my friends: {{ allMyFriends }}
{{/watch}}
<input type="text" {{ ref "input" }}>
<button {{ on click="add" }}>Add</button>
`,
data: {
allMyFriends() {
return this.friends.join(", ");
},
friends: ["Mike", "David", "Todd", "Keith"],
},
methods: {
add({ rootData, $refs }) {
const $input = $refs().input;
this.friends.push($input.value);
$input.value = "";
},
},
};
Any method defined in your data Object will be scoped to your data object this
You cannot however watch a method from your data. You would need to watch the item or items in your data that the method relies on its computation for.
ReBars has the following hooks for use. These methods can be useful for manipulating initial data, instantiating 3rd party libraries ect.
They are called with the same scope as other functions in ReBars, this
being your data, and a parameter of context
Hook | Description |
---|---|
beforeRender |
Called right before your application renders for the first time. |
afterRender |
Called right after your application renders for the first time |
When using
beforeRender
hook, your DOM will not be available. It has not yet been rendered to the page. Context items such as$refs
and$app
are undefined.
data: {
name: "Dave",
},
hooks: {
afterRender({ $app, methods, rootData, $refs, $nextTick }) {
console.log(this); // { name: "Dave" }
}
}
If you would like to add helpers to your app you can pass a helpers Object to the ReBars.app
function.
ReBars.app({
helpers: {} // your custom helpers
...
});
The helpers operate just as any other Handlebars helper you would add. this
is the scope of the render block. more about Handlebars helpers here
In the example below, you would then be able to use your isChecked
helper anywhere in your application.
export default {
template: /*html*/ `
<label>
{{#watch}}
<input type="checkbox" {{ isChecked }} {{ on input="toggle" }}>
{{/watch}}
Is On
</label>
<button {{ on click="toggle" }}>Toggle</button>
`,
data: {
isOn: false,
},
methods: {
toggle(context) {
this.isOn = !this.isOn;
},
},
helpers: {
isChecked(context) {
if (this.isOn) return "checked";
},
},
};
ReBars simply registers these helpers for you to the Handlebars instance of your app. Should you want to register more helpers yourself instead of defining them in your app definition, you can do so using the instance returned from creating your app. It's the same thing.
const { instance } = ReBars.app(...);
instance.registerHelper("myCustomHelper", function () {
// helper code...
})
Methods define functions that can be called from event handlers, see on helper or can be called from another method in your application. This allows you to share code, and prevent redundant declarations.
When a method is trigged, it is called with the current scope of the template from where it was called this
, similar to how Handlebars helpers are called with this
as the scope of which the helper was triggered.
The first param when invoked is an object containing the following.
methods: {
myMethod({ event, $app, rootData, $refs, $nextTick, methods}) {
...
}
}
Key | Type | Description |
---|---|---|
event |
Event Object |
the event Object triggered from the UI interaction MouseEvent ect. |
$app |
Element |
the element that the app is rendered to. |
rootData |
Object |
the data at the root of your application. |
$refs |
Function |
$refs() returns all the elements in your application that have been marked with a ref |
$nextTick |
Function |
returns a Promise when called. Allows you to wait until after the next render to preform an action on the DOM |
methods |
Object |
the methods defined in your app. If called, they will be called with the same scope. |
If you call a method from another method. The scope remains the same. (the context in the template where the call originated)
Here is an example of chaining methods from within a ReBars application.
export default {
template: /*html*/ `
{{#each foods as | food | }}
<button {{ on click="isFavorite" }}>{{ food }}</button>
{{/each}}
{{#watch}}
{{ favorite }}
{{/watch}}
`,
data: {
favorite: null,
foods: ["pizza", "cake", "donuts"],
},
methods: {
display({ rootData }) {
// this is the scope of the template
// here it is a string inside of the each loop
rootData.favorite = `${this.toUpperCase()}!! is my favorite food`;
},
isFavorite({ event, $refs, $nextTick, rootData, methods }) {
// here we call another method, and the scope remains the same
methods.display();
},
},
};
The partials object in a ReBars app is simply a way to use Handlebars built in partials functionality in a ReBars application.
This lets you break up your templates into pieces.
This is another great candidate for using
ReBars.load
to have separate files for your partials.
// app.js
const { ReBars } = window;
export default {
template: /*html*/ `
<h1>All the people</h1>
{{#each people as | person | }}
{{> Person person=person }}
{{/each}}
`,
data: {
people: [
{ firstName: "Mike", lastName: "Jones", profession: "Doctor" },
{ firstName: "David", lastName: "Smith", profession: "Programmer" },
]
},
partials: {
Person: ReBars.load("./person.hbs")
}
}
This is simply a convenience method giving you access to Handlebar's registerPartial
method. Just like with helpers, if you would like to work directly with Handlebars, you simply reference the instance passed back after you create your application. See Handlebars Partials for more info.
const app = ReBars.app(...);
app.instance.registerPartial("myPartial", "<h1><{{ name }}</h1>");
ReBars consists of a few very powerful Handlebars helpers. Of course you can add your own to extend even further, but the following is what you get on install.
The watch helper tells ReBars to re-render this block on change of the item you pass in as the second parameter.
Watch allows you to re-render a block of your template on change. Watch takes an optional arguments of what properties to watch. The arguments can be string or a regular expression. You may also as many as you like. When any change, the block will re-render.
{
data: {
open: false,
hobby: "running",
name: {
first: "David",
last: "Morrow"
},
friends: [
{ name: "Joe", hobby: "boxing" },
{ name: "Fred", hobby: "cooking" }
]
}
}
The above omits the what to watch. In this situation, ReBars will pre-render the block, and captures any references used. It would evaluate to the same as.
Sometimes automatically inferring what to watch will not have the desired effect.
In the example above, only name.first
name.last
will be watched. This is because open was false and hobby was not referenced. When in doubt, be specific.
If you are unsure what to watch, ReBars traces out changes to the console when you pass
trace: true
to your application. It's best to be explicit when telling ReBars what to watch.
Argument Example | re-renders when |
---|---|
{{#watch "name(*.)" }} |
on any change to name Object |
{{#watch "name.first" }} |
on changes to the string name.first |
{{#watch "name(*.)" "friends(*.)" }} |
any change to name or friends |
{{#watch "friends[1].hobby" }} |
on changes to friends index 1 hobby change |
{{#watch "friends(*.)hobby" }} |
on change to any friend's hobby change |
You can use any regular expression you would like. The examples above use
(*.)
which equates to any character.
Each {{#watch}}
block gets wrapped by default in a <span>
tag with attributes marking what outlet this represents. Sometimes this can get in the way of styling your layout.
As a solution you can add a tag, class id, any attribute you want to the watch block.
Remember, Handlebars helper arguments must have the params before
key="value"
arguments{{#watch "name.first" tag="h1" }}
export default {
template: /*html*/ `
{{#watch "name" tag="h3"}}
{{ name }}
{{/watch}}
<input type="text" value="{{ name }}" {{ bind input="name" }}>
`,
data: {
name: "David",
},
};
This allows you to bind your component's methods to events in your template. The method will be called with the first param an Object as described above and any additional params that are passed to the helper.
The method will be called with this
(scope) as the context in the template from where the on helper was called
<button {{ on "yes" click="save" }}>Save</button>
methods: {
save(context, arg) {
console.log(arg);
// yes
}
}
Remember Handlebars requires params to be first, and then
key="val"
arguments second
You can also call multiple events on one use of the on helper. For example.
<input {{ on focus="focused" blur="blurred" input="inputChange" >
The bind helper is very simimar to the on helper with one exception. It saves you from having to write a method in your app when all you want to do is set a value.
For example:
data: {
name: {
first: "David",
last: "Morrow"
}
}
As opposed to:
data: {
name: {
first: "David",
last: "Morrow"
}
},
methods: {
updateLastName({ event }) {
this.name.last = event.target.value;
}
}
On each input event of the text input, the last name will be updated to the input's current value. This is merely a convienance, and could be accomplished by defining a method. But is useful in many common cases.
The ref helper gives you an alias to a DOM element in your template. The $refs
method can be accessed in the context passed like other items in the context.
<button {{ ref "myButton" }}>Save</button>
<a {{ on click="doSomething" }}>Click</a>
methods: {
doSomething(context) {
console.log(context.$refs().myButton);
// <button>Save</button>
}
}
This simple little helper marks individual items with a unique identifier you provide. The main use for this is when you have a {{#watch}}
around an Array in your data.
{
data: {
friends: [
{ id: 1, name: "Fred" },
{ id: 2, name: "Mike" },
]
}
}
In the above example, on each change of any item in your todos, the entire UL block would re-render. This is not ideal, and ReBars is smart enough to determine which elements need changed.
Alternativly:
Now when the Array friends is updated, ReBars will have a unique identifier to compare which items have changed and only update those items.
Allthough it may work initially, using @index as your key value is not encouraged. Should you sort or reasign your Array, those indexes will no longer be a valid identifier for that item in the Array.