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 函数签名
typescript
export const captureScreen = (
onSuccess: (dataUrl: string, message: string) => void,
onError: (message: string) => void
) => {
// 实现逻辑...
};
参数说明:
onSuccess: 成功回调函数,接收base64图片数据和成功消息onError: 失败回调函数,接收错误消息
2.1.2 状态变量
typescript
// 用于管理截图流程的状态
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()
typescript
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;
}
};
清理时机:
- 用户按ESC取消
- 选择超时(30秒)
- 脚本注入失败
- 收到取消/完成消息
2.2.2 强制停止选择 forceStopSelection()
typescript
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键监听
typescript
globalKeyHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isCompleted) {
isCompleted = true; // 标记为已完成
cleanup(); // 清理资源
forceStopSelection(); // 强制停止内容脚本中的选择
onError("截图已取消"); // 调用错误回调
}
};
window.addEventListener('keydown', globalKeyHandler, true);
三、截图流程实现
3.1 初始化阶段
3.1.1 获取当前标签页
typescript
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 检查特殊页面
typescript
// 检查是否是浏览器内置页面
if (
tabUrl &&
(tabUrl.startsWith("chrome://") || // Chrome特殊页面
tabUrl.startsWith("edge://") || // Edge特殊页面
tabUrl.startsWith("about:")) // Firefox特殊页面
) {
cleanup();
onError("无法在特殊页面上截屏");
return;
}
3.2 消息处理器
typescript
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 超时控制
typescript
// 设置30秒超时
timeoutId = window.setTimeout(() => {
if (isCompleted) return;
console.log('Screenshot timeout');
isCompleted = true;
cleanup();
forceStopSelection();
onError("截图选择超时,请重试");
}, 30000);
四、内容脚本注入
4.1 注入选择功能
typescript
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 变量定义
typescript
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 清理函数
typescript
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 创建选择界面
typescript
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 鼠标按下事件
typescript
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 键盘事件处理
typescript
keydownHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
stopSelectionMode();
}
};
document.addEventListener('keydown', keydownHandler);
五、图片裁剪模块
5.1 裁剪函数 cropImage()
typescript
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屏幕需要缩放计算
解决方案:
typescript
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 类别 | 核心方法/属性 | 关键作用 |
|---|---|---|
| Tabs | query, sendMessage, captureVisibleTab, Tab.id, Tab.windowId, Tab.url |
管理标签页、获取上下文信息、执行截图 |
| Scripting | executeScript |
注入页面脚本,实现前端交互(如选择覆盖层) |
| Runtime | onMessage, sendMessage, lastError |
扩展内部通信、错误捕获 |