observable.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. /**
  2. * Durandal 2.1.0 Copyright (c) 2012 Blue Spire Consulting, Inc. All Rights Reserved.
  3. * Available via the MIT license.
  4. * see: http://durandaljs.com or https://github.com/BlueSpire/Durandal for details.
  5. */
  6. /**
  7. * Enables automatic observability of plain javascript object for ES5 compatible browsers. Also, converts promise properties into observables that are updated when the promise resolves.
  8. * @module observable
  9. * @requires system
  10. * @requires binder
  11. * @requires knockout
  12. */
  13. define(['durandal/system', 'durandal/binder', 'knockout'], function(system, binder, ko) {
  14. var observableModule,
  15. toString = Object.prototype.toString,
  16. nonObservableTypes = ['[object Function]', '[object String]', '[object Boolean]', '[object Number]', '[object Date]', '[object RegExp]'],
  17. observableArrayMethods = ['remove', 'removeAll', 'destroy', 'destroyAll', 'replace'],
  18. arrayMethods = ['pop', 'reverse', 'sort', 'shift', 'slice'],
  19. additiveArrayFunctions = ['push', 'unshift'],
  20. es5Functions = ['filter', 'map', 'reduce', 'reduceRight', 'forEach', 'every', 'some'],
  21. arrayProto = Array.prototype,
  22. observableArrayFunctions = ko.observableArray.fn,
  23. logConversion = false,
  24. changeDetectionMethod = undefined,
  25. skipPromises = false,
  26. shouldIgnorePropertyName;
  27. /**
  28. * You can call observable(obj, propertyName) to get the observable function for the specified property on the object.
  29. * @class ObservableModule
  30. */
  31. if (!('getPropertyDescriptor' in Object)) {
  32. var getOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
  33. var getPrototypeOf = Object.getPrototypeOf;
  34. Object['getPropertyDescriptor'] = function(o, name) {
  35. var proto = o, descriptor;
  36. while(proto && !(descriptor = getOwnPropertyDescriptor(proto, name))) {
  37. proto = getPrototypeOf(proto);
  38. }
  39. return descriptor;
  40. };
  41. }
  42. function defaultShouldIgnorePropertyName(propertyName){
  43. var first = propertyName[0];
  44. return first === '_' || first === '$' || (changeDetectionMethod && propertyName === changeDetectionMethod);
  45. }
  46. function isNode(obj) {
  47. return !!(obj && obj.nodeType !== undefined && system.isNumber(obj.nodeType));
  48. }
  49. function canConvertType(value) {
  50. if (!value || isNode(value) || value.ko === ko || value.jquery) {
  51. return false;
  52. }
  53. var type = toString.call(value);
  54. return nonObservableTypes.indexOf(type) == -1 && !(value === true || value === false);
  55. }
  56. function createLookup(obj) {
  57. var value = {};
  58. Object.defineProperty(obj, "__observable__", {
  59. enumerable: false,
  60. configurable: false,
  61. writable: false,
  62. value: value
  63. });
  64. return value;
  65. }
  66. function makeObservableArray(original, observable, hasChanged) {
  67. var lookup = original.__observable__, notify = true;
  68. if(lookup && lookup.__full__){
  69. return;
  70. }
  71. lookup = lookup || createLookup(original);
  72. lookup.__full__ = true;
  73. es5Functions.forEach(function (methodName) {
  74. observable[methodName] = function () {
  75. return arrayProto[methodName].apply(original, arguments);
  76. };
  77. });
  78. observableArrayMethods.forEach(function(methodName) {
  79. original[methodName] = function() {
  80. notify = false;
  81. var methodCallResult = observableArrayFunctions[methodName].apply(observable, arguments);
  82. notify = true;
  83. return methodCallResult;
  84. };
  85. });
  86. arrayMethods.forEach(function(methodName) {
  87. original[methodName] = function() {
  88. if(notify){
  89. observable.valueWillMutate();
  90. }
  91. var methodCallResult = arrayProto[methodName].apply(original, arguments);
  92. if(notify){
  93. observable.valueHasMutated();
  94. }
  95. return methodCallResult;
  96. };
  97. });
  98. additiveArrayFunctions.forEach(function(methodName){
  99. original[methodName] = function() {
  100. for (var i = 0, len = arguments.length; i < len; i++) {
  101. convertObject(arguments[i], hasChanged);
  102. }
  103. if(notify){
  104. observable.valueWillMutate();
  105. }
  106. var methodCallResult = arrayProto[methodName].apply(original, arguments);
  107. if(notify){
  108. observable.valueHasMutated();
  109. }
  110. return methodCallResult;
  111. };
  112. });
  113. original['splice'] = function() {
  114. for (var i = 2, len = arguments.length; i < len; i++) {
  115. convertObject(arguments[i], hasChanged);
  116. }
  117. if(notify){
  118. observable.valueWillMutate();
  119. }
  120. var methodCallResult = arrayProto['splice'].apply(original, arguments);
  121. if(notify){
  122. observable.valueHasMutated();
  123. }
  124. return methodCallResult;
  125. };
  126. for (var i = 0, len = original.length; i < len; i++) {
  127. convertObject(original[i], hasChanged);
  128. }
  129. }
  130. /**
  131. * Converts an entire object into an observable object by re-writing its attributes using ES5 getters and setters. Attributes beginning with '_' or '$' are ignored.
  132. * @method convertObject
  133. * @param {object} obj The target object to convert.
  134. */
  135. function convertObject(obj, hasChanged) {
  136. var lookup, value;
  137. if (changeDetectionMethod) {
  138. if(obj && obj[changeDetectionMethod]) {
  139. if (hasChanged) {
  140. hasChanged = hasChanged.slice(0);
  141. } else {
  142. hasChanged = [];
  143. }
  144. hasChanged.push(obj[changeDetectionMethod]);
  145. }
  146. }
  147. if(!canConvertType(obj)){
  148. return;
  149. }
  150. lookup = obj.__observable__;
  151. if(lookup && lookup.__full__){
  152. return;
  153. }
  154. lookup = lookup || createLookup(obj);
  155. lookup.__full__ = true;
  156. if (system.isArray(obj)) {
  157. var observable = ko.observableArray(obj);
  158. makeObservableArray(obj, observable, hasChanged);
  159. } else {
  160. for (var propertyName in obj) {
  161. if(shouldIgnorePropertyName(propertyName)){
  162. continue;
  163. }
  164. if (!lookup[propertyName]) {
  165. var descriptor = Object.getPropertyDescriptor(obj, propertyName);
  166. if (descriptor && (descriptor.get || descriptor.set)) {
  167. defineProperty(obj, propertyName, {
  168. get:descriptor.get,
  169. set:descriptor.set
  170. });
  171. } else {
  172. value = obj[propertyName];
  173. if(!system.isFunction(value)) {
  174. convertProperty(obj, propertyName, value, hasChanged);
  175. }
  176. }
  177. }
  178. }
  179. }
  180. if(logConversion) {
  181. system.log('Converted', obj);
  182. }
  183. }
  184. function innerSetter(observable, newValue, isArray) {
  185. //if this was originally an observableArray, then always check to see if we need to add/replace the array methods (if newValue was an entirely new array)
  186. if (isArray) {
  187. if (!newValue) {
  188. //don't allow null, force to an empty array
  189. newValue = [];
  190. makeObservableArray(newValue, observable);
  191. }
  192. else if (!newValue.destroyAll) {
  193. makeObservableArray(newValue, observable);
  194. }
  195. } else {
  196. convertObject(newValue);
  197. }
  198. //call the update to the observable after the array as been updated.
  199. observable(newValue);
  200. }
  201. /**
  202. * Converts a normal property into an observable property using ES5 getters and setters.
  203. * @method convertProperty
  204. * @param {object} obj The target object on which the property to convert lives.
  205. * @param {string} propertyName The name of the property to convert.
  206. * @param {object} [original] The original value of the property. If not specified, it will be retrieved from the object.
  207. * @return {KnockoutObservable} The underlying observable.
  208. */
  209. function convertProperty(obj, propertyName, original, hasChanged) {
  210. var observable,
  211. isArray,
  212. lookup = obj.__observable__ || createLookup(obj);
  213. if(original === undefined){
  214. original = obj[propertyName];
  215. }
  216. if (system.isArray(original)) {
  217. observable = ko.observableArray(original);
  218. makeObservableArray(original, observable, hasChanged);
  219. isArray = true;
  220. } else if (typeof original == "function") {
  221. if(ko.isObservable(original)){
  222. observable = original;
  223. }else{
  224. return null;
  225. }
  226. } else if(!skipPromises && system.isPromise(original)) {
  227. observable = ko.observable();
  228. original.then(function (result) {
  229. if(system.isArray(result)) {
  230. var oa = ko.observableArray(result);
  231. makeObservableArray(result, oa, hasChanged);
  232. result = oa;
  233. }
  234. observable(result);
  235. });
  236. } else {
  237. observable = ko.observable(original);
  238. convertObject(original, hasChanged);
  239. }
  240. if (hasChanged && hasChanged.length > 0) {
  241. hasChanged.forEach(function (func) {
  242. if (system.isArray(original)) {
  243. observable.subscribe(function (arrayChanges) {
  244. func(obj, propertyName, null, arrayChanges);
  245. }, null, "arrayChange");
  246. } else {
  247. observable.subscribe(function (newValue) {
  248. func(obj, propertyName, newValue, null);
  249. });
  250. }
  251. });
  252. }
  253. Object.defineProperty(obj, propertyName, {
  254. configurable: true,
  255. enumerable: true,
  256. get: observable,
  257. set: ko.isWriteableObservable(observable) ? (function (newValue) {
  258. if (newValue && system.isPromise(newValue) && !skipPromises) {
  259. newValue.then(function (result) {
  260. innerSetter(observable, result, system.isArray(result));
  261. });
  262. } else {
  263. innerSetter(observable, newValue, isArray);
  264. }
  265. }) : undefined
  266. });
  267. lookup[propertyName] = observable;
  268. return observable;
  269. }
  270. /**
  271. * Defines a computed property using ES5 getters and setters.
  272. * @method defineProperty
  273. * @param {object} obj The target object on which to create the property.
  274. * @param {string} propertyName The name of the property to define.
  275. * @param {function|object} evaluatorOrOptions The Knockout computed function or computed options object.
  276. * @return {KnockoutObservable} The underlying computed observable.
  277. */
  278. function defineProperty(obj, propertyName, evaluatorOrOptions) {
  279. var computedOptions = { owner: obj, deferEvaluation: true },
  280. computed;
  281. if (typeof evaluatorOrOptions === 'function') {
  282. computedOptions.read = evaluatorOrOptions;
  283. } else {
  284. if ('value' in evaluatorOrOptions) {
  285. system.error('For defineProperty, you must not specify a "value" for the property. You must provide a "get" function.');
  286. }
  287. if (typeof evaluatorOrOptions.get !== 'function' && typeof evaluatorOrOptions.read !== 'function') {
  288. system.error('For defineProperty, the third parameter must be either an evaluator function, or an options object containing a function called "get".');
  289. }
  290. computedOptions.read = evaluatorOrOptions.get || evaluatorOrOptions.read;
  291. computedOptions.write = evaluatorOrOptions.set || evaluatorOrOptions.write;
  292. }
  293. computed = ko.computed(computedOptions);
  294. obj[propertyName] = computed;
  295. return convertProperty(obj, propertyName, computed);
  296. }
  297. observableModule = function(obj, propertyName){
  298. var lookup, observable, value;
  299. if (!obj) {
  300. return null;
  301. }
  302. lookup = obj.__observable__;
  303. if(lookup){
  304. observable = lookup[propertyName];
  305. if(observable){
  306. return observable;
  307. }
  308. }
  309. value = obj[propertyName];
  310. if(ko.isObservable(value)){
  311. return value;
  312. }
  313. return convertProperty(obj, propertyName, value);
  314. };
  315. observableModule.defineProperty = defineProperty;
  316. observableModule.convertProperty = convertProperty;
  317. observableModule.convertObject = convertObject;
  318. /**
  319. * Installs the plugin into the view model binder's `beforeBind` hook so that objects are automatically converted before being bound.
  320. * @method install
  321. */
  322. observableModule.install = function(options) {
  323. var original = binder.binding;
  324. binder.binding = function(obj, view, instruction) {
  325. if(instruction.applyBindings && !instruction.skipConversion){
  326. convertObject(obj);
  327. }
  328. original(obj, view);
  329. };
  330. logConversion = options.logConversion;
  331. if (options.changeDetection) {
  332. changeDetectionMethod = options.changeDetection;
  333. }
  334. skipPromises = options.skipPromises;
  335. shouldIgnorePropertyName = options.shouldIgnorePropertyName || defaultShouldIgnorePropertyName;
  336. };
  337. return observableModule;
  338. });