圖片體積最佳化計劃
兩年半之前,我第一次瞭解到 WebP 可以有效減少圖片體積的時候,有一瞬間產生了“把網站中所有資源替換成 WebP”的衝動,但當我開啟 CanIUse 的時候,發現 Safari 遲遲不支援,況且網站中涉及到圖片的地方實在是太多,修改程式碼不太現實。
我當時搜了一些資料,是關於讓 Nginx 針對瀏覽器提供的 Accept 頭來分別提供不同格式的檔案。這個方法確實有,然而我實在是太懶了,以至於寫一段命令將所有圖片轉個格式都不想寫,並且想到以後所有圖片都要提供兩個版本,實在是太麻煩了!於是這個計劃就被擱置了。
後來我更多的接觸了 OpenResty,這是一個集成了 Lua 的 Nginx,可以利用 Lua 指令碼來方便的做一些擴充套件。我還摸索出瞭如何利用 Lua FFI 呼叫 OpenCC API 無痛讓網站支援了繁體版本。既然 FFI 都能支援,那麼如果發現瀏覽器支援 WebP,則呼叫一個 cwebp 命令轉換圖片格式並返回,不是更好實現了?不過我的伺服器已經完全 Docker 化,OpenResty 是使用了現成的 hustshawn/openresty-opencc-docker,如果想安裝 cwebp 進去,需要修改 Dockerfile,因此這個計劃又被懶惰的我給擱置了。
直到前兩天,我無意間看到了這樣一篇文章:AVIF has landed,裡面提到了一個新的圖片格式:AVIF,文章裡提供了很多例子,看起來壓縮率和效果都 WebP 要好。這激起了我的興趣,想試一下能否為支援 AVIF 的瀏覽器提供 AVIF 的圖片。
當然,由於 AVIF 的瀏覽器支援率實在是太低(據說很快會高起來),於是我還是需要順便提供一個 WebP 的轉換。
於是計劃正式開始!
將 AVIF 與 WebP 轉換器打包進 Docker
之前我為了升級 OpenCC 版本,自己 Fork 了一下 hustshawn 的映象:RexSkz/openresty-opencc-docker,剛好在這個上面做修改。我要做的就是將 AVIF 和 WebP 相關的程式打包進去。
WebP 很簡單,用 cwebp 就可以了,我用的 OpenResty 映象是基於 Alpine 的,因此需要找到在 Alpine 下面安裝 cwebp 的方法。在 Alpine Package 中搜索 cwebp,發現需要安裝 libwebp-tools,於是在 Dockerfile 中添加了一句:
RUN apk add libwebp-tools至於 AVIF 的部分,我找到了一個 Kagami/go-avif 專案。這個專案提供了 Linux 環境的二進位制可執行檔案,我可以直接用 curl 在 Build 的時候下載到映象中,但還需要安裝 libaom-dev。我搜了一下,並沒有發現相關的包,於是只能 Google 了一下 alpine libaom-dev 關鍵字,發現了 這樣一個網站,但裡面只有 Debian 的資料,沒有 Alpine……
熟悉 Linux 的小夥伴可能會知道,libaom-dev 最核心的部分應當是 aom,之前的 lib 表示是一個庫,-dev 表示這會帶上一些必要的 C 語言標頭檔案。於是我在網站搜尋框裡搜尋 aom,發現了一個叫 aom-dev 的包,應該是符合要求的,但需要至少 Alpine 3.12,因此我不得不升級了 OpenResty 映象版本。最終跟 AVIF 相關的部分如下:
FROM openresty/openresty:1.17.8.2-5-alpine
ARG AOM_VERSION="v1.0.0"
# Alpine 是沒有 curl 的,這個也要自己安裝
RUN apk add aom-dev curl \
&& curl -L https://github.com/Kagami/go-avif/releases/download/${GO_AVIF_VERSION}/avif-linux-x64 > /usr/bin/avif \
&& chmod +x /usr/bin/avif \
&& rm -rf /var/cache/apk/*將 Dockerfile 推到 GitHub,觸發了 Docker Hub 的自動構建。當構建完成後,我在本地輸入了這兩條命令:
docker pull rexskz/openresty-opencc-docker
docker-compose -f local.yaml up -d nginx然後我又上 Portainer 的 Shell 中看了一下,cwebp 和 avif 兩個命令都已經可以使用,並可以轉換檔案了。
使用 OpenResty 轉換圖片並返回
由於我對 Lua 和 OpenResty 還沒有那麼熟悉,於是還是得參考網上大佬們寫的 OpenResty 配置 WebP 的文章,例如 這篇。關鍵程式碼如下:
-- 作者假定我們訪問 xxx.jpg.webp
-- 才會返回 WebP 格式
local newFile = ngx.var.request_filename
local originalFile = newFile:sub(1, #newFile - 5)
-- 如果原始檔不存在,直接報 404
if not fileExists(originalFile) then
ngx.exit(404)
return
end
-- 轉換格式,生成新檔案
os.execute("cwebp -q 75 " .. originalFile .. " -o " .. newFile)
-- 如果轉換成功則返回新檔案,否則報 404
if not fileExists(newFile) then
ngx.exit(404)
return
end
ngx.exec(ngx.var.uri)我稍加改動,又封裝了幾個函式,便使它支援了 AVIF,並且在已有轉換結果的時候不會重複轉換:
-- 當 Accept 中有 image/avif 時才轉換
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
local newFile = originalFile .. ".converted.avif"
-- 首先嚐試 serve 新檔案,如果沒有才會做轉換
if not tryServeFile(newFile, "image/avif") then
os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
serveFileOr404(newFile, "image/avif")
end
end
-- WebP 的配置類似,就不重複寫了最後我還不忘把 *.converted.avif 和 *.converted.webp 加入了 .gitignore,簡直 Perfect!
轉換的效果如何呢?
我開啟本地的網站試了一下,發現一張 Banner 圖片就等了十幾秒,大部分時間都花在轉換上了……我可是用的外星人啊!

沒關係,大不了之後我每次上傳圖片,都觸發一下自動轉換,然後再把頁面公開化就好了。那麼 AVIF 的壓縮率有多少呢?
我去目錄下 ls 之後發現:對於 Banner 這種 JPG 格式壓縮率還是挺高的,能在之前 75% 的基礎上幾乎無損的再壓縮一半體積;但對於 PNG 檔案來說,由於我需要保證圖片不失真,因此選擇了無損壓縮,結果一個 1.2 KB 的圖示硬是被壓縮成了 2 KB……

難道 AVIF 的無失真壓縮率其實並沒有想象中的好?我又搜了一圈,發現了一個 Google 表格,裡面是 WebP 與 AVIF 的無失真壓縮對比。資料表明,在一半以上的情況下,AVIF 的無失真壓縮會讓圖片體積增大,甚至經常能達到兩三倍!
得了,我用於無損的 PNG 就不要考慮轉換為 AVIF 了,又慢體積還大。
最終的效果與程式碼
部署到伺服器上之後,我發現,一直在提示 404。看了 Nginx 的 Log 才發現,由於 Docker 許可權的原因,無法將生成的圖片寫入到目標路徑。修復了一下許可權問題後,圖片終於顯示了出來。

既然都調通了,就放出來造福一下大眾吧。不過寫的比較冗餘,也沒有太多最佳化。
location ~ \.(jpe?g|png|gif)$ {
# convert using lua code
content_by_lua_file /etc/nginx/conf.d/image-convert.lua;
}function tryServeFile(name, contentType)
if fileExists(name) then
local f = io.open(name, "rb")
local content = f:read("*all")
f:close()
if contentType ~= "" then
ngx.header["Content-Type"] = contentType
end
ngx.print(content)
return true
end
return false
end
function serveFileOr404(name, contentType)
if not tryServeFile(name, contentType) then
ngx.exit(404)
end
end
-- 根據 https://docs.google.com/spreadsheets/d/1TE5iLE08oV90EqOmFHnzBLwiPtQSs1XAvI3QfoMgKQM/edit#gid=0
-- 不是所有格式都應當轉換為 avif,因為這樣體積可能會更大
if string.find(originalFile, ".png") ~= nil then
-- 對於 PNG(無損),只嘗試用 webp 的無失真壓縮
if string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
local newFile = originalFile .. ".converted.webp"
if not tryServeFile(newFile, "image/webp") then
os.execute("cwebp -q 100 -lossless " .. originalFile .. " -o " .. newFile);
serveFileOr404(newFile, "image/webp")
end
else
serveFileOr404(originalFile, "")
end
else
-- 對於其它格式,先嚐試用 avid,再嘗試 webp
if string.find(ngx.req.get_headers()["accept"], "image/avif") ~= nil then
local newFile = originalFile .. ".converted.avif"
if not tryServeFile(newFile, "image/avif") then
os.execute("avif -e " .. originalFile .. " -o " .. newFile .. " --best -q 12");
serveFileOr404(newFile, "image/avif")
end
elseif string.find(ngx.req.get_headers()["accept"], "image/webp") ~= nil then
local newFile = originalFile .. ".converted.webp"
if not tryServeFile(newFile, "image/webp") then
os.execute("cwebp -q 80 " .. originalFile .. " -o " .. newFile);
serveFileOr404(newFile, "image/webp")
end
else
serveFileOr404(originalFile, "")
end
end