前端项目骨架模板化:从 Create React App 到定制化脚手架
CRA 给你一个项目,定制化脚手架给你的是一整个团队的工程共识。
一、场景痛点
新项目启动,npx create-react-app一把梭。然后开始删代码:删 App.css、删 logo.svg、删测试文件。接下来装依赖:axios、react-router、zustand、dayjs、ESLint 全家桶、prettier、husky、lint-staged……最后改 tsconfig.json,改 webpack 配置(用 craco 或 eject)。
一套流程走下来,半天没了。项目跑起来后,你发现和隔壁项目的目录结构、状态管理方案、API 封装方式都不一样。新人入职面对 3 个仓库,3 种写法,3 套规范——这就是"模板缺失综合征"。
本质问题不是 CRA 不好,而是 CRA 解决的是从 0 到 1 的问题,而团队需要的是从 0 到 60 的加速。CRA 给你的是空白画布,但一个成熟团队需要的是带着预设配色、画笔、图层结构的画布——打开就能画。
二、底层机制与原理剖析
2.1 脚手架的分层架构
2.2 模板引擎的核心:EJS / Handlebars 的条件渲染
一个好的脚手架生成的不是死板的模板拷贝,而是根据用户选项动态裁切的代码。核心机制是模板引擎的条件渲染。
{{!-- .template/react-app/package.json.hbs --}} { "name": "{{projectName}}", "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" {{#if useRouter}} ,"react-router-dom": "^6.20.0" {{/if}} {{#if useStateManagement}} ,"zustand": "^4.4.0" {{/if}} {{#if useAntd}} ,"antd": "^5.12.0" {{/if}} }, "devDependencies": { "typescript": "^5.3.0" {{#if useESLint}} ,"eslint": "^8.55.0", "@typescript-eslint/eslint-plugin": "^6.13.0" {{/if}} } }一个模板文件能覆盖几十种项目配置组合,而不是维护几十个模板分支。
2.3 create 命令的完整生命周期
用户执行 create → 收集选项 → 解析模板 → 写入文件 → 安装依赖 → 初始化 Git → 输出提示 │ │ │ │ │ │ │ │ 交互式问答 EJS 渲染 文件 I/O npm/pnpm git init 彩色日志 │ │ │ │ │ │ │ v v v v v v v Commander.js Inquirer Handlebars fs-extra execa/spawn simple-git chalk/ora三、生产级代码实现
3.1 CLI 入口:基于 Commander 和 Inquirer
#!/usr/bin/env node /** * @fe-scaffold 前端项目脚手架 CLI * 使用: npx @team/fe-scaffold create my-app * * 设计原则: * 1. 不生成原始文件,而是从模板仓库拉取 + 条件渲染 * 2. 每次创建后记录模板版本号,支持后续升级 * 3. 所有可选功能通过 inquirer 交互式收集,避免记不住参数 */ const { Command } = require('commander'); const inquirer = require('inquirer'); const path = require('path'); const fs = require('fs-extra'); const ora = require('ora'); const chalk = require('chalk'); const { execa } = require('execa'); const program = new Command(); program .name('fe-scaffold') .description('团队前端项目脚手架:统一初始化、模板管理、漂移检测') .version('2.3.0'); // ========== create 命令 ========== program .command('create <project-name>') .description('从模板创建新项目') .option('-t, --template <name>', '模板名称', 'react-app') .option('-p, --package-manager <pm>', '包管理器', 'pnpm') .action(async (projectName, options) => { const spinner = ora('正在准备脚手架...').start(); try { // Step 1: 检查目标目录 const targetDir = path.resolve(process.cwd(), projectName); if (await fs.pathExists(targetDir)) { spinner.fail(`目录 ${projectName} 已存在`); const { overwrite } = await inquirer.prompt([{ type: 'confirm', name: 'overwrite', message: '是否覆盖已有目录?', default: false }]); if (!overwrite) process.exit(0); await fs.remove(targetDir); } // Step 2: 交互式收集项目配置 spinner.text = '收集项目配置...'; const config = await collectProjectConfig(options); // Step 3: 从模板生成项目 spinner.text = '从模板生成项目文件...'; await scaffoldProject(targetDir, config); // Step 4: 安装依赖 spinner.text = '安装依赖中...'; await installDependencies(targetDir, config.packageManager); // Step 5: 初始化 Git spinner.text = '初始化 Git 仓库...'; await initGit(targetDir); spinner.succeed(`项目 ${projectName} 创建成功!`); // 输出后续操作提示 printNextSteps(projectName, config); } catch (error) { spinner.fail(`创建失败: ${error.message}`); process.exit(1); } }); /** * 收集项目配置:通过交互式问答获取所有选项 * 这里的设计要点是"有意义的默认值"——每个选项都有团队推荐的默认值 * 新手直接回车一路到底,老手通过选项自由组合 */ async function collectProjectConfig(options) { const answers = await inquirer.prompt([ { type: 'list', name: 'template', message: '选择项目模板:', choices: [ { name: 'React + TypeScript (推荐)', value: 'react-app' }, { name: 'React + TypeScript + Admin', value: 'react-admin' }, { name: 'Vue3 + TypeScript', value: 'vue3-app' }, { name: 'Node 微服务', value: 'node-service' }, { name: 'Monorepo (pnpm workspace)', value: 'monorepo' } ], default: options.template || 'react-app' }, { type: 'list', name: 'packageManager', message: '包管理器:', choices: ['pnpm', 'yarn', 'npm'], default: options.packageManager || 'pnpm' }, { type: 'checkbox', name: 'features', message: '选择内置功能:', choices: [ { name: '路由 (react-router-dom v6)', value: 'router', checked: true }, { name: '状态管理 (zustand)', value: 'zustand', checked: true }, { name: 'UI 组件库 (antd)', value: 'antd' }, { name: 'HTTP 客户端 (axios + 拦截器封装)', value: 'axios', checked: true }, { name: '国际化 (react-i18next)', value: 'i18n' }, { name: '单元测试 (vitest + testing-library)', value: 'test' }, { name: 'PWA 支持', value: 'pwa' }, { name: 'Mock 服务 (MSW)', value: 'msw' } ] }, { type: 'confirm', name: 'useGitHooks', message: '是否启用 Git Hooks?(husky + lint-staged + commitlint)', default: true }, { type: 'confirm', name: 'useDocker', message: '是否生成 Dockerfile 和 docker-compose?', default: false }, { type: 'input', name: 'description', message: '项目描述:', default: 'A frontend project' } ]); return { ...answers, projectName: options.projectName }; } /** * 核心生成逻辑:模板渲染 + 条件文件生成 * * 关键设计: * 1. 模板存储在脚手架内部的 .template/ 目录,随 npm 包发布 * 2. 使用 EJS 做条件渲染,一个模板文件支持所有功能组合 * 3. 只拷贝被选中的功能相关文件,不生成冗余代码 */ async function scaffoldProject(targetDir, config) { const templateDir = path.resolve(__dirname, '..', '.template', config.template); // 核心模板文件(始终存在) const coreFiles = [ 'package.json', 'tsconfig.json', 'vite.config.ts', 'src/main.tsx', 'src/App.tsx', 'src/vite-env.d.ts', 'index.html', '.gitignore', '.env', '.env.development' ]; // 根据用户选择,映射到对应的模板文件 const featureFileMap = { router: ['src/router/index.tsx', 'src/pages/Home.tsx'], zustand: ['src/stores/index.ts'], antd: ['src/styles/theme.ts'], axios: ['src/utils/request.ts', 'src/api/index.ts'], i18n: ['src/i18n/index.ts', 'src/i18n/zh-CN.json', 'src/i18n/en-US.json'], test: ['src/__tests__/App.test.tsx', 'vitest.config.ts'], pwa: ['public/sw.js', 'public/manifest.json'], msw: ['src/mocks/handlers.ts', 'src/mocks/browser.ts', 'src/mocks/server.ts'] }; // 渲染核心文件 const Handlebars = require('handlebars'); for (const relativePath of coreFiles) { const templatePath = path.join(templateDir, `${relativePath}.hbs`); if (await fs.pathExists(templatePath)) { const templateContent = await fs.readFile(templatePath, 'utf-8'); const compiled = Handlebars.compile(templateContent); const rendered = compiled({ projectName: config.projectName, description: config.description, useRouter: config.features.includes('router'), useZustand: config.features.includes('zustand'), useAntd: config.features.includes('antd'), useI18n: config.features.includes('i18n'), // ... 更多变量注入 }); const outputPath = path.join(targetDir, relativePath); await fs.ensureDir(path.dirname(outputPath)); await fs.writeFile(outputPath, rendered, 'utf-8'); } } // 条件渲染功能文件(只在选中时才生成) for (const feature of config.features) { const featureFiles = featureFileMap[feature] || []; for (const relativePath of featureFiles) { const templatePath = path.join(templateDir, `${relativePath}.hbs`); if (await fs.pathExists(templatePath)) { const templateContent = await fs.readFile(templatePath, 'utf-8'); const rendered = Handlebars.compile(templateContent)(config); const outputPath = path.join(targetDir, relativePath); await fs.ensureDir(path.dirname(outputPath)); await fs.writeFile(outputPath, rendered, 'utf-8'); } } } // 非模板文件直接拷贝(如 CSS 文件、图片等) const staticFiles = ['src/styles/global.css', 'public/favicon.ico']; for (const relativePath of staticFiles) { const sourcePath = path.join(templateDir, relativePath); if (await fs.pathExists(sourcePath)) { await fs.copy(sourcePath, path.join(targetDir, relativePath)); } } // 写入模板元数据文件(用于后续版本管理和漂移检测) const meta = { template: config.template, version: require('../package.json').version, features: config.features, createdAt: new Date().toISOString(), packageManager: config.packageManager }; await fs.writeJSON(path.join(targetDir, '.scaffold.json'), meta, { spaces: 2 }); return { coreFiles, featureFiles: config.features.length }; } /** * 安装依赖:使用 execa 保证跨平台兼容 * 记录安装耗时,便于后续对比不同包管理器的性能 */ async function installDependencies(targetDir, packageManager) { const pmCommand = { pnpm: 'pnpm', yarn: 'yarn', npm: 'npm' }[packageManager] || 'pnpm'; const startTime = Date.now(); await execa(pmCommand, ['install'], { cwd: targetDir, stdio: 'inherit', // 把安装日志直接输出 env: { ...process.env, ADBLOCK: '1', DISABLE_OPENCOLLECTIVE: '1' } }); const elapsed = Date.now() - startTime; console.log(chalk.gray(` 依赖安装耗时: ${(elapsed / 1000).toFixed(1)}s`)); } async function initGit(targetDir) { await execa('git', ['init'], { cwd: targetDir }); await execa('git', ['add', '-A'], { cwd: targetDir }); await execa('git', ['commit', '-m', 'chore: init project from scaffold'], { cwd: targetDir, env: { ...process.env, GIT_AUTHOR_NAME: 'scaffold', GIT_AUTHOR_EMAIL: 'scaffold@team.dev' } }); } function printNextSteps(projectName, config) { console.log(''); console.log(chalk.cyan(' 后续操作:')); console.log(chalk.gray(` cd ${projectName}`)); console.log(chalk.gray(` ${config.packageManager} run dev`)); console.log(''); if (config.features.includes('test')) { console.log(chalk.gray(` ${config.packageManager} run test`)); } console.log(chalk.gray(` ${config.packageManager} run build`)); console.log(''); console.log(chalk.yellow(` 📌 已启用功能: ${config.features.join(', ') || '无'}`)); console.log(chalk.yellow(` 📌 模板版本已记录在 .scaffold.json`)); } program.parse();3.2 漂移检测:确保项目与模板同步
/** * drift-check 命令:检测项目是否偏离了模板规范 * 使用场景:模板版本升级后,检查已有项目是否兼容 * * 检测维度: * 1. 关键配置文件是否被修改(package.json scripts、tsconfig、ESLint config) * 2. 目录结构是否符合团队约定 * 3. 依赖版本是否在允许范围内 */ program .command('drift-check') .description('检测项目与模板的漂移') .action(async () => { const meta = await fs.readJSON('.scaffold.json').catch(() => null); if (!meta) { console.log(chalk.red('❌ 当前项目不是通过脚手架创建的')); process.exit(1); } // 检查目录结构规范性 const requiredDirs = ['src/pages', 'src/components', 'src/utils', 'src/api']; const missingDirs = []; for (const dir of requiredDirs) { if (!await fs.pathExists(dir)) { missingDirs.push(dir); } } // 检查配置完整性 const requiredConfigs = ['tsconfig.json', 'eslint.config.js', '.prettierrc']; const missingConfigs = []; for (const cfg of requiredConfigs) { if (!await fs.pathExists(cfg)) { missingConfigs.push(cfg); } } // 输出检测报告 console.log(chalk.cyan('\n📊 漂移检测报告')); console.log(chalk.gray(` 模板版本: ${meta.version}`)); console.log(chalk.gray(` 创建时间: ${meta.createdAt}`)); console.log(chalk.gray(` 原始功能: ${meta.features.join(', ')}`)); if (missingDirs.length > 0) { console.log(chalk.yellow(`\n ⚠️ 缺失目录: ${missingDirs.join(', ')}`)); } if (missingConfigs.length > 0) { console.log(chalk.yellow(` ⚠️ 缺失配置: ${missingConfigs.join(', ')}`)); } if (missingDirs.length === 0 && missingConfigs.length === 0) { console.log(chalk.green('\n ✅ 项目结构完整,无漂移')); } });3.3 模板升级:一键同步最新骨架
/** * upgrade 命令:将现有项目升级到最新模板版本 * 采用三路合并策略:原始模板 → 当前项目 → 新模板 * 冲突文件标记为 .conflict,需要开发者人工处理 */ program .command('upgrade') .description('升级项目到最新模板版本') .option('--dry-run', '预览变更,不实际修改文件') .action(async (options) => { const meta = await fs.readJSON('.scaffold.json'); const currentVersion = meta.version; const latestVersion = require('../package.json').version; if (currentVersion === latestVersion) { console.log(chalk.green('✅ 已是最新版本')); return; } console.log(chalk.cyan(`📦 升级 ${currentVersion} → ${latestVersion}`)); // 获取两个版本间的差异文件列表 const diffFiles = await getDiffFiles(meta.template, currentVersion, latestVersion); if (options.dryRun) { console.log(chalk.gray('\n 变更预览:')); diffFiles.forEach(f => console.log(chalk.gray(` ${f.action}: ${f.path}`))); return; } // 三路合并核心逻辑 for (const file of diffFiles) { const originalContent = await getTemplateFile(meta.template, currentVersion, file.path); const currentContent = await fs.readFile(file.path, 'utf-8').catch(() => null); const newContent = await getTemplateFile(meta.template, latestVersion, file.path); // 如果当前项目没改过,直接更新 if (currentContent === originalContent) { await fs.outputFile(file.path, newContent); console.log(chalk.green(` ✅ ${file.path}`)); } else { // 三路合并 const merged = threeWayMerge(originalContent, currentContent, newContent); await fs.outputFile(file.path, merged); console.log(chalk.yellow(` ⚠️ ${file.path} (已合并,请检查)`)); } } });四、边界分析与架构权衡
4.1 模板粒度:大而全 vs 小而精
大而全模板:一个模板包含路由、状态管理、UI 库、国际化、测试……好处是功能齐全、减少后期决策;坏处是强耦合、升级困难、非必要功能成为认知负担。
小而精模板:每个模板只解决一个问题,通过组合叠加。好处是灵活、解耦;坏处是组合爆炸、用户需要理解每个模板的边界。
我们选折中:一个核心模板(react-app),通过features选项按需添加功能。用户只需在一张清单上勾选,不需要知道背后有多少个模板。
4.2 模板更新策略的陷阱
模板升级最大的坑是用户改了模板生成的代码后不敢升级。解决方案:
- 区分"模板代码"和"用户代码":通过注释标记
// @scaffold:managed和// @scaffold:custom - 模板代码走三路合并,用户代码只读不写
- 如果合并冲突,保留双方代码,标记为
.conflict
4.3 CRA vs Vite 的选择
在脚手架里默认用 Vite 而不是 CRA。原因:
- CRA 维护停滞,webpack 配置对新手是黑盒
- Vite 的 dev server 启动速度是 webpack 的 10-30 倍
- Vite 的插件生态已经成熟,Rollup 构建输出更可控
但 Vite 的问题在于 SSR 支持不如 Next.js 成熟,如果你的团队重度依赖 SSR,应该考虑 Next.js 模板而非 CRA/Vite。
五、总结
一个好的脚手架不只是生成文件,它是团队工程共识的载体:
- 目录结构 = 架构师对模块边界的理解
- ESLint 配置 = 团队对代码风格的共识
- 状态管理方案 = 技术 leader 对复杂度的控制
- Git Hooks = 对质量底线的坚守
"Create React App 不够用"是表象,本质原因是你的团队需要的不只是一个空白项目,而是一套已经被验证过的工程标准。脚手架的作用就是把隐形知识显性化——把架构师脑子里的"最佳实践"变成每一个新项目的默认配置。
记住三个原则:
- 默认值要激进:默认开启你推荐的所有功能,降低关闭门槛(checkbox 勾掉),而不是反过来
- 模板要版本化:每个项目记录创建时的模板版本,才有升级的可能
- 漂移要可视化:不让项目在不知不觉中偏离规范,
drift-check是刚需不是绣花
花一周写好脚手架,未来上百个项目省下的是几百个小时的重复劳动。