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

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         │  │
│  └─────────────┘  └────────────────┘  │
└───────────────────────────────────────┘

隔离世界的含义

特性说明
共享 DOMContent 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_startDOM 构建之前,CSS 之后注入自定义 CSS、拦截页面脚本
document_endDOM 完成,图片/框架加载前修改 DOM 结构
document_idledocument_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 扩展阅读