Namek Dev
a developer's log
NamekDev

My small secret web weapon to bind things - rivets.js

May 22, 2017

Hey, I need something. Uumm. Yeah, I need… JavaScript template engine! No? Something more? A small library that would bind data to existing DOM, locally! Yes!

rivets.js - it’s not too popular but it works and it’s only 26 KB minified without gzipping (which would go to just 6 KB!). Let’s go through it’s features and see some snippets I’ve developed through few months.

Story short - what the library is

  • developed with CoffeeScript
  • small but rather feature complete + extensible
  • not as big as AngularJS
  • so it just binds things
  • and it’s much more than simple templating systems like swig or nunjucks
  • it’s reactive on data change
  • flexible through custom formatters, binders, adapters and components

Community right now is rather small and original author doesn’t seem maintaining it anymore but there are other people who do the job. Somewhat passively, I’m one of them and I hope this post would be a useful contribution.

The engine + compare to AngularJS

  • rivets formatters are like Angular filters - those get the value and transform it to something else.
  • rivets binders are like Angular directives - those get element and optional value passed in.
  • rivets components are like Angular 1.5 components or Angular directives used as a components.
  • adapters probably don’t exist in Angular because…
  • Angular usually takes JavaScript expressions while rivets uses much more simplier custom format for expressions
  • so rivets adapters are a way to register custom operators for watching objects differently.
  • Angular does dirty checking by marking objects while rivets overwrites setters in Objects to notify the engine about changes. The latter mechanism is sometimes called to be reactive.

Performance may not be great since there is no Virtual DOM or similiar fancy technique. However, there’s a reactiveness to data change which makes some little promise. Also, there’s rv-if which happens to be a mix between ng-if and ng-show.

Talk is cheap. Show me the code!

Let’s train on an example - a list of github repositories with custom tags:

a showcase of few tricks with rivets

a showcase of few tricks with rivets

Let’s say we have a collection of GitHub repositories (repos) where each one of them can be tagged (has a collection of tags).

This is our scope defined in TypeScript using class syntax:

interface IRepo {
  name: string
  author: string
  tags: string[]
  isExpanded: boolean
}

class PageController {
  repos: IRepo[]
  sortBy = 'name'
  selectedFilter = ''
  textFilter = ''

  constructor() {
    this.setRepos([])
  }

  setRepos = (repos) => {
    this.repos = repos
    this.refreshFiltering()
  }

  refreshFiltering = () => {
    const filterFunc = ((filter) => {
      if (filter == 'all')
        return el => true
      if (filter == 'tagged')
        return el => el.tags && el.tags.length > 0
      else if (filter == 'untagged')
        return el => !el.tags || el.tags.length == 0
      else
        throw new Error(`unknown filter type: ${filter}`)
    })(this.selectedFilter)
  
    // you will find FILTER_AND_SORT in script.js file
    this.filteredRepos = FILTER_AND_SORT(this.repos, filterFunc, this.textFilter, this.sortBy)
  }

  toggleRepo = (repo) => {
    repo.isExpanded = !repo.isExpanded
    this.refreshFiltering()
  }

  deleteTag = (repo, tag) => {
    let tagIndex = repo.tags.indexOf(tag)
    repo.tags.splice(tagIndex, 1)
    this.refreshFiltering()
  }
}

let scope = new PageController()

See the arrow functions? It’s important to have them due to definition of this  keyword in call context made by rivets. After transpilation to JavaScript it would look like this:

class PageController {
  constructor() {
    this.setRepos = (repos) => {
      this.repos = repos
      this.refreshFiltering()
    }

    this.toggleRepo = (repo) => {
      repo.isExpanded = !repo.isExpanded
      this.refreshFiltering()
    }
    
    this.deleteTag = (repo ,tag) => {
      let tagIndex = repo.tags.indexOf(tag)
      repo.tags.splice(tagIndex, 1)
      this.refreshFiltering()
    }
    
    this.refreshFiltering = () => {
      const filterFunc = ((filter) => {
        if (filter == 'all')
          return el => true
        if (filter == 'tagged')
          return el => el.tags && el.tags.length > 0
        else if (filter == 'untagged')
          return el => !el.tags || el.tags.length == 0
        else
          throw new Error(`unknown filter type: ${filter}`)
      })(this.selectedFilter)
    
      // you will find FILTER_AND_SORT in script.js file
      this.filteredRepos = FILTER_AND_SORT(this.repos, filterFunc, this.textFilter, this.sortBy)
    }
    
    
    this.sortBy = 'name'
    this.selectedFilter = 'all'
    this.textFilter = ''
    this.setRepos([])
  }
}

let scope = new PageController()

Another option would be to use simple object with properties:

