之前翻译的 《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 48 | Safari 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#div
和 input#input
,然后定义一个统一的事件回调 doSomething()
,向控制台输出事件的目标对象 ID 值和事件类型,分别对 onclick
与 onmouseenter
和 onchange
与 onkeydown
进行事件响应:
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,也就是说 onclick
、onkeydown
等等这些事件属性,除了不可配置之外,也不是 HTMLElement.prototype
的属性之一。于是我写了另一个测试 ( 测试代码 ) ,结果如下:
prototype | Chrome / Firefox | Safari |
---|---|---|
of Window | false | false |
of Document | true | true |
of HTMLElement | true | false |
查了一下文档,这些事件属性由 GlobalEventHandlers
接口所定义,它主要描述了 HTMLElement
或 Document
或 Window
的事件属性,所以 Safari 与 Chrome / Firefox 不同点仅仅是 GlobalEventHandlers
接口是否作为 Document.prototype
与 HTMLElement.prototype
的属性,因此从兼容方面考虑,使用 Document
来覆写事件行为比 HTMLElement
更好。