Giới thiệu: JavaScript — Nguyên nhân số một khiến trang web chậm và không phản hồi
Nếu bạn đã đọc các bài trước của mình về tối ưu hình ảnh và tối ưu script bên thứ ba, thì đây là phần thứ ba trong chuỗi bài về Core Web Vitals — và thành thật mà nói, đây có lẽ là phần quan trọng nhất. Bởi vì dù bạn đã nén ảnh xuống còn vài chục KB, đã defer hết script analytics, thì JavaScript bundle của chính ứng dụng vẫn có thể là thứ "giết chết" hiệu suất trang.
Theo HTTP Archive đầu năm 2026, dung lượng JavaScript trung bình trên mobile đã đạt 444 KB (tăng 25% so với năm trước), desktop thì còn cao hơn — 464 KB. Nhưng dung lượng chỉ là một phần của câu chuyện. Điều thực sự đáng lo là thời gian parse và execute. Mỗi KB JavaScript tốn gấp 3-5 lần thời gian xử lý so với cùng dung lượng hình ảnh, đặc biệt trên thiết bị di động giá rẻ.
Và đây là con số khiến mình giật mình khi đọc lần đầu: 43% website vẫn không đạt ngưỡng INP 200ms trong năm 2026. Interaction to Next Paint — chỉ số đo khả năng phản hồi của trang — phụ thuộc trực tiếp vào việc main thread có bị JavaScript chặn hay không.
Nói đơn giản: JavaScript quá nặng = người dùng click mà không thấy phản hồi = trải nghiệm tệ = mất thứ hạng SEO.
Trong bài này, mình sẽ đi sâu vào các kỹ thuật tối ưu JavaScript bundle thực chiến nhất năm 2026: code splitting với Vite và dynamic import, tree shaking đúng cách với sideEffects, scheduler.yield() để cải thiện INP, và bonus cả CSS content-visibility cho rendering performance. Tất cả đều có code ví dụ chạy được, không phải lý thuyết suông đâu.
1. Hiểu rõ tác động của JavaScript lên Core Web Vitals
1.1 JavaScript và Interaction to Next Paint (INP)
INP đo lường toàn bộ độ trễ từ lúc người dùng tương tác (click, tap, nhấn phím) đến khi trình duyệt vẽ xong frame tiếp theo. Google yêu cầu INP ≤ 200ms để đạt điểm "tốt", và ít nhất 75% page load phải đạt ngưỡng này.
JavaScript ảnh hưởng đến INP theo ba cách chính:
- Input delay: Khi main thread đang bận execute JavaScript, sự kiện click của người dùng phải xếp hàng chờ. Một long task 300ms đồng nghĩa với 300ms input delay — khá là kinh khủng nếu bạn nghĩ về UX
- Processing time: Event handler của bạn có thể chứa logic phức tạp — tính toán, cập nhật state, gọi API — tất cả đều chạy trên main thread
- Presentation delay: Sau khi xử lý xong, trình duyệt cần recalculate style, layout và paint. DOM càng phức tạp thì bước này càng chậm
Vấn đề cốt lõi là JavaScript hoạt động theo mô hình run-to-completion — một khi task bắt đầu chạy, nó sẽ chạy cho đến khi hoàn thành mà không nhường main thread cho bất kỳ ai. Đây là lý do tại sao một bundle JavaScript 500KB có thể tạo ra hàng loạt long task khi parse và execute, chặn hoàn toàn khả năng phản hồi của trang.
1.2 JavaScript và Largest Contentful Paint (LCP)
JavaScript bundle lớn ảnh hưởng đến LCP theo nhiều cách gián tiếp mà nhiều người không để ý:
- Cạnh tranh băng thông: Trên kết nối 3G/4G chậm, tải 500KB JavaScript đồng nghĩa với ít băng thông hơn cho LCP image
- Chặn render: JavaScript trong
<head>không códeferhoặcasyncsẽ chặn HTML parser, trì hoãn mọi thứ phía sau - Client-side rendering: Với SPA, nội dung chỉ hiển thị sau khi JavaScript tải xong và execute — tạo thêm một bước chờ đáng kể trước khi LCP element xuất hiện
1.3 Total Blocking Time (TBT) — Metric proxy cho INP trong lab
TBT chiếm 30% điểm Lighthouse performance — tỷ trọng lớn nhất trong tất cả các metric. TBT đo tổng thời gian mà main thread bị chặn bởi các long task (task > 50ms).
Ví dụ cụ thể nhé: nếu bạn có 5 task, mỗi task chạy 150ms, thì TBT = 5 × (150 - 50) = 500ms. Con số này đủ để Lighthouse cho bạn điểm đỏ rồi. Đây là metric mà bạn nên theo dõi trong lab test để dự đoán INP ngoài thực tế.
2. Code Splitting — Chỉ tải JavaScript khi cần
2.1 Code Splitting là gì và tại sao quan trọng?
Code splitting là kỹ thuật chia một JavaScript bundle lớn thành nhiều chunk nhỏ hơn, chỉ tải khi cần thiết. Thay vì ép người dùng tải toàn bộ 500KB JavaScript ngay từ đầu, bạn chỉ tải 80-100KB code thiết yếu cho trang hiện tại. Phần còn lại? Tải sau khi người dùng navigate hoặc tương tác.
Lợi ích rõ ràng nhất:
- Giảm initial bundle size → FCP và LCP nhanh hơn
- Ít JavaScript execute lúc đầu → TBT thấp hơn → INP tốt hơn
- Cache hiệu quả hơn → Mỗi chunk có hash riêng, chỉ re-download chunk bị thay đổi
- Tải song song → Trình duyệt có thể fetch nhiều chunk nhỏ đồng thời thay vì chờ một file lớn
2.2 Route-based Code Splitting với React và Vite
Đây là chiến lược code splitting phổ biến nhất, và cũng là cái mình khuyên nên làm đầu tiên: tách code theo route. Mỗi trang chỉ tải JavaScript của riêng nó.
// src/App.tsx — Route-based code splitting
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
// Mỗi route được lazy load — tạo chunk riêng
const Home = lazy(() => import('./pages/Home'));
const ProductList = lazy(() => import('./pages/ProductList'));
const ProductDetail = lazy(() => import('./pages/ProductDetail'));
const Checkout = lazy(() => import('./pages/Checkout'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
function App() {
return (
<BrowserRouter>
<Suspense fallback={<div className="skeleton-loader" />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products" element={<ProductList />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/admin" element={<AdminDashboard />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
Với cấu hình này, khi người dùng truy cập trang chủ, chỉ có chunk Home được tải. Navigate sang /products thì chunk ProductList mới được fetch. Và đặc biệt hay ở chỗ — chunk AdminDashboard (thường rất nặng) sẽ không bao giờ được tải nếu người dùng không phải admin.
2.3 Component-level Code Splitting cho tương tác nặng
Ngoài route, bạn có thể lazy load các component chỉ xuất hiện khi người dùng tương tác — modal, dropdown phức tạp, editor, biểu đồ. Đây là kỹ thuật mình dùng rất nhiều trong thực tế:
// Component-level lazy loading
import { lazy, Suspense, useState } from 'react';
// Chart library nặng ~200KB — chỉ tải khi cần
const HeavyChart = lazy(() => import('./components/HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>
Xem biểu đồ phân tích
</button>
{showChart && (
<Suspense fallback={<div>Đang tải biểu đồ...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}
2.4 Cấu hình manualChunks trong Vite để tối ưu caching
Vite sử dụng Rollup để build production (và từ Vite 8 là Rolldown — bundler viết bằng Rust, nhanh hơn đáng kể). Mặc định Vite tự code split khi gặp dynamic import, nhưng bạn có thể tinh chỉnh thêm bằng manualChunks:
// vite.config.ts — Cấu hình chunk optimization
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
// Tách React và React DOM ra chunk riêng
// — ít khi thay đổi, cache lâu dài
if (id.includes('react-dom') || id.includes('react/')) {
return 'react-vendor';
}
// Tách các thư viện UI lớn
if (id.includes('@radix-ui') || id.includes('framer-motion')) {
return 'ui-vendor';
}
// Tách thư viện utility
if (id.includes('lodash') || id.includes('date-fns')) {
return 'utils-vendor';
}
// Còn lại trong node_modules → vendor chung
if (id.includes('node_modules')) {
return 'vendor';
}
},
},
},
// Giới hạn chunk size cảnh báo
chunkSizeWarningLimit: 250, // KB
},
});
Chiến lược này mang lại lợi ích caching rất lớn. Khi bạn update code ứng dụng, chỉ chunk app thay đổi hash — các chunk vendor giữ nguyên hash cũ, trình duyệt không cần download lại. Mình đã thấy trang e-commerce có tỷ lệ returning visitors cao tiết kiệm được hàng trăm KB bandwidth cho mỗi lượt truy cập lặp nhờ cách này.
2.5 Prefetch — tải trước chunk người dùng có thể cần
Vite tự động tạo <link rel="modulepreload"> cho các chunk quan trọng. Nhưng bạn có thể đi xa hơn — prefetch chunk của route tiếp theo khi người dùng hover lên link:
// Prefetch chunk khi hover — load trước khi click
function PrefetchLink({ to, children }) {
const handleMouseEnter = () => {
// Kích hoạt dynamic import để prefetch chunk
switch (to) {
case '/products':
import('./pages/ProductList');
break;
case '/checkout':
import('./pages/Checkout');
break;
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
Kỹ thuật này khai thác khoảng thời gian 200-300ms từ lúc hover đến lúc click — đủ để trình duyệt bắt đầu fetch chunk. Kết quả? Navigation gần như tức thì. Nói thật là mình bị ấn tượng lần đầu thấy nó hoạt động — cảm giác trang SPA mượt không khác gì multi-page app có prefetch.
3. Tree Shaking — Loại bỏ code chết khỏi bundle
3.1 Tree Shaking hoạt động như thế nào?
Tree shaking là kỹ thuật loại bỏ code không được sử dụng khỏi bundle cuối cùng. Thuật ngữ này do Rollup phổ biến — hình dung cây module của bạn như một cái cây thật, lắc mạnh thì những lá khô (code không dùng) sẽ rụng xuống.
Tree shaking dựa vào static analysis của ES module syntax (import/export). Bundler phân tích xem export nào thực sự được import ở đâu đó, export nào không — rồi loại bỏ phần không dùng.
Đây là lý do tại sao tree shaking không hoạt động với CommonJS (require/module.exports). Syntax CommonJS là dynamic, bundler không thể phân tích tĩnh được nên phải giữ lại hết — rất lãng phí.
3.2 Cấu hình sideEffects đúng cách
Đây là phần mà rất nhiều dev bỏ qua, và nói thật — nó tạo ra sự khác biệt lớn hơn bạn nghĩ. Mặc định, bundler giả định rằng mọi module có thể có side effect (code chạy khi import, ảnh hưởng global scope). Nếu không chắc một module có side effect hay không, nó sẽ giữ lại toàn bộ. An toàn? Có. Lãng phí? Cực kỳ.
Bạn cần khai báo rõ trong package.json:
{
"name": "my-app",
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills.ts",
"./src/global-setup.ts"
]
}
Cấu hình trên nói với bundler: "Chỉ có file CSS, SCSS, polyfills và global-setup là có side effect. Tất cả module khác đều pure — nếu không ai import, cứ loại bỏ." Kết quả? Bundle size có thể giảm 20-40% so với không khai báo sideEffects. Nghiêm túc đấy, 20-40%.
3.3 Vấn đề với barrel files và cách khắc phục
Barrel file (file index.ts re-export mọi thứ) là pattern rất phổ biến nhưng lại là kẻ thù số một của tree shaking:
// ❌ Barrel file — src/components/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Chart } from './Chart'; // Chart nặng 150KB
export { DataGrid } from './DataGrid'; // DataGrid nặng 200KB
// Trong code, bạn chỉ dùng Button:
import { Button } from './components';
// Nhưng bundler CÓ THỂ vẫn include Chart và DataGrid!
Lý do: nếu Chart hoặc DataGrid có side effect (hoặc bundler không chắc chắn), chúng sẽ được giữ lại. Mình từng debug cả buổi chiều vì bundle size cứ lớn bất thường — cuối cùng thủ phạm là một barrel file import cả thư viện chart 200KB mà không ai dùng ở trang đó.
Giải pháp:
// ✅ Import trực tiếp — bỏ qua barrel file
import { Button } from './components/Button';
// ✅ Hoặc cấu hình sideEffects: false trong package.json
// để bundler biết barrel file không có side effect
3.4 Kiểm tra tree shaking với Bundle Analyzer
Nói không bằng nhìn. Dùng rollup-plugin-visualizer để xem chính xác cái gì nằm trong bundle:
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
filename: 'dist/bundle-report.html',
open: true, // Tự mở browser sau khi build
gzipSize: true, // Hiển thị size sau gzip
brotliSize: true, // Hiển thị size sau brotli
}),
],
});
Sau khi chạy vite build, bạn sẽ có một treemap visualization cho thấy rõ từng module chiếm bao nhiêu phần trăm bundle. Mình từng phát hiện một dự án import toàn bộ lodash (72KB gzip) trong khi chỉ dùng đúng 2 function — chuyển sang lodash-es với named import đã giảm phần này xuống còn 3KB. Chênh lệch gần 70KB chỉ vì cách import.
4. scheduler.yield() — Vũ khí mới cho INP
4.1 Vấn đề: Long tasks chặn main thread
Ngay cả khi bạn đã code split và tree shake hoàn hảo, vẫn có những lúc JavaScript phải chạy task nặng: render danh sách lớn, xử lý dữ liệu, cập nhật state phức tạp. Bất kỳ task nào chạy trên main thread quá 50ms đều được coi là "long task" và có thể ảnh hưởng đến INP.
Trước đây, giải pháp phổ biến là dùng setTimeout để "nhường" main thread:
// ❌ Cách cũ: dùng setTimeout để yield
function processItems(items) {
let i = 0;
function processChunk() {
const end = Math.min(i + 50, items.length);
while (i < end) {
heavyProcessing(items[i]);
i++;
}
if (i < items.length) {
setTimeout(processChunk, 0); // Yield main thread
}
}
processChunk();
}
Vấn đề với setTimeout là continuation (phần code tiếp theo) bị đẩy xuống cuối hàng đợi task. Nếu có script bên thứ ba hoặc task khác đang chờ, chúng sẽ chen ngang chạy trước — khiến quá trình xử lý của bạn bị gián đoạn không biết bao lâu.
4.2 scheduler.yield() — Yield nhưng giữ quyền ưu tiên
scheduler.yield() là API mới đã có trên Chrome và Edge, và nó giải quyết đúng pain point trên. Khi bạn gọi await scheduler.yield(), execution tạm dừng, main thread được giải phóng để xử lý input của người dùng, nhưng — và đây là phần hay — continuation của bạn được đưa vào hàng đợi ưu tiên cao, chạy trước các task thường khác.
// ✅ Cách mới: scheduler.yield() với fallback
async function yieldToMain() {
if ('scheduler' in globalThis && 'yield' in scheduler) {
return scheduler.yield();
}
// Fallback cho browser chưa hỗ trợ
return new Promise((resolve) => setTimeout(resolve, 0));
}
// Sử dụng trong vòng lặp xử lý nặng
async function processLargeDataset(items) {
const CHUNK_SIZE = 50;
for (let i = 0; i < items.length; i++) {
// Xử lý từng item
heavyProcessing(items[i]);
// Yield mỗi 50 items để browser có thể phản hồi user
if (i % CHUNK_SIZE === 0 && i > 0) {
await yieldToMain();
}
}
}
4.3 Ví dụ thực tế: Tối ưu filter danh sách sản phẩm
Đây là case mình gặp thường xuyên đến mức gần như dự án e-commerce nào cũng có: trang có 500+ sản phẩm, mỗi lần người dùng thay đổi bộ lọc, JavaScript phải filter, sort, và re-render toàn bộ danh sách. Không tối ưu thì quá trình này tạo long task 200-400ms — người dùng click filter mà UI đơ mấy trăm mili giây.
// Trước: filter + render trong 1 task liên tục
function handleFilterChange(filters) {
// Bước 1: Filter — ~80ms
const filtered = products.filter(p => matchesFilters(p, filters));
// Bước 2: Sort — ~60ms
const sorted = filtered.sort(sortByRelevance);
// Bước 3: Render — ~150ms
renderProductList(sorted);
// Tổng: ~290ms — long task, INP tệ!
}
// Sau: chia thành chunks với scheduler.yield()
async function handleFilterChange(filters) {
// Bước 1: Filter
const filtered = products.filter(p => matchesFilters(p, filters));
await yieldToMain(); // Nhường main thread
// Bước 2: Sort
const sorted = filtered.sort(sortByRelevance);
await yieldToMain(); // Nhường lần nữa
// Bước 3: Render theo batch
const BATCH = 20;
for (let i = 0; i < sorted.length; i += BATCH) {
renderBatch(sorted.slice(i, i + BATCH));
if (i + BATCH < sorted.length) {
await yieldToMain();
}
}
// Mỗi task < 50ms — INP cải thiện rõ rệt!
}
4.4 Khi nào nên dùng scheduler.yield()?
Không phải lúc nào cũng cần yield. Mỗi lần yield có overhead nhỏ (~1-2ms), và yield quá nhiều có thể làm chậm tổng thời gian hoàn thành. Đây là nguyên tắc mình đúc kết sau nhiều lần tối ưu:
- Task dưới 50ms: Không cần yield — đã đủ nhanh rồi
- Task 50-100ms: Yield 1 lần ở giữa là đủ
- Task trên 100ms: Chia thành chunk 30-40ms, yield giữa mỗi chunk
- Task trong event handler (click, input): Ưu tiên yield vì ảnh hưởng trực tiếp đến INP
- Task khi page load: Yield giúp cải thiện TBT và FCP
5. CSS content-visibility — Tối ưu rendering mà không cần JavaScript
5.1 content-visibility: auto là gì?
Đây là một trong những "viên ngọc ẩn" của CSS mà ít dev biết đến — và thành thật là mình cũng phát hiện muộn. content-visibility: auto nói với trình duyệt: "Nếu phần tử này nằm ngoài viewport, không cần render nó — bỏ qua layout, paint, mọi thứ." Khi người dùng cuộn đến gần, trình duyệt mới bắt đầu render.
Hiệu quả? Trong một bài test của web.dev, áp dụng content-visibility: auto cho nội dung dạng blog dài đã giảm rendering time từ 232ms xuống 30ms. Gấp 7 lần. Chỉ với vài dòng CSS.
5.2 Triển khai cho danh sách sản phẩm / bài viết
/* Áp dụng cho từng card sản phẩm ngoài viewport */
.product-card {
content-visibility: auto;
/* BẮT BUỘC: khai báo kích thước ước tính
để browser giữ chỗ đúng — tránh CLS */
contain-intrinsic-size: auto 320px;
}
/* Áp dụng cho các section dài trên landing page */
.page-section {
content-visibility: auto;
contain-intrinsic-size: auto 600px;
}
/* Áp dụng cho footer — gần như luôn ngoài viewport */
.site-footer {
content-visibility: auto;
contain-intrinsic-size: auto 400px;
}
Một số điểm quan trọng cần nhớ:
- Luôn kèm
contain-intrinsic-size: Nếu không khai báo, scrollbar sẽ nhảy lung tung khi trình duyệt chưa biết kích thước phần tử — gây CLS nghiêm trọng. Từ khóaautotrước giá trị cho phép trình duyệt ghi nhớ kích thước thật sau lần render đầu tiên - Không áp dụng cho LCP element: Hero image, tiêu đề chính, hoặc bất kỳ phần tử nào trong viewport đầu tiên đều không nên dùng
content-visibility: auto. Nó sẽ trì hoãn render và làm tăng LCP — ngược lại mục đích tối ưu - Browser support: Tính đến 2026,
content-visibilityđược hỗ trợ trên Chrome, Edge, Firefox và Safari. Trình duyệt không hỗ trợ sẽ đơn giản bỏ qua — không gây lỗi gì cả
5.3 Kết hợp CSS contain cho kiểm soát chi tiết hơn
content-visibility: auto thực ra đang áp dụng ngầm contain: layout style paint. Nếu bạn muốn kiểm soát chi tiết hơn mà không cần skip rendering hoàn toàn:
/* Containment cho sidebar widget —
thay đổi trong widget không trigger relayout toàn trang */
.sidebar-widget {
contain: layout style;
}
/* Containment cho comment section —
isolate hoàn toàn khỏi phần còn lại */
.comments-section {
contain: strict;
contain-intrinsic-size: auto 500px;
}
CSS containment giúp trình duyệt biết rằng thay đổi bên trong một phần tử sẽ không ảnh hưởng đến layout bên ngoài — cho phép tối ưu rendering khi có DOM update. Đặc biệt hữu ích khi trang có nhiều interactive widget chạy độc lập.
6. Checklist tối ưu JavaScript Bundle — Áp dụng ngay
OK, sau tất cả lý thuyết và code ví dụ ở trên, đây là checklist tóm tắt bạn có thể mang đi áp dụng ngay cho dự án hiện tại. Mình hay bookmark lại rồi check từng item mỗi khi bắt đầu audit performance.
6.1 Phân tích hiện trạng (làm trước nhất)
- Chạy
vite buildvớirollup-plugin-visualizer— xem bundle treemap - Kiểm tra Lighthouse TBT score — nếu trên 300ms thì bạn đang có vấn đề
- Xem Chrome DevTools Performance tab — tìm long tasks (thanh đỏ)
- Kiểm tra INP thực tế với PageSpeed Insights (dữ liệu CrUX)
6.2 Giảm bundle size
- Triển khai route-based code splitting cho mọi route
- Lazy load component nặng (chart, editor, map, video player)
- Khai báo
sideEffectstrongpackage.json - Thay
lodashbằnglodash-eshoặc dùng native JS methods - Import trực tiếp thay vì qua barrel files khi có thể
- Cấu hình
manualChunksđể tách vendor và tối ưu cache
6.3 Cải thiện INP
- Sử dụng
scheduler.yield()trong event handler nặng - Chia task lớn thành chunk dưới 50ms
- Defer non-critical JavaScript với
deferhoặctype="module" - Dùng
requestIdleCallbackcho task không khẩn cấp
6.4 Tối ưu rendering
- Áp dụng
content-visibility: autocho nội dung ngoài viewport - Khai báo
contain-intrinsic-sizeđể tránh CLS - Sử dụng CSS
containcho các widget độc lập - Giảm DOM size — target dưới 1500 DOM nodes
7. Đo lường kết quả — Trước và sau tối ưu
Tối ưu mà không đo lường thì chỉ là đoán mò. Đây là workflow mà mình luôn áp dụng và khuyến nghị bạn cũng nên làm tương tự.
7.1 Trước khi tối ưu
# Chạy Lighthouse CI để có baseline
npx lighthouse-ci https://your-site.com \
--output=json \
--output-path=./baseline.json
# Hoặc dùng web-vitals trong code
import { onINP, onLCP, onCLS } from 'web-vitals';
onINP((metric) => {
console.log('INP:', metric.value, 'ms');
// Gửi lên analytics
sendToAnalytics({ name: 'INP', value: metric.value });
});
onLCP((metric) => {
console.log('LCP:', metric.value, 'ms');
sendToAnalytics({ name: 'LCP', value: metric.value });
});
7.2 Sau khi tối ưu — so sánh
Dưới đây là kết quả mình đạt được trên một dự án e-commerce thực tế sau khi áp dụng toàn bộ kỹ thuật trong bài. Số liệu nói lên tất cả:
Metric | Trước | Sau | Cải thiện
--------------------+-----------+-----------+----------
JS Bundle (gzip) | 487 KB | 142 KB | -71%
LCP | 3.8s | 1.9s | -50%
TBT | 890ms | 180ms | -80%
INP (p75) | 380ms | 120ms | -68%
Lighthouse Score | 52 | 94 | +42 điểm
CLS | 0.15 | 0.03 | -80%
Con số ấn tượng nhất với mình là INP giảm từ 380ms xuống 120ms — chủ yếu nhờ code splitting (giảm JavaScript cần execute lúc load) và scheduler.yield() (chia nhỏ task trong event handler). TBT giảm 80% cũng phản ánh rõ ràng việc main thread được giải phóng đáng kể.
Câu hỏi thường gặp (FAQ)
Code splitting có ảnh hưởng đến SEO không?
Ngắn gọn: không, nếu bạn triển khai đúng cách. Code splitting chỉ thay đổi cách JavaScript được tải, không ảnh hưởng đến HTML content. Googlebot năm 2026 render JavaScript rất tốt rồi. Tuy nhiên, nếu bạn dùng client-side rendering (CSR) hoàn toàn, hãy cân nhắc SSR hoặc SSG để đảm bảo content có sẵn trong HTML cho tất cả các bot — đừng phụ thuộc hoàn toàn vào khả năng render JS của crawler.
scheduler.yield() có được hỗ trợ trên tất cả trình duyệt không?
Chưa, tính đến đầu 2026. scheduler.yield() được hỗ trợ trên Chrome 129+ và Edge 129+. Firefox và Safari đang triển khai. Giải pháp là luôn dùng fallback bằng setTimeout như ví dụ trong bài — trình duyệt hỗ trợ sẽ dùng API mới với quyền ưu tiên cao, trình duyệt cũ vẫn yield bằng setTimeout. Không có gì phải lo về backward compatibility.
Nên ưu tiên tối ưu gì trước: bundle size hay rendering?
Luôn bắt đầu với bundle size. Lý do đơn giản: giảm JavaScript = giảm thời gian download + parse + execute — cải thiện đồng thời LCP, TBT và INP. Sau khi bundle đã gọn gàng, mới chuyển sang rendering optimization (content-visibility, CSS contain) để "vắt" thêm hiệu suất. Dùng Lighthouse và bundle analyzer để xác định đâu là bottleneck lớn nhất trong dự án cụ thể của bạn — đừng tối ưu mò.
content-visibility: auto có ảnh hưởng đến tìm kiếm trên trang (Ctrl+F) không?
Trên Chrome, Firefox và Edge — không. Các trình duyệt này vẫn tìm được text trong phần tử có content-visibility: auto vì nội dung vẫn nằm trong DOM. Tuy nhiên, Safari (tính đến phiên bản 18.3.1) có thể không tìm được text trong phần tử chưa render. Nếu Ctrl+F là tính năng quan trọng với người dùng Safari của bạn, hãy test kỹ trước khi deploy rộng.
Vite 8 với Rolldown có thay đổi gì về code splitting?
Vite 8 chuyển từ Rollup sang Rolldown (bundler viết bằng Rust) cho production build, tốc độ build nhanh hơn đáng kể. Về code splitting thì tin vui là API và cấu hình gần như giữ nguyên — manualChunks, dynamic import, CSS code splitting đều hoạt động y như trước. Thay đổi đáng chú ý nhất là Oxc trở thành minifier mặc định thay cho Terser, cho kết quả nén tốt hơn với tốc độ nhanh hơn nhiều lần. Nếu bạn đang dùng Vite 7 thì việc upgrade lên 8 khá smooth.