Skip to content

Repository files navigation

DEMO一. pnpm 和npm的不同点

图为在Git bash中 分别创建两个不同的依赖(npm pnpm)创建的相同的vue文件

未加载成功 其显示出的pnpm与npm所依赖的node_modules大小几乎一致 原因是二者的vue项目目前都是空的 依赖包数量少 体积小 如果创建了n个npm项目 那么内存就会成为n倍 而同样的情况下 pnpm的所占内存不变

查询inode等node_modules 目录内容 都是输入 ls -la node_modules/

如果vue和pnpm系统分区相同 那么他们的inode是相同的 反之一定不相同

相同则说明确实证明是硬链接,不是复制 这极大地省下了巨大的内存空间


DEMO二.Vite 多环境配置与 mode 运行时验证

未加载成功 未加载成功

这两张图说明确实证明是硬链接,不是复制 这极大地省下了巨大的内存空间

证明pnpm强制的严格隔离性 lodash未申明未依赖 绝不安装

未加载成功

未在所属环境中 引入VITE_前缀的变量 在界面内直接引用 会报undefined

未加载成功 未加载成功

修改vite.config.js 以及为什么要修改

不修改它,Vite 就用默认配置:

1.端口 5173 2.不自动打开浏览器3.不生成 sourcemap 4.不区分环境加载变量(只能用默认的 development/production)

修改它,可以定制 Vite 的行为:

1.改端口2.自动打开浏览器3.生成 sourcemap 4.根据 command 和 mode 做条件判断5.配置代理、别名、插件等

改完之后他会在终端中显示体现 alt text

import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; import fs from 'fs'; import path from 'path';

// ============================================================ // 这部分代码在 Node 环境执行(终端里看输出) // ============================================================ console.log('\n========== Vite 配置文件加载(pnpm 环境)=========='); console.log('当前命令:', process.argv[2]); // 'serve' 或 'build' console.log('当前工作目录:', process.cwd());

// 验证 pnpm 的 .pnpm 目录是否存在 const pnpmDir = path.join(process.cwd(), 'node_modules', '.pnpm'); if (fs.existsSync(pnpmDir)) { console.log('✅ 检测到 pnpm 的 .pnpm 目录(严格隔离模式)');

// 读取 .pnpm 目录下的内容,展示 pnpm 的存储结构 const items = fs.readdirSync(pnpmDir).slice(0, 6); console.log(' .pnpm 目录内容(前6个):', items.join(', ')); } else { console.log('❌ 未检测到 .pnpm 目录,当前可能不是 pnpm 项目'); }

// 验证 node_modules 顶层只有声明过的依赖 const nodeModulesDir = path.join(process.cwd(), 'node_modules'); const topLevelDeps = fs.readdirSync(nodeModulesDir).filter((name) => !name.startsWith('.')); console.log(' node_modules 顶层依赖:', topLevelDeps.join(', ')); console.log('==================================================\n');

// ============================================================ // Vite 配置导出 // ============================================================ export default defineConfig(({ command, mode }) => { console.log('>> defineConfig 接收到的 command:', command); console.log('>> defineConfig 接收到的 mode:', mode); console.log('>> 将加载 .env.' + mode + ' 文件');

return { plugins: [vue()],

// 开发服务器配置
server: {
  port: mode === 'test' ? 3000 : 5173,
  open: true, // 自动打开浏览器
},

// 构建配置
build: {
  sourcemap: true, // 生成 sourcemap 便于调试
},

}; });

添加的代码部分作用

拓展工具:fs+path 是为了做路径处理、本地文件读写,绝大多数用来配置路径别名、本地 https 证书

console.log('当前命令:', process.argv[2]); // 'serve' 或 'build' process.argv[]是node函数内置数组,记录终端启动的整条命令

console.log('当前工作目录:', process.cwd()); //获取你执行命令的文件夹路径(项目根目录)

2、核心:检测是否为 pnpm 项目(最关键)

const pnpmDir = path.join(process.cwd(), 'node_modules', '.pnpm'); if (fs.existsSync(pnpmDir)) { //fs.existsSync:Node 文件 API,判断这个文件夹是否存在 // 找到node_modules/.pnpm,判定是pnpm const items = fs.readdirSync(pnpmDir).slice(0,6); console.log('✅ 检测到 pnpm 的 .pnpm 目录(严格隔离模式)'); console.log(' .pnpm 目录内容(前6个):', items.join(',')); } else { console.log('❌ 未检测到 .pnpm 目录,当前可能不是 pnpm 项目'); }

const nodeModulesDir = path.join(process.cwd(), 'node_modules'); // 拼接路径:获取当前命令行所在根目录下 node_modules 文件夹的绝对路径

// 读取 node_modules 整个目录下所有文件/文件夹名称 // filter 过滤规则:保留「不以英文点 . 开头」的内容,剔除 .pnpm、.DS_Store 这类隐藏文件/隐藏目录 const topLevelDeps = fs.readdirSync(nodeModulesDir).filter(name => !name.startsWith('.'));

// 将过滤后的顶层依赖数组用英文逗号拼接为字符串,打印到终端 console.log(' node_modules 顶层依赖:', topLevelDeps.join(','));

返回Vite最终配置对象

return { // 注册vue插件,让Vite支持解析.vue单文件组件 plugins: [vue()],

开发服务相关配置,只在pnpm dev生效

server: {
  port: mode === 'test' ? 3000 : 5173, // 固定本地开发端口为5173,不使用随机端口
  <!-- defineConfig 回调写法优势:可以根据mode/command做动态配置 -->
  open: true, // 启动服务后自动打开默认浏览器访问项目地址
},

打包构建配置,只在pnpm build打包时生效

build.sourcemap: true 生成 .map 文件,方便调试时看到源码位置(工程化实战中,这用于线上报错定位)

检测开发环境 pnpm dev

alt text

检测自定义 staging 环境 pnpm build -- --mode staging

alt text

检测生产环境 pnpm build -- --mode test pnpm preview

alt text

需要切回开发模式的话 直接pnpm dev即可 毫不影响 正是工程化“构建与交付分离”的核心设计思想


DEMO3、ESLint + Prettier + Husky + lint-staged 全流程卡点实战 (直接在Demo2内操作)

在项目根目录执行:

第一步: 安装 ESLint 及相关 Vue 3 规则集

pnpm add -D eslint eslint-plugin-vue @vue/eslint-config-typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser 包的作用:

安装 Prettier 及相关插件

pnpm add -D prettier eslint-config-prettier eslint-plugin-prettier

安装 Husky 和 lint-staged

pnpm add -D husky lint-staged

第二步:配置ESLint cjs文件

比如说:.eslintrc.cjs

为什么在ESModule 项目中要使用CommonJS的写法? :如果起名为xxxx.js Node会把它当成ESM ESM不可以直接写module.exports 语法报错 所以说要用xxx.cjs 强制走CommonJS 写法固定成熟 几乎不会出现解析BUG 稳定性更高

第三步:创建 Prettier 配置文件

项目根目录创建 .prettierrc.cjs:

第四步:创建 Prettier 忽略文件

在该文件中声明的文件/目录 Prettier 会跳过这些目录/文件,不进行格式化。 一般就是dist/ node*modules/ *.log _.lock _.yaml _.yml _.html _.svg 这些文件 总而言之 就是Prettier 只管手写的业务源码(ts、js、vue、css 等);

第五步:在 package.json 中添加 scripts 和 lint-staged 配置

打开 package.json,在 "scripts" 中添加以下命令,并在根节点添加 "lint-staged" 字段:

具体操作:只改 3 个地方

  1. 在 "scripts" 里添加 5 个新命令(原有的 dev、build、preview 不要动) json { "scripts": { "lint": "eslint . --ext .vue,.js,.ts --fix", // ← 新增 "format": "prettier --write .", // ← 新增 "lint:check": "eslint . --ext .vue,.js,.ts", // ← 新增 "format:check": "prettier --check .", // ← 新增 "prepare": "husky" // ← 新增 //"prepare": "husky" 的作用是让 pnpm install 时自动初始化 Husky,这样团队成员克隆项目后只需 pnpm install,Git Hooks 就会自动配置好 } }
  2. 在 package.json 的根节点(和 "scripts" 平级)添加 "lint-staged" 字段 json { "name": "demo2-env", "private": true, "version": "0.0.0", "scripts": { /_ ... / }, "lint-staged": { // ← 整个字段都是新增的 ".{js,ts,vue}": [ "eslint --fix", "prettier --write" ], ".{css,scss}": [ "prettier --write" ], ".{json,md}": [ "prettier --write" ] }, "dependencies": { /_ ... / }, "devDependencies": { / ... */ } // ← 新增的 devDependencies 也在这里 }
  3. 在 "devDependencies" 中添加新包的列表(原有的 @vitejs/plugin-vue、vite、vue 等都要保留) json { "devDependencies": { "typescript": "^5.5.3", "vue-eslint-parser": "^9.4.3" } }

第六步:初始化 Husky 在Git bash终端中输入

必须是"prepare": "husky install" 否则钩子无法初始化!!

Husky 9.x 的初始化方式和使用 package.json 中的 "prepare": "husky" 脚本自动触发。为了让 Husky 立即生效,手动执行一次: pnpm run prepare

但需要在当前文件夹内部申明 Git Husky若无Git(git init)存在 则无法初始化

这会在项目根目录创建 .husky/ 文件夹。然后手动创建 pre-commit 钩子文件:

一键生成钩子pre-commit :npx husky hook add pre-commit "npx lint-staged"

查看 .husky/pre-commit 文件内容,确保它是这样的: #!/bin/sh . "$(dirname "$0")/_/husky.sh" //里面没有\

pnpm lint-staged

这个文件的作用: 当你执行 git commit 时,Git 会先执行这个脚本。脚本调用 pnpm lint-staged,而 lint-staged 只会检查你本次 git add 的那些文件。

第七步:验证安装是否成功

验证 1:ESLint 能正常运行 pnpm lint 验证 2:Prettier 能正常运行 pnpm format 验证 3:lint-staged 能正常运行 git add src/App.vue //先 add 一个文件到暂存区 pnpm lint-staged alt text 发现代码报错 原因是 eslint版本过新 不支持原本的.eslintrc.cjs文件 需要改用eslint.config.js

解决方案:

方案一:(最简单推荐:降级 ESLint 到 8.x,适配你现有的.eslintrc.cjs) 新版本 ESLint 改动极大,大量规则写法全部重写,新手最稳妥是降级回稳定的 v8 版本,兼容你现在所有配置: 卸载现有高版本 eslint 全套 powershell pnpm remove eslint eslint-plugin-vue @vue/eslint-config-typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser 固定安装 8 系列稳定版 powershell pnpm add -D eslint@8 eslint-plugin-vue @vue/eslint-config-typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser 再次执行 powershell pnpm lint .eslintrc.cjs 可以正常被识别,不用改配置代码。 方案二:(保留 ESLint 10,全面迁移新版配置) 需要做 3 件事: 把根目录的 .eslintrc.cjs 重命名为 eslint.config.js 把配置语法从 CommonJS module.exports 改成 ESM export default,重写整套规则格式(语法变动非常多) 适配插件导入写法,大部分旧规则名失效需要替换 pnpm add -D @eslint/js typescript-eslint eslint-config-prettier //10代需要添加的扁平包 缺点:改动量大,你整套 Vue+TS 规则都要重写,不适合现阶段。

ESLint 9/10 是大版本破坏性更新,抛弃传统 .eslintrc 所有格式,强制全新扁平化配置

alt text 运行正常 但是一堆报错 是因为他扫描了一堆本不该扫描的文档/文件 可以改为"lint": "eslint src --ext .vue,.js,.ts --fix", alt text 可以发现 改完不扫描无关文档/文件 就不会报错了

alt text Prettier正常运行

alt text Lint-staged正常运行

第八步:故意写一段“坏代码”(验证卡点)

如:故意用双引号,故意不加分号等

第九步:提交坏代码(观察被拦截)

#将坏代码添加到暂存区 git add src/App.vue

#尝试提交 git commit -m "test: 故意提交坏代码验证 ESLint 拦截" 而后就会看到Husky的日志 报错 阻止提交

ESLint 和 Prettier 谁先执行?

lint-staged 配置里从左到右依次执行:["eslint --fix", "prettier --write"]。先 ESLint 修代码风格,再 Prettier 格式化,避免相互覆盖

--fix 修不了的问题怎么办?

自动修复失败时,lint-staged 会终止提交,你需要手动修复后再 add 提交。这是故意的——宁可不让提交,也不能让坏代码进仓库。


DEMO4 Vite 分包策略与路由懒加载效果验证

第一步:在现有项目里创建 3 个页面 Home User Admin

第二步:安装并配置路由

pnpm add vue-router 在router/index.ts里加上 // src/router/index.ts import { createRouter, createWebHistory } from 'vue-router';

// ⭐ 关键:所有页面都用 () => import() 实现懒加载 const routes = [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue'), }, { path: '/user', name: 'User', component: () => import('@/views/User.vue'), }, { path: '/admin', name: 'Admin', component: () => import('@/views/Admin.vue'), }, ];

const router = createRouter({ history: createWebHistory(), routes, });

export default router;

在这段代码中 用component:()=>import('文件地址')实现路由的懒加载

第三步:修改(添加几行代码) src/main.ts 注册路由

import router from './router';

app.use(router);

第四步:修改 src/App.vue 显示路由出口

🌍 Vite 分包验证 Demo

当前环境:{{ mode }}

//import.meta.env.MODE无法在非js/ts我机组生效 此处现在替换为了mode 同时在下方声明
<nav style="margin: 20px 0; padding: 12px; background: #f5f5f5; border-radius: 6px;">
  <router-link to="/" style="margin-right: 20px;">🏠 首页</router-link>
  <router-link to="/user" style="margin-right: 20px;">👤 用户中心</router-link>
  <router-link to="/admin">🔐 管理后台</router-link>
</nav>

<hr />

<!-- 路由出口:当前页面的内容会渲染在这里 -->
<router-view />
<script setup> console.log('App.vue 加载了(主入口)'); const mode =import.meta.env.MODE </script>

第五步:修改 vite.config.ts 配置分包

这一步是关键——让 Vite 在打包时主动拆分代码: 修改vite.config.ts 保留所有原有配置,只新增 resolve 和 build.rollupOptions

export default defineConfig({ plugins: [vue()],

resolve: { // 配置路径别名,让 @ 指向 src/ alias: { '@': path.resolve(__dirname, 'src'), }, },

build: { // 生成 sourcemap,便于调试时定位源码位置 sourcemap: true, rollupOptions: { output: {

分包拆分规范:手动分包:把 Vue 核心库单独打成一个 vendor 包 !!!!此处最新版Rollup要求使用函数形式而非对象形式

sourcemap的作用不只是为了便于调试时定位源码位置 当你开启 sourcemap: true,Vite 构建时会生成一个 .map 文件

.map 文件里记录了“压缩后的代码”和“原始代码”之间的映射关系(JSON 格式),比如:浏览器会自动读取 .map 文件(只要 DevTools 是打开状态),在 Sources 面板里直接显示你原始的 math.ts 代码。

现在报错看到的是:

text add@src/utils/math.ts:3 而不是:

text o@index-abc123.js:1

Debug 效率提升 10 倍。 这就是 SourceMap 的价值。

manualChunks(id) { // 所有以 'vue' 开头的包(vue、vue-router 等)都打包到 vue-vendor 中 if (id.includes('vue') || id.includes('vue-router')) { return 'vue-vendor' } }, // 每个 chunk 的文件名格式,[name] 会自动取 manualChunks 里定义的 key

chunk里的[hash]值的作用:每次构建会生成唯一的hash值,重新构建会生成一个新的hash值,浏览器就会认为他变了,会重新强制加载一次缓存;如果hash值不变,则浏览器就只会一直读缓存里的,不会重复下载

chunkFileNames: 'assets/[name]-[hash].js', }, }, }, });

第六步:在终端一次性验证所有场景

在开发环境下: pnpm dev : 不做完整打包,按需加载文件,启动,热更新极快

alt text 可以看出:所有 .vue 组件是通过 ?import 请求分别加载的(因为 Vite 在开发模式下不做打包合并) alt text 再次观察:打开其他界面时(切换路由),浏览器network面板可以看见该界面的动态加载

在生产环境下: pnpm build

1.读取环境变量2.解析入口3.构建依赖图谱4.插件转换(把输入的.vue,.ts,.less转换为.js,.css 5.摇树优化6.代码拆分7.压缩混淆8.输出dist/目录+清单(manifest) alt text

在预览环境下: pnpm preview(最重要的一步)

alt text 明显的发现Network面板少了不少加载的东西 没有出现 User-xxx.js 或 Admin-xxx.js。说明它们没有被加载,实现了“按需加载” 然后点击导航栏的“用户中心”,观察 Network 面板,或者也可以观察console面板 发现这时出现了我们点击对应的路由的名字 alt text

这就是路由懒加载的效果——用户不访问的页面,代码不会提前加载

如果失去分包规范,即失去manualChunks的功能 会出现:文件重复下载

manualChunks的功能:分包规范,Rollup 原生提供的手动分包配置项,Vue 核心库只加载一次,后续页面直接读浏览器缓存,减少重复下载,这个文件的 hash 只在这些库的版本号变化时才会改变——你改业务代码不会让 vendor 的 hash 变化,用户不需要重新下载 85KB 的 Vue 核心库,只下载被改动的业务 chunk

该步骤核心:分包的核心目标就是把首页必须加载的 JS 压缩到最小


DEMO5:基于 fs + inquirer 的自动化代码生成器

第一步:安装依赖

用 Node.js 脚本,通过命令行交互询问用户,然后自动创建 view、api、store 三个目录下的文件骨架 inquirer 从 v9 开始是纯 ESM 包,如果项目是 ESM("type": "module"),直接 import 即可。但我们这里用 CommonJS 方式写脚本(方便在终端直接运行),所以使用 inquirer@8.2.6(最后一个支持 CommonJS 的版本) 最新版本: pnpm add -D inquirer @types/inquirer 支持cjs写法的8.2.6版本: pnpm add -D inquirer@8.2.6

第二步:创建脚本文件

在项目根目录下创建scripts文件夹,然后创建 generate.ts 解决了:日常开发每个新业务模块都要手动新建 3 个文件、重复写基础模板,效率低且格式不统一: 不用反复复制粘贴 Vue、API、Pinia 样板代码 强制统一全项目代码格式、命名规范(大驼峰接口 / 小驼峰文件) 自带 TS 类型模板,不用从零手写 interface 内置文件覆盖保护,防止误删已有代码,支持--force强制覆盖

运行使用方式: 常规唤起交互 ts-node generate.ts

第三步:在 package.json 中添加脚本命令

"gen": "node scripts/generate.js"

第四步:创建必要的目录结构

在 src/ 下创建 api/、stores/modules/、views/ 目录(如果还没有的话): //mkdir = make directory -p 父目录不存在时自动逐级创建;文件夹已经存在也不会报错,静默跳过

mkdir -p src/api mkdir -p src/stores/modules mkdir -p src/views

同时需要创建 src/utils/request.ts(API 模板里引用了它): // src/utils/request.ts import axios from 'axios';

const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, });

// 响应拦截器:直接返回 data request.interceptors.response.use( (response) => response.data, (error) => Promise.reject(error) );

export default request;

第六步:执行脚本生成模块

pnpm gen alt text

第七步:验证生成的文件

在src/views内部新生成了一个新的文件夹product 里面有新生成的index.vue 以及在api/中生成的product.ts

第八步:将生成的路由注册到 Vue Router

打开 src/router/index.ts,在 routes 数组中新增一个路由: // ========== 新增:自动生成的 product 路由 ========== { path: '/product', name: 'Product', component: () => import('@/views/product/index.vue'), },

第九步:在首页添加导航链接

打开views/Home.vue 在导航区添加 /product 的链接 去产品页

第十步:验证完整流程

pnpm dev

真正作用:大量的模块只需要在终端输入pnpm gen输入多次 输入多次模块名,就可以省去大量时间。并且这个脚本“目录分层规范”变成了一条自动化生产线。 新人入职第一天跑一遍 pnpm gen product,生成的文件结构跟团队老员工的一模一样,不需要有人口头告诉他“你要在 api 下建文件、还要按这个格式写”————让机器帮你执行规范,而不是靠人脑记


DEMO 6 : GitHub Actions CI 流水线

git push 代码到 GitHub 时,GitHub 自动拉取代码 → 安装依赖 → 代码校验(ESLint) → 执行测试(Vitest) → 打包构建。任何一步失败,流水线标红,阻止代码合并。

第一步:在项目中安装 Vitest(测试框架)

pnpm add -D vitest @vue/test-utils jsdom @vitest/coverage-v8 : vitest:测试运行器(Vite 原生集成,启动极快) @vue/test-utils:Vue 组件测试工具库,用于挂载和交互 Vue 组件 jsdom:在 Node 环境里模拟浏览器 DOM API(如 document、window),让组件测试能在 Node 中运行 @vitest/coverage-v8:测试覆盖率统计工具(V8 引擎原生支持)

第二步:写一个简单的单元测试(验证用)

// src/utils/__tests__/math.test.ts import { describe, it, expect } from 'vitest';

// 一个简单的加法函数(直接写在测试里,不用单独建文件) function add(a: number, b: number): number { return a + b; }

describe('add 函数', () => { it('1 + 2 应该等于 3', () => { expect(add(1, 2)).toBe(3); });

it('-1 + 1 应该等于 0', () => { expect(add(-1, 1)).toBe(0); }); });

第三步:在 package.json 中添加测试命令(加两行就行)

"test": "vitest run", "test:coverage": "vitest run --coverage"

第四步:本地验证测试能跑通

pnpm test

预期结果: alt text 如果显示 Test Files 1 passed,说明测试跑通了。如果没有输出或报错,检查是否安装了 vitest,以及 tests 目录是否在 src/utils/ 下

第五步:在 GitHub 上创建仓库并推送代码

这一步先确认你已经把代码推到 GitHub 了: git remote -v 然后把代码推送上去,确保 GitHub 仓库里有你的代码。

第六步:创建 GitHub Actions 流水线配置文件 !!一定要在总文件的根目录配置 否则检测不出来!!

创建 .github/workflows/ci.yml:

name: CI 流水线

on: push: branches: [main] pull_request: branches: [main]

jobs: build-and-test: # 运行环境:最新 Ubuntu runs-on: ubuntu-latest

steps:

  • name: 检出代码 uses: actions/checkout@v4
  • name: 安装 pnpm uses: pnpm/action-setup@v3 with: version: 8
  • name: 安装 Node.js uses: actions/setup-node@v4 with: node-version: '18' cache: 'pnpm'
  • name: 安装依赖 run: pnpm install
  • name: 代码规范检查 run: pnpm lint
  • name: 单元测试 run: pnpm test
  • name: 构建项目 run: pnpm build
提交并推送: git add .github/workflows/ci.yml git commit -m "feat: 添加 GitHub Actions CI 流水线" git push

第七步:观察流水线执行结果

打开你的 GitHub 仓库页面

点击 Actions 标签页

你会看到 CI 流水线 正在运行(黄色圆点表示运行中)

点击进去,可以看到每一步的执行日志 alt text alt text报错详细说明 流水线标红 ❌,阻止了这次提交的合并(如果你配置了分支保护规则,这个 PR 会被自动标记为不可合并)


Demo 7:Playwright 端到端(E2E)测试(对应导图中的 E2E 部分)

验证: 用 Playwright 自动打开浏览器,模拟用户访问首页 → 点击“用户页” → 检查页面是否正常。

对应你导图中的: Vitest, Playwright E2E 测试 自动执行冒烟测试,验证页面可以正常访问,接口 500 无报错

第一步:安装 Playwright

pnpm add -D @playwright/test npx playwright install:npx playwright install 会下载 Chromium、Firefox 和 WebKit 三个浏览器内核,用于在不同浏览器中运行测试,下载时间可能较长 可以只安装 Chromium 节省时间:npx playwright install Chromium 也可以安装edge:Playwright 里 Edge 命名为 msedge :npx playwright install msedge

第二步:创建 E2E 测试文件

创建 e2e/homepage.spec.ts: // e2e/homepage.spec.ts import { test, expect } from '@playwright/test';

test.describe('首页冒烟测试', () => { test('首页应该正常加载', async ({ page }) => { // 访问首页 await page.goto('http://localhost:5173/');

// 检查页面标题是否包含 "Vite 分包验证 Demo"
await expect(page.locator('h1')).toContainText('Vite 分包验证');

});

test('点击用户页链接应该跳转到 /user', async ({ page }) => { await page.goto('http://localhost:5173/');

// 点击 "用户中心" 链接
await page.click('text=用户中心');

// 验证 URL 变成了 /user
await expect(page).toHaveURL(/.*\/user/);

// 验证页面出现了 "用户中心" 标题
await expect(page.locator('h1')).toContainText('用户中心');

}); });

第三步:在 package.json 中添加 E2E 测试命令

{ "scripts": { "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" } }

第四步:本地验证 E2E 测试(注意: Playwright 需要开发服务器正在运行。开两个终端:)

终端 1(启动开发服务器):

pnpm dev

终端 2(运行 E2E 测试):

pnpm test:e2e 预期: Running 2 tests using 1 worker

✓ e2e/homepage.spec.ts:4:7 › 首页冒烟测试 › 首页应该正常加载 (2.1s) ✓ e2e/homepage.spec.ts:10:7 › 首页冒烟测试 › 点击用户页链接应该跳转到 /user (1.5s)

2 passed (4.2s)

第五步:把 E2E 测试加入 CI 流水线

修改 .github/workflows/ci.yml,在 build 步骤之后添加 E2E 测试步骤:

  • name: 构建项目 run: pnpm build
  • name: 安装 Playwright 浏览器 run: npx playwright install --with-deps msedge

  • name: 运行 E2E 测试 run: | pnpm preview & npx wait-on http://localhost:4173 pnpm test:e2e env: CI: true

第六步:提交并推送,观察流水线

git add . git commit -m "feat: 添加 Playwright E2E 测试并集成到 CI" git push

打开 GitHub Actions,会看到流水线中新增了 “安装 Playwright 浏览器” 和 “运行 E2E 测试” 两个步骤,全部通过后流水线变绿。


Demo 8:性能监控 SDK(对应导图中的“运行时监控层”)

监控LCP、CLS、FID 等 Web Vitals 指标,并上报到监控平台。 LCP → 页面主要内容加载完成 FID → 用户首次交互响应延迟 CLS → 页面视觉稳定性 采集LCP/CLS/FID/FCP/TTFB五大前端性能指标,打印日志,可对接接口上报后端

第一步:创建性能监控脚本

创建 src/utils/performanceReporter.ts:

// src/utils/performanceReporter.ts // 定义性能指标上报的数据TS类型约束 interface ReportData { // 当前页面完整URL地址 url: string; // 最大内容绘制,可选,单位ms lcp?: number; // 累积布局偏移,可选 cls?: number; // 首次输入延迟,可选(FID已废弃,浏览器主推INP) fid?: number; // 首次内容绘制,可选,ms fcp?: number; // 首字节时间,服务器返回首个字节耗时,ms ttfb?: number; // 性能采集时间戳(毫秒时间戳) timestamp: number; }

/**

  • 页面核心Web Vitals性能采集函数
  • 采集LCP/CLS/FID/FCP/TTFB五大前端性能指标,打印日志,可对接接口上报后端 */ export function reportWebVitals() { // 初始化性能上报对象,基础字段:当前页面地址、采集时间戳 const report: ReportData = { url: window.location.href, timestamp: Date.now(), };

// ====================== 1. 监听 LCP 最大内容绘制 ====================== // PerformanceObserver:浏览器原生性能监听API,异步捕获性能条目 new PerformanceObserver((list) => { // 获取本次回调所有性能条目数组 const entries = list.getEntries(); // LCP会多次触发,取最后一次(最终最大元素) const lastEntry = entries[entries.length - 1]; if (lastEntry) { // 赋值LCP耗时:页面开始加载到最大可视元素渲染完成的毫秒数 report.lcp = lastEntry.startTime; console.log('[性能监控] LCP:', report.lcp); // 可放开注释,把完整指标上报后端接口 // sendToServer(report); } // 监听的性能类型:largest-contentful-paint 专门对应LCP指标 }).observe({ entryTypes: ['largest-contentful-paint'] });

// ====================== 2. 监听 CLS 累积布局偏移 ====================== // 用来累加所有布局偏移值 let clsValue = 0; new PerformanceObserver((list) => { // 遍历本次所有布局偏移记录 for (const entry of list.getEntries() as any[]) { // hadRecentInput:用户近期是否有点击/输入操作 // 规范要求:用户主动交互引发的布局偏移不计入CLS,只算意外抖动 if (!entry.hadRecentInput) { clsValue += entry.value; } } // 把累加后的总偏移存入上报对象 report.cls = clsValue; console.log('[性能监控] CLS:', report.cls); // 监听页面布局变动事件 }).observe({ entryTypes: ['layout-shift'] });

// ====================== 3. 监听 FID 首次输入延迟 ====================== new PerformanceObserver((list) => { const entries = list.getEntries(); // FID只取页面第一次用户交互的记录 const firstEntry = entries[0] as any; if (firstEntry) { // 计算延迟:浏览器收到用户输入时间 → 浏览器真正开始处理输入的时间差 report.fid = firstEntry.processingStart - firstEntry.startTime; console.log('[性能监控] FID:', report.fid); } // first-input:浏览器首次用户输入行为(点击、按键等) }).observe({ entryTypes: ['first-input'] });

// ====================== 4. 监听 FCP 首次内容绘制 ====================== new PerformanceObserver((list) => { const entries = list.getEntries(); const firstEntry = entries[0]; if (firstEntry) { // startTime:从页面初始化到页面第一次渲染出文字/图片等内容的耗时 report.fcp = firstEntry.startTime; console.log('[性能监控] FCP:', report.fcp); } // paint 绘制类指标,包含 FCP / LCP 等绘制事件 }).observe({ entryTypes: ['paint'] });

// ====================== 5. 监听 TTFB 首字节时间 ====================== new PerformanceObserver((list) => { const entries = list.getEntries(); // 遍历导航性能条目 for (const entry of entries) { // 筛选页面导航类型的性能数据 if (entry.entryType === 'navigation') { // 类型断言成导航详细性能对象 const navEntry = entry as PerformanceNavigationTiming; // TTFB = 浏览器发起请求开始 到 接收到服务器第一个字节的间隔 report.ttfb = navEntry.responseStart - navEntry.requestStart; console.log('[性能监控] TTFB:', report.ttfb); } } // 监听页面导航(页面刷新、跳转)全链路耗时 }).observe({ entryTypes: ['navigation'] }); }

第二步:在 main.ts 中启用性能监控

第三步:验证(用 Lighthouse 跑分对比)

打开 Chrome DevTools → Lighthouse 面板 点击 Analyze page load(分析页面加载) 运行完成后,你会看到 LCP、CLS、FID 的官方评分 对比脚本里 console.log 打印出来的数值,应该基本一致

但是我们前面ESLint把console全部都给Error了 所以现在需要去把他关闭掉才会让她再次显示

在eslint里把eslintrc.cjs里的rule里的 no-console的内容 改成 'off' (必须是单引号) 就像这样: alt text


Demo 9:自动化部署脚本

这个demo验证: 写一个 Node.js 脚本,模拟 Jenkins/GitLab CI 的部署流程——备份当前版本 → 上传新版本 → 刷新 Nginx → 验证页面可访问(在本地模拟,不真的操作服务器)。

第一步:创建部署脚本

创建 scripts/deploy.ts: #!/usr/bin/env ts-node // scripts/deploy.ts import _ as fs from 'fs'; import _ as path from 'path'; import { execSync } from 'child_process'; import { fileURLToPath } from 'url';

const **filename = fileURLToPath(import.meta.url); const **dirname = path.dirname(__filename);

const DIST_DIR = path.join(**dirname, '../dist'); const BACKUP_DIR = path.join(**dirname, '../backups');

// 1. 校验dist文件夹存在 if (!fs.existsSync(DIST_DIR)) { console.error('❌ dist 目录不存在,请先执行 pnpm build'); process.exit(1); }

// 2. 创建备份目录(递归创建) if (!fs.existsSync(BACKUP_DIR)) { fs.mkdirSync(BACKUP_DIR, { recursive: true }); }

// 3. 生成时间戳版本号,替换冒号小数点避免路径非法字符 const version = new Date().toISOString().replace(/[:.]/g, '-'); const backupPath = path.join(BACKUP_DIR, dist-${version});

console.log(📦 备份当前版本到: ${backupPath}); fs.cpSync(DIST_DIR, backupPath, { recursive: true });

// 4. 复制产物到部署目录 const DEPLOY_DIR = path.join(__dirname, '../deploy'); if (!fs.existsSync(DEPLOY_DIR)) { fs.mkdirSync(DEPLOY_DIR, { recursive: true }); }

console.log('📤 上传新版本到部署目录...'); fs.cpSync(DIST_DIR, DEPLOY_DIR, { recursive: true });

// 5. 模拟重载Nginx console.log('🔄 模拟重载 Nginx...');

// 6. 冒烟测试 console.log('🧪 执行冒烟测试...'); try { const result = execSync('curl -s -o /dev/null -w "%{http_code}" http://localhost:5173', { timeout: 5000, }).toString().trim();

if (result === '200') { console.log('✅ 冒烟测试通过!首页可访问'); } else { console.log(⚠️ 冒烟测试异常:HTTP ${result}); } } catch (error) { console.log('⚠️ 冒烟测试失败(开发服务器未启动,请先 pnpm dev)'); }

console.log(\n🎉 部署完成!版本号: ${version}); console.log(📌 备份位置: ${backupPath}); console.log(📌 回滚命令: cp -r ${backupPath}/* ${DIST_DIR}/);

第二步:在 package.json 中添加部署命令

{ "scripts": { "deploy": "pnpm build && node scripts/deploy.ts" } }

deploy文件夹

相当于:生产服务器上正在运行的 Web 服务根目录(比如 Nginx 指向的 /usr/share/nginx/html)。

里面的内容:assets/ 目录下存放着 index-yMkJyNDY...(入口 JS)和 vue-vendor-Ys7F...(第三方库 JS)。

怎么生成的:当执行 pnpm deploy 时,脚本会把构建好的 dist/ 文件夹完整复制一份到这里。如果服务器部署,运维就会把这里的内容挂载给用户访问。

backup文件夹

2.(操作2) Backups 文件夹(历史版本快照) 相当于:生产环境上的“版本历史仓库”。每次你执行 pnpm deploy,脚本都会先把当前的 deploy 文件夹压缩打包(以时间戳命名),放进 Backups 里。

为什么要有它:为了 Demo 10(版本回滚)。如果新的 deploy 版本出了严重 Bug,你可以执行 pnpm rollback,脚本会从 Backups 里挑一个旧版本,直接覆盖回 deploy,实现“秒级回滚”。

这两个文件夹作用:deploy 是正在运行的现场,Backups 是时间机器。你不需要真的买一台云服务器,就在本机把“持续交付(CD)”的核心流程亲手跑了一遍

为什么文件名全是乱码?(DEMO4

这是 Vite 构建时加的 Hash(哈希值)。 index-yMkJyNDY.js 里的 yMkJyNDY 是根据 index.js 的文件内容算出来的。内容变了,Hash 就变。 作用:强制浏览器刷新缓存。如果你改了一行代码,新版本的 index-新Hash.js 会被加载,而旧的 index-旧Hash.js 会留在 Backups 里,不会被浏览器误用

第三步:验证

pnpm deploy

预计输出结果:

未加载成功


Demo 10:版本回滚机制( 写一个回滚脚本,列出所有备份版本,选择其中一个快速恢复,模拟线上出问题后秒级回滚)

第一步:创建回滚脚本 scripts/rollback.ts

#!/usr/bin/env ts-node // scripts/rollback.ts

import _ as fs from 'fs'; import _ as path from 'path'; import * as readline from 'readline'; import { fileURLToPath } from 'url';

const **filename = fileURLToPath(import.meta.url); const **dirname = path.dirname(__filename);

const DIST_DIR = path.join(**dirname, '../dist'); const BACKUP_DIR = path.join(**dirname, '../backups');

// 1. 检查备份目录 if (!fs.existsSync(BACKUP_DIR)) { console.error('❌ 没有找到备份目录'); process.exit(1); }

// 2. 列出所有备份版本 const backups = fs.readdirSync(BACKUP_DIR) // 过滤:只保留以 dist- 开头的备份文件夹,排除无关文件 .filter(name => name.startsWith('dist-')) // 按字符串升序排序(时间戳命名,旧版本靠前) .sort() // 数组倒序翻转,最新的备份排在最前面 .reverse();

if (backups.length === 0) { console.error('❌ 没有找到任何备份版本'); process.exit(1); // 终止node进程,返回异常退出码 }

// 打印提示文案 console.log('📋 可用的备份版本:'); // 遍历所有备份,按序号打印给用户看 backups.forEach((name, index) => { console.log( ${index + 1}. ${name}); });

// ========== 开始交互式命令行输入 ========== // 引入readline模块,创建命令行交互实例,读取键盘输入、控制台输出 const rl = readline.createInterface({ input: process.stdin, // 输入来源:终端键盘 output: process.stdout, // 输出位置:终端控制台 });

// 弹出问题,等待用户输入版本数字,回车后执行回调函数 rl.question('\n请选择要回滚到的版本编号(输入数字):', (answer) => { // 用户输入是字符串,转数字;用户看到的序号从1开始,数组下标从0开始,所以-1 const index = parseInt(answer.trim()) - 1;

// 合法性校验:不是数字 / 下标小于0 / 下标超过备份总数 → 输入非法 if (isNaN(index) || index < 0 || index >= backups.length) { console.error('❌ 无效的选择'); rl.close(); // 关闭命令行交互 process.exit(1); // 异常退出 }

// 根据合法下标拿到选中的备份文件夹名称 const selectedVersion = backups[index]; // 拼接得到该备份文件夹的完整绝对路径 const backupPath = path.join(BACKUP_DIR, selectedVersion);

// 二次风险提醒:告知用户要回滚的版本,提示dist会被全覆盖 console.log(\n⚠️ 即将回滚到: ${selectedVersion}); console.log( 当前 dist 将被完全替换);

// 再次弹窗确认,询问是否真的执行回滚 rl.question('确认继续?(y/N):', (confirm) => { // 用户输入不是y/Y,判定取消回滚 if (confirm.toLowerCase() !== 'y') { console.log('👋 已取消回滚'); rl.close(); return; // 终止后续逻辑 }

// 开始执行回滚操作
console.log(`🔄 正在回滚到 ${selectedVersion}...`);

// 如果当前dist文件夹存在
if (fs.existsSync(DIST_DIR)) {
  // 强制递归删除整个dist目录,recursive删文件夹、force无视权限/只读强制删除
  fs.rmSync(DIST_DIR, { recursive: true, force: true });
}
// 将选中的备份文件夹完整复制覆盖到dist
fs.cpSync(backupPath, DIST_DIR, { recursive: true });

// 回滚成功提示
console.log(`✅ 回滚完成!当前版本: ${selectedVersion}`);
rl.close(); // 关闭命令行交互,脚本正常结束

}); });

第二步:在 package.json 中添加回滚命令

{ "scripts": { "rollback": "node scripts/rollback.js" } }

第三步:验证(先创建几个备份版本(多次执行 pnpm deploy),然后执行回滚:)

pnpm rollback

效果如图所示: 未成功加载


Demo 11:CDN 加速模拟

配置 Vite 的 base 选项,让构建产物的所有资源路径指向 CDN 地址,而不是相对路径。然后用 http-server 模拟 CDN 服务器,观察资源加载变化

第一步:修改 vite.config.ts 配置 CDN 地址

base: mode === 'production' ? 'https://cdn.example.com/demo2-env/' // 生产环境走 CDN : '/', // 开发环境走相对路径

第二步:构建并观察产物路径变化

pnpm build 打开 dist/index.html,会看到:未成功加载 原本的绝对路径 变为了相对路径

第三步:用 http-server 模拟 CDN 服务器 (pnpm add -D http-server)

在 package.json 中添加模拟 CDN 的命令: "serve:cdn": "http-server dist --port 8080 --cors"(scripts下加)

第四步:验证 CDN 加载效果

在一个终端启动 CDN 服务器:pnpm serve:cdn(此时 dist/ 下的资源可以通过 http://localhost:8080/assets/xxx.js 访问) 在另一个终端启动预览:pnpm preview(预览服务器会加载 dist/index.html,里面的资源 URL 指向 https://cdn.example.com/...,但因为 CDN 地址是假的,资源会 404) 未成功加载就像这样404

那怎么验证 CDN 生效了? 将 vite.config.ts 中的 base 改为 http://localhost:8080/(指向本地 CDN 模拟服务器),重新构建,再预览,此时资源就能正常加载了: 更进一步: 如果你有一个真实的 CDN 域名(比如阿里云 OSS、腾讯云 COS),可以把构建产物上传上去,然后把 base 改成真实的 CDN 域名,就完成了生产环境的 CDN 部署。


Demo12:请求池与并发控制

验证: 模拟在一个页面里同时发起 100 个接口请求,但通过请求池控制,同一时刻最多只发 6 个(浏览器同域名并发限制),剩下的排队等待,防止浏览器崩溃或页面卡死。

第一步:创建请求池工具

创建 src/utils/requestPool.ts:

// src/utils/requestPool.ts

/**

- 请求池:控制并发请求数量,防止浏览器因并发过多崩溃

- 浏览器对同一域名的并发连接数有限制(HTTP/1.1 约 6 个 HTTP/2大得多)

*/ export class RequestPool { private limit: number; private running: number; private queue: Array<() => void>;

constructor(limit = 6) { this.limit = limit; // 最大并发数 this.running = 0; // 当前正在运行的请求数 this.queue = []; // 等待队列 }

/**

  • 执行请求

  • @param fn 返回 Promise 的请求函数(如 () => axios.get('/api'))

  • @returns 请求结果 */ async request(fn: () => Promise): Promise { // 1. 如果当前并发数已达上限,进入排队等待 if (this.running >= this.limit) { await new Promise((resolve) => { this.queue.push(resolve); }); }

    // 2. 拿到执行名额,开始执行 this.running++; console.log([请求池] 当前并发数: ${this.running}/${this.limit});

    try { // 3. 执行真实请求 const result = await fn(); return result; } finally { // 4. 请求结束(无论成功/失败),释放名额 this.running--; console.log([请求池] 释放并发,剩余: ${this.running}/${this.limit});

    // 5. 如果队列里还有等待的任务,唤醒下一个
    if (this.queue.length > 0) {
      const next = this.queue.shift();
      if (next) {
        next(); // 唤醒等待的 request 调用
      }
    }
    

    }

} }

// 导出单例(整个应用共享一个请求池) export const requestPool = new RequestPool(6);

第二步:在 Vue 组件中模拟并发请求

🌍 请求池并发控制 Demo

<!-- 按钮:点击触发批量发起20个请求 -->
<button @click="sendRequests" style="padding: 12px 24px; font-size: 16px; cursor: pointer;">
  🚀 发送 20 个并发请求
</button>
<!-- 清空日志按钮 -->
<button @click="clearLogs" style="padding: 12px 24px; font-size: 16px; cursor: pointer; margin-left: 12px;">
  🗑️ 清空日志
</button>

<!-- 黑色日志面板,模拟终端控制台 -->
<div style="margin-top: 20px; padding: 16px; background: #1e1e1e; color: #d4d4d4; border-radius: 8px; max-height: 400px; overflow-y: auto; font-size: 13px;">
  <!-- 循环渲染每一条日志,index作为key(简易日志场景可用) -->
  <div v-for="(log, index) in logs" :key="index" style="padding: 2px 0; border-bottom: 1px solid #333;">
    {{ log }}
  </div>
</div>
<script setup lang="ts"> import { ref } from 'vue'; // 导入我们实现好的全局请求池单例 import { requestPool } from '@/utils/requestPool'; // 响应式数组:存放页面上展示的所有日志文本 const logs = ref([]); /** * ✅ 就是你问的 addLog 函数! * 作用:统一新增一条日志,自动带上当前时间,推入logs数组 * 数组更新后页面自动渲染这条日志 */ const addLog = (msg: string) => { logs.value.push(`[${new Date().toLocaleTimeString()}] ${msg}`); }; // 点击按钮触发的主函数:一次性发起20个模拟请求 const sendRequests = () => { logs.value = []; // 先清空旧日志 addLog('🚀 开始发送 20 个并发请求...'); addLog(`📌 最大并发数: 6,超出部分将排队等待`); // 收集所有请求Promise,最后统一监听全部完成 const promises: Promise[] = []; // 循环创建20个请求任务 for (let i = 1; i <= 20; i++) { // 请求函数:交给请求池调度的任务 const requestFn = () => { // 模拟接口耗时:1000 ~ 3000ms 随机延迟 const delay = 1000 + Math.random() * 2000; // 模拟异步接口 return new Promise<{ id: number; delay: number }>((resolve) => { setTimeout(() => { resolve({ id: i, delay }); }, delay); }).then((result) => { // 请求完成后打印日志到页面 addLog(`✅ 请求 #${result.id} 完成 (耗时 ${result.delay.toFixed(0)}ms)`); }); }; // 交给请求池管控执行(核心!限制最多同时跑6个) const promise = requestPool.request(requestFn); promises.push(promise); } // 等待20个请求全部执行完毕,打印结束提示 Promise.all(promises).then(() => { addLog('🎉 所有请求已完成!'); }); }; // 清空日志数组,页面日志随之消失 const clearLogs = () => { logs.value = []; }; </script>

第三步:验证

启动项目:pnpm dev

打开页面,点击 "发送 20 个并发请求"

观察控制台输出和页面日志面板

预期结果:

未加载成功

1.控制台显示 当前并发数: 1/6 → 2/6 → ... → 6/6

2.当并发数达到 6 后,第 7 个请求会等待,不会立即发起

3.一个请求完成后,并发数降为 5,下一个等待的请求立即顶上来

4.最终 20 个请求全部完成,整个过程并发数始终不超过 6 注意:这个依旧是FIFO 而不是SJF 不是按发起顺序先后完成,因为每个请求设置了【随机耗时】 设置了const delay=1000+Math.random()*2000

如果不用请求池会发生什么? 20 个请求同时发起,浏览器的网络调度器会把它们全部排队,但由于没有“主动控制并发”的逻辑,20 个请求会瞬间占满所有可用连接,页面可能变得响应缓慢。


Demo 13:指数退避重试(限制最大重试次数,杜绝无限死循环)

“指数退避”就是:重试的间隔越来越长。比如:第一次失败等 500ms 重试,第二次失败等 1000ms 重试,第三次失败等 2000ms 重试。既给了服务器恢复的时间,又不会造成“重试风暴”——如果所有客户端都在 500ms 后同时重试,服务器可能刚恢复又被冲垮。指数退避让重试请求分散到不同的时间点,错峰重试,降低了集群雪崩的风险。

验证: 模拟接口请求失败(返回 500 错误或超时),自动按 500ms → 1000ms → 2000ms 的间隔重试,最多重试 3 次

第一步:创建重试工具

创建 src/utils/retry.ts: // src/utils/retry.ts

// 1. 定义一个类型守卫,检查错误是否具有类似 Axios 错误的特征 function isAxiosError(error: unknown): error is { response?: { status: number }; code?: string; message?: string; } { return typeof error === 'object' && error !== null; }

export interface RetryOptions { maxRetries?: number; // 最大重试次数,默认 3 baseDelay?: number; // 基础延迟(毫秒),默认 500 maxDelay?: number; // 最大延迟上限(毫秒),默认 10000 // ⭐ 这里把 any 改成了 unknown shouldRetry?: (error: unknown) => boolean; }

/**

  • 指数退避重试 */ // fn: () => Promise:参数1,传入一个无参函数,执行后返回Promise // options: RetryOptions = {}:参数2,配置对象,不传则默认是空对象{} // ): Promise:整个函数最终返回Promise,内部解析值类型为T export async function retryWithBackoff( fn: () => Promise, options: RetryOptions = {} //这是函数两个形参 把请求逻辑 “包成一个函数” 交给重试函数,让重试代码内部可以反复执行它。 ❗如果不包函数,直接传 Promise(错误思路) 一开始promise就执行了 后面没法重新跑 这两段代码是和后端交互用的形参 ): Promise { //options:参数名 RetryOptions:TS 接口,约束配置对象的结构(maxRetries /baseDelay/maxDelay /shouldRetry) = {}:默认值,代表调用时不传第二个参数,options默认等于空对象{} // 对象解构,取出配置,同时设置每一项的默认值 const { maxRetries = 3, baseDelay = 500, maxDelay = 10000, // 默认的重试判断逻辑 shouldRetry = (error: unknown) => { // 如果不是对象类型,直接不重试 if (!isAxiosError(error)) return false;

            // 只有 5xx 错误才重试(服务端错误)
            if (error.response?.status && error.response.status >= 500) return true;
            // 网络超时错误(Axios 的 ECONNABORTED)
            if (error.code === 'ECONNABORTED') return true;
            // 其他包含 timeout 关键字的错误
            if (error.message?.includes('timeout')) return true;
    
            return false;
          },
    

    } = options;

// ⭐ lastError 也改为 unknown let lastError: unknown = null;

for (let attempt = 1; attempt <= maxRetries; attempt++) { try { if (attempt > 1) { console.log([重试] 第 ${attempt - 1} 次重试...); } return await fn(); } catch (error) { lastError = error;

  // 判断是否需要重试
  if (!shouldRetry(error)) {
    console.log('[重试] 错误类型不支持重试,直接抛出');
    throw error;
  }

  if (attempt === maxRetries) {
    console.log(`[重试] 已达最大重试次数 ${maxRetries},停止重试`);
    throw error;
  }

  // 计算退避延迟:2^(attempt-1) * baseDelay
  const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
  console.log(`[重试] ${delay}ms 后重试 (尝试 ${attempt}/${maxRetries})`);

  await new Promise((resolve) => setTimeout(resolve, delay));
}

}

throw lastError; } any在eslintd的recommand模式里是默认被判断为error的 可以把any换成unknown 他比他的类型更强 注意使用unknown的时候要加一个类型守卫 不然无法使用 // 1.声明 function isAxiosError(error: unknown): error is { response?: { status: number }; code?: string; message?: string; } { return typeof error === 'object' && error !== null; } // 2. 在重试逻辑里使用它 catch (error) { // 这里 error 是 unknown(未知包裹)

// ⭐ 类型守卫在这里发挥作用!
// 我们调用 isAxiosError,如果返回 true,那么在这个 if 块内部,
// TypeScript 就知道 error 不再是“未知包裹”,而是“拆开后的具体对象”
if (isAxiosError(error)) {

  // ✅ 这里可以安全地访问 response.status 了!
  // 因为 isAxiosError 保证了 error 是一个对象,并且有 response 和 code 属性
  if (error.response?.status >= 500) {
    // 服务端错误,决定重试...
  }

  if (error.code === 'ECONNABORTED') {
    // 超时错误,决定重试...
  }
}

其他逻辑.....

第二步:创建测试 API(模拟失败)

(error as any).response = { status: 500 };如果报错说不让使用any 可以声明 (error as Error & { response: { status: number } }).response = { status: 500 }; 表明:这是一个 Error,同时还有一个 response 属性” 创建 src/api/testApi.ts:

第三步:在 App.vue 中添加测试按钮

保持原有内容不变 新增一点内容 其实就是加了个按钮 !!!但要注意@click要在style之后 不然会被warning

🔄 测试重试 (前2次失败,第3次成功) 🔄 重置状态

在script部分里 import { fetchWithRetry, resetAttemptCount } from '@/api/testApi';const testRetry = async () => { addLog('🔁 开始测试重试...'); try { const result = await fetchWithRetry(); addLog(✅ 请求成功: ${result.data}); } catch (error) { addLog(❌ 请求失败: ${(error as Error).message}); } };

const resetTest = () => { resetAttemptCount(); addLog('🔄 已重置计数器'); };

第四步:验证

点击 "测试重试",观察控制台输出:

第 1 次调用失败 → [重试] 500ms 后重试

第 2 次调用失败 → [重试] 1000ms 后重试

第 3 次调用成功 → 请求成功!

点击 "重置状态" 后再次测试,会重复上述过程 如图: 未能加载成功

这一步是在浏览器console控制台里输出的 不是界面的输出处输出的 界面的输出处不会输出这些内容!!


Demo 14:Service Worker 离线缓存(断网依旧可以加载资源)

验证: 注册 Service Worker,在第一次访问时缓存关键资源(index.html、核心 JS 等),然后断网刷新页面,依然能正常显示首页。

第一步:创建 Service Worker 文件

在项目根目录(demo2-env/ 下,不是 src/)创建 sw.js: 注意是项目根目录!!!

// 定义缓存版本标识,修改后缀v1→v2 代表缓存版本更新,触发清理旧缓存 const CACHE_NAME = 'demo-cache-v1';

// 预缓存资源清单:install阶段主动缓存的静态资源 const urlsToCache = [ '/', '/index.html', // Vite 构建产物JS/CSS带有hash,文件名每次构建变化,手动维护不现实 // 正式项目推荐使用 Workbox,构建时自动注入资源清单 // 本示例仅演示基础原理,采用运行时动态缓存策略 ];

// ===================== 1. Service Worker 安装事件 ===================== // sw脚本首次被浏览器下载解析完成后,浏览器自动触发 install 生命周期 self.addEventListener('install', (event) => { console.log('[Service Worker] 安装中...');

// event.waitUntil():告诉浏览器【等待内部异步操作全部完成】,再结束install阶段 // 如果不包裹,浏览器可能提前终止sw,缓存操作中途失败 event.waitUntil( // 打开指定名称的缓存存储空间,不存在则自动创建 caches.open(CACHE_NAME) .then((cache) => { console.log('[Service Worker] 缓存资源...'); // 批量请求清单内所有资源,并存入缓存;任意一个资源加载失败,整体失败 return cache.addAll(urlsToCache); }) .then(() => { console.log('[Service Worker] 安装完成'); // self.skipWaiting():强制跳过等待阶段,新sw立即进入activate激活状态 // 不调用的话:新sw会处于waiting,等待所有旧页面关闭后才激活 return self.skipWaiting(); }) ); });

// ===================== 2. Service Worker 激活事件 ===================== // install成功、执行完skipWaiting后,浏览器触发 activate // 作用:清理过期缓存、接管页面 self.addEventListener('activate', (event) => { console.log('[Service Worker] 激活中...');

event.waitUntil( // 获取当前浏览器存在的所有缓存名称列表 caches.keys() .then((cacheNames) => { // Promise.all 并行执行所有删除操作 return Promise.all( cacheNames.map((cacheName) => { // 和当前缓存版本对比,删除所有旧版本缓存 if (cacheName !== CACHE_NAME) { console.log([Service Worker] 删除旧缓存: ${cacheName}); return caches.delete(cacheName); } }) ); }) .then(() => { console.log('[Service Worker] 激活完成'); // self.clients.claim():让当前激活的sw,立刻接管所有已打开的页面 // 不调用:只有后续新打开页面才会被sw控制 return self.clients.claim(); }) ); });

// ===================== 3. 全局请求拦截 fetch 事件 ===================== // 页面发起任意网络请求(img/fetch/axios/css/js)时,浏览器触发fetch事件 self.addEventListener('fetch', (event) => { // 解析请求完整URL,方便判断域名、路径 const requestUrl = new URL(event.request.url);

// 规则1:非同源请求(CDN、第三方接口),不经过缓存逻辑,直接放行走网络 if (requestUrl.origin !== self.location.origin) { // event.respondWith:自定义本次请求返回的响应 event.respondWith(fetch(event.request)); return; }

// 规则2:只缓存GET请求,POST/PUT/DELETE等写操作不缓存 if (event.request.method !== 'GET') { event.respondWith(fetch(event.request)); return; }

console.log([Service Worker] 拦截请求: ${event.request.url});

// 自定义本次请求的响应逻辑:缓存优先策略(Cache-First) event.respondWith( // 在缓存中查找匹配当前请求的资源 caches.match(event.request) .then((cachedResponse) => { // ✅ 缓存命中:直接返回缓存资源,不去请求网络 if (cachedResponse) { console.log([Service Worker] ✅ 命中缓存: ${event.request.url}); return cachedResponse; }

    // ❌ 缓存未命中:发起真实网络请求
    console.log(`[Service Worker] 缓存未命中,请求网络: ${event.request.url}`);
    return fetch(event.request)
      .then((response) => {
        // 仅缓存HTTP状态200的成功响应,404/500等错误不存入缓存
        if (response.status === 200) {
          // ⚠️ Response对象是流,只能读取一次!必须clone一份副本存入缓存
          const responseToCache = response.clone();
          caches.open(CACHE_NAME)
            .then((cache) => {
              // 将 请求-响应 键值对存入缓存
              cache.put(event.request, responseToCache);
            });
        }
        // 返回原始响应给页面
        return response;
      })
      .catch(() => {
        // 网络彻底断开、fetch失败,进入离线兜底逻辑
        console.error(`[Service Worker] ❌ 网络请求失败: ${event.request.url}`);
        // 如果请求的是HTML页面(SPA路由),返回缓存中的首页index.html,实现离线访问
        if (event.request.headers.get('accept')?.includes('text/html')) {
          return caches.match('/');
        }
        // 其他资源兜底返回404响应
        return new Response('Not Found', { status: 404 });
      });
  })

); });

第二步:配置 Vite 构建,把 sw.js 复制到 dist/

修改 vite.config.ts,添加 rollupOptions.input 和 publicDir 配置: // vite.config.ts

// ========== 新增:让 sw.js 也被构建 ==========
build: {
  rollupOptions: {
    // 确保 sw.js 作为入口被处理
    input: {
      main: path.resolve(__dirname, 'index.html'),
      sw: path.resolve(__dirname, 'sw.js'),
    },
    output: {

      // 让 sw.js 输出到根目录(而不是 assets 里)
      entryFileNames: (chunkInfo) => {
        if (chunkInfo.name === 'sw') {
          return '[name].js';
        }
      },
    },
  },
},

}; });

但更简单的方式: 把 sw.js 放到 public/ 目录下,Vite 构建时会自动复制到 dist/,不需要改 rollupOptions。

bash中

创建 public 目录

mkdir -p public

把 sw.js 移到 public/

mv sw.js public/

Vite 默认会将 public/ 下的文件原样复制到 dist/ 根目录。这是最简单的方式。

第三步:注册 Service Worker

修改 src/main.ts:

// 条件1:'serviceWorker' in navigator // navigator 是浏览器全局对象,检测浏览器是否具备 Service Worker 能力 // 老旧浏览器、部分无痕模式、部分移动端webview可能不存在该API

// 条件2:import.meta.env.PROD // Vite 内置环境变量:true = 生产打包环境;false = npm run dev 开发环境 // 👉 含义:开发环境不注册sw,避免缓存导致调试时资源不刷新 if ('serviceWorker' in navigator && import.meta.env.PROD) { window.addEventListener('load', () => { // 核心API:注册 Service Worker // 参数:sw脚本路径 /sw.js navigator.serviceWorker.register('/sw.js') .then((registration) => { // registration 对象包含sw状态、等待中的sw、激活的sw等信息 console.log('[SW] 注册成功:', registration); }) .catch((error) => { // 注册失败常见原因: // 1. sw.js 404 路径写错 // 2. 协议不是 HTTPS(localhost本地开发除外) // 3. 浏览器禁用service worker console.log('[SW] 注册失败:', error); }); }); }

第四步:验证离线缓存

第一步:构建并启动

pnpm build pnpm preview

第二步:首次访问

打开 http://localhost:4173,查看 DevTools → Console,应该看到 未能加载成功 为什么会发生这种情况?(问题根源) 在demo11中修改了vite.config.ts 导致这个配置会让 Vite 在构建时,把所有资源路径(JS、CSS、图片)都加上 https://cdn.example.com/demo2-env/ 前缀 在 Demo 14 中,注册了 Service Worker。 当访问 http://localhost:4173 时,页面加载了。 但页面里的资源(index-xxx.js)请求的是 https://cdn.example.com/demo2-env/assets/...。 电脑无法解析 cdn.example.com(因为这只是个假域名),所以资源加载失败(报错 ERR_NAME_NOT_RESOLVED)。 Service Worker 的 fetch 事件监听到这些失败请求,尝试缓存它们,结果把“404 错误页面”也缓存下来了。 之后刷新页面,SW 直接从缓存里取出“404 错误页面”,导致白屏。

解决方案

方案 A:清除 Service Worker 缓存(立即修复)1.在浏览器里手动清除 SW 缓存:2.打开 http://localhost:4173(或你的页面)。3.按 F12 打开 DevTools。4.切换到 Application 面板。5.左侧菜单选择 Service Workers。6.点击 Unregister 按钮(注销当前 SW)。7.左侧菜单选择 Cache Storage。8.右键点击 demo-cache-v1(或任何缓存),选择 Delete。9.硬刷新页面:按 Ctrl + Shift + R(Windows)或 Cmd + Shift + R(Mac)。

方案 B:把 CDN 地址改回相对路径(永久修复) 如果你只是想跑通 Demo 14,不想用假 CDN,把 vite.config.ts 里的 base 改回默认值: // vite.config.ts export default defineConfig({ base: '/', // 改回相对路径 }) 然后重新构建(pnpm build)、重新预览(pnpm preview)。这样所有资源请求都会走本地 localhost,不会去请求 cdn.example.com。

方案 C:在真实项目中使用(正确的 CDN 用法) 如果将来真的想用 CDN,需要把 base 改成真实的 CDN 地址(比如阿里云 OSS 或腾讯云 COS 分配的域名),并确保: CDN 服务器上确实有 demo2-env/assets/ 下的文件(需要把 dist/ 上传上去)。 CDN 域名是真实可访问的(不是 cdn.example.com 这种假地址)。

推荐使用方案B:

开发时尽量用 base: '/' 跑通所有 Demo,CDN 配置单独作为一个演示分支,避免互相干扰。

且谨记 Service Worker 规范要求:SW 脚本必须是 JavaScript 文件,且 MIME 类型必须是 text/javascript 或 application/javascript。如果是ts类型 就会拿到视频类型,直接拒绝注册。

就像这样: alt text 改正后 正常运行 alt text


Demo 15:本地抓包环境配置 + HTTPS 解密验证

1.拦截 HTTP/HTTPS 请求,查看明文数据。

2.把线上域名代理到本地开发服务。

3.理解 HTTPS 中间人解密原理。

在日常开发中的体现:

1.后端接口还没写好,但要先写页面(Mock 数据) Whistle 体现:你在 Whistle 里配一条规则,把 api.xxx.com/user/info 这个请求,直接返回你写好的 {name:'张三', age:18} 的 JSON 数据。 效果:页面正常发请求,拿到假数据,UI 渲染完毕。等后端好了,把规则删掉,无缝切换到真实接口。不比后端慢,甚至还快。

2.线上页面出了 Bug,但本地死活复现不出来(热替换调试) Whistle 体现:把线上 https://www.xxx.com/app.js 的请求,代理到本地修改后的 app.js 文件(file:// 规则)。 效果:在本地改代码,保存,刷新线上页面,立刻生效。把线上页面当成你的“开发调试环境”,不用等发版就能验证修复。

3.开发时接口跨域(CORS)问题:本地 localhost:5173 请求 api.xxx.com,浏览器报错 Access-Control-Allow-Origin 方案 A(Vite 自带):配 vite.config.ts 的 proxy,把 /api 转发到后端。(这是最常见的开发方式,本质就是一个轻量级的代理) 方案 B(Whistle):直接配规则 api.xxx.com 127.0.0.1:3000,让浏览器认为你在请求同源接口。 效果:跨域问题被“绕”过去了。 你甚至不需要后端配 CORS 响应头,自己就能解决开发环境联调问题。

4.手机真机调试 H5(微信/浏览器):手机上打开 H5 页面,发现点击按钮没反应,但手机上没控制台,看不到报错 Whistle 体现:1.手机 WiFi 设置代理指向你电脑(Whistle)。 2.Whistle 注入 vConsole 脚本(htmlAppend 规则)。3.手机上自动出现绿色 vConsole 按钮。 效果:在手机上点按钮,手机上实时显示 Console.log 和报错信息。在手机上拥有了 PC 浏览器的“F12 调试能力”。

5.模拟极端网络环境(弱网/超时):用户说“我们这儿网不好,页面总转圈”。自己是千兆光纤,永远复现不了慢网情况。 Whistle 体现:配一条规则 api.xxx.com resDelay://3000(所有接口延迟 3 秒返回)。 效果:你的页面瞬间变成“3G 弱网模式”。你可以亲自体验 Loading 动画、超时重试逻辑是否正常,提前优化用户体验,而不是等用户投诉后再改代码。

如图: alt text 这些东西不需要天天用,但当你碰到“后端没写好”、“线上报错复现不了”、“手机上看不到报错”时,你心里得清楚 ——Whistle(或类似的代理工具)就是专门解决这些“非代码本身”的环境问题的。 这就是它在日常开发里的“隐形价值”——它是你的后备武器,不是日常主武器。

第一步:安装 Whistle 优点:规则配置灵活(类正则)、支持HTTP/HTTPS/WebSocket、自带界面

Whistle 是一个基于 Node.js 的跨平台抓包代理工具,安装前确保 Node.js 已安装whistle: C 端项目(电商、资讯、H5),线上出了 Bug 但本地无法复现时,Whistle 能把线上页面的 JS 替换成本地文件帮你调试,这是 DevTools 做不到的。

pnpm install -g whistle 可能会出现: alt text 这是因为pnpm 全局命令的存放目录 C:\Users\29453\AppData\Local\pnpm\bin 没有加入系统环境变量 PATH。 在powershell 直接输入 pnpm setup pnpm setup 会自动把 pnpm 的全局 bin 目录写入 Windows 用户环境变量。 然后关掉powershell 重新打开 再次输入即可 如果还是报错呢 就可以使用非全局安装whistle 直接使用 npx 临时启动,完全规避这个报错 npx whistle 首次运行会自动下载 whistle,之后直接启动代理,不需要任何全局安装、不需要修改 PATH。

w2 start 但如果是npx非全局安装 就需要输入npx whistle start 否则会出现: alt text

补充说明: Whistle 启动后会在后台运行一个代理服务器,默认监听 8899 端口。可以在终端执行 w2 stop 停止服务,w2 restart 重启服务。

第二步:配置浏览器代理

方式 A:使用 SwitchyOmega 插件(推荐) 在 Chrome 应用商店搜索安装 SwitchyOmega。(edge也行) alt text选这个 稳定 新建情景模式 → 选择“代理服务器”: 协议:HTTP 服务器:127.0.0.1 端口:8899

点击“应用选项”,然后点击插件图标选择该情景模式。

方式 B:系统代理(不推荐,会代理所有流量) Mac:系统偏好设置 → 网络 → 高级 → 代理 → 网页代理(HTTP) 和 安全网页代理(HTTPS),都填 127.0.0.1:8899。 Windows:设置 → 网络和Internet → 代理 → 手动设置代理,地址填 127.0.0.1,端口填 8899。

注意: 用系统代理会影响所有应用(包括系统更新、其他软件),建议用完切回“无代理”。SwitchyOmega 可以一键切换,更方便。

第三步:访问 Whistle 管理界面

打开浏览器,访问 http://127.0.0.1:8899,你会看到一个仪表盘界面: alt text Network 面板:查看所有经过代理的请求列表,点开任意请求可以看到完整的请求头、响应头、响应体(明文)。要看接口返回了什么 -> 点 Network 标签页,找请求。

Rules 面板:配置代理规则(把 api.xxx.com 指向 localhost:3000、修改请求/响应内容等)。要写 Mock 数据 -> 点 Rules 标签页,写规则

HTTPS 面板:管理 HTTPS 解密需要的根证书。要抓手机包或看 HTTPS 明文 -> 点 HTTPS 区域下载证书,然后手机连代理,再回来看 Network 里的请求(此时不再是 Tunnel)。 要看 WebSocket 消息 -> 点 WS 过滤。

只需要记住:遇到网络问题,开 Whistle -> 点 Network 看日志;要改网络数据,点 Rules 写配置。

第四步:安装 Whistle 根证书(HTTPS 解密的关键)

为什么要装根证书? HTTPS 抓包的本质是“中间人劫持(MITM)”,Whistle 需要用自己的根证书动态生成目标域名的假证书。浏览器信任这个假证书的前提是——它信任签发这个假证书的根证书。所以你必须手动把 Whistle 的根证书安装到系统信任区。

安装步骤: 在 Whistle 管理界面,点击顶部菜单 HTTPS。 点击 下载根证书,保存到本地。

Mac 安装: 双击 .crt 文件,自动打开钥匙串访问。 找到 whistle 证书,双击 → 展开“信任” → 选择“始终信任”。

Windows 安装: 双击 .crt 文件 → 点击“安装证书” → 选择“本地计算机” → 选择“将所有证书放入下列存储” → 浏览选择“受信任的根证书颁发机构” → 完成。 alt text

验证 HTTPS 解密是否生效:

安装完成后,重启 Whistle(终端执行 w2 restart),

然后访问一个 HTTPS 网站(比如 https://www.baidu.com),回到 Whistle 管理界面(http://127.0.0.1:8899)的 Network 面板。如果你看到 HTTPS 请求的状态是 200 而不是 Tunnel,点击请求能看到明文响应内容,就说明根证书安装成功了。 但是此处极易出现这样的情况 alt text

这是因为没有在浏览器自带的扩展里 打开设置的预先配置(对应第二步)

这样就可以了: alt text 在 Whistle 的 Network 面板里,应该能看到百度首页的所有 HTTPS 请求(www.baidu.com、sp0.baidu.com 等)。

点击任意一个请求,右侧查看 Response 标签页,能看到 HTML 源码(明文)。 这里也会出现这样的问题 没有在右侧面板里看到对应的response标签页

这也是因为没有打开一些设置

在setting里打开response设置: alt text 在http里打开 alt text 直至http显示为 alt text 就可以在Inspector里底部看见Response界面了 alt text

对比不开启代理时,HTTPS 请求的内容在浏览器 DevTools 里虽然也能看到,但 Whistle 里能看到的是“明文传输内容”,证明了 HTTPS 抓包解密的成功。

第五步:配置代理规则(把线上域名指向本地)

这是前端开发中最常用的场景之一。在 Whistle 管理界面,点击 Rules 菜单,在左侧编辑区输入:

将线上 api 请求代理到本地 dev 服务

api.xxx.com/api 127.0.0.1:3000

将线上某个 JS 文件替换为本地文件(用于调试线上页面)

https://cdn.xxx.com/app.js file:///Users/你的用户名/project/dist/app.js

模拟接口返回数据(Mock,不依赖后端)

api.xxx.com/user resBody://{code:0, data:{name:'test'}}

模拟慢网络(延迟 500ms 返回)

api.xxx.com/api resDelay://500

第六步:验证代理生效

在本 Vue 项目里,发起一个请求到 api.xxx.com/api/test,然后打开 Whistle 的 Network 面板,你会看到这个请求被捕获。如果配置了 resBody:// 规则,会看到接口返回了你指定的 Mock 数据,而不是真实后端的数据。 这意味着不需要等后端接口开发完成,就可以提前模拟数据调试页面逻辑了。


PS:具体实现步骤

那么第六步如何实施呢?:

第一步:在项目中添加一个“发请求”的按钮(如果没有的话)

🌍 Whistle Mock 测试 Demo

<button
  @click="fetchUserInfo"
  style="padding: 12px 24px; font-size: 16px; cursor: pointer; background: #42b883; border: none; border-radius: 4px; color: white;"
>
  📡 请求用户信息
</button>

<div style="margin-top: 20px; padding: 16px; background: #f5f5f5; border-radius: 8px;">
  <h4>返回结果:</h4>
  <pre style="background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 4px; overflow-x: auto;">
    {{ responseData }}
  </pre>
</div>
<script setup lang="ts"> import { ref } from 'vue'; const responseData = ref('等待请求...'); const fetchUserInfo = async () => { responseData.value = '⏳ 加载中...'; try { // ⚠️ 注意:这里的域名要和 Whistle 规则匹配! const res = await fetch('https://api.example.com/user/info'); const data = await res.json(); responseData.value = JSON.stringify(data, null, 2); } catch (error) { responseData.value = `❌ 请求失败:${error}`; } }; </script>

第二步:在 Whistle 里创建 Mock 数据 1.打开 Whistle 管理界面 http://127.0.0.1:8899。

2.点击顶部菜单 Values。

3.在左侧的 “Create” 输入框中输入 userInfo(这是数据名,后面要在 Rules 里引用它)。

4.点击 “Create” 按钮(或按回车)。

5.在右侧编辑区输入以下 JSON 数据: { "code": 0, "message": "success", "data": { "id": 1001, "name": "张三", "avatar": "https://avatars.githubusercontent.com/u/1?v=4", "email": "zhangsan@example.com", "role": "管理员", "permissions": ["read", "write", "delete"] } }

第三步:在 Whistle 里配置拦截规则

1.点击顶部菜单 Rules。2.在左侧编辑区输入以下规则:

https://api.example.com/user/info resBody://{userInfo}

这行规则的解读: https://api.example.com/user/info:匹配你代码里请求的完整 URL。 resBody://{userInfo}:把该 URL 的响应内容替换为 Values 里 userInfo 中存储的数据。 点击右上角的 Save 保存。

第四步:验证 Mock 生效

1.回到浏览器,打开你的项目页面 http://localhost:5173。2.点击 “请求用户信息” 按钮。3.观察页面显示的内容 就会发现 alt text alt text 这是因为localhost:5173和我们既定的https://api.example.com/user/info产生了跨域 引发了CORS警报

解决方案:在 Whistle 里直接加上 CORS 响应头

这是最“工程化”的解法。你不需要改项目代码,直接在 Whistle Rules 里告诉它:“除了返回 Mock 数据,还要告诉浏览器允许跨域。”

把 Whistle Rules 改成这样: https://api.example.com/user/info resBody://{userInfo} resHeaders://{corsHeaders}

然后在 Values 里新建一个 corsHeaders,内容写:

Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: Content-Type, Authorization

就可以使用whistle了 alt text


Demo 16:vConsole 调试钩子 + SourceMap 配置

用处: // 1.在项目中嵌入vConsole 调试面板(移动端H5调试利器) // 2.通过URL参数?debug=1控制vConsole是否加载 // 3.配置Vite生成SourceMap文件(用于线上报错定位)

第一步:安装 vConsole

pnpm add -D vconsole

第二步:在 main.ts 中实现动态调试钩子

修改 src/main.js: // ========== vConsole 调试钩子 ========== // 条件:开发模式 或 URL 中有 ?debug=1 const isDev = import.meta.env.MODE === 'development'; const hasDebugParam = window.location.search.includes('debug=1');

if (isDev || hasDebugParam) { // 动态导入 vConsole(仅在需要时加载,减少首屏体积) import('vconsole').then(({ default: VConsole }) => { // 如果已经有 vConsole 实例,不重复创建 if (!window.vConsole) { window.vConsole = new VConsole(); console.log('[vConsole] 调试面板已开启'); } }); }

第三步:配置 Vite 生成 SourceMap

修改 vite.config.js // ========== 新增:开启 SourceMap ========== build: { sourcemap: true, // 构建时生成 .map 文件 },

第四步:验证 vConsole

1.执行 pnpm dev,打开 http://localhost:5173。

2.预期结果:页面右下角出现绿色的 vConsole 按钮。点击它,可以看到 Console、Network、Elements 等调试面板。 alt text alt text

3.执行 pnpm build,然后 pnpm preview,打开 http://localhost:4173。

4.预期结果:默认不显示 vConsole 按钮(因为生产环境,且 URL 中没有 debug=1)。 alt text 因为判断为import.meta.env.MODE != 'development' 所以不执行下面的内容

5.在 URL 后加 ?debug=1(如 http://localhost:4173/?debug=1),刷新页面。 alt text 因为判断为hasDebugParam = window.location.search.includes('debug=1'); 所以执行下面的内容

6.预期结果:vConsole 按钮出现 ✅


Demo 17:WebSocket 双向通信验证

一、为什么会有这个 Demo?(痛点场景

在传统的 HTTP 通信中,有一个根本性的限制:只能由客户端主动发起请求,服务器被动响应。

想象这些真实业务场景: 聊天应用:别人给你发消息,你必须刷新页面才能看到? 股票行情:价格变动了,你要手动刷新页面才能看到最新价格? 订单推送:用户下单了,你要不断刷新后台才能看到新订单? 游戏对战:对手出招了,你要等页面刷新才能看到? 如果只能用 HTTP 实现,唯一的办法是轮询(Polling): 短轮询:每隔 1 秒发一次 HTTP 请求“有新消息吗?” 长轮询:发一个请求,有消息时返回,否则挂住,但有超时限制,超时后重新发请求

这两种方式的共同问题: 大量无意义的请求浪费带宽、消耗服务器资源、实时性差。HTTP 协议本身是无状态的“一问一答”模式,服务器无法主动“推”数据给客户端,这就是 WebSocket 诞生的根本原因。

二、Demo 17 的功效(它能做什么)

这个 Demo 在浏览器里建立了一条 WebSocket 长连接,实现:

全双工实时通信:客户端可以发消息给服务器,服务器也可以随时主动推送消息给客户端,互不干扰。

极低延迟:消息发出后毫秒级到达,不需要等待下次请求周期(HTTP 轮询最短也要 1 秒以上)。

极低开销:连接建立后,传输消息只需要很小的帧头,不需要每次请求都携带 Header(HTTP 每次都要带 Cookie、User-Agent 等几百字节的 Header)。

连接持久化:一次握手,长期保持,不会断开(除非网络故障或主动关闭)。

三、要从中学到什么

理解 WebSocket 的核心概念:全双工、长连接、服务端推送

掌握 WebSocket 的浏览器 API :new WebSocket()、onopen、onmessage、onclose、send()

对比 HTTP 和 WebSocket :短轮询 vs 长轮询 vs WebSocket 的优劣

掌握 WebSocket 的调试方法 :Chrome DevTools → Network → WS 标签页查看帧

建立“实时通信”的技术选型思维 :什么场景用 HTTP,什么场景用 WebSocket

第一步:创建 WebSocket 测试组件

在 src/components/WebSocketDemo.vue 中创建:

📡 WebSocket 实时推送 Demo

<div style="margin: 12px 0;">
  <button @click="connect" :disabled="connected" style="padding: 6px 16px; cursor: pointer;">
    🔗 连接
  </button>
  <button @click="disconnect" :disabled="!connected" style="padding: 6px 16px; margin-left: 8px; cursor: pointer;">
    ❌ 断开
  </button>
  <button @click="clearMessages" style="padding: 6px 16px; margin-left: 8px; cursor: pointer;">
    🗑️ 清空消息
  </button>
  <span style="margin-left: 16px; font-size: 14px;">
    状态: <strong :style="{ color: connected ? '#42b883' : '#ff6b6b' }">
      {{ connected ? '✅ 已连接' : '❌ 未连接' }}
    </strong>
  </span>
  <span style="margin-left: 16px; font-size: 14px; color: #666;">
    消息数: {{ messages.length }}
  </span>
</div>

<div style="max-height: 300px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 12px; border-radius: 6px; font-size: 13px; font-family: 'Courier New', monospace;">
  <div v-for="(msg, idx) in messages" :key="idx" style="padding: 4px 0; border-bottom: 1px solid #333;">
    <span style="color: #888;">[{{ msg.time }}]</span>
    <span :style="{ color: msg.type === 'recv' ? '#4ec9b0' : '#dcdcaa' }">
      {{ msg.type === 'recv' ? '⬅️ 收到' : '➡️ 发送' }}
    </span>
    {{ msg.content }}
  </div>
</div>

<div style="margin-top: 12px; display: flex; gap: 8px;">
  <input
    v-model="inputMessage"
    placeholder="输入消息..."
    @keyup.enter="sendMessage"
    style="flex: 1; padding: 6px 12px; border: 1px solid #ddd; border-radius: 4px;"
  />
  <button @click="sendMessage" :disabled="!connected" style="padding: 6px 16px; cursor: pointer;">
    📤 发送
  </button>
</div>
<script setup lang="ts"> import { ref, onUnmounted } from 'vue'; const messages = ref<{ time: string; type: string; content: string }[]>([]); const connected = ref(false); const inputMessage = ref(''); let ws: WebSocket | null = null; const addMessage = (type: string, content: string) => { const now = new Date(); const time = now.toLocaleTimeString(); messages.value.push({ time, type, content }); }; const connect = () => { if (ws && ws.readyState === WebSocket.OPEN) return; // 使用公共 WebSocket 测试服务(会回显你发的任何消息) ws = new WebSocket('wss://echo.websocket.org'); ws.onopen = () => { connected.value = true; addMessage('sys', '🔗 连接已建立'); }; ws.onmessage = (event) => { addMessage('recv', event.data); }; ws.onclose = () => { connected.value = false; addMessage('sys', '🔌 连接已断开'); ws = null; }; ws.onerror = () => { addMessage('sys', '❌ 连接出错,请检查网络'); }; }; const disconnect = () => { if (ws) { ws.close(); ws = null; connected.value = false; } }; const sendMessage = () => { if (!ws || ws.readyState !== WebSocket.OPEN) { alert('请先连接 WebSocket'); return; } const msg = inputMessage.value.trim(); if (!msg) return; ws.send(msg); addMessage('send', msg); inputMessage.value = ''; }; const clearMessages = () => { messages.value = []; }; // 组件销毁时自动断开 WebSocket 连接 onUnmounted(() => { disconnect(); }); </script>

第二步:在 App.vue 中引入 WebSocketDemo 组件

🌍 WebSocket 双向通信 Demo

<script setup lang="ts"> import WebSocketDemo from './components/WebSocketDemo.vue'; </script>

第三步:验证 WebSocket

执行 pnpm dev,打开 http://localhost:5173。

点击 “连接” 按钮。

预期结果:按钮上方显示“✅ 已连接”,消息框出现 [时间] 🔗 连接已建立。 alt text 在输入框中输入任意文字(如 Hello),点击 “发送”。

预期结果:消息框出现 [时间] ➡️ 发送 Hello,紧接着出现 [时间] ⬅️ 收到 Hello(回显)。 alt text 点击 “断开” 按钮。

预期结果:状态变为“❌ 未连接”,消息框出现 [时间] 🔌 连接已断开。 alt text

第四步:观察 WebSocket 长连接(对比 HTTP)

在浏览器中按 F12 打开 DevTools。

切换到 Network 面板,点击 WS 标签(过滤 WebSocket 请求)。

点击页面上的 “连接” 按钮。

预期结果:Network 面板中出现一条 WebSocket 连接记录(wss://echo.websocket.org)。 alt text 点击该记录,切换到 Messages 标签页。

预期结果:你可以看到所有发送和接收的消息帧(Frame)。 alt text 关键观察:连接建立后,即使不发送任何消息,连接也会一直保持(长连接),不会像 HTTP 那样每次请求都断开。


Demo18:

About

A note about whole Front-end Engineering.A lots demos of Frontend Architecture Setup

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages