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
beforeCreatecreated直接在 setup 中执行
beforeMountonBeforeMount
mountedonMounted
beforeDestroyonBeforeUnmount
destroyedonUnmounted

性能和编译优化不同

对比项Vue 2Vue 3
响应式原理Object.defineProperty 监听每个属性Proxy 代理整个对象
初始化递归遍历所有数据,数据多时较慢用到深层数据时再代理,速度更快
新增、删除属性不能自动监听,需要 Vue.set/delete可以自动监听
数组监听重写数组方法,不能直接监听索引变化可以监听数组方法和索引变化
编译优化更新时需要对比较多内容提前标记动态内容,只对比可能变化的部分
虚拟 DOM对比范围较大对比更精准,性能更好
打包体积Tree Shaking 支持较弱支持 Tree Shaking,不需要的功能可以移除
数据结构主要支持对象和数组支持对象、数组、MapSet
浏览器兼容支持 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.onErrorApp.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) 切换当前页。
  • 监听 touchstarttouchmovetouchend,根据滑动距离和速度判断是否切换。
  • 自动播放用定时器,组件销毁时清理。
  • 无限轮播可以在首尾各复制一张,过渡结束后无动画切回真实位置。

简单版本

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 给每个属性设置 getset

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.setVue.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
  • 支持 MapSet 等复杂数据结构。
  • 深层对象可以在访问时再代理,初始化成本更低。

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

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

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

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

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

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

常见宏任务

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

常见微任务

  • Promise.thencatchfinally
  • 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。
  • 应用隔离:避免样式污染、全局变量冲突和运行时冲突。
  • 生命周期管理:子应用提供 bootstrapmountunmount 等方法。
  • 通信机制:通过 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
写法statemutationsactionsgettersstateactionsgetters
修改状态通常通过 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 自带的 reactiveprovide/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> <!-- 关闭模板根标签 -->

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

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

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

不同点

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

什么时候用 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).lengthtext.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 字符串响应
}) // 结束服务端路由处理

需要注意的问题:

  • 服务端没有 windowdocument,浏览器 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() 可以捕获加载失败。
  • defineAsyncComponenterrorComponentonError 可以做失败兜底。
  • 路由懒加载失败可以在 router.onError 里统一处理。
js
router.onError(error => { // 注册路由错误监听,路由懒加载组件失败时会触发
  console.error('路由组件加载失败:', error) // 输出错误日志,实际项目中也可以上报监控平台
  location.reload() // 简单兜底刷新页面,常用于解决用户缓存旧 HTML 导致的 Chunk 加载失败
}) // 结束路由错误监听

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

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

useMemouseCallback 都是 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 中需要稳定函数引用。

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

29、项目性能优化?

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

加载优化

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

图片优化

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

渲染优化

  • 长列表使用虚拟列表。
  • 减少不必要的响应式数据。
  • 避免频繁操作 DOM 和触发布局。
  • 动画优先使用 transformopacity
  • 大计算放到 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 的 catchasync/awaittry catch,全局兜底可以用 window.onerrorunhandledrejection

基于 MIT 许可发布