本文和本项目完全由 DeepSeek v4 和 Claude Code 完成
构建一个浏览器脚本执行器:Claw 扩展开发实践
本文介绍如何使用 WXT + React 19 开发一个浏览器扩展,实现网页 DOM 元素自动标注和自定义脚本执行功能。
项目背景
在网页自动化测试、数据采集、前端调试等场景中,我们经常需要在网页上执行自定义 JavaScript 代码。传统的做法是打开开发者工具的 Console 面板手动输入代码,但这种方式有以下局限:
- 代码无法持久化 - 每次刷新页面需要重新输入
- 元素定位困难 - 动态页面中元素选择器容易失效
- 缺乏可视化 - 无法直观看到哪些元素已被操作
Claw 浏览器扩展旨在解决这些问题,提供:
- 自动为所有 DOM 元素标注唯一 ID
- 在侧边栏编写、保存、执行 JavaScript 脚本
- 直观展示已标注元素的数量和状态
技术栈选型
| 技术 | 版本 | 选型理由 |
|---|---|---|
| WXT | 0.20.26 | 现代 browser extension 开发框架,支持热更新、TypeScript、多浏览器 |
| React | 19.1.0 | 侧边栏 UI 开发,组件化、状态管理方便 |
| TypeScript | 5.8.3 | 类型安全,减少运行时错误 |
为什么选择 WXT?
相比传统的 extension 开发方式,WXT 提供:
- 文件路由 -
entrypoints/background.ts自动识别为后台脚本 - HMR - 开发模式下代码修改即时生效
- 多浏览器支持 - 一套代码编译为 Chrome/Firefox/Safari
- 模块化 - 内置 React、Vue、Svelte 等模板
核心架构设计
浏览器扩展有多种组件,运行在不同的 JavaScript 世界中。理解这些隔离边界是设计的关键。
JavaScript 世界隔离
Chrome 扩展有三种 JavaScript 执行环境:
┌─────────────────────────────────────────────────────────────┐
│ Browser Extension │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Background │ │ Content │ │ Side Panel │ │
│ │ Service │ │ Script │ │ (React) │ │
│ │ Worker │ │ (ISOLATED) │ │ │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ chrome.tabs │ │ │
│ │ sendMessage │ │ │
│ └───────────────────┘ │ │
│ │ │ │
│ │ injectScript() │ │
│ ↓ │ │
│ ┌─────────────┐ │ │
│ │ MAIN World │ │ │
│ │ (Page JS) │ │ │
│ └─────────────┘ │ │
└─────────────────────────────────────────────────────────────┘
关键区别:
| 环境 | 访问权限 | 典型用途 |
|---|---|---|
| Background (Service Worker) | Chrome APIs, 无 DOM | 消息中继、状态管理、事件监听 |
| Content Script (ISOLATED) | Chrome APIs + DOM | DOM 操作、事件拦截 |
| MAIN World | 页面完整 JS 上下文 | 执行用户脚本、访问页面变量 |
用户脚本需要在 MAIN World 执行,才能访问页面的全局变量、函数、原型链。Content Script 默认运行在 ISOLATED World,无法访问页面的 JavaScript 上下文。
通信架构设计
消息在组件间层层传递:
用户点击执行
↓
Side Panel (React UI)
↓ chrome.runtime.sendMessage({ type: 'EXECUTE_SCRIPT', code })
Background Service Worker
↓ chrome.tabs.sendMessage(tabId, { type: 'FORWARD_EXECUTE', code })
Content Script (ISOLATED)
↓ window.postMessage({ type: 'CLAW_EXECUTE', code })
MAIN World Script
↓ new Function('claw', code)()
执行用户代码
↓ window.postMessage({ type: 'CLAW_RESULT', result })
Content Script
↓ chrome.runtime.sendMessage({ type: 'SCRIPT_RESULT', result })
Background
↓ chrome.runtime.sendMessage({ type: 'SCRIPT_RESULT_FORWARD', result })
Side Panel
↓ 更新 UI 显示结果
为什么需要 Background 中继?
Side Panel 无法直接向 Content Script 发送消息,必须通过 Background:
- Side Panel 使用
chrome.runtime.sendMessage - Content Script 接收
chrome.tabs.sendMessage(需要 tabId) - Background 作为中间人,将 Side Panel 的消息转发到指定 tab
核心技术实现
1. DOM 元素自动标注
需求分析
- 页面加载时标注所有现有元素
- 动态添加的元素也要标注
- ID 必须唯一且简短
- 不能影响页面原有功能
实现方案
唯一 ID 生成:
// src/utils/claw-id.ts
let counter = 0;
export function generateClawId(): string {
counter++;
return `claw-${Date.now().toString(36)}-${counter.toString(36)}`;
}
// 示例输出: claw-m4q8r2k-1, claw-m4q8r2k-2, ...
使用时间戳(36进制)+ 计数器组合,确保:
- 跨页面唯一(时间戳)
- 同页面内唯一(计数器)
- 简短易读(36进制编码)
DOM 遍历标注:
// src/entrypoints/content.ts
function tagElement(element: Element): void {
if (!element.hasAttribute('data-claw-id')) {
element.setAttribute('data-claw-id', generateClawId());
}
}
function tagAllElements(): void {
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_ELEMENT
);
while (walker.nextNode()) {
tagElement(walker.currentNode as Element);
}
}
使用 TreeWalker 而非 querySelectorAll('*') 的原因:
- TreeWalker 是惰性遍历,内存效率更高
- 可以精确控制遍历深度和过滤条件
- 不创建中间数组,性能更优
动态元素监听:
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
tagElementTree(node); // 标注该元素及其所有子元素
}
}
}
});
observer.observe(document.body, {
childList: true, // 监听子节点增删
subtree: true, // 监听所有后代节点
});
2. MAIN World 脚本注入
技术难点
Content Script 无法直接在 MAIN World 执行代码。需要借助:
<script>标签注入chrome.scripting.executeScript({ world: 'MAIN' })
WXT injectScript 方案
WXT 提供 injectScript() 函数,封装了脚本注入逻辑:
// src/entrypoints/content.ts
export default defineContentScript({
matches: ['<all_urls>'],
runAt: 'document_idle',
main(ctx) {
// 注入 MAIN world 脚本
injectScript('/claw-main-world.js', { keepInDom: true });
// 标注 DOM 元素
tagAllElements();
// ...
},
});
injectScript 原理:
- 动态创建
<script src="chrome-extension://xxx/claw-main-world.js"> - 插入到 DOM 中,浏览器加载并执行该脚本
- 脚本在 MAIN World 运行,可访问页面上下文
{ keepInDom: true }保持 script 标签,支持后续通信
Web Accessible Resources 配置:
// wxt.config.ts
export default defineConfig({
manifest: {
web_accessible_resources: [
{
resources: ['claw-main-world.js'],
matches: ['<all_urls>']
}
]
}
});
未配置此项时,网页无法加载扩展内部脚本,会触发安全错误。
3. 用户脚本执行器
安全考量
用户脚本直接使用 eval() 或 Function() 执行存在风险:
- 可访问扩展内部变量(如果脚本在 ISOLATED World)
- 无法控制执行权限
在 MAIN World 执行,脚本仅能访问页面上下文,无法访问扩展 API,天然隔离。
实现代码
// src/entrypoints/claw-main-world.unlisted.ts
export default defineUnlistedScript(() => {
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data?.type !== 'CLAW_EXECUTE') return;
const code = event.data.code;
try {
// 创建 claw 辅助对象
const claw = {
get: (id) => document.querySelector(`[data-claw-id="${id}"]`),
getAll: () => document.querySelectorAll('[data-claw-id]'),
count: () => document.querySelectorAll('[data-claw-id]').length,
// ...
};
// 执行用户代码
const fn = new Function('claw', code);
const result = fn(claw);
// 处理异步结果
if (result instanceof Promise) {
result
.then(r => window.postMessage({ type: 'CLAW_RESULT', result: r }, '*'))
.catch(e => window.postMessage({ type: 'CLAW_RESULT', error: e.message }, '*'));
} else {
window.postMessage({ type: 'CLAW_RESULT', result }, '*');
}
} catch (error) {
window.postMessage({ type: 'CLAW_RESULT', error: error.message }, '*');
}
});
});
关键设计:
- 使用
new Function()而非eval()- 更安全,作用域可控 - 注入
claw参数 - 用户无需了解data-claw-id属性细节 - 支持 Promise - 异步代码自动处理
- 双向 postMessage - 结果回传给 Content Script
4. 消息类型系统
使用 TypeScript 定义消息类型,确保类型安全:
// src/types/messages.ts
export interface ExecuteScriptMessage {
type: 'EXECUTE_SCRIPT';
tabId: number;
code: string;
}
export interface ClawResultMessage {
type: 'CLAW_RESULT';
result?: unknown;
error?: string;
}
export type MessageType = ExecuteScriptMessage | ClawResultMessage | ...;
5. React 侧边栏 UI
Side Panel API
Chrome 114+ 提供 chrome.sidePanel API,侧边栏可以:
- 与页面内容并排显示
- 保持打开状态(不像 popup 点击外部就关闭)
- 访问当前 tab 信息
// src/entrypoints/background.ts
export default defineBackground(() => {
// 点击扩展图标打开侧边栏
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
});
Tab 切换上下文跟踪
Side Panel 打开后,用户可能切换到其他 Tab。侧边栏需要:
- 跟踪当前活动的 Tab
- 自动更新页面 URL 显示
- 自动刷新 tagged elements 数量
- 清除旧的执行结果(避免混淆)
实现方案:监听 chrome.tabs.onActivated 事件
// src/entrypoints/sidepanel/App.tsx
// 监听 Tab 切换事件
useEffect(() => {
const handleTabActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
// 更新当前 Tab ID
chrome.tabs.get(activeInfo.tabId).then((tab) => {
setCurrentTabId(activeInfo.tabId);
setCurrentTabUrl(tab.url || '');
// 清除旧的输出(避免混淆)
setOutput(null);
setError(null);
});
};
chrome.tabs.onActivated.addListener(handleTabActivated);
return () => {
chrome.tabs.onActivated.removeListener(handleTabActivated);
};
}, []);
// 监听 Tab URL 变化(页面内导航)
useEffect(() => {
const handleTabUpdated = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
if (tabId !== currentTabId) return;
if (changeInfo.url) {
setCurrentTabUrl(changeInfo.url);
setOutput(null);
setError(null);
}
};
chrome.tabs.onUpdated.addListener(handleTabUpdated);
return () => {
chrome.tabs.onUpdated.removeListener(handleTabUpdated);
};
}, [currentTabId]);
不支持页面检测:
某些页面不支持 content script 注入:
chrome://- Chrome 内部页面edge://- Edge 内部页面chrome-extension://- 扩展页面devtools://- 开发者工具
function isSupportedUrl(url: string | undefined): boolean {
if (!url) return false;
const unsupportedPrefixes = [
'chrome://',
'edge://',
'about:',
'chrome-extension://',
'devtools://',
];
return !unsupportedPrefixes.some(prefix => url.startsWith(prefix));
}
对于不支持的页面,侧边栏显示警告并禁用执行按钮。
组件设计
// src/entrypoints/sidepanel/App.tsx
export default function App() {
const [code, setCode] = useState('');
const [output, setOutput] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isExecuting, setIsExecuting] = useState(false);
const [taggedCount, setTaggedCount] = useState(0);
const [currentTabId, setCurrentTabId] = useState<number | null>(null);
const [currentTabUrl, setCurrentTabUrl] = useState<string>('');
const [isSupportedPage, setIsSupportedPage] = useState(true);
// 执行脚本
const handleExecute = async () => {
if (!currentTabId || !isSupportedPage) return;
chrome.runtime.sendMessage({
type: 'EXECUTE_SCRIPT',
tabId: currentTabId,
code
});
};
// 监听执行结果
useEffect(() => {
chrome.runtime.onMessage.addListener((msg) => {
if (msg?.type === 'SCRIPT_RESULT_FORWARD') {
setIsExecuting(false);
if (msg.error) setError(msg.error);
else setOutput(formatResult(msg.result));
}
});
}, []);
return (
<div className="app-container">
<header className="app-header">
<h1>🦀 Claw Script Executor</h1>
<p className="subtitle">
{isSupportedPage
? `📄 ${currentTabUrl}`
: '⚠️ This page does not support content scripts'}
</p>
</header>
<CodeEditor value={code} onChange={setCode} disabled={!isSupportedPage} />
<button onClick={handleExecute} disabled={!isSupportedPage}>Execute</button>
<OutputPanel output={output} error={error} />
{isSupportedPage && <ElementList count={taggedCount} />}
</div>
);
}
使用示例
基础用法
// 获取已标注元素数量
return claw.count();
// 输出: 152
// 获取所有按钮
const buttons = claw.findByTag('button');
return `找到 ${buttons.length} 个按钮`;
// 查找包含特定文本的元素
const loginBtns = claw.findByText('登录');
return loginBtns.map(el => el.textContent);
高级用法
// 批量操作元素
const links = claw.query('a[href]');
links.forEach(link => {
link.style.border = '2px solid red';
});
return `已高亮 ${links.length} 个链接`;
// 异步操作
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await sleep(1000);
return '等待完成';
// 访问页面变量(MAIN World 特权)
return window.location.href;
return document.cookie;
性能优化
DOM 标注优化
- 惰性标注 - 仅标注用户可见区域
- 批量处理 - MutationObserver 批量回调
- 避免重复 - 检查
hasAttribute防止重复标注
消息通信优化
- 类型过滤 - 消息处理前检查
type字段 - 源验证 -
event.source !== window防止跨域消息 - 单向流 - 消息不回流,避免循环
项目结构
extension-claw/
├── wxt.config.ts # WXT 配置(权限、资源)
├── src/
│ ├── types/messages.ts # 消息类型定义
│ ├── utils/claw-id.ts # ID 生成器
│ ├── components/ # React UI 组件
│ └── entrypoints/
│ ├── background.ts # 后台服务(消息中继)
│ ├── content.ts # 内容脚本(DOM 标注)
│ ├── claw-main-world.unlisted.ts # MAIN 脚本执行器
│ └── sidepanel/ # 侧边栏 UI
│ ├── App.tsx
│ ├── main.tsx
│ └── style.css
└── .output/chrome-mv3/ # 构建输出
开发与调试
# 开发模式(热更新)
npm run dev
# 生产构建
npm run build
# 加载到 Chrome
# 1. chrome://extensions/
# 2. 开启开发者模式
# 3. 加载已解压的扩展程序 → .output/chrome-mv3
调试技巧:
- Background 日志:
chrome://extensions/→ 扩展详情 → Service Worker - Content Script 日志:页面开发者工具 → Console
- Side Panel 日志:右键侧边栏 → 检查
总结
Claw 扩展展示了现代浏览器扩展开发的完整流程:
- 理解执行环境 - Background、Content Script、MAIN World 的隔离边界
- 设计通信架构 - 多层消息传递,Background 作为中继
- DOM 操作策略 - TreeWalker + MutationObserver 组合
- 脚本注入技术 - injectScript + Web Accessible Resources
- 用户体验 - Side Panel API + React UI + Tab 上下文跟踪
核心技术点:
- WXT 框架简化了扩展开发流程
- MAIN World 注入让用户脚本拥有完整页面权限
- 唯一 ID 标注解决了动态元素定位问题
- Tab 切换监听确保侧边栏始终操作当前页面
项目开源,欢迎参考学习:extension-claw
参考资料: