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

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

[复制链接]
  • TA的每日心情
    奋斗
    2025-3-1 19:55
  • 签到天数: 6 天

    [LV.2]偶尔看看I

    10

    主题

    11

    回帖

    124

    积分

    荣誉开发者

    积分
    124

    油中2周年新人报道荣誉开发者

    发表于 2025-2-25 20:21:50 | 显示全部楼层 | 阅读模式

    本帖最后由 溯水流光 于 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是一样的, 就多了个回调函数, 这里的回调函数会导致回调地狱的问题, 要用Promiseawait来优化

    示例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=falsemaxIntervals=-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,其中expression1expression2是任意合法的JavaScript表达式。

    运算规则
    • 如果expression1为真值(即非false0""nullundefinedNaN),则||运算结果为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);
        });
    })();
    202117nr0en5l0hr5u1eth.png

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

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

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

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

    下面我是代码的改进版本,添加了错误回调,修复了无法重复获取元素的 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 略*/
    已有1人评分好评 油猫币 理由
    朱焱伟 + 1 + 7 ggnb!

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

  • TA的每日心情
    开心
    2024-11-21 13:37
  • 签到天数: 213 天

    [LV.7]常住居民III

    309

    主题

    4356

    回帖

    4207

    积分

    管理员

    积分
    4207

    管理员荣誉开发者油中2周年生态建设者喜迎中秋油中3周年挑战者 lv2

    发表于 2025-2-25 20:57:05 | 显示全部楼层
    202117nr0en5l0hr5u1eth.png

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

    <img src="https://bbs.tampermonkey.net.cn/data/attachment/forum/202502/25/202117nr0en5l0hr5u1eth.png" width="500" />
    上不慕古,下不肖俗。为疏为懒,不敢为狂。为拙为愚,不敢为恶。
    回复

    使用道具 举报

  • TA的每日心情
    奋斗
    2025-3-1 19:55
  • 签到天数: 6 天

    [LV.2]偶尔看看I

    10

    主题

    11

    回帖

    124

    积分

    荣誉开发者

    积分
    124

    油中2周年新人报道荣誉开发者

    发表于 2025-2-25 20:58:02 | 显示全部楼层
    王一之 发表于 2025-2-25 20:57
    [md]

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

    Ok, 谢谢哥哥
    回复

    使用道具 举报

    发表回复

    本版积分规则

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