How to build a reactive engine in JavaScript?

Inspired by Vue.js and MobX

What do we need?

Data model

Event Emitter

  • Declare data
  • Declare computed
  • Transform to getters/setters
  • Subscribe to events
  • Emit change events

DOM driver

  • Read the DOM
  • Add handlers (data -> DOM)

Let’s build a prototype called Seer.

We’ll start with the Event Emitter

Event Emitter

// Emitter starts as an empty object
let emitter = {}

function subscribe (property, eventHandler) {
  // If there is NO handler for the given property, 
  // we create it and set it to a new array 
  // to store the eventHandlers
  if(!emitter[property]) emitter[property] = [] 

  // We push the eventHandler into the emitter array, 
  // which effectively gives us an array 
  // of callback functions
  emitter[property].push(eventHandler) 
}

Event Emitter

function emit (event) {
  // Early return if there are no event handlers
  if(!emitter[event] || emitter[event].length < 1) return

  // We call each eventHandler that’s 
  // observing the given property
  emitter[event].forEach(eventHandler => eventHandler())
}

Data model

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

Transform

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

  Object.defineProperty(obj, key, {
    get () {
      return val // Simply return the value
    },
    set (newVal) {
      val = newVal // Save the newVal
      emit(key) // Emit the change event
    }
  })
}

Transform

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

observeData(data)

Transform

function Seer (dataObj) {
  let emitter = {}

  observeData(dataObj)

  // Besides the reactive data object, 
  // we also want to return and thus expose 
  // the subscribe and emit functions.
  return {
    data: dataObj,
    subscribe,
    emit
  }

It’s already working!

const App = new Seer({
  firstName: 'Jon',
  lastName: 'Snow',
  age: 25
})

// To subscribe and react to 
// changes made to the reactive App object:
App.subscribe('firstName', () => console.log(App.data.firstName))
App.subscribe('lastName', () => console.log(App.data.lastName))

// To trigger the above callbacks 
// simply change the values like this:
App.data.firstName = 'Sansa'
App.data.lastName = 'Stark'

DOM driver

function syncNode (node, obj, property) {
  // Initialize the node’s textContent value 
  // with the observed object’s property value
  node.textContent = obj[property]

  // Subscribe the property using 
  // our Seer instance App.observe method.
  subscribe(property, value => node.textContent = obj[property] || '')
}

DOM driver

<h1 s-text="firstName">firstName comes here</h1>
function parseDOM (DOMTree, observable) {
  // We get all nodes that have the s-text custom attribute
  const nodes = DOMTree.querySelectorAll('[s-text]')

  // For each 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 with document.body 
// as the root node. All `s-text` nodes will automatically
// create bindings to the corresponding reactive property.
parseDOM(document.body, App.data)

What we have so far

Computed properties

fullName: Ember.computed('firstName', 'lastName', function() {
  return this.get('firstName') + ' ' + this.get('lastName')
})

Ember

Drawback? Called whenever any dependency changes,
even if it doesn’t affect the final result.

selectedTransformedList: Ember.computed(
  'story', 'listA', 'listB', 'listC', function() {
  switch (this.story) {
    case 'A':
      return expensiveTransformation(this.listA)
    case 'B':
      return expensiveTransformation(this.listB)
    default:
      return expensiveTransformation(this.listC)
  }
})

Computed properties

const app = new Vue({
  data: {
    firstName: 'Cloud',
    lastName: 'Strife'
  },
  computed: {
    fullName () {
      return this.firstName + ' ' + this.lastName
    }
  }
})

Solution? Let the engine figure out the dependencies.

This is what Vue.js and MobX do.

Dependency tracking

Dep.target = 'fullName'

start eval fullName ()

get firstName

get lastName

deps.push(Dep.target)

deps.push(Dep.target)

Dep.target = null

finish eval fullName ()

Dependency tracking

function makeComputed (obj, key, computeFunc) {
  Object.defineProperty(obj, key, {
    get () {
      // If there is no target set
      if (!Dep.target) {
        // Set the currently evaluated property as the target
        Dep.target = key
      }
      const value = computeFunc.call(obj)

      // Clear the target context
      Dep.target = null
      return value
    },
    set () {
      // Do nothing!
    }
  })
}

Dependency tracking

function makeReactive (obj, key) {
  let val = obj[key]
  // create an empty array for storing dependencies
  let deps = []
  Object.defineProperty(obj, key, {
    get () {
      // Run only when called within a computed property context
      if (Dep.target) {
        // Add the computed property as depending on this value
        // if not yet added
        if (!deps.includes(Dep.target)) {
          deps.push(Dep.target)
        }
      }
      return val
    },
    set (newVal) {
      val = newVal
      // If there are computed properties
      // that depend on this value
      if (deps.length) {
        // Notify each computed property observers
        deps.forEach(emit)
      }
      emit(key)
    }
  })
}

Dependency tracking

function observeData (obj) {
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      if (typeof obj[key] === 'function') {
        makeComputed(obj, key, obj[key])
      } else {
        makeReactive(obj, key)
      }
    }
  }
  parseDOM(document.body, obj)
}

