Script และ Bundle Optimization
ไอเดียหลักในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียหลักในหนึ่งประโยค”ส่ง JavaScript ให้น้อยลงด้วยการโหลด third-party script ในจังหวะที่เหมาะสมผ่าน next/script, code-split client component ที่หนักด้วย next/dynamic, รักษาเส้น Server/Client boundary ให้แคบไว้ และรู้วิธีอ่าน bundle จริง ๆ แทนการเดา
next/script และ strategy ของตัวเอง
หัวข้อที่มีชื่อว่า “next/script และ strategy ของตัวเอง”tag <script> ดิบ ๆ block การ parse และรันทันทีที่ browser ไปถึง next/script ให้คุณเลือกจังหวะด้วย strategy เลือกตัวที่ช้าที่สุดเท่าที่ script ทนได้
beforeInteractive— โหลดก่อนโค้ด Next.js ใด ๆ จะ hydrate เก็บไว้ให้ script ที่วิกฤตจริง ๆ เท่านั้นafterInteractive— ค่า default โหลดหลัง hydration เริ่ม เหมาะกับ analyticslazyOnload— โหลดตอน browser ว่าง เหมาะกับ widget ความสำคัญต่ำอย่าง chat
// A chat widget is low priority — defer it to idle timeimport Script from 'next/script'
export default function Layout({ children }: { children: React.ReactNode }) { return ( <> {children} <Script src="https://widget.example.com/chat.js" strategy="lazyOnload" /> </> )}code-split client component ด้วย next/dynamic
หัวข้อที่มีชื่อว่า “code-split client component ด้วย next/dynamic”next/dynamic (สร้างบน React.lazy) แยก client component ที่หนักออกเป็น chunk ของตัวเองที่โหลดเฉพาะตอน render สำหรับ component ที่แตะ browser-only API ให้ส่ง ssr: false เพื่อไม่ให้ component นั้นรันบน server เลย ทำแบบนี้ได้เฉพาะข้างใน Client Component เท่านั้น — พิสูจน์กับ probe แล้วว่าเรียก dynamic(..., { ssr: false }) จาก Server Component ทำให้ build fail ไม่ใช่แค่ lint warning:
Error: `ssr: false` is not allowed with `next/dynamic` in Server Components.Please move it into a Client Component.'use client'import dynamic from 'next/dynamic'
// Heavy, browser-only — split into its own chunk, no SSRconst Chart = dynamic(() => import('./Chart'), { ssr: false, loading: () => <p>Loading chart…</p>,})
export default function Dashboard() { return <Chart />}อ่านผลลัพธ์ next experimental-analyze
หัวข้อที่มีชื่อว่า “อ่านผลลัพธ์ next experimental-analyze”ทั้ง optimizePackageImports และตำนาน barrel import ล้วนเป็นแค่การเดาจนกว่าคุณจะดูจริง ๆ ว่ามีอะไร ship ออกไปบ้าง Next.js 16.1+ มี bundle analyzer แบบ Turbopack-native เป็น subcommand ของตัวเอง (ยังติดป้าย experimental) มันวิเคราะห์ production build โดยไม่สร้าง build จริง แล้วเปิด UI แบบ interactive หรือเขียนไฟล์ static
npx next experimental-analyze --port 4181 # interactive UInpx next experimental-analyze --output # write .next/diagnostics/analyze, no serverรันกับ probe จริง --output สร้าง tree ที่ตรวจสอบได้จริง — ไม่ใช่ report ไฟล์เดียว แต่เป็น HTML export ต่อ route บวกกับโฟลเดอร์ data/ ที่มี payload ต่อ route และต่อ module:
.next/diagnostics/analyze/ index.html # UI shell data/routes.json # ["/", "/_not-found", "/lodash-barrel", "/lodash-direct", ...] data/modules.data # every module Turbopack saw, by ident/path data/<route>/analyze.data # per-route module graph + sizesเปิด UI แบบ interactive จะเห็น route picker, toggle Client/Server ของ module type, file-type filter, search box และส่วนที่สำคัญที่สุดในบทนี้คือ treemap พร้อมยอดรวมของสิ่งที่กำลังแสดงอยู่: ขนาด compressed (ประมาณการ), ขนาด uncompressed และจำนวน module เลือก route ใดตัวหนึ่งจะปรับตัวเลขทั้งสามให้อยู่ในขอบเขตของ client bundle ของ route นั้นเท่านั้น
optimizePackageImports: มันแก้อะไรจริง ๆ พิสูจน์ด้วยตัวเลขจริง
หัวข้อที่มีชื่อว่า “optimizePackageImports: มันแก้อะไรจริง ๆ พิสูจน์ด้วยตัวเลขจริง”คำกล่าวอ้างคลาสสิกคือ “barrel import (import { X } from 'big-package') ทำ production bundle บวมแน่ ๆ เพิ่ม package เข้า optimizePackageImports แล้วจะเห็นมันเล็กลง” ทดสอบตรง ๆ กับ probe ด้วย lodash (package ที่ไม่อยู่ใน default-optimized list ของ Next.js เอง) — barrel import กับ deep import ของฟังก์ชันเดียวกันเป๊ะ:
// app/lodash-barrel/page.tsx — needs lodash installed (not in the shared harness probe)'use client'import { debounce } from 'lodash'
const log = debounce(() => console.log('typed'), 200)
export default function Page() { return <input onChange={log} placeholder="barrel import" />}// app/lodash-direct/page.tsx — needs lodash installed (not in the shared harness probe)'use client'import debounce from 'lodash/debounce'
const log = debounce(() => console.log('typed'), 200)
export default function Page() { return <input onChange={log} placeholder="direct import" />}ตัวเลขจริงจาก next experimental-analyze production build เดียวกัน ไม่มี optimizePackageImports ตั้งไว้เลย:
/lodash-barrel: 231.63 KB compressed (est.) · 625.79 KB uncompressed · 176 modules/lodash-direct: 231.63 KB compressed (est.) · 625.80 KB uncompressed · 176 modulesเหมือนกันแบบไม่มีนัยสำคัญทางสถิติ เหตุผลอยู่ในตาราง option ของ Turbopack เอง: turbopackRemoveUnusedExports กับ turbopackInferModuleSideEffects default เป็น true สำหรับ next build อยู่แล้ว (การตัด unused export default ปิดเฉพาะใน next dev เท่านั้น) production build ของ Turbopack ตัด unused named export ของ barrel lodash ออกไปให้ก่อนที่ optimizePackageImports จะเข้ามาเกี่ยวข้องด้วยซ้ำ — สำหรับ barrel แบบ ESM/CJS ธรรมดาไม่มี side effect แบบนี้ ไม่มีอะไรเหลือให้ option ตัวนี้ไปแก้แล้ว
// next.config.ts — configuring it anyway is still correct for packages Turbopack can't safely inferimport type { NextConfig } from 'next'
const nextConfig: NextConfig = { experimental: { optimizePackageImports: ['lodash'], },}
export default nextConfigแล้วมันช่วยจริงตรงไหน? สองที่ ทั้งคู่มาจาก docs ที่ bundle มาตรง ๆ ไม่ใช่ตัวเลข production ข้างบน:
- default list ยาว ๆ ครอบคลุมตัวยอดฮิตไว้แล้ว
lucide-react,date-fns,react-icons/*,@mui/material,@mui/icons-material,antd,rxjsและอีกหลายตัวถูก optimize โดย default ไม่ต้องตั้งค่าอะไร — นี่คือเหตุผลที่ถ้าทดสอบตั้งแต่แรกด้วยlucide-react(แทนlodash) ก็จะไม่เห็นความต่างเช่นกัน แต่ด้วยเหตุผลคนละอย่าง: option ถูกเปิดใช้แบบเงียบ ๆ อยู่แล้ว - จำนวน module ตอน dev ซึ่ง docs พูดถึงตรง ๆ: “Some packages can export hundreds or thousands of modules, which can cause performance issues in development and production”
turbopackRemoveUnusedExportsปิดโดย default ในnext devดังนั้น barrel ที่มี re-export เป็นพัน ๆ ตัวทำให้ cold start กับ HMR ช้าลงจริงได้ที่นั่น แม้ production bundle ที่ ship ออกไปจะออกมาเหมือนกันก็ตาม
serverExternalPackages: อีกฝั่งของฝั่ง server
หัวข้อที่มีชื่อว่า “serverExternalPackages: อีกฝั่งของฝั่ง server”optimizePackageImports พูดถึง client bundle ส่วน serverExternalPackages คือทิศทางตรงข้าม: มันบอก Next.js ให้เอา dependency ตัวนั้นออกจากการ bundle ฝั่ง server ทั้งหมด แล้วโหลดด้วย native Node require แทน — เหมาะกับ native addon หรือ package ที่ทำงานกับการ bundle ไม่ดี (Next.js ตัดออกให้อัตโนมัติแล้วสำหรับ package ยอดฮิตบางตัว เช่น @prisma/client และ @aws-sdk/* หลายตัว)
import type { NextConfig } from 'next'
const nextConfig: NextConfig = { serverExternalPackages: ['@acme/native-image-lib'],}
export default nextConfigรักษา Server/Client boundary ให้แคบ
หัวข้อที่มีชื่อว่า “รักษา Server/Client boundary ให้แคบ”ชัยชนะเรื่อง bundle ที่ใหญ่ที่สุดคือการไม่ส่งโค้ดเลย ทุกอย่างที่ import ผ่านเส้น "use client" กลายเป็น client JavaScript ดังนั้นดัน "use client" ลงไปให้ลึกที่สุดเท่าที่ทำได้ — เก็บงานที่ใช้ data หนักไว้ใน Server Component แล้ว mark เฉพาะใบ interactive เล็ก ๆ ให้เป็น client
flowchart TD Page["Server Component (0 KB client JS)"] --> Leaf["'use client' leaf"] Page --> Split["next/dynamic chunk (ssr:false only from a Client Component)"] Leaf --> JS[Client bundle] Split -->|loads on demand| JS Page --> Script["next/script (strategy-timed)"] JS --> Analyze["next experimental-analyze reads this"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”มอง client bundle เป็น shipping manifest แล้ว next experimental-analyze คือทางเดียวที่จะอ่านมันจริง ๆ แทนการเดาจาก packing list optimizePackageImports กับความกังวลเรื่อง barrel file พูดถึงสิ่งที่ใส่ เข้า กล่องก่อนจะปิดผนึก — แต่ Turbopack ยุคใหม่ทำ pass “จริง ๆ แล้วเราแพ็กของชิ้นนี้ไปหรือเปล่า” (turbopackRemoveUnusedExports) ให้กับ production build โดย default อยู่แล้ว ต้นทุนที่เหลือของ barrel file ยักษ์ไม่ได้อยู่ในกล่องที่ ship เสมอไป — บ่อยครั้งมันอยู่ที่ โกดัง (dev server, HMR) ต้องใช้เวลานานแค่ไหนในการเรียงของทุกชิ้นก่อนตัดสินใจว่าจะแพ็กอะไร
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ได้อะไร | ประโยชน์ | ต้นทุน |
|---|---|---|
strategy ของ next/script | third-party script ไม่ block สิ่งที่สำคัญกว่า | เลือก strategy ผิดสำหรับ script วิกฤต ทำให้สิ่งที่ user ต้องการก่อนถูกหน่วง |
next/dynamic + ssr: false | ไม่มีต้นทุนฝั่ง server สำหรับโค้ดที่ใช้ได้แค่ใน browser | ใช้ได้เฉพาะข้างใน Client Component เท่านั้น — ไม่งั้น build error จริง |
optimizePackageImports | แก้ปัญหา dev-mode ช้าจาก barrel ยักษ์ package ยอดฮิตหลายตัวได้ฟรี | สำหรับ production Turbopack build ของ barrel ธรรมดาไม่มี side effect อาจวัดออกมาว่าไม่มีผลอะไรเลย — ต้องตรวจก่อนให้เครดิต |
serverExternalPackages | native addon และ package ที่ bundle ไม่ได้ดี โหลดถูกต้องผ่าน native require | เมื่อ opt out จากการ bundle คุณต้องแบกรับ footprint ของ dependency นั้นตามสภาพเดิม |
ดัน "use client" ลงไป | ต้นไม้ส่วนใหญ่ ship client JS เป็นศูนย์ | ต้องมีวินัยไม่ใส่ "use client" ทั้ง subtree เพราะใบ interactive ใบเดียว |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “barrel import ไหน ๆ (
{ X } from 'big-lib') ก็ทำ production bundle บวมแน่นอน” พิสูจน์แล้วว่าไม่จริงอย่างน้อยหนึ่งเคสทั่วไปบน Next.js 16.3 + Turbopack ของคอร์สนี้:turbopackRemoveUnusedExports/turbopackInferModuleSideEffectsdefault เป็นtrueสำหรับnext buildดังนั้น barrel ธรรมดาไม่มี side effect วัดได้เหมือนกับ deep import เป๊ะ — 625.79 KB เทียบกับ 625.80 KB uncompressed, 176 modules เท่ากัน - “
optimizePackageImportsคือสิ่งที่ทำให้ lucide-react/react-icons tree-shake ได้ดี” สำหรับ package พวกนี้มันเป็นพฤติกรรม default อยู่แล้วโดยไม่ต้องตั้งค่าอะไร — พวกมันอยู่ใน default-optimized list ของ Next.js เอง - “
ssr: falseในnext/dynamicเป็นแค่ hint จะ degrade อย่างนุ่มนวลใน Server Component” มันเป็น build error จริง พิสูจน์กับ probe แล้ว ไม่ใช่แค่ warning - “
@next/bundle-analyzerตัวเก่ากับnext experimental-analyzeคือเครื่องมือเดียวกันแค่เปลี่ยนชื่อ” เป็นคนละเครื่องมือสำหรับคนละ bundler — ตัวแรก webpack เท่านั้น (แล้ว print ข้อความ no-op เมื่อ default เป็น Turbopack) ตัวหลังเป็น Turbopack-native
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อโค้ดที่ AI สร้างแตะเรื่อง bundling เช็กสิ่งเหล่านี้ก่อนเชื่อ:
- มันอ้างว่า barrel import ทำ bundle บวมแน่ ๆ โดยไม่ได้รัน
next experimental-analyzeเช็กจริงหรือเปล่า? Turbopack defaultturbopackRemoveUnusedExportsอาจจัดการไปแล้วก็ได้ ssr: falseถูกส่งให้next/dynamicจากไฟล์ที่ไม่ใช่ Client Component (ไม่มี'use client'บนสุด หรือ.tsxใต้ Server Component tree) หรือเปล่า? นั่นคือ build fail ไม่ใช่แค่ style nitoptimizePackageImportsถูกเพิ่มให้ package ที่อยู่ใน default-optimized list ของ Next.js อยู่แล้วหรือเปล่า (ซ้ำซ้อน แม้จะไม่เป็นอันตราย)?- dependency ที่เป็น native หรือ bundle ไม่ดี (database driver, native addon) ถูก import โดยไม่มี
serverExternalPackagesเสี่ยง bundling fail หรือ server output บวม หรือเปล่า? - โค้ดสมมติว่า
@next/bundle-analyzerทำงานได้ภายใต้ default Turbopack build หรือเปล่า? มันจะ no-op เงียบ ๆ ที่นั่น — ต้องใช้--webpack
🐛 หา bug ในโค้ดนี้
// app/dashboard/page.tsx — no 'use client' directiveimport dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./chart'), { ssr: false })
export default function Dashboard() {return <Chart />}เฉลย
app/dashboard/page.tsx ไม่มี 'use client' directive จึงเป็น Server Component โดย default — และ ssr: false ใน next/dynamic ใช้ได้เฉพาะข้างใน Client Component เท่านั้น พิสูจน์กับ probe แล้วว่ารูปแบบนี้ทำให้ build fail ด้วย Error: \ssr: false` is not allowed with `next/dynamic` in Server Components. Please move it into a Client Component.วิธีแก้คือเพิ่ม’use client’ให้ไฟล์นี้ หรือย้ายdynamic(…)` ไปไว้ใน Client Component เล็ก ๆ ที่ Server Component นี้ render แทน