সরাসরি পর্বে যান
পর্ব ৭৯ · ৯৩-এর মধ্যে · মডিউল ১২

Build আর bundle — Vite, tree shaking আর code splitting

Build and Bundling — Vite, Tree Shaking and Code Splitting

Taught in Bangla, this lesson explains what a bundler does, Vite 8 with native ES modules in dev and Rolldown for builds with measured request counts, tree shaking with ES modules versus CommonJS, the silent traps of sideEffects and barrel files, code splitting, vendor chunks and content hashes for long caching, minification and keeping production source maps private, browser targets, polyfills, CSS and assets, and bundle analysis and the Next.js 16 build.

এই পর্বে যা শিখবেন

  • একটি bundler ভেতরে কী করে, আর Vite 8-এর dev ও build কেন আলাদা পথে চলে
  • Tree shaking, package.json-এর sideEffects আর barrel file-এর আসল খরচ মাপা
  • Vendor chunk, content hash আর import map দিয়ে deploy-এর পরেও cache বাঁচানো
  • Minifier, hidden source map, browser target, CSS আর asset-এর build
  • Next.js 16-এর Turbopack build, Express API-র build আর “বাজারঘর”-এর বাজেট

১ · Bundler কী করে, আর কেন লাগে

ডাকঘরের বাছাইঘরের কথা ভাবুন। সারা দিনে হাজার চিঠি আসে, প্রতিটি আলাদা ঠিকানায়। ডাকপিয়ন প্রতিটি চিঠি আলাদা করে পৌঁছাতে গেলে দিন শেষ হবে না। তাই বাছাইঘরে চিঠি এলাকা ধরে বস্তায় ভরা হয়, প্রতিটি বস্তায় লেবেল লাগে, আর একটি গাড়ি একবারে একটি এলাকার বস্তা নিয়ে যায়। Bundler আপনার কোডের সাথে ঠিক এই কাজটি করে।

“বাজারঘর”-এর ছোট React সংস্করণেও vite build বলেছে 395 modules transformed. মানে আপনার নিজের ছয়-সাতটি ফাইল, আর React, React DOM ও চার্ট library-র ভেতরের শত শত ফাইল। এগুলো আলাদা করে browser-এ পাঠালে প্রতিটির জন্য একটি request, আর প্রতিটি import খুঁজতে আরও এক ধাপ অপেক্ষা। Bundler এর বদলে তিনটি কাজ করে:

  1. Graph বানানো। Entry ফাইল, যেমন main.jsx, থেকে শুরু করে প্রতিটি import অনুসরণ করে। ফলে পায় একটি module graph: কে কাকে ডাকে তার পুরো জাল।
  2. অনুবাদ। Browser JSX বা TypeScript বোঝে না। Bundler সেগুলো সাধারণ JavaScript-এ transpile করে, CSS আর ছবিও প্রক্রিয়া করে।
  3. অনেক থেকে কয়েকটি। অব্যবহৃত কোড বাদ দেয়, বাকিটা কয়েকটি chunk-এ ভাগ করে, ছোট করে, আর ফাইলের নামে content hash বসিয়ে লেখে।

নিচের ব্লকটি একটি খেলনা bundler. মেমরিতে চারটি ফাইল আছে। সে graph হাঁটে, dependency আগে রেখে ক্রম ঠিক করে, তারপর সবকিছু একটি স্ট্রিং-এ জুড়ে চালায়। আসল bundler এর চেয়ে অনেক বেশি করে, কিন্তু মূল ধারণা এটুকুই।

// চার ফাইলের ছোট্ট "বাজারঘর": মেমরিতে রাখা
const files = {
  'main.js':  "import { App } from './app.js'; App();",
  'app.js':   "import { formatTaka } from './money.js'; import { cart } from './cart.js';\nexport function App() { console.log('ঝুড়ি:', formatTaka(cart.total())); }",
  'cart.js':  "import { vat } from './money.js';\nexport const cart = { total: () => 270 + vat(270) };",
  'money.js': "export const vat = n => Math.round(n * 0.15);\nexport const formatTaka = n => n + ' টাকা';",
};
const order = [], seen = new Set();
function visit(file, depth) {
  if (seen.has(file)) return console.log(' '.repeat(depth * 2) + file + ' (আগেই দেখা)');
  seen.add(file);
  console.log(' '.repeat(depth * 2) + file);
  const deps = [...files[file].matchAll(/from '\.\/([\w.]+)'/g)].map(m => m[1]);
  deps.forEach(d => visit(d, depth + 1));
  order.push(file);                       // সব dependency-র পরে নিজে
}
visit('main.js', 0);
console.log('ক্রম:', order.join(' → '));
// প্রতিটি module একটি function-এ; import/export বদলে একটি ভাগ করা খাতায়
let out = 'const mod = {};\n';
for (const f of order) {
  const body = files[f]
    .replace(/import \{([^}]+)\} from '[^']+';/g, (_, names) => `const {${names}} = mod;`)
    .replace(/export (const|function) (\w+)/g, (_, kind, name) => `${kind} ${name}`);
  const names = [...files[f].matchAll(/export (?:const|function) (\w+)/g)].map(m => m[1]);
  out += `(function () { ${body}\n Object.assign(mod, { ${names.join(', ')} }); })();\n`;
}
console.log('bundle:', out.length, 'অক্ষর, ফাইল ৪টি থেকে ১টি');
new Function(out)();                       // bundle চালানো

আউটপুট:

main.js
  app.js
    money.js
    cart.js
      money.js (আগেই দেখা)
ক্রম: money.js → cart.js → app.js → main.js
bundle: 519 অক্ষর, ফাইল ৪টি থেকে ১টি
ঝুড়ি: 311 টাকা

money.js দুজনে ডাকে, কিন্তু graph-এ সে একবারই ঢোকে। এটিই depth-first post-order: আগে সব dependency, তারপর নিজে। ফলে cart.js চলার সময় vat তৈরি থাকে। 270 + 41 = 311 টাকা ঠিকঠাক এল। আমাদের খেলনায় সব নাম একটি খাতায় জমে, তাই দুই ফাইলে একই নাম থাকলে মারামারি হতো। আসল bundler নাম বদলে (যেমন vat$1) এই সংঘাত এড়ায়।

সংক্ষিপ্ত ইতিহাস

JavaScript-এ ২০১৫ সালের আগে ভাষার নিজস্ব module ছিল না। তাই bundler-এর ইতিহাস আসলে module-এর ইতিহাস। নিচের সাল আনুমানিক, প্রথম প্রকাশ বা ঘোষণার সময় ধরে।

টুলমোটামুটি সময়যা নতুন আনল
Browserify২০১১-র দিকেNode-এর require browser-এ চালানো
webpack২০১২-র দিকেসবকিছুই module: CSS, ছবি; loader আর code splitting
Rollup২০১৫-র দিকেES module-ভিত্তিক, tree shaking জনপ্রিয় করা
esbuild২০২০Go-তে লেখা, বহুগুণ দ্রুত
Vite২০২০Dev-এ bundle না করে native ES module
Turbopackঘোষণা ২০২২; Next.js 16 (২০২৫)-এ defaultRust-এ লেখা, incremental build
RolldownVite 8 (মার্চ ২০২৬)-এ defaultRust-এ Rollup-ধাঁচের API, dev আর build এক টুলে

একটি প্রবণতা পরিষ্কার: টুলগুলো JavaScript থেকে Go আর Rust-এ সরেছে। কারণ bundler-এর কাজ মূলত parse আর রূপান্তর, যা ভারী CPU-র কাজ, আর নেটিভ ভাষায় সমান্তরালে চালানো সহজ।

২ · Vite 8: dev-এ native ESM, build-এ Rolldown

পর্ব ০৫-এ আপনি dev, build আর preview তিনটি মোড চালিয়েছেন। এবার ভেতরে তাকাই। লেখার সময়ে Vite 8.3.5 চলছে। Vite-এর docs অনুযায়ী Vite 8 আগের দুই টুলের (dev-এ esbuild, build-এ Rollup) জায়গায় Rolldown আর Oxc নিয়েছে। কোড অনুবাদ আর minify করে Oxc, CSS minify করে Lightning CSS. পুরোনো build.rollupOptions এখন build.rolldownOptions-এর deprecated নাম।

Dev: browser নিজেই ফাইল চায়

Dev server পুরো অ্যাপ bundle করে না। Browser যে ফাইল চায়, শুধু সেটি অনুবাদ করে পাঠায়। নিচে আমাদের dev server-এ একটি ফাইল সরাসরি চাওয়া হলো (সত্যিকারের run, Vite 8.3.5, ছোট করা):

$ npx vite --port 5791 --host 127.0.0.1
  VITE v8.3.5  ready in 195 ms

$ curl -s http://127.0.0.1:5791/src/App.jsx
import Home from "/src/routes/Home.jsx";
import logoUrl from "/src/assets/logo.svg?import&url";
import __vite__cjsImport3_react_jsxDevRuntime from "/node_modules/.vite/deps/react_jsx-dev-runtime.js?v=065e0134";
const Cart = lazy(_c = () => import("/src/routes/Cart.jsx"));
…

তিনটি জিনিস লক্ষ করুন। JSX আর নেই, সাধারণ JavaScript হয়ে গেছে। import-গুলো এখনো import, শুধু পথ পুরো URL হয়েছে, কারণ browser নিজেই এগুলো চাইবে। আর React এসেছে .vite/deps থেকে: Vite প্রথমবার চালানোর সময় dependency-গুলো একবার আগে থেকে bundle করে রাখে (dependency pre-bundling), নইলে React-এর শত ফাইল আলাদা request হতো।

Edge-এ হোম পাতা খুলে request গুনলাম (Playwright দিয়ে, একটি স্থানীয় run):

মোডRequestমোট body (unzip করা)
vite (dev)১৫টি: ১ document, ১৪ scriptপ্রায় 385 kB
vite build + vite preview৫টি: ১ document, ৩ script, ১ stylesheetপ্রায় 219 kB

Dev-এ বেশি request আর বড় ফাইল কোনো সমস্যা নয়, কারণ সব আপনার নিজের যন্ত্রে। লাভ হলো, একটি ফাইল বদলালে শুধু সেটি নতুন করে অনুবাদ হয়, আর HMR সেটি পাতায় বসায়। খুব বড় অ্যাপে অবশ্য হাজার module-এর request নিজেই ধীর হয়ে যায়। Vite 8.1 (জুন ২০২৬) তাই পরীক্ষামূলক experimental.bundledDev এনেছে, যা dev-এও bundle করে। লেখার সময়ে এটি পরীক্ষামূলক।

Build: পুরো bundle

$ npx vite build
vite v8.3.5 building client environment for production...
✓ 395 modules transformed.
dist/index.html                   0.36 kB │ gzip:   0.28 kB
dist/assets/index-CBkHMBcy.css    5.71 kB │ gzip:   1.94 kB
dist/assets/Cart-BMN0ET_r.js      0.41 kB │ gzip:   0.32 kB
dist/assets/index-DL0ZPs8R.js   223.66 kB │ gzip:  70.53 kB
dist/assets/Admin-BV9UnW_S.js   348.45 kB │ gzip: 100.92 kB
✓ built in 174ms

প্রতিটি লাইনে দুটি সংখ্যা। প্রথমটি minify করা ফাইলের আকার, দ্বিতীয়টি gzip করলে কত, যা নেটওয়ার্কে মোটামুটি যাবে। Browser-কে অবশ্য unzip করা পুরো 223.66 kB parse করতে হয়, তাই দুটিই গুরুত্বপূর্ণ। Cart আর Admin আলাদা কারণ App.jsx-এ তারা lazy(() => import(…)), যা পর্ব ২৩-এ দেখেছেন। নামের মাঝের DL0ZPs8R হলো content hash, যা ৫ নম্বর অংশে খুলব।

একটি production build-এর ধাপ

From source files to hashed files in dist/

