面试题-腾讯
1、你项目中遇到过的难点是怎么处理的?
- 考察思路:能识别核心矛盾、做技术决策、推动协作,并最终产生可量化的结果。
- 回答思路:准备两个案例:一个体现技术深度,一个体现架构决策和跨团队推动能力。这样更符合“高级开发”的岗位预期。
2、vue2 和 vue3 的区别?
响应式原理不同
Vue 2 使用 Object.defineProperty 劫持对象属性。
新增、删除属性无法自动监听,需要 Vue.set、Vue.delete
数组通过重写部分方法实现监听
初始化时需要递归遍历数据
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
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 2 | Vue 3 Composition API |
|---|---|
beforeCreate、created | 直接在 setup 中执行 |
beforeMount | onBeforeMount |
mounted | onMounted |
beforeDestroy | onBeforeUnmount |
destroyed | onUnmounted |
性能和编译优化不同
| 对比项 | Vue 2 | Vue 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 增加加载提示、有限重试和失败兜底:
/**
* 加载小程序分包
*
* 简单流程:
* 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、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 是项目中封装的错误上报方法,例如:
// 将错误信息发送到监控服务
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,根据滑动距离和速度判断是否切换。 - 自动播放用定时器,组件销毁时清理。
- 无限轮播可以在首尾各复制一张,过渡结束后无动画切回真实位置。
简单版本
<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> <!-- 关闭轮播图最外层容器 -->.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%; /* 让每个轮播项高度占满外层容器 */
} /* 结束单个轮播项样式 */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。
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 代理对象本身。
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、让你实现一个没有做过的功能,你会怎么处理?
我一般不会直接上手写,而是先把不确定性拆开。
- 先确认业务目标:这个功能解决什么问题,成功标准是什么,是否有类似竞品或历史方案。
- 拆需求边界:输入是什么,输出是什么,异常情况有哪些,哪些必须做,哪些可以后置。
- 做技术调研:查官方文档、成熟方案、项目里是否已有类似实现,避免重复造轮子。
- 做最小 Demo:先验证核心链路,比如接口是否可行、浏览器能力是否支持、性能是否能接受。
- 设计实现方案:拆组件、接口、状态、异常兜底、埋点监控和灰度方案。
- 开发和自测:覆盖主流程、异常流程、兼容性和边界数据。
- 上线后观察:看错误日志、性能指标、用户行为和业务数据。
如果时间紧,我会先保证核心链路可用,再把体验优化和边缘能力分阶段补齐。
9、宏任务 微任务 有哪些?
JavaScript 事件循环里,一轮同步代码执行完成后,会先清空微任务队列,再执行下一个宏任务。
常见宏任务
script整体代码setTimeoutsetIntervalsetImmediate,主要是 Node 环境MessageChannel- I/O 回调
- UI 渲染相关任务
- DOM 事件回调
常见微任务
Promise.then、catch、finallyqueueMicrotaskMutationObserver- Node 中的
process.nextTick
执行顺序示例
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 渲染完成后,再执行回调。
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、怎么处理客户白屏的问题,客户不能帮你进行远程调试?
不能远程调试时,关键是让线上环境自己把信息带回来。
处理流程
- 先收集用户信息:账号、页面地址、操作路径、设备型号、系统版本、浏览器版本、网络环境、出现时间。
- 看监控平台:检查 JS 报错、资源加载失败、接口异常、白屏检测截图或页面 DOM 状态。
- 看发布记录:确认是否刚发过版,是否存在 HTML 缓存旧版本、Chunk 404、CDN 没同步等问题。
- 做灰度和回滚:如果影响范围扩大,先回滚或切到稳定资源。
- 给用户兜底:提示刷新、清缓存、切网络、升级浏览器,但不能只依赖用户操作。
可以加一个白屏检测
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 都可以做实时通信,但适用场景不一样。
| 对比项 | SSE | WebSocket |
|---|---|---|
| 全称 | Server-Sent Events | WebSocket |
| 通信方向 | 服务端主动推给客户端,单向 | 客户端和服务端双向通信 |
| 协议基础 | HTTP | 先 HTTP 握手,再升级协议 |
| 数据格式 | 文本流 | 文本或二进制 |
| 自动重连 | 浏览器原生支持 | 需要自己实现 |
| 使用复杂度 | 简单 | 相对复杂 |
| 适合场景 | 消息通知、日志流、AI 流式输出、行情展示 | 聊天、协同编辑、游戏、实时互动 |
SSE 示例
const source = new EventSource('/api/events') // 创建 SSE 连接
source.onmessage = event => { // 监听服务端推送的普通 message 事件
console.log('收到消息:', event.data) // 读取服务端推送的数据,event.data 默认是字符串
} // 结束消息监听
source.onerror = () => { // 监听 SSE 连接异常事件
console.log('连接异常,浏览器会自动重连') // SSE 原生支持自动重连
} // 结束异常监听WebSocket 示例
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 动画
适合简单的旋转、渐隐渐现、位移。
.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 控制开始、结束、组合动作的动画。
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 调用帧动画
帧动画本质是按一定时间切换图片,适合复杂动效,比如人物动作、礼物特效。
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 实现
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。
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移动到正确位置。
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 配置<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,写法更轻量。
| 对比项 | Vuex | Pinia |
|---|---|---|
| 写法 | state、mutations、actions、getters | state、actions、getters |
| 修改状态 | 通常通过 commit mutation | 可以直接修改 state |
| TypeScript | 类型推导相对麻烦 | 类型推导更自然 |
| 模块化 | modules 嵌套较多 | 每个 store 天然独立 |
| Composition API | 支持一般 | 更贴合 |
| 体积和心智负担 | 较重 | 更轻 |
Pinia 没有 mutation,同步和异步逻辑都可以写在 actions 里。
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 实现一个轻量全局状态。
// 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 方法组件中使用:
import { useStore } from './store' // 引入前面封装好的轻量全局状态方法
const { state, setToken } = useStore() // 获取只读状态 state 和修改 token 的方法 setToken
setToken('xxx') // 调用统一的方法修改全局 token
console.log(state.token) // 输出当前全局 token,因为 state 是响应式的,所以组件中也会自动更新如果希望更规范,可以在入口使用 provide。
// 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 节点上// 组件中 // 这里演示子组件如何拿到 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,所以会丢失响应式。
错误写法
const state = reactive({ // 使用 reactive 创建一个响应式对象
name: '小明', // 定义响应式属性 name,初始值是小明
age: 18 // 定义响应式属性 age,初始值是 18
}) // 结束响应式对象定义
const { name } = state // 直接解构 reactive 对象,会把 name 变成普通变量
state.name = '小红' // 修改响应式对象里的 name,视图会更新,但普通变量 name 不会跟着变
console.log(name) // 输出解构时保存的旧值,所以结果还是小明使用 toRefs
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
import { computed } from 'vue' // 引入 computed,用来创建基于响应式数据的计算值
const userName = computed(() => state.name) // 创建计算属性 userName,state.name 变化时它会自动更新模板中可以直接使用
如果只是模板展示,不一定要解构,直接使用 state.name 更简单。
<template> <!-- Vue 模板根标签,用来包裹当前组件的模板内容 -->
<div>{{ state.name }}</div> <!-- 直接在模板中读取 state.name,Vue 会自动追踪依赖并保持响应式 -->
</template> <!-- 关闭模板根标签 -->总结:要解构响应式对象,用 toRef 或 toRefs;要派生数据,用 computed;不要随意用 ...state 复制响应式对象。
20、watch和watchEffect实现原理有什么不同。什么情况下你会选用watchEffect,如果用async/await包装了watchEffect的执行函数可以吗?会有问题吗?
watch 和 watchEffect 底层都依赖 Vue 的响应式副作用系统,本质都是依赖收集和触发更新,但依赖来源不同。
不同点
| 对比项 | watch | watchEffect |
|---|---|---|
| 依赖来源 | 显式指定监听对象 | 自动收集回调中用到的响应式数据 |
| 是否立即执行 | 默认不立即执行,可配置 immediate | 默认立即执行 |
| 新旧值 | 可以拿到 newValue 和 oldValue | 不直接提供新旧值 |
| 适合场景 | 精准监听某个数据变化 | 多个依赖共同触发一个副作用 |
什么时候用 watchEffect
当副作用依赖多个响应式数据,而且不关心新旧值时,我会使用 watchEffect。
watchEffect(() => { // 创建 watchEffect,回调会立即执行,并自动收集内部读取到的响应式依赖
console.log(keyword.value, page.value, pageSize.value) // 读取依赖,自动被收集
fetchList() // 根据当前筛选条件请求列表数据,任意依赖变化后都会重新执行
}) // 结束 watchEffectasync/await 的问题
watchEffect 只会自动收集 await 之前同步访问到的响应式依赖。await 之后再访问的响应式数据,可能不会被收集。
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,并用清理函数处理竞态问题。
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
} // 结束过期判断
}) // 结束 watch21、observer对象是啥?
Observer 一般指观察者对象,核心作用是观察某个目标的变化,并在变化时执行回调。
在前端里常见的 Observer 有:
| API | 作用 |
|---|---|
MutationObserver | 监听 DOM 结构或属性变化 |
IntersectionObserver | 监听元素是否进入可视区域 |
ResizeObserver | 监听元素尺寸变化 |
PerformanceObserver | 监听性能指标 |
IntersectionObserver 示例
常用于图片懒加载、曝光埋点、无限滚动。
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 是否计数,也要确认。
示例实现
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 的核心优势是开发阶段快、配置轻、现代浏览器体验好。
| 对比项 | Webpack | Vite |
|---|---|---|
| 开发启动 | 先整体打包再启动 | 利用浏览器原生 ESM,按需编译 |
| 热更新 | 项目越大越慢 | 通常只更新当前模块,速度更快 |
| 构建工具 | 自己完成打包链路 | 开发用 esbuild,生产用 Rollup |
| 配置复杂度 | 大型项目配置较重 | 默认配置更简洁 |
| 依赖预构建 | 需要配置优化 | 使用 esbuild 预构建依赖 |
为什么快
- 开发环境不需要先把整个项目打包。
- 源码按需转换,请求到哪个模块才编译哪个模块。
- 第三方依赖通过 esbuild 预构建,esbuild 是 Go 写的,速度很快。
- HMR 能精确更新变更模块。
Webpack 的优势是生态成熟、复杂工程能力强、历史项目兼容性好。新 Vue 3 项目我一般优先考虑 Vite,老项目或复杂定制项目会评估迁移成本。
24、防抖和节流?
防抖和节流都是为了控制函数执行频率。
防抖
防抖是多次触发只执行最后一次。适合搜索输入、窗口 resize、表单校验。
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 方法节流
节流是固定时间内最多执行一次。适合滚动监听、按钮防连点、拖拽移动。
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,浏览器加载后再激活成可交互应用。
基本流程
- 浏览器请求页面。
- Node 服务根据路由匹配组件。
- 服务端调用接口获取页面数据。
- 使用框架的服务端渲染 API 把组件转成 HTML 字符串。
- 把 HTML 和初始数据一起返回给浏览器。
- 浏览器下载 JS 后进行 hydration,让静态 HTML 绑定事件,变成可交互页面。
简化示例
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 路由懒加载
const routes = [ // 定义路由配置数组
{ // 定义一个路由对象
path: '/order', // 当访问 /order 路径时匹配这个路由
component: () => import('@/views/order/index.vue') // 使用动态 import 懒加载订单页面组件
} // 结束订单路由配置
] // 结束 routes 数组Vue 异步组件
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里统一处理。
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 缓存一个计算结果。
const total = useMemo(() => { // 使用 useMemo 缓存总价计算结果,只有依赖变化时才重新计算
return list.reduce((sum, item) => sum + item.price, 0) // 遍历列表,把每一项价格累加成总价
}, [list]) // 依赖项是 list,只有 list 引用变化时才会重新执行计算函数适合场景:
- 计算成本比较高。
- 数据量较大,比如列表过滤、排序、聚合。
- 计算结果要传给子组件,避免每次渲染都重新生成。
useCallback
useCallback 缓存一个函数引用。
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。
const imageUrl = `${cdnHost}/avatar/${fileHash}.png` // 用 hash 生成新图片地址如果业务必须覆盖同名图片,可以在展示时加版本参数。
const url = `${originUrl}?v=${Date.now()}` // 追加时间戳,绕过缓存更规范的做法是使用资源版本号。
const url = `${originUrl}?v=${userInfo.avatarVersion}` // 用版本号控制图片缓存同时可以做 CDN 刷新或预热。用户头像这类资源,我会尽量避免同名覆盖,而是每次上传生成新文件名,接口返回最新 URL。
31、异常捕获 try catch 什么情况下使用?
try catch 适合捕获可预期、可恢复的异常,不适合把所有代码都包起来。
适合使用的场景
JSON.parse解析不可信字符串。await请求接口时处理失败。- 调用第三方 SDK,避免 SDK 异常影响主流程。
- 操作本地缓存,比如
localStorage可能因为隐私模式或容量问题报错。 - 业务降级,比如主接口失败后使用缓存数据。
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 方法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能捕获异步回调里的错误。
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。