這是一個從右鍵存圖的小煩惱出發,跟朋友一起把一個 Chrome 擴充功能從第一行程式碼做到準備上架的故事。技術上最有意思的部分,是那些看起來簡單、實際動手才發現完全不簡單的地方。
一切都始於一個微小的煩惱
你有沒有過這樣的經歷:在瀏覽 Instagram 或 Pinterest 時看到一張美麗的圖片,想要保存下來,卻發現需要右鍵、選擇「另存圖片」、等待彈窗、選擇路徑… 整個過程繁瑣到讓人失去耐心?
這正是我去年在整理設計靈感時遇到的問題。作為一個開發者,我的第一反應是:「一定有更好的方法。」
當時我正在為一個設計專案收集素材,需要從各個網站下載大量圖片。每次右鍵保存的操作讓我倍感煩躁,特別是當我發現下載的圖片解析度經常不是最高的版本時。
「為什麼不能像手機 App 那樣,長按就能保存圖片呢?」我想著。
從想法到第一行程式碼
週末的下午,我決定動手解決這個問題。最初的想法很簡單:做一個 Chrome 擴充功能,讓用戶可以通過懸停和點擊來快速保存圖片。
我把這個想法分享給了我的朋友 Felix Falkenberg(@ffalkenberg),一位 ML Engineer 和 DevOps 專家。作為數字游牧者的他對效率工具有著敏銳的嗅覺,立刻就認識到了這個想法的潛力。
「這確實是個痛點,」Felix 說,「我們可以一起做這個專案,正好我們都沒有上架過 Chrome 擴充功能的經驗,這算是個不錯的學習練習。」
Chrome 擴充功能我其實寫過幾個,但都停在練習的層次,寫完自己用兩天就冰起來,沒有一個真的走到上架。這次不一樣的地方在於,我們一開始就打算把它做成一個給別人用的東西。
而當我們開始深入研究時,才發現這個「簡單」的想法背後藏著不少麻煩。
第一個挑戰:如何檢測滑鼠懸停

最終成品的效果是這樣:滑到任何圖片上,右下角就浮現一顆下載鈕,點一下直接存檔。但要讓這顆按鈕穩定出現,比想像中麻煩得多。
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); } });
document.addEventListener('mouseout', function(e) { if (e.target.tagName === 'IMG') { clearTimeout(hoverTimeout); setTimeout(() => { hideDownloadButton(); }, 300); } });
|
最大的挑戰:找到最高解析度的圖片
當基本功能運作後,我們發現了真正的技術挑戰:很多網站顯示的是縮圖,但用戶想要的是原始大小的圖片。這個看似簡單的需求,實際上是整個專案最複雜的部分。
Google Images 的謎題

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
| function getGoogleImagesHighRes(img) { const dataUrl = img.getAttribute('data-src') || img.getAttribute('data-original-src') || img.getAttribute('data-iurl'); const parent = img.closest('[data-ri]'); if (parent) { try { const metadata = JSON.parse(parent.getAttribute('data-ri')); if (metadata.ou) return metadata.ou; } catch (e) { } } if (img.srcset) { const srcsetUrls = parseSrcset(img.srcset); return srcsetUrls[srcsetUrls.length - 1].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, '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')) { 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; } 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 提醒我,「我們需要仔細設計這個功能的架構。」
批次下載的技術挑戰

實現批次下載看起來簡單,但實際上涉及複雜的非同步處理和錯誤處理。我們需要考慮:
- 避免同時發起太多下載請求(會被瀏覽器限制)
- 處理部分下載失敗的情況
- 給用戶提供進度反饋
- 避免下載重複圖片
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
|
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 屬性、二十種長寬比詭異的裝飾圖,和一個你永遠料不到會怎麼拖動那顆按鈕的使用者。
這中間的距離,不真的走到上架那一步,是感覺不到的。
如果你的硬碟裡也有幾個這樣的專案,挑一個把它推過那條線看看,跟寫一個新的完全是兩種運動。
專案相關連結:
如果你也在做類似的工具,歡迎在留言區聊聊你踩過的坑。