Vite 8 production build-এর সাত ধাপ ১ · Source src/*.jsx, node_modules ২ · Module graph import ধরে ধরে, 395 module ৩ · Transform JSX, TS → JS (Oxc) ৪ · Tree shaking অব্যবহৃত export বাদ ৫ · Chunk-এ ভাগ entry, lazy route, vendor ৬ · Minify JS: Oxc · CSS: Lightning CSS ৭ · Hash আর লেখা index-DL0ZPs8R.js → dist/ Dev server শুধু ২ আর ৩ browser চাইলে তবেই
ছবি ৭৯.১ — Production build সাত ধাপ পুরোটা করে। Dev server শুধু graph-এর পথ আর অনুবাদ করে, তাও যে ফাইল browser চায় সেটুকুই।

৩ · Tree shaking: ES module বনাম CommonJS

গাছ ঝাঁকালে শুকনো পাতা পড়ে যায়, সবুজ পাতা থাকে। Tree shaking নামটি এখান থেকে: যে কোড কেউ ব্যবহার করে না, build সেটি ফেলে দেয়। কিন্তু ঝাঁকানোর আগে জানতে হয় কোন পাতা সবুজ। ES module-এ import { debounce } from '…' লেখা থাকে কোডের ওপরে, স্থির নামে। Build কোড না চালিয়েই বুঝতে পারে কী লাগবে। CommonJS-এ require() একটি সাধারণ ফাংশন ডাক, যা শর্তের ভেতরে থাকতে পারে, নাম চলার সময় তৈরি হতে পারে, আর module.exports যেকোনো সময় বদলাতে পারে। তাই নিশ্চিত হওয়া কঠিন, আর bundler সাধারণত পুরোটাই রাখে।

পরীক্ষা: একই এক লাইনের কাজ, শুধু debounce, তিনভাবে import করে আলাদা vite build. সত্যিকারের run, Vite 8.3.5, lodash আর lodash-es দুটিই 4.18.1:

import { debounce } from 'lodash-es';   // অন্য দুটিতে শুধু এই লাইন আলাদা
window.search = debounce(q => console.log('খোঁজ', q), 300);
ImportModule formatOutputgzipTransform হওয়া module
import { debounce } from 'lodash'CommonJS, এক বিশাল ফাইল71,269 B25,844 B৩
import debounce from 'lodash/debounce.js'CommonJS, আলাদা ফাইল3,330 B1,470 B১৬
import { debounce } from 'lodash-es'ES module, sideEffects: false2,258 B1,086 B৬৪২

একই ফাংশন, প্রায় ২৪ গুণ পার্থক্য। প্রথম সারিতে bundler পুরো lodash.js রেখেছে, কারণ সেটি একটি CommonJS ফাইল, ভেতরে কী লাগবে সে প্রমাণ করতে পারে না। দ্বিতীয় সারিতে আপনি নিজেই ফাইলের পথ দিয়ে বাছাই করেছেন। তৃতীয়টিতে library-র ৬৪২টি ছোট ES module ফাইল পড়া হয়েছে, কিন্তু রাখা হয়েছে শুধু যেগুলো debounce-এর দরকার। মজার দিক: সবচেয়ে ছোট output এসেছে সবচেয়ে বেশি module পড়ে। Build-এর সময় একটু বাড়ে, ব্যবহারকারীর ফোনে কমে।

Tree shaking-এর মূল কাজ দুই ধাপের: entry থেকে যা ব্যবহার হয় তা চিহ্নিত করা, তারপর চিহ্নহীন সব বাদ দেওয়া। কিন্তু একটি ফাঁদ আছে। কিছু কোড শুধু ফাইল লোড হলেই কাজ করে, যেমন ফাইলের ওপরে registerLocale("bn"). এটি কোনো export নয়, কেউ import-ও করে না, তবু বাদ দিলে আচরণ বদলাতে পারে। একে বলে side effect. নিচের মডেলে দুবার একই import, পার্থক্য শুধু package-এর ঘোষণায়:

// একটি utility library: প্রতিটি export-এর আকার (byte) আর সে কাকে ডাকে
const lib = {
  debounce:   { size: 1200, uses: ['now', 'isObject'] },
  throttle:   { size: 400,  uses: ['debounce', 'isObject'] },
  now:        { size: 60,   uses: [] },
  isObject:   { size: 90,   uses: [] },
  cloneDeep:  { size: 9800, uses: ['isObject'] },
  formatDate: { size: 3100, uses: [] },
};
// মডিউলের শীর্ষে একটি কাজ: কেউ import না করলেও চলে
const topLevel = { code: 'registerLocale("bn")', size: 700, sideEffect: true };

function shake(entryImports, sideEffectsFalse) {
  const used = new Set();
  const mark = name => {
    if (used.has(name)) return;
    used.add(name);                          // ধাপ ১: ব্যবহৃত চিহ্নিত করা
    lib[name].uses.forEach(mark);            // ধাপ ২: সে যাকে ডাকে, তাকেও
  };
  entryImports.forEach(mark);
  let bytes = [...used].reduce((s, n) => s + lib[n].size, 0);
  const keepTop = topLevel.sideEffect && !sideEffectsFalse;
  if (keepTop) bytes += topLevel.size;
  const dropped = Object.keys(lib).filter(n => !used.has(n));
  console.log('রাখা:', [...used].join(', ') + (keepTop ? ', ' + topLevel.code : ''));
  console.log('বাদ:', dropped.join(', '), '| মোট', bytes, 'byte');
}
const all = Object.values(lib).reduce((s, x) => s + x.size, 0) + topLevel.size;
console.log('পুরো library:', all, 'byte');
console.log('— import { debounce }');
shake(['debounce'], false);
console.log('— একই import, package বলেছে sideEffects: false');
shake(['debounce'], true);

আউটপুট:

পুরো library: 15350 byte
— import { debounce }
রাখা: debounce, now, isObject, registerLocale("bn")
বাদ: throttle, cloneDeep, formatDate | মোট 2050 byte
— একই import, package বলেছে sideEffects: false
রাখা: debounce, now, isObject
বাদ: throttle, cloneDeep, formatDate | মোট 1350 byte

১৫,৩৫০ byte থেকে ২,০৫০ byte. debounce নিজে ১,২০০, সাথে যাদের সে ডাকে (now ৬০, isObject ৯০) আর শীর্ষের কাজ ৭০০. Package যখন ঘোষণা করে “আমার কোনো ফাইল শুধু লোড হলে কিছু করে না”, তখন bundler শীর্ষের কাজটিও ফেলে দেয়: ১,৩৫০ byte. কথাটি সত্য হলে লাভ, মিথ্যা হলে বাগ। পরের অংশে সেই বাগ।

Entryশুরু
import { debounce } from 'utils'
utils-এ ৬টি export, মোট 15,350 byte
চিহ্ন দেওয়াযা ডাকা হয়েছে
✓ debounce   1200
  throttle    400
  now          60
  isObject     90
  cloneDeep  9800
  formatDate 3100
চিহ্ন ছড়ানোdebounce যাকে ডাকে
✓ debounce   1200
  throttle    400   debounce-কে ডাকে, উল্টো নয়
✓ now          60
✓ isObject     90
  cloneDeep  9800
  formatDate 3100
শীর্ষের কোডside effect?
registerLocale("bn")   700
sideEffects ঘোষণা নেই → রাখা হলো
sideEffects: false     → বাদ যেত
Outputবাদ দেওয়ার পর
রাখা: debounce, now, isObject, registerLocale
বাদ: throttle, cloneDeep, formatDate
মোট 2,050 byte (১৩.৪%)
  1. Entry দেখে শুরু। আপনার কোড শুধু debounce import করেছে। ES module-এ এই নাম ফাইলের ওপরে স্থির, তাই কোড না চালিয়েই জানা যায়।
  2. প্রথম চিহ্ন। debounce-এ ✓. বাকি পাঁচটি এখনো চিহ্নহীন।
  3. চিহ্ন ছড়াল। debounce নিজে now আর isObject ডাকে, তাই তারাও ✓. throttle উল্টো দিকে debounce-কে ডাকে, তাতে তার চিহ্ন হয় না।
  4. Side effect যাচাই। শীর্ষের registerLocale("bn") কোনো export নয়, কিন্তু চললে কিছু বদলায়। ঘোষণা না থাকায় bundler নিরাপদে থাকতে সেটি রাখে, ৭০০ byte.
  5. বাদ। চিহ্নহীন তিনটি ফেলে দেওয়া হলো। ১৫,৩৫০ থেকে ২,০৫০ byte, মানে আগের ১৩.৪%. বাকি ৮৬.৬% ব্যবহারকারীর ফোনে আর যায় না।

৪ · sideEffects আর barrel file: দুটি নীরব ফাঁদ

যখন sideEffects: false CSS খেয়ে ফেলে

package.json-এর sideEffects একটি প্রতিশ্রুতি। আপনি লিখছেন: “এই package-এর কোনো ফাইল শুধু import হলেই কিছু করে না।” Bundler কথাটি বিশ্বাস করে। এবার একটি ছোট UI kit বানাই, যার index.js একটি থিম CSS টানে আর বোতাম export করে:

// ui-kit/package.json: { "name": "ui-kit", "type": "module", "sideEffects": false }
import './theme.css';          // :root { --brand } আর body-র font
export * from './button.js';   // button.js নিজে import করে './button.css'

অ্যাপ শুধু import { Button } from './ui-kit/index.js' লিখল। Build-এর CSS ফাইল (সত্যিকারের run, Vite 8.3.5):

.btn{color:#fff;background:#059669;border-radius:6px;padding:4px 12px}

বোতামের CSS আছে, কিন্তু theme.css নেই। কোনো warning নেই, কোনো error নেই। Production-এ পাতার font আর রঙের variable হারিয়ে গেছে। কেন? index.js-এর নিজের কোনো কোড নেই, শুধু আবার export. sideEffects: false বলায় bundler সরাসরি button.js-এ চলে গেছে, index.js পুরো বাদ, আর তার সাথে বাদ গেছে তার CSS import.

প্রচলিত পরামর্শ হলো "sideEffects": ["**/*.css"]. আমাদের run-এ শুধু এটুকু দিয়ে থিম ফেরেনি, কারণ CSS ফাইল নয়, বাদ পড়ছিল তাকে import করা index.js. যেটি কাজ করল:

