Chrome外掛:切圖壓縮工具

雲音樂技術團隊發表於2023-04-18
作者:lkl

前言

在前端專案開發中,尤其是活動專案,大量使用未壓縮的圖片必將會影響頁面開啟速度,降低使用者體驗。因此,我們需要對下載的切圖進行壓縮處理。常見的圖片壓縮工具有 TinyPNGPP鴨,但這兩款軟體是收費的,並且不支援定製化。使用這些軟體壓縮圖片的過程更是複雜繁瑣,如果有一款工具可以在下載切圖時就幫助我們壓縮圖片,或直接提供壓縮後的圖片地址,那將會大大提高當前的工作效率。本文將介紹實現這樣一個切圖壓縮工具的關鍵技術點。

獲取原圖片

常用的設計稿軟體有兩個,藍湖和 Figma。這裡用藍湖作例簡述如何獲取原圖。首先藍湖是一個網頁,需要用 Chrome 開啟。這時候就必須祭出 F12 這個大殺器了,直接除錯原始碼來定位下載操作的走向。會發現在最終下載圖片的程式碼塊中有以下一段。

image-20230210162923928

這裡的 t 變數就是一個 a 標籤,透過呼叫 dispatchEvent 方法來觸發 click 事件進行檔案下載。知道了下載方式,下一步就是如何去攔截它。直接上原型大法,把 dispatchEvent 方法給重寫以便拿到 a 標籤例項,來獲取要下載的檔案資訊。

const originDispatchEvent = EventTarget.prototype.dispatchEvent;
Object.defineProperty(HTMLAnchorElement.prototype, 'dispatchEvent', {
    writable: true,
    configurable: true,
    enumerable: true,
    value: function (event) {
        const nodeName = this.nodeName;
        const href = this.href;
        const filename = this.download;
        if (nodeName === 'A' && filename && /^blob:/.test(href)) {
              console.warn(filename, href);
            return false;
        }
        return originDispatchEvent.apply(this, [event]);
    }
});

把以上程式碼輸入到控制檯,點選下載圖片就會看到這樣的日誌輸出。至此便能拿到下載的切圖資料了。

image-20230210163156375

外掛注入指令碼

有了可以攔截下載資料的指令碼,那如何把它利用起來,實現自動注入呢?這就必須使用到 Chrome 外掛了。可以使用其提供的 scripting_api 實現。

function inject(eventName) {
    const originDispatchEvent: Function = EventTarget.prototype.dispatchEvent;
    Object.defineProperty(HTMLAnchorElement.prototype, 'dispatchEvent', {
        writable: true,
        configurable: true,
        enumerable: true,
        value: function (event) {
            const nodeName = this.nodeName;
            const href = this.href;
            const filename = this.download;
            if (nodeName === 'A' && filename && /^blob:/.test(href)) {
                // ...
                return false;
            }
            return originDispatchEvent.apply(this, [event]);
        }
    });
}

// 在網頁重新整理後,注入攔截指令碼
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
    if (tab.status === 'complete' && /^https?/.test(tab.url || '')) {
          // 在指定的tab頁下執行函式
        chrome.scripting.executeScript({
            func: inject,
            target: { tabId },
            world: 'MAIN',
            args: [SITE_DOWN_IMAGE]
        }).catch((err) => {
              console.error(err);
        });
    }
});

需要注意的是注入時配置 world: 'MAIN' 是必須的,否則注入的指令碼將在隔離環境中執行,就無法訪問頁面上的 JS 環境了。

攔截到下載的切圖資料後,可以透過 postMessage 傳送給外掛的內容指令碼。下面就是在內容指令碼中來實現圖片壓縮的能力。

壓縮能力實現

