原生JavaScript:拦截并修改所有链接打开事件的实现问题
问题描述
存在一个耗时的异步函数f,接收URL字符串并返回修改后的URL,示例代码如下:
async function f(url) { // 模拟耗时处理的函数 return new Promise((resolve, reject) => { setTimeout(() => { resolve(url + "#processed"); }, 1000) }); }
需求是让目标页面中所有待打开的链接(包括动态生成的内容),在实际打开前都经过f处理。限制条件:无法控制目标网页,仅能通过Tampermonkey注入脚本实现。
已尝试方案
处理window.open打开的链接比较简单,只需钩子该方法即可,但处理<a>元素时遇到问题。
原思路是在document上添加capture: true的事件监听器,拦截与<a>元素相关的交互事件,暂停事件、修改<a>的href属性后再恢复事件执行。实现代码如下:
const tag1 = "url-handling"; // 标记元素正在处理中 const tag2 = "url-handled"; // 标记元素已处理完成 const eventName = "url-handle-done"; // 处理完成的通知事件 // 克隆事件并阻止原事件 function cloneAndStop(e) { const newEvt = new e.constructor(e.type, e); e.preventDefault(); e.stopImmediatePropagation(); return newEvt; } // 事件拦截处理函数 async function eventHandler(e) { const ele = e.target.tagName === "A" ? e.target : e.target.closest("a"); if (ele && !ele.hasAttribute(tag2) && ele.href) { if (!ele.hasAttribute(tag1)) { // 元素未在处理中 ele.toggleAttribute(tag1, true); const href = ele.href; if (!href.startsWith("https://") && !href.startsWith("http://")) return; // 忽略非HTTP(S)链接 const newEvt = cloneAndStop(e); console.log(`拦截到链接: "${ele.href}"`); const processed = await f(href); ele.href = processed; console.log(`处理完成: "${ele.href}"`); ele.toggleAttribute(tag2, true); ele.removeAttribute(tag1); ele.dispatchEvent(newEvt); // 恢复初始事件 ele.dispatchEvent(new Event(eventName, { bubbles: false, cancelable: true })); // 通知其他等待的处理逻辑 } else { // 元素正在处理中,等待完成 const newEvt = cloneAndStop(e); console.log(`等待处理完成: "${ele.href}"`); ele.addEventListener(eventName, function () { console.log(`等待结束,执行操作: "${ele.href}"`); ele.dispatchEvent(newEvt); }, { once: true }); } } } // 绑定需要拦截的事件 ["click", "mousedown", "auxclick", "contextmenu"].forEach((name) => { document.addEventListener(name, eventHandler, { capture: true }); });
该方案对普通点击<a>元素(含修饰键)的场景有效,处理后的URL会在1秒后正常打开,但对auxclick(鼠标中键点击)和contextmenu(右键菜单)事件无效:日志能看到处理流程,但无法在新标签页打开链接或显示右键菜单。
虽然可以通过调用window.open模拟auxclick的默认行为,但希望找到模拟原生鼠标/指针事件的方案,而非手动调用API。
问题根源
浏览器的原生默认行为(如中键打开新标签、显示右键菜单)仅与原生触发的事件绑定,通过dispatchEvent克隆并分发的事件只能触发页面上的JS监听器,无法触发浏览器的原生默认行为,这就是原方案失效的核心原因。
解决方案
方案一:预处理+兜底拦截(推荐)
通过MutationObserver监听页面所有动态生成的元素,提前异步处理<a>标签的href,减少事件拦截时的等待,同时保留事件拦截作为兜底,处理刚生成就被点击的极端情况。
代码实现:
async function f(url) { return new Promise((resolve) => { setTimeout(() => resolve(url + "#processed"), 1000); }); } const handledAttr = "data-url-handled"; // 处理单个链接 async function processLink(link) { if (link.hasAttribute(handledAttr) || !link.href || !link.href.startsWith("http")) return; link.setAttribute(handledAttr, "processing"); try { const processedUrl = await f(link.href); link.href = processedUrl; } catch (err) { console.error("链接处理失败:", err); } finally { link.setAttribute(handledAttr, "done"); } } // 预处理页面现有链接 document.querySelectorAll("a").forEach(processLink); // 监听动态新增的链接 const observer = new MutationObserver((mutations) => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.tagName === "A") { processLink(node); } else if (node.querySelectorAll) { node.querySelectorAll("a").forEach(processLink); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); // 兜底拦截:处理刚生成就被点击的链接 async function interceptEvent(e) { const link = e.target.closest("a"); if (!link || link.getAttribute(handledAttr) === "done" || !link.href.startsWith("http")) return; // 阻止默认行为,等待处理完成 e.preventDefault(); e.stopImmediatePropagation(); // 等待正在处理的链接完成 if (link.getAttribute(handledAttr) === "processing") { await new Promise(resolve => { const checkStatus = () => { if (link.getAttribute(handledAttr) === "done") resolve(); else requestAnimationFrame(checkStatus); }; checkStatus(); }); } else { await processLink(link); } // 根据事件类型执行对应行为 switch(e.type) { case "click": if (e.ctrlKey || e.metaKey || e.shiftKey || link.target === "_blank") { window.open(link.href, "_blank"); } else { window.location.href = link.href; } break; case "auxclick": if (e.button === 1) { // 鼠标中键 window.open(link.href, "_blank"); } break; case "contextmenu": // 右键菜单需用户再次触发,因为已阻止当前事件 console.log("链接已处理,请再次右键操作"); break; } } // 绑定拦截事件 ["click", "mousedown", "auxclick", "contextmenu"].forEach(evt => { document.addEventListener(evt, interceptEvent, { capture: true }); });
该方案的优势:
- 提前处理所有链接(包括动态生成的),用户操作时大多无需等待;
- 兜底拦截仅处理极端情况,逻辑更稳定;
- 避免了模拟原生事件的局限性。
方案二:改进事件拦截,手动模拟默认行为
针对原方案中dispatchEvent无法触发原生行为的问题,在处理完链接后,根据事件类型、修饰键、标签属性等手动模拟浏览器的默认行为:
修改原方案中eventHandler函数的dispatchEvent部分,替换为以下代码:
// 替换原有的 ele.dispatchEvent(newEvt); switch(e.type) { case "click": if (e.ctrlKey || e.metaKey || e.shiftKey || ele.target === "_blank") { window.open(ele.href, "_blank"); } else { window.location.href = ele.href; } break; case "auxclick": if (e.button === 1) { window.open(ele.href, "_blank"); } break; case "contextmenu": console.log("链接已处理,请再次右键操作"); // 若要显示右键菜单,需用户再次触发,或自定义菜单(成本较高) break; }
该方案的缺点:需要手动覆盖所有浏览器默认行为的场景(如不同修饰键组合、target属性等),容易遗漏细节,稳定性不如预处理方案。
内容的提问来源于stack exchange,提问作者PRO

