其显示出的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: {
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;
}
/**
-
指数退避重试
*/
export async function retryWithBackoff(
fn: () => Promise,
options: RetryOptions = {}
): Promise {
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 次调用成功 → 请求成功!