let scope = {
  sortBy: 'name',
  selectedFilter: 'all',
  textFilter: '',
  repos: [],
  filteredRepos: [],
  
  setRepos: (repos) => {
    scope.repos = repos
    scope.refreshFiltering()
  },

  toggleRepo: (repo) => {
    repo.isExpanded = !repo.isExpanded
    scope.refreshFiltering()
  },
    
  deleteTag: (repo, tag) => {
    let tagIndex = repo.tags.indexOf(tag)
    repo.tags.splice(tagIndex, 1)
    scope.refreshFiltering()
  },
    
  refreshFiltering: () => {
    const filterFunc = ((filter) => {
      if (filter == 'all')
        return el => true
      if (filter == 'tagged')
        return el => el.tags && el.tags.length > 0
      else if (filter == 'untagged')
        return el => !el.tags || el.tags.length == 0
      else
        throw new Error(`unknown filter type: ${filter}`)
    })(scope.selectedFilter)

    // you will find FILTER_AND_SORT in script.js file
    scope.filteredRepos = FILTER_AND_SORT(scope.repos, filterFunc, scope.textFilter, scope.sortBy)
  }
}

There’s a nuance about the this keyword which may break all your code. Read the “nuance about this keyword” chapter in the next article about details to understand the issue.

Let’s get back to the example - fill repos with:

[
  { name: "github-star-tagger-chrome", author: "Namek", tags: ["chrome", "extension", "github", "stars"] },
  { name: "xstream", author: "staltz", tags: ["web", "reactive", "rxjs", "stream", "observables", "cyclejs"] },
  { name: "rivets", author: "mikeric", tags: ["web", "binding", "javascript"] }
]

Now, the** view**:

<div id="app">
  <select
    rv-value="sortBy"
    rv-on-change="refreshFiltering"
  >
    <option value="">Do not sort</option>
    <option value="name">Sort by Name</option>
    <option value="tagCount" rv-if="selectedFilter | equals 'untagged' | not">Sort by Tag count</option>
  </select>
  
  <select
    rv-value="selectedFilter"
    rv-on-change="refreshFiltering"
  >
    <option value="all">Show all</option>
    <option value="untagged">Untagged</option>
    <option value="tagged">Tagged</option>
  </select>

  <div rv-if="filteredRepos | isEmpty">
    No repositories found!
  </div>
  
  <div
    class="repo"
    rv-each-repo="filteredRepos"
    rv-class-expanded="repo.isExpanded"
  >
    <div
      class="repo-name"
      rv-on-click="toggleRepo | args repo"
    >
      repo: { repo.author } / { repo.name }
    </div>
  
    <div
      class="repo-btns"
      rv-if="repo.isExpanded"
    >
      <span class="tag" rv-each-tag="repo.tags">
        { tag }
        <button
          class="btn-delete-tag"
          rv-on-click="deleteTag | args repo tag"
        >&times;</button>
      </span>
    </div>
  </div>
</div>

Let’s highlight some things in the HTML above:

  1. every attribute prefixed with rv-  is a binder
  2. (by default) data is bound in 2 ways:
    1. rv-value directive which is a two-way binding
    2. one-way text binding using curly braces in element’s content, e.g. { tag }
  3. you can show/hide elements conditionally using built-in rv-if binder
  4. you can loop collection using built-in rv-each-* binder
  5. you can toggle classes using built-in rv-class-* binder
  6. you can chain formatters, i.e. they launch one after another, e.g. rv-if="filter | equals 'untagged' | not" where equals is the first launched formatter (with value “untagged”) and not is the second (without additional value). The second one is called with an argument which is a result of previous formatter (the equals)
  7. you can register on events using rv-on-* binder, e.g. rv-on-change, rv-on-click

However, few things are mysterious since they’re not built-in:

  • formatters: isEmpty, equals, not
  • passing args to function call!

Let’s define them!

rivets.formatters['equals'] = (val, expectedVal) => val == expectedVal
rivets.formatters['not'] = val => !val
rivets.formatters['isEmpty'] = val => !val || val.length == 0

rivets.formatters.args = function(fn) {
  let args = Array.prototype.slice.call(arguments, 1)
  return () => fn.apply(null, args)
}

Finally, let’s bind the scope with element of id=“app” (defined in view):

rivets.bind(document.getElementById('app'), scope)

Full example

To see this example live open the plunker: → Example for rivets.js - presenting basic features

Also, you can take a look at bigger example which is a source of the small plunker: → Namek/github-star-tagger-chrome

Summary

There’s some learning curve due to it’s **syntax **which is really simple in terms of implementation but you can’t have JavaScript expressions as we used to have in AngularJS. That’s especially visible with chained formatters.

As this library is pretty small there is more to learn to have more power. Follow the next part:

Binding with rivets.js - details and tricks

References

Daj Się Poznać, Get Noticed 2017, javascript, rivets.js, web
comments powered by Disqus