溯水流光 发表于 2025-2-25 20:21:50

⭐ [源码解析|附录A]: 再来点源码阅读, 评鉴经典的异步库设计

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

>附录A:
>
>**引用和参考:**
>
>讲解**(https://gist.github.com/BrockA/2625891)**使用的中文帖子: https://bbs.tampermonkey.net.cn/thread-2729-1-1.html , 感谢朱焱伟大佬的分享
>
>**代码参考**:
>
>https://github.com/CoeJoder/waitForKeyElements.js
>
>源码创建时间: 2020, 最后修改时间: 2024

## [源码解析|附录A]: 再来点源码阅读, 评鉴经典的异步库设计

>本文是《从 0 到 1,手把手带你剖析异步查询库 elmGetter 的源码,进行深度定制和二次开发,附 MutationObserver 讲解》系列文章中的附录。
>
>该系列文章目录网址为:
>
>https://bbs.tampermonkey.net.cn/thread-8196-1-1.html
>
>作者: 溯水流光 (脚本猫论坛,油猴中文网)

我们这里读的是CoeJoder的waitForKeyElements, github 90个star, 其实现代码逻辑有点问题 (原版也有)

CoeJoder 的 WaitForKeyElement 相较于原版, 有两个改进的点: 简化了代码结构, 去除了原版库对于jQuery库的依赖

起初,我以为 WaitForKeyElement 的使用类似于 elmGetter.get,是个异步的querySelector查询。但实际用起来才发现,它不能当作异步查询库来用,不能反复 WaitForKeyElement 同一个DOM元素。翻阅了源码后,我确定是它的代码逻辑有问题。

后面有详细的分析

```js
/**
* A utility function for userscripts that detects and handles AJAXed content.
*
* @example
* waitForKeyElements("div.comments", (element) => {
*   element.innerHTML = "This text inserted by waitForKeyElements().";
* });
*
* waitForKeyElements(() => {
*   const iframe = document.querySelector('iframe');
*   if (iframe) {
*   const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
*   return iframeDoc.querySelectorAll("div.comments");
*   }
*   return null;
* }, callbackFunc);
*
* @param {(string|function)} selectorOrFunction - The selector string or function.
* @param {function}          callback         - The callback function; takes a single DOM element as parameter.
*                                                 If returns true, element will be processed again on subsequent iterations.
* @param {boolean}             - Whether to stop after the first elements are found.
* @param {number}               - The time (ms) to wait between iterations.
* @param {number}            - The max number of intervals to run (negative number for unlimited).
*/
function waitForKeyElements(selectorOrFunction, callback, waitOnce, interval, maxIntervals) {
if (typeof waitOnce === "undefined") {
      waitOnce = true;
}
if (typeof interval === "undefined") {
      interval = 300;
}
if (typeof maxIntervals === "undefined") {
      maxIntervals = -1;
}
if (typeof waitForKeyElements.namespace === "undefined") {
      waitForKeyElements.namespace = Date.now().toString();
}
var targetNodes = (typeof selectorOrFunction === "function")
          ? selectorOrFunction()
          : document.querySelectorAll(selectorOrFunction);

var targetsFound = targetNodes && targetNodes.length > 0;
if (targetsFound) {
      targetNodes.forEach(function(targetNode) {
          var attrAlreadyFound = `data-userscript-${waitForKeyElements.namespace}-alreadyFound`;
          var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
          if (!alreadyFound) {
            var cancelFound = callback(targetNode);
            if (cancelFound) {
                  targetsFound = false;
            }
            else {
                  targetNode.setAttribute(attrAlreadyFound, true);
            }
          }
      });
}

if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
      maxIntervals -= 1;
      setTimeout(function() {
          waitForKeyElements(selectorOrFunction, callback, waitOnce, interval, maxIntervals);
      }, interval);
}
}
```

下面开始让我们来逐行解析代码

### 示例1:

```js
// @example
waitForKeyElements("div.comments", (element) => {
   element.innerHTML = "This text inserted by waitForKeyElements().";
});
```

这里是展示如何调用这个函数, 这里调用就跟`querySelector`是一样的, 就多了个回调函数, 这里的回调函数会导致回调地狱的问题, 要用`Promise`和`await`来优化

### 示例2

```js
waitForKeyElements(() => {
   const iframe = document.querySelector('iframe');
   if (iframe) {
   const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
   return iframeDoc.querySelectorAll("div.comments");
   }
   return null;
}, callbackFunc);
```

这里是展示如何调用这个函数, 但第一个参数从`selector`变成了一个函数, 这里的函数代表你将如何去查找元素

waitForKeyElements底层将会通过调用你传进来的函数来查找目标元素, 而不是通过querySelector

这个函数如果返回了null就代表没有找到, interval毫秒(参数)后将再次调用你写的函数进行查找; 如果返回元素, 就代表找到了, 不用再找了, 将会把返回的元素, 传给回调函数

`waitForKeyElements`支持函数的目的是为了兼容`iframe`查找, 或处理更复杂的逻辑, 这里如果看不明朗可以直接略过, 因为用得很少

### 额外补充:

#### waitForKeyElements的bug

waitForKeyElement正常的含义是等待一个关键元素出现后,调用我们的回调

但函数实现有问题(原版也有相关问题),你如果尝试去等待相同的元素,我们的回调是不会调用的

如果是开发人员故意这么设计的, 这个函数应该改名为waitForNewElement,更加的合适

后面我们会详细解析, waitForKeyElements这个bug的底层逻辑

```js
(function() {
    'use strict';

    // Your code here...
    waitForKeyElements(".child", (item) => {
      console.log(item); // 执行
    });

    waitForKeyElements(".child", (item) => {
      console.log(item); // 永远都不会执行
    });
})();
```




### 参数的讲解

+ selectorOrFunction, 选择器或返回dom元素的查找函数
+ callback, 一个参数为dom元素的回调函数
+ waitOnce, 是否在查找成功后, 仍然反复查询. 避免dom树结构发生了变化, 如新增加了一个元素, 脚本捕获不到新元素. 默认值为true
+实现这个功能代码的底层逻辑有问题, 导致了上面介绍到的bug.
+`waitOnce=false`且`maxIntervals=-1`的话, 将监听dom树, 持续监听`maxIntervals * interval`毫秒
+`waitOnce=false`的情况, 该函数的行为表现类似于`elmGetter.each`方法


+ interval, 间隔, 单位为毫秒, 类似setInterval设置的间隔, 默认值为300
+ maxIntervals, 限制最大查询次数, -1为不加限制, 默认值为-1

设计问题: 这个函数的参数缺少失败的回调函数

### 源码的分析

#### 初始化参数

```js
if (typeof waitOnce === "undefined") {
      waitOnce = true;
}
if (typeof interval === "undefined") {
      interval = 300;
}
if (typeof maxIntervals === "undefined") {
      maxIntervals = -1;
}
if (typeof waitForKeyElements.namespace === "undefined") {
      waitForKeyElements.namespace = Date.now().toString();
}
```

过时的写法, 直接用

```js
function waitForKeyElements(selectorOrFunction, callback, options = {}) {
      // 参数解构
      let {
            waitOnce = true,
            interval = 300,
            maxIntervals = -1
      } = options;
}
```

代码会变得非常优雅

`waitForKeyElements.namespace`是给这个函数设置了一个属性叫`namespace`, 其值为`Date.now().toString()`

#### 获取目标元素集合

```js
var targetNodes = (typeof selectorOrFunction === "function")
          ? selectorOrFunction()
          : document.querySelectorAll(selectorOrFunction);
```

```js
var targetsFound = targetNodes && targetNodes.length > 0;
```

这里是进行了`targetNodes`的空判断, 然后取其成员变量`length`, 避免空指针异常(NPE)

这种写法已经过时淘汰了, 直接用`?.`操作符

```js
var targetsFound = targetNodes?.length > 0;
```

#### 遍历目标元素集合, 并将迭代元素传入回调函数

```js
if (targetsFound) {
      targetNodes.forEach(function(targetNode) {
          var attrAlreadyFound = `data-userscript-${waitForKeyElements.namespace}-alreadyFound`;
          var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
          if (!alreadyFound) {
            var cancelFound = callback(targetNode);
            if (cancelFound) {
                  targetsFound = false;
            }
            else {
                  targetNode.setAttribute(attrAlreadyFound, true);
            }
          }
      });
}
```

```js
var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
```

获取元素是否有"标记", 也就是一个用户自定义属性, 属性名为`attrAlreadyFound`. 如果没有获取到标记, 设置`alreadyFound`为false; 元素如果被标记为"已查找过", `alreadyFound`将为`true`, 这将导致元素不会被传入回调函数

##### 额外补充

```js
Boolean(''); // false
Boolean(null); // false
Boolean(undefined); // false
Boolean(0); // false
Boolean(NaN); // false
```

###### `||`

在JavaScript中,`||`是逻辑或操作符,用于对两个表达式进行逻辑或运算,以下是关于它的详细介绍:

######基本语法

`expression1 || expression2`,其中`expression1`和`expression2`是任意合法的JavaScript表达式。

###### 运算规则

- 如果`expression1`为真值(即非`false`、`0`、`""`、`null`、`undefined`、`NaN`),则`||`运算结果为`expression1`。
- 如果`expression1`为假值,才会计算`expression2`,此时若`expression2`为真值,结果为`expression2`;若`expression2`也为假值,结果为`expression2`。

###### 示例

```javascript
console.log(true || false); // true
console.log(0 || 5); // 5
console.log("hello" || "world"); // "world"
console.log(null || undefined); // undefined
```

###### 常见用途

- **为变量设置默认值**:可以为可能未赋值的变量提供默认值,如`let name = user.name || "Guest"`,若`user.name`不存在或为假值,`name`将被赋值为`"Guest"`。



##### 为什么要标记"已查找过"

该函数要实现监听dom树变化, 还记得参数waitOnce吗: 是否在查找成功后, 仍然反复查询. 避免dom树结构发生了变化,如新增加了一个元素, 脚本捕获不到新元素

而这个函数实现监听dom树的变化, 不是通过MutationObserver, 而是通过setTimeout + 递归, 一遍遍地querySelectorAll, 再forEach, 再调用callback

为了避免callback的参数重复, 这个代码的作者就想出了这一招来去除重复查找到的元素, 只将新变化的元素传给callback

这里就是这个函数的bug, 代码逻辑有问题, 如果你反复查询同一个元素,是无法正常查询到的

```js
(function() {
    'use strict';

    // Your code here...
    waitForKeyElements(".child", (item) => {
      console.log(item); // 执行
    });

    waitForKeyElements(".child", (item) => {
      console.log(item); // 永远都不会执行
    });
})();
```

#### 循环

```js
if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
      maxIntervals -= 1;
      setTimeout(function() {
          waitForKeyElements(selectorOrFunction, callback, waitOnce, interval, maxIntervals);
      }, interval);
}
```

```js
if (maxIntervals !== 0 && !(targetsFound && waitOnce))
```

`maxIntervals`是用于判断当前查询次数没有超过我们设置的查询次数

`(targetsFound && waitOnce)`是是否继续监听dom树变化

如果两个条件都成立, 就`setTimeout`循环调用自身

### 代码问题总结

参数没有错误回调

#### 无法重复地获取元素

这个函数代码逻辑有严重的问题,如果你反复查询同一个元素,是无法正常查询到的.

因为该方法为了支持监听dom树, `waitOnce=false`的效果, 使用了`data-userscript-${waitForKeyElements.namespace}-alreadyFound` 自定义用户属性来标记元素, 来排除查询. 这导致标记过的元素无法二次查询

这里完全是代码逻辑有问题

```js
(function() {
    'use strict';

    // Your code here...
    waitForKeyElements(".child", (item) => {
      console.log(item);
    });

    waitForKeyElements(".child", (item) => {
      console.log(item);
    });
})();
```

<img src="data/attachment/forum/202502/25/202117nr0en5l0hr5u1eth.png" width="500" />

这段代码虽然被广泛使用,却存在不少问题。

常有人问我如何改进这段代码,我的建议是重新构思编写。有句话说得好:“勿在浮沙筑高台 。” 这段代码质量欠佳,在这样的基础上继续优化,不如彻底推倒重来。

与其花费精力梳理这些代码逻辑,不如重新设计,往往能取得更好的效果。

我们要有勇气重新构建优质代码,而非在混乱的代码中艰难补救。

下面我是代码的改进版本,添加了错误回调,修复了无法重复获取元素的 bug。

```js
// ==UserScript==
// @name         New Userscript
// @namespace    http://tampermonkey.net/
// @version      2025-02-21
// @descriptiontry to take over the world!
// @author       2402398917
// @match      http://127.0.0.1:5500/playground.html
// @icon         https://www.google.com/s2/favicons?sz=64&domain=0.1
// @grant      none
// ==/UserScript==

(function() {
'use strict';

// Your code here...
waitForKeyElements(".error", (item) => {
      console.log(item);
}, {
    onError() {
      console.log("error");
    },
    maxIntervals: 5
});

waitForKeyElements(".child", (item) => {
    console.log(item);
});

waitForKeyElements(".child", (item) => {
      console.log(item);
});


})();


function waitForKeyElements(selectorOrFunction, callback, options = {}) {
// 参数解构
const {
    waitOnce = true,
    interval = 300,
    maxIntervals = -1,
    onError = () => {
      console.warn(" can't find" + selectorOrFunction);
    },
    _isFrist = true
} = options;

if (typeof waitForKeyElements.namespace === "undefined") {
    waitForKeyElements.namespace = Date.now().toString();
}

var targetNodes = (typeof selectorOrFunction === "function")
          ? selectorOrFunction()
          : document.querySelectorAll(selectorOrFunction);

var targetsFound = targetNodes?.length > 0;
if (targetsFound) {
      targetNodes.forEach(function(targetNode) {
          var attrAlreadyFound = `data-userscript-${waitForKeyElements.namespace}-alreadyFound`;
          var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
          if (!alreadyFound || _isFrist) {
            var cancelFound = callback(targetNode);
            if (cancelFound) {
                  targetsFound = false;
            }
            else {
                  targetNode.setAttribute(attrAlreadyFound, true);
            }
          }
      });
}

if (maxIntervals == 0) {
    onError();
}

if (targetsFound) {
    options._isFrist = false;
}

if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
      options.maxIntervals -= 1;
      
      setTimeout(function() {
          waitForKeyElements(selectorOrFunction, callback, options);
      }, interval);
}
}
```

## 附录B: 祖师爷的代码, fork的起源, BlockA版waitForKeyElements

> 源码地址: https://gist.github.com/BrockA/2625891
>
> 13年前的代码了, 2012年创建的
>
> 275个star, 156个fork

> 使用参考 (中文教程):
>
> https://bbs.tampermonkey.net.cn/thread-2729-1-1.html

这里已经假设你阅读过附录A了, CoeJoder版的waitForKeyElements就是去掉Jquery和setInterval BlockA版版的waitForKeyElements

用setTimeout的替换setInterval的好处是可以简化代码结构

这里需要jQuery基础, 可以看这个文章: (https://blog.csdn.net/RedDragon_Will/article/details/145778892?sharetype=blogdetail&sharerId=145778892&sharerefer=PC&sharesource=RedDragon_Will&spm=1011.2480.3001.8118)

我简单总结下jQuery的用法

+ 其 $() 类似于querySelector, 但其返回的是jQuery包装后的元素, 我们可以通过jNode获取其原始元素
+ $() 可以包装普通dom元素, 使其成为jQuery包装后的元素
+ 使用jQuery包装后的元素, 可以调用其jQuery特有的方法如设置元素css属性`jNode.css('color', 'red')`
+ jQuery包装后的元素可以使用each来迭代, 类似于list.forEach
+ jQuery包装后的元素可以用data设置属性, 类似于设置元素的`data-*`

```js
/*--- waitForKeyElements():A utility function, for Greasemonkey scripts,
    that detects and handles AJAXed content.

    Usage example:

      waitForKeyElements (
            "div.comments"
            , commentCallbackFunction
      );

      //--- Page-specific function to do what we want when the node is found.
      function commentCallbackFunction (jNode) {
            jNode.text ("This comment changed by waitForKeyElements().");
      }

    IMPORTANT: This function requires your script to have loaded jQuery.
*/
function waitForKeyElements(
selectorTxt,    /* Required: The jQuery selector string that
                      specifies the desired element(s).
                  */
actionFunction, /* Required: The code to run when elements are
                      found. It is passed a jNode to the matched
                      element.
                  */
bWaitOnce,      /* Optional: If false, will continue to scan for
                      new elements even after the first match is
                      found.
                  */
iframeSelector/* Optional: If set, identifies the iframe to
                      search.
                  */
) {
var targetNodes, btargetsFound;

if (typeof iframeSelector === "undefined") {
      targetNodes = $(selectorTxt);
} else {
      targetNodes = $(iframeSelector).contents().find(selectorTxt);
}

if (targetNodes && targetNodes.length > 0) {
      btargetsFound = true;
      /*--- Found target node(s).Go through each and act if they
          are new.
      */
      targetNodes.each(function() {
          var jThis = $(this); // 讲解点1
          var alreadyFound = jThis.data('alreadyFound') || false;

          if (!alreadyFound) {
            //--- Call the payload function.
            var cancelFound = actionFunction(jThis);
            if (cancelFound) {
                  btargetsFound = false;
            } else {
                  jThis.data('alreadyFound', true);
            }
          }
      });
} else {
      btargetsFound = false;
}

//--- Get the timer-control variable for this selector.
var controlObj = waitForKeyElements.controlObj || {};
var controlKey = selectorTxt.replace(/[^\w]/g, "_"); // 讲解点2
var timeControl = controlObj;

//--- Now set or clear the timer as appropriate.
if (btargetsFound && bWaitOnce && timeControl) {
      //--- The only condition where we need to clear the timer.
      clearInterval(timeControl);
      delete controlObj;
} else {
      //--- Set a timer, if needed.
      if (!timeControl) {
          timeControl = setInterval(function() {
                  waitForKeyElements(
                      selectorTxt,
                      actionFunction,
                      bWaitOnce,
                      iframeSelector
                  );
            },
            300
          );
          controlObj = timeControl;
      }
}
waitForKeyElements.controlObj = controlObj;
}
```

代码流程基本上是和CoeJoder版一致

我挑几个重点来讲讲就ok了

### 讲解点1

```js
var jThis = $(this);
```

jQuery 的each 相当于 list 的forEach, 但jQuery会绑定当前迭代的元素到回调函数的this上, 而绑定的元素是元素dom元素, 不是jQuery包装后的元素, 无法使用data方法, 于是这里用`$()`包裹了一层

### 讲解点2

```js
var controlKey = selectorTxt.replace(/[^\w]/g, "_");
```

这里是把选择器的非字符(a-zA-Z0-9)替换为`_`, 这里应该是为了浏览器兼容性而做的边界处理

JS是支持key为乱七八糟的字符的, 如`obj["33**cc$$"] = 3`



### 用一段话描述这个的代码逻辑

通过选择器找页面元素, 如果没找到就开定时器(setInterval), 如果找到了就清定时器

如果 bWaitOnce = false, 讲持续监听页面变化, 并将新增加的dom元素传给回调函数



### 这个函数的bug

跟coeJoder版的bug是一样的, 毕竟师出同门

```js
// ==UserScript==
// @name         New Userscript
// @namespace    http://tampermonkey.net/
// @version      2025-02-21
// @descriptiontry 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
// @require      https://z.chaoxing.com/js/jquery-3.5.0.min.js
// @grant      none
// ==/UserScript==

(function() {
'use strict';

waitForKeyElements(".child", (item) => {
    console.log(item); // 会执行
});

waitForKeyElements(".child", (item) => {
      console.log(item); // 永远都不会执行
});


})();

/*waitForKeyElements 略*/
```

王一之 发表于 2025-2-25 20:57:05

<img src="https://bbs.tampermonkey.net.cn/data/attachment/forum/202502/25/202117nr0en5l0hr5u1eth.png" width="500" />

哥哥可以这样设置图片宽度

```html
<img src="https://bbs.tampermonkey.net.cn/data/attachment/forum/202502/25/202117nr0en5l0hr5u1eth.png" width="500" />
```

溯水流光 发表于 2025-2-25 20:58:02

王一之 发表于 2025-2-25 20:57


哥哥可以这样设置图片宽度


Ok, 谢谢哥哥
页: [1]
查看完整版本: ⭐ [源码解析|附录A]: 再来点源码阅读, 评鉴经典的异步库设计