skip to content
BlogZzz

[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 端,負責抓檔、解密、轉成 blob
  • demo-assets/manifest.json:加密檔清單(含 key/iv)
  • demo-assets/*.bin:加密後圖片

主執行緒:只負責渲染 :

AlbumPhotosDemo.tsx

  1. 建立 Worker
  2. 載入 manifest → 產生 pics → 建 placeholder,啟動解密流程
  3. 收到 blob 結果後更新 UI,逐張回傳 + 同步更新 progress
  4. 卸載時終止 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 避免已卸載後還更新 state
useEffect(() => {
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.revokeObjectURL
useEffect(() => {
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 端

  1. 動態批次處理:getBatchSize
  2. 解密流程:base64ToBytes + decryptImage + pkcs7Unpad
  3. 重試與錯誤分類:fetchWithRetry + ProcessingError + ERROR_TYPES (這段不會展示程式碼了,程式碼主要是為了穩定性,確保 Worker 不會因為單一圖片失敗而中斷整個流程)
  4. 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 URLrevoke
  • Worker.terminate() 確保卸載時資源釋放

實作效益

  • UI 操作順暢,不會被解密阻塞
  • 圖片可逐張顯示,使用者等待的感覺下降

為什麼還要做加密(即使前端最終會有 blob)

前端解密後確實會產生 blob,理論上仍然可以被爬蟲抓取,所以這種加密不是絕對防爬,而是提高門檻、控制存取、降低大規模濫用

加密的實際價值

  • 避免直連與熱連結:原始檔案不直接暴露在公開 URL
  • 保護原始素材:爬蟲拿不到未解密的原始圖,必須跑解密流程
  • 權限控管:解密 key 可依會員/權限發放,未授權就無法解密
  • 提升抓取成本:爬蟲需執行 JS、跑 Worker、處理 blob,成本變高
  • 便於搭配短效 token:即使抓到 blob,也不容易大規模批量下載

做不到什麼

  • 無法阻止已授權使用者截圖或抓 blob:只要能顯示,就一定有被抓取的可能

實務上常見的配套

  1. 短效簽名 URL + 授權驗證
  2. 分段載入 + 速率限制
  3. 動態/可追蹤水印
  4. 低畫質預覽,完整版需更嚴格授權

結論

前端加密不是 DRM (Digital Rights Management),但它的價值在於降低批量爬取成本、保護原始素材、強化授權控制,在產品層面,這樣的保護已經有實際效益