Skip to content

面试题-腾讯 ​

1、你项目中遇到过的难点是怎么处理的? ​

  • 考察思路:能识别核心矛盾、做技术决策、推动协作,并最终产生可量化的结果。
  • 回答思路:准备两个案例:一个体现技术深度,一个体现架构决策和跨团队推动能力。这样更符合“高级开发”的岗位预期。

2、vue2 和 vue3 的区别? ​

响应式原理不同 ​

  • Vue 2 使用 Object.defineProperty 劫持对象属性。

    新增、删除属性无法自动监听,需要 Vue.set、Vue.delete

    数组通过重写部分方法实现监听

    初始化时需要递归遍历数据

js
const data = { name: "Vue 2" };

Object.defineProperty(data, "name", {
  get() {
    console.log("读取 name");
    return this._name;
  },
  set(value) {
    console.log("更新 name:", value);
    this._name = value;
  }
});

data.name = "Vue 2.7";

// 新增属性无法被上述劫持自动监听,需要:
// Vue.set(data, "age", 10)
// Vue.delete(data, "age")
  • Vue 3 使用 Proxy。

    能监听属性新增、删除和数组索引变化

    支持 Map、Set 等数据结构

    按访问需求进行深层代理

    但仍然存在解构响应式对象后丢失响应性的问题,可使用 toRefs

js
const user = {
  name: "小明",
  age: 18
};

const proxy = new Proxy(user, {
  get(target, key) {
    console.log("读取:", key);
    return target[key];
  },

  set(target, key, value) {
    console.log("修改:", key, value);
    target[key] = value;
    return true;
  },

  deleteProperty(target, key) {
    console.log("删除:", key);
    delete target[key];
    return true;
  }
});

console.log(proxy.name); // 读取
proxy.age = 20;          // 修改
proxy.city = "北京";     // 新增
delete proxy.name;       // 删除

API 和代码组织方式不同 ​

  • Vue 2 主要使用 Options API,代码按 data、methods、computed、生命周期等类型组织。

  • Vue 3 增加了 Composition API,可以按照业务功能组织代码,更适合复杂组件和逻辑复用。

生命周期不同 ​

Vue 2Vue 3 Composition API
beforeCreate、created直接在 setup 中执行
beforeMountonBeforeMount
mountedonMounted
beforeDestroyonBeforeUnmount
destroyedonUnmounted

性能和编译优化不同 ​

对比项Vue 2Vue 3
响应式原理Object.defineProperty 监听每个属性Proxy 代理整个对象
初始化递归遍历所有数据,数据多时较慢用到深层数据时再代理,速度更快
新增、删除属性不能自动监听,需要 Vue.set/delete可以自动监听
数组监听重写数组方法,不能直接监听索引变化可以监听数组方法和索引变化
编译优化更新时需要对比较多内容提前标记动态内容,只对比可能变化的部分
虚拟 DOM对比范围较大对比更精准,性能更好
打包体积Tree Shaking 支持较弱支持 Tree Shaking,不需要的功能可以移除
数据结构主要支持对象和数组支持对象、数组、Map、Set 等
浏览器兼容支持 IE不支持 IE
总结Vue 2 先把模板编译成 JavaScript,然后监听数据变化,数据变了就对比虚拟 DOM,只更新页面中需要变化的地方Vue 3 会提前找出页面中可能变化的地方,数据变化后只更新这些地方;同时使用 Proxy 监听数据,比 Vue 2 更灵活、性能也更好

Vue3 新增了一些功能 ​

  • Fragment:组件可以有多个根节点
  • Teleport:把弹窗等内容渲染到指定 DOM
  • Suspense:协调异步组件的加载状态
  • 多个 v-model

TypeScript ​

Vue3 使用 ts 重新,对 ts 的支持性,更高

3、处理性能优化 ​

性能问题常见处理方式
首屏加载慢路由懒加载、拆包、CDN、资源压缩、关键资源预加载
JS 包过大Tree Shaking、按需引入、依赖分析、替换重量级依赖
图片加载慢WebP/AVIF、懒加载、响应式尺寸、CDN
接口等待时间长请求并行、缓存、合并请求、预请求、服务端优化
页面渲染卡顿虚拟列表、减少组件更新、分片执行、Web Worker
输入或滚动卡顿防抖、节流、减少主线程长任务、被动事件监听
页面跳动提前设置图片和容器尺寸,控制异步内容占位
内存持续增长清理事件、定时器、订阅、缓存和组件外部引用

4、微信小程序包体积超了怎么处理,分包后加载失败怎么处理? ​

小程序包体积超限,我会先通过开发者工具的代码依赖分析,查看是图片、第三方依赖还是业务代码占用较大。

优化时,主包只保留首页、TabBar 页面和公共代码,订单、营销等低频业务按模块分包;图片放到 CDN;组件库按需引入;同时清理无用代码、重复依赖和大 JSON 文件。高频访问的分包可以配置预下载。

分包后加载失败,我会先检查分包路径、页面注册和跨包引用。如果是弱网导致下载失败,会用 wx.loadSubpackage 增加加载提示、有限重试和失败兜底:

js
/**
 * 加载小程序分包
 *
 * 简单流程:
 * 1. 调用 wx.loadSubpackage 加载分包
 * 2. 加载成功,执行 resolve
 * 3. 加载失败,判断是否还有重试次数
 * 4. 有重试次数则重新加载
 * 5. 重试结束仍然失败,则抛出错误
 *
 * @param {string} name 分包名称,对应 app.json 中的分包 name
 * @param {number} retry 剩余重试次数,默认重试 2 次
 */
function loadSubpackage(name, retry = 2) {
  return new Promise((resolve, reject) => {
    wx.loadSubpackage({
      name,

      // 加载成功,进入调用处的 then
      success: resolve,

      // 加载失败,进入下面的 catch
      fail: reject
    })
  }).catch(err => {
    // 还有重试次数,重新调用加载方法
    if (retry > 0) {
      return loadSubpackage(name, retry - 1)
    }

    // 重试结束仍然失败,将错误抛给调用处
    throw err
  })
}

/**
 * 使用流程:
 * 1. 加载 packageOrder 分包
 * 2. 加载成功后跳转订单页面
 * 3. 多次加载失败后,提示用户重试
 */
loadSubpackage('packageOrder')
  .then(() => {
    // 分包加载成功,跳转到对应页面
    wx.navigateTo({
      url: '/packageOrder/pages/list/index'
    })
  })
  .catch(err => {
    // 分包重试后仍然加载失败
    console.error('订单分包加载失败:', err)

    // 给用户显示失败提示
    wx.showToast({
      title: '加载失败,请重试',
      icon: 'none'
    })
  })

另外,我会通过 App.onError 和 App.onUnhandledRejection 上报异常,区分是分包下载失败,还是分包下载成功后页面运行报错。最后结合网络类型、基础库版本和小程序版本定位问题。

5、线上项目,在部分用户的手机/电脑上白屏了,怎么处理? ​

排查方向 ​

浏览器兼容问题:某些旧手机或浏览器不支持新语法,检查 Babel、Polyfill 和浏览器兼容配置。

缓存和版本问题:用户缓存了旧 HTML,但引用的旧 Chunk 已经被删除,导致 JS 404。

资源或网络问题:CDN、DNS、弱网、运营商或公司代理导致 JS 加载失败。

特定数据问题:部分账号返回的数据为空或格式异常,前端没有判空,导致运行时报错。

监控代码 ​

js
// 监听普通 JS 报错和图片、JS、CSS 等资源加载失败
window.addEventListener(
  'error',
  event => {
    // event.target 是具体标签时,表示资源加载失败
    const isResourceError = event.target !== window

    reportError({
      // 错误类型:资源错误或 JS 错误
      type: isResourceError ? '资源加载错误' : 'JS运行错误',

      // 错误信息
      message: event.message || '资源加载失败',

      // 当前页面地址
      url: location.href,

      // 用户设备及浏览器信息
      userAgent: navigator.userAgent
    })
  },
  true // 使用捕获模式,才能监听资源加载错误
)

// 监听没有被 catch 处理的 Promise 错误
window.addEventListener('unhandledrejection', event => {
  reportError({
    type: 'Promise错误',

    // Promise 抛出的错误信息
    message: String(event.reason),

    // 当前页面地址
    url: location.href,

    // 用户设备及浏览器信息
    userAgent: navigator.userAgent
  })
})

其中 reportError 是项目中封装的错误上报方法,例如:

js
// 将错误信息发送到监控服务
function reportError(error) {
  console.error('错误信息:', error)

  // 实际项目中可以通过接口上报
  // fetch('/api/report-error', {
  //   method: 'POST',
  //   body: JSON.stringify(error)
  // })
}

6、实现一个swiper组件,实现细节 ​

实现 Swiper 组件,我会先拆成几个核心能力:容器布局、当前索引、手势滑动、边界处理、自动播放、指示器和异常场景。

核心实现思路 ​

  • 外层容器设置 overflow: hidden,内层轨道使用 display: flex 横向排列。
  • 通过 transform: translateX(-index * width) 切换当前页。
  • 监听 touchstart、touchmove、touchend,根据滑动距离和速度判断是否切换。
  • 自动播放用定时器,组件销毁时清理。
  • 无限轮播可以在首尾各复制一张,过渡结束后无动画切回真实位置。

简单版本 ​

html
<div class="swiper" id="swiper"> <!-- 轮播外层容器,JS 用 id 获取,CSS 用 class 设置样式 -->
  <div class="swiper-track"> <!-- 轮播轨道,后续通过 translateX 控制移动 -->
    <div class="swiper-item">1</div> <!-- 第 1 个轮播项,实际项目中可放图片或内容 -->
    <div class="swiper-item">2</div> <!-- 创建第 2 个轮播项,每一项宽度都会占满整个 swiper 容器 -->
    <div class="swiper-item">3</div> <!-- 创建第 3 个轮播项,用来演示多页横向滑动效果 -->
  </div> <!-- 关闭轮播轨道容器 -->
</div> <!-- 关闭轮播图最外层容器 -->
css
.swiper { /* 选中轮播图外层容器 */
  width: 300px; /* 设置轮播容器宽度,后续滑动距离会基于这个宽度计算 */
  height: 180px; /* 设置轮播容器高度,保证轮播区域有固定展示空间 */
  overflow: hidden; /* 隐藏容器外的轮播项,只展示当前可视区域 */
} /* 结束轮播外层容器样式 */

.swiper-track { /* 选中轮播轨道,也就是横向移动的那一层 */
  display: flex; /* 使用 flex 横向排列每一个轮播项 */
  height: 100%; /* 让轨道高度继承外层容器高度 */
  transition: transform 0.3s; /* 给 transform 位移添加 0.3 秒过渡动画 */
} /* 结束轮播轨道样式 */

.swiper-item { /* 选中单个轮播项 */
  flex: 0 0 100%; /* 每个轮播项固定占容器 100% 宽度,不压缩也不放大 */
  height: 100%; /* 让每个轮播项高度占满外层容器 */
} /* 结束单个轮播项样式 */
js
const swiper = document.querySelector('#swiper') // 获取轮播容器,用来监听手势和计算宽度
const track = swiper.querySelector('.swiper-track') // 获取轮播轨道,通过 transform 实现滑动
const items = swiper.querySelectorAll('.swiper-item') // 获取所有轮播项,用来判断总页数和边界

let index = 0 // 记录当前展示的是第几张,从 0 开始计算
let startX = 0 // 记录手指按下时的横向坐标,用于计算滑动距离
let currentX = 0 // 记录手指移动过程中的横向坐标,用于实时跟随手指移动
let width = swiper.clientWidth // 记录轮播容器宽度,切换一页时需要移动这个距离

function setTransform(offset, animation = true) { // 设置轨道位移,animation 控制是否开启动画
  track.style.transition = animation ? 'transform 0.3s' : 'none' // 控制滑动是否有过渡效果
  track.style.transform = `translateX(${offset}px)` // 通过 translateX 改变轨道横向位置,实现轮播切换
} // 结束 setTransform 方法

function goTo(nextIndex) { // 封装跳转到指定索引的方法,避免每次都重复边界判断和位移计算
  index = Math.max(0, Math.min(nextIndex, items.length - 1)) // 限制索引在合法范围内
  setTransform(-index * width) // 根据当前索引计算轨道偏移量,负数表示向左移动
} // 结束 goTo 方法

swiper.addEventListener('touchstart', event => { // 监听手指按下事件,作为一次滑动的开始
  startX = event.touches[0].clientX // 记录手指刚按下时的横坐标
  currentX = startX // 初始化当前横坐标,避免 touchmove 没触发时 touchend 计算异常
  width = swiper.clientWidth // 重新获取容器宽度,兼容屏幕旋转或容器尺寸变化
  setTransform(-index * width, false) // 按下时取消过渡动画,保证手指拖动时能实时跟随
}) // 结束 touchstart 监听

swiper.addEventListener('touchmove', event => { // 监听手指移动事件,让轮播轨道跟随手指移动
  currentX = event.touches[0].clientX // 记录手指移动到的最新横坐标
  const diff = currentX - startX // 计算本次滑动距离,正数表示右滑,负数表示左滑
  setTransform(-index * width + diff, false) // 当前页基础偏移量加上手指滑动距离,实现拖拽效果
}) // 结束 touchmove 监听

swiper.addEventListener('touchend', () => { // 监听手指松开事件,根据滑动距离决定是否切换页面
  const diff = currentX - startX // 计算手指从按下到松开的总滑动距离
  const shouldMove = Math.abs(diff) > width / 4 // 超过容器四分之一才切换

  if (shouldMove && diff < 0) { // 如果滑动距离达标并且是左滑,说明要看下一张
    goTo(index + 1) // 当前索引加 1,切换到下一页
  } else if (shouldMove && diff > 0) { // 如果滑动距离达标并且是右滑,说明要看上一张
    goTo(index - 1) // 当前索引减 1,切换到上一页
  } else { // 如果滑动距离不够,说明用户只是轻微拖动
    goTo(index) // 回到当前页,避免轮播停在半路
  } // 结束切换方向判断
}) // 结束 touchend 监听

实际项目里还要处理窗口 resize、图片懒加载、自动播放暂停、快速连续滑动、防止滚动冲突和可访问性。

7、vue2 和 vue3 的数据劫持 ​

Vue 2 和 Vue 3 的核心区别是:Vue 2 劫持对象属性,Vue 3 代理整个对象。

Vue 2 ​

Vue 2 使用 Object.defineProperty 给每个属性设置 get 和 set。

js
function defineReactive(obj, key, value) { // 简化版响应式方法,监听 obj[key]
  Object.defineProperty(obj, key, { // 使用 Object.defineProperty 劫持对象上的指定属性
    get() { // 当读取 obj[key] 时会触发 get,用于依赖收集
      console.log('收集依赖') // 模拟 Vue 在读取数据时收集当前组件或 watcher
      return value // 返回闭包中保存的真实属性值
    }, // 结束 get 方法
    set(newValue) { // 当修改 obj[key] 时会触发 set,用于派发更新
      if (newValue === value) return // 如果新旧值相同,直接返回,避免重复更新
      value = newValue // 更新闭包中保存的真实值
      console.log('通知更新') // 模拟 Vue 在数据变化后通知依赖重新渲染
    } // 结束 set 方法
  }) // 结束 Object.defineProperty 配置
} // 结束 defineReactive 方法

缺点:

  • 初始化时需要递归遍历所有属性。
  • 新增和删除属性监听不到,需要 Vue.set、Vue.delete。
  • 数组索引和长度变化监听不完整,所以 Vue 2 重写了数组的部分方法。

Vue 3 ​

Vue 3 使用 Proxy 代理对象本身。

js
function reactive(target) { // 定义一个简化版 reactive 方法,用来把普通对象包装成响应式对象
  return new Proxy(target, { // 使用 Proxy 代理整个对象,而不是只劫持某一个属性
    get(target, key, receiver) { // 当读取代理对象属性时触发 get
      console.log('收集依赖') // 模拟 Vue 3 在读取属性时进行依赖收集
      return Reflect.get(target, key, receiver) // 使用 Reflect 保持默认读取行为,并正确处理 this 指向
    }, // 结束 get 捕获器
    set(target, key, value, receiver) { // 当新增或修改代理对象属性时触发 set
      const result = Reflect.set(target, key, value, receiver) // 用 Reflect 完成赋值
      console.log('通知更新') // 模拟 Vue 3 在属性变化后通知相关副作用重新执行
      return result // 返回赋值结果,Proxy 的 set 必须返回布尔值表示是否成功
    }, // 结束 set 捕获器
    deleteProperty(target, key) { // 当删除代理对象属性时触发 deleteProperty
      const result = Reflect.deleteProperty(target, key) // 使用 Reflect 执行默认删除逻辑
      console.log('通知更新') // 模拟 Vue 3 在删除属性后通知视图更新
      return result // 返回删除结果,表示属性是否删除成功
    } // 结束 deleteProperty 捕获器
  }) // 结束 Proxy 配置并返回代理对象
} // 结束 reactive 方法

优势:

  • 可以监听新增、删除、数组索引和 length。
  • 支持 Map、Set 等复杂数据结构。
  • 深层对象可以在访问时再代理,初始化成本更低。

8、让你实现一个没有做过的功能,你会怎么处理? ​

我一般不会直接上手写,而是先把不确定性拆开。

  1. 先确认业务目标:这个功能解决什么问题,成功标准是什么,是否有类似竞品或历史方案。
  2. 拆需求边界:输入是什么,输出是什么,异常情况有哪些,哪些必须做,哪些可以后置。
  3. 做技术调研:查官方文档、成熟方案、项目里是否已有类似实现,避免重复造轮子。
  4. 做最小 Demo:先验证核心链路,比如接口是否可行、浏览器能力是否支持、性能是否能接受。
  5. 设计实现方案:拆组件、接口、状态、异常兜底、埋点监控和灰度方案。
  6. 开发和自测:覆盖主流程、异常流程、兼容性和边界数据。
  7. 上线后观察:看错误日志、性能指标、用户行为和业务数据。

如果时间紧,我会先保证核心链路可用,再把体验优化和边缘能力分阶段补齐。

9、宏任务 微任务 有哪些? ​

JavaScript 事件循环里,一轮同步代码执行完成后,会先清空微任务队列,再执行下一个宏任务。

常见宏任务 ​

  • script 整体代码
  • setTimeout
  • setInterval
  • setImmediate,主要是 Node 环境
  • MessageChannel
  • I/O 回调
  • UI 渲染相关任务
  • DOM 事件回调

常见微任务 ​

  • Promise.then、catch、finally
  • queueMicrotask
  • MutationObserver
  • Node 中的 process.nextTick

执行顺序示例 ​

js
console.log('start') // 同步代码立即执行,所以第一个输出 start

setTimeout(() => { // 注册一个宏任务,等同步代码和本轮微任务执行完后才会执行
  console.log('timeout') // 宏任务执行时输出 timeout
}) // 结束 setTimeout 注册

Promise.resolve().then(() => { // 注册一个微任务,当前同步代码执行完后会优先执行
  console.log('promise') // 微任务执行时输出 promise
}) // 结束 Promise.then 注册

console.log('end') // 同步代码继续执行,所以第二个输出 end

// start // 第一行同步输出结果
// end // 第二行同步输出结果
// promise // 同步代码结束后,微任务队列先执行
// timeout // 微任务清空后,才执行下一个宏任务

10、vue3 中的 nextTick? ​

Vue 的 DOM 更新不是同步执行的。数据变化后,Vue 会把组件更新任务放到队列里,等当前同步代码执行完,再统一刷新 DOM。

nextTick 的作用是:等待这次响应式数据更新引起的 DOM 渲染完成后,再执行回调。

js
import { ref, nextTick } from 'vue' // 引入 ref 和 nextTick

const count = ref(0) // 创建响应式计数器,初始值是 0
const titleRef = ref(null) // 创建 DOM 引用,模板中绑定 ref 后可以拿到真实 DOM 节点

async function add() { // 定义一个异步方法,用来演示数据更新后读取 DOM
  count.value++ // 修改响应式数据,Vue 会把组件更新任务放入异步队列

  // 此时 DOM 可能还没更新 // 因为 Vue 会批量合并更新,不会每次改数据都立刻刷新 DOM
  await nextTick() // 等待本轮响应式数据引起的 DOM 更新完成

  // 这里可以拿到更新后的 DOM // nextTick 之后再读 DOM,拿到的是最新渲染结果
  console.log(titleRef.value.textContent) // 输出更新后的 DOM 文本内容
} // 结束 add 方法

常见使用场景:

  • 修改数据后立刻读取 DOM 尺寸。
  • 列表更新后滚动到底部。
  • 弹窗显示后调用组件实例方法。
  • 表单渲染后自动聚焦。

Vue 3 内部优先使用微任务实现,例如 Promise.then,这样可以在一次事件循环内尽快完成批量更新。

11、怎么处理客户白屏的问题,客户不能帮你进行远程调试? ​

不能远程调试时,关键是让线上环境自己把信息带回来。

处理流程 ​

  1. 先收集用户信息:账号、页面地址、操作路径、设备型号、系统版本、浏览器版本、网络环境、出现时间。
  2. 看监控平台:检查 JS 报错、资源加载失败、接口异常、白屏检测截图或页面 DOM 状态。
  3. 看发布记录:确认是否刚发过版,是否存在 HTML 缓存旧版本、Chunk 404、CDN 没同步等问题。
  4. 做灰度和回滚:如果影响范围扩大,先回滚或切到稳定资源。
  5. 给用户兜底:提示刷新、清缓存、切网络、升级浏览器,但不能只依赖用户操作。

可以加一个白屏检测 ​

js
function checkWhiteScreen() { // 定义白屏检测函数,用来判断页面关键位置是否都没有业务内容
  const points = [ // 定义多个采样点,避免只检测一个点导致误判
    [window.innerWidth / 2, window.innerHeight / 2], // 采样屏幕中心点,通常这里应该有主要内容
    [window.innerWidth / 2, window.innerHeight / 4], // 采样屏幕上方区域
    [window.innerWidth / 2, window.innerHeight * 3 / 4] // 采样屏幕下方区域
  ] // 结束采样点数组

  const emptyTags = ['HTML', 'BODY', 'APP', 'ROOT'] // 空白容器标签集合
  const isWhite = points.every(([x, y]) => { // 所有采样点都空,才认为疑似白屏
    const element = document.elementFromPoint(x, y) // 获取当前坐标点最上层的 DOM 元素
    return element && emptyTags.includes(element.tagName) // 判断该元素是否属于空白容器标签
  }) // 结束白屏判断

  if (isWhite) { // 如果多个采样点都命中空白标签,说明页面大概率白屏
    reportError({ // 调用项目里的错误上报方法,把白屏信息传给监控系统
      type: '白屏', // 标记错误类型,方便监控平台按白屏问题聚合
      url: location.href, // 上报当前页面地址,方便定位具体白屏页面
      userAgent: navigator.userAgent, // 上报浏览器和设备信息,方便分析兼容性问题
      html: document.body.innerHTML.slice(0, 500) // 截取部分 HTML,辅助排查
    }) // 结束上报数据对象
  } // 结束白屏条件判断
} // 结束 checkWhiteScreen 函数

setTimeout(checkWhiteScreen, 3000) // 页面加载 3 秒后再检测,避免首屏接口稍慢时误报白屏

实际项目中我会结合 sourcemap、用户行为日志、接口日志和资源加载日志定位。如果是版本缓存问题,会调整 HTML 不强缓存,静态资源使用 hash 文件名并保留历史 Chunk 一段时间。

12、SSE 和 WebSocket 协议 ​

SSE 和 WebSocket 都可以做实时通信,但适用场景不一样。

对比项SSEWebSocket
全称Server-Sent EventsWebSocket
通信方向服务端主动推给客户端,单向客户端和服务端双向通信
协议基础HTTP先 HTTP 握手,再升级协议
数据格式文本流文本或二进制
自动重连浏览器原生支持需要自己实现
使用复杂度简单相对复杂
适合场景消息通知、日志流、AI 流式输出、行情展示聊天、协同编辑、游戏、实时互动

SSE 示例 ​

js
const source = new EventSource('/api/events') // 创建 SSE 连接

source.onmessage = event => { // 监听服务端推送的普通 message 事件
  console.log('收到消息:', event.data) // 读取服务端推送的数据,event.data 默认是字符串
} // 结束消息监听

source.onerror = () => { // 监听 SSE 连接异常事件
  console.log('连接异常,浏览器会自动重连') // SSE 原生支持自动重连
} // 结束异常监听

WebSocket 示例 ​

js
const socket = new WebSocket('wss://example.com/ws') // 创建 WebSocket 连接,wss 表示加密的 WebSocket 协议

socket.onopen = () => { // 监听连接成功事件,只有连接建立后才能发送消息
  socket.send(JSON.stringify({ type: 'ping' })) // 发送 JSON 消息,可用于心跳
} // 结束连接成功监听

socket.onmessage = event => { // 监听服务端通过 WebSocket 推送过来的消息
  console.log('收到消息:', event.data) // 输出消息,实际项目中可按 type 分发
} // 结束消息监听

如果只是服务端持续推送,比如大模型返回流式内容,我会优先考虑 SSE;如果需要双方频繁实时交互,就选 WebSocket。

13、微前端原理,为什么要选用微前端? ​

微前端的核心思想是:把一个大型前端应用拆成多个可以独立开发、独立构建、独立部署的子应用,再由一个主应用统一加载和管理。

实现原理 ​

  • 路由分发:主应用根据路径决定加载哪个子应用。
  • 资源加载:动态加载子应用的 JS、CSS。
  • 应用隔离:避免样式污染、全局变量冲突和运行时冲突。
  • 生命周期管理:子应用提供 bootstrap、mount、unmount 等方法。
  • 通信机制:通过 props、事件总线、全局状态或 URL 参数通信。

为什么使用 ​

  • 老项目和新项目可以渐进式迁移。
  • 多团队可以并行开发,减少互相阻塞。
  • 子应用可以独立发版,降低单次发布风险。
  • 技术栈可以不完全一致,比如主应用 Vue,部分子应用 React。
  • 大型后台系统可以按业务域拆分,降低维护成本。

需要注意的问题 ​

微前端不是所有项目都适合。如果项目规模不大、团队不多、发布节奏一致,引入微前端反而会增加复杂度。真正要关注的是样式隔离、公共依赖复用、权限统一、路由同步、部署运维和性能问题。

14、小程序怎么实现动画,帧动画怎么实现? ​

小程序常见动画有三种方式:CSS 动画、wx.createAnimation 和帧动画。

CSS 动画 ​

适合简单的旋转、渐隐渐现、位移。

css
.loading { /* 选中需要执行动画的元素,比如加载图标 */
  animation: rotate 1s linear infinite; /* 使用 rotate 动画,1 秒一圈,匀速执行,并且无限循环 */
} /* 结束 loading 样式 */

@keyframes rotate { /* 定义名为 rotate 的关键帧动画 */
  from { /* 动画起始状态 */
    transform: rotate(0deg); /* 起始角度为 0 度 */
  } /* 结束起始状态 */
  to { /* 动画结束状态 */
    transform: rotate(360deg); /* 结束角度为 360 度,形成完整旋转 */
  } /* 结束结束状态 */
} /* 结束 rotate 关键帧定义 */

wx.createAnimation ​

适合由 JS 控制开始、结束、组合动作的动画。

js
const animation = wx.createAnimation({ // 创建小程序动画实例,用来描述接下来要执行的动画
  duration: 300, // 设置动画持续时间为 300 毫秒
  timingFunction: 'ease' // 设置动画缓动函数为 ease,让动画开始和结束更自然
}) // 结束动画实例配置

animation.translateX(100).opacity(0.5).step() // 定义一步动画:向右移动 100px,并把透明度变为 0.5

this.setData({ // 通过 setData 把动画数据传给 WXML 中绑定 animation 的元素
  animationData: animation.export() // 导出动画配置,供小程序视图层执行动画
}) // 结束 setData 调用

帧动画 ​

帧动画本质是按一定时间切换图片,适合复杂动效,比如人物动作、礼物特效。

js
Page({ // 注册一个小程序页面
  data: { // 定义页面响应式数据,setData 修改后会驱动视图更新
    frameIndex: 0, // 当前展示的帧图片索引,初始显示第 0 帧
    frames: [ // 保存所有帧图片路径,按顺序切换就能形成动画
      '/images/run-1.png', // 第 1 帧图片路径
      '/images/run-2.png', // 第 2 帧图片路径
      '/images/run-3.png' // 第 3 帧图片路径
    ] // 结束帧图片数组
  }, // 结束 data 配置

  startFrameAnimation() { // 定义开始帧动画的方法
    this.timer = setInterval(() => { // 创建定时器,按固定时间间隔切换帧图片
      const nextIndex = (this.data.frameIndex + 1) % this.data.frames.length // 计算下一帧,取模实现循环
      this.setData({ // 更新页面数据,让 WXML 中绑定的图片地址跟着变化
        frameIndex: nextIndex // 把当前帧索引更新为下一帧索引
      }) // 结束 setData 调用
    }, 80) // 每 80 毫秒切换一次图片,数值越小动画越快
  }, // 结束 startFrameAnimation 方法

  onUnload() { // 页面卸载生命周期,用户离开页面时会触发
    clearInterval(this.timer) // 清理定时器,避免页面销毁后定时器继续运行造成内存泄漏
  } // 结束 onUnload 生命周期
}) // 结束 Page 配置

实际项目中,如果帧图很多,我会先合成雪碧图,或者使用 Canvas 绘制,减少图片请求和频繁切换带来的性能问题。

15、图片预加载怎么实现? ​

图片预加载就是提前把图片加载到浏览器缓存里,真正展示时就可以更快显示。

Web 实现 ​

js
function preloadImages(urls) { // 定义批量预加载图片的方法,参数 urls 是图片地址数组
  return Promise.all( // 使用 Promise.all 等待所有图片都加载完成
    urls.map(url => { // 遍历每一个图片地址,为每张图片创建一个加载任务
      return new Promise((resolve, reject) => { // 把单张图片加载过程包装成 Promise
        const img = new Image() // 创建一个 Image 对象,赋值 src 后浏览器会开始下载图片

        img.onload = () => resolve(url) // 图片加载成功时,把当前图片地址作为成功结果返回
        img.onerror = reject // 图片加载失败时,把错误交给 reject,方便调用方统一处理
        img.src = url // 设置图片地址,触发浏览器开始请求图片资源
      }) // 结束单张图片 Promise
    }) // 结束图片地址遍历
  ) // 结束 Promise.all,返回所有图片加载结果
} // 结束 preloadImages 方法

preloadImages([ // 调用图片预加载方法,传入需要提前加载的图片列表
  '/images/banner-1.png', // 预加载第一张轮播图
  '/images/banner-2.png' // 预加载第二张轮播图
]).then(() => { // 所有图片都加载成功后执行 then 回调
  console.log('图片预加载完成') // 图片都加载后输出日志
}) // 结束 then 回调

小程序实现 ​

小程序里可以用隐藏的 image 组件预加载,也可以用 wx.getImageInfo。

js
function preloadImage(src) { // 定义小程序单张图片预加载方法,参数 src 是图片地址
  return new Promise((resolve, reject) => { // 使用 Promise 封装 wx.getImageInfo,方便配合 async/await 使用
    wx.getImageInfo({ // 调用小程序 API 获取图片信息,同时会触发图片下载和缓存
      src, // 指定要预加载的图片地址
      success: resolve, // 图片加载成功时执行 resolve,并返回图片宽高等信息
      fail: reject // 图片加载失败时执行 reject,方便调用方捕获异常
    }) // 结束 wx.getImageInfo 调用
  }) // 结束 Promise 封装
} // 结束 preloadImage 方法

需要注意不要一次预加载太多图片,否则会影响首屏和占用内存。一般会优先预加载首屏、下一屏、轮播图下一张。

16、小程序怎么实现虚拟列表? ​

虚拟列表的目标是:数据很多,但页面只渲染可视区域附近的数据,减少节点数量。

实现思路 ​

  • 每一项高度固定时,计算会更简单。
  • 根据 scrollTop 算出开始索引和结束索引。
  • 只渲染这个范围内的数据。
  • 外层用一个总高度撑开滚动区域。
  • 内层列表用 transform: translateY 移动到正确位置。
js
Page({ // 注册小程序页面,用来演示固定高度虚拟列表
  data: { // 定义页面数据,setData 后会驱动视图层更新
    list: [], // 保存完整列表数据,真实项目中通常来自接口
    visibleList: [], // 保存当前需要渲染的可视区域数据,避免一次性渲染全部节点
    itemHeight: 60, // 每一项固定高度为 60px,固定高度可以简化滚动位置计算
    startIndex: 0, // 当前可视列表对应完整列表中的起始索引
    offsetY: 0, // 当前可视列表需要向下偏移的距离,用来对齐真实滚动位置
    totalHeight: 0 // 完整列表的总高度,用来撑开 scroll-view 的滚动条
  }, // 结束 data 配置

  onLoad() { // 页面加载生命周期,适合初始化列表数据
    const list = Array.from({ length: 10000 }, (_, index) => ({ // 创建 10000 条模拟数据
      id: index, // 设置每一项唯一 id,作为 wx:key 使用
      name: `第 ${index} 条` // 设置每一项展示文案
    })) // 结束模拟数据创建

    this.setData({ // 把完整列表和总高度写入页面数据
      list, // 保存完整列表,后续滚动时会从这里截取可视数据
      totalHeight: list.length * this.data.itemHeight // 通过数据长度乘以单项高度计算完整列表总高度
    }) // 结束 setData 调用

    this.updateVisibleList(0) // 初始化首屏可视数据,scrollTop 为 0 表示列表顶部
  }, // 结束 onLoad 生命周期

  onScroll(event) { // 监听 scroll-view 滚动事件
    this.updateVisibleList(event.detail.scrollTop) // 根据当前滚动距离更新可视列表范围
  }, // 结束 onScroll 方法

  updateVisibleList(scrollTop) { // 定义更新可视列表的方法,参数 scrollTop 是当前滚动距离
    const { list, itemHeight } = this.data // 从页面数据中取出完整列表和单项高度
    const screenHeight = wx.getSystemInfoSync().windowHeight // 获取屏幕高度
    const buffer = 5 // 设置缓冲条数,提前多渲染几条,减少滚动时白边
    const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer) // 计算起始索引
    const visibleCount = Math.ceil(screenHeight / itemHeight) + buffer * 2 // 计算渲染条数
    const endIndex = Math.min(list.length, startIndex + visibleCount) // 计算结束索引

    this.setData({ // 更新视图需要的数据
      startIndex, // 保存当前起始索引,方便调试或后续扩展
      offsetY: startIndex * itemHeight, // 根据起始索引计算可视列表整体偏移距离
      visibleList: list.slice(startIndex, endIndex) // 从完整列表中截取当前真正需要渲染的数据
    }) // 结束 setData 调用
  } // 结束 updateVisibleList 方法
}) // 结束 Page 配置
html
<scroll-view scroll-y bindscroll="onScroll" style="height: 100vh;"> <!-- 滚动容器,绑定 onScroll -->
  <view style="height: {{totalHeight}}px; position: relative;"> <!-- 占位容器,撑开滚动条 -->
    <view style="transform: translateY({{offsetY}}px);"> <!-- 可视列表偏移到真实位置 -->
      <view wx:for="{{visibleList}}" wx:key="id" style="height: 60px;"> <!-- 只渲染可视数据 -->
        {{item.name}} <!-- 渲染当前列表项的名称 -->
      </view> <!-- 关闭单个列表项节点 -->
    </view> <!-- 关闭可视列表容器 -->
  </view> <!-- 关闭总高度占位容器 -->
</scroll-view> <!-- 关闭滚动容器 -->

如果每一项高度不固定,就需要缓存每项高度,通过二分查找定位当前滚动位置,复杂度会更高。

17、全局状态管理 Pinia 相较于 Vuex 有什么优势? ​

这里应该是 Pinia。相比 Vuex,Pinia 更适合 Vue 3,写法更轻量。

对比项VuexPinia
写法state、mutations、actions、gettersstate、actions、getters
修改状态通常通过 commit mutation可以直接修改 state
TypeScript类型推导相对麻烦类型推导更自然
模块化modules 嵌套较多每个 store 天然独立
Composition API支持一般更贴合
体积和心智负担较重更轻

Pinia 没有 mutation,同步和异步逻辑都可以写在 actions 里。

js
import { defineStore } from 'pinia' // 从 Pinia 中引入 defineStore,用来创建一个独立的 store

export const useUserStore = defineStore('user', { // 定义 user 模块的 store,组件中通过 useUserStore 使用
  state: () => ({ // 定义状态数据,使用函数返回对象可以避免多实例状态污染
    token: '', // 保存用户登录 token,初始值为空字符串
    userInfo: null // 保存用户信息,未登录或未获取时为 null
  }), // 结束 state 定义

  actions: { // 定义修改状态或处理业务逻辑的方法
    setToken(token) { // 定义设置 token 的 action,参数 token 是新的登录凭证
      this.token = token // 直接修改当前 store 的 token,Pinia 支持这种直观写法
    } // 结束 setToken 方法
  } // 结束 actions 配置
}) // 结束 user store 定义

面试里可以总结为:Pinia 减少了模板代码,模块拆分更清晰,类型推导更好,更符合 Vue 3 的组合式写法。

18、我的项目状态管理很简单我不想用三方库实现,给我一个全局状态管理的方案 ​

如果项目状态很简单,可以使用 Vue 3 自带的 reactive 和 provide/inject 实现一个轻量全局状态。

js
// store.js // 当前文件用于封装一个不依赖第三方库的轻量全局状态
import { reactive, readonly } from 'vue' // 引入响应式和只读 API

const state = reactive({ // 创建全局响应式状态对象,所有组件共享同一份数据
  token: '', // 保存用户登录 token,初始值为空字符串
  userInfo: null // 保存用户信息,初始值为 null 表示还没有用户数据
}) // 结束全局状态定义

function setToken(token) { // 定义修改 token 的方法,统一收口状态修改入口
  state.token = token // 更新全局状态里的 token
} // 结束 setToken 方法

function setUserInfo(userInfo) { // 定义修改用户信息的方法,参数是新的用户信息对象
  state.userInfo = userInfo // 更新全局状态里的 userInfo
} // 结束 setUserInfo 方法

export function useStore() { // 导出 useStore 方法,组件调用它获取状态和修改方法
  return { // 返回状态和操作方法
    state: readonly(state), // 暴露只读状态,避免组件绕过方法直接改数据
    setToken, // 暴露 setToken 方法,供组件更新 token
    setUserInfo // 暴露 setUserInfo 方法,供组件更新用户信息
  } // 结束返回对象
} // 结束 useStore 方法

组件中使用:

js
import { useStore } from './store' // 引入前面封装好的轻量全局状态方法

const { state, setToken } = useStore() // 获取只读状态 state 和修改 token 的方法 setToken

setToken('xxx') // 调用统一的方法修改全局 token
console.log(state.token) // 输出当前全局 token,因为 state 是响应式的,所以组件中也会自动更新

如果希望更规范,可以在入口使用 provide。

js
// main.js // 项目入口文件,适合做全局注入
import { createApp } from 'vue' // 引入 createApp,用来创建 Vue 应用实例
import App from './App.vue' // 引入根组件 App
import { useStore } from './store' // 引入轻量全局状态方法

const app = createApp(App) // 创建 Vue 应用实例

app.provide('store', useStore()) // 通过 provide 把 store 注入到整个应用,子孙组件都可以 inject 获取
app.mount('#app') // 把 Vue 应用挂载到 id 为 app 的 DOM 节点上
js
// 组件中 // 这里演示子组件如何拿到 main.js 中 provide 的 store
import { inject } from 'vue' // 引入 inject,用来接收上层 provide 的数据

const store = inject('store') // 通过 key 获取全局 store,之后就可以使用 store.state 和 store.setToken

这种方案适合用户信息、主题、权限等简单状态。如果状态复杂、需要插件、持久化、调试工具和模块拆分,再考虑 Pinia。

19、vue3中解构赋值和...展开操作都会破坏数据响应式,怎么避免 ​

Vue 3 的 reactive 返回的是 Proxy。直接解构或展开时,拿到的是普通值,不再经过 Proxy 的 get/set,所以会丢失响应式。

错误写法 ​

js
const state = reactive({ // 使用 reactive 创建一个响应式对象
  name: '小明', // 定义响应式属性 name,初始值是小明
  age: 18 // 定义响应式属性 age,初始值是 18
}) // 结束响应式对象定义

const { name } = state // 直接解构 reactive 对象,会把 name 变成普通变量

state.name = '小红' // 修改响应式对象里的 name,视图会更新,但普通变量 name 不会跟着变
console.log(name) // 输出解构时保存的旧值,所以结果还是小明

使用 toRefs ​

js
import { reactive, toRefs } from 'vue' // 引入 reactive 和 toRefs

const state = reactive({ // 创建响应式状态对象
  name: '小明', // 定义 name 属性,初始值是小明
  age: 18 // 定义 age 属性,初始值是 18
}) // 结束响应式状态定义

const { name, age } = toRefs(state) // 把 reactive 对象的每个属性转换成 ref,再进行解构

state.name = '小红' // 修改原始响应式对象中的 name
console.log(name.value) // 通过 ref 的 value 读取最新值,所以输出小红

使用 computed ​

js
import { computed } from 'vue' // 引入 computed,用来创建基于响应式数据的计算值

const userName = computed(() => state.name) // 创建计算属性 userName,state.name 变化时它会自动更新

模板中可以直接使用 ​

如果只是模板展示,不一定要解构,直接使用 state.name 更简单。

html
<template> <!-- Vue 模板根标签,用来包裹当前组件的模板内容 -->
  <div>{{ state.name }}</div> <!-- 直接在模板中读取 state.name,Vue 会自动追踪依赖并保持响应式 -->
</template> <!-- 关闭模板根标签 -->

总结:要解构响应式对象,用 toRef 或 toRefs;要派生数据,用 computed;不要随意用 ...state 复制响应式对象。

20、watch和watchEffect实现原理有什么不同。什么情况下你会选用watchEffect,如果用async/await包装了watchEffect的执行函数可以吗?会有问题吗? ​

watch 和 watchEffect 底层都依赖 Vue 的响应式副作用系统,本质都是依赖收集和触发更新,但依赖来源不同。

不同点 ​

对比项watchwatchEffect
依赖来源显式指定监听对象自动收集回调中用到的响应式数据
是否立即执行默认不立即执行,可配置 immediate默认立即执行
新旧值可以拿到 newValue 和 oldValue不直接提供新旧值
适合场景精准监听某个数据变化多个依赖共同触发一个副作用

什么时候用 watchEffect ​

当副作用依赖多个响应式数据,而且不关心新旧值时,我会使用 watchEffect。

js
watchEffect(() => { // 创建 watchEffect,回调会立即执行,并自动收集内部读取到的响应式依赖
  console.log(keyword.value, page.value, pageSize.value) // 读取依赖,自动被收集
  fetchList() // 根据当前筛选条件请求列表数据,任意依赖变化后都会重新执行
}) // 结束 watchEffect

async/await 的问题 ​

watchEffect 只会自动收集 await 之前同步访问到的响应式依赖。await 之后再访问的响应式数据,可能不会被收集。

js
watchEffect(async () => { // 异步 watchEffect,依赖只在同步阶段收集
  console.log(id.value) // await 之前读取 id.value,可以被 watchEffect 自动收集

  await fetch('/api') // 执行异步请求,await 之后的代码会进入下一轮异步流程

  console.log(name.value) // await 之后才读取 name.value,可能不会被当前 watchEffect 收集为依赖
}) // 结束 watchEffect

如果有异步请求,更推荐显式使用 watch,并用清理函数处理竞态问题。

js
watch(id, async (newId, oldId, onCleanup) => { // 显式监听 id 变化,id 改变时执行异步回调
  let expired = false // 标记当前请求是否已经过期,用来处理请求竞态

  onCleanup(() => { // 注册清理函数,下一次 watch 回调执行前会先调用这里
    expired = true // 把旧请求标记为过期,避免旧请求晚返回后覆盖新数据
  }) // 结束清理函数注册

  const res = await fetch(`/api/detail/${newId}`) // 根据最新 id 请求详情接口
  const data = await res.json() // 把接口响应体解析成 JSON 数据

  if (!expired) { // 只有当前请求没有过期时,才允许更新页面数据
    detail.value = data // 把接口返回的详情写入响应式 ref
  } // 结束过期判断
}) // 结束 watch

21、observer对象是啥? ​

Observer 一般指观察者对象,核心作用是观察某个目标的变化,并在变化时执行回调。

在前端里常见的 Observer 有:

API作用
MutationObserver监听 DOM 结构或属性变化
IntersectionObserver监听元素是否进入可视区域
ResizeObserver监听元素尺寸变化
PerformanceObserver监听性能指标

IntersectionObserver 示例 ​

常用于图片懒加载、曝光埋点、无限滚动。

js
const observer = new IntersectionObserver(entries => { // 创建可视区域观察器
  entries.forEach(entry => { // 遍历本次发生可视状态变化的所有元素
    if (entry.isIntersecting) { // 判断当前元素是否已经进入可视区域
      const img = entry.target // 获取当前被观察的图片元素
      img.src = img.dataset.src // 把真实图片地址从 data-src 赋值给 src,触发图片加载
      observer.unobserve(img) // 图片加载后取消观察,避免重复触发和浪费性能
    } // 结束可视状态判断
  }) // 结束 entries 遍历
}) // 结束 IntersectionObserver 创建

document.querySelectorAll('img[data-src]').forEach(img => { // 遍历懒加载图片
  observer.observe(img) // 开始观察当前图片,等它进入可视区域后再加载真实图片
}) // 结束图片节点遍历

如果面试官问 Vue 响应式里的 Observer,Vue 2 中也有一个 Observer 类,会把对象的每个属性转成 getter/setter,并给对象挂上依赖收集能力。

22、开发过富文本吗?现在有个统计富文本字数的需求,我的富文本里面有中英文还有表格啊图片啊,我现在需要统计实际的字符数,怎么做? ​

富文本字数统计不能直接用 HTML 字符串长度,因为标签、样式、属性都不应该算字数。

我的处理方式是:先把 HTML 解析成 DOM,再按节点类型统计真实内容。

统计规则先定清楚 ​

  • HTML 标签不计数。
  • 中文、英文、数字按字符数统计,还是英文单词按一个统计,需要和产品确认。
  • 空格、换行是否计数,需要确认。
  • 图片是否按 1 个字符计数,需要确认。
  • 表格里的文字要计入。
  • 隐藏内容、脚注、公式、emoji 是否计数,也要确认。

示例实现 ​

js
function countRichText(html) { // 定义富文本字数统计方法,参数 html 是富文本字符串
  const container = document.createElement('div') // 创建临时容器
  container.innerHTML = html // 把富文本 HTML 放入临时容器,浏览器会自动生成对应 DOM 树

  let count = 0 // 定义计数器,用来累计真实字符数

  function walk(node) { // 定义递归遍历节点的方法
    if (node.nodeType === Node.TEXT_NODE) { // 如果当前节点是文本节点,就统计文本内容
      const text = node.textContent.replace(/\s+/g, '') // 去掉空白字符
      count += Array.from(text).length // 使用 Array.from 统计字符数,更好兼容 emoji 等特殊字符
      return // 文本节点统计完后直接返回,不需要继续遍历子节点
    } // 结束文本节点判断

    if (node.nodeType !== Node.ELEMENT_NODE) { // 如果不是元素节点,比如注释节点,就不参与统计
      return // 直接返回,跳过无关节点
    } // 结束元素节点判断

    const element = node // 把当前节点当作元素节点使用,方便读取 tagName 和 childNodes

    if (element.tagName === 'IMG') { // 如果当前元素是图片
      count += 1 // 按业务规则把一张图片计为 1 个字符
      return // 图片没有继续统计子节点的必要,直接返回
    } // 结束图片节点判断

    Array.from(element.childNodes).forEach(walk) // 递归统计子节点
  } // 结束 walk 方法

  walk(container) // 从临时容器开始递归遍历整棵富文本 DOM 树

  return count // 返回最终统计出来的字符数
} // 结束 countRichText 方法

Array.from(text).length 比 text.length 更适合处理 emoji 和部分特殊字符。如果规则要求英文单词按一个算,可以把文本分成中文字符、英文单词、数字再分别统计。

实际项目里还要注意 XSS,展示富文本前需要白名单过滤,不能直接信任用户输入的 HTML。

23、vite相较于webpack有哪些优势? ​

Vite 的核心优势是开发阶段快、配置轻、现代浏览器体验好。

对比项WebpackVite
开发启动先整体打包再启动利用浏览器原生 ESM,按需编译
热更新项目越大越慢通常只更新当前模块,速度更快
构建工具自己完成打包链路开发用 esbuild,生产用 Rollup
配置复杂度大型项目配置较重默认配置更简洁
依赖预构建需要配置优化使用 esbuild 预构建依赖

为什么快 ​

  • 开发环境不需要先把整个项目打包。
  • 源码按需转换,请求到哪个模块才编译哪个模块。
  • 第三方依赖通过 esbuild 预构建,esbuild 是 Go 写的,速度很快。
  • HMR 能精确更新变更模块。

Webpack 的优势是生态成熟、复杂工程能力强、历史项目兼容性好。新 Vue 3 项目我一般优先考虑 Vite,老项目或复杂定制项目会评估迁移成本。

24、防抖和节流? ​

防抖和节流都是为了控制函数执行频率。

防抖 ​

防抖是多次触发只执行最后一次。适合搜索输入、窗口 resize、表单校验。

js
function debounce(fn, delay = 300) { // 定义防抖函数,fn 是要执行的函数,delay 是延迟时间
  let timer = null // 保存定时器 id,用来取消上一次还没执行的任务

  return function (...args) { // 返回一个新函数,外部真正调用的是这个包装后的函数
    clearTimeout(timer) // 每次触发时先清除上一次定时器,保证只执行最后一次

    timer = setTimeout(() => { // 重新创建定时器,等待 delay 时间后执行目标函数
      fn.apply(this, args) // 使用 apply 保留 this 指向,并把调用参数传给原函数
    }, delay) // 设置防抖延迟时间
  } // 结束返回的包装函数
} // 结束 debounce 方法

节流 ​

节流是固定时间内最多执行一次。适合滚动监听、按钮防连点、拖拽移动。

js
function throttle(fn, delay = 300) { // 定义节流函数,fn 是要执行的函数,delay 是最小执行间隔
  let lastTime = 0 // 记录上一次执行函数的时间戳

  return function (...args) { // 返回包装函数,用来控制原函数执行频率
    const now = Date.now() // 获取当前时间戳

    if (now - lastTime >= delay) { // 判断距离上次执行是否已经超过指定间隔
      lastTime = now // 更新最近一次执行时间
      fn.apply(this, args) // 执行原函数,并保留 this 指向和调用参数
    } // 结束时间间隔判断
  } // 结束返回的包装函数
} // 结束 throttle 方法

总结:只关心最终结果用防抖,需要持续但降低频率用节流。

25、为什么要用ssr ​

SSR 是服务端渲染,页面 HTML 在服务端生成后返回给浏览器。

使用 SSR 的原因 ​

  • 首屏更快:浏览器拿到 HTML 后可以更快看到内容。
  • SEO 更好:搜索引擎可以直接抓取完整内容。
  • 弱网体验更好:首屏内容不完全依赖 JS 下载和执行。
  • 便于做动态 meta:比如文章标题、描述、分享卡片。

适合场景 ​

  • 官网、营销页、内容站、新闻资讯、博客、电商详情页。
  • 对 SEO 和首屏体验要求高的页面。

不适合场景 ​

  • 强交互后台系统。
  • 用户登录后才能访问、SEO 不重要的页面。
  • 服务端成本敏感、页面个性化缓存困难的业务。

SSR 不是银弹,它会增加服务器压力、开发复杂度和同构代码约束,所以要看业务是否真的需要 SEO 和首屏收益。

26、ssr的实现原理 ​

SSR 的核心流程是:同一套组件代码,在服务端先渲染成 HTML,浏览器加载后再激活成可交互应用。

基本流程 ​

  1. 浏览器请求页面。
  2. Node 服务根据路由匹配组件。
  3. 服务端调用接口获取页面数据。
  4. 使用框架的服务端渲染 API 把组件转成 HTML 字符串。
  5. 把 HTML 和初始数据一起返回给浏览器。
  6. 浏览器下载 JS 后进行 hydration,让静态 HTML 绑定事件,变成可交互页面。

简化示例 ​

js
import { createSSRApp } from 'vue' // 引入 createSSRApp,用来创建支持服务端渲染的 Vue 应用实例
import { renderToString } from '@vue/server-renderer' // 引入服务端渲染方法
import App from './App.vue' // 引入根组件,服务端会从这个组件开始渲染页面

server.get('*', async (req, res) => { // 监听所有 GET 请求,让服务端根据请求返回 SSR 页面
  const app = createSSRApp(App) // 创建一个新的 Vue SSR 应用实例,避免多个请求共享状态
  const html = await renderToString(app) // 把 Vue 应用渲染成 HTML 字符串

  res.send(/* 向浏览器返回完整 HTML 文档字符串 */ `
    <html> <!-- HTML 文档根标签 -->
      <body> <!-- 页面主体内容 -->
        <div id="app">${html}</div> <!-- 把服务端渲染出的 HTML 注入到客户端挂载容器中 -->
        <script src="/client.js"></script> <!-- 加载客户端 JS,用于 hydration 和后续交互 -->
      </body> <!-- 关闭页面主体 -->
    </html> <!-- 关闭 HTML 文档 -->
  `) // 结束 HTML 字符串响应
}) // 结束服务端路由处理

需要注意的问题:

  • 服务端没有 window、document,浏览器 API 要放到客户端生命周期里。
  • 服务端数据要安全注入,避免 XSS。
  • 服务端 HTML 和客户端首次渲染结果要一致,否则会 hydration mismatch。
  • 要做好缓存,不然所有请求都实时渲染,服务器压力会很大。

27、如何实现组件异步按需加载?如何知道异步组件加载正常还是加载失败? ​

组件异步按需加载,本质是通过动态 import() 拆包,在需要时再加载对应组件。

Vue 路由懒加载 ​

js
const routes = [ // 定义路由配置数组
  { // 定义一个路由对象
    path: '/order', // 当访问 /order 路径时匹配这个路由
    component: () => import('@/views/order/index.vue') // 使用动态 import 懒加载订单页面组件
  } // 结束订单路由配置
] // 结束 routes 数组

Vue 异步组件 ​

js
import { defineAsyncComponent } from 'vue' // 引入 defineAsyncComponent,用来创建 Vue 异步组件

