个人简历面经总结二

1.什么是闭包,闭包的使用场景有哪些

闭包是指有权访问另一个函数作用域中变量的函数 。简单理解,当一个内层函数引用了其外层函数作用域中的变量或参数,且内层函数在其外层函数执行结束后依然能被访问和调用时,就形成了闭包。比如在 JavaScript 中,创建闭包常见方式是在一个函数内部创建另一个函数,且内部函数使用了外部函数的变量 。

人话:

想象你有一个盒子工厂(外部函数),工厂里有一些工具和材料(外部函数的变量) 。工厂里有个工人(内部函数),他熟悉这些工具和材料的使用方法。

当工厂完成一批盒子制造任务后,正常情况下,工厂里的工具和材料会被收拾起来。但如果把这个工人派出去(外部函数返回内部函数),他因为熟悉在工厂里的工具和材料,即使离开了工厂(外部函数执行完),他依然能使用这些工具和材料(访问外部函数的变量)去完成一些工作。这个被派出去的工人和他能使用的工厂资源的组合,就类似闭包。

内部函数引用了外部函数的变量,且外部函数返回内部函数,这时形成的函数组合就是闭包

闭包的使用场景

封装:在 JavaScript 等语言中,利用闭包可创建私有变量和函数,实现信息隐藏和封装。例如,通过闭包将某些变量和函数封装在内部,只对外暴露特定的接口来访问和操作内部数据,避免全局变量污染,提高代码的安全性和可维护性。

函数式编程:用于创建高阶函数(函数可以作为参数传递或作为返回值返回 )、延迟执行函数、柯里化等功能。比如柯里化通过闭包将多个参数的函数转化为一系列单参数函数,增强函数的灵活性和复用性。

定时器和事件处理:在处理定时器和事件时,闭包能保存局部状态。例如在定时器回调函数中,利用闭包保存每次迭代的变量值,确保在定时器触发时能获取到正确的值;在事件处理函数中,可保存相关的状态信息,方便后续逻辑处理。

模块模式:可将相关的函数和数据封装在一起形成模块。在模块内部通过闭包管理私有状态和行为,只向外暴露必要的接口,实现模块化编程,提高代码的组织性和可维护性。

回调函数(异步编程):在异步编程中,闭包常与回调函数配合,捕获周围作用域的状态,并在回调触发时使用这些状态。比如在 Ajax 请求的回调中,通过闭包保存请求相关的上下文信息,确保回调能正确处理响应 。

循环中的异步操作:在 JavaScript 循环中进行异步操作时,变量提升可能导致问题,使用闭包能确保在异步操作完成时获取到正确的循环变量值,避免因变量共享造成的逻辑错误。

函数节流与防抖:函数节流控制函数在一定时间内只执行一次,防抖是在一定时间内多次触发只执行最后一次。通过闭包可以保存计时状态等信息,实现节流和防抖功能,常用于优化窗口 resize、scroll 等频繁触发事件的处理,提高性能 。

缓存计算结果:利用闭包缓存函数的计算结果,避免重复计算。例如在计算复杂或耗时的函数结果时,将结果缓存起来,下次相同输入时直接返回缓存值,提升程序性能 。

2.原型链

  1. 什么是原型、原型链

    • 每个对象都有 __proto__,指向它的原型对象
    • 原型对象也有自己的原型,直到 null,这条链就是原型链
  2. prototype__proto__ 的区别

    • prototype函数独有,是构造函数的原型
    • __proto__对象独有,指向该对象的原型
  3. constructor 是什么

    • 原型对象里的 constructor 指向构造函数本身
  4. new 一个对象发生了什么

    • 创建空对象
    • 绑定原型(obj.__proto__ = Fn.prototype
    • 执行构造函数(this 指向新对象)
    • 返回对象
  5. instanceOf 原理

    • 沿着对象的 __proto__ 找,看是否等于构造函数的 prototype
  6. 如何判断对象自身属性 / 原型属性

    • hasOwnProperty():只查自身
    • in:自身 + 原型链上都算
  7. 继承的实现(必考)

    • 原型链继承
    • 构造函数继承
    • 组合继承
    • 寄生组合继承(最优)
    • ES6 class extends 本质还是原型链
  8. Object 和 Function 的关系

    • Object.__proto__ === Function.prototype
    • Function.__proto__ === Function.prototype
    • 所有对象最终都继承自 Object.prototype

3.VueRouter 面试高频问题

Q1:VueRouter 有哪几种路由模式?区别是什么?

  • hash 模式(默认):

    • 原理:基于 URL 的 # 锚点,# 后的内容不会发送到服务器,通过 hashchange 监听路由变化;

    • 特点:兼容性好(支持低版本浏览器),URL 带 # 不够美观,无需后端配置。

  • history 模式

    • 原理:基于 HTML5 History API(pushState/replaceState),URL 无 #,通过 popstate 监听;

    • 特点:URL 更规范,需要后端配置(Nginx/Apache),否则刷新页面会 404(需配置所有路由指向 index.html)。

  • abstract 模式:非浏览器环境(如 Node/Weex)的兜底模式,基于内存模拟路由。

Q2:路由传参有几种方式?优缺点?

方式 用法 优点 缺点
动态路由参数 /user/:id$route.params.id 语义化,URL 清晰 刷新页面参数不丢失,参数必须在路由配置中定义
query 参数 /user?id=1$route.query.id 灵活,无需配置路由 URL 带参数,刷新不丢失,参数暴露在地址栏
params 隐式传参 router.push({ name: 'user', params: { id: 1 } }) 参数不暴露 刷新页面参数丢失,必须配合 name 跳转
路由元信息(meta) 配置 meta: { requireAuth: true } 用于全局路由守卫(如鉴权) 仅存储静态 / 全局信息,不适合动态传参

Q3:路由守卫有哪些?执行顺序是什么?

  • 分类
    1. 全局守卫:router.beforeEach(前置)、router.afterEach(后置)、router.beforeResolve(解析完成前);
    2. 路由独享守卫:beforeEnter(在路由配置中定义);
    3. 组件内守卫:beforeRouteEnterbeforeRouteUpdatebeforeRouteLeave
  • 执行顺序 (跳转新路由时):beforeEachbeforeEnterbeforeRouteEnterbeforeResolveafterEach → 组件创建(beforeRouteEnter 的回调获取组件实例)。
  • 核心用途:登录鉴权、页面权限控制、页面离开前确认(如表单未保存)。

Q4:如何实现路由懒加载?为什么要做?

  • 目的:减少首屏加载体积,提升 FCP/TTI 性能(首屏只加载当前路由组件,其他路由按需加载)。
  • 实现方式
javascript 复制代码
// Vue 2/3 通用
const User = () => import('./views/User.vue');
// 批量懒加载(webpack 魔法注释,打包拆分 chunk)
const User = () => import(/* webpackChunkName: "user-chunk" */ './views/User.vue');

const routes = [{ path: '/user', component: User }];

Q5:VueRouter 如何实现嵌套路由?

  • 路由配置中通过 children 定义子路由,父组件中必须添加 <router-view /> 作为子路由的挂载点:
javascript 复制代码
const routes = [
  {
    path: '/home',
    component: Home,
    children: [
      { path: 'page1', component: Page1 }, // 匹配 /home/page1
      { path: '', redirect: 'page1' } // 默认子路由
    ]
  }
];

4.Vuex 面试高频问题

Q1:Vuex 的核心模块有哪些?各自的作用?

  • State :存储全局状态(单一状态树),通过 this.$store.statemapState 访问;
  • Getter :基于 State 派生的计算属性(类似组件的 computed),支持传参、缓存,通过 mapGetters 访问;
  • Mutation :唯一修改 State 的方式(同步操作),通过 commit 触发,必须是纯函数;
  • Action :处理异步逻辑(如请求接口),通过 dispatch 触发,内部可 commit Mutation;
  • Module :拆分复杂状态(模块化),解决单一状态树体积过大问题,支持命名空间(namespaced: true)。

Q2:Vuex 中 Mutation 和 Action 的区别?

维度 Mutation Action
操作类型 同步操作 异步操作(支持 Promise/async-await)
触发方式 store.commit('mutationName') store.dispatch('actionName')
修改 State 直接修改 间接通过 commit Mutation 修改
传参 第二个参数为 payload 第二个参数为 payload,支持返回值

Q3:Vuex 模块化(Module)如何使用?命名空间的作用?

  • 模块化配置
javascript 复制代码
const userModule = {
  namespaced: true, // 开启命名空间(核心)
  state: () => ({ name: '小明' }),
  mutations: { updateName(state, payload) { state.name = payload; } },
  actions: { asyncUpdateName({ commit }, payload) { setTimeout(() => commit('updateName', payload), 1000); } }
};

const store = new Vuex.Store({
  modules: { user: userModule }
});
  • 命名空间作用

  • 避免不同模块的 mutation/action/getter 名称冲突;

  • 精准定位模块内的方法,如 store.dispatch('user/asyncUpdateName', '小红')

  • 组件中通过 mapState('user', ['name']) 映射模块内状态。

Q4:Vuex 数据持久化如何实现?

  • 核心思路:将 Vuex 的 State 同步到 localStorage/sessionStorage,页面刷新时从本地存储恢复。
  • 实现方式
    1. 手动实现:监听 beforeunload 保存 State,页面初始化时读取;
    2. 插件实现(推荐):使用 vuex-persistedstate
javascript 复制代码
import createPersistedState from 'vuex-persistedstate';
const store = new Vuex.Store({
  modules: { user: userModule },
  plugins: [createPersistedState({
    key: 'vuex-store', // 本地存储的 key
    paths: ['user'] // 只持久化 user 模块,默认全部
  })]
});

Q5:Vue3 中 Vuex 被 Pinia 替代的原因?Pinia 对比 Vuex 的优势?

  • Pinia 优势
    1. 更简洁:无 mutations,直接在 action 中修改状态(支持同步 / 异步);
    2. 天生模块化:无需手动配置 namespaced,每个 store 就是独立模块;
    3. 更好的 TypeScript 支持:类型推导更完善,无需手动定义类型;
    4. 轻量化:体积更小,API 更简洁,无需嵌套模块;
    5. 兼容 Vue2/Vue3,支持 DevTools 调试。

Q6:Pinia常问

  • Pinia 响应式底层依靠什么? Vue3 的reactive/ref;store 实例本质是一个 reactive 代理对象,所以直接修改属性能触发响应式更新。

  • Pinia 实现模块化的原理,怎么做到无命名空间冲突? 每个 store 通过唯一 id 独立创建,各自拥有独立 state、作用域; 不存在 Vuex 的 rootState、modules 嵌套,导入哪个 store 就只用哪个,天然隔离,不需要 namespaced:true。

  • Pinia 的 DevTools 调试原理? 内部订阅 state 和 actions 变更,把操作快照发送给 Vue DevTools; 支持时间旅行、回滚 state、追踪 action 调用,区分每个独立 store。

  • Pinia 数据持久化怎么做?两种方案 方案 1:原生 $subscribe 监听手动存 localStorage; 方案 2:插件 pinia-plugin-persistedstate(企业主流),一行配置自动持久化,支持 sessionStorage/cookie/ 加密。

javascript 复制代码
// main.js
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

// store中开启
defineStore('user',{
  state:()=>({}),
  persist:true // 开启持久化
})
  • 组件中批量解构 store 丢失响应式怎么解决? 直接解构会脱离 reactive 代理,失去响应:
javascript 复制代码
const store = useUserStore()
const { name } = store // 非响应式

解决三种方案:

  1. 不解构,直接 store.name
  2. 计算属性包裹。
  3. storeToRefs 工具函数(推荐):
javascript 复制代码
import {storeToRefs} from 'pinia'
const { name } = storeToRefs(store)

5.RTK(Redux Toolkit)面试高频问题

Q1:Redux Toolkit 是什么?解决了原生 Redux 的哪些痛点?

  • RTK 定义 :Redux 官方推荐的工具集,封装了 reduxredux-thunkimmer 等核心库,简化 Redux 开发。
  • 解决原生 Redux 痛点
    1. 原生 Redux 样板代码过多(actionType/actionCreator/reducer 分开写);
    2. 需手动配置中间件(如 thunk 处理异步);
    3. 不可直接修改 state(需手动浅拷贝,RTK 内置 immer 支持 "可变写法修改不可变数据");
    4. 需手动合并 reducer,RTK 提供 combineSlices 简化。

Q2:RTK 的核心 API 有哪些?各自作用?

  • createSlice:核心 API,封装 actionType、actionCreator、reducer,支持 immer :
javascript 复制代码
import { createSlice } from '@reduxjs/toolkit';
const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; }, // 直接修改(immer 处理不可变)
    decrement: (state) => { state.value -= 1; }
  }
});
export const { increment, decrement } = counterSlice.actions;
export default counterSlice.reducer;
  • configureStore :替代 createStore,自动配置中间件(thunk)、devTools、合并 reducer:
javascript 复制代码
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
  reducer: { counter: counterReducer }
});
  • createAsyncThunk:处理异步 action(如接口请求):
javascript 复制代码
const fetchUser = createAsyncThunk('user/fetchUser', async (userId) => {
  const res = await axios.get(`/api/user/${userId}`);
  return res.data;
});
// 在 slice 中处理异步状态(pending/fulfilled/rejected)
extraReducers: (builder) => {
  builder
    .addCase(fetchUser.pending, (state) => { state.loading = true; })
    .addCase(fetchUser.fulfilled, (state, action) => { state.loading = false; state.data = action.payload; });
}

Q3:RTK 中如何处理异步逻辑?

  • 核心用 createAsyncThunk 定义异步 action,返回 Promise;
  • createSliceextraReducers 中监听异步 action 的三个状态:
    • pending:请求中(可设置 loading 为 true);
    • fulfilled:请求成功(处理返回数据);
    • rejected:请求失败(处理错误);
  • 组件中通过 dispatch(fetchUser(1)) 触发异步 action。

Q4:RTK Query(RTKQ)是什么?解决了什么问题?

  • RTKQ 定义:RTK 内置的数据请求层,替代 axios + 手动管理请求状态(loading/error/data);
  • 核心优势
    1. 自动缓存请求数据,避免重复请求;
    2. 自动管理请求状态(loading/error/success);
    3. 支持数据失效、轮询、乐观更新;
    4. 减少样板代码(无需手动写 action/reducer 处理请求)。

Q5:RTK 中 immer 的作用?为什么可以 "直接修改 state"?

  • immer 作用:基于 "代理" 实现不可变数据的 "可变写法",简化状态更新逻辑;
  • 原理 :RTK 的 reducer 中,immer 会将 state 包装为 Proxy 对象,看似 "直接修改 state",实际 immer 会在底层生成新的不可变对象,既保留了 Redux 不可变的核心原则,又简化了代码(无需手动 ...state 拷贝)。

6.浏览器工作原理

一、浏览器渲染过程

Q1:浏览器从输入 URL 到页面展示的完整流程?

  1. URL 解析与网络请求
    • 解析 URL 协议 / 域名 / 端口,若为域名则通过 DNS 解析成 IP 地址;
    • 建立 TCP 连接(三次握手),发送 HTTP 请求;
    • 服务器响应请求,返回 HTML/CSS/JS 等资源,TCP 四次挥手断开连接。
  2. 解析与构建阶段
    • HTML 解析:生成 DOM 树(Document Object Model);
    • CSS 解析:生成 CSSOM 树(CSS Object Model);
    • 合并 DOM + CSSOM 生成渲染树(Render Tree) (只包含可见节点,排除 display: none 元素)。
  3. 渲染与绘制阶段
    • 布局(Layout/Reflow):计算渲染树节点的位置、大小,生成布局树;
    • 绘制(Paint):将布局树节点绘制到屏幕(像素填充,如颜色、边框、背景);
    • 合成(Composite):将绘制后的图层合成为最终页面(GPU 加速,避免重绘 / 重排)。

Q2:重排(Reflow)和重绘(Repaint)的区别?如何减少重排重绘?

  • 区别
维度 重排(Reflow/Layout) 重绘(Repaint)
触发原因 元素几何属性变化(宽高 / 位置 / 尺寸)、DOM 增删、窗口大小变化 元素样式变化但不影响几何属性(颜色 / 背景 / 阴影)
性能开销 大(需重新计算布局) 小(仅重新绘制像素)
关联关系 重排必然触发重绘 重绘不一定触发重排
  • 优化手段
  • 批量修改 DOM(如用文档碎片 DocumentFragment、离线 DOM);
  • 避免频繁读取布局属性(如 offsetTop/clientWidth,浏览器会缓存,频繁读会强制重排);
  • 将元素设为图层(will-change: transform/transform: translateZ(0)),图层重排不影响其他元素;
  • 避免使用 table 布局(table 重排成本远高于普通元素)。

Q3:为什么 JS 会阻塞 DOM 解析?如何解决?

  • 原因 :JS 可修改 DOM/CSSOM(如 document.write 改写 HTML),浏览器为避免解析冲突,会暂停 DOM 解析,先执行 JS 代码,执行完成后再继续解析。
  • 解决方式
    1. JS 标签加 defer:异步加载 JS,DOM 解析完成后执行(按加载顺序执行);
    2. JS 标签加 async:异步加载 JS,加载完成后立即执行(不保证顺序);
    3. 将 JS 放在 </body> 底部,等待 DOM 解析完成后执行;
    4. 使用动态导入 import() 懒加载非首屏 JS。

Q4:CSS 会阻塞 DOM 解析吗?会阻塞页面渲染吗?

  • CSS 不阻塞 DOM 解析(DOM 解析和 CSSOM 构建并行);
  • CSS 阻塞页面渲染(渲染树依赖 DOM + CSSOM,CSSOM 未构建完成时,渲染树无法生成,页面会空白);
  • 额外:CSS 会阻塞 JS 执行(JS 可能读取 / 修改 CSSOM,因此浏览器会等待 CSSOM 构建完成后再执行 JS)。

二、浏览器缓存原理

Q1:浏览器缓存分为哪几类?各自的特点和优先级?

:按优先级从高到低:

  1. Memory Cache(内存缓存)
    • 特点:缓存在内存中,读取最快,页面关闭后失效;
    • 缓存内容:JS/CSS/ 图片等资源(已解析的)。
  2. Disk Cache(磁盘缓存 / HTTP 缓存)
    • 核心分类:
      • 强缓存:通过 Expires(HTTP1.0)/Cache-Control(HTTP1.1,优先级更高)控制,如 Cache-Control: max-age=3600,缓存期内直接用本地资源,不发请求;
      • 协商缓存:强缓存失效后触发,通过 Last-Modified``/If-Modified-SinceETag``/If-None-Match 与服务器协商,资源未修改则返回 304,使用本地缓存;
        • Last-Modified:响应头,服务器返回,最后一次修改的时间戳
        • If-Modified-Since:请求头,浏览器发送,浏览器第二次请求同一资源时,从本地缓存中取出之前保存的 Last-Modified 时间
        • ETag:响应头,服务器返回,服务端对资源的唯一标识
        • If-None-Match 请求头,浏览器发送,浏览器第二次请求同一资源时,从本地取出之前保存的 ETag
    • 特点:缓存在硬盘中,页面关闭后仍存在,容量更大。
  3. Service Worker Cache
    • 特点:独立于主线程的缓存,可自定义缓存策略,支持离线缓存(PWA 核心)。

Q2:强缓存和协商缓存的区别?如何设置?

维度 强缓存 协商缓存
请求发送 不发送请求,直接用本地缓存 发送请求,服务器判断是否用缓存
状态码 无(200 from disk/memory cache) 304(未修改)/200(已修改)
控制字段 Cache-Control/Expires Last-Modified/ETag
优先级 更高(优先触发) 更低(强缓存失效后触发)

设置示例(服务器响应头):

javascript 复制代码
# 强缓存(1小时)
Cache-Control: max-age=3600
# 协商缓存
ETag: "abc123"
Last-Modified: Wed, 06 Mar 2026 10:00:00 GMT

刷新对缓存的影响

Q3:为什么需要 ETag?比 Last-Modified 有什么优势?

  • Last-Modified 缺陷:
    1. 精度只能到秒,文件若 1 秒内多次修改,无法识别;
    2. 服务器时间与客户端时间不一致时,判断失效;
    3. 文件内容未变,但修改时间变了(如重新保存),会误判为资源更新。
  • ETag 优势:
    • 基于文件内容生成唯一标识(如哈希值),内容不变则 ETag 不变,判断更精准;
    • 不受时间影响,兼容性更好。

Q4:如何设置缓存策略更合理?

  1. 首屏核心资源(如 HTML):禁用强缓存(Cache-Control: no-cache),开启协商缓存(避免用户看到旧页面);
  2. 静态资源(JS/CSS/ 图片):设置长时强缓存(max-age=31536000)+ 文件名加哈希(如 app.abc123.js),更新时改哈希即可触发重新请求;
  3. 接口数据:根据时效性设置(如非实时数据用协商缓存,实时数据禁用缓存)。

三、事件循环(Event Loop)

Q1:浏览器事件循环的执行机制?宏任务(Macrotask)和微任务(Microtask)的区别?

  • 核心机制
  • ① 执行「执行栈」中的所有同步代码(从上到下、阻塞执行),直到执行栈为空;
  • ② 执行「微任务队列」中的所有微任务(逐个执行,直到微任务队列清空);
  • ③ 执行UI 渲染(更新 DOM、绘制页面);
  • ④ 从「宏任务队列」中取出一个宏任务执行(仅一个),执行过程中产生的同步代码 / 微任务 / 新宏任务,会按规则进入对应队列;
  • ⑤ 重复步骤①-④,直至所有任务队列清空。
  • 宏任务 vs 微任务
