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?
- 1,925