🧑💻 写在开头
点赞 + 收藏 === 学会🤣🤣🤣
在日常工作中,我们经常为会遇到需要创建新项目的需求,为了统计代码风格,项目配置,提升效率,我们可以创建一个cli工具,帮助我们实现这样的功能。你也可以搭建一个自己用,毕竟省下来的时间都是自己的
🥑 你能学到什么?
希望在你阅读本篇文章之后,不会觉得浪费了时间。如果你跟着读下来,你将会学到:
- cli工具的基本搭建流程
- 如何通过模板引擎实现可选依赖
- 模板系统如何设计
- 如何根据模板引擎生成所需项目
- 熟悉一个组件库的基本结构
- 熟悉一个类型库的基本结构
- 熟悉一个cli项目的基本结构
后续你可以在此项目ObjectX-CLI的基础上,扩展项目支持的技术栈,和项目类型,以此了解各种项目的搭建流程
实现效果

🍎 系列文章
本系列是一个从0到1的实现过程,如果您有耐心跟着实现,您可以实现一个完整的react18 + ts5 + webpack5 + 代码质量&代码风格检测&自动修复 + storybook8 + rollup + git action
实现的一个完整的组件库模板项目。如果您不打算自己配置,也可以直接clone组件库仓库切换到rollup_comp
分支即是完整的项目,当前实现已经足够个人使用,后续我们会新增webpack5优化、按需加载组件、实现一些常见的组件封装:包括但不限于拖拽排序、瀑布流、穿梭框、弹窗等
- 【前端工程化】项目搭建篇-项目初始化&prettier、eslint、stylelint、lint-staged、husky
- 【前端工程化】项目搭建篇-配置changelog、webpack5打包
- 【前端工程化】项目搭建篇-引入react、ts、babel解析es6+、配置css module
- 【前端工程化】组件库搭建篇-引入storybook、rollup打包组件、本地测试组件库
- 【前端工程化】包管理器篇-三大包管理器、npm工程管理、npm发布流程
- 【前端工程化】自动化篇-Github Action基本使用、自动部署组件库文档、github3D指标统计
- 【前端工程化】自动化篇-手写脚本一键自动tag、发包、引导登录npm
- 【前端工程化】monorepo篇-rush管理monorepo实践
- 【前端工程化】monorepo篇-monorepo多包发布脚本实现
项目概述
ObjectX-CLI 是一个现代化的前端项目脚手架工具,支持快速创建以下三种类型的项目:
- 组件库项目 (component-lib):基于 React + TypeScript + Vite
- 工具包项目 (tool-lib):通用 JavaScript/TypeScript 工具库
- 类型包项目 (types-lib):纯 TypeScript 类型定义包
核心特性
- 🚀 零配置启动:开箱即用,一键创建项目
- 📦 模板化设计:基于 EJS 模板引擎,支持灵活配置
- 🎨 多样式方案:支持 Less、Tailwind CSS、CSS Modules
- 📚 文档集成:可选 Storybook 组件文档
- 🧩 Monorepo 支持:使用 pnpm workspace
- ⚡ 现代化打包:Rollup + TypeScript
核心架构设计
整体架构图
python
objectx-cli/
├── bin/ # CLI 入口
│ └── index.js # 可执行文件入口
├── src/ # 源代码
│ ├── index.ts # 主程序入口
│ ├── commands/ # 命令实现
│ │ └── create.ts # create 命令
│ └── utils/ # 工具函数
│ ├── generate.ts # 项目生成逻辑
│ └── validate.ts # 验证逻辑
├── templates/ # 项目模板
│ ├── component-lib/ # 组件库模板
│ ├── tool-lib/ # 工具库模板
│ └── types-lib/ # 类型库模板
├── lib/ # 编译输出(发布到 npm)
├── package.json # 包配置
├── rollup.config.js # 打包配置
└── tsconfig.json # TypeScript 配置
核心流程图
bash
用户执行命令
↓
bin/index.js(可执行入口)
↓
src/index.ts(初始化 Commander)
↓
src/commands/create.ts(处理 create 命令)
↓
├─ validate.ts(验证项目名)
├─ inquirer 交互式问答
└─ generate.ts(生成项目文件)
↓
├─ 读取模板文件
├─ EJS 模板渲染
├─ 条件文件过滤
└─ 生成 package.json
↓
Git 初始化
↓
完成提示
技术栈分析
1. CLI 框架层
Commander.js - 命令行框架
typescript
// src/index.ts
import { Command } from 'commander';
const cli = new Command();
cli
.name('objectx-cli')
.description('前端项目脚手架工具')
.version(pkg.version);
cli
.command('create <project-name>')
.description('创建一个新的项目')
.option('-t, --template <template>', '指定项目模板')
.action(create);
作用:
- 定义 CLI 命令和参数
- 自动生成
--help
和--version
- 参数解析和验证
2. 交互层
@inquirer/prompts - 交互式问答
typescript
// src/commands/create.ts
import * as inquirer from '@inquirer/prompts';
// 确认覆盖
const overwriteResult = await inquirer.confirm({
message: `目标目录 ${chalk.cyan(projectName)} 已存在。是否覆盖?`,
default: false
});
// 单选
const projectType = await inquirer.select({
message: '请选择项目类型:',
choices: [
{ value: 'component-lib', name: '组件库项目' },
{ value: 'tool-lib', name: '工具包项目' },
{ value: 'types-lib', name: '类型包项目' }
]
});
// 多选
const styles = await inquirer.checkbox({
message: '选择样式解决方案 (可多选):',
choices: [
{ value: 'less', name: 'Less', checked: true },
{ value: 'tailwind', name: 'Tailwind CSS' },
{ value: 'css-modules', name: 'CSS Modules', checked: true }
]
});
3. 用户体验层
Chalk - 终端颜色
typescript
import chalk from 'chalk';
console.log(`${chalk.bgBlue('OBJECTX CLI ')} 🚀 创建新项目...`);
console.log(chalk.green('✔') + ' 项目创建成功!');
console.log(chalk.red('✖') + ' 项目名称不能为空');
console.log(` cd ${chalk.cyan(projectName)}`);
Ora - 加载动画
typescript
import ora from 'ora';
const spinner = ora('正在生成项目文件...').start();
await generateProject(targetDir, projectOptions);
spinner.succeed('项目文件生成完成');
// 失败情况
spinner.fail('项目文件生成失败');
4. 文件处理层
fs-extra - 增强的文件系统
typescript
import fs from 'fs-extra';
// 确保目录存在
await fs.ensureDir(targetDir);
// 删除目录
await fs.remove(targetDir);
// 输出文件(自动创建父目录)
await fs.outputFile(targetPath, content);
// 检查文件是否存在
if (fs.existsSync(targetDir)) { ... }
fast-glob - 文件搜索
typescript
import glob from 'fast-glob';
// 读取所有模板文件(包括隐藏文件)
const templateFiles = await glob('**/*', {
cwd: templateDir,
dot: true, // 包括 .gitignore 等
ignore: ['**/node_modules/**', '**/.git/**']
});
5. 模板引擎层【核心】
EJS - 模板渲染
typescript
import ejs from 'ejs';
// 模板数据
const templateData = {
projectName: 'my-component',
hasLess: true,
hasTailwind: false,
year: 2024
};
// 渲染模板
const content = await fs.readFile(sourcePath, 'utf8');
const renderedContent = ejs.render(content, templateData);
EJS 模板示例:
typescript
// Button.tsx.ejs
<% if (hasCssModules) { %>
import styles from './Button.module.css';
<% } else if (hasLess) { %>
import './Button.less';
<% } else if (hasTailwind) { %>
// 使用Tailwind类名
<% } %>
export const Button: React.FC<ButtonProps> = ({ children }) => {
<% if (hasCssModules) { %>
return <button className={styles.button}>{children}</button>;
<% } else if (hasTailwind) { %>
return <button className="bg-blue-500 text-white">{children}</button>;
<% } %>
};
6. 子进程管理
execa - 执行外部命令
typescript
import { execa } from 'execa';
// 初始化 Git
await execa('git', ['init'], { cwd: targetDir });
实现原理深度解析【核心】
1. 可执行入口实现
package.json 配置
json
{
"name": "objectx-cli",
"type": "module",
"bin": {
"objectx-cli": "./bin/index.js"
},
"files": ["bin", "lib", "templates"]
}
bin
字段:指定命令名称和执行文件type: "module"
:使用 ES Modulefiles
字段:指定发布到 npm 的文件
bin/index.js
javascript
#!/usr/bin/env node
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pkg = require('../package.json');
import '../lib/index.js';
关键点:
#!/usr/bin/env node
:Shebang,告诉系统用 Node.js 执行createRequire
:在 ESM 中使用require
加载 JSON- 引入编译后的
lib/index.js
(不是源码src/index.ts
)
2. 项目名称验证
typescript
// src/utils/validate.ts
export function validateProjectName(projectName: string): void {
// 1. 空值检查
if (!projectName) {
console.log(chalk.red('✖') + ' 项目名称不能为空');
process.exit(1);
}
// 2. 空格检查
if (projectName.trim() !== projectName) {
console.log(chalk.red('✖') + ' 项目名称不能以空格开头或结尾');
process.exit(1);
}
// 3. npm 包名验证
const npmNameValidation = validateNpmPackageName(projectName);
if (!npmNameValidation.validForNewPackages) {
npmNameValidation.errors.forEach(err => {
console.log(' ' + chalk.red('✖') + ' ' + err);
});
process.exit(1);
}
// 4. 保留关键字检查
const RESERVED_KEYWORDS = ['node_modules', 'favicon.ico', '.git'];
if (RESERVED_KEYWORDS.includes(projectName.toLowerCase())) {
console.log(chalk.red('✖') + ` 项目名称不能使用保留关键字`);
process.exit(1);
}
}
function validateNpmPackageName(name: string) {
const errors: string[] = [];
if (name.length > 214) {
errors.push('名称不能超过214个字符');
}
if (name.match(/^[._]/)) {
errors.push('名称不能以 . 或 _ 开头');
}
if (name.match(/[/\\]/)) {
errors.push('名称不能包含斜杠');
}
return {
validForNewPackages: errors.length === 0,
errors
};
}
3. 目录覆盖处理
typescript
// src/commands/create.ts
const targetDir = path.join(process.cwd(), projectName);
// 检查目录是否已存在
if (fs.existsSync(targetDir)) {
const overwriteResult = await inquirer.confirm({
message: `目标目录 ${chalk.cyan(projectName)} 已存在。是否覆盖?`,
default: false
});
if (!overwriteResult) {
console.log(chalk.red('✖') + ' 操作取消');
return;
}
const spinner = ora(`正在删除 ${chalk.cyan(targetDir)}...`).start();
await fs.remove(targetDir);
spinner.succeed(`已删除 ${chalk.cyan(targetDir)}`);
}
await fs.ensureDir(targetDir);
安全性考虑:
- 先询问用户确认
- 显示加载动画,提升体验
- 使用
fs.remove
完全删除旧目录
模板系统设计【核心】
1. 模板目录结构
css
templates/
├── component-lib/ # 组件库模板
│ ├── _gitignore.ejs # .gitignore(以 _ 开头)
│ ├── demo/ # 示例应用(无 Storybook 时使用)
│ │ ├── App.tsx.ejs
│ │ ├── index.html.ejs
│ │ └── main.tsx.ejs
│ ├── pnpm-workspace.yaml.ejs
│ ├── README.md.ejs
│ ├── src/
│ │ ├── components/
│ │ │ ├── Button/
│ │ │ │ ├── Button.tsx.ejs
│ │ │ │ ├── Button.module.css.ejs
│ │ │ │ ├── Button.module.less.ejs
│ │ │ │ └── Button.stories.tsx.ejs
│ │ │ └── Card/
│ │ │ ├── Card.tsx.ejs
│ │ │ └── Card.stories.tsx.ejs
│ │ ├── index.ts
│ │ ├── styles/
│ │ │ └── tailwind.css.ejs
│ │ └── types/
│ │ └── css.d.ts
│ ├── tailwind.config.js.ejs
│ ├── tsconfig.json.ejs
│ └── vite.config.ts.ejs
│
├── tool-lib/ # 工具库模板
│ ├── README.md.ejs
│ ├── src/
│ │ ├── index.ts
│ │ └── utils/
│ │ └── string.ts
│ ├── tsconfig.json.ejs
│ └── vite.config.ts.ejs
│
└── types-lib/ # 类型库模板
├── README.md.ejs
├── src/
│ ├── index.ts
│ └── types/
│ ├── api.ts
│ └── common.ts
├── tests/
│ └── api.test-d.ts
└── tsconfig.json.ejs
2. 特殊文件命名约定
隐藏文件处理
typescript
// src/utils/generate.ts
function getTargetPath(file: string, targetDir: string, options: ProjectOptions): string {
let filename = file;
// 处理点文件(如 .gitignore)
// 模板中命名为 _gitignore.ejs,生成时转为 .gitignore
if (filename.startsWith('_')) {
filename = `.${filename.slice(1)}`;
}
return path.join(targetDir, filename);
}
为什么这样做?
- npm 发布时会忽略
.gitignore
文件 - 使用
_gitignore
绕过这个限制 - 生成项目时再重命名为
.gitignore
3. 条件文件生成
typescript
function getTargetPath(file: string, targetDir: string, options: ProjectOptions): string {
// ...文件名处理...
// 如果选择了Storybook,跳过demo目录
if (filename.startsWith('demo') && options.needDocs) {
return ''; // 返回空字符串表示跳过
}
// 根据样式选择过滤文件
if (filename.endsWith('.less') && !options.styles.includes('less')) {
return '';
}
if (filename.includes('tailwind') && !options.styles.includes('tailwind')) {
return '';
}
// Card组件只在选择了tailwind时生成
if (filename.includes('components/Card') && !options.styles.includes('tailwind')) {
return '';
}
return path.join(targetDir, filename);
}
设计思想:
- 根据用户选择动态生成文件
- 避免生成无用文件
- 保持项目清爽
文件生成机制【核心】
1. 核心生成流程
typescript
// src/utils/generate.ts
export async function generateProject(
targetDir: string,
options: ProjectOptions
): Promise<void> {
// 1. 定位模板目录
const templateDir = path.resolve(
__dirname,
'../../templates',
options.projectType
);
// 2. 确保模板目录存在
if (!fs.existsSync(templateDir)) {
throw new Error(`找不到模板目录:${templateDir}`);
}
// 3. 读取所有模板文件
const templateFiles = await glob('**/*', {
cwd: templateDir,
dot: true,
ignore: ['**/node_modules/**', '**/.git/**']
});
// 4. 准备模板数据
const templateData: TemplateData = {
projectName: options.projectName,
needDocs: options.needDocs,
hasLess: options.styles.includes('less'),
hasTailwind: options.styles.includes('tailwind'),
hasCssModules: options.styles.includes('css-modules'),
year: new Date().getFullYear(),
packageManager: options.packageManager
};
// 5. 遍历并处理每个文件
for (const file of templateFiles) {
const sourcePath = path.join(templateDir, file);
const targetPath = getTargetPath(file, targetDir, options);
// 跳过不需要的文件
if (targetPath === '') continue;
// 处理目录
if (fs.statSync(sourcePath).isDirectory()) {
await fs.ensureDir(targetPath);
continue;
}
// 读取文件内容
const content = await fs.readFile(sourcePath, 'utf8');
// 6. EJS 模板渲染
if (file.endsWith('.ejs')) {
const renderedContent = ejs.render(content, templateData);
// 去掉 .ejs 后缀
await fs.outputFile(
targetPath.replace(/\.ejs$/, ''),
renderedContent
);
} else {
// 非模板文件直接复制
await fs.outputFile(targetPath, content);
}
}
// 7. 生成 package.json
await generatePackageJson(targetDir, options);
}
2. package.json 动态生成
typescript
async function generatePackageJson(
targetDir: string,
options: ProjectOptions
): Promise<void> {
// 基础配置
const packageJson: any = {
name: options.projectName,
version: '0.1.0',
private: false,
scripts: {
dev: 'vite',
build: 'vite build && tsc --emitDeclarationOnly',
lint: 'eslint src --ext .ts,.tsx',
test: 'jest'
},
files: ['dist'],
devDependencies: {
"husky": "^9.0.7",
"typescript": "^5.2.2",
"eslint": "^8.52.0",
"terser": "^5.24.0"
}
};
// 根据项目类型定制
switch (options.projectType) {
case 'component-lib':
// 组件库配置
packageJson.type = 'module';
packageJson.main = './dist/index.js';
packageJson.module = './dist/index.js';
packageJson.types = './dist/index.d.ts';
packageJson.exports = {
'.': {
import: './dist/index.js',
require: './dist/index.cjs'
}
};
packageJson.peerDependencies = {
react: "^18.0.0",
"react-dom": "^18.0.0"
};
packageJson.devDependencies = {
...packageJson.devDependencies,
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.2.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31"
};
// Storybook 支持
if (options.needDocs) {
packageJson.scripts.dev = 'storybook dev -p 6006';
packageJson.scripts.storybook = 'storybook dev -p 6006';
packageJson.scripts['build-storybook'] = 'storybook build';
packageJson.devDependencies = {
...packageJson.devDependencies,
"@storybook/addon-essentials": "^7.5.3",
"@storybook/react": "^7.5.3",
"@storybook/react-vite": "^7.5.3",
"storybook": "^7.5.3"
};
} else {
// 使用 demo 作为开发环境
packageJson.scripts.dev = 'vite demo --open';
}
// 样式依赖
if (options.styles.includes('less')) {
packageJson.devDependencies.less = "^4.2.0";
}
if (options.styles.includes('tailwind')) {
packageJson.devDependencies.tailwindcss = "^3.3.5";
}
break;
case 'tool-lib':
// 工具库配置
packageJson.type = 'module';
packageJson.main = './dist/index.js';
packageJson.module = './dist/index.js';
packageJson.types = './dist/index.d.ts';
packageJson.scripts.dev = 'vite build --mode watch';
packageJson.scripts.docs = 'typedoc --out docs src/index.ts';
packageJson.devDependencies = {
...packageJson.devDependencies,
"vite": "^5.0.0",
"typedoc": "^0.25.3"
};
break;
case 'types-lib':
// 类型库配置
packageJson.scripts.build = 'tsc --emitDeclarationOnly';
packageJson.scripts.dev = 'tsc --emitDeclarationOnly --watch';
packageJson.scripts['test:types'] = 'tsc --noEmit';
packageJson.main = './dist/index.d.ts';
packageJson.types = './dist/index.d.ts';
packageJson.devDependencies = {
"husky": "^9.0.7",
"typescript": "^5.2.2",
"eslint": "^8.52.0",
"tsd": "^0.30.0",
"@types/node": "^20.8.10"
};
break;
}
// 写入文件
await fs.writeFile(
path.join(targetDir, 'package.json'),
JSON.stringify(packageJson, null, 2)
);
}
亮点:
- 动态配置:根据项目类型和用户选择生成不同的依赖和脚本
- 双模式支持:同时支持 ESM 和 CommonJS
- 按需加载:只添加用户选择的功能的依赖
打包与发布
1. Rollup 打包配置
javascript
// rollup.config.js
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import json from '@rollup/plugin-json';
import terser from '@rollup/plugin-terser';
export default {
// 入口文件
input: 'src/index.ts',
// 输出配置
output: [{
dir: 'lib', // 输出目录
format: 'esm', // ES Module 格式
sourcemap: true, // 生成 source map
preserveModules: true, // 保留模块结构
entryFileNames: '[name].js'
}],
// 外部依赖(不打包)
external: [
'fs', 'path', 'os', 'util', 'child_process', // Node.js 内置模块
'commander', 'chalk', 'ora', // CLI 依赖
'@inquirer/prompts', 'fs-extra', 'ejs', // 工具库
'fast-glob', 'execa', 'semver'
],
// 插件
plugins: [
resolve({
extensions: ['.ts', '.js']
}),
commonjs(),
json(),
typescript({
tsconfig: './tsconfig.json',
outputToFilesystem: true
}),
terser() // 压缩代码
]
};
为什么使用 Rollup?
- Tree Shaking:更好的死代码消除
- 模块保留 :
preserveModules: true
保持源码结构 - 更小的包体积:相比 Webpack 更适合库的打包
2. TypeScript 配置
json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext", // Node.js ESM 支持
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"declaration": true, // 生成 .d.ts
"resolveJsonModule": true,
"skipLibCheck": true,
"outDir": "lib", // 输出到 lib 目录
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*", "bin/**/*"],
"exclude": ["node_modules", "src/templates/**/*"]
}
3. npm 发布配置
package.json 关键字段
json
{
"name": "objectx-cli",
"version": "0.1.0",
"type": "module",
"bin": {
"objectx-cli": "./bin/index.js"
},
"files": [
"bin",
"lib",
"templates"
],
"scripts": {
"build": "rollup -c",
"release": "bumpp && npm publish"
},
"keywords": [
"cli", "react", "typescript", "vite",
"component-library", "tooling"
],
"engines": {
"node": ">=16.0.0"
}
}
关键点解析:
-
files
字段:- 只发布
bin
、lib
、templates
目录 - 不发布源码
src/
,减小包体积
- 只发布
-
engines
字段:- 限制 Node.js 版本 >= 16
- 确保 ESM 特性可用
-
keywords
字段:- 提升 npm 搜索排名
发布流程
bash
# 1. 构建
pnpm build
# 2. 版本管理(使用 bumpp)
pnpm release
# bumpp 会自动:
# - 提示选择版本号(patch/minor/major)
# - 更新 package.json
# - 创建 git tag
# - 推送到远程
# - 发布到 npm
后续
好了,这就是一个完整的企业级脚手架搭建流程,后续我还会介绍webpack、vite的实现原理,eslint插件开发,babel实现原理,babel插件开发,感兴趣的可以关注下