1. 项目概述:一个团队内部的“瑞士军刀”
在任何一个有一定规模的研发团队里,你总会发现一些重复性的、琐碎的,但又不得不做的“脏活累活”。比如,新项目启动时,如何快速搭建一个统一、规范的工程脚手架?如何管理团队内部通用的工具函数、组件库或者配置模板?当需要跨项目共享一段业务逻辑时,是选择复制粘贴,还是费力地抽离成一个独立的NPM包,然后陷入版本管理、发布和权限的泥潭?
“Team-Commonly/commonly”这个项目,从名字上就直白地揭示了它的定位:团队通用。它不是一个面向公众的开源库,而是一个团队内部的、私有的、高度定制化的“工具箱”或“基础设施集合”。你可以把它理解为一个团队技术栈的“结晶”和“操作手册”的代码化体现。它的核心价值不在于技术有多前沿,而在于将团队的最佳实践、通用解决方案和开发约束,固化成一个可被所有成员直接引用和执行的代码资产,从而极大提升团队内部的协作效率和代码质量的一致性。
我经历过不少团队,早期都是靠口口相传或者一个陈旧的Wiki来维护规范,但执行起来总是参差不齐。后来,我们开始尝试构建自己的“commonly”项目,它可能包含从项目初始化脚本、ESLint/Prettier配置、通用的工具函数(如日期处理、数据校验、HTTP请求封装)、到团队内部UI组件库的桥梁、甚至是一些特定业务场景的Hooks集合。这个仓库逐渐成为了团队开发的“起手式”和“标准答案”。接下来,我就结合实战经验,拆解如何从零开始构建并维护好这样一个团队核心资产。
2. 项目整体设计与核心思路
构建一个团队通用库,首要问题不是写代码,而是明确边界和设计原则。它不同于完整的业务系统,也不同于追求普适性的开源项目,它的设计必须紧紧围绕“对内服务”和“提升效率”这两个核心目标。
2.1 核心定位与模块划分
一个健康的commonly项目应该是一个“微内核”架构,内核稳定,模块可插拔。通常,我会将其划分为以下几个核心模块:
工程化套件 (Engineering Kit):这是基石。包含团队统一的代码规范配置(如
.eslintrc.js,.prettierrc)、TypeScript配置模板(tsconfig.base.json)、Git提交规范(commitlint.config.js)、构建工具配置(如Vite/Rollup/Webpack的通用预设)等。它的目标是让任何一个新项目,通过几条命令就能获得完全一致的、开箱即用的工程化环境。通用工具库 (Utilities):这是使用频率最高的部分。包含纯函数式的工具,例如:
formatDate: 日期格式化,统一团队对时间显示的规则。debounce/throttle: 防抖节流函数,避免重复造轮子。typeGuard: 类型守卫函数,增强TypeScript的类型安全。httpClient: 基于axios或fetch封装的、带有团队统一错误处理、请求拦截、认证令牌管理的HTTP客户端。storage: 对localStorage/sessionStorage的封装,提供过期时间、自动序列化/反序列化功能。
公共组件/样式 (Components & Styles):如果团队使用React、Vue等框架,这里可以存放那些业务无关的底层UI组件(如高阶的
Loading组件、增强的Modal组件)或工具类Hooks(如useAsync、useLocalStorage)。更常见的是,这里只提供与团队主UI组件库(如Ant Design, Element Plus)配套的主题变量、通用样式混合(mixins)或工具类(utility classes)的定义。业务通用逻辑 (Business Commons):这是最有团队特色的部分。它封装了特定业务领域内可复用的逻辑,但必须保持与具体UI和路由的解耦。例如,在一个电商团队中,可能包含“优惠券计算逻辑”、“SKU选择算法”;在一个内容管理团队中,可能包含“富文本内容清洗函数”、“图片上传预处理逻辑”。这部分需要谨慎设计,确保其纯粹性。
2.2 技术选型与架构考量
技术选型必须与团队主流技术栈对齐,并考虑长期维护成本。
- 语言与规范:毫无疑问选择TypeScript。它不仅提供类型安全,其类型定义本身就是最好的文档。配合团队统一的TS配置,能强制所有使用方遵循同一套类型规则。
- 包管理工具:选择
pnpm或yarn(开启workspace功能)。这对于管理commonly项目自身的多包结构(如果采用Monorepo)以及清晰处理依赖关系至关重要。pnpm的硬链接机制能极大节省磁盘空间和安装时间。 - 构建与发布:对于需要发布到私有NPM仓库的模块,选择
tsup、rollup或microbundle这类轻量级构建工具。它们配置简单,能快速将TS源码打包为ESM、CJS等多种格式。关键点在于生成清晰的类型声明文件(.d.ts)。 - Monorepo vs Polyrepo:
- Monorepo:将所有模块(工具、配置、组件)放在一个仓库内,用
pnpm workspace管理。优点是依赖管理清晰、代码共享方便、版本同步简单。适合模块间联系紧密、团队希望统一管理的场景。 - Polyrepo:每个独立模块(如
@team/utils,@team/eslint-config)拥有自己的仓库。优点是职责更清晰、可独立发布和版本化、权限控制更细粒度。适合模块相对独立、迭代节奏不同的场景。 - 我的建议:对于中小团队,初期采用Monorepo管理所有“通用”内容,复杂度低,上手快。当某个模块(如UI组件)发展得非常庞大且独立时,再考虑将其拆分出去。
- Monorepo:将所有模块(工具、配置、组件)放在一个仓库内,用
注意:切忌在
commonly中引入重量级、版本迭代快的框架(如特定的UI组件库完整版)。它应该是对这些框架的“胶水层”或“增强包”,而不是替代品。依赖应尽可能少、尽可能稳定。
3. 核心细节解析与实操要点
3.1 工程化套件的实现:以ESLint配置为例
团队代码规范是“基础设施中的基础设施”。我们通常会在commonly中创建一个eslint-config包。
项目结构示例:
/packages /eslint-config /index.js # 主配置 /package.json /README.md/packages/eslint-config/index.js内容:
module.exports = { env: { browser: true, es2021: true, node: true }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', // TS规则 'plugin:prettier/recommended' // 整合Prettier,避免冲突 ], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, plugins: ['@typescript-eslint'], // 团队自定义规则 rules: { '@typescript-eslint/no-explicit-any': 'warn', // 不允许any,但先警告 'no-console': ['warn', { allow: ['warn', 'error'] }], // 允许console.warn/error 'prefer-const': 'error' }, // 针对特定文件覆盖规则 overrides: [ { files: ['*.test.ts', '*.spec.ts'], rules: { 'no-console': 'off' } } ] };对应的package.json:
{ "name": "@your-team/eslint-config", "version": "1.0.0", "main": "index.js", "dependencies": { "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "eslint": "^8.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0" }, "peerDependencies": { "typescript": ">=4.0.0" } }在业务项目中使用:
- 安装:
pnpm add -D eslint @your-team/eslint-config - 创建
.eslintrc.js:module.exports = require('@your-team/eslint-config'); - (可选)在
package.json中添加脚本:"lint": "eslint . --ext .ts,.tsx"
实操心得:
- 规则分级:将规则设置为
error(阻塞)、warn(警告)、off(关闭)。初期可以多使用warn,让团队有个适应过程,再逐步收紧。 - Prettier集成:务必使用
eslint-config-prettier和eslint-plugin-prettier,将Prettier作为ESLint的规则来运行,可以统一在ESLint流程中完成代码检查和格式化,避免两者冲突。 - 文档化:在包的
README.md中清晰说明为何要制定某条规则,并给出好代码和坏代码的对比示例。这比干巴巴的规则列表有效得多。
3.2 通用工具函数的设计:以HTTP客户端封装为例
一个健壮的HTTP客户端是前后端协作的桥梁。在commonly中封装,可以统一处理鉴权、错误、拦截器等。
项目结构示例:
/packages /http-client /src /index.ts # 导出主类 /types.ts # 类型定义 /interceptors.ts # 拦截器定义 /error.ts # 自定义错误类 /package.json /tsconfig.json /build.config.ts # 构建配置核心实现要点 (/src/index.ts):
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { CustomError } from './error'; import { setupInterceptors } from './interceptors'; // 定义统一的响应数据结构 export interface CommonResponse<T = any> { code: number; data: T; message: string; success: boolean; } export class HttpClient { private instance: AxiosInstance; constructor(baseURL: string) { this.instance = axios.create({ baseURL, timeout: 10000, headers: { 'Content-Type': 'application/json' } }); // 安装拦截器 setupInterceptors(this.instance); } // 泛型请求方法 async request<T>(config: AxiosRequestConfig): Promise<T> { try { const response: AxiosResponse<CommonResponse<T>> = await this.instance(config); const { data: resData } = response; // 根据后端约定处理业务逻辑错误 if (resData.success) { return resData.data; } else { // 抛出业务错误 throw new CustomError(resData.message, resData.code); } } catch (error) { // 统一处理网络错误、超时等 if (axios.isAxiosError(error)) { // 可以在这里处理401跳转登录、503提示维护等 throw new CustomError(`网络请求失败: ${error.message}`, error.response?.status || -1); } throw error; // 重新抛出其他未知错误 } } // 提供便捷方法 get<T>(url: string, params?: any): Promise<T> { return this.request({ method: 'GET', url, params }); } post<T>(url: string, data?: any): Promise<T> { return this.request({ method: 'POST', url, data }); } // ... 其他方法 }拦截器示例 (/src/interceptors.ts):
import { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; export function setupInterceptors(instance: AxiosInstance) { // 请求拦截器:统一添加token instance.interceptors.request.use( (config: InternalAxiosRequestConfig) => { const token = localStorage.getItem('auth_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } // 可以在这里统一添加请求时间戳、设备信息等 return config; }, (error) => Promise.reject(error) ); // 响应拦截器:统一处理错误状态码 instance.interceptors.response.use( (response) => response, (error) => { if (error.response?.status === 401) { // 触发登出逻辑,清理token,跳转登录页 console.warn('未授权,请重新登录'); // 触发全局事件或调用登出函数 } if (error.response?.status === 500) { // 提示服务器内部错误 } return Promise.reject(error); } ); }实操心得:
- 错误分类:一定要区分网络错误(如超时、断网)、HTTP错误(如404, 500)和业务错误(如后端返回的
success: false)。为业务错误创建自定义错误类(CustomError),便于在业务代码中做差异化捕获和处理。 - 类型安全:利用TypeScript泛型,让
get、post等方法能推断出返回的数据类型(Promise<T>),极大提升开发体验。 - 可测试性:将
axios实例的创建和拦截器配置分离,便于在单元测试中模拟(mock)或使用不同的配置。 - 避免全局单例:
HttpClient类允许传入不同的baseURL创建多个实例,这比导出一个全局的单例更灵活,可以适配微前端或多后端服务的场景。
4. 开发、构建与发布流程
4.1 Monorepo下的开发工作流
假设我们采用pnpm workspace的Monorepo结构。
根目录package.json:
{ "name": "team-commonly", "private": true, "scripts": { "dev": "pnpm -r run dev", // 并行运行所有包的dev脚本 "build": "pnpm -r run build", // 并行运行所有包的build脚本 "lint": "pnpm -r run lint", "test": "pnpm -r run test", "publish:all": "node scripts/publish.mjs" // 自定义发布脚本 }, "devDependencies": { "typescript": "^5.0.0" } }一个工具包(如utils)的package.json:
{ "name": "@your-team/utils", "version": "1.2.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "scripts": { "dev": "tsup --watch", "build": "tsup", "lint": "eslint src --ext .ts" }, "dependencies": { // 内部依赖,使用 workspace:* "@your-team/types": "workspace:*" }, "devDependencies": { "tsup": "^7.0.0", "@your-team/eslint-config": "workspace:*" } }tsup.config.ts(构建配置):
import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], // 入口文件 format: ['cjs', 'esm'], // 生成两种格式 dts: true, // 生成类型声明文件 clean: true, // 清理dist目录 sourcemap: true, // 生成sourcemap便于调试 // 外部化依赖,不打包进bundle external: ['lodash-es', 'dayjs'], });4.2 版本管理与发布策略
这是团队库管理的难点。我推荐采用“固定版本” + “Changesets”的策略。
固定版本:在Monorepo中,所有包共享一个版本号(在根目录用
version字段管理)。这简化了管理,确保所有包同时升级。使用pnpm version 1.3.0命令统一更新根版本和所有子包版本。使用Changesets:
- 安装:
pnpm add -Dw @changesets/cli && pnpm changeset init - 开发者在完成功能或修复后,运行
pnpm changeset。CLI会引导你选择哪些包需要更新(@your-team/utils,@your-team/http-client),并选择版本更新类型(patch,minor,major),同时编写变更日志。 - 这会生成一个
.changeset目录下的Markdown文件。 - 合并到主分支后,通过CI/CD或手动运行
pnpm changeset version,它会根据changeset文件自动提升package.json版本号,并生成汇总的CHANGELOG.md。 - 最后运行
pnpm publish -r发布所有版本更新的包到私有NPM仓库。
- 安装:
实操心得:
- 语义化版本:严格遵守
主版本号.次版本号.修订号。破坏性变更升主版本,向下兼容的新功能升次版本,Bug修复升修订号。在commonly的README中明确版本策略。 - 私有NPM仓库:使用Verdaccio或直接使用GitHub Packages、GitLab Package Registry搭建私有仓库。在
.npmrc中配置@your-team:registry=https://your-private-registry.com/。 - 预发布版本:对于重大更新,可以先发布
1.3.0-beta.1这样的预发布版本,让团队内部先行测试,稳定后再发布正式版。
5. 在业务项目中的集成与使用
5.1 安装与配置
在业务项目的package.json中:
{ "devDependencies": { "@your-team/eslint-config": "^1.0.0", "@your-team/ts-config": "workspace:*" // 如果使用pnpm workspace,可以直接链接 }, "dependencies": { "@your-team/utils": "^1.2.0", "@your-team/http-client": "^1.0.0" } }对于配置类包(如ESLint),在业务项目根目录创建配置文件并扩展:
// .eslintrc.js module.exports = require('@your-team/eslint-config');// tsconfig.json { "extends": "@your-team/ts-config/base.json", // 扩展基础配置 "compilerOptions": { "outDir": "./dist" // ... 项目特定配置 }, "include": ["src"] }5.2 使用示例
// 1. 使用工具函数 import { formatDate, debounce } from '@your-team/utils'; const formatted = formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss'); const search = debounce((keyword) => { console.log(keyword); }, 300); // 2. 使用HTTP客户端 import { HttpClient } from '@your-team/http-client'; const api = new HttpClient(import.meta.env.VITE_API_BASE_URL); async function fetchUser() { try { const user = await api.get<{ id: number; name: string }>('/user/profile'); console.log(user.name); } catch (error) { if (error instanceof CustomError && error.code === 1001) { // 处理特定业务错误 alert(error.message); } else { // 处理网络或其他错误 console.error('请求失败', error); } } }6. 维护、演进与团队协作规范
6.1 代码贡献与审查流程
- Issue驱动:任何新功能、Bug修复都应先创建Issue,描述清楚背景、需求和方案,讨论通过后再开始编码。
- 分支策略:采用
main(或master) 作为稳定分支,develop作为开发分支。功能分支从develop拉取,命名为feat/xxx或fix/xxx。 - 提交规范:使用Commitizen或团队约定的格式,例如
feat(utils): add deepClone function。这便于自动生成CHANGELOG。 - 严格的Code Review:
commonly的代码质量要求应高于业务项目。Review时重点检查:API设计是否合理、类型定义是否完备、测试是否覆盖、文档是否更新、是否引入了不必要的依赖。
6.2 文档与示例
文档是通用库的命脉。每个包都必须有清晰的README.md,至少包含:
- 安装说明
- 快速开始:一个最简单的、可运行的示例。
- API文档:使用TypeDoc或类似工具从代码注释自动生成,并部署到内部文档站点。
- 示例代码:在仓库中建立
examples目录,提供不同场景的使用示例。 - 常见问题
6.3 质量保障
- 单元测试:使用Jest或Vitest,对工具函数、工具类进行高覆盖率的单元测试。特别是边界条件。
- 类型测试:对于TypeScript库,可以使用
tsd或@typescript-eslint/parser来测试类型定义的正确性,确保导出的类型符合预期。 - 集成测试:对于像HTTP客户端这样的模块,需要模拟服务器响应进行集成测试。
- 持续集成:配置GitHub Actions或GitLab CI,在推送代码时自动运行 lint、test、build,确保主分支的稳定性。
7. 常见问题与排查技巧实录
问题1:在业务项目中引入@your-team/utils后,Tree Shaking失效,打包体积过大。
- 排查:检查工具包的构建输出。确保
package.json中设置了"sideEffects": false,并且构建工具(如tsup, rollup)正确配置了输出格式为ESM。确保工具函数是独立导出,而不是全部挂载在一个默认对象上。 - 解决:在工具包的
src目录下使用多入口点,或确保每个函数都是具名导出。在业务项目中使用按需导入:import { debounce } from '@your-team/utils',而不是import * as utils from ...。
问题2:更新了commonly的某个包,但业务项目安装后似乎还是旧版本。
- 排查:首先检查业务项目的
node_modules下该包的实际版本号。然后检查私有NPM仓库的版本是否已成功发布。最后,检查是否有锁文件(pnpm-lock.yaml,package-lock.json)的缓存。 - 解决:在业务项目中,删除锁文件和
node_modules,重新安装。对于pnpm,可以使用pnpm store prune清理存储。确保CI/CD环境也执行了干净的安装。
问题3:TypeScript类型报错:“模块‘@your-team/utils’没有导出的成员‘xxx’”。
- 排查:首先确认该成员是否确实在工具包的最新版本中导出。检查工具包的
dist/index.d.ts类型声明文件是否正确生成并包含了该导出。 - 解决:在工具包中,确保
tsconfig.json的compilerOptions中设置了"declaration": true。如果使用tsup,确认dts: true选项已开启。发布新版本后,业务项目需要更新类型。
问题4:两个不同的业务项目,因为间接依赖了commonly中某个包的不同版本,导致运行时错误。
- 排查:这是典型的“依赖地狱”。使用
pnpm why <package-name>或npm ls <package-name>查看依赖树。 - 解决:在
commonly的包中,尽量将第三方依赖声明为peerDependencies并指定宽松的版本范围(如"lodash": "^4.17.0"),让业务项目来决定安装哪个具体版本。同时,commonly自身应尽量减少第三方依赖,保持轻量。
问题5:团队有新成员,如何快速上手commonly?
- 解决:在仓库根目录维护一个详尽的
CONTRIBUTING.md文档。内容应包括:项目结构介绍、开发环境搭建步骤(pnpm install,pnpm build)、如何添加新包、如何运行测试、如何提交更改、以及发布流程。组织一次简短的内部分享会,演示核心模块的使用和贡献流程。
构建和维护一个优秀的Team-Commonly/commonly项目,是一个“磨刀不误砍柴工”的过程。初期投入看似增加了工作量,但它带来的团队协作效率提升、代码质量保障和知识沉淀价值,会在项目规模扩大和团队人员流动时得到指数级的回报。它最终会成为团队技术文化的基石和新人入职的第一本“代码手册”。关键在于持续迭代、保持简洁、并倾听团队的声音,让它真正服务于每一个开发者。