我如何開發出一個 Chrome 圖片下載擴充功能

這是一個從右鍵存圖的小煩惱出發,跟朋友一起把一個 Chrome 擴充功能從第一行程式碼做到準備上架的故事。技術上最有意思的部分,是那些看起來簡單、實際動手才發現完全不簡單的地方。

一切都始於一個微小的煩惱

你有沒有過這樣的經歷:在瀏覽 Instagram 或 Pinterest 時看到一張美麗的圖片,想要保存下來,卻發現需要右鍵、選擇「另存圖片」、等待彈窗、選擇路徑… 整個過程繁瑣到讓人失去耐心?

這正是我去年在整理設計靈感時遇到的問題。作為一個開發者,我的第一反應是:「一定有更好的方法。」

當時我正在為一個設計專案收集素材,需要從各個網站下載大量圖片。每次右鍵保存的操作讓我倍感煩躁,特別是當我發現下載的圖片解析度經常不是最高的版本時。

「為什麼不能像手機 App 那樣,長按就能保存圖片呢?」我想著。

從想法到第一行程式碼

週末的下午,我決定動手解決這個問題。最初的想法很簡單:做一個 Chrome 擴充功能,讓用戶可以通過懸停和點擊來快速保存圖片。

我把這個想法分享給了我的朋友 Felix Falkenberg(@ffalkenberg),一位 ML Engineer 和 DevOps 專家。作為數字游牧者的他對效率工具有著敏銳的嗅覺,立刻就認識到了這個想法的潛力。

「這確實是個痛點,」Felix 說,「我們可以一起做這個專案,正好我們都沒有上架過 Chrome 擴充功能的經驗,這算是個不錯的學習練習。」

Chrome 擴充功能我其實寫過幾個,但都停在練習的層次,寫完自己用兩天就冰起來,沒有一個真的走到上架。這次不一樣的地方在於,我們一開始就打算把它做成一個給別人用的東西。

而當我們開始深入研究時,才發現這個「簡單」的想法背後藏著不少麻煩。

第一個挑戰:如何檢測滑鼠懸停

滑鼠移到 Instagram 圖片上,右下角浮現一鍵下載按鈕

最終成品的效果是這樣:滑到任何圖片上,右下角就浮現一顆下載鈕,點一下直接存檔。但要讓這顆按鈕穩定出現,比想像中麻煩得多。

1
2
3
4
5
6
// 我的第一次嘗試:天真的方法
document.addEventListener('mouseover', function(e) {
if (e.target.tagName === 'IMG') {
showDownloadButton(e.target);
}
});

看起來簡單對吧?但實際測試時問題接踵而來:

  • 按鈕閃爍不停
  • 效能問題導致頁面卡頓
  • 有些圖片檢測不到
  • 按鈕位置不正確

這讓我意識到,做好一個看似簡單的功能需要考慮的細節遠比想像中複雜。

深入兔子洞:事件優化

經過幾天的研究和測試,我學到了事件委派和防抖的重要性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 優化後的版本
let hoverTimeout;
let currentImage = null;

document.addEventListener('mouseover', function(e) {
if (e.target.tagName === 'IMG' && e.target !== currentImage) {
clearTimeout(hoverTimeout);
hoverTimeout = setTimeout(() => {
showDownloadButton(e.target);
currentImage = e.target;
}, 200); // 200ms 延遲避免閃爍
}
});

document.addEventListener('mouseout', function(e) {
if (e.target.tagName === 'IMG') {
clearTimeout(hoverTimeout);
setTimeout(() => {
hideDownloadButton();
}, 300); // 給用戶時間移動到按鈕上
}
});

最大的挑戰:找到最高解析度的圖片

當基本功能運作後,我們發現了真正的技術挑戰:很多網站顯示的是縮圖,但用戶想要的是原始大小的圖片。這個看似簡單的需求,實際上是整個專案最複雜的部分。

Google Images 的謎題

在 Google 圖片搜尋結果上懸停,同樣浮現 ClickSave 的下載按鈕

Google Images 是我們遇到的最大挑戰。表面上看到的圖片元素,其 src 屬性指向的只是一個低解析度的預覽圖,而真正的高解析度圖片 URL 被深深埋藏在複雜的 DOM 結構和 data 屬性中。

