前端项目骨架模板化:从 Create React App 到定制化脚手架
前端项目骨架模板化从 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: scaffoldteam.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模板代码走三路合并用户代码只读不写如果合并冲突保留双方代码标记为.conflict4.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是刚需不是绣花花一周写好脚手架未来上百个项目省下的是几百个小时的重复劳动。