มาจัดการ react build bundle แบบบ้านๆกัน

O'leang Sothon
odds.team
Published in
4 min readJun 18, 2024

หนึ่งในวิธีจัดการขนาดของ bundle ที่เราคิดออกได้ทันทีคือ code ไหน ที่ไม่ได้ใช้ ก็ไม่ต้อง build ซะสิ กระบวนการนี้เราเรียกว่า Tree Shaking โดยปกติ เครื่องมือที่ใช้ในการ build เช่น Webpack, Rollup และอื่นๆ จะเปิดใช้งาน Tree Shaking เป็นค่า default อยู่แล้ว

// utils.js
export function add(a, b) {
return a + b;
}

export function subtract(a, b) {
return a - b;
}
// main.js
import { add } from './utils';

console.log(add(2, 3));

ไฟล์ utils.js มี export มา 2 function แต่ใน main.js ใช้แค่ function add อันเดียว ตอน build function subtract ก็จะถูกลบออกไปเพราะไม่ได้ใช้งาน ทำให้ bundle มีขนาดเล็กลง

อย่างไรก็ตาม การที่เครื่องมือช่วย build ทำ Tree Shaking อย่างเดียวอาจยังไม่เพียงพอ เพราะเมื่อเราใช้คำสั่ง npm run build เราจะได้ไฟล์ .js และ .css ตามรูปด้านล่างนี้ ถึงแม้ว่าเราจะลบโค้ดที่ไม่ได้ใช้ออกไปแล้ว แต่ขนาดของไฟล์ .js ก็ยังคงใหญ่อยู่ดี เนื่องจากเครื่องมือเหล่านี้ยังไม่สามารถ split file ได้อย่างเหมาะสมตามการใช้งานจริง

Build แบบไม่ split อะไรเลย

ทำไมเราต้อง split ?

ในรูปตัวอย่าง ไฟล์ index-xxx.js มีขนาดเล็กเพราะโปรเจกต์เพิ่งถูกสร้างขึ้นและยังไม่มี component ใดๆ แต่ถ้าใน project ของเรามี pages ต่างๆ มากมาย รวมกันขนาด bundle เราคงใหญ่ไปเรื่อยๆ คงจะเกิน 1000Kb ได้สบายๆเลย

หากผู้ใช้ต้องการเข้าไปที่ path /Home ซึ่งอาจมีขนาดแค่ 2kB ถ้าเราไม่ split code ผู้ใช้ก็ต้องโหลดของทั้งหมด 1000kB ข้อดีของการโหลด code ทั้งหมดคือ เมื่อผู้ใช้เปลี่ยนหน้า อาจจะไม่ต้องโหลดใหม่ แต่ถ้าไฟล์มีขนาดใหญ่มากๆ ก็จะมีปัญหาในการโหลดครั้งแรกที่ใช้เวลานาน และยิ่งมีผลกระทบมากขึ้นหากความเร็วอินเทอร์เน็ตช้าหรือไม่เสถียร

การ split code ช่วยแก้ปัญหานี้ได้ โดยแยกไฟล์ตาม pages หรือ components ต่างๆ ทำให้ผู้ใช้โหลดเฉพาะของที่จำเป็นต้องใช้จริงๆเท่านั้น ซึ่งช่วยให้การโหลดเร็วขึ้นและประสบการณ์การใช้งานดีขึ้นด้วย

ในการทำ Code Splitting มีหลายแบบ แต่ละแบบก็มีข้อดีและข้อเสียต่างกันไป วันนี้ผมจะขอแนะนำวิธีต่างๆ ที่น่าสนใจกันครับ

1. Dynamic imports

// Static Import
import Dropdown from "./modules/Dropdown.js";

// Dynamic Import
const Dropdown = await import("./modules/Dropdown.js");

Dynamic import นั้นมีมาตอน JS ES2020 จะเป็นการ import แบบ Async ทำให้สามารถช่วยลดขนาดของ chunk size และเรียกใช้ไฟล์ตอน runtime ถ้าเราสร้าง function เป็น async และใช้ dynamic import พวก tools ที่ช่วย build ต่างๆ ก็จะรู้เองว่า เราต้องการจะ split file

Static Import จะเป็นแบบ Synchronous ทำให้ตอน build ไฟล์จะถูก bundle รวมกันทั้งหมด

2. Component-level Code Splitting

React เองก็มี function React.lazy() เพื่อ import component แบบ Asyn และใช้ Suspense เพื่อแสดง Loading State ได้ด้วย ในตอนที่ Component Home กับ Aboutกำลัง load จะแสดงข้อความ “Loading…” และเราก็สามารถใส่ Spin loading หรือ Skeleton placeholder เพิ่มเข้าไปได้อีกด้วย

import React, { lazy, Suspense } from 'react';

const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));

