最近用cursor快速写了两个谷歌插件:

一个是图片放大器,在浏览网页的时候,鼠标悬浮可以放大图片,查看更多细节。(github)

alt text

一个是[图片收集器]Treasure Chest - Image Collector,在浏览网页的时候,看到好看的、觉得有价值的图片,可以收藏在一个画廊里,后面可以一键下载。(github)

alt text

最终写出来的效果我觉得很不错,ai考虑得很全面。

图片方法器

比如图片方法器这个插件

配置管理

方案上设计了10个设置属性,考虑了图片、图片链接和视频链接的预览,以及图片放大的最大宽度、最小的宽度、放大倍数和悬浮延迟时间。可以说全面考虑了主要功能、性能、边界和用户体验,这种灵活的配置系统,可以让用户更好地自定义

1
2
3
4
5
6
7
8
9
10
11
12
let settings = {
enlargeOnHover: true,
enlargeImages: true,
showImagesFromLinks: true,
showVideosFromLinks: true,
zoomFactor: 2,
hoverDelay: 100,
displayPosition: 'cursor',
minImageSize: 50,
maxEnlargedSize: 1500,
excludedDomains: []
};

这些设置通过Chrome的存储API保存,确保用户的偏好能够在不同会话中保持一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
chrome.storage.sync.get('imageEnlargerSettings', (data) => {
if (data.imageEnlargerSettings) {
settings = { ...settings, ...data.imageEnlargerSettings };
}
initializeEnlarger();
});


function initializeEnlarger() {
if (!settings.enlargeOnHover) return;

// Add event listeners for images
if (settings.enlargeImages) {
document.querySelectorAll('img').forEach((img) => {
if (shouldProcessImage(img)) {
img.addEventListener('mouseenter', handleMouseEnter);
img.addEventListener('mouseleave', handleMouseLeave);
img.addEventListener('mousemove', handleMouseMove);
}
});
}
}

动态内容监听

网页内容通常是动态加载的,扩展使用MutationObserver API来监听DOM变化,确保新添加的图片和链接也能被正确处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
processNewElement(node);
}
});
}
});
});

observer.observe(document.body, {
childList: true,
subtree: true
});

事件处理机制

扩展实现了精细的事件处理机制,包括鼠标进入、离开、移动页面缩放事件:

为滚动事件添加passive: true标志,提高滚动性能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

img.addEventListener('mouseenter', handleMouseEnter);
img.addEventListener('mouseleave', handleMouseLeave);
img.addEventListener('mousemove', handleMouseMove);


// Listen for window resize to reposition overlay if needed
window.addEventListener('resize', () => {
if (overlay.style.display !== 'none' && lastEvent) {
positionOverlaySmartly(lastEvent);
}
});

// Listen for scroll events to reposition overlay
window.addEventListener(
'scroll',
() => {
if (overlay.style.display !== 'none' && lastEvent) {
positionOverlaySmartly(lastEvent);
}
},
{ passive: true }
);

用户体验

用了三个变量来提高用户体验:

  • 用来实现延迟显示,避免了用户在页面上快速移动鼠标时触发不必要的放大效果;
  • 当用户调整浏览器窗口大小或滚动页面时,重新计算放大图片的位置;
1
2
3
let hoverTimer = null;
let currentTarget = null;
let lastEvent = null;

而且我很好奇为什么要设计currentTargetlastEvent两个对象,用一个不就行了吗?仔细思考下,觉得AI牛逼,还为了体现”关注点分离”的设计原则:

  • lastEvent负责事件上下文信息

  • currentTarget负责元素引用

智能定位算法

扩展的一个亮点是其智能定位算法,它能根据当前视口大小目标元素位置,自动选择最佳的显示位置:

算法按优先级考虑了四个方向(下、右、左、上),确保放大内容尽可能完整地显示在视口内,同时不遮挡原始内容。

为了提供最佳的视觉体验,扩展实现了智能的图片尺寸调整算法,在保持原始宽高比的同时,确保放大后的图片不会超出设定的最大尺寸:

还用了requestAnimationFrame进行布局计算,避免布局抖动,实现性能优化
图片加载采用异步方式,不阻塞主线程。

requestAnimationFrame是一个浏览器API,它告诉浏览器你希望执行一个动画,并请求浏览器在下一次重绘之前调用指定的函数来更新动画。

其中callback是一个函数,将在浏览器下一次重绘前被调用。浏览器通常以每秒60次(60fps)的频率进行重绘,与显示器的刷新率同步,确保DOM测量和更新在视觉上同步且高效。

为什么使用requestAnimationFrame?

  1. 与浏览器渲染周期同步
    requestAnimationFrame的最大优势在于它与浏览器的渲染周期同步。浏览器在一个渲染周期中通常执行以下步骤:
  • 处理JavaScript
  • 计算样式
  • 布局计算
  • 绘制

通过使用requestAnimationFrame,代码可以确保视觉更新在最合适的时机执行,避免在一个渲染周期内多次触发布局计算。

  1. 避免布局抖动(Layout Thrashing)
    在Image Enlarger中,代码需要先读取DOM属性(如overlay.offsetWidth),然后设置样式(如overlay.style.left)。如果这些操作不在正确的时机执行,可能导致布局抖动:
1
2
3
4
// 不好的做法:可能导致布局抖动
const width = element.offsetWidth; // 读取 - 强制布局
element.style.width = '100px'; // 写入 - 标记布局为无效
const height = element.offsetHeight; // 读取 - 再次强制布局

使用requestAnimationFrame可以将所有读取操作和写入操作分组,减少布局重计算:

1
2
3
4
5
6
7
8
9
requestAnimationFrame(() => {
// 所有读取操作集中在一起
const width = element.offsetWidth;
const height = element.offsetHeight;

// 所有写入操作集中在一起
element.style.width = '100px';
element.style.height = '200px';
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
function positionOverlaySmartly(event) {
// Get viewport dimensions
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;

// Get scroll position
const scrollX = window.scrollX;
const scrollY = window.scrollY;

// Get target element dimensions and position
const targetRect = currentTarget
? currentTarget.getBoundingClientRect()
: null;

// Wait for overlay to have dimensions
requestAnimationFrame(() => {
// Get overlay dimensions
const overlayWidth = overlay.offsetWidth;
const overlayHeight = overlay.offsetHeight;

// Default position variables
let left, top;

// Smart positioning logic
if (targetRect) {
// Calculate available space in different directions
const spaceBelow = viewportHeight - (targetRect.bottom + 10);
const spaceRight = viewportWidth - (targetRect.right + 10);
const spaceLeft = targetRect.left - 10;
const spaceAbove = targetRect.top - 10;

// PRIORITY 1: Position below the target (preferred position)
if (spaceBelow >= overlayHeight) {
top = targetRect.bottom + 10;
// Center horizontally with the target
left = targetRect.left + targetRect.width / 2 - overlayWidth / 2;
}
// PRIORITY 2: Position to the right
else if (spaceRight >= overlayWidth) {
left = targetRect.right + 10;
// Center vertically with the target
top = targetRect.top + targetRect.height / 2 - overlayHeight / 2;
}
// PRIORITY 3: Position to the left
else if (spaceLeft >= overlayWidth) {
left = targetRect.left - overlayWidth - 10;
// Center vertically with the target
top = targetRect.top + targetRect.height / 2 - overlayHeight / 2;
}
// PRIORITY 4: Position above
else if (spaceAbove >= overlayHeight) {
top = targetRect.top - overlayHeight - 10;
// Center horizontally with the target
left = targetRect.left + targetRect.width / 2 - overlayWidth / 2;
}
// PRIORITY 5: If no good position, use a modified version of below
// that shows as much of the image as possible
else {
top = Math.max(10, viewportHeight - overlayHeight - 10);
// Center horizontally with the target if possible
left = targetRect.left + targetRect.width / 2 - overlayWidth / 2;
}
} else {
// Fallback to cursor position if no target
const x = event.clientX;
const y = event.clientY;
left = x + 20;
top = y + 20;
}

// Final bounds checking to ensure overlay stays within viewport
if (left + overlayWidth > viewportWidth) {
left = Math.max(10, viewportWidth - overlayWidth - 10);
}
if (left < 0) {
left = 10;
}
if (top + overlayHeight > viewportHeight) {
top = Math.max(10, viewportHeight - overlayHeight - 10);
}
if (top < 0) {
top = 10;
}

// Apply the calculated position (add scroll offset to convert to absolute position)
overlay.style.left = `${left + scrollX}px`;
overlay.style.top = `${top + scrollY}px`;
overlay.style.transform = 'none'; // Reset any transform
});
}

设计模式

观察者模式 (Observer Pattern)

观察者模式是扩展中最显著的设计模式之一,体现在多个层面:

DOM事件监听
1
2
3
img.addEventListener('mouseenter', handleMouseEnter);
img.addEventListener('mouseleave', handleMouseLeave);
img.addEventListener('mousemove', handleMouseMove);

这是观察者模式的典型应用,其中:

  • DOM元素作为”被观察者”(Subject)
  • 事件处理函数作为”观察者”(Observer)
  • 事件系统作为通知机制
MutationObserver监听DOM变化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === 1) {
processNewElement(node);
}
});
}
});
});

