嗯,手搓一個(gè)TinyPng壓縮圖片的WebpackPlugin也SoEasy啦

作者:JowayYoung
倉(cāng)庫(kù):Github、CodePen
博客:掘金思否、知乎簡(jiǎn)書(shū)、頭條、CSDN
公眾號(hào):IQ前端
聯(lián)系我:關(guān)注公眾號(hào)后有我的微信
特別聲明:原創(chuàng)不易,未經(jīng)授權(quán)不得對(duì)此文章進(jìn)行轉(zhuǎn)載或抄襲,否則按侵權(quán)處理,如需轉(zhuǎn)載或開(kāi)通公眾號(hào)白名單可聯(lián)系我,希望各位尊重原創(chuàng)的知識(shí)產(chǎn)權(quán)

前言

曾經(jīng)發(fā)表過(guò)一篇性能優(yōu)化的文章《前端性能優(yōu)化指南》,筆者總結(jié)了一些在項(xiàng)目開(kāi)發(fā)過(guò)程中使用過(guò)的性能優(yōu)化經(jīng)驗(yàn)。說(shuō)句真話,性能優(yōu)化可能在面試過(guò)程中會(huì)有用,實(shí)際在項(xiàng)目開(kāi)發(fā)過(guò)程中可能沒(méi)幾個(gè)同學(xué)會(huì)注意這些性能優(yōu)化的細(xì)節(jié)。

若經(jīng)常關(guān)注性能優(yōu)化的話題,可能會(huì)發(fā)現(xiàn)無(wú)論怎樣對(duì)代碼做最好的優(yōu)化也不及對(duì)一張圖片做一次壓縮好。所以壓縮圖片成了性能優(yōu)化里最常見(jiàn)的操作,不管是手動(dòng)壓縮圖片還是自動(dòng)壓縮圖片,在項(xiàng)目開(kāi)發(fā)過(guò)程中必須得有。

自動(dòng)壓縮圖片通常在webpack構(gòu)建項(xiàng)目時(shí)接入一些第三方Loader&Plugin來(lái)處理。打開(kāi)Github,搜素webpack image等關(guān)鍵字,Star最多還是image-webpack-loaderimagemin-webpack-plugin這兩個(gè)Loader&Plugin。很多同學(xué)可能都會(huì)選擇它們,方便快捷,簡(jiǎn)單易用,無(wú)腦接入。

可是,這兩個(gè)Loader&Plugin存在一些特別問(wèn)題,它們都是基于imagemin開(kāi)發(fā)的。imagemin的某些依賴托管在國(guó)外服務(wù)器,在npm i xxx安裝它們時(shí)會(huì)默認(rèn)走GitHub Releases的托管地址,若不是規(guī)范上網(wǎng),你們是不可能安裝得上的,即使規(guī)范上網(wǎng)也不一定安裝得上。所以筆者又刨根到底發(fā)表了一篇關(guān)于NPM鏡像處理的文章《聊聊NPM鏡像那些險(xiǎn)象環(huán)生的坑》,專門(mén)解決這些因?yàn)榫W(wǎng)絡(luò)環(huán)境而導(dǎo)致安裝失敗的問(wèn)題。除了這個(gè)安裝問(wèn)題,imagemin還存在另一個(gè)大問(wèn)題,就是壓縮質(zhì)感損失得比較嚴(yán)重,圖片體積越大越明顯,壓縮出來(lái)的圖片總有幾張是失真的,而且總體壓縮率不是很高。這樣在交付項(xiàng)目時(shí)有可能被細(xì)心的QA小姐姐抓個(gè)正著,怎么和設(shè)計(jì)圖對(duì)比起來(lái)不清晰啊!

工具

圖片壓縮工具

此時(shí)可能有些同學(xué)已轉(zhuǎn)戰(zhàn)到手動(dòng)壓縮圖片了。比較好用的圖片壓縮工具無(wú)非就是以下幾個(gè),若有更好用的工具麻煩在評(píng)論里補(bǔ)充喔!同時(shí)筆者也整理出它們的區(qū)別,供各位同學(xué)參考。

工具集合
工具 開(kāi)源 收費(fèi) API 免費(fèi)體驗(yàn)
QuickPicture ?? ?? ?? 可壓縮類(lèi)型較多,壓縮質(zhì)感較好,有體積限制,有數(shù)量限制
ShrinkMe ?? ?? ?? 可壓縮類(lèi)型較多,壓縮質(zhì)感一般,無(wú)數(shù)量限制,有體積限制
Squoosh ?? ?? ?? 可壓縮類(lèi)型較少,壓縮質(zhì)感一般,無(wú)數(shù)量限制,有體積限制
TinyJpg ?? ?? ?? 可壓縮類(lèi)型較少,壓縮質(zhì)感很好,有數(shù)量限制,有體積限制
TinyPng ?? ?? ?? 可壓縮類(lèi)型較少,壓縮質(zhì)感很好,有數(shù)量限制,有體積限制
Zhitu ?? ?? ?? 可壓縮類(lèi)型一般,壓縮質(zhì)感一般,有數(shù)量限制,有體積限制

從上述表格對(duì)比可看出,免費(fèi)體驗(yàn)都會(huì)存在體積限制,這個(gè)可理解,即使收費(fèi)也一樣,畢竟每個(gè)人都上傳單張10多M的圖片,哪個(gè)服務(wù)器能受得了。再來(lái)就是數(shù)量限制,一次只能上傳20張,好像有個(gè)規(guī)律,壓縮質(zhì)感好就限制數(shù)量,否則就不限制數(shù)量,當(dāng)然收費(fèi)后就沒(méi)有限制了。再來(lái)就是可壓縮類(lèi)型,圖片類(lèi)型一般是jpg、png、gifsvgwebp,gif壓縮后一般都會(huì)失真,svg通常用在矢量圖標(biāo)上很少用在場(chǎng)景圖片上,webp由于兼容性問(wèn)題很少被使用,故能壓縮jpgpng就足夠了。當(dāng)然壓縮質(zhì)感是最優(yōu)考慮,綜上所述,大部分同學(xué)都會(huì)選擇TinyJpgTinyPng,其實(shí)它倆就是兄弟,出自同一廠商。

在筆者公眾號(hào)的微信討論群里發(fā)起了一個(gè)簡(jiǎn)單的投票,最終還是TinyJpgTinyPng勝出。

工具投票
TinyJpg/TinyPng存在問(wèn)題
  • 上傳下載全靠手動(dòng)
  • 只能壓縮jpgpng
  • 每次只能壓縮20
  • 每張?bào)w積最大不能超過(guò)5M
  • 可視化處理信息不是特別齊全
TinyJpg/TinyPng壓縮原理

TinyJpg/TinyPng使用智能有損壓縮技術(shù)將圖片體積降低,選擇性地減少圖片中相似顏色,只需很少字節(jié)就能保存數(shù)據(jù)。對(duì)視覺(jué)影響幾乎不可見(jiàn),但是在文件體積上就有很大的差別。而使用到智能有損壓縮技術(shù)被稱為量化。

TinyJpg/TinyPng在壓縮png文件時(shí)效果更顯著。掃描圖片中相似顏色并將其合并,通過(guò)減少顏色數(shù)量將24位png文件轉(zhuǎn)換成體積更小的8位png文件,丟棄所有不必要的元數(shù)據(jù)。

大部分png文件都有50%~70%的壓縮率,即使視力再好也很難區(qū)分出來(lái)。使用優(yōu)化過(guò)的圖片可減少帶寬流量和加載時(shí)間,整個(gè)網(wǎng)站使用到的圖片經(jīng)TinyJpg/TinyPng壓縮一遍,其成效是再多的代碼優(yōu)化也無(wú)法追趕得上的。

熊貓
TinyJpg/TinyPng開(kāi)發(fā)API

查閱相關(guān)資料,發(fā)現(xiàn)TinyJpg/TinyPng暫時(shí)還未開(kāi)源其壓縮算法,不過(guò)提供了適合開(kāi)發(fā)者使用的API。有興趣的同學(xué)可到其開(kāi)發(fā)API文檔瞧瞧。

Node方面,TinyJpg/TinyPng官方提供了tinify作為壓縮圖片的核心JS庫(kù),使用很簡(jiǎn)單,看文檔吧??墒菗Q成開(kāi)發(fā)API還是逃不過(guò)收費(fèi),你是想包月呢還是免費(fèi)呢,想免費(fèi)的話就繼續(xù)往下看,土豪隨意!

圖片壓縮工具

實(shí)現(xiàn)

筆者也是經(jīng)常使用TinyJpg/TinyPng的程序猿,收費(fèi),那是不可能的??。尋找突破口,解決問(wèn)題,是作為一位程序猿最基本的素養(yǎng)。我們需明確什么問(wèn)題,需解決什么問(wèn)題。

分析

從上述得知,只需對(duì)TinyJpg/TinyPng原有功能改造成以下功能。

  • 上傳下載全自動(dòng)
  • 可壓縮jpgpng
  • 沒(méi)有數(shù)量限制
  • 存在體積限制,最大體積不能超過(guò)5M
  • 壓縮成功與否輸出詳細(xì)信息

自動(dòng)處理

對(duì)于前端開(kāi)發(fā)者來(lái)說(shuō),這種無(wú)腦的上傳下載操作必須得自動(dòng)化,省事省心省力。但是這個(gè)操作得結(jié)合webpack來(lái)處理,到底是開(kāi)發(fā)成Loader還是Plugin,后面再分析。不過(guò)細(xì)心的同學(xué)看標(biāo)題就知道用什么方式處理了。

壓縮類(lèi)型

gif壓縮后一般都會(huì)失真,svg通常用在矢量圖標(biāo)上很少用在場(chǎng)景圖片上,webp由于兼容性問(wèn)題很少被使用,故能壓縮jpgpng就足夠了。在過(guò)濾圖片時(shí),使用path模塊判斷文件類(lèi)型是否為jpgpng,是則繼續(xù)處理,否則不處理。

數(shù)量限制

數(shù)量限制當(dāng)然是不能存在的,萬(wàn)一項(xiàng)目里超過(guò)20張圖片,那不是得分批處理,這個(gè)不能有。對(duì)于這種無(wú)需登錄狀態(tài)就能處理一些用戶文件的網(wǎng)站,通常都會(huì)通過(guò)IP來(lái)限制用戶的操作次數(shù)。有些同學(xué)可能會(huì)說(shuō),刷新頁(yè)面不就行了嗎,每次壓縮20張圖片,再刷新再壓縮,萬(wàn)一有500張圖片呢,你就刷新25次嗎,這樣很好玩是吧!

由于大多數(shù)Web架構(gòu)很少會(huì)將應(yīng)用服務(wù)器直接對(duì)外提供服務(wù),一般都會(huì)設(shè)置一層Nginx作為代理和負(fù)載均衡,有的甚至可能有多層代理。鑒于大多數(shù)Web架構(gòu)都是使用Nginx作為反向代理,用戶請(qǐng)求不是直接請(qǐng)求應(yīng)用服務(wù)器的,而是通過(guò)Nginx設(shè)置的統(tǒng)一接入層將用戶請(qǐng)求轉(zhuǎn)發(fā)到服務(wù)器的,所以可通過(guò)設(shè)置HTTP請(qǐng)求頭字段X-Forwarded-For來(lái)偽造IP。

X-Forwarded-For指用來(lái)識(shí)別通過(guò)代理負(fù)載均衡的方式連接到Web服務(wù)器的客戶端最原始的IP地址的HTTP請(qǐng)求頭字段。當(dāng)然,這個(gè)IP也不是一成不變的,每次請(qǐng)求都需隨機(jī)更換IP,騙過(guò)應(yīng)用服務(wù)器。若應(yīng)用服務(wù)器增加了偽造IP識(shí)別,那可能就無(wú)法繼續(xù)使用隨機(jī)IP了。

體積限制

體積限制這個(gè)能理解,也沒(méi)必要搞一張那么大的圖片,多浪費(fèi)帶寬流量和加載時(shí)間啊。在上傳圖片時(shí),使用fs模塊判斷文件體積是否超過(guò)5M,是則不上傳,否則繼續(xù)上傳。當(dāng)然,交給TinyJpg/TinyPng接口判斷也行。

輸出信息

壓縮成功與否得讓別人知道,輸出原始大小、壓縮大小、壓縮率和錯(cuò)誤提示等,讓別人清楚這些處理信息。

編碼

通過(guò)上述抽絲剝繭的分析,那么就開(kāi)始著手編碼了。

隨機(jī)生成HTTP請(qǐng)求頭

既然可通過(guò)X-Forwarded-For來(lái)偽造IP,那么得有一個(gè)隨機(jī)生成HTTP請(qǐng)求頭字段的函數(shù),每次請(qǐng)求接口時(shí)都隨機(jī)生成相關(guān)的請(qǐng)求頭字段。打開(kāi)tinyjpg.comtinypng.com上傳一張圖片,通過(guò)Chrome DevTools分析Network發(fā)現(xiàn)其請(qǐng)求接口是web/shrink。另外每次請(qǐng)求也不要集中在單一的hostname上,隨機(jī)派發(fā)到tinyjpg.comtinypng.com上會(huì)更好。通過(guò)封裝RandomHeader函數(shù)隨機(jī)生成請(qǐng)求頭信息,后續(xù)使用https模塊RandomHeader()生成的配置作為入?yún)⑦M(jìn)行請(qǐng)求。

trample是筆者開(kāi)發(fā)的一個(gè)Web/Node通用函數(shù)工具庫(kù),包含常規(guī)的工具函數(shù),助你少寫(xiě)更多通用代碼。詳情請(qǐng)查看文檔,順便給一個(gè)Star以作鼓勵(lì)。

工具接口
const { RandomNum } = require("trample/node");

const TINYIMG_URL = [
    "tinyjpg.com",
    "tinypng.com"
];

function RandomHeader() {
    const ip = new Array(4).fill(0).map(() => parseInt(Math.random() * 255)).join(".");
    const index = RandomNum(0, 1);
    return {
        headers: {
            "Cache-Control": "no-cache",
            "Content-Type": "application/x-www-form-urlencoded",
            "Postman-Token": Date.now(),
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36",
            "X-Forwarded-For": ip
        },
        hostname: TINYIMG_URL[index],
        method: "POST",
        path: "/web/shrink",
        rejectUnauthorized: false
    };
}

上傳圖片與下載圖片

使用Promise封裝上傳圖片下載圖片的函數(shù),方便后續(xù)使用Async/Await同步化異步代碼。以下函數(shù)的具體斷點(diǎn)調(diào)試就不說(shuō)了,有興趣的同學(xué)自行調(diào)試函數(shù)的入?yún)⒑统鰠⒐?/p>

const Https = require("https");
const Url = require("url");

function UploadImg(file) {
    const opts = RandomHeader();
    return new Promise((resolve, reject) => {
        const req = Https.request(opts, res => res.on("data", data => {
            const obj = JSON.parse(data.toString());
            obj.error ? reject(obj.message) : resolve(obj);
        }));
        req.write(file, "binary");
        req.on("error", e => reject(e));
        req.end();
    });
}

function DownloadImg(url) {
    const opts = new Url.URL(url);
    return new Promise((resolve, reject) => {
        const req = Https.request(opts, res => {
            let file = "";
            res.setEncoding("binary");
            res.on("data", chunk => file += chunk);
            res.on("end", () => resolve(file));
        });
        req.on("error", e => reject(e));
        req.end();
    });
}

壓縮圖片

通過(guò)上傳圖片函數(shù)獲取壓縮后的圖片信息,再依據(jù)圖片信息通過(guò)下載圖片函數(shù)生成本地文件。

const Fs = require("fs");
const Path = require("path");
const Chalk = require("chalk");
const Figures = require("figures");
const { ByteSize, RoundNum } = require("trample/node");

async function CompressImg(path) {
    try {
        const file = Fs.readFileSync(path, "binary");
        const obj = await UploadImg(file);
        const data = await DownloadImg(obj.output.url);
        const oldSize = Chalk.redBright(ByteSize(obj.input.size));
        const newSize = Chalk.greenBright(ByteSize(obj.output.size));
        const ratio = Chalk.blueBright(RoundNum(1 - obj.output.ratio, 2, true));
        const dpath = Path.join("img", Path.basename(path));
        const msg = `${Figures.tick} Compressed [${Chalk.yellowBright(path)}] completed: Old Size ${oldSize}, New Size ${newSize}, Optimization Ratio ${ratio}`;
        Fs.writeFileSync(dpath, data, "binary");
        return Promise.resolve(msg);
    } catch (err) {
        const msg = `${Figures.cross} Compressed [${Chalk.yellowBright(path)}] failed: ${Chalk.redBright(err)}`;
        return Promise.resolve(msg);
    }
}

壓縮目標(biāo)圖片

完成上述步驟對(duì)應(yīng)的函數(shù)后,就能自由壓縮圖片了,以下使用一張圖片作為演示。

const Ora = require("ora");

(async() => {
    const spinner = Ora("Image is compressing......").start();
    const res = await CompressImg("src/pig.png");
    spinner.stop();
    console.log(res);
})();

你看,壓縮完后笨豬都變帥豬了,能電眼的豬都是好豬。源碼請(qǐng)查看compress-img

電眼豬

若壓縮指定文件夾里符合條件的所有圖片,可通過(guò)fs模塊獲取圖片并使用map()將單個(gè)圖片路徑映射為CompressImg(path),再通過(guò)Promise.all()操作即可。在這里就不貼代碼了,當(dāng)作思考題,自行完成。

將上述壓縮圖片的功能封裝成Loader還是Plugin呢?接下來(lái)會(huì)一步一步分析。

Loader&Plugin

webpack是一個(gè)前端資源打包工具,它根據(jù)模塊依賴關(guān)系進(jìn)行靜態(tài)分析,然后將這些模塊按照指定規(guī)則生成對(duì)應(yīng)的靜態(tài)資源。

網(wǎng)上一大堆webpack教程,筆者就不再花大篇幅啰嗦了,相信各位同學(xué)都是一位標(biāo)準(zhǔn)的Webpack配置工程師。以下簡(jiǎn)單回顧一次webpack的組成、構(gòu)建機(jī)制和構(gòu)建流程,相信也能從這些知識(shí)點(diǎn)中定位出LoaderPluginWebpack構(gòu)建流程中是處于一個(gè)什么樣的角色地位。

本文所說(shuō)的webpack都是基于webpack v4

組成

  • Entry:入口
  • Output:輸出
  • Loader:轉(zhuǎn)換器
  • Plugin:擴(kuò)展器
  • Mode:模式
  • Module:模塊
  • Target:目標(biāo)

構(gòu)建機(jī)制

  • 通過(guò)Babel轉(zhuǎn)換代碼并生成單個(gè)文件依賴
  • 從入口文件開(kāi)始遞歸分析并生成依賴圖譜
  • 將各個(gè)引用模塊打包成一個(gè)立即執(zhí)行函數(shù)
  • 將最終bundle文件寫(xiě)入bundle.js

構(gòu)建流程

  • 初始
    • 初始參數(shù):合并命令行和配置文件的參數(shù)
  • 編譯
    • 執(zhí)行編譯:依據(jù)參數(shù)初始Compiler對(duì)象,加載所有Plugin,執(zhí)行run()
    • 確定入口:依據(jù)配置文件找出所有入口文件
    • 編譯模塊:依據(jù)入口文件找出所有依賴模塊關(guān)系,調(diào)用所有Loader進(jìn)行轉(zhuǎn)換
    • 生成圖譜:得到每個(gè)模塊轉(zhuǎn)換后的內(nèi)容及其之間的依賴關(guān)系
  • 輸出
    • 輸出資源:依據(jù)依賴關(guān)系將模塊組裝成塊再組裝成包(module → chunk → bundle)
    • 生成文件:依據(jù)配置文件將確認(rèn)輸出的內(nèi)容寫(xiě)入文件系統(tǒng)
Loader

Loader用于轉(zhuǎn)換模塊源碼,筆者將其翻譯為轉(zhuǎn)換器。Loader可將所有類(lèi)型文件轉(zhuǎn)換為webpack能夠處理的有效模塊,然后利用webpack的打包能力對(duì)它們進(jìn)行二次處理。

Loader具有以下特點(diǎn):

  • 單一職責(zé)原則(只完成一種轉(zhuǎn)換)
  • 轉(zhuǎn)換接收內(nèi)容
  • 返回轉(zhuǎn)換結(jié)果
  • 支持鏈?zhǔn)秸{(diào)用

Loader將所有類(lèi)型文件轉(zhuǎn)換為應(yīng)用程序的依賴圖譜可直接引用的模塊,所以Loader可用于編譯一些文件,例如pug → html、sass → css、less → css、es5 → es6ts → js等。

處理一個(gè)文件可使用多個(gè)Loader,Loader的執(zhí)行順序和配置順序是相反的,即末尾Loader最先執(zhí)行,開(kāi)頭Loader最后執(zhí)行。最先執(zhí)行的Loader接收源文件內(nèi)容作為參數(shù),其它Loader接收前一個(gè)執(zhí)行的Loader的返回值作為參數(shù),最后執(zhí)行的Loader會(huì)返回該文件的轉(zhuǎn)換結(jié)果。一句話概括:富土康流水線廠工

Loader開(kāi)發(fā)思路總結(jié)如下:

  • 通過(guò)module.exports導(dǎo)出一個(gè)函數(shù)
  • 函數(shù)第一默認(rèn)參數(shù)為source(源文件內(nèi)容)
  • 在函數(shù)體中處理資源(可引入第三方模塊擴(kuò)展功能)
  • 通過(guò)return返回最終轉(zhuǎn)換結(jié)果(字符串形式)
編寫(xiě)Loader時(shí)要遵循單一職責(zé)原則,每個(gè)Loader只做一種轉(zhuǎn)換工作
Plugin

Plugin用于擴(kuò)展執(zhí)行范圍更廣的任務(wù),筆者將其翻譯為擴(kuò)展器Plugin的范圍很廣,在Webpack構(gòu)建流程里從開(kāi)始到結(jié)束都能找到時(shí)機(jī)作為插入點(diǎn),只要你想不到?jīng)]有你做不到。所以筆者認(rèn)為Plugin的功能比Loader更加強(qiáng)大。

Plugin具有以下特點(diǎn):

  • 監(jiān)聽(tīng)webpack運(yùn)行生命周期中廣播的事件
  • 在合適時(shí)機(jī)通過(guò)webpack提供的API改變輸出結(jié)果
  • webpack的Tapable事件流機(jī)制保證Plugin的有序性

webpack運(yùn)行生命周期中會(huì)廣播出許多事件,Plugin可監(jiān)聽(tīng)這些事件并在合適時(shí)機(jī)通過(guò)webpack提供的API改變輸出結(jié)果。在webpack啟動(dòng)后,在讀取配置過(guò)程中執(zhí)行new MyPlugin(opts)初始化自定義Plugin獲取其實(shí)例,在初始化Compiler對(duì)象后,通過(guò)compiler.hooks.event.tap(PLUGIN_NAME, callback)監(jiān)聽(tīng)webpack廣播事件,當(dāng)捕抓到指定事件后,會(huì)通過(guò)Compilation對(duì)象操作相關(guān)業(yè)務(wù)邏輯。一句話概括:自己看著辦。

Plugin開(kāi)發(fā)思路總結(jié)如下:

  • 通過(guò)module.exports導(dǎo)出一個(gè)函數(shù)或類(lèi)
  • 函數(shù)原型或類(lèi)上綁定apply()訪問(wèn)Compiler對(duì)象
  • apply()中指定一個(gè)綁定到webpack自身的事件鉤子
  • 在事件鉤子中通過(guò)webpack提供的API處理資源(可引入第三方模塊擴(kuò)展功能)
  • 通過(guò)webpack提供的方法返回該資源
傳給每個(gè)Plugin的Compiler和Compilation都是同一個(gè)引用,若修改它們身上的屬性會(huì)影響后面的Plugin,所以需謹(jǐn)慎操作
Loader/Plugin區(qū)別
  • 本質(zhì)
    • Loader本質(zhì)是一個(gè)函數(shù),轉(zhuǎn)換接收內(nèi)容,返回轉(zhuǎn)換結(jié)果
    • Plugin本質(zhì)是一個(gè)類(lèi),監(jiān)聽(tīng)webpack運(yùn)行生命周期中廣播的事件,在合適時(shí)機(jī)通過(guò)webpack提供的API改變輸出結(jié)果
  • 配置
    • Loadermodule.rule中配置,類(lèi)型是數(shù)組,每一項(xiàng)對(duì)應(yīng)一個(gè)模塊解析規(guī)則
    • Pluginplugin中配置,類(lèi)型是數(shù)組,每一項(xiàng)對(duì)應(yīng)一個(gè)擴(kuò)展器實(shí)例,參數(shù)通過(guò)構(gòu)造函數(shù)傳入

封裝

分析

從上述可知LoaderPlugin在角色定位和執(zhí)行機(jī)制上有很多不一樣,到底如何選擇呢?各有各好,當(dāng)然還是需分析后進(jìn)行選擇。

Loaderwebpack中扮演著轉(zhuǎn)換器的角色,用于轉(zhuǎn)換模塊源碼,簡(jiǎn)單理解就是將文件轉(zhuǎn)換成另外形式的文件,而本文主題是壓縮圖片,jpg壓縮后還是jpg,png壓縮后還是png,在文件類(lèi)型上來(lái)說(shuō)還是沒(méi)有變化。Loader的轉(zhuǎn)換過(guò)程是附屬在整個(gè)Webpack構(gòu)建流程中的,意味著打包時(shí)間包含了壓縮圖片的時(shí)間成本,對(duì)于追求webpack性能優(yōu)化來(lái)說(shuō)實(shí)屬有點(diǎn)違背原則。而Plugin恰好是監(jiān)聽(tīng)webpack運(yùn)行生命周期中廣播的事件,在合適時(shí)機(jī)通過(guò)webpack提供的API改變輸出結(jié)果,所以可在整個(gè)Webpack構(gòu)建流程完成后(全部打包文件輸出完成后)插入壓縮圖片的操作。換句話說(shuō),打包時(shí)間不再包含壓縮圖片的時(shí)間成本,打包完成后該干嘛就干嘛,還能干嘛,壓縮圖片啊。

所以依據(jù)需求情況,Plugin作為首選。

編碼

依據(jù)上述Plugin開(kāi)發(fā)思路,那么就開(kāi)始著手編碼了。

筆者把這個(gè)壓縮圖片的Plugin命名為tinyimg-webpack-plugin,tinyimg意味著TinyJpgTinyPng合體。

新建項(xiàng)目,目錄結(jié)構(gòu)如下。

tinyimg-webpack-plugin
├─ src
│  ├─ index.js
│  ├─ schema.json
├─ util
│  ├─ getting.js
│  ├─ setting.js
├─ .gitignore
├─ .npmignore
├─ license
├─ package.json
├─ readme.md

主要文件如下。

  • src
    • index.js:入口函數(shù)
    • schema.json:參數(shù)校驗(yàn)
  • util
    • getting.js:常量集合
    • setting.js:函數(shù)集合

安裝項(xiàng)目所需模塊,和上述compress-img的依賴一致,額外安裝schema-utils用于校驗(yàn)Plugin參數(shù)是否符合規(guī)定。

npm i chalk figures ora schema-utils trample

封裝常量集合和函數(shù)集合

將上述compress-imgTINYIMG_URLRandomHeader()封裝到工具集合中,其中常量集合增加IMG_REGEXPPLUGIN_NAME兩個(gè)常量。

// getting.js
const IMG_REGEXP = /\.(jpe?g|png)$/;

const PLUGIN_NAME = "tinyimg-webpack-plugin";

const TINYIMG_URL = [
    "tinyjpg.com",
    "tinypng.com"
];

module.exports = {
    IMG_REGEXP,
    PLUGIN_NAME,
    TINYIMG_URL
};
// setting.js
const { RandomNum } = require("trample/node");

const { TINYIMG_URL } = require("./getting");

function RandomHeader() {
    const ip = new Array(4).fill(0).map(() => parseInt(Math.random() * 255)).join(".");
    const index = RandomNum(0, 1);
    return {
        headers: {
            "Cache-Control": "no-cache",
            "Content-Type": "application/x-www-form-urlencoded",
            "Postman-Token": Date.now(),
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36",
            "X-Forwarded-For": ip
        },
        hostname: TINYIMG_URL[index],
        method: "POST",
        path: "/web/shrink",
        rejectUnauthorized: false
    };
}

module.exports = {
    RandomHeader
};

通過(guò)module.exports導(dǎo)出一個(gè)函數(shù)或類(lèi)

// index.js
module.exports = class TinyimgWebpackPlugin {};

函數(shù)原型或類(lèi)上綁定apply()訪問(wèn)Compiler對(duì)象

// index.js
module.exports = class TinyimgWebpackPlugin {
    apply(compiler) {
        // Do Something
    }
};

apply()中指定一個(gè)綁定到webpack自身的事件鉤子

從上述分析中可知,在全部打包文件輸出完成后插入壓縮圖片的操作,所以應(yīng)該選擇該時(shí)機(jī)對(duì)應(yīng)的事件鉤子。從Webpack Compiler Hooks API文檔中可發(fā)現(xiàn),emit正是這個(gè)Plugin所需的事件鉤子。emit生成資源到輸出目錄前執(zhí)行,此刻可獲取所有圖片文件的數(shù)據(jù)和輸出路徑。

為了方便在特定條件下啟用功能打印日志,所以設(shè)置相關(guān)配置。

  • enabled:是否啟用功能
  • logged:是否打印日志

apply()中處理相關(guān)業(yè)務(wù)邏輯,可能使用到Plugin的入?yún)?,那么就得?duì)參數(shù)進(jìn)行校驗(yàn)。定義一個(gè)PluginSchema,通過(guò)schema-utils來(lái)校驗(yàn)Plugin的入?yún)ⅰ?/p>

// schema.json
{
    "type": "object",
    "properties": {
        "enabled": {
            "description": "start plugin",
            "type": "boolean"
        },
        "logged": {
            "description": "print log",
            "type": "boolean"
        }
    },
    "additionalProperties": false
}
// index.js
const SchemaUtils = require("schema-utils");

const { PLUGIN_NAME } = require("../util/getting");
const Schema = require("./schema");

module.exports = class TinyimgWebpackPlugin {
    constructor(opts) {
        this.opts = opts;
    }
    apply(compiler) {
        const { enabled } = this.opts;
        SchemaUtils(Schema, this.opts, { name: PLUGIN_NAME });
        enabled && compiler.hooks.emit.tap(PLUGIN_NAME, compilation => {
            // Do Something
        });
    }
};

整合compress-imgPlugin

在整合過(guò)程中會(huì)有一些小修改,各位同學(xué)可對(duì)比看看哪些細(xì)節(jié)發(fā)生了變化。

// index.js
const Fs = require("fs");
const Https = require("https");
const Url = require("url");
const Chalk = require("chalk");
const Figures = require("figures");
const { ByteSize, RoundNum } = require("trample/node");

const { RandomHeader } = require("../util/setting");

module.exports = class TinyimgWebpackPlugin {
    constructor(opts) { ... }
    apply(compiler) { ... }
    async compressImg(assets, path) {
        try {
            const file = assets[path].source();
            const obj = await this.uploadImg(file);
            const data = await this.downloadImg(obj.output.url);
            const oldSize = Chalk.redBright(ByteSize(obj.input.size));
            const newSize = Chalk.greenBright(ByteSize(obj.output.size));
            const ratio = Chalk.blueBright(RoundNum(1 - obj.output.ratio, 2, true));
            const dpath = assets[path].existsAt;
            const msg = `${Figures.tick} Compressed [${Chalk.yellowBright(path)}] completed: Old Size ${oldSize}, New Size ${newSize}, Optimization Ratio ${ratio}`;
            Fs.writeFileSync(dpath, data, "binary");
            return Promise.resolve(msg);
        } catch (err) {
            const msg = `${Figures.cross} Compressed [${Chalk.yellowBright(path)}] failed: ${Chalk.redBright(err)}`;
            return Promise.resolve(msg);
        }
    }
    downloadImg(url) {
        const opts = new Url.URL(url);
        return new Promise((resolve, reject) => {
            const req = Https.request(opts, res => {
                let file = "";
                res.setEncoding("binary");
                res.on("data", chunk => file += chunk);
                res.on("end", () => resolve(file));
            });
            req.on("error", e => reject(e));
            req.end();
        });
    }
    uploadImg(file) {
        const opts = RandomHeader();
        return new Promise((resolve, reject) => {
            const req = Https.request(opts, res => res.on("data", data => {
                const obj = JSON.parse(data.toString());
                obj.error ? reject(obj.message) : resolve(obj);
            }));
            req.write(file, "binary");
            req.on("error", e => reject(e));
            req.end();
        });
    }
};

在事件鉤子中通過(guò)webpack提供的API處理資源

通過(guò)compilation.assets獲取全部打包文件的對(duì)象,篩選出jpgpng,使用map()將單個(gè)圖片數(shù)據(jù)映射為this.compressImg(file),再通過(guò)Promise.all()操作即可。

整個(gè)業(yè)務(wù)邏輯結(jié)合了PromiseAsync/Await兩個(gè)ES6常用特性,它倆組合起來(lái)玩異步編程極其有趣,關(guān)于它倆更多細(xì)節(jié)可查看筆者這篇4000點(diǎn)贊量14萬(wàn)閱讀量的文章《1.5萬(wàn)字概括ES6全部特性》。

// index.js
const Ora = require("ora");
const SchemaUtils = require("schema-utils");

const { IMG_REGEXP, PLUGIN_NAME } = require("../util/getting");
const Schema = require("./schema");

module.exports = class TinyimgWebpackPlugin {
    constructor(opts) { ... }
    apply(compiler) {
        const { enabled, logged } = this.opts;
        SchemaUtils(Schema, this.opts, { name: PLUGIN_NAME });
        enabled && compiler.hooks.emit.tap(PLUGIN_NAME, compilation => {
            const imgs = Object.keys(compilation.assets).filter(v => IMG_REGEXP.test(v));
            if (!imgs.length) return Promise.resolve();
            const promises = imgs.map(v => this.compressImg(compilation.assets, v));
            const spinner = Ora("Image is compressing......").start();
            return Promise.all(promises).then(res => {
                spinner.stop();
                logged && res.forEach(v => console.log(v));
            });
        });
    }
    async compressImg(assets, path) { ... }
    downloadImg(url) { ... }
    uploadImg(file) { ... }
};

通過(guò)webpack提供的方法返回該資源

由于壓縮圖片的操作是在整個(gè)Webpack構(gòu)建流程完成后,所以沒(méi)有什么可返回了,故不作處理。

控制webpack依賴版本

由于tinyimg-webpack-plugin基于webpack v4,所以需在package.json中添加peerDependencies,用來(lái)告知安裝該Plugin的模塊必須存在peerDependencies里的依賴。

{
    "peerDependencies": {
        "webpack": ">= 4.0.0",
        "webpack-cli": ">= 3.0.0"
    }
}

總結(jié)

按照上述總結(jié)的開(kāi)發(fā)思路一步一步來(lái)完成編碼,其實(shí)是挺簡(jiǎn)單的。若需開(kāi)發(fā)一些跟自己項(xiàng)目相關(guān)的Plugin,還是需多多熟悉Webpack Compiler Hooks API文檔,相信各位同學(xué)都能手戳一個(gè)完美的Plugin出來(lái)。

tinyimg-webpack-plugin源碼請(qǐng)戳這里查看,Star一個(gè)如何,嘻嘻。

電眼豬

測(cè)試

整個(gè)Plugin開(kāi)發(fā)完成,接下來(lái)需走一遍測(cè)試流程,看能不能把這個(gè)壓縮圖片的擴(kuò)展器跑通。相信各位同學(xué)都是一位標(biāo)準(zhǔn)的Webpack配置工程師,可自行編寫(xiě)測(cè)試Demo驗(yàn)證你們的Plugin。

在根目錄下創(chuàng)建test文件夾,并按照以下目錄結(jié)構(gòu)加入文件。

tinyimg-webpack-plugin
├─ test
│  ├─ src
│  │  ├─ img
│  │  │  ├─ favicon.ico
│  │  │  ├─ gz.jpg
│  │  │  ├─ pig-1.jpg
│  │  │  ├─ pig-2.jpg
│  │  │  ├─ pig-3.jpg
│  │  ├─ index.html
│  │  ├─ index.js
│  │  ├─ index.scss
│  │  ├─ reset.css
│  └─ webpack.config.js

安裝測(cè)試Demo所需的webpack相關(guān)配置模塊。

npm i -D @babel/core @babel/preset-env babel-loader clean-webpack-plugin css-loader file-loader html-webpack-plugin mini-css-extract-plugin node-sass sass sass-loader style-loader url-loader webpack webpack-cli webpackbar

安裝完成后,著手完善webpack.config.js代碼,代碼量有點(diǎn)多,直接貼鏈接好了,請(qǐng)戳這里。

最后在package.json中的scripts插入以下npm scripts,然后執(zhí)行npm run test調(diào)試測(cè)試Demo。

{
    "scripts": {
        "test": "webpack --config test/webpack.config.js"
    }
}

發(fā)布

發(fā)布到NPM倉(cāng)庫(kù)上非常簡(jiǎn)單,僅需幾行命令。若還沒(méi)注冊(cè),趕緊去NPM上注冊(cè)一個(gè)賬號(hào)。若當(dāng)前鏡像為淘寶鏡像,需執(zhí)行npm config set registry https://registry.npmjs.org/切換回源鏡像。

接下來(lái)一波操作就可完成發(fā)布了。

  • 進(jìn)入目錄:cd my-plugin
  • 登錄賬號(hào):npm login
  • 校驗(yàn)狀態(tài):npm whoami
  • 發(fā)布模塊:npm publish
  • 退出賬號(hào):npm logout

若不想牢記這么多命令,可用筆者開(kāi)發(fā)的pkg-master一鍵發(fā)布,若存在某些錯(cuò)誤會(huì)立馬中斷發(fā)布并提示錯(cuò)誤信息,是一個(gè)非常好用的集成創(chuàng)建和發(fā)布的NPM模塊管理工具。詳情請(qǐng)查看文檔,順便給一個(gè)Star以作鼓勵(lì)。

安裝

npm i -g pkg-master

使用

命令 縮寫(xiě) 功能 描述
pkg-master create pkg-master c 創(chuàng)建模塊 生成模塊的基礎(chǔ)文件
pkg-master publish pkg-master p 發(fā)布模塊 檢測(cè)NPM的運(yùn)行環(huán)境賬號(hào)狀態(tài),通過(guò)則自動(dòng)發(fā)布模塊
發(fā)布模塊

接入

安裝

npm i tinyimg-webpack-plugin

使用

配置 功能 格式 描述
enabled 是否啟用功能 true/false 建議只在生產(chǎn)環(huán)境下開(kāi)啟
logged 是否打印日志 true/false 打印處理信息

webpack.config.jswebpack配置插入以下代碼。

在CommonJS中使用

const TinyimgPlugin = require("tinyimg-webpack-plugin");

module.exports = {
    plugins: [
        new TinyimgPlugin({
            enabled: process.env.NODE_ENV === "production",
            logged: true
        })
    ]
};

在ESM中使用

必須在babel加持下的Node環(huán)境中使用

import TinyimgPlugin from "tinyimg-webpack-plugin";

export default {
    plugins: [
        new TinyimgPlugin({
            enabled: process.env.NODE_ENV === "production",
            logged: true
        })
    ]
};

推薦一個(gè)零配置開(kāi)箱即用的React/Vue應(yīng)用自動(dòng)化構(gòu)建腳手架

bruce-cli是一個(gè)React/Vue應(yīng)用自動(dòng)化構(gòu)建腳手架,其零配置開(kāi)箱即用的優(yōu)點(diǎn)非常適合入門(mén)級(jí)、初中級(jí)、快速開(kāi)發(fā)項(xiàng)目的前端同學(xué)使用,還可通過(guò)創(chuàng)建brucerc.js文件來(lái)覆蓋其默認(rèn)配置,只需專注業(yè)務(wù)代碼的編寫(xiě)無(wú)需關(guān)注構(gòu)建代碼的編寫(xiě),讓項(xiàng)目結(jié)構(gòu)更簡(jiǎn)潔。使用時(shí)記得查看文檔喲,喜歡的話給個(gè)Star。

當(dāng)然,筆者已將tinyimg-webpack-plugin集成到bruce-cli中,零配置開(kāi)箱即用走起。

  • Github地址:請(qǐng)戳這里
  • 官網(wǎng)文檔:請(qǐng)戳這里
  • 掘金文檔:請(qǐng)戳這里

總結(jié)

總體來(lái)說(shuō)開(kāi)發(fā)一個(gè)Webpack Plugin不難,只需好好分析需求,了解webpack運(yùn)行生命周期中廣播的事件,編寫(xiě)自定義Plugin在合適時(shí)機(jī)通過(guò)webpack提供的API改變輸出結(jié)果。

若覺(jué)得tinyimg-webpack-plugin對(duì)你有幫助,可在Issue提出你的寶貴建議,筆者會(huì)認(rèn)真閱讀并整合你的建議。喜歡tinyimg-webpack-plugin的請(qǐng)給一個(gè)Star,或Fork本項(xiàng)目到自己的Github上,根據(jù)自身需求定制功能。

結(jié)語(yǔ)

??關(guān)注+點(diǎn)贊+收藏+評(píng)論+轉(zhuǎn)發(fā)??,原創(chuàng)不易,鼓勵(lì)筆者創(chuàng)作更好的文章

關(guān)注公眾號(hào)IQ前端,一個(gè)專注于CSS/JS開(kāi)發(fā)技巧的前端公眾號(hào),更多前端小干貨等著你喔

  • 關(guān)注后回復(fù)關(guān)鍵詞免費(fèi)領(lǐng)取視頻教程
  • 關(guān)注后添加我微信拉你進(jìn)技術(shù)交流群
  • 歡迎關(guān)注IQ前端,更多CSS/JS開(kāi)發(fā)技巧只在公眾號(hào)推送
image
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容