第 4 章:内容脚本(Content Scripts)
第 4 章:内容脚本(Content Scripts)
内容脚本是 Chrome 扩展与网页交互的桥梁——它们运行在网页上下文中,可以直接读取和修改页面 DOM,同时又能访问部分 Chrome API。
4.1 什么是 Content Script
Content Script 是注入到网页中的 JavaScript 和 CSS 文件。它们运行在被称为 “隔离世界”(Isolated World) 的特殊环境中:
┌───────────────────────────────────────┐
│ Web Page Context │
│ │
│ ┌─────────────┐ ┌────────────────┐ │
│ │ 主世界 │ │ 隔离世界 │ │
│ │ (Main World) │ │ (Isolated World)│ │
│ │ │ │ │ │
│ │ 页面 JS │ │ Content Script │ │
│ │ 页面全局变量 │ │ 扩展全局变量 │ │
│ │ │ │ │ │
│ │ ❌ chrome.* │ │ ✅ chrome.* │ │
│ │ ✅ DOM │ │ ✅ DOM │ │
│ └─────────────┘ └────────────────┘ │
└───────────────────────────────────────┘
隔离世界的含义
| 特性 | 说明 |
|---|---|
| 共享 DOM | Content Script 可以读取和修改页面的 DOM |
| 独立 JS 环境 | Content Script 无法访问页面的 JavaScript 变量 |
| 独立 CSS 命名空间 | Content Script 的样式默认不会被页面覆盖(但会覆盖页面样式) |
| 受限的 Chrome API | 只能访问 runtime、storage、i18n 等安全 API |
4.2 注入方式
4.2.1 静态注入(manifest.json 声明)
{
"content_scripts": [
{
"matches": ["*://*.github.com/*"],
"js": ["content/github.js", "content/utils.js"],
"css": ["content/styles/github.css"],
"run_at": "document_idle",
"all_frames": false
}
]
}
4.2.2 动态注入(程序化注入)
// 在 Service Worker 中按需注入
async function injectContentScript(tabId) {
try {
await chrome.scripting.executeScript({
target: { tabId },
files: ['content/content.js']
});
await chrome.scripting.insertCSS({
target: { tabId },
files: ['content/content.css']
});
console.log('脚本注入成功');
} catch (error) {
console.error('注入失败:', error);
}
}
// 注入内联代码
async function highlightLinks(tabId) {
await chrome.scripting.executeScript({
target: { tabId },
func: (color) => {
document.querySelectorAll('a').forEach(link => {
link.style.borderBottom = `2px solid ${color}`;
});
},
args: ['#4CAF50']
});
}
⚠️ 注意:程序化注入需要
"scripting"权限和对应的 host 权限(或"activeTab"权限)。
4.2.3 三种注入时机对比
run_at 值 | 注入时机 | 适用场景 |
|---|---|---|
document_start | DOM 构建之前,CSS 之后 | 注入自定义 CSS、拦截页面脚本 |
document_end | DOM 完成,图片/框架加载前 | 修改 DOM 结构 |
document_idle | document_end 之后,window.onload 前 | 默认值,大多数场景推荐 |
4.3 DOM 操作
Content Script 可以完整地操作页面 DOM:
4.3.1 查询元素
// 基本选择器
const title = document.querySelector('h1');
const links = document.querySelectorAll('a[href]');
// XPath
const xpath = '//div[@class="article"]//p';
const result = document.evaluate(xpath, document, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for (let i = 0; i < result.snapshotLength; i++) {
const node = result.snapshotItem(i);
console.log(node.textContent);
}
4.3.2 创建 UI 组件
// 创建一个浮动通知面板
function createNotificationPanel(message, type = 'info') {
// 移除已有的面板
const existing = document.getElementById('ext-notification');
if (existing) existing.remove();
const panel = document.createElement('div');
panel.id = 'ext-notification';
panel.innerHTML = `
<div class="ext-notification-content ext-${type}">
<span class="ext-icon">${type === 'info' ? 'ℹ️' : '⚠️'}</span>
<span class="ext-message">${message}</span>
<button class="ext-close">×</button>
</div>
`;
// 使用 Shadow DOM 隔离样式
const shadow = panel.attachShadow({ mode: 'closed' });
shadow.innerHTML = `
<style>
.ext-notification-content {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 2147483647;
display: flex;
align-items: center;
gap: 8px;
font-family: system-ui, sans-serif;
font-size: 14px;
animation: slideIn 0.3s ease-out;
}
.ext-info { background: #e3f2fd; color: #1565c0; border: 1px solid #90caf9; }
.ext-warning { background: #fff3e0; color: #e65100; border: 1px solid #ffcc80; }
.ext-error { background: #fce4ec; color: #c62828; border: 1px solid #ef9a9a; }
.ext-close {
background: none; border: none; cursor: pointer;
font-size: 18px; opacity: 0.7; margin-left: 8px;
}
.ext-close:hover { opacity: 1; }
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
</style>
<div class="ext-notification-content ext-${type}">
<span>${type === 'info' ? 'ℹ️' : '⚠️'}</span>
<span>${message}</span>
<button class="ext-close">×</button>
</div>
`;
shadow.querySelector('.ext-close').addEventListener('click', () => {
panel.remove();
});
document.body.appendChild(panel);
// 自动关闭
setTimeout(() => {
if (panel.parentNode) {
panel.style.animation = 'slideOut 0.3s ease-in';
setTimeout(() => panel.remove(), 300);
}
}, 5000);
}
// 使用
createNotificationPanel('数据已保存成功!', 'info');
4.3.3 拦截页面事件
// 监听页面上的表单提交
document.addEventListener('submit', (event) => {
const form = event.target;
if (form.action.includes('login')) {
const formData = new FormData(form);
console.log('捕获登录表单:', Object.fromEntries(formData));
}
}, true); // 使用捕获阶段
// 拦截键盘快捷键
document.addEventListener('keydown', (event) => {
if (event.ctrlKey && event.shiftKey && event.key === 'S') {
event.preventDefault();
savePageContent();
}
}, true);
4.4 注入 CSS
Content Script 的 CSS 在页面加载时注入,可以用来:
覆盖页面样式
/* content/content.css */
/* 隐藏页面上的广告元素 */
.ad-banner, .sidebar-ad, [data-ad-slot] {
display: none !important;
}
/* 高亮特定元素 */
.highlight-element {
outline: 3px solid #4CAF50 !important;
outline-offset: 2px;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
限制 CSS 作用范围
/* 使用命名空间前缀避免冲突 */
.ext-my-plugin-overlay { /* ... */ }
.ext-my-plugin-button { /* ... */ }
4.5 消息通信
Content Script 需要与 Service Worker、Popup 等其他组件通信。
4.5.1 发送消息到 Service Worker
// Content Script → Service Worker (单次消息)
async function fetchFromBackground(query) {
try {
const response = await chrome.runtime.sendMessage({
type: 'API_REQUEST',
endpoint: '/search',
params: { q: query }
});
if (response.success) {
return response.data;
} else {
throw new Error(response.error);
}
} catch (error) {
console.error('通信失败:', error);
throw error;
}
}
// 监听来自 Service Worker 的消息
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
switch (message.type) {
case 'UPDATE_DOM':
updatePageContent(message.data);
sendResponse({ success: true });
break;
case 'GET_PAGE_DATA':
const pageData = extractPageData();
sendResponse({ data: pageData });
break;
case 'HIGHLIGHT':
highlightElements(message.selector, message.color);
sendResponse({ success: true });
break;
}
return true; // 保持通道开放
});
4.5.2 长连接端口
// Content Script 中建立长连接
const port = chrome.runtime.connect({ name: 'content-script' });
port.onMessage.addListener((message) => {
if (message.type === 'REALTIME_UPDATE') {
updateUI(message.data);
}
});
port.onDisconnect.addListener(() => {
console.log('连接断开,正在重连...');
// 可以实现重连逻辑
});
// 发送消息
port.postMessage({ type: 'subscribe', channel: 'notifications' });
4.5.3 在 MAIN_WORLD 注入脚本
当需要访问页面 JavaScript 变量时:
{
"content_scripts": [{
"matches": ["*://target-site.com/*"],
"js": ["content/main-world.js"],
"world": "MAIN",
"run_at": "document_start"
}]
}
// content/main-world.js (运行在 MAIN_WORLD)
// 此脚本可以访问页面的全局变量和函数
// 拦截页面函数
const originalFetch = window.fetch;
window.fetch = async function (...args) {
console.log('Fetch 被拦截:', args[0]);
const response = await originalFetch.apply(this, args);
// 通知 Content Script
window.postMessage({
type: 'FROM_MAIN_WORLD',
url: args[0],
status: response.status
}, '*');
return response;
};
// 通知 Content Script 页面已加载
window.__EXTENSION_READY = true;
// content/isolated.js (运行在 ISOLATED_WORLD - 默认)
// 监听 MAIN_WORLD 脚本的消息
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data.type !== 'FROM_MAIN_WORLD') return;
// 转发到 Service Worker
chrome.runtime.sendMessage({
type: 'PAGE_EVENT',
data: event.data
});
});
4.6 生命周期管理
4.6.1 Content Script 与页面同生命周期
// Content Script 在页面刷新或导航时会被重新注入
// 但 SPA(单页应用)中页面不会刷新,需要监听 URL 变化
// 方案一:监听 popstate
window.addEventListener('popstate', () => {
console.log('URL 变化:', window.location.href);
handleNavigation();
});
// 方案二:使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
// 检测页面主要内容是否变化
if (mutation.type === 'childList') {
handleDOMChange();
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// 方案三:拦截 History API
const originalPushState = history.pushState;
history.pushState = function (...args) {
originalPushState.apply(this, args);
console.log('pushState:', args[2]);
handleNavigation();
};
const originalReplaceState = history.replaceState;
history.replaceState = function (...args) {
originalReplaceState.apply(this, args);
console.log('replaceState:', args[2]);
handleNavigation();
};
4.7 业务场景
场景一:网页翻译增强
// content/translator.js
class PageTranslator {
constructor() {
this.translatedNodes = new WeakSet();
this.observer = null;
}
async start() {
// 获取页面主要文本节点
this.translateVisibleContent();
// 监听动态加载的内容
this.observer = new MutationObserver((mutations) => {
this.translateVisibleContent();
});
this.observer.observe(document.body, {
childList: true,
subtree: true
});
}
async translateVisibleContent() {
const textNodes = this.getTextNodes(document.body);
const untranslated = textNodes.filter(
node => !this.translatedNodes.has(node)
);
if (untranslated.length === 0) return;
const texts = untranslated.map(n => n.textContent.trim());
const result = await chrome.runtime.sendMessage({
type: 'TRANSLATE',
texts,
targetLang: 'zh-CN'
});
if (result.translations) {
untranslated.forEach((node, i) => {
if (result.translations[i]) {
node.textContent = result.translations[i];
this.translatedNodes.add(node);
}
});
}
}
getTextNodes(element) {
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
const text = node.textContent.trim();
if (text.length < 3) return NodeFilter.FILTER_REJECT;
if (node.parentElement?.tagName === 'SCRIPT') return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
}
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
return nodes;
}
}
// 启动翻译
const translator = new PageTranslator();
translator.start();
场景二:页面数据提取
// content/data-extractor.js
function extractProductInfo() {
const product = {
title: document.querySelector('h1.product-title')?.textContent?.trim(),
price: document.querySelector('.price')?.textContent?.trim(),
rating: document.querySelector('.rating')?.getAttribute('data-score'),
images: [...document.querySelectorAll('.product-images img')]
.map(img => img.src),
url: window.location.href,
extractedAt: Date.now()
};
return product;
}
// 监听 Service Worker 的提取请求
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'EXTRACT_DATA') {
const data = extractProductInfo();
sendResponse({ success: true, data });
}
return true;
});