Skip to main content




In this chapter, I'll explain how to create a simple yet powerful datalink library with the new ES6 proxies.

Pre requirements

ES6 made JavaScript much more elegant, but most of the new features are just syntactic. Proxies are one of the few non-refillable additions. If you are not familiar with them, please take a look at the MDN Proxy docs before proceeding.

A basic understanding of the ES6 Reflection API and Set, Map, and WeakMap objects will also be helpful.

The nx-observe library

nx-observe is a data binding solution in less than 140 lines of code. It exposes the observable (obj) and observe (fn) functions, which are used to create observable objects and observer functions. An observer function runs automatically when an observable property used by it changes. The following example demonstrates this.

// this is an observable example const person = observable ({name: 'John', age: 20}) function print () {console.log (`$ {person.name}, $ {person.age}`)} // this creates an observer function // outputs 'John, 20' to console observer (print) // console output 'Dave, 20' setTimeout (() => person.name = 'Dave', 100) / / console output 'Dave, 22' setTimeout (() => person.age = 22, 200)

The print function passed to observe () is rerun every time the person.name or person.age changes, print calls an observer function again.

Implementing a simple observable.

In this section, I am going to explain what goes on under the hood of nx-observe. First, I will show you how changes in the properties of an observable are detected and combined with observers. Then I'll explain a way to run the observer functions triggered by these changes.

Change Log

Changes are recorded by wrapping observable objects in ES6 proxies. These proxies seamlessly intercept get and set operations with the help of the Reflection API.

Variables currentObserver and queueObserver () are used in the code below, but will only be explained in the next section. For now, it's enough to know that currentObserver always points to the currently running observer function, and queueObserver () is a function that queues an observer to run soon.

/ * maps the observable properties to a set of observer functions, using the property * / const observers = new WeakMap () / * points to the observer function currently being carried out, it can be undefined * / let currentObserver / * makes an object observable by wrapping it in a proxy, it also adds a blank Map for the property-observer pairs to be saved later. * / function observable (obj) {observers.set (obj, new Map ()) return new Proxy (obj, {get, set})} / * this trap intercepts operations, it does nothing if no observer is executing on that moment. * / function get (target, key, receiver) {const result = Reflect.get (target, key, receiver) if (currentObserver) {registerObserver (target, key, currentObserver)} return result} / * if a function is executing As an observer, this function pairs the observer function with the currently retrieved observable property and saves it to the Observer Map. * / function registerObserver (target, key, observer) {let observersForKey = observers.get (target) .get (key) if (! observersForKey) {observersForKey = new Set () observers.get (target) .set (key, observersForKey )} observersForKey.add (observer)} / * this trap intercepts set operations, queues each observer associated with the currently set property to be executed later * / function set (target, key, value, receiver) {const observersForKey = observers.get (target) .get (key) if (observersForKey) {observersForKey.forEach (queueObserver)} return Reflect.set (target, key, value, receiver)}

The gett ramp does nothing if currentObserver is not set. Otherwise, it matches the obtained observable property observers and the currently running observer and saves them to the weak map. Observers are stored in an observable Set property. This ensures that there are no duplicates.

The sett ramp is to retrieve all the observers paired with the modified observable property and queue them for later execution.

You can find a figure and a step-by-step description explaining the sample code from nx-observe below.

JavaScript data binding with es6 proxy - observable code example

  • The person observable object is created.
  • currentObserver is set to print.
  • print begins to run.
  • person.name is retrieved inside print.
  • The proxy get capture is invoked in person
  • The set of observers belonging to the (person, name) pair is
  • retrieved by observers.get (person) .get ('name').
  • currentObserver (print) is added to the set of observers.
  • Step 4-7 run again with person.age.
  • $ {person.name}, $ {person.age} Printed to the console.
  • print finishes executing.
  • currentObserver is set to undefined.
  • Some other code starts executing.
  • person.age is set to a new value (22).
  • The proxy set capture is invoked in person
  • The set of observers belonging to the (person, age) pair is
  • retrieved by observers.get (person) .get ('age').
  • The observers in the observer set (including print)
  • they are queued for execution.
  • print runs again.

Running watchers

Queued observers run asynchronously in a batch, resulting in superior performance. During registration, observers are synchronously added to the Set queuedObservers. Set cannot contain duplicates, so queuing the same observer multiple times will not result in multiple executions. If Set was empty before, a new task is scheduled to iterate and execute all queued observers after some time.

/ * contains the activated observer functions, which should run soon * / const queuedObservers = new Set () / * points to the current observer, can be undefined * / let currentObserver / * the exposed observation function * / function observe (fn) {queueObserver (fn)} / * adds the observer to the queue and makes sure the queue executes soon * / function queueObserver (observer) {if (queuedObservers.size === 0) {Promise.resolve (). then (runObservers)} queuedObservers.add (observer)} / * executes queued observers, currentObserver is set to undefined at the end * / function runObservers () {try {queuedObservers.forEach (runObserver)} finally {currentObserver = undefined queuedObservers.clear ()}} / * sets the global currentObserver to observer, and then executes it * / function runObserver (observer) {currentObserver = observer observer ()}

The above code ensures that every time an observer runs, the global currentObserver variable points to it. Activating currentObserver 'turns on' the gett ramps to listen and match currentObserver to all observable properties it uses while running.

Building a dynamic observable tree

So far our model works fine with single-level data structures, but forces us to wrap each new object-valued property in an observable by hand. For example, the following code would not work as expected.

const person = observable ({data: {name: 'John'}}) function print () {console.log (person.data.name)} // console output 'John' observe (print) // does nothing setTimeout (() => person.data.name = 'Dave', 100)

To make this code work, we would have to replace observable ({data: {name: 'John'}}) with observable ({data: observable ({name: 'John'})}). Fortunately, we can eliminate this drawback by modifying a get.

function get (target, key, receiver) {const result = Reflect.get (target, key, receiver) if (currentObserver) {registerObserver (target, key, currentObserver) if (typeof result === 'object') {const observableResult = observable (result) Reflect.set (target, key, observableResult, receiver) return observableResult}} return result}

The get capture above wraps the returned value in an observable proxy before returning it, in case it is an object. This is also perfect from a performance point of view, as observables are only created when they are actually needed by an observer.

R Marketing Digital