Chrome扩展(Manifest V3)多标签下按钮点击事件绑定失效求助
问题
开发了一个Manifest V3的Chrome扩展,仅使用Content Script,功能是在多个ServiceTitan网页上添加自定义按钮并绑定点击事件。单标签页下功能正常,但多标签页时,部分标签页的按钮能显示,但偶尔出现点击事件未绑定的情况,该问题偶发可复现。
绑定点击事件的代码:
$("#btnEZsaltGo").on("click", function(){ezsaltButtonClicked()});
尝试检测事件绑定的函数(始终返回null,即使事件生效):
function isClickHandlerAttachedToButton(){ // check whether click event handler has been attached var clickHandlers = $("#btnEZsaltGo").data('events')?.click; var clickHandlerIsAttached = (clickHandlers && clickHandlers.length > 0); return clickHandlerIsAttached; }
完整源码:
var interval = 0; var rootEZsaltURL; console.log("EZsaltGo extension loaded..."); if(location.href.indexOf("next.servicetitan.com") > -1 || location.href.indexOf("integration.servicetitan.com") > -1){ // Sandbox ServiceTitan rootEZsaltURL = "https://test.ezsaltgo.com/ServiceTitanLanding.php?CustomerID="; // TEST EzsaltGo }else if(location.href.indexOf("go.servicetitan.com") > -1){ // LIVE ServiceTitan rootEZsaltURL = "https://ezsaltgo.com/ServiceTitanLanding.php?CustomerID="; // LIVE EZsaltGo } (function() { // initial page load (static page content) interval = setInterval(function(){ checkForEZsaltButton(); }, 1000); })(); function checkForEZsaltButton() { if($("#btnEZsaltGo").length == 0){ // only create the ezsalt button if it's not already there var insertBeforeAnchorElement; // insert the EZsaltGo button BEFORE this element var insertAfterAnchorElement; // insert the EZsaltGo button AFTER this element var pageType; var buttonMarginLeft = 0; var buttonMarginRight = 0; var hasBillingAddress = ($("span:contains('Billing Address')").length > 0); var hasServiceAddress = ($("span:contains('Service Address')").length > 0); var hasMoreActionsMenu = ($('div[data-testid="more-actions-menu"]').length > 0); var hasCustomerIDSpan = ($("span:contains('Customer ID')").length > 0); var hasCustomerIDLink = ($('a[role="link"][href*="/Customer/"]').length > 0); var isModal = ($("h2.Modal__header-title").length > 0); if((hasBillingAddress || hasServiceAddress) && hasMoreActionsMenu && (hasCustomerIDSpan || (!hasCustomerIDSpan && hasCustomerIDLink))){ // clearInterval(interval); // console.log("Customer DIV loaded!! Interval stopped."); if(isModal){ // we're inside the modal customer window pageType = "modal"; insertAfterAnchorElement = $("h2.Modal__header-title"); }else{ // we're inside a normal ST web page - let's figure out which page exactly if(location.href.indexOf("/customer/") > 0) pageType = "customer"; if(location.href.indexOf("/location/") > 0) pageType = "location"; if(pageType == "customer"){ insertBeforeAnchorElement = $("div.ButtonGroup").parent(); // no margin padding needed } if(pageType == "location"){ insertBeforeAnchorElement = $("div.ButtonGroup").parent().parent(); buttonMarginRight = 19; } } var ezsaltButton = $('<button id="btnEZsaltGo" class="Button Button--blue Button--solid Button--focus-visible" style="' + (buttonMarginRight > 0 ? "margin-right: " + buttonMarginRight + "px" : "") + (buttonMarginLeft > 0 ? "margin-left: " + buttonMarginLeft + "px" : "") + '>EZsaltGo</button>'); if(insertBeforeAnchorElement) ezsaltButton.insertBefore(insertBeforeAnchorElement); else if(insertAfterAnchorElement) ezsaltButton.insertAfter(insertAfterAnchorElement); else return; // we don't have anything to anchor against, so just return } $("#btnEZsaltGo").on("click", function(){ezsaltButtonClicked()}); // attach click event to the little X at the top right of the modal - if that is clicked, the modal will close, and we need to resume checking if($(".Modal__header-close").length > 0) $(".Modal__header-close").on("click", function(){ //interval = setInterval(function(){ checkForEZsaltButton(); }, 1000); console.log("Modal closed. Interval check resuming..."); }); }else{ // check whether click event handler has been attached - doesn't work // var clickHandlers = $("#btnEZsaltGo").data('events')?.click; // clickHandlers always null even when button is there and click handler is attached // if(clickHandlers && clickHandlers.length > 0) doSomething(); // clickHandlers always null even when button is there and click handler is attached } } function ezsaltButtonClicked(){ console.log("EZsalt button clicked!"); if(rootEZsaltURL && rootEZsaltURL != ""){ var customerID = getCustomerIDForEZsalt(); if(customerID && customerID != ""){ window.open(rootEZsaltURL + customerID); }else{ alert("Unable to locate Customer ID. Contact EZsalt."); } }else{ alert("EZsalt URL could not be obtained. Contact EZsalt."); } } function getCustomerIDForEZsalt(){ var hasCustomerIDSpan = ($("span:contains('Customer ID')").length > 0); if(hasCustomerIDSpan){ var customerIDSpan = $("span:contains('Customer ID')"); var customerIDChunk = customerIDSpan.next('p'); return $.trim(customerIDChunk.html()); }else{ var hasCustomerIDLink = ($('a[role="link"][href*="/Customer/"]').length > 0); if(hasCustomerIDLink){ var hrefChunk = $('a[role="link"][href*="/Customer/"]').attr('href'); var customerID = $.trim(hrefChunk.match(/\d+/)[0]); return customerID; } } return ""; // something went wrong - should never get this far }
解决方案
改用事件委托绑定点击事件:直接绑定动态元素容易因DOM重新渲染导致事件丢失,委托给稳定的父元素(比如
document)能确保事件始终生效。修改绑定代码为:$(document).on("click", "#btnEZsaltGo", ezsaltButtonClicked);移除原来直接绑定到按钮的代码,这样不管按钮何时被创建或替换,点击事件都会被捕获。
修正事件检测逻辑:jQuery 1.8及以上版本不再将事件存储在
data('events')中,所以原检测方法无效。如果需要验证事件状态,可以直接触发测试点击,或者确认委托事件已在页面加载时初始化(无需每次定时器执行都重复绑定)。替换定时器为MutationObserver:定时器每秒轮询可能导致竞态条件(比如按钮未插入DOM就绑定事件),用
MutationObserver监听目标区域的DOM变化,能精准捕捉按钮需要插入的时机,减少不必要的执行,提升稳定性。示例代码:// 替换原定时器初始化代码 (function() { const observer = new MutationObserver(() => { checkForEZsaltButton(); }); observer.observe(document.body, { childList: true, subtree: true }); })();同时在按钮创建成功后,可以根据需要停止观察特定区域,避免重复执行。
确保脚本隔离性:虽然Manifest V3默认隔离每个标签页的Content Script,但将代码完全包裹在IIFE中,避免全局变量潜在的冲突问题,确保每个标签页的状态独立:
(function() { // 所有代码放在这里 var interval = 0; var rootEZsaltURL; // ... 剩余代码 })();
内容的提问来源于stack exchange,提问作者HerrimanCoder