维度 宏任务(Macrotask) 微任务(Microtask)
核心定义 异步任务的 "大单元",执行间隔较长,可能触发渲染 异步任务的 "小单元",执行间隔极短,优先于渲染
包含类型 script 整体代码(执行单元)、setTimeout/setInterval、AJAX(XHR/fetch)、DOM 事件(click/load)、setImmediate(非标准) Promise.then/catch/finally、async/await(本质是 Promise)、MutationObserver、queueMicrotask ()
执行时机 微任务队列清空 + UI 渲染后,每次仅执行一个 同步代码执行完后,立即执行所有微任务(队列清空)
优先级 低(微任务之后执行) 高(宏任务内同步代码执行完后优先执行)
与渲染的关系 执行完一个宏任务后,先清微任务、再渲染 微任务执行完后触发渲染,微任务内的 DOM 修改会同步渲染

Q2:手写代码题:说出执行顺序

javascript 复制代码
console.log('1');
setTimeout(() => {
  console.log('2');
  Promise.resolve().then(() => console.log('3'));
}, 0);
Promise.resolve().then(() => console.log('4'));
async function fn() {
  console.log('5');
  await Promise.resolve();
  console.log('6');
}
fn();
console.log('7');

答案 :1 → 5 → 7 → 4 → 6 → 2 → 3解析

  1. 同步代码:console.log('1')fn() 执行(打印 5)→ console.log('7')
  2. 微任务队列:Promise.then(4)await 后的 console.log('6')(await 后是微任务);
  3. 执行所有微任务:打印 4 → 6;
  4. 宏任务队列:setTimeout 回调(打印 2),执行后触发微任务 console.log('3')
  5. 执行微任务:打印 3。

Q3:async/await 的执行机制?和 Promise 的关系?

  • async/await 是 Promise 的语法糖,底层基于微任务:
    1. async 函数执行时,先同步执行到 await 处;
    2. await 后的表达式执行(如 Promise.resolve()),返回 Promise;
    3. await 后的代码(如 console.log('6'))被包装成微任务,加入微任务队列;
    4. 同步代码执行完后,再执行该微任务。

Q4:Node 事件循环和浏览器事件循环的区别?

  • 核心区别:
    1. 任务队列分类不同:Node 事件循环分 6 个阶段(timers、pending callbacks、idle/prepare、poll、check、close callbacks),浏览器只有宏 / 微任务;
    2. 微任务执行时机不同:Node 11 前,每个阶段执行完后执行微任务;Node 11+ 与浏览器一致(宏任务后执行所有微任务);
    3. 宏任务优先级不同:Node 中 process.nextTick(微任务)优先级高于 Promise.then,浏览器中微任务优先级一致。

总结

  1. 渲染过程:URL → 网络 → DOM/CSSOM → 渲染树 → 布局 → 绘制 → 合成;重排 / 重绘的区别与优化是核心;
  2. 缓存原理 :强缓存(Cache-Control)> 协商缓存(ETag),静态资源用 "长时强缓存 + 哈希",HTML 用协商缓存;
  3. 事件循环:同步 → 宏任务(一个)→ 微任务(所有)→ 渲染,async/await 是 Promise 语法糖,微任务优先级高于宏任务。

7.性能优化

一、首屏加载优化

1. 怎么优化首屏加载速度?

  • 路由 / 组件 懒加载
  • 静态资源 压缩、合并、分包
  • 图片优化:WebP、压缩、懒加载、雪碧图
  • HTTP 缓存:强缓存 + 协商缓存
  • CDN 加速静态资源
  • 减少第三方包体积,tree-shaking
  • 服务端优化:SSR/SSG、接口合并、减少请求

2. 什么是懒加载?怎么实现?

  • 图片懒加载:loading="lazy" / IntersectionObserver
  • 路由懒加载:() => import('./xxx')
  • 作用:减少首屏资源体积,提升 FCP、LCP

二、网络 / HTTP 优化

3. 浏览器缓存怎么优化?

  • 强缓存Cache-Control: max-age
  • 协商缓存ETag / If-None-Match
  • 静态资源:长缓存 + 文件名哈希
  • HTML:一般用协商缓存

4. 如何减少 HTTP 请求?

  • 合并接口、合并小图片
  • 雪碧图、内联小图片(base64)

三、渲染优化

5. 重排(reflow)重绘(repaint)是什么?怎么优化?

  • 重排:改位置 / 大小,开销大
  • 重绘:改颜色 / 样式,开销小
  • 优化:
    • 批量修改 DOM
    • 离线 DOM(DocumentFragment)
    • 使用 transform / opacity 开启 GPU 加速
    • 避免频繁读取布局属性

6. 如何避免卡顿、掉帧?

  • 减少长任务(>50ms 会卡)
  • 复杂计算放到 Web Worker
  • 防抖节流(debounce / throttle
  • 合理使用 requestAnimationFrame

四、React / Vue 框架优化

7. React 怎么优化渲染?

  • React.memo 缓存组件
  • useMemo 缓存计算结果
  • useCallback 缓存函数
  • 列表加唯一 key
  • 避免在 render 里创建新对象 / 函数

8. Vue 怎么优化?

  • v-once 只渲染一次
  • v-show 替代频繁切换的 v-if
  • 路由懒加载
  • computed 缓存计算
  • 大数据列表用虚拟滚动(只渲染当前视口内的列表项,而非全部数据)

五、图片 / 资源优化

9. 图片怎么优化?

  • 使用 WebP/AVIF
  • 图片压缩
  • 图片懒加载
  • 根据屏幕大小加载对应尺寸
  • 小图用 base64 或雪碧图

六、性能指标

10. 核心性能指标有哪些?

  • FCP 首次内容绘制
  • LCP 最大内容绘制(核心)
  • CLS 布局偏移
  • INP 交互响应速度
  • FPS 流畅度

七、你可以直接背的 "万能回答"

面试官问:你做过哪些性能优化?

直接答:

我主要从首屏加载、网络缓存、渲染优化、框架优化、图片优化五个方面做。

  1. 首屏用懒加载、代码分割、压缩打包,把 FCP 从 2.1s 优化到 0.9s;
  2. 网络用强缓存 + 协商缓存 + CDN,减少请求与耗时;
  3. 渲染避免频繁重排重绘,用 useMemo/useCallback 减少不必要渲染;
  4. 图片用 WebP、懒加载、压缩;
  5. 最终页面 FPS 稳定 55+,交互更流畅。

8.Git 协作流程

一、基础协作规范

Q1:Git 常用的协作分支模型有哪些?各自的适用场景?

  1. Git Flow(经典分支模型)

    • 核心分支:
      • master/main:生产环境分支(稳定);
      • develop:开发主分支(日常开发);
      • feature/*:功能分支(从 develop 拉取,完成后合并回 develop);
      • release/*:发布分支(从 develop 拉取,测试完成后合并到 master + develop);
      • hotfix/*:紧急修复分支(从 master 拉取,修复后合并到 master + develop)。
    • 适用场景:大型团队、版本迭代周期长、对稳定性要求高的项目(如企业级应用)。
  2. GitHub Flow(简化分支模型)

    • 核心规则:只有 main 分支,所有功能开发从 main 拉取 feature/* 分支,完成后通过 PR/MR 合并回 main,合并前必须通过代码评审和测试。
    • 适用场景:小团队、敏捷开发、持续部署的项目(如前端小应用、ToC 产品)。
  3. GitLab Flow(折中方案)

    • 核心:以 main 为核心,按环境划分分支(如 pre/test/prod),通过 MR 逐级合并,兼顾规范与灵活性。
    • 适用场景:中型团队、多环境部署的项目。

Q2:日常开发中,你遵循的 Git 协作规范是什么?

  1. 分支命名规范
    • 功能分支:feature/xxx-需求名称(如 feature/user-login);
    • 修复分支:bugfix/xxx-问题描述(如 bugfix/order-pay-fail);
    • 紧急修复:hotfix/xxx-紧急问题(如 hotfix/home-crash)。
  2. 提交信息规范
    • 格式:type(scope): subject(如 feat(user): 新增用户登录功能);
    • type 分类:feat(新功能)、fix(修复)、docs(文档)、style(格式)、refactor(重构)、test(测试)、chore(杂项)。
  3. 协作流程
    • 拉取最新主分支代码 → 新建功能分支 → 开发并频繁小提交 → 提 PR/MR → 代码评审 → 解决冲突 → 合并分支 → 删除本地 / 远程功能分支。

二、分支操作与冲突解决

Q3:多人协作时,如何处理 Git 冲突?

  1. 预防冲突
    • 频繁拉取主分支代码(git pull origin develop),减少本地代码与远程的差异;
    • 按模块划分开发任务,避免多人同时修改同一文件。
  2. 解决冲突(本地分支)
    • 步骤 1:拉取远程最新代码:git pull origin develop
    • 步骤 2:若出现冲突,Git 会标记冲突文件,打开文件找到 <<<<<<< HEAD(本地代码)、=======(远程代码)、>>>>>>> develop(冲突分界符);
    • 步骤 3:手动合并代码(保留正确逻辑,删除冲突分界符);
    • 步骤 4:标记冲突已解决:git add 冲突文件
    • 步骤 5:提交解决后的代码:git commit -m "fix: 解决与develop分支的合并冲突"
  3. 解决冲突(PR/MR 阶段)
    • 方式 1:本地拉取目标分支,合并后推送到功能分支,PR 会自动更新;
    • 方式 2:在 GitLab/GitHub 界面直接编辑冲突文件,完成合并。

Q4:Git 中 mergerebase 的区别?你在什么场景下使用?

维度 merge(合并) rebase(变基)
提交历史 保留原有提交记录,生成新的合并提交(历史呈 "分叉状") 改写提交历史,将当前分支的提交 "移到" 目标分支最新提交之后(历史呈 "线性")
冲突处理 只需要解决一次合并冲突 可能需要多次解决冲突(每一个提交都可能冲突)
适用场景 公共分支(如 develop/main)合并 本地功能分支同步远程主分支代码(保持历史整洁)

使用原则

  1. 公共分支(如 develop)用 merge,避免改写公共历史;
  2. 本地功能分支用 rebase 同步远程主分支(如 git rebase origin develop),让提交历史更清晰;
  3. 禁止对已推送到远程的公共分支执行 rebase(会导致他人代码冲突)。

Q5:如何撤销已提交的代码?

  1. 撤销未推送到远程的提交
    • 撤销最后一次提交(保留代码修改):git reset --soft HEAD~1
    • 撤销最后一次提交(删除代码修改):git reset --hard HEAD~1
  2. 撤销已推送到远程的提交
    • 方式 1(推荐):新建提交回滚:git revert HEAD(生成反向提交,保留历史);
    • 方式 2(慎用):强制推送覆盖:git reset --hard HEAD~1 && git push -f origin 分支名(会改写远程历史,仅限个人分支)。
  3. 撤销已添加到暂存区的文件git reset HEAD 文件名

三、进阶协作问题

Q6:如何处理大型版本的发布与回滚?

  1. 版本发布
    • 从 develop 拉取 release 分支(如 release/v1.2.0);
    • 在 release 分支做测试、修复小 bug(不新增功能);
    • 测试通过后,将 release 分支合并到 main/develop,并打标签:git tag -a v1.2.0 -m "发布v1.2.0版本"
    • 推送标签到远程:git push origin v1.2.0
  2. 版本回滚
    • 场景 1:发布后发现严重 bug,需回滚到上一版本;
    • 步骤 1:找到上一版本的标签 / 提交哈希:git log/git tag
    • 步骤 2:创建回滚分支:git checkout -b hotfix/rollback-v1.1.0 v1.1.0
    • 步骤 3:将回滚分支合并到 main 分支,重新发布。

Q7:Git 子模块(submodule)的使用场景?如何操作?

  • 使用场景:项目中依赖独立的 Git 仓库(如公共组件库、工具库),需要单独维护版本。
  • 核心操作
    1. 添加子模块:git submodule add 仓库地址 目录名
    2. 拉取项目时同步子模块:git clone --recurse-submodules 项目地址
    3. 更新子模块:git submodule update --remote

Q8:如何避免多人协作时,误推敏感信息(如密钥)到远程?

  1. 事前预防
    • .gitignore 文件中添加敏感文件(如 .envconfig.js、私钥文件);
    • 使用环境变量或配置中心管理密钥,不硬编码到代码中。
  2. 事后处理
    • 若已提交,使用 git filter-branch 或 BFG Repo-Cleaner 工具删除历史中的敏感信息;
    • 立即更换泄露的密钥(如 API 密钥、数据库密码);
    • 更新 .gitignore,避免再次提交。

9.Sass/Less 预编译工具

一、基础核心问题

Q1:Sass 和 Less 的区别?

维度 Sass(Scss) Less
语法风格 支持两种语法:缩进式(Sass)+ 花括号式(Scss,更接近 CSS) 仅花括号式,完全兼容原生 CSS
变量定义 $变量名: 值(如 $primary: #0088ff @变量名: 值(如 @primary: #0088ff
编译环境 基于 Ruby/Node.js(Dart Sass),需编译工具 可通过 JS 实时编译(浏览器端),也可预编译
嵌套规则 支持,且支持父选择器 & 进阶用法(如 &:hover 支持基础嵌套,& 用法与 Sass 一致
运算规则 单位运算更严格(如 10px + 5 会报错,需统一单位) 单位运算更宽松(如 10px + 5 自动转为 15px
继承 / 混合 @extend(继承选择器)+ @mixin/@include(混合器,支持参数) &:extend()(继承)+ .mixin() { } + @import(混合),参数支持较弱
条件 / 循环语句 支持 @if/@else@for/@each/@while 等逻辑语句 仅支持简单条件(when),无原生循环(需借助递归)
社区生态 更成熟,第三方库多(如 Compass、Bourbon) 轻量,生态较简单
适用场景 大型项目、复杂样式逻辑(如组件库) 中小型项目、快速开发

Q2:为什么使用 CSS 预编译工具(Sass/Less)?解决了原生 CSS 的哪些痛点?

  1. 原生 CSS 痛点
    • 无变量,重复值需多次修改(如主题色);
    • 无嵌套,多层选择器代码冗余(如 .box .title .text);
    • 无复用机制,相同样式需复制粘贴;
    • 无逻辑能力,无法根据条件生成样式。
  2. 预编译工具优势
    • 变量化:统一管理主题色、尺寸等,一键修改;
    • 嵌套化:简化层级选择器,代码更清晰;
    • 复用化:通过混合(Mixin)/ 继承(Extend)复用样式,减少冗余;
    • 逻辑化:支持条件、循环,动态生成样式(如生成多尺寸按钮);
    • 模块化:通过 @import 拆分样式文件,便于维护。

二、核心特性与实战应用

Q3:Sass 中 @mixin 和 @extend 的区别?各自的适用场景?

维度 @mixin(混合器) @extend(继承)
本质 定义可复用的样式片段,支持参数 继承已有选择器的样式,无参数
编译结果 样式会被复制到使用处(可能冗余) 生成组合选择器(如 .btn, .btn-primary {}),代码更精简
灵活性 高(支持参数、默认值、逻辑) 低(仅纯样式继承)
适用场景 带参数的可变样式(如不同尺寸 / 颜色的按钮、圆角) 纯静态样式复用(如基础样式、通用布局)

示例

css 复制代码
// @mixin(带参数,适合可变样式)
@mixin btn($size: 14px, $color: #fff) {
  font-size: $size;
  color: $color;
  padding: 8px 16px;
}
.btn-primary {
  @include btn(16px, #0088ff); // 编译后样式直接复制到此处
}

// @extend(纯样式继承,适合静态样式)
.base-btn {
  border: none;
  border-radius: 4px;
}
.btn-secondary {
  @extend .base-btn; // 编译后生成 .base-btn, .btn-secondary {}
  background: #f5f5f5;
}

Q4:如何在项目中配置 Sass/Less?

1. Vue 项目配置 Sass(最常用)
  • 步骤 1:安装依赖:
bash 复制代码
npm install sass sass-loader --save-dev # Vue3 用 sass,Vue2 用 node-sass
  • 步骤 2:在 vue.config.js 配置全局变量(可选):
bash 复制代码
module.exports = {
  css: {
    loaderOptions: {
      sass: {
        additionalData: `@import "@/styles/variables.scss";` // 全局引入变量文件
      }
    }
  }
};
  • 步骤 3:组件中使用:
html 复制代码
<style lang="scss" scoped>
.box {
  color: $primary; // 直接使用全局变量
}
</style>
2. React 项目配置 Less
  • 步骤 1:安装依赖:
bash 复制代码
npm install less less-loader --save-dev
  • 步骤 2:修改 webpack.config.js(或 craco 配置):
bash 复制代码
module.exports = {
  module: {
    rules: [
      {
        test: /\.less$/,
        use: ['style-loader', 'css-loader', 'less-loader']
      }
    ]
  }
};

Q5:Sass/Less 中的模块化如何实现?如何避免样式污染?

  1. 文件拆分(模块化)

    • 按功能拆分样式文件:variables.scss(变量)、mixins.scss(混合器)、reset.scss(重置样式)、components/button.scss(组件样式);
    • 通过 @import 整合:main.scss 中引入所有子模块,统一编译。
  2. 避免样式污染

    • Vue 中使用 scoped 属性:<style scoped>,通过添加唯一属性选择器隔离样式;
    • React 中使用 CSS Modules:样式文件命名为 xxx.module.less,组件中导入并使用:
    javascript 复制代码
    import styles from './App.module.less';
    <div className={styles.box}>...</div>
    • 命名空间:为组件样式添加唯一前缀(如 .btn-component-xxx)。

三、进阶深挖问题

Q6:Sass 的函数(Function)如何使用?举例说明?

Sass 支持自定义函数,用于动态计算样式值(如颜色转换、尺寸计算),核心场景是主题适配、响应式尺寸计算。

css 复制代码
// 自定义函数:计算响应式字体大小
@function rem($px) {
  @return $px / 16 * 1rem; // 基于 16px 基准转换为 rem
}

// 自定义函数:加深颜色
@function darken-color($color, $percent) {
  @return darken($color, $percent); // 调用 Sass 内置颜色函数
}

// 使用
.box {
  font-size: rem(14); // 编译为 0.875rem
  background: darken-color($primary, 10%); // 主色加深 10%
}

Q7:PostCSS 和 Sass/Less 的区别?如何配合使用?

  • 核心区别
维度 Sass/Less PostCSS
定位 预编译工具(扩展 CSS 语法) 后处理工具(转换已编译的 CSS)
执行时机 编译阶段(CSS 生成前) 编译后(CSS 生成后)
核心功能 变量、嵌套、混合、逻辑 自动补全前缀(autoprefixer)、px 转 rem、样式 lint、压缩
  • 配合使用流程
  • Sass/Less 编译为原生 CSS;
  • PostCSS 处理编译后的 CSS(如 autoprefixer 补全 -webkit- 前缀、px-to-viewport 转换单位);
  • 最终输出优化后的 CSS。

Q8:现在 CSS 原生支持变量、嵌套了,还需要用 Sass/Less 吗?

:需要,核心原因:

  1. 兼容性 :CSS 原生变量(--var)在低版本浏览器(如 IE)不支持,预编译工具编译后为原生 CSS,兼容性更好;
  2. 功能丰富度 :CSS 原生仅支持基础变量、嵌套,无 @mixin、条件 / 循环、颜色函数等高级功能;
  3. 工程化:预编译工具支持模块化拆分、全局变量注入,更适配大型项目的样式管理;
  4. 生态整合:可与 PostCSS、CSS Modules 无缝配合,形成完整的样式工程化体系。

10.TypeScript

1. TypeScript 是什么?和 JavaScript 的核心区别是什么?

:TypeScript 是 JavaScript 的超集,扩展了 JS 的语法,增加了静态类型系统,最终会编译为纯 JS 运行。核心区别:

  • JS 是弱类型、动态类型语言(类型在运行时确定,易出现类型错误);
  • TS 是强类型、静态类型语言(类型在编译期确定,提前发现错误);
  • TS 支持接口、泛型、类型守卫等高级特性,JS 无原生支持。
2. 为什么使用 TypeScript?它能解决 JS 的哪些痛点?

:核心解决 JS 弱类型导致的三大痛点:

  1. 类型安全:编译期校验类型,避免线上因类型错误(如传参错、访问不存在的属性)导致的 bug;
  2. 可维护性:类型定义即 "活文档",新人接手 / 代码重构时,通过类型就能理解数据结构,减少注释成本;
  3. 开发效率:编辑器基于类型推导提供精准补全、语法提示,降低拼写 / 逻辑错误。尤其适合中大型项目、团队协作场景。
3. TS 的基本类型有哪些?和 JS 类型的区别?

  • 基础类型:string/number/boolean/null/undefined/symbol/bigint/Object(和 JS 一致);
  • TS 扩展类型:any(任意值)、unknown(未知类型)、void(无返回值)、never(永不存在的值)、tuple(元组)、enum(枚举);
  • 核心区别:TS 增加了类型约束 ,比如 tuple 限定数组长度和类型([string, number]),enum 定义命名常量(enum Status { Success = 1 })。
4. interfacetype 的区别?项目中如何选择?

维度 interface(接口) type(类型别名)
扩展性 支持多次声明自动合并(如扩展第三方库类型) 不支持合并,一旦定义无法修改
适用场景 定义对象 / 类的结构(如组件 Props、API 接口) 定义联合类型、交叉类型、基本类型别名
类实现 类可 implements 接口 类无法 implements 复杂 type(如联合类型)
  • 定义对象结构(如 Props、接口返回值)→ 用 interface(支持扩展);
  • 定义联合 / 交叉类型(如 type ID = string | number)→ 用 type
  • 简单场景两者均可,优先保持项目内统一。
5. 如何避免 any 类型的滥用?

any 会失去 TS 类型校验的意义,核心替代方案:

  1. 精准类型 :已知结构用 interface/type 定义,未知结构用 unknown(更安全,使用前必须类型校验);
  2. 类型缩小 :通过类型守卫(typeof/instanceof/in)缩小类型范围,避免 any
  3. 工程化约束tsconfig.json 开启 noImplicitAny: true,强制推导类型,无法推导时报错;
  4. 类型断言 :临时用 as 断言(如 data as User),但避免过度使用。
6. 泛型(Generic)的作用?举例说明使用场景?

:泛型是 "类型参数化",让函数 / 接口 / 类支持多种类型,同时保持类型安全,核心场景:

  1. 通用工具函数
TypeScript 复制代码
// 支持任意类型数组反转,输入输出类型一致
function reverse<T>(arr: T[]): T[] {
  return arr.reverse();
}
reverse(['a', 'b']); // 返回 string[],自动推导类型

2.组件 Props 复用

TypeScript 复制代码
// 分页列表通用类型,适配不同数据类型
interface Pagination<T> {
  list: T[];
  pageSize: number;
}
type UserPagination = Pagination<User>; // 用户列表分页

3.工具类型封装 :如 TS 内置的 Partial<T>/Required<T> 都是基于泛型实现。

7. 什么是类型守卫?常见实现方式?

:类型守卫是运行时判断类型,缩小类型范围的手段,常见方式:

  1. typeof:判断基本类型(typeof x === 'string');
  2. instanceof:判断类实例(x instanceof Array);
  3. in:判断对象属性('name' in x);
  4. 自定义类型守卫:
TypeScript 复制代码
function isUser(x: unknown): x is User {
  return typeof x === 'object' && x !== null && 'name' in x;
}
8. 如何处理异步请求的类型(如 API 接口)?

:核心是定义接口规范,结合泛型约束返回值:

TypeScript 复制代码
// 通用接口返回类型
interface ApiResponse<T> {
  code: number;
  data: T;
  msg: string;
}
// 具体接口类型
interface User { id: number; name: string; }

// 异步函数类型标注
async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const res = await fetch(`/api/user/${id}`);
  return res.json(); // TS 校验返回值是否符合类型
}
9. React/Vue 中如何用 TS 实现组件类型安全?

:以 React 为例(核心):

TypeScript 复制代码
// 定义 Props 接口,明确类型约束
interface ButtonProps {
  type: 'primary' | 'secondary'; // 限定枚举值
  onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
  children: React.ReactNode;
}
// 泛型组件标注 Props
const Button: React.FC<ButtonProps> = ({ type, onClick, children }) => {
  return <button onClick={onClick}>{children}</button>;
};

以 Vue3 为例:

TypeScript 复制代码
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
// Props 类型约束
interface Props { msg: string; count?: number; }
const props = defineProps<Props>();
// 事件类型约束
const emit = defineEmits<{ (e: 'change', value: number): void }>();
</script>
10. unknownany 的区别?为什么说 unknown 更安全?

  • any:完全关闭类型校验,可任意访问属性、调用方法,无任何提示;
  • unknown:类型安全的 any,不能直接访问属性 / 调用方法,必须先通过类型守卫缩小类型范围;示例
TypeScript 复制代码
let a: any = 'hello';
a.foo(); // 无报错,运行时可能崩溃

let u: unknown = 'hello';
u.foo(); // 编译报错,必须先校验类型
if (typeof u === 'string') {
  u.length; // 校验后可安全访问
}
11. tsconfig.json 中核心配置项有哪些?

:核心配置(影响类型校验和编译):

  • target:编译后的 JS 版本(如 ES6);
  • module:模块系统(如 ESNext);
  • strict:开启严格模式(包含 noImplicitAny/strictNullChecks 等);
TypeScript 复制代码
// src/types/jquery.d.ts
declare module 'jquery' {
  interface JQuery {
    myPlugin(): JQuery; // 扩展 jQuery 方法
  }
  export default $;
}
13. TS 中如何处理循环依赖?

:核心方案:

  1. 类型导入 :用 import type 导入仅用于类型的模块,避免运行时依赖;
TypeScript 复制代码
import type { User } from './user'; // 仅导入类型,不生成运行时代码
  1. 延迟导入 :运行时通过动态导入(import())解决;
  2. 提取公共类型 :将循环依赖的类型抽离到独立文件(如 types/common.d.ts)。
14. 如何实现 TS 类型的复用和模块化?

  1. 抽离公共类型 :将通用类型(如 API 接口、工具类型)放在 src/types 目录下,如 types/api.d.tstypes/utils.d.ts
  2. 全局类型声明 :在 tsconfig.json 中配置 typeRoots,自动识别 @types 和自定义声明文件;
  3. 工具类型封装:基于泛型封装通用工具类型(如分页、请求返回类型),跨模块复用。
15. TS 编译报错但运行正常,常见原因?

  • 原因 1:类型定义和实际值不匹配(如 API 返回值和定义的接口不一致);
  • 原因 2:开启 strictNullChecks,但代码中未处理 null/undefined
  • 原因 3:第三方库无类型声明,用 any 临时规避但类型校验不通过;
  • 解决:补充类型定义、开启非严格模式(临时)、用类型断言(as)临时兼容。

总结

  1. 核心考点interface/type 区别、泛型、类型守卫、unknown vs any、严格模式配置是高频必问;
  2. 答题技巧:结合项目场景回答(如 "在 XX 项目中,我用泛型封装了分页组件的类型,减少了重复代码");
  3. 避坑点 :避免滥用 any、忽略 strictNullChecks、不写类型声明文件。

11.异步并发竞态治理

基础概念题

Q1:什么是接口竞态?举一个业务场景说明。 答:短时间并发多个异步接口,网络响应快慢不可控,请求发出顺序和响应返回顺序不一致,旧接口数据晚到达,覆盖最新页面数据,造成渲染错乱。 业务场景:商户首页多模块并行请求,慢接口后返回覆盖已有页面内容,出现数据闪烁、空白。

Q2:并发接口竞态会造成哪些线上问题? 答:1. 页面数据错乱、闪烁、空白,展示过期数据;2. 重复无效请求,增加后端服务器压力;3. 数据状态冲突引发前端渲染报错;4. 用户看到不一致数据,业务逻辑出错。

场景实操题

Q3:商户首页多模块独立请求并发产生时序错乱,你是怎么解决的? 答:基于 Promise + 请求状态锁封装全局请求调度工具,核心能力:

  1. 重复请求拦截:相同接口未完成时,阻止二次发起,取消重复请求;
  2. 请求状态锁管理:给每个接口维护 pending 状态标记;
  3. 竞态消除机制:区分请求批次,丢弃过期请求的返回结果;
  4. 统一调度所有页面接口,从根源解决时序错乱; 落地效果:线上数据报错率接近 0,后端无效请求减少 35%。

Q4:如何区分并丢弃过期的接口响应,解决时序覆盖问题? 答:两种主流方案,项目使用请求标记锁方案:

  1. 请求唯一批次标记:每次发起请求生成唯一 token / 序号,存储在当前页面作用域;接口返回时对比标记,标记不匹配则直接丢弃该次响应,不赋值页面;
  2. Axios CancelToken:切换页面 / 重复请求时取消上一次请求,不再接收回调;
  3. 状态锁 Map:全局 Map 存储接口 key 和 pending 状态,未完成的旧请求直接忽略回调执行。

Q5:什么是请求锁?你封装的全局调度工具里状态锁原理是什么? 答:请求状态锁本质是全局 Map 缓存,key 为接口唯一标识(url + 参数),value 存储请求 Promise、状态、批次标记;

  • 发起请求前先查 Map,存在 pending 锁则拦截重复请求;
  • 请求完成 / 失败后清除锁;
  • 通过锁记录当前有效请求,旧请求响应到达时识别过期,不更新视图。

方案对比题

Q6:解决接口竞态有哪些常见方案,各自优缺点? 答:

  1. 串行请求(await 顺序执行):简单,但页面加载速度慢,首页体验差;
  2. Axios CancelToken 取消上一次请求:适合搜索、Tab 切换,无法处理多模块并行独立接口;
  3. 请求批次标记丢弃过期响应(项目方案):不阻塞并发加载,保留并行渲染速度,完美适配首页多模块场景;
  4. 防抖节流:仅适用于频繁触发类操作(搜索输入),不适用页面初始化批量请求。

Q7:为什么不使用串行 await 处理首页多模块接口? 答:串行会等第一个接口完成才请求下一个,拉长页面白屏加载时间,性能差;商户首页模块无依赖关系,应当并行请求提升加载速度,只需要治理竞态,不需要串行阻塞。

工具封装原理拔高题

Q8:简单说下你封装的全局请求调度工具核心实现思路? 答:

  1. 全局创建 pendingMap 作为状态锁容器;
  2. 封装基于 Axios 的统一 request 方法,入参携带接口唯一 key;(key 相同 = 同一查询,直接复用已有 pending Promise,不重复发接口)
  3. 发起前判断 Map 是否存在 pending 请求,存在则调用旧 CancelToken 取消请求,再复用 / 新建请求;(切换筛选、参数变更时,新旧 key 不一致,旧请求还在 pending,这里判断的是否为正在跑的请求,直接取消全部正在跑的旧请求,防止慢接口覆盖页面,解决竞态)
  4. 生成当前请求唯一批次标识,绑定 Promise 回调;
  5. 创建 Axios CancelToken 存入 Map,发起网络请求;
  6. 请求成功 / 失败后,清除 Map 中对应锁;
  7. 回调内部校验批次标识,过期请求直接 return,不赋值 state;
  8. 统一挂载 Axios 请求拦截、响应拦截、错误拦截,全局生效。

Q9:重复请求拦截逻辑怎么实现? 答:以 url + 请求参数序列化字符串作为唯一 key 存入 Map;调用接口时先校验 key 是否存在且处于 pending;若存在直接复用已有 Promise,不发起新 HTTP;请求结束删除 Map 内 key,释放锁。

Q10:多个页面同时调用同一个接口,请求锁会不会互相干扰?怎么处理? 答:会存在跨页面锁冲突;优化方案:给锁增加页面路由标识,Map 键改为「路由 + 接口 key」,不同页面的同接口互相不阻塞,仅同一页面内拦截重复请求。

性能与业务价值题

Q11:这套请求调度工具上线后带来哪些收益? 答:1. 彻底消除接口时序错乱导致的页面空白、数据闪烁,线上数据相关报错率接近 0;2. 拦截大量重复无效请求,后端请求量降低 35%,减轻服务器压力;3. 统一管理所有接口请求逻辑,复用拦截、锁、过期处理,减少重复业务代码。

拓展深挖题

Q12:Promise 本身能不能解决竞态?为什么? 答:不能。Promise 仅代表异步操作容器,无法控制网络返回顺序,多个 Promise 并行执行时,回调执行顺序完全由网络耗时决定,依旧会出现先请求后响应的竞态问题,必须额外增加批次 / 锁机制过滤过期回调。

Q13:Axios 中如何结合 CancelToken 与请求锁做双重优化? 答:在状态锁 Map 中同时存储 CancelToken 的 source 实例;发起同接口新请求时,取出上一次 source 调用 source.cancel () 终止旧网络请求;同时在响应回调中通过批次标记兜底过滤残留回调;双重保障:前端直接中断传输减少无效响应,标记兜底防止取消失败后的旧数据渲染,彻底杜绝竞态

补充配套概念面试题(Axios 专属,面试官大概率追问)

Q14:Axios 中 CancelToken 是什么?source.cancel () 作用是什么? 答:

  1. CancelToken 是 Axios 内置的请求取消机制,用于手动中断正在进行的网络请求,替代浏览器原生 AbortController(Axios v0.22 前标准方案);
  2. 调用 source.cancel (' 提示文案 ') 会立刻终止当前接口,进入 Axios 响应错误拦截,抛出 Cancel 异常,不会执行正常成功回调。

Q15:Axios 新版也支持 AbortController,为什么项目选用 CancelToken? 答:项目内部 Axios 版本偏低,未全面兼容 signal 绑定;CancelToken 兼容性更好,可直接存入请求锁 Map 统一管理,对老项目改造成本更低;新版本可混合使用 signal 与 CancelToken 双重取消

12.首屏加载全链路优化(多级资源预加载 + 接口内存缓存+懒加载 + 渐进式低清渲染)

基础业务题

Q1:你们首页首屏白屏久、海报资源大,做了哪些整体优化? 答:搭建三级缓存 + 多级资源预加载,搭配图片懒加载 + 渐进式渲染整套方案:

  1. 三级缓存:接口内存缓存、静态图片缓存、路由 keep-alive 页面缓存,减少 40% 重复网络请求;
  2. 多级预加载:按优先级预拉取首屏关键 JS、接口、海报资源,空闲时预加载次要资源;
  3. 图片优化:视口外图片懒加载,大图先用低清模糊图占位,高清图异步替换; 最终 FCP 从 2.37s 降至 1.32s,首屏速度提升 44%。

Q2:什么是三级缓存策略,分别怎么实现、解决什么问题? 答:分为接口内存缓存、静态图片缓存、路由页面缓存:

  1. 接口内存缓存:全局 Map 存储接口返回数据 + 过期时间,重复查询直接读内存,减少后端请求;
  2. 静态图片缓存:浏览器强缓存 + 内存缓存,加载过的海报图二次打开无需重新下载;
  3. 路由缓存:keep-alive 缓存页面组件实例,再次进入页面不重新请求基础数据、不重建 DOM; 三者配合,从接口、图片、页面三个维度避免重复资源加载。

多级资源预加载专项

Q3:多级资源预加载是怎么分级的,各级分别处理哪些资源? 答:分三档优先级:

  1. 最高优先级:首屏核心 JS、CSS、商户基础信息、顶部横幅接口,页面初始化立即并行预请求;
  2. 中优先级:首屏可视区内海报、封面图,通过 link preload 预加载;
  3. 低优先级:底部模块、弹窗、二级页面资源,使用 requestIdleCallback 在浏览器空闲时段加载,不抢占首屏带宽。

Q4:link preload 作用是什么?和懒加载冲突吗? 答:preload 是提前强制加载关键资源,用于首屏可视区大图、核心脚本;懒加载用于视口外图片;两者不冲突,可视区内预加载,可视区外懒加载,搭配使用。

Q5:requestIdleCallback 有什么用,为什么用它预加载次要资源? 答:该 API 在浏览器主线程空闲时才执行任务,不会抢占渲染、JS 执行主线程;次要资源放到空闲时加载,不会延长白屏、阻塞首屏渲染。

图片优化专项(高频)

Q6:图片懒加载怎么实现,为什么能优化首屏? 答:使用 IntersectionObserver 监听图片 DOM 进入可视区域后再请求图片;首屏只加载视口内少量图片,减少首屏并发请求数,降低网络阻塞,缩短加载时间。

Q7:渐进式低清渲染方案是什么,解决什么用户体验问题? 答:每张高清海报配套极小模糊缩略图;页面先展示低清占位图,同时异步请求高清大图,大图加载完成后平滑替换;避免大图加载期间大片空白,缓解用户等待焦虑,提升感知速度。

Q8:除了懒加载 + 渐进图,图片还有哪些配套优化手段? 答:1. 图片格式转换 WebP/AVIF,压缩体积;2. 图片尺寸适配,不同设备加载对应分辨率图;3. CDN 图片压缩;4. 静态图片开启强缓存 Cache-Control。

缓存机制深挖

Q9:接口内存缓存用 Map 实现有什么优缺点,怎么避免内存泄漏? 答:优点:读写速度远快于 localStorage,无序列化损耗,实时释放; 缺点:页面刷新缓存全部丢失,长时间页面停留会堆积数据; 处理泄漏:1. 页面路由离开时清空当前页面相关缓存;2. 设置缓存过期时间,定时清理过期数据;3. 切换商户 / 用户时全量清空缓存池。

Q10:内存缓存和 localStorage 缓存怎么取舍,为什么首屏接口优先用内存缓存? 答:localStorage 存在 JSON 序列化 / 反序列化性能损耗,容量有限;内存 Map 读写无转换开销,速度更快,适合首屏高频访问的临时接口数据;持久化数据才用本地存储,首屏临时数据只用内存缓存。

Q11:keep-alive 页面路由缓存有什么坑,怎么处理? 答:坑 1:页面缓存后接口不刷新,数据老旧;解决:路由进入钩子判断场景,主动清除对应接口缓存重新请求; 坑 2:缓存 DOM 过多占用内存;解决:设置 max 最大缓存页面数量,超出自动销毁; 坑 3:组件生命周期错乱;搭配 onActivated/onDeactivated 做缓存刷新与资源释放。

性能指标 & 收益题

Q12:FCP 是什么指标,优化前后数据怎么体现优化效果? 答:FCP 首次内容绘制,代表页面第一次渲染出可见内容的时间,是衡量白屏核心指标;优化前 2.37s,优化后 1.32s,速度提升 44%,直观说明白屏时间大幅缩短。

Q13:整套优化上线后带来哪些业务收益? 答:1. FCP 大幅降低,白屏等待变短,用户流失减少;2. 三级缓存减少 40% 重复网络请求,减轻后端与 CDN 带宽压力;3. 渐进式图片解决海报空白问题,用户体验显著提升。

拓展深挖拔高题

Q14:预加载会不会抢占首屏带宽,反而拖慢加载?怎么规避? 答:会,不分优先级全部预加载会造成请求并发拥堵; 规避方案:分级控制,核心资源立即预加载,次要资源利用浏览器空闲时段加载,不与首屏关键资源抢占网络。

Q15:如果用户网络很差,多级预加载策略需要降级吗?怎么处理? 答:需要降级;通过 navigator.connection 监测网络类型,弱网下关闭低优先级资源预加载,只预加载核心接口与首屏最小图片,优先保证页面基础渲染。

Q16:首屏资源还可以搭配哪些优化手段,和你这套方案配合? 答:1. 代码分割路由懒加载,减小首屏 JS 体积;2. 压缩打包、Tree-Shaking 剔除无用代码;3. 组件异步拆分;4. 骨架屏配合渐进图片,进一步优化感知体验;5. 静态资源 CDN 加速。

13.Vue 大文件分片上传 + 断点续传组件

一、基础概念类(必问,入门打底)

1. 为什么大文件不能直接整文件上传,要分片?

答:

  1. 浏览器请求有超时限制,超大文件一次性上传极易超时失败;
  2. 网络中断、刷新页面会导致全部重传,流量损耗大;
  3. 服务器单请求接收大文件内存占用高,容易 OOM;
  4. 分片可并行上传,提升上传速度;支持断点续传,只重传失败分片。

2. Blob、File、ArrayBuffer 三者关系,你怎么切割文件?

答:

  • File 继承自 Blob,是本地文件特殊 Blob;
  • 文件切割核心:file.slice(start, end) 返回 Blob 分片; 流程:计算分片大小(如 5MB)→ 循环计算每片起止下标 → slice 切割生成单个分片 Blob。

3. MD5 文件指纹作用是什么?为什么不用文件名做唯一标识?

答: MD5 用途:

  1. 全局唯一文件指纹,区分不同文件,实现断点续传匹配;
  2. 分片前后校验:前端分片 MD5 + 后端校验分片完整性,防止传输损坏;
  3. 秒传:后端存在相同 MD5 文件,直接返回上传完成,无需传分片。 不用文件名:同名不同内容文件、用户重命名、大小写、后缀修改都会冲突。

4. 断点续传核心逻辑是什么?

答:

  1. 上传前计算完整文件 MD5;
  2. 请求后端接口,携带 fileMd5 查询已上传分片列表;
  3. 前端对比本地全部分片下标,过滤掉已上传分片,仅上传缺失分片;
  4. 上传过程用 localStorage/indexDB 缓存上传进度、分片状态,刷新页面不丢失进度。

二、核心实现原理类(面试重点深挖)

1. 大文件计算 MD5 会卡顿页面,你怎么优化?

答: 原生同步读取文件计算 MD5 会阻塞主线程,页面卡死,优化方案:

  1. WebWorker:文件切片后在 worker 中异步计算 MD5,不阻塞 UI;
  2. 分块增量计算:不用一次性读取整个文件,分段读取逐步生成 md5;
  3. 限制 worker 数量,避免多文件上传创建大量线程;
  4. 降级:极小文件直接主线程计算,大文件走 worker。

2. 分片并行上传如何实现?怎么控制并发数量?为什么要限制并发?

答: 实现:使用 Promise 队列 + Promise.allSettled,多请求同时发分片; 并发控制:手写并发调度器,设置最大并发数(如 3/5),避免一次性发起几十条请求; 限制并发原因:

  1. 浏览器同源请求有最大连接限制(Chrome 同域 6 个),超量请求排队阻塞;
  2. 大量并发占用带宽,单分片上传速度下降,甚至丢包;
  3. 防止服务器短时间接收海量请求压垮接口。

3. 单个分片上传失败,自动重试机制怎么设计?

答:

  1. 每个分片维护状态:pending/success/fail;
  2. 分片请求捕获异常 / 后端返回失败状态码,标记失败;
  3. 重试策略:单分片最大重试次数(3 次),间隔递增重试(1s、2s、4s 指数退避);
  4. 重试耗尽仍失败:暂停上传,提示用户手动重试整个任务;
  5. 区分错误类型:4xx 客户端错误不重试,5xx / 网络中断触发重试。

4. 分片全部上传完成后,后端合并文件流程,前端需要做什么?

答: 前端操作:

  1. 所有分片上传成功后,调用合并接口
  2. 参数携带文件 MD5、总分片数、文件名、文件大小; 后端逻辑:
  3. 根据 md5 找到对应分片文件夹;
  4. 按分片下标顺序读取所有分片二进制流,合并为完整文件;
  5. 校验合并后文件总大小、整体 MD5,校验失败返回前端重新上传缺失分片;
  6. 清理临时分片文件,返回完整文件访问地址。

5. 你用什么存储上传进度、分片状态?localStorage 和 IndexedDB 怎么选?

答:

  • localStorage:容量小(5M),仅存简单 md5、进度、分片下标列表,适合小缓存;刷新页面读取恢复续传;
  • IndexedDB:异步大容量存储,适合超大文件、多任务同时上传场景,存储完整分片状态、文件信息; 业务选择:单文件简单上传用 localStorage;多文件批量上传组件用 IndexedDB。

三、Vue 组件封装 & 工程化复用问题(贴合你的场景:全局公共组件)

1. 你如何封装成全局公共组件,全项目页面复用?有几种调用方式?

答: 两种封装方案同时实现:

  1. 组件式调用:全局注册UploadBigFile,页面 template 直接标签使用,支持插槽自定义上传区域、进度展示;
  2. 函数式调用(挂载 Vue 原型 / 单独 utils):this.$uploadBigFile({ file, success }),弹窗上传,无需写 DOM; 工程化:
  • 单独文件夹维护组件、上传工具类、api 请求、worker 文件;
  • 抽离纯逻辑工具类uploadChunk.js,解耦 DOM,方便其他 JS 文件复用;
  • props 提供配置项:分片大小、并发数、重试次数、接口地址、是否开启秒传;
  • 事件抛出:onProgress、onSuccess、onError、onCancel,页面自定义业务逻辑。

2. 组件如何解耦?DOM 视图、上传核心逻辑、请求 API 分层?

分层结构(面试官爱问架构分层):

  1. 视图层:Vue 组件,只负责渲染上传按钮、进度条、错误提示,不处理上传逻辑;
  2. 核心工具层(纯 JS):分片切割、MD5 计算、并发调度、断点校验、重试逻辑;无 Vue 依赖,可单独导入;
  3. API 请求层:统一封装 axios 分片请求、查询分片、合并接口,统一处理请求头、token、错误拦截;
  4. WebWorker 层:单独 md5.worker.js,单独文件不污染主线程。

3. props 设计有哪些关键配置?如何做到页面灵活自定义?

核心 props: chunkSize 分片大小、maxConcurrent 最大并发、retryTimes 单分片重试次数、api 接口地址、autoRetry 自动重试、enableMd5 秒传开关、accept 文件类型、limit 多文件数量; 自定义能力:上传区域插槽、进度自定义插槽、错误提示插槽;支持 v-model 双向绑定上传列表。

4. 多文件同时上传怎么处理?组件如何管理多个上传任务?

答: 内部维护任务队列 Map,key 为文件 md5,value 为单个文件上传实例; 每个文件独立拥有:分片列表、进度、并发调度器、重试计数; 支持单独取消单个文件、清空全部、暂停 / 继续单个任务; 全局统一控制最大总并发,防止多文件叠加请求超限。

四、网络、性能优化面试题

1. 分片上传请求头、参数怎么设计?后端接收哪些参数?

前端每片请求携带: 文件 md5、chunkIndex 分片序号、chunkSize 分片大小、fileTotalSize、blob 分片二进制(FormData 传递); 查询分片接口:仅传 fileMd5; 合并接口:fileMd5、totalChunks、filename。

2. FormData 上传二进制分片,有什么优化点?

  1. 不用每次创建新 FormData,复用实例;
  2. 取消上传时调用xhr.abort()中断请求,释放网络资源;
  3. axios 配置取消令牌,支持批量取消所有分片请求;
  4. 避免携带多余参数,减少请求体体积。

3. 如何精准计算实时上传进度?

答:

  1. 记录每个分片大小,维护已上传总字节数;
  2. 累加所有成功分片字节 + 当前上传分片已传输字节;
  3. 进度 = 已上传总字节 / 文件总大小 * 100; 注意:不能按分片数量简单百分比,分片最后一片大小不统一,会进度失真。

4. 网络弱网场景怎么兼容?

  1. 分片重试指数退避,不频繁轰炸接口;
  2. 离线监听:navigator.onLine,断网自动暂停,联网后恢复续传;
  3. 超时配置:单个分片单独设置 timeout,避免单个慢分片阻塞整个任务;
  4. 缓存分片状态,刷新页面可恢复。

五、异常、边界故障场景(区分初级 / 高级候选人)

1. 上传中途关闭页面、刷新、浏览器崩溃,如何恢复断点?

答: 每成功上传一个分片,立刻持久化存储该分片下标(localStorage/IndexedDB); 再次选择同一文件时,先读取本地缓存 md5,请求后端校验已上传分片,合并本地缓存与后端数据,过滤已上传分片,直接续传。

2. MD5 计算失败、文件损坏怎么处理?

  1. Worker 异常捕获,提示文件读取失败;
  2. 文件读取中断(本地文件删除 / 损坏)直接终止任务,抛出错误;
  3. 提供重新选择文件按钮重置任务。

3. 后端合并文件校验 MD5 不一致,前端怎么处理?

答:

  1. 后端返回校验失败,告知缺失 / 损坏分片下标;
  2. 前端自动筛选对应分片,重新发起上传;
  3. 多次校验失败提示用户网络异常,建议重新上传。

4. 用户中途取消上传,需要做哪些清理工作?

  1. 通过 axios 的 AbortController 批量中断所有分片请求,区分手动取消不触发重试;
  2. 清空并发调度队列,停止分发新的分片请求;
  3. 终止 MD5 计算 WebWorker,清空内存里的分片、进度、任务实例;
  4. 删除 IndexedDB/localStorage 中该文件 md5 对应的断点缓存;
  5. 可选调用后端接口删除服务器临时分片;
  6. 重置 UI 进度,抛出取消回调,完成业务层面清理。

补充代码示意(AbortController 标准用法)

javascript 复制代码
// 每个分片创建控制器
const controller = new AbortController()
this.abortList.push(controller)
axios.post('/upload/chunk', formData, {
  signal: controller.signal
})

// 取消时统一中断
cancelUpload() {
  this.abortList.forEach(controller => controller.abort())
  this.abortList = []
  // 后续执行队列清空、缓存删除、worker终止...
}

六、拓展、进阶深挖(3 年 + 开发必问)

1. 除了 Blob 分片,还有没有其他大文件上传方案?优缺点对比

  1. 分片上传(当前方案):兼容性好、断点续传、可控,主流方案;
  2. WebSocket 流式上传:实时流传输,不适合大文件断点恢复;
  3. 流媒体 MediaSource:多用于视频,普通文件不适用;
  4. 第三方 OSS 直传(阿里云 / 腾讯云):前端直传 OSS,减轻业务服务器压力,分片逻辑由 OSS 提供。

2. 如果对接 OSS 对象存储,分片上传逻辑要怎么改动?

  1. 前端先向后端获取 OSS 上传凭证;
  2. 调用 OSS 原生分片上传接口,OSS 自身支持断点、分片合并;
  3. 不再需要业务服务器存储临时分片,节省服务器资源;
  4. MD5 校验逻辑保留,用于秒传和文件校验。

3. 如何实现上传限速?

在并发调度器中增加上传间隔控制,限制同一时间传输字节总量;通过延迟下一个分片请求降低整体上传速度,提供限速开关给用户。

4. 讲一下整体组件完整执行流程(高频流程口述题)

  1. 用户选择本地 File 文件;
  2. 初始化上传任务,创建 WebWorker 异步计算文件 MD5;
  3. MD5 生成完成,请求后端查询该 md5 已上传分片;
  4. 对比本地所有分片下标,过滤出未上传分片;
  5. 启动并发调度器,并行上传剩余分片,失败分片自动重试;
  6. 实时计算、更新上传进度;
  7. 所有分片上传成功,调用后端合并接口;
  8. 后端合并并校验文件完整性,返回文件地址;
  9. 触发 success 事件,清除本地临时缓存,上传完成;
  10. 中途断网 / 刷新:下次打开自动读取缓存,重复步骤 3 实现续传。

5. 项目中踩过什么坑?怎么解决(面试官最爱听实战坑)

  1. 同步计算 MD5 页面卡死 → 改用 WebWorker 增量计算;
  2. 并行请求过多浏览器阻塞 → 实现并发调度限制最大请求数;
  3. 刷新页面进度丢失 → IndexedDB 持久化分片状态;
  4. 最后一片分片大小不一致,进度计算错误 → 按字节而非分片数量算进度;
  5. 取消上传后请求还在后台发送 → axios 取消令牌 abort 中断 xhr;
  6. 多文件上传任务状态互相污染 → 使用 Map 隔离每个文件独立任务实例;
  7. 弱网下分片无限重试 → 增加最大重试次数 + 指数退避策略。

14.JSON 规则引擎(表单校验 + 动态表格)

一、基础背景类(开场必问)

1. 项目里为什么要做这套可配置 JSON 规则引擎?之前痛点是什么?

答:

  1. 页面泛滥:大量商户活动、运营表单、后台列表页面,每个页面单独写表单校验、表格列渲染、搜索条件、弹窗字段,重复代码极多;
  2. 硬编码维护成本高:新增 / 修改字段规则、表格列,必须改 Vue 模板 + JS 校验逻辑,多人开发容易出现格式不统一、校验遗漏;
  3. 复用性差:相同输入框(手机号、金额、身份证)、相同表格列(状态、操作按钮)每个页面重复复制粘贴;
  4. 迭代慢:新增商户活动页面,需要从零写模板、校验、表格渲染,重复工作量大;
  5. 规则散落在各个业务组件,没有统一标准,新人上手成本高。

所以抽离一套基于 JSON 配置的规则引擎,把渲染模板、校验规则、字段属性和页面业务代码完全解耦,统一配置驱动页面。

2. 你说组件复用提升 60%,开发效率提升 80%,这个数据怎么衡量出来的?

答:

  1. 复用率统计:改造前每个页面独立编写表单 / 表格组件,公共逻辑无法复用;改造后通用输入组件、校验规则、表格列全部抽入规则引擎,多页面共用一套配置,重复代码减少约 60%;
  2. 开发效率对比:
    • 改造前:新增商户活动页,写模板、校验、表格、搜索平均 4~6 小时;
    • 改造后:仅编写 JSON 配置,少量业务逻辑,平均 0.8~1 小时完成; 整体开发耗时降低 80%;
  3. 量化依据:对比同类型 3 个新旧活动页面代码行数,业务模板代码减少 80% 以上,公共通用逻辑统一维护。

3. 这套规则引擎主要支撑哪两块能力?

答:两大核心模块

  1. 动态表单模块:JSON 配置渲染表单组件、联动逻辑、校验规则、显隐、禁用、默认值;
  2. 动态表格模块:JSON 配置表格列、格式化、状态标签、操作按钮、排序、筛选、自定义插槽; 共用一套规则解析器、统一校验器、公共渲染组件。

二、架构 & 分层设计(核心重点,面试官深挖)

4. 整套规则引擎分层架构怎么设计的?如何实现业务解耦?

四层分层,完全解耦模板、规则、业务逻辑:

  1. 配置层(纯 JSON) 页面只导出一份配置对象,包含表单字段配置、表格列配置、校验规则、联动条件,无 DOM、无硬编码;
  2. 规则解析层(纯 JS 工具,独立 utils) 通用解析器:解析 JSON 配置,处理联动、显隐、格式化、分支条件; 统一校验引擎:解析 rules 数组,执行正则、非空、数字、自定义函数校验;
  3. 通用基础渲染组件层(全局公共组件)
    • DynamicForm 动态表单组件
    • DynamicTable 动态表格组件 只接收解析后的配置数据,负责渲染,不写任何业务相关逻辑;
  4. 业务页面层(Vue 页面) 只引入 JSON 配置、处理接口请求、特殊业务回调;模板一行代码渲染表单 / 表格,不再写循环、校验、列定义。

解耦关键点:页面不关心组件怎么渲染、怎么校验,只提供配置,所有通用能力下沉公共层。

5. JSON 配置长什么样?举一段表单 + 表格简单示例结构

动态表单配置示例
javascript 复制代码
export const formConfig = {
  labelWidth: "120px",
  fields: [
    {
      field: "merchantName",
      label: "商户名称",
      type: "input",
      placeholder: "请输入商户名称",
      // 内置校验规则
      rules: [{ required: true, message: "商户名称不能为空" }],
      // 联动显隐规则
      show: (formData) => formData.type === 1
    },
    {
      field: "phone",
      label: "联系电话",
      type: "input",
      rules: [{ pattern: /^1[3-9]\d{9}$/, message: "手机号格式错误" }]
    },
    {
      field: "amount",
      label: "活动金额",
      type: "number",
      rules: [{ min: 0, max: 999999, message: "金额范围0~999999" }]
    }
  ]
}
动态表格配置示例
javascript 复制代码
export const tableConfig = {
  border: true,
  columns: [
    { label: "商户ID", prop: "id" },
    { label: "活动状态", prop: "status", type: "tag", tagMap: { 0: "未开始", 1: "进行中", 2: "已结束" } },
    { label: "操作", type: "action", btns: [
      { label: "编辑", click: "handleEdit" },
      { label: "删除", danger: true, click: "handleDelete" }
    ]}
  ]
}

6. 校验规则引擎是怎么实现的?支持哪些校验类型?

  1. 内置基础规则:required、min/max 长度、数字区间、正则 pattern、邮箱、手机号、身份证;
  2. 联动条件校验:支持when条件,满足条件才触发对应校验;
  3. 自定义校验:支持传入 validator 自定义函数,处理复杂业务规则(如活动起止时间对比);
  4. 统一校验流程:
    • 接收表单数据 + 字段 rules 配置;
    • 循环遍历字段,逐条执行校验规则;
    • 收集所有错误信息,统一返回,供 DynamicForm 渲染提示;
  5. 与 UI 解耦:校验逻辑抽离独立工具函数,表单组件只负责展示错误文案。

7. JSON 配置如何实现字段联动、显隐、禁用、只读?

两种实现方案结合:

  1. 简单静态联动:配置show: booleandisabled: boolean
  2. 动态联动:配置传函数show(formData)=>boolean,解析器实时传入完整表单数据,根据其他字段值动态控制当前字段显示 / 禁用; 页面输入值变化时触发解析器重新计算所有字段状态,驱动组件刷新。

8. 动态表格如何处理复杂场景:状态标签、格式化、操作按钮、自定义插槽?

  1. 内置类型映射:type=tag/text/number,配置 tagMap 自动渲染不同颜色标签;
  2. formatter 格式化函数:配置formatter(row)=>string,处理时间、金额、百分比;
  3. 操作按钮配置:统一 btns 数组,支持权限控制、弹窗、跳转、回调事件;
  4. 插槽兼容兜底:配置 slotName,页面可自定义插槽覆盖默认渲染,兼顾简单配置和复杂自定义需求。

三、工程化复用 & 落地问题(贴合你简历提升效率的点)

9. 怎么封装成全局公共组件,全项目页面一键复用?

  1. 全局注册 DynamicFormDynamicTable,main.js 统一挂载;
  2. 配置文件单独抽离xxx.config.js,和业务页面分离存放,便于统一维护;
  3. 提供统一工具函数validateForm(formData, config),非 Vue 页面也能单独调用校验规则。
  4. 页面使用极简模板,无需循环、不用重复写 el-input/el-table-column:
javascript 复制代码
<!-- 表单 -->
<DynamicForm :config="formConfig" v-model="formData" @submit="onSubmit"/>
<!-- 表格 -->
<DynamicTable :config="tableConfig" :data="tableList"/>

10. 新增商户活动页面开发流程简化成什么样?怎么做到效率提升 80%?

改造前流程: 写 template 循环表单→逐个写校验规则→写表格列→写格式化函数→写状态标签→写操作按钮→处理显隐联动,大量模板代码;

改造后流程:

  1. 新建activity.config.js,编写 JSON 表单、表格配置;
  2. Vue 页面引入配置,一行标签渲染表单和表格;
  3. 仅编写接口请求、提交、删除等少量业务回调; 无需编写重复 DOM 和通用校验逻辑,90% 通用能力由规则引擎承载,极大缩短开发时间。

11. 多页面共用相同字段(手机号、金额、状态列)如何复用配置?

抽离公共配置片段,导出公共常量:

javascript 复制代码
// src/config/common.js 全局公共规则
export const phoneField = {
  field: "phone", label: "手机号", type: "input", rules: [...]
}
export const statusColumn = {
  label: "状态", prop: "status", type: "tag", tagMap: {...}
}

业务页面配置直接导入扩展,无需重复定义:

javascript 复制代码
import { phoneField, statusColumn } from "@/config/common"
fields: [phoneField, ...其他字段]
columns: [statusColumn, ...其他列]

四、性能、边界、兼容深挖(区分初级 / 中级)

12. 如果表单字段几十上百个,JSON 解析、联动判断会不会卡顿?怎么优化?

优化方案:

  1. 缓存解析结果:相同配置只解析一次,缓存字段计算后的状态;
  2. 节流处理联动更新:表单输入频繁变更时,用节流控制解析执行频率;
  3. 按需渲染:复杂长表单做虚拟滚动,只渲染可视区域字段;
  4. 轻量化解析器:解析工具只做纯数据处理,不产生 DOM 操作,无额外渲染开销;
  5. 区分静态 / 动态字段:无联动的静态字段提前预解析,减少运行时计算。

13. 复杂业务场景 JSON 配置不好表达怎么办?如何平衡「配置化」和「自定义代码」?

不能强制所有场景都用 JSON,采用配置优先,插槽 / 回调兜底策略:

  1. 简单通用场景:全部 JSON 配置驱动,快速开发;
  2. 复杂定制场景(复杂弹窗、特殊交互、复杂渲染):提供插槽、自定义 render 函数、自定义回调;
  3. 设计原则:80% 常规页面走配置快速开发,20% 复杂页面保留自定义扩展能力,不牺牲业务灵活性。

14. 校验大量字段时,会不会出现校验阻塞页面?

  1. 基础同步校验逻辑极轻量,不会阻塞;
  2. 存在异步校验(如商户名称重复请求后端校验):规则支持 async 自定义 validator,异步校验加 loading 状态,不阻塞主线程;
  3. 批量提交时统一收集校验结果,错峰展示错误提示。

15. 配置化后出现问题,如何快速定位是配置错误还是组件 bug?

  1. 配置解析器增加日志输出,打印字段解析、校验失败信息;
  2. 开发环境增加配置校验工具,校验 JSON 字段格式、必填参数是否缺失;
  3. 分层排查:先看配置是否正确→再看解析器输出数据→最后看公共组件渲染逻辑;
  4. 提供调试工具函数,页面打印解析后的完整字段状态。

五、踩坑 & 优化实战题(面试官最爱)

16. 开发这套规则引擎过程中踩过哪些坑?怎么解决?

  1. 坑 1:联动函数中修改表单数据造成死循环 解决:联动只读取 formData,不直接修改;修改值单独通过组件提供 setFieldValue 方法;
  2. 坑 2:大量页面重复导入公共配置,冗余代码多 解决:统一封装公共配置导出文件,全局引入;
  3. 坑 3:JSON 无法处理复杂异步校验、复杂渲染 解决:支持自定义 validator、render 插槽、页面回调函数作为扩展;
  4. 坑 4:配置层级太深,多人维护格式不统一 解决:编写 TS 类型定义 / JS 注释规范,统一配置模板,增加开发环境配置格式校验;
  5. 坑 5:表格格式化、标签逻辑散落在各个页面 解决:全部下沉到表格规则引擎,统一配置 tagMap/formatter。

17. 有没有用 TypeScript 约束这套 JSON 配置?怎么做类型提示?

加分回答: 定义表单字段、表格列、校验规则 TS 接口,给配置提供完整类型提示,约束必填参数、type 枚举值,从编译阶段规避配置写错字段名、属性不存在的问题,降低线上 bug。

六、拓展进阶问题(3 年 + 深度提问)

18. 这套规则引擎能不能扩展到低代码平台?如何改造?

可以,改造方向:

  1. 提供可视化配置面板,拖拽生成 JSON 配置;
  2. 配置持久化到后端,接口下发配置,页面动态拉取渲染;
  3. 增加组件物料库、条件分支、循环容器等高级配置; 当前实现是前端静态 JSON 配置,是轻量化低代码雏形。

19. 如果后端需要动态下发表单配置(不同商户不同表单),你的引擎需要改动哪里?

仅改动两层:

  1. 配置层:不再本地导入 JSON,改为接口请求后端获取配置;
  2. 解析层无需改动,解析器只接收标准配置对象,不关心来源; DynamicForm/DynamicTable 组件完全不用修改,无缝兼容远程配置。

20. 和 Element Plus 自带表单校验、表格渲染相比,你的规则引擎优势在哪?

  1. 统一规范:全项目表单、表格一套标准,不会出现每个人写法不一致;
  2. 代码极简:不用写大量循环模板,页面代码量大幅减少;
  3. 开箱即用:内置手机号、金额、状态标签等通用业务规则,无需重复封装;
  4. 可复用:公共字段、表格列统一抽离,一处修改全页面生效;
  5. 解耦业务:页面只关心业务数据,剥离渲染与校验细节。

15.万级设备长列表虚拟列表优化

一、背景痛点类(开场必问)

1. 项目之前万级设备列表卡顿的根本原因是什么?

答:

  1. DOM 数量爆炸:一次性渲染上万条设备行,DOM 节点上千个,浏览器构建 DOM 树、布局树、渲染树开销极大;
  2. 滚动高频触发重排重绘:scroll 事件持续高频执行,每次滚动全量刷新列表;
  3. 内存占用高:每条设备包含状态标签、操作按钮、图标,大量 DOM 常驻内存,GC 频繁触发造成掉帧;
  4. 滚动时视图全部参与计算,页面 FPS 暴跌到 30 以内,滑动卡顿、滚动延迟,极端场景出现空白白屏。

2. 为什么不用分页 / 懒加载,非要做虚拟列表?两者区别?

答:

  1. 业务场景:设备列表需要快速上下自由滚动检索,分页需要切换页码、等待请求,用户体验差;懒加载滚动到底再加载,往上滚动还要重新请求,无法实现一次性全量本地数据流畅滑动;
  2. 分页:分割数据、减少单次 DOM,但滚动体验割裂;
  3. 虚拟列表:前端一次性加载全部万级数据,只渲染可视区域少量 DOM,滚动全程无接口等待,顺滑浏览全部数据,适配设备批量检索场景。

3. 简历里 FPS 从 30 提升到 70+,这个指标怎么观测、怎么验证优化效果?

答: 观测工具:Chrome Performance 面板、FPS meter、开发者工具渲染面板;

  1. 优化前:滚动时频繁红色长任务,主线程阻塞,FPS 稳定 20~30;
  2. 优化后:长任务消失,仅几十条固定 DOM 持续复用,滚动稳定 70 帧以上,接近显示器刷新率;
  3. 辅助指标:DOM 节点数量由上万降至 10~30 个可视 DOM,内存占用降低 60%+,无滚动白屏、滚动延迟消失。

二、虚拟列表核心原理(面试重中之重)

4. 虚拟列表核心实现原理是什么?

核心思想:只渲染视口内可见数据,复用固定数量 DOM,通过占位容器模拟总高度实现滚动条

  1. 外层滚动容器固定高度,生成一个空白占位 div,高度 = 总条数 × 单行高度,撑起完整滚动条;
  2. 容器内部只渲染可视区域 + 上下缓冲条数的真实 DOM 行(一般 20~30 条);
  3. 监听 scroll 滚动偏移,实时计算当前可视数据区间,替换 DOM 内列表内容;
  4. DOM 节点全程复用,不会新增 / 销毁大量节点,从根源减少渲染开销。

5. 单行高度分两种:固定高度 / 动态不定高度,分别怎么实现?你的设备列表用哪种?

  1. 固定高度(设备列表场景首选) 每条设备行高度统一,计算简单:总高度 = itemSize * total;根据 scrollTop 直接算出起始索引,性能极高,无额外计算损耗,我们项目设备行布局统一,采用固定高度方案。
  2. 动态不定高度 每条高度不一致,需要缓存每条真实高度、缓存每个条目 offsetTop,滚动二分查找可视区间;计算开销更大,适合图文混合不规则列表。

6. 缓冲区域是什么?为什么要设置上下缓冲条数?如何解决滚动白屏?

  1. 缓冲:可视区域上方、下方多渲染几条数据(比如各 5 条);
  2. 作用:快速大幅度滚动时,滚动计算有延迟,缓冲 DOM 提前渲染好,不会出现中间空白白屏;
  3. 无缓冲问题:快速滑动时计算还没完成,可视区 DOM 没更新,出现大片空白;
  4. 项目配置:上下各缓冲 5 条,彻底解决高速滚动白屏问题。

7. 虚拟列表三大核心计算逻辑,口述完整流程

  1. 基础参数:容器可视高度、单行高度、缓冲条数、滚动偏移 scrollTop;
  2. 计算起始索引 startIndex = Math.floor (scrollTop /itemSize) - 缓冲条数;
  3. 计算结束索引 endIndex = startIndex + 可视条数 + 上下缓冲条数;
  4. 计算偏移位移:用一个 transform: translate3d 垂直偏移真实 DOM 列表,模拟滚动位置,不修改 top(避免重排);
  5. 截取 startIndex, endIndex 区间数据渲染到复用 DOM 中。

8. 为什么用 translate3d 而不是 top/margin-top 做位移?

top/margin-top 会触发浏览器重排(回流),每次滚动重新计算布局,性能差; translate3d 开启 GPU 硬件加速,只触发复合层重绘,不影响页面布局,滚动性能大幅提升。

三、防抖、滚动性能优化配套方案

9. scroll 事件为什么要加防抖?防抖怎么配合虚拟列表使用?区分防抖 / 节流

  1. scroll 事件每秒触发几十上百次,不节流会频繁执行索引计算、数据切片、DOM 更新,主线程堵塞掉帧;
  2. 方案:使用节流 throttle(不是防抖)控制滚动计算频率,固定 50ms 执行一次滚动逻辑; 补充区分:
  • 节流:滚动过程持续执行,控制频率,适合 scroll 拖拽;
  • 防抖:停止滚动后延迟执行,适合滚动结束后统计、导出;
  1. 项目组合:滚动过程节流更新可视列表,滚动停止防抖执行统计、高亮定位等次要逻辑。

10. 除虚拟列表 + 节流防抖外,还做了哪些配套渲染优化?

  1. DOM 复用:固定 DOM 池,只替换内部文本、状态,不频繁创建销毁 div;
  2. 避免行内复杂渲染:设备状态标签、按钮使用 CSS 简单样式,移除滚动时动画;
  3. 图片懒加载:列表内设备图标使用懒加载,不在视口不请求图片;
  4. 避免滚动时读取 DOM 布局属性(offsetTop/clientHeight),全部缓存数值;
  5. 使用 requestAnimationFrame 在下一帧执行 DOM 更新,同步浏览器渲染周期;
  6. 列表容器开启 css will-change: transform,提前分配 GPU 资源。

四、工程化封装与业务落地(贴合 Vue 项目场景)

11. 在 Vue 中如何封装通用虚拟列表组件,全项目复用?组件设计关键 props

封装独立公共组件 VirtualList,解耦设备业务,其他长列表页面直接复用; 核心 props:

  • data:完整万级设备数组;
  • item-size:单行固定高度;
  • height:外层滚动容器高度;
  • buffer:上下缓冲条数;
  • item-render:渲染每条行的插槽 / 渲染函数; 对外事件:滚动、选中行、数据切换回调; 页面使用示例:
javascript 复制代码
<VirtualList
  :data="deviceList"
  :item-size="64"
  :height="700"
  :buffer="5"
>
  <template #default="{ item }">
    <!-- 单条设备UI:设备编号、在线状态、操作按钮 -->
  </template>
</VirtualList>

12. 万级设备数据一次性传入组件,会不会造成初始化卡顿?怎么处理?

优化手段:

  1. 数据分片赋值:接口返回万条数据,使用 requestIdleCallback 分批写入列表,不阻塞主线程;
  2. 初始只计算基础总高度,不循环遍历全部数据,初始化计算开销恒定;
  3. 不遍历全部数据做预处理,仅截取可视区间数据渲染。

13. 设备列表存在搜索、筛选、状态过滤,虚拟列表如何适配?

过滤逻辑在父页面处理,过滤后生成新数组传给 VirtualList; 组件内部监听 data 变化,重新计算总占位高度、重新计算可视索引,无需修改虚拟列表底层逻辑; 过滤后数据量变化也不会卡顿,因为始终只渲染可视区 DOM。

五、性能、边界问题 & 踩坑实战(高频深挖)

14. 快速滚动时出现数据错乱、重复行是什么原因?怎么修复?

原因:滚动节流延迟,上一次 DOM 更新未完成又执行下一次渲染,数据覆盖错乱; 解决:

  1. 每次更新前清除上一帧渲染任务;
  2. 使用唯一 key 绑定每条渲染行,Vue 精准 diff,避免复用 DOM 渲染错乱;
  3. 控制渲染逻辑单线程串行执行,避免并发计算。

15. 虚拟列表滚动定位(跳转到指定设备行)怎么实现?

根据目标索引计算偏移距离 scrollTop = index * itemSize,赋值给滚动容器 scrollTop,容器自动滚动到对应位置;搭配防抖,定位完成后高亮对应设备条目。

16. 大量设备行绑定点击、操作按钮事件,会不会造成性能损耗?怎么优化?

不每条 DOM 单独绑定事件,使用事件委托:外层虚拟列表容器统一监听点击,通过事件 target 区分当前点击设备条目,减少上千个事件监听,节省内存。

17. 虚拟列表有什么缺点?哪些场景不适合用?

缺点:

  1. 固定高度模式下,单行高度变更需要重新计算,维护成本增加;
  2. 复杂嵌套表格、多层树结构虚拟列表实现难度高; 不适用场景:
  • 数据只有几十条,没必要增加虚拟列表复杂度;
  • 每条高度差异极大且无法缓存的不规则图文列表。

18. 项目开发过程中踩过哪些典型坑?如何解决?

  1. 坑 1:无缓冲高速滚动出现大片白屏 解决:增加上下缓冲条数,提升渲染提前量;
  2. 坑 2:滚动使用 top 定位,持续重排导致 FPS 低 解决:替换为 transform3d GPU 加速;
  3. 坑 3:scroll 不加节流,主线程持续阻塞,帧率暴跌 解决:滚动计算逻辑加节流控制执行频率;
  4. 坑 4:Vue 渲染行不设置唯一 key,筛选后 DOM 复用错乱 解决:用设备唯一 ID 作为 key;
  5. 坑 5:初始化一次性遍历万条数据做处理,页面加载卡顿 解决:延后、分片处理数据,初始化只做高度计算。

六、进阶拓展问题(3 年 + 资深面试官提问)

19. 市面上虚拟列表方案对比:手写原生虚拟列表 /vue-virtual-scroller/el-virtual-list,你为什么选择自己封装?

  1. 第三方库体积大,内置很多不需要的不定高度、树列表逻辑,业务仅需固定高度设备列表,轻量化自研体积更小;
  2. 业务定制化需求:设备状态高亮、批量操作、滚动定位、设备筛选联动,第三方库扩展成本高;
  3. 自主可控,可针对性优化设备场景,搭配防抖、事件委托、设备特殊渲染逻辑深度适配,性能调优更灵活。

20. 如果是十万、百万级超大设备数据,当前方案如何升级?

  1. 前端内存压力大,结合虚拟列表 + 后端分片查询(无限滚动 + 虚拟混合方案);
  2. 缓存已请求分片数据,滚动到区间再请求对应设备数据,降低前端内存占用;
  3. 使用 WebWorker 预处理设备数据,避免主线程阻塞。

21. 如何监控虚拟列表性能,线上发现滚动卡顿怎么排查?

  1. 埋点:统计页面平均 FPS、长任务耗时、滚动延迟时长;
  2. 线上排查:
    • 查看是否意外渲染大量 DOM(缓冲配置过大);
    • 检查滚动回调内是否存在大量同步循环、DOM 读写;
    • 排查是否有滚动动画、复杂 CSS 滤镜占用 GPU 资源。

七、口述完整优化流程(必背,面试官常让梳理方案)

  1. 现状:万级设备一次性渲染上万 DOM,scroll 高频触发重排,FPS 低、滚动卡顿、高速滑动白屏;
  2. 核心方案:自研固定高度虚拟列表组件,仅渲染可视区 + 缓冲 DOM,用占位 div 撑起滚动条,transform3d 做位移;
  3. 性能配套:scroll 滚动逻辑加节流,滚动结束防抖处理附加逻辑,事件委托减少监听、图片懒加载、GPU 加速;
  4. 业务适配:支持设备筛选、搜索、滚动定位、批量操作,封装为全局组件复用;
  5. 优化结果:DOM 数量缩减至 30 个以内,滚动 FPS 从 30 提升至 70+,万级列表上下滑动无卡顿、无空白白屏。

16.设备检索防抖 + 请求竞态优化(Vue3 Hook 封装)

一、背景痛点类(必开场提问)

1. 实时检索场景原始存在两个核心问题分别是什么?现象是什么?

答:

  1. 频繁重复请求(性能问题) 用户快速连续输入文字,每敲一个字符就发一次接口,短时间批量并发 axios 请求,浪费带宽、增加后端压力,输入过程页面频繁加载闪烁。
  2. 请求竞态:后发先至(数据错乱核心) 网络不稳定时,晚发起的请求先返回、早发起的请求后返回,旧接口结果覆盖最新检索条件的列表,设备页面数据来回闪烁、展示错误设备数据。

2. 简单说下什么是请求竞态(后发先至),举你项目里的实例?

用户输入 12

  • 输入 1 → 发起请求 A(耗时 1500ms)
  • 立刻输入 2 → 发起请求 B(耗时 300ms) B 先返回,页面渲染 B 的设备列表;过一会 A 请求返回,直接覆盖页面,列表变回只搜 "1" 的旧数据,出现错乱闪烁,这就是竞态。

3. 为什么简单加 setTimeout 防抖无法彻底解决竞态?

单纯防抖只能减少请求次数,不能中断正在路上的旧请求: 即便间隔 500ms 发请求,若上一轮请求还在 pending,网络慢依旧会出现旧响应覆盖新数据,只能降低概率,无法根治数据错乱。

二、防抖原理与实现设计问题

4. 检索防抖的实现逻辑是什么?为什么选用节流不行?

  1. 防抖逻辑:输入停止后延迟 N 毫秒再发起请求;中途持续输入则清空定时器,重置倒计时。
  2. 场景区分:
  • 防抖:适合输入框实时搜索(等用户输完再查);
  • 节流:适合滚动、拖拽,固定频率持续执行; 实时检索核心需求是减少无效中间输入请求,因此用防抖。

5. 防抖延时一般设置多少毫秒?依据是什么?

项目统一设置 300~500ms;

  • 太短:还是会大量发请求,失去防抖意义;
  • 太长:用户输入完等待过久,交互卡顿; 300--500ms 是兼顾性能与用户体验的通用标准。

三、竞态解决方案核心:AbortController 高频深挖

6. AbortController 解决竞态的完整思路是什么?

  1. 每次发起新检索请求前,调用上一次请求的 controller.abort() 终止未完成的旧 axios 请求;
  2. 每次请求创建全新 AbortController 实例,挂载到当前接口;
  3. 被中断的请求会抛出canceled错误,在响应拦截过滤,不执行列表赋值逻辑;
  4. 保证永远只有最新一次检索请求能正常返回并渲染数据,彻底杜绝旧数据覆盖。

7. axios 如何绑定 AbortController?中断后会出现报错,怎么处理不污染业务错误提示?

javascript 复制代码
const controller = new AbortController()
axios.get('/api/device/search', {
  params: searchParams,
  signal: controller.signal
})

错误处理区分类型:

  • err.name === 'CanceledError':手动中断的请求,直接 return,不弹出错误提示、不更新列表;
  • 其他 5xx/4xx 网络错误,正常走业务报错逻辑。

8. 如果不使用 AbortController,还有哪些兜底方案?优缺点?

  1. 用标记位 flag(isLatestSearch):每次新请求置为 true,旧请求回调判断 flag=false 则不赋值; 缺点:请求依然在后台完成,占用网络带宽,大量无效请求堆积;
  2. 请求队列清空:记录所有 pending 请求,循环丢弃回调; 缺点:无法终止浏览器真实网络请求,仅前端拦截赋值,治标不治本。 最优方案一定是 AbortController 直接中断网络请求。

四、Vue3 Hook 工程化封装相关重点面试题

9. 为什么选择封装 Vue3 hook(useSearchDevice)而不是组件内硬写 / 全局混入?

  1. 复用性:多页面(设备列表、商户设备、监控设备)实时检索直接引入 hook,无需重复复制防抖、中断、清理逻辑;
  2. 逻辑解耦:检索防抖、请求管理、生命周期清理逻辑与 UI 模板完全分离;
  3. 无副作用:hook 作用域独立,不会像 mixin 存在命名冲突、变量覆盖问题;
  4. 统一管控:延时、请求中断、自动销毁逻辑统一维护,一处优化全页面生效。

10. 你的 hook 内部维护了哪些核心变量?

  1. timer:防抖定时器 ID,用于清空延时;
  2. abortController:保存当前请求控制器实例,用于中断旧请求;
  3. loading:检索加载状态;
  4. tableData:设备检索结果列表;
  5. searchParams:多字段检索条件。

11. 组件卸载时需要做哪些自动清理?为什么必须清理?

hook 内部监听onUnmounted执行双重清理,防止内存泄漏、页面销毁后回调执行报错:

  1. 清除防抖定时器 clearTimeout (timer),避免组件销毁后定时器还发请求;
  2. 判断 abortController 存在,执行 abort () 中断当前正在 pending 的检索请求; 不清理的后果:
  • 页面关闭后定时器触发,发起无效请求;
  • 接口返回后操作已卸载组件的响应式变量,Vue 抛出内存泄漏警告;
  • 弹窗 / 路由切换后旧请求回来,污染新页面列表数据。

12. hook 如何支持多字段联合模糊检索?

hook 接收统一检索条件对象(设备名称、编号、在线状态、时间区间),每次输入任意字段变更触发防抖函数,统一把完整参数传给后端接口,无需单独处理单个输入框。

五、边界、异常与性能优化问题

13. 用户快速连续删除、粘贴大量文字,这套逻辑会不会失效?

不会: 每次输入变更都会清空上一次防抖计时器,重新计时;同时立刻中断上一轮未完成请求,保证只执行最新一次检索。

14. 同时存在输入检索 + 分页切换,如何避免两者互相产生竞态?

  1. 分页改变时,同样执行 abort 中断所有检索请求;
  2. 统一复用同一套 AbortController 实例,无论输入还是分页,新操作直接杀死旧网络请求;
  3. hook 对外暴露统一 search 方法,检索、分页共用一套请求管控逻辑。

15. 输入空值、全空格场景怎么优化减少无效请求?

在防抖执行的回调内增加前置校验: 过滤纯空、空白字符,直接清空列表并 return,不发起网络请求,节省接口调用。

16. 网络超时和请求中断怎么区分处理?

  • 中断(CanceledError):静默丢弃,无提示;
  • 超时 / 404/500:弹出业务错误提示,清空 loading,保留上一次有效列表数据。

六、完整执行流程口述题(面试官高频要求梳理流程)

  1. 用户输入设备检索字段,触发 v-model 更新;
  2. hook 触发防抖逻辑:清除原有定时器,重置 300ms 倒计时;
  3. 倒计时结束,执行检索函数: ① 判断存在上一次 abort 实例,调用 abort () 中断旧请求; ② 创建新 AbortController 保存到变量; ③ 携带完整多字段参数发起 axios 请求,绑定 signal;
  4. 请求成功:赋值设备列表,清除 loading;
  5. 请求被中断:捕获 CanceledError,不执行任何列表更新;
  6. 组件切换路由 / 关闭弹窗触发 onUnmounted:清除定时器、中断活跃请求,释放资源。

七、实战踩坑题(加分项,体现真实项目经验)

17. 开发过程踩过哪些坑,如何解决?

  1. 坑 1:只做防抖没中断请求,弱网依旧数据闪烁 解决:引入 AbortController 每次新请求终止旧请求;
  2. 坑 2:组件销毁未清理定时器,控制台大量 Vue 内存泄漏警告 解决:onUnmounted 统一清除 timer、中断请求;
  3. 坑 3:中断错误统一走业务报错弹窗,频繁输入弹出大量提示 解决:单独判断 CanceledError,跳过错误提示逻辑;
  4. 坑 4:多次快速输入,controller 重复覆盖导致无法中断上上个请求 解决:每次发起请求前先中断当前保存的 controller,再新建实例;
  5. 坑 5:hook 多组件共用,控制器变量互相污染 解决:hook 内部变量均为局部作用域,每个组件实例独立一套 timer、controller。

18. 和单纯在页面写防抖函数相比,hook 封装带来哪些工程化收益?

  1. 统一标准:延时、请求中断、销毁清理逻辑全局统一,不会每个人写法不一致;
  2. 减少重复代码:新页面实时检索只需引入一行 hook,不用重复写定时器、AbortController;
  3. 易于迭代:后续要修改防抖时长、增加请求拦截逻辑,仅修改 hook 一处;
  4. 逻辑内聚:检索相关状态、加载、清理全部收拢,可读性、维护性大幅提升。

八、拓展进阶深挖(3 年 + 中级 / 高级提问)

19. 如果同时开启多个 tab 页签检索设备,AbortController 会互相干扰吗?

不会,Vue3 hook 每一个组件实例拥有独立作用域,timer、abortController 都是局部变量,tab 之间完全隔离,互不影响。

20. 低版本浏览器不支持 AbortController 如何降级兼容?

降级方案组合:

  1. 存在 AbortController 则使用原生中断;
  2. 不存在时启用isLatest标记位兜底,旧请求回调判断标记不赋值列表;
  3. 同时保留防抖减少请求数量,双重保障避免数据错乱。

21. 如何拓展 hook 支持手动点击查询按钮、重置检索条件?

hook 对外暴露方法:

  • handleSearch():立即执行检索(跳过防抖延时);
  • resetSearch():清空检索条件、中断当前请求、重置列表; 页面按钮可直接调用,一套逻辑同时支持实时输入检索 + 手动查询。

17.WebWorker 数万级设备数据运算(主线程阻塞优化)

一、背景痛点基础题(开场必问)

1. 为什么数万条设备筛选、统计会卡死页面?底层原理是什么?

答: JS 是单线程模型,主线程同时负责三件事:JS 逻辑执行、DOM 渲染、用户交互(点击 / 滚动输入)。 批量循环过滤、字段格式化、求和 / 分组统计属于密集同步计算,执行时独占主线程,浏览器无法渲染页面、响应鼠标操作,表现为页面卡死、滚动卡顿、按钮点不动。

2. 为什么不用 setTimeout /requestIdleCallback 拆分循环,非要用 WebWorker?两种方案优缺点?

  1. setTimeout 分片循环:只是把计算切成多段穿插执行,依旧跑在主线程,大量数据依然会出现轻微卡顿,只能缓解不能根治;且拆分逻辑代码繁琐,大量循环分片不好维护。
  2. requestIdleCallback 利用浏览器空闲时间运算:优先级极低,数据量大时计算完成速度极慢,无法满足批量统计实时出结果的业务需求。
  3. WebWorker:开辟独立后台线程,运算完全脱离主线程,UI 交互完全不受影响,海量数据筛选、分组统计性能最优,适合纯数据密集型运算场景。

3. 你的业务里 WebWorker 具体承载了哪些设备数据 heavy 运算?

三类耗时逻辑全部移入 worker:

  1. 批量筛选:多条件组合过滤设备数组(状态、在线、区域、设备类型多字段匹配);
  2. 数据格式化:统一时间格式化、状态码转中文标签、数值单位换算、空值清洗;
  3. 聚合统计:设备总数、在线离线数量分组、各类设备数量求和、平均值计算。

二、WebWorker 核心原理 & 通信深挖(面试核心)

4. WebWorker 工作机制是什么?主线程和子线程怎么通信?

  1. 主线程 new Worker ('xxx.worker.js') 创建独立线程;
  2. 两者完全隔离,不能互相访问 DOM、window、document、Vue 实例,无共享内存;
  3. 通信唯一方式:postMessage() 传递数据,通过 message 事件监听返回结果;
  4. 数据传递采用结构化克隆算法,复制一份数据副本,不是引用传递。

5. postMessage 传上万条设备完整数组会不会性能损耗大?怎么优化大数据传输?

问题:数万条对象完整拷贝会有序列化开销,数据超大时传输慢。 优化方案分两种:

  1. 常规场景(万级设备):直接传递,业务可接受;精简传输字段,过滤无用冗余属性,减小数据体积;
  2. 超大十万级数据:使用 Transferable 二进制转移(如 ArrayBuffer),所有权转移不复制内存,零拷贝; 本项目设备数据为普通 JSON 对象,无二进制,采用精简字段方案优化传输速度。

6. Worker 线程不能操作 DOM,那筛选完的数据怎么渲染页面列表?

流程:

  1. 主线程把原始设备数组、筛选条件传给 worker;
  2. worker 独立完成过滤、格式化、统计;
  3. 通过 postMessage 把处理后的结果数组 + 统计指标发回主线程;
  4. 主线程拿到纯数据,赋值给列表响应式变量,再由 Vue 渲染 DOM。 所有 DOM 渲染、UI 交互始终留在主线程,worker 只做纯数据运算。

7. WebWorker 常用生命周期事件有哪些,分别作用?

  1. message:接收对方发送的数据(核心通信事件);
  2. error:worker 内部计算报错,主线程捕获异常,避免整个页面崩溃;
  3. messageerror:传输数据序列化失败(传递循环引用对象会触发);
  4. terminate ():主线程手动销毁 worker,释放线程资源;
  5. self.close ():worker 内部自行关闭线程。

三、工程化封装(Vue3 项目落地高频问题)

8. Vue 项目中如何封装 WebWorker,避免重复创建线程?怎么处理打包路径问题?

  1. 封装通用数据处理 worker 文件 dataCalc.worker.js,统一承载筛选 / 格式化 / 统计逻辑;
  2. 封装 useDataCalc 组合式 hook 管理 worker 实例:
    • 组件初次加载创建一次 worker,复用线程,不重复 new Worker;
    • 组件卸载自动 terminate 销毁,防止线程常驻内存泄漏;
  3. Vite/Webpack 打包适配:
    • Vite:使用 new Worker(new URL('./xxx.worker.js', import.meta.url)) 解析路径;
    • Webpack:配置 worker-loader,区分工作线程文件; 不处理路径会出现 404,找不到 worker 脚本文件。

9. 多组件同时使用批量筛选,会创建多个 Worker 线程吗?如何控制线程数量?

  1. 基础方案:每个 hook 实例独立 worker,简单但大量页面会创建多线程,浏览器有线程上限(一般 8 个左右);
  2. 优化方案(项目采用):单例全局 worker,所有页面共用同一个后台线程,通过消息唯一标识区分不同组件的计算任务; 主线程发送消息携带 taskId,worker 返回结果带回 taskId,对应分发到对应组件,控制线程数量,避免浏览器线程耗尽。

10. hook 内部完整执行流程(口述必背)

  1. 用户切换筛选条件,触发筛选方法;
  2. 主线程判断 worker 是否存在,不存在则初始化创建;
  3. 发送 postMessage,携带:原始设备列表、筛选参数、taskId;
  4. 子线程接收数据,离线执行过滤、格式化、统计;
  5. 运算完成,将结果、统计指标、taskId 传回主线程;
  6. hook 监听 message 事件,匹配 taskId,更新页面列表与统计面板;
  7. 组件卸载调用 terminate () 销毁 worker,释放资源。

四、边界异常、性能优化实战题(区分初级 / 中级)

11. 往 postMessage 传递包含循环引用的设备对象会报错,怎么处理?

结构化克隆算法不支持循环引用,触发 messageerror:

  1. 预处理传参前深拷贝并清除循环引用字段;
  2. 只传递业务需要的纯基础数据字段,剔除互相引用的嵌套对象;
  3. 监听 messageerror 事件捕获异常,给用户提示数据解析失败。

12. 连续快速切换筛选条件,worker 多个计算任务并发返回,出现列表数据错乱怎么解决?

解决方案:

  1. 每次发起新运算时,记录最新 taskId;
  2. worker 返回结果时,对比当前最新 taskId,旧任务结果直接丢弃,不更新页面;
  3. 进阶:取消未完成旧任务(简易 worker 任务队列标记,放弃过期计算)。

13. 组件切换路由 / 弹窗关闭,不销毁 Worker 会有什么问题?

  1. 后台线程持续占用浏览器资源,多页面叠加造成线程堆积;
  2. worker 返回 message 时,组件已卸载,操作不存在的响应式变量,控制台 Vue 内存泄漏警告;
  3. 闲置线程持续占用内存,页面越多越卡顿。 处理:onUnmounted 钩子执行 worker.terminate () 彻底关闭线程。

14. Worker 内部运算报错如何捕获,不影响主线程整体功能?

主线程绑定 worker.onerror 监听错误:

  1. 捕获计算异常,打印错误日志;
  2. 清空 loading 状态,给用户友好提示「数据统计失败」;
  3. 不阻塞页面其他操作、滚动、点击。

15. 什么场景不适合使用 WebWorker?

  1. 数据量很小(几百条以内):创建线程、数据拷贝通信有额外开销,得不偿失;
  2. 需要 DOM 操作、window/BOM API 的逻辑(worker 无法访问);
  3. 频繁极小块即时运算,通信成本高于计算收益。

五、进阶深挖(3 年 + 高级面试提问)

16. WebWorker 共享线程方案 SharedWorker 和普通 Worker 区别,为什么项目不用?

  1. Worker:单页面私有线程,页面销毁线程销毁;
  2. SharedWorker:同域名多页面、多 tab 共享一个线程; 本项目设备筛选多为单页面独立运算,无需跨 tab 共享;SharedWorker 兼容性略差、调试复杂,因此选用普通 Worker。

17. 十万级超大设备数组传输,如何用 Transferable 优化传输性能?

如果后端返回二进制 / 数组缓冲区数据,传递时加第二个参数转移内存:

javascript 复制代码
worker.postMessage(buffer, [buffer])

所有权转移,不复制内存,大幅降低大数据传输耗时; 本项目是普通 JSON 设备对象无 ArrayBuffer,未采用该方案。

18. WebWorker 里能不能引入工具函数(日期格式化、过滤工具类)?

可以,worker 内部通过 importScripts () 引入公共工具脚本,把格式化、过滤通用方法抽离公共 js,主线程不用重复传递工具逻辑,简化 worker 代码。

19. 对比主流方案:分片循环 / WebWorker / 后端分页统计,各自适用场景?

  1. 分片循环:千条以内轻量计算,简单低成本;
  2. WebWorker:前端已有完整数万条数据,需实时多条件筛选、本地统计;
  3. 后端分页统计:数据百万级以上,前端无法一次性承载全量数据,每次筛选请求后端计算。

六、实战踩坑(面试官最爱加分项)

20. 开发中踩过哪些典型坑,怎么解决?

  1. 坑 1:Vue 打包后 Worker 文件 404 找不到 解决:Vite 使用 import.meta.url 构建 Worker 路径;
  2. 坑 2:传递超大数组页面短暂卡顿(序列化复制开销) 解决:精简传输字段,剔除无用属性,减少克隆体积;
  3. 坑 3:组件销毁未 terminate,后台线程残留内存泄漏 解决:onUnmounted 强制销毁 worker;
  4. 坑 4:多次快速切换筛选,旧任务结果覆盖最新列表 解决:taskId 标记,丢弃过期任务返回数据;
  5. 坑 5:worker 内部报错无捕获,页面无任何提示 解决:主线程绑定 onerror 捕获并提示用户;
  6. 坑 6:在 worker 中调用 window/document 直接报错 解决:把所有 DOM、Vue 相关逻辑留在主线程,worker 只做纯数据处理。

七、完整优化总结口述(面试收尾梳理方案)

原有问题:数万设备批量筛选、格式化、统计同步运算阻塞 JS 主线程,页面滚动、点击卡死; 解决方案:抽离纯数据密集逻辑到独立 WebWorker 线程,通过 postMessage 实现线程间数据通信;封装 Vue3 hook 统一管理 worker 实例、消息分发、自动销毁;通过 taskId 解决多任务返回数据错乱问题,组件卸载释放线程资源; 收益:海量数据运算完全不占用主线程,页面交互全程流畅无延迟,无卡死、无阻塞。

18.SSE 流式 AI 对话面试全套题

一、基础背景 & 选型类(开场必问)

1. 为什么 AI 流式对话不用轮询、WebSocket,选择 SSE?三者对比

(1)轮询(短轮询 / 长轮询)缺点
  • 短轮询:定时发请求,空闲时大量无效 HTTP 请求,浪费带宽、后端压力大;消息延迟高,做不到实时打字效果;
  • 长轮询:后端 hold 住连接,消息返回后立刻断开,下一条消息需要重新建连,频繁握手,AI 逐字输出场景延迟波动大,打字割裂。
(2)WebSocket 缺点

双向通信,但 AI 对话仅后端单向推流,不需要前端上行实时推送;

  • 协议升级握手复杂,部分企业防火墙、反向代理不兼容 ws 协议;
  • 需额外处理断线重连、心跳、二进制 / 文本解析,开发成本更高;
  • 后端要维护 ws 连接池,资源开销高于 SSE。
(3)SSE 优势(贴合 AI 流式场景)
  1. 基于原生 HTTP/HTTPS,无需协议升级,nginx、网关兼容友好;
  2. 天然服务端单向推送,完美匹配 AI 逐段返回文本场景;
  3. 浏览器原生EventSourceAPI,开箱即用,内置断线自动重连、消息 ID、重试间隔配置;
  4. 轻量,头部开销小,后端实现简单,只需要设置固定响应头持续输出数据流;
  5. 自带事件、id 机制,支持断点续推,断连恢复不丢失对话内容。

2. SSE 底层核心原理、必备响应头是什么?

SSE 本质:一次 HTTP GET 请求,后端不关闭响应流,持续向 response 输出文本流,浏览器持续解析。 后端必须返回响应头:

javascript 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

数据格式固定规范:data: xxx\n\n,单条消息以双换行符结尾。 支持额外字段:

  • event: 自定义事件名:区分普通文本、结束、报错事件;
  • id: 消息序号:断线重连携带 Last-Event-ID,后端断点续传;
  • retry: 3000:设置断线自动重连毫秒间隔。

3. 你的 AI 对话业务中,后端是如何分片推送文本?

  1. AI 大模型流式生成 token,后端按字符 / 短句分片;
  2. 每生成一段文本,封装 data: 片段内容\n\n 实时写入响应流;
  3. 全部生成完毕后推送特殊结束标记 event: done
  4. 异常场景推送 event: error 附带错误信息; 全程不关闭 HTTP 连接,持续分片输出。

二、前端实现核心问题(EventSource、打字机渲染)

4. 浏览器 EventSource 基础用法,怎么分段解析实现打字机实时效果?

  1. 创建 EventSource 实例建立 SSE 通道,监听 message 事件;
  2. 每次接收data字段增量文本,追加到对话气泡末尾,不替换原有内容;
  3. 拿到片段立刻更新响应式变量,Vue 自动渲染,视觉上逐字弹出打字机效果;
  4. 监听自定义 done 事件:标记流式输出完成,关闭加载状态;
  5. 监听 error 事件:捕获网络、服务端异常,做重连 / 报错提示。

极简示例:

javascript 复制代码
const source = new EventSource('/api/ai/chat-stream', {
  method: 'GET',
  headers: { Authorization: token }
})
source.onmessage = (e) => {
  const chunk = e.data
  // 增量拼接文本,实时渲染
  answerText.value += chunk
}
source.addEventListener('done', () => {
  source.close() // 输出完毕主动关闭连接
  loading.value = false
})

5. EventSource 默认仅支持 GET 请求,业务需要携带 token、POST 传提问参数怎么解决?

两种落地方案(项目用方案 1):

  1. 请求头携带 token:部分浏览器 EventSource 构造函数支持 headers;不兼容则把 token 放在 url 参数里(短期对话安全可接受);
  2. 后端改造接口:前端 POST 提交对话提问参数到缓存,返回唯一会话 id;再用 EventSource GET 携带会话 id 建立 SSE 长连接,后端根据 id 读取提问并流式返回。

6. 流式渲染如何优化,避免频繁更新 DOM 卡顿?

  1. 增量拼接字符串,不频繁拆分重组 DOM,仅单次追加文本;
  2. 对话容器开启white-space: pre-wrap,后端直接换行无需前端处理;
  3. 大量长文本滚动自动置底使用节流,避免 onmessage 高频触发滚动逻辑;
  4. 不使用 v-if 频繁销毁重建气泡,用缓存容器持续追加内容;
  5. 超大文本分段渲染时禁用滚动动画,降低重绘开销。

7. 流式输出中途切换页面、关闭弹窗,如何销毁 SSE 连接,防止内存泄漏?

Vue3 组合式方案:

  1. 将 EventSource 实例存为 ref 变量;
  2. onUnmounted生命周期执行 source.close() 主动断开长连接;
  3. 移除所有 message、error、done 事件监听,防止组件销毁后回调执行报错;
  4. 清空对话文本、loading 状态,中断未完成推送。

三、断线重连、断点续推异常处理(高频深挖)

8. SSE 断网、网关超时断开,如何实现恢复后不丢失对话内容?

依靠 SSE 原生 Last-Event-ID 机制:

  1. 后端每条推送携带 id: 数字序号,代表当前分片下标;
  2. 浏览器自动记录 id,重连请求头部携带 Last-Event-ID
  3. 后端读取该 ID,从对应分片继续推送剩余 AI 文本,无需重新生成整段回答;
  4. 前端缓存已接收完整文本,重连后只拼接后续增量片段,不会重复渲染、文字错乱。

9. 区分几种 SSE 异常,分别怎么处理?

  1. 临时网络波动(断网):EventSource 内置 retry 自动重连,无需手动封装;搭配 loading 防抖,避免频繁闪烁报错;
  2. 服务端报错(event: error):关闭连接,弹出提示,停止接收消息;
  3. 会话过期、token 失效:监听 error 状态码 401,清除登录态跳转登录页;
  4. AI 生成超时、限流:后端推送特殊错误 data,前端终止流式输出,提示用户重试。

10. 如何控制重连频率,避免短时间大量重试打满后端接口?

  1. 后端推送 retry: 5000 指定 5 秒重试间隔,覆盖浏览器默认策略;
  2. 前端增加最大重连次数(如 5 次),超过次数永久关闭连接,提示用户网络异常;
  3. 主动关闭的连接(用户切换页面、对话结束)禁止自动重连。

四、工程化封装(Vue3 Hook 封装)

11. 如何封装通用 useSSE AI 流式对话 Hook,多页面复用?

Hook 内部封装完整能力:

  1. 内部维护 EventSource 实例、对话文本、loading、重连计数;
  2. 对外暴露方法:startStream(question) 开启流式对话、closeStream() 手动关闭;
  3. 抛出回调钩子:onChunk(每段文本回调)、onFinish(输出完成)、onError(异常);
  4. onUnmounted 自动 close 销毁连接,统一管理清理逻辑;
  5. 内置 token 处理、断点 id 缓存、重连次数限制,业务页面仅调用一行代码即可实现流式打字对话。

12. 多轮连续对话场景,SSE 如何隔离不同会话流?

  1. 每一轮对话生成唯一 sessionId,拼接在 SSE 请求 url;
  2. 同一页面多气泡并行流式输出,多个独立 EventSource 实例隔离消息;
  3. 每条消息携带分片 id 与 sessionId,防止多轮对话文本互相串位。

五、SSE vs WebSocket 拓展深挖(高级面试官必问)

13. 什么场景不适合 SSE,必须改用 WebSocket?

  1. 需要前端双向实时发送(聊天室、协同编辑、实时键鼠操作);
  2. 低版本 IE 完全不支持 EventSource,且无法做兼容垫片;
  3. 高并发高频双向通信,需要减少 HTTP 头部冗余; AI 单向流式输出属于纯后端推送,SSE 更轻量更合适。

14. SSE 有什么原生局限?

  1. 标准 EventSource 仅支持 GET 请求;
  2. 单条浏览器同源 SSE 连接数有限(Chrome 最多 6 个),多标签多对话会排队阻塞;
  3. 无法传输二进制数据,只能纯文本;
  4. 依赖 HTTP 长连接,部分运营商网关会 30s~2min 强制切断空闲连接,必须依靠重连补偿。

六、实战踩坑(加分项,体现项目落地经验)

15. 开发过程遇到的典型问题与解决方案

  1. 坑 1:nginx 缓冲区缓存数据流,前端收不到实时分片,打字卡顿 解决:nginx 配置关闭缓冲 proxy_buffering off;,禁止聚合响应流,后端文本立即推送到前端。
  2. 坑 2:页面销毁未调用 close,后台持续推送、控制台内存泄漏警告 解决:hook onUnmounted 强制关闭 EventSource,清空监听。
  3. 坑 3:断连重连重复接收全部文本,内容叠加错乱 解决:使用 Last-Event-ID 断点续推,前端缓存已有文本,只追加新分片。
  4. 坑 4:token 放在 header 部分浏览器不兼容,鉴权失败 解决:降级方案 token 拼接 URL 参数,或先 POST 传参拿 sessionId。
  5. 坑 5:大段文本 onmessage 频繁触发,滚动一直抖动 解决:滚动置底逻辑加节流控制执行频率。
  6. 坑 6:后端忘记双换行 \n\n 分隔消息,前端一次性接收整段无流式效果 解决:规范后端 data 分隔格式,单条消息结尾必须 \n\n

七、完整业务流程口述(面试梳理方案)

  1. 用户输入 AI 提问点击发送,前端携带对话参数请求后端;
  2. 后端缓存提问内容,返回会话标识;
  3. 前端创建 EventSource 建立 SSE GET 长连接,携带会话 id 与鉴权 token;
  4. 后端持续生成 AI 文本分片,按 SSE 规范实时写入响应流推送;
  5. 前端监听 message 事件,增量拼接文本,实时渲染打字机对话气泡;
  6. 全部内容推送完成,后端下发 done 事件,前端关闭加载状态;
  7. 断网自动根据 Last-Event-ID 断点续传;页面卸载主动 close 关闭长连接释放资源; 对比轮询方案,无无效请求、毫秒级分片推送,流式交互延迟大幅降低。

18.Promise 并发池(异步流量管控)

一、背景与选型基础题(开场必问)

1. 批量查询地图点位、AI 翻译直接用 Promise.all 有什么致命问题?

答:

  1. Promise.all一次性同时发起所有请求,批量场景几十条接口瞬间并发,触发后端限流(429 Too Many Requests),大量请求失败;
  2. 浏览器同源连接数有限(Chrome 同域最大 6 条),超出后请求阻塞排队,页面加载变慢;
  3. 没有失败重试、任务排队机制,某个接口报错直接全部中断批量任务,容错性差;
  4. 无法动态控制并发流量,多页面同时批量操作会叠加请求,加剧服务端压力。

2. 为什么不用分批循环 + setTimeout 延时,非要手写通用并发池?两种方案对比

  1. 简单分批(每批 5 个,sleep 后下一批)缺点:
    • 固定批次死板,一批全部执行完才走下一批,资源利用率低;
    • 封装性差,每个批量业务都要重复写循环延时,无法全局复用;
    • 某一批里有请求提前完成,空闲连接不会复用,浪费带宽。
  2. 并发池优势:
    • 动态调度:有空闲槽位立刻执行下一个排队任务,最大化利用并发上限;
    • 纯工具化抽离,全项目批量接口统一调用;
    • 支持等待全部完成、获取所有结果、单独捕获失败,扩展性强。

3. 业务里哪些场景在用这个并发池?

  1. 地图批量点位批量详情查询;
  2. 批量 AI 文本翻译接口调用;
  3. 设备批量导出前批量拉取详情;
  4. 批量上传分片也可复用这套调度逻辑。

二、并发池核心原理与手写实现(面试重中之重,大概率让现场写简易版)

4. Promise 并发池底层设计思路是什么?三大核心变量

核心逻辑:固定最大并发槽位,维护任务等待队列,每完成一个任务就从队列取出新任务填充空位。 内部维护 3 个变量:

  1. maxConcurrent:最大并发阈值(如 5);
  2. runningCount:当前正在执行的请求数量;
  3. taskQueue:等待执行的异步任务队列(存放返回 Promise 的函数)。

完整执行流程:

  1. 批量传入所有任务函数,循环推入等待队列;
  2. 触发调度函数:若运行数 < 最大并发,取出队首任务执行;
  3. 任务执行完毕,runningCount--,递归再次执行调度,拉取队列剩余任务;
  4. 全部队列清空且无运行任务,resolve 返回所有任务结果。

5. 为什么队列里存「返回 Promise 的函数」,不直接存 Promise 实例?

Promise 创建即立刻执行,若直接存 Promise,所有接口会瞬间全部发起,失去限流意义; 存包装函数:只有调度取出、手动执行函数时,才会发起接口请求,实现延迟调度、排队执行。

6. 并发池如何收集所有任务返回结果?成功 / 失败分别怎么处理?

两种模式对外提供:

  1. 类似Promise.all:任意任务报错直接整体 reject,适合强依赖、缺一不可的批量场景;
  2. 类似Promise.allSettled:收集每一条任务成功 / 失败状态、返回数据,全部执行完统一返回,地图点位、AI 翻译批量场景用此模式,单条失败不阻断整体批量流程。

7. 简易并发池核心代码口述(面试手写精简版)

javascript 复制代码
class PromisePool {
  constructor(max) {
    this.max = max
    this.running = 0
    this.queue = []
    this.results = []
  }
  add(task) {
    return new Promise(resolve => {
      this.queue.push({ task, resolve })
      this.run()
    })
  }
  run() {
    if(this.running >= this.max || !this.queue.length) return
    this.running++
    const { task, resolve } = this.queue.shift()
    task().then(res=>{
      this.results.push({ status:'fulfilled', data:res })
    }).catch(err=>{
      this.results.push({ status:'rejected', err })
    }).finally(()=>{
      this.running--
      this.run()
      // 队列空且无运行任务,统一返回结果
      if(this.running === 0 && !this.queue.length) resolve(this.results)
    })
  }
}
// 使用
const pool = new PromisePool(5)
tasks.forEach(fn => pool.add(fn))

三、业务落地、工程化封装问题

8. 如何封装成全局通用工具,项目任意页面批量接口直接复用?

  1. 单独抽离 src/utils/promisePool.js 工具类,无 Vue 依赖,JS / 组件均可调用;
  2. 封装快捷函数 batchRequest(tasks, maxConcurrent),内部实例化并发池;
  3. 统一参数:任务数组、最大并发数、是否中断整体(all/allSettled 模式);
  4. 地图、AI 翻译页面直接导入调用,无需重复实现调度逻辑。

9. 批量地图点位请求,如何配置合理的 maxConcurrent?依据是什么?

常规配置 5~8:

  • 低于浏览器同源 6 连接上限,不会造成浏览器请求排队;
  • 兼顾速度,同时不会瞬间压满后端接口触发限流; 可根据后端限流阈值动态调整,封装支持动态传入并发数。

10. 并发池搭配请求拦截器,双重防护限流怎么做?

两层限流保障:

  1. 上层并发池:前端控制同时活跃请求数量,从源头减少并发;
  2. axios 响应拦截捕获 429 限流报错,给用户提示 "操作过快,请稍后重试"; 双重机制彻底杜绝大批量接口同时触发限流。

四、边界场景、异常处理高频提问

11. 批量任务中有接口 429 限流失败,并发池怎么处理?能否加自动重试?

扩展并发池支持单任务重试策略:

  1. 单个任务捕获 429 错误,达到重试次数前重新推入队列尾部等待执行;
  2. 重试耗尽标记该任务失败,不阻塞其他点位 / 翻译任务;
  3. 指数退避间隔重试,避免短时间重复轰炸接口。

12. 用户中途切换页面、关闭弹窗,正在排队 / 运行的批量任务如何取消?

给并发池增加终止方法 clear()

  1. 清空等待任务队列,不再调度新请求;
  2. 配合 axios AbortController,存储所有请求控制器,终止正在运行的接口;
  3. Vue 页面 onUnmounted 调用终止方法,防止页面销毁后请求回调修改已卸载组件数据,消除内存泄漏。

13. 多个页面同时使用并发池,会互相干扰流量管控吗?

不会: 每个页面调用都会新建独立 PromisePool 实例,running 计数、任务队列完全隔离; 如果需要全局总流量管控(限制整个项目所有页面总并发),可封装单例全局并发池统一调度。

14. 任务队列堆积大量任务,会不会造成内存占用过高?

  1. 批量执行完成后自动清空队列、结果数组;
  2. 页面卸载强制清空队列、中断请求,释放引用;
  3. 超大批量(上千点位)拆分分片送入并发池,避免一次性存入超长队列。

五、性能优化与拓展进阶问题(3 年 + 深度提问)

15. 并发池相比第三方异步库(p-limit),自己手写的优势是什么?

  1. 轻量化,无第三方依赖,减少打包体积;
  2. 业务定制扩展:内置限流重试、任务取消、allSettled 结果收集,贴合地图 / AI 翻译业务;
  3. 可控可改造,可灵活增加埋点、日志、全局流量监控;
  4. 方便面试讲解原理,完全掌握底层调度逻辑。

16. 如何实现动态调整并发上限?比如后端返回负载高时自动降低并发数

  1. 接口响应头携带服务端负载 / 限流提示;
  2. 并发池监听响应,检测到限流自动临时下调 maxConcurrent;
  3. 等待批量任务执行完毕后恢复默认并发阈值。

17. 并发池能和之前的 AbortController、SSE、WebWorker 方案结合使用吗?怎么结合?

完全可以组合使用:

  1. 批量 AI 翻译:并发池控制同时翻译请求数量,每个任务绑定 AbortController,页面销毁批量中断;
  2. 批量地图点位返回数万数据:并发池管控请求,拿到结果后丢入 WebWorker 做点位筛选、统计,不阻塞主线程。

六、实战踩坑(加分项,体现真实项目经验)

18. 开发并发池踩过哪些坑,如何解决?

  1. 坑 1:队列直接存 Promise,所有请求一次性并发,限流失效 解决:统一改为存放懒执行函数,调度时再调用发起请求;
  2. 坑 2:running 计数逻辑错误,并发数超限无限制,依然触发 429 解决:执行前 running++,finally 中 running--,严格计数;
  3. 坑 3:某个任务报错直接终止整个批量,点位数据全部丢失 解决:默认 allSettled 模式,单独记录每条任务成功失败;
  4. 坑 4:页面销毁未清空队列,后台持续执行请求,控制台内存泄漏 解决:增加 clear 终止方法,组件卸载调用中断请求、清空队列;
  5. 坑 5:递归 run 函数造成调用栈溢出(上万超长队列) 解决:用 setTimeout 异步执行 run,拆解递归调用栈。

七、完整业务流程口述(面试总结必背)

  1. 批量操作(地图点位 / AI 翻译)生成一批异步请求函数;
  2. 传入自定义 Promise 并发池,设置最大并发阈值控制流量;
  3. 内部维护运行计数器与等待队列,空闲槽位自动调度排队任务;
  4. 同一时间仅允许指定数量接口并行,其余排队等候;
  5. 所有任务执行完毕统一返回每条请求的成功 / 失败结果;
  6. 配合 axios 拦截捕获限流异常,支持页面卸载一键取消全部任务; 效果:不再一次性爆发数十并发请求,彻底解决后端 429 限流报错。

19.多模态交互全链路 AI 前端业务

一、项目整体 & 业务背景类(开场必问)

1. 什么是多模态交互?你这套系统包含哪几种模态,各自作用?

答: 多模态指文本、图像、音频多种媒介协同和 AI 交互,我独立实现四条核心链路:

  1. 图像模态:上传图片调用 AI 识别接口,完成文字提取、内容识别、场景标签生成;
  2. 音频模态:麦克风录音 + 浏览器 Web Speech / 流式 SSE 语音转文字,语音输入转 AI 提问文本;
  3. 文本模态:多语种互译、大模型流式对话、历史对话管理;
  4. 数据可视化模态:统计每次对话输入 / 输出 Token,折线、柱状图展示消耗趋势。 整体一套页面同时支持文字、语音、图片三种方式和 AI 对话,适配 PC、平板、移动端。

2. 为什么要把图片、语音、翻译、对话、统计整合一套全链路,分开做有什么问题?

  1. 重复鉴权、重复封装请求、重复 SSE / 上传逻辑,代码冗余;
  2. Token 消耗、对话记录分散存储,无法统一统计;
  3. 语音、图片识别结果无法直接联动翻译、AI 问答,交互割裂;
  4. 多端适配逻辑分散,统一封装一套自适应交互组件降低维护成本;
  5. 统一全局状态管理多模态临时缓存、上传队列、录音状态,避免多组件状态冲突。

3. 你独立完成全套逻辑,整体架构怎么分层解耦?

四层分层,模态能力完全解耦,可单独复用:

  1. 底层工具层:语音录制工具、图片 Blob 切割上传、MD5 文件校验、多语种翻译请求、Token 计算工具、SSE 流式工具;
  2. 公共 Hook 层
    • useAudioRec:录音、暂停、停止、语音转文字;
    • useImageAI:图片压缩、AI 识别、结果解析;
    • useTranslate:多语种互译并发管控;
    • useChatStream:SSE 流式对话;
    • useTokenStat:Token 计算、本地持久化统计;
  3. 状态管理层(Pinia) 统一存储对话列表、临时图片识别结果、录音文本、全局语种、累计 Token 消耗;
  4. 视图层 多模态输入组合组件、对话气泡、Token 可视化图表、移动端自适应布局。

二、图像模态:AI 图片识别相关问题

4. 前端上传图片给 AI 识别做了哪些前置优化?

  1. 图片压缩:canvas 等比例压缩大尺寸原图,降低上传体积,减少接口耗时;
  2. 格式统一:webp/jpg 统一转换,过滤透明大图;
  3. 分片上传:大图走之前封装的 Blob 分片逻辑,支持断点续传;
  4. 图片校验:大小、格式、宽高校验,提前拦截无效图片;
  5. MD5 文件指纹:重复图片直接读取缓存识别结果,减少重复请求;
  6. 并发管控:多张图片批量识别使用 Promise 并发池,限制并行数量,防止 429 限流。

5. AI 图片识别返回结果如何联动其他模态功能?

  1. 识别出的文字一键填充到对话输入框;
  2. 选中文字直接调用多语种翻译;
  3. 图片 + 提取文本组合作为上下文发给大模型进行图文问答;
  4. 识别内容计入输入 Token,实时更新消耗统计图表。

三、音频模态:语音转文字核心问题

6. 前端语音转文字两种实现方案,项目用哪套,优缺点?

  1. 浏览器原生 Web Speech API 优点:不用后端,本地实时转写;缺点:兼容性差、语种少、无离线能力,移动端部分浏览器禁用;
  2. 前端录音上传二进制流 + 后端流式语音识别(SSE)(项目主方案) 流程:MediaRecorder 录制音频 Blob → 分片流式上传后端 → 后端实时转文字 SSE 分段推送; 优势:全浏览器兼容、支持多语种、长语音分段实时出字,搭配打字机流式效果。

7. 录音过程中需要处理哪些边界问题?

  1. 麦克风权限拦截:无权限弹窗引导授权;
  2. 静音检测:纯静音自动停止录音,提示无有效语音;
  3. 长音频分片上传,避免单次文件过大;
  4. 切换页面销毁录音实例,释放麦克风;
  5. 录音中断、网络断开自动缓存音频片段,支持重传;
  6. 录音状态全局统一管理:录制 / 暂停 / 转写中 / 完成,多组件不冲突。

8. 语音转文字结果如何联动翻译、AI 对话?

语音实时转写文本实时展示;转写完成可一键翻译、直接发送给大模型,语音输入完全替代手动打字。

四、多语种翻译模块面试题

9. 批量多语种翻译如何控制并发防止限流?

复用通用 Promise 并发池:批量文本翻译设置最大并发阈值,排队执行;捕获 429 限流错误支持单条重试,指数退避间隔。

10. 翻译如何和图文、语音模态打通?

  1. 手动输入文本、图片识别文字、语音转文字,任意内容均可一键翻译;
  2. AI 模型返回的回答支持一键切换目标语种;
  3. 记忆用户常用翻译语种,全局状态持久化缓存。

五、对话记录存储 & 上下文链路问题

11. 对话记录存在哪里?本地持久化方案怎么设计?

  1. 短期会话内存:Pinia 存储当前会话对话,快速渲染;
  2. 长期本地持久化:IndexedDB 存储完整历史对话、附带图片、语音文本、翻译记录;
  3. 每条对话携带唯一会话 ID、模态类型(文本 / 语音 / 图片)、时间、Token 消耗;
  4. 支持分页加载历史对话、按关键词检索、批量删除、清空记录;
  5. 登出 / 切换账号自动清空当前用户本地对话缓存,数据隔离。

12. 多模态上下文如何拼接传给大模型?

统一封装上下文组装工具: 自动区分输入类型:纯文本、图片 OCR 文本、语音转写文本、翻译结果,格式化统一 Prompt 结构,附带历史对话上下文,控制上下文长度避免 Token 超限,过长自动裁剪早期历史消息。

六、Token 消耗可视化统计(简历亮点高频深挖)

13. 前端如何精准计算输入、输出 Token?

两种方案结合:

  1. 前端轻量化分词工具粗算,实时展示实时消耗(可视化图表实时更新);
  2. AI 流式接口返回每条消息真实 token 数量(prompt_tokens/completion_tokens),用于精准统计; 两者结合:实时粗算展示,接口返回后修正真实数值。

14. Token 可视化做了哪些图表,如何适配多端?

  1. 指标卡片:当日总消耗、图片 / 语音 / 文本各模态占比;
  2. 折线图:近 7 天每日 Token 消耗趋势;
  3. 饼图:多模态消耗占比分布; 适配方案:使用自适应图表组件,屏幕宽度自动切换图表尺寸、隐藏冗余图例,移动端简化展示。

15. Token 数据持久化逻辑?

IndexedDB 按用户维度存储每日消耗记录,页面初始化读取历史数据渲染图表;支持清除统计数据、导出消耗记录。

七、SSE 流式对话全链路联动

16. 多模态输入(图 / 语音 / 文字)统一走 SSE 流式输出,如何统一封装?

统一封装 useChatStream Hook,接收统一入参结构: {type: 'text/audio/image', content: '', translateLang?: ''} 内部统一拼接 prompt、建立 SSE 长连接、增量渲染打字机效果、实时统计 token、自动保存对话记录;输入来源不影响底层流式逻辑,实现一套代码承载三类模态提问。

八、多端自适应交互相关问题

17. PC、平板、移动端分别做了哪些适配差异处理?

  1. 布局:PC 左右分栏(输入区 + 对话列表);移动端上下堆叠,底部固定输入框;
  2. 输入交互:PC 支持快捷键发送、粘贴图片;移动端优先语音输入,隐藏复杂侧边统计;
  3. 性能适配:移动端限制并发池最大请求数、压缩图片倍率更高;
  4. 组件兼容:移动端录音适配移动端浏览器 MediaRecorder 兼容问题;
  5. 图表自适应:小屏简化可视化,折叠统计面板。

18. 如何实现一套组件多端复用,不用写两套页面?

  1. CSS 媒体查询 + 动态类名控制布局;
  2. useMediaQuery Hook 监听屏幕宽度,区分 pc/tablet/mobile 三种设备标识;
  3. 模态输入组件、对话气泡、图表全部做自适应兼容,根据设备标识切换交互逻辑;
  4. 冗余复杂功能移动端按需懒加载,减少移动端首屏资源体积。

九、工程化、异常、性能综合问题

19. 整套多模态系统做了哪些性能优化?

  1. 图片压缩、分片上传、MD5 缓存识别结果;
  2. 批量翻译、批量识图使用 Promise 并发池限流;
  3. 数万条历史对话使用虚拟列表渲染,滚动无卡顿;
  4. WebWorker 单独计算 Token、批量解析历史对话,不阻塞主线程;
  5. SSE 长连接自动销毁、录音 MediaRecorder 页面卸载释放资源;
  6. IndexedDB 异步存储对话,不占用主线程;
  7. 多模态组件按需懒加载,首屏加快。

20. 全链路统一异常处理体系是怎么设计的?

分模态统一捕获异常并友好提示:

  1. 图片模态:格式错误、上传失败、AI 识别超时、限流 429;
  2. 语音模态:麦克风无权限、录制失败、转写接口报错;
  3. 翻译 / 对话:token 过期、模型限流、上下文过长报错; 统一错误拦截:axios 拦截 + SSE error 监听 + 录音 / 图片工具内部错误捕获; 区分静默失败(自动重试)和用户提示类错误,不阻塞整体交互。

21. 组件卸载时全链路资源清理需要做哪些操作?

  1. SSE 调用 close 关闭长连接;
  2. 终止 MediaRecorder 录音,释放麦克风;
  3. 并发池清空排队任务、AbortController 中断所有上传 / 翻译请求;
  4. WebWorker terminate 销毁线程;
  5. 清除临时图片 Blob 缓存、内存对话临时数据; 避免内存泄漏、后台持续请求、麦克风常驻占用。

十、实战踩坑加分题

22. 开发多模态全链路踩过哪些典型问题,怎么解决?

  1. 坑 1:语音、图片、翻译同时并发大量请求触发限流 解决:统一使用 Promise 并发池管控并行数量;
  2. 坑 2:移动端录音 MediaRecorder 兼容性差,部分浏览器无音频流 解决:降级为上传文件后端转写,兼容兜底;
  3. 坑 3:大量历史对话页面滚动卡顿 解决:虚拟列表渲染对话记录;
  4. 坑 4:多模态输入上下文拼接过长,模型报错超限 解决:前端工具自动裁剪早期历史消息,控制上下文长度;
  5. 坑 5:切换页面麦克风不释放,手机录音一直占用 解决:onUnmounted 强制停止录制销毁实例;
  6. 坑 6:Token 前端粗算和后端真实数值偏差大,图表数据不准 解决:接口返回真实 token 覆盖前端估算值,保证统计图表准确。

十一、进阶拓展深挖(3 年 + 高级提问)

23. 如果后续增加视频多模态识别,现有架构需要改动哪里?

现有分层架构完全可扩展,改动极小:

  1. 底层工具新增视频录制、视频分片上传、帧截图工具;
  2. 新增 useVideoAI Hook 处理视频识别;
  3. 状态层新增 video 模态类型;
  4. 对话组装、Token 统计、历史存储逻辑无需大幅改动,模态类型做分支判断即可。

24. 本地 IndexedDB 存储大量对话会不会出现页面加载缓慢?

不会:

  1. 历史对话采用分页懒加载,不一次性读取全部记录;
  2. 查询对话、统计 Token 消耗放入 WebWorker 异步计算;
  3. 提供删除、归档功能,避免存储数据无限膨胀。

25. 多用户切换场景,多模态数据如何隔离不串数据?

IndexedDB 存储增加 userId 主键;Pinia 状态切换用户时清空当前会话、销毁所有长连接、停止录音、清空临时缓存,读取对应用户历史记录。

十二、完整业务流程口述(面试总结)

用户可通过文字、录音语音、上传图片三种模态输入;

  1. 图片自动压缩分片 AI 识别,语音录制流式转文字;
  2. 识别 / 转写文本支持一键多语种翻译,并发池管控批量翻译流量;
  3. 统一组装多模态上下文,通过 SSE 建立长连接实现 AI 流式对话打字机效果;
  4. 实时计算 Token 消耗,存入 IndexedDB 持久化;
  5. 读取历史消耗数据渲染可视化图表,展示各模态使用占比;
  6. 所有对话记录本地持久存储,虚拟列表渲染历史会话;
  7. 整套组件适配 PC / 平板 / 移动端自适应布局; 配套完整资源销毁、异常捕获、并发限流、主线程优化方案,独立完成整套前端逻辑开发。

20.React 作业列表精准渲染优化(memo + useMemo + useCallback)

  • 父组件轮询接口更新 list 状态,父组件自身触发重渲染;
  • 子组件未做缓存时,父一更新全部重渲染;即便用了 memo,函数、对象引用每次变化,浅对比判定 props 改变,依然全部重渲染;
  • 列表几十上百条 Item,每条内部有格式化、标签、按钮,大量重复渲染阻塞主线程,滚动帧率下跌。
  • 父渲染时,每行行内会生成全新函数 / 对象
javascript 复制代码
{list.map(item => (
  <ListItem
    key={item.id}
    item={item}
    // 每次父渲染都是新函数引用
    onEdit={() => handleEdit(item)}
    // 每次生成新对象
    statusInfo={formatStatus(item)}
  />
))}

2. 什么叫「粗粒度渲染」?你说的「精准细粒度优化」核心思路是什么?

  1. 粗粒度:父状态一变,整个列表所有行统一渲染,不区分哪些行数据真正变更;
  2. 细粒度优化目标:只重渲染数据发生变化的 Item,不变 Item 完全跳过渲染
  3. 实现三板斧组合:
    • memo:子组件做 props 浅层对比;
    • useCallback:稳定事件函数引用,不每次生成新函数;
    • useMemo:缓存派生计算值、派生对象 / 数组,固定引用地址。

3. 只给子组件包 memo,不配合 useCallback /useMemo 能优化吗?为什么?

不能,几乎无优化效果。 memo 仅对比 props 的引用地址

  • 父每次渲染,行内箭头函数、格式化后的对象都是全新引用;
  • 哪怕 item 数据完全没变,props 引用变了,memo 依旧判定组件需要重渲染; 必须搭配 useCallback、useMemo 稳定 props 引用,memo 才能拦截无效渲染。

二、三大 API 核心原理深挖(面试重点,常现场写代码)

4. useCallback 作用、项目中缓存哪些回调,依赖数组注意事项

  1. 作用:缓存函数实例,依赖不变时函数引用永远不变,传给子组件不会触发重渲染;
  2. 列表中缓存的回调:编辑、删除、查看详情、批量勾选、状态变更、分页切换;
  3. 依赖坑点:
    • 所有函数内部使用的 state、props、外部变量必须写进依赖数组,否则闭包捕获旧值;
    • 依赖频繁变化时,useCallback 会失效(每次生成新函数);
  4. 示例:
javascript 复制代码
const handleEdit = useCallback((id) => {
  setCurrentId(id)
}, [])

5.useMemo 在作业列表两种核心使用场景,分别解决什么问题

场景 1:缓存复杂派生计算 每条作业需要计算逾期状态、进度百分比、格式化时间、状态标签文本,循环大量同步计算占用主线程;用 useMemo 缓存结果,仅 item 变更才重新计算。

场景 2:缓存数组 / 对象引用

  1. 缓存经过筛选、格式化后的完整列表数组,避免父渲染生成全新数组;
  2. 缓存单行传递给子组件的派生对象,固定引用地址,配合 memo 减少渲染。

6. memo、useCallback、useMemo 三者完整协作流程,口述一遍

  1. 列表 Item 子组件用 memo(ListItem) 包裹,开启 props 浅对比;
  2. 父组件所有行点击事件用 useCallback 缓存,函数引用永久稳定;
  3. 每一行派生数据(状态、进度、格式化信息)用 useMemo 缓存,对象引用不变;
  4. 父轮询刷新:未变更行的 item、事件、派生对象引用全部不变;
  5. memo 对比 props 无变化,直接跳过该 Item 的 render、不生成 DOM;
  6. 只有数据真正变更的行,才会重新计算、重新渲染,实现精准局部刷新。

7. useMemo、useCallback 有性能开销,什么场景不建议滥用?

  1. 简单基础运算(三元判断、简单字符串拼接):缓存开销 > 计算开销;
  2. 组件数据极少更新、列表只有十几条以内:优化收益极低,徒增依赖维护成本;
  3. 依赖频繁变动的场景:每次都会重新生成缓存值,完全失去缓存意义。

三、业务落地 & 代码规范问题

8. map 循环内部不能直接写 useMemo /useCallback,你怎么处理单行数据缓存?

Hook 不能写在循环、条件内部,两种方案:

  1. 抽离单行逻辑到自定义 Hook,封装 useWorkItemInfo(item),内部使用 useMemo;
  2. 将单行格式化、计算逻辑下沉到 Item 子组件内部,在子组件中使用 useMemo 缓存自身计算; 推荐方案 2:业务分离,父组件只传原始 item,派生计算下沉子组件,父逻辑更简洁。

9. 列表轮询高频更新,如何减少父组件无意义重渲染?

配套优化:

  1. 接口返回数据做浅对比,数据完全一致时不 setState,不触发父重渲染;
  2. 分页、筛选、搜索状态拆分,避免一处更新带动全列表刷新;
  3. 列表数据用 useMemo 缓存加工后的数组,避免衍生状态频繁变化。

10. memo 只是浅对比,如果 props 是深层嵌套对象怎么办?

两种方案:

  1. 规范传参:拆分 props,把深层对象拆成独立基础类型参数(id、status、progress);
  2. 自定义 memo 对比函数,第二层做浅层对比:
javascript 复制代码
memo(ListItem, (prev, next) => {
  return prev.item.id === next.item.id && prev.item.status === next.item.status
})

缺点:自定义对比函数会增加渲染耗时,优先拆分简单类型 props。

四、性能指标、FPS 优化相关问题

11. FPS 从 55 提升到 70+,这个数据怎么观测、优化前后区别是什么?

观测工具:Chrome Performance、FPS Meter、Rendering 面板;

  1. 优化前:轮询刷新时大量红色长任务,海量子组件执行 render、虚拟 DOM 对比、重绘,主线程阻塞,滚动稳定 50~55 帧,快速滑动明显卡顿;
  2. 优化后:90% 不变 Item 跳过渲染,render、diff 耗时大幅下降,无长时间阻塞任务,滚动稳定 70 帧以上,滑动顺滑无掉帧;
  3. 辅助指标:DOM 重绘次数减少 60%+,主线程长任务几乎消失。

12. 除了三件套缓存,还搭配了哪些列表渲染辅助优化?

  1. 列表 Item 稳定 key(用唯一业务 id,不用数组下标),减少 diff 开销;
  2. 滚动容器开启 will-change: transform,GPU 加速;
  3. 复杂超长作业列表叠加虚拟列表,只渲染可视区域 DOM;
  4. 滚动事件加节流,避免滚动时频繁执行计算;
  5. 子组件内部复杂计算全部使用 useMemo 缓存;
  6. 图片懒加载,滚动区域外不加载图片资源。

五、边界场景、踩坑实战(加分高频提问)

13. 开发时踩过哪些缓存失效的坑,如何解决?

坑 1:useCallback 依赖数组缺失,闭包捕获旧变量,点击事件数据错乱 解决:用 eslint-plugin-react-hooks 自动校验依赖,补齐依赖项;

坑 2:行内直接生成对象传递给子组件,useMemo 写在循环外无法缓存单行数据 解决:计算逻辑下沉子组件内部缓存,不把派生对象放在父 map 里;

坑 3:memo 配合复杂嵌套对象 props,浅对比失效,子组件依旧重复渲染 解决:拆分 props 为基础类型,或自定义对比函数;

坑 4:滥用 useMemo 包裹简单计算,代码冗余且无性能提升 解决:仅对循环大量复杂计算、高频刷新场景使用缓存;

坑 5:轮询接口每次返回全新数组引用,父无差别重渲染所有 Item 解决:对比新旧列表,无变更时不更新 state,阻断父渲染触发。

14. 分页切换、筛选搜索时,缓存会不会失效?怎么保证性能?

分页 / 筛选会生成全新列表数据,此时本就需要全部重新渲染,缓存不会造成负面影响; 优化:筛选逻辑用 useMemo 缓存筛选后的列表,避免每次渲染重复过滤数组。

六、进阶深挖(3 年 + 高级面试)

15. React 18 自动批处理更新,对你这个列表优化有影响吗?

无负面影响,反而收益更高:

  1. 轮询、批量操作多个 setState 会自动批处理,只触发一次父重渲染;
  2. 并发渲染模式下,无效子组件渲染会占用可中断的渲染时间,缓存减少渲染任务,降低并发阻塞风险。

16. useMemo 能缓存 JSX 节点吗?什么场景适合?

可以缓存整行 JSX,减少虚拟 DOM 创建开销:

javascript 复制代码
const renderItem = useMemo(() => (
  <ListItem item={item} onEdit={handleEdit} />
), [item, handleEdit])

适合列表单行结构复杂、高频刷新场景;缺点:代码可读性下降,优先下沉子组件缓存。

17. React.memo、useMemo、useCallback 底层原理简单说下?

  1. memo:组件记忆化,缓存上一次 vdom props,渲染前对比,相同则跳过 render;
  2. useCallback:内部维护缓存链表,根据依赖数组生成 key,key 不变复用函数;
  3. useMemo:和 useCallback 底层同一套缓存机制,区别是缓存函数返回值而非函数本身。

七、完整优化流程口述(面试总结背诵)

  1. 问题:作业列表轮询高频刷新,父组件重渲染生成全新函数、对象 props,所有 ListItem 全部执行渲染,主线程阻塞,滚动 FPS 偏低;
  2. 优化方案:
    1. 子组件使用 React.memo 开启 props 浅对比;
    2. 所有列表操作事件通过 useCallback 缓存,稳定函数引用;
    3. 作业状态、进度、格式化等复杂派生计算用 useMemo 缓存;
    4. 过滤、加工后的列表数组用 useMemo 缓存,减少重复遍历;
  3. 效果:仅数据变更的作业行重新渲染,绝大多数不变组件跳过 render 与 diff;主线程阻塞大幅减少,滚动 FPS 由 55 提升至 70+,海量列表滑动流畅。

21.纯前端离线 Excel 解析(SheetJS/xlsx)

一、业务背景 & 选型基础题(开场必问)

1. 为什么选择前端离线解析 Excel,不交给后端统一解析?各自优缺点

后端解析方案缺点
  1. 大 Excel 文件上传占用带宽,批量上万条数据接收、解析、校验占用服务器 CPU / 内存,易 OOM;
  2. 校验失败后需要整份文件重传,用户体验差,反复消耗流量;
  3. 并发批量导入场景,大量文件同时解析会压垮接口,频繁触发限流。
前端离线解析优势
  1. 浏览器本地解析,零文件上传,不占用带宽;
  2. 提前在校本地完成格式、字段、业务规则校验,不合格直接提示,无需等待接口响应;
  3. 后端只接收校验通过的干净 JSON 数组,省去解析、过滤、校验耗时,服务器压力大幅降低;
  4. 实时预览表格数据,用户可直接修改错误行再提交,交互更友好。

2. 项目为什么选用 sheetjs (xlsx),不用其他 Excel 库?

  1. 兼容性最强:支持 .xlsx/.xls/.csv,纯浏览器运行,无依赖;
  2. 体积轻量,提供多种构建版本(完整版 / 仅解析版),可按需引入减小打包体积;
  3. API 完善:读取二进制文件、行列遍历、单元格类型识别、自定义表头映射;
  4. 支持大数据分片读取,适配上万条学生信息表格;
  5. 社区成熟,无付费授权限制,后台管理系统导入通用方案。

3. 整体业务流程是什么(学生 Excel 导入完整链路)

  1. input 上传文件,获取 File 二进制对象;
  2. 使用 FileReader 读取文件为 ArrayBuffer;
  3. XLSX.read () 离线解析二进制,得到工作簿 workbook;
  4. 提取第一个工作表 sheet,转换为 JSON 数组;
  5. 自定义规则批量校验每一行学生数据(学号、手机号、身份证、空值、重复学号等);
  6. 区分成功行、错误行,展示错误位置 + 错误提示;
  7. 用户修正或剔除错误数据后,将干净数组传给后端保存; 全程解析、校验都在浏览器完成,仅最终合法数据请求接口。

二、XLSX 库底层核心实现问题(高频深挖)

4. 浏览器如何读取本地 Excel 二进制?FileReader 两种读取方式区别

  1. readAsArrayBuffer(项目使用):读取为二进制缓冲区,适合 XLSX 解析,性能好,支持大文件;
  2. readAsBinaryString:旧方案,存在编码兼容问题,大数据易卡顿,废弃不用。 流程:File → FileReader → ArrayBuffer → XLSX.read (buffer)。

5. sheet_to_json 转换参数怎么配置?关键配置项作用

javascript 复制代码
const jsonData = XLSX.utils.sheet_to_json(sheet, {
  header: 1, // 1=数组格式(无自动映射表头),null=自动用第一行作为key
  defval: "", // 空单元格默认填充空字符串,避免undefined报错
  raw: false, // false:格式化日期/数字;true:返回原始单元格存储值
})

业务场景使用 header: null,自动将 Excel 第一行表头作为对象 key,如 {学号:"2026001",姓名:"张三"},方便字段映射校验。

6. 上万条学生 Excel 一次性解析会不会阻塞主线程?如何优化?

原生 XLSX 同步读取大文件会阻塞 JS 主线程,页面卡死,两种优化方案结合使用:

  1. WebWorker 离线解析:把 ArrayBuffer 传入 worker,子线程完成 Excel 解析,完全不阻塞页面滚动、点击;
  2. 数据分片校验:解析完成后,分批次循环校验学生数据,搭配 requestIdleCallback,避免一次性循环上万条造成卡顿; 项目中文件解析放入 Worker,主线程只做渲染和提示。

7. 如何区分单元格数据类型(数字、日期、空单元格、文本)?

  1. 开启 raw: true 读取原始 cell 对象,识别 cell.t 类型:
    • s:字符串文本
    • n:数字(学号、手机号会被自动转数字,产生丢失前导 0 坑)
    • d:日期
    • b:布尔值
  2. 业务坑点:Excel 里纯数字学号会自动识别为数字,开头 0 丢失,解决方案: 读取时配置单元格格式为文本,或读取后统一转字符串补全前导零。

三、自定义学生数据校验规则核心问题(简历重点)

8. 自定义了哪些学生信息校验规则?如何设计可配置校验规则

内置校验规则
  1. 非空校验:学号、姓名、班级、手机号必填;
  2. 格式正则:手机号、身份证、学号固定位数正则;
  3. 范围校验:年龄、年级数字区间;
  4. 唯一性校验:全表学号不可重复;
  5. 业务联动校验:入学日期不能晚于当前日期;
  6. 表头校验:必须包含指定表头(学号 / 姓名 / 班级),缺少直接拦截。
可配置规则引擎设计

抽离校验规则 JSON 配置,一行对应一个字段:

javascript 复制代码
const rules = [
  { field: "studentId", label: "学号", required: true, pattern: /^\d{8}$/, unique: true },
  { field: "phone", label: "手机号", required: true, pattern: /^1[3-9]\d{9}$/ }
]

通用校验工具循环遍历规则,批量校验每行数据,新增字段只需新增配置,不用改循环逻辑。

9. 校验后如何标记错误,展示给用户定位 Excel 问题?

每条错误存储信息:行号、字段名、错误提示; 页面渲染两栏:

  1. 校验通过列表:预览可直接提交;
  2. 错误数据列表:标红行号、错误字段提示,支持删除错误行、手动修改单元格内容; 行号和 Excel 实际行数对应(表头为第 1 行,数据从第 2 行开始),用户可快速打开原 Excel 修正。

10. 全表学号唯一性怎么校验,上万条数据性能如何?

  1. 解析完成后用 Map 存储已出现学号,循环每行判断是否重复;
  2. 上万条数据放入 WebWorker 执行去重校验,不阻塞主线程;
  3. 重复学号记录对应两行行号,统一展示重复提示。

四、工程化封装、大文件兼容问题

11. 如何封装通用 Excel 导入工具,多页面复用(学生 / 教师 / 批量设备通用)

抽离独立工具模块 src/utils/excelImport.js,对外暴露统一方法:

  1. parseExcel(file, rules):接收文件 + 自定义校验规则,返回 {successList, errorList};
  2. 内部封装 FileReader、Worker 解析、行列转换、通用校验逻辑;
  3. 管理系统所有导入页面直接引入,只传入对应校验规则,无需重复写解析代码。

12. 超大 Excel(5 万 + 学生数据)前端解析内存溢出怎么处理?

优化手段:

  1. WebWorker 解析,主线程不持有二进制大对象;
  2. 解析完成后分块存储,不一次性渲染全部表格;
  3. 不用一次性存储全量错误信息,滚动虚拟列表渲染预览数据;
  4. 解析完成及时销毁 ArrayBuffer、FileReader 实例释放内存。

13. 如何兼容不同 Excel 模板(表头顺序不一致、多余无关列)

  1. 基于表头字段名映射,不依赖列下标;
  2. 自动过滤 Excel 多余无关列,只保留配置规则中存在的字段;
  3. 缺失必填表头直接抛出错误,提示用户使用标准导入模板;
  4. 提供标准模板下载功能,统一用户上传文件格式。

五、边界坑点与异常处理(面试高频加分)

14. 开发中踩过哪些 Excel 解析经典坑,怎么解决?

  1. 坑 1:学号、工号纯数字,Excel 自动去除开头 0 解决:读取单元格原始文本,强制转为字符串,不足 8 位前置补零;

  2. 坑 2:日期读取为数字序列号(Excel 日期底层是数字) 解决:封装日期转换工具,识别 cell.t === 'd',转换为 YYYY-MM-DD 标准格式;

  3. 坑 3:大文件同步解析页面卡死 解决:解析逻辑移入 WebWorker 异步处理;

  4. 坑 4:空单元格读取为 undefined,校验报错 解决:sheet_to_json 配置 defval:"",统一为空字符串;

  5. 坑 5:xls 旧格式文件解析乱码 / 失败 解决:xlsx 库完整版本兼容 xls,统一读取二进制不分后缀;

  6. 坑 6:用户上传后缀伪装的非 Excel 文件 解决:双重校验:文件后缀 + 文件二进制魔数校验,拦截篡改后缀文件。

15. 文件读取、解析、校验三层异常分别怎么捕获?

  1. FileReader 读取异常:文件损坏、文件过大,提示文件损坏;
  2. XLSX 解析异常:非表格文件、加密 Excel,弹窗提示不支持加密文件;
  3. 业务校验异常:收集行错误,不阻断整体流程,区分可修复数据。

16. 加密 / 带密码 Excel 前端能解析吗?

SheetJS 标准版无法解析加密 xlsx,两种处理方案:

  1. 前端直接拦截,提示用户去除文件密码后重新上传;
  2. 加密文件走传统后端解析方案作为兜底降级。

六、前后端协同 & 性能拓展问题

17. 前端校验完成后传给后端的数据结构是什么?后端收益在哪?

前端输出纯净数组,字段统一格式化(学号字符串、标准日期、无空 undefined); 后端收益:

  1. 无需引入 Excel 解析依赖,不用处理二进制文件;
  2. 省去表头匹配、格式校验、去重逻辑,只做数据库批量新增;
  3. 接口请求体体积远小于原 Excel 文件,传输更快;
  4. 避免批量解析占用服务器内存,并发导入压力大幅下降。

18. 如果要求支持后端兜底解析,如何设计降级方案?

双层导入策略:

  1. 优先前端离线解析校验;
  2. 浏览器不支持 FileReader、文件加密、超大文件时,切换传统上传接口,由后端解析; 一套组件同时兼容两种模式,自动判断降级。

19. 除了导入,xlsx 库能否前端导出 Excel?和导入怎么复用工具

可以,XLSX.utils.json_to_sheet 反向将 JSON 数组生成工作表,导出 Blob 下载; 工具模块统一封装 importExcel /exportExcel 两个方法,共用表头映射、字段格式化逻辑,复用校验 / 转换工具函数。

七、完整业务流程口述(面试背诵总结)

用户上传学生信息 Excel 文件,前端通过 FileReader 读取二进制 ArrayBuffer,使用 WebWorker 离线调用 xlsx 库解析工作表;将表格转为标准 JSON 数组,通过可配置校验规则批量校验学号、手机号、空值、重复学号等业务规则;区分错误行与合法数据,页面展示错误位置供用户修改;校验无误后仅将干净 JSON 数组提交后端,全程解析、校验不依赖服务端,减少文件传输与服务器批量解析压力。

相关推荐
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第154题】【06_Spring篇】第14题:Spring 支持的 Bean 作用域
java·开发语言·spring·面试
小林ixn1 小时前
用 100 行代码手搓一个 MCP Server,让 LLM 直接读你本地文件
面试·llm
用户059540174461 小时前
用了半年 LangChain Memory,才发现回滚测试压根没测对
前端·css
木木的木云2 小时前
从零构建微前端框架:PavilionMfe 设计揭秘
前端·架构·vite
wear工程师2 小时前
可重复读能不能防幻读?MVCC 和 Next-Key Lock 到底谁在起作用
mysql·面试
weedsfly2 小时前
Cookie 安全三属性:HttpOnly、Secure、SameSite 分别防什么?
前端·javascript·面试
IT_陈寒2 小时前
SpringBoot自动配置没生效?你可能漏了这个注解
前端·人工智能·后端
碎碎念_4922 小时前
ACL包过滤、NAT技术、广域网协议
服务器·网络·安全·acl·nat
monologues2 小时前
Vue3 底层原理深度解析:从编译到运行的源码之旅
前端