上一主题 下一主题
ScriptCat,新一代的脚本管理器脚本站,与全世界分享你的用户脚本油猴脚本开发指南教程目录
返回列表 发新帖

⭐ [源码解析|正文] elmGetter的源码解析

[复制链接]
  • TA的每日心情
    奋斗
    2025-3-1 19:55
  • 签到天数: 6 天

    [LV.2]偶尔看看I

    10

    主题

    11

    回帖

    124

    积分

    荣誉开发者

    积分
    124

    油中2周年新人报道荣誉开发者

    发表于 2025-2-25 20:27:28 | 显示全部楼层 | 阅读模式

    本帖最后由 溯水流光 于 2025-2-25 21:06 编辑

    [源码解析|正文] : elmGetter的源码解析

    本文是《从 0 到 1,手把手带你剖析异步查询库 elmGetter 的源码,进行深度定制和二次开发,附 MutationObserver 讲解》系列文章中的一篇。

    该系列文章目录网址为:

    https://bbs.tampermonkey.net.cn/thread-8196-1-1.html

    作者: 溯水流光 (脚本猫论坛, 油猴中文网)

    源码分析的方法论 & 我阅读源码的契机

    分析方法

    • 静态分析:直接查看源代码。

    • 动态分析:借助浏览器开发者工具,设置断点,单步调试,观察变量状态,以此分析程序结构。

    大体分析思路

    • 分析源码的关键在于掌握知识点和找准切入点。我们可以先编写并运行一个简单的小 demo,使用debugger;设置断点,再借助开发者工具一步步单步调试,以此了解执行流程。
    • 分析过程中,最好不要仅依赖静态分析,而要动静结合。
    • 源码最好亲自阅读和调试,我也会尽力为大家梳理代码涉及的知识点,方便大家阅读。
    • 在文章中,我会一边解析源码,一边阐述我具体的分析思路,供大家参考。
    • 我是在床上用手机借助spck代码器(可在Google Play下载)完成了全部代码的静态分析,因此就不向大家详细演示动态分析部分了。
    202524fs1nzxs8rtngit8p.png

    阅读源码的契机

    elmGetter原有的部分代码设计用起来不太顺手,所以我决定对它进行魔改,添加自己需要的功能。

    我希望给 elmGetter 添加三个功能:一是增加超时错误处理回调参数,在未找到元素时打印日志,便于调试;二是通过 bool 参数控制超时时,让 promise 保持 pending 或者 resolve (null);三是支持自定义超时时 resolve 的值,比如 resolve 一个无法删除的 div。

    我不仅把这些参数保留为函数参数,还将其设为 elmGetter 成员变量,便于统一设置。为传参方便,我重构了几乎所有函数的传参方式,支持对象传参,无需记参数顺序。

    这些修改我都已完成,这也是我发文的底气。

     /**
     * 异步的 querySelector
     * @param selector
     * @param options 一个对象
     *  - parent 父元素, 默认值是 document
     *  - timeout 设置 get 的超时时间, 默认值是 elmGetter.timeout, 其值默认为 0
     *      - 如果该值为 0, 表示永不超时, 如果 selector 有误, 返回的 Promise 将永远 pending
     *      - 如果该值不为 0, 表示等待多少毫秒, 和 setTimeout 单位一致
     *  - onError 超时后的失败回调, 参数为 selector, 默认值为 elmGetter.onError, 其默认行为是 console.warn 打印 selector
     *  - isPending 超时后 Promise 是否仍然保持 pending, 默认值为 elmGetter.isPending, 其值默认为 true
     *  - errEl 超时后 Promise 返回的值, 需要 isPending 为 false 才能有效, 默认值为 elmGetter.errorEl, 其值默认为一个 class 为一个 class 为 no-found 的元素
     * @returns {Promise<Awaited<unknown>[]>|Promise<unknown>}
     */
    get(selector, options = {}) {
        let {
            parent = doc,
            timeout = this.timeout,
            onError = this.onError,
            isPending = this.isPending,
            errEl = this.errEl,
        } = options;

    魔改库的详细介绍帖:

    https://bbs.tampermonkey.net.cn/thread-8183-1-1.html

    源码分析 引言

    接下来, 我将以小白视角,逐步剖析cxxjackie大佬的elmGetter代码执行流程,解析完成后, 会去添加自己想要的功能。 期待我的文章能帮助大家将elmGetter改造成自己理想中的异步查询库,使其成为大家得心应手的如意兵器。

    我将以css模式为主线去剖析 elmGetter 源码,去繁从简,了解 elmGetter代码的执行流程,这样才能知道如何对它进行定制。

    先上源码,后面会详细解析,大家可以先大致浏览下。

    // ==UserScript==
    // @name         ElementGetter
    // @author       cxxjackie
    // @version      2.0.1
    // @supportURL   https://bbs.tampermonkey.net.cn/thread-2726-1-1.html
    // ==/UserScript==
    
    var elmGetter = function() {
        const win = window.unsafeWindow || document.defaultView || window;
        const doc = win.document;
        const listeners = new WeakMap();
        let mode = 'css';
        let $;
        const elProto = win.Element.prototype;
        const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector ||
            elProto.mozMatchesSelector || elProto.oMatchesSelector;
        const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver;
        function addObserver(target, callback) {
            const observer = new MutationObs(mutations => {
                for (const mutation of mutations) {
                    if (mutation.type === 'attributes') {
                        callback(mutation.target, 'attr');
                        if (observer.canceled) return;
                    }
                    for (const node of mutation.addedNodes) {
                        if (node instanceof Element) callback(node, 'insert');
                        if (observer.canceled) return;
                    }
                }
            });
            observer.canceled = false;
            observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
            return () => {
                observer.canceled = true;
                observer.disconnect();
            };
        }
        function addFilter(target, filter) {
            let listener = listeners.get(target);
            if (!listener) {
                listener = {
                    filters: new Set(),
                    remove: addObserver(target, (el, reason) => listener.filters.forEach(f => f(el, reason)))
                };
                listeners.set(target, listener);
            }
            listener.filters.add(filter);
        }
        function removeFilter(target, filter) {
            const listener = listeners.get(target);
            if (!listener) return;
            listener.filters.delete(filter);
            if (!listener.filters.size) {
                listener.remove();
                listeners.delete(target);
            }
        }
        function query(selector, parent, root, curMode, reason) {
            switch (curMode) {
                case 'css': {
                    if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
                    const checkParent = parent !== root && matches.call(parent, selector);
                    return checkParent ? parent : parent.querySelector(selector);
                }
                case 'jquery': {
                    if (reason === 'attr') return $(parent).is(selector) ? $(parent) : null;
                    const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
                    return jNodes.length ? $(jNodes.get(0)) : null;
                }
                case 'xpath': {
                    const ownerDoc = parent.ownerDocument || parent;
                    selector += '/self::*';
                    return ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 9, null).singleNodeValue;
                }
            }
        }
        function queryAll(selector, parent, root, curMode, reason) {
            switch (curMode) {
                case 'css': {
                    if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
                    const checkParent = parent !== root && matches.call(parent, selector);
                    const result = parent.querySelectorAll(selector);
                    return checkParent ? [parent, ...result] : [...result];
                }
                case 'jquery': {
                    if (reason === 'attr') return $(parent).is(selector) ? [$(parent)] : [];
                    const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
                    return $.map(jNodes, el => $(el));
                }
                case 'xpath': {
                    const ownerDoc = parent.ownerDocument || parent;
                    selector += '/self::*';
                    const xPathResult = ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 7, null);
                    const result = [];
                    for (let i = 0; i < xPathResult.snapshotLength; i++) {
                        result.push(xPathResult.snapshotItem(i));
                    }
                    return result;
                }
            }
        }
        function isJquery(jq) {
            return jq && jq.fn && typeof jq.fn.jquery === 'string';
        }
        function getOne(selector, parent, timeout) {
            const curMode = mode;
            return new Promise(resolve => {
                const node = query(selector, parent, parent, curMode);
                if (node) return resolve(node);
                let timer;
                const filter = (el, reason) => {
                    const node = query(selector, el, parent, curMode, reason);
                    if (node) {
                        removeFilter(parent, filter);
                        timer && clearTimeout(timer);
                        resolve(node);
                    }
                };
                addFilter(parent, filter);
                if (timeout > 0) {
                    timer = setTimeout(() => {
                        removeFilter(parent, filter);
                        resolve(null);
                    }, timeout);
                }
            });
        }
        return {
            get currentSelector() {
                return mode;
            },
            get(selector, ...args) {
                let parent = typeof args[0] !== 'number' && args.shift() || doc;
                if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
                const timeout = args[0] || 0;
                if (Array.isArray(selector)) {
                    return Promise.all(selector.map(s => getOne(s, parent, timeout)));
                }
                return getOne(selector, parent, timeout);
            },
            each(selector, ...args) {
                let parent = typeof args[0] !== 'function' && args.shift() || doc;
                if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
                const callback = args[0];
                const curMode = mode;
                const refs = new WeakSet();
                for (const node of queryAll(selector, parent, parent, curMode)) {
                    refs.add(curMode === 'jquery' ? node.get(0) : node);
                    if (callback(node, false) === false) return;
                }
                const filter = (el, reason) => {
                    for (const node of queryAll(selector, el, parent, curMode, reason)) {
                        const _el = curMode === 'jquery' ? node.get(0) : node;
                        if (refs.has(_el)) break;
                        refs.add(_el);
                        if (callback(node, true) === false) {
                            return removeFilter(parent, filter);
                        }
                    }
                };
                addFilter(parent, filter);
            },
            create(domString, ...args) {
                const returnList = typeof args[0] === 'boolean' && args.shift();
                const parent = args[0];
                const template = doc.createElement('template');
                template.innerHTML = domString;
                const node = template.content.firstElementChild;
                if (!node) return null;
                parent ? parent.appendChild(node) : node.remove();
                if (returnList) {
                    const list = {};
                    node.querySelectorAll('[id]').forEach(el => list[el.id] = el);
                    list[0] = node;
                    return list;
                }
                return node;
            },
            selector(desc) {
                switch (true) {
                    case isJquery(desc):
                        $ = desc;
                        return mode = 'jquery';
                    case !desc || typeof desc.toLowerCase !== 'function':
                        return mode = 'css';
                    case desc.toLowerCase() === 'jquery':
                        for (const jq of [window.jQuery, window.$, win.jQuery, win.$]) {
                            if (isJquery(jq)) {
                                $ = jq;
                                break;
                            }
                        }
                        return mode = $ ? 'jquery' : 'css';
                    case desc.toLowerCase() === 'xpath':
                        return mode = 'xpath';
                    default:
                        return mode = 'css';
                }
            }
        };
    }();

    轻松一下:如何正确拼写 elmGetter ?

    我编码习惯把 element 缩写成 el,而非 elm,所以之前老拼错成 elGetter。
    后来我记了个口诀 “e(饿)l(了)m(么)Getter,elmGetter”,就再也没拼错过。

    源码分析 第一小节 先看整体

    elmGetter 的成员方法都是闭包

    // ==UserScript==
    // @name         New Userscript
    // @namespace    http://tampermonkey.net/
    // @version      2025-02-21
    // @description  try to take over the world!
    // @author       You
    // @match        http://127.0.0.1:5500/playground.html
    // @icon         https://www.google.com/s2/favicons?sz=64&domain=0.1
    // @grant        unsafeWindow
    // @grant       GM_registerMenuCommand
    // ==/UserScript==
    
    var elmGetter = (() => {
        const _私有成员变量 = 33;
        function _私有成员方法() {
            console.log("私有成员方法被调用")
        }
    
        return {
            公共成员方法() {
                _私有成员方法();
                console.log("私有成员变量", _私有成员变量);
            }
        }
    })();
    
    (async function () {
        'use strict';
    
        elmGetter.公共成员方法();
    
        // 无法 elmGetter._私有成员方法()
        // elmGetter._私有成员方法();
    })();
    

    考古学:

    我翻论坛帖子: https://bbs.tampermonkey.net.cn/thread-2726-1-1.html
    1.2.1
    对私有属性的语法降级,以兼容低版本浏览器。

    我推测这里降级的方式就是转用闭包结构

    源码分析 第二小节 初始化私有成员变量

    var elmGetter = function() {
        const win = window.unsafeWindow || document.defaultView || window;
        const doc = win.document;
        const listeners = new WeakMap();
        let mode = 'css';
        let $;
        const elProto = win.Element.prototype;
        const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector ||
            elProto.mozMatchesSelector || elProto.oMatchesSelector;
        const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver;
    

    阅读源码时,一个重要技巧是简化代码,先把各种边界条件的判断代码放到一边。阅读源码要少量多次。

    先大致读几遍,在脑海里形成基本的代码流程和框架。如果想深入学习,再去琢磨边界条件的设计逻辑。

    千万别一开始就陷入极端边界条件的判断中,不然很容易迷失方向,抓不住重点。

    上面的代码做了大量针对不同浏览器的兼容性处理,就如同CSS前缀一样。这些代码可以简化成下面这样。

    var elmGetter = function() {
        const win =  window;
        const doc = win.document;
        const listeners = new WeakMap();
        let mode = 'css';
        let $;
        const elProto = win.Element.prototype;
        const matches = elProto.matches; // Element.prototype.matches
        const MutationObs = win.MutationObserver;

    这里唯一需要讲解的是matches,它是Element类的成员方法,我们稍后会详细介绍。

    原型链: https://www.bilibili.com/video/BV15T411t725
    补充:在 JS 中,任意的 function 都能作为类的构造函数。这个构造函数有一个 prototype 属性,它指向该类的原型。使用这个构造函数创建的实例,所有实例的__proto__ 都指向这个 prototype。当访问实例的属性时,就会沿着原型链向上查找,因此 prototype 可用于存储成员方法。

    源码分析 第三小节 从 get 接口入手

    get(selector, ...args) {
        let parent = typeof args[0] !== 'number' && args.shift() || doc;
        if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
        const timeout = args[0] || 0;
        if (Array.isArray(selector)) {
            return Promise.all(selector.map(s => getOne(s, parent, timeout)));
        }
        return getOne(selector, parent, timeout);
    },

    接口说明

    文档: https://bbs.tampermonkey.net.cn/thread-2726-1-1.html

    get(selector[, parent][, timeout])

    selector 必须, 选择器或选择器数组,默认使用css选择器
    parent 可选,父节点,默认值document。
    timeout 可选,超时时间(毫秒),默认值0。
    返回值 Promise,selector为选择器时返回元素,为数组时返回元素数组。

    && 运算符

    可以打开 chrome 开发者工具, 调到控制台, 试试下面指令

    true && "被执行了" // 表达式结果为 "被执行了"
    false && "永远不会被执行" // 表达式结果为 false

    shift

    let arr = ['11', '22', '33'];
    arr.shift(); // 返回 '11'
    console.log(arr); // ['22', '33']

    Array.prototype.shift 的作用为:移除数组的首元素,并将其返回。

    || 运算符

    true || "永远不会被执行" // 表达式结果为 true
    false || "被执行了" // 表达式结果为 "被执行了"

    它经常用于设置默认值

    function foo(param) {
        let bar = param || "默认值";
        console.log(bar);
    }
    
    foo() // 输出: 默认值
    foo("111") // 输出: 111

    parent的赋值

     let parent = typeof args[0] !== 'number' && args.shift() || doc;

    等同于

    let parent;
    if (typeof args[0]!== 'number') {
        parent = args.shift();
    } else {
        parent = doc;
    }

    args 数组的第一个元素不是数字类型,则移除并返回 args 数组的第一个元素赋值给 parent,否则将 doc 的值赋给 parent
    这里进行类型判断是为了避免把 timeout 错误赋值给 parent

    // `get(selector[, parent][, timeout])`
    get('.child', 3000); // 避免把 3000 赋值给parent
    get('.child', document.body) // 实现把 body 元素赋值给parent

    继续流程分析

    if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);

    此处是判断 parent 是否为经 jQuery 包裹的实例,parent.get(0) 能取出原始的 DOM 元素。

    鉴于我们以 elmGetter 的 CSS 模式为主线进行分析,这属于边界条件判断,直接忽略即可。

    const timeout = args[0] || 0;
    if (Array.isArray(selector)) {
        return Promise.all(selector.map(s => getOne(s, parent, timeout)));
    }

    map 高阶函数的使用如下:

    function addOne(num) {
      return num + 1;
    }
    
    let arr = [1, 2, 3];
    
    let newArr = arr.map(num => addOne(num));
    
    console.log(newArr); // [2, 3, 4]

    getOne 我们马上就会开始分析

    这里是为了支持以数组作为参数进行调用

    // ==UserScript==
    // @name         New Userscript
    // @namespace    http://tampermonkey.net/
    // @version      2025-02-21
    // @description  try to take over the world!
    // @author       You
    // @match        http://127.0.0.1:5500/playground.html
    // @icon         https://www.google.com/s2/favicons?sz=64&domain=0.1
    // @grant        unsafeWindow
    // @require https://scriptcat.org/lib/2847/3.0.0/ElementGetter%20%E6%B0%B4%E6%9E%9C%E7%8E%89%E7%B1%B3%20%E9%AD%94%E6%94%B9%E7%89%88.js
    // ==/UserScript==
    
    (async function () {
        'use strict';
    
        // TODO: 发布脚本时, 请去掉延时, 延时会导致 elmGetter 启动 setTimeout, 降低脚本性能, 这里设置延时, 仅仅是方便日志打印和调试bug
        elmGetter.timeout = 999;
    
        let elArr = await elmGetter.get([".child", ".child", ".child"]);
    
        console.log(elArr); // 三个DOM元素 [p.child, p.child, p.child]
    })();
    return getOne(selector, parent, timeout);

    接下来就是分析 getOne 了

    源码分析 第四小节 getOne

    function getOne(selector, parent, timeout) {
        const curMode = mode;
        return new Promise(resolve => {
            const node = query(selector, parent, parent, curMode);
            if (node) return resolve(node);
            let timer;
            const filter = (el, reason) => {
                const node = query(selector, el, parent, curMode, reason);
                if (node) {
                    removeFilter(parent, filter);
                    timer && clearTimeout(timer);
                    resolve(node);
                }
            };
            addFilter(parent, filter);
            if (timeout > 0) {
                timer = setTimeout(() => {
                    removeFilter(parent, filter);
                    resolve(null);
                }, timeout);
            }
        });
    }

    getOne是核心函数,它相当于基于MutationObserve实现的异步querySelector。

    刚刚分析的get,其作用仅仅是支持默认参数调用以及选择器为数组时的调用。也就是说,get接口存在的意义只是为了更便捷地调用getOne,实际的关键操作都是getOne在执行 。

    query 函数

    function query(selector, parent, root, curMode, reason) {
        switch (curMode) {
            case 'css': {
                if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
                const checkParent = parent !== root && matches.call(parent, selector);
                return checkParent ? parent : parent.querySelector(selector);
            }
        }
    }
    先大体上看

    去掉所有的边界条件, query 就是 querySelector

    function query(selector, parent, root, curMode, reason) {
        switch (curMode) {
            case 'css': {
                return parent.querySelector(selector);
            }
        }
    }
    边界条件的解析

    边界条件解析属于进阶内容, 可以读完文章 再回头来看

    判断 selector 是否为 parent

    接下来讲讲其他的边界条件是在判断什么

    const checkParent = parent !== root && matches.call(parent, selector);

    querySelector存在局限性,它无法查找自身,仅会从子元素开始查找。

    document.querySelector("body"); // body 元素
    document.body.querySelector("body") // 返回值为null, 找不到

    Element.prototype.matches (selector) 会使用 selector 对元素自身进行匹配。若匹配成功,返回 true;否则返回 false。

    call 的作用是绑定 this 指向并调用函数。

    关于 call 和 this 指向的学习内容,可以参考 coderwhy 的 JS 高级课程。

    https://mp.weixin.qq.com/s/15b8I7FNhhFqR0jX_mWLRg

    所以,matches.call(parent, selector); 用于判断 selector 是否为 parent 的 CSS 选择器。

    parent !== root是为了避免报错:

    Element.prototype.matches.call(document, "document"); // 报错
    // VM116:1 Uncaught TypeError: Illegal invocation
    //   at <anonymous>:1:27

    小思考:

    Q: root 为什么不直接写死为 doc?

    A: 因为要兼容 iframe 查询

    监听属性变化

    边界条件解析属于进阶内容, 可以读完文章 再回头来看

    下面有对过滤器的详细介绍: <源码分析 第五小节 Mutation Observer & 监听器 & 过滤器>

    if (reason === 'attr') return matches.call(parent, selector) ? parent : null;

    Mutation Observe 可以监听元素属性的变化, 如果一个元素的属性变化了, 其 selector 也会发生变化

    如一个元素类名从child变成了son, 其selector也就从.child变成了.son

    这时候就需要再次判断 selector 是否为该属性发生变化的元素

     return new Promise(resolve => {
            const node = query(selector, parent /* parent */, parent /* root */, curMode);
            if (node) return resolve(node);
            let timer;
            const filter = (el, reason) => {
                const node = query(selector, el /* parent */, parent /* root */, curMode, reason);
                if (node) {

    这里的 filter 过滤器, 可以简单理解为 Mutation Observe 的回调函数, 下面是简化后的代码:

    function addObserver(target, callback) {
        const observer = new MutationObs(mutations => {
            for (const mutation of mutations) {
                if (mutation.type === 'attributes') {
                    callback(mutation.target, 'attr'); // 注意点1
                }
                for (const node of mutation.addedNodes) {
                    if (node instanceof Element) callback(node, 'insert');
                }
            }
        });
        observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
        return () => {
            observer.disconnect();
        };
    }
    function addFilter(target, filter) {
        addObserver(target, (el, reason) => filter(el, reason))
    }
    注意点1

    这里实际上是把属性发生变化的元素传递给了回调函数。

    当有新元素插入 parent 时,这个新元素会作为 el 传递给回调函数。

    试一试

    这里采用动态代码分析,通过 console.log 打印 el,以深入理解函数流程。

    这里直接复制粘贴代码,让脚本运行起来就行,无需关注代码逻辑。重点是观察控制台的输出。

    202709zxcz0e9wma9mevpu.png

    此处的演练场与《Mutation Observer 快速上手》里所用的一致,具体可查看目录。

    // ==UserScript==
    // @name         New Userscript
    // @namespace    http://tampermonkey.net/
    // @version      2025-02-21
    // @description  try to take over the world!
    // @author       You
    // @match        http://127.0.0.1:5500/playground.html
    // @icon         https://www.google.com/s2/favicons?sz=64&domain=0.1
    // @grant        unsafeWindow
    // ==/UserScript==
    
    // 没有中间层
    initGlobalVar();
    
    (async function () {
        'use strict';
    
        let el2 = await elmGetter.get(".child2");
    
        console.log(el2);
    })();
    
    function initGlobalVar() {
        const win = window.unsafeWindow || document.defaultView || window;
    
        // 魔改版
        win.elmGetter = function () {
            const win = window.unsafeWindow || document.defaultView || window;
            const doc = win.document;
            const listeners = new WeakMap();
            let mode = 'css';
            let $;
            const elProto = win.Element.prototype;
            const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector ||
                elProto.mozMatchesSelector || elProto.oMatchesSelector;
            const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver;
    
            function addObserver(target, callback) {
                const observer = new MutationObs(mutations => {
                    for (const mutation of mutations) {
                        if (mutation.type === 'attributes') {
                            callback(mutation.target, 'attr');
                            if (observer.canceled) return;
                        }
                        for (const node of mutation.addedNodes) {
                            if (node instanceof Element) callback(node, 'insert');
                            if (observer.canceled) return;
                        }
                    }
                });
                observer.canceled = false;
                observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
                return () => {
                    observer.canceled = true;
                    observer.disconnect();
                };
            }
    
            function addFilter(target, filter) {
                let listener = listeners.get(target);
                if (!listener) {
                    listener = {
                        filters: new Set(),
                        remove: addObserver(target, (el, reason) => listener.filters.forEach(f => f(el, reason)))
                    };
                    listeners.set(target, listener);
                }
                listener.filters.add(filter);
            }
    
            function removeFilter(target, filter) {
                const listener = listeners.get(target);
                if (!listener) return;
                listener.filters.delete(filter);
                if (!listener.filters.size) {
                    listener.remove();
                    listeners.delete(target);
                }
            }
    
            function query(selector, options = {}) {
                let {
                    parent,
                    root,
                    curMode,
                    reason
                } = options;
    
                console.log(reason);
    
                switch (curMode) {
                    case 'css': {
                        if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
                        const checkParent = parent !== root && matches.call(parent, selector);
                        return checkParent ? parent : parent.querySelector(selector);
                    }
                    case 'jquery': {
                        if (reason === 'attr') return $(parent).is(selector) ? $(parent) : null;
                        const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
                        return jNodes.length ? $(jNodes.get(0)) : null;
                    }
                    case 'xpath': {
                        const ownerDoc = parent.ownerDocument || parent;
                        selector += '/self::*';
                        return ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 9, null).singleNodeValue;
                    }
                }
            }
    
            function queryAll(selector, options = {}) {
                let {
                    parent,
                    root,
                    curMode,
                    reason
                } = options;
    
                switch (curMode) {
                    case 'css': {
                        if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
                        const checkParent = parent !== root && matches.call(parent, selector);
                        const result = parent.querySelectorAll(selector);
                        return checkParent ? [parent, ...result] : [...result];
                    }
                    case 'jquery': {
                        if (reason === 'attr') return $(parent).is(selector) ? [$(parent)] : [];
                        const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
                        return $.map(jNodes, el => $(el));
                    }
                    case 'xpath': {
                        const ownerDoc = parent.ownerDocument || parent;
                        selector += '/self::*';
                        const xPathResult = ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 7, null);
                        const result = [];
                        for (let i = 0; i < xPathResult.snapshotLength; i++) {
                            result.push(xPathResult.snapshotItem(i));
                        }
                        return result;
                    }
                }
            }
    
            function isJquery(jq) {
                return jq && jq.fn && typeof jq.fn.jquery === 'string';
            }
    
            function getOne(selector, options = {}) {
                let {
                    parent,
                    timeout,
                    onError,
                    isPending,
                    errEl
                } = options;
    
                const curMode = mode;
                return new Promise(resolve => {
                    const node = query(
                        selector,
                        {
                            parent: parent,
                            root: parent,
                            curMode
                        });
    
                    if (node) return resolve(node);
                    let timer;
                    const filter = (el, reason) => {
                        console.log(el);
                        const node = query(
                            selector,
                            {
                                parent: el,
                                root: parent,
                                curMode,
                                reason
                            });
    
                        if (node) {
                            removeFilter(parent, filter);
                            timer && clearTimeout(timer);
                            resolve(node);
                        }
                    };
                    addFilter(parent, filter);
                    if (timeout > 0) {
                        timer = setTimeout(() => {
                            removeFilter(parent, filter);
                            onError(selector);
                            if (!isPending) {
                                resolve(errEl);
                            }
                        }, timeout);
                    }
                });
            }
    
            let errEl = document.createElement('div');
            errEl.classList.add('no-found');
            errEl.remove = () => {};
    
            return {
                timeout: 0,
                onError:  (selector) => {console.warn(`[elmGetter] [get失败] selector为: ${selector} 的查询超时`)},
                isPending: true,
                errEl,
                get currentSelector() {
                    return mode;
                },
                /**
                 * 异步的 querySelector
                 * @param selector
                 * @param options 一个对象
                 *  - parent 父元素, 默认值是 document
                 *  - timeout 设置 get 的超时时间, 默认值是 elmGetter.timeout, 其值默认为 0
                 *      - 如果该值为 0, 表示永不超时, 如果 selector 有误, 返回的 Promise 将永远 pending
                 *      - 如果该值不为 0, 表示等待多少毫秒, 和 setTimeout 单位一致
                 *  - onError 超时后的失败回调, 参数为 selector, 默认值为 elmGetter.onError, 其默认行为是 console.warn 打印 selector
                 *  - isPending 超时后 Promise 是否仍然保持 pending, 默认值为 elmGetter.isPending, 其值默认为 true
                 *  - errEl 超时后 Promise 返回的值, 需要 isPending 为 false 才能有效, 默认值为 elmGetter.errorEl, 其值默认为一个 class 为一个 class 为 no-found 的元素
                 * @returns {Promise<Awaited<unknown>[]>|Promise<unknown>}
                 */
                get(selector, options = {}) {
                    let {
                        parent = doc,
                        timeout = this.timeout,
                        onError = this.onError,
                        isPending = this.isPending,
                        errEl = this.errEl,
                    } = options;
    
                    options.parent = parent;
                    options.timeout = timeout;
                    options.onError = onError;
                    options.isPending = isPending;
                    options.errEl = errEl;
    
                    if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
    
                    if (Array.isArray(selector)) {
                        return Promise.all(selector.map(s => getOne(s, options)));
                    }
                    return getOne(selector, options);
                },
                /**
                 * 为父节点设置监听,所有符合选择器的元素(包括页面已有的和新插入的)都将被传给回调函数处理,
                 * each方法适用于各种滚动加载的列表(如评论区),或者发生非刷新跳转的页面等
                 * @param selector
                 * @param callback 回调函数, 只在每个元素上触发一次。 回调函数接收2个参数,第一个是符合选择器的元素,第二个表明该元素是否为新插入的(已有为false,插入为true)
                 * @param options 一个对象
                 *  - parent 父元素, 默认值是 document
                 */
                each(selector, callback, options = {}) {
                    let {
                        parent = doc,
                    } = options;
    
                    if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
    
                    const curMode = mode;
                    const refs = new WeakSet();
                    for (const node of queryAll(selector, {parent, root: parent, curMode})) {
                        refs.add(curMode === 'jquery' ? node.get(0) : node);
                        if (callback(node, false) === false) return;
                    }
                    const filter = (el, reason) => {
                        for (const node of queryAll(selector, {parent:el, root:parent, curMode, reason})) {
                            const _el = curMode === 'jquery' ? node.get(0) : node;
                            if (refs.has(_el)) break;
                            refs.add(_el);
                            if (callback(node, true) === false) {
                                return removeFilter(parent, filter);
                            }
                        }
                    };
                    addFilter(parent, filter);
                },
                /**
                 * 将html字符串解析为元素
                 * @param domString
                 * @param options 一个对象
                 *  - returnList 布尔值,是否返回以 id 作为索引的元素列表, 默认值为 false
                 *  - parent 父节点,将创建的元素添加到父节点末尾处, 如果不指定, 解析后的元素将
                 * @returns {Element|{}|null} 元素或对象,取决于returnList参数
                 */
                create(domString, options = {}) {
                    let {
                        returnList = false,
                        parent = null
                    } = options;
                    const template = doc.createElement('template');
                    template.innerHTML = domString;
                    const node = template.content.firstElementChild;
                    if (!node) return null;
                    parent ? parent.appendChild(node) : node.remove();
                    if (returnList) {
                        const list = {};
                        node.querySelectorAll('[id]').forEach(el => list[el.id] = el);
                        list[0] = node;
                        return list;
                    }
                    return node;
                },
                selector(desc) {
                    switch (true) {
                        case isJquery(desc):
                            $ = desc;
                            return mode = 'jquery';
                        case !desc || typeof desc.toLowerCase !== 'function':
                            return mode = 'css';
                        case desc.toLowerCase() === 'jquery':
                            for (const jq of [window.jQuery, window.$, win.jQuery, win.$]) {
                                if (isJquery(jq)) {
                                    $ = jq;
                                    break;
                                }
                            }
                            return mode = $ ? 'jquery' : 'css';
                        case desc.toLowerCase() === 'xpath':
                            return mode = 'xpath';
                        default:
                            return mode = 'css';
                    }
                }
            };
        }();
    }
    

    继续分析 getOne 流程

    我们在知道 query 为 querySelector, 剩下的就好分析了

    function getOne(selector, parent, timeout) {
        const curMode = mode;
        return new Promise(resolve => {
            const node = query(selector, parent, parent, curMode);
            if (node) return resolve(node); // 上来就查询, 如果找到了就不创建 Mutation Observer了
            let timer; 
            const filter = (el, reason) => { // filter 就是 Mutation Observer 的回调函数
                const node = query(selector, el, parent, curMode, reason);
                if (node) {
                    removeFilter(parent, filter);
                    timer && clearTimeout(timer);
                    resolve(node);
                }
            };
            addFilter(parent, filter); // 创建 Mutation Observer, 进行异步查询
            if (timeout > 0) {
                timer = setTimeout(() => {
                    removeFilter(parent, filter);
                    resolve(null); // 超时后, 降级返回null, 这里不用reject, 是怕脚本报错, 停止运行
                }, timeout);
            }
        });
    }

    源码分析 第五小节 Mutation Observer & 监听器 & 过滤器

    const win = window;
    const doc = win.document;
    
    // DOM元素 -> 监听器 (listener)
    // 
    // 这里使用 WeakMap 是为了避免内存泄露
    // 当 DOM 元素调用 .remove() 的时候, 如果 DOM 元素仍然存在引用
    // 也就是作为 listeners 的 key, 其引用计数就不会归零, 
    // 也就是说这个 DOM 元素是不会被垃圾回收器 (GC) 回收的,
    // 所以这里要使用 WeakMap 来解决这个问题
    //
    // 监听器是一个对 Mutation Observer 进行封装后的对象
    // 监听器包含两个部分, 一个是用于删除 Mutation Observer 的 remove 成员变量
    // 还有一个过滤器集合 (filters), 用于存储 Mutation Observer 触发时, 需要调用的回调函数
    const listeners = new WeakMap(); 
    let mode = 'css';
    
    const MutationObs = win.MutationObserver;
    
    // 可参阅《Mutation Observer 快速上手》,具体可查看目录
    function addObserver(target, callback) {
        const observer = new MutationObs(mutations => {
            for (const mutation of mutations) {
                if (mutation.type === 'attributes') {
                    callback(mutation.target, 'attr');
                    if (observer.canceled) return;
                }
                for (const node of mutation.addedNodes) {
                    if (node instanceof Element) callback(node, 'insert');
                    if (observer.canceled) return;
                }
            }
        });
        observer.canceled = false;
        observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
        return () => {
            observer.canceled = true;
            observer.disconnect();
        };
    }
    
    // 注册 filter, 当 target 发生变化时, filter 会被调用
    // filter 是一个回调函数, 有两个参数
    // 一个是 el, 发生变化的元素
    // 一个是 reason, 值为 'attr', 或 'insert', 代表是属性发生了变化, 还是新插入了元素
    function addFilter(target, filter) {
        let listener = listeners.get(target);
        if (!listener) {
            listener = {
                filters: new Set(),
                remove: addObserver(target, (el, reason) => listener.filters.forEach(f => f(el, reason)))
            };
            listeners.set(target, listener);
        }
        listener.filters.add(filter);
    }
    
    // 在找到元素后, 记得移除 filter, 减小浏览器负担
    function removeFilter(target, filter) {
        const listener = listeners.get(target);
        if (!listener) return;
        listener.filters.delete(filter);
        if (!listener.filters.size) {
            listener.remove();
            listeners.delete(target);
        }
    }

    封装思想在编程中无处不在,axios是对ajax的封装,为上层提供了易用接口。这里抽象出的filter是对Mutation Observer的封装,底层复用了Mutation Observer。注册首个filter时会自动创建Mutation Observer,filters清空时则自动释放,减轻了浏览器负担。

    源码分析 第六小节 each

    each(selector, ...args) {
        let parent = typeof args[0] !== 'function' && args.shift() || doc;
        if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
        const callback = args[0];
        const curMode = mode;
        const refs = new WeakSet();
        for (const node of queryAll(selector, parent, parent, curMode)) {
            refs.add(curMode === 'jquery' ? node.get(0) : node);
            if (callback(node, false) === false) return;
        }
        const filter = (el, reason) => {
            for (const node of queryAll(selector, el, parent, curMode, reason)) {
                const _el = curMode === 'jquery' ? node.get(0) : node;
                if (refs.has(_el)) break;
                refs.add(_el);
                if (callback(node, true) === false) {
                    return removeFilter(parent, filter);
                }
            }
        };
        addFilter(parent, filter);
    },
    
    function queryAll(selector, parent, root, curMode, reason) {
        switch (curMode) {
            case 'css': {
                if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
                const checkParent = parent !== root && matches.call(parent, selector);
                const result = parent.querySelectorAll(selector);
                return checkParent ? [parent, ...result] : [...result];
            }
            case 'jquery': {
                if (reason === 'attr') return $(parent).is(selector) ? [$(parent)] : [];
                const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
                return $.map(jNodes, el => $(el));
            }
            case 'xpath': {
                const ownerDoc = parent.ownerDocument || parent;
                selector += '/self::*';
                const xPathResult = ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 7, null);
                const result = [];
                for (let i = 0; i < xPathResult.snapshotLength; i++) {
                    result.push(xPathResult.snapshotItem(i));
                }
                return result;
            }
        }
    }

    查考引用: https://bbs.tampermonkey.net.cn/thread-2726-1-1.html

    each(selector[, parent], callback)

    selector 必须,选择器,默认使用css选择器
    parent 可选,父节点,默认值document。
    callback 必须,回调函数。
    返回值 无。

    为父节点设置监听,所有符合选择器的元素(包括页面已有的和新插入的)都将被传给回调函数处理,回调函数只在每个元素上触发一次。 回调函数接收2个参数,第一个是符合选择器的元素,第二个表明该元素是否为新插入的(已有为false,插入为true)。each方法适用于各种滚动加载的列表(如评论区),或者发生非刷新跳转的页面等,参考以下示例:

    // b站评论区自动展开回复
    elmGetter.each('.reply-item', document, reply => {
     const btn = reply.querySelector('.view-more-btn');
     if (btn) btn.click();
    });

    令回调函数返回false即可移除监听,参考以下示例:

    const listener = elmGetter.each('div', document, (elm, isInserted) => {
     if (isInserted) {
         return false;
     }
    });

    简化:

    each(selector, ...args) {
        let parent = typeof args[0] !== 'function' && args.shift() || doc;
        const callback = args[0];
        const curMode = mode;
        // 避免使用相同的 DOM 元素去调用回调方法
        const refs = new WeakSet();
        for (const node of queryAll(selector, parent, parent, curMode)) {
            if (callback(node, false) === false) return;
        }
        const filter = (el, reason) => {
            for (const node of queryAll(selector, el, parent, curMode, reason)) {
                if (refs.has(_el)) break;
                refs.add(_el);
                if (callback(node, true) === false) {
                    return removeFilter(parent, filter);
                }
            }
        };
        addFilter(parent, filter);
    },
    
    function queryAll(selector, parent, root, curMode, reason) {
        switch (curMode) {
            case 'css': {
                // if (reason === 'attr') 时 parent 为发生变化的 DOM 元素
                // DOM 属性发生了变化, 其 selector 也有可能发生了变化, 所以这里需要重新matches.call
                if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
                // parent !== root 是为了避免 matches.call(document, selector); 报错
                const checkParent = parent !== root && matches.call(parent, selector);
                const result = parent.querySelectorAll(selector);
                return checkParent ? [parent, ...result] : [...result];
            }
        }
    }

    尾声

    以上就是源码分析的全部内容。像 createselector 这类代码都很简单、细枝末节,相信各位读者凭借聪明才智轻松就能读懂。

    最后的最后, 感谢你的阅读! 如有任何问题 欢迎一起讨论

    ✿✿ヽ(°▽°)ノ✿完结撒花!

    下一章节: <附录A: 再来点源码阅读, 评鉴经典的异步库设计>

    文章地址: https://bbs.tampermonkey.net.cn/thread-8197-1-1.html

    发表回复

    本版积分规则

    快速回复 返回顶部 返回列表