"sideEffects": ["**/*.css", "./index.js"]
→ :root{--brand:#059669}body{font-family:Hind Siliguri,sans-serif}.btn{…}

"sideEffects" ঘোষণা মুছে দিলে
→ :root{--brand:#059669}body{font-family:Hind Siliguri,sans-serif}.btn{…}

আরও পরিষ্কার সমাধান হলো নকশায়: থিম CSS অ্যাপের entry-তে সরাসরি import করুন (import 'ui-kit/theme.css'), অথবা যে component যে CSS চায় সে নিজেই import করুক, যেমন button.js করেছে। শিক্ষা: sideEffects একটি ঘোষণা, যাচাই নয়। ঘোষণা ভুল হলে bundler ভুলটিই নিখুঁতভাবে মানে। তাই এটি বদলালে build-এর CSS ফাইল খুলে চোখে দেখুন।

Barrel file-এর দাম

অনেক প্রজেক্টে lib/index.js থাকে যা সবকিছু আবার export করে, যাতে import { formatTaka } from './lib' লেখা যায়। একে বলে barrel file, যেন একটি পিপের ভেতরে সব জিনিস। আরামদায়ক, কিন্তু খরচ আছে। আমাদের lib-এ চারটি ফাইল: money.js, ৬৪টি জেলার সূচি বানানো districts.js, শীর্ষে window.bazarEvents বসানো analytics.js, আর recharts আবার export করা charts.js. দরকার শুধু formatTaka. সত্যিকারের run:

ImportOutputgzipTransform হওয়া module
from './lib/money.js'74 B94 B৩
from './lib/index.js' (barrel)385 B297 B৩৮৪

Barrel দিয়ে আসা output-এ ঢুকে পড়েছে জেলার সূচি বানানোর ফাংশন ডাক আর window.bazarEvents=window.bazarEvents||[]. দুটিই শীর্ষের কোড, bundler প্রমাণ করতে পারেনি এগুলো নিরীহ। Recharts অবশ্য বাদ গেছে, কারণ সে নিজে sideEffects: false ঘোষণা করে। কিন্তু তাকে খুঁজে দেখতে ৩৮৪টি module পড়তে হয়েছে, যেখানে সরাসরি import-এ ৩টি।

Byte-এর পার্থক্য এখানে ছোট, কিন্তু barrel যত বড়, ক্ষতি তত বড়। Vite-এর performance guide-ও বলে, সম্ভব হলে barrel এড়িয়ে সরাসরি import করুন, কারণ dev-এ প্রথম পাতায় দরকারের চেয়ে বেশি ফাইল লোড হয়। Test runner-ও একইভাবে বাড়তি ফাইল পড়ে। সমাধান সহজ: নিজের অ্যাপের ভেতরে সরাসরি ফাইল থেকে import করুন। Barrel রাখুন শুধু প্রকাশিত library-র সামনের দরজায়, আর সেখানে sideEffects সৎভাবে লিখুন।

৫ · Code splitting, vendor chunk আর content hash

পর্ব ২০-এ route ধরে ভাগ করেছেন, পর্ব ২৩-এ lazy দিয়ে মেপেছেন, আর পর্ব ৫২-এ দেখেছেন Next.js server আর client-এর আলাদা bundle বানায়। এখানে প্রশ্ন অন্য: deploy-এর পর ফেরত আসা ক্রেতাকে কতটা আবার নামাতে হয়?

Content hash আর লম্বা cache

ফাইলের নাম index-DL0ZPs8R.js. মাঝের অংশ ফাইলের বিষয়বস্তু থেকে হিসাব করা। বিষয়বস্তু একই থাকলে নাম একই, এক অক্ষর বদলালেও নাম নতুন। তাই server এই ফাইলগুলোকে Cache-Control-এ এক বছর আর immutable দিতে পারে, browser আর কখনো জিজ্ঞেসও করবে না। নতুন কোড এলে নতুন নাম, আর নতুন index.html সেই নামটি লেখে। শর্ত একটিই: index.html নিজে লম্বা সময় cache করা যাবে না। Header-এর বিস্তারিত পর্ব ৮৪-এ, HTTP cache-এর তত্ত্ব Computer Networks কোর্সে।

পরীক্ষা: ঝুড়ির পাতায় “মোট:” বদলে “সর্বমোট:” লিখে আবার build. শুধু একটি শব্দ, একটি ফাইলে। সত্যিকারের run, দুবার build-এর ফাইলের নাম:

আগে                          পরে
index-CBkHMBcy.css            index-CBkHMBcy.css     একই
Cart-BMN0ET_r.js              Cart-BJk1Md97.js       নতুন
index-DL0ZPs8R.js   70.53 kB  index-DA1Y63ug.js      নতুন
Admin-BV9UnW_S.js  100.92 kB  Admin-xb12zKpk.js      নতুন

একটি শব্দ বদলাল, অথচ তিনটি JavaScript ফাইলের নাম নতুন। কারণ দুটি। index-এর ভেতরে লেখা আছে import("./Cart-BJk1Md97.js"), তাই Cart-এর নাম বদলালে index-এর বিষয়বস্তুও বদলায়। আর Admin React-এর কোড নেয় index থেকে, from "./index-….js" লিখে, তাই index-এর নাম বদলালে Admin-ও বদলায়। এই ঢেউকে বলে hash cascade.

Vendor chunk: যা কম বদলায় তা আলাদা

React আর চার্ট library মাসে একবারও হয়তো বদলায় না, আপনার কোড দিনে কয়েকবার বদলায়। তাই এদের আলাদা vendor chunk-এ রাখি। Vite 8-এ Rolldown-এর codeSplitting দিয়ে। Vite-এর migration guide বলে, পুরোনো manualChunks-এর object রূপ আর চলে না, function রূপ deprecated:

export default defineConfig({
  plugins: [react(), tailwindcss()],
  build: {
    rolldownOptions: {
      output: {
        codeSplitting: {
          groups: [
            { name: 'react-vendor', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/, priority: 20 },
            { name: 'charts', test: /node_modules[\\/]/, priority: 10 },
          ],
        },
      },
    },
  },
});

[\\/] রাখা হয়েছে যাতে Windows-এর \ আর Linux-এর / দুই পথই মেলে। priority বেশি হলে সেই group আগে বাছাই করে, তাই React আগে নিজের ঘরে যায়, বাকি node_modules পরে charts-এ। সত্যিকারের run:

dist/index.html                             0.53 kB │ gzip:   0.33 kB
dist/assets/index-CBkHMBcy.css              5.71 kB │ gzip:   1.94 kB
dist/assets/Admin-lTkbFtzv.js               0.38 kB │ gzip:   0.29 kB
dist/assets/Cart-wKUX_Rsk.js                0.44 kB │ gzip:   0.35 kB
dist/assets/rolldown-runtime-XKyaHtl9.js    0.72 kB │ gzip:   0.42 kB
dist/assets/index-DKzZ158T.js               4.65 kB │ gzip:   2.30 kB
dist/assets/react-vendor-DyYzDaJB.js      218.70 kB │ gzip:  68.26 kB
dist/assets/charts-v0OkwtNT.js            348.25 kB │ gzip: 100.78 kB
Entrymain.jsx
main.jsx
├ react-dom/client
├ App.jsx
└ index.css
Static importএক chunk-এ যাবে
App.jsx
├ Home.jsx → money.js
├ logo.svg?url (197 B, inline)
└ react
সব প্রথম পাতায় দরকার
Dynamic importনতুন chunk-এর দরজা
lazy(() => import('./Cart.jsx'))  → Cart chunk
lazy(() => import('./Admin.jsx')) → Admin chunk
Admin → recharts, d3, redux …
codeSplitting groupspriority অনুযায়ী
react, react-dom, scheduler → react-vendor (20)
বাকি node_modules            → charts (10)
money.js: Home আর Cart দুজনেই → index-এ থাকল
ফলgzip
হোম:     index 2.30 + vendor 68.26 + runtime 0.42
         + CSS 1.94
ঝুড়ি:    + Cart 0.35
অ্যাডমিন: + Admin 0.29 + charts 100.78
  1. Entry. Bundler main.jsx থেকে শুরু করে। এখানে React DOM, App আর CSS-এর import.
  2. Static import এক জায়গায়। Home, money.js, ছোট লোগো সাধারণ import-এ এসেছে, তাই এগুলো প্রথম পাতার অংশ। 197 byte-এর লোগো ৪ KiB সীমার নিচে, তাই কোডের ভেতরে inline.
  3. Dynamic import নতুন দরজা। প্রতিটি import() একটি নতুন chunk-এর শুরু। Admin-এর পেছনে recharts আর তার সব dependency টেনে আসে।
  4. Group-এ বাছাই। priority 20-এর group আগে React নেয়, 10-এর group বাকি node_modules. money.js হোম আর ঝুড়ি দুজনেই ব্যবহার করে; হোম আগে থেকেই লোড হয় বলে সে index-এ থাকে, Cart তাকে index থেকে নেয়।
  5. ফল। হোমে 70.97 kB JavaScript আর 1.94 kB CSS. অ্যাডমিনে বাড়তি 101.07 kB, যা ১০০ kB-র route বাজেট সামান্য পার হয়েছে। Build সফল মানেই বাজেট ঠিক, এমন নয়।

হোম পাতার HTML এখন index চালায় আর rolldown-runtime ও react-vendor-এর জন্য modulepreload দেয়, যাতে browser এগুলো একসাথে নামায়। charts নেই, সে আসবে শুধু অ্যাডমিনে গেলে। আবার সেই একই শব্দ বদলানো হলো:

Cart-wKUX_Rsk.js      → Cart-BebD_hWa.js        নতুন
index-DKzZ158T.js     → index-Bf3hilhL.js       নতুন (2.29 kB gzip)
react-vendor, charts, Admin, rolldown-runtime, CSS → একই

Index এখনো বদলায়, কারণ সে Cart-এর নাম লিখে রাখে। কিন্তু সে এখন 2.29 kB, আর 68 kB-র React cache-এই থাকে। Vite 8.1 এর পরের ধাপও এনেছে: পরীক্ষামূলক build.chunkImportMap. এটি chunk-গুলো স্থির নামে import করে, আর HTML-এর একটি import map browser-কে বলে কোন স্থির নামের আসল hash-যুক্ত ফাইল কোনটি। চালু করে একই পরীক্ষা:

<script type="importmap">{"imports":{"/assets/Cart-KiXb8dQ5.js":"/assets/Cart-DEJzHSEK.js", …}}</script>

Cart-Bc1lE4WC.js      → Cart-DEJzHSEK.js        নতুন
index-D61jEVz2.js     → index-D61jEVz2.js       একই
বাকি সব               → একই

এবার শুধু Cart. বদল ঢুকেছে শুধু HTML-এর import map-এ, আর HTML প্রতিবার নতুন আনা হয়ই। Vite-এর docs-এ এটি experimental, আর experimental.renderBuiltUrl-এর সাথে এখনো চলে না, তাই নিজের অ্যাপে test করে তবেই চালু করুন।

ক্রেতার browserগতকালের visit
cache-এ:
  index-DL0ZPs8R.js   70.53 kB
  index-CBkHMBcy.css   1.94 kB
Admin, Cart কখনো খোলেননি
কোডে বদলএকটি শব্দ
Cart.jsx
- মোট:
+ সর্বমোট:
npx vite build → নতুন dist/ → deploy
Vendor ভাগ ছাড়াআজকের visit
index.html          নতুন (no-cache)
index-DA1Y63ug.js   cache-এ নেই → 70.53 kB
index-CBkHMBcy.css  cache-এ আছে ✓
Vendor ভাগসহআজকের visit
index-Bf3hilhL.js   নতুন → 2.29 kB
react-vendor-DyYzDaJB.js  cache ✓ 68.26 kB
rolldown-runtime          cache ✓
Vendor + import mapআজকের visit
index.html-এর import map বদলেছে
index-D61jEVz2.js         cache ✓
react-vendor-Cp0g7DYm.js  cache ✓
হোমের জন্য নতুন JS: 0 kB
Cart-DEJzHSEK.js শুধু ঝুড়িতে গেলে
  1. গতকাল। একজন ক্রেতা হোম পাতা দেখেছেন। তাঁর browser-এ index আর CSS জমা, কারণ নামে hash আছে আর server এক বছরের cache দিয়েছে।
  2. বদল। দোকানের দল ঝুড়ির পাতায় একটি শব্দ বদলে নতুন build deploy করল। হোম পাতার কোনো কোড বদলায়নি।
  3. ভাগ ছাড়া। নতুন HTML নতুন index-এর নাম চায়, কারণ index-এর ভেতরে Cart-এর নাম লেখা। ক্রেতাকে পুরো 70.53 kB আবার নামাতে হলো, React সহ, শুধু অন্য পাতার একটি শব্দের জন্য।
  4. Vendor ভাগসহ। Index এখনো বদলায়, কিন্তু সে এখন শুধু অ্যাপের কোড, 2.29 kB. React-এর 68.26 kB cache থেকে আসে।
  5. Import map সহ। Index Cart-কে স্থির নামে ডাকে, আসল নাম থাকে HTML-এর map-এ। তাই হোমের জন্য কোনো নতুন JavaScript নেই। নতুন Cart আসবে শুধু ঝুড়ি খুললে।

নিচের ব্লকে একই ধারণা ছোট করে: node:crypto দিয়ে content hash, আর index যদি Cart-এর আসল নাম লেখে বনাম স্থির নাম লেখে।

const { createHash } = require('node:crypto');
const hash = s => createHash('sha256').update(s).digest('base64url').slice(0, 8);

// প্রতিটি chunk-এর নিজের কোড; index জানে Cart কোন নামে আছে
function build(src, useImportMap) {
  const name = {};
  for (const c of ['vendor', 'Cart', 'Admin']) name[c] = `${c}-${hash(src[c])}.js`;
  // import map থাকলে index স্থির নাম লেখে, নইলে hash-সহ আসল নাম
  const cartRef = useImportMap ? 'Cart.js' : name.Cart;
  name.index = `index-${hash(src.index + cartRef)}.js`;
  return name;
}
const v1 = {
  vendor: 'react + react-dom ...', index: 'App() { lazy(() => import(CART)) }',
  Cart: "মোট: {sub}", Admin: 'LineChart ...',
};
const v2 = { ...v1, Cart: "সর্বমোট: {sub}" };     // শুধু একটি লেখা বদল

for (const im of [false, true]) {
  const a = build(v1, im), b = build(v2, im);
  console.log(im ? '— import map সহ' : '— import map ছাড়া');
  for (const k of Object.keys(a)) {
    const changed = a[k] !== b[k];
    console.log(`  ${b[k].padEnd(20)} ${changed ? 'নতুন, আবার নামাতে হবে' : 'একই, cache থেকে'}`);
  }
}

আউটপুট:

— import map ছাড়া
  vendor-Xo6n1OwK.js   একই, cache থেকে
  Cart-twRNgOtg.js     নতুন, আবার নামাতে হবে
  Admin-h34395ik.js    একই, cache থেকে
  index-xQCsHSRm.js    নতুন, আবার নামাতে হবে
— import map সহ
  vendor-Xo6n1OwK.js   একই, cache থেকে
  Cart-twRNgOtg.js     নতুন, আবার নামাতে হবে
  Admin-h34395ik.js    একই, cache থেকে
  index-WnTbkY4l.js    একই, cache থেকে

SHA-256-এর প্রথম ৮টি অক্ষর নাম হিসেবে যথেষ্ট, কারণ এখানে উদ্দেশ্য শুধু বদল ধরা, নিরাপত্তা নয়। দুই ক্ষেত্রেই Cart-এর hash একই, twRNgOtg, কারণ তার বিষয়বস্তু একই। পার্থক্য index-এ: আসল নাম লিখলে Cart-এর নতুন hash তার বিষয়বস্তুর অংশ হয়ে যায়। এই মডেলে Admin index থেকে কিছু নেয় না, তাই আসল build-এর মতো সে বদলায়নি। Vendor ভাগের পরের আসল build-এর সাথে এটি মেলে।

Dynamic import-এর আরেকটি ব্যবহার: ভারী কাজ দরকারের সময়

Route ছাড়াও import() একটি ভারী কাজকে ক্লিক পর্যন্ত পিছিয়ে দিতে পারে। যেমন অ্যাডমিনের “Excel-এ নামান” বোতাম:

async function exportOrders(rows) {
  const { buildSheet } = await import('./export/sheet.js');  // আলাদা chunk, ক্লিকের পরে আসে
  const blob = buildSheet(rows);
  // … ডাউনলোড শুরু
}

যে কোড ১০০ জনের মধ্যে ২ জন ব্যবহার করেন, তার দাম ১০০ জনের ফোনে দেওয়া অন্যায্য। তবে মনে রাখুন, import()-এর পথ স্থির স্ট্রিং হলে bundler সেটি আগেই খুঁজে পায়। পথ চলার সময় জোড়া লাগালে bundler জানে না কোন ফাইল ভাগ করতে হবে।

৬ · Minification আর production-এর source map

Minification মানে দর্জির কাপড় কাটার মতো: আকার বদলায়, জামা একই থাকে। ফাঁকা জায়গা, মন্তব্য যায়; formatTaka হয়ে যায় e; অপ্রয়োজনীয় বন্ধনী আর অসম্ভব শাখা বাদ পড়ে। Vite 8-এর default JavaScript minifier Oxc. Docs বলে Oxc, Terser-এর চেয়ে অনেক দ্রুত, তবে সংকোচন একটু কম, আর 'esbuild' option deprecated. আমাদের অ্যাপে চারটি বিকল্প চালালাম, vendor ভাগসহ (একটি স্থানীয় run; Terser 5.51.2, esbuild 0.28.2):

--minifyreact-vendor (gzip)charts (gzip)মোট build সময়
false564.30 kB (106.94)849.78 kB (192.76)152 ms
oxc (default)218.70 kB (68.26)348.25 kB (100.78)153 ms
terser218.56 kB (69.10)357.12 kB (101.94)1.50 s
esbuild (deprecated)220.40 kB (69.48)361.76 kB (107.28)182 ms

এই অ্যাপে Oxc gzip-এ সবচেয়ে ছোট ফল দিয়েছে, আর Terser প্রায় দশ গুণ সময় নিয়েছে। Docs-এর “সংকোচন একটু কম” কথাটি অন্য কোডে সত্য হতে পারে, তাই এটিকে সাধারণ নিয়ম নয়, একটি run-এর ফল হিসেবে নিন। আর minify না করলে charts প্রায় দ্বিগুণ, এটি সব ক্ষেত্রে সত্য।

Source map: বানান, কিন্তু প্রকাশ্যে দেবেন না

Minify করা কোডে error এলে stack trace বলে at a (index-DKzZ158T.js:1:4521). এটি দিয়ে কিছু বোঝা যায় না। Source map হলো একটি অনুবাদের খাতা, যা minify করা অবস্থানকে মূল ফাইল, লাইন আর নামে ফেরত নেয়। Vite-এ build.sourcemap-এর তিন মান: false (default), true, আর 'hidden'. সত্যিকারের run:

$ npx vite build --sourcemap hidden
dist/assets/Cart-wKUX_Rsk.js                0.44 kB │ gzip:   0.35 kB │ map:     1.06 kB
dist/assets/react-vendor-DyYzDaJB.js      218.70 kB │ gzip:  68.26 kB │ map: 1,043.78 kB
dist/assets/charts-v0OkwtNT.js            348.25 kB │ gzip: 100.78 kB │ map: 1,959.12 kB
…
$ tail -c 40 dist/assets/Cart-wKUX_Rsk.js
… export{a as default};

$ npx vite build --sourcemap
$ tail -c 60 dist/assets/Cart-wKUX_Rsk.js
… export{a as default};
//# sourceMappingURL=Cart-wKUX_Rsk.js.map

true দিলে প্রতিটি ফাইলের শেষে একটি মন্তব্য browser-কে map-এর ঠিকানা দেয়। 'hidden' একই map বানায়, কিন্তু মন্তব্যটি লেখে না, আর hash-ও বদলায় না। Map-এর ভেতরে কী আছে দেখুন:

{"version":3,"file":"Cart-wKUX_Rsk.js","names":["vat",…,"formatTaka"],
 "sources":["../../src/routes/Cart.jsx"],
 "sourcesContent":["import { formatTaka, vat } from '../lib/money.js';\nconst items = …

sourcesContent-এ পুরো মূল কোড, মন্তব্যসহ। Map প্রকাশ্যে থাকলে যে কেউ আপনার পুরো frontend-এর পরিষ্কার কোড, ভেতরের API পথ আর ভুলে রেখে যাওয়া মন্তব্য পড়তে পারে। Frontend-এর কোড এমনিতেই গোপন নয়, তাই এটি নিরাপত্তার দেয়াল নয়, কিন্তু খোঁজার কাজ অনেক সহজ করে দেয়। Secret কখনো client কোডেই থাকা উচিত নয়, সেই নিয়ম পর্ব ৭৮-এর বিষয়।

Production-এর প্রচলিত পথ তাই তিন ধাপের:

  1. sourcemap: 'hidden' দিয়ে build.
  2. .map ফাইলগুলো error tracking সেবায় পাঠানো, release-এর নামসহ। সেবা তখন stack trace-কে মূল লাইনে অনুবাদ করে দেখায়। পর্ব ৮৩-এ নিজের বানানো error গ্রহণের endpoint দিয়ে এটি বিস্তারিত।
  3. Server-এ ওঠানোর আগে dist থেকে .map মুছে ফেলা, অথবা server-এ .map-এর request আটকানো। Deploy-এর পর browser-এ একটি map-এর URL খুলে দেখুন 404 আসে কি না।

৭ · Browser target, polyfill, CSS আর asset

কোন browser-এর জন্য build?

নতুন JavaScript syntax, যেমন ??=, পুরোনো browser বোঝে না। build.target ঠিক করে output কতটা পুরোনো browser-এ চলবে। Vite 8-এর docs অনুযায়ী default 'baseline-widely-available', যা ২০২৬-এর ১ জানুয়ারির হিসাবে Chrome 111, Edge 111, Firefox 114, Safari 16.4. মোটামুটি ২০২৩ সালের শুরুর browser. Next.js 16-এর docs-ও প্রায় একই সীমা বলে: Chrome 111+, Safari 16.4+.

দুটি আলাদা জিনিস গুলিয়ে ফেলবেন না। Syntax (যেমন ?.) build-এ বদলে লেখা যায়। কিন্তু নতুন ফাংশন (যেমন Array.prototype.findLast) পুরোনো browser-এ নেই। তার জন্য লাগে polyfill: বাড়তি কোড যা ফাংশনটি বানিয়ে দেয়। Vite নিজে polyfill যোগ করে না। পুরোনো browser সত্যিই দরকার হলে @vitejs/plugin-legacy (লেখার সময়ে 8.2.3) আছে, যা browserslist-এর প্রশ্ন পড়ে। targets: ['defaults', 'not IE 11'] দিয়ে সত্যিকারের run:

dist/assets/index-legacy-DYXx3tio.js              11.84 kB │ gzip:   3.93 kB
dist/assets/polyfills-legacy-D2ElLikq.js          56.64 kB │ gzip:  19.68 kB
dist/assets/react-vendor-legacy-Cgw2hcRP.js      219.00 kB │ gzip:  68.37 kB
dist/assets/charts-legacy-CNDi_FyC.js            354.91 kB │ gzip: 103.23 kB
dist/index.html                             1.85 kB │ gzip:   0.87 kB
dist/assets/index-Cg_wNzYG.js               4.89 kB │ gzip:   2.40 kB
dist/assets/react-vendor-BteaPEYr.js      218.83 kB │ gzip:  68.27 kB
…
✓ built in 2.76s

প্রতিটি chunk-এর দুটি সংস্করণ, সাথে 19.68 kB gzip-এর polyfill. আধুনিক browser type="module"-এর সংস্করণ নেয়, পুরোনো browser nomodule-এর legacy সংস্করণ, তাই নতুন ফোনের ক্রেতা polyfill-এর দাম দেন না। কিন্তু build সময় প্রায় দশ গুণ, HTML 0.36 থেকে 1.85 kB, আর দুটি পথ test করতে হয়। সিদ্ধান্ত আসা উচিত আপনার ক্রেতাদের আসল browser-এর তথ্য থেকে, অনুমান থেকে নয়। Modern-only build, মানে default target, বেশিরভাগ নতুন অ্যাপের জন্য যুক্তিসংগত শুরু।

CSS আর Tailwind-এর output

CSS-ও bundle হয়। cssCodeSplit default true, তাই lazy chunk-এর CSS আলাদা ফাইলে সেই chunk-এর সাথে আসে। পর্ব ২২-এর Tailwind v4 (লেখার সময়ে 4.3.4) source ফাইলে যে class পায়, শুধু সেগুলোর CSS লেখে। আমাদের পুরো অ্যাপের CSS 5.71 kB, gzip 1.94 kB.

একটি আসল ফাঁদ: আমাদের প্রথম দুই build-এর মধ্যে, কোনো class না বদলেও, CSS 5.71 থেকে 9.31 kB হয়ে গেল। কারণ: আগের build-এর dist কপি প্রজেক্টের ভেতরে পড়ে ছিল, আর Tailwind-এর স্বয়ংক্রিয় খোঁজ সেই minify করা JavaScript-ও পড়ে recharts-এর class-এর মতো দেখতে শব্দগুলোকে class ধরেছে। Tailwind .gitignore-এ লেখা ফাইল বাদ দেয়। dist আর visualizer-এর stats.html সেখানে যোগ করার পর CSS আবার 5.71 kB. একই কারণে একবার 8.12 kB-ও দেখেছি, যখন stats.html বাদের তালিকায় ছিল না।

ছবি আর ?url

JavaScript থেকে ছবি import করলে Vite সেটি asset হিসেবে সামলায়। ?url-এর মানে “শুধু URL চাই”। ছোট ফাইল হলে, docs অনুযায়ী assetsInlineLimit-এর 4096 byte-এর নিচে, ফাইলটি base64 হয়ে কোডের ভেতরেই ঢুকে যায়, আলাদা request লাগে না। বড় হলে hash-যুক্ত নামে dist/assets-এ যায়। সত্যিকারের run:

import logoUrl from './logo.svg?url';     // 197 byte
import bannerUrl from './banner.svg?url'; // 5,891 byte
→ logoUrl   = "data:image/svg+xml;base64,PHN2…"
→ bannerUrl = "/assets/banner-CiqKj0VQ.svg"   (gzip 802 B)

Inline করা ছবি আলাদা cache হয় না, JavaScript বদলালে সেটিও আবার নামে। তাই সীমাটি ছোট রাখা যুক্তিসংগত। Product-এর ছবির মতো বড় ও অনেক ছবি bundle-এ নয়, object storage আর image pipeline-এ রাখা ভালো (পর্ব ৬৪)।

৮ · Bundle বিশ্লেষণ আর Next.js 16-এর build

Build-এর শেষে শুধু chunk-এর আকার দেখা যায়, ভেতরে কী আছে তা নয়। পর্ব ২৩-এ আমরা rollup-plugin-visualizer একবার চালিয়েছি। এখানে vendor ভাগের পরের build-এ আবার চালালাম, লেখার সময়ের version 7.1.1. একসাথে দুটি output: দেখার জন্য treemap, script-এর জন্য raw-data:

import { defineConfig, mergeConfig } from 'vite';
import base from './vite.config.js';
import { visualizer } from 'rollup-plugin-visualizer';

export default mergeConfig(base, defineConfig({
  plugins: [
    visualizer({ filename: 'stats.html', template: 'treemap', gzipSize: true }),
    visualizer({ filename: 'stats.json', template: 'raw-data', gzipSize: true }),
  ],
}));

npx vite build -c vite.viz.config.js চালালে 410 kB-র stats.html তৈরি হলো। Browser-এ খুললে একটি treemap: পুরো আয়তক্ষেত্র আপনার সব JavaScript, ভেতরে প্রতিটি chunk একটি বড় ঘর, তার ভেতরে package, তার ভেতরে ফাইল। ঘরের আয়তন আকারের সমান অনুপাতে। আমাদের ছবিতে দুটি ঘর প্রায় সব জায়গা নিয়েছে: বাঁয়ে charts-এ recharts-এর বিশাল ঘর, তার চারপাশে ছোট ছোট d3-এর টুকরো; ডানে react-vendor-এ প্রায় পুরোটাই react-dom. অ্যাপের নিজের কোড এত ছোট যে খুঁজে পেতে zoom করতে হয়। JSON থেকে package ধরে যোগ করলে (rendered মানে minify-এর আগে, আর gzip প্রতিটি module-এর আলাদা, তাই chunk-এর gzip-এর চেয়ে বড়):

react-vendor   react-dom         536.7 kB rendered   101.4 kB gz
charts         recharts          480.0 kB rendered   152.5 kB gz
charts         es-toolkit         30.7 kB rendered    13.9 kB gz
charts         d3-shape           30.3 kB rendered     5.1 kB gz
charts         @reduxjs/toolkit   26.6 kB rendered     6.9 kB gz
charts         d3-scale           26.3 kB rendered     5.4 kB gz
charts         decimal.js-light   25.4 kB rendered     7.0 kB gz
charts         immer              20.4 kB rendered     5.6 kB gz
react-vendor   react              17.1 kB rendered     4.9 kB gz
…

একটি তথ্য চোখে পড়ার মতো: চার্টের জন্য আমরা Redux, Immer আর decimal.js-ও পাঠাচ্ছি, কারণ recharts 3 ভেতরে এগুলো ব্যবহার করে। এগুলো আপনি install করেননি, কিন্তু ব্যবহারকারী নামাচ্ছেন। Visualizer ছাড়া এটি জানা প্রায় অসম্ভব। vite-bundle-visualizer (লেখার সময়ে 1.2.1) একই কাজ config না বদলে এক কমান্ডে করে, তবে এটি আমরা চালাইনি।

Next.js 16: Turbopack আর বদলে যাওয়া build output

Next.js 16 থেকে next build default-এ Turbopack ব্যবহার করে; webpack চাইলে --webpack দিতে হয়। “বাজারঘর”-এর চার পাতার ছোট Next.js সংস্করণে সত্যিকারের run, Next.js 16.4.4:

$ npx next build
▲ Next.js 16.4.4 (Turbopack)
  Creating an optimized production build ...
✓ Compiled successfully in 1122ms
  Running TypeScript ...
  Collecting page data using 6 workers ...
✓ Generating static pages using 6 workers (4/4) in 389ms
  Finalizing page optimization ...

Route (app)
┌ ○ /
├ ○ /_not-found
├ ○ /admin
└ ƒ /products/[id]

○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

পুরোনো টিউটোরিয়ালে আপনি প্রতিটি route-এর পাশে “Size” আর “First Load JS” দেখবেন। Next.js 16-এ নেই। Upgrade guide বলে, Server Component-এর যুগে এই সংখ্যাগুলো ভুল দিত, Turbopack আর webpack-ও একমত হতো না, তাই সরানো হয়েছে। Route-এর ধরন অবশ্য আছে: ○ মানে build-এ তৈরি static পাতা, ƒ মানে প্রতি request-এ server-এ। এর অর্থ পর্ব ৫৫-এ, আর route-এর গঠন পর্ব ৫১-এ।

তাহলে আকার মাপবেন কীভাবে? দুটি পথ। এক, Next.js 16.1 থেকে Turbopack-এর নিজের analyzer: npx next analyze browser-এ একটি treemap খোলে, server আর client আলাদা করে, আর কোন import শৃঙ্খলে একটি module এসেছে তা দেখায়। --output দিলে ফল ফাইলে লেখে:

$ npx next analyze --output
  Analyzing a production build...
✓ Analyze completed in 1200ms. Results written to ~/bazarghar-next/.next/diagnostics/analyze.

দুই, আসল browser-এ মাপা। next start চালিয়ে Edge-এ প্রতিটি route খুলে নেটওয়ার্কে আসা script গুনলাম (একটি স্থানীয় run; compression চালু):

RouteScript requestনেটওয়ার্কে আসা JS
/৮টি139.0 kB
/admin৭টি135.7 kB
/products/1৬টি135.4 kB

তিন পাতায় প্রায় একই, কারণ বেশিরভাগই Next.js আর React-এর ভিত্তি। হোমে একটু বেশি, কারণ পাতার Link-গুলো অন্য route আগে থেকে নামায়, যাকে বলে prefetch. অ্যাডমিনের ছোট client component-টি প্রায় কিছুই যোগ করেনি। Client-এর ভার কমানোর আসল হাতিয়ার Next.js-এ server component: যা server-এ চলে, তার library client-এ যায় না (পর্ব ৫২)। Next.js-এর docs আসল route-এর পারফরম্যান্স Lighthouse-এর মতো টুলে মাপার পরামর্শ দেয়, যা পর্ব ৮৪-এর বিষয়।

৯ · Node backend-এর build আর “বাজারঘর”-এর বাজেট

Express API: bundle করব, না সরাসরি চালাব?

Browser-এর মতো server-এ request-এর খরচ নেই, তাই backend-এ bundle বাধ্যতামূলক নয়। প্রশ্ন বরং TypeScript চালানো আর deploy-এর আকার। লেখার সময়ে Node 24.15 দিয়ে “বাজারঘর”-এর ছোট Express 5.3 API তিনভাবে চালালাম। একটি .ts ফাইল, ভেতরে interface আর type-only import:

$ node src/server.ts
200 [{"id":1,"name":"মসুর ডাল ১ কেজি","price":135,"total":155}]

$ npx tsx src/server.ts
200 [{"id":1,"name":"মসুর ডাল ১ কেজি","price":135,"total":155}]

কোনো flag নেই, কোনো warning নেই। Node-এর docs অনুযায়ী type stripping 23.6 আর 22.18 থেকে default-এ চালু, আর 25.2 ও 24.12 থেকে stable. পুরোনো --experimental-strip-types flag তাই আর লাগে না। Node যা করে তা সীমিত: type-এর লেখা ফাঁকা জায়গায় বদলায়, type যাচাই করে না, tsconfig.json পড়ে না, import-এ .ts extension চায়। আর যে TypeScript কোড JavaScript-এ রূপান্তর চায়, তা চলে না:

$ node src/enum-demo.ts
enum Status { Pending, Paid }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode

Node 26-এ রূপান্তরের পরীক্ষামূলক flag-টিও সরানো হয়েছে, docs তাই বলে। তাই সরাসরি Node-এ চালাতে চাইলে tsconfig-এ erasableSyntaxOnly চালু রাখুন, যাতে editor-ই enum আটকায়। tsx (লেখার সময়ে 4.23.15) ভেতরে esbuild দিয়ে অনুবাদ করে, তাই enum-ও চালায়, আর dev-এ tsx watch সুবিধাজনক। কোনোটিই type যাচাই করে না, তাই CI-তে tsc --noEmit আলাদা চালাতে হয়, যা পর্ব ৮১-এর অংশ।

তৃতীয় পথ bundle. esbuild দিয়ে দুইভাবে, সত্যিকারের run, esbuild 0.28.2:

$ npx esbuild src/server.ts --bundle --platform=node --format=esm \
    --target=node24 --packages=external --minify --sourcemap=external --outfile=dist-ext/server.js
  dist-ext\server.js      425b
  dist-ext\server.js.map  1.6kb
$ node dist-ext/server.js
200 [{"id":1,"name":"মসুর ডাল ১ কেজি","price":135,"total":155}]

$ npx esbuild src/server.ts --bundle --platform=node --format=esm \
    --target=node24 --minify --outfile=dist-all/server.js
  dist-all\server.js  794.3kb
$ node dist-all/server.js
Error: Dynamic require of "tty" is not supported

--packages=external-এ শুধু আপনার কোড bundle হলো, 425 byte; Express তখনো node_modules থেকে আসবে। দ্বিতীয়টিতে Express আর তার সব dependency একটি 794 kB ফাইলে, কিন্তু চলেনি। কারণ Express-এর ভেতরের একটি dependency CommonJS-এ require("tty") ডাকে, আর ESM output-এ require নেই। একই কমান্ড --format=cjs দিয়ে চালালে ঠিকমতো 200 এল। শিক্ষা: পুরো bundle করার আগে output চালিয়ে দেখুন, শুধু build সফল হওয়া যথেষ্ট নয়।

tsup (লেখার সময়ে 9.0.0) একই esbuild-এর ওপর একটি সহজ config দেয়, আর default-এ dependency বাইরে রাখে:

$ npx tsup
CLI tsup v9.0.0
CLI Target: node24
ESM Build start
ESM dist\server.js     681.00 B
ESM dist\server.js.map 1.37 KB
ESM ⚡️ Build success in 17ms
পথসুবিধাখরচ
node server.tsকোনো build ধাপ নেই, stack trace-এ আসল লাইনenum ইত্যাদি চলে না, type যাচাই নেই, production-এ node_modules লাগে
tsxসব TypeScript চলে, dev-এ watchপ্রতিবার চালু হওয়ার সময় অনুবাদ, বাড়তি dependency
esbuild/tsup, dependency বাইরেছোট, দ্রুত চালু, পরিষ্কার JavaScriptBuild ধাপ, source map সামলাতে হয়, node_modules লাগে
esbuild, সব ভেতরেএকটি ফাইল, node_modules ছাড়া deployCommonJS/ESM-এর ফাঁদ, native module বা dynamic require ভাঙতে পারে

আমাদের API-র production dependency-র গাছে ৬৫টি package. সব ভেতরে নিলে container image ছোট হয়, deploy দ্রুত হয়; পর্ব ৮০-তে Docker-এর multi-stage build আর Cloud & DevOps কোর্সের Dockerfile পর্ব এর সাথে মিলিয়ে দেখবেন। Prisma-র মতো native engine থাকা package সাধারণত বাইরে রাখতে হয়। Next.js-এ একই কাজ করে serverExternalPackages.

“বাজারঘর”-এর build বাজেট

পর্ব ২৩-এ আমরা ধরে নেওয়া নেটওয়ার্কে (200 kB/s, 150 ms RTT, চালানোর খরচ 3 ms/kB) হিসাব করেছিলাম: প্রথম পাতার JavaScript gzip-এ ১৫০ kB, প্রতি route chunk ১০০ kB. এবার আসল build এর বিপরীতে মিলাই। নিচের ব্লক নিজে gzip মাপে, আর বাজেট যাচাই করে:

const zlib = require('node:zlib');
let seed = 42;                                   // নির্দিষ্ট seed: প্রতিবার একই ফল
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const word = () => Array.from({ length: 3 + Math.floor(rnd() * 6) },
  () => 'abcdefghijklmnopqrstuvwxyz'[Math.floor(rnd() * 26)]).join('');
// বাস্তবধর্মী minified কোড: ২০০টি component, নাম আর সংখ্যা আলাদা
const comp = () => `function ${word()}(e){let ${word()}=e.${word()}*${Math.floor(rnd() * 900)};` +
  `return jsx("${word()}",{className:"${word()} ${word()}",children:[e.${word()},${word()}(e)]})}\n`;
const files = Array.from({ length: 200 }, comp);
const one = files.join('');

const kb = n => (n / 1000).toFixed(2) + ' kB';
const gz = s => zlib.gzipSync(s, { level: 9 }).length;
const br = s => zlib.brotliCompressSync(s).length;
console.log('এক ফাইল   raw', kb(one.length), '| gzip', kb(gz(one)), '| brotli', kb(br(one)));

// একই কোড ২০টি আলাদা chunk-এ: প্রতিটি নিজে নিজে সংকুচিত হয়
const chunks = Array.from({ length: 20 }, (_, c) => files.slice(c * 10, c * 10 + 10).join(''));
const sumGz = chunks.reduce((s, c) => s + gz(c), 0);
console.log('২০ chunk   gzip যোগফল', kb(sumGz), `(${Math.round(sumGz / gz(one) * 100)}%)`);

// বাজেট: প্রথম পাতার JS gzip-এ 150 kB-এর মধ্যে? (সংখ্যাগুলো একটি আসল build থেকে)
const budget = 150_000;
const firstLoad = { 'index.js': 2290, 'react-vendor.js': 68260, 'rolldown-runtime.js': 420 };
const total = Object.values(firstLoad).reduce((a, b) => a + b, 0);
for (const [f, n] of Object.entries(firstLoad)) console.log('  ' + f.padEnd(22), kb(n));
console.log('প্রথম পাতা', kb(total), total <= budget ? 'বাজেটের ভেতরে' : 'বাজেট ছাড়িয়েছে',
  `(${Math.round(total / budget * 100)}%)`);

আউটপুট:

এক ফাইল   raw 23.64 kB | gzip 8.41 kB | brotli 7.71 kB
২০ chunk   gzip যোগফল 10.59 kB (126%)
  index.js               2.29 kB
  react-vendor.js        68.26 kB
  rolldown-runtime.js    0.42 kB
প্রথম পাতা 70.97 kB বাজেটের ভেতরে (47%)

দুটি শিক্ষা। প্রথমত, gzip একটি ফাইলের ভেতরে পুনরাবৃত্তি খোঁজে, তাই একই কোড ২০টি ছোট chunk-এ ভাগ করলে মোট ২৬% বড় হলো। খুব ছোট chunk-এ এই খরচ বেশি। আমাদের আসল build-এ chunk বড় বলে ক্ষতি কম: সব একসাথে 70.53 + 100.92 + 0.32 = 171.77 kB, ভাগ করে 172.40 kB, মাত্র ০.৪% বেশি। Brotli আরও প্রায় ৮% ছোট করেছে; server কোনটি পাঠাবে তা পর্ব ৮৪-এর বিষয়। দ্বিতীয়ত, প্রথম পাতা 70.97 kB, বাজেটের ৪৭%.

বাজেটের হিসাব, ধাপে ধাপে, ধরে নেওয়া 200 kB/s-এ:

  1. প্রথম পাতা নামানো: 70.97 kB ÷ 200 kB/s ≈ 0.355 s, মানে প্রায় 355 ms. সাথে CSS 1.94 kB, প্রায় 10 ms.
  2. অ্যাডমিনে বাড়তি: 101.07 kB ÷ 200 ≈ 505 ms. বাজেট 100 kB, বাড়তি 1.07 kB, প্রায় ১%. বিকল্প তিনটি: হালকা চার্ট library, server-এ SVG বানিয়ে পাঠানো, অথবা সচেতনভাবে বাজেট বাড়ানো, কারণ অ্যাডমিন অফিসের কম্পিউটারে চলে। তিনটিই বৈধ, শর্ত হলো সিদ্ধান্তটি লেখা থাকবে।
  3. Deploy-এর পরে: ধরুন দিনে ১০,০০০ ক্রেতা ফেরত আসেন আর শুধু হোম দেখেন। ভাগ ছাড়া প্রতি deploy-এ 70.53 × 10,000 = 705,300 kB ≈ 705.3 MB আবার নামে। Vendor ভাগে 2.29 × 10,000 = 22,900 kB ≈ 22.9 MB. Import map-এ হোমের JavaScript প্রায় 0. মোবাইল ডেটা যাঁরা কিনে ব্যবহার করেন, তাঁদের জন্য পার্থক্যটি সরাসরি টাকার।
মাপবাজেটএখন (একটি local build)অবস্থা
প্রথম পাতার JS (gzip)≤ 150 kB70.97 kB✓
প্রথম পাতার CSS (gzip)≤ 20 kB1.94 kB✓
প্রতি route chunk (gzip)≤ 100 kBঅ্যাডমিন 101.07 kB✗ সিদ্ধান্ত বাকি
একটি শব্দ বদলের deploy-এ হোমের নতুন JS≤ 5 kB2.29 kB (vendor ভাগে)✓
প্রকাশ্য .map ফাইল০টিhidden, upload-এর পর মোছা✓
vite build সময়≤ 30 sপ্রায় 0.2 s✓

CSS, deploy আর build সময়ের সীমা আমাদের নিজের সিদ্ধান্ত, কোনো মানদণ্ড নয়। বাজেট তখনই কাজের যখন যন্ত্র তা পাহারা দেয়: প্রতিটি pull request-এ build চালিয়ে আকার মেলানো, সীমা পার হলে build ব্যর্থ। এই পাইপলাইন পর্ব ৮১-এ।

আরও গভীরে: Source map-এর format বহু বছর একটি অনানুষ্ঠানিক Google ডকুমেন্টে ছিল (“Revision 3”, ২০১১ সালের দিকে)। ২০২৪ সালের ডিসেম্বরে Ecma এটিকে ECMA-426 নামে মান হিসেবে গ্রহণ করে, TC39-এর একটি task group এখনো এর উন্নয়ন চালাচ্ছে। একটি চলমান দিক হলো “debug ID”: bundle আর map-এ একই অনন্য চিহ্ন বসানো, যাতে error tracking সেবা ফাইলের নাম নয়, চিহ্ন ধরে সঠিক map খুঁজে পায়। Next.js 16-এর Turbopack config-এ debugIds option এর একটি উদাহরণ।

পরীক্ষা ও ইন্টারভিউতে:

  • Tree shaking কেন CommonJS-এ দুর্বল? require চলার সময়ের ফাংশন ডাক; ES module-এর import/export স্থির, build-এ বিশ্লেষণ করা যায়।
  • Content hash কেন? বিষয়বস্তু না বদলালে নাম একই, তাই লম্বা cache; বদলালে নতুন নাম, তাই পুরোনো ফাইল ভুল করে চলে না।
  • sideEffects: false-এর ঝুঁকি? শুধু import-এ কাজ করা ফাইল, যেমন CSS, নীরবে বাদ পড়তে পারে।
  • Hidden source map কী? Map তৈরি হয়, কিন্তু bundle-এ ঠিকানার মন্তব্য থাকে না; map শুধু error tracking-এ পাঠানো হয়।
  • Dev আর production build-এর পার্থক্য (Vite)? Dev ফাইল ধরে native ESM দেয়, build পুরো graph tree shake, chunk, minify আর hash করে।

১০ · AI-এর যুগে: build config যাচাই করে নিন

Build config হলো সেই জায়গা যেখানে AI-এর পুরোনো স্মৃতি সবচেয়ে বেশি ধরা পড়ে। Vite, Next.js আর Node এক বছরে বড় বদল এনেছে: Rolldown, Oxc, Turbopack default, size কলাম সরানো, type stripping stable. AI প্রায়ই আগের বছরের উত্তর দেয়, আর সেই config অনেক সময় চলেও, শুধু চুপচাপ ভুল করে।

AI যা প্রায়ই করেসমস্যাযাচাই
rollupOptions.output.manualChunks-এর object রূপVite 8-এ object রূপ আর চলে নাVite-এর migration guide আর build output
build.minify: 'esbuild' “দ্রুততার জন্য”Deprecated; আমাদের run-এ ফলও বড়দুবার build, gzip কলাম তুলনা
sourcemap: true “debug সহজ করতে”পুরো মূল কোড প্রকাশ্যেDeploy-এর পর .map-এর URL খুলে 404 দেখা
Library-তে "sideEffects": false বসানোCSS বা শীর্ষের কোড নীরবে হারায়Build-এর CSS ফাইল খুলে দেখা
“First Load JS দেখে নিন” (Next.js 16)এই কলাম আর নেইnext analyze বা আসল browser-এ মাপা
node --experimental-strip-types বা সব জায়গায় ts-nodeপুরোনো পথ; enum সমস্যা আড়াল হয়নিজের Node version-এর docs, আর output চালিয়ে দেখা
ছোট করতে নতুন plugin বা package যোগবাড়তি dependency, অচেনা কোডVisualizer-এ আগে-পরে, package-টি সত্যিই আছে কি না দেখা

AI-কে config লিখতে দেওয়ার আগে আপনার version আর আসল build output দিন, আর প্রতিটি option-এর উৎস চান।

আমি Vite 8.3 (Rolldown) আর React 19.3 ব্যবহার করি। নিচে আমার vite.config.js আর `npx vite build`-এর পুরো output দিলাম। [config] [build output] ১. কোন chunk প্রথম পাতায় লোড হয় আর কোনটি lazy, output দেখে বলো। ২. React আলাদা vendor chunk-এ নিতে build.rolldownOptions.output.codeSplitting দিয়ে config দাও। manualChunks বা rollupOptions ব্যবহার করো না। ৩. প্রতিটি option-এর জন্য vite.dev বা rolldown.rs-এর পাতার নাম বলো। ৪. বদলের পর build output-এ কী কী দেখলে বুঝব কাজ হয়েছে, আর কোন ফাইলের hash বদলানোর কথা নয়, তা বলো। ৫. নতুন package যোগ করো না। sourcemap বা sideEffects বদলাতে হলে আগে কারণ বলো।

AI-এর build বদল নেওয়ার আগে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১HTTP/2 একটি সংযোগে অনেক ফাইল আনতে পারে, browser-ও এখন ES module বোঝে। তাহলে bundler কি এখনো দরকার?

প্রশ্নটি নতুন নয়। HTTP/2 প্রমিত হয় ২০১৫ সালে, আর একই সময়ে ES module ভাষায় আসে। অনেকে তখন ভেবেছিলেন, একটি সংযোগে শত ফাইল আনা গেলে আর জোড়া লাগানোর দরকার কী? Browser-এ <script type="module"> ২০১৭-১৮ সালের মধ্যে প্রধান browser-গুলোতে আসে। Vite-এর dev server আসলে এই ধারণার জীবন্ত প্রমাণ: dev-এ সে bundle করে না।

কিন্তু production-এ সমস্যা request-এর সংখ্যা নয়, request-এর গভীরতা। Browser main.js নামিয়ে পড়ার পরেই জানে তার App.js লাগবে, সেটি পড়ার পরে জানে Home.js লাগবে। প্রতিটি স্তর একটি পূর্ণ round trip. 150 ms RTT-এর নেটওয়ার্কে ১০ স্তরের graph মানে শুধু অপেক্ষায় দেড় সেকেন্ড, HTTP/2 যতই সমান্তরাল হোক। আমাদের dev run-এ হোম পাতায় ১৫টি request গেছে; সত্যিকারের অ্যাপে কয়েকশো হয়।

দ্বিতীয় কারণ tree shaking. Browser জানে না lodash-es-এর ৬৪২টি ফাইলের মধ্যে কোনগুলো লাগবে না; সে যা import হয় তাই নামায়। Bundler আগেভাগে পুরো graph দেখে বাদ দিতে পারে। তৃতীয় কারণ সংকোচন: gzip বা brotli একটি বড় ফাইলে পুনরাবৃত্তি বেশি পায়, আমাদের মডেলে ২০টি ছোট chunk ২৬% বড় হয়েছে। চতুর্থ, JSX আর TypeScript browser বোঝে না, অনুবাদ এমনিতেই লাগে।

তবে পুরোনো চরম অবস্থাও ফিরে আসেনি। আগে একটি বিশাল bundle বানানো হতো, এখন route আর vendor ধরে কয়েকটি মাঝারি chunk, modulepreload দিয়ে গভীরতা সমান করা। Vite-এর chunkImportMap বা browser-এর import map দেখায় যে আদর্শ উত্তর মাঝামাঝি: মান-সম্মত ES module, কিন্তু build-এ সাজানো।

মূল উপলব্ধি: নতুন প্রোটোকল একটি খরচ কমায়, সব খরচ নয়। Bundler টিকে আছে কারণ সে শুধু ফাইল জোড়া দেয় না, আগে থেকে পুরো graph দেখে এমন সিদ্ধান্ত নেয় যা browser চলার সময়ে নিতে পারে না।

প্র ০২Tree shaking-কে অনেকে dead code elimination-এর আরেক নাম বলেন। দুটি কি একই? আর side effect এত জটিল কেন?

Dead code elimination compiler-এর পুরোনো কৌশল, কয়েক দশকের পুরোনো: যে কোডের ফল কেউ ব্যবহার করে না, বা যে শাখায় কখনো পৌঁছানো যায় না, তা মুছে ফেলা। Rich Harris যখন Rollup-এ “tree shaking” শব্দটি জনপ্রিয় করেন, তিনি পার্থক্যটি এভাবে বলেছিলেন বলে প্রচলিত: dead code elimination তৈরি কেকের ওপর থেকে অপ্রয়োজনীয় অংশ চেঁছে ফেলা, আর tree shaking হলো শুরুতেই শুধু দরকারি উপকরণ নেওয়া। অর্থাৎ বাদ দেওয়া নয়, যা লাগবে তা অন্তর্ভুক্ত করা। আমাদের মডেলে ঠিক তাই হয়েছে: entry থেকে চিহ্ন ছড়ানো, চিহ্নহীন সব বাইরে।

বাস্তবে দুটিই একসাথে চলে। Bundler module ও export স্তরে বাছাই করে, তারপর minifier ফাংশনের ভেতরে অসম্ভব শাখা মোছে। যেমন if (process.env.NODE_ENV !== 'production') build-এ if (false) হয়ে মুছে যায়, React-এর development warning এভাবেই production থেকে বাদ পড়ে।

Side effect জটিল কারণ JavaScript-এ প্রায় সবকিছুই সম্ভাব্য side effect. একটি property পড়াও getter চালাতে পারে, একটি ফাংশন ডাক বাইরের কিছু বদলাতে পারে। আমাদের barrel পরীক্ষায় buildIndex(NAMES) শুধু একটি object বানায়, কিন্তু bundler প্রমাণ করতে পারেনি, তাই রেখেছে। এই অনিশ্চয়তায় bundler নিরাপদ দিকে থাকে: সন্দেহ হলে রেখে দেওয়া। ভুল করে মুছলে অ্যাপ ভাঙে, ভুল করে রাখলে শুধু কয়েক byte বেশি।

এজন্য দুটি মানুষ-লেখা ঘোষণা আছে। /*#__PURE__*/ মন্তব্য একটি নির্দিষ্ট ডাক নিরীহ বলে, আর package.json-এর sideEffects পুরো ফাইল ধরে বলে। দুটিই ঘোষণা, যাচাই নয়। আমাদের theme.css-এর ঘটনা দেখিয়েছে, ঘোষণা মিথ্যা হলে bundler নিখুঁতভাবে মিথ্যাটাই মানে।

মূল উপলব্ধি: Tree shaking গণিত নয়, প্রমাণ আর বিশ্বাসের মিশ্রণ। যেখানে প্রমাণ নেই সেখানে ঘোষণা, আর ঘোষণা যত শক্তিশালী, ভুল ঘোষণার ক্ষতি তত নীরব।

প্র ০৩JavaScript-এর টুল কেন JavaScript ছেড়ে Go আর Rust-এ গেল? এতে কী পেলাম, কী হারালাম?

২০২০ সালে Evan Wallace esbuild প্রকাশ করেন, Go-তে লেখা। তাঁর benchmark-এ এটি তখনকার JavaScript-ভিত্তিক bundler-এর চেয়ে বহুগুণ দ্রুত ছিল। কারণগুলো তিনি নিজেই ব্যাখ্যা করেছিলেন: নেটিভ কোড, সব CPU core-এ সমান্তরাল কাজ, আর কম ধাপে parse. এরপর ঢেউ শুরু হয়। SWC (Rust), Turbopack (Rust, ঘোষণা ২০২২), Oxc, Rolldown আর Lightning CSS, সবই Rust-এ। Vite 8 এখন প্রায় পুরোটাই Rust-এর ওপর।

লাভ সরাসরি মাপা যায়। আমাদের minifier তুলনায় JavaScript-এ লেখা Terser 1.50 s নিয়েছে, Oxc-সহ পুরো build 153 ms. বড় প্রজেক্টে এটি মিনিট বনাম সেকেন্ড, যা CI-এর খরচ আর ডেভেলপারের মনোযোগ দুটোই বাঁচায়। Dev server-এ ফাইল সেভ করার পর পাতা বদলানো যত দ্রুত, কাজের ছন্দ তত ভালো।

হারানোর দিকও আছে। প্রথমত, plugin লেখা কঠিন হয়। Webpack আর Rollup-এর plugin JavaScript-এ, যে কেউ লিখতে পারতেন। Rust-এর core-এ JavaScript plugin চালাতে দুই ভাষার মধ্যে ডেটা পারাপার করতে হয়, যার নিজের খরচ আছে। Rolldown তাই Rollup-এর plugin API রেখেছে, কিন্তু ভারী plugin core-এর গতি কমিয়ে দিতে পারে। দ্বিতীয়ত, বাগ ধরা কঠিন: টুলের ভেতর দেখতে Rust জানতে হয়। তৃতীয়ত, ecosystem-এর বদলের ধাক্কা: manualChunks-এর object রূপ সরানো, esbuild option deprecated, এমন ছোট বদল হাজার প্রজেক্টে কাজ বাড়ায়।

একটি দার্শনিক প্রশ্নও আছে। JavaScript-এর টুল JavaScript-এ থাকলে ব্যবহারকারীরাই অবদান রাখতে পারতেন। নেটিভ টুল কয়েকটি দল আর কোম্পানির হাতে বেশি কেন্দ্রীভূত। Open source থাকায় ঝুঁকি কম, কিন্তু অবদানের দেয়াল উঁচু।

মূল উপলব্ধি: গতি একটি feature, এবং বড় feature. কিন্তু প্রতিটি নতুন টুলের সাথে migration guide পড়া, plugin যাচাই আর নিজের output মাপার দায়িত্বও আসে।

প্র ০৪Next.js 16 build output থেকে “First Load JS” সরিয়ে দিল। সংখ্যা সরানো কি স্বচ্ছতা কমানো, না বাড়ানো?

বহু বছর Next.js-এর build শেষে প্রতিটি route-এর পাশে দুটি সংখ্যা থাকত, আর দলগুলো এগুলো দেখে সিদ্ধান্ত নিত। কেউ কেউ CI-তে এর ওপর সীমাও বসিয়েছিল। Next.js 16-এর upgrade guide বলে, React Server Component-এর যুগে এই সংখ্যাগুলো ভুল দিত। Turbopack আর webpack দুটির হিসাবে সমস্যা ছিল, আর client component-এর payload কীভাবে গুনবে তা নিয়ে তারা একমত হতো না।

কেন কঠিন? পুরোনো পাতাভিত্তিক অ্যাপে একটি route মানে মোটামুটি নির্দিষ্ট JavaScript ফাইলের সেট। Server component আসার পর পাতার একটি অংশ server-এ চলে, আর client-এ যায় তার ফল, RSC payload, যা অনুরোধ ধরে বদলাতে পারে। Layout ভাগ হয়, prefetch আলাদা হয়। আমাদের মাপে হোমে বেশি script এসেছে মূলত অন্য route-এর prefetch-এর জন্য। এটি কি হোমের “আকার”? প্রশ্নটির এক উত্তর নেই।

ভুল সংখ্যা না থাকার চেয়েও খারাপ হতে পারে, কারণ তা ভুল নিশ্চয়তা দেয়। একটি দল হয়তো First Load JS কমাতে সপ্তাহ খরচ করেছে, অথচ আসল ধীরতা ছিল একটি বড় ছবিতে বা ধীর server response-এ। তাই সংখ্যা সরানো এক অর্থে সৎ: কাঠামো বদলালে পুরোনো মাপ টিকিয়ে রাখা উচিত নয়।

অন্য দিকে, দৃশ্যমান সংখ্যা একটি নিয়মিত সতর্কতা ছিল। এখন কেউ খোঁজ না করলে bundle বাড়লেও চোখে পড়বে না। Next.js এর বদলে next analyze দিয়েছে, যা আরও বিস্তারিত, কিন্তু আলাদা করে চালাতে হয়। তাই দায়িত্ব এখন দলের: CI-তে analyzer-এর export বা আসল browser-এ মাপ রাখা।

মূল উপলব্ধি: মাপ বদলের সাথে প্রশ্নও বদলাতে হয়। “এই route কত kB” থেকে “ব্যবহারকারী আসলে কী নামাচ্ছেন আর কতক্ষণে পাতা কাজের হচ্ছে”। দ্বিতীয় প্রশ্নটি কঠিন, কিন্তু সেটিই আসল।

প্র ০৫Node এখন নিজেই TypeScript চালায়। তাহলে backend-এ build ধাপ কি অপ্রয়োজনীয় হয়ে গেল?

Node.js-এ TypeScript-এর ইতিহাস দীর্ঘ। ২০১২ সালে TypeScript আসার পর থেকে দুটি পথ ছিল: আগে tsc দিয়ে JavaScript বানানো, অথবা ts-node-এর মতো টুলে চলার সময় অনুবাদ। পরে esbuild-ভিত্তিক tsx জনপ্রিয় হয়। Node 22.6 (২০২৪)-এ type stripping পরীক্ষামূলকভাবে আসে, 23.6 আর 22.18-এ default চালু হয়, আর 24.12 ও 25.2-এ stable হয়। এর পেছনের ধারণাটি TC39-এর “type annotations” প্রস্তাবের কাছাকাছি: type হলো মন্তব্যের মতো, মুছে দিলেই JavaScript.

এই নকশার শক্তি সরলতা। Node কোনো অনুবাদ করে না, শুধু type-এর অক্ষর ফাঁকা জায়গায় বদলায়, তাই লাইন আর কলাম একই থাকে, source map লাগে না। আমাদের node src/server.ts কোনো build ছাড়াই চলেছে। ছোট API, script আর tool-এর জন্য এটি সত্যিই অনেক build ধাপ মুছে দেয়।

কিন্তু তিনটি কাজ এখনো বাকি থাকে। এক, type যাচাই: Node তা করে না, ভুল type-এর কোডও চলবে। তাই tsc --noEmit CI-তে লাগবেই। দুই, রূপান্তর-নির্ভর syntax: enum, parameter property, runtime namespace. আমাদের run-এ enum সরাসরি ব্যর্থ হয়েছে। নতুন কোডে এগুলো এড়ানো যায়, পুরোনো কোডবেসে বদলাতে সময় লাগে। তিন, deploy-এর আকার আর চালু হওয়ার গতি: bundle করলে node_modules ছাড়া একটি ফাইল, ছোট container, serverless-এ দ্রুত cold start. কিন্তু আমাদের Express-এর পুরো bundle ESM-এ ভেঙে গেছে, তাই এই পথে সতর্ক থাকতে হয়।

তাই বাস্তব উত্তর পরিস্থিতিভেদে আলাদা। নিজের VPS-এ চলা মাঝারি API-তে node server.ts আর CI-তে type যাচাই যথেষ্ট হতে পারে। Serverless বা ছোট image চাইলে esbuild বা tsup. কোনো পথই সবার জন্য সঠিক নয়, আর দল ভেদে মত আলাদা।

মূল উপলব্ধি: Build ধাপ অদৃশ্য হয়নি, ভাগ হয়েছে। চালানো সহজ হয়েছে, যাচাই আর প্যাকেজ করা এখন আলাদা সচেতন সিদ্ধান্ত।

অনুশীলন

হিসাব, অনুমান, কমান্ড আর নকশা, চার রকম কাজ। আগে নিজে চেষ্টা করুন।

  1. হিসাব: একটি দোকানের প্রথম পাতার entry chunk 84 kB gzip, যার ভেতরে React-এর 68 kB. দিনে ৮,০০০ ক্রেতা ফেরত আসেন, সপ্তাহে ৫ বার deploy হয়, প্রতিবার শুধু অ্যাপের কোড বদলায়। ধরে নিন প্রতিটি deploy-এর পর প্রত্যেক ফেরত-আসা ক্রেতা নতুন entry একবার নামান। (ক) সপ্তাহে কত MB নামে? (খ) React আলাদা vendor chunk-এ নিলে কত? (গ) 200 kB/s-এ একজন ক্রেতার জন্য প্রতি deploy-এ কত ms বাঁচে?

    (ক) 84 × 8,000 × 5 = 3,360,000 kB ≈ 3,360 MB. (খ) Entry-তে থাকে 84 − 68 = 16 kB: 16 × 8,000 × 5 = 640,000 kB ≈ 640 MB. সাপ্তাহিক ২,৭২০ MB কম, প্রায় ৮১%. (গ) বাঁচে 68 kB ÷ 200 kB/s = 340 ms. শর্ত: React-এর version না বদলানো, নইলে সেই deploy-এ vendor-ও নতুন।

  2. আউটপুট অনুমান: একটি Vite 8 অ্যাপে এই chunk-গুলো আছে: index (হোম আর money.js), Cart (lazy, money.js-এর ফাংশন index থেকে import করে), Admin (lazy, শুধু charts আর react-vendor থেকে import করে), react-vendor, charts, rolldown-runtime. Import map চালু নেই। money.js-এর একটি অক্ষর বদলে আবার build দিলে কোন কোন ফাইলের নাম বদলাবে?

    index আর Cart. index-এর বিষয়বস্তু সরাসরি বদলেছে। Cart-এর নিজের কোড একই, কিন্তু সে from "./index-….js" লিখে রাখে, তাই index-এর নতুন নাম তার বিষয়বস্তু বদলায়। Admin, react-vendor, charts, rolldown-runtime আর CSS একই থাকে। আমরা আসল build-এ এটি চালিয়ে দেখেছি: index-DKzZ158T থেকে index-DS6YzvIg, Cart-wKUX_Rsk থেকে Cart-CajhiZZf, বাকি সব অপরিবর্তিত।

  3. কমান্ডের কাজ: আপনার Express API src/server.ts. এমন একটি esbuild কমান্ড লিখুন যা শুধু আপনার কোড bundle করবে, Node 24-এর জন্য ESM বানাবে, minify করবে আর আলাদা source map দেবে। তারপর বলুন, সব dependency ভেতরে নিতে চাইলে Dynamic require of "tty" is not supported এড়াতে কী বদলাবেন, আর deploy-এর আগে কী যাচাই করবেন।

    npx esbuild src/server.ts --bundle --platform=node --format=esm --target=node24 --packages=external --minify --sourcemap=external --outfile=dist/server.js. সব ভেতরে নিতে সবচেয়ে সরল পথ --format=cjs দিয়ে .cjs ফাইল বানানো; আমাদের run-এ এটি 200 দিয়েছে। ESM-ই চাইলে output-এর শুরুতে createRequire দিয়ে একটি require বানানোর banner যোগ করা প্রচলিত কৌশল, তবে নিজের run-এ যাচাই করে নিন। যাচাই: node dist/server.js চালিয়ে health check বা একটি route-এ request, tsc --noEmit, আর native module থাকা package (যেমন database engine) বাইরে রাখা হয়েছে কি না।

  4. নকশা সমালোচনা: একটি দলের vite.config.js: sourcemap: true, minify: false (“error পড়তে সুবিধা”), সব node_modules একটিমাত্র vendor group-এ, আর তাদের ui-kit-এর package.json-এ "sideEffects": false. অ্যাডমিনের চার্ট library-ও আছে। চারটি সমস্যা আর প্রতিটির সমাধান বলুন।

    (১) sourcemap: true: পুরো মূল কোড প্রকাশ্যে। সমাধান 'hidden', map error tracking-এ পাঠিয়ে server থেকে মুছে ফেলা। (২) minify: false: আমাদের run-এ charts প্রায় দ্বিগুণ; error পড়ার কাজ source map করে, minify বন্ধ করে নয়। (৩) একটি vendor-এ সব: চার্ট library হোম পাতাতেও নামবে, আর যেকোনো package বদলালে পুরো vendor-এর cache যাবে। React আলাদা group-এ উচ্চ priority দিয়ে, চার্ট শুধু lazy route-এর পেছনে। (৪) ui-kit-এ sideEffects: false: index থেকে import হওয়া থিম CSS নীরবে হারাতে পারে। CSS আর CSS import করা ফাইল তালিকায় রাখা, অথবা থিম CSS অ্যাপের entry-তে সরাসরি import; তারপর build-এর CSS খুলে যাচাই।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি production build-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Entry থেকে import ধরে module graph বানানো
  2. JSX আর TypeScript-কে JavaScript-এ অনুবাদ
  3. অব্যবহৃত export বাদ দেওয়া (tree shaking)
  4. Entry, lazy route আর vendor ধরে chunk-এ ভাগ
  5. Minify করা
  6. বিষয়বস্তুর hash দিয়ে নাম দিয়ে dist-এ লেখা

একই debounce ফাংশন lodash (CommonJS) থেকে import করলে bundle অনেক বড় হয়, কিন্তু lodash-es থেকে করলে ছোট। মূল কারণ কী?

  • lodash-es-এর debounce ভিন্নভাবে লেখা, তাই ছোট
  • ES module-এর import স্থির, তাই bundler অব্যবহৃত অংশ নিরাপদে বাদ দিতে পারে
  • CommonJS কোড minify করা যায় না
  • CommonJS ফাইল gzip হয় না

একটি UI package-এর package.json-এ "sideEffects": false. তার index.js শুধু import './theme.css' আর export * from './button.js' করে। অ্যাপ শুধু Button import করলে production build-এ সাধারণত কী ঘটতে পারে?

  • Build error দিয়ে থেমে যাবে
  • বোতামের JavaScript বাদ পড়বে
  • theme.css নীরবে বাদ পড়তে পারে
  • পুরো package bundle-এ ঢুকে যাবে

একটি Vite build-এ শুধু Cart.jsx-এর লেখা বদলানো হলো, কিন্তু build-এর পর index-….js-এর নামও বদলে গেল। সবচেয়ে সঠিক ব্যাখ্যা কোনটি?

  • Index-এর ভেতরে Cart-এর hash-যুক্ত ফাইলের নাম লেখা থাকে
  • প্রতিটি build-এ সময় ধরে সব hash নতুন হয়
  • Vite প্রতিবার সব ফাইলের নাম ইচ্ছামতো বদলায়
  • CSS বদলেছে বলে index বদলায়

Vite-এ build.sourcemap: 'hidden' দিলে কী হয়?

  • কোনো source map তৈরি হয় না
  • Map JavaScript ফাইলের ভেতরে ঢুকিয়ে দেওয়া হয়
  • Map এনক্রিপ্ট করা হয়, শুধু ডেভেলপার পড়তে পারেন
  • .map ফাইল তৈরি হয়, কিন্তু bundle-এ তার ঠিকানার মন্তব্য থাকে না

একটি দোকানের entry chunk 70.53 kB (gzip). দিনে ১০,০০০ ক্রেতা ফেরত আসেন। Deploy-এ entry-র hash বদলালে এবং প্রত্যেকে একবার নতুন entry নামালে মোট কত MB নামবে (1 MB = 1,000 kB)?

  • 70.53 MB
  • 705.3 MB
  • 7,053 MB
  • 7.05 MB

Node.js 24.15-এ কোনো flag ছাড়া node app.ts চালানো হলো। নিচের কোনটি থাকলে ফাইলটি চলবে না?

  • interface Product { id: number }
  • function f(x: number): number
  • enum Status { Pending, Paid }
  • import { type Request } from 'express'

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Bundler graph দেখে সিদ্ধান্ত নেয়: কী লাগবে, কী বাদ, কোন chunk-এ কী। Vite 8-এ dev ফাইল ধরে native ESM, build Rolldown আর Oxc-এ পুরো bundle.
  2. Tree shaking চায় ES module আর সৎ ঘোষণা। CommonJS প্রায় পুরোটা টানে; sideEffects: false ভুল হলে CSS নীরবে হারায়; নিজের অ্যাপে barrel এড়ান।
  3. Content hash লম্বা cache দেয়, কিন্তু নাম লিখে রাখা chunk-ও বদলায়। Vendor chunk আর import map এই ঢেউ থামায়।
  4. Source map বানান, প্রকাশ করবেন না: 'hidden', error tracking-এ upload, server থেকে মুছুন। Target আর polyfill ঠিক করুন ক্রেতার আসল browser দেখে।
  5. বাজেট সংখ্যায়, পাহারা যন্ত্রে: প্রথম পাতা ≤ 150 kB, route ≤ 100 kB. Next.js 16-এ next analyze, backend-এ bundle করার আগে output চালিয়ে দেখুন।

পরের পর্বে: Build তৈরি। এবার সেটি একটি বাক্সে ভরা: Docker-এর multi-stage build দিয়ে “বাজারঘর”-এর frontend, API আর database একসাথে চালানো, compose দিয়ে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান