本帖最后由 溯水流光 于 2025-2-25 21:06 编辑
[源码解析|正文] : elmGetter的源码解析
本文是《从 0 到 1,手把手带你剖析异步查询库 elmGetter 的源码,进行深度定制和二次开发,附 MutationObserver 讲解》系列文章中的一篇。
该系列文章目录网址为:
https://bbs.tampermonkey.net.cn/thread-8196-1-1.html
作者: 溯水流光 (脚本猫论坛, 油猴中文网)
源码分析的方法论 & 我阅读源码的契机
分析方法
大体分析思路
- 分析源码的关键在于掌握知识点和找准切入点。我们可以先编写并运行一个简单的小 demo,使用
debugger;
设置断点,再借助开发者工具一步步单步调试,以此了解执行流程。
- 分析过程中,最好不要仅依赖静态分析,而要动静结合。
- 源码最好亲自阅读和调试,我也会尽力为大家梳理代码涉及的知识点,方便大家阅读。
- 在文章中,我会一边解析源码,一边阐述我具体的分析思路,供大家参考。
- 我是在床上用手机借助
spck代码器
(可在Google Play
下载)完成了全部代码的静态分析,因此就不向大家详细演示动态分析部分了。
阅读源码的契机
elmGetter原有的部分代码设计用起来不太顺手,所以我决定对它进行魔改,添加自己需要的功能。
我希望给 elmGetter 添加三个功能:一是增加超时错误处理回调参数,在未找到元素时打印日志,便于调试;二是通过 bool 参数控制超时时,让 promise 保持 pending 或者 resolve (null);三是支持自定义超时时 resolve 的值,比如 resolve 一个无法删除的 div。
我不仅把这些参数保留为函数参数,还将其设为 elmGetter 成员变量,便于统一设置。为传参方便,我重构了几乎所有函数的传参方式,支持对象传参,无需记参数顺序。
这些修改我都已完成,这也是我发文的底气。
/**
* 异步的 querySelector
* @param selector
* @param options 一个对象
* - parent 父元素, 默认值是 document
* - timeout 设置 get 的超时时间, 默认值是 elmGetter.timeout, 其值默认为 0
* - 如果该值为 0, 表示永不超时, 如果 selector 有误, 返回的 Promise 将永远 pending
* - 如果该值不为 0, 表示等待多少毫秒, 和 setTimeout 单位一致
* - onError 超时后的失败回调, 参数为 selector, 默认值为 elmGetter.onError, 其默认行为是 console.warn 打印 selector
* - isPending 超时后 Promise 是否仍然保持 pending, 默认值为 elmGetter.isPending, 其值默认为 true
* - errEl 超时后 Promise 返回的值, 需要 isPending 为 false 才能有效, 默认值为 elmGetter.errorEl, 其值默认为一个 class 为一个 class 为 no-found 的元素
* @returns {Promise<Awaited<unknown>[]>|Promise<unknown>}
*/
get(selector, options = {}) {
let {
parent = doc,
timeout = this.timeout,
onError = this.onError,
isPending = this.isPending,
errEl = this.errEl,
} = options;
魔改库的详细介绍帖:
https://bbs.tampermonkey.net.cn/thread-8183-1-1.html
源码分析 引言
接下来, 我将以小白视角,逐步剖析cxxjackie大佬的elmGetter代码执行流程,解析完成后, 会去添加自己想要的功能。 期待我的文章能帮助大家将elmGetter改造成自己理想中的异步查询库,使其成为大家得心应手的如意兵器。
我将以css模式为主线去剖析 elmGetter 源码,去繁从简,了解 elmGetter代码的执行流程,这样才能知道如何对它进行定制。
先上源码,后面会详细解析,大家可以先大致浏览下。
// ==UserScript==
// @name ElementGetter
// @author cxxjackie
// @version 2.0.1
// @supportURL https://bbs.tampermonkey.net.cn/thread-2726-1-1.html
// ==/UserScript==
var elmGetter = function() {
const win = window.unsafeWindow || document.defaultView || window;
const doc = win.document;
const listeners = new WeakMap();
let mode = 'css';
let $;
const elProto = win.Element.prototype;
const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector ||
elProto.mozMatchesSelector || elProto.oMatchesSelector;
const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver;
function addObserver(target, callback) {
const observer = new MutationObs(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
callback(mutation.target, 'attr');
if (observer.canceled) return;
}
for (const node of mutation.addedNodes) {
if (node instanceof Element) callback(node, 'insert');
if (observer.canceled) return;
}
}
});
observer.canceled = false;
observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
return () => {
observer.canceled = true;
observer.disconnect();
};
}
function addFilter(target, filter) {
let listener = listeners.get(target);
if (!listener) {
listener = {
filters: new Set(),
remove: addObserver(target, (el, reason) => listener.filters.forEach(f => f(el, reason)))
};
listeners.set(target, listener);
}
listener.filters.add(filter);
}
function removeFilter(target, filter) {
const listener = listeners.get(target);
if (!listener) return;
listener.filters.delete(filter);
if (!listener.filters.size) {
listener.remove();
listeners.delete(target);
}
}
function query(selector, parent, root, curMode, reason) {
switch (curMode) {
case 'css': {
if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
const checkParent = parent !== root && matches.call(parent, selector);
return checkParent ? parent : parent.querySelector(selector);
}
case 'jquery': {
if (reason === 'attr') return $(parent).is(selector) ? $(parent) : null;
const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
return jNodes.length ? $(jNodes.get(0)) : null;
}
case 'xpath': {
const ownerDoc = parent.ownerDocument || parent;
selector += '/self::*';
return ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 9, null).singleNodeValue;
}
}
}
function queryAll(selector, parent, root, curMode, reason) {
switch (curMode) {
case 'css': {
if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
const checkParent = parent !== root && matches.call(parent, selector);
const result = parent.querySelectorAll(selector);
return checkParent ? [parent, ...result] : [...result];
}
case 'jquery': {
if (reason === 'attr') return $(parent).is(selector) ? [$(parent)] : [];
const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
return $.map(jNodes, el => $(el));
}
case 'xpath': {
const ownerDoc = parent.ownerDocument || parent;
selector += '/self::*';
const xPathResult = ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 7, null);
const result = [];
for (let i = 0; i < xPathResult.snapshotLength; i++) {
result.push(xPathResult.snapshotItem(i));
}
return result;
}
}
}
function isJquery(jq) {
return jq && jq.fn && typeof jq.fn.jquery === 'string';
}
function getOne(selector, parent, timeout) {
const curMode = mode;
return new Promise(resolve => {
const node = query(selector, parent, parent, curMode);
if (node) return resolve(node);
let timer;
const filter = (el, reason) => {
const node = query(selector, el, parent, curMode, reason);
if (node) {
removeFilter(parent, filter);
timer && clearTimeout(timer);
resolve(node);
}
};
addFilter(parent, filter);
if (timeout > 0) {
timer = setTimeout(() => {
removeFilter(parent, filter);
resolve(null);
}, timeout);
}
});
}
return {
get currentSelector() {
return mode;
},
get(selector, ...args) {
let parent = typeof args[0] !== 'number' && args.shift() || doc;
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
const timeout = args[0] || 0;
if (Array.isArray(selector)) {
return Promise.all(selector.map(s => getOne(s, parent, timeout)));
}
return getOne(selector, parent, timeout);
},
each(selector, ...args) {
let parent = typeof args[0] !== 'function' && args.shift() || doc;
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
const callback = args[0];
const curMode = mode;
const refs = new WeakSet();
for (const node of queryAll(selector, parent, parent, curMode)) {
refs.add(curMode === 'jquery' ? node.get(0) : node);
if (callback(node, false) === false) return;
}
const filter = (el, reason) => {
for (const node of queryAll(selector, el, parent, curMode, reason)) {
const _el = curMode === 'jquery' ? node.get(0) : node;
if (refs.has(_el)) break;
refs.add(_el);
if (callback(node, true) === false) {
return removeFilter(parent, filter);
}
}
};
addFilter(parent, filter);
},
create(domString, ...args) {
const returnList = typeof args[0] === 'boolean' && args.shift();
const parent = args[0];
const template = doc.createElement('template');
template.innerHTML = domString;
const node = template.content.firstElementChild;
if (!node) return null;
parent ? parent.appendChild(node) : node.remove();
if (returnList) {
const list = {};
node.querySelectorAll('[id]').forEach(el => list[el.id] = el);
list[0] = node;
return list;
}
return node;
},
selector(desc) {
switch (true) {
case isJquery(desc):
$ = desc;
return mode = 'jquery';
case !desc || typeof desc.toLowerCase !== 'function':
return mode = 'css';
case desc.toLowerCase() === 'jquery':
for (const jq of [window.jQuery, window.$, win.jQuery, win.$]) {
if (isJquery(jq)) {
$ = jq;
break;
}
}
return mode = $ ? 'jquery' : 'css';
case desc.toLowerCase() === 'xpath':
return mode = 'xpath';
default:
return mode = 'css';
}
}
};
}();
轻松一下:如何正确拼写 elmGetter ?
我编码习惯把 element 缩写成 el,而非 elm,所以之前老拼错成 elGetter。
后来我记了个口诀 “e(饿)l(了)m(么)Getter,elmGetter”,就再也没拼错过。
源码分析 第一小节 先看整体
elmGetter 的成员方法都是闭包
// ==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
// @grant unsafeWindow
// @grant GM_registerMenuCommand
// ==/UserScript==
var elmGetter = (() => {
const _私有成员变量 = 33;
function _私有成员方法() {
console.log("私有成员方法被调用")
}
return {
公共成员方法() {
_私有成员方法();
console.log("私有成员变量", _私有成员变量);
}
}
})();
(async function () {
'use strict';
elmGetter.公共成员方法();
// 无法 elmGetter._私有成员方法()
// elmGetter._私有成员方法();
})();
考古学:
我翻论坛帖子: https://bbs.tampermonkey.net.cn/thread-2726-1-1.html
1.2.1
对私有属性的语法降级,以兼容低版本浏览器。
我推测这里降级的方式就是转用闭包结构
源码分析 第二小节 初始化私有成员变量
var elmGetter = function() {
const win = window.unsafeWindow || document.defaultView || window;
const doc = win.document;
const listeners = new WeakMap();
let mode = 'css';
let $;
const elProto = win.Element.prototype;
const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector ||
elProto.mozMatchesSelector || elProto.oMatchesSelector;
const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver;
阅读源码时,一个重要技巧是简化代码,先把各种边界条件的判断代码放到一边。阅读源码要少量多次。
先大致读几遍,在脑海里形成基本的代码流程和框架。如果想深入学习,再去琢磨边界条件的设计逻辑。
千万别一开始就陷入极端边界条件的判断中,不然很容易迷失方向,抓不住重点。
上面的代码做了大量针对不同浏览器的兼容性处理,就如同CSS前缀一样。这些代码可以简化成下面这样。
var elmGetter = function() {
const win = window;
const doc = win.document;
const listeners = new WeakMap();
let mode = 'css';
let $;
const elProto = win.Element.prototype;
const matches = elProto.matches; // Element.prototype.matches
const MutationObs = win.MutationObserver;
这里唯一需要讲解的是matches
,它是Element类的成员方法,我们稍后会详细介绍。
原型链: https://www.bilibili.com/video/BV15T411t725
补充:在 JS 中,任意的 function 都能作为类的构造函数。这个构造函数有一个 prototype 属性,它指向该类的原型。使用这个构造函数创建的实例,所有实例的__proto__
都指向这个 prototype。当访问实例的属性时,就会沿着原型链向上查找,因此 prototype 可用于存储成员方法。
源码分析 第三小节 从 get 接口入手
get(selector, ...args) {
let parent = typeof args[0] !== 'number' && args.shift() || doc;
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
const timeout = args[0] || 0;
if (Array.isArray(selector)) {
return Promise.all(selector.map(s => getOne(s, parent, timeout)));
}
return getOne(selector, parent, timeout);
},
接口说明
文档: https://bbs.tampermonkey.net.cn/thread-2726-1-1.html
get(selector[, parent][, timeout])
selector
必须, 选择器或选择器数组,默认使用css选择器。
parent
可选,父节点,默认值document。
timeout
可选,超时时间(毫秒),默认值0。
返回值
Promise,selector为选择器时返回元素,为数组时返回元素数组。
&& 运算符
可以打开 chrome 开发者工具, 调到控制台, 试试下面指令
true && "被执行了" // 表达式结果为 "被执行了"
false && "永远不会被执行" // 表达式结果为 false
shift
let arr = ['11', '22', '33'];
arr.shift(); // 返回 '11'
console.log(arr); // ['22', '33']
Array.prototype.shift
的作用为:移除数组的首元素,并将其返回。
|| 运算符
true || "永远不会被执行" // 表达式结果为 true
false || "被执行了" // 表达式结果为 "被执行了"
它经常用于设置默认值
function foo(param) {
let bar = param || "默认值";
console.log(bar);
}
foo() // 输出: 默认值
foo("111") // 输出: 111
parent的赋值
let parent = typeof args[0] !== 'number' && args.shift() || doc;
等同于
let parent;
if (typeof args[0]!== 'number') {
parent = args.shift();
} else {
parent = doc;
}
若 args
数组的第一个元素不是数字类型,则移除并返回 args
数组的第一个元素赋值给 parent
,否则将 doc
的值赋给 parent
。
这里进行类型判断是为了避免把 timeout 错误赋值给 parent
// `get(selector[, parent][, timeout])`
get('.child', 3000); // 避免把 3000 赋值给parent
get('.child', document.body) // 实现把 body 元素赋值给parent
继续流程分析
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
此处是判断 parent
是否为经 jQuery 包裹的实例,parent.get(0)
能取出原始的 DOM 元素。
鉴于我们以 elmGetter 的 CSS 模式为主线进行分析,这属于边界条件判断,直接忽略即可。
const timeout = args[0] || 0;
if (Array.isArray(selector)) {
return Promise.all(selector.map(s => getOne(s, parent, timeout)));
}
map 高阶函数的使用如下:
function addOne(num) {
return num + 1;
}
let arr = [1, 2, 3];
let newArr = arr.map(num => addOne(num));
console.log(newArr); // [2, 3, 4]
getOne 我们马上就会开始分析
这里是为了支持以数组作为参数进行调用
// ==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
// @grant unsafeWindow
// @require https://scriptcat.org/lib/2847/3.0.0/ElementGetter%20%E6%B0%B4%E6%9E%9C%E7%8E%89%E7%B1%B3%20%E9%AD%94%E6%94%B9%E7%89%88.js
// ==/UserScript==
(async function () {
'use strict';
// TODO: 发布脚本时, 请去掉延时, 延时会导致 elmGetter 启动 setTimeout, 降低脚本性能, 这里设置延时, 仅仅是方便日志打印和调试bug
elmGetter.timeout = 999;
let elArr = await elmGetter.get([".child", ".child", ".child"]);
console.log(elArr); // 三个DOM元素 [p.child, p.child, p.child]
})();
return getOne(selector, parent, timeout);
接下来就是分析 getOne 了
源码分析 第四小节 getOne
function getOne(selector, parent, timeout) {
const curMode = mode;
return new Promise(resolve => {
const node = query(selector, parent, parent, curMode);
if (node) return resolve(node);
let timer;
const filter = (el, reason) => {
const node = query(selector, el, parent, curMode, reason);
if (node) {
removeFilter(parent, filter);
timer && clearTimeout(timer);
resolve(node);
}
};
addFilter(parent, filter);
if (timeout > 0) {
timer = setTimeout(() => {
removeFilter(parent, filter);
resolve(null);
}, timeout);
}
});
}
getOne是核心函数,它相当于基于MutationObserve实现的异步querySelector。
刚刚分析的get,其作用仅仅是支持默认参数调用以及选择器为数组时的调用。也就是说,get接口存在的意义只是为了更便捷地调用getOne,实际的关键操作都是getOne在执行 。
query 函数
function query(selector, parent, root, curMode, reason) {
switch (curMode) {
case 'css': {
if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
const checkParent = parent !== root && matches.call(parent, selector);
return checkParent ? parent : parent.querySelector(selector);
}
}
}
先大体上看
去掉所有的边界条件, query 就是 querySelector
function query(selector, parent, root, curMode, reason) {
switch (curMode) {
case 'css': {
return parent.querySelector(selector);
}
}
}
边界条件的解析
边界条件解析属于进阶内容, 可以读完文章 再回头来看
判断 selector 是否为 parent
接下来讲讲其他的边界条件是在判断什么
const checkParent = parent !== root && matches.call(parent, selector);
querySelector存在局限性,它无法查找自身,仅会从子元素开始查找。
document.querySelector("body"); // body 元素
document.body.querySelector("body") // 返回值为null, 找不到
Element.prototype.matches (selector) 会使用 selector 对元素自身进行匹配。若匹配成功,返回 true;否则返回 false。
call 的作用是绑定 this 指向并调用函数。
关于 call 和 this 指向的学习内容,可以参考 coderwhy 的 JS 高级课程。
https://mp.weixin.qq.com/s/15b8I7FNhhFqR0jX_mWLRg
所以,matches.call(parent, selector);
用于判断 selector
是否为 parent 的 CSS 选择器。
parent !== root
是为了避免报错:
Element.prototype.matches.call(document, "document"); // 报错
// VM116:1 Uncaught TypeError: Illegal invocation
// at <anonymous>:1:27
小思考:
Q: root 为什么不直接写死为 doc?
A: 因为要兼容 iframe 查询
监听属性变化
边界条件解析属于进阶内容, 可以读完文章 再回头来看
下面有对过滤器的详细介绍: <源码分析 第五小节 Mutation Observer & 监听器 & 过滤器>
if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
Mutation Observe 可以监听元素属性的变化, 如果一个元素的属性变化了, 其 selector 也会发生变化
如一个元素类名从child
变成了son
, 其selector也就从.child
变成了.son
这时候就需要再次判断 selector 是否为该属性发生变化的元素
return new Promise(resolve => {
const node = query(selector, parent /* parent */, parent /* root */, curMode);
if (node) return resolve(node);
let timer;
const filter = (el, reason) => {
const node = query(selector, el /* parent */, parent /* root */, curMode, reason);
if (node) {
这里的 filter 过滤器, 可以简单理解为 Mutation Observe 的回调函数, 下面是简化后的代码:
function addObserver(target, callback) {
const observer = new MutationObs(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
callback(mutation.target, 'attr'); // 注意点1
}
for (const node of mutation.addedNodes) {
if (node instanceof Element) callback(node, 'insert');
}
}
});
observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
return () => {
observer.disconnect();
};
}
function addFilter(target, filter) {
addObserver(target, (el, reason) => filter(el, reason))
}
注意点1
这里实际上是把属性发生变化的元素传递给了回调函数。
当有新元素插入 parent
时,这个新元素会作为 el
传递给回调函数。
试一试
这里采用动态代码分析,通过 console.log
打印 el
,以深入理解函数流程。
这里直接复制粘贴代码,让脚本运行起来就行,无需关注代码逻辑。重点是观察控制台的输出。
此处的演练场与《Mutation Observer 快速上手》里所用的一致,具体可查看目录。
// ==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
// @grant unsafeWindow
// ==/UserScript==
// 没有中间层
initGlobalVar();
(async function () {
'use strict';
let el2 = await elmGetter.get(".child2");
console.log(el2);
})();
function initGlobalVar() {
const win = window.unsafeWindow || document.defaultView || window;
// 魔改版
win.elmGetter = function () {
const win = window.unsafeWindow || document.defaultView || window;
const doc = win.document;
const listeners = new WeakMap();
let mode = 'css';
let $;
const elProto = win.Element.prototype;
const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector ||
elProto.mozMatchesSelector || elProto.oMatchesSelector;
const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver;
function addObserver(target, callback) {
const observer = new MutationObs(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
callback(mutation.target, 'attr');
if (observer.canceled) return;
}
for (const node of mutation.addedNodes) {
if (node instanceof Element) callback(node, 'insert');
if (observer.canceled) return;
}
}
});
observer.canceled = false;
observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
return () => {
observer.canceled = true;
observer.disconnect();
};
}
function addFilter(target, filter) {
let listener = listeners.get(target);
if (!listener) {
listener = {
filters: new Set(),
remove: addObserver(target, (el, reason) => listener.filters.forEach(f => f(el, reason)))
};
listeners.set(target, listener);
}
listener.filters.add(filter);
}
function removeFilter(target, filter) {
const listener = listeners.get(target);
if (!listener) return;
listener.filters.delete(filter);
if (!listener.filters.size) {
listener.remove();
listeners.delete(target);
}
}
function query(selector, options = {}) {
let {
parent,
root,
curMode,
reason
} = options;
console.log(reason);
switch (curMode) {
case 'css': {
if (reason === 'attr') return matches.call(parent, selector) ? parent : null;
const checkParent = parent !== root && matches.call(parent, selector);
return checkParent ? parent : parent.querySelector(selector);
}
case 'jquery': {
if (reason === 'attr') return $(parent).is(selector) ? $(parent) : null;
const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
return jNodes.length ? $(jNodes.get(0)) : null;
}
case 'xpath': {
const ownerDoc = parent.ownerDocument || parent;
selector += '/self::*';
return ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 9, null).singleNodeValue;
}
}
}
function queryAll(selector, options = {}) {
let {
parent,
root,
curMode,
reason
} = options;
switch (curMode) {
case 'css': {
if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
const checkParent = parent !== root && matches.call(parent, selector);
const result = parent.querySelectorAll(selector);
return checkParent ? [parent, ...result] : [...result];
}
case 'jquery': {
if (reason === 'attr') return $(parent).is(selector) ? [$(parent)] : [];
const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
return $.map(jNodes, el => $(el));
}
case 'xpath': {
const ownerDoc = parent.ownerDocument || parent;
selector += '/self::*';
const xPathResult = ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 7, null);
const result = [];
for (let i = 0; i < xPathResult.snapshotLength; i++) {
result.push(xPathResult.snapshotItem(i));
}
return result;
}
}
}
function isJquery(jq) {
return jq && jq.fn && typeof jq.fn.jquery === 'string';
}
function getOne(selector, options = {}) {
let {
parent,
timeout,
onError,
isPending,
errEl
} = options;
const curMode = mode;
return new Promise(resolve => {
const node = query(
selector,
{
parent: parent,
root: parent,
curMode
});
if (node) return resolve(node);
let timer;
const filter = (el, reason) => {
console.log(el);
const node = query(
selector,
{
parent: el,
root: parent,
curMode,
reason
});
if (node) {
removeFilter(parent, filter);
timer && clearTimeout(timer);
resolve(node);
}
};
addFilter(parent, filter);
if (timeout > 0) {
timer = setTimeout(() => {
removeFilter(parent, filter);
onError(selector);
if (!isPending) {
resolve(errEl);
}
}, timeout);
}
});
}
let errEl = document.createElement('div');
errEl.classList.add('no-found');
errEl.remove = () => {};
return {
timeout: 0,
onError: (selector) => {console.warn(`[elmGetter] [get失败] selector为: ${selector} 的查询超时`)},
isPending: true,
errEl,
get currentSelector() {
return mode;
},
/**
* 异步的 querySelector
* @param selector
* @param options 一个对象
* - parent 父元素, 默认值是 document
* - timeout 设置 get 的超时时间, 默认值是 elmGetter.timeout, 其值默认为 0
* - 如果该值为 0, 表示永不超时, 如果 selector 有误, 返回的 Promise 将永远 pending
* - 如果该值不为 0, 表示等待多少毫秒, 和 setTimeout 单位一致
* - onError 超时后的失败回调, 参数为 selector, 默认值为 elmGetter.onError, 其默认行为是 console.warn 打印 selector
* - isPending 超时后 Promise 是否仍然保持 pending, 默认值为 elmGetter.isPending, 其值默认为 true
* - errEl 超时后 Promise 返回的值, 需要 isPending 为 false 才能有效, 默认值为 elmGetter.errorEl, 其值默认为一个 class 为一个 class 为 no-found 的元素
* @returns {Promise<Awaited<unknown>[]>|Promise<unknown>}
*/
get(selector, options = {}) {
let {
parent = doc,
timeout = this.timeout,
onError = this.onError,
isPending = this.isPending,
errEl = this.errEl,
} = options;
options.parent = parent;
options.timeout = timeout;
options.onError = onError;
options.isPending = isPending;
options.errEl = errEl;
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
if (Array.isArray(selector)) {
return Promise.all(selector.map(s => getOne(s, options)));
}
return getOne(selector, options);
},
/**
* 为父节点设置监听,所有符合选择器的元素(包括页面已有的和新插入的)都将被传给回调函数处理,
* each方法适用于各种滚动加载的列表(如评论区),或者发生非刷新跳转的页面等
* @param selector
* @param callback 回调函数, 只在每个元素上触发一次。 回调函数接收2个参数,第一个是符合选择器的元素,第二个表明该元素是否为新插入的(已有为false,插入为true)
* @param options 一个对象
* - parent 父元素, 默认值是 document
*/
each(selector, callback, options = {}) {
let {
parent = doc,
} = options;
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
const curMode = mode;
const refs = new WeakSet();
for (const node of queryAll(selector, {parent, root: parent, curMode})) {
refs.add(curMode === 'jquery' ? node.get(0) : node);
if (callback(node, false) === false) return;
}
const filter = (el, reason) => {
for (const node of queryAll(selector, {parent:el, root:parent, curMode, reason})) {
const _el = curMode === 'jquery' ? node.get(0) : node;
if (refs.has(_el)) break;
refs.add(_el);
if (callback(node, true) === false) {
return removeFilter(parent, filter);
}
}
};
addFilter(parent, filter);
},
/**
* 将html字符串解析为元素
* @param domString
* @param options 一个对象
* - returnList 布尔值,是否返回以 id 作为索引的元素列表, 默认值为 false
* - parent 父节点,将创建的元素添加到父节点末尾处, 如果不指定, 解析后的元素将
* @returns {Element|{}|null} 元素或对象,取决于returnList参数
*/
create(domString, options = {}) {
let {
returnList = false,
parent = null
} = options;
const template = doc.createElement('template');
template.innerHTML = domString;
const node = template.content.firstElementChild;
if (!node) return null;
parent ? parent.appendChild(node) : node.remove();
if (returnList) {
const list = {};
node.querySelectorAll('[id]').forEach(el => list[el.id] = el);
list[0] = node;
return list;
}
return node;
},
selector(desc) {
switch (true) {
case isJquery(desc):
$ = desc;
return mode = 'jquery';
case !desc || typeof desc.toLowerCase !== 'function':
return mode = 'css';
case desc.toLowerCase() === 'jquery':
for (const jq of [window.jQuery, window.$, win.jQuery, win.$]) {
if (isJquery(jq)) {
$ = jq;
break;
}
}
return mode = $ ? 'jquery' : 'css';
case desc.toLowerCase() === 'xpath':
return mode = 'xpath';
default:
return mode = 'css';
}
}
};
}();
}
继续分析 getOne 流程
我们在知道 query 为 querySelector, 剩下的就好分析了
function getOne(selector, parent, timeout) {
const curMode = mode;
return new Promise(resolve => {
const node = query(selector, parent, parent, curMode);
if (node) return resolve(node); // 上来就查询, 如果找到了就不创建 Mutation Observer了
let timer;
const filter = (el, reason) => { // filter 就是 Mutation Observer 的回调函数
const node = query(selector, el, parent, curMode, reason);
if (node) {
removeFilter(parent, filter);
timer && clearTimeout(timer);
resolve(node);
}
};
addFilter(parent, filter); // 创建 Mutation Observer, 进行异步查询
if (timeout > 0) {
timer = setTimeout(() => {
removeFilter(parent, filter);
resolve(null); // 超时后, 降级返回null, 这里不用reject, 是怕脚本报错, 停止运行
}, timeout);
}
});
}
源码分析 第五小节 Mutation Observer & 监听器 & 过滤器
const win = window;
const doc = win.document;
// DOM元素 -> 监听器 (listener)
//
// 这里使用 WeakMap 是为了避免内存泄露
// 当 DOM 元素调用 .remove() 的时候, 如果 DOM 元素仍然存在引用
// 也就是作为 listeners 的 key, 其引用计数就不会归零,
// 也就是说这个 DOM 元素是不会被垃圾回收器 (GC) 回收的,
// 所以这里要使用 WeakMap 来解决这个问题
//
// 监听器是一个对 Mutation Observer 进行封装后的对象
// 监听器包含两个部分, 一个是用于删除 Mutation Observer 的 remove 成员变量
// 还有一个过滤器集合 (filters), 用于存储 Mutation Observer 触发时, 需要调用的回调函数
const listeners = new WeakMap();
let mode = 'css';
const MutationObs = win.MutationObserver;
// 可参阅《Mutation Observer 快速上手》,具体可查看目录
function addObserver(target, callback) {
const observer = new MutationObs(mutations => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
callback(mutation.target, 'attr');
if (observer.canceled) return;
}
for (const node of mutation.addedNodes) {
if (node instanceof Element) callback(node, 'insert');
if (observer.canceled) return;
}
}
});
observer.canceled = false;
observer.observe(target, {childList: true, subtree: true, attributes: true, attributeOldValue: true});
return () => {
observer.canceled = true;
observer.disconnect();
};
}
// 注册 filter, 当 target 发生变化时, filter 会被调用
// filter 是一个回调函数, 有两个参数
// 一个是 el, 发生变化的元素
// 一个是 reason, 值为 'attr', 或 'insert', 代表是属性发生了变化, 还是新插入了元素
function addFilter(target, filter) {
let listener = listeners.get(target);
if (!listener) {
listener = {
filters: new Set(),
remove: addObserver(target, (el, reason) => listener.filters.forEach(f => f(el, reason)))
};
listeners.set(target, listener);
}
listener.filters.add(filter);
}
// 在找到元素后, 记得移除 filter, 减小浏览器负担
function removeFilter(target, filter) {
const listener = listeners.get(target);
if (!listener) return;
listener.filters.delete(filter);
if (!listener.filters.size) {
listener.remove();
listeners.delete(target);
}
}
封装思想在编程中无处不在,axios是对ajax的封装,为上层提供了易用接口。这里抽象出的filter是对Mutation Observer的封装,底层复用了Mutation Observer。注册首个filter时会自动创建Mutation Observer,filters清空时则自动释放,减轻了浏览器负担。
源码分析 第六小节 each
each(selector, ...args) {
let parent = typeof args[0] !== 'function' && args.shift() || doc;
if (mode === 'jquery' && parent instanceof $) parent = parent.get(0);
const callback = args[0];
const curMode = mode;
const refs = new WeakSet();
for (const node of queryAll(selector, parent, parent, curMode)) {
refs.add(curMode === 'jquery' ? node.get(0) : node);
if (callback(node, false) === false) return;
}
const filter = (el, reason) => {
for (const node of queryAll(selector, el, parent, curMode, reason)) {
const _el = curMode === 'jquery' ? node.get(0) : node;
if (refs.has(_el)) break;
refs.add(_el);
if (callback(node, true) === false) {
return removeFilter(parent, filter);
}
}
};
addFilter(parent, filter);
},
function queryAll(selector, parent, root, curMode, reason) {
switch (curMode) {
case 'css': {
if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
const checkParent = parent !== root && matches.call(parent, selector);
const result = parent.querySelectorAll(selector);
return checkParent ? [parent, ...result] : [...result];
}
case 'jquery': {
if (reason === 'attr') return $(parent).is(selector) ? [$(parent)] : [];
const jNodes = $(parent !== root ? parent : []).add([...parent.querySelectorAll('*')]).filter(selector);
return $.map(jNodes, el => $(el));
}
case 'xpath': {
const ownerDoc = parent.ownerDocument || parent;
selector += '/self::*';
const xPathResult = ownerDoc.evaluate(selector, reason === 'attr' ? root : parent, null, 7, null);
const result = [];
for (let i = 0; i < xPathResult.snapshotLength; i++) {
result.push(xPathResult.snapshotItem(i));
}
return result;
}
}
}
查考引用: https://bbs.tampermonkey.net.cn/thread-2726-1-1.html
each(selector[, parent], callback)
selector
必须,选择器,默认使用css选择器。
parent
可选,父节点,默认值document。
callback
必须,回调函数。
返回值
无。
为父节点设置监听,所有符合选择器的元素(包括页面已有的和新插入的)都将被传给回调函数处理,回调函数只在每个元素上触发一次。 回调函数接收2个参数,第一个是符合选择器的元素,第二个表明该元素是否为新插入的(已有为false,插入为true)。each方法适用于各种滚动加载的列表(如评论区),或者发生非刷新跳转的页面等,参考以下示例:
// b站评论区自动展开回复
elmGetter.each('.reply-item', document, reply => {
const btn = reply.querySelector('.view-more-btn');
if (btn) btn.click();
});
令回调函数返回false即可移除监听,参考以下示例:
const listener = elmGetter.each('div', document, (elm, isInserted) => {
if (isInserted) {
return false;
}
});
简化:
each(selector, ...args) {
let parent = typeof args[0] !== 'function' && args.shift() || doc;
const callback = args[0];
const curMode = mode;
// 避免使用相同的 DOM 元素去调用回调方法
const refs = new WeakSet();
for (const node of queryAll(selector, parent, parent, curMode)) {
if (callback(node, false) === false) return;
}
const filter = (el, reason) => {
for (const node of queryAll(selector, el, parent, curMode, reason)) {
if (refs.has(_el)) break;
refs.add(_el);
if (callback(node, true) === false) {
return removeFilter(parent, filter);
}
}
};
addFilter(parent, filter);
},
function queryAll(selector, parent, root, curMode, reason) {
switch (curMode) {
case 'css': {
// if (reason === 'attr') 时 parent 为发生变化的 DOM 元素
// DOM 属性发生了变化, 其 selector 也有可能发生了变化, 所以这里需要重新matches.call
if (reason === 'attr') return matches.call(parent, selector) ? [parent] : [];
// parent !== root 是为了避免 matches.call(document, selector); 报错
const checkParent = parent !== root && matches.call(parent, selector);
const result = parent.querySelectorAll(selector);
return checkParent ? [parent, ...result] : [...result];
}
}
}
尾声
以上就是源码分析的全部内容。像 create
、selector
这类代码都很简单、细枝末节,相信各位读者凭借聪明才智轻松就能读懂。
最后的最后, 感谢你的阅读! 如有任何问题 欢迎一起讨论
✿✿ヽ(°▽°)ノ✿完结撒花!
下一章节: <附录A: 再来点源码阅读, 评鉴经典的异步库设计>
文章地址: https://bbs.tampermonkey.net.cn/thread-8197-1-1.html