npm 模块编写 / 工程化(TypeScript + CI/CD)
TypeScript 模块工程搭建
项目初始化
创建一个 TypeScript npm 模块的第一步是初始化项目并配置 TypeScript 编译选项。
bash
mkdir my-ts-library
cd my-ts-library
npm init -y
npm install --save-dev typescript @types/node初始化 tsconfig.json:
bash
npx tsc --init推荐的核心编译配置:
json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"strict": true,
"strictNullChecks": true,
"esModuleInterop": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "**/__tests__/**"]
}关键配置说明:
| 配置项 | 说明 |
|---|---|
strict: true | 启用所有严格类型检查选项,包括 noImplicitAny、strictNullChecks 等 |
strictNullChecks: true | 强制对 null 和 undefined 进行显式检查,避免空指针异常 |
esModuleInterop: true | 允许 import 导入 CommonJS 模块,解决 import * as 问题 |
declaration: true | 生成 .d.ts 类型声明文件,供使用者获取类型提示 |
declarationMap: true | 生成 .d.ts.map 源映射,方便跳转到源码 |
outDir: "./dist" | 编译输出目录,与源码目录分离 |
目录结构设计
推荐的 TypeScript 模块目录结构:
my-ts-library/
├── src/ # 源码目录
│ ├── index.ts # 模块入口,导出公共 API
│ ├── core/ # 核心逻辑
│ │ ├── index.ts
│ │ └── utils.ts
│ └── types/ # 类型定义
│ └── index.ts
├── __tests__/ # 测试文件
│ ├── unit/
│ │ └── core.test.ts
│ └── integration/
│ └── api.test.ts
├── examples/ # 使用示例
│ ├── basic.ts
│ └── advanced.ts
├── docs/ # 文档资源
├── dist/ # 编译输出(gitignore)
├── node_modules/
├── package.json
├── tsconfig.json # 基础 tsconfig
├── tsconfig.cjs.json # CommonJS 构建配置
├── tsconfig.esm.json # ESM 构建配置
├── vitest.config.ts # 测试配置
├── .eslintrc.cjs # 代码规范
├── .prettierrc # 格式化配置
├── .github/
│ └── workflows/
│ └── ci.yml # CI/CD 流水线
├── typedoc.json # TypeDoc 配置
├── .gitignore
├── LICENSE
└── README.mdpackage.json 模块入口配置
json
{
"name": "my-ts-library",
"version": "1.0.0",
"description": "A TypeScript npm module example",
"main": "./dist/cjs/index.js",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"exports": {
".": {
"import": {
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/types/index.d.ts",
"default": "./dist/cjs/index.js"
}
},
"./package.json": "./package.json"
},
"browser": "./dist/esm/index.js",
"files": [
"dist",
"README.md",
"LICENSE"
],
"sideEffects": false,
"scripts": {
"build": "npm run build:esm && npm run build:cjs && npm run build:types",
"build:esm": "tsc -p tsconfig.esm.json",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:types": "tsc -p tsconfig.types.json",
"build:tsup": "tsup",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint src/",
"format": "prettier --write src/",
"docs": "typedoc",
"prepublishOnly": "npm run build && npm run test"
}
}关键字段说明:
| 字段 | 说明 |
|---|---|
main | CommonJS 入口,供 require() 使用 |
module | ESM 入口,供打包工具(Webpack、Rollup、Vite)使用 |
types | TypeScript 类型声明入口 |
exports | 条件导出,根据环境选择不同的入口文件 |
files | 发布白名单,仅包含必要文件 |
sideEffects | 标记模块无副作用,支持更好的 tree-shaking |
browser | 浏览器环境入口 |
ESM vs CJS 双格式输出
双 tsconfig 构建方案
tsconfig.esm.json -- ESM 构建设置:
json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist/esm"
},
"include": ["src"]
}tsconfig.cjs.json -- CommonJS 构建设置:
json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "node",
"outDir": "./dist/cjs"
},
"include": ["src"]
}tsconfig.types.json -- 类型声明单独构建:
json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "./dist/types"
},
"include": ["src"]
}src 目录内的 type 字段配置
在 src/ 目录下放置一个 package.json 文件,标记源码为 ESM 模块:
json
// src/package.json
{
"type": "module"
}package.json exports 条件导出完整示例
json
{
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./utils": {
"types": "./dist/types/utils/index.d.ts",
"import": "./dist/esm/utils/index.js",
"require": "./dist/cjs/utils/index.js"
},
"./package.json": "./package.json"
}
}发布后的 node_modules 目录结构
node_modules/my-ts-library/
├── dist/
│ ├── esm/
│ │ ├── index.js
│ │ └── utils/
│ │ └── index.js
│ ├── cjs/
│ │ ├── index.js
│ │ └── utils/
│ │ └── index.js
│ └── types/
│ ├── index.d.ts
│ ├── index.d.ts.map
│ └── utils/
│ └── index.d.ts
├── package.json
├── README.md
└── LICENSETypeScript 模块开发
类型定义导出
interface / type / enum
typescript
// src/types/index.ts
// interface 定义对象结构
export interface User {
id: string;
name: string;
email: string;
role: UserRole;
createdAt: Date;
}
// type 定义联合类型或工具类型
export type UserRole = 'admin' | 'editor' | 'viewer';
export type PartialUser = Partial<User>;
export type ReadonlyUser = Readonly<User>;
export type UserWithoutId = Omit<User, 'id'>;
// enum 定义枚举常量
export enum HttpMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
DELETE = 'DELETE',
PATCH = 'PATCH',
}泛型约束
typescript
// src/core/api-client.ts
export interface ApiResponse<T> {
code: number;
message: string;
data: T;
timestamp: number;
}
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
// 泛型约束:确保 T 具有 id 属性
export interface Identifiable {
id: string;
}
export class Repository<T extends Identifiable> {
private items: Map<string, T> = new Map();
getById(id: string): T | undefined {
return this.items.get(id);
}
save(item: T): void {
this.items.set(item.id, item);
}
delete(id: string): boolean {
return this.items.delete(id);
}
findAll(): T[] {
return Array.from(this.items.values());
}
}函数重载
typescript
// src/core/format.ts
// 函数重载签名
export function formatDate(date: Date): string;
export function formatDate(date: Date, format: 'iso'): string;
export function formatDate(date: Date, format: 'relative'): string;
export function formatDate(date: Date, locale: string): string;
// 实现签名
export function formatDate(
date: Date,
option?: string
): string {
if (!option) {
return date.toISOString();
}
if (option === 'iso') {
return date.toISOString();
}
if (option === 'relative') {
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
return `${days} 天前`;
}
// locale 参数
return date.toLocaleDateString(option);
}命名空间
typescript
// src/types/api-types.ts
export namespace Api {
export namespace Request {
export interface CreateUser {
name: string;
email: string;
role: UserRole;
}
export interface UpdateUser {
name?: string;
email?: string;
role?: UserRole;
}
export interface ListUsers {
page?: number;
pageSize?: number;
role?: UserRole;
}
}
export namespace Response {
export interface UserDetail {
id: string;
name: string;
email: string;
role: string;
createdAt: string;
}
export interface Error {
code: number;
message: string;
details?: Record<string, string[]>;
}
}
}
// 使用时:import { Api } from './types/api-types';
// const req: Api.Request.CreateUser = { ... };打包工具选择
工具对比表
| 特性 | tsup | esbuild | Rollup |
|---|---|---|---|
| 底层引擎 | esbuild | esbuild | 自定义(支持多种插件) |
| 配置复杂度 | 低(零配置起步) | 中 | 高(需大量插件) |
| 构建速度 | 极快 | 极快 | 中等 |
| TypeScript 支持 | 内置 | 内置(需插件) | 需 @rollup/plugin-typescript |
| 双格式输出 | 内置支持 | 需手动处理 | 需 @rollup/plugin-replace |
| Tree-shaking | 优秀 | 优秀 | 优秀(最早实现) |
| 代码分割 | 支持 | 支持 | 原生支持 |
| 插件生态 | 有限 | 有限 | 丰富 |
| bundle 大小优化 | 内置 minify | 内置 minify | 需 @rollup/plugin-terser |
| 适用场景 | npm 库打包 | 快速构建/工具库 | 复杂库/框架 |
tsup 配置示例(推荐)
typescript
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true, // 生成类型声明
sourcemap: true,
clean: true,
splitting: true,
treeshake: true,
minify: true, // 压缩输出
minifyWhitespace: true,
minifyIdentifiers: true,
target: 'es2020',
outDir: 'dist',
external: ['node-fetch'], // 外部依赖不打包
esbuildOptions(options) {
options.chunkNames = 'chunks/[name]-[hash]';
},
});esbuild 配置示例
javascript
// esbuild.config.js
const esbuild = require('esbuild');
const baseConfig = {
entryPoints: ['src/index.ts'],
sourcemap: true,
minify: true,
target: 'es2020',
};
// ESM 构建
esbuild.build({
...baseConfig,
format: 'esm',
outfile: 'dist/esm/index.js',
});
// CJS 构建
esbuild.build({
...baseConfig,
format: 'cjs',
outfile: 'dist/cjs/index.js',
});Rollup 配置示例
javascript
// rollup.config.js
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
export default [
{
input: 'src/index.ts',
output: [
{
file: 'dist/esm/index.js',
format: 'esm',
sourcemap: true,
},
{
file: 'dist/cjs/index.js',
format: 'cjs',
sourcemap: true,
},
],
plugins: [
resolve(),
commonjs(),
typescript({
tsconfig: './tsconfig.json',
declaration: true,
declarationDir: './dist/types',
}),
terser(),
],
external: ['node-fetch'],
},
];Bundle 大小优化策略
typescript
// tsup.config.ts - 优化版
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
treeshake: {
preset: 'recommended',
},
minify: true,
target: 'es2020',
// 按需导入 lodash 等大库
esbuildPlugins: [
{
name: 'lodash-tree-shaking',
setup(build) {
build.onResolve({ filter: /^lodash\// }, (args) => {
return { path: args.path, external: true };
});
},
},
],
});Tree-shaking 注意事项
- 使用
sideEffects: false标记模块无副作用 - 避免在模块顶层执行有副作用的代码(如 polyfill、全局样式导入)
- 使用 ESM 语法(
import/export),CJS 的require()无法被 tree-shake - 对于 lodash 等大型工具库,使用子路径导入:
import cloneDeep from 'lodash/cloneDeep'
JSDoc 文档
JSDoc 注解示例
typescript
// src/core/validator.ts
/**
* 校验邮箱地址格式是否合法
*
* @param email - 待校验的邮箱地址字符串
* @returns 如果邮箱格式合法返回 true,否则返回 false
*
* @example
* ```typescript
* import { isValidEmail } from './validator';
*
* isValidEmail('user@example.com');
* // => true
*
* isValidEmail('invalid-email');
* // => false
* ```
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* 解析 JWT token 并返回 payload 部分
*
* @typeParam T - payload 的类型参数,默认为 Record<string, unknown>
* @param token - JWT 字符串
* @returns 解码后的 payload 对象
* @throws {TypeError} 当 token 格式无效或无法解码时抛出
*
* @example
* ```typescript
* import { parseToken } from './validator';
*
* interface CustomPayload {
* userId: string;
* role: string;
* }
*
* const payload = parseToken<CustomPayload>('eyJ...');
* // => { userId: '123', role: 'admin' }
* ```
*
* @deprecated 请使用 `jwtDecode` 函数替代,该函数有更好的错误处理
*/
export function parseToken<T = Record<string, unknown>>(token: string): T {
try {
const payload = token.split('.')[1];
if (!payload) {
throw new TypeError('Invalid token format');
}
return JSON.parse(atob(payload)) as T;
} catch (error) {
throw new TypeError('Failed to parse token');
}
}
/**
* 分页查询参数
*
* @param page - 页码,从 1 开始
* @param pageSize - 每页条数,范围 1-100,默认 20
* @returns 包含 offset 和 limit 的查询参数对象
*
* @throws {RangeError} 当 page < 1 或 pageSize 不在 1-100 范围内时抛出
*
* @example
* ```typescript
* import { paginate } from './validator';
*
* // 基本用法
* paginate(1);
* // => { offset: 0, limit: 20 }
*
* // 自定义每页条数
* paginate(3, 50);
* // => { offset: 100, limit: 50 }
* ```
*/
export function paginate(page: number, pageSize = 20) {
if (page < 1) {
throw new RangeError('page must be >= 1');
}
if (pageSize < 1 || pageSize > 100) {
throw new RangeError('pageSize must be between 1 and 100');
}
return {
offset: (page - 1) * pageSize,
limit: pageSize,
};
}JSDoc 标签汇总
| 标签 | 用途 | 示例 |
|---|---|---|
@param | 描述函数参数 | @param name - 用户名 |
@returns | 描述返回值 | @returns 格式化后的字符串 |
@example | 提供使用示例 | @example \``typescript ... ```` |
@deprecated | 标记废弃 API | @deprecated 请使用新函数替代 |
@throws | 描述可能抛出的异常 | @throws {TypeError} 参数无效时抛出 |
@typeParam | 描述泛型类型参数 | @typeParam T - 数据类型 |
@see | 引用相关文档 | @see {@link OtherClass} |
@since | 标注引入版本 | @since 2.0.0 |
@default | 标注默认值 | @default 20 |
TypeDoc 生成文档站
安装 TypeDoc:
bash
npm install --save-dev typedoctypedoc.json 配置:
json
{
"entryPoints": ["src/index.ts"],
"out": "docs/api",
"tsconfig": "tsconfig.json",
"excludePrivate": true,
"excludeProtected": true,
"excludeExternals": true,
"includeVersion": true,
"theme": "default",
"plugin": ["typedoc-plugin-markdown"],
"readme": "README.md"
}在 package.json 中添加脚本:
json
{
"scripts": {
"docs:generate": "typedoc",
"docs:serve": "typedoc --watch"
}
}测试体系
Jest / Vitest 配置
推荐使用 Vitest,与 Vite 生态兼容,性能优于 Jest。
Vitest 配置
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['__tests__/**/*.{test,spec}.{ts,js}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.ts'],
exclude: [
'src/**/*.d.ts',
'src/index.ts',
'src/types/**',
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
setupFiles: ['./__tests__/setup.ts'],
},
});Jest 配置(ts-jest + swc)
javascript
// jest.config.cjs
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
roots: ['<rootDir>/__tests__'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: 'tsconfig.json',
},
],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/index.ts',
],
coverageThreshold: {
global: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
};使用 SWC 加速 Jest(推荐):
javascript
// jest.config.cjs
module.exports = {
transform: {
'^.+\\.tsx?$': [
'@swc/jest',
{
jsc: {
parser: {
syntax: 'typescript',
tsx: false,
decorators: true,
},
target: 'es2020',
},
module: {
type: 'commonjs',
},
},
],
},
};单元测试
纯函数测试
typescript
// __tests__/unit/validator.test.ts
import { describe, it, expect } from 'vitest';
import { isValidEmail, paginate } from '../../src/core/validator';
describe('isValidEmail', () => {
it('应返回 true 对于合法的邮箱地址', () => {
expect(isValidEmail('user@example.com')).toBe(true);
expect(isValidEmail('test.user+tag@domain.co.uk')).toBe(true);
expect(isValidEmail('a@b.cn')).toBe(true);
});
it('应返回 false 对于不合法的邮箱地址', () => {
expect(isValidEmail('')).toBe(false);
expect(isValidEmail('not-an-email')).toBe(false);
expect(isValidEmail('@example.com')).toBe(false);
expect(isValidEmail('user@')).toBe(false);
expect(isValidEmail('user@.com')).toBe(false);
});
});错误路径测试
typescript
// __tests__/unit/validator.test.ts
describe('paginate', () => {
it('应返回正确的分页参数', () => {
const result = paginate(1);
expect(result).toEqual({ offset: 0, limit: 20 });
});
it('应支持自定义每页条数', () => {
const result = paginate(3, 50);
expect(result).toEqual({ offset: 100, limit: 50 });
});
it('page 小于 1 时应抛出 RangeError', () => {
expect(() => paginate(0)).toThrow(RangeError);
expect(() => paginate(-1)).toThrow(RangeError);
});
it('pageSize 超出范围时应抛出 RangeError', () => {
expect(() => paginate(1, 0)).toThrow(RangeError);
expect(() => paginate(1, 101)).toThrow(RangeError);
});
it('pageSize 为边界值时应正常工作', () => {
expect(() => paginate(1, 1)).not.toThrow();
expect(() => paginate(1, 100)).not.toThrow();
});
});异步测试
typescript
// src/core/http-client.ts
export interface HttpClientConfig {
baseUrl: string;
timeout?: number;
headers?: Record<string, string>;
}
export async function fetchData<T>(
url: string,
config?: HttpClientConfig
): Promise<T> {
const baseUrl = config?.baseUrl ?? '';
const timeout = config?.timeout ?? 5000;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(`${baseUrl}${url}`, {
headers: config?.headers,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
}
return response.json() as Promise<T>;
} finally {
clearTimeout(timeoutId);
}
}typescript
// __tests__/unit/http-client.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchData } from '../../src/core/http-client';
describe('fetchData', () => {
beforeEach(() => {
vi.spyOn(global, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
it('应成功获取数据', async () => {
const mockData = { id: 1, name: 'Test' };
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
} as Response);
const result = await fetchData('/api/users/1', {
baseUrl: 'https://api.example.com',
});
expect(result).toEqual(mockData);
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/api/users/1',
expect.objectContaining({ headers: undefined })
);
});
it('HTTP 错误时应抛出异常', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
} as Response);
await expect(
fetchData('/api/users/999')
).rejects.toThrow('HTTP Error: 404 Not Found');
});
it('超时应抛出异常', async () => {
vi.mocked(fetch).mockImplementation(
() => new Promise((_, reject) => {
setTimeout(() => reject(new DOMException('Aborted', 'AbortError')), 100);
})
);
await expect(
fetchData('/api/slow', { timeout: 10 })
).rejects.toThrow();
});
});边界条件测试
typescript
// __tests__/unit/boundary.test.ts
describe('边界条件测试', () => {
describe('空值处理', () => {
it('应处理 undefined 输入', () => {
// 测试函数对 undefined 的处理
});
it('应处理 null 输入', () => {
// 测试函数对 null 的处理
});
it('应处理空字符串', () => {
// 测试函数对空字符串的处理
});
});
describe('数值边界', () => {
it('应处理 Number.MAX_SAFE_INTEGER', () => {
// 测试大数处理
});
it('应处理 Number.MIN_SAFE_INTEGER', () => {
// 测试负数边界
});
it('应处理浮点数精度', () => {
expect(0.1 + 0.2).not.toBe(0.3); // JavaScript 浮点数问题
// 应使用 toBeCloseTo
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
});
describe('集合边界', () => {
it('应处理空数组', () => {
// 测试空数组输入
});
it('应处理单元素数组', () => {
// 测试最小有效输入
});
it('应处理超大数组', () => {
// 测试大规模数据
});
});
});集成测试
Node 环境测试
typescript
// __tests__/integration/api.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, Server } from 'http';
import { fetchData } from '../../src/core/http-client';
// 启动本地测试服务器
let server: Server;
let baseUrl: string;
beforeAll(() => {
return new Promise<void>((resolve) => {
server = createServer((req, res) => {
if (req.url === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else if (req.url?.startsWith('/api/users/')) {
const id = req.url.split('/').pop();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id, name: `User ${id}` }));
} else {
res.writeHead(404);
res.end();
}
});
server.listen(0, () => {
const addr = server.address();
if (addr && typeof addr !== 'string') {
baseUrl = `http://localhost:${addr.port}`;
}
resolve();
});
});
});
afterAll(() => {
return new Promise<void>((resolve) => {
server.close(() => resolve());
});
});
describe('API 集成测试', () => {
it('应调用健康检查接口', async () => {
const result = await fetchData('/api/health', { baseUrl });
expect(result).toEqual({ status: 'ok' });
});
it('应通过 ID 获取用户', async () => {
const result = await fetchData('/api/users/42', { baseUrl });
expect(result).toEqual({ id: '42', name: 'User 42' });
});
it('接口返回 404 时应抛出错误', async () => {
await expect(
fetchData('/api/not-found', { baseUrl })
).rejects.toThrow();
});
});DOM 环境测试 (jsdom)
typescript
// vitest.config.ts - DOM 测试配置
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['__tests__/dom/**/*.test.ts'],
setupFiles: ['./__tests__/dom/setup.ts'],
},
});typescript
// src/dom/dom-utils.ts
/**
* 创建 HTML 元素并设置属性和内容
*/
export function createElement<K extends keyof HTMLElementTagNameMap>(
tag: K,
attrs?: Record<string, string>,
children?: string | HTMLElement[]
): HTMLElementTagNameMap[K] {
const el = document.createElement(tag);
if (attrs) {
Object.entries(attrs).forEach(([key, value]) => {
el.setAttribute(key, value);
});
}
if (typeof children === 'string') {
el.textContent = children;
} else if (children) {
children.forEach((child) => el.appendChild(child));
}
return el;
}typescript
// __tests__/dom/dom-utils.test.ts
import { describe, it, expect } from 'vitest';
import { createElement } from '../../src/dom/dom-utils';
describe('createElement (DOM 环境)', () => {
it('应创建元素并设置属性', () => {
const el = createElement('button', {
class: 'btn-primary',
'data-id': '123',
type: 'submit',
}, '点击提交');
expect(el.tagName).toBe('BUTTON');
expect(el.getAttribute('class')).toBe('btn-primary');
expect(el.getAttribute('data-id')).toBe('123');
expect(el.textContent).toBe('点击提交');
});
it('应创建包含子元素的容器', () => {
const child1 = createElement('span', {}, '子元素 1');
const child2 = createElement('span', {}, '子元素 2');
const container = createElement('div', { class: 'container' }, [child1, child2]);
expect(container.children.length).toBe(2);
expect(container.children[0].textContent).toBe('子元素 1');
expect(container.children[1].textContent).toBe('子元素 2');
});
it('无子元素时应创建空元素', () => {
const el = createElement('div');
expect(el.textContent).toBe('');
});
});CI/CD 流水线
GitHub Actions 工作流
完整 CI 工作流
yaml
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
paths-ignore:
- 'docs/**'
- '**.md'
pull_request:
branches: [main]
release:
types: [published]
permissions:
contents: read
actions: read
checks: write
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run format -- --check
test:
name: Test (Node ${{ matrix.node-version }})
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm run test:coverage
- uses: davelosert/vitest-coverage-report@v2
if: matrix.node-version == 20
with:
json-summary-path: ./coverage/coverage-summary.json
build:
name: Build
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
dependency-review:
name: Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
allow-licenses: MIT, Apache-2.0, ISC, BSD-2-Clause, BSD-3-Clause
codeql:
name: CodeQL Analysis
runs-on: ubuntu-latest
permissions:
security-events: write
if: github.event_name != 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with:
languages: javascript-typescript
queries: security-and-quality
- run: npm ci
- run: npm run build
- uses: github/codeql-action/analyze@v3
with:
category: '/language:javascript-typescript'工作流说明
| 阶段 | 说明 |
|---|---|
on: push | 推送到 main/develop 分支时触发 |
on: pull_request | 向 main 分支提 PR 时触发 |
on: release | 创建 Release 时触发(用于发布 npm 包) |
lint | 代码规范检查 + 格式检查 |
test | 矩阵构建策略,在 Node 18/20/22 上运行测试 |
build | 构建产物并上传 artifact |
dependency-review | 依赖审查,检查许可证和已知漏洞 |
codeql | 代码安全分析,检测潜在漏洞 |
npm publish 自动化
npm 自动化发布
yaml
# .github/workflows/publish.yml
name: Publish to npm
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
# 发布到 npmjs.org
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# 同时发布到 GitHub Packages
- uses: actions/setup-node@v4
with:
registry-url: 'https://npm.pkg.github.com'
- run: npm publish --registry=https://npm.pkg.github.com
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}npm Token 配置
- 在 npmjs.com 生成 Automation token(settings -> tokens -> generate new token -> Automation)
- 在 GitHub 仓库设置中添加 Secrets(Settings -> Secrets and variables -> Actions)
- 添加
NPM_TOKEN变量,值为生成的 token
文档部署到 GitHub Pages
yaml
# .github/workflows/docs.yml
name: Deploy Documentation
on:
push:
branches: [main]
paths:
- 'src/**'
- 'typedoc.json'
release:
types: [published]
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run docs:generate
- uses: actions/configure-pages@v4
- uses: actions/upload-pages-artifact@v3
with:
path: docs/api
- id: deployment
uses: actions/deploy-pages@v4自动化版本管理
standard-version
bash
npm install --save-dev standard-versionjson
{
"scripts": {
"release": "standard-version",
"release:patch": "standard-version --release-as patch",
"release:minor": "standard-version --release-as minor",
"release:major": "standard-version --release-as major",
"postrelease": "git push --follow-tags origin main && npm publish"
}
}versionrc 配置:
json
{
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "chore", "section": "Chores" },
{ "type": "docs", "section": "Documentation" },
{ "type": "refactor", "section": "Code Refactoring" },
{ "type": "test", "section": "Tests" },
{ "type": "perf", "section": "Performance Improvements" }
],
"commitUrlFormat": "https://github.com/org/my-ts-library/commits/{{hash}}",
"compareUrlFormat": "https://github.com/org/my-ts-library/compare/{{previousTag}}...{{currentTag}}",
"issueUrlFormat": "https://github.com/org/my-ts-library/issues/{{id}}"
}Changesets
bash
npm install --save-dev @changesets/cli
npx changeset initjson
{
"scripts": {
"changeset": "changeset",
"version-packages": "changeset version",
"release": "changeset publish"
}
}GitHub Actions 集成 Changesets:
yaml
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
publish: npm run release
commit: 'chore: version packages'
title: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}semantic-release
bash
npm install --save-dev semantic-release @semantic-release/changelog @semantic-release/git @semantic-release/npm @semantic-release/githubrelease.config.js 配置:
javascript
module.exports = {
branches: ['main'],
repositoryUrl: 'https://github.com/org/my-ts-library',
plugins: [
'@semantic-release/commit-analyzer',
'@semantic-release/release-notes-generator',
'@semantic-release/changelog',
'@semantic-release/npm',
[
'@semantic-release/git',
{
assets: ['package.json', 'CHANGELOG.md'],
message: 'chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}',
},
],
'@semantic-release/github',
],
};Gitmoji 提交规范
推荐的提交信息格式:
:emoji: type(scope): subject
body
footer常用 Gitmoji 类型:
| Emoji | 类型 | 说明 |
|---|---|---|
:sparkles: | feat | 新功能 |
:bug: | fix | 修复 Bug |
:memo: | docs | 文档更新 |
:recycle: | refactor | 代码重构 |
:white_check_mark: | test | 添加测试 |
:zap: | perf | 性能优化 |
:wrench: | chore | 配置更新 |
:art: | style | 代码格式 |
:green_heart: | ci | CI/CD 配置 |
npm 发包规范
注册与登录
npm 账号注册
bash
# 注册新账号(交互式输入用户名、密码、邮箱)
npm adduser
# 登录已有账号
npm login
# 查看当前登录用户
npm whoami
# 退出登录
npm logoutnpm Token 管理
bash
# 生成自动化 token(用于 CI/CD)
npm token create
# 列出所有 token
npm token list
# 删除 token
npm token revoke <token-id>Token 类型:
| 类型 | 用途 | 权限 |
|---|---|---|
| Automation | CI/CD 自动发布 | 无法通过 Web 登录,仅用于自动化 |
| Publish | 手动发布 | 可发布和更新包 |
| Publish & 2FA | 二次验证发布 | 需要 OTP 验证 |
| Read-only | 只读访问 | 仅能读取包信息 |
package.json 关键字段
json
{
"name": "@scope/my-ts-library",
"version": "1.0.0",
"description": "A professional TypeScript npm module",
"keywords": ["typescript", "library", "utility"],
"license": "MIT",
"private": false,
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org",
"tag": "latest"
},
"unpkg": "dist/cjs/index.min.js",
"jsdelivr": "dist/cjs/index.min.js",
"types": "dist/types/index.d.ts",
"typings": "dist/types/index.d.ts",
"engines": {
"node": ">=18.0.0"
},
"os": ["darwin", "linux", "win32"],
"cpu": ["x64", "arm64"],
"files": [
"dist",
"README.md",
"LICENSE",
"CHANGELOG.md"
],
"sideEffects": false,
"scripts": {
"prepublishOnly": "npm run build && npm test",
"prepack": "npm run build",
"postpublish": "git push --tags"
}
}关键字段说明:
| 字段 | 说明 |
|---|---|
private: false | 必须设为 false 才能发布 |
publishConfig.access | 作用域包需要设为 public 才能公开发布 |
publishConfig.registry | 指定发布到的 registry |
publishConfig.tag | 发布标签,默认 latest |
unpkg / jsdelivr | CDN 入口,供 unpkg.com / jsdelivr.net 使用 |
types / typings | TypeScript 类型声明入口 |
files | 发布白名单,只包含需要的文件 |
sideEffects | 标记模块副作用,false 表示纯模块 |
engines | 指定 Node.js 版本要求 |
os / cpu | 限制操作系统和 CPU 架构 |
发包流程
版本管理
bash
# 补丁版本:1.0.0 -> 1.0.1(向后兼容的 Bug 修复)
npm version patch
# 次要版本:1.0.0 -> 1.1.0(向后兼容的新功能)
npm version minor
# 主要版本:1.0.0 -> 2.0.0(不兼容的 API 变更)
npm version major
# 预发布版本:1.0.0 -> 1.0.1-alpha.0
npm version prepatch --preid alpha
npm version preminor --preid beta
npm version premajor --preid rcnpm version 会执行以下操作:
- 更新
package.json中的 version 字段 - 创建对应的 git tag
- 提交版本变更
发布与更新
bash
# 发布包(公开)
npm publish
# 发布作用域包(公开)
npm publish --access public
# 发布到指定 tag(默认 latest)
npm publish --tag beta
# 发布带有 provenance 的包(增加可验证性)
npm publish --provenance --access public
# 更新包版本后重新发布
npm version patch
npm publish撤回与废弃
bash
# 撤回已发布的包(72 小时内可用)
npm unpublish <package-name>@<version>
# 撤回整个包(需要满足条件:无依赖此包、下载量小、发布后 72 小时内)
npm unpublish <package-name> --force
# 废弃包(推荐替代方案,不会移除包)
npm deprecate <package-name>@<version> "This version has a critical bug, please upgrade to 2.0.0"
# 废弃整个包
npm deprecate <package-name> "This package is no longer maintained. Use <alternative> instead."重要限制:
npm unpublish仅能在发布后 72 小时内执行- 已撤回的包名可以在 24 小时后被其他人使用
- 强烈建议使用
npm deprecate替代npm unpublish,避免破坏依赖链 - 撤回已被他人依赖的包可能导致下游构建失败
废弃包示例
bash
# 废弃特定版本
npm deprecate my-ts-library@1.0.0 "包含安全漏洞,请升级到 1.0.1"
# 废弃所有旧版本
npm deprecate my-ts-library@"<2.0.0" "请升级到 2.0.0 以获得最新功能和安全更新"
# 废弃整个包
npm deprecate my-ts-library "此包已不再维护,请使用 @new-scope/new-package 替代"
# 取消废弃
npm deprecate my-ts-library ""Scope 包
作用域包命名
json
{
"name": "@scope/package-name",
"publishConfig": {
"access": "public"
}
}作用域包的特点:
| 特性 | 说明 |
|---|---|
| 命名格式 | @scope/package,scope 通常是组织名或用户名 |
| 私有包 | 默认为私有,需付费 npm 组织版 |
| 公开包 | 免费发布,需 --access public |
| 安装方式 | npm install @scope/package |
| 导入方式 | import { something } from '@scope/package' |
私有包
json
{
"name": "@my-org/internal-utils",
"private": false,
"publishConfig": {
"access": "restricted",
"registry": "https://registry.npmjs.org"
}
}bash
# 发布私有包(需要 npm 付费组织版)
npm publish
# 安装私有包(需要认证)
npm login
npm install @my-org/internal-utils在 GitHub Packages 上发布 Scope 包
json
{
"name": "@my-org/package",
"publishConfig": {
"registry": "https://npm.pkg.github.com"
}
}bash
# 登录 GitHub Packages
npm login --registry=https://npm.pkg.github.com
# 发布
npm publish --registry=https://npm.pkg.github.com在 .npmrc 中配置:
# 全局配置
registry=https://registry.npmjs.org/
@my-org:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}公开付费包
npm 允许公开付费包(paid public packages),适用于需要商业化但又不想关闭源码的场景。通过 npm pack 打包并配合 license 管理,或使用 npm 的付费公共包功能来发布。
json
{
"name": "@scope/premium-package",
"publishConfig": {
"access": "public"
},
"license": "SEE LICENSE IN LICENSE"
}发布检查清单
发布前需确认以下事项:
- [ ]
private字段已设为false - [ ]
main、module、types、exports等入口配置正确 - [ ]
files字段包含所有必要文件,排除多余文件 - [ ] 版本号已正确更新(
npm version) - [ ] 构建产物已生成且验证可用(
npm run prepublishOnly) - [ ] 测试全部通过(
npm test) - [ ] 变更日志已更新(CHANGELOG.md)
- [ ] git tag 已创建
- [ ] npm 已登录(
npm whoami) - [ ] 对于 scope 包,确认
publishConfig.access是否正确 - [ ] 对于 CI/CD 发布,确认
NPM_TOKEN已在 GitHub Secrets 中配置