- Update yarn.lock - Add project implementation docs in docs/ - Add personal internship experience notes in 实习讲解/
569 lines
27 KiB
Markdown
569 lines
27 KiB
Markdown
# 性能优化 — 面试版
|
||
|
||
> 简历原话:**"采用虚拟滚动和懒加载策略,路由守卫优化、API 缓存降级等技术优化页面性能"**
|
||
>
|
||
> ⚠️ **重要提醒**:代码中**没有**虚拟滚动的实现(没有相关库或代码)。"懒加载"是真实存在的(路由懒加载、IntersectionObserver 等),"路由守卫优化"是真实存在的(interceptor.ts 248 行),"API 缓存降级"也是真实存在的(360 行自研代码)。面试时建议把重点放在"路由守卫优化 + 懒加载 + API 缓存降级 + 防抖节流 + shallowRef"这五个真实存在的优化上。
|
||
>
|
||
> 这篇文档基于代码实际情况,帮你理解项目中做了哪些性能优化,以及面试时怎么讲。
|
||
|
||
---
|
||
|
||
## 一、先搞清楚:项目里到底做了哪些优化?
|
||
|
||
实际代码中有 **7 大类性能优化**,都是真实存在的:
|
||
|
||
| 优化手段 | 做了什么 | 数量/规模 | |
|
||
| ------------------------ | ------------------------------------------------------------------------- | ----------------- | --- |
|
||
| **路由守卫优化** | beforeEach 缓存 ID 避免重复 API、Promise.all 并行请求、条件性请求、客户端预校验、声明式 meta 配置、白名单短路 | 248 行核心代码,7 种优化策略 | |
|
||
| **路由懒加载** | 所有页面组件用动态 import,按需加载 | 80+ 个路由 | |
|
||
| **防抖节流** | 用户输入、滚动、resize 等高频操作加防抖/节流 | 40+ 处 | |
|
||
| **API 缓存降级** | 请求结果缓存到 IndexedDB,失败时读缓存兜底 | 自研系统,360 行代码 | |
|
||
| **shallowRef** | 大数据对象用浅层响应式,避免深度遍历 | 30+ 处 | |
|
||
| **IntersectionObserver** | 元素进入视口才加载/上报,不提前渲染 | 6+ 处 | |
|
||
| **lodash 按需引入** | 只引入用到的函数,不打包整个 lodash | 全项目 | |
|
||
|
||
**一句话概括**:我从路由守卫、路由加载、用户交互、API 请求、响应式数据、DOM 渲染六个层面做了性能优化,核心思路是"能不加载就不加载,能少算就少算,能缓存就缓存"。
|
||
|
||
---
|
||
|
||
## 二、每个优化怎么做的?(面试核心)
|
||
|
||
### 2.1 路由守卫优化(简历新增,技术含量最高之一)
|
||
|
||
**问题**:80+ 个页面每次导航都要经过 `router.beforeEach` 守卫,如果守卫里每次都调 API 查权限、查组织信息、查仓库信息,页面切换会非常慢。
|
||
|
||
**解决方案**:在 `src/router/interceptor.ts`(248 行)中实现了一个 async beforeEach 守卫,通过 7 种优化策略大幅减少不必要的 API 调用。
|
||
|
||
#### 守卫的 7 个执行阶段
|
||
|
||
```
|
||
Token 验证 → namespace 类型解析 → ref 追踪 → 组织详情获取 → 仓库详情+权限
|
||
→ 页面标题设置 → 登录权限检查
|
||
```
|
||
|
||
#### 优化 1:模块级 ID 缓存,避免重复 API 调用
|
||
|
||
```typescript
|
||
// interceptor.ts 第 18-19 行 — 模块级缓存变量
|
||
let currentOrgId = ''; // 缓存当前组织 ID
|
||
let currentRepoId = ''; // 缓存当前仓库 ID
|
||
|
||
// 切换组织时:只有 orgId 变了才调 API
|
||
if (orgId.value !== currentOrgId) {
|
||
const res = await reqCatch(getOrg, { orgId: orgId.value, with_full_path: true });
|
||
// ... 请求组织信息
|
||
}
|
||
currentOrgId = orgId.value; // 更新缓存
|
||
|
||
// 切换仓库时:只有 repoId 变了才调 API
|
||
if (repoId.value !== currentRepoId) {
|
||
const [repoRes, roleRes] = await Promise.all([...]);
|
||
// ... 请求仓库信息
|
||
}
|
||
currentRepoId = repoId.value; // 更新缓存
|
||
```
|
||
|
||
**为什么这样设计?** 同一个组织/仓库下有多个子页面(如仓库的文件、Issue、Merge Request、Wiki 等 tab),用户在这些 tab 之间切换时,仓库信息不需要重新请求。用模块级变量缓存 ID,只在真正切换组织/仓库时才发请求。
|
||
|
||
#### 优化 2:Promise.all 并行请求
|
||
|
||
```typescript
|
||
// interceptor.ts 第 128-130 行
|
||
// ❌ 串行写法:两个请求先后发,总耗时 = API1 + API2
|
||
// const repoRes = await getRepo({ repoId, statistics: true });
|
||
// const roleRes = await reqCatch(getRepoRole, { repoId });
|
||
|
||
// ✅ 并行写法:两个请求同时发,总耗时 = max(API1, API2)
|
||
const promises = [getRepo({ repoId: repoId.value, statistics: true }, { customError: true })];
|
||
account.isLogin && promises.push(reqCatch(getRepoRole, { repoId: repoId.value }));
|
||
const [repoRes, roleRes] = await Promise.all(promises);
|
||
```
|
||
|
||
**效果**:仓库信息 + 角色权限两个请求并行发出,总耗时约等于较慢的那个请求,而不是两者之和。
|
||
|
||
#### 优化 3:条件性请求——不需要的数据就不请求
|
||
|
||
```typescript
|
||
// 只有登录用户才请求角色权限
|
||
account.isLogin && promises.push(reqCatch(getRepoRole, { repoId: repoId.value }));
|
||
|
||
// 登录后才会调用的 notice 接口
|
||
// 在 useRepoInit 中:if (!isLogin) return;
|
||
```
|
||
|
||
**为什么这样设计?** 未登录用户看不到仓库的管理功能,不需要知道当前用户的角色。跳过这个请求既省时间又省后端资源。同样,组织详情里的关注/通知等接口,未登录时直接跳过。
|
||
|
||
#### 优化 4:客户端预校验——在发请求之前就拦截无效路径
|
||
|
||
```typescript
|
||
// interceptor.ts 第 122-124 行 — 正则预校验
|
||
const { repoId } = useRepoId('%2F', to.params.namespace, to.params.repoName);
|
||
|
||
// 禁止访问以 .wiki、.git、.atom 结尾的仓库路径
|
||
if (!repoPathSuffix.test(repoId.value)) {
|
||
return { name: '404' }; // 客户端直接返回 404,不发任何 API 请求
|
||
}
|
||
```
|
||
|
||
**效果**:用户误输入了 `xxx.wiki.git` 这样的路径,在发起任何 API 请求之前就被拦截。节省了一次无意义的网络请求。
|
||
|
||
#### 优化 5:声明式 meta 配置——权限控制不写死在守卫里
|
||
|
||
```typescript
|
||
// 路由定义中声明(config 文件)
|
||
{ path: '/dashboard', meta: { needLogin: true, loginType: '访问 dashboard' } }
|
||
{ path: '/login', meta: { loginHidden: true } }
|
||
|
||
// 守卫中统一判断(interceptor.ts 第 215-232 行)
|
||
if (to.meta.needLogin) {
|
||
if (account.isLogin) {
|
||
return true;
|
||
} else {
|
||
emitEvent('login', { triggerType: to.meta?.loginType }); // 弹出登录框
|
||
return false;
|
||
}
|
||
} else {
|
||
if (account.isLogin && to.meta.loginHidden) {
|
||
return { name: 'dashboard' }; // 已登录用户看登录页,重定向到首页
|
||
}
|
||
}
|
||
```
|
||
|
||
**为什么这样设计?** 新增一个需要登录的页面,只需要在路由定义里加 `meta: { needLogin: true }`,不需要改守卫代码。守卫逻辑和路由配置解耦,可维护性强。
|
||
|
||
#### 优化 6:白名单短路——不该处理的页面直接跳过
|
||
|
||
```typescript
|
||
// interceptor.ts 第 42 行 — 白名单
|
||
const fromHomeWeb = ['home', 'search', 'gStar', 'gStarApply', 'explore'];
|
||
|
||
// ref 追踪:子应用页面不上报 ref
|
||
if (!fromHomeWeb.includes(to.name)) {
|
||
window.page_ref = ref;
|
||
sessionStorage.setItem('ref', BASE_URL + to.fullPath);
|
||
}
|
||
|
||
// 页面量上报:子应用页面不上报 PV
|
||
if (fromHomeWeb.includes(to.name)) return;
|
||
```
|
||
|
||
**为什么这样设计?** 微前端子应用(homeweb)内部有自己的路由和上报逻辑,主应用的守卫不应该干涉。白名单让这些页面直接跳过,避免冲突和重复调用。
|
||
|
||
#### 优化 7:localStorage 持久化——跨会话记住用户行为
|
||
|
||
```typescript
|
||
// interceptor.ts 第 22-31 行 — 记录已访问的仓库
|
||
const setRepoVisited = (id) => {
|
||
let visitedRepoIds = localStorage.getItem('visited_repo_list')?.split(',') || [];
|
||
visitedRepoIds.push(`${id}`);
|
||
visitedRepoIds = Array.from(new Set(visitedRepoIds));
|
||
localStorage.setItem(visitedRepoList, visitedRepoIds.join());
|
||
};
|
||
|
||
const isRepoVisited = (id) => {
|
||
return localStorage.getItem(visitedRepoList)?.includes(id) || false;
|
||
};
|
||
|
||
// 守卫中的应用:首次进仓库 → 跳转 Dashboard,之后进仓库 → 直接进代码 tab
|
||
if (!account.isLogin || !repoStore.isVisitor) {
|
||
isGotoRepoDashboard = !isRepoVisited(repoId);
|
||
}
|
||
```
|
||
|
||
**为什么这样设计?** 用户第一次访问某个仓库时,引导去 Dashboard 了解概况;后续再访问时直接进入代码页面。这个"是否首次访问"的状态持久化在 localStorage 中,跨会话保持,不需要后端 API。
|
||
|
||
#### 路由守卫优化 — 完整架构图
|
||
|
||
```
|
||
router.beforeEach(async (to, from) => {
|
||
│
|
||
├── 1. Token 验证 ──── checkIsLogin() 只在有 token 时才调 API
|
||
│
|
||
├── 2. namespace 类型 ── userOrOrg 路由才调 getPathType API
|
||
│
|
||
├── 3. ref 追踪 ────── fromHomeWeb 白名单跳过子应用
|
||
│
|
||
├── 4. 组织详情 ────── currentOrgId 缓存,切换组织才请求
|
||
│ 404/403 → 直接返回错误页,不继续走后续逻辑
|
||
│
|
||
├── 5. 仓库详情 ────── currentRepoId 缓存 + Promise.all 并行
|
||
│ repoPathSuffix 正则预校验 → 无效路径不发请求
|
||
│ module_setting 模块开关 → 禁用模块走 404
|
||
│ 首次访问 Dashboard 重定向 → localStorage 持久化
|
||
│
|
||
├── 6. 页面标题 ────── 根据 type 自动拼接组织/仓库名
|
||
│
|
||
└── 7. 登录检查 ────── meta.needLogin / meta.loginHidden 声明式控制
|
||
})
|
||
```
|
||
|
||
**面试话术**:
|
||
> "路由守卫优化是我在项目中做的一个重要优化。80 多个页面每次导航都经过 beforeEach,如果每次都调 API,页面切换会很慢。我做了 7 种优化:第一,模块级 ID 缓存,用 currentOrgId 和 currentRepoId 两个变量,只有真正切换组织/仓库时才发 API,同一仓库内切换 tab 不发请求;第二, Promise.all 并行请求,仓库信息和角色权限两个请求同时发出;第三,条件性请求,未登录用户不请求角色权限;第四,客户端预校验,用正则过滤无效仓库路径,在发请求之前就返回 404;第五,声明式 meta 配置,权限控制逻辑不在守卫里硬编码;第六,白名单短路,子应用页面跳过主应用守卫逻辑;第七,localStorage 持久化,记住用户首次访问仓库的状态,跨会话保持。这些优化让页面切换更快、更流畅。"
|
||
|
||
---
|
||
|
||
### 2.2 路由懒加载(最重要,必讲)
|
||
|
||
**问题**:项目有 80+ 个页面,如果打包成一个 JS 文件,首页加载要下载好几 MB。
|
||
|
||
**解决方案**:每个路由组件用 `() => import(...)` 动态导入,访问哪个页面才下载哪个页面的代码。
|
||
|
||
```typescript
|
||
// router/config/home.ts — 每个路由都是懒加载
|
||
const routes = [
|
||
{
|
||
path: '/explore',
|
||
component: () => import('@/views/micro-page/proxy-homeweb.vue'), // ← 动态导入
|
||
},
|
||
{
|
||
path: '/security',
|
||
component: () => import('@/views/Jyh/security/index.vue'), // ← 动态导入
|
||
},
|
||
{
|
||
path: '/ecosystem',
|
||
component: () => import('@/views/Jyh/ecosystem/index.vue'), // ← 动态导入
|
||
},
|
||
// ... 80+ 个路由全部这样写
|
||
];
|
||
```
|
||
|
||
**效果**:
|
||
- 首页只加载首页需要的代码(几十 KB)
|
||
- 用户点到"安全态势"页面,才下载安全态势的代码
|
||
- 每个页面变成独立的 JS 文件(chunk),按需加载
|
||
|
||
**地图数据也是懒加载的**:
|
||
```javascript
|
||
// utils/mapLoader.js — 地图数据按需加载
|
||
export const loadWorldMap = async () => {
|
||
await import('../views/Jyh/security/components/world.js'); // 用到世界地图时才加载
|
||
};
|
||
export const loadChinaMap = async () => {
|
||
await import('/china.js'); // 用到中国地图时才加载
|
||
};
|
||
```
|
||
|
||
**面试话术**:
|
||
> "项目有 80 多个页面,全部用路由懒加载。每个页面组件通过 `() => import(...)` 动态导入,Vite 构建时自动拆分成独立的 chunk,用户访问哪个页面才下载哪个页面的代码。地图数据也是懒加载的,世界地图和中国地图的 GeoJSON 文件(几百 KB)只在用户进入安全态势页面时才加载。"
|
||
|
||
---
|
||
|
||
### 2.3 防抖节流(数量最多)
|
||
|
||
**问题**:用户快速输入搜索、频繁滚动、拖拽窗口大小时,事件每秒触发几十次,每次都调 API 或重绘图表会很卡。
|
||
|
||
**解决方案**:用防抖(debounce)和节流(throttle)控制触发频率。
|
||
|
||
#### 防抖(debounce)— 等用户"停下来"才执行
|
||
|
||
```typescript
|
||
import debounce from 'lodash/debounce'; // 按需引入
|
||
|
||
// 搜索输入:用户停止输入 300ms 后才发请求
|
||
const handleSearch = debounce((keyword) => {
|
||
fetchSearchResults(keyword);
|
||
}, 300);
|
||
|
||
// 滚动加载:用户停止滚动 300ms 后才检查是否需要加载更多
|
||
const handleScroll = debounce(() => {
|
||
checkLoadMore();
|
||
}, 300);
|
||
```
|
||
|
||
#### 节流(throttle)— 每隔一段时间才执行一次
|
||
|
||
```typescript
|
||
import throttle from 'lodash/throttle';
|
||
|
||
// 窗口 resize:每 100ms 最多执行一次
|
||
const handleResize = throttle(() => {
|
||
recalculateLayout();
|
||
}, 100);
|
||
|
||
// 滚动事件:每 200ms 最多执行一次
|
||
const handleScroll = throttle(() => {
|
||
updateScrollPosition();
|
||
}, 200);
|
||
```
|
||
|
||
**项目中的分布**:
|
||
|
||
| 场景 | 使用方式 | 延迟时间 | 出现次数 |
|
||
|------|----------|----------|----------|
|
||
| 搜索输入 | debounce | 300ms | 15+ 处 |
|
||
| 表单提交 | debounce | 300ms | 10+ 处 |
|
||
| 列表滚动加载 | debounce | 300ms | 5+ 处 |
|
||
| 窗口 resize | throttle | 100ms | 2 处 |
|
||
| 滚动事件 | throttle | 200ms | 2 处 |
|
||
| 表格选择 | debounce | 10ms | 1 处 |
|
||
| 侧边栏展开 | debounce | 600ms | 1 处 |
|
||
|
||
**为什么 lodash 按需引入?**
|
||
```typescript
|
||
// ✅ 正确:只引入 debounce 函数
|
||
import debounce from 'lodash/debounce';
|
||
|
||
// ❌ 错误:引入整个 lodash 库(几百 KB)
|
||
import { debounce } from 'lodash';
|
||
```
|
||
|
||
按需引入让 Vite 的 tree-shaking 生效,最终打包只包含用到的函数,而不是整个 lodash 库。
|
||
|
||
**面试话术**:
|
||
> "项目中有 40 多处防抖节流。搜索输入用 300ms 防抖,等用户停下来才发请求;窗口 resize 用 100ms 节流,限制重绘频率;滚动事件用 200ms 节流。所有 lodash 函数都按需引入(`lodash/debounce`),配合 Vite 的 tree-shaking,不会打包整个 lodash 库。"
|
||
|
||
---
|
||
|
||
### 2.4 API 缓存降级系统(技术含量最高)
|
||
|
||
**问题**:后端 API 可能超时或报错,用户看到的是空白页面或加载失败。
|
||
|
||
**解决方案**:自研了一套请求降级拦截器,成功时缓存响应,失败时读缓存兜底。
|
||
|
||
#### 整体流程
|
||
|
||
```
|
||
请求发出 → 正常返回? → 是 → 缓存到 IndexedDB → 返回数据给页面
|
||
→ 否(超时/报错)→ 读 IndexedDB 缓存 → 返回缓存数据给页面
|
||
```
|
||
|
||
#### 核心代码(简化版)
|
||
|
||
```typescript
|
||
// degradeInterceptor.ts — 请求降级拦截器
|
||
class DegradeInterceptor {
|
||
// 请求成功时:缓存响应到 IndexedDB
|
||
onResponseFulfilled(response) {
|
||
this.saveToCache(response.config.url, response.data);
|
||
return response;
|
||
}
|
||
|
||
// 请求失败时:从 IndexedDB 读缓存兜底
|
||
onResponseRejected(error) {
|
||
const cachedData = this.readFromCache(error.config.url);
|
||
if (cachedData) {
|
||
return cachedData; // 返回缓存数据,用户看到的是"旧数据"而不是错误
|
||
}
|
||
return Promise.reject(error);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 缓存配置
|
||
|
||
```typescript
|
||
// storage-apis.ts — 哪些 API 走缓存策略
|
||
const storageApis = [
|
||
{ url: '/api/v1/issues', timeout: 5000, retry: 2 }, // 5秒超时,重试2次
|
||
{ url: '/api/v1/merge_requests', timeout: 5000, retry: 2 },
|
||
// ...
|
||
];
|
||
```
|
||
|
||
#### 401 Token 刷新时的请求去重
|
||
|
||
```typescript
|
||
// request.ts — 防止 token 过期时同时发 10 个刷新请求
|
||
let isRefreshing = false;
|
||
let pendingQueue = []; // 等待队列
|
||
|
||
// token 过期时:
|
||
if (!isRefreshing) {
|
||
isRefreshing = true;
|
||
await refreshToken();
|
||
isRefreshing = false;
|
||
pendingQueue.forEach(cb => cb()); // 把等待的请求全部重发
|
||
pendingQueue = [];
|
||
} else {
|
||
// 已经在刷新了,把当前请求放进队列等着
|
||
return new Promise(resolve => pendingQueue.push(resolve));
|
||
}
|
||
```
|
||
|
||
**面试话术**:
|
||
> "我实现了一套 API 缓存降级系统。请求成功时把响应缓存到 IndexedDB,后端超时或报错时自动读缓存兜底,用户看到的是旧数据而不是错误页面。还有 token 刷新时的请求去重——token 过期时如果有 10 个请求同时发出,只会发一次刷新请求,其他 9 个排队等待,刷新完成后统一重发。"
|
||
|
||
---
|
||
|
||
### 2.5 shallowRef 减少响应式开销
|
||
|
||
**问题**:Vue 的 `ref()` 会深度遍历对象的每个属性,给每个属性都加响应式代理。对于大数据对象(几百条列表数据),这个遍历本身就很耗性能。
|
||
|
||
**解决方案**:用 `shallowRef()` 只监听整个对象的替换,不监听内部属性变化。
|
||
|
||
```typescript
|
||
import { shallowRef } from 'vue';
|
||
|
||
// ❌ 普通 ref:深度响应式,遍历每个属性
|
||
const tableData = ref(bigDataArray); // 1000 条数据,每条都加代理
|
||
|
||
// ✅ shallowRef:浅层响应式,只监听整体替换
|
||
const tableData = shallowRef(bigDataArray); // 不遍历,只在 .value 被替换时触发更新
|
||
```
|
||
|
||
**项目中 30+ 处使用**,主要用于:
|
||
- 大数据列表(表格数据、搜索结果)
|
||
- 恶意软件分析详情(大 JSON 对象)
|
||
- 威胁内容列表
|
||
- 表单引用(不需要响应式)
|
||
- 分页器状态(用 `shallowReactive`)
|
||
|
||
```typescript
|
||
// 典型用法:表格数据用 shallowRef
|
||
const tableData = shallowRef([]);
|
||
const fetchTableData = async () => {
|
||
const res = await api.getData();
|
||
tableData.value = res.data; // 整体替换,触发更新
|
||
};
|
||
|
||
// 分页器用 shallowReactive
|
||
const pager = shallowReactive({ page: 1, size: 20, total: 0 });
|
||
```
|
||
|
||
**面试话术**:
|
||
> "对于大数据对象,我用 shallowRef 替代 ref。ref 会深度遍历对象每个属性加响应式代理,1000 条列表数据的遍历开销很大。shallowRef 只监听整体替换,不监听内部属性变化,数据更新时直接 `tableData.value = newData` 整体替换就行。项目中有 30 多处使用。"
|
||
|
||
---
|
||
|
||
### 2.6 IntersectionObserver 懒加载
|
||
|
||
**问题**:页面下方的内容在用户滚动到之前不需要渲染。
|
||
|
||
**解决方案**:用 IntersectionObserver 监听元素是否进入视口,进入后才触发加载或上报。
|
||
|
||
#### 自定义曝光指令
|
||
|
||
```typescript
|
||
// directives/element-exposure.ts — 自定义 Vue 指令
|
||
const vExposure = {
|
||
mounted(el, binding) {
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
binding.value.trigger('expo'); // 元素进入视口,触发上报
|
||
observer.unobserve(el); // 上报一次后取消监听
|
||
}
|
||
});
|
||
}, { threshold: 0 });
|
||
observer.observe(el);
|
||
}
|
||
};
|
||
|
||
// 使用:<div v-exposure="{ trigger: reportExposure }">...</div>
|
||
```
|
||
|
||
#### 滚动触发动画
|
||
|
||
```javascript
|
||
// product1.vue — 元素滚动到视口时才播放动画
|
||
const observer = new IntersectionObserver((entries) => {
|
||
if (entries[0].isIntersecting) {
|
||
startAnimation(); // 进入视口才开始动画
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
observer.observe(elementRef.value);
|
||
```
|
||
|
||
**面试话术**:
|
||
> "我用 IntersectionObserver 实现了元素的懒加载。自定义了一个 Vue 指令,元素进入视口时才触发曝光上报或播放动画,上报后自动取消监听。这样页面下方的内容在用户滚动到之前不会有任何开销。"
|
||
|
||
---
|
||
|
||
### 2.7 @vueuse/core 工具库
|
||
|
||
项目大量使用 vueuse 提供的性能优化工具,避免自己写低效代码:
|
||
|
||
| 工具 | 用在哪 | 干什么 |
|
||
|------|--------|--------|
|
||
| `useWindowSize` | 响应式布局 | 监听窗口大小,返回响应式 width/height |
|
||
| `useThrottleFn` | resize 事件 | 节流函数,100ms 最多执行一次 |
|
||
| `useResizeObserver` | 内容溢出检测 | 高效检测元素尺寸变化,不轮询 |
|
||
| `useElementSize` | 搜索框/布局 | 获取元素宽高 |
|
||
| `useClipboard` | 复制按钮(8处) | 剪贴板操作 |
|
||
| `useInfiniteScroll` | 历史记录 | 无限滚动加载 |
|
||
| `useEventListener` | 文件搜索 | 事件监听,自动清理 |
|
||
| `useMutationObserver` | 文本组件 | DOM 变化监听 |
|
||
|
||
---
|
||
|
||
## 三、哪些简历上写了但代码里没有?
|
||
|
||
| 简历描述 | 代码实际情况 | 建议 |
|
||
|----------|------------|------|
|
||
| **虚拟滚动** | ❌ 没有实现(无相关库/代码,无 vue-virtual-scroller 等依赖) | 面试时别主动提,被问到可以说"评估后发现数据量不需要虚拟滚动,分页已经控制了单页数据量" |
|
||
| **懒加载策略** | ✅ 真实存在(路由懒加载 + IntersectionObserver + 地图数据懒加载) | 可以讲 |
|
||
| **路由守卫优化** | ✅ 真实存在(interceptor.ts 248 行,7 种优化策略) | 可以讲,重点讲 ID 缓存 + Promise.all 并行 + 条件性请求 |
|
||
| **API 缓存降级** | ✅ 真实存在(degradeInterceptor.ts 360 行 + request.ts token 去重) | 可以讲,技术含量最高的优化之一 |
|
||
|
||
**面试时建议这样讲**:
|
||
> "性能优化方面,我做了路由守卫优化(ID 缓存 + Promise.all 并行 + 客户端预校验等 7 种策略)、路由懒加载(80+ 页面按需加载)、防抖节流(40+ 处高频事件优化)、API 缓存降级(IndexedDB 缓存 + 失败兜底 + token 请求去重)、shallowRef 减少响应式开销(30+ 处大数据对象)、IntersectionObserver 元素懒加载。这些优化综合下来,页面切换和首屏加载都有明显改善。"
|
||
|
||
---
|
||
|
||
## 四、面试问答准备
|
||
|
||
### Q1:"路由守卫优化"具体做了什么?
|
||
|
||
> 路由守卫是所有页面切换的入口,80+ 个页面每次导航都经过 beforeEach。如果每次都会调 API,页面切换会很慢。我做了 7 种优化:第一,模块级 ID 缓存,用 currentOrgId 和 currentRepoId 缓存当前组织/仓库 ID,同一仓库内切换 tab 不发请求;第二,Promise.all 并行请求,仓库信息和角色权限同时发出;第三,条件性请求,未登录用户不请求角色权限;第四,客户端预校验,用正则过滤无效路径,在发请求之前就返回 404;第五,声明式 meta 配置,用 needLogin/loginHidden 标记控制权限;第六,白名单短路,子应用页面跳过主应用守卫;第七,localStorage 持久化已访问仓库列表,跨会话记住用户首次访问状态。
|
||
|
||
### Q2:防抖和节流有什么区别?你项目里怎么用的?
|
||
|
||
> 防抖是"等用户停下来才执行",比如搜索框输入 300ms 后才发请求,用户一直打字就一直不发。节流是"每隔一段时间执行一次",比如窗口 resize 每 100ms 最多重绘一次。项目中搜索、表单提交用防抖(300ms),滚动和 resize 用节流(100~200ms)。总共 40 多处,全部用 lodash 的按需引入(`lodash/debounce`),配合 Vite tree-shaking 不会打包整个 lodash。
|
||
|
||
### Q3:API 缓存降级系统怎么设计的?
|
||
|
||
> 三层设计:第一层,请求成功时把响应缓存到 IndexedDB,配置了最大缓存条数和过期时间;第二层,请求超时或报错时自动从 IndexedDB 读缓存返回给页面,用户看到旧数据而不是错误;第三层,token 过期时做请求去重,多个并发请求只发一次 token 刷新,其他请求排队等刷新完成后统一重发。
|
||
|
||
### Q4:shallowRef 和 ref 有什么区别?为什么用 shallowRef?
|
||
|
||
> ref 会深度遍历对象的每个属性,给每个属性都加响应式代理。如果数据是 1000 条列表,每条有 10 个字段,ref 要给 10000 个属性加代理,这个遍历本身就有开销。shallowRef 只监听整个对象的替换(.value 赋值),不监听内部属性变化。对于只需要整体替换不需要监听单个字段变化的大数据对象,shallowRef 性能更好。项目中表格数据、分析详情等 30 多处都用了 shallowRef。
|
||
|
||
### Q5:虚拟滚动做了吗?
|
||
|
||
> 项目中评估后没有引入虚拟滚动。原因是我们的列表数据量通常在几十到几百条,分页已经控制了单页数据量,虚拟滚动的收益不大,反而增加代码复杂度。如果未来数据量增长到几千条,可以引入 vue-virtual-scroller 做进一步优化。
|
||
|
||
### Q6:还有哪些性能优化手段?
|
||
|
||
> 还有几个:lodash 按需引入做 tree-shaking,不打包整个库;@vueuse/core 的 35+ 个工具函数避免重复造轮子;Vite 构建配置了 esbuild 压缩、4KB 以下资源内联为 base64、常用依赖预构建;用 rollup-plugin-visualizer 分析打包体积。
|
||
|
||
### Q7:路由守卫里的 reqCatch 是干什么的?
|
||
|
||
> reqCatch 是一个错误包装函数,包装 API 调用以捕获错误而不触发未捕获异常。在路由守卫里特别重要——比如 getOrg 请求返回 403(私有组织无权限),如果没有 reqCatch 包装,这个错误会中断整个守卫流程,导致页面白屏。reqCatch 让守卫能优雅地处理 404/403,返回对应的错误页面。
|
||
|
||
---
|
||
|
||
## 五、关键数字(面试时用)
|
||
|
||
| 数据 | 数字 |
|
||
|------|------|
|
||
| 路由守卫优化策略 | 7 种(ID 缓存、并行请求、条件性请求、预校验、声明式 meta、白名单、localStorage 持久化) |
|
||
| 路由守卫代码量 | 248 行 |
|
||
| 路由懒加载页面数 | 80+ 个 |
|
||
| 防抖/节流使用处 | 40+ 处 |
|
||
| shallowRef 使用处 | 30+ 处 |
|
||
| @vueuse/core 使用处 | 35+ 处 |
|
||
| API 缓存降级系统 | 360 行自研代码 |
|
||
| lodash 按需引入 | 全项目统一 |
|
||
| IntersectionObserver | 6+ 处 |
|
||
|
||
---
|
||
|
||
## 六、涉及的源码文件(需要看的时候查)
|
||
|
||
| 做什么 | 文件在哪 |
|
||
|--------|----------|
|
||
| 路由守卫优化(核心) | `src/router/interceptor.ts`(248 行,7 种优化策略) |
|
||
| 路由守卫中的错误包装 | `src/utils/catch.ts`(reqCatch 函数) |
|
||
| 仓库路径正则预校验 | `src/utils/regex.ts`(repoPathSuffix) |
|
||
| 路由懒加载配置 | `src/router/config/home.ts`(40+ 路由)等 |
|
||
| API 缓存降级系统 | `src/utils/degradeInterceptor.ts`(核心,360 行) |
|
||
| 缓存存储层 | `src/utils/apiStorage.ts`(IndexedDB 封装) |
|
||
| 缓存策略配置 | `src/api/storage-apis.ts`(哪些 API 走缓存) |
|
||
| 请求去重/Token 刷新 | `src/utils/request.ts`(118-238 行) |
|
||
| 自定义曝光指令 | `src/directives/element-exposure.ts` |
|
||
| 响应式布局 Hook | `src/utils/hooks/usePageResize.ts` |
|
||
| 地图懒加载 | `src/utils/mapLoader.js` |
|
||
| Vite 构建配置 | `vite.config.ts` |
|
||
| 微前端路由同步 | `src/utils/microAppConfig.ts` |
|