Hackability of DOM

之前翻译的 《DOM 属性已迁移到 JavaScript 原型》 文章提到了这种迁移所带来的好处之一:提升了 DOM 编程的灵活性。

提高 DOM 编程的可自定义能力(hackability)。比如你可以实现 polyfill 来填补浏览器所缺失的功能,也可以实现一个 JavaScript 库来覆写浏览器 DOM 属性的默认行为

原文 的评论也表达了同样的想法(20160212:原文链接现已跳转到 Chrome 官方文章):

不仅可以自定义 DOM 方法,还可以覆写 DOM 属性的默认行为,也可以拦截 DOM 的原生方法。 Jason Brady

然而现实总是残酷的 …… 由于 input 元素的 pattern 属性在移动端并 没有得到广泛支持 ,所以本来打算利用这个特性实现一个 polyfill,但是结果意外连连,主要是 Chrome 与 Safari 之间的行为差异。

Hackablitiy in Chrome and Firefox is better than in Safari

经本地实际测试(环境为 Chrome 48,Firefox 41 和 Safari 9;IE 未测试),Chrome/Firefox 和 Safari 均遵循了 Web IDL 规范( code ),但在 DOM 编程的自定义能力上,Chrome/Firefox 却强于 Safari。仅对 HTMLElement.prototype 对象的可配置 configurable: true 属性进行测试( code ),Chrome 与 Safari 的实现截然不同:

Chrome 48Safari 9
[“title”, “lang”, “translate”, “dir”, “dataset”, “hidden”, “tabIndex”, “accessKey”, “draggable”, “spellcheck”, “contentEditable”, “isContentEditable”, “offsetParent”, “offsetTop”, “offsetLeft”, “offsetWidth”, “offsetHeight”, “style”, “innerText”, “outerText”, “webkitdropzone”, “onabort”, “onblur”, “oncancel”, “oncanplay”, “oncanplaythrough”, “onchange”, “onclick”, “onclose”, “oncontextmenu”, “oncuechange”, “ondblclick”, “ondrag”, “ondragend”, “ondragenter”, “ondragleave”, “ondragover”, “ondragstart”, “ondrop”, “ondurationchange”, “onemptied”, “onended”, “onerror”, “onfocus”, “oninput”, “oninvalid”, “onkeydown”, “onkeypress”, “onkeyup”, “onload”, “onloadeddata”, “onloadedmetadata”, “onloadstart”, “onmousedown”, “onmouseenter”, “onmouseleave”, “onmousemove”, “onmouseout”, “onmouseover”, “onmouseup”, “onmousewheel”, “onpause”, “onplay”, “onplaying”, “onprogress”, “onratechange”, “onreset”, “onresize”, “onscroll”, “onseeked”, “onseeking”, “onselect”, “onshow”, “onstalled”, “onsubmit”, “onsuspend”, “ontimeupdate”, “ontoggle”, “onvolumechange”, “onwaiting”, “click”, “focus”, “blur”, “onautocomplete”, “onautocompleteerror”][“insertAdjacentElement”, “insertAdjacentHTML”, “insertAdjacentText”, “click”]

Firefox 的结果也并不一致,但大体与 Chrome 48 一致

针对 contentEditable 属性,在 Chrome/Firefox 浏览器中是 configurable: true 而 Safari 则是 configurable: false。查了下 Web IDL 对 ES Attribute 的 configurable 描述:

configurable is false if the attribute was declared with the [Unforgeable] extended attribute and true otherwise

对于 Unforgeable 属性有一个特点:the property will be non-configurable and will exist as an own property on the object itself rather than on its prototype,也就是说,这样的属性并不是附着在对象的原型上而在于对象本身:

> anObjectWithForgeableProps.hasOwnProperty(forgeableProp) ==> true
> ObjectWithForgeableProps.protptype.hasOwnProperty(forgeableProp) ==> false
> anObjectWithForgeableProps.hasOwnProperty(forgeableProp) ==> true
> ObjectWithForgeableProps.protptype.hasOwnProperty(forgeableProp) ==> false

然而似乎规范总是形同乌托邦,经测试,Safari 中 contentEditable 也是 HTMLElement.prototype 的属性 ~~ 也许 Safari 还是存在部分实现没有遵循 Web IDL。既然 Safari 基本不存在可以配置的 DOM 对象属性,也就基本不具备自定义能力了。

> HTMLElement.prototype.hasOwnProperty('contentEditable') ==> true
> HTMLElement.prototype.hasOwnProperty('contentEditable') ==> true

覆写默认行为:非侵入式的 DOM 事件统计

  • 仅在 Chrome 48,Firefox 41 和 Safari 9 测试
  • Safari 9 无法运行
  • Demo

有的时候我们需要对页面元素进行事件统计,但是我们不希望在每个事件函数中处理统计逻辑,或者不想让事件统计逻辑侵入视图逻辑,这时我们就可以通过覆写 DOM 事件属性的默认行为,实现非侵入式的事件统计。

实现过程