我花了整整一週時間研究 Google Images 的頁面結構,像偵探一樣追蹤每個可能的線索:

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
// Google Images 高解析度檢測:經過無數次試錯後的最終方案
function getGoogleImagesHighRes(img) {
// 方法 1: 檢查各種可能的 data 屬性
const dataUrl = img.getAttribute('data-src') ||
img.getAttribute('data-original-src') ||
img.getAttribute('data-iurl');

// 方法 2: 爬取父元素的 data-ri 屬性(Google的中繼資料)
const parent = img.closest('[data-ri]');
if (parent) {
try {
const metadata = JSON.parse(parent.getAttribute('data-ri'));
// 'ou' 是 original URL 的縮寫
if (metadata.ou) return metadata.ou;
} catch (e) {
// JSON 解析失敗,繼續嘗試其他方法
}
}

// 方法 3: 分析 srcset 找最高解析度
if (img.srcset) {
const srcsetUrls = parseSrcset(img.srcset);
return srcsetUrls[srcsetUrls.length - 1].url;
}

// 方法 4: 嘗試從 URL 參數中獲取原始圖片
if (img.src.includes('googleusercontent.com')) {
return img.src.replace(/=s\d+/, ''); // 移除尺寸限制參數
}

return img.src; // 最後的回退方案
}

每個網站都是一個新謎題

接下來我們發現,每個主流網站都有自己的圖片存儲邏輯:

  • Instagram:高解析度圖片藏在 data-* 屬性中,但屬性名會變化
  • Twitter:需要修改 URL 參數來獲取大圖版本(:large vs :small
  • Pinterest:原始圖片 URL 隱藏在複雜的 JSON 結構中
  • Facebook:多層嵌套的圖片版本,需要遞歸查找

我們需要為每個網站開發專門的解析邏輯:

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
class ImageResolver {
constructor() {
this.siteHandlers = {
'instagram.com': this.handleInstagram,
'twitter.com': this.handleTwitter,
'x.com': this.handleTwitter, // Twitter 改名後的域名
'images.google.com': this.handleGoogleImages,
'pinterest.com': this.handlePinterest,
'facebook.com': this.handleFacebook
};
}

async resolveHighResUrl(img) {
const hostname = window.location.hostname;
const handler = this.siteHandlers[hostname];

if (handler) {
try {
const result = await handler(img);
if (result && result !== img.src) return result;
} catch (error) {
console.warn(`Handler failed for ${hostname}:`, error);
}
}

// 通用回退策略:嘗試常見的高解析度模式
return this.genericResolve(img);
}

handleTwitter(img) {
if (img.src.includes('pbs.twimg.com')) {
// 將 :small 或其他尺寸替換為 :large
return img.src.replace(/:(small|medium|thumb)/, ':large');
}
return img.src;
}
}

意想不到的用戶體驗挑戰

技術問題解決後,我們開始面對各種意想不到的用戶體驗問題。

可拖曳按鈕的定位難題

最初我們把下載按鈕固定在圖片的右上角,但很快發現問題:

  • 在小圖片上按鈕過於突兀
  • 有時會遮擋重要內容(如水印、文字)
  • 不同網站的 CSS 會影響按鈕顯示

我們決定讓按鈕可拖曳,但這帶來了新的技術挑戰:如何確保按鈕始終保持在圖片邊界內?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 限制按鈕在圖片邊界內的拖曳邏輯
function constrainButtonPosition(button, image, newX, newY) {
const imgRect = image.getBoundingClientRect();
const btnRect = button.getBoundingClientRect();

// 計算有效拖曳範圍
const minX = 0;
const minY = 0;
const maxX = imgRect.width - btnRect.width;
const maxY = imgRect.height - btnRect.height;

// 限制在邊界內
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));

return { x: constrainedX, y: constrainedY };
}

裝飾性圖片的過濾問題

測試過程中我們發現了一個意想不到的問題:很多網站都有用來裝飾的小圖片,比如:

  • 1x1 像素的追蹤圖片
  • 用於背景裝飾的重複圖案
  • 載入中的佔位符圖片
  • CSS 背景圖片

這些圖片上出現下載按鈕既沒用又影響體驗。

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
// 智慧圖片過濾系統
function shouldShowDownloadButton(img) {
// 過濾太小的圖片(可能是裝飾性的)
if (img.width < 50 || img.height < 50) return false;

// 過濾明顯的追蹤圖片
if (img.width === 1 && img.height === 1) return false;

// 過濾常見的佔位符和圖標
const src = img.src.toLowerCase();
const suspiciousPatterns = [
'placeholder', 'loading', 'spinner', 'icon-',
'logo-small', 'avatar-default', '1x1', 'pixel'
];

if (suspiciousPatterns.some(pattern => src.includes(pattern))) {
return false;
}

// 檢查是否為 CSS 背景圖片
const computedStyle = window.getComputedStyle(img);
if (computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden') {
return false;
}

// 檢查圖片的長寬比是否異常(可能是分隔線等裝飾元素)
const aspectRatio = img.width / img.height;
if (aspectRatio > 20 || aspectRatio < 0.05) return false;

return true;
}

