本文档涉及知识点
- 如何监听页面dom变化
- 如何触发自定义事件
- 如何让自定义事件支持onxxx属性绑定
- 如何跳过原生js事件,仅触发jQuery冒泡
- 如何给还未创建的元素绑定事件
- 利用分割函数+微队列处理长时任务,防止页面假死
1. 背景
在维护老项目的时候经常会有这样的需求:为页面某个特定元素增加一些功能或操作
但是,你可能无法确定页面元素会在什么时候以什么方式被添加页面上
特别是在代码中存在各种动态创建元素的项目中
部分页
$.html(...)
第三方组件
所以必须有一种方法可以捕获元素创建行为
2. MutationObserver
MutationObserver 接口提供了监视对 DOM 树所做更改的能力。
并且兼容性也没问题
官方 demo
// 选择需要观察变动的节点
const targetNode = document.getElementById('some-id');
// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };
// 当观察到变动时执行的回调函数
const callback = function(mutationsList, observer) {
// Use traditional 'for loops' for IE 11
for(let mutation of mutationsList) {
if (mutation.type === 'childList') {
console.log('A child node has been added or removed.');
}
else if (mutation.type === 'attributes') {
console.log('The ' + mutation.attributeName + ' attribute was modified.');
}
}
};
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);
// 以上述配置开始观察目标节点
observer.observe(targetNode, config);
// 之后,可停止观察
observer.disconnect();
3. 让js支持 created 事件
目标是以下两种方式都可以监听created
事件
window.addEventListener("created", function (e) {
console.log("window.addEventListener >> ", e.target);
}, true);
$(document).on("created", "*", function (e) {
console.log("$(document).on >> ", e.target);
})
之前直接依赖
jQuery
实现过一个版本,但效率比较差,重新实现一个原生的提高性能,并兼容jQuery
3.1 创建 MutationObserver
// 等页面加载完成后再执行,
window.addEventListener("load", function () {
// 当观察到变动时执行的回调函数
function callback(mutationsList) {
const array = [];
for (let i = 0; i < mutationsList.length; i++) {
const nodes = mutationsList[i].addedNodes;
for (var j = 0; j < nodes.length; j++) {
const node = nodes[j];
if ("tagName" in node) { // 忽略没有 tagName 的元素,如:#text 等
array.push(node);
}
}
}
// 触发数组中所有元素的 created 事件
if (array.length > 0) {
oncreated(array);
}
};
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);
observer.observe(document, { childList: true, subtree: true });
// 将创建观察器之前的就已经创建元素全部处理一遍
oncreated(document.children);
});
3.2 MutationObserver.takeRecords
方法返回已检测到但尚未由观察者的回调函数处理的所有匹配 DOM 更改的列表,使变更队列保持为空。
由于 MutationObserver.observe
是异步且回调时间不可控,有时候一个元素创建了3,4秒还不触发回调,会导致一些问题
所以需要一个额外的定时轮询来加速处理
setInterval(() => callback(observer.takeRecords()), 100);
3.3 手动触发事件
参考 创建和触发 events
var event = new Event('build');// Listen for the event. elem.dispatchEvent(event);
为每个元素原型中增加一个方法来触发 created
事件
/**
* 触发元素的created事件
*/
Element.prototype.created = function() {
const event = new Event('created');
elem.dispatchEvent(event);
}
4. 执行 oncreated
但是这个方式触发的事件有一个缺陷
无法触发 属性中的 onXXXX
的代码
这部分需要手动完成
很明显,原生事件click
可以正确被执行,但自定义事件created
不行
/**
* 以事件方式执行指定代码
* @param {Element} elem 需要触发事件的元素
* @param {Event} event 需要触发的事件参数
* @param {String} code 需要触发的时间代码
*/
function dispatchByCode(elem, event, code) {
try {
new Function("event", code).apply(elem, [event]); // 修改 this 作用域,并传入参数 event
} catch (e) {
console.error(e);
}
}
所以完整的触发事件代码改成这样 (eventType in elem.__proto__)
用于判断是否是原生属性,如onclick
会返回true
/**
* 触发元素的created事件
*/
Element.prototype.created = function() {
const event = new Event('created');
dispatch(this, event);
}
/**
* 触发元素事件
* @param {Element} elem 需要触发事件的元素
* @param {Event} event 需要触发的事件参数
*/
function dispatch(elem, event) {
const eventType = "on" + event.type;
if (!(eventType in elem.__proto__)) {
const code = elem.getAttribute(eventType);
if (code) {
dispatchByCode(elem, event, code);
}
}
elem.dispatchEvent(event);
}
3.5 oncreated 函数
现在就可以实现 3.1 中未完成的 oncreated
函数了
/**
* 递归循环元素的子元素, 并在 hookeds 中返回钩子元素
* @param {Array} elements 元素数组
* @param {Set} [hookeds] 需要触发 oncreated 事件的元素集合
*/
function oncreated(elements, hookeds) {
if (hookeds == null) {
hookeds = new Set();
oncreated(elements, hookeds);
hookeds = Array.from(hookeds);
while (hookeds.length > 0) {
const topN = hookeds.splice(0, 300);
setTimeout(() => topN.forEach(x => x.created()), 50);
}
}
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (isHooking(element)) {
hookeds.add(element);
}
if (element.childElementCount > 0) {
oncreated(element.children, hookeds);
}
}
}
/**
* 判断一个元素是否是钩子元素
* @param {Element} element 待判断的元素
* @returns {boolean} 是/否
*/
function isHooking(element) {
return true; // 预留后期优化,页面元素不多的情况下直接返回true即可
}
const topN = hookeds.splice(0, 1000);
setTimeout(() => topN.forEach(x => x.created()));
使用微队列每300个元素执行一次,防止页面卡死
到现在为止已经可以触发 window.addEventListener
了 OnCreated.js(1)
但还没有办法触发 jQuery.on(...)
3.6 触发 jQuery.on(...)
但曾经地表最强的 jQuery 为我们提供了丰富的API
只要使用 triggerHandler(type, [data]) + eve.isImmediatePropagationStopped() 就可以轻松的实现
window.addEventListener("created", function (e) {
if (window.$) {
const $e = new window.$.Event(e);
$e.isImmediatePropagationStopped = function () {
return this.target !== this.currentTarget;
}
jQueryBubble(window.$, e.target, $e);
}
}, true);
/**
* 模拟jQuery中的事件冒泡
* @param {jQuery} $ jQuery 主函数
* @param {Element} elem 事件元素
* @param {jQuery.Event} event 事件
*/
function jQueryBubble($, elem, event) {
$(elem).triggerHandler(event);
if (elem === document.documentElement) {
jQueryBubble($, document, event);
} else if (elem.parentElement) {
jQueryBubble($, elem.parentElement, event);
}
}
4. 完整代码
(_ => {
if (Element.prototype.created) {
return;
}
/**
* 触发元素的created事件
*/
Element.prototype.created = function () {
const event = new Event('created');
dispatch(this, event);
}
// 计数器
const counter = {
count: 0, // 捕获元素个数
hookeds: 0, // 触发元素个数
last: 0, // 记录最后观察到的元素个数
stopwatch: [0, 0], // 记录执行时间
hookedStart() {
let begin = new Date().getTime();
return () => {
if (begin) {
this.stopwatch[1] += new Date().getTime() - begin;
begin = null;
}
}
},
capturedStart() {
let begin = new Date().getTime();
return () => {
if (begin) {
this.stopwatch[0] += new Date().getTime() - begin;
begin = null;
}
}
}
};
const createdHooks = _.createdHooks = [];
/**
* 触发元素事件
* @param {Element} elem 需要触发事件的元素
* @param {Event} event 需要触发的事件参数
*/
function dispatch(elem, event) {
if (!elem || !event || !event.type) {
return;
}
const eventType = "on" + event.type;
if (!(eventType in elem.__proto__)) {
const code = elem.getAttribute(eventType);
if (code) {
dispatchByCode(elem, event, code);
}
}
elem.dispatchEvent(event);
}
/**
* 以事件方式执行指定代码
* @param {Element} elem 需要触发事件的元素
* @param {Event} event 需要触发的事件参数
* @param {String} code 需要触发的时间代码
*/
function dispatchByCode(elem, event, code) {
try {
new Function("event", code).apply(elem, [event]);
} catch (e) {
console.error(e);
}
}
/**
* 判断一个元素是否是钩子元素
* @param {Element} element 待判断的元素
* @returns {boolean} 是/否
*/
function isHooking(element) {
if (element === document.documentElement || element === document || element === document.body || element === document.head) {
return false;
}
if (createdHooks.length == 0) {
return true;
}
for (let i = 0; i < createdHooks.length; i++) {
const hook = createdHooks[i];
const test = typeof hook.test === "function" ? hook.test : hookTest;
if (test(element, hook)) {
return true;
}
}
return false;
}
/**
* 判断一个元素是否是钩子元素
* @param {Element} element 待判断的元素
* @param {Object} hook 钩子配置
* @returns {boolean} 是/否
*/
function hookTest(element, hook) {
if (hook.tagName && element.tagName !== hook.tagName) {
return false;
}
if (hook.attr && !(hook.attr in element.attributes)) {
return false;
}
return true;
}
/**
* 递归循环元素的子元素, 并在 hookeds 中返回钩子元素
* @param {Array} elements 元素数组
* @param {Set} [hookeds] 需要触发 oncreated 事件的元素集合
*/
function oncreated(elements, hookeds) {
if (hookeds == null) {
const stop = counter.capturedStart();
hookeds = new Set();
oncreated(elements, hookeds);
hookeds = Array.from(hookeds);
counter.hookeds += hookeds.length;
stop();
while (hookeds.length > 0) {
const topN = hookeds.splice(0, 300);
setTimeout(() => {
const stop = counter.hookedStart();
topN.forEach(x => x.created(), 50);
stop();
});
}
return;
}
counter.count += elements.length;
for (let i = 0; i < elements.length; i++) {
const element = elements[i];
if (isHooking(element)) {
hookeds.add(element);
}
if (element.childElementCount > 0) {
oncreated(element.children, hookeds);
}
}
}
// 当观察到变动时执行的回调函数
function callback(mutationsList) {
const stop = counter.capturedStart();
const array = [];
for (let i = 0; i < mutationsList.length; i++) {
const nodes = mutationsList[i].addedNodes;
for (var j = 0; j < nodes.length; j++) {
const node = nodes[j];
if ("tagName" in node) {
array.push(node);
}
}
}
stop();
if (array.length > 0) {
oncreated(array);
}
};
// 防抖函数
function debounce(seconds, fn) {
let timer = null;
return function (then) {
timer && clearTimeout(timer);
timer = setTimeout(() => {
fn();
then();
}, seconds * 1000);
}
}
const timeoutSecond = 5;
const over = debounce(timeoutSecond, () => {
const duration = (counter.stopwatch[0] + counter.stopwatch[1]) / 1000;
const info = {
页面元素: (document.querySelectorAll("*").length - 3) + " 个",
捕获元素: counter.count + " 个",
触发元素: counter.hookeds + " 个",
捕获执行时间: (counter.stopwatch[0] / 1000).toFixed(3) + " 秒",
触发执行时间: (counter.stopwatch[1] / 1000).toFixed(3) + " 秒",
};
if (duration > 5) {
console.group("OnCreated执行时间过长")
} else {
console.group("OnCreated执行完成")
}
console.table(info)
console.groupEnd();
});
// 页面加载事件
window.addEventListener("load", function () {
counter.begin = new Date().getTime(); // 开始时间
// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);
observer.observe(document, { childList: true, subtree: true });
// MutationObserver 是异步的,回调时间不可控,所以需要设置一个轮询
setInterval(() => callback(observer.takeRecords()), 100);
oncreated(document.children);
const watcher = setInterval(() => {
const diff = counter.count - counter.last;
if (diff < 0) {
return;
}
counter.last = counter.count
if (diff > 100) {
over(() => this.clearInterval(watcher));
}
}, 1000);
over(() => this.clearInterval(watcher));
})
// 兼容 JQuery
if (_.window) {
_.window.addEventListener("created", function (e) {
if (_.jQuery) {
const $e = new $.Event(e);
$e.isImmediatePropagationStopped = function () {
return this.target !== this.currentTarget;
}
jQueryBubble(_.jQuery, e.target, $e); // 如果这样代码对页面造成了性能影响 可以考虑注释掉
_.jQuery(document).triggerHandler($e);
}
}, true);
/**
* 模拟jQuery中的事件冒泡
* @param {jQuery} $
* @param {Element} elem
* @param {jQuery.Event} event
*/
function jQueryBubble($, elem, event) {
while (elem) {
$(elem).triggerHandler(event);
elem = elem.parentElement;
}
}
}
})(window);
5. 使用场景
例1. 将QueryString
自动填入同名input
$(document.body).on("created", ":text[name]", function (e) {
const elem = e.target;
if (elem.name) {
const reg = new RegExp("(^|&)" + elem.name.trim() + "=([^&]*)(&|$)");
const r = location.search.substr(1).match(reg);
if (r) {
elem.value = decodeURI(r[2]);
}
}
})
例2. 各种控件的自动创建
$(document.body).on("created", "input[type=date]", function (e) {
const elem = e.target;
elem.type = "text"
$(elem).datetimepicker({
format: 'yyyy-mm-dd',
minView: 2,
autoclose: true,
todayBtn: true,
});
})
例3. 处理 style[scoped]
【JS 】让浏览器支持<style scoped> - 简书 (jianshu.com)
window.addEventListener("created", e => {
if(e.target.tagName === "STYLE")){
updateStyle(e.target)
}
}, true);
6. 已知问题
捕获阶段的效率没有问题,但触发OnCreated事件触发的比较多的情况下,效率就需要看回调方法的执行时间了
另外模拟触发jQuery冒泡在元素很多的页面也会有一定的性能问题,如果不需要兼容jQuery可以去掉
也可以修改为
window.addEventListener("created", function (e) {
if (window.jQuery) {
const $e = new windiw.jQuery.Event(e);
$e.isImmediatePropagationStopped = function () {
return this.target !== this.currentTarget;
}
// 仅触发 $(document).on("created", "...", ...) ,2w个元素可以从3.5s下降到0.2s
window.jQuery(document).triggerHandler($e);
}
}, true);