From 12266c7bc5e847ce3a8cc48bf3e9fe3c40906c51 Mon Sep 17 00:00:00 2001 From: Meirion Hughes Date: Sun, 2 Oct 2016 21:57:34 +0100 Subject: [PATCH] feat(mutation): notify change intersection (#15) * chore: remove redundant code * refactor: clean up mutation polyfill * feat: add MutationObserver to global * refactor: remove casts * feat(mutation): add better change notification. * feat: remove redundent dispose. * refactor: clean up mutation-observer methods * fix: debounce change events * closes #14 --- spec/mutation-observer.spec.ts | 18 +--- src/global.ts | 1 + src/index.ts | 10 +-- src/nodejs-dom.ts | 4 +- src/nodejs-pal-builder.ts | 79 +++++++++++++++--- src/polyfills/mutation-observer.ts | 128 +++++++++++++---------------- 6 files changed, 132 insertions(+), 108 deletions(-) diff --git a/spec/mutation-observer.spec.ts b/spec/mutation-observer.spec.ts index e5aca71..1a10667 100644 --- a/spec/mutation-observer.spec.ts +++ b/spec/mutation-observer.spec.ts @@ -1,5 +1,4 @@ import { buildPal } from '../src/nodejs-pal-builder'; -import { disposeObservers } from '../src/polyfills/mutation-observer'; describe("MutationObserver", () => { let pal, observer, dom, document; @@ -262,20 +261,5 @@ describe("MutationObserver", () => { expect(records.length).toBe(1); expect(records[0].oldValue).toBe('Foo'); - }); - - it("can dispose observers via global disposeObservers method", function () { - var pal = buildPal(); - var dom = pal.dom; - var document = pal.global.window.document; - var div = document.createElement('div'); - - dom.createMutationObserver((changes) => { }).observe(div, { - attributes: true, - characterData: true, - childList: true - }); - - disposeObservers(); // without this test-runner will never end. - }); + }); }); diff --git a/src/global.ts b/src/global.ts index a6c1ef5..ac472f8 100644 --- a/src/global.ts +++ b/src/global.ts @@ -1,4 +1,5 @@ export interface IGlobal extends Window { + MutationObserver: typeof MutationObserver; Element: typeof Element; SVGElement: typeof SVGElement; XMLHttpRequest: typeof XMLHttpRequest; diff --git a/src/index.ts b/src/index.ts index 41b8211..732f35e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,5 @@ import { initializePAL } from 'aurelia-pal'; import { buildPal } from './nodejs-pal-builder'; -import { disposeObservers } from './polyfills/mutation-observer'; let isInitialized = false; @@ -68,11 +67,4 @@ export function initialize(): void { } }); }); -} - -/** -* Terminate any long-life timers -*/ -export function terminate() { - disposeObservers(); -} +} \ No newline at end of file diff --git a/src/nodejs-dom.ts b/src/nodejs-dom.ts index 70706c7..9c0a69e 100644 --- a/src/nodejs-dom.ts +++ b/src/nodejs-dom.ts @@ -8,8 +8,8 @@ export class NodeJsDom implements IDom { constructor(public global: IGlobal) { - this.Element = (global).Element; - this.SVGElement = (global).SVGElement; + this.Element = global.Element; + this.SVGElement = global.SVGElement; } Element: typeof Element; diff --git a/src/nodejs-pal-builder.ts b/src/nodejs-pal-builder.ts index c0afde0..f6df331 100644 --- a/src/nodejs-pal-builder.ts +++ b/src/nodejs-pal-builder.ts @@ -7,25 +7,80 @@ import { NodeJsPlatform } from './nodejs-platform'; import { NodeJsFeature } from './nodejs-feature'; import { NodeJsDom } from './nodejs-dom'; import { jsdom } from 'jsdom'; +import { MutationObserver } from './polyfills/mutation-observer'; +import { MutationNotifier } from './polyfills/mutation-observer'; + +let _patchedjsdom = false; export function buildPal(): { global: IGlobal, platform: IPlatform, dom: IDom, feature: IFeature } { - var _global: IGlobal = jsdom(undefined, {}).defaultView; + var global: IGlobal = jsdom(undefined, {}).defaultView; + + if (!_patchedjsdom) { + patchNotifyChange(global); + _patchedjsdom = true; + } - ensurePerformance(_global.window); - ensureMutationObserver(_global.window); + ensurePerformance(global.window); + ensureMutationObserver(global.window); - var _platform = new NodeJsPlatform(_global); - var _dom = new NodeJsDom(_global); - var _feature = new NodeJsFeature(_global); + var platform = new NodeJsPlatform(global); + var dom = new NodeJsDom(global); + var feature = new NodeJsFeature(global); return { - global: _global, - platform: _platform, - dom: _dom, - feature: _feature + global: global, + platform: platform, + dom: dom, + feature: feature }; } +let intersectSetter = function (proto, propertyName: string, intersect: Function) { + let old = Object.getOwnPropertyDescriptor(proto, propertyName); + let oldSet = old.set; + let newSet = function set(V) { + oldSet.call(this, V); + intersect(this); + }; + Object.defineProperty(proto, propertyName, { + set: newSet, + get: old.get, + configurable: old.configurable, + enumerable: old.enumerable + }); +}; + +let intersectMethod = function (proto, methodName: string, intersect: Function) { + let orig = proto[methodName]; + proto[methodName] = function (...args) { + var ret = orig.apply(this, args); + intersect(this); + return ret; + }; +}; + +function patchNotifyChange(window: Window) { + let notifyInstance = MutationNotifier.getInstance(); + let notify = function (node: Node) { notifyInstance.notifyChanged(node); }; + + let node_proto = (window)._core.Node.prototype; + + intersectMethod(node_proto, "appendChild", notify); + intersectMethod(node_proto, "insertBefore", notify); + intersectMethod(node_proto, "removeChild", notify); + intersectMethod(node_proto, "replaceChild", notify); + intersectSetter(node_proto, "nodeValue", notify); + intersectSetter(node_proto, "textContent", notify); + + let element_proto = (window)._core.Element.prototype; + + intersectMethod(element_proto, "setAttribute", notify); + intersectMethod(element_proto, "removeAttribute", notify); + intersectMethod(element_proto, "removeAttributeNode", notify); + intersectMethod(element_proto, "removeAttributeNS", notify); +} + + function ensurePerformance(window) { if (window.performance === undefined) { window.performance = {}; @@ -45,5 +100,7 @@ function ensurePerformance(window) { } function ensureMutationObserver(window) { - require('./polyfills/mutation-observer').polyfill(window); + if (!window.MutationObserver) { + window.MutationObserver = MutationObserver; + } } diff --git a/src/polyfills/mutation-observer.ts b/src/polyfills/mutation-observer.ts index dd83f2c..edc4183 100644 --- a/src/polyfills/mutation-observer.ts +++ b/src/polyfills/mutation-observer.ts @@ -4,44 +4,10 @@ * Repository: https://github.com/megawac/MutationObserver.js */ -import { jsdom } from 'jsdom'; - -let _dispose = false; - -export function polyfill(window) { - if (!window.MutationObserver) { - window.MutationObserver = MutationObserver; - } -} - -export function disposeObservers() { - _dispose = true; -} - -let dom = jsdom(undefined, {}).defaultView; -let document = dom.document; -let counter = 1; -let expando = 'mo_id'; - -let elmt = document.createElement('i'); -elmt.style.top = "0"; -let hasAttributeBug = elmt.attributes.style.value !== 'null'; -document = null; -dom = null; - -// GCC hack see http:// stackoverflow.com/a/23202438/1517919 -const JSCompiler_renameProperty = a => a; - -const getAttributeWithStyleHack = (el, attr) => { - // As with getAttributeSimple there is a potential warning for custom attribtues in IE7. - return attr.name !== 'style' ? attr.value : el.style.cssText; -}; - -const getAttributeSimple = (el, attr) => attr.value; - -const getAttributeValue = hasAttributeBug ? getAttributeSimple : getAttributeWithStyleHack; - export class Util { + static counter = 1; + static expando = 'mo_id'; + static clone($target, config) { let recurse = true; // set true so childList we'll always check the first level return (function copy($target) { @@ -67,7 +33,7 @@ export class Util { */ elestruct.attr = Util.reduce($target.attributes, (memo, attr) => { if (!config.afilter || config.afilter[attr.name]) { - memo[attr.name] = getAttributeValue($target, attr); + memo[attr.name] = attr.value; } return memo; }, {}); @@ -94,6 +60,7 @@ export class Util { * @return {number} */ static indexOfCustomNode(set, $node, idx) { + const JSCompiler_renameProperty = a => a; return this.indexOf(set, $node, idx, JSCompiler_renameProperty('node')); } @@ -105,12 +72,12 @@ export class Util { */ static getElementId($ele) { try { - return $ele.id || ($ele[expando] = $ele[expando] || counter++); + return $ele.id || ($ele[this.expando] = $ele[this.expando] || this.counter++); } catch (e) { // 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++; + return this.counter++; } } } @@ -171,11 +138,13 @@ export class MutationObserver { private _period = 30; private _timeout = null; private _disposed = false; + private _notifyListener = null; constructor(listener) { this._watched = []; this._listener = listener; this._period = 30; + this._notifyListener = () => { this.scheduleMutationCheck(this); }; } observe($target, config) { @@ -191,6 +160,8 @@ export class MutationObserver { afilter: null }; + MutationNotifier.getInstance().on("changed", this._notifyListener); + let watched = this._watched; // remove already observed target element from pool @@ -211,13 +182,8 @@ export class MutationObserver { watched.push({ tar: $target, - fn: this._createMutationSearcher($target, settings) + fn: this.createMutationSearcher($target, settings) }); - - // reconnect if not connected - if (!this._timeout) { - this._startMutationChecker(this); - } } takeRecords() { @@ -233,12 +199,13 @@ export class MutationObserver { disconnect() { this._watched = []; // clear the stuff being observed + MutationNotifier.getInstance().removeListener("changed", this._notifyListener ); this._disposed = true; clearTimeout(this._timeout); // ready for garbage collection this._timeout = null; } - _createMutationSearcher($target, config) { + private createMutationSearcher($target, config) { /** type {Elestuct} */ let $oldstate = Util.clone($target, config); // create the cloned datastructure @@ -261,12 +228,12 @@ export class MutationObserver { // Alright we check base level changes in attributes... easy if (config.attr && $oldstate.attr) { - this._findAttributeMutations(mutations, $target, $oldstate.attr, config.afilter); + this.findAttributeMutations(mutations, $target, $oldstate.attr, config.afilter); } // check childlist or subtree for mutations if (config.kids || config.descendents) { - dirty = this._searchSubtree(mutations, $target, $oldstate, config); + dirty = this.searchSubtree(mutations, $target, $oldstate, config); } // reclone data structure if theres changes @@ -277,22 +244,26 @@ export class MutationObserver { }; } - _startMutationChecker(observer) { - const check = () => { - let mutations = observer.takeRecords(); + private scheduleMutationCheck(observer) { + // Only schedule if there isn't already a timer. + if (!observer._timeout) { + observer._timeout = setTimeout(() => this.mutationChecker(observer), this._period); + } + } - 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 */ - if (observer._disposed == false && _dispose == false) - observer._timeout = setTimeout(check, this._period); - }; - check(); + private mutationChecker(observer) { + // allow scheduling a new timer. + observer._timeout = null; + + let 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); + } } - _searchSubtree(mutations, $target, $oldstate, config) { + private searchSubtree(mutations, $target, $oldstate, config) { // Track if the tree is dirty and has to be recomputed (#14). let dirty; /* @@ -330,7 +301,7 @@ export class MutationObserver { } // Alright we found the resorted nodes now check for other types of mutations - if (config.attr && oldstruct.attr) this._findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + if (config.attr && oldstruct.attr) this.findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); if (config.charData && $cur.nodeType === 3 && $cur.nodeValue !== oldstruct.charData) { mutations.push(new MutationRecord({ type: 'characterData', @@ -384,7 +355,7 @@ export class MutationObserver { 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 */ - this._findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); + this.findAttributeMutations(mutations, $cur, oldstruct.attr, config.afilter); } // check character data if node is a comment or textNode and it's being observed if (config.charData && oldstruct.charData !== undefined && $cur.nodeValue !== oldstruct.charData) { @@ -471,10 +442,7 @@ export class MutationObserver { return dirty; } - _findCharDataMutations(mutations, $target, $oldstate, filter) { - } - - _findAttributeMutations(mutations, $target, $oldstate, filter) { + private findAttributeMutations(mutations, $target, $oldstate, filter) { let checked = {}; let attributes = $target.attributes; let attr; @@ -484,7 +452,7 @@ export class MutationObserver { attr = attributes[i]; name = attr.name; if (!filter || Util.has(filter, name)) { - if (getAttributeValue($target, attr) !== $oldstate[name]) { + if (attr.value !== $oldstate[name]) { // The pushing is redundant but gzips very nicely mutations.push(new MutationRecord({ type: 'attributes', @@ -529,3 +497,25 @@ export class MutationRecord { return settings; } } + +import { EventEmitter } from 'events'; + +export class MutationNotifier extends EventEmitter { + private static _instance: MutationNotifier = null; + + static getInstance() { + if (!MutationNotifier._instance) { + MutationNotifier._instance = new MutationNotifier(); + } + return MutationNotifier._instance; + } + + constructor() { + super(); + this.setMaxListeners(100); + } + + notifyChanged(node: Node) { + this.emit("changed", node); + } +} \ No newline at end of file