function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<div>
<h1>Welcome to My App</h1>
<Home />
<About />
</div>
</Suspense>
);
}

export default App;
Home และ About ถูก split ด้วยการใช้ React.lazy()

3. Route-based Code Splitting

ส่วนใหญ่แล้ว app ของเรามักจะเป็นแบบ pages โดยมีเมนูต่างๆ การใช้ dynamic imports ช่วยให้เราสามารถ load ของเป็นก้อนๆ ตอน runtime แต่ถ้าเรามี route ที่เป็น group ของ components อยู่แล้ว เราก็สามารถใช้ร่วมกับ React Router เพื่อที่จะ split code ตาม component ของ page นั้นๆได้อีกที

ตัวอย่างเช่น เมื่อผู้ใช้เข้าไปที่ route /Home app จะ load เฉพาะ code ที่จำเป็นสำหรับหน้า Home เท่านั้น ทำให้การโหลดเร็วขึ้น และลดขนาดไฟล์ที่ต้องโหลดในครั้งแรก

import { Outlet, Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from 'react-router-dom';
import './App.css'
import { Suspense, lazy } from 'react';

const LoadingPage = () => (
<Suspense fallback={<div>Loading...</div>}>
<Outlet />
</Suspense>
)

const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));

const routes = createRoutesFromElements(
<Route path="/" element={<LoadingPage />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
</Route>
);

const router = createBrowserRouter(routes);

function App() {
return <RouterProvider router={router} />;
}

export default App
Split ตาม router

4. Vendor Splitting

จากหัวข้อก่อนหน้า ทำให้เราสามารถ splite component ให้ load แบบ runtime รวมถึง split page ต่างๆ ได้แล้ว แต่ถ้าสังเกตุดู จะพบว่าไฟล์ index-xxx.js ยังคงมีขนาดที่ใหญ่กว่าคนอื่นๆ อยู่ดี ทำไมถึงเป็นอย่างนั้นนะ ?? เรามาดูกัน

ก่อนอื่นในบทความนี้ ผมใช้ vite/rollup เลยต้องเพิ่ม plugin ที่ชื่อว่า rollup-plugin-visualizer ใน file vite.config.tsกันก่อน

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from "rollup-plugin-visualizer";

export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
}),
]
})

เมื่อเรา npm run build เราจะได้ ไฟล์ stats.html ที่ด้านในมีหน้าตาแบบรูปด้านล่างนี้ ถ้า zoom ดูจะพบว่า สิ่งที่มันใหญ่ มันคือ node_module หรือก็คือ พวก lib ต่างๆ ที่เรา เพิ่มเข้ามานั่นเอง (ในกรณีของผม react-dom กับ router มันเลยใหญ่ที่สุดเลย)

visualizer chunk

จะดีกว่าไหม ถ้าเราแยก code หลักของ app ออกจาก third-party libraries วิธีนี้จะช่วยลดขนาดของไฟล์ที่ต้องโหลดในครั้งแรก และยังช่วยให้ browsor cache ได้ดียิ่งขึ้นอีกด้วย เพราะว่า code ของ third-party libraries มักจะไม่เปลี่ยนแปลงบ่อยๆนั่นเอง

ตัวอย่างการ splite vendor แบบง่ายๆ เราจะได้ ไฟล์ vendor-xxx.js มาเพิ่มอีกอันนึง

export default defineConfig({
...
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes('node_modules')) {
return 'vendor';
}
return null;
}
}
}
}
})
ได้ file vendor แยกจาก index แล้ว

เราสามารถจัด group ของ lib แบบนี้ก็ได้ด้วยนะ

export default defineConfig({
...
build: {
rollupOptions: {
output: {
manualChunks(id: string) {
if (id.includes('node_modules')) {
if (
id.includes('react-router-dom') ||
id.includes('@remix-run') ||
id.includes('react-router')
) {
return 'react-router';
}

return 'vendor';
}

return null;
},
},
},
},
})
Splite react-router

5. Compression

ถ้าสังเกตุดู log ตอนที่เราได้ตอน build มันจะมี |gzip: xxx kB อยู่ด้วย แต่จริงๆแล้ว ของที่เราได้ใน /dist หลังจาก build เราได้แค่ file .js

ถ้าเราอยากได้ file gzip ด้วย เราต้องเพิ่ม plugin vite-plugin-compression หรือ rollup-plugin-gzip ไปด้วยครับ

import { defineConfig } from 'vite';
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
plugins: [viteCompression()],
});

** ถ้าใช้ nginx ต้องไป set ให้รองรับ gzip กันด้วยนะ

จบแล้ว…

ขอบคุณที่สนใจ และอ่านจนจบนะครับ

Ref:
https://dev.to/tassiofront/splitting-vendor-chunk-with-vite-and-loading-them-async-15o3

https://blog.logrocket.com/react-dynamic-imports-route-centric-code-splitting-guide/

--

--