Source: modules/mvvm/observable.js

/**
 * Observable provides ways of defining properties that the value changes of
 * them can be observed. In addition to normal properties, it allows to
 * create read-only properties and dependency properties.
 *
 * Observable creation:
 * There are two ways of defining an Observable: object literal or extending
 * from Observable. Object literal is useful when defining a singleton or you
 * want to create an observable easily.
 *
 * @example
 *   var observable = Observable({
 *     prop: 10,
 *     func: function() {}
 *   });
 *
 * Extending from Observable allow you to define a class of Observable. The
 * advantage of this compared to object literal is that the accessers are
 * shared across all instances of the class. The syntax compatible with the
 * javascript prototype definition.
 *
 * @example
 *   var ExtendedObservable = Module.create(function ExtendedObservable() {
 *     this.super(Observable).call(this);
 *   }).extend(Observable);
 *   Observable.defineObservableProperty(ExtendedObservable.prototype, 'prop',
 *   {
 *     value: 10
 *   });
 *   ExtendedObservable.prototype.func = function() {};
 *
 *   // Extend from NewObservable
 *   var ExtendedObservable2 = Module.create(function() {
 *     // constructor
 *   }).extend(ExtendedObservable);
 *   Observable.defineObservableProperty(
 *     ExtendedObservable2.prototype, 'prop2',
 *   {
 *     value: 20
 *   });
 *
 *   var observable = ExtendedObservable2();
 *   console.log(observable.prop);  // 10
 *   console.log(observable.prop2); // 20
 *
 * Defining a read-only property:
 * This is only supported when extending from Observable. When you define a
 * read-only property, an internal property with a '_' prefix is defined at
 * the same time so you can still change the value inside the observable.
 *
 * @example
 *   Observable.defineObservableProperty(ExtendedObservable.prototype, 'prop',
 *   {
 *     readonly: true,
 *     value: 10
 *   });
 *   ExtendedObservable.prototype.inc = function() {
 *     this._prop = 100;
 *   };
 *   var observable = new ExtendedObservable();
 *   observable.prop = 100; // throws an exception
 *   observable.inc();      // observers on "prop" are called
 *
 * Defining a dependency property:
 * This is only supported when extending from Observable. You provide a list
 * of the dependent properties and when each of them changes, the observsers
 * on the defined property are called. Dependency properties are read-only and
 * their values are determined by the specified getter.
 *
 * @example
 *   Observable.defineObservableProperty(ExtendedObservable.prototype, 'prop',
 *   {
 *     value: 10
 *   });
 *   Observable.defineObservableProperty(
 *     ExtendedObservable.prototype, 'prop2',
 *   {
 *     value: 20
 *   });
 *   Observable.defineObservableProperty(
 *     ExtendedObservable.prototype, 'dependencyProp',
 *   {
 *     dependency: ['prop', 'prop2'],
 *     get: function() {
 *       return this.prop + this.prop2;
 *     }
 *   });
 *   var observable = new ExtendedObservable();
 *   observable.prop = 100;  // observers on "dependencyProp" are called
 *   observable.prop2 = 200; // observers on "dependencyProp" are called
 *   // observable.dependencyProp is 300.
 *
 * @module modules/mvvm/observable
 */
