Font là một trong những tài nguyên bị xem nhẹ nhất khi tối ưu hiệu năng. Ảnh hero được nén kỹ, JavaScript được tách nhỏ, nhưng trang vẫn hiện ra với một khoảng trắng ở chỗ tiêu đề, rồi chữ "nhảy" sang font khác và đẩy cả layout xuống. Với website tiếng Việt còn thêm một cái bẫy: font hiển thị đẹp với chữ không dấu nhưng vỡ ở chữ "ệ" hay "ữ". Bài này là cách mình xử lý font từ đầu đến cuối — và vì sao mỗi bước lại quan trọng.
Font ảnh hưởng LCP và CLS thế nào#
Khi trình duyệt gặp một đoạn chữ dùng web font chưa tải xong, nó phải chọn giữa hai điều không hoàn hảo:
- FOIT (Flash of Invisible Text): giấu chữ đi cho đến khi font tải xong. Người dùng thấy khoảng trống. Nếu tiêu đề lớn là phần tử LCP, LCP bị đẩy lùi theo thời gian tải font.
- FOUT (Flash of Unstyled Text): hiển thị chữ bằng font fallback ngay, rồi đổi sang web font khi tải xong. Chữ hiện sớm, nhưng lúc đổi font, kích thước chữ thay đổi — dòng dài hơn hoặc ngắn hơn, xuống dòng ở chỗ khác — và mọi thứ bên dưới bị xê dịch. Đó là CLS.
Nói cách khác: FOIT hại LCP, FOUT hại CLS. Mục tiêu của bài này là có được cái lợi của FOUT (chữ hiện sớm) mà gần như không phải trả giá về layout shift, và làm cho font tải xong sớm nhất có thể để cả hai vấn đề đều ngắn lại.
Font cũng tốn băng thông thật. Một font đầy đủ ký tự với bốn độ đậm có thể nặng vài trăm KB — nhiều hơn toàn bộ CSS của trang. Mà trình duyệt chỉ phát hiện ra font sau khi tải và phân tích CSS, rồi lại phải thấy có phần tử thật sự dùng font đó, nên font thường nằm khá sâu trong chuỗi tải.
font-display: chọn đánh đổi của bạn#
font-display trong @font-face quyết định trình duyệt cư xử ra sao trong lúc chờ font. Nó chia thời gian chờ thành hai giai đoạn: block (chữ bị ẩn) và swap (hiện bằng fallback, vẫn đổi sang web font nếu tải xong kịp).
| Giá trị | Giai đoạn block | Giai đoạn swap | Khi nào dùng |
|---|---|---|---|
auto | Do trình duyệt quyết định (thường giống block) | Do trình duyệt quyết định | Hầu như không nên để mặc định |
block | Ngắn (khoảng 3 giây) | Vô hạn | Icon font, nơi chữ fallback vô nghĩa |
swap | Rất ngắn | Vô hạn | Font thương hiệu, tiêu đề — muốn chữ hiện ngay và chắc chắn đổi font |
fallback | Rất ngắn | Ngắn (khoảng 3 giây) | Thân bài: tải chậm quá thì giữ fallback, tránh đổi font khi người dùng đang đọc |
optional | Rất ngắn | Không có | Ưu tiên ổn định layout tuyệt đối; font chỉ được dùng nếu có gần như ngay lập tức |
Mình thường dùng:
swapcho font chính khi thiết kế phụ thuộc nhiều vào font đó, kết hợp với chỉnh metric fallback (phần sau) để lúc đổi font gần như không xê dịch.optionalcho những trang ưu tiên ổn định tuyệt đối. Lần truy cập đầu có thể chỉ thấy fallback, nhưng font được cache lại và lần sau sẽ hiện ngay. Kết hợp với preload, nhiều khi font kịp tải ngay từ lần đầu.
swap không phải lúc nào cũng là câu trả lời "đúng chuẩn". Nó đảm bảo chữ hiện sớm, nhưng không có chỉnh metric fallback thì nó chính là nguồn CLS. Hãy xem swap và metric override như một cặp đi cùng nhau.
Preload font quan trọng#
Vì font bị phát hiện muộn, cách hiệu quả nhất để tải sớm là nói thẳng với trình duyệt ngay trong HTML:
<link
rel="preload"
href="/fonts/inter-var-latin.woff2"
as="font"
type="font/woff2"
crossorigin
/>
Ba chi tiết dễ sai:
crossoriginlà bắt buộc, kể cả khi font nằm cùng domain. Font luôn được tải ở chế độ CORS; preload không cócrossoriginsẽ tạo ra một request không khớp, và trình duyệt tải font hai lần.- URL phải khớp chính xác với URL trong
@font-face, kể cả query string. - Chỉ preload một hoặc hai file — font của nội dung màn hình đầu tiên, thường là font chính ở độ đậm thường. Preload mọi thứ nghĩa là chúng tranh băng thông với ảnh LCP và CSS, và rốt cuộc chẳng cái nào được ưu tiên.
Chỉ dùng WOFF2. Mọi trình duyệt hiện đại đều hỗ trợ và nó nén tốt hơn rõ rệt so với WOFF hay TTF; không có lý do gì để phục vụ định dạng khác cho web nữa.
Subset và unicode-range — chuyện riêng của tiếng Việt#
Một font đầy đủ chứa hàng nghìn glyph: Latin mở rộng, Cyrillic, Hy Lạp, ký hiệu... Trang của bạn chỉ dùng một phần nhỏ. Subsetting là cắt font chỉ còn những glyph cần thiết, và unicode-range cho trình duyệt biết file nào chứa dải ký tự nào để chỉ tải file khi trang thật sự có ký tự trong dải đó.
Đây là chỗ tiếng Việt cần chú ý. Các dịch vụ font lớn thường chia font thành các subset như latin, latin-ext, vietnamese. Chữ tiếng Việt cần cả hai subset latin và vietnamese:
- Những chữ như "à", "á", "â", "ê", "ô" nằm trong Latin-1, thuộc subset
latin. - Nhưng "ă", "đ", "ơ", "ư" và phần lớn chữ có dấu kép như "ế", "ệ", "ữ" nằm ở các dải khác (ví dụ khối Latin Extended Additional, từ
U+1EA0đếnU+1EF9), thuộc subsetvietnamese.
Nếu chỉ khai báo latin, trình duyệt sẽ vẽ những ký tự thiếu bằng font fallback — và bạn có những từ mà nửa chữ một font, nửa chữ font khác. Lỗi này rất dễ lọt qua review nếu người test chỉ nhìn chữ tiếng Anh.
/* latin — rút gọn dải ký tự để minh hoạ */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var-latin.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+2000-206F, U+20AC, U+2122;
}
/* vietnamese — chỉ được tải khi trang có ký tự trong các dải này */
@font-face {
font-family: "Inter";
src: url("/fonts/inter-var-vietnamese.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+1EA0-1EF9, U+20AB;
}
Hai file này dùng chung font-family, nên trong CSS bạn chỉ viết font-family: "Inter" như bình thường; trình duyệt tự chọn file theo ký tự. Với trang tiếng Việt, cả hai file đều được tải — nên preload cả hai nếu font này nằm ở màn hình đầu tiên.
Văn bản tiếng Việt có thể ở dạng dựng sẵn (NFC, "ệ" là một ký tự) hoặc tổ hợp (NFD, "e" cộng các dấu riêng). Dữ liệu copy từ một số nguồn hay ở dạng NFD và hiển thị dấu lệch với vài font. Chuẩn hoá nội dung về NFC bằng text.normalize("NFC") khi lưu là một thói quen tốt.
Variable font#
Font truyền thống cần một file cho mỗi độ đậm và kiểu: Regular, Medium, Semibold, Bold, cộng thêm Italic — dễ thành sáu tám file. Variable font gói cả dải độ đậm (và có khi cả độ rộng, độ nghiêng) vào một file, khai báo bằng một dải như font-weight: 100 900.
Variable font không tự động nhỏ hơn: một file variable thường nặng hơn một file tĩnh đơn lẻ. Nó thắng khi bạn dùng từ hai, ba độ đậm trở lên — vì khi đó một file thay cho nhiều file, ít request hơn, và bạn được tự do dùng font-weight: 550 nếu thiết kế cần. Nếu cả trang chỉ dùng Regular và Bold, hai file tĩnh đã subset kỹ có khi còn nhẹ hơn. Hãy đo thay vì đoán.
Tự host hay dùng CDN bên thứ ba#
Trước đây, lý lẽ phổ biến cho Google Fonts là "người dùng có thể đã có font trong cache từ site khác". Lý lẽ đó không còn đúng: các trình duyệt lớn đã phân vùng HTTP cache theo site, nên font tải từ site A không được dùng lại cho site B.
| Tự host | CDN bên thứ ba | |
|---|---|---|
| Kết nối | Dùng chung kết nối với site | Cần thêm DNS, TCP, TLS tới domain khác (thường là hai domain: CSS và font) |
| Preload | Dễ, URL ổn định | Khó hơn, URL file font có thể thay đổi |
| Cache | Bạn kiểm soát header | Không còn lợi thế cache chéo giữa các site |
| Quyền riêng tư | Không gửi IP người dùng cho bên thứ ba | Có thể là vấn đề pháp lý ở một số nơi (ví dụ EU) |
| Công sức | Phải tự subset, cập nhật | Gần như bằng không |
Mình gần như luôn tự host. Phục vụ font với Cache-Control: public, max-age=31536000, immutable và đặt tên file kèm hash hoặc phiên bản để có thể cache vĩnh viễn. Nếu vẫn dùng CDN bên thứ ba, ít nhất thêm preconnect tới domain chứa file font.
Chỉnh metric font fallback để giảm layout shift#
Đây là kỹ thuật tạo ra khác biệt lớn nhất cho CLS. Ý tưởng: tạo một @font-face "giả" trỏ tới font hệ thống (như Arial), rồi chỉnh các thông số của nó để chiếm đúng bằng không gian mà web font sẽ chiếm. Khi đổi font, chữ vẫn trông khác, nhưng dòng không dài ra, không xuống dòng ở chỗ khác — và layout đứng yên.
Các descriptor dùng được trong @font-face:
size-adjust— co giãn kích thước glyph theo phần trăm, để độ rộng trung bình khớp với web font.ascent-override,descent-override— chỉnh phần trên và dưới đường chân chữ, ảnh hưởng chiều cao dòng.line-gap-override— chỉnh khoảng cách thêm giữa các dòng.
@font-face {
font-family: "Inter Fallback";
src: local("Arial");
/* Giá trị minh hoạ — hãy tính từ metric thật của font bạn dùng */
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: "Inter", "Inter Fallback", system-ui, sans-serif;
}
Đừng đoán những con số này bằng mắt. Chúng được tính từ metric thật của hai font (units per em, ascender, descender, độ rộng trung bình của glyph); có những công cụ và thư viện làm việc này tự động — và như phần sau sẽ thấy, Next.js làm sẵn cho bạn. Mức hỗ trợ các descriptor này khác nhau giữa trình duyệt, nhưng trình duyệt không hỗ trợ chỉ đơn giản bỏ qua chúng, nên dùng không có hại gì.
next/font trong Next.js#
Nếu dùng Next.js, next/font gói gần như mọi thứ ở trên vào vài dòng:
- Tự host tự động: với
next/font/google, file font được tải về lúc build và phục vụ từ chính domain của bạn. Trình duyệt người dùng không gửi request nào tới Google. - Subset theo khai báo, kèm
unicode-rangeđúng. - Preload các subset bạn khai báo.
- Fallback đã chỉnh metric: Next.js tự sinh
@font-facefallback vớisize-adjustvà các override tính từ metric font.
import type { ReactNode } from "react";
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin", "vietnamese"], // đừng quên "vietnamese"
display: "swap",
variable: "--font-sans",
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="vi" className={inter.variable}>
<body>{children}</body>
</html>
);
}
Sau đó dùng var(--font-sans) trong CSS hoặc trong cấu hình Tailwind. Với font tự mua hay font nội bộ, next/font/local cho cùng lợi ích: bạn trỏ tới file WOFF2 trong repo, Next.js lo phần còn lại.
Vài lưu ý khi dùng:
- Gọi hàm font ở cấp module, một lần, rồi dùng lại ở mọi nơi. Gọi trong nhiều file sẽ tạo ra nhiều bản font.
- Chọn font có subset
vietnamese. Không phải font nào trên Google Fonts cũng có; kiểm tra trước khi thiết kế chốt font. - Nếu font chỉ dùng cho một phần nhỏ không nằm ở màn hình đầu tiên, đặt
preload: falseđể nó không giành băng thông với nội dung chính.
Tóm tắt#
- FOIT làm chậm LCP, FOUT gây CLS — mục tiêu là chữ hiện sớm mà layout không nhảy.
- Chọn
font-displaycó chủ đích:swapđi cùng metric override,optionalkhi ưu tiên ổn định tuyệt đối. - Preload một hoặc hai file font quan trọng nhất, nhớ
crossorigin, chỉ dùng WOFF2. - Subset font và dùng
unicode-range; trang tiếng Việt cần cả subsetlatinlẫnvietnamese. - Variable font đáng dùng khi cần từ hai, ba độ đậm trở lên — đo kích thước trước khi quyết định.
- Ưu tiên tự host: cache chéo giữa các site không còn, và bạn bớt một kết nối.
- Chỉnh fallback bằng
size-adjust,ascent-override,descent-overridetừ metric thật. - Trong Next.js, dùng
next/fontvớisubsets: ["latin", "vietnamese"].