• How to build a Reactive engine in JavaScript. Part 1: Observable Objects
  • Author: This article has been authorized by the original author Damian Dulisz
  • The Nuggets translation Project
  • Translator: IridescentMia
  • Proofread by: Reid3290, Malcolm U

In a responsive way

As the need for robust, interactive web interfaces grows, many developers are embracing responsive programming specifications.

Before we start implementing our own responsive engine, let’s quickly explain what responsive programming really is. Wikipedia gives a classic example of a responsive interface implementation called a Spreadsheet. I’m going to define a criterion that, for lambda equals A1 plus B1, whenever A1 or B1 changes, lambda equals A1 plus B1 changes. Such guidelines can also be interpreted as a computed value.

We will learn how to implement computed Value in Part 2 of this tutorial series. Before we do that, we first need to have a basic understanding of responsive engines.

engine

There are many different solutions that can observe and react to changes in application state.

  • Angular 1.x has a dirty check.
  • React doesn’t track changes in the data model because of the way it works. It compares and tinkers with the virtual DOM.
  • Cycle.js and Angular 2 are more responsive to stream implementations like XStream and rx.js.
  • Libraries like vue.js, MobX, or ractive.js use getters/setters variables to create observable data models.

In this tutorial, we’ll use getters/ Setters to observe and respond to changes.

Note: To keep this tutorial as simple as possible, the code lacks support for non-primary data types or nested attributes, and much of it requires integrity checks, so it should never be considered ready for production. The following code is a vue.js-inspired implementation of a responsive engine, written using the ES2015 standard.

Observable objects

Let’s start with a data object, and we want to observe its properties.

let data = {
  firstName: 'Jon',
  lastName: 'Snow',
  age: 25
}Copy the code

Start by creating two functions that use getters/setters to convert the object’s ordinary properties into observable properties.

