Inspired by Vue.js and MobX
Data model
Event Emitter
DOM driver
// 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)
}
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())
}
let data = {
firstName: 'Jon',
lastName: 'Snow',
age: 25
}
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
}
})
}
function observeData (obj) {
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
makeReactive(obj, key)
}
}
}
observeData(data)
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
}
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'
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] || '')
}
<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)
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)
}
})
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.
Dep.target = 'fullName'
start eval fullName ()
get firstName
get lastName
deps.push(Dep.target)
deps.push(Dep.target)
Dep.target = null
finish eval fullName ()
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!
}
})
}
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)
}
})
}
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
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! }
})
}
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.
We want our computed properties to only depend on the most recently detected dependencies.
Solution using 2 dependency lists.
stored in: Observable
example: ['fullName']
Those are the values that depend on me.
stored in: Computed
example: ['firstName', 'lastName']
Those are the values I’m depending on.
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)
}
}
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)
}
})
}
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! }
})
}
fullNameLength
fullName
firstName
lastName
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: