Chrome扩展截图主要逻辑实现

本文档详细描述了一个浏览器扩展程序中的截图功能实现,该功能允许用户通过鼠标拖拽选择网页上的特定区域,并将该区域截取为图片(base64格式)。

一、整体架构

1.1 系统组件

┌─────────────────────────────────────────┐
│          用户界面 (Popup/Page)          │
│        (触发截图,接收结果)             │
└───────────────────┬─────────────────────┘
                    │ 调用 captureScreen()
┌───────────────────▼─────────────────────┐
│          后台脚本 (Background)          │
│  (核心协调逻辑,当前代码所在位置)       │
└───────────────────┬─────────────────────┘
                    │ 注入脚本 + 消息通信
┌───────────────────▼─────────────────────┐
│         内容脚本 (Content Script)       │
│  (在网页中实现选择界面和交互)           │
└─────────────────────────────────────────┘

1.2 文件结构

screenshot-extension/
├── manifest.json          # 扩展配置文件
├── background.ts          # 后台脚本(本文件)
├── content.ts            # 内容脚本(注入部分)
├── popup.html            # 用户界面
├── popup.js              # 界面逻辑
└── styles.css            # 样式文件

二、核心功能模块

2.1 截图主函数 captureScreen()

2.1.1 函数签名
export const captureScreen = (
onSuccess: (dataUrl: string, message: string) => void,
onError: (message: string) => void
) => {
// 实现逻辑...
};

参数说明:

  • onSuccess: 成功回调函数,接收base64图片数据和成功消息
  • onError: 失败回调函数,接收错误消息
2.1.2 状态变量
// 用于管理截图流程的状态
let isCompleted = false;           // 完成标志,防止重复调用
let currentTabId: number | null = null;      // 当前标签页ID
let globalKeyHandler: ((e: KeyboardEvent) => void) | null = null;  // 全局键盘事件处理器
let messageHandler: ((msg: any, sender: any, response: any) => boolean | void) | null = null; // 消息监听器
let timeoutId: number | null = null;         // 超时计时器ID

2.2 资源管理模块

2.2.1 统一清理函数 cleanup()
const cleanup = () => {
// 移除全局ESC键监听器
if (globalKeyHandler) {
window.removeEventListener('keydown', globalKeyHandler, true);
globalKeyHandler = null;
}
// 移除消息监听器
if (messageHandler) {
chrome.runtime.onMessage.removeListener(messageHandler);
messageHandler = null;
}
// 清除超时计时器
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
};

清理时机:

  1. 用户按ESC取消
  2. 选择超时(30秒)
  3. 脚本注入失败
  4. 收到取消/完成消息
2.2.2 强制停止选择 forceStopSelection()
const forceStopSelection = () => {
if (currentTabId) {
chrome.tabs.sendMessage(
currentTabId,
{ action: "forceStopScreenshot" },
() => {
// 忽略可能出现的错误(如标签页关闭)
if (chrome.runtime.lastError) {
console.log('Force stop failed:', chrome.runtime.lastError.message);
}
}
);
}
};

2.3 事件监听模块

2.3.1 全局ESC键监听
globalKeyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isCompleted) {
isCompleted = true;      // 标记为已完成
cleanup();               // 清理资源
forceStopSelection();    // 强制停止内容脚本中的选择
onError("截图已取消");   // 调用错误回调
}
};
window.addEventListener('keydown', globalKeyHandler, true);

三、截图流程实现

3.1 初始化阶段

3.1.1 获取当前标签页
chrome.tabs.query(
{ active: true, currentWindow: true },
(tabs: chrome.tabs.Tab[]) => {
// 检查标签页是否有效
if (!tabs[0]?.id || !tabs[0].windowId) {
cleanup();
onError("无法获取当前标签页");
return;
}
const tabId = tabs[0].id;           // 标签页ID
const windowId = tabs[0].windowId;  // 窗口ID
const tabUrl = tabs[0].url;         // 标签页URL
currentTabId = tabId;               // 保存当前标签页ID
}
);
3.1.2 检查特殊页面
// 检查是否是浏览器内置页面
if (
tabUrl &&
(tabUrl.startsWith("chrome://") ||   // Chrome特殊页面
tabUrl.startsWith("edge://") ||     // Edge特殊页面
tabUrl.startsWith("about:"))        // Firefox特殊页面
) {
cleanup();
onError("无法在特殊页面上截屏");
return;
}

3.2 消息处理器

messageHandler = (
message: any,
_sender: chrome.runtime.MessageSender,
_sendResponse: (response?: unknown) => void
) => {
// 防重复检查
if (isCompleted) {
return false;
}
// 处理选择完成
if (message.action === 'screenshotSelectionResult' && message.rect) {
isCompleted = true;
cleanup();
// 截取整个标签页
chrome.tabs.captureVisibleTab(
windowId,
{ format: "png", quality: 100 },
(dataUrl: string) => {
if (chrome.runtime.lastError) {
onError(chrome.runtime.lastError.message || "截屏失败");
return;
}
if (dataUrl) {
// 裁剪图片
cropImage(dataUrl, message.rect, (croppedDataUrl) => {
if (croppedDataUrl) {
onSuccess(croppedDataUrl, "截屏成功!");
} else {
onError("图片裁剪失败");
}
});
}
}
);
}
// 处理用户取消
if (message.action === 'screenshotSelectionCancelled') {
isCompleted = true;
cleanup();
onError("截图已取消");
}
return false;
};

3.3 超时控制

// 设置30秒超时
timeoutId = window.setTimeout(() => {
if (isCompleted) return;
console.log('Screenshot timeout');
isCompleted = true;
cleanup();
forceStopSelection();
onError("截图选择超时,请重试");
}, 30000);

四、内容脚本注入

4.1 注入选择功能

chrome.scripting.executeScript({
target: { tabId },       // 目标标签页
func: startSelectionFunction, // 要执行的函数
}).then(() => {
console.log("Selection script injected"); // 日志记录注入成功
// 如果已经完成,强制停止选择
if (isCompleted) {
forceStopSelection();
}
}).catch((error) => {
// 如果已经完成,直接返回
if (isCompleted) return;
console.error("Failed to inject script:", error); // 记录注入失败
isCompleted = true;  // 标记为已完成
cleanup();           // 清理资源
onError("无法启动选择模式,请刷新页面后重试"); // 调用错误回调
});

4.2 选择功能实现 startSelectionFunction()

4.2.1 变量定义
let selectionOverlay: HTMLDivElement | null = null;   // 选择覆盖层
let isSelecting = false;                              // 是否正在选择
let startX = 0;                                       // 鼠标起始X坐标
let startY = 0;                                       // 鼠标起始Y坐标
let selectionBox: HTMLDivElement | null = null;       // 选择框元素
let keydownHandler: ((e: KeyboardEvent) => void) | null = null; // 键盘事件处理器
let mousedownHandler: ((e: MouseEvent) => void) | null = null; // 鼠标按下处理器
let messageListener: ((msg: any) => void) | null = null; // 消息监听器
4.2.2 清理函数
const cleanupAll = () => {
// 移除所有事件监听器
if (keydownHandler) document.removeEventListener('keydown', keydownHandler);
if (messageListener) chrome.runtime.onMessage.removeListener(messageListener);
if (selectionOverlay) {
if (mousedownHandler) selectionOverlay.removeEventListener('mousedown', mousedownHandler);
selectionOverlay.remove();
}
// 重置变量
selectionBox = null;
isSelecting = false;
};
4.2.3 创建选择界面
const startSelectionMode = () => {
// 创建覆盖层
selectionOverlay = document.createElement('div');
selectionOverlay.id = 'rcc-selection-overlay';
selectionOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
z-index: 2147483646;
cursor: crosshair;
`;
// 创建选择框
selectionBox = document.createElement('div');
selectionBox.style.cssText = `
position: absolute;
border: 2px dashed #1890ff;
background: rgba(24, 144, 255, 0.1);
pointer-events: none;
display: none;
`;
// 创建提示文字
const hint = document.createElement('div');
hint.textContent = '拖拽选择截图区域,按 ESC 取消';
hint.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 2147483647;
pointer-events: none;
`;
selectionOverlay.appendChild(selectionBox);
selectionOverlay.appendChild(hint);
document.body.appendChild(selectionOverlay);
};

4.3 鼠标事件处理

4.3.1 鼠标按下事件
mousedownHandler = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
startX = e.clientX;
startY = e.clientY;
// 显示选择框
if (selectionBox) {
selectionBox.style.display = 'block';
selectionBox.style.left = startX + 'px';
selectionBox.style.top = startY + 'px';
selectionBox.style.width = '0px';
selectionBox.style.height = '0px';
}
// 动态更新选择框
const handleMouseMove = (e: MouseEvent) => {
const currentX = e.clientX;
const currentY = e.clientY;
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
if (selectionBox) {
selectionBox.style.left = left + 'px';
selectionBox.style.top = top + 'px';
selectionBox.style.width = width + 'px';
selectionBox.style.height = height + 'px';
}
};
// 选择完成
const handleMouseUp = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const currentX = e.clientX;
const currentY = e.clientY;
const left = Math.min(startX, currentX);
const top = Math.min(startY, currentY);
const width = Math.abs(currentX - startX);
const height = Math.abs(currentY - startY);
//起始点 (100, 100) → 结束点 (200, 150)
//left = min(100, 200) = 100
//top = min(100, 150) = 100
//width = |200 - 100| = 100
//height = |150 - 100| = 50
// 最小选择区域检查
if (width < 10 || height < 10) {
stopSelectionMode();
return;
}
// 收集选择区域信息
const rect = {
x: left,
y: top,
width,
height,
viewportWidth: window.innerWidth,//视口宽度
viewportHeight: window.innerHeight,//视口高度
devicePixelRatio: window.devicePixelRatio || 1 //设备像素比
};
cleanupAll();
// 发送结果到后台
chrome.runtime.sendMessage({
action: 'screenshotSelectionResult',
rect
});
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp, { once: true });
};

4.4 键盘事件处理

keydownHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
stopSelectionMode();
}
};
document.addEventListener('keydown', keydownHandler);

五、图片裁剪模块

5.1 裁剪函数 cropImage()

function cropImage(
dataUrl: string,
rect: {
x: number;
y: number;
width: number;
height: number;
viewportWidth?: number;
viewportHeight?: number;
devicePixelRatio?: number;
},
callback: (croppedDataUrl: string | null) => void
) {
const img = new Image();
img.onload = () => {
// 计算缩放比例
const viewportWidth = rect.viewportWidth || window.innerWidth;
const viewportHeight = rect.viewportHeight || window.innerHeight;
const devicePixelRatio = rect.devicePixelRatio || (img.width / viewportWidth);
const scale = devicePixelRatio;
// 创建画布
const canvas = document.createElement("canvas");
canvas.width = rect.width;
canvas.height = rect.height;
const ctx = canvas.getContext("2d");
if (!ctx) {
callback(null);
return;
}
// 设置高质量渲染
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = "high";
// 裁剪并绘制
ctx.drawImage(
img,                        // 源图像
rect.x * scale,             // 源图像裁剪起点X(考虑设备像素比)
rect.y * scale,             // 源图像裁剪起点Y(考虑设备像素比)
rect.width * scale,         // 源图像裁剪宽度(考虑设备像素比)
rect.height * scale,        // 源图像裁剪高度(考虑设备像素比)
0,                          // 画布绘制起点X
0,                          // 画布绘制起点Y
rect.width,                 // 画布绘制宽度(原始选择宽度)
rect.height                 // 画布绘制高度(原始选择高度)
);
// 转换为base64
const croppedDataUrl = canvas.toDataURL("image/png");
callback(croppedDataUrl);
};
img.onerror = () => {
callback(null);
};
img.src = dataUrl;
}

5.2 像素比处理说明

问题:

  • 网页中的坐标是逻辑像素
  • 截图得到的是物理像素
  • 高DPI屏幕需要缩放计算

解决方案:

const devicePixelRatio = window.devicePixelRatio || 1;
const scale = devicePixelRatio;
// 实际裁剪时乘以缩放比例
rect.x * scale,      // 考虑高DPI屏幕
rect.y * scale,
rect.width * scale,
rect.height * scale

六、数据流分析

6.1 完整流程图

用户操作 → 系统响应 → 数据流转 → 最终结果
   ↓          ↓          ↓          ↓
点击截图 → 获取标签页 → 检查权限 → 准备环境
   ↓          ↓          ↓          ↓
选择区域 → 注入脚本 → 创建界面 → 等待交互
   ↓          ↓          ↓          ↓
拖拽框选 → 事件处理 → 计算坐标 → 发送消息
   ↓          ↓          ↓          ↓
确认选择 → 截全屏 → 裁剪图片 → 返回数据
   ↓          ↓          ↓          ↓
显示结果 → 清理资源 → 状态重置 → 流程结束

6.2 消息通信流程

┌──────────┐     ESC取消     ┌──────────┐
│  用户    ├────────────────►│ 全局监听 │
└────┬─────┘                 └────┬─────┘
     │                            │
     │ 点击截图                   │ 传递消息
     ▼                            ▼
┌──────────┐    注入脚本     ┌──────────┐
│  Popup   ├────────────────►│  后台    │
└────┬─────┘                 └────┬─────┘
     │                            │
     │ 返回结果                   │ 发送消息
     ▼                            ▼
┌──────────┐    选择完成     ┌──────────┐
│  结果    │◄────────────────┤ 内容脚本 │
└──────────┘                 └──────────┘

6.3 错误处理流程

┌──────────────┐
│  开始截图    │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 检查标签页   │───失败───► 清理资源,返回错误
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 检查特殊页面 │───失败───► 清理资源,返回错误
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 注入内容脚本 │───失败───► 清理资源,返回错误
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 用户选择阶段 │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 可能的失败: │
│ 1. ESC取消   │
│ 2. 选择太小  │
│ 3. 超时      │
│ 4. 截图失败  │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│  清理资源    │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ 返回错误信息 │
└──────────────┘

附录:完整代码索引

文件/函数作用位置
captureScreen()主入口函数background.ts
cleanup()资源清理background.ts: cleanup()
forceStopSelection()强制停止background.ts: forceStopSelection()
startSelectionFunction()选择功能注入的内容脚本
cropImage()图片裁剪background.ts: cropImage()
messageHandler消息处理background.ts: messageHandler
globalKeyHandler全局ESC监听background.ts: globalKeyHandler

本实现提供了一个完整、健壮的截图功能解决方案,具有良好的可扩展性和可维护性。

七、截图涉及到的ChromeAPI

1、Tabs API(标签页管理)

用于查询和操作浏览器标签页,是截图功能的核心依赖。

API 方法/属性用途
chrome.tabs.query查询符合条件的标签页(如当前活动标签页)。
chrome.tabs.sendMessage向指定标签页的内容脚本发送消息(用于强制停止选择模式)。
chrome.tabs.captureVisibleTab截取指定窗口的可见区域(生成完整页面的截图数据)。
tabs.Tab.id标签页的唯一标识(用于后续操作的目标标识)。
tabs.Tab.windowId标签页所属窗口的 ID(用于截取窗口级别的截图)。
tabs.Tab.url标签页的 URL(用于检查是否为特殊页面,如 chrome://)。
2、Scripting API(脚本注入)

用于将自定义 JavaScript 代码注入到网页上下文中,实现页面内的交互逻辑(如选择覆盖层)。

API 方法/属性用途
chrome.scripting.executeScript向指定标签页注入并执行 JavaScript 函数(用于创建选择覆盖层)。
3、Runtime API(运行时通信)

用于扩展内部(后台脚本、内容脚本、弹出页)之间的消息传递和生命周期管理。

API 方法/属性用途
chrome.runtime.onMessage监听来自其他扩展组件(如内容脚本)的消息。
chrome.runtime.sendMessage向其他扩展组件发送消息(如内容脚本通知后台选择完成或取消)。
chrome.runtime.lastError获取最近一次 API 调用的错误信息(用于错误处理)。
4、总结表格
API 类别核心方法/属性关键作用
Tabsquery, sendMessage, captureVisibleTab, Tab.id, Tab.windowId, Tab.url管理标签页、获取上下文信息、执行截图
ScriptingexecuteScript注入页面脚本,实现前端交互(如选择覆盖层)
RuntimeonMessage, sendMessage, lastError扩展内部通信、错误捕获