Loading Search Modal Component...
https://thadaw.com/posts/feed.xml

ทำไมการ Bundle Node.js ให้ไฟล์เล็กลง ถึงไม่ง่ายอย่างที่คิด

2025-09-27

สวัสดีครับ วันนี้มาเขียนบล็อกนะครับ เป็นสิ่งที่เก็บรวบรวมข้อมูลแล้วก็ประสบการณ์อยู่นาน ก็คือว่าด้วยเรื่องของว่าทำไมถึงเวลาที่เราใช้ Node.js โปรเจกต์ถึงมีขนาดใหญ่ และทำไมการ bundle ถึงไม่ง่ายอย่างที่คิดนะครับ แต่บอกก่อนว่าจริง ๆ แล้วเราอาจจะใช้ bundle ได้ แต่มันจะมีบางกรณีที่อาจจะใช้ไม่ได้นะครับ โอเค เป็นยังไงไปดูกันครับ

หากเรามาจากฝั่งเว็บ เราอาจจะคุ้นเคยกับคำว่า "Bundler" เป็นอย่างดี ไม่ว่าจะเป็นเครื่องมืออย่าง Webpack, Rollup, หรือ Vite ที่ทำหน้าที่รวมไฟล์ JavaScript, CSS, และ assets ต่างๆ ให้เหลือเพียงไม่กี่ไฟล์เพื่อประสิทธิภาพในการโหลดบนเบราว์เซอร์ ในโลกของการพัฒนาเว็บ การทำ bundle ถือเป็นเรื่องปกติและเครื่องมือเหล่านี้ก็ถูกพัฒนามาอย่างดีมากๆ จนหลายครั้งเราแทบไม่ต้องเข้าไปยุ่งกับการตั้งค่าที่ซับซ้อนเลย

แนวคิดเดียวกันนี้จึงถูกนำมาปรับใช้กับฝั่ง Node.js เพื่อแก้ปัญหา node_modules ที่มีขนาดใหญ่ โดยเฉพาะเมื่อเราต้องจัดการกับ Docker image ที่อาจบวมไปถึงระดับ GB ได้ การใช้ Bundler จะช่วยวิเคราะห์โค้ดของเรา แล้วดึงเฉพาะส่วนที่จำเป็นจริงๆ มารวมกันเป็นไฟล์เล็กๆ ทำให้เราไม่ต้อง copy node_modules ทั้งหมดเข้าไปใน image แต่เส้นทางกลับไม่ได้โรยด้วยกลีบกุหลาบเหมือนฝั่งเว็บ และนี่คือเหตุผลที่มันอาจจะไม่ใช่ Silver Bullet สำหรับทุกกรณีครับ

กรณีที่ Bundler มีปัญหา: Dynamic Imports และ Decorators

อย่างเช่นตัวที่มีปัญหาเยอะ ๆ ก็จะเป็นพวกเวลาที่เราใช้ bundler อย่าง esbuild เนื่องจาก bundler ประเภทนี้ทำงานแบบ Static Analysis คือมันจะวิเคราะห์โค้ด ณ เวลาที่ build โดยไม่ได้รันโค้ดจริง ดังนั้น ถ้าเรามีการเรียกใช้โมดูลแบบ dynamic หรือใช้เทคนิคบางอย่างที่ไม่ได้แสดงความเชื่อมโยงของไฟล์อย่างชัดเจน bundler ก็จะไม่สามารถตามไปเก็บโค้ดส่วนนั้นมารวมได้

ตัวอย่างที่เห็นได้ชัดคือ NestJS ซึ่งเป็นเฟรมเวิร์กที่ใช้ Dependency Injection (DI) และ Decorators เยอะมาก ถ้าเราลองเอา esbuild ไป build โปรเจกต์ NestJS ตรงๆ สิ่งที่เกิดขึ้นก็คือพวก Service ต่างๆ ที่ถูกเรียกผ่าน decorator อาจจะไม่ถูก bundle เข้ามาด้วย เพราะในมุมมองของ static bundler มันไม่เห็นว่ามีการ import ไฟล์เหล่านั้นโดยตรง

เพื่อแก้ปัญหานี้ เราจำเป็นต้องใช้ plugin เข้ามาช่วยครับ มีผู้พัฒนาที่เจอ workaround สำหรับปัญหานี้และได้สร้าง plugin ที่ชื่อว่า @anatine/esbuild-decorators ขึ้นมาเพื่อจัดการตรงนี้โดยเฉพาะ โดยเราสามารถเพิ่ม plugin นี้เข้าไปใน config ของ esbuild เพื่อให้มันสามารถ bundle โค้ดที่ใช้ decorator ของ NestJS ได้อย่างถูกต้อง

// ตัวอย่าง custom esbuild config
import { build, BuildOptions } from 'esbuild';
import { esbuildDecorators } from '@anatine/esbuild-decorators';

const tsconfig = 'path/to/your/tsconfig.json';
const cwd = process.cwd();

const buildConfig: BuildOptions = {
  entryPoints: ['src/main.ts'],
  bundle: true,
  platform: 'node',
  outdir: 'dist',
  tsconfig,
  plugins: [
    esbuildDecorators({ // เพิ่ม plugin เข้าไปตรงนี้
      tsconfig,
      cwd,
    }),
  ],
  // ... config ส่วนอื่นๆ
};

await build(buildConfig);

Note

ผมเคยเขียนบทความเต็มๆ เกี่ยวกับการใช้ esbuild ใช้คู่กับ plugin @anatine/esbuild-decorators กับ NestJS ไว้ด้วยนะครับ เผื่อใครสนใจลองอ่านดูได้ที่ บทความนี้

อีกหนึ่งตัวอย่างสุดคลาสสิก: การจัดการ Log ด้วย Pino

อีกกรณีหนึ่งก็คือการเขียน log ครับ ปกติเราอาจจะใช้ console.log ซึ่งเป็นวิธีที่ง่าย แต่ข้อเสียคือมันเป็นการทำงานแบบ Block I/O หมายความว่าถ้าเราเขียน log บ่อยๆ มันก็จะทำให้ Performance ของแอปเราช้าลงได้ หลายคนจึงหันไปใช้ Logger ตัวอื่นอย่าง Pino ที่ออกแบบมาให้ทำงานแบบ Asynchronous โดยการแตก thread ไปใช้ worker thread ของ Node.js เพื่อไม่ให้การเขียน log มาขัดขวางการทำงานของ main thread ครับ

แต่พอมีการใช้ worker thread นี่แหละครับที่ทำให้การ bundle ซับซ้อนขึ้น เพราะโดยพื้นฐานแล้ว Pino ไม่สามารถถูกรวมเป็นไฟล์เดียว (single-file bundle) ได้ เนื่องจากสถาปัตยกรรมของมันต้องแยกไฟล์ worker ออกมาต่างหาก

โชคดีที่ถ้าเราใช้ bundler ยอดนิยมอย่าง esbuild เขาก็มี plugin อย่าง esbuild-plugin-pino มาให้เลย ซึ่งมันจะช่วยจัดการสร้างไฟล์ worker ที่จำเป็น (เช่น thread-stream.js) แยกออกมาให้โดยอัตโนมัติ ทำให้เราแค่ติดตั้งและตั้งค่านิดหน่อยก็สามารถใช้งานได้เลย

// ตัวอย่างการใช้ plugin ของ pino
import { build } from "esbuild";
import esbuildPluginPino from "esbuild-plugin-pino";

await build({
  entryPoints: ["src/index.ts"],
  outdir: "dist",
  platform: "node",
  bundle: true,
  plugins: [esbuildPluginPino({ transports: ["pino-pretty"] })]
});

Note

สามารถอ่านรายละเอียดเพิ่มเติมเกี่ยวกับการใช้ Pino กับการ Bundle ได้ที่ pino documentation

แล้วถ้าไม่มี Plugin ให้ใช้ล่ะ?

ทีนี้เราก็ได้เห็นแล้วนะครับว่าถ้ามี plugin ชีวิตก็จะสบายขึ้นมาก แต่ถ้าไม่มีล่ะ? เราจะทำยังไง? ทางเลือกหลักๆ ก็จะมีอยู่ประมาณนี้ครับ:

  1. ทำด้วยมือ (Manual Scripting): วิธีนี้คือการเขียนสคริปต์ของเราเองเพื่อคัดลอกไฟล์ที่จำเป็นของ Pino ไปวางไว้ในโฟลเดอร์ dist หลังจากการ build เสร็จสิ้น เหตุผลที่ต้องทำแบบนี้เพราะสถาปัตยกรรมของ Pino ที่ใช้ Worker Threads ทำให้มันไม่สามารถถูก "บันเดิลเป็นไฟล์เดียวล้วนๆ" ได้อยู่แล้ว ซึ่งพวก plugin มันก็แค่มาช่วยทำให้กระบวนการนี้เป็นอัตโนมัติให้เรานั่นเอง
  2. ใช้กลยุทธ์ "External Deps": เราสามารถบอก bundler ได้ว่า "ไม่ต้องพยายาม bundle library พวก pino หรือ thread-stream นะ" โดยกำหนดให้มันเป็น external dependencies วิธีนี้จะทำให้โค้ดของเรา build ผ่าน และแก้ปัญหาเรื่องการหาไฟล์ worker ได้ แต่ก็มีข้อแลกเปลี่ยนที่สำคัญคือ ขนาด image ของเราจะไม่เล็กลงตามเป้าหมาย เพราะสุดท้ายเราก็ยังต้อง copy node_modules ที่มี dependencies เหล่านี้ติดไปด้วยอยู่ดี
  3. หลีกเลี่ยงการใช้ Worker ไปเลย: อีกทางเลือกคือการปรับการตั้งค่าของ Pino ให้ไปใช้ transport ที่ไม่ได้ทำงานบน worker thread (เช่น เขียน log ไปที่ stdout หรือเขียนไฟล์ใน main thread โดยตรง) วิธีนี้จะช่วยลดความซับซ้อนในการ build ลงได้มาก แต่เราก็จะเสียข้อดีเรื่องการแยกงาน I/O หนักๆ ออกจาก event loop ซึ่งเป็นแนวทางที่ Pino แนะนำสำหรับงานบน production

สรุปก็คือ ถ้ามี plugin ก็ใช้เถอะครับ สบายสุดแล้ว เพราะมันจัดการเรื่องยากๆ ให้เราอัตโนมัติ แต่ถ้าไม่มีจริงๆ การทำด้วยมือหรือการใช้ external ก็ยังเป็นทางออกที่พอไปได้ แค่เราต้องยอมรับ trade-off ที่ตามมา ไม่ว่าจะเป็นเรื่องขนาดของ image หรือประสิทธิภาพที่อาจลดลง ซึ่งก็ต้องเลือกให้เหมาะกับบริบทของโปรเจกต์เราครับ

ก็หวังว่าจะเป็นประโยชน์กันนะครับ สวัสดีครับ