我们用最原始的方式定义 DOM 事件回调函数。假设页面中有两个 DOM 对象,div#divinput#input,然后定义一个统一的事件回调 doSomething(),向控制台输出事件的目标对象 ID 值和事件类型,分别对 onclickonmouseenteronchangeonkeydown 进行事件响应:

var elDiv = document.querySelector('#div');
var elInput = document.querySelector('#input');
var doSomething = function(event) {
  console.log(event.target.id + ' does something as ' + event.type);
};

elDiv.onclick = doSomething;
elDiv.onmouseenter = doSomething;
elInput.onchange = doSomething;
elInput.onkeydown = doSomething;
var elDiv = document.querySelector('#div');
var elInput = document.querySelector('#input');
var doSomething = function(event) {
  console.log(event.target.id + ' does something as ' + event.type);
};

elDiv.onclick = doSomething;
elDiv.onmouseenter = doSomething;
elInput.onchange = doSomething;
elInput.onkeydown = doSomething;

接着我们先通过 Object.getOwnPropertyDescriptor 方法获取特定 DOM 属性的原始 set 方法,再通过 Object.defineProperty 方法覆写原始的 set 行为,最后我们包装一下事件回调函数,加一层事件统计逻辑,然后调用一开始获得的原始 set 方法,这样我们就实现了非侵入式的事件统计逻辑:

/**
 * Initialize an event counter on the specified property of the DOM prototype.
 *
 * @param {object} domPrototype the prototype of DOM
 * @param {string} eventName the property name of DOM
 */
function initDOMEventCounter(domPrototype, eventName) {
  var descriptor = Object.getOwnPropertyDescriptor(domPrototype, eventName);
  var setter;
  var eventCallback;

  if (!Boolean(descriptor) || !descriptor.configurable) {
    // do nothing with the non-exist property
    // or skips the non-configurable property
    return;
  }

  setter = descriptor && descriptor.set;

  Object.defineProperty(domPrototype, eventName, {
    get: function() {
      // returns the decorated callback
      return eventCallback;
    },
    set: function(callback) {
      if (!callback) {
        eventCallback = undefined;
        return;
      }

      // decorates `callback` with an event counter
      eventCallback = function() {
        console.log('#' + this.id + ' counted: ' + eventName);
        callback.apply(this, arguments);
      };

      if (setter) {
        // sets the decorated callback as the event callback
        setter.call(this, eventCallback);
      }
    }
  });
}
/**
 * Initialize an event counter on the specified property of the DOM prototype.
 *
 * @param {object} domPrototype the prototype of DOM
 * @param {string} eventName the property name of DOM
 */
function initDOMEventCounter(domPrototype, eventName) {
  var descriptor = Object.getOwnPropertyDescriptor(domPrototype, eventName);
  var setter;
  var eventCallback;

  if (!Boolean(descriptor) || !descriptor.configurable) {
    // do nothing with the non-exist property
    // or skips the non-configurable property
    return;
  }

  setter = descriptor && descriptor.set;

  Object.defineProperty(domPrototype, eventName, {
    get: function() {
      // returns the decorated callback
      return eventCallback;
    },
    set: function(callback) {
      if (!callback) {
        eventCallback = undefined;
        return;
      }

      // decorates `callback` with an event counter
      eventCallback = function() {
        console.log('#' + this.id + ' counted: ' + eventName);
        callback.apply(this, arguments);
      };

      if (setter) {
        // sets the decorated callback as the event callback
        setter.call(this, eventCallback);
      }
    }
  });
}

这里我们实现一个入口函数,负责对 DOM 各个事件属性进行事件统计的初始化:

function initDomEventCounters(dom) {
  var prototype = dom.prototype;
  var eventNames = ['click', 'keydown', 'change', 'mouseenter', /* etc. */];

  eventNames.forEach(function(name) {
    initDOMEventCounter(prototype, 'on' + name);
  });
}
function initDomEventCounters(dom) {
  var prototype = dom.prototype;
  var eventNames = ['click', 'keydown', 'change', 'mouseenter', /* etc. */];

  eventNames.forEach(function(name) {
    initDOMEventCounter(prototype, 'on' + name);
  });
}

这样我们就可以对 HTMLElement 的事件属性进行事件统计了:

initDomEventCounters(HTMLElement);
initDomEventCounters(HTMLElement);

Document 上覆写事件属性行为

但是在测试过程中发现,Safari 在获取属性描述符时表现出不同的行为,结果为 undefined,也就是说 onclickonkeydown 等等这些事件属性,除了不可配置之外,也不是 HTMLElement.prototype 的属性之一。于是我写了另一个测试 测试代码 ,结果如下:

prototypeChrome / FirefoxSafari
of Windowfalsefalse
of Document true true
of HTMLElement true false

查了一下文档,这些事件属性由 GlobalEventHandlers 接口所定义,它主要描述了 HTMLElementDocumentWindow 的事件属性,所以 Safari 与 Chrome / Firefox 不同点仅仅是 GlobalEventHandlers 接口是否作为 Document.prototypeHTMLElement.prototype的属性,因此从兼容方面考虑,使用 Document 来覆写事件行为比 HTMLElement 更好。

Category Programming
Published