一直觉得,前端项目初始化这件事,不应该靠肌肉记忆。上个月连续往三个新仓库里搬运同样的配置文件,每次都要手动改包名、改端口、删掉用不到的示例代码,终于把我搞烦了。于是花了两个多小时,写了一个前端简易脚手架,专门干“问几个问题→生成项目骨架→装依赖”这件事。现在回头看,它谈不上高级,但确实让新项目落地从“一上午”压缩到了“一杯咖啡”。这篇文章就把我当初的设计思路、完整实现和踩过的坑都整理出来。不管你是想摆脱复制粘贴的日常,还是想给团队沉淀内部工具,都可以拿来参考。我用的技术栈只有 Node.js 和四个常用命令行工具,不涉及任何复杂框架,只要你能看懂 JavaScript 和基本的终端操作,就能跟上。
1. 内容整体设计与思路拆解
1.1 自建脚手架,不是为了造轮子
大多数人第一反应是:“这不已经有现成的脚手架工具了吗?”确实有,但实际用起来你会发现三个痛点:第一,官方模板是个“标准货”,出来之后还得手动改成团队约定,比如统一用 Vite、统一装 ESLint 配置、统一状态管理方案;第二,模板升级往往牵一发动全身,哪天官方更新了一个依赖,你所有老项目都会被影响,测试成本不低;第三,模板里塞了一堆你用不到的东西,生成后第一件事反而是删。
自建脚手架本质上是把“固定技术栈 + 团队规范”固化成一套模板,生成项目时把变量填进去。你可以把它想象成订做西装而不是买成衣:成衣便宜但穿起来并不合身,订做一开始费点功夫,但之后每次穿都舒服。所以这不是为了造轮子,而是为了让你正在用的“那一个轮子”更合脚。
1.2 明确“脚手架要做什么,不做什么”
我一开始非常容易陷入一种“万能工具病”,总想做一个什么都能干的脚手架。但冷静下来之后,我给这个工具划了一条清晰的功能边界。
它要做的只有四件事:
- 接收命令行参数:项目名、模板类型、包管理器。
- 交互式提问:项目描述、作者、是否自动安装依赖。
- 生成项目目录:把模板文件复制过去,并把其中的变量替换成用户输入。
- 安装依赖:在生成目录里执行对应包管理器的 install 命令。
它不做的其实也很明确:不做构建、不做热更新、不做代码生成器、不提供网页管理界面。脚手架的生命周期只有项目初始化那几十秒,做得太重,本身就是一种浪费。很多工具死于功能膨胀,而不是死于功能太少。
1.3 技术选型与“为什么够用了”
技术选型上我选择了 Node.js,配合四个非常轻量的包:commander 负责命令行参数解析,inquirer 负责交互式提问,handlebars 负责模板渲染,chalk 负责在终端里输出带颜色的提示。有人可能会问,为什么不用更流行或更强大的工具?答案很简单:依赖越少,安装越快,维护成本越低。脚手架本身非常短命,用户启动完就再也不会运行它,没必要为了一场十分钟的初始化去搭建一个“五星级厨房”。
四个包的组合已经能覆盖 90% 的需求:参数解析是入口,交互问答是体验,模板渲染是核心,颜色输出是锦上添花。模块之间各司其职,没有任何重叠。
1.4 目录结构先设计好,后续才不会乱
一个好的脚手架项目,目录结构必须一眼就能看清职责。我最后定下来的结构是:
my-create-app/ bin/ index.js lib/ create.js prompts.js render.js install.js templates/ react/ vue/ plain/ package.jsonbin 目录放命令入口,lib 目录放核心业务逻辑,templates 目录放不同技术栈的模板文件。这样的好处是:你加一个新模板,只需要在 templates 下新建一个文件夹,完全不用动主流程代码。做一个“模板即插即用”的脚手架,听起来很高级,其实目录设计对了,自然就做到了。
2. 核心细节解析与实操要点
2.1 package.json 的 bin 配置与 shebang 首行
要让用户安装后能在终端直接输入my-create-app执行,第一步是在 package.json 里配置 bin 字段:
{ "name": "my-create-app", "version": "0.1.0", "bin": { "my-create-app": "./bin/index.js" } }然后在bin/index.js的第一行写上:
#!/usr/bin/env node这行叫 shebang,作用是从环境变量里找到 node 解释器来运行这个脚本。这样在 Unix 系统中,系统才知道要用哪个程序去执行它。Windows 不依赖 shebang,但保留这一行不会出错,而且可以做到跨平台一致。
还有一个容易忽略的细节:在 Unix 系统下,需要给脚本加上执行权限,否则可能报 “Permission denied”。在项目目录里执行chmod +x bin/index.js就可以了。
2.2 命令行参数这样设计,脚本和交互才不会打架
我用 commander 来解析命令行参数。设计上我遵循一个原则:能在命令行直接指定,就不非要在交互里再问一遍。这样既能支持全自动脚本,也保留了手动交互的灵活性。
入口代码大概是这样的:
#!/usr/bin/env node const { program } = require('commander'); const create = require('../lib/create'); program .name('my-create-app') .description('一个极简前端项目脚手架') .version('0.1.0') .argument('[project-name]', '项目名称') .option('-t, --template <template>', '模板名称', 'react') .option('--skip-install', '跳过依赖安装') .option('--force', '如果目录已存在则强制执行') .action((projectName, options) => { create({ projectName, ...options }); }); program.parse(process.argv);注意,project-name我用的是方括号而不是尖括号,表示它是可选的。如果用户在命令行里漏了项目名,后面在交互问答里会再补一次。这样既不会打断全自动脚本,也不会让新手直接卡死。
2.3 交互问答要克制,只问影响项目“基因”的问题
交互提示是最容易做多的地方。有些人会把十几个问题全塞给用户,比如要不要 TypeScript、要不要 ESLint、要不要 Prettier、要不要单元测试……我自己的建议是,问题越少越好。初始化项目时用户大概率只关心几个点:项目叫什么、干什么用、用哪套模板、装不装依赖。
所以我只保留这几个问题,并用 inquirer 实现:
const inquirer = require('inquirer'); async function promptForMissing(options) { const questions = []; if (!options.projectName) { questions.push({ type: 'input', name: 'projectName', message: '项目名称', validate: (v) => (v && v.length > 0 ? true : '项目名称不能为空'), }); } questions.push({ type: 'input', name: 'description', message: '一句话介绍项目', default: 'A new frontend project', }); questions.push({ type: 'list', name: 'template', message: '选择模板', choices: ['react', 'vue', 'plain'], default: options.template || 'react', }); if (!options.skipInstall) { questions.push({ type: 'confirm', name: 'install', message: '是否自动安装依赖?', default: true, }); } const answers = await inquirer.prompt(questions); return { ...options, ...answers }; } module.exports = { promptForMissing };这段代码的关键是...options和...answers的合并顺序:用户通过命令行参数传入的值是最优先的,只有在命令行没提供时才用交互结果覆盖,避免两者冲突。
2.4 Handlebars 渲染中的转义和分隔符问题
模板渲染我使用 handlebars。它基本的用法是把{{ projectName }}这类占位符替换成实际值。但有一个非常经典的坑:handlebars 默认会做 HTML 转义。如果你在模板里写{{ description }},当 description 含有引号或特殊字符时,生成出来的 package.json 会因为转义问题变成一个坏 JSON。
解决办法是在需要保留原始内容的地方使用三花括号{{{ description }}}。三花括号告诉 handlebars:不要转义,原样输出。这一点在处理 JSON、脚本文件和配置文件时非常重要。
还有一个更大的坑是模板里本身就含有双花括号。举个例子,Vue 模板里到处都是{{ user.name }},如果整个文件都走 handlebars 渲染,这些原本是框架语法的内容就会被 handlebars 当成变量尝试解析,结果可能变成空字符串,甚至直接报错。我的方案是:只在文件名以.hbs结尾的文件上做 handlebars 渲染,渲染完成后再把.hbs后缀去掉,其余文件原样复制。这样 Vue 模板里的双花括号完全不会被 handlebars 碰触,安全又省心。
2.5 文件复制不要踩二进制文件的坑
复制文件这件事看起来简单,其实很容易翻车。如果模板目录里有 favicon、logo 或者字体这类二进制文件,你不能用读字符串的方式去复制,否则文件会被破坏。一个稳妥的做法是:非.hbs文件一律用fs.copyFile,按二进制方式完整复制;只有.hbs文件才使用 utf8 读取并交给 handlebars 渲染。这样二进制文件永远不会被错误地“解码”成文本。
2.6 错误处理要像给用户“安全感”
命令行工具最怕用户在一个黑框里等半天,然后毫无反馈地退出。我的习惯是,每一步都输出当前状态:正在复制模板、正在安装依赖、完成之后要怎么启动项目。一旦遇到错误,比如模板不存在、目录已存在、安装失败,都要清晰打印原因并给出下一步建议。这不仅是用户体验问题,更是排查问题的效率问题。
3. 实操过程与核心环节实现
3.1 一分钟初始化脚手架工程
创建一个脚手架的脚手架,本身也就是一个 Node.js 项目。我在终端里执行:
mkdir my-create-app && cd my-create-app npm init -y npm install commander inquirer handlebars chalk mkdir -p bin lib templates/react/public templates/react/src这里不需要手动创建太多文件,我后面会逐步把代码写进去。装依赖的时候注意,commander 的最新大版本 API 和旧版略有差别,我下面的示例代码是以 commander v12 为基础的。如果你用的是更早的版本,可能需要把.argument()改成.arguments()。
3.2 做一个最小可用模板
模板是整个脚手架的“灵魂”。我以 React 模板为例,里面至少要有 package.json、入口 HTML、入口 JS 和 .gitignore。模板中凡是需要动态替换的文件,我用.hbs后缀命名,比如templates/react/package.json.hbs,内容如下:
{ "name": "{{ projectName }}", "version": "0.1.0", "description": "{{{ description }}}", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@vitejs/plugin-react": "^4.0.0", "vite": "^4.4.0" } }同时,我在templates/react/.gitignore里直接写了一个普通文件,不用.hbs后缀,因为它不需要被渲染。生成的目录里会自动带上.gitignore,直接初始化 git 仓库就会忽略 node_modules 这些目录,非常方便。
3.3 写核心生成逻辑,让整套流程跑通
接下来是脚手架的发动机:递归复制目录和渲染模板。我实现了一个render.js,核心逻辑只有几十行:
const fs = require('fs/promises'); const path = require('path'); const Handlebars = require('handlebars'); async function copyDir(src, dest, data) { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await copyDir(srcPath, destPath, data); } else if (path.extname(entry.name) === '.hbs') { const content = await fs.readFile(srcPath, 'utf8'); const template = Handlebars.compile(content); const target = path.join(dest, entry.name.replace(/\.hbs$/, '')); await fs.writeFile(target, template(data), 'utf8'); } else { await fs.copyFile(srcPath, destPath); } } } module.exports = { copyDir };递归遍历目录时,遇到普通文件直接复制,遇到.hbs文件就渲染后写入,并把目标文件名里的.hbs后缀去掉。这个写法的好处是,模板目录结构与最终项目结构保持一致,你不需要额外维护一份“文件映射表”。
然后写install.js,负责安装依赖。这里有一个跨平台细节:在 Windows 下,npm 的真实可执行文件名是npm.cmd,如果直接用spawn('npm', ...)会报ENOENT。所以正确的做法是:
const { spawn } = require('child_process'); const path = require('path'); async function installDeps(targetDir) { const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; return new Promise((resolve, reject) => { const child = spawn(npmCmd, ['install'], { cwd: targetDir, stdio: 'inherit', }); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`npm install 退出码: ${code}`)); } }); }); } module.exports = { installDeps };最后把整个流程串到create.js里:
const fs = require('fs/promises'); const path = require('path'); const { promptForMissing } = require('./prompts'); const { copyDir } = require('./render'); const { installDeps } = require('./install'); async function create(options) { try { const answers = await promptForMissing(options); const targetDir = path.resolve(process.cwd(), answers.projectName); const templateDir = path.resolve(__dirname, '../templates', answers.template); await fs.access(templateDir).catch(() => { console.error(`模板不存在: ${answers.template}`); process.exit(1); }); const dirExists = await fs.access(targetDir).then(() => true).catch(() => false); if (dirExists && !answers.force) { console.error(`目录 ${targetDir} 已存在,加 --force 可覆盖`); process.exit(1); } await copyDir(templateDir, targetDir, answers); console.log(`项目已生成到 ${targetDir}`); if (!answers.skipInstall && answers.install !== false) { await installDeps(targetDir); } console.log(`完成!下一步: cd ${answers.projectName} && npm run dev`); } catch (err) { console.error('创建失败:', err.message); process.exit(1); } } module.exports = create;这个流程把安全考虑放在很靠前的位置:先校验模板是否存在,再检查目标目录是否已存在。默认情况下,只要目标目录存在就直接退出,防止覆盖用户已有文件。用户只有在明确加--force时才允许继续,而且代码里也没有直接删除目录,而是把原有内容和模板内容混在一起。实际使用中我更推荐这种做法,毕竟一键删除目录这种危险操作,能不做就不做。
3.4 本地调试:让命令在你的机器上跑起来
代码写完,先在本地试一遍。在脚手架项目根目录执行:
npm link这个命令会把my-create-app这个命令链接到全局,相当于模拟“已安装”的状态。然后我去一个临时目录,运行:
my-create-app demo --template react --skip-install如果不报错,并且demo目录里生成了完整的 React 项目,说明整条链路已经通了。这里有一个容易被忽略的点:npm link是软链接,项目里的代码修改会实时生效,不需要每次都重新 link。但如果你改了 package.json 里的bin字段,或者新增了 npm 依赖包,那就需要重新 link 一次,否则全局命令可能还是旧的。
3.5 发布与团队内复用
本地调试没问题后,就可以考虑发布。发布前有一个细节非常关键:如果 package.json 里没有配置files字段,npm 会把整个项目目录都打进去,包括你本地的调试笔记,甚至 node_modules。这不是我们想要的。正确的做法是明确指定要发布的目录:
{ "files": ["bin", "lib", "templates"] }这样发布包只会包含入口代码、核心逻辑和模板文件,体积小,内容干净。之后执行npm publish,另一个同事就可以直接npm install -g my-create-app使用了。如果有私有 npm 源,也可以把发布目标切换到私有源,团队内部使用会更安全可控。
4. 常见问题与排查技巧实录
4.1 命令找不到怎么办
新手遇到最多的就是执行my-create-app提示 “command not found”。排查顺序先看环境,再看代码:
- 用
npm list -g --depth=0确认包是否真的全局安装。 - 打开 package.json,确认
bin字段是否写对了层级,它必须和name、version同级。 - 检查入口文件第一行是不是
#!/usr/bin/env node。 - 在项目根目录执行
node bin/index.js,如果这一步报错,说明问题出在代码逻辑而不是全局安装。
我踩过一次很隐蔽的坑:项目刚开始叫my-cli,bin 里也是my-cli,后来我改了项目名和 bin 字段,但忘了重新npm link,导致全局命令一直指向旧名字。所以,只要改了 package.json 里和命令相关的字段,记得重新 link 一次。
4.2 生成目录里全都是 .hbs 文件
如果你一开始的复制逻辑是把所有文件都当作“潜在模板”去处理,那很容易出现生成之后的文件后缀依然是.hbs的情况。比如目标是package.json.hbs,但因为渲染逻辑没有覆盖到,就直接复制了原文件。解决方法是把“渲染”和“复制”两种行为分开:只有.hbs文件走渲染引擎,其他文件一律复制。这样既不会出现多余后缀,也能避免误伤普通文件。
4.3 Windows 下安装依赖失败
最常见的一个坑是spawn npm ENOENT。原因前面也提到过,在 Windows 下 npm 的可执行文件名是npm.cmd,不是npm。直接在 child_process 里用npm去 spawn 就会找不到文件。解决办法就是判断平台,Windows 时换成npm.cmd。另外,如果你要执行的是npm run dev这种命令,建议使用spawn(npmCmd, ['run', 'dev'], { shell: true }),避免跨平台兼容问题。
4.4 Vue/React 模板里的双花括号被“吃掉”
这是我强烈建议所有脚手架都要提前规避的坑。如果你把整个.vue或.jsx文件直接交给 handlebars 渲染,那么模板代码里的{{ user.name }}、{{ count }}会被当成 handlebars 表达式解析,轻则输出空值,重则直接让模板编译失败。我的方案前面已经说过:模板文件使用.hbs后缀标记“需要渲染”,其余都复制。这样 Vue 模板、React 模板里的框架语法都安然无恙。如果某个.hbs文件里确实需要原样输出双花括号,可以在花括号前面加反斜杠转义,写成\{{ message }}。
4.5 生成目录被覆盖后的“灾难”现场
有些脚手架为了追求“一键体验”,在用户加--force之后直接rm -rf目标目录,然后重新生成。这种设计我强烈不建议,因为用户很可能只是手滑,或者目标目录里保存着本地还没提交的代码。我的建议是:即使加了--force,也只是把模板文件“合并”进现有目录,而不是直接删除。如果用户真的想要一个全新的项目,让他们自己手动删除再执行,或者先备份到一个临时目录。命令行工具要谨慎,少做危险操作,这是我对自己的要求。
4.6 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 全局执行命令 not found | bin 字段缺失或未 link | 检查 package.json bin,重新 npm link |
| 生成文件带 .hbs 后缀 | 复制逻辑没有区分渲染文件 | 只对 .hbs 文件渲染,其他直接复制 |
| Windows 下安装依赖失败 | 使用了npm而不是npm.cmd | 平台判断后使用正确命令 |
模板里的{{ }}被解析 | handlebars 处理了模板源文件 | 非 .hbs 文件不要进入渲染流程 |
| 目录已存在但想继续 | 缺少覆盖策略 | 增加 --force,但建议合并而不删除 |
| 发布包过于臃肿 | 未配置 files 字段 | 只发布 bin、lib、templates 等必要目录 |
5. 从简易到进阶的扩展思路
5.1 多模板支持很简单
现在 templates 下已经有 react、vue、plain 三个目录,加新模板就是在 templates 里再放一个新文件夹。如果你希望多个模板共享一组公共文件,比如团队统一的 ESLint 配置、Prettier 配置,可以考虑先把公共文件复制到目标目录,再复制具体模板文件,后复制的覆盖先复制的同名文件。这样公共配置只需要维护一份。
5.2 远程模板仓库
团队内部把模板放到一个 git 仓库,脚手架在执行时先拉取远程仓库到本地缓存目录,再执行复制。这个做法的好处是模板更新之后,团队所有成员拿到的是最新版;但要注意缓存过期问题。一般可以引入一个本地版本号文件,每次创建前比较远程版本号,和npm update的机制类似。这个扩展对团队发展期非常有用,但需要额外处理网络和缓存问题。
5.3 用配置文件替代硬编码问题
目前模板的变量只有项目名和描述,问题列表还写在代码里。如果你想要一个更通用的脚手架,可以在每个模板目录里放一个config.json,描述这个模板支持哪些变量、需要哪些交互问题。脚手架启动时读取模板的 config,动态生成 inquirer 问题列表。这样增加一个新模板,你不再需要改主流程代码,模板作者只需要维护自己的 config 和模板文件即可。
5.4 插件化生命周期
更进阶的做法是提供“生成前”“生成后”“安装依赖后”这些生命周期钩子。团队可以针对自己的场景写插件,比如生成后自动初始化 git 仓库、自动创建远程分支、自动写入本地环境变量。不过我不建议一开始就上插件系统,生命周期和插件机制本身需要搭一套框架,复杂度会明显上升。稳妥的顺序是先跑通最小可用版本,等到确实有多个团队都提出类似需求,再考虑抽象。
最后说点我自己的体会吧。如果让我重做一遍,我不会从一开始就上远程模板、插件系统,而会把“生成一个可运行的 React 项目”这一个小目标先做扎实。你先亲手用两次,把团队里大家都会踩的坑写进模板,再迭代功能。脚手架这类工具,最难的不是技术,而是你真的知道团队需要什么。所以第一版越朴素,后面反而越好用。等你真正用三五个项目之后,自然就感受到哪里是该优化的地方,那时候再去调整模板和流程,才是团队真正需要的工具。