那要如何實現一個可以在網頁中使用的壓縮工具呢?先看下現有的壓縮工具 TinyPNG 和 PP鴨,一個是網頁一個是本地 App。本地 App 肯定是不行了,TinyPNG 理論上是沒問題的,其有提供壓縮介面。但由於它是收費的且需要上傳,也沒法直接使用。經過查詢資料,瞭解到業界開源的 PNG 圖片壓縮工具有 pngquantadvpngoxipng 等,經過嘗試,選取 pngquantadvpng 來進行 PNG 圖片的壓縮。

  • pngquant

這是一個命令列工具,用於 PNG 圖片的有失真壓縮。其轉換後的圖片可以降低高達 70% 的大小,並且保留了完整的 alpha 透明度,生成的影像與所有 Web 瀏覽器和作業系統相容。具有以下特點:

  1. 使用向量量化演算法的組合,生成高質量的調色盤
  2. 與標準的 Floyd-Steinberg 相比,獨特的自適應抖動演算法為影像增加了更少的噪音
引用自 pngquant 官網簡介
  • advpng

這是一個 PNG 圖片的無失真壓縮庫,透過移除 PNG 圖片中的輔助塊,整合 IDAT 資料塊和使用 7zip Deflate 進行更高比例的壓縮實現。

那如何把他們用到 Web 上呢?那必須要是 WebAssembly 呀。首先把這兩個庫整合到一起,使用 Emscripten 編譯成 Wasm,提供介面以便前端呼叫。這個過程中你可能會遇到這些問題:

  1. 記憶體檔案轉換

由於 pngquant 是一個命令列工具,其壓縮操作都是基於磁碟檔案讀寫的,但是在 WebAssembly 傳參時需要的是位元組陣列,都是在記憶體中的。就需要對原始碼進行一定的改造。主要是將 fopen 替換為 fmemopenopen_memstream 實現在記憶體資料中進行 FILE 的操作。如讀取檔案修改。

FILE *infile;
if ((infile = fopen(filename, "rb")) == NULL) {
  fprintf(stderr, "  error: cannot open %s for reading\n", filename);
  return READ_ERROR;
}
// 修改為如下
if ((infile = fmemopen(file_buffer, file_size, "rb")) == NULL) {
  return READ_ERROR;
}
  1. 內建 libpng 和 zlib 包

在透過 emcmake 構建時,會發現無法使用系統安裝的 libpngzlib 動態庫。需要將這兩個庫的原始碼下載到專案中,一起進行編譯。

...
file(GLOB PNG_SOURCE libpng/*.c)
file(GLOB ZLIB_SOURCE zlib/*.c)
...
add_library(${PROJECT_NAME} STATIC pngquant.c rwpng.c ${PNG_SOURCE} ${ZLIB_SOURCE} ${QUA_SOURCE} ${ADVPNG_SOURCE})

在內容指令碼中,就能直接透過這個 Wasm 模組來實現圖片的壓縮了。在藍湖中,內容指令碼直接使用 Wasm 時並不會有任何阻力,但是放到 figma 中就會被內容安全策略所禁止。因為 figma 在響應時就直接設定了內容安全策略,這時候就要藉助 Chrome 外掛提供的 sandbox 能力,透過在頁面中新增 iFrame 頁面來實現。

到此該工具的基本能力就都已經實現了,可以把要下載的切圖資料拿到,經過內容指令碼壓縮後再下載。為了更方便使用切圖,下一步就是要把壓縮後的圖片上傳到 CDN 並提供 URL 來進行復制。

切圖上傳

到這裡就簡單起來了,以上已經可以在內容指令碼中獲取到壓縮後的切圖資料,下面就是把它上傳到一個合適的圖床平臺了。如使用七牛雲,唯一可能會遇到的問題是上傳介面不支援跨域。這是就需要利用 Chrome 外掛的主機許可權,把用的的介面配置上。

// manifest.json
{
  ...
  "host_permissions": [
    "https://rsf-z0.qiniuapi.com/*",
    "https://*.qiniu.com/*"
  ]
  ...
}

關於如何直接在前端上傳檔案到七牛,可以參考七牛開發者文件。這裡唯一麻煩的可能是上傳憑證的生成,七牛官方推薦的是在服務端生成憑證,前端 SDK 就沒直接提供生成方法。可以參考生成演算法在前端實現。

import { urlSafeBase64Encode } from 'qiniu-js';
import HmacSHA1 from 'crypto-js/hmac-sha1';
import encBase64 from 'crypto-js/enc-base64';

function getUploadToken(bucket, secretKey) {
  const returnBody = {
    key: '$(key)',
    hash: '$(etag)',
    name: '$(fname)',
    size: '$(fsize)',
    width: '$(imageInfo.width)',
    height: '$(imageInfo.height)'
  };
  const putPolicy = JSON.stringify({
    scope: bucket,
    deadline,
    returnBody: JSON.stringify(returnBody)
  });

  const encodedPolicy = urlSafeBase64Encode(putPolicy);

  const hash = HmacSHA1(encodedPolicy, secretKey);
  const encodedSigned = hash.toString(encBase64);

  return this.accessKey + ':' + safe64(encodedSigned) + ':' + encodedPolicy;
}

切圖管理

如果你使用的 CDN 有現成的列表介面,那直接呼叫就行。但如七牛並沒有提供好用的列表介面,為了管理上傳的圖片列表,就需要在前端儲存切圖列表,這時你就要選擇一個合適的儲存。你能想到的可能會有 localStorageCookieIndexedDB,可能還會有 chrome.stroage。考慮到切圖列表的性質,其需要較大的儲存空間並且要能方便的進行分頁查詢,那這樣使用 IndexedDB 作為儲存將會是一個更好的選擇。

下面就來用 IndexedDB 實現切圖管理的功能,方便檢視已上傳的切圖列表。首先明確兩個介面定義:

  • 新增已上傳的切圖
  • 分頁查詢已上傳的切圖
// 儲存的資料結構
interface ImageEntry {
  name: string
  width: number
  height: number
  size: number
  cdnUrl: string
  uploadTime: number
}

// 介面定義
interface IImageDB {
  add(...images: ImageEntry[]): Promise<void>
  findPage(page: number, limit: number): Promise<ImageEntry>
}

藉助開源庫 dexie 實現起來也很簡單

import Dexie from 'dexie';

export default class ImageDB {
    constructor(name = 'zimagedb') {
        let db = new Dexie(name);
        db.version(1).stores({
            images: '++_id,name,cdnUrl'
        });
        this.db = db;
    }

    async add(...datas) {
        for (const item of datas) {
            await this.db.images.add(item);
        }
    }

    async findPage(page = 0, limit = 10) {
        const offset = page * limit;
        return this.db.images.limit(limit).offset(offset).toArray();
    }
}

為了能讓 IndexedDB 儲存唯一,你應該把它放在 Chrome 擴充套件的背景頁內,再透過訊息通訊在內容指令碼中使用。

// 如background.js監聽訊息
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  if (message.type === 'getImages') {
    db.findPage(message.page).then(images => {
      sendResponse({ success: true, images });
    }).catch(err => {
      ...
    });
    return true;
  }
});

// 在content.js中查詢切圖列表
function getImages(page = 0) {
  return new Promise((resolve, reject) => {
    chrome.runtime.sendMessage({type: 'getImages', page}, response => {
      if (response?.success) {
        resolve(response.images);
      } else {
        reject(...);
      }
    });
  });
}

總結

以上簡述了該切圖壓縮工具的一些關鍵技術點。涉及到有 Chrome 擴充套件開發,JavaScript 原型的利用,WebAssembly 開發,IndexedDB 儲存等。尤其是 WebAssembly 和 IndexedDB 這讓本需依賴服務端才能實現的一些功能在前端也能很好的完成,給前端帶來了更多的可能性。

參考資料

本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章