LinLin00 发表于 2023-12-19 21:43:37

仿 elmGetter (opinionated version)

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

# 仿 elmGetter (opinionated version)

看了 @cxxjackie 写的 (https://bbs.tampermonkey.net.cn/thread-2726-1-1.html),哥哥真是太强啦!

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

- 不考虑 jQuery、XPath 选择器,并移除了兼容方面的考量
- 去掉了 `create` 方法,因为这与 elmGetter 这个库的主题相违背,变成 util 库了
- 原有的 ```get``` 方法为返回 `Prosime<Element>`,本意是使用现代 await 语法简化开发,但会带来一些问题

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

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

    然后再看一个例子

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

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

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

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

- 原有的 ```each``` 函数不支持选择器数组和 timeout 参数,且每调用一次 each 函数,都会对每一个 `mutation.target` 或者每个 `mutation.addedNodes` 中的元素应用一次 `querySelectorAll`。虽然这可以通过 [用逗号将选择器合并](https://bbs.tampermonkey.net.cn/forum.php?mod=redirect&goto=findpost&ptid=2726&pid=31395) 解决一部分,但是在如果不同选择器对应不同的 callback 函数时该问题仍然存在。

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

- 原有的函数不支持 `querySelectorAll`,但是 [“不好实现,因为列表何时加载完毕是不可知的”](https://bbs.tampermonkey.net.cn/forum.php?mod=redirect&goto=findpost&ptid=2726&pid=73580)

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

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

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

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

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

```javascript
/**
* 该函数用于观察和响应 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
*/
```

```typescript
export type processNode = (node: Element) => void
export type SelectorCallbackTuple =

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(() => {
                        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 :

      // 总是会先立即执行 querySelector(All) 并应用 callback
      const notExistSelectors = selectors.filter(selector => {
            const result = all
                ? parent.querySelectorAll(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 =
            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 })

```

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

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

LinLin00 发表于 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 写脚本并打包的开发者🥹

LinLin00 发表于 2023-12-19 23:26:50

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

其实 promise 版本很容易改成回调
```javascript
const getWithCallback = (selector, parent, f, timeout) => elmGetter.get(selector, parent, timeout).then(f)
```

我的回调也很容易改成 promise
```javascript
const dynamicQueryWithPromise = (selector, options) => new Promise(resolve => dynamicQuery(selector, resolve, options))
```
随便封装 😂

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

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

cxxjackie 发表于 2023-12-19 23:32:53

LinLin00 发表于 2023-12-19 23:26
其实 promise 版本很容易改成回调
```javascript
const getWithCallback = (selector, parent, f, time ...

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

greatYear 发表于 2024-1-30 13:39:05

本帖最后由 greatYear 于 2024-1-30 13:48 编辑

赞。我加了个 callback 返回数组
/**
* callback 返回数组,非一个个元素处理,一次性返回所有符合 selector 的元素
*/
export const foreverQueryAll = (
selector: string | string[],
callback: (nodes: Element[]) => void,
options: Options = {}
) => {
const checked: Set<Element> = new Set()
let timmer: NodeJS.Timeout;

return dynamicQuery(selector, (node) => {
    checked.add(node)

    clearTimeout(timmer)
    timmer = setTimeout(() => {
      const nodes = Array.from(checked.values())
      checked.clear()
      callback(nodes)
    }, 500)
}, { once: false, ...options });
}

页: [1]
查看完整版本: 仿 elmGetter (opinionated version)