observer.observe(document.body, {
childList: true,
subtree: true
});

这是观察者模式的现代实现,专门用于监听DOM树的变化,确保扩展能够处理动态加载的内容。

消息通信
1
2
3
4
5
6
chrome.runtime.onMessage.addListener((message) => {
if (message.action === 'settingsUpdated') {
settings = message.settings;
initializeEnlarger();
}
});

扩展的不同组件之间通过消息系统进行通信,也是观察者模式的一种应用。

策略模式 (Strategy Pattern)

策略模式允许在运行时选择算法的行为,在扩展中主要体现在定位算法的实现上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function positionOverlaySmartly(event) {
if (settings.displayPosition === 'fixed') {
positionOverlayFixed();
return;
}

// 智能定位逻辑
// ...
}

function positionOverlayFixed() {
// 固定定位逻辑
overlay.style.left = '50%';
overlay.style.top = '50%';
overlay.style.transform = 'translate(-50%, -50%)';
}

状态管理模式 (State Management Pattern)

扩展实现了一个简单但有效的状态管理模式,通过全局变量维护应用状态:

1
2
3
4
let settings = { /* 默认设置 */ };
let hoverTimer = null;
let currentTarget = null;
let lastEvent = null;

这些变量共同构成了应用的状态,各个函数通过读写这些变量来协调行为。虽然不如Redux等现代状态管理库复杂,但对于扩展的需求而言已经足够高效。

配置对象模式 (Configuration Object Pattern)

扩展使用配置对象模式来管理设置:

1
2
3
4
5
6
7
8
9
10
11
12
let settings = {
enlargeOnHover: true,
enlargeImages: true,
showImagesFromLinks: true,
showVideosFromLinks: true,
zoomFactor: 2,
hoverDelay: 100,
displayPosition: 'cursor',
minImageSize: 50,
maxEnlargedSize: 1500,
excludedDomains: []
};

这种模式使设置管理更加灵活,便于扩展和修改。

延迟加载模式 (Lazy Loading Pattern)

扩展实现了延迟加载模式,仅在需要时才加载和显示内容:

1
2
3
4
5
6
7
8
function handleMouseEnter(event) {
// ...
hoverTimer = setTimeout(() => {
if (event.target.tagName === 'IMG') {
showEnlargedImage(event.target, event);
}
}, settings.hoverDelay);
}

通过setTimeout实现的延迟加载确保只有当用户真正停留在元素上时才触发内容加载,避免了不必要的资源消耗。

适配器模式 (Adapter Pattern)

扩展在处理不同类型的媒体内容时使用了适配器模式的思想:

1
2
3
4
5
6
7
function isImageLink(url) {
return /\.(jpe?g|png|gif|webp|svg|bmp)(\?.*)?$/i.test(url);
}

function isVideoLink(url) {
return /\.(mp4|webm|ogg)(\?.*)?$/i.test(url);
}

这些函数作为适配器,将通用的URL转换为特定的媒体类型判断,统一了处理接口。

请求-动画帧模式 (RequestAnimationFrame Pattern)

扩展使用requestAnimationFrame来优化视觉更新:

1
2
3
4
5
6
7
8
requestAnimationFrame(() => {
// 获取overlay尺寸
const overlayWidth = overlay.offsetWidth;
const overlayHeight = overlay.offsetHeight;

// 计算和应用位置
// ...
});

图片收集器

从技术实现角度,该扩展主要利用了Chrome扩展API中的存储功能、标签页交互以及下载管理等能力。

数据存储设计

扩展使用Chrome的storage.local API进行数据持久化存储。这种存储方式相比于传统的localStorage有几个明显优势:
数据在不同浏览器会话间保持同步
存储容量更大,适合存储图片元数据
与扩展的生命周期绑定,提供更好的数据隔离

1
2
3
4
chrome.storage.local.get({ collectedImages: [] }, function (data) {
const collectedImages = data.collectedImages;
// 处理收集的图片数据
});

每个收集的图片对象包含以下关键信息:

  • 图片源URL (src)
  • 来源页面URL (pageUrl)
  • 页面标题 (pageTitle)
  • 收集日期时间戳 (date)

这种数据结构设计使得扩展能够提供丰富的元数据信息,增强用户体验。

交互体验优化

为了提升用户体验,扩展实现了以下交互优化:

  1. 引导角色与语音气泡:通过随机展示提示信息,帮助用户了解功能
  2. 删除确认对话框:防止用户误操作,提供二次确认机制
  3. 操作反馈:通过语音气泡提供操作结果反馈

导出功能实现

扩展的一个亮点功能是支持将收集的图片导出为ZIP压缩包。这一功能使用了JSZip库,实现了以下步骤:

  1. 获取所有收集的图片数据
  2. 使用fetch API获取每个图片的二进制数据
  3. 创建ZIP文件结构,包含图片文件夹和元数据JSON文件
  4. 生成并下载ZIP文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const zip = new JSZip();
const imgFolder = zip.folder('treasure-chest-images');

// 下载并添加图片到ZIP
const fetchPromises = collectedImages.map(async (image, index) => {
const response = await fetch(image.src);
const blob = await response.blob();
// 添加到ZIP
});

// 添加元数据
zip.file('metadata.json', JSON.stringify(metadata, null, 2));

// 生成并下载ZIP
const zipBlob = await zip.generateAsync({ type: 'blob' });

这种实现方式解决了批量下载图片的需求,同时通过元数据文件保留了图片的来源信息,便于用户后续管理和溯源。

跨域图片处理

在实现导出功能时,一个主要挑战是处理跨域图片资源。由于浏览器的同源策略限制,直接获取跨域图片可能会失败。扩展通过以下方式解决:

  1. 使用fetch API尝试获取图片,这在扩展环境中有更高的权限
  2. 对获取失败的图片进行错误处理,确保整体导出流程不会中断
  3. 在元数据中记录成功导出的图片信息

异步操作管理

导出功能涉及多个异步操作,包括获取存储数据、下载多个图片和生成ZIP文件。扩展使用了现代JavaScript的Promise和async/await语法优雅地处理这些异步操作.

这种实现方式不仅提高了代码可读性,还确保了异步操作的正确顺序和错误处理。

性能优化考量

为了确保扩展在处理大量图片时仍能保持良好性能,实现了以下优化:

  1. 延迟加载:图片卡片采用按需创建的方式,减少初始加载时间
  2. 事件委托:通过在父元素上统一处理事件,减少事件监听器数量
  3. 资源释放:在完成ZIP下载后,及时释放Blob URL资源
1
URL.revokeObjectURL(url);