[Web] Web Worker 解密圖片
/ 10 min read
Updated:Table of Contents
事件觸發
相簿頁面需要同時載入多張圖片,而這些圖片是加密檔案
每張圖都要經過「抓取 → 解密 → 轉成 blob → 更新 UI」
當圖片數量一多,這些工作會阻塞主執行緒,造成 UI 卡頓、滑動不順、瀑布流排版延遲
避免卡頓,把解密與轉檔移到 Web Worker,讓主執行緒只負責渲染與互動
為什麼用 Web Worker
- 解密與 base64→blob 轉換屬於 CPU 密集工作,不適合放在主執行緒
- Worker 可在背景處理,主執行緒能保持 UI 流暢
- 圖片可「逐張回傳」,讓使用者先看到已完成的部分
整體
這份範例不依賴任何專案結構,只有幾個檔案:
AlbumPhotosDemo.tsx:UI + Worker 協調image-worker.js:Worker 端,負責抓檔、解密、轉成 blobdemo-assets/manifest.json:加密檔清單(含 key/iv)demo-assets/*.bin:加密後圖片
主執行緒:只負責渲染 :
AlbumPhotosDemo.tsx
- 建立 Worker
- 載入 manifest → 產生 pics → 建 placeholder,啟動解密流程
- 收到
blob結果後更新 UI,逐張回傳 + 同步更新 progress - 卸載時終止 Worker、回收 blob URL
核心概念:主執行緒不做重工作,只做 UI
// 建立 Worker 檔案的 URL(new URL("./image-worker.js", import.meta.url)),確保 worker 可以被 bundler 正確打包const workerUrl = useMemo(() => new URL("./image-worker.js", import.meta.url), []);
// 第一個 useEffect:載入 manifest、轉成 pics、建立 placeholder、初始化進度// - fetch(manifestUrl) 抓 JSON// - 轉換資料格式 mapManifestToPics// - setPics + setPhotos + setProgress(0)// - 使用 active 避免已卸載後還更新 stateuseEffect(() => { let active = true; const loadManifest = async () => { const response = await fetch(manifestUrl); if (!response.ok) { throw new Error(`Failed to load manifest: ${response.status}`); } const manifest = await response.json(); if (!active) return; const mapped = mapManifestToPics(manifest); setPics(mapped); setPhotos(buildPlaceholderList(mapped.length)); setProgress(0); };
loadManifest().catch((error) => { console.error(error); if (active) { setPics([]); setPhotos(buildPlaceholderList(0)); } });
return () => { active = false; }; }, [manifestUrl]);
// mapManifestToPics:將 manifest 轉成 pics 陣列,統一每張圖的 iv/key/contentType const mapManifestToPics = (manifest) => { const sharedKey = manifest?.key; const sharedIv = manifest?.iv; const items = Array.isArray(manifest?.items) ? manifest.items : [];
return items.map((item, index) => ({ order: item.order ?? index + 1, url: item.encrypted, iv: item.iv ?? sharedIv, key: item.key ?? sharedKey, contentType: item.contentType || "image/jpeg", }));};
// 第二個 useEffect:啟動 Worker、收結果、清理資源// - new Worker(...) 建立 worker// - postMessage({ action: "decode", data: { pics } }) 開始解密// - 監聽 worker.onmessage// - action === "blob":更新單張圖片(逐張 UI 更新)// - action === "progress":更新進度// - 清理:worker.terminate() + URL.revokeObjectURLuseEffect(() => { if (!pics.length || typeof Worker === "undefined") return undefined;
const worker = new Worker(workerUrl, { type: "module" });
worker.postMessage({ action: "decode", data: { pics } });
worker.onmessage = (event) => { const { action, result } = event.data; if (action === "blob") { createdUrls.current.push(result.url); setPhotos((prev) => prev.map((item) => (item.order === result.order ? result : item))); }
if (action === "progress") { setProgress(result.percentage); } };
return () => { worker.terminate(); createdUrls.current.forEach((url) => URL.revokeObjectURL(url)); createdUrls.current = []; };}, [pics, workerUrl]);Worker:負責解密
image-decrypt.worker.js
- 參數驗證(iv/key/path)
- 下載加密圖檔
- 解密
- base64 轉 blob
- 逐張回傳
Worker 端
- 動態批次處理:getBatchSize
- 解密流程:base64ToBytes + decryptImage + pkcs7Unpad
- 重試與錯誤分類:fetchWithRetry + ProcessingError + ERROR_TYPES (這段不會展示程式碼了,程式碼主要是為了穩定性,確保 Worker 不會因為單一圖片失敗而中斷整個流程)
- Worker 主流程:self.onmessage + schedule + processSingleImage
1) 動態批次處理
依 hardwareConcurrency,Worker 會根據硬體執行緒數調整批次計算 batch size,避免一次解太多導致 CPU 飆高
這裡取核心數的一半,並限制最大 6 筆,保持效能與穩定性
const getBatchSize = () => { const cores = typeof self !== "undefined" && self.navigator && Number.isInteger(self.navigator.hardwareConcurrency) ? self.navigator.hardwareConcurrency : 4; const dynamicSize = Math.max(1, Math.ceil(cores / 2)); return Math.min(dynamicSize, 6);};2) 解密流程
解密使用 AES-CBC
先把 key/iv 做 base64 還原成 bytes,再用 Web Crypto 解密,最後做 PKCS#7 去 padding,避免多餘填充影響轉檔
const base64ToBytes = (value) => { if (!value) return new Uint8Array(); const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); const remainder = normalized.length % 4; const padded = remainder === 0 ? normalized : normalized + "=".repeat(4 - remainder); const binary = self.atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i += 1) { bytes[i] = binary.charCodeAt(i); } return bytes;};
const pkcs7Unpad = (bytes) => { if (!bytes.length) return bytes; const padding = bytes[bytes.length - 1]; if (!padding || padding > 16) return bytes; for (let i = bytes.length - padding; i < bytes.length; i += 1) { if (bytes[i] !== padding) return bytes; } return bytes.slice(0, bytes.length - padding);};
const decryptImage = async (ivBase64, keyBase64, encryptedData) => { const keyBytes = base64ToBytes(keyBase64); const ivBytes = base64ToBytes(ivBase64);
const cryptoKey = await self.crypto.subtle.importKey( "raw", keyBytes, { name: "AES-CBC" }, false, ["decrypt"] );
const decrypted = await self.crypto.subtle.decrypt( { name: "AES-CBC", iv: ivBytes }, cryptoKey, encryptedData );
return pkcs7Unpad(new Uint8Array(decrypted));};3) Worker 主流程 + 逐張回傳
Worker 收到 decode 後依 batch size 控制併發
每張圖完成就回傳 blob + progress,主執行緒可逐張更新 UI,降低等待感
self.onmessage = async (event) => { const { action, data } = event.data; if (action === "cancel") { shouldCancel = true; cleanupUrls(); self.postMessage({ action: "cancelled", result: { message: "Processing cancelled" } }); return; } if (action === "cleanup") { cleanupUrls(data?.urls); self.postMessage({ action: "cleaned", result: { message: "URLs cleaned" } }); return; } if (action !== "decode") { self.postMessage({ action: "error", result: { error: "Unknown action" } }); return; } shouldCancel = false; batchSize = getBatchSize(); const pics = Array.isArray(data?.pics) ? data.pics : []; const total = pics.length; let completed = 0; const executing = new Set(); const schedule = async (pic) => { if (shouldCancel) return; const task = (async () => { // processSingleImage 包含:fetchWithRetry → decryptImage → 转 blob URL // fetchWithRetry 是用來抓取加密圖檔,包含重試機制與錯誤分類,這部分就不展示程式碼了 const result = await processSingleImage(pic); completed += 1; if (result) { self.postMessage({ action: "blob", result }); self.postMessage({ action: "progress", result: { completed, total, percentage: Math.round((completed / total) * 100) }, }); } return result; })(); executing.add(task); task.finally(() => executing.delete(task)); if (executing.size >= batchSize) { await Promise.race(executing); } }; for (const pic of pics) { if (shouldCancel) break; await schedule(pic); } await Promise.allSettled(executing); self.postMessage({ action: "complete", result: { total, completed, cancelled: shouldCancel }, });};主執行緒端的穩定性
AlbumPhotosDemo.tsx 內部做了:
- 收到
blob即時更新photos,減少等待感 - 針對舊的
blob URL做revoke Worker.terminate()確保卸載時資源釋放
實作效益
- UI 操作順暢,不會被解密阻塞
- 圖片可逐張顯示,使用者等待的感覺下降
為什麼還要做加密(即使前端最終會有 blob)
前端解密後確實會產生 blob,理論上仍然可以被爬蟲抓取,所以這種加密不是絕對防爬,而是提高門檻、控制存取、降低大規模濫用
加密的實際價值
- 避免直連與熱連結:原始檔案不直接暴露在公開 URL
- 保護原始素材:爬蟲拿不到未解密的原始圖,必須跑解密流程
- 權限控管:解密 key 可依會員/權限發放,未授權就無法解密
- 提升抓取成本:爬蟲需執行 JS、跑 Worker、處理 blob,成本變高
- 便於搭配短效 token:即使抓到 blob,也不容易大規模批量下載
做不到什麼
- 無法阻止已授權使用者截圖或抓 blob:只要能顯示,就一定有被抓取的可能
實務上常見的配套
- 短效簽名 URL + 授權驗證
- 分段載入 + 速率限制
- 動態/可追蹤水印
- 低畫質預覽,完整版需更嚴格授權
結論
前端加密不是 DRM (Digital Rights Management),但它的價值在於降低批量爬取成本、保護原始素材、強化授權控制,在產品層面,這樣的保護已經有實際效益