Hầu hết các ứng dụng web vẫn xử lý việc tải lên hình ảnh như một "hộp đen". Người dùng thả một tệp vào, trình duyệt gửi nó đi, và máy chủ sẽ chấp nhận payload đó hoặc ném ra một lỗi 413 mà không ai chuẩn bị trước. Nén phía trình duyệt sẽ thay đổi cục diện này. Nó cho bạn cơ hội thu nhỏ payload trước khi chúng được truyền đi, đồng nghĩa với việc tải lên nhanh hơn, hóa đơn băng thông thấp hơn và ít xảy ra tình trạng timeout trên máy chủ hơn. Nhưng công việc này rất dễ mắc sai lầm. Nếu bạn coi việc nén giống như một thanh trượt ma thuật được dán nhãn "chất lượng", bạn sẽ tạo ra những bức ảnh bị lỗi, các hình thu nhỏ (thumbnail) bị kéo giãn và trải nghiệm người dùng gây khó hiểu. Cách tiếp cận tốt hơn là coi toàn bộ quy trình như một pipeline (đường ống).
Tư duy theo Pipeline, thay vì các thanh trượt
Hãy chia công việc thành các giai đoạn riêng biệt. Đọc tệp từ phần tử input. Thay đổi kích thước (scale) hình ảnh xuống kích thước mục tiêu của bạn. Mã hóa một Blob mới. Sau đó hiển thị kết quả lại cho người dùng. Mỗi giai đoạn chỉ thực hiện một việc và chuyển đầu ra của nó cho giai đoạn tiếp theo. Sự tách biệt này không chỉ giúp mã nguồn gọn gàng hơn mà còn giúp việc kiểm thử đơn vị (unit testing) trở nên dễ dàng. Bạn có thể đưa một buffer đã biết vào giai đoạn scale mà không cần chạm vào input tệp. Bạn có thể xác minh rằng bộ mã hóa của mình xuất ra một tệp JPEG dưới 200 KB mà không cần chờ đợi phản hồi từ máy chủ. Khi có lỗi xảy ra, bạn sẽ biết chính xác bước nào đã thất bại.
Việc giữ các nhiệm vụ này tách biệt cũng giúp ngăn chặn những bất ngờ trong quá trình tải lên. Nếu bạn gộp việc scale và mã hóa vào một hàm lộn xộn, một lỗi giải mã (decoding error) ở giữa chừng có thể khiến hàng đợi tải lên của bạn rơi vào trạng thái không nhất quán. Một pipeline buộc bạn phải xác thực tại mọi ranh giới. Nếu tệp không thể giải mã, bạn sẽ bắt được lỗi trước khi kịp tạo một canvas. Nếu Blob đã mã hóa quá lớn, bạn sẽ bắt được lỗi trước khi yêu cầu máy chủ lưu trữ nó.
Xác định một "hợp đồng" trước khi viết code
Trước khi bất kỳ ai viết lệnh vẽ canvas, hãy viết ra các quy tắc và chia sẻ chúng với nhóm. Chọn các loại MIME được chấp nhận. Bạn sẽ cho phép JPEG, PNG, WebP hay AVIF? Mỗi loại đều có những ảnh hưởng đến kênh alpha, khả năng hỗ trợ của trình duyệt và chi phí CPU. Thiết lập kích thước đầu vào tối đa. Một bức ảnh thô 30 MB từ một chiếc điện thoại cao cấp sẽ làm đóng băng hoặc làm treo một chiếc laptop cũ nếu bạn cố gắng giải mã toàn bộ nó trong bộ nhớ. Xác định kích thước đầu ra tối đa. Nếu giao diện người dùng (UI) của bạn không bao giờ hiển thị hình ảnh rộng hơn 2048 pixel, thì không có lý do gì để cho phép một bức ảnh rộng 6000 pixel đi qua pipeline.
Quan trọng nhất, hãy lập kế hoạch cho các lỗi giải mã. Một tệp bị hỏng, một profile màu lạ, hoặc một tệp tải lên bị cắt ngang có thể gây lỗi trong hàm khởi tạo Image. Pipeline của bạn cần một khối catch rõ ràng và một thông báo lỗi dễ hiểu cho con người. Đừng để trình duyệt im lặng "chết" và để người dùng nhìn chằm chằm vào biểu tượng xoay vòng trong khi không có gì xảy ra.
Hãy tôn trọng hình ảnh
Sự biến dạng trông rất thiếu chuyên nghiệp. Hãy giữ nguyên tỷ lệ khung hình và giới hạn cạnh dài nhất. Nếu khung mục tiêu của bạn là 1024 x 1024 pixel, một bức ảnh 4000 x 3000 nên được đưa về kích thước 1024 x 768, chứ không phải 1024 x 1024. Hãy tính toán hệ số tỷ lệ từ cạnh dài hơn và để cạnh ngắn hơn tự điều chỉnh theo. Điều này ngăn hình ảnh bị kéo giãn thành những hình dạng kỳ dị.
Để xuất tệp thực tế, hãy sử dụng phương thức toBlob của canvas. Nó cho phép bạn kiểm soát trực tiếp định dạng đầu ra và cài đặt chất lượng, đồng thời nó chạy bất đồng bộ để không chặn luồng chính (main thread). Hãy tạo một offscreen canvas, vẽ hình ảnh đã thay đổi kích thước lên đó, sau đó gọi canvas.toBlob với loại tệp và giá trị chất lượng mong muốn. Blob mới đó chính là thứ bạn sẽ chuyển cho logic tải lên hoặc API lưu trữ của mình.
Hãy đưa ra bằng chứng
Nén là một công việc vô hình. Nếu bạn không hiển thị các con số, người dùng sẽ không tin tưởng vào quy trình. Hãy xây dựng một giao diện cho phép họ so sánh bản gốc với kết quả. Hiển thị kích thước tệp gốc, kích thước tệp mới, kích thước mới và loại định dạng cuối cùng. Việc thấy một bức ảnh từ điện thoại nặng 4,2 MB giảm xuống còn 380 KB định dạng WebP sẽ xóa tan nỗi lo rằng bạn đang âm thầm làm hỏng hình ảnh của họ.
Sự minh bạch này cũng giúp ích cho việc khắc phục sự cố. Khi người dùng phàn nàn rằng việc tải lên thất bại, điều đầu tiên bạn sẽ kiểm tra là liệu kích thước đầu ra có vượt quá giới hạn máy chủ hay không, hoặc liệu định dạng có bị thay đổi từ PNG sang JPEG và làm mất kênh alpha hay không. Hãy đưa dữ liệu đó lên UI để người dùng có thể tự chẩn đoán vấn đề trước khi gửi phiếu hỗ trợ (support ticket).
Sử dụng Preset tốt hơn việc nén lại
Đừng bao giờ nén cùng một hình ảnh hai lần. Mỗi lần đi qua một bộ mã hóa lossy sẽ làm mất đi nhiều chi tiết hơn và tạo ra các hiện tượng nhiễu hạt (artifacts). Nếu bạn để người dùng nhấn "tối ưu hóa" liên tục, thế hệ hình ảnh thứ ba sẽ trông giống như một bản photocopy của một bản photocopy khác. Thay vào đó, hãy tạo mọi đầu ra từ tệp nguồn gốc và cung cấp các preset (thiết lập sẵn):
- Tệp nhỏ hơn: Giảm chất lượng và giới hạn kích thước một cách triệt để cho ảnh thu nhỏ hoặc xem trước nhanh.
- Cân bằng: Nhắm tới mức chất lượng trung bình với kích thước hợp lý, phù hợp cho các bảng tin mạng xã hội và thư viện ảnh.
- Nhiều chi tiết hơn: Giữ chất lượng cao và bảo toàn kích thước lớn hơn cho nhiếp ảnh, tác phẩm nghệ thuật hoặc xem trước bản in.
Lưu trữ Blob gốc trong bộ nhớ để người dùng có thể chuyển đổi giữa các thiết lập sẵn (presets) mà không làm tích tụ sự suy giảm chất lượng qua nhiều thế hệ. Luôn tạo từ nguồn gốc, không bao giờ tạo từ kết quả đầu ra cuối cùng.
Kiểm thử như cách người dùng tải lên
Máy phát triển của bạn với kết nối cáp quang và 32 GB RAM không phải là thực tế. Hãy kiểm thử với các tệp thực tế mà người dùng thực sự sử dụng. Ảnh chụp từ điện thoại iOS và Android sử dụng các định hướng metadata khác nhau và có thể bắt nguồn từ các nguồn HEIC. Các tài nguyên trong suốt như logo và biểu tượng sẽ hoạt động khác khi chuyển đổi sang JPEG vì JPEG đơn giản là không hỗ trợ kênh alpha. Các tệp khổng lồ sẽ bộc lộ giới hạn bộ nhớ trên các thiết bị có 2 GB RAM. Các CPU di động chậm sẽ cho thấy chính xác lệnh gọi toBlob thực sự mất bao lâu.
Sử dụng Chrome DevTools để giới hạn (throttle) CPU và mạng. Hãy thử với một chiếc điện thoại Android đã 5 năm tuổi. Nếu quy trình (pipeline) của bạn làm treo UI trong ba giây khi đang mã hóa, bạn cần chuyển các tác vụ nặng vào một Web Worker để giao diện luôn phản hồi mượt mà.
Ưu tiên hoàn thiện các tính năng cơ bản trước
Thật hấp dẫn khi muốn hỗ trợ mọi định dạng và