const AsyncDialog = defineAsyncComponent({ // 定义一个异步弹窗组件,只有使用到时才会加载代码
  loader: () => import('./Dialog.vue'), // 动态加载真实组件
  loadingComponent: Loading, // 指定加载过程中展示的占位组件
  errorComponent: ErrorView, // 指定加载失败后展示的错误兜底组件
  delay: 200, // 延迟 200 毫秒再展示 loading,避免加载很快时 loading 闪烁
  timeout: 10000, // 设置 10 秒超时时间,超过后认为组件加载失败
  onError(error, retry, fail, attempts) { // 定义加载失败处理函数,可以决定重试还是彻底失败
    if (attempts <= 2) { // 如果失败次数不超过 2 次,认为可能是网络抖动
      retry() // 重新执行 loader,尝试再次加载异步组件
    } else { // 如果已经多次失败,继续重试意义不大
      fail() // 标记组件加载失败,触发 errorComponent 兜底展示
    } // 结束重试次数判断
  } // 结束 onError 方法
}) // 结束异步组件定义

怎么知道成功或失败 ​

  • import().then() 表示资源加载成功。
  • import().catch() 可以捕获加载失败。
  • defineAsyncComponent 的 errorComponent 和 onError 可以做失败兜底。
  • 路由懒加载失败可以在 router.onError 里统一处理。
js
router.onError(error => { // 注册路由错误监听,路由懒加载组件失败时会触发
  console.error('路由组件加载失败:', error) // 输出错误日志,实际项目中也可以上报监控平台
  location.reload() // 简单兜底刷新页面,常用于解决用户缓存旧 HTML 导致的 Chunk 加载失败
}) // 结束路由错误监听

线上常见失败原因是网络异常、Chunk 404、用户缓存了旧 HTML。解决时要配合静态资源 hash、保留历史资源、HTML 不强缓存和失败重试。

28、react 什么是useMemo、useCallback(什么情况下用到) ​

useMemo 和 useCallback 都是 React 的性能优化 Hook,用来减少不必要的计算或引用变化。

useMemo ​

useMemo 缓存一个计算结果。

jsx
const total = useMemo(() => { // 使用 useMemo 缓存总价计算结果,只有依赖变化时才重新计算
  return list.reduce((sum, item) => sum + item.price, 0) // 遍历列表,把每一项价格累加成总价
}, [list]) // 依赖项是 list,只有 list 引用变化时才会重新执行计算函数

适合场景:

  • 计算成本比较高。
  • 数据量较大,比如列表过滤、排序、聚合。
  • 计算结果要传给子组件,避免每次渲染都重新生成。

useCallback ​

useCallback 缓存一个函数引用。

jsx
const handleClick = useCallback(() => { // 缓存点击函数引用
  setCount(count => count + 1) // 使用函数式更新,让 count 在原值基础上加 1
}, []) // 依赖数组为空,表示这个函数引用在组件生命周期内保持稳定

适合场景:

  • 函数作为 props 传给使用 React.memo 优化过的子组件。
  • 函数作为 useEffect 依赖,避免依赖每次变化。
  • 自定义 Hook 中需要稳定函数引用。

不要为了用而用。useMemo 和 useCallback 本身也有成本,如果计算很轻或组件很简单,直接写普通函数更清晰。

29、项目性能优化? ​

项目性能优化我会先用数据定位瓶颈,再按加载、渲染、接口、缓存、体验几个方向处理。

加载优化 ​

  • 路由懒加载、组件异步加载。
  • 使用 Tree Shaking、按需引入组件库。
  • 大依赖替换或拆分,比如只引入 lodash 的具体方法。
  • 静态资源开启 gzip 或 br 压缩。
  • CDN 加速,合理设置缓存。

图片优化 ​

  • 使用 WebP/AVIF。
  • 图片懒加载。
  • 根据设备加载不同尺寸图片。
  • 小图可以合并雪碧图或转 base64,但不要滥用。

渲染优化 ​

  • 长列表使用虚拟列表。
  • 减少不必要的响应式数据。
  • 避免频繁操作 DOM 和触发布局。
  • 动画优先使用 transform、opacity。
  • 大计算放到 Web Worker 或分片执行。

接口优化 ​

  • 请求并行。
  • 合并重复请求。
  • 对不常变的数据做缓存。
  • 搜索接口加防抖。
  • 分页、增量加载和骨架屏。

监控闭环 ​

优化前后要看指标,比如 FCP、LCP、CLS、INP、接口耗时、JS 错误率和白屏率。没有指标的优化容易变成主观感觉。

30、图片上传资源服务器后没有更新怎么处理? ​

图片上传后没有更新,常见原因是缓存。

排查方向 ​

  • 浏览器缓存:图片 URL 没变,浏览器直接用了本地缓存。
  • CDN 缓存:资源服务器已经更新,但 CDN 节点还没刷新。
  • 服务端缓存:接口返回的图片地址还是旧地址。
  • 上传失败:前端以为上传成功,但资源实际没有覆盖。
  • 文件同名覆盖:同一个 URL 指向新内容,但中间缓存没有失效。

处理方式 ​

最推荐的方式是图片文件名带 hash 或版本号,上传新图片后生成新 URL。

js
const imageUrl = `${cdnHost}/avatar/${fileHash}.png` // 用 hash 生成新图片地址

如果业务必须覆盖同名图片,可以在展示时加版本参数。

js
const url = `${originUrl}?v=${Date.now()}` // 追加时间戳,绕过缓存

更规范的做法是使用资源版本号。

js
const url = `${originUrl}?v=${userInfo.avatarVersion}` // 用版本号控制图片缓存

同时可以做 CDN 刷新或预热。用户头像这类资源,我会尽量避免同名覆盖,而是每次上传生成新文件名,接口返回最新 URL。

31、异常捕获 try catch 什么情况下使用? ​

try catch 适合捕获可预期、可恢复的异常,不适合把所有代码都包起来。

适合使用的场景 ​

  • JSON.parse 解析不可信字符串。
  • await 请求接口时处理失败。
  • 调用第三方 SDK,避免 SDK 异常影响主流程。
  • 操作本地缓存,比如 localStorage 可能因为隐私模式或容量问题报错。
  • 业务降级,比如主接口失败后使用缓存数据。
js
async function getUserInfo() { // 定义异步方法,用来请求用户信息
  try { // 使用 try 包裹可能失败的异步请求和 JSON 解析
    const res = await fetch('/api/user') // 请求用户信息接口,网络异常或接口异常都可能导致失败
    return await res.json() // 把响应内容解析成 JSON,并作为函数结果返回
  } catch (error) { // 捕获 try 中发生的异常
    reportError(error) // 把错误上报到监控平台,方便线上排查
    return null // 返回兜底值,避免调用方拿不到返回结果导致页面继续报错
  } // 结束 catch
} // 结束 getUserInfo 方法
js
function safeParse(json, defaultValue = null) { // 定义安全 JSON 解析方法,解析失败时返回默认值
  try { // 使用 try 包裹 JSON.parse,因为传入字符串不合法时会直接抛错
    return JSON.parse(json) // 尝试把 JSON 字符串解析成 JavaScript 数据
  } catch (error) { // 捕获 JSON.parse 抛出的语法错误
    return defaultValue // 解析失败时返回默认值,避免页面因为异常中断
  } // 结束 catch
} // 结束 safeParse 方法

不适合的场景 ​

  • 不要用 try catch 掩盖代码逻辑错误。
  • 不要捕获后什么都不做,否则问题会被吞掉。
  • 不要以为 try catch 能捕获异步回调里的错误。
js
try { // 这里的 try 只能捕获当前同步调用栈中的异常
  setTimeout(() => { // setTimeout 回调会在未来的宏任务中执行,已经不在当前 try 的调用栈里
    throw new Error('异步错误') // 在异步回调中抛出错误,外层 try catch 捕获不到
  }) // 结束 setTimeout 注册
} catch (error) { // 这个 catch 只能捕获 try 内同步执行阶段的异常
  // 捕获不到 // 由于错误发生在异步回调里,所以这里不会执行
} // 结束 try catch 示例

异步错误要在异步内部捕获,或者用 Promise 的 catch、async/await 的 try catch,全局兜底可以用 window.onerror 和 unhandledrejection。

基于 MIT 许可发布