define(function(require) {
  'use strict';

  var Module = require('modules/base/module');
  var DependencyGraph = require('modules/base/dependency_graph');

  var OP_PREFIX = (name) => { return '$OP_' + name; };

  /**
   * @class Observable
   * @requires module:modules/base/module
   * @requires module:modules/base/dependency_graph
   * @returns {Observable}
   */
  var Observable = Module.create(function Observable(object) {
    this._observers = {};
    if (object) {
      this._initWithObject(object);
    }
  });

  /**
   * Initialize the observable with a prototype object. This is not required
   * for objects that are defined using prototype as everything should be
   * defined via Observable.defineObservableProperty explicitly.
   *
   * @access private
   * @memberOf Observable.prototype
   * @param {Object} object
   */
  Observable.prototype._initWithObject = function o_init(object) {
    for (var name in object) {
      // If name is a function, simply add it to the observable.
      if (typeof object[name] === 'function') {
        this[name] = object[name];
      } else {
        _defineObservableProperty(this, name, {
          value: object[name]
        });
      }
    }
  };

  /**
   * Notify the value change of a property.
   *
   * @access private
   * @memberOf Observable.prototype
   * @param {String} name
   * @param {Object} newValue
   * @param {Object} oldValue
   */
  Observable.prototype._notify = function o__notify(name, newValue, oldValue) {
    var observers = this._observers[name];
    if (observers) {
      observers.forEach(function(observer) {
        observer(newValue, oldValue);
      });
    }
  };

  /**
   * Remove an observer from a property.
   *
   * @access private
   * @memberOf Observable.prototype
   * @param {Function} observer
   * @param {String} name
   */
  Observable.prototype._removeObserver =
    function o__removeObserver(observer, name) {
      // arguments in reverse order to support .bind(observer) for the
      // unbind from all case
      var observers = this._observers[name];
      if (observers) {
        var index = observers.indexOf(observer);
        if (index >= 0) {
          observers.splice(index, 1);
        }
      }
  };

  /**
   * Observe a property with an observer. The observer is called when the
   * property changes.
   *
   * @access public
   * @memberOf Observable.prototype
   * @param {String} name
   * @param {Function} observer
   */
  Observable.prototype.observe = function o_observe(name, observer) {
    if (typeof observer !== 'function') {
      return;
    }
    (this._observers[name] = this._observers[name] || []).push(observer);
  };

  /**
   * Unobserve a property
   *
   * @access public
   * @memberOf Observable.prototype
   * @param {String} name
   * @param {Function} observer
   */
  Observable.prototype.unobserve = function o_unobserve(name, observer) {
    if (typeof name === 'function') {
      // (observer) -- remove from every key in _observers
      Object.keys(this._observers).forEach(
        this._removeObserver.bind(this, name));
    } else {
      if (observer) {
        // (prop, observer) -- remove observer from the specific prop
        this._removeObserver(observer, name);
      } else if (name in this._observers) {
        // (prop) -- otherwise remove all observers for property
        this._observers[name] = [];
      }
    }
  };

  // Static functions
  var _dependencyGraphs = new Map();
  /**
   * Each module should have its own dependency graph that decides what
   * observers to called when a property changes. The function returns the
   * dependency graph of a module. It creates one if the map does not exist.
   *
   * @param {Object} modulePrototype
   */
  function _getDependencyGraph(modulePrototype) {
    var dependencyGraph = _dependencyGraphs.get(modulePrototype);
    if (!dependencyGraph) {
      // register a new dependency graph of the module based on the existing
      // dependency graph on the module.
      dependencyGraph = DependencyGraph(modulePrototype._dependencyGraph);
      modulePrototype._dependencyGraph = dependencyGraph;
      _dependencyGraphs.set(modulePrototype, dependencyGraph);
    }
    return dependencyGraph;
  }

  /**
   * The function helps query the values of all dependent properties of a
   * specified property.
   *
   * @param {Observable} observable
   * @param {String} sourceProperty
   *                 The source property name.
   */
  function _getAllDependentValues(observable, sourceProperty) {
    var dependentList = observable._dependencyGraph &&
      observable._dependencyGraph.getAllDependent(sourceProperty);
    if (dependentList && dependentList.length) {
      return dependentList.map((name) => {
        return {
          name: name,
          value: observable[name]
        };
      });
    } else {
      return null;
    }
  }

  function _getterTemplate(name, defaultValue) {
    return function() {
      var value = this[OP_PREFIX(name)];
      if (typeof value === 'undefined') {
        value = this[OP_PREFIX(name)] = defaultValue;
      }
      return value;
    };
  }

  function _setterTemplate(name) {
    return function(value) {
      var oldValue = this[name];
      if (oldValue !== value) {
        // cache the old values of all dependent
        var dependentValues = _getAllDependentValues(this, name);
        // change the value
        this[OP_PREFIX(name)] = value;
        // notify the changes
        this._notify(name, value, oldValue);
        if (dependentValues) {
          dependentValues.forEach((obj) => {
            this._notify(obj.name, this[obj.name], obj.value);
          });
        }
      }
    };
  }

  function _defineObservablePropertyCore(object, name, options) {
    var dependency = options && options.dependency;

    // Update dependency information
    if (dependency) {
      var dependencyGraph = _getDependencyGraph(object);
      dependency.forEach((dependentName) => {
        // name depends on dependentName
        dependencyGraph.addDependency(name, dependentName);
      });
    }

    Object.defineProperty(object, name, {
      enumerable: true,
      get: options.get,
      set: options.set
    });
  }

  function _defineObservableProperty(object, name, options) {
    if (options && options.readonly) {
      var internalName = '_' + name;
      _defineObservablePropertyCore(object, name, {
        dependency: [internalName],
        get: function() {
          return this[internalName];
        }
      });
      _defineObservablePropertyCore(object, internalName, {
        get: _getterTemplate(internalName, options && options.value),
        set: _setterTemplate(internalName)
      });
    } else if (options && options.dependency && options.dependency.length) {
      if (typeof options.get !== 'function') {
        throw new Error('Observable: getter of ' + name + ' is invalid');
      }
      _defineObservablePropertyCore(object, name, {
        dependency: options.dependency,
        get: options.get
      });
    } else {
      _defineObservablePropertyCore(object, name, {
        get: _getterTemplate(name, options && options.value),
        set: _setterTemplate(name)
      });
    }
  }

  /**
   * Observe a property with an observer. The observer is called when the
   * property changes.
   *
   * @access public
   * @memberOf Observable
   * @param {Object} object
   * @param {String} name
   * @param {Object} options
   * @param {Boolean} options.readonly
   *                  Indicating if the property is read-only.
   * @param {Array.<String>} options.dependency
   *                         List of the dependent properties.
   * @param {Function} options.get
   *                   Getter of the property.
   */
  Object.defineProperty(Observable, 'defineObservableProperty', {
    get: function() {
      return _defineObservableProperty;
    }
  });
  return Observable;
});