function makeReactive (obj, key) {
  let val = obj[key]

  Object.defineProperty(obj, key, {
    get () {
      returnVal // simply returns the cached value},set(newVal) {val = newVal // save newVal notify(key) // ignore this for a while}})} // loop over the keys of the iterated objectfunction observeData (obj) {
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      makeReactive(obj, key)
    }
  }
}

observeData(data)Copy the code

Convert the original object into an observable object by running observeData(data); We now have a way to create notifications when the value of an object changes.

Responding to change

Before we can start receiving notifying, we need some content for notifications. Here is an excellent example of using the observer pattern. In this case we will use the signals implementation.

Let’s start with the observe function.

letSignals = {} // signals starts with an empty objectfunction observe (property, signalHandler) {
  if(! Signal [property]) signal [property] = [] SignalHandler [property]. Push (signalHandler) // Store signalHandler in the signal array, efficiently get a set of callback functions stored in the array}Copy the code

Observe (‘propertyName’, callback). The callback function should be called every time the property value changes. When observe is called multiple times on a property, each callback function is stored in the signal array for the property. In this way, all callbacks can be stored and easily retrieved.

Now look at the notify function mentioned above.

function notify (signal, newVal) {
  if(! signals[signal] || signals[signal].length < 1)return// Advance if there is no processor for signalreturnSignal [signal].foreach ((signalHandler) => signalHandler()) // call each signalHandler for the given attribute}Copy the code

As you can see, now every time a property changes, the signalHandlers assigned to it are called.

So we wrap it all up as a factory function that passes in the data object that we want to respond to. I’ll call it Seer. We ended up with the following:

function Seer (dataObj) {
  letSignals = {} observeData(dataObj) // In addition to responsive data objects, we also need to return and expose the observeData and notify functions.return {
    data: dataObj,
    observe,
    notify
  }

  function observe (property, signalHandler) {
    if(! signals[property]) signals[property] = [] signals[property].push(signalHandler) }function notify (signal) {
    if(! signals[signal] || signals[signal].length < 1)return

    signals[signal].forEach((signalHandler) => signalHandler())
  }

  function makeReactive (obj, key) {
    let val = obj[key]

    Object.defineProperty(obj, key, {
      get () {
        return val
      },
      set (newVal) {
        val = newVal
        notify(key)
      }
    })
  }

  function observeData (obj) {
    for (let key in obj) {
      if (obj.hasOwnProperty(key)) {
        makeReactive(obj, key)
      }
    }
  }
}Copy the code

Now all we need to do is create a new responsive object. Thanks to the exposed notify and observe functions, we can observe and respond to object changes.

const App = new Seer({
  title: 'Game of Thrones',
  firstName: 'Jon',
  lastName: 'Snow', age: 25}) // To subscribe and respond to changes in the responsive APP object: app.observe ('firstName', () => console.log(App.data.firstName))
App.observe('lastName', () => console.log(app.data.lastname)) // To trigger the above callback, simply change values as follows: app.data.firstname ='Sansa'
App.data.lastName = 'Stark'Copy the code

Simple, isn’t it? Now that we’ve covered the basics of a responsive engine, let’s put it to use. I mentioned that with the proliferation of responsive approaches to front-end programming, we can’t always think about manually updating the DOM after a change.

There are many ways to accomplish this task. I guess the most popular trend is to use the virtual DOM. If you’re interested in learning how to create your own virtual DOM implementation, there are plenty of tutorials out there. However, we will use a simpler approach here.

HTML looks like this: HTML

Title comes here

The function that updates the DOM in response looks like this:

// First you need to get the nodes you want to keep updated. const h1Node = document.querySelector('h1')

functionsyncNode (node, obj, TextContent = obj[property] // Start observing the property with our Seer instance app.observe. App.observe(property, value => node.textContent = obj[property] ||' ')
}

syncNode(h1Node, App.data, 'title')Copy the code

This works, but it takes a lot of work to bind all the data models to DOM elements using it.

That’s why we want to take it one step further and automate all of this. If you’re familiar with AngularJS or vue.js, you’ll remember to use the custom ng-bind or V-text attribute. Let’s create something similar here. Our custom property is called s-text. We’ll look for ways to bind between the DOM and the data model.

Let’s update the HTML:

<! --'title'Is the attribute we want to display inside <h1> --> <h1 s-text="title">Title comes here</h1>
functionParseDOM (node, observables) {/ / get all has a custom property s - text node const nodes = document. QuerySelectorAll ('[s-text]'// forEach existing node, we call the syncNode function nodes.foreach ((node) => {syncNode(node, observable, node.attributes[)'s-text'].value)})} // Now all we need to do is call it on the root document.body. All 's-text' nodes will automatically create binding for the corresponding reactive property. parseDOM(document.body, App.data)Copy the code

conclusion

Now we can parse the DOM and bind the data model to the node, adding these two functions to the Seer factory function so that the DOM can be parsed at initialization time.

The result should look something like this:

function Seer (dataObj) {
  let signals = {}

  observeData(dataObj)

  return {
    data: dataObj,
    observe,
    notify
  }

  function observe (property, signalHandler) {
    if(! signals[property]) signals[property] = [] signals[property].push(signalHandler) }function notify (signal) {
    if(! signals[signal] || signals[signal].length < 1)return

    signals[signal].forEach((signalHandler) => signalHandler())
  }

  function makeReactive (obj, key) {
    let val = obj[key]

    Object.defineProperty(obj, key, {
      get () {
        return val
      },
      set (newVal) {
        val = newVal
        notify(key)
      }
    })
  }

  function observeData (obj) {
    for (let key in obj) {
      if(obj. HasOwnProperty (key)) {makeReactive(obj, key)}} // After converting the data object, the DOM binding can be safely parsed. parseDOM(document.body, obj) }functionsyncNode (node, observable, Property) {node.textContent = Observable [property] // Remove 'Seer.' because the observe function is in the available scope. observe(property, () => node.textContent = observable[property]) }function parseDOM (node, observable) {
    const nodes = document.querySelectorAll('[s-text]')

    nodes.forEach((node) => {
      syncNode(node, observable, node.attributes['s-text'].value)
    })
  }
}Copy the code

Examples on JsFiddle:

HTML

<h1 s-text="title"></h1>
<div class="form-inline">
  <div class="form-group">
    <label for="title">Title: </label>
    <input 
      type="text" 
      class="form-control" 
      id="title" placeholder="Enter title"
      oninput="updateText('title', event)">
  </div>
  <button class="btn btn-default" type="button" onclick="resetTitle()">Reset title</button>
</div>Copy the code

JS

// Use ES2015, use compatible browsers, such as Chrome, Opera, Firefoxfunction Seer (dataObj) {
  let signals = {}

  observeData(dataObj)

  return {
    data: dataObj,
    observe,
    notify
  }

  function observe (property, signalHandler) {
    if(! signals[property]) signals[property] = [] signals[property].push(signalHandler) }function notify (signal) {
    if(! signals[signal] || signals[signal].length < 1)return

    signals[signal].forEach((signalHandler) => signalHandler())
  }

  function makeReactive (obj, key) {
    let val = obj[key]

    Object.defineProperty(obj, key, {
      get () {
        return val
      },
      set (newVal) {
        val = newVal
        notify(key)
      }
    })
  }

  function observeData (obj) {
    for (let key in obj) {
      if(obj. HasOwnProperty (key)) {makeReactive(obj, key)}} // After converting the data object, the DOM binding can be safely parsed. parseDOM(document.body, obj) }functionsyncNode (node, observable, Property) {node.textContent = Observable [property] // Remove 'Seer.' because the observe function is in the available scope. observe(property, () => node.textContent = observable[property]) }function parseDOM (node, observable) {
    const nodes = document.querySelectorAll('[s-text]')

    for (const node of nodes) {
      syncNode(node, observable, node.attributes['s-text'].value)
    }
  }
}

const App = Seer({
  title: 'Game of Thrones',
  firstName: 'Jon',
  lastName: 'Snow',
  age: 25
})

function updateText (property, e) {
    App.data[property] = e.target.value
}

function resetTitle () {
    App.data.title = "Game of Thrones"
}Copy the code

Resources

EXTERNAL RESOURCES LOADED INTO THIS FIDDLE:

bootstrap.min.cssCopy the code

Result

Markdown

The code above can be found here: github.com/shentao/see…

To be continued…

This is the first article in a series on building your own responsive engine.

The next articleIt will be about creating computed Properties, where each property has its own traceable dependencies.

Feel free to post your feedback and thoughts on the next article in the comments section!

Thanks for reading.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. Android, iOS, React, front end, back end, product, design, etc. Keep an eye on the Nuggets Translation project for more quality translations.