- Biết lịch sử module JS: IIFE → AMD/UMD → CommonJS → ESM (ES6 2015) và vì sao có nhiều format đến vậy.
- Phân biệt CommonJS (Node truyền thống, sync, dynamic) vs ESM (chuẩn, static, async).
- Master
named exportvsdefault export, re-export barrel, dynamicimport(). - Hiểu tree-shaking hoạt động ra sao và tại sao chỉ ESM mới làm được.
- Nắm landscape bundler 2026: Vite, esbuild, Webpack, Rollup, Parcel, tsup — chọn cái nào khi nào.
- Đọc hiểu các field
main,module,exports,typetrongpackage.json. - Setup tối thiểu Node ESM và Vite SPA từ zero.
1. Lịch sử module JS — vì sao rối
Khi Brendan Eich tạo JavaScript năm 1995, nó được nhắm cho việc viết vài dòng script gắn vào HTML. Không có
khái niệm module. Mọi <script> đều share global scope. Khi project lớn lên,
điều này thành thảm họa: biến đè nhau, không biết file nào export gì, không cách nào reuse code thật sự.
Cộng đồng phải tự chế giải pháp. Suốt 20 năm, JS có 4 format module cùng tồn tại:
| Format | Năm | Môi trường | Đặc điểm |
|---|---|---|---|
| IIFE (Immediately Invoked Function Expression) | ~2000 | Browser | Wrap code trong (function(){ ... })() để tạo private scope. Expose qua global object. |
| CommonJS | 2009 | Node.js | require() + module.exports. Sync, dynamic. Default của Node 17+ năm. |
| AMD (Asynchronous Module Definition) | 2011 | Browser (RequireJS) | define([deps], factory). Async load — phù hợp browser nhưng syntax verbose. |
| UMD (Universal Module Definition) | 2014 | Cả hai | Wrapper detect runtime, tương thích với CJS, AMD, hoặc global. Hỗn loạn nhưng "chạy mọi nơi". |
| ESM (ES6 Modules) | 2015 | Chuẩn ECMAScript | import/export. Static, async, tree-shakeable. Browser native 2017, Node hỗ trợ 2019. |
Xem qua syntax từng cái để cảm nhận:
// math.js — gắn vào window/global
var Math2 = (function() {
function add(a, b) { return a + b; }
function sub(a, b) { return a - b; }
return { add: add, sub: sub }; // expose public
})();
// dùng:
Math2.add(1, 2); // 3
// math.js
function add(a, b) { return a + b; }
module.exports = { add: add };
// main.js
const math = require('./math');
math.add(1, 2);
// math.js
export function add(a, b) { return a + b; }
// main.js
import { add } from './math.js';
add(1, 2);
ESM xuất hiện năm 2015 nhưng Node phải đợi đến v13.2 (2019) mới hỗ trợ stable. Suốt giai đoạn đó, hàng triệu package npm đã viết bằng CommonJS. Không ai migrate được toàn bộ ecosystem ngay → ESM và CJS phải chạy song song.
Đó là lý do năm 2026 vẫn thấy require() trong code Node, package.json có cả main (CJS) và
module (ESM), và đôi khi gặp lỗi "ERR_REQUIRE_ESM". Hiểu lịch sử = bớt hoang mang.
2. CommonJS — module Node truyền thống
CommonJS sinh ra cho Node năm 2009. Đặc điểm: synchronous (đọc file blocking), dynamic
(có thể require trong if/loop/function bất cứ đâu). Mặc định Node coi mọi .js là CJS, trừ khi
package.json set "type": "module".
// utils.js — nhiều cách export
module.exports = { foo, bar }; // object export
module.exports.baz = function() {}; // gán dần
exports.qux = 1; // shorthand (cẩn thận: không reassign exports trực tiếp)
// main.js
const utils = require('./utils'); // .js extension optional
const { foo } = require('./utils'); // destructure
const express = require('express'); // package từ node_modules
// Dynamic require — chạy ở mọi nơi
if (process.env.NODE_ENV === 'development') {
const devTool = require('./dev-only');
}
for (const name of pluginNames) {
const plugin = require(`./plugins/${name}`);
}
Cơ chế: khi gọi require(), Node:
- Resolve path: tìm file (relative/absolute/node_modules).
- Load file: đọc nội dung từ disk (sync).
- Wrap code trong function:
(function(exports, require, module, __filename, __dirname) { ... }). - Execute: chạy code, trả
module.exports. - Cache: lần require thứ 2 không load lại — trả từ cache.
exports vs module.exports
exports chỉ là alias của module.exports lúc đầu. Nếu reassign trực tiếp
exports = ..., alias bị mất, không export gì.
// ❌ Sai
exports = { foo: 1 }; // alias bị reassign → không có effect
// ✅ Đúng — 2 cách
module.exports = { foo: 1 };
exports.foo = 1;
3. ESM — ES6 Modules chuẩn
ESM là format chính thức của ECMAScript từ 2015. Đặc điểm khác CJS:
| — | CommonJS | ESM |
|---|---|---|
| Syntax | require / module.exports | import / export |
| Loading | Synchronous, blocking | Asynchronous (browser), top-level await được |
| Static / Dynamic | Dynamic — require trong if/loop OK | Static — import ở top-level. Có import() dynamic riêng. |
| Tree-shaking | ❌ Không (dynamic) | ✅ Có (static analysis) |
| Default trong Node | Mặc định | Cần "type": "module" hoặc .mjs |
| Browser support | Phải bundle | Native từ 2017 (<script type="module">) |
| File extension trong import | Optional (./mod) | Bắt buộc (./mod.js) |
this ở top-level | module.exports | undefined |
| Path/URL helpers | __dirname, __filename | import.meta.url |
// math.js — export nhiều cách
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Calculator { /* ... */ }
// Hoặc gom cuối file:
function sub(a, b) { return a - b; }
function mul(a, b) { return a * b; }
export { sub, mul };
// Default export — 1 cái/file
export default function multiply(a, b) { return a * b; }
// main.js — import
import { PI, add } from './math.js'; // named
import multiply from './math.js'; // default
import multiply, { PI, add } from './math.js'; // mix
import * as math from './math.js'; // namespace
import { PI as CIRCLE_PI } from './math.js'; // rename
import './side-effects.js'; // chỉ chạy code, không import gì
import phải ở top-level và path phải là literal string. Không thể:
// ❌ SyntaxError
if (condition) {
import foo from './foo.js';
}
// ❌ SyntaxError
import mod from `./${name}.js`;
Ràng buộc này có lý do: bundler/runtime phải biết module graph trước khi chạy → tree-shaking, async
loading, code splitting đều dựa vào tính static. Khi cần dynamic, dùng import() function (mục 6).
3.1. ESM trong browser native
Từ 2017 mọi browser hỗ trợ ESM native qua attribute type="module":
<!-- index.html -->
<script type="module" src="main.js"></script>
// main.js
import { add } from './math.js'; // bắt buộc có .js
console.log(add(1, 2));
Điều browser native bắt buộc khác Node:
- CORS: file phải serve qua HTTP (không chạy được
file://). - MIME type: server phải trả
Content-Type: text/javascript. - Full path:
./math.jschứ không phải./math(Node chấp nhận thiếu ext khi CJS, ESM không). - Bare specifier (
import x from 'lodash') không chạy native — phải quaimportmaphoặc bundler.
4. Named export vs Default export
2 cách export, mỗi cái có triết lý riêng:
| — | Named export | Default export |
|---|---|---|
| Syntax export | export const foo = ... | export default ... |
| Syntax import | import { foo } from ... | import Foo from ... |
| Số lượng / file | Không giới hạn | Tối đa 1 |
| Rename khi import | { foo as bar } | Tự do — viết tên nào cũng được |
| Refactor an toàn | ✅ Tên cố định, IDE rename được | ⚠️ Mỗi file đặt tên khác nhau |
| Tree-shaking | ✅ Tốt | ✅ Tốt nhưng default thường là object lớn |
// Button.jsx
export default function Button() {}
// Mỗi file import có thể đặt tên khác nhau:
import Button from './Button'; // OK
import MyBtn from './Button'; // OK — tên tùy ý
import X from './Button'; // OK — đặt tên dở cũng được
// Khi grep "Button" trong project, có thể miss MyBtn/X
- Named cho 90% case. Helper, utility, type, hằng số — rõ ràng, refactor-safe.
- Default khi file có 1 thứ chính nổi bật (React component, page Next.js, class chính của library). Tên file thường = tên default.
- Một số codebase (Airbnb style guide) cấm hoàn toàn
defaultđể tránh rename chaos. - TypeScript hỗ trợ
import { type Foo }chỉ cho named — thêm lý do thiên về named.
5. Re-export & barrel files
Barrel là file (thường index.js/index.ts) chỉ re-export từ các file khác.
Mục đích: import gọn, ẩn cấu trúc folder bên trong.
// utils/string.js
export function capitalize(s) { /* ... */ }
export function slugify(s) { /* ... */ }
// utils/number.js
export function clamp(n, min, max) { /* ... */ }
// utils/index.js — BARREL
export * from './string.js'; // re-export tất cả named
export * from './number.js';
export { capitalize as cap } from './string.js'; // chọn lọc + rename
export { default as Button } from './Button.jsx'; // re-export default
// main.js — gọn
import { capitalize, clamp } from './utils';
// thay vì:
// import { capitalize } from './utils/string';
// import { clamp } from './utils/number';
Pro: import gọn, API ổn định kể cả khi reorganize folder bên trong.
Con:
- Tree-shaking khó hơn — bundler phải đọc qua barrel để biết symbol nào từ đâu. Một số config cũ kéo cả module dù chỉ dùng 1 function.
- Circular dependency dễ xảy ra hơn — A và B đều đi qua barrel, có thể vô tình tham chiếu vòng.
- Cold start chậm trong dev (Vite, Jest) — phải resolve cả barrel rồi mới biết file thật cần load.
Khuyến nghị 2026: dùng barrel ở top-level public API của library, hạn chế ở internal folder. React core team có blog "Why we removed barrel files" — tham khảo.
6. Dynamic import()
import() (có dấu ngoặc) là function-like syntax — async, trả Promise, có thể gọi ở bất
cứ đâu, path có thể là expression. Đây là cầu nối giữa "static analysis" và "dynamic loading".
// Lazy load — chỉ load khi user click
button.addEventListener('click', async () => {
const { default: Chart } = await import('./Chart.js');
new Chart().render();
});
// Conditional load — A/B test
const theme = await import(
user.experimental ? './theme-new.js' : './theme.js'
);
// Polyfill khi cần
if (!Array.prototype.at) {
await import('./polyfills/array-at.js');
}
// Path từ variable
async function loadLocale(name) {
return import(`./locales/${name}.js`);
}
Bundler hiểu import() = ranh giới code splitting. Mỗi import() sinh ra một
chunk JS riêng. Browser chỉ tải chunk khi cần → first paint nhanh hơn.
import { lazy, Suspense } from 'react';
const Chart = lazy(() => import('./Chart.jsx'));
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Chart />
</Suspense>
);
}
Vite/Webpack tự động split Chart.jsx ra chunk riêng. Khi Chart được render lần đầu, browser fetch chunk
đó. Đây là pattern phổ biến cho SPA lớn.
7. Tree-shaking — bundler dọn rác
Tree-shaking = bundler phân tích static module graph, loại bỏ code không được dùng. Tên ẩn dụ: cây code rung sẽ rụng lá khô.
// utils.js
export function used() { return 'A'; }
export function unused() { return 'B'; }
// main.js
import { used } from './utils.js';
console.log(used());
// Output bundle: chỉ chứa used(), unused() bị loại
Điều kiện để tree-shaking hoạt động:
- ESM static syntax — bundler phân tích được. CJS dynamic → không tree-shake được.
- Pure exports — không có side effect ở top-level (như đăng ký global event, mutate object).
- Đánh dấu
"sideEffects": falsetrong package.json — báo bundler "an toàn cắt". - Production build — dev mode thường tắt để rebuild nhanh.
// package.json
{
"name": "my-lib",
"sideEffects": false // tất cả file đều "pure", an toàn cắt
}
// Hoặc whitelist file có side effect:
{
"sideEffects": ["./src/polyfill.js", "*.css"]
}
// utils.js
window.myGlobal = 'hi'; // ❌ side effect ở top-level
Array.prototype.myExtension = function() {}; // ❌ mutate global
export function unused() {} // bundler KHÔNG dám cắt cả file vì sợ mất 2 dòng trên
CSS import (import './style.css') cũng là side effect — không có symbol nào được dùng nhưng phải giữ.
Đó là lý do "sideEffects": ["*.css"] thường có trong config.
8. Bundler — vì sao cần
Cuộc đời một SPA modern:
src/
index.tsx // TypeScript + JSX
App.tsx
components/
Button.tsx
Form.tsx
utils/
api.ts
format.ts
styles/
main.css
node_modules/
react/ // CommonJS
lodash-es/ // ESM
...300 packages
↓ Bundler
dist/
index.html
assets/
main-a3f5.js // entry chunk (minified)
vendor-b1e8.js // node_modules split
Chart-c2d9.js // lazy chunk
main-a3f5.css
main-a3f5.js.map // source map cho debug
Công việc bundler:
- Resolve: từ entry, đi theo
importdựng module graph. - Transform: chạy TS → JS, JSX → JS, SCSS → CSS, PNG → base64/URL.
- Tree-shake: loại bỏ export không dùng.
- Code split: tách
import()ra chunk riêng, tách vendor riêng. - Minify: rename biến ngắn, xóa whitespace, dead code elimination.
- Source map: file
.js.mapmap code minified ngược về source để debug. - Hash & cache: filename có hash (
main-a3f5.js) — đổi content = đổi tên = bypass CDN cache.
9. Landscape bundler 2026
| Tool | Viết bằng | Tốc độ | Use case |
|---|---|---|---|
| esbuild | Go | ⚡⚡⚡ Cực nhanh (10-100×) | Build tool nội bộ, transform TS, dùng làm engine cho tool khác. |
| Vite | JS (esbuild + Rollup) | ⚡⚡⚡ Dev tức thời | Mặc định cho SPA 2026. Dev: ESM native + esbuild prebundle. Prod: Rollup. |
| Webpack | JS | ⚡ Chậm | Project lớn legacy, ecosystem plugin khổng lồ. Vẫn dùng nhiều ở enterprise. |
| Rollup | JS | ⚡⚡ Khá nhanh | Build library (output sạch, tree-shake tốt). Vite dùng nội bộ. |
| Parcel | JS/Rust | ⚡⚡ Khá nhanh | Zero-config, đơn giản — phù hợp demo, prototype. |
| tsup | JS (wrap esbuild) | ⚡⚡⚡ Nhanh | Library TypeScript — output CJS + ESM + types nhanh gọn. |
| Turbopack | Rust | ⚡⚡⚡ Nhanh | Next.js mới — kế thừa Webpack trong Next. |
| Rolldown | Rust | ⚡⚡⚡ Nhanh | Rollup viết lại bằng Rust. Tương lai của Vite. |
- SPA mới (React/Vue/Svelte): Vite. Hết bàn.
- Next.js app: Turbopack (default Next 15+).
- Library publish lên npm: tsup hoặc Rollup.
- Project Webpack cũ: giữ đến khi rảnh migrate. Vite có Webpack-to-Vite guide.
- Build tool riêng / CLI: gọi esbuild API trực tiếp — nhanh nhất.
9.1. Vite làm gì khác Webpack
Vite chiến thắng nhờ chia 2 mode rõ rệt:
| — | Dev mode | Production build |
|---|---|---|
| Module source code | Serve qua ESM native — không bundle | Bundle bằng Rollup |
| node_modules | Prebundle bằng esbuild 1 lần (cache) | Bundle cùng source |
| Transform TS/JSX | esbuild (Go, nhanh) | esbuild + Rollup |
| HMR (Hot Module Reload) | Chỉ invalidate module thay đổi | — |
| Start time | < 1s cả project lớn | — |
Trong dev, Vite không bundle source code → browser fetch từng file .js/.ts qua HTTP. Mỗi
file Vite transform on-the-fly (TS → JS). Khi edit 1 file, chỉ file đó được invalidate — HMR cực nhanh.
10. package.json — các field module
Module fields trong package.json tích lũy qua nhiều năm. Hiểu từng cái:
{
"name": "my-lib",
"version": "1.0.0",
"type": "module", // 🔑 mọi .js là ESM (Node 12+)
"main": "./dist/cjs/index.cjs", // CJS entry — Node cũ, tool cũ
"module": "./dist/esm/index.js", // ESM entry — bundler nhận diện (de-facto)
"types": "./dist/index.d.ts", // TypeScript definitions
"exports": { // 🔑 modern — conditional, Node 12.7+, ưu tiên hơn main/module
".": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.js"
},
"./helpers": "./dist/helpers.js",
"./package.json": "./package.json"
},
"sideEffects": false, // tree-shake aggressive
"files": ["dist"], // chỉ publish folder dist lên npm
"engines": { "node": ">=18" }
}
| Field | Vai trò | Năm |
|---|---|---|
main | Entry mặc định cho CommonJS. Field cổ nhất, mọi tool đều hiểu. | ~2010 |
module | Entry ESM. De-facto standard nhưng không trong spec Node. Webpack/Rollup/Vite đều check. | ~2017 |
type | "module" = file .js là ESM; "commonjs" (default) = CJS. | Node 12 (2019) |
exports | Modern — conditional export theo runtime/condition. Có thể "đóng" subpath, ngăn import nội bộ. | Node 12.7 (2019) |
types / typings | Đường dẫn file .d.ts cho TypeScript. | — |
sideEffects | Hint cho bundler tree-shake. | Webpack ~2017 |
browser | Override entry cho browser (vd: thay fs bằng shim). Bundler-specific. | ~2014 |
Khi bundler/Node tìm entry của một package:
exports— nếu có, dùng cái này, bỏ qua các field khác.module— bundler thường ưu tiên hơnmain.main— fallback cuối cùng.
exports còn cho phép "đóng cửa": chỉ những path declared mới import được. Trước đó, mọi file trong
package đều có thể bị import — gây phá API ngẫu nhiên khi update.
11. Setup tối thiểu — Node ESM
Mục tiêu: chạy 1 file .js dùng import/export trong Node.
mkdir my-esm-app && cd my-esm-app
npm init -y
# mở package.json thêm "type": "module"
{
"name": "my-esm-app",
"version": "1.0.0",
"type": "module", // 🔑 dòng quan trọng nhất
"scripts": { "start": "node index.js" }
}
export function add(a, b) { return a + b; }
export function sub(a, b) { return a - b; }
import { add, sub } from './math.js'; // 🔑 BẮT BUỘC có .js
console.log(add(1, 2)); // 3
console.log(sub(5, 3)); // 2
// __dirname không tồn tại — dùng import.meta.url
console.log(import.meta.url);
- Quên
"type": "module"→SyntaxError: Cannot use import statement outside a module. - Quên
.jsextension →ERR_MODULE_NOT_FOUNDdù file tồn tại. - Dùng
require→ReferenceError: require is not defined in ES module scope. - Dùng
__dirname→ undefined. Thay bằngnew URL('.', import.meta.url). - Import package CJS → có thể không destructure được named export, phải
import pkg from 'pkg'; const { foo } = pkg;.
12. Setup tối thiểu — Vite SPA
Mục tiêu: project web có HMR, ESM native, build production sẵn sàng.
npm create vite@latest my-app
# Chọn: Vanilla → JavaScript (hoặc TypeScript)
cd my-app
npm install
npm run dev # http://localhost:5173 — start < 1s
npm run build # output vào dist/
npm run preview # serve dist/ để test
Cấu trúc Vite Vanilla tạo ra:
my-app/
index.html // 🔑 entry — Vite tìm script type="module" trong đây
package.json
vite.config.js // (optional) config bundler
public/ // static assets — copy nguyên
src/
main.js
style.css
counter.js
<!DOCTYPE html>
<html>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
import './style.css'; // CSS — Vite hiểu
import { setupCounter } from './counter.js';
document.querySelector('#app').innerHTML = `
<h1>Hello Vite</h1>
<button id="cnt">Count</button>
`;
setupCounter(document.getElementById('cnt'));
// HMR API (Vite-specific)
if (import.meta.hot) {
import.meta.hot.accept();
}
Mở DevTools → Network khi npm run dev: bạn sẽ thấy browser fetch từng file
(main.js, counter.js, style.css) qua HTTP — không bundle. Edit counter.js
→ chỉ file đó reload qua WebSocket, UI giữ nguyên state.
Khi npm run build: Vite gọi Rollup, output minified bundle vào dist/. Sẵn sàng deploy lên CDN.
Bài tập
Bài 1 — Tree-shake với esbuild
Tạo 3 file:
math.js: export 2 functionadd,subtract.string.js: export 1 functioncapitalize.index.js: re-export tất cả từ 2 file trên (barrel).main.js:import { add } from './index.js', chỉ dùngadd.
Cài esbuild, build với cờ --bundle --minify. Kiểm tra bundle output — subtract và capitalize có còn trong file không?
Lệnh + kết quả mong đợi
npm i -D esbuild
npx esbuild main.js --bundle --minify --outfile=bundle.js
cat bundle.js | grep -E "subtract|capitalize"
# Không match → đã tree-shake
Lý do: ESM static, esbuild đọc graph, thấy main.js chỉ dùng add, các symbol còn lại bị loại.
Thử lại với version barrel có console.log('init') ở top-level index.js — esbuild có thể phải giữ cả file vì side effect.
Bài 2 — Migrate CJS sang ESM
Cho module CJS:
// db.js
const fs = require('fs');
function read(path) { return fs.readFileSync(path, 'utf-8'); }
function write(path, data) { fs.writeFileSync(path, data); }
module.exports = { read, write };
// app.js
const { read } = require('./db');
console.log(read('./data.txt'));
Chuyển sang ESM. Liệt kê 3 thay đổi bắt buộc.
Đáp án
3 thay đổi:
- Thêm
"type": "module"vàopackage.json. - Thay
require/module.exportsbằngimport/export. - Thêm
.jsvào path import.
// db.js
import fs from 'node:fs'; // dùng prefix node: cho rõ
export function read(path) { return fs.readFileSync(path, 'utf-8'); }
export function write(path, data) { fs.writeFileSync(path, data); }
// app.js
import { read } from './db.js';
console.log(read('./data.txt'));
Bonus: nếu code có __dirname, thay bằng new URL('.', import.meta.url).pathname.
Bài 3 — Code splitting với Vite
Setup Vite Vanilla. Tạo:
greeting.js: export functionsayHi(name)in alert.main.js: 1 button. Khi click, dùngawait import('./greeting.js')rồi gọisayHi.
Mở DevTools → Network → Disable cache. Chạy npm run dev, refresh page. Click button. Quan sát: greeting.js chỉ fetch khi click, chứ không lúc load trang.
Code mẫu
// greeting.js
export function sayHi(name) {
alert(`Xin chào, ${name}!`);
}
// main.js
document.getElementById('btn').addEventListener('click', async () => {
const { sayHi } = await import('./greeting.js');
sayHi('Việt');
});
Sau khi npm run build, kiểm tra folder dist/assets/ — greeting sẽ
là 1 chunk riêng (file greeting-XXXX.js). Đây là code splitting tự động.
Bài 4 — Đọc package.json 3 thư viện
Mở node_modules/<pkg>/package.json của 3 package:
lodash-es— bản ESM của lodash.axios— HTTP client phổ biến.react— framework UI.
Với mỗi cái, ghi lại main, module, exports, type, sideEffects. Trả lời:
- Cái nào có
"sideEffects": false? Vì sao? - Cái nào có
"exports"conditional? Có những condition nào? - Cái nào còn dùng
mainCJS? Vì sao chưa migrate?
Gợi ý quan sát
- lodash-es:
"sideEffects": false, mỗi function là 1 file ESM riêng → tree-shake cực tốt.import { debounce } from 'lodash-es'chỉ kéodebounce. - axios:
"exports"phân nhánhbrowser/node/defaultđể chọn entry phù hợp runtime (XHR cho browser, http cho Node). - react: vẫn dùng cả
main(CJS) — vì hàng triệu legacy app + Webpack 4 chưa tốt với pure ESM. React 19 đã bắt đầu publish dual.
Bài 5 — Mini bundler bằng esbuild API
Viết script Node build.js dùng esbuild JavaScript API (không CLI) để bundle:
- Entry:
src/index.js(có nhiềuimport). - Output:
dist/bundle.js. - Options: bundle, minify, source map, target ES2020.
- In ra thời gian build và kích thước bundle.
Code mẫu
// build.js
import esbuild from 'esbuild';
import { statSync } from 'node:fs';
const start = performance.now();
await esbuild.build({
entryPoints: ['src/index.js'],
outfile: 'dist/bundle.js',
bundle: true,
minify: true,
sourcemap: true,
target: 'es2020',
format: 'esm',
});
const elapsed = performance.now() - start;
const size = statSync('dist/bundle.js').size;
console.log(`✓ Build ${elapsed.toFixed(1)}ms, ${(size / 1024).toFixed(2)} KB`);
Chạy: node build.js. Bạn sẽ thấy build < 100ms cho project nhỏ. So với Webpack cùng config: thường 3-5 giây.
Bonus: thêm watch: true để rebuild khi save file.
Quiz
Trong Node.js, file .js mặc định là module CommonJS hay ESM?
Xem đáp án
CommonJS mặc định, trừ khi package.json set "type": "module". Ngoài ra .mjs luôn là ESM, .cjs luôn là CJS — bất kể setting nào.
Browser native dùng module format nào? Cần khai báo thế nào trong HTML?
Xem đáp án
Browser dùng ESM. Cần <script type="module" src="main.js"></script>. Lưu ý: phải serve qua HTTP (không chạy file://), path import phải có .js extension, không hỗ trợ bare specifier (import x from 'lodash') trừ khi có <script type="importmap">.
Tree-shaking có hoạt động với CommonJS không? Vì sao?
Xem đáp án
Không. CJS là dynamic: require() có thể gọi trong if/loop, path là expression, module.exports có thể gán động. Bundler không thể static analyze để biết export nào unused. Chỉ ESM (static, top-level) cho phép tree-shaking. Đây là lý do nhiều library mới publish dual hoặc bỏ hẳn CJS.
import.meta.url là gì? Dùng để làm gì?
Xem đáp án
import.meta.url là URL của module hiện tại, chỉ tồn tại trong ESM. Ví dụ: file:///Users/me/app/index.js. Dùng để thay thế __dirname/__filename của CJS:
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = new URL('.', import.meta.url).pathname;
Cũng dùng để load file relative module: new URL('./data.json', import.meta.url).
Vite trong dev mode có bundle code không? Khác Webpack thế nào?
Xem đáp án
Không bundle source code. Vite tận dụng ESM native của browser: serve từng file .js/.ts riêng qua HTTP, transform on-the-fly bằng esbuild. node_modules được prebundle 1 lần bằng esbuild (cache lại), vì có quá nhiều file. Khi bạn edit 1 file, chỉ file đó invalidate qua WebSocket → HMR cực nhanh.
Webpack ngược lại: bundle toàn bộ thành 1-2 file ngay trong dev → start chậm vài giây, rebuild cũng chậm vì phải walk graph lại.
Khi build production, Vite chuyển sang Rollup để bundle thực sự — vì ESM native lúc deploy không tối ưu (quá nhiều round-trip).
Khi nào dùng default export vs named export?
Xem đáp án
Named cho 90% trường hợp — helper, utility, hằng số, type. Refactor an toàn (IDE rename tracking được), import grep được, không bị mỗi file đặt một tên khác.
Default khi file có 1 export chính nổi bật và tự nhiên = tên file: React component (Button.jsx export default Button), page Next.js, class chính của library. Một số style guide (Airbnb, base của Next.js) thực sự khuyến nghị tránh default hoàn toàn.
"sideEffects": false trong package.json có ý nghĩa gì?
Xem đáp án
Tín hiệu cho bundler (Webpack/Rollup/Vite) rằng mọi file trong package này pure — chỉ định nghĩa export, không có code chạy ở top-level ảnh hưởng bên ngoài (global mutation, polyfill, đăng ký listener, CSS import). Khi đó bundler có thể tree-shake aggressive: loại bỏ cả file nếu không export nào được dùng.
Nếu có file ngoại lệ (vd polyfill), whitelist: "sideEffects": ["./src/polyfill.js", "*.css"].
Ngược lại, "sideEffects": true (default) buộc bundler giữ mọi file dù không dùng export — vì sợ làm vỡ side effect ngầm.
Tổng kết
Sau chương 8, bạn nên đã master:
- Lịch sử module: IIFE → CommonJS → AMD/UMD → ESM. Hiểu vì sao 2026 vẫn còn 2 format chính (CJS & ESM) song song.
- CommonJS:
require/module.exports, sync, dynamic, default Node trừ khi"type": "module". - ESM:
import/export, static, async, tree-shakeable. Phải có.jstrong path, không dùngrequire/__dirname. - Named vs default: named 90%, default cho main export. Re-export barrel có pro/con.
- Dynamic
import(): async, path expression, code splitting, lazy load. - Tree-shaking: chỉ ESM static, cần
"sideEffects": false, chỉ bật production. - Bundler: resolve + transform + tree-shake + split + minify + source map + hash. Landscape 2026: Vite (SPA), tsup (lib), esbuild (engine), Webpack (legacy).
- package.json:
type,main,module,exports,sideEffects— đọc hiểu là kỹ năng. - Setup tối thiểu: Node ESM (chỉ cần
"type": "module") và Vite SPA (npm create vite).
Kết nối
- Chương 9 (TypeScript Type System) — đây là chương đầu của nhánh TypeScript trong sub-pillar JS. Module syntax giữ nguyên ESM nhưng có thêm
import type { ... }và type re-export. - Chương 12 (TS Compiler & tsconfig) — option
moduletrongtsconfig.json(CommonJS, ESNext, NodeNext) quyết địnhtscoutput format nào, gắn chặt với những gì chương này nói. - Chương 1 (Hello JavaScript) — quay lại đọc kỹ
package.jsonđã setup, nay đã hiểu mọi field. - Dart/Flutter (Pillar 9 nhánh kia) — Dart không có bundler runtime; thay vào đó là AOT compile sinh native binary. Triết lý khác: JS bundle & ship cho browser, Dart compile sẵn cho VM/native.
Đây là chương cuối của nhánh JavaScript core. Bạn đã đi qua: hello world, types & coercion, functions & closures, objects & prototypes, arrays & iteration, async & promises, errors & debugging, và bây giờ modules & bundlers.
Chương 9 trở đi mở ra phần TypeScript — gắn type system lên JS. Mọi thứ học được vẫn áp dụng nguyên,
TypeScript chỉ thêm tầng kiểm tra tĩnh. Hẹn gặp ở chương 9 với type, interface,
generic, narrowing và discriminated union.