强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Chrome 扩展开发完全指南 / 第 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 只能访问 runtimestoragei18n 等安全 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">&times;</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">&times;</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;
});

4.8 扩展阅读