首页 / 资讯中心 / 文章详情

从零手写前端简易脚手架:原理、实现与工程化实践

从零手写前端简易脚手架:原理、实现与工程化实践 ★ FEATURED ARTICLE
做了这么多年前端我越来越觉得“脚手架”这件事被太多人想复杂了。一提到脚手架大家首先想到的是各种重型CLI但你有没有遇到过这种尴尬用官方脚手架初始化项目装了一堆根本用不上的依赖想按团队规范调整目录结构又得去翻源码改配置甚至还要eject。前端简易脚手架搭建这件事恰恰就是为了解决“新项目初始化时不断复制粘贴、东改西改”这个最普遍的痛点。这篇内容主要讲怎么从零手写一个轻量、够用的前端脚手架不依赖那些重量级框架工具。我会把原理、完整实现、以及我在实际使用中踩过的那些坑都讲清楚。不管你是前端新手想搞懂脚手架背后的机制还是带团队想做内部工程化基建这篇都值得看看。相信我自己写一个脚手架之后你对项目初始化、模板渲染这些事的理解会完全不一样。1. 放着现成脚手架不用为什么非要自己写一个很多人的第一反应是有必要吗社区里有那么多现成的脚手架create系列或者各类generator拿过来用不就行了我在前两年也是这么想的直到有一次团队里密集地开了几个新项目我发现大家做初始化这件事的方式高度统一找老项目复制一份然后开始删代码。删路由、删页面、删依赖、改项目名、改标题、换图标。运气好十分钟搞定运气不好某个配置文件里残留了上个项目的痕迹排查起来要命。1.1 现成脚手架解决不了团队规范问题官方脚手架解决的是“从零到一个能跑的项目”的问题但团队真正需要的是“从零到一个符合我们规范的项目”。这两者差距非常大。举个例子。官方脚手架生成的项目目录结构是它自己定义的代码风格是它默认的依赖版本是它固定的。如果你团队用的是别致的lint规则、特殊的目录分层、统一的提交规范那每次初始化之后仍然要手工调整一大堆东西。更麻烦的是一旦官方脚手架调整了模板结构你团队里的老司机还好新来的同事照着文档生成的项目可能跟你们现有项目的组织方式完全对不上。这种情况下一个几十行代码的简易脚手架价值反而比那些大而全的方案要高得多。因为它把“规范”固化在了模板里而不是停留在口头约定或者文档里。用脚手架生成的项目天然就是符合规范的不需要人再去记那些约定。1.2 自己搭脚手架的真实成本没那么高很多人不想自己搭是觉得复杂度高。但实际上一个能用起来的简易脚手架核心代码可能也就一两百行。它不需要涉及复杂的算法也不需要做包发布只需要做好三件事接收参数、收集配置、把模板内容渲染后写入目标目录。这三件事分别对应三个非常成熟的库commander处理命令行参数inquirer处理交互式提问ejs做模板渲染。剩下的就是Node.js本身的文件操作能力。我甚至见过有人用Shell脚本加sed替换实现过一版也跑得挺好。如果你正在做前端工程化相关的事情或者团队里有重复性较高的项目初始化工作自己搭一个脚手架的成本远比你想象的低但收益是长期的。下面这张表是我当时对比几种方案之后的判断方案初始化速度定制灵活度维护成本规范收拢能力官方脚手架快低改配置困难跟随上游版本被动弱各项目自行调整手工复制老项目中清理耗时高但易出错无但隐患多极弱容易跑偏自建简易脚手架快高模板自己说了算低模板即代码强规范固化在模板1.3 搭建脚手架的额外价值理解工程化底层逻辑抛开实用价值不谈实现一个脚手架本身也是一次特别好的Node.js命令行编程实践。它会逼着你接触命令行参数解析、进程调用、文件系统递归操作、模板引擎渲染这些基础知识。这些知识平时在业务开发里未必用得到但理解了之后你再去看那些大型脚手架工具的实现原理会发现其实都是同一个套路只是多了些插件机制和远程拉取模板的能力。所以关于“为什么自己写”我的结论是它不是用来替代官方脚手架的而是用来补充它们覆盖不到的团队级初始化需求的。简易脚手架重点在“简易”但解决的是“标准”的问题。2. 脚手架的本质模板、配置和文件生成的三层结构想清楚原理再动手比直接抄代码重要得多。脚手架拆开来看就是三样东西一份模板、一组配置、一段生成逻辑。2.1 模板目录项目骨架的完整拷贝脚手架的核心资产是模板目录。你希望新项目长什么样就把它先做成一棵树放在template目录下面。比如我维护的模板结构大概是这样的template/ ├── src/ │ ├── api/ │ ├── assets/ │ ├── components/ │ ├── pages/ │ ├── router/ │ ├── store/ │ ├── styles/ │ ├── utils/ │ ├── App.ejs │ └── main.ejs ├── .eslintrc.ejs ├── .prettierrc.ejs ├── .gitignore ├── index.html.ejs ├── package.json.ejs └── vite.config.ejs注意这里很多文件带了.ejs后缀。这代表着这个文件是“模板文件”里面会有需要被替换的占位内容。比如package.json.ejs里面会有类似这样的写法{ name: % projectName %, version: 0.1.0, description: % projectDescription %, scripts: { dev: vite --port % devPort %, build: vite build, lint: eslint . --fix } }而那些不带.ejs后缀的文件比如.gitignore就是纯静态文件复制过去就行。2.2 配置收集从用户那里拿到“变量”的值模板里有一堆占位符比如项目名、描述、端口号这些值从哪来两种来源一种是用户在命令行里直接传的比如create-app my-project另一种是通过交互式问答一问一答之间收集。我倾向于把两种方式结合起来提供了命令行参数就直接用没提供就通过问答引导用户输入。收集到的信息最终会被组装成一个对象在渲染模板时作为数据源。这个对象才是整个脚手架的“配置中枢”const config { projectName: my-app, projectDescription: A frontend project, needRouter: true, needStore: true, cssScheme: less, devPort: 5173 };之所以要做成结构化对象而不是零散变量是因为渲染阶段所有模板文件都要依赖这个对象。模板里既能直接引用某个字段也能用EJS的条件判断和循环来生成不同形态的代码。2.3 生成逻辑读取、渲染、写入三步走模板和配置都有了生成逻辑其实就是一个三步走的流程读文件、渲染内容、写到目标位置。但这三步如果处理不好很容易出问题。原因是模板目录是一个树状结构里面有嵌套目录、有静态文件、也有需要渲染的动态文件。你需要在递归遍历的过程中区分它们。我最初的实现版本是这样的思路async function generate(templatePath, targetPath, data) { const items await fs.readdir(templatePath); for (const item of items) { const srcPath path.join(templatePath, item); const destPath path.join(targetPath, item.replace(/\.ejs$/, )); const stat await fs.stat(srcPath); if (stat.isDirectory()) { await generate(srcPath, destPath, data); } else { if (item.endsWith(.ejs)) { const content await fs.readFile(srcPath, utf-8); const rendered ejs.render(content, data); await fs.outputFile(destPath, rendered); } else { await fs.copy(srcPath, destPath); } } } }这段代码虽然简单但它体现了最关键的设计决策用文件后缀来区分“需要渲染的文件”和“直接复制的文件”。这个约定让模板目录的维护变得非常直观哪个文件需要动态化就把后缀改成.ejs并在内容里加上占位符不需要动态化就保持原样。逻辑简单团队成员也容易理解。2.4 为什么用EJS而不是字符串拼接我见过有人写脚手架用字符串拼接来生成代码类似const content export const baseUrl config.apiHost 。这种做法也不是不行但一旦代码量上来维护体验会很痛苦。用模板引擎核心好处是代码逻辑和模板内容分离。EJS里面支持% if (needRouter) { %这种条件判断也支持% for (...) { %循环。比如在路由文件里我需要根据用户是否选择某个功能模块来决定是否需要注册对应的路由EJS可以这样写const routes [ { path: /, component: Home } % if (needAboutPage) { % ,{ path: /about, component: About } % } % ];这种能力在字符串拼接方案里几乎没法优雅实现。EJS模板本身还是合法可读的JS代码不渲染的时候你也能大概看懂文件长什么样调试成本低得多。3. 从零实现脚手架命令行入口、交互问答与文件生成的完整拆解原理清楚了接下来进入实操环节。这一节我会把整个脚手架的代码骨架贴出来并且解释每一步的关键决策。你可以直接参照这份代码搭一个能用的版本再根据自己的业务场景去改模板。3.1 初始化脚手架工程本身脚手架本身也是一个Node.js项目。我推荐的目录结构是这样的my-cli/ ├── bin/ │ └── index.js # 命令行入口 ├── lib/ │ ├── questions.js # 交互问题定义 │ ├── generate.js # 模板生成逻辑 │ └── utils.js # 公共方法 ├── template/ │ ├── javascript/ # 纯JS版本模板 │ └── typescript/ # TS版本模板 └── package.json脚手架自己的package.json里面需要加一个bin字段这是让命令能被全局执行的关键{ name: my-cli, bin: { my-cli: ./bin/index.js }, dependencies: { chalk: ^4.1.2, commander: ^9.4.1, ejs: ^3.1.8, fs-extra: ^10.1.0, inquirer: ^8.2.4 } }提示inquirer从9.x开始变成了纯ESM包在CommonJS项目里使用会比较别扭。如果你用的是CommonJS风格建议锁定8.x版本如果项目本身就是ESM直接上最新版没问题。这里我按比较稳妥的CommonJS写法来演示。然后在项目根目录执行npm link这个命令会把脚手架命令链接到全局环境之后在任意目录都能直接调用。3.2 命令行入口参数的解析与兜底入口文件bin/index.js做的事情很简单解析参数、引导问答、调起生成逻辑。我用commander来管理命令同时注册一个init子命令这样以后想扩展其他命令也方便#! /usr/bin/env node const { program } require(commander); const { prompt } require(inquirer); const chalk require(chalk); const { questions } require(../lib/questions); const { generate } require(../lib/generate); const { normalizeProjectName } require(../lib/utils); program .version(1.0.0) .description(A simple frontend scaffold); program .command(init) .description(Create a new project from template) .argument([projectName], project name) .option(-t, --typescript, use TypeScript template) .option(-p, --port port, dev server port) .action(async (projectName, options) { const initialAnswers { projectName: projectName ? normalizeProjectName(projectName) : undefined }; const answers await prompt(questions, initialAnswers); const mergedOptions { ...answers, typescript: options.typescript || answers.typescript }; try { await generate(mergedOptions); console.log(chalk.green(\n Project created successfully!)); console.log(chalk.cyan( Next steps:)); console.log(chalk.cyan( cd mergedOptions.projectName)); console.log(chalk.cyan( npm install)); console.log(chalk.cyan( npm run dev)); } catch (error) { console.error(chalk.red( Generation failed:), error.message); process.exit(1); } }); program.parse(process.argv);这里面有个容易被忽略的细节如果用户直接传了项目名还需要做一次规范化处理。npm对包名有严格的限制理论上不能用大写字母、不能有空格、不能以点开头。但用户可能输入中文名、带空格的目录名。我的做法是提供一个normalizeProjectName函数把非法字符过滤掉转成kebab-case风格function normalizeProjectName(name) { return name .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, -) .replace(/-/g, -) .replace(/^-|-$/g, ); }这样一来哪怕用户输入了一个中文的项目名生成的目录名和package.json里的name字段也一定是合法的。3.3 交互问答用提问驱动配置收集问答模块的设计直接决定了脚手架易用性。我见过很多脚手架的问答环节冗长到让人想放弃动不动就七八个问题。我的原则是高频且影响项目结构的选项留下低频选项用默认值或者后续自行修改。一个基础的问答列表大概长这样const questions [ { type: input, name: projectName, message: Project name:, default: my-app, validate: (input) { if (!input.trim()) return Project name is required; if (!/^[a-z0-9-]$/.test(input.trim())) { return Project name can only contain lowercase letters, numbers and hyphens; } return true; }, when: (answers) !answers.projectName }, { type: input, name: projectDescription, message: Project description:, default: A frontend project }, { type: list, name: cssScheme, message: Choose a CSS solution:, choices: [less, scss, plain-css] }, { type: confirm, name: needRouter, message: Need Vue Router?, default: true }, { type: confirm, name: needStore, message: Need Pinia?, default: true }, { type: number, name: devPort, message: Dev server port:, default: 5173, validate: (input) input 1 input 65535 } ];注意我第一个问题用了when字段判断条件是!answers.projectName。这就是前面提到的“命令行传了就直接用没传才问”的兜底策略。用户如果在命令里写了项目名这一个问题就不会出现整个初始化流程会更顺畅。3.4 生成逻辑完整处理模板目录树核心的generate函数在上文原理部分已经展示过基础版本这里给出一个增强版本加入了一些项目实践中必不可少的保护逻辑const fs require(fs-extra); const path require(path); const ejs require(ejs); const { spawn } require(child_process); const TEMPLATE_ROOT path.resolve(__dirname, ../template); async function generate(options) { const { projectName } options; const targetDir path.resolve(process.cwd(), projectName); // 检查目标目录是否已存在 if (await fs.pathExists(targetDir)) { throw new Error(Directory ${projectName} already exists. Please choose another name.); } const templateDir path.join( TEMPLATE_ROOT, options.typescript ? typescript : javascript ); // 递归生成文件 await renderDirectory(templateDir, targetDir, options); // 创建git仓库 await runCommand(git, [init], { cwd: targetDir }); // 询问是否继续执行包安装 await runCommand(npm, [install], { cwd: targetDir }); } async function renderDirectory(srcDir, destDir, data) { await fs.ensureDir(destDir); const items await fs.readdir(srcDir); for (const item of items) { const srcPath path.join(srcDir, item); const destName item.replace(/\.ejs$/, ); const destPath path.join(destDir, destName); const stat await fs.stat(srcPath); if (stat.isDirectory()) { await renderDirectory(srcPath, destPath, data); } else { const ext path.extname(srcPath).toLowerCase(); // 二进制文件或带ejs标记的文件分开处理 if (item.endsWith(.ejs)) { const content await fs.readFile(srcPath, utf-8); const rendered ejs.render(content, data); await fs.outputFile(destPath, rendered); } else if (isBinaryFile(ext)) { await fs.copy(srcPath, destPath); } else { const content await fs.readFile(srcPath, utf-8); await fs.outputFile(destPath, content); } } } } function isBinaryFile(ext) { return [.png, .jpg, .jpeg, .gif, .ico, .woff, .woff2, .ttf].includes(ext); } function runCommand(command, args, opts) { return new Promise((resolve, reject) { const child spawn(command, args, { ...opts, stdio: inherit }); child.on(error, reject); child.on(close, (code) { if (code ! 0) { reject(new Error(${command} exited with code ${code})); return; } resolve(); }); }); }3.5 为什么这些设计决策是合理的有几个设计上的取舍我觉得值得展开讲讲逻辑。首先是渲染和安装的先后顺序。很多脚手架生成完文件之后只是打印一句“把目录切换到项目然后自己npm install”。我觉得既然是脚手架安装依赖这一步就应该帮忙做了省得用户再多敲一次命令。但也注意我说的是在文件全部生成完、且git仓库初始化完之后再执行install。这个顺序是刻意的如果先执行install再生成文件package.json可能还不存在npm install会因找不到包描述文件而报错。如果把git init放在npm install之后会把node_modules目录也纳入git的追踪范围手动改.gitignore又显得多此一举。所以顺序是先保证文件完整再初始化git最后装依赖。其次是为什么要单独处理二进制文件。模板目录里如果放了favicon.ico、logo.png这类资源直接拿ejs去渲染会发生什么ejs会把整个文件当字符串解析遇到二进制内容里的特殊字符就会报错或者更糟的是静默地生成一个破坏的文件。所以我在renderDirectory里加了文件扩展名判断属于图片字体类扩展名的一律走fs.copy原样复制。这个坑是我第一次打包模板时真实踩过的不写下来的话后面的人可能还会踩一遍。第三是child_process的spawn和exec的区别。我一开始图省事用exec执行npm install遇到了Windows上命令长度超限的报错。后来改成了spawn加参数数组就再也没出过问题。exec会把整个命令拼成一个字符串交给Shell去执行参数里有特殊字符时很可能出幺蛾子spawn直接把命令和参数分开传递不经过Shell的解析更稳定也更安全。4. 用起来之后的真实问题清单模板语法冲突、跨平台路径与二进制文件保护脚手架写出来能跑这只能算完成了百分之六十。真正的打磨是在各种真实使用场景里不断踩坑再补丁。这一节记几个我觉得最有代表性的问题它们都不是“写不出来”的问题而是“用过才知道”的问题。4.1 EJS标签和项目代码之间的语法冲突EJS默认使用% %作为标签包裹逻辑。但如果你模板里的某个文件内容本身包含了%这个字符串比如一些特殊格式的文档或内联的模板片段EJS就会尝试解析它随即抛错。最大的真实冲突场景不是Vue项目里的{{ }}那个和EJS完全不冲突。反而不太起眼的纯文本文件最危险。我举个实际例子模板里有个README.md里面是一段演示代码代码里写了一行const plugin % plugins.forEach(...) %本意是展示一个伪代码示例结果EJS把它当作真实标签去执行了。解决方案有两个层面。一是约定模板作者在写模板内容时尽量减少%这种组合的出现。二是更稳妥的做法给EJS换一个自定义定界符比如用[[ ]]或者{{ }}如果目标框架不用这种语法的话。EJS支持通过delimiter选项配置const rendered ejs.render(content, data, { delimiter: ? });这样模板里的标签就变成? projectName ?和? if (needRouter) { ?。虽然看起来不如默认标签熟悉但和绝大多数前端框架的自定义模板语法可以彻底隔离从根上避免冲突。我用了一段自定义定界符之后再也没为这个问题头疼过。4.2 模板文件的换行符问题这个问题刚开始真的完全没意识到。在Windows上开发的同学用记事本风格的编辑器改过模板文件之后文件的换行符可能变成CRLF。CRLF的换行符如果被提交进代码库而团队其他人用的是macOS或Linux那么Git会在diff里疯狂提示整行变更因为每一行的行尾都被改了。我的处理方式是在模板代码库根目录放一个.editorconfig文件强制所有模板文件统一使用LF换行root true [*] charset utf-8 end_of_line lf indent_style space indent_size 2 [*.ejs] end_of_line lf设置完之后我在代码提交前再加一道保险写一个简单的脚本扫描template目录检查有没有包含CRLF的文件有就直接报错。把这个问题挡在模板维护阶段而不是等到用户生成项目之后才发现。4.3 路径分隔符与跨平台兼容这个更多是脚手架代码自身的问题。在拼接路径时我见过有人图省事用templateDir / item这种字符串拼接方式这在macOS和Linux上没问题但在Windows上会得到类似template\src\components和template/src/components混用的路径某些环节就会出问题。正确的做法是一律使用path.join。另外还有一个容易被忽视的细节spawn执行命令时如果命令名是npm在Windows上有时会找不到npm.cmd。这是个已知的Node.js行为差异。我现在要么用process.platform win32判断后给命令名加上.cmd后缀要么直接调用npm-cli.js的路径。虽然有点丑但胜在稳定。4.4 自定义EJS定界符在团队中的可维护性前面说到自定义定界符能解决语法冲突但它也带来一个团队协作问题模板维护者看到? name ?可能一开始会愣一下不习惯。所以我建议在template目录外面放一个template-guide.md专门说明这个脚手架用了什么定界符、模板里哪些文件可以放逻辑、哪些文件要避免动态化。这个文档的成本极低但能省去后来维护者大量的困惑时间。我把这些问题按类型整理成了一张表方便排查时快速对照问题类型触发场景核心原因解决方案EJS语法冲突模板内出现%文本默认定界符与其他模板语法撞车自定义delimiter或者约定模板内禁写该组合CRLF换行Windows环境修改模板编辑器自动转换换行符.editorconfig 提交前检查路径分隔符混用Windows运行脚手架代码里手动拼接路径统一使用path.join二进制文件损坏模板含图片字体ejs对二进制内容做字符串渲染按扩展名白名单跳过渲染npm命令在Windows找不到spawn调用npm需要npm.cmd平台判断后补.cmd后缀5. 比脚手架本身更重要的模板设计、多版本支持与迭代思路代码层面的内容讲得差不多了但脚手架能不能在团队里真正用起来我觉得模板设计比脚手架代码更重要。代码只是生成机制模板的质量决定了生成出来的项目长什么样。5.1 模板文件也用上EJS的条件渲染能力一个好的模板不只是把项目名替换掉就结束了。它应该根据用户的选项动态决定要不要生成某些文件。比如我在模板里对package.json的scripts做了条件判断用户没有选择less/scss时不生成对应的脚本。又比如在入口文件main.ejs里如果用户确认需要状态管理库则自动生成对应的注册代码否则干干净净地跳过import { createApp } from vue; % if (needStore) { % import { createPinia } from pinia; % } % import App from ./App.vue; const app createApp(App); % if (needStore) { % app.use(createPinia()); % } % app.mount(#app);这种能力是模板设计的灵魂。如果只是简单的“换名字”那跟复制粘贴没什么区别。真正让脚手架有威力的是它能根据不同的使用场景生成出形态不完全一致的项目。5.2 JS和TS双模板一个选择两种产出我选择在template目录下直接分成javascript和typescript两个子目录而不是做一个模板用EJS条件来控制。这样做的原因是两者的差异不只是几个文件后缀而是整个代码风格和依赖列表都有系统性差异。如果硬塞进一个模板里面的条件分支会多到难以维护。目录结构上留了两个入口用户在选择模板类型时我会根据选项动态决定读哪个目录。这样在模板维护层面看起来会有些重复但每个模板内部的代码都保持简单纯粹没有一堆互相干扰的条件判断长期来看更划算。5.3 脚手架在团队内的进化路径从生成器到规范收口器最后说一点更长远的。简易脚手架一旦用起来它会慢慢从一个“项目生成器”进化成团队规范的“收口器”。最开始模板里放的是目录结构、基础依赖、lint配置这是第一层。接下来你会发现团队规范里那些“约定”也可以固化进来。比如提交信息格式、分支命名、代码格式化规则。脚手架可以在生成项目的时候顺手把husky配置和commitlint配置一起生成好新人在本地初始化完就自动具备这些约束。再往后如果团队有内部的组件库、请求封装库、埋点SDK也可以把这些依赖和对应的初始化代码放进模板里。新人不需要知道“每个项目都要接一下埋点”这个隐含规则因为脚手架已经帮他做了。这时候脚手架的价值就不再只是省几分钟的复制粘贴而是让所有新项目默认站在同一条高线上。我自己的体验是一个脚手架从不成熟到好用靠的不是一开始就设计得多么宏大而是每经历一次“啊这个约定又有人忘了”的瞬间就把它固化进模板。迭代几次之后它自然就成熟了。当初花在那两三百行核心代码上的时间早就在团队效率上成倍赚了回来。如果你打算尝试我的建议是别一上来就想着做一个能应对所有场景的通用脚手架。先挑一个你最近要开工的项目把它的骨架抽成模板写一个最简单的生成器跑通一次。然后在这个基础上慢慢加选项、加保护逻辑。脚手架这个东西用起来才会知道哪里需要磨合空想是想不出所有边界情况的。
阅读完成 · 觉得有帮助?
咨询建站