Shipping JPEG XL Today: A Practical Guide to Conversion, Content Negotiation and Safe Fallbacks
Tin Chrome đưa JPEG XL trở lại đang được bàn nhiều trên Hacker News. Với nhiều anh em làm web ở Việt Nam, đây là chuyện có ảnh hưởng thật. Site thương mại điện tử, báo điện tử hay landing page thường có ảnh chiếm 60-70% dung lượng trang, mà người dùng thì phần lớn lướt bằng 4G trên điện thoại tầm trung. Nhưng format mới được hỗ trợ không có nghĩa là bạn nên convert hết ảnh rồi deploy ngay chiều nay. Bài này chia sẻ cách mình đưa JPEG XL (JXL) vào production theo kiểu progressive enhancement: trình duyệt nào hỗ trợ thì nhận JXL, còn lại vẫn nhận AVIF/WebP/JPEG như cũ, không vỡ ảnh nào.
Vì sao JPEG XL đáng để quan tâm
AVIF và WebP đã có từ lâu, vậy JXL hơn ở đâu? Theo kinh nghiệm của mình, có ba điểm đáng kể:
- Lossless JPEG recompression: JXL nén lại một file JPEG có sẵn nhỏ hơn khoảng 20% mà không mất một bit nào. Bạn còn decode ngược lại được đúng file JPEG gốc. Với kho ảnh cũ vài trăm GB, đây là lợi thế lớn: không phải lo chất lượng giảm, không cần QA lại từng ảnh.
-
- Progressive decoding: ảnh hiện bản mờ trước rồi nét dần khi tải tiếp, nên mạng chậm vẫn thấy nội dung sớm.
-
- Encode nhanh hơn AVIF ở mức chất lượng tương đương, nhất là với ảnh độ phân giải cao. Pipeline build ảnh vì thế chạy nhanh hơn rõ.
Điểm yếu thì vẫn là độ phủ trình duyệt. Safari hỗ trợ từ bản 17, Chrome đang ship lại bằng decoder viết bằng Rust, còn Firefox vẫn để sau flag. Vì vậy fallback là bắt buộc, không phải tùy chọn.
Bước 1: Convert ảnh với libjxl
Cài bộ công cụ libjxl (mình dùng bản 0.11.x):
# macOS
brew install jpeg-xl
# Ubuntu 24.04+
sudo apt install libjxl-tools
cjxl --version
Script dưới đây convert cả thư mục. Ảnh JPEG đi theo đường lossless recompression, ảnh PNG thì nén lossy ở mức distance 1.0 (mắt thường gần như không phân biệt được với bản gốc):
#!/usr/bin/env bash
set -euo pipefail
SRC_DIR=${1:-./public/images}
find "$SRC_DIR" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \) -print0 |
while IFS= read -r -d '' img; do
out="${img%.*}.jxl"
[[ -f "$out" && "$out" -nt "$img" ]] && continue # bỏ qua file đã convert
case "${img,,}" in
*.jpg|*.jpeg)
# Lossless recompression, có thể khôi phục đúng JPEG gốc
cjxl "$img" "$out" --lossless_jpeg=1 -e 7 --quiet ;;
*.png)
# Lossy, distance 1.0 ~ visually lossless
cjxl "$img" "$out" -d 1.0 -e 7 --quiet ;;
esac
before=$(stat -c%s "$img" 2>/dev/null || stat -f%z "$img")
after=$(stat -c%s "$out" 2>/dev/null || stat -f%z "$out")
echo "$(basename "$img"): $before -> $after bytes ($(( 100 - after * 100 / before ))% nhỏ hơn)"
done
```
Vài lưu ý thực tế:
- `-e` (effort) chạy từ 1 đến 10. Mức 7 là mặc định và cân bằng tốt. Lên 9-10 thì file nhỏ thêm được 1-3% nhưng thời gian encode tăng gấp nhiều lần, không đáng cho CI.
- Muốn kiểm tra lossless JPEG có đúng là lossless không, chạy `djxl out.jxl restored.jpg` rồi `cmp restored.jpg original.jpg`. Không có output nghĩa là hai file giống hệt nhau.
- **Đừng xóa file gốc.** JXL chỉ là thêm một biến thể mới, các format cũ vẫn phải giữ để fallback.
## Bước 2: Chọn format phía client với `<picture>`
Đây là cách an toàn và dễ làm nhất. Trình duyệt tự đọc lần lượt từng `<source>` và chọn cái đầu tiên nó hỗ trợ:
```html
<picture>
<source srcset="/images/hero.jxl" type="image/jxl">
<source srcset="/images/hero.avif" type="image/avif">
<source srcset="/images/hero.webp" type="image/webp">
<img src="/images/hero.jpg" alt="Hero banner"
width="1600" height="900" loading="lazy" decoding="async">
</picture>
```
```mermaid
flowchart TD
A[Browser parse picture] --> B{Hỗ trợ image/jxl?}
B -- Có --> C[Tải hero.jxl]
B -- Không --> D{Hỗ trợ image/avif?}
D -- Có --> E[Tải hero.avif]
D -- Không --> F{Hỗ trợ image/webp?}
F -- Có --> G[Tải hero.webp]
F -- Không --> H[Tải hero.jpg]
```
Thứ tự `<source>` rất quan trọng: format tốt nhất đặt lên trước. Nhớ luôn khai báo `width` và `height` trên `<img>` để không bị layout shift (CLS). Lỗi này mình gặp ở rất nhiều dự án.
Nhược điểm của cách này là HTML dài dòng. Nếu CMS lưu sẵn thẻ `<img>` trong nội dung bài viết (WordPress chẳng hạn) thì sửa lại toàn bộ khá mệt. Lúc đó nên chuyển sang cách thứ ba.
## Bước 3: Content negotiation ở Nginx
Khi trình duyệt request ảnh, nó gửi header `Accept` liệt kê các format nó hỗ trợ. Server dựa vào đó để trả file phù hợp mà **không cần sửa HTML** (URL vẫn là `hero.jpg`):
```nginx
# Đặt trong block http {}
map $http_accept $img_suffix {
default "";
"~*image/jxl" ".jxl";
"~*image/avif" ".avif";
"~*image/webp" ".webp";
}
server {
listen 443 ssl;
server_name example.vn;
root /var/www/site;
types {
image/jxl jxl;
}
location ~* ^(?<base>/images/.+)\.(jpe?g|png)$ {
add_header Vary Accept;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $base$img_suffix $uri =404;
}
}
```
```mermaid
sequenceDiagram
participant B as Browser
participant CDN as CDN
participant N as Nginx
B->>CDN: GET /images/hero.jpg (Accept: image/jxl,...)
CDN->>N: Cache miss, forward kèm Accept
N->>N: map Accept -> .jxl, try_files
N-->>CDN: hero.jxl (Content-Type: image/jxl, Vary: Accept)
CDN-->>B: Trả JXL, cache theo Accept
```
Ba cái bẫy mình từng dính:
1. **Thiếu `Vary: Accept`**: CDN cache bản JXL rồi trả luôn bản đó cho Firefox, thế là ảnh vỡ. Đây là lỗi nghiêm trọng nhất.
2. **CDN không tôn trọng `Vary: Accept`**: một số CDN bỏ qua header này hoặc làm cache key bị phân mảnh. Cách xử lý là normalize header Accept ở edge thành vài giá trị cố định (`jxl`, `avif`, `webp`, `default`) rồi dùng giá trị đó làm cache key.
3. **Thiếu MIME type**: `mime.types` mặc định của Nginx bản cũ chưa có `image/jxl`, nên file bị trả về với `application/octet-stream`. Khối `types {}` ở trên là để sửa lỗi này.
Test nhanh bằng `curl`:
```bash
curl -sI -H 'Accept: image/jxl,image/*' https://example.vn/images/hero.jpg | grep -i -E 'content-type|vary'
curl -sI -H 'Accept: image/webp,image/*' https://example.vn/images/hero.jpg | grep -i content-type
```
## Đo lường trước khi tuyên bố thắng
Đừng chỉ nhìn dung lượng file. Nên theo dõi thêm mấy chỉ số này:
- **LCP** trên thiết bị thật, đặc biệt là Android tầm trung. Decode JXL tốn CPU hơn JPEG một chút, nên với ảnh hero thì phải đo, đừng đoán.
- **Tỷ lệ request trả về JXL** so với tổng request ảnh. Thêm `$img_suffix` vào `log_format` của Nginx là thấy ngay. Tùy tệp người dùng, con số này có thể chỉ 20-30% trong giai đoạn đầu.
- **Dung lượng lưu trữ**: mỗi ảnh giờ có 3-4 biến thể. Với kho ảnh lớn, chi phí storage tăng lên là có thật.
## Kết luận
JPEG XL đáng thử ngay bây giờ, miễn là bạn làm theo hướng thêm dần chứ không thay thế toàn bộ:
1. **Bắt đầu với kho JPEG có sẵn**: dùng `cjxl --lossless_jpeg=1` để giảm khoảng 20% dung lượng mà không có rủi ro về chất lượng, rồi kiểm tra lại bằng `djxl` + `cmp`.
2. **Site mới hoặc component mới**: dùng `<picture>` với thứ tự JXL, AVIF, WebP, JPEG.
3. **Site cũ hoặc nội dung trong CMS**: dùng content negotiation ở Nginx, và **bắt buộc** có `Vary: Accept` cùng với chiến lược cache key ở CDN.
4. **Giữ nguyên file gốc** và đưa bước convert vào CI, bỏ qua những file đã convert để build không bị chậm.
5. **Đo LCP và tỷ lệ trả về JXL** trong 2-4 tuần, sau đó mới quyết định có mở rộng ra toàn bộ site hay không.
Một format ảnh mới không làm site nhanh lên được nếu triển khai sai. Nhưng nếu fallback chắc chắn và cache cấu hình đúng, bạn giảm được bandwidth gần như miễn phí, và người dùng 4G sẽ thấy trang tải nhanh hơn.
All rights reserved