⭐ [源码解析|附录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 略*/
```
<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:57
哥哥可以这样设置图片宽度
Ok, 谢谢哥哥
页:
[1]