Cách viết một thư viện JavaScript với Vite + tsdown + Rolldown + pnpm
Bài viết này mình sẽ hướng dẫn tạo một thư viện JavaScript/TypeScript dùng pnpm, từ dev, test local, đến publish lên npm. Stack: TypeScript, tsdown (build với Rolldown), Vitest, pnpm, output ESM only, test local qua file: protocol.
1. Tổng quan stack
| Thành phần | Công cụ | Vai trò |
|---|---|---|
| Package manager | pnpm | Quản lý dependency, publish |
| Build | tsdown | Bundle TypeScript → ESM, dùng Rolldown engine |
| Test | Vitest | Test runner nhanh, native ESM |
| Language | TypeScript | Type safety, auto generate .d.ts |
| Local test | file: protocol | Import lib như package thật mà không cần publish |
2. Khởi tạo thư viện
mkdir my-lib
cd my-lib
pnpm init
package.json
{
"name": "my-lib",
"version": "0.0.1",
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"files": ["dist"],
"scripts": {
"dev": "tsdown --watch",
"build": "tsdown",
"test": "vitest run",
"test:watch": "vitest",
"prepublishOnly": "pnpm build"
},
"packageManager": "pnpm@10.25.0"
}
Một số điểm quan trọng:
"type": "module"— toàn bộ project dùng ESM.mainvàexportstrỏ vàodist/index.mjs— tsdown mặc định xuất.mjscho ESM output.files: ["dist"]— chỉ publish thư mục dist, không push src/test/config lên npm.prepublishOnly— tự động build trước khi publish, đảm bảo dist luôn mới nhất.
Cài dependencies
pnpm add -D tsdown typescript vitest unrun
unrunlà peer dependency runtime của tsdown, cần cài tường minh.
3. Cấu hình TypeScript
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
"moduleResolution": "bundler"— tương thích với tsdown/Rolldown."declaration": true— tạo.d.tslàm nguồn cho tsdown dùng với pluginrolldown-plugin-dts.
4. Cấu hình Build với tsdown
tsdown là bundler chuyên cho thư viện, built on Rolldown, của cùng team làm Vite.
tsdown.config.ts
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["src/index.ts"],
outDir: "dist",
format: ["esm"],
dts: true,
clean: true,
});
format: ["esm"]— chỉ xuất ESM (.mjs).dts: true— tự động generate type declarations quarolldown-plugin-dts.clean: true— xóa dist trước mỗi lần build.
.gitignore
node_modules
dist
*.log
Không commit
dist/. Chỉ generate khi publish.
5. Viết code
src/index.ts
export function greet(name: string): string {
return `Hello, ${name}!`;
}
export function sum(a: number, b: number): number {
return a + b;
}
6. Viết test
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.test.ts"],
},
});
tests/index.test.ts
import { describe, it, expect } from "vitest";
import { greet, sum } from "../src/index";
describe("greet", () => {
it("returns greeting with name", () => {
expect(greet("World")).toBe("Hello, World!");
});
});
describe("sum", () => {
it("adds two numbers", () => {
expect(sum(1, 2)).toBe(3);
});
});
pnpm test # chạy test 1 lần
pnpm test:watch # watch mode
7. Build
pnpm build
Output:
dist/
├── index.mjs ← bundle ESM
└── index.d.mts ← type declarations
8. Test local (giống như consumer thật)
Không cần publish lên npm để test. Dùng file: protocol.
Tạo project consumer
mkdir ../my-lib-test
cd ../my-lib-test
pnpm init
package.json của consumer
{
"name": "my-lib-test",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts"
},
"dependencies": {
"my-lib": "file:../my-lib"
},
"devDependencies": {
"tsx": "^4.19.0",
"typescript": "^5.8.0"
}
}
"my-lib": "file:../my-lib" — pnpm tạo symlink từ my-lib-test/node_modules/my-lib → my-lib/dist. Mỗi lần build lại lib, consumer dùng code mới ngay, không cần pnpm install lại.
src/index.ts của consumer
import { greet, sum } from "my-lib";
console.log(greet("a"));
console.log("1 + 2 =", sum(1, 2));
Chạy
pnpm install
pnpm start
Hello, a!
1 + 2 = 3
9. Quy trình dev hàng ngày
# Terminal 1 — lib
cd my-lib
pnpm dev # watch mode, auto build khi code thay đổi
# Terminal 2 — consumer
cd my-lib-test
pnpm start # chạy lại để test code mới
Hoặc không cần watch:
cd my-lib && pnpm build # build một lần
cd my-lib-test && pnpm start
10. Publish lên npm
cd my-lib
# Đảm bảo đã login
pnpm whoami # kiểm tra, nếu chưa: pnpm login
# Đảm bảo code sạch, test pass
pnpm test
# Publish (prepublishOnly sẽ tự chạy build)
pnpm publish
Lần publish đầu tiên nên thêm --access public nếu package không scoped:
pnpm publish --access public
Các lần sau tăng version rồi publish:
pnpm version patch # 0.0.1 → 0.0.2
# hoặc: minor (0.1.0), major (1.0.0)
pnpm publish
11. Cấu trúc thư mục cuối cùng
my-lib/
├── src/
│ └── index.ts
├── tests/
│ └── index.test.ts
├── package.json
├── tsconfig.json
├── tsdown.config.ts
├── vitest.config.ts
└── .gitignore
my-lib-test/
├── src/
│ └── index.ts
├── package.json
12. Một số lưu ý
- tsdown đang phát triển nhanh — nếu gặp lỗi peer dependency như
unmet peer typescript@^6.0.0, có thể bỏ qua hoặc cài thêmunrunnếu thiếu. - Không bundle dependencies — nếu lib có dependency bên ngoài, thêm vào
package.jsondependency và configtsdown.config.tsvớiexternal: true. - Muốn xuất CJS? Sửa
format: ["esm", "cjs"]trongtsdown.config.ts, output sẽ thêmindex.cjs+index.d.cts. - Scoped package? Đổi
"name": "@scope/my-lib"trongpackage.json, publish với--access public.
Cảm ơn bác bạn đã đọc bài viết!
All rights reserved