普通视图

发现新文章,点击刷新页面。
昨天以前首页

前端工程化实战:手把手教你构建项目脚手架

作者 云枫晖
2025年11月6日 16:54

面对如今丰富的前端生态,开启新项目时你是否经常陷入这样的纠结:

  1. 在选择构建工具、UI框架、要不要TS等技术选型时,是不是都要重新研究最新的最佳实践?
  2. 当团队需要内部的代码规范、工具链配置、私有依赖等总要手动添加,而影响开发效率?
  3. 当新成员加入时,是否需要大量时间理解项目结构、配置规范,导致配置不一致导致各种奇怪问题?
  4. 当团队项目需要添加特定的中后台、组件库等场景,总要重复的基建代码的Copy

以上烦恼都可以通过前端脚手架搞定,从而不再重复造轮子,而是打造专属自身团队的最佳实践。

本文将从0到1带你构建一个简单的脚手架,以抛砖引玉的方式带了解脚手架的开发。

前端脚手架

前端脚手架本质上是一个Node.js命令程序,它通常有以下功能:

  • 交互式询问用户 通过命令行交互,如确定项目名称、选择框架
  • 模板管理 根据命令行交互的结果远程拉取的项目模板
  • 交互式配置 根据命令行让用户自行选择具体配置
  • 依赖安装 自动安装项目依赖(npm/yarn/pnpm)
  • 命令扩展 支持插件化或自定义命令(可选,进阶功能)

在开发脚手架过程中,使用到一些第三方依赖来帮助我们完成脚手架开发:

  • commander 命令行处理工具
  • chalk 命名行输出美化工具
  • inquirer 命名行交互工具
  • ora 终端loading美化工具
  • git-clone 下载项目模板工具,
  • figlet 终端生成艺术字
  • fs-extra 操作本地目录
  • ejs/handlebars 动态渲染模板文件

前端脚手架实现

1. 初始化项目

mkdir case-cli && cd case-cli
npm init -y

2.配置命令入口

{
  "name": "case-cli",
  "version": "0.0.1",
  "main": "index.js",
  "bin": "/bin/index.js",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^14.0.2",
    "fs-extra": "^11.3.2",
    "git-clone": "^0.2.0",
    "inquirer": "^8.2.7", 
    "ora": "^5.4.1"
  }
}

📢 注意
package.json中多数依赖包的最新版本都采用ESM模块化,如果采用Common.js模块化方式,需要适当降级

3. 编写入口文件

#!/usr/bin/env node
const ora = require("ora"); // loading 美化工具
const chalk = require("chalk"); // 命令行美化工具
const inquirer = require("inquirer"); // 命令行交互
const fs = require("fs-extra"); // 操作本地目录
const path = require("path");
const gitClone = require("git-clone"); // 拉取github模板
const packageJson = require("../package.json"); // 获取package.json
const { program } = require("commander"); // 命令行处理工具
console.log(chalk.blue("学习脚手架工具已启动!"));

📢 注意
必须在文件开头添加 #!/usr/bin/env node,告知操作系统 该文件是通过Node执行

现在我们就可以在命令行中输入case-cli后回车:

image.png

然后我们再添加一行代码,通过commanderprogram解析命令行参数:

#!/usr/bin/env node
/* 依赖引入就此省略 */ 
console.log(chalk.blue("学习脚手架工具已启动!"));
// 解析命令行参数
program.parse(process.argv);

输入case-cli -h命令:

image.png

添加获取版本的指令

#!/usr/bin/env node
/* 依赖引入就此省略 */ 
console.log(chalk.blue("学习脚手架工具已启动!"));
program.version(chalk.green.bold(packageJson.version))
// 解析命令行参数
program.parse(process.argv);

输入case-cli -V将显示脚手架版本号,而且case-cli -h也有变化

image.png

一般情况下脚手架类似vue create [project name]来创建项目,在没有输入任何指令时如case-cli将会执行case-cli --help命令显示该脚手架有哪些命令操作。可以如下实现:

program.action(() => program.help());

注册命令

program
  .command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
  .description("创建新项目")
  .action(async (projectName) => {
    console.log(projectName);
  });

添加交互配置

program
  .command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
  .description("创建新项目")
  .action(async (projectName) => {
    inquirer.prompt([
      {
        type: "list",
        name: 'framework',
        message: '请选择框架',
        choices: ["vue", "react"],
      }
    ]).then(async (answers) => {
      const { framework } = answers;
      console.log(chalk.green(`正在创建项目 ${projectName}`));
      console.log(chalk.green(`正在创建 ${framework} 项目`));
    })
  });

当我们输入case-cli create app时,将呈现如下画面:

image.png 任意选择一项后: image.png

检查项目名称是否重复

脚手架是以项目名称为目录名称,在当前输入指令的目录下创建的,因此需要检查是否有相同的目录名。并给出提示。

program
  .command("create <project-name>") // <project-name> 表示必填参数,如果不填写将会报错
  .description("创建新项目")
  .action(async (projectName) => {
    inquirer.prompt([
      {
        type: "list",
        name: 'framework',
        message: '请选择框架',
        choices: ["vue", "react"],
      }
    ]).then(async (answers) => {
      const { framework } = answers;
      // 拼接创建项目目录地址
      const projectPath = path.join(process.cwd(), projectName);
      // 检查是否存在相同目录
      const isExist = fs.existsSync(projectPath);
      if (isExist) {
        // 提供交互选择 覆盖则删除之前目录,反之则退出此次命令
        const result = await inquirer.prompt([
          {
            type: "confirm",
            message: "当前目录下已存在同名项目,是否覆盖?",
            name: "overwrite",
            default: false,
          },
        ]);
        if (result.overwrite) {
          fs.removeSync(projectPath);
          console.log(chalk.green("已删除同名项目"));
        } else {
          console.log(chalk.yellow("请重新创建项目"));
          return;
        } 
      }
    })
  });

拉取远程模板

const spinner = ora(chalk.magenta("正在创建项目...")).start();
const remoteUrl = `https://github.com/gardenia83/${framework}-template.git`;
gitClone(remoteUrl, projectPath, { checkout: "main" }, function (err) {
  if (err) {
    spinner.fail(chalk.red("拉取模板失败"));
  } else {
    spinner.color = "magenta";
    // 由于拉取会将他人的.git,因此需要移除
    fs.removeSync(path.join(projectPath, ".git")); // 删除.git文件
    spinner.succeed(chalk.cyan("项目创建成功"));
    console.log("Done now run: \n");
    console.log(`cd ${projectName}`);
    console.log("npm install");
    console.log("npm run dev");
  }
});

拉取远程模板项目: image.png 拉取完成后:

image.png

小结

通过本文,我们完成了一个基础但功能完整的前端脚手架,实现了项目创建、模板拉取、冲突处理等核心功能。这个简单的脚手架已经能够解决文章开头提到的部分痛点:

✅ 统一技术选型 - 通过预设模板固化团队最佳实践
✅ 快速初始化 - 一键生成项目结构,告别手动配置
✅ 规范团队协作 - 新成员无需理解复杂配置,开箱即用

但这仅仅是一个开始!  你可以基于这个基础版本,根据团队实际需求进行深度定制:

🛠 模板动态化 - 集成 ejs 等模板引擎,根据用户选择动态生成配置文件
🛠 生态集成 - 添加 ESLint、Prettier、Husky 等工程化工具链
🛠 场景扩展 - 针对中后台、组件库、H5 等不同场景提供专属模板
🛠 插件机制 - 设计插件系统,让团队成员也能贡献功能模块

最好的脚手架不是功能最全的,而是最适合团队工作流的。  希望本文能成为你打造团队专属工具链的起点,让重复的配置工作成为历史,把宝贵的时间留给更有价值的创新!

重识 alias —— npm包开发的神器

作者 井柏然
2025年11月3日 09:04

本文基于 vite 和 pnpm workspace 的 monorepo 架构跟大家一起重识 alias 的原理和用法!(ps:为方便大家自己研究下案例代码的执行,完整代码已经上传 github,有需要的朋友可以自行 clone 下来玩玩。

前言

说起 alias 大家肯定不陌生,毕竟几乎在任何一个现代前端工程的项目中都有它的影子。常用的打包工具 webpack 和 vite 都有 alias 的配置,并且当我们使用脚手架生成一些基础项目的时候,大家都会默契地把 @ 通过 alias 配置指向了工程中的 src 目录。

比如这样,我们配置了 @ 指向当前目录下的 src:

alias:[
  {
    find: '@',
    replacement: path.resolve('src'),
  },
]

我们在应用中便可以通过 @/xxx 来便捷的导入 src 下的任何模块。比如一个文件中的 import 如下:

image.png

当配置了 alias 后,我们可以改写成 import '@/App.css' 这样写法。从某种程度上来说,alias 别名代替一些相对、绝对路径的写法,在开发阶段给我们提供便利。

我举一个很常见的例子,我们开发过程中经常会因为一些调整来变更文件的位置,那此时如果我使用相对路径来写模块引用的话,当我的文件位置变更的时候,往往需要同步修改我的模块导入路径。比如当我的文件提升了一个层级的时候,我的相对路径 import '../App.css' 写法可能就要改成 import './App.css' 这样了。但是如果我是通过 '@/App.css' 的方式来引用,只要 App.css 的位置不变,导入其的文件无论换到哪里都不需要变更这个导入的路径。

好吧,相信这个用法大家都很熟悉了!但不知道大家有没有了解过它背后是怎么运作的呢?我们接着往下看。

alias 原理

我们看 vite 文档 对其的介绍可知,它的底层实现是一个 rollup 插件:@rollup/plugin-alias。当我们点进去这个插件的 源码 大概看了看,有写过 vite 或者 rollup 插件的同学可能就发现了两个熟悉的钩子:

image.png

buildStartresolveId。这两个钩子我们在 rollup 的插件文档中可以直接看到:

image.png

当然我们也同样在 vite 的插件文档中找到相关钩子的说明:

image.png

只要我们简单看看 @rollup/plugin-alias 的源码,大概就能猜到 alias 的底层实现原理了,如下图:

image.png

dev 和 build

基于上述的一些文档说明和源码阅读,我们大概知道了 alias 的实现原理是基于两点:

  1. 别名规则的匹配
  2. 路径替换

基于此我们可以简单拓展下,大家都知道 vite 的 dev mode 下是不打包文件的,通过直接启动开发服务器并借助浏览器原生支持的 import 能力进行模块的按需导入。那我们大概可以猜测到 dev 和 build 模式下的 alias 表象是有区别的。

比如我有一个 AliasTest 的组件通过 @ 的方式在入口组件中导入:

const AliasTest = lazy(() => import('@/components/alias-test'));

当我运行 dev 的时候在浏览器下可以看到对应的路径截图如下:

image.png

有经验的都知道,此时我们的源文件的导入路径是不会变化的(也就是说不修改源文件),所以我们可以认为这是 dev 服务器的实现。紧接着我们对这个 react-demo 的单页应用进行打包,再看看 @ 这个别名的变化情况。(我特意使用了 lazy 引入,就为了分包出来看得清晰点)

产物结果如下,此时的 AliasTest 组件被打包成了 index-BsXGEvj6.js 文件:

image.png

此时我们再回到入口文件中看看我们源代码写的 '@/components/alias-test' 会变成什么?

image.png

确实已经正确转换为指向打包后的文件的相对路径了(当然源文件也随之被修改)。

巧妙的用途

除了我们项目中常见的通过配置 alias 实现 @ 指向 src 目录,这个配置还有没有其他妙用呢?答案是有的,比如我们常做 npm 包开发的同学应该就会用到。

这里我还是回到同一个 react 的 monorepo 工程中进行讲解。比如我要开发一个 npm 包:react-bundle,并且我通过 react-demo 这个单页应用的项目作为代码调试的工程,它提供一个 vite dev 的环境能让我们很方便的开发调试 react-bundle 的 js包:

image.png

回顾上一篇文章——从 Monorepo 重温 ESM 的模块化机制中提到的模块解析规则,package.json 中的入口字段决定着 node 的模块解析如何找到最终对应的文件,所以当我们需要在 react-demo 中启动 dev 能调试到 react-bundle 时,我们必须要正确地安装好依赖并且 react-bundle 的入口配置正确。

此时我们看看 react-bundle 中的 package.json 配置是怎么指向入口的:

"exports": {
  ".": {
    "import": "./dist/index.js",
    "types": "./dist/react-bundle/index.d.ts"
  }
},

它的入口配置为 dist/index.js,也就是说我们真正在 react-demo 中调试的文件是一个打包后的文件:

image.png

那这样会有一个怎么样的问题?是不是意味着我们每在 react-bundle 中该一行代码,都需要重新 build 一次才能生效。这种情况对于 npm 包的开发者来说是不能容忍的,因为效率真的太低了。并且我们可能会因为忘记 build 了而发现自己的改动迟迟没有生效从而浪费时间去排查。那此时怎么办呢?

聪明的朋友一定想到了,那直接改掉 package.json 中的入口指向不就好了吗?比如我修改了一下 react-bundle 的 pkj 的入口指向。比如这里我把指向从 ./dist/index.js 改为了项目源码的入口文件: ./index.ts

"exports": {
  ".": {
    "import": "./index.ts",
    "types": "./dist/react-bundle/index.d.ts"
  }
},

此时在 react-bundle 的每一行修改都实时反馈到 react-demo 的 dev 调试 web 中了,让我们这些 npm 包开发者感受到了无比的愉悦。比如我修改了一下 react-bundle 中的代码:

image.png

实时调试效果: image.png

看似一切美好的解决方案,但当我们要把 npm包 发布到 npm 仓库并被用户安装到自己的项目时,问题就出现了。这里大家可以自己想一想问题是什么?但是如果说你的 npm 包本意就是提供源码文件给其他用户使用,那这样确实没毛病。

大多数情况下,npm 包的开发者都会提供“开箱即用”的代码包,他不需要要求用户的项目装什么 ts,用什么打包,反正他就提供一个 js 文件,只要是运行在有 js 引擎的环境中都能正常运行的产物。另外随着前端工程的不断发展,各种 npm包还会提供各式各样的产物模式,如 umd、iife、es module 规范、commonjs 规范等产物。

所以从大部分场景看,我们开发 npm包的 package.json 入口一般都是指向打包后的入口文件的,如果说我们开发的时候改了对应的入口指向,保不齐我们发包的时候没有改回来就会导致用这个 npm 包的同学出问题。当然有同学说可以多搞几个 package.json 不就好了?但此时我想说的是,我们可以配置 alias 来解决这个问题

比如此时我们的 package.json 的指向依然是打包后的地址,但是我们给调试用的应用配置了 alias 指向我们的 react-bundle 的源码文件。此时我们依然可以实时调试我们的 react-bundle,但是又不用担心发包后的入口配置不对从而引用其他开发者了。

image.png

于是乎,alias 在这里的妙用可以极大地降低我们 npm 包开发时的心智负担,我们可以配置好 alias 并借助 monorepo 的代码组织方式很丝滑地实时开发调试我们的 npm 包,又不用担心会影响到使用该 npm包 的用户们。

总结

配置别名 alias 的场景不仅仅是我们每个单页应用中的 src 映射,还可以应用到 npm 包的开发调试场景。当然,alias 的玩法肯定不局限在这两种场景,本文更多是借 npm包 开发调试妙用 alias 的引子让大家更多地 get 到这个配置的作用。相信本文可以拓宽我们对 alias 的理解,并拓宽我们在一些业务场景中解决问题的解题思路!

❌
❌