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

仿 elmGetter (opinionated version)

[复制链接]

该用户从未签到

3

主题

15

回帖

88

积分

初级工程师

积分
88
发表于 2023-12-19 21:43:37 | 显示全部楼层 | 阅读模式

本帖最后由 LinLin00 于 2023-12-19 21:54 编辑

仿 elmGetter (opinionated version)

看了 @cxxjackie 写的 elmGetter 库,哥哥真是太强啦!

本着学习的心态,我也用 ts 模仿着写了一个 "opinionated" 的自用函数,并做了一些改动:

  • 不考虑 jQuery、XPath 选择器,并移除了兼容方面的考量

  • 去掉了 create 方法,因为这与 elmGetter 这个库的主题相违背,变成 util 库了

  • 原有的 get 方法为返回 Prosime<Element>,本意是使用现代 await 语法简化开发,但会带来一些问题

    首先这会导致 get 和 each 的 API 风格不统一

    接着是是使用选择器数组同时获取多个元素时,分开的多次 await 可能造成性能问题,这个在哥哥的代码里使用 Promise.all 来解决

    然后再看一个例子

    await elmGetter.get(无效的选择器, parentA)
    await elmGetter.get(有效的选择器, parentB)

    如果在开发过程中有一个无效的元素器且没有设置超时值, 将会无限阻塞线程,不会往下运行,这会增加开发者的调试时间。

    想要程序按照你想的方式运行,应该使用 elmGetter.get(无效的选择器, parentA).then(e => { ... })

    因此我统一了 API 风格,第一个参数总是为选择器或选择器数组,第二个参数总是为回调函数,第三个参数是可选的 options。这可以让正确的事情变得容易

  • 原有的 each 函数不支持选择器数组和 timeout 参数,且每调用一次 each 函数,都会对每一个 mutation.target 或者每个 mutation.addedNodes 中的元素应用一次 querySelectorAll。虽然这可以通过 用逗号将选择器合并 解决一部分,但是在如果不同选择器对应不同的 callback 函数时该问题仍然存在。

    这对监听大量选择器时有可能产生性能上的问题,因此我做了一个优化,保证对单个 mutation.target 仅会调用一次 querySelectorAll

  • 原有的函数不支持 querySelectorAll,但是 “不好实现,因为列表何时加载完毕是不可知的”

    我仿照事件防抖 debounce 的思想,假定符合 selector 的元素连续出现,在某个指定的时间 delay 都没有再次出现之后,可以视为全部出现,以此来模拟 querySelectorAll。在我的代码中这个 allDelay 默认为 1000 ms

  • 我的版本中函数返回一个手动移除本次函数调用添加的所有 selector 的函数,这个函数的返回值是是否在调用函数的时候移除了所有 selector

    如果因为新出现的元素有符合 selector 的,执行 callback 之后自动 remove 了相应的 processor,则返回值为假

还有一些小改动,就不展开说了,Talk is cheap, Show you the code!

如有错误,还请哥哥们指出

/**
 * 该函数用于观察和响应 DOM 的动态变化。
 * 它提供了一种方式,可以将自定义回调应用到匹配特定选择器的元素上,用于动态查询和处理 DOM 元素。
 * @param selector 要匹配元素的 CSS 选择器或选择器数组。
 * @param callback 处理匹配元素的回调函数。
 * @param options 配置选项,包含一系列可选设置:
 *        - parent?: ParentNode - 父节点,默认为 document。
 *        - once?: boolean - 是否只执行一次,默认为 true。
 *        - timeout?: number - 超时时间(毫秒),默认为 -1,表示无超时。
 *        - onTimeout?: () => void - 超时时的回调函数。
 *        - all?: boolean - 是否模拟 querySelectorAll,默认为 true。
 *        - allDelay?: number - 模拟 querySelectorAll 时的 debounce 延时(毫秒),默认为 1000。
 * @returns 一个函数,用于取消所有 selector,其返回值为是否在调用函数时移除了所有 selector
 */
export type processNode = (node: Element) => void
export type SelectorCallbackTuple = [string, processNode]

export const dynamicQuery = (() => {
    function addObserver(target: Node, callback: (node: Node) => void) {
        // observer 断开连接之后,正在运行的循环仍会运行,所以使用一个 canceled 变量在断开之后立即结束循环
        let canceled = false
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList' || mutation.type === 'attributes') {
                    if (canceled) return
                    callback(mutation.target)

                    for (const node of mutation.addedNodes) {
                        if (canceled) return
                        callback(node)
                    }
                }
            }
        })

        observer.observe(target, {
            subtree: true,
            childList: true,
            attributes: true,
        })
        return () => {
            canceled = true
            observer.disconnect()
        }
    }

    // 确保每个 Node 仅有一个 observer,避免创建大量的观察者对象
    const observedNodeMap = new WeakMap<Node, { processors: Set<SelectorCallbackTuple>, remove: () => void }>()

    function addProcessor(target: Node, processor: SelectorCallbackTuple) {
        let observedNode = observedNodeMap.get(target)
        if (!observedNode) {
            // 确保每个元素仅触发一次检查
            let checked: WeakSet<Node> | null = new WeakSet()

            // 这里 Set 仅保存二元组(数组)的引用,作用是为了能方便地删除 processor
            let processors: Set<SelectorCallbackTuple> | null = new Set()

            const checkAndApply = (e: Element) => {
                if (checked && !checked.has(e)) {
                    checked.add(e)
                    processors?.forEach(([s, f]) => {
                        if (e.matches(s)) {
                            f(e)
                        }
                    })
                }
            }

            const disconnect = addObserver(target, e => {
                if (e instanceof Element) {
                    checkAndApply(e)

                    // 当一个 observer 绑定大量 selector 时,仅需执行一次 querySelectorAll
                    e.querySelectorAll('*').forEach(checkAndApply)
                }
            })

            observedNode = {
                processors,
                remove: () => {
                    disconnect()
                    checked = null
                    processors = null
                },
            }
            observedNodeMap.set(target, observedNode)
        }
        observedNode.processors.add(processor)
    }

    // 返回是否在本次 removeProcessor 中删除了 processor,不论如何总是会保证删除
    function removeProcessor(target: Node, processor: SelectorCallbackTuple) {
        const observedNode = observedNodeMap.get(target)
        if (!observedNode) return false

        const isDeleteInThisTime = observedNode.processors.delete(processor)
        if (!observedNode.processors.size) {
            observedNode.remove()
            observedNodeMap.delete(target)
        }
        return isDeleteInThisTime
    }

    /**
     * 该函数用于观察和响应 DOM 的动态变化。
     * 它提供了一种方式,可以将自定义回调应用到匹配特定选择器的元素上,用于动态查询和处理 DOM 元素。
     * @param selector 要匹配元素的 CSS 选择器或选择器数组。
     * @param callback 处理匹配元素的回调函数。
     * @param options 配置选项,包含一系列可选设置:
     *        - parent?: ParentNode - 父节点,默认为 document。
     *        - once?: boolean - 是否只执行一次,默认为 true。
     *        - timeout?: number - 超时时间(毫秒),默认为 -1,表示无超时。
     *        - onTimeout?: () => void - 超时时的回调函数。
     *        - all?: boolean - 是否模拟 querySelectorAll,默认为 true。
     *        - allDelay?: number - 模拟 querySelectorAll 时的 debounce 延时(毫秒),默认为 1000。
     * @returns 一个函数,用于取消所有 selector,其返回值为是否在调用函数时移除了所有 selector
     */
    return function (selector: string | string[], callback: processNode = console.log, options: {
        parent?: ParentNode,
        once?: boolean,
        timeout?: number,
        onTimeout?: () => void,
        all?: boolean,
        allDelay?: number,
    } = {}) {
        const {
            parent = document,
            once = true,
            timeout = -1,
            onTimeout = () => console.log('dynamicQuery Timeout!', arguments),
            all = true,
            allDelay = 1000,
        } = options

        const selectors = Array.isArray(selector) ? selector : [selector]

        // 总是会先立即执行 querySelector(All) 并应用 callback
        const notExistSelectors = selectors.filter(selector => {
            const result = all
                ? parent.querySelectorAll(selector)
                : ([parent.querySelector(selector)].filter(e => e !== null)) as Element[]
            result.forEach(callback) // Side Effect!

            // 筛选留下查询不到的 selector
            return result.length === 0
        })

        if (once && notExistSelectors.length === 0) return () => false

        // 如为 once,仅需监听现存页面查询不到的 selector,否则持续监听所有 selector
        const listenSelectors = once ? notExistSelectors : selectors

        const processors = listenSelectors.map(selector => {
            // 对于每个 selector, 保证对符合要求的每个元素仅处理一次 
            const processed = new WeakSet()
            let timer
            const process = (e: Element) => {
                if (!processed.has(e)) {
                    processed.add(e)
                    callback(e)

                    if (once) {
                        if (all) {
                            // 使用 timer 实现 debounce,在一定时间间隔内出现的符合 selector 的每个元素都会被处理
                            // 以此来模拟 querySelectorAll 的效果
                            clearTimeout(timer)
                            timer = setTimeout(remove, allDelay)
                        }
                        else {
                            // 如果 once 为 true 且 all 为假,则类似单个 querySelector,回调触发一次就立即 remove
                            remove()
                        }
                    }
                }
            }
            const processor: SelectorCallbackTuple = [selector, process]
            const remove = () => removeProcessor(parent, processor)

            addProcessor(parent, processor)
            return remove
        })
        const removeAllProcessor = () => processors.every(f => f())

        // 如果设置了 timeout 超时参数,则不论其他参数如何都会移除本次函数调用添加的所有 selector
        if (timeout >= 0) {
            setTimeout(() => {
                removeAllProcessor()
                onTimeout()
            }, timeout)
        }

        // 返回一个手动移除本次函数调用添加的所有 selector 的函数
        // 这个函数的返回值是是否在调用函数的时候移除了所有 selector
        // 如果因为新出现的元素有符合 selector 的,执行 callback 之后自动 remove 了相应的 processor,则返回值为假
        return removeAllProcessor
    }
})()

