diff --git a/src/dom-observer.js b/src/dom-observer.js index 05b0dc0e..5a8d6adf 100644 --- a/src/dom-observer.js +++ b/src/dom-observer.js @@ -25,8 +25,565 @@ define([ return realChangedNodes.length > 0; } - var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; - + var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver || + (function(undefined) { + "use strict"; + /** + * @param {function(Array., MutationObserver)} listener + * @constructor + */ + function MutationObserver(listener) { + /** + * @type {Array.} + * @private + */ + this._watched = []; + /** @private */ + this._listener = listener; + } + + /** + * Start a recursive timeout function to check all items being observed for mutations + * @type {MutationObserver} observer + * @private + */ + function startMutationChecker(observer) { + (function check() { + var mutations = observer.takeRecords(); + + if (mutations.length) { //fire away + //calling the listener with context is not spec but currently consistent with FF and WebKit + observer._listener(mutations, observer); + } + /** @private */ + observer._timeout = setTimeout(check, MutationObserver._period); + })(); + } + + /** + * Period to check for mutations (~32 times/sec) + * @type {number} + * @expose + */ + MutationObserver._period = 30 /*ms+runtime*/ ; + + /** + * Exposed API + * @expose + * @final + */ + MutationObserver.prototype = { + /** + * see http://dom.spec.whatwg.org/#dom-mutationobserver-observe + * not going to throw here but going to follow the current spec config sets + * @param {Node|null} $target + * @param {Object|null} config : MutationObserverInit configuration dictionary + * @expose + * @return undefined + */ + observe: function($target, config) { + /** + * Using slightly different names so closure can go ham + * @type {!Object} : A custom mutation config + */ + var settings = { + attr: !! (config.attributes || config.attributeFilter || config.attributeOldValue), + + //some browsers are strict in their implementation that config.subtree and childList must be set together. We don't care - spec doesn't specify + kids: !! config.childList, + descendents: !! config.subtree, + charData: !! (config.characterData || config.characterDataOldValue) + }; + + var watched = this._watched; + + //remove already observed target element from pool + for (var i = 0; i < watched.length; i++) { + if (watched[i].tar === $target) watched.splice(i, 1); + } + + if (config.attributeFilter) { + /** + * converts to a {key: true} dict for faster lookup + * @type {Object.} + */ + settings.afilter = reduce(config.attributeFilter, function(a, b) { + a[b] = true; + return a; + }, {}); + } + + watched.push({ + tar: $target, + fn: createMutationSearcher($target, settings) + }); + + //reconnect if not connected + if (!this._timeout) { + startMutationChecker(this); + } + }, + + /** + * Finds mutations since last check and empties the "record queue" i.e. mutations will only be found once + * @expose + * @return {Array.} + */ + takeRecords: function() { + var mutations = []; + var watched = this._watched; + + for (var i = 0; i < watched.length; i++) { + watched[i].fn(mutations); + } + + return mutations; + }, + + /** + * @expose + * @return undefined + */ + disconnect: function() { + this._watched = []; //clear the stuff being observed + clearTimeout(this._timeout); //ready for garbage collection + /** @private */ + this._timeout = null; + } + }; + + /** + * Simple MutationRecord pseudoclass. No longer exposing as its not fully compliant + * @param {Object} data + * @return {Object} a MutationRecord + */ + function MutationRecord(data) { + var settings = { //technically these should be on proto so hasOwnProperty will return false for non explicitly props + type: null, + target: null, + addedNodes: [], + removedNodes: [], + previousSibling: null, + nextSibling: null, + attributeName: null, + attributeNamespace: null, + oldValue: null + }; + for (var prop in data) { + if (has(settings, prop) && data[prop] !== undefined) settings[prop] = data[prop]; + } + return settings; + } + + /** + * Creates a func to find all the mutations + * + * @param {Node} $target + * @param {!Object} config : A custom mutation config + */ + function createMutationSearcher($target, config) { + /** type {Elestuct} */ + var $oldstate = clone($target, config); //create the cloned datastructure + + /** + * consumes array of mutations we can push to + * + * @param {Array.} mutations + */ + return function(mutations) { + var olen = mutations.length; + + //Alright we check base level changes in attributes... easy + if (config.attr && $oldstate.attr) { + findAttributeMutations(mutations, $target, $oldstate.attr, config.afilter); + } + + //check childlist or subtree for mutations + if (config.kids || config.descendents) { + searchSubtree(mutations, $target, $oldstate, config); + } + + + //reclone data structure if theres changes + if (mutations.length !== olen) { + /** type {Elestuct} */ + $oldstate = clone($target, config); + } + }; + } + + /* attributes + attributeFilter helpers */ + + /** + * fast helper to check to see if attributes object of an element has changed + * doesnt handle the textnode case + * + * @param {Array.} mutations + * @param {Node} $target + * @param {Object.} $oldstate : Custom attribute clone data structure from clone + * @param {Object} filter + */ + function findAttributeMutations(mutations, $target, $oldstate, filter) { + var checked = {}; + var attributes = $target.attributes; + var attr; + var name; + var i = attributes.length; + while (i--) { + attr = attributes[i]; + name = attr.name; + if (!filter || has(filter, name)) { + if (attr.value !== $oldstate[name]) { + //The pushing is redundant but gzips very nicely + mutations.push(MutationRecord({ + type: "attributes", + target: $target, + attributeName: name, + oldValue: $oldstate[name], + attributeNamespace: attr.namespaceURI //in ie<8 it incorrectly will return undefined + })); + } + checked[name] = true; + } + } + for (name in $oldstate) { + if (!(checked[name])) { + mutations.push(MutationRecord({ + target: $target, + type: "attributes", + attributeName: name, + oldValue: $oldstate[name] + })); + } + } + } + + /** + * searchSubtree: array of mutations so far, element, element clone, bool + * synchronous dfs comparision of two nodes + * This function is applied to any observed element with childList or subtree specified + * Sorry this is kind of confusing as shit, tried to comment it a bit... + * codereview.stackexchange.com/questions/38351 discussion of an earlier version of this func + * + * @param {Array} mutations + * @param {Node} $target + * @param {!Object} $oldstate : A custom cloned node from clone() + * @param {!Object} config : A custom mutation config + */ + function searchSubtree(mutations, $target, $oldstate, config) { + /* + * Helper to identify node rearrangment and stuff... + * There is no gaurentee that the same node will be identified for both added and removed nodes + * if the positions have been shuffled. + * conflicts array will be emptied by end of operation + */ + function resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes) { + // the distance between the first conflicting node and the last + var distance = conflicts.length - 1; + // prevents same conflict being resolved twice consider when two nodes switch places. + // only one should be given a mutation event (note -~ is used as a math.ceil shorthand) + var counter = -~((distance - numAddedNodes) / 2); + var $cur; + var oldstruct; + var conflict; + while((conflict = conflicts.pop())) { + $cur = $kids[conflict.i]; + oldstruct = $oldkids[conflict.j]; + + //attempt to determine if there was node rearrangement... won't gaurentee all matches + //also handles case where added/removed nodes cause nodes to be identified as conflicts + if (config.kids && counter && Math.abs(conflict.i - conflict.j) >= distance) { + mutations.push(MutationRecord({ + type: "childList", + target: node, + addedNodes: [$cur], + removedNodes: [$cur], + // haha don't rely on this please + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling + })); + counter--; //found conflict + } + + //Alright we found the resorted nodes now check for other types of mutations + if (config.attr && oldstruct.attr) findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + if (config.charData && $cur.nodeType === 3 && $cur.nodeValue !== oldstruct.charData) { + mutations.push(MutationRecord({ + type: "characterData", + target: $cur, + oldValue: oldstruct.charData + })); + } + //now look @ subtree + if (config.descendents) findMutations($cur, oldstruct); + } + } + + /** + * Main worker. Finds and adds mutations if there are any + * @param {Node} node + * @param {!Object} old : A cloned data structure using internal clone + */ + function findMutations(node, old) { + var $kids = node.childNodes; + var $oldkids = old.kids; + var klen = $kids.length; + // $oldkids will be undefined for text and comment nodes + var olen = $oldkids ? $oldkids.length : 0; + // if (!olen && !klen) return; //both empty; clearly no changes + + //we delay the intialization of these for marginal performance in the expected case (actually quite signficant on large subtrees when these would be otherwise unused) + //map of checked element of ids to prevent registering the same conflict twice + var map; + //array of potential conflicts (ie nodes that may have been re arranged) + var conflicts; + var id; //element id from getElementId helper + var idx; //index of a moved or inserted element + + var oldstruct; + //current and old nodes + var $cur; + var $old; + //track the number of added nodes so we can resolve conflicts more accurately + var numAddedNodes = 0; + + //iterate over both old and current child nodes at the same time + var i = 0, j = 0; + //while there is still anything left in $kids or $oldkids (same as i < $kids.length || j < $oldkids.length;) + while( i < klen || j < olen ) { + //current and old nodes at the indexs + $cur = $kids[i]; + oldstruct = $oldkids[j]; + $old = oldstruct && oldstruct.node; + + if ($cur === $old) { //expected case - optimized for this case + //check attributes as specified by config + if (config.attr && oldstruct.attr) /* oldstruct.attr instead of textnode check */findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + //check character data if set + if (config.charData && $cur.nodeType === 3 && $cur.nodeValue !== oldstruct.charData) { + mutations.push(MutationRecord({ + type: "characterData", + target: $cur, + oldValue: oldstruct.charData + })); + } + + //resolve conflicts; it will be undefined if there are no conflicts - otherwise an array + if (conflicts) resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes); + + //recurse on next level of children. Avoids the recursive call when there are no children left to iterate + if (config.descendents && ($cur.childNodes.length || oldstruct.kids && oldstruct.kids.length)) findMutations($cur, oldstruct); + + i++; + j++; + } else { //(uncommon case) lookahead until they are the same again or the end of children + if(!map) { //delayed initalization (big perf benefit) + map = {}; + conflicts = []; + } + if ($cur) { + //check id is in the location map otherwise do a indexOf search + if (!(map[id = getElementId($cur)])) { //to prevent double checking + //mark id as found + map[id] = true; + //custom indexOf using comparitor checking oldkids[i].node === $cur + if ((idx = indexOfCustomNode($oldkids, $cur, j)) === -1) { + if (config.kids) { + mutations.push(MutationRecord({ + type: "childList", + target: node, + addedNodes: [$cur], //$cur is a new node + nextSibling: $cur.nextSibling, + previousSibling: $cur.previousSibling + })); + numAddedNodes++; + } + } else { + conflicts.push({ //add conflict + i: i, + j: idx + }); + } + } + i++; + } + + if ($old && + //special case: the changes may have been resolved: i and j appear congurent so we can continue using the expected case + $old !== $kids[i] + ) { + if (!(map[id = getElementId($old)])) { + map[id] = true; + if ((idx = indexOf($kids, $old, i)) === -1) { + if(config.kids) { + mutations.push(MutationRecord({ + type: "childList", + target: old.node, + removedNodes: [$old], + nextSibling: $oldkids[j + 1], //praise no indexoutofbounds exception + previousSibling: $oldkids[j - 1] + })); + numAddedNodes--; + } + } else { + conflicts.push({ + i: idx, + j: j + }); + } + } + j++; + } + }//end uncommon case + }//end loop + + //resolve any remaining conflicts + if (conflicts) resolveConflicts(conflicts, node, $kids, $oldkids, numAddedNodes); + } + findMutations($target, $oldstate); + } + + /** + * Utility + * Cones a element into a custom data structure designed for comparision. https://gist.github.com/megawac/8201012 + * + * @param {Node} $target + * @param {!Object} config : A custom mutation config + * @return {!Object} : Cloned data structure + */ + function clone($target, config) { + var recurse = true; // set true so childList we'll always check the first level + return (function copy($target) { + var isText = $target.nodeType === 3; + var elestruct = { + /** @type {Node} */ + node: $target + }; + + //is text or comemnt node + if (isText || $target.nodeType === 8) { + if (isText && config.charData) { + elestruct.charData = $target.nodeValue; + } + } else { //its either a element or document node (or something stupid) + + if(config.attr && recurse) { // add attr only if subtree is specified or top level + /** + * clone live attribute list to an object structure {name: val} + * @type {Object.} + */ + elestruct.attr = reduce($target.attributes, function(memo, attr) { + if (!config.afilter || config.afilter[attr.name]) { + memo[attr.name] = attr.value; + } + return memo; + }, {}); + } + + // whether we should iterate the children of $target node + if(recurse && ((config.kids || config.charData) || (config.attr && config.descendents)) ) { + /** @type {Array.} : Array of custom clone */ + elestruct.kids = map($target.childNodes, copy); + } + + recurse = config.descendents; + } + return elestruct; + })($target); + } + + /** + * indexOf an element in a collection of custom nodes + * + * @param {NodeList} set + * @param {!Object} $node : A custom cloned node + * @param {number} idx : index to start the loop + * @return {number} + */ + function indexOfCustomNode(set, $node, idx) { + return indexOf(set, $node, idx, JSCompiler_renameProperty("node")); + } + + //using a non id (eg outerHTML or nodeValue) is extremely naive and will run into issues with nodes that may appear the same like
  • + var counter = 1; //don't use 0 as id (falsy) + /** @const */ + var expando = "mo_id"; + + /** + * Attempt to uniquely id an element for hashing. We could optimize this for legacy browsers but it hopefully wont be called enough to be a concern + * + * @param {Node} $ele + * @return {(string|number)} + */ + function getElementId($ele) { + try { + return $ele.id || ($ele[expando] = $ele[expando] || counter++); + } catch (o_O) { //ie <8 will throw if you set an unknown property on a text node + try { + return $ele.nodeValue; //naive + } catch (shitie) { //when text node is removed: https://gist.github.com/megawac/8355978 + return counter++; + } + } + } + + /** + * **map** Apply a mapping function to each item of a set + * @param {Array|NodeList} set + * @param {Function} iterator + */ + function map(set, iterator) { + var results = []; + for (var index = 0; index < set.length; index++) { + results[index] = iterator(set[index], index, set); + } + return results; + } + + /** + * **Reduce** builds up a single result from a list of values + * @param {Array|NodeList|NamedNodeMap} set + * @param {Function} iterator + * @param {*} [memo] Initial value of the memo. + */ + function reduce(set, iterator, memo) { + for (var index = 0; index < set.length; index++) { + memo = iterator(memo, set[index], index, set); + } + return memo; + } + + /** + * **indexOf** find index of item in collection. + * @param {Array|NodeList} set + * @param {Object} item + * @param {number} idx + * @param {string} [prop] Property on set item to compare to item + */ + function indexOf(set, item, idx, prop) { + for (/*idx = ~~idx*/; idx < set.length; idx++) {//start idx is always given as this is internal + if ((prop ? set[idx][prop] : set[idx]) === item) return idx; + } + return -1; + } + + /** + * @param {Object} obj + * @param {(string|number)} prop + * @return {boolean} + */ + function has(obj, prop) { + return obj[prop] !== undefined; //will be nicely inlined by gcc + } + + // GCC hack see http://stackoverflow.com/a/23202438/1517919 + function JSCompiler_renameProperty(a) { + return a; + } + + return MutationObserver; + })(void 0); + // Flag to avoid running recursively var runningPostMutation = false;