本帖最后由 溯水流光 于 2025-2-25 21:03 编辑
附录A:
引用和参考:
讲解waitForKeyElements.js使用的中文帖子: 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元素。翻阅了源码后,我确定是它的代码逻辑有问题。
后面有详细的分析
/**
* 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} [waitOnce=true] - Whether to stop after the first elements are found.
* @param {number} [interval=300] - The time (ms) to wait between iterations.
* @param {number} [maxIntervals=-1] - 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:
// @example
waitForKeyElements("div.comments", (element) => {
element.innerHTML = "This text inserted by waitForKeyElements().";
});
这里是展示如何调用这个函数, 这里调用就跟querySelector
是一样的, 就多了个回调函数, 这里的回调函数会导致回调地狱的问题, 要用Promise
和await
来优化
示例2
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的底层逻辑
(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
设计问题: 这个函数的参数缺少失败的回调函数
源码的分析
初始化参数
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();
}
过时的写法, 直接用
function waitForKeyElements(selectorOrFunction, callback, options = {}) {
// 参数解构
let {
waitOnce = true,
interval = 300,
maxIntervals = -1
} = options;
}
代码会变得非常优雅
waitForKeyElements.namespace
是给这个函数设置了一个属性叫namespace
, 其值为Date.now().toString()
获取目标元素集合
var targetNodes = (typeof selectorOrFunction === "function")
? selectorOrFunction()
: document.querySelectorAll(selectorOrFunction);
var targetsFound = targetNodes && targetNodes.length > 0;
这里是进行了targetNodes
的空判断, 然后取其成员变量length
, 避免空指针异常(NPE)
这种写法已经过时淘汰了, 直接用?.
操作符
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) {
var cancelFound = callback(targetNode);
if (cancelFound) {
targetsFound = false;
}
else {
targetNode.setAttribute(attrAlreadyFound, true);
}
}
});
}
var alreadyFound = targetNode.getAttribute(attrAlreadyFound) || false;
获取元素是否有"标记", 也就是一个用户自定义属性, 属性名为attrAlreadyFound
. 如果没有获取到标记, 设置alreadyFound
为false; 元素如果被标记为"已查找过", alreadyFound
将为true
, 这将导致元素不会被传入回调函数
额外补充
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
。
示例
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, 代码逻辑有问题, 如果你反复查询同一个元素,是无法正常查询到的
(function() {
'use strict';
// Your code here...
waitForKeyElements(".child", (item) => {
console.log(item); // 执行
});
waitForKeyElements(".child", (item) => {
console.log(item); // 永远都不会执行
});
})();
循环
if (maxIntervals !== 0 && !(targetsFound && waitOnce)) {
maxIntervals -= 1;
setTimeout(function() {
waitForKeyElements(selectorOrFunction, callback, waitOnce, interval, maxIntervals);
}, interval);
}
if (maxIntervals !== 0 && !(targetsFound && waitOnce))
maxIntervals
是用于判断当前查询次数没有超过我们设置的查询次数
(targetsFound && waitOnce)
是是否继续监听dom树变化
如果两个条件都成立, 就setTimeout
循环调用自身
代码问题总结
参数没有错误回调
无法重复地获取元素
这个函数代码逻辑有严重的问题,如果你反复查询同一个元素,是无法正常查询到的.
因为该方法为了支持监听dom树, waitOnce=false
的效果, 使用了data-userscript-${waitForKeyElements.namespace}-alreadyFound
自定义用户属性来标记元素, 来排除查询. 这导致标记过的元素无法二次查询
这里完全是代码逻辑有问题
(function() {
'use strict';
// Your code here...
waitForKeyElements(".child", (item) => {
console.log(item);
});
waitForKeyElements(".child", (item) => {
console.log(item);
});
})();
这段代码虽然被广泛使用,却存在不少问题。
常有人问我如何改进这段代码,我的建议是重新构思编写。有句话说得好:“勿在浮沙筑高台 。” 这段代码质量欠佳,在这样的基础上继续优化,不如彻底推倒重来。
与其花费精力梳理这些代码逻辑,不如重新设计,往往能取得更好的效果。
我们要有勇气重新构建优质代码,而非在混乱的代码中艰难补救。
下面我是代码的改进版本,添加了错误回调,修复了无法重复获取元素的 bug。
// ==UserScript==
// @name New Userscript
// @namespace http://tampermonkey.net/
// @version 2025-02-21
// @description try 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("[waitForKeyElements] 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基础, 可以看这个文章: CSDN
我简单总结下jQuery的用法
- 其 $() 类似于querySelector, 但其返回的是jQuery包装后的元素, 我们可以通过jNode[0]获取其原始元素
- $() 可以包装普通dom元素, 使其成为jQuery包装后的元素
- 使用jQuery包装后的元素, 可以调用其jQuery特有的方法如设置元素css属性
jNode.css('color', 'red')
- jQuery包装后的元素可以使用each来迭代, 类似于list.forEach
- jQuery包装后的元素可以用data设置属性, 类似于设置元素的
data-*
/*--- 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[controlKey];
//--- 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[controlKey];
} else {
//--- Set a timer, if needed.
if (!timeControl) {
timeControl = setInterval(function() {
waitForKeyElements(
selectorTxt,
actionFunction,
bWaitOnce,
iframeSelector
);
},
300
);
controlObj[controlKey] = timeControl;
}
}
waitForKeyElements.controlObj = controlObj;
}
代码流程基本上是和CoeJoder版一致
我挑几个重点来讲讲就ok了
讲解点1
var jThis = $(this);
jQuery 的each 相当于 list 的forEach, 但jQuery会绑定当前迭代的元素到回调函数的this上, 而绑定的元素是元素dom元素, 不是jQuery包装后的元素, 无法使用data方法, 于是这里用$()
包裹了一层
讲解点2
var controlKey = selectorTxt.replace(/[^\w]/g, "_");
这里是把选择器的非字符(a-zA-Z0-9)替换为_
, 这里应该是为了浏览器兼容性而做的边界处理
JS是支持key为乱七八糟的字符的, 如obj["33**cc$$"] = 3
用一段话描述这个的代码逻辑
通过选择器找页面元素, 如果没找到就开定时器(setInterval), 如果找到了就清定时器
如果 bWaitOnce = false, 讲持续监听页面变化, 并将新增加的dom元素传给回调函数
这个函数的bug
跟coeJoder版的bug是一样的, 毕竟师出同门
// ==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
// @require https://z.chaoxing.com/js/jquery-3.5.0.min.js
// @grant none
// ==/UserScript==
(function() {
'use strict';
waitForKeyElements(".child", (item) => {
console.log(item[0]); // 会执行
});
waitForKeyElements(".child", (item) => {
console.log(item[0]); // 永远都不会执行
});
})();
/*waitForKeyElements 略*/