其显示出的pnpm与npm所依赖的node_modules大小几乎一致 原因是二者的vue项目目前都是空的 依赖包数量少 体积小
如果创建了n个npm项目 那么内存就会成为n倍 而同样的情况下 pnpm的所占内存不变
不修改它,Vite 就用默认配置:
1.端口 5173 2.不自动打开浏览器3.不生成 sourcemap 4.不区分环境加载变量(只能用默认的 development/production)
修改它,可以定制 Vite 的行为:
1.改端口2.自动打开浏览器3.生成 sourcemap 4.根据 command 和 mode 做条件判断5.配置代理、别名、插件等
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()); //获取你执行命令的文件夹路径(项目根目录)
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(','));
return { // 注册vue插件,让Vite支持解析.vue单文件组件 plugins: [vue()],
server: {
port: mode === 'test' ? 3000 : 5173, // 固定本地开发端口为5173,不使用随机端口
<!-- defineConfig 回调写法优势:可以根据mode/command做动态配置 -->
open: true, // 启动服务后自动打开默认浏览器访问项目地址
},
build.sourcemap: true 生成 .map 文件,方便调试时看到源码位置(工程化实战中,这用于线上报错定位)
在项目根目录执行:
pnpm add -D eslint eslint-plugin-vue @vue/eslint-config-typescript @typescript-eslint/eslint-plugin @typescript-eslint/parser 包的作用:
pnpm add -D prettier eslint-config-prettier eslint-plugin-prettier
pnpm add -D husky lint-staged
为什么在ESModule 项目中要使用CommonJS的写法? :如果起名为xxxx.js Node会把它当成ESM ESM不可以直接写module.exports 语法报错 所以说要用xxx.cjs 强制走CommonJS 写法固定成熟 几乎不会出现解析BUG 稳定性更高
项目根目录创建 .prettierrc.cjs:
在该文件中声明的文件/目录 Prettier 会跳过这些目录/文件,不进行格式化。 一般就是dist/ node*modules/ *.log _.lock _.yaml _.yml _.html _.svg 这些文件 总而言之 就是Prettier 只管手写的业务源码(ts、js、vue、css 等);
打开 package.json,在 "scripts" 中添加以下命令,并在根节点添加 "lint-staged" 字段:
具体操作:只改 3 个地方
- 在 "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 就会自动配置好 } }
- 在 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 也在这里 }
- 在 "devDependencies" 中添加新包的列表(原有的 @vitejs/plugin-vue、vite、vue 等都要保留) json { "devDependencies": { "typescript": "^5.5.3", "vue-eslint-parser": "^9.4.3" } }
Husky 9.x 的初始化方式和使用 package.json 中的 "prepare": "husky" 脚本自动触发。为了让 Husky 立即生效,手动执行一次: pnpm run prepare
这会在项目根目录创建 .husky/ 文件夹。然后手动创建 pre-commit 钩子文件:
查看 .husky/pre-commit 文件内容,确保它是这样的:
#!/bin/sh
. "$(dirname "$0")/_/husky.sh" //里面没有\
pnpm lint-staged
验证 1:ESLint 能正常运行 pnpm lint
验证 2:Prettier 能正常运行 pnpm format
验证 3:lint-staged 能正常运行
git add src/App.vue //先 add 一个文件到暂存区
pnpm lint-staged
发现代码报错 原因是 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 规则都要重写,不适合现阶段。
运行正常 但是一堆报错 是因为他扫描了一堆本不该扫描的文档/文件
可以改为"lint": "eslint src --ext .vue,.js,.ts --fix",
可以发现 改完不扫描无关文档/文件 就不会报错了
如:故意用双引号,故意不加分号等
#将坏代码添加到暂存区
git add src/App.vue
#尝试提交
git commit -m "test: 故意提交坏代码验证 ESLint 拦截"
而后就会看到Husky的日志 报错 阻止提交
lint-staged 配置里从左到右依次执行:["eslint --fix", "prettier --write"]。先 ESLint 修代码风格,再 Prettier 格式化,避免相互覆盖
自动修复失败时,lint-staged 会终止提交,你需要手动修复后再 add 提交。这是故意的——宁可不让提交,也不能让坏代码进仓库。
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;
import router from './router';
app.use(router);
当前环境:{{ 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 在打包时主动拆分代码: 修改vite.config.ts
保留所有原有配置,只新增 resolve 和 build.rollupOptions
export default defineConfig({
plugins: [vue()],
resolve: {
// 配置路径别名,让 @ 指向 src/
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
// 生成 sourcemap,便于调试时定位源码位置
sourcemap: true,
rollupOptions: {
output: {
.map 文件里记录了“压缩后的代码”和“原始代码”之间的映射关系(JSON 格式),比如:浏览器会自动读取 .map 文件(只要 DevTools 是打开状态),在 Sources 面板里直接显示你原始的 math.ts 代码。
现在报错看到的是:
text
add@src/utils/math.ts:3
而不是:
text
o@index-abc123.js:1
manualChunks(id) {
// 所有以 'vue' 开头的包(vue、vue-router 等)都打包到 vue-vendor 中
if (id.includes('vue') || id.includes('vue-router')) {
return 'vue-vendor'
}
},
// 每个 chunk 的文件名格式,[name] 会自动取 manualChunks 里定义的 key
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
});
可以看出:所有 .vue 组件是通过 ?import 请求分别加载的(因为 Vite 在开发模式下不做打包合并)
再次观察:打开其他界面时(切换路由),浏览器network面板可以看见该界面的动态加载
1.读取环境变量2.解析入口3.构建依赖图谱4.插件转换(把输入的.vue,.ts,.less转换为.js,.css 5.摇树优化6.代码拆分7.压缩混淆8.输出dist/目录+清单(manifest)

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

manualChunks的功能:分包规范,Rollup 原生提供的手动分包配置项,Vue 核心库只加载一次,后续页面直接读浏览器缓存,减少重复下载,这个文件的 hash 只在这些库的版本号变化时才会改变——你改业务代码不会让 vendor 的 hash 变化,用户不需要重新下载 85KB 的 Vue 核心库,只下载被改动的业务 chunk
用 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
"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;
在src/views内部新生成了一个新的文件夹product 里面有新生成的index.vue 以及在api/中生成的product.ts
打开 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 下建文件、还要按这个格式写”————让机器帮你执行规范,而不是靠人脑记
git push 代码到 GitHub 时,GitHub 自动拉取代码 → 安装依赖 → 代码校验(ESLint) → 执行测试(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);
});
});
"test": "vitest run", "test:coverage": "vitest run --coverage"
pnpm test
预期结果:
如果显示 Test Files 1 passed,说明测试跑通了。如果没有输出或报错,检查是否安装了 vitest,以及 tests 目录是否在 src/utils/ 下
这一步先确认你已经把代码推到 GitHub 了: git remote -v 然后把代码推送上去,确保 GitHub 仓库里有你的代码。
创建 .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 流水线 正在运行(黄色圆点表示运行中)
点击进去,可以看到每一步的执行日志
报错详细说明
流水线标红 ❌,阻止了这次提交的合并(如果你配置了分支保护规则,这个 PR 会被自动标记为不可合并)
验证: 用 Playwright 自动打开浏览器,模拟用户访问首页 → 点击“用户页” → 检查页面是否正常。
对应你导图中的: Vitest, Playwright E2E 测试 自动执行冒烟测试,验证页面可以正常访问,接口 500 无报错
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/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('用户中心');
});
});
{ "scripts": { "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" } }
终端 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)
修改 .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 测试” 两个步骤,全部通过后流水线变绿。
监控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'] });
}
打开 Chrome DevTools → Lighthouse 面板 点击 Analyze page load(分析页面加载) 运行完成后,你会看到 LCP、CLS、FID 的官方评分 对比脚本里 console.log 打印出来的数值,应该基本一致
在eslint里把eslintrc.cjs里的rule里的 no-console的内容 改成 'off' (必须是单引号)
就像这样:

这个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}/);
{
"scripts": {
"deploy": "pnpm build && node scripts/deploy.ts"
}
}
相当于:生产服务器上正在运行的 Web 服务根目录(比如 Nginx 指向的 /usr/share/nginx/html)。
里面的内容:assets/ 目录下存放着 index-yMkJyNDY...(入口 JS)和 vue-vendor-Ys7F...(第三方库 JS)。
怎么生成的:当执行 pnpm deploy 时,脚本会把构建好的 dist/ 文件夹完整复制一份到这里。如果服务器部署,运维就会把这里的内容挂载给用户访问。
2.(操作2) Backups 文件夹(历史版本快照) 相当于:生产环境上的“版本历史仓库”。每次你执行 pnpm deploy,脚本都会先把当前的 deploy 文件夹压缩打包(以时间戳命名),放进 Backups 里。
为什么要有它:为了 Demo 10(版本回滚)。如果新的 deploy 版本出了严重 Bug,你可以执行 pnpm rollback,脚本会从 Backups 里挑一个旧版本,直接覆盖回 deploy,实现“秒级回滚”。
这是 Vite 构建时加的 Hash(哈希值)。 index-yMkJyNDY.js 里的 yMkJyNDY 是根据 index.js 的文件内容算出来的。内容变了,Hash 就变。 作用:强制浏览器刷新缓存。如果你改了一行代码,新版本的 index-新Hash.js 会被加载,而旧的 index-旧Hash.js 会留在 Backups 里,不会被浏览器误用
pnpm deploy
#!/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(); // 关闭命令行交互,脚本正常结束
});
});
{ "scripts": { "rollback": "node scripts/rollback.js" } }
pnpm rollback
配置 Vite 的 base 选项,让构建产物的所有资源路径指向 CDN 地址,而不是相对路径。然后用 http-server 模拟 CDN 服务器,观察资源加载变化
base: mode === 'production' ? 'https://cdn.example.com/demo2-env/' // 生产环境走 CDN : '/', // 开发环境走相对路径
pnpm build
打开 dist/index.html,会看到:
原本的绝对路径 变为了相对路径
在 package.json 中添加模拟 CDN 的命令: "serve:cdn": "http-server dist --port 8080 --cors"(scripts下加)
在一个终端启动 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 部署。
创建 src/utils/requestPool.ts:
// src/utils/requestPool.ts
/**
*/
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);
<!-- 按钮:点击触发批量发起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
“指数退避”就是:重试的间隔越来越长。比如:第一次失败等 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') {
// 超时错误,决定重试...
}
}
其他逻辑.....
(error as any).response = { status: 500 };如果报错说不让使用any 可以声明 (error as Error & { response: { status: number } }).response = { status: 500 }; 表明:这是一个 Error,同时还有一个 response 属性” 创建 src/api/testApi.ts:
保持原有内容不变 新增一点内容 其实就是加了个按钮 !!!但要注意@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 次调用成功 → 请求成功!
验证: 注册 Service Worker,在第一次访问时缓存关键资源(index.html、核心 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.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。
mkdir -p public
mv sw.js public/
修改 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 这种假地址)。
开发时尽量用 base: '/' 跑通所有 Demo,CDN 配置单独作为一个演示分支,避免互相干扰。
且谨记 Service Worker 规范要求:SW 脚本必须是 JavaScript 文件,且 MIME 类型必须是 text/javascript 或 application/javascript。如果是ts类型 就会拿到视频类型,直接拒绝注册。
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 动画、超时重试逻辑是否正常,提前优化用户体验,而不是等用户投诉后再改代码。
如图:
这些东西不需要天天用,但当你碰到“后端没写好”、“线上报错复现不了”、“手机上看不到报错”时,你心里得清楚
——Whistle(或类似的代理工具)就是专门解决这些“非代码本身”的环境问题的。 这就是它在日常开发里的“隐形价值”——它是你的后备武器,不是日常主武器。
Whistle 是一个基于 Node.js 的跨平台抓包代理工具,安装前确保 Node.js 已安装whistle: C 端项目(电商、资讯、H5),线上出了 Bug 但本地无法复现时,Whistle 能把线上页面的 JS 替换成本地文件帮你调试,这是 DevTools 做不到的。
pnpm install -g whistle
可能会出现:
这是因为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 否则会出现:

方式 A:使用 SwitchyOmega 插件(推荐)
在 Chrome 应用商店搜索安装 SwitchyOmega。(edge也行)
选这个 稳定
新建情景模式 → 选择“代理服务器”:
协议:HTTP
服务器:127.0.0.1
端口:8899
点击“应用选项”,然后点击插件图标选择该情景模式。
方式 B:系统代理(不推荐,会代理所有流量) Mac:系统偏好设置 → 网络 → 高级 → 代理 → 网页代理(HTTP) 和 安全网页代理(HTTPS),都填 127.0.0.1:8899。 Windows:设置 → 网络和Internet → 代理 → 手动设置代理,地址填 127.0.0.1,端口填 8899。
打开浏览器,访问 http://127.0.0.1:8899,你会看到一个仪表盘界面:
Network 面板:查看所有经过代理的请求列表,点开任意请求可以看到完整的请求头、响应头、响应体(明文)。要看接口返回了什么 -> 点 Network 标签页,找请求。
Rules 面板:配置代理规则(把 api.xxx.com 指向 localhost:3000、修改请求/响应内容等)。要写 Mock 数据 -> 点 Rules 标签页,写规则
HTTPS 面板:管理 HTTPS 解密需要的根证书。要抓手机包或看 HTTPS 明文 -> 点 HTTPS 区域下载证书,然后手机连代理,再回来看 Network 里的请求(此时不再是 Tunnel)。 要看 WebSocket 消息 -> 点 WS 过滤。
为什么要装根证书? HTTPS 抓包的本质是“中间人劫持(MITM)”,Whistle 需要用自己的根证书动态生成目标域名的假证书。浏览器信任这个假证书的前提是——它信任签发这个假证书的根证书。所以你必须手动把 Whistle 的根证书安装到系统信任区。
安装步骤: 在 Whistle 管理界面,点击顶部菜单 HTTPS。 点击 下载根证书,保存到本地。
Mac 安装: 双击 .crt 文件,自动打开钥匙串访问。 找到 whistle 证书,双击 → 展开“信任” → 选择“始终信任”。
Windows 安装:
双击 .crt 文件 → 点击“安装证书” → 选择“本地计算机” → 选择“将所有证书放入下列存储” → 浏览选择“受信任的根证书颁发机构” → 完成。

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

这样就可以了:
在 Whistle 的 Network 面板里,应该能看到百度首页的所有 HTTPS 请求(www.baidu.com、sp0.baidu.com 等)。
点击任意一个请求,右侧查看 Response 标签页,能看到 HTML 源码(明文)。 这里也会出现这样的问题 没有在右侧面板里看到对应的response标签页
在setting里打开response设置:
在http里打开
直至http显示为
就可以在Inspector里底部看见Response界面了

这是前端开发中最常用的场景之一。在 Whistle 管理界面,点击 Rules 菜单,在左侧编辑区输入:
api.xxx.com/api 127.0.0.1:3000
https://cdn.xxx.com/app.js file:///Users/你的用户名/project/dist/app.js
api.xxx.com/user resBody://{code:0, data:{name:'test'}}
api.xxx.com/api resDelay://500
在本 Vue 项目里,发起一个请求到 api.xxx.com/api/test,然后打开 Whistle 的 Network 面板,你会看到这个请求被捕获。如果配置了 resBody:// 规则,会看到接口返回了你指定的 Mock 数据,而不是真实后端的数据。 这意味着不需要等后端接口开发完成,就可以提前模拟数据调试页面逻辑了。
那么第六步如何实施呢?:
<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>
第二步:在 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"]
}
}
1.点击顶部菜单 Rules。2.在左侧编辑区输入以下规则:
https://api.example.com/user/info resBody://{userInfo}
这行规则的解读: https://api.example.com/user/info:匹配你代码里请求的完整 URL。 resBody://{userInfo}:把该 URL 的响应内容替换为 Values 里 userInfo 中存储的数据。 点击右上角的 Save 保存。
1.回到浏览器,打开你的项目页面 http://localhost:5173。2.点击 “请求用户信息” 按钮。3.观察页面显示的内容
就会发现
这是因为localhost:5173和我们既定的https://api.example.com/user/info产生了跨域 引发了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
用处: // 1.在项目中嵌入vConsole 调试面板(移动端H5调试利器) // 2.通过URL参数?debug=1控制vConsole是否加载 // 3.配置Vite生成SourceMap文件(用于线上报错定位)
pnpm add -D vconsole
修改 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.config.js // ========== 新增:开启 SourceMap ========== build: { sourcemap: true, // 构建时生成 .map 文件 },
1.执行 pnpm dev,打开 http://localhost:5173。
2.预期结果:页面右下角出现绿色的 vConsole 按钮。点击它,可以看到 Console、Network、Elements 等调试面板。

3.执行 pnpm build,然后 pnpm preview,打开 http://localhost:4173。
4.预期结果:默认不显示 vConsole 按钮(因为生产环境,且 URL 中没有 debug=1)。
因为判断为import.meta.env.MODE != 'development' 所以不执行下面的内容
5.在 URL 后加 ?debug=1(如 http://localhost:4173/?debug=1),刷新页面。
因为判断为hasDebugParam = window.location.search.includes('debug=1'); 所以执行下面的内容
6.预期结果:vConsole 按钮出现 ✅
在传统的 HTTP 通信中,有一个根本性的限制:只能由客户端主动发起请求,服务器被动响应。
想象这些真实业务场景: 聊天应用:别人给你发消息,你必须刷新页面才能看到? 股票行情:价格变动了,你要手动刷新页面才能看到最新价格? 订单推送:用户下单了,你要不断刷新后台才能看到新订单? 游戏对战:对手出招了,你要等页面刷新才能看到? 如果只能用 HTTP 实现,唯一的办法是轮询(Polling): 短轮询:每隔 1 秒发一次 HTTP 请求“有新消息吗?” 长轮询:发一个请求,有消息时返回,否则挂住,但有超时限制,超时后重新发请求
这两种方式的共同问题: 大量无意义的请求浪费带宽、消耗服务器资源、实时性差。HTTP 协议本身是无状态的“一问一答”模式,服务器无法主动“推”数据给客户端,这就是 WebSocket 诞生的根本原因。
这个 Demo 在浏览器里建立了一条 WebSocket 长连接,实现:
在 src/components/WebSocketDemo.vue 中创建:
<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>
<script setup lang="ts">
import WebSocketDemo from './components/WebSocketDemo.vue';
</script>
执行 pnpm dev,打开 http://localhost:5173。
点击 “连接” 按钮。
预期结果:按钮上方显示“✅ 已连接”,消息框出现 [时间] 🔗 连接已建立。
在输入框中输入任意文字(如 Hello),点击 “发送”。
预期结果:消息框出现 [时间] ➡️ 发送 Hello,紧接着出现 [时间] ⬅️ 收到 Hello(回显)。
点击 “断开” 按钮。
预期结果:状态变为“❌ 未连接”,消息框出现 [时间] 🔌 连接已断开。

在浏览器中按 F12 打开 DevTools。
切换到 Network 面板,点击 WS 标签(过滤 WebSocket 请求)。
点击页面上的 “连接” 按钮。
预期结果:Network 面板中出现一条 WebSocket 连接记录(wss://echo.websocket.org)。
点击该记录,切换到 Messages 标签页。
预期结果:你可以看到所有发送和接收的消息帧(Frame)。
关键观察:连接建立后,即使不发送任何消息,连接也会一直保持(长连接),不会像 HTTP 那样每次请求都断开。

