/**
 * 该函数用于观察和响应 DOM 的动态变化。
 * 它提供了一种方式,可以将自定义回调应用到匹配特定选择器的元素上,用于动态查询和处理 DOM 元素。
 * @param selector 要匹配元素的 CSS 选择器或选择器数组。
 * @param callback 处理匹配元素的回调函数。
 * @param options 配置选项,包含一系列可选设置:
 *        - parent?: ParentNode - 父节点,默认为 document。
 *        - timeout?: number - 超时时间(毫秒),默认为 -1,表示无超时。
 *        - onTimeout?: () => void - 超时时的回调函数。
 *        - all?: boolean - 是否模拟 querySelectorAll,默认为 true。
 *        - allDelay?: number - 模拟 querySelectorAll 时的 debounce 延时(毫秒),默认为 1000。
 * @returns 一个函数,用于取消所有 selector,其返回值为是否在调用函数时移除了所有 selector
 */
export const foreverQuery = (s, f, o) => dynamicQuery(s, f, { once: false, ...o })
已有1人评分好评 油猫币 理由
王一之 + 1 + 4 赞一个!

查看全部评分 总评分:好评 +1  油猫币 +4 

  • TA的每日心情
    慵懒
    2022-3-8 11:41
  • 签到天数: 2 天

    [LV.1]初来乍到

    22

    主题

    862

    回帖

    1361

    积分

    荣誉开发者

    积分
    1361

    荣誉开发者卓越贡献油中2周年生态建设者油中3周年挑战者 lv2

    发表于 2023-12-19 22:52:48 | 显示全部楼层
    get设计为Promise是为了方便开发,但我发现大部分人还是写成了.then回调,后来确实考虑过改成与each统一的风格,但是API的变动已经难以向下兼容,因此作罢。
    模拟querySelectorAll的思路是一个可选的方案,不过每个人的硬件配置、网络状况等有差别,而且定时器置于后台时有问题,我想尽量避免这类不确定性太大的方案。
    create的加入只是个人喜好,因为我自己开发时会用到。后续我还想加入一个函数,用于监听特定元素的变动,比如文本变化,由于在回调里修改文本会造成死循环,我的想法是在回调执行期间暂停监听(可能需要重新包装MutationObserver),但又涉及异步回调的问题,目前已有一些解决的想法,但我不确定这个函数会不会跟create一样食之无味,所以迟迟没有加入。
    回复

    使用道具 举报

    该用户从未签到

    3

    主题

    15

    回帖

    88

    积分

    初级工程师

    积分
    88
    发表于 2023-12-19 23:08:16 | 显示全部楼层

    cxxjackie 发表于 2023-12-19 22:52

    get设计为Promise是为了方便开发,但我发现大部分人还是写成了.then回调,后来确实考虑过改成与each统一的 ...

    1. 直接发 3.0.0🙂
    2. 自己开发用到的小函数可以单独放在一个文件啊,不然这个库的命名不太好,做了太多的事情
    3. 修改文本的话也许可以参考我代码里的 checked: WeakSet<Node>,因为这保证了 MutationObserver 产生的节点只会触发一次
    4. 要不直接再发个 util 工具包,各种常用的油猴脚本开发小函数都可以往里塞,再发个 npm 包,方便像我这样的使用 vscode 写脚本并打包的开发者🥹
    回复

    使用道具 举报

    该用户从未签到

    3

    主题

    15

    回帖

    88

    积分

    初级工程师

    积分
    88
    发表于 2023-12-19 23:26:50 | 显示全部楼层

    本帖最后由 LinLin00 于 2023-12-19 23:31 编辑

    其实 promise 版本很容易改成回调

    const getWithCallback = (selector, parent, f, timeout) => elmGetter.get(selector, parent, timeout).then(f)

    我的回调也很容易改成 promise

    const dynamicQueryWithPromise = (selector, options) => new Promise(resolve => dynamicQuery(selector, resolve, options))

    随便封装 😂

    但是 promise 版本有个缺点就是无法手动取消监听了,除非同时返回 promise 和 dynamicQuery() 的结果,要么就是使用 AbortController 了,太麻烦了

    回调和 promise 各有其适用场景,现在这个场景想想还是回调好

    回复

    使用道具 举报

  • TA的每日心情
    慵懒
    2022-3-8 11:41
  • 签到天数: 2 天

    [LV.1]初来乍到

    22

    主题

    862

    回帖

    1361

    积分

    荣誉开发者

    积分
    1361

    荣誉开发者卓越贡献油中2周年生态建设者油中3周年挑战者 lv2

    发表于 2023-12-19 23:32:53 | 显示全部楼层
    LinLin00 发表于 2023-12-19 23:26
    [md]其实 promise 版本很容易改成回调
    ```javascript
    const getWithCallback = (selector, parent, f, time ...

    就是因为很容易转换所以我目前没有改动API的想法,不然更新版本后原有脚本需要做太多修改,我还是希望尽量向下兼容的。
    回复

    使用道具 举报

    该用户从未签到

    0

    主题

    3

    回帖

    4

    积分

    助理工程师

    积分
    4
    发表于 2024-1-30 13:39:05 | 显示全部楼层
    本帖最后由 greatYear 于 2024-1-30 13:48 编辑

    赞。我加了个 callback 返回数组
    1. /**
    2. * callback 返回数组,非一个个元素处理,一次性返回所有符合 selector 的元素
    3. */
    4. export const foreverQueryAll = (
    5.   selector: string | string[],
    6.   callback: (nodes: Element[]) => void,
    7.   options: Options = {}
    8. ) => {
    9.   const checked: Set<Element> = new Set()
    10.   let timmer: NodeJS.Timeout;
    11.   
    12.   return dynamicQuery(selector, (node) => {
    13.     checked.add(node)

    14.     clearTimeout(timmer)
    15.     timmer = setTimeout(() => {
    16.       const nodes = Array.from(checked.values())
    17.       checked.clear()
    18.       callback(nodes)
    19.     }, 500)
    20.   }, { once: false, ...options });
    21. }
    复制代码


    回复

    使用道具 举报

    发表回复

    本版积分规则

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