普通视图

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

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

作者 云枫晖
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 等不同场景提供专属模板
🛠 插件机制 - 设计插件系统,让团队成员也能贡献功能模块

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

Webpack系列-开发环境

作者 云枫晖
2025年11月3日 17:12

本文将深入探讨Webpack开发环境的核心配置和原理,帮助你搭建高效的本地开发环境

为什么需要开发服务器?

在本地开发过程中,每次修改代码后手动执行构建命令会严重影响开发效率。Webpack DevServer为我们提供了一个带热更新的开发服务器,能够显著提升开发体验。

devServer的核心配置

module.exports = {
  devServer: {
    port: 3000, // 服务器监听端口
    static: {
      directory: path.resolve(__dirname, 'dist')
    },
    open: true, // 自动打开浏览器
    hot: true, // 热模块替换
    host: 'localhost',
    setupMiddlewares: (middlewares, devServer) => {
      // 此可以做如下事情: 
      // 1. mock服务数据
      // 2. 静态资源扩展
      // 3. 代理和重写请求
    },
    proxy: {
      '/api': {
        target: 'http://localhost:8000',  // 代理目标域名
        pathRewrite: { '^/api': '' }, // 代理时将/api替换为''
        changeOrigin: true, // 修改 Origin 头与目标服务器匹配 主要解决CORS问题
        secure: false // 忽略验证 SSL 证书
      }
    }
  }
}

上述为devServer的核心配置,其他配置进入webpack官网自行查阅。

devServer的底层原理

将从webpack serve指令开始一步步解释devServer的底层原理

运行指令

当执行webapck serve指令,主要如下步骤:

  1. 当终端看到webpack,将会找到项目根目录下node_modules/webpck/bin/webpack.js文件,它是webpack包本身提供的CLI入口。
  2. webapck检测是否安装了webapck-cli,如果安装则加载运行,开始解析参数serve
  3. 当CLI解析到serve时,将加载并执行serve对应的插件@webpack-cli/serve
  4. @webpack-cli/serve插件是整个连接的桥梁,此插件主要职责:
    • 创建Webpack编译器(Compiler实例)
    • 动态引入webapck-dev-server
    • 创建Webpack的Compiler实例,对项目初次编译。
    • 通过Webpack配置和命令行解析出来的参数,创建webpack-dev-server实例
    • 调用webapck-dev-server的实例的start方法开启开发服务器

image.png

webpack-dev-server的初始化

当执行webapck-dev-server实例的start方法主要做如下事情:

  • 校验配置和合并默认配置
  • 执行内部initialize方法,这也是webpack-dev-server的核心方法,它处理如下事情:
    • 创建了基于Express实例和基础服务器,基于它实现静态页面托管以及接口代理。
      // webapck-dev-server/lib/Server.js代码片段
      async initialize () {
      // 此方法实现了搭建本地服务
      await this.setupApp();
      }
      // 依据配置获取服务实例
      async setupApp() {
      this.app =
      (
        typeof this.options.app === "function"
          ? await this.options.app()
          : getExpress()()
      );
      }
      // 获取express实例
      const getExpress = memoize(() => require("express"));
      
    • 修改入口配置,通过addAdditionalEntries方法实现
      // webapck-dev-server/lib/Server.js代码片段
      addAdditionalEntries(compiler) {
        // 需要的入口地址数组
        const additionalEntries = [];
        const isWebTarget = Server.isWebTarget(compiler);
        if (this.options.client && isWebTarget) {
          let webSocketURLStr = "";
          /* 依据options中的数据生成 webSocketURLStr值 具体逻辑省略*/
          // 生成入口地址添加到additionalEntries中
          additionalEntries.push(`${this.getClientEntry()}?${webSocketURLStr}`);
        }
        // 获取入口地址
        const clientHotEntry = this.getClientHotEntry();
        if (clientHotEntry) {
          additionalEntries.push(clientHotEntry);
        }
      
        const webpack = compiler.webpack || require("webpack");
        for (const additionalEntry of additionalEntries) {
          // 通过webpack的EntryPlugin插件添加入口配置
          new webpack.EntryPlugin(compiler.context, additionalEntry, {
            name: undefined,
          }).apply(compiler);
        }
      }
      getClientEntry() {
        return require.resolve("../client/index.js");
      }
      getClientHotEntry() {
        if (this.options.hot === "only") {
          return require.resolve("webpack/hot/only-dev-server");
        } else if (this.options.hot) {
          return require.resolve("webpack/hot/dev-server");
        }
      }
      
      在内存变成类似如下入口配置:
      entry: [
        'webpack-dev-server/client/index.js?http://localhost:8080', // WebSocket客户端
        'webpack/hot/dev-server.js',                                // HMR运行时(如果启用)
        './src/index.js'                                            // 你的原始入口
      ]
      
      • webpack-dev-server/client/index.js?http://localhost:8080此脚本主要是当访问项目时,建立浏览器和服务端的通信连接,就是websocket的连接
      • webpack/hot/dev-server.js此脚本是启用HMR运行逻辑、模块热替换。
    • 创建HMR核心插件HotModuleReplacementPlugin 它是实现不刷新页面更新内容插件
    • 根据配置选项watchFiles的值观察文件变化,一旦watchFiles配置的文件变化,websocket发送消息告知客户端
    • 根据配置选项static的值观察文件变化,一旦static配置的文件变化,websocket发送消息告知客户端
    • 调用setupMiddlewares设置和管理开发服务器的中间,其中就包括将编译好的文件托管于本地服务器
    // webpack-dev-server/lib/Server.js -> setupMiddlewares方法
    if (staticOptions.length > 0) {
      for (const staticOption of staticOptions) {
        for (const publicPath of staticOption.publicPath) {
          middlewares.push({
            name: "express-static",
            path: publicPath,
            middleware: getExpress().static(
              staticOption.directory,
              staticOption.staticOptions,
            ),
          });
        }
      }
    }
    
  • 创建WebSocket服务器

首次访问项目

当通过http://localhost:8080访问项目时,将会执行Webpack的入口脚本、也是上面讲到的修改入口的配置,从而建立本地服务器和浏览器之间WebSocket连接。

项目内容修改

当项目内容修改时,具体流程如下:

  1. Webpack进行增量编译,即是只针对修改的文件以及依赖的相关模块进行编译,不是对整个项目、因此速度很快
  2. 服务器通过WebSocket通知浏览器,告知哪个模块(模块ID)进行了编译
{ type: "hash", data: "251b0115e68fe39343cd" }
  1. 客户端拉取更新:
  • 通过webpack-dev-server/client/index.js此脚本接收WebSocket消息
  • 根据哈希值通过JSONP请求向Webpack-dev-server请求两个关键文件
    • [hash].hot.update.json 此次更新设计到了哪些模块
    • [hash].hot.update.js 包含所有更新模块的最新代码

其中[hash].host.update.json的格式如下:

{
  "c":["main"], // 更新的Chunk列表
  "r":[], // 需要重新加载的Chunk列表
  "m":[] // 更新模式 较新版本中新增
}
  1. 应用更新
  • 当客户端脚本接收到代码后,交给webapck/hot,HMR开始运作,会检查当前是否有对应的更新模块的HMR处理函数。
    • 执行模块特有的更新逻辑,例如处理module.hot.accept定义的如何更新函数
    • 从而触发刷新整个页面location.reload()

devServer的整体流程

image.png

小结

通过本文的深入探讨,我们可以看到Webpack DevServer不仅仅是一个简单的本地服务器,而是一个集成了模块热替换(HMR)实时重新加载代理转发等功能的完整开发环境解决方案。

核心价值

  • 提升开发效率:通过HMR实现代码修改的即时反馈,避免手动刷新
  • 简化开发流程:内置静态资源服务、API代理等常用功能
  • 贴近生产环境:支持配置转发规则,模拟真实部署场景

技术要点回顾

  1. 配置驱动:通过devServer选项灵活定制开发服务器行为
  2. 双向通信:基于WebSocket实现服务端与客户端的实时通信
  3. 智能编译:增量编译机制确保快速响应代码变更
  4. 模块热更新:HMR机制实现局部更新,保持应用状态

实践建议

  • 合理配置代理解决跨域问题
  • 结合setupMiddlewares实现Mock数据等高级功能
  • 根据项目规模调整HMR策略(全量更新 vs 局部更新)

演进趋势

随着前端工具链的不断发展,虽然出现了Vite、Snowpack等基于ESM的新一代构建工具,但Webpack DevServer凭借其稳定性生态完整性生产环境一致性,仍然是众多项目的首选开发环境解决方案。

掌握Webpack DevServer的配置和原理,不仅能够提升日常开发体验,更有助于理解现代前端工程化的核心思想,为学习其他构建工具打下坚实基础。

Webpack系列-SourceMap

作者 云枫晖
2025年10月31日 17:47

在上一篇文章中,我们深入探讨了Webpack Plugin的工作原理和开发实践。今天,我们将继续Webpack系列,聚焦于一个同样重要的主题——SourceMap。作为现代前端开发中不可或缺的调试工具,SourceMap能够显著提升开发效率和调试体验。让我们一起来揭开SourceMap的神秘面纱。

什么是SourceMap❓

SourceMap是一种映射关系文件,它将编译、压缩的代码映射原代码。在开发过程中,我们经常遇到如下场景:

  • 使用TS等预编译语言
  • 使用ES6高级语法需通过Babel转译
  • 对代码进行压缩、混淆
  • 将多个文件打包合并

以上处理后生成的运行代码与原始代码差异巨大,给调试代码来了巨大的困难。SourceMap正是解决这一问题的关键技术。

SourceMap配置

在Webpack里可以通过devtool配置evalsource-mapcheapmoduleinline这些关键词相互组合的值,达到不同SourceMap的效果。

module.exports = {
  devtool: 'eval-source-map'
}

每个关键词的作用

关键词 作用 特点 使用场景
eval 通过eval函数执行模块代码 构建和重构速度最快 开发环境、需要快速的构建速度
source-map 生成独立的.map文件 映射质量高 生产环境、高质量错误跟踪
cheap 减少VLQ编码的计算量,减少source-map的体积 只映射行号,不映射列号,提升性能 开发环境、减少source-map的体积
module 包含loader的sourcemap信息 对于使用babel、ts的项目方便定位 开发环境、以便使用loader的文件定位问题
inline 将sourcemap作为DataURL嵌入到bundle中 不需要额外的.map文件,但增加了bundle的体积 开发环境

SourceMap的推荐配置

开发环境 - eval-cheap-module-source-map

module.exports = {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map'
}

生产环境 - source-map

module.exports = {
  mode: 'production',
  devtool: 'source-map'
}

如果不想让用户看到.map文件,可以配置hidden-source-map。配置后生成的.map文件不包含引用注释,需要手动关联。

SoureMap的底层原理

生成SourceMap的方法

生成SourceMap的方法很多,我比较喜欢uglify-js的API生成SourceMap文件

安装uglify-js

npm i uglify-js

源文件内容

let a = 1;
let b = 2;
let c = 3;

生成SourceMap

const UglifyJS = require("uglify-js");
const fs = require("fs");
const path = require("path");
const result = UglifyJS.minify(
  {
    "index.js": fs.readFileSync(path.join(__dirname, "./src/index.js"), "utf8"), // 读取生成source map的源文件
  },
  {
    compress: false, // 代码不进行压缩
    output: {
      beautify: true,
      indent_level: 2,
    },
    sourceMap: {
      filename: "index.min.js",
      url: "index.min.js.map",
    },
  }
);
fs.writeFileSync("index.min.js", result.code);
fs.writeFileSync("index.min.js.map", result.map);

处理后的源代码

let a = 1;

let b = 2;

let c = 3;
// 此行浏览器会解析此行注释 获取.map文件通过VLQ编码获取源文件精准定位
//# sourceMappingURL=index.min.js.map 

SourceMap文件格式

{
  "version": 3,
  "file": "index.min.js",
  "sources": [
    "index.js"
  ],
  "names": [
    "let",
    "a",
    "b",
    "c"
  ],
  "mappings": "AAAAA,IAAIC,IAAI;;AACRD,IAAIE,IAAI;;AACRF,IAAIG,IAAI"
}

整个文件其实就是一个JS对象,可以被解释器读取。主要有以下几个属性:

  • version Source Map的版本 目前为3
  • file 转换后的文件名
  • sourceRoot 转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空
  • sources 转换前的文件。值为数组类型,表示可以存在多个文件合并
  • names 转换前的所有变量名和属性名
  • mappings 记录位置信息和字符串,后续详解

mapping属性

mapping属性的字符串值是SourceMap的灵魂,以最少得字符表示最多的映射信息。编码规则如下:

  1. 按行分组mapping字符串首先以;分隔,每个分号代表转换后代码的一行。如AAAA,IAAIC,IAAI;;AACRD,IAAIE,IAAI;;AACRF,IAAIG,IAAI 代码转换后的代码有5
  2. 按段分隔:每一行用,号分隔成多个映射段。每段代表该行的一个位置(通常是某个词法标记的开始)
  3. 相对位置:每段是VLQ编码的字符串,通常包含1、4或5个字段(不是"变量"),分别表示:
  • 生成代码的列位置
  • 源文件索引
  • 源文件行位置
  • 源文件列位置
  • (可选)names数组中的变量索引

一个典型的 4 段 VLQ 编码 AAAA 解码后可能代表 [0, 0, 0, 0],它的含义是:

  • 生成的列(Generated Column)0
  • 源文件索引(Source Index)0 (对应 sources 数组中的第一个文件)
  • 原始行(Original Line)0 (第 1 行)
  • 原始列(Original Column)0

💡 解释
VLQ编码最早用于MIDI文件,后来被多种格式采用。它的特点就是可以非常精简地表示很大的数值。
VLQ编码是变长的。如果(整)数值在-15到+15之间(含两个端点),用一个字符表示;超出这个范围,就需要用多个字符表示。它规定,每个字符使用6个两进制位,正好可以借用Base 64编码的字符表。

image.png

有可能有第五个数字,但不是必需的,如果有的话,表示属于names中的哪个变量。再看一个例子:

// 源码
let a = 1;

通过uglify-js处理后的mapping值为:

{
  "version": 3,
  "file": "index.min.js",
  "sources": [
    "index.js"
  ],
  "names": [
    "let",
    "a"
  ],
  "mappings": "AAAAA,IAAIC,EAAI"
}

通过VLQ编码转换后得出映射信息

[0,0,0,0,0], [4,0,0,4,1], [2,0,0,4]
  • [0,0,0,0,0] 对应源文件的标识符let
  • [4,0,0,4,1] 对应源文件的变量名a
  • [2,0,0,4] 对应源文件的标识符;

当浏览器加载包含SourceMap注释的JS文件时,会在开发者工具打开时自动下载并解析对应的.map文件。即使没有显式打开开发者工具,现代浏览器也会在控制台报错时使用SourceMap信息。然后通过VLQ编码解析得到一串数组如[0,0,0,0,0], [4,0,0,4,1], [2,0,0,4],当浏览器遇到断点或者错误时,根据一串数组找到源文件定位到具体的行和列,然后高亮或者报错。

小结

通过本文的学习,我们深入了解了SourceMap在前端开发中的重要作用:

  1. 调试利器:SourceMap解决了编译后代码难以调试的问题,让我们能够在浏览器中直接调试原始源代码
  2. 灵活配置:Webpack提供了多种devtool配置选项,我们可以根据开发和生产环境的不同需求选择合适的SourceMap策略
  3. 底层原理:SourceMap通过VLQ编码和映射关系,实现了编译后代码与源代码之间的精确定位

掌握SourceMap的工作原理和配置技巧,能够显著提升我们的开发效率和调试体验。希望本文能帮助大家更好地理解和使用这一重要工具!

❌
❌