視覺反饋的重要性

我添加了細緻的動畫和狀態指示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.download-button {
opacity: 0;
transform: scale(0.8);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}

.download-button.show {
opacity: 1;
transform: scale(1);
}

.download-button:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}

這些微小的細節讓整個體驗變得流暢和愉悅。

朋友的反饋打開了第二個方向

當我們把第一個版本分享給朋友時,收到的反饋讓我們重新思考產品定位。

「這個很棒!但是我經常需要下載整個頁面的所有圖片,能不能添加批次下載功能?」

這個建議打開了我們的思路。Felix 和我意識到,這不僅僅是一個單張圖片下載工具,而是一個完整的圖片管理解決方案。

「批次下載涉及複雜的並發控制和錯誤處理,」Felix 提醒我,「我們需要仔細設計這個功能的架構。」

批次下載的技術挑戰

ClickSave 的側邊面板:把整頁圖片列成網格,勾選要的再一次下載

實現批次下載看起來簡單,但實際上涉及複雜的非同步處理和錯誤處理。我們需要考慮:

  • 避免同時發起太多下載請求(會被瀏覽器限制)
  • 處理部分下載失敗的情況
  • 給用戶提供進度反饋
  • 避免下載重複圖片
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
class BatchDownloader {
async downloadAll(images) {
const results = [];
const concurrency = 3; // 限制並發數量

for (let i = 0; i < images.length; i += concurrency) {
const batch = images.slice(i, i + concurrency);
const promises = batch.map(img => this.downloadSingle(img));

try {
const batchResults = await Promise.allSettled(promises);
results.push(...batchResults);

// 更新進度
this.updateProgress(i + batch.length, images.length);

// 避免過於頻繁的請求
await this.delay(500);
} catch (error) {
console.error('Batch download error:', error);
}
}

return results;
}
}

技術債務與重構

隨著功能增加,程式碼開始變得複雜和難以維護,我們決定重構:

1
2
3
4
5
6
7
8
9
// 重構前:一個巨大的文件,500+ 行
// 重構後:模塊化架構
const ClickSave = {
core: new CoreModule(),
ui: new UIModule(),
downloader: new DownloadModule(),
settings: new SettingsModule(),
analytics: new AnalyticsModule()
};

重構的動機很實際:每支援一個新網站就要加一個 handler,如果每次都要在一個 500 行的檔案裡找位置,這個專案撐不到第十個網站。模組拆開之後,加新網站的成本從「讀懂整個檔案」降到「實作一個介面」。

回頭看,真正難的是什麼

寫這種工具,難的不是任何單一技術點,是每個網站都在對抗你。Google Images 的高解析度 URL 藏在會變的 data 屬性裡,Twitter 靠 URL 參數區分尺寸,Pinterest 埋在 JSON 深處,而且這些結構隨時會改。你寫的不是一個功能,是一場跟各大網站前端團隊的長期軍備競賽。這也是為什麼市面上同類工具那麼多、好用的那麼少:這種程式碼會腐爛,因為對面一直在動。

另一個體會是分工的價值。Felix 從 ML 和 DevOps 的角度提問,我從架構的角度回答,很多設計是在互相挑刺的過程中變好的,批次下載的並發控制就是他先質疑「瀏覽器會不會直接掐死你」才有的。兩個人都是第一次把擴充功能推向上架,但這種互補讓大部分彎路在動手前就被說掉了。

下一步

上架審核走完了,ClickSave 現在就掛在 Chrome Web Store 上。之後想做的方向有幾個:Felix 在探索圖片的 AI 處理(放大、去背這類),跨瀏覽器支援(Firefox 和 Safari),還有設定的跨裝置同步。哪些真的會做,等累積到真實的使用數據再決定,現在排優先級都是猜。

寫在最後

我電腦裡躺著好幾個寫完就冰起來的擴充功能,每一個當初都學到了點東西,然後就沒有然後了。這次寫到一半我大概明白了差別在哪:練習專案只要寫到「我懂了」,產品要寫到「別人不用懂」。前者是 happy path 加上一點好奇心,後者是 1x1 的追蹤像素、會變的 data 屬性、二十種長寬比詭異的裝飾圖,和一個你永遠料不到會怎麼拖動那顆按鈕的使用者。

這中間的距離,不真的走到上架那一步,是感覺不到的。

如果你的硬碟裡也有幾個這樣的專案,挑一個把它推過那條線看看,跟寫一個新的完全是兩種運動。


專案相關連結

如果你也在做類似的工具,歡迎在留言區聊聊你踩過的坑。