ข้ามไปยังเนื้อหา

Script และ Bundle Optimization

ส่ง JavaScript ให้น้อยลงด้วยการโหลด third-party script ในจังหวะที่เหมาะสมผ่าน next/script, code-split client component ที่หนักด้วย next/dynamic, รักษาเส้น Server/Client boundary ให้แคบไว้ และรู้วิธีอ่าน bundle จริง ๆ แทนการเดา

tag <script> ดิบ ๆ block การ parse และรันทันทีที่ browser ไปถึง next/script ให้คุณเลือกจังหวะด้วย strategy เลือกตัวที่ช้าที่สุดเท่าที่ script ทนได้

  • beforeInteractive — โหลดก่อนโค้ด Next.js ใด ๆ จะ hydrate เก็บไว้ให้ script ที่วิกฤตจริง ๆ เท่านั้น
  • afterInteractive — ค่า default โหลดหลัง hydration เริ่ม เหมาะกับ analytics
  • lazyOnload — โหลดตอน browser ว่าง เหมาะกับ widget ความสำคัญต่ำอย่าง chat
// A chat widget is low priority — defer it to idle time
import Script from 'next/script'
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<>
{children}
<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
</>
)
}

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 SSR
const Chart = dynamic(() => import('./Chart'), {
ssr: false,
loading: () => <p>Loading chart…</p>,
})
export default function Dashboard() {
return <Chart />
}

ทั้ง optimizePackageImports และตำนาน barrel import ล้วนเป็นแค่การเดาจนกว่าคุณจะดูจริง ๆ ว่ามีอะไร ship ออกไปบ้าง Next.js 16.1+ มี bundle analyzer แบบ Turbopack-native เป็น subcommand ของตัวเอง (ยังติดป้าย experimental) มันวิเคราะห์ production build โดยไม่สร้าง build จริง แล้วเปิด UI แบบ interactive หรือเขียนไฟล์ static

Terminal window
npx next experimental-analyze --port 4181 # interactive UI
npx 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 infer
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
optimizePackageImports: ['lodash'],
},
}
export default nextConfig

แล้วมันช่วยจริงตรงไหน? สองที่ ทั้งคู่มาจาก docs ที่ bundle มาตรง ๆ ไม่ใช่ตัวเลข production ข้างบน:

  1. default list ยาว ๆ ครอบคลุมตัวยอดฮิตไว้แล้ว lucide-react, date-fns, react-icons/*, @mui/material, @mui/icons-material, antd, rxjs และอีกหลายตัวถูก optimize โดย default ไม่ต้องตั้งค่าอะไร — นี่คือเหตุผลที่ถ้าทดสอบตั้งแต่แรกด้วย lucide-react (แทน lodash) ก็จะไม่เห็นความต่างเช่นกัน แต่ด้วยเหตุผลคนละอย่าง: option ถูกเปิดใช้แบบเงียบ ๆ อยู่แล้ว
  2. จำนวน 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 ออกไปจะออกมาเหมือนกันก็ตาม

optimizePackageImports พูดถึง client bundle ส่วน serverExternalPackages คือทิศทางตรงข้าม: มันบอก Next.js ให้เอา dependency ตัวนั้นออกจากการ bundle ฝั่ง server ทั้งหมด แล้วโหลดด้วย native Node require แทน — เหมาะกับ native addon หรือ package ที่ทำงานกับการ bundle ไม่ดี (Next.js ตัดออกให้อัตโนมัติแล้วสำหรับ package ยอดฮิตบางตัว เช่น @prisma/client และ @aws-sdk/* หลายตัว)

next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
serverExternalPackages: ['@acme/native-image-lib'],
}
export default nextConfig

ชัยชนะเรื่อง 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"]
client JavaScript มาจากไหน

มอง 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/scriptthird-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 อาจวัดออกมาว่าไม่มีผลอะไรเลย — ต้องตรวจก่อนให้เครดิต
serverExternalPackagesnative 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/turbopackInferModuleSideEffects default เป็น 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 สร้างแตะเรื่อง bundling เช็กสิ่งเหล่านี้ก่อนเชื่อ:

  • มันอ้างว่า barrel import ทำ bundle บวมแน่ ๆ โดยไม่ได้รัน next experimental-analyze เช็กจริงหรือเปล่า? Turbopack default turbopackRemoveUnusedExports อาจจัดการไปแล้วก็ได้
  • ssr: false ถูกส่งให้ next/dynamic จากไฟล์ที่ไม่ใช่ Client Component (ไม่มี 'use client' บนสุด หรือ .tsx ใต้ Server Component tree) หรือเปล่า? นั่นคือ build fail ไม่ใช่แค่ style nit
  • optimizePackageImports ถูกเพิ่มให้ 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' directive
import 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 แทน

strategy ไหนของ next/script เป็นค่า default สำหรับ analytics
ถ้าเรียก next/dynamic ด้วย ssr: false จาก Server Component (ไม่มี "use client") จะเกิดอะไรขึ้น
นี่คือตัวเลข next experimental-analyze จริงของฟังก์ชันเดียวกันที่ import แบบ barrel เทียบกับ deep import ไม่มี optimizePackageImports ตั้งไว้ ตัวเลขนี้แสดงอะไร
/lodash-barrel:  625.79 KB uncompressed · 176 modules
/lodash-direct:  625.80 KB uncompressed · 176 modules
package ไหนต่อไปนี้อยู่ใน default-optimized list ของ Next.js สำหรับ optimizePackageImports อยู่แล้ว (ไม่ต้องตั้งค่า)
docs Next.js ที่ bundle มาบอกว่า optimizePackageImports ช่วยตรงไหนบ้าง นอกเหนือจากขนาด production bundle
@next/bundle-analyzer ทำอะไรภายใต้ default Turbopack build และถ้ายังต้องใช้อยู่ต้องแก้ยังไง