WXT 框架下的 Window 对象获取

前言

在浏览器扩展开发中,Content Script(内容脚本)运行在与网页相同的环境中,但由于其处于隔离的执行环境(Isolated World),无法直接访问网页的 JavaScript 上下文,包括 window 对象的自定义属性。这在需要获取页面特定数据时(如 Etsy 店铺的 shopId、订单状态等)会成为一个问题。

本文档基于实际项目经验,详细介绍了在 WXT 框架下获取页面 window 对象的几种方案,并提供了完整的实现代码和对比分析。

需求

在开发 Etsy 订单管理扩展时,需要从页面的 window 对象中获取以下数据:

  1. 店铺 ID(shopId)window.Etsy.Context.data.shop_id
  2. 订单状态列表(orderStates)window.Etsy.Context.data.order_states

这些数据存储在页面的主世界(Main World)中,而 Content Script 运行在隔离世界(Isolated World),无法直接访问。

方案

方案一:web_accessible_resources + window.postMessage

原理

通过在 manifest.json 中配置 web_accessible_resources,将脚本文件注入到页面的主世界(Main World),使其能够直接访问页面的 window 对象。使用 window.postMessage 在 Content Script 和注入脚本之间进行双向通信。

实现流程

  1. 创建一个注入脚本文件(如 page-inject.js),用于在主世界中执行代码
  2. wxt.config.ts 中配置 web_accessible_resources,将脚本指定为可访问资源
  3. 在 Content Script 中动态创建 <script> 标签,将脚本注入到页面中
  4. 使用 window.postMessage 在 Content Script 和注入脚本之间进行通信

代码实现

1. 创建注入脚本(public/page-inject.js

javascript 复制代码
/**
 * 注入到页面主世界的脚本
 * 用于直接访问页面的 window 对象,获取 Etsy 相关数据
 */
(function () {
  "use strict";

  // 监听来自隔离世界(ISOLATED world)的 content script 的消息
  window.addEventListener("message", function (event) {
    // 确保消息来自当前窗口
    if (event.source !== window) return;

    // 检查消息类型
    if (event.data && event.data.type === "get-etsy-data") {
      try {
        // 在主世界中直接访问 window.Etsy.Context.data 对象
        const etsyData = window.Etsy?.Context?.data;

        if (etsyData) {
          const shopId = etsyData.shop_id;
          const orderStates = etsyData.order_states;

          console.log("✅ [主世界] 成功获取 Etsy 数据");

          // 发送响应回隔离世界
          window.postMessage(
            {
              type: "etsy-data-response",
              requestId: event.data.requestId,
              success: true,
              shopId: shopId,
              orderStates: orderStates,
            },
            "*"
          );
        } else {
          // 发送错误响应
          window.postMessage(
            {
              type: "etsy-data-response",
              requestId: event.data.requestId,
              success: false,
              error: "无法获取 Etsy 数据,请确保在 Etsy 店铺管理页面打开此扩展",
            },
            "*"
          );
        }
      } catch (error) {
        // 发送错误响应
        window.postMessage(
          {
            type: "etsy-data-response",
            requestId: event.data.requestId,
            success: false,
            error: error instanceof Error ? error.message : "未知错误",
          },
          "*"
        );
      }
    }
  });

  console.log("✅ [主世界] page-inject.js 已加载,可以访问 window.Etsy 对象");
})();

2. 配置 WXT(wxt.config.ts

typescript 复制代码
import { defineConfig } from 'wxt';

export default defineConfig({
  modules: ['@wxt-dev/module-vue'],
  manifest: {
    web_accessible_resources: [
      {
        resources: ['page-inject.js'],
        matches: ['*://*.etsy.com/*'],
        use_dynamic_url: true,
      },
    ],
  },
});

3. Content Script 实现(entrypoints/content.ts

typescript 复制代码
/**
 * 注入脚本到页面主世界
 */
function injectScript(scriptPath: string): Promise<void> {
  return new Promise((resolve, reject) => {
    // 检查脚本是否已经注入
    const scriptId = `injected-script-${scriptPath}`;
    if (document.getElementById(scriptId)) {
      console.log(`✅ [隔离世界] 脚本 ${scriptPath} 已存在,跳过注入`);
      resolve();
      return;
    }

    const script = document.createElement("script");
    script.id = scriptId;
    script.src = browser.runtime.getURL(scriptPath as any);
    script.onload = function () {
      console.log(`✅ [隔离世界] 脚本 ${scriptPath} 注入成功`);
      resolve();
    };
    script.onerror = function () {
      console.error(`❌ [隔离世界] 脚本 ${scriptPath} 注入失败`);
      reject(new Error(`脚本 ${scriptPath} 注入失败`));
    };
    (document.head || document.documentElement).appendChild(script);
  });
}

/**
 * 通过 postMessage 与主世界脚本通信,获取 Etsy 数据
 */
function getEtsyDataFromMainWorld(): Promise<{
  success: boolean;
  shopId?: number;
  orderStates?: OrderState[];
  error?: string;
}> {
  return new Promise(async (resolve, reject) => {
    try {
      // 确保注入脚本已加载
      await injectScript("page-inject.js");

      // 生成唯一的请求 ID
      const requestId = `etsy-data-${Date.now()}-${Math.random()}`;

      // 设置超时,避免无限等待
      const timeout = setTimeout(() => {
        window.removeEventListener("message", handleResponse);
        reject(new Error("获取 Etsy 数据超时,主世界脚本可能未响应"));
      }, 5000); // 5秒超时

      // 处理响应
      function handleResponse(event: MessageEvent) {
        // 确保消息来自当前窗口
        if (event.source !== window) return;

        // 检查消息类型和请求 ID
        if (
          event.data &&
          event.data.type === "etsy-data-response" &&
          event.data.requestId === requestId
        ) {
          clearTimeout(timeout);
          window.removeEventListener("message", handleResponse);

          const { success, shopId, orderStates, error } = event.data;

          if (success && shopId !== undefined) {
            console.log("✅ [隔离世界] 成功从主世界获取 Etsy 数据");
            resolve({ success: true, shopId, orderStates });
          } else {
            console.warn("⚠️ [隔离世界] 从主世界获取 Etsy 数据失败:", error);
            resolve({
              success: false,
              error: error || "无法获取 Etsy 数据",
            });
          }
        }
      }

      // 监听响应消息
      window.addEventListener("message", handleResponse);

      // 发送请求到主世界
      window.postMessage(
        {
          type: "get-etsy-data",
          requestId: requestId,
        },
        "*"
      );
      console.log("📤 [隔离世界] 已发送获取 Etsy 数据请求到主世界");
    } catch (error) {
      console.error("❌ [隔离世界] 获取 Etsy 数据时发生错误:", error);
      reject(error);
    }
  });
}

优点

  • ✅ 实现相对简单,逻辑清晰
  • ✅ 兼容性好,适用于所有支持 Manifest V3 的浏览器
  • ✅ 使用标准的 postMessage API,安全性高
  • ✅ 不需要序列化函数,避免安全风险
  • ✅ 在 WXT 框架中配置简单,易于维护

缺点

  • ⚠️ 需要在 manifest.json 中配置 web_accessible_resources
  • ⚠️ 需要动态注入脚本,可能有加载时序问题(可通过 onload 回调解决)

方案二:web_accessible_resources + CustomEvent + 函数序列化

原理

同样通过 web_accessible_resources 注入脚本到页面主世界,但使用 CustomEvent 进行通信,并通过函数序列化的方式传递复杂逻辑。

实现流程

  1. Content Script 将需要在页面上下文中执行的函数序列化为字符串
  2. Content Script 创建并分发一个包含序列化函数的 CustomEvent
  3. 注入脚本监听该事件,反序列化函数并在页面上下文中执行
  4. 执行结果通过另一个 CustomEvent 传回 Content Script

代码示例

javascript 复制代码
// Content Script 中
function getMyData() {
  return window.Etsy?.Context?.data;
}

indexSendMessageToLucky('run-index-fun', {
  function: getMyData.toString()
}).then((res) => {
  console.log('res-->', res)
});

// 注入脚本中
window.addEventListener('custom-index-type', async (e) => {
  const { type, data } = e.detail
  switch (type) {
    case 'run-index-fun': {
      const fn = new Function(`return (${data.function})(...arguments)`)
      const rs = await fn(...(data.args ?? []))
      luckySendMessageToIndex(type, rs)
      break
    }
  }
})

优点

  • ✅ 可以传递复杂的函数逻辑
  • CustomEvent 通信更灵活

缺点

  • ❌ 使用 new Function(),存在安全风险(可能受到 CSP 限制)
  • ❌ 需要序列化函数,代码复杂度较高
  • ❌ 同样需要配置 web_accessible_resources
  • ❌ 需要处理 iframe 检测

方案三:world: "MAIN" 配置

原理

在 Content Script 的配置中指定 world: "MAIN",使 Content Script 直接在页面的主世界中执行,从而直接访问页面的 window 对象。

实现流程

  1. 在 Content Script 的配置中设置 world: "MAIN"
  2. Content Script 直接在页面主世界中执行,访问 window 对象获取所需数据

代码示例

typescript 复制代码
export default defineContentScript({
  matches: ["*://*.etsy.com/*"],
  runAt: "document_end",
  world: "MAIN", // 运行在页面主世界
  main() {
    // 直接访问 window.Etsy 对象
    const shopId = window.Etsy?.Context?.data?.shop_id;
    // ...
  },
});

优点

  • ✅ 实现最简单,无需额外的通信机制
  • ✅ 不需要 web_accessible_resources 配置
  • ✅ 性能最好,无需动态注入脚本
  • ✅ 符合 Manifest V3 的推荐方式

缺点

  • WXT 框架可能不支持 world: "MAIN" 配置(这是关键问题)
  • ❌ 即使支持,配置可能不会正确生成到 manifest.json
  • ❌ 在实际测试中发现主世界脚本无法正确加载

方案对比

特性 方案一(postMessage) 方案二(CustomEvent) 方案三(world: MAIN)
实现复杂度 中等 较高 低(理论上)
兼容性 ✅ 高 ✅ 高 ❌ 低(WXT 可能不支持)
安全性 ✅ 高 ⚠️ 中(eval 风险) ✅ 高
性能 ✅ 良好 ✅ 良好 ✅ 最好
配置复杂度 中等 中等 低(理论上)
CSP 限制 ✅ 无 ❌ 可能受限 ✅ 无
WXT 支持 ✅ 完全支持 ✅ 完全支持 ❌ 不支持或未验证

实际测试

测试环境

  • 框架:WXT v0.20.6
  • 浏览器:Chrome(Manifest V3)
  • 目标网站:Etsy 店铺管理页面

方案一测试结果

成功

  • 脚本注入正常
  • postMessage 通信正常
  • 能够成功获取 window.Etsy.Context.data
  • 性能表现良好

测试日志:

css 复制代码
✅ [隔离世界] 脚本 page-inject.js 注入成功
✅ [主世界] page-inject.js 已加载,可以访问 window.Etsy 对象
📤 [隔离世界] 已发送获取 Etsy 数据请求到主世界
✅ [主世界] 成功获取 Etsy 数据
✅ [隔离世界] 成功从主世界获取 Etsy 数据

方案三测试结果

失败

  • 配置 world: "MAIN" 后,生成的 manifest.json 中可能不包含该配置
  • 即使手动添加配置,主世界脚本也无法正确加载
  • 控制台出现超时错误:获取 shopId 超时,主世界脚本可能未加载

测试日志:

css 复制代码
📤 [隔离世界] 已发送获取 shopId 请求到主世界
⚠️ 获取 shopId 失败: 获取 shopId 超时,主世界脚本可能未加载

方案二测试结果

⚠️ 未测试

  • 由于方案一已经满足需求,且方案二存在安全风险,未进行实际测试
  • 理论上可行,但不推荐使用

最终采用

基于实际测试结果和方案对比,最终采用方案一(web_accessible_resources + window.postMessage)

选择理由

  1. 兼容性最佳:在 WXT 框架中完全支持,配置简单
  2. 可靠性高:经过实际测试,功能稳定可靠
  3. 安全性好 :使用标准的 postMessage API,不涉及 eval 等安全风险
  4. 易于维护:代码结构清晰,逻辑简单,便于后续维护
  5. 性能良好 :虽然需要动态注入脚本,但通过 onload 回调确保时序正确,性能影响可忽略

完整实现代码

参考本文档的"方案一"部分,包含完整的代码实现。


总结

在 WXT 框架下获取页面 window 对象,推荐使用 web_accessible_resources + window.postMessage 方案。虽然理论上 world: "MAIN" 配置是最优方案,但由于 WXT 框架可能不支持或配置不生效,实际项目中无法使用。

关键要点

  1. 隔离世界限制 :Content Script 运行在隔离世界,无法直接访问页面主世界的 window 对象
  2. 注入脚本方案 :通过 web_accessible_resources 将脚本注入到主世界是可靠的解决方案
  3. 通信机制 :使用 window.postMessage 进行跨世界通信,安全且标准
  4. 框架兼容性:在选择方案时,必须考虑框架的实际支持情况,不能仅依赖理论最优方案

最佳实践

  1. ✅ 使用 web_accessible_resources 配置注入脚本
  2. ✅ 通过 script.onload 确保脚本加载完成后再通信
  3. ✅ 使用请求 ID 机制支持并发请求
  4. ✅ 添加超时机制避免无限等待
  5. ✅ 完整的错误处理和日志记录
  6. ✅ 在注入前检查脚本是否已存在,避免重复注入

注意事项

  1. ⚠️ 确保 web_accessible_resources 中的 matches 配置正确,只允许在需要的域名下访问
  2. ⚠️ 使用 use_dynamic_url: true 确保资源 URL 动态生成,提高安全性
  3. ⚠️ 在 postMessage 中验证消息来源,确保安全性
  4. ⚠️ 处理 iframe 场景时,需要检查 window.top === window.self

通过本文档的方案,可以在 WXT 框架下可靠地获取页面 window 对象,满足各种实际开发需求。


文档版本 :v1.0
最后更新 :2024年
作者:基于实际项目经验总结

相关推荐
少卿17 分钟前
Webpack 插件开发指南:深入理解 Compiler Hooks
前端·webpack
一名普通的程序员17 分钟前
Design Tokens的设计与使用详解:构建高效设计系统的核心技术
前端
VaJoy17 分钟前
Cocos Creator Shader 入门 ⒇ —— 液态玻璃效果
前端·cocos creator
suke19 分钟前
听说前端又死了?
前端·人工智能·程序员
肠胃炎24 分钟前
Flutter 线性组件详解
前端·flutter
肠胃炎27 分钟前
Flutter 布局组件详解
前端·flutter
Jing_Rainbow39 分钟前
【AI-5 全栈-1 /Lesson9(2025-10-29)】构建一个现代前端 AI 图标生成器:从零到完整实现 (含 AIGC 与后端工程详解)🧠
前端·后端
阿明Drift1 小时前
用 RAG 搭建一个 AI 小说问答系统
前端·人工智能
1***s6321 小时前
React区块链开发
前端·react.js·区块链