圖片不壓縮,前端要背鍋
作者:JustCarryOn
https://juejin.cn/post/7153086294409609229
這次要聊的主題是「圖片壓縮」。在一般頁面裏面,使用最多的「靜態素材」非圖片莫屬了,這次輪到對它動手 👊 !
背景
🎨(美術): 這是這次需求的切圖 📁 ,你看看有沒問題?
🧑💻(前端): 好的。
頁面上線 ...
🧑💼(產品): 這圖片怎麼半天加載不出來 💢 ?
🧑💻(前端): 我看看 🤔 (卑微)。
... 📁(size: 15MB)
🧑💻(前端): 😅。
很多時候,我們從 PS
等工具導出來的圖片,或者是美術直接給到切圖,都是未經過壓縮的,體積都比較大。這裏,就有了可優化的空間。
TinyPng
TinyPNG
使用智能的「有損壓縮技術」來減少WEBP
、JPEG
和PNG
文件的文件大小。通過選擇性地減少圖像中的「顏色數量」,使用更少的字節來存儲數據。這種效果幾乎是看不見的,但在文件大小上有非常大的差別。
使用過 TinyPng[1] 的都知道,它的壓縮效果非常好,體積大幅度降低且顯示效果幾乎沒有區別 ( 👀 看不出區別)。因此,選擇其作爲壓縮工具,是一個不錯的選擇。
TinyPng
提供兩種壓縮方法:
-
通過在官網上進行手動壓縮;
-
通過官方提供的
tinify
進行壓縮;
身爲一個程序員 🧑💻 ,是不能接受手動一張張上傳壓縮這種方法的。因此,選擇第二種方法,通過封裝一個工具,對項目內的圖片自動壓縮,徹底釋放雙手 🤲 。
工具類型
第一步,思考這個工具的「目的」是什麼?沒錯,「壓縮圖片」。
第二步,思考在哪個「環節」進行壓縮?沒錯,「發佈前」。
這樣看來,開發一個webpack plugin
是一個不錯選擇,在打包「生產環境」代碼的時候,啓用該plugin
對圖片進行處理,完美 🥳 !
但是,這樣會面臨兩個問題 🤔 :
-
頁面迭代,新增了幾張圖片,重新打包上線時,會導致舊圖片被多次壓縮;
-
無法選擇哪些圖片要被壓縮,哪些圖片不被壓縮;
雖然可以通過「配置」的方式解決上述問題,但每次打包都要特殊配置,略顯麻煩,這樣看來plugin
好像不是最好的選擇。
以上兩個問題,使用「命令行工具」就能完美解決。在打包「生產環境」代碼之前,執行「壓縮命令」,通過命令行交互,選擇需要壓縮的圖片。
效果演示
話不多說,先上才藝 💃 !
- 安裝
$ npm i yx-tiny -D
- 使用
$ npx tiny
- 根據命令行提示輸入
流程:輸入「文件夾名稱 -tinyImg
」,接着工具會找到當前項目下所有的tinyImg
,接着選擇一或多個tinyImg
,緊接着,工具會找出tinyImg
下所有的png
、jpe?g
和svga
,最後選擇壓縮模式「全量」或「自定義」,選擇需要壓縮的圖片。
從最後的輸出結果可以看到,壓縮前的資源體積爲2.64MB
,壓縮後體積爲1.02MB
,足足壓縮了1.62MB
👍 !
然後再繼續執行一遍命令再次壓縮,剛剛壓縮過的資源被識別出來,因爲沒有新增資源,所以輸出「目標文件夾內」找不到「可壓縮」的資源!
實現思路
總體分爲五個過程:
-
查找:找出所有的圖片資源;
-
分配:均分任務到每個進程;
-
上傳:把原圖上傳到
TinyPng
; -
下載:從
TinyPng
中下載壓縮好的圖片; -
寫入:用下載的圖片覆蓋本地圖片;
項目地址:yx-tiny[2]
查找
找出所有的圖片資源。
packages/tiny/src/index.ts
/**
* 遞歸找出所有圖片
* @param { string } path
* @returns { Array<imageType> }
*/
interface IdeepFindImg {
(path: string): Array<imageType>
}
let deepFindImg: IdeepFindImg
deepFindImg = (path: string) => {
// 讀取文件夾的內容
const content = fs.readdirSync(path)
// 用於保存發現的圖片
let images: Array<imageType> = []
// 遍歷該文件夾內容
content.forEach(folder => {
const filePath = resolve(path, folder)
// 獲取當前內容的語法信息
const info = fs.statSync(filePath)
// 當前內容爲“文件夾”
if (info.isDirectory()) {
// 對該文件夾進行遞歸操作
images = [...images, ...deepFindImg(filePath)]
} else {
const fileNameReg = /\.(jpe?g|png|svga)$/
const shouldFormat = fileNameReg.test(filePath)
// 判斷當前內容的路徑是否包含圖片格式
if (shouldFormat) {
// 讀取圖片內容保存到images
const imgData = fs.readFileSync(filePath)
images.push({
path: filePath,
file: imgData
})
}
}
})
return images
}
通過命令行交互後,拿到目標文件夾的路徑path
,然後獲取該path
下的所有內容,接着遍歷所有內容。
首先判斷該內容的文件信息:若爲 “文件夾”,則把該文件夾路徑作爲path
,遞歸調用deepFindImg
;若不爲 “文件夾”,判斷該內容爲圖片,則讀取圖片數據,push
到images
中。最後,返回所有找到的圖片。
分配
均分任務到每個進程。
packages/tiny/src/index.ts
// ...
cluster.setupPrimary({
exec: resolve(__dirname, 'features/process.js')
})
// 若資源數小於則創建一個進程,否則創建多個進程
const works: Array<{
work: Worker;
tasks: Array<imageType>
}> =[]
if (list.length <= cpuNums) {
works.push({
work: cluster.fork(),
tasks: list
})
} else {
for (let i = 0; i < cpuNums; ++i) {
const work = cluster.fork()
works.push({
work,
tasks: []
})
}
}
// 平均分配任務
let workNum = 0
list.forEach(task = >{
if (works.length === 1) {
return
} else if (workNum >= works.length) {
works[0].tasks.push(task)
workNum = 1
} else {
works[workNum].tasks.push(task)
workNum += 1
}
})
// 用於記錄進程完成數
let pageNum = works.length
// 初始化進度條
// ...
works.forEach(({
work,
tasks
}) = >{
// 發送任務到每個進程
work.send(tasks)
// 接收任務完成
work.on('message', (details: Idetail[]) = >{
// 更新進度條
// ...
pageNum--
// 所有任務執行完畢
if (pageNum === 0) {
// 關閉進程
cluster.disconnect()
}
})
})
使用cluster
,根據「cpu 核心數」創建等量的進程,works
用於保存已創建的進程,list
中保存的是要處理的壓縮任務,通過遍歷list
,把任務依次分給每一個進程。接着遍歷works
,通過send
方法發送進程任務。通過監聽message
事件,利用pageNum
記錄進程任務的完成情況,當所有進程任務執行完畢後,則關閉進程。
上傳
官方提供的tinify
工具有「500 張 / 月」的限額,超過限額後,需要付費。
由於家境貧寒,且出於學習的目的,就沒有使用tinify
,而是通過構造隨機IP
來直接請求「壓縮接口」來達到「破解限額」的目的。大家在真正使用的時候,還是要使用tinyfy
來壓縮,不要做這種投機取巧的事。
好了,回到正文。
把原圖上傳到TinyPng
。
packages/tiny/src/features/index.ts
/**
* 上傳函數
* @param { Buffer } file 文件buffer數據
* @returns { Promise<DataUploadType> }
*/
interface Iupload {
(file: Buffer): Promise<DataUploadType>
}
export let upload: Iupload
upload = (file: Buffer) => {
// 生成隨機請求頭
const header = randomHeader()
return new Promise((resolve, reject) => {
const req = Https.request(header, res => {
res.on('data', data => {
try {
const resp = JSON.parse(data.toString()) as DataUploadType
if (resp.error) {
reject(resp)
} else {
resolve(resp)
}
} catch (err) {
reject(err)
}
})
})
// 上傳圖片buffer
req.write(file)
req.on('error', err => reject(err))
req.end()
})
}
使用node
自帶的Https
模塊,構造請求頭,把deepFindImg
中返回的圖片進行上傳。上傳成功後,會返回已經壓縮好的圖片的url
鏈接。
下載
從TinyPng
中下載壓縮好的圖片。
packages/tiny/src/features/index.ts
/**
* 下載函數
* @param { string } path
* @returns { Promise<string> }
*/
interface Idownload {
(path: string): Promise<string>
}
export let download: Idownload
download = (path: string) => {
const header = new Url.URL(path)
return new Promise((resolve, reject) => {
const req = Https.request(header, res => {
let content = ''
res.setEncoding('binary')
res.on('data', data => (content += data))
res.on('end', () => resolve(content))
})
req.on('error', err => reject(err))
req.end()
})
}
使用node
自帶的Https
模塊把upload
中返回的圖片鏈接進行下載。下載成功後,返回圖片的buffer
數據。
寫入
把下載好的圖片覆蓋本地圖片。
packages/tiny/src/features/process.ts
/**
* 接收進程任務
*/
process.on('message', (tasks: imageType[]) => {
;(async () => {
// 優化 png/jpg
const data = tasks
.filter(({ path }: { path: string }) => /\.(jpe?g|png)$/.test(path))
.map(ele => {
return compressImg({ ...ele, file: Buffer.from(ele.file) })
})
// 優化 svga
const svgaData = tasks
.filter(({ path }: { path: string }) => /\.(svga)$/.test(path))
.map(ele => {
return compressSvga(ele.path, Buffer.from(ele.file))
})
const details = await Promise.all([
...data.map(fn => fn()),
...svgaData.map(fn => fn())
])
// 寫入
await Promise.all(
details.map(
({ path, file }) =>
new Promise((resolve, reject) => {
fs.writeFile(path, file, err => {
if (err) reject(err)
resolve(true)
})
})
)
)
// 發送結果
if (process.send) {
process.send(details)
}
})()
})
process.on
監聽每個進程發送的任務,當接收到任務類型爲「圖片」,使用compressImg
方法來處理圖片。當任務類型爲「svga」,使用compressSvga
方法來處理svga
。最後把處理好的資源寫入到本地覆蓋舊資源。
compressImg
packages/tiny/src/features/process.ts
/**
* 壓縮圖片
* @param { imageType } 圖片資源
* @returns { promise<Idetail> }
*/
interface IcompressImg {
(payload: imageType): () => Promise<Idetail>
}
let compressImg: IcompressImg
compressImg = ({ path, file }: imageType) => {
return async () => {
const result = {
input: 0,
output: 0,
ratio: 0,
path,
file,
msg: ''
}
try {
// 上傳
const dataUpload = await upload(file)
// 下載
const dataDownload = await download(dataUpload.output.url)
result.input = dataUpload.input.size
result.output = dataUpload.output.size
result.ratio = 1 - dataUpload.output.ratio
result.file = Buffer.alloc(dataDownload.length, dataDownload, 'binary')
} catch (err) {
result.msg = `[${chalk.blue(path)}] ${chalk.red(JSON.stringify(err))}`
}
return result
}
}
compressImg
返回一個async
函數,該函數先調用upload
進行圖片上傳,接着調用download
進行下載,最終返回該圖片的buffer
數據。
compressSvga
packages/tiny/src/features/process.ts
/**
* 壓縮svga
* @param { string } path 路徑
* @param { buffer } source svga buffer
* @returns { promise<Idetail> }
*/
interface IcompressSvga {
(path: string, source: Buffer): () => Promise<Idetail>
}
let compressSvga: IcompressSvga
compressSvga = (path, source) => {
return async () => {
const result = {
input: 0,
output: 0,
ratio: 0,
path,
file: source,
msg: ''
}
try {
// 解析svga
const data = ProtoMovieEntity.decode(
pako.inflate(toArrayBuffer(source))
) as unknown as IsvgaData
const { images } = data
const list = Object.keys(images).map(path => {
return compressImg({ path, file: toBuffer(images[path]) })
})
// 對svga圖片進行壓縮
const detail = await Promise.all(list.map(fn => fn()))
detail.forEach(({ path, file }) => {
data.images[path] = file
})
// 壓縮buffer
const file = pako.deflate(
toArrayBuffer(ProtoMovieEntity.encode(data).finish() as Buffer)
)
result.input = source.length
result.output = file.length
result.ratio = 1 - file.length / source.length
result.file = file
} catch (err) {
result.msg = `[${chalk.blue(path)}] ${chalk.red(JSON.stringify(err))}`
}
return result
}
}
compressSvga
的「輸入」、「輸出」和compressImg
保持一致,目的是爲了可以使用promise.all
同時調用。在compressSvga
內部,對svga
進行解析成data
,獲取到svga
的圖片列表images
,接着調用compressImg
對images
進行壓縮,使用壓縮後的圖片覆蓋data.images
,最後再把data
編碼後,寫入到本地覆蓋原本的svga
。
最後
再說一遍,大家真正使用的時候,要使用官方的tinify
進行壓縮。
參考文章:
-
protobuf.js[3]
-
SVGAPlayer-Web-Lite[4]
-
tinify[5]
參考資料
[1]
TinyPng: https://tinypng.com/
[2]
yx-tiny: https://github.com/yxichan/lerna-npm/tree/master/packages/tiny
[3]
protobuf.js: https://github.com/protobufjs/protobuf.js
[4]
SVGAPlayer-Web-Lite: https://github.com/svga/SVGAPlayer-Web-Lite
[5]
tinify: https://tinypng.com/developers/reference/nodejs
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/tM3QrXpF00wlBfEIy07JSQ