Small tweak to our observeData method

Done?

Not yet!

We still need:

  • caching the computed values

  • removing dead dependencies

Cache

function makeComputed (obj, key, computeFunc) {
  let cache = null
  // Observe self to clear cache when deps change
  // kinda like dirty flag
  subscribe(key, () => {
    // Clear cache
    cache = null
  })
  Object.defineProperty(obj, key, {
    get () {
      if (!Dep.target) Dep.target = key
      // If not cached yet
      if (!cache) {
        // calculate new value and save to cache
        cache = computeFunc.call(obj)
      }
      Dep.target = null
      return cache
    },
    set () { // Do nothing! }
  })
}

Cache

That’s it! Each time we access our computed property after the initial computation, it will return the cached value until it has to be recalculated.

Thanks to the subscribe method used during the transformation process, we ensure to always clean the cache before other event handlers are executed.

Dependency tree-shaking

We want our computed properties to only depend on the most recently detected dependencies.

Dependency tree-shaking

Solution using 2 dependency lists.

Primary

stored in: Observable

example: ['fullName']

Those are the values that depend on me.

Secondary

stored in: Computed

example: ['firstName', 'lastName']

Those are the values I’m depending on.

Dependency tree-shaking

let Dep = {
  target: null,
  // Stores the dependencies of computed properties
  subs: {},
  // Create a two-way dependency relation between computed properties
  // and other computed or observable values
  depend (deps, dep) {
    // Add the current context (Dep.target) to local deps
    // as depending on the current property
    // if not yet added
    if (!deps.includes(this.target)) deps.push(this.target)
    // Add the current property as a dependency of the computed value
    // if not yet added
    if (!Dep.subs[this.target].includes(dep)) Dep.subs[this.target].push(dep)
  },
  getValidDeps (deps, key) {
    // Filter only valid dependencies by removing dead dependencies
    // that were not used during last computation
    return deps.filter(dep => this.subs[dep].includes(key))
  },
  notifyDeps (deps) {
    // notify all existing deps
    deps.forEach(emit)
  }
}

Dependency tree-shaking

function makeReactive (obj, key, computeFunc) {
  let deps = []
  let val = obj[key]
  Object.defineProperty(obj, key, {
    get () {
      // Run only when getting within a computed value context
      if (Dep.target) {
        // Add Dep.target as depending on this value
        // this will mutate the deps Array
        // as we’re passing a reference to it
        Dep.depend(deps, key)
      }
      return val
    },
    set (newVal) {
      val = newVal
      // Clean up dead dependencies
      deps = Dep.getValidDeps(deps, key)
      // and notify valid deps
      Dep.notifyDeps(deps, key)
      emit(key)
    }
  })
}

Dependency tree-shaking

function makeComputed (obj, key, computeFunc) {
  let cache = null
  // Create a local deps list similar to makeReactive deps
  let deps = []
  observe(key, () => {
    cache = null
    // Clean up and notify valid deps
    deps = Dep.getValidDeps(deps, key)
    Dep.notifyDeps(deps, key)
  })
  Object.defineProperty(obj, key, {
    get () {
      // If evaluated during the evaluation of another computed property
      if (Dep.target) {
        // Create a dependency relationship between those two computed properties
        Dep.depend(deps, key)
      }
      // Normalize Dep.target back to self
      // This makes it possible to build a dependency tree instead of a flat structure
      Dep.target = key
      if (!cache) {
        // Clear dependencies list to ensure getting a fresh one
        Dep.subs[key] = []
        cache = computeFunc.call(obj)
      }
      // Clear the target context
      Dep.target = null
      return cache
    },
    set () { // Do nothing! }
  })
}

Dependency graph

fullNameLength

fullName

firstName

lastName

Done!

Thanks!

Further read:

 

https://monterail.com/blog/2016/how-to-build-a-reactive-engine-in-javascript-part-1-observable-objects

 

https://monterail.com/blog/2017/computed-properties-javascript-dependency-tracking

 

Demo:

 

How to build a reactive engine in JavaScript?

By Damian Dulisz

How to build a reactive engine in JavaScript?

  • 731
Loading comments...

More from Damian Dulisz