Skip to content

Dead simple FE framework for Handlebars re-render blocks

License

Notifications You must be signed in to change notification settings

dperrymorrow/re-bars

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ReBars

A simple, modern approach to Obvervables and DOM re-rendering and patching.


The Problem...

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

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.

Getting Started

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");

Handlebars

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,
  ...
});

Template

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"
  }
};

Loading from external files

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.

<!-- template.hbs -->
<h1>{{ myName }}</h1>
const { ReBars } = window;

export default {
  template: ReBars.load("./template.hbs"),
  data: {
    myName: "Dave"
  }
};

Data

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

Methods in your data

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.

Hooks

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" }
  }
}

Helpers

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

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();
    },
  },
};

Partials

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.

<!-- person.hbs -->
<ul>
  </li>{{ fullName }}</li>
  </li>{{ person.profession }}</li>
</ul>
// 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 built in helpers

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

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.

{{#watch}}
  My name is {{ name.first }} {{ name.last }}.
{{/watch}}
{
  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.

{{#watch "name.first" "name.last" }}

Automatic Watch pitfalls

Sometimes automatically inferring what to watch will not have the desired effect.

{{#watch}}
  My name is: {{ name.first }} {{ name.last }}
  {{#if open }}
    {{ hobby }}
  {{/if}}
{{/watch}}

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",
  },
};

The on helper

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

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:

<input type="text" {{ bind input="name.last" }} />
data: {
  name: {
    first: "David",
    last: "Morrow"
  }
}

As opposed to:

<input type="text" {{ on input="updateLastName" }} />
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

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>
  }
}

The Key Helper

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.

{{#watch "friends" }}
  <ul>
    {{#each friends as |friend| }}
      <li>{{ friend.name }}</li>
    {{/each}}
  </ul>
{{/watch}}
{
  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:

{{#watch "friends" }}
  <ul>
    {{#each friends as |friend| }}
      <li {{ key friend.id }}>{{ friend.name }}</li>
    {{/each}}
  </ul>
{{/watch}}

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.

About

Dead simple FE framework for Handlebars re-render blocks

Resources

License

Stars

Watchers

Forks

Packages

No packages published