/* A lazy binding take on computed */ // - doesn't start watching observables until itself is watched, and then releases if unwatched // - avoids memory/watcher leakage // - attaches to inner observables if these are returned from value // - doesn't broadcast if value is same as last value (and is `value type` or observable - can't make assuptions about reference types) // - doesn't broadcast if value is computed.NO_CHANGE var resolve = require('./resolve') var isObservable = require('./is-observable') var isSame = require('./lib/is-same') var onceIdle = require('./once-idle') module.exports = computed computed.NO_CHANGE = {} computed.extended = extendedComputed function computed (observables, lambda, opts) { // opts: nextTick, comparer, context, passthru var instance = new ProtoComputed(observables, lambda, opts) return instance.MutantComputed.bind(instance) } // optimise memory usage function ProtoComputed (observables, lambda, opts) { if (!Array.isArray(observables)) { observables = [observables] } this.values = [] this.releases = [] this.computedValue = null this.outputValue = null this.inner = null this.updating = false this.live = false this.initialized = false this.listeners = [] this.observables = observables this.lambda = lambda this.opts = opts this.comparer = opts && opts.comparer || null // when true, don't expand nested observables, just treat as values this.passthru = opts && opts.passthru || null this.context = opts && opts.context || {} this.boundOnUpdate = this.onUpdate.bind(this) this.boundUpdateNow = this.updateNow.bind(this) this.boundUnlisten = this.unlisten.bind(this) } ProtoComputed.prototype = { MutantComputed: function (listener) { if (!listener) { return this.getValue() } if (typeof listener !== 'function') { throw new Error('Listeners must be functions.') } this.listeners.push(listener) this.listen() return this.removeListener.bind(this, listener) }, removeListener: function (listener) { for (var i = 0, len = this.listeners.length; i < len; i++) { if (this.listeners[i] === listener) { this.listeners.splice(i, 1) break } } if (!this.listeners.length) { this.unlisten() } }, listen: function () { if (!this.live) { for (var i = 0, len = this.observables.length; i < len; i++) { if (isObservable(this.observables[i])) { this.releases.push(this.observables[i](this.boundOnUpdate)) } } if (this.inner) { this.releaseInner = this.inner(this.onInnerUpdate.bind(this, this.inner)) } this.live = true if (!this.update() && this.inner) { // no change, but make sure that inner value is up to date this.onInnerUpdate(this.inner, resolve(this.inner)) } if (this.opts && this.opts.onListen) { var release = this.opts.onListen() if (typeof release === 'function') { this.releases.push(release) } } } }, unlisten: function () { if (this.live && !this.listeners.length) { this.live = false if (this.releaseInner) { this.releaseInner() this.releaseInner = null } while (this.releases.length) { this.releases.pop()() } if (this.opts && this.opts.onUnlisten) { this.opts.onUnlisten() } } }, update: function () { var changed = false for (var i = 0, len = this.observables.length; i < len; i++) { var newValue = resolve(this.observables[i]) if (!isSame(newValue, this.values[i], this.comparer)) { changed = true this.values[i] = newValue } } if (changed || !this.initialized) { this.initialized = true var newComputedValue = this.lambda.apply(this.context, this.values) if (newComputedValue === computed.NO_CHANGE) { return false } if (!isSame(newComputedValue, this.computedValue, this.comparer)) { if (this.releaseInner) { this.releaseInner() this.inner = this.releaseInner = null } this.computedValue = newComputedValue if (isObservable(newComputedValue) && !this.passthru) { // handle returning observable from computed this.outputValue = newComputedValue() this.inner = newComputedValue if (this.live) { this.releaseInner = this.inner(this.onInnerUpdate.bind(this, this.inner)) } } else { this.outputValue = this.computedValue } return true } } return false }, onUpdate: function () { if (this.opts && this.opts.idle) { if (!this.updating) { this.updating = true onceIdle(this.boundUpdateNow) } } else if (this.opts && this.opts.nextTick) { if (!this.updating) { this.updating = true setImmediate(this.boundUpdateNow) } } else { this.updateNow() } }, onInnerUpdate: function (obs, value) { if (obs === this.inner) { if (!isSame(value, this.outputValue, this.comparer)) { this.outputValue = value this.broadcast() } } }, updateNow: function () { this.updating = false if (this.update()) { this.broadcast() } }, getValue: function () { if (!this.updating && !this.live) { // temporarily become live to watch for changes until next cycle to stop // potential double refresh and handle weird race conditions this.listen() // triggers update setImmediate(this.boundUnlisten) // only runs if no listeners have been added } return this.outputValue }, broadcast: function () { // cache listeners in case modified during broadcast var listeners = this.listeners.slice(0) for (var i = 0, len = listeners.length; i < len; i++) { listeners[i](this.outputValue) } } } function extendedComputed (observables, update) { var live = false var instance = computed(observables, function () { return update() }, { onListen: function () { live = true }, onUnlisten: function () { live = false } }) instance.checkUpdated = function () { if (!live) { update() } } return instance }