前言
在浏览器扩展开发中,Content Script(内容脚本)运行在与网页相同的环境中,但由于其处于隔离的执行环境(Isolated World),无法直接访问网页的 JavaScript 上下文,包括 window 对象的自定义属性。这在需要获取页面特定数据时(如 Etsy 店铺的 shopId、订单状态等)会成为一个问题。
本文档基于实际项目经验,详细介绍了在 WXT 框架下获取页面 window 对象的几种方案,并提供了完整的实现代码和对比分析。
需求
在开发 Etsy 订单管理扩展时,需要从页面的 window 对象中获取以下数据:
- 店铺 ID(shopId) :
window.Etsy.Context.data.shop_id - 订单状态列表(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 和注入脚本之间进行双向通信。
实现流程
- 创建一个注入脚本文件(如
page-inject.js),用于在主世界中执行代码 - 在
wxt.config.ts中配置web_accessible_resources,将脚本指定为可访问资源 - 在 Content Script 中动态创建
<script>标签,将脚本注入到页面中 - 使用
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 的浏览器
- ✅ 使用标准的
postMessageAPI,安全性高 - ✅ 不需要序列化函数,避免安全风险
- ✅ 在 WXT 框架中配置简单,易于维护
缺点
- ⚠️ 需要在
manifest.json中配置web_accessible_resources - ⚠️ 需要动态注入脚本,可能有加载时序问题(可通过
onload回调解决)
方案二:web_accessible_resources + CustomEvent + 函数序列化
原理
同样通过 web_accessible_resources 注入脚本到页面主世界,但使用 CustomEvent 进行通信,并通过函数序列化的方式传递复杂逻辑。
实现流程
- Content Script 将需要在页面上下文中执行的函数序列化为字符串
- Content Script 创建并分发一个包含序列化函数的
CustomEvent - 注入脚本监听该事件,反序列化函数并在页面上下文中执行
- 执行结果通过另一个
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 对象。
实现流程
- 在 Content Script 的配置中设置
world: "MAIN" - 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)。
选择理由
- 兼容性最佳:在 WXT 框架中完全支持,配置简单
- 可靠性高:经过实际测试,功能稳定可靠
- 安全性好 :使用标准的
postMessageAPI,不涉及eval等安全风险 - 易于维护:代码结构清晰,逻辑简单,便于后续维护
- 性能良好 :虽然需要动态注入脚本,但通过
onload回调确保时序正确,性能影响可忽略
完整实现代码
参考本文档的"方案一"部分,包含完整的代码实现。
总结
在 WXT 框架下获取页面 window 对象,推荐使用 web_accessible_resources + window.postMessage 方案。虽然理论上 world: "MAIN" 配置是最优方案,但由于 WXT 框架可能不支持或配置不生效,实际项目中无法使用。
关键要点
- 隔离世界限制 :Content Script 运行在隔离世界,无法直接访问页面主世界的
window对象 - 注入脚本方案 :通过
web_accessible_resources将脚本注入到主世界是可靠的解决方案 - 通信机制 :使用
window.postMessage进行跨世界通信,安全且标准 - 框架兼容性:在选择方案时,必须考虑框架的实际支持情况,不能仅依赖理论最优方案
最佳实践
- ✅ 使用
web_accessible_resources配置注入脚本 - ✅ 通过
script.onload确保脚本加载完成后再通信 - ✅ 使用请求 ID 机制支持并发请求
- ✅ 添加超时机制避免无限等待
- ✅ 完整的错误处理和日志记录
- ✅ 在注入前检查脚本是否已存在,避免重复注入
注意事项
- ⚠️ 确保
web_accessible_resources中的matches配置正确,只允许在需要的域名下访问 - ⚠️ 使用
use_dynamic_url: true确保资源 URL 动态生成,提高安全性 - ⚠️ 在
postMessage中验证消息来源,确保安全性 - ⚠️ 处理 iframe 场景时,需要检查
window.top === window.self
通过本文档的方案,可以在 WXT 框架下可靠地获取页面 window 对象,满足各种实际开发需求。
文档版本 :v1.0
最后更新 :2024年
作者:基于实际项目经验总结