【JS 】给HTML元素增加OnCreated事件

本文档涉及知识点

  1. 如何监听页面dom变化
  2. 如何触发自定义事件
  3. 如何让自定义事件支持onxxx属性绑定
  4. 如何跳过原生js事件,仅触发jQuery冒泡
  5. 如何给还未创建的元素绑定事件
  6. 利用分割函数+微队列处理长时任务,防止页面假死

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.addEventListenerOnCreated.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]);
        }
    }
})
image.png

例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);
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容