目录
1. JavaScript / 核心基础
1.1 手写 Promise.all / Promise.race
问题:实现 Promise.all 和 Promise.race
答案:
Promise.all 就像”团购”——所有人都到了才能开饭;Promise.race 就像”赛跑”——谁先到听谁的。
Promise.all 手写:
Promise.myAll = function (promises) {
return new Promise((resolve, reject) => {
const iterable = Array.from(promises)
if (iterable.length === 0) return resolve([])
const results = new Array(iterable.length)
let completed = 0
iterable.forEach((item, index) => {
Promise.resolve(item).then(
value => {
results[index] = value
completed++
if (completed === iterable.length) resolve(results)
},
reason => reject(reason)
)
})
})
}
关键细节:
– 结果数组要保持顺序,不能用 push,要用 results[index] = value
– 用 Promise.resolve() 包裹,兼容非 Promise 的普通值
– 空数组时立即 resolve []
Promise.race 手写:
Promise.myRace = function (promises) {
return new Promise((resolve, reject) => {
for (const item of promises) {
Promise.resolve(item).then(resolve, reject)
}
})
}
1.2 手写 deepClone(考虑循环引用)
问题:实现一个深拷贝
答案:
浅拷贝像”复印了通讯录”——电话号码还是同一个人的;深拷贝像”重新办了一张手机卡”——完全独立。
function deepClone(obj, hash = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj
if (hash.has(obj)) return hash.get(obj)
if (obj instanceof Date) return new Date(obj)
if (obj instanceof RegExp) return new RegExp(obj)
if (obj instanceof Map) {
const clone = new Map()
hash.set(obj, clone)
obj.forEach((v, k) => clone.set(deepClone(k, hash), deepClone(v, hash)))
return clone
}
if (obj instanceof Set) {
const clone = new Set()
hash.set(obj, clone)
obj.forEach(v => clone.add(deepClone(v, hash)))
return clone
}
const clone = Array.isArray(obj) ? [] : {}
hash.set(obj, clone)
;[...Object.keys(obj), ...Object.getOwnPropertySymbols(obj)].forEach(key => {
clone[key] = deepClone(obj[key], hash)
})
return clone
}
为什么用 WeakMap 不用普通 Map? WeakMap 的 key 是弱引用——当原始对象被销毁,WeakMap 里对应的记录会自动被 GC 回收,不会造成内存泄漏。
补充:WeakMap 详解
如果你没见过没用过,这是正常的——它平时确实很少出现在业务代码里,但一旦用对地方,它就是最好的工具。
一句话理解 WeakMap:
普通 Map 像”户籍登记”——人死了(对象被回收)户口还在,必须手动销户。
WeakMap 像”请客吃饭的座位名单”——人走了(对象被回收),名字自动擦掉。
Map vs WeakMap 对比:
| 维度 | Map | WeakMap |
|---|---|---|
| key 类型 | 任意类型(对象、字符串、数字、Symbol) | 只能是对象 |
| 引用强度 | 强引用 —— 只要 Map 还在,key 就不会被 GC | 弱引用 —— 如果 key 变量被置空,GC 可以回收它 |
| 内存泄漏风险 | 高(忘记清理会一直占内存) | 低(自动回收) |
| 可遍历性 | ✅ 可遍历(有 size / forEach / keys / values / entries) |
❌ 不可遍历(没有 size、forEach、keys) |
| 常用方法 | set / get / has / delete / clear / size | set / get / has / delete(没有 clear,没有 size) |
| 性能 | 略慢(需要维护内部链表支持遍历) | 略快(不需要维护遍历信息) |
核心原理:什么是”弱引用”?
let obj = { name: '张三' }
const map = new Map()
map.set(obj, '一些关联数据')
obj = null // 我把 obj 置空了
// 按理说 { name: '张三' } 这个对象应该被 GC 回收
// 但是!Map 里还强引用着它 → 对象无法回收 → 内存泄漏 💀
换成 WeakMap:
let obj = { name: '张三' }
const wm = new WeakMap()
wm.set(obj, '一些关联数据')
obj = null // 我把 obj 置空了
// WeakMap 是弱引用,不阻止 GC 回收
// 对象被自动回收 ✓ 关联数据也被自动清除 ✓
说白了:WeakMap 不”强留”它的 key 对象——如果你在外面的代码把 key 变量置空了,WeakMap 里对应的一整条记录自动失效并被 GC 清扫。
WeakMap 为什么不能遍历?
这是一个有意为之的设计,不是疏忽:
// 如果 WeakMap 可以遍历
const wm = new WeakMap()
// ... 存了很多数据 ...
for (const [key, value] of wm) {
// ❌ 问题:此刻 GC 刚好回收了某个 key,那遍历结果还准吗?
// 遍历到一半 key 被回收了怎么办?
}
// 所以干脆:不可遍历,你只能通过 key 来拿值
// 这保证了 WeakMap 的行为是确定性的
WeakMap 的 4 个经典应用场景
场景1:deepClone 中防循环引用(你已经见过了)
function deepClone(obj, hash = new WeakMap()) {
if (hash.has(obj)) return hash.get(obj) // 如果已经克隆过,直接返回
const clone = Array.isArray(obj) ? [] : {}
hash.set(obj, clone) // 记录本次克隆
// ... 递归复制属性 ...
return clone
}
// ✅ 函数执行完,hash 和 obj 一起被回收,不泄漏
如果换成 Map:每次 deepClone 都要手动 hash.clear(),否则泄漏。
场景2:DOM 节点关联数据
// ❌ 用 Map 的问题
const nodeMap = new Map()
const button = document.getElementById('btn')
nodeMap.set(button, { clickCount: 42 })
document.body.removeChild(button) // button 从 DOM 移除了
// 但 nodeMap 还强引用着 button → button 无法被 GC → 泄漏
// ✅ 用 WeakMap 解决
const nodeData = new WeakMap()
const btn = document.getElementById('btn')
nodeData.set(btn, { clickCount: 42 })
document.body.removeChild(btn)
// btn 变量超出作用域后 → 对象被 GC → nodeData 自动清理
场景3:给对象打标签(不污染原对象)
// 需求:给一些对象标记"是否已处理过",但又不想改原对象
const processed = new WeakMap()
function process(obj) {
if (processed.has(obj)) return // 已处理跳过
processed.set(obj, true)
// ... 处理逻辑 ...
}
// ✅ 不会泄漏,不会污染对象,不需要的时候自动消失
场景4:私有属性实现(经典面试题)
const _private = new WeakMap()
class Person {
constructor(name, age) {
_private.set(this, { age }) // 把 age 藏在 WeakMap 里
this.name = name
}
getAge() {
return _private.get(this).age // 只有内部方法能访问
}
}
const p = new Person('张三', 30)
console.log(p.name) // "张三"
console.log(p.age) // undefined ❌ 外部访问不到
console.log(p.getAge()) // 30 ✅ 内部方法可以
// p 被回收时,_private 里的记录自动清理
WeakMap 的兼容性
| 环境 | 支持版本 | 备注 |
|---|---|---|
| Chrome | 36+ (2014) | ✅ 广泛可用 |
| Firefox | 6+ (2011) | ✅ |
| Safari | 8+ (2014) | ✅ |
| Edge | 12+ | ✅ |
| IE | 不支持 🚫 | WeakMap 是 ES2015 特性,IE 全系列不兼容 |
| Node.js | 0.12+ | ✅ |
| 小程序/uni-app | 各端均支持 | 基础库 2.0+ 已内置 |
Polyfill 的可能性:WeakMap 的”弱引用”行为是 JS 引擎层面的能力,无法用 JS 代码完整模拟。所有 polyfill 都只是用 Map 替代,会丢失弱引用特性。如果必须兼容 IE,要么不用,要么用普通 Map + 手动清理。
延伸:WeakSet 和 WeakRef
WeakSet —— 跟 WeakMap 一样,但只存对象不存值:
const visited = new WeakSet()
function traverse(node) {
if (visited.has(node)) return // 已访问过
visited.add(node)
// ... 遍历逻辑 ...
}
WeakRef(ES2021)—— 更”弱”的引用,连活着都可能被回收:
let ref = new WeakRef({ data: '重要的数据' })
// 随时可能被 GC 回收,使用时需要解引用
ref.deref() // → 可能返回对象,也可能返回 undefined(已被回收)
应用场景极少,只有极度敏感的内存场景(缓存框架、大型图遍历)才用得上,日常开发不要碰。
面试官可能问的追问:
Q:WeakMap 的 key 可以是原始类型吗?
A:不行,只能是对象(非 null 的对象)。因为原始类型值不可变,不存在”引用”的概念,弱引用没有意义。Q:Map 转 WeakMap 有什么风险?
A:WeakMap 不可遍历,不能获取size,不能clear()。如果业务上需要遍历或统计元素数量,只能用 Map。Q:WeakMap 能防止所有内存泄漏吗?
A:不能。WeakMap 只弱引用 key,value 仍然是强引用。如果 value 引用了 key(形成循环引用),仍然可能泄漏——需要确保 value 不反指 key。不过在 deepClone 这种”用完就丢”的场景下,value 是临时新建的对象,随 WeakMap 一起被回收,所以是安全的。
1.3 手写 debounce / throttle(leading + trailing)
问题:实现带 leading/trailing 选项的防抖和节流
答案:
本质追问:防抖和节流到底解决的是什么问题?
很多人说”防抖控制频率,节流控制间隔”——这是对的,但没说到根上。它们的本质是 事件驱动模型中,消费速率 > 生产速率时的背压(backpressure)策略。
浏览器的事件循环是单线程的,而用户的操作(滚动、输入、拖拽)可以远快于浏览器的渲染帧(16.6ms)和业务逻辑的处理速度。如果不加限制,事件队列会持续堆积,导致:
– 帧率下降(卡顿)
– 请求数爆炸(后端压力)
– 中间态覆盖最终态(竞态条件)
从事件循环视角理解两者的区别:
防抖(debounce):把 N 次连续调用折叠成 1 次
调用序列:--a--b--c--d--e----->
setTimeout: 每次调用重置定时器
实际执行:--------------------> e(只有最后一次活着)
微观过程:
a 到来 → 设 timer(delay)
b 到来 → 清 timer,设新 timer(delay) ← 重置
c 到来 → 清 timer,设新 timer(delay) ← 重置
... 安静 delay 时间后 → 执行 fn
关键洞察:防抖的本质是"用 setTimeout 的清除/重设机制实现事件折叠"
节流(throttle):把 N 次连续调用均匀分布在时间轴上
调用序列:--a--b--c--d--e--f--g--h-->
时间轴: |--interval--|--interval--|-->
实际执行:a d g (按固定节拍放行)
关键洞察:节流的本质是"用时间戳 + 剩余时间计算实现固定节拍放行"
lodash 源码级别的实现思路(你的手写如果能提到这些,面试官会点头):
// 真正的生产级 debounce 还需要考虑:
// 1. 取消能力(用于组件卸载时清理)
// 2. flush 能力(立即执行并清空等待)
// 3. 返回值(Promise)
// 4. maxWait(最大等待时间——这是 debounce + throttle 的混合体)
function debounce(fn, delay = 300, options = {}) {
let timer = null
let lastArgs = null
let lastThis = null
let result = null
let lastCallTime = null // 上次调用的时间戳
// leading:第一次调用是否立即执行
// trailing:最后一次调用是否执行
// maxWait:最大等待时间(防饥饿)
const { leading = false, trailing = true, maxWait } = options
// 关键辅助函数:判断是否应该调用
function shouldInvoke(time) {
const timeSinceLastCall = time - lastCallTime
const timeSinceLastInvoke = time - lastInvokeTime
return (
lastCallTime === null ||
timeSinceLastCall >= delay ||
(maxWait !== undefined && timeSinceLastInvoke >= maxWait)
)
}
// 执行函数
function invokeFunc(time) {
const args = lastArgs
const thisArg = lastThis
lastArgs = lastThis = null
lastInvokeTime = time
result = fn.apply(thisArg, args)
return result
}
function debounced(...args) {
const time = Date.now()
const isInvoking = shouldInvoke(time)
lastArgs = args
lastThis = this
lastCallTime = time
if (isInvoking) {
if (timer === null) {
// leading 模式:首次调用直接执行
if (leading) {
invokeFunc(time)
}
// 设置 timer 处理 trailing 调用
timer = setTimeout(timerExpired, delay)
} else if (maxWait !== undefined) {
// 有 maxWait 时,即使 timer 存在也要检查是否超时
timer = setTimeout(timerExpired, delay)
}
}
// 没有 timer 时(首次/清理后),设一个
if (timer === null) {
timer = setTimeout(timerExpired, delay)
}
return result
}
function timerExpired() {
const time = Date.now()
// 检查是否应该调用(trailing 模式)
if (shouldInvoke(time)) {
return trailingEdge(time)
}
// 还没到时间,重设定时器
timer = setTimeout(timerExpired, delay - (time - lastCallTime))
}
function trailingEdge(time) {
timer = null
if (trailing && lastArgs) {
return invokeFunc(time)
}
lastArgs = lastThis = null
return result
}
// 取消等待中的调用
debounced.cancel = () => {
if (timer) clearTimeout(timer)
timer = null
lastInvokeTime = 0
lastArgs = lastCallTime = lastThis = null
}
// 立即执行并清空等待
debounced.flush = () => {
return timer === null ? result : trailingEdge(Date.now())
}
return debounced
}
节流的核心实现逻辑(时间戳 + 定时器双轨制):
function throttle(fn, interval = 300, options = { leading: true, trailing: true }) {
let lastTime = 0
let timer = null
return function (...args) {
const context = this
const now = Date.now()
// leading = false 时,第一次调用也不立即执行
if (!lastTime && options.leading === false) lastTime = now
// 计算距离下次执行还剩多少时间
const remaining = interval - (now - lastTime)
if (remaining <= 0) {
// 已经超过间隔了,立即执行
if (timer) {
clearTimeout(timer)
timer = null
}
lastTime = now
fn.apply(context, args)
} else if (options.trailing && !timer) {
// 还没到时间,但设置了 trailing,设个定时器兜底
timer = setTimeout(() => {
// trailing 执行时,更新 lastTime
// 如果 ledaing = true,lastTime = Date.now(),下一次 leading 从此刻开始算
// 如果 leading = false,lastTime = 0,保证下次还是从零开始
lastTime = options.leading ? Date.now() : 0
timer = null
fn.apply(context, args)
}, remaining)
}
}
}
lodash throttle 的实现本质:
// lodash 的 throttle 其实就一行:
function throttle(fn, interval, options) {
return debounce(fn, interval, { ...options, maxWait: interval })
}
// 为什么?因为 debounce + maxWait = throttle
// 防抖保证"安静后才执行",但 maxWait 保证"最多等 interval 时间必须执行一次"
// 两个约束同时生效,就是节流的效果
真实项目中的坑与决策:
场景 建议 原因
─────────────────────────────────────────────────────────────────
搜索输入(请求后端) debounce(300) 减少请求,等用户停
搜索输入(本地过滤) throttle(16) 1600+ 条数据本地过滤,按帧率算
自动保存草稿 debounce(2000, maxWait: 5000) 既防抖,又保证 5s 内必存
滚动加载更多 throttle(200, leading: true) 保证滚动过程按节奏加载
WebSocket 消息批量更新 debounce(100) 合并高频消息成一批更新 DOM
按钮点击提交 debounce(trailing: true) 只认最后一次点击
表单实时校验 throttle(200, leading: true) 用户输入时立刻验,但不过度
Canvas/WebGL 连续绘制 throttle(16) 60fps 正好一帧一次
面试追问:防抖节流的边界情况:
Q:防抖的 delay = 300,用户在第 290ms 又触发了一次,此时还剩多少时间?
A:重新计时 300ms。因为每次触发都 clearTimeout + setTimeout。
Q:节流的 leading: true, trailing: true,第 1ms 触发一次,第 400ms 触发一次
interval = 300ms,实际执行了几次?
A:两次。第 1ms leading 执行一次,第 400ms 触发的检查和计算:
remaining = 300 - (400 - 1) = -99 ≤ 0,所以立即执行——第 400ms 执行第二次。
trailing 没有机会触发,因为 leading 已覆盖。
Q:如果 interval = 300,用户在第 0ms、第 10ms、第 20ms 各触发一次,结果如何?
A:第 0ms leading 执行;第 10ms、第 20ms 进入 else-if,设一个 290ms 的 timer;
第 310ms trailing 执行(此时距第 20ms 正好 290ms)。
最终:执行两次(0ms 和 310ms),中间密集的调用被折叠。
Q:组件卸载时没有调用 .cancel(),会有什么后果?
A:如果防抖函数内有 setState / Pinia action,卸载后执行会触发"在已卸载组件上更新状态"
的 warning。如果在回调中操作了已销毁的 DOM 元素,还可能报错。
所以组件卸载时必须调用 debouncedFn.cancel()。
1.4 宏任务 vs 微任务执行顺序
问题:说出以下代码的输出
console.log('1')
setTimeout(() => console.log('2'), 0)
Promise.resolve().then(() => console.log('3'))
queueMicrotask(() => console.log('4'))
requestAnimationFrame(() => console.log('5'))
console.log('6')
答案:
输出顺序:1, 6, 3, 4, 2, 5
事件循环模型:
执行栈清空 → 清空微任务队列 → 渲染(update rendering)→ 取一个宏任务 → 循环
┌─────────────────────────────┐
│ 执行栈清空 │
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 清空微任务队列 │
│ • Promise.then/catch/finally│
│ • queueMicrotask │
│ • MutationObserver │
│ • process.nextTick (Node) │
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 渲染更新 (update rendering) │
│ • 执行 requestAnimationFrame│
└──────────┬──────────────────┘
▼
┌─────────────────────────────┐
│ 取一个宏任务 │
│ • setTimeout/setInterval │
│ • I/O │
│ • UI 事件 │
│ • postMessage │
└──────────┬──────────────────┘
▼
循环
为什么微任务优先于宏任务? 这是为了”承诺”——Promise 的 .then 表示”我一定会在下一个时机执行”,如果被宏任务插队,就违背了”尽快执行”的语义。
1.5 async/await 的底层实现
问题:async/await 是如何实现的?
答案:
async/await 本质上是 Generator + Promise 的语法糖。就像你点外卖——await 是”等外卖到了再继续做饭”,async 是告诉编译器”这个函数里有异步操作”。
V8 编译后的等价实现(简化版):
// 你的代码
async function fetchData() {
const res = await fetch('/api')
const data = await res.json()
return data
}
// 编译后等价于
function fetchData() {
return new Promise((resolve, reject) => {
const gen = fetchDataGenerator()
function step(method, arg) {
try {
const result = gen[method](arg)
if (result.done) {
resolve(result.value)
} else {
Promise.resolve(result.value).then(
val => step('next', val),
err => step('throw', err)
)
}
} catch (e) {
reject(e)
}
}
step('next')
})
}
// Generator 版本的函数
function* fetchDataGenerator() {
const res = yield fetch('/api')
const data = yield res.json()
return data
}
关键洞察:await 后面的表达式会被包装成 Promise,然后通过 step 递归实现”暂停后恢复”的效果。
实际表现:
async function test() {
console.log('A')
await Promise.resolve()
console.log('B')
}
test()
console.log('C')
// 输出: A, C, B
B 在 C 之后输出,因为 await 把后续代码放到了微任务中。
1.6 闭包的真实应用场景
问题:闭包在真实项目中除了防抖节流,还有哪些场景?
答案:
闭包就是”函数记住了它出生时的环境”——就像你记得小时候的家在哪里,即使后来搬走了。
场景1:封装私有变量
function createCounter() {
let count = 0
return {
increment() { count++; return this },
decrement() { count--; return this },
getCount() { return count }
}
}
Vue 3 的 setup 函数本质上就是闭包——组件实例销毁时,闭包里的变量被 GC 回收。
场景2:工厂函数与自定义事件
function createEventEmitter() {
const listeners = {}
return {
on(event, fn) { (listeners[event] ??= []).push(fn) },
emit(event, ...args) { (listeners[event] || []).forEach(fn => fn(...args)) },
off(event, fn) {
if (listeners[event]) listeners[event] = listeners[event].filter(f => f !== fn)
}
}
}
场景3:函数记忆(柯里化)
const memoize = (fn) => {
const cache = new Map()
return (...args) => {
const key = JSON.stringify(args)
if (cache.has(key)) return cache.get(key)
const result = fn(...args)
cache.set(key, result)
return result
}
}
场景4:循环中的异步问题
// 问题:全是5
for (var i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100)
}
// 闭包修复
for (var i = 0; i < 5; i++) {
((j) => setTimeout(() => console.log(j), 100))(i)
}
// let 本质也是闭包
for (let i = 0; i < 5; i++) {
setTimeout(() => console.log(i), 100) // 0,1,2,3,4
}
面试加分点:闭包并不会导致内存泄漏——泄漏的是使用不当的闭包(比如把 DOM 元素引用了但是 DOM 已移除)。只要闭包函数自己也被 GC 了,里面的变量自然释放。
1.7 Proxy vs Object.defineProperty
问题:Vue 3 为什么用 Proxy 替换 Object.defineProperty?
答案:
对比表格:
| 能力 | Object.defineProperty | Proxy |
|---|---|---|
| 监听数组变化 | 需要重写 7 个方法 | 原生支持 |
| 新增/删除属性 | 无法监听(Vue 2 用 Vue.set) | 原生监听 |
| 监听所有操作 | 只有 get/set | 13 种拦截器 |
| 性能 | 首次递归慢 | 懒代理,按需拦截 |
| 嵌套对象 | 一次性递归 | 访问时才代理 |
// Object.defineProperty —— 必须提前知道 key
const obj = {}
Object.defineProperty(obj, 'name', {
get() { return 'fixed' },
set(v) { /* ... */ }
})
obj.age = 25 // 完全监听不到!
// Proxy —— 无需预知 key
const proxy = new Proxy(obj, {
get(target, key) { return Reflect.get(target, key) },
set(target, key, value) {
console.log(`设置 ${key} = ${value}`) // 新增属性也能触发
return Reflect.set(target, key, value)
}
})
proxy.age = 25 // "设置 age = 25"
Proxy 的”懒代理”优势:Vue 3 在 reactive 时,只有当你访问了一个嵌套对象,才会对这个嵌套对象做代理。而 Vue 2 在初始化时就递归遍历所有属性。这就是为什么 Vue 3 在处理大型深度嵌套对象时性能更好的根本原因。
1.8 JS 几种循环的说明与区别
问题:for / for…of / for…in / forEach / map 有什么区别?各适合什么场景?
答案:
一句话速记:
for:万能但啰嗦 |for...of:遍历值,支持异步 |for...in:遍历键,适合对象 |forEach:简单遍历,不能中断 |map:遍历 + 变形,返回新数组
完整对比表:
| 特性 | for | for…of | for…in | forEach | map |
|---|---|---|---|---|---|
| 遍历目标 | 任意 | 可迭代对象(Array/Map/Set/String) | 对象的可枚举属性 | 数组 | 数组 |
| 拿到的是 | index + 手动取值 | 值 | 键(字符串) | 值、index、原数组 | 值、index、原数组 |
| 能否 break | ✅ | ✅ | ✅ | ❌ | ❌ |
| 能否 return / continue | ✅ | ✅ | ✅ | ❌ (continue ≈ return) | ❌ |
| await 同步 | ✅ 完全可控 | ✅ 完美支持 | ✅ 但几乎没人用 | ❌ 并发,不等待 | ❌ 并发,不等待 |
| 返回值 | 无 | 无 | 无 | 无 | 新数组 |
| 性能 | 最快 | 略慢于 for | 最慢 | 略慢于 for | 同 forEach |
| 空位处理 | 正常处理 | 返回 undefined |
不遍历 | 不遍历空位 | 保留空位 |
详细说明 + 代码场景:
1. for —— 经典循环
for (let i = 0; i < arr.length; i++) {
if (arr[i] < 0) break // ✅ 可以 break
if (arr[i] === 0) continue // ✅ 可以 continue
arr[i] *= 2
}
特点:最灵活,能拿到 index 和 value,支持所有流程控制。
缺点:啰嗦,容易下标越界,没有语义。
适用场景:需要精确控制步长、反向遍历、同时操作多个数组。
// ✅ 适合:两个数组同步遍历
for (let i = 0; i < names.length; i++) {
users[i] = { name: names[i], age: ages[i] }
}
// ✅ 适合:反向遍历
for (let i = arr.length - 1; i >= 0; i--) { /* ... */ }
// ✅ 适合:每隔一个取一个
for (let i = 0; i < arr.length; i += 2) { /* ... */ }
2. for…of —— 遍历可迭代对象
for (const value of arr) {
if (value < 0) break // ✅ 可以 break
console.log(value)
}
特点:拿到的是值,语法简洁,支持所有可迭代对象(Array、Map、Set、String、arguments、NodeList)。
为什么 for…of 能支持 await?
// ❌ forEach 不行——并发执行,不等待
async function processForEach(items) {
items.forEach(async (item) => {
await fetch(`/api/${item}`) // 所有请求同时发出,不等待
})
console.log('这里会在请求完成前就执行完') // 过早执行了 😱
}
// ✅ for...of 完美——每次都等待
async function processForOf(items) {
for (const item of items) {
await fetch(`/api/${item}`) // 依次请求,上一个完成才发下一个
}
console.log('这里等所有请求完成后才执行') // ✅
}
原理:for...of 底层用的是迭代器协议,每次循环都是同步等待 next() 的结果。而 forEach 是函数调用,传入 async 回调相当于扔了 3 个 Promise 进去,但 forEach 本身不 await 它们。
适用场景:
for (const [key, value] of map) // ✅ Map 遍历
for (const char of 'hello') // ✅ 字符串遍历
for (const node of document.querySelectorAll('div')) // ✅ NodeList
for (const item of set) // ✅ Set 遍历
3. for…in —— 遍历对象的可枚举属性
for (const key in obj) {
if (Object.hasOwn(obj, key)) { // 建议过滤原型链
console.log(key, obj[key])
}
}
特点:拿到的是键名(字符串),会遍历原型链上的可枚举属性。
误区:不要用 for…in 遍历数组:
const arr = [10, 20, 30]
arr.customProp = 'oops'
for (const i in arr) {
console.log(i) // "0", "1", "2", "customProp" 😱
}
为什么?因为数组也是对象,for...in 会把数组上附加的自定义属性也遍历出来。
适用场景:
// ✅ 适合:遍历普通对象
for (const key in user) {
if (Object.hasOwn(user, key)) { // ES2022 推荐用法
console.log(`${key}: ${user[key]}`)
}
}
// ✅ 适合:检查对象是否为空
function isEmpty(obj) {
for (const _ in obj) return false
return true
}
4. forEach —— 简单遍历数组
arr.forEach((value, index, array) => {
console.log(value, index)
// ❌ 不能 break,不能 return 跳过
// ❌ await 不生效(见上文)
})
特点:语义清晰,回调函数天然隔离作用域。
不能 break 的替代方案:
// 方案1:用 some 替代(短路)
arr.some(item => {
if (item < 0) return true // 相当于 break
console.log(item)
})
// 方案2:用 for...of(推荐)
for (const item of arr) {
if (item < 0) break
console.log(item)
}
// 方案3:抛异常(不推荐,像 hack)
try {
arr.forEach(item => {
if (item < 0) throw BreakSignal
console.log(item)
})
} catch (e) {}
空位处理:
const arr = [1, , 3] // 中间有一个空位
arr.forEach(v => console.log(v)) // 1, 3 —— 跳过空位
arr.map(v => v * 2) // [2, 空, 6] —— 保留空位
arr.filter(v => true) // [1, 3] —— 跳过空位
for (const v of arr) console.log(v) // 1, undefined, 3 —— 不跳过
5. map —— 映射(遍历 + 变形)
const doubled = arr.map((value, index) => value * 2)
// ✅ 返回一个新数组,长度与原数组相同
// ✅ 不修改原数组(immutable)
特点:返回新数组,适合值的转换,不产生副作用。
// ✅ 正确用法:纯粹的值映射
const userIds = users.map(u => u.id)
const displayNames = users.map(u => `${u.lastName}${u.firstName}`)
// ❌ 错误用法:在 map 里搞副作用
users.map(u => {
console.log(u.name) // 副作用 —— 应该用 forEach
sendEmail(u.email) // 副作用 —— 应该用 forEach
return u
})
6. 其他数组遍历方法一览
| 方法 | 返回 | 何时中断 | 用途 |
|---|---|---|---|
some |
boolean | ✅ 找到满足条件就停 | 检查是否有符合条件的元素 |
every |
boolean | ✅ 遇到不满足就停 | 检查是否全部符合 |
filter |
新数组 | ❌ | 筛选出符合条件的元素 |
find |
第一个元素或 undefined |
✅ 找到即停 | 查找符合条件的单个元素 |
findIndex |
index 或 -1 | ✅ 找到即停 | 查找符合条件的元素位置 |
reduce |
累计值 | ❌ | 汇总计算(求和、分组、拍平) |
flatMap |
新数组 | ❌ | map + flat 一步到位 |
// some —— 短路,找到即停
const hasNegative = arr.some(v => v < 0) // 遇到负数立即返回 true
// every —— 短路,遇到不满足即停
const allPositive = arr.every(v => v > 0) // 遇到非正数立即返回 false
// find —— 短路,找到即停
const firstAdult = users.find(u => u.age >= 18)
// reduce —— 万能累加器
const total = orders.reduce((sum, o) => sum + o.price, 0)
const groupByStatus = orders.reduce((acc, o) => {
(acc[o.status] ??= []).push(o)
return acc
}, {})
// flatMap —— 先 map 再拍平一层
const words = ['hello world', 'foo bar']
const tokens = words.flatMap(s => s.split(' ')) // ['hello','world','foo','bar']
总结:场景速查表
| 你想做的事 | 用哪个 |
|---|---|
| 纯遍历,不修改数据 | for...of 或 forEach |
| 需要 break / return 提前退出 | for...of 或 some / every / find |
| 遍历并转换成新数组 | map |
| 筛选符合条件的元素 | filter |
| 遍历对象的键值 | for...in(配合 hasOwn) |
| 异步依次执行(await 每个) | for...of |
| 需要 index 且需要 break | for |
| 反向遍历 / 跳跃遍历 | for |
| 遍历 Map / Set | for...of |
| 累加、分组、拍平等归约操作 | reduce |
| 检查是否有符合条件的 | some |
| 检查是否全部符合条件 | every |
2. TypeScript
2.1 手写实现 Partial / Required / Pick / Omit
问题:实现内置工具类型
答案:
type MyPartial<T> = {
[P in keyof T]?: T[P]
}
type MyRequired<T> = {
[P in keyof T]-?: T[P]
}
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
type MyOmit<T, K extends keyof T> = {
[P in Exclude<keyof T, K>]: T[P]
}
type MyReadonly<T> = {
readonly [P in keyof T]: T[P]
}
type MyRecord<K extends keyof any, V> = {
[P in K]: V
}
关键概念:
– keyof T 拿到 T 的所有 key 的联合类型
– [P in ...] 是映射类型(mapped type),遍历联合类型
– -? 意思是”去掉可选”;+? 是”加上可选”
– extends 是约束,确保泛型参数符合条件
2.2 infer 关键字 + 递归类型
问题:用 infer 实现获取 Promise 内部类型、数组元素类型
答案:
infer 好比”模式匹配中的变量声明”——当你匹配一个类型结构时,用 infer X 来”捕获”其中的一部分。
// 提取 Promise 的返回值类型
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type A = UnwrapPromise<Promise<string>> // string
// 递归提取
type DeepUnwrap<T> = T extends Promise<infer V> ? DeepUnwrap<V> : T
type C = DeepUnwrap<Promise<Promise<Promise<string>>>> // string
// 提取数组元素类型
type ArrayItem<T> = T extends (infer U)[] ? U : never
type D = ArrayItem<string[]> // string
// 提取函数参数和返回值
type FnParams<T> = T extends (...args: infer P) => any ? P : never
type FnReturn<T> = T extends (...args: any[]) => infer R ? R : never
实际项目应用:
type AxiosResponse<T = any> = { data: T; status: number }
type ExtractResponseData<T> = T extends AxiosResponse<infer D> ? D : T
2.3 给 axios 封装完整的请求/响应类型
问题:给团队写一个带完整类型的请求封装
答案:
interface ApiResponse<T = any> {
code: number
message: string
data: T
}
interface PaginatedData<T> {
list: T[]
total: number
page: number
pageSize: number
}
interface RequestConfig extends AxiosRequestConfig {
showLoading?: boolean
showError?: boolean
codeKey?: string
}
class HttpClient {
private instance: AxiosInstance
constructor() {
this.instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
timeout: 10000
})
this.setupInterceptors()
}
private setupInterceptors() {
this.instance.interceptors.request.use(config => {
const token = getToken()
if (token) config.headers.Authorization = `Bearer ${token}`
if ((config as RequestConfig).showLoading) showLoading()
return config
})
this.instance.interceptors.response.use(
res => {
hideLoading()
const data = res.data as ApiResponse
if (data.code !== 200) {
showError(data.message)
return Promise.reject(new Error(data.message))
}
return data.data
},
err => {
hideLoading()
handleHttpError(err)
return Promise.reject(err)
}
)
}
get<T = any>(url: string, params?: any, config?: RequestConfig): Promise<T> {
return this.instance.get(url, { params, ...config })
}
post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return this.instance.post(url, data, config)
}
getPage<T = any>(url: string, params: { page: number; pageSize: number }, config?: RequestConfig) {
return this.get<PaginatedData<T>>(url, params, config)
}
}
export const http = new HttpClient()
2.4 协变 / 逆变 / 双变
问题:什么是协变和逆变?
答案:
用”动物”和”猫”来理解:
– 协变:你是 () => Cat,我能当 () => Animal 用——返回值可以变得更宽泛
– 逆变:你是 (Animal) => void,我能当 (Cat) => void 用——参数可以变得更具体
– 双变:两边都可以
// 协变 —— 返回值
type ReturnCovariance = () => Cat
type ReturnAnimal = () => Animal // ✅ 可以赋值
// 逆变 —— 参数(严格模式下)
type ParamAnimal = (a: Animal) => void
type ParamCat = (a: Cat) => void // ✅ 可以赋值
// 为什么参数是逆变的?
// 如果你期待一个 (Animal) => void 的函数,
// 传入 (Cat) => void:调用时你传 Animal, 但函数只操作 Cat 的属性 —— 安全
// 反过来就不行:期待 (Cat) => void, 传入 (Animal) => void:
// 调用时你传 Cat, 但函数可能访问 Animal 独有属性 —— 不安全
TypeScript 通过 strictFunctionTypes: true 启用逆变(默认开启)。
3. Vue 2 → Vue 3 核心
3.1 Vue 3 响应式原理(完整链路)
问题:Vue 3 的响应式是如何工作的?
答案:
浅出理解:想象你是一个快递站站长(reactive),你的快递单(Proxy)上记录了每个包裹(属性)的状态。当有人取走包裹(get),你就在本子上记下他的名字(track)。当有人退回包裹(set),你就翻开本子,通知所有记了名字的人(trigger)。
源码级链路:
第一步:reactive 创建 Proxy
function reactive(target) {
if (target?.[ReactiveFlags.IS_REACTIVE]) return target
return new Proxy(target, baseHandlers)
}
第二步:get 中 track(收集依赖)
const baseHandlers = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, TrackOpTypes.GET, key)
if (isObject(result)) return reactive(result) // 懒代理
return result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const hadKey = hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
if (hadNewKey || hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
return result
}
}
第三步:track 的细节
const targetMap = new WeakMap()
function track(target, type, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, depsMap = new Map())
let deps = depsMap.get(key)
if (!deps) depsMap.set(key, deps = new Set())
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
第四步:trigger 的细节
function trigger(target, type, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const effects = new Set()
if (key !== void 0) {
depsMap.get(key)?.forEach(eff => effects.add(eff))
}
if (type === TriggerOpTypes.ADD && Array.isArray(target)) {
depsMap.get('length')?.forEach(eff => effects.add(eff))
}
effects.forEach(eff => {
if (eff !== activeEffect) {
eff.scheduler ? eff.scheduler(eff) : eff.run()
}
})
}
关键设计决策:
1. 懒代理:Vue 2 递归;Vue 3 访问到才代理嵌套对象
2. WeakMap:组件销毁后,targetMap 自动清理 → 无内存泄漏
3. activeEffect 全局变量:用栈结构支持嵌套 effect
3.2 ref / reactive / shallowRef / shallowReactive 差异
问题:这几种响应式 API 有什么区别?
答案:
| API | 深层响应 | 支持原始类型 | 重新赋值 | 适用场景 |
|---|---|---|---|---|
ref |
✅ 深层 | ✅ | ✅ 整体替换 | 基础类型、需要整体替换的对象 |
reactive |
✅ 深层 | ❌ 必须对象 | ❌ 不能整体替换 | 深层嵌套对象、表单数据 |
shallowRef |
❌ 只 .value |
✅ | ✅ | 大对象只需整体替换 |
shallowReactive |
❌ 只第一层 | ❌ | ❌ | 只关注第一层变化 |
// ref —— 任何类型,通过 .value 访问
const count = ref(0)
count.value = 1
// reactive —— 只能对象,直接访问属性
const state = reactive({ count: 0 })
state.count = 1
// shallowRef —— 只有 .value 的变化触发响应
const list = shallowRef([1, 2, 3])
list.value.push(4) // ❌ 不触发更新
list.value = [1, 2, 3, 4] // ✅ 触发更新
// shallowReactive —— 只有第一层属性变化触发响应
const obj = shallowReactive({ a: { b: 1 } })
obj.a = { b: 2 } // ✅ 触发
obj.a.b = 2 // ❌ 不触发
性能优化场景:
// 大列表用 shallowRef
const bigList = shallowRef([])
async function loadData() {
const data = await fetch('/big-list')
bigList.value = data // 只触发一次响应
}
3.3 watch / watchEffect / watchPostEffect 的区别
问题:这三种”监听”方式有什么区别?
答案:
| API | 是否自动收集依赖 | 是否 lazy | 能否拿到旧值 | 执行时机 |
|---|---|---|---|---|
watch |
❌ 显式指定源 | ✅ 默认不执行 | ✅ | pre(默认) |
watchEffect |
✅ 自动收集 | ❌ 立即执行 | ❌ | pre(默认) |
watchPostEffect |
✅ 自动收集 | ❌ 立即执行 | ❌ | post(DOM 更新后) |
watchSyncEffect |
✅ 自动收集 | ❌ 立即执行 | ❌ | sync(同步) |
const count = ref(0)
// watch —— 你告诉它看什么
watch(count, (newVal, oldVal) => {
console.log(`从 ${oldVal} 变成 ${newVal}`)
})
// watchEffect —— 它自己识别看什么
watchEffect(() => {
console.log(`count 是 ${count.value}`)
})
// watchPostEffect —— 等 DOM 更新完再执行
watchPostEffect(() => {
// 这里可以访问更新后的 DOM
})
场景选择:
// 需要根据旧值做判断 → watch
watch(route, (to, from) => {
if (to.path !== from.path) analytics.trackPage(to.path)
})
// 不需要旧值,但要自动收集 → watchEffect
watchEffect(() => {
localStorage.setItem('form', JSON.stringify(formData))
})
// 需要等待 DOM 更新 → watchPostEffect
watchPostEffect(() => {
if (scrollRef.value) scrollRef.value.scrollTop = scrollRef.value.scrollHeight
})
3.4 组件通信的 8+ 种方式
问题:Vue 3 中组件间如何通信?
答案:
父子通信:
├─ props / emit
├─ v-model(语法糖)
├─ ref / defineExpose
├─ attrs / $attrs
├─ slots / scoped slots
跨级/任意:
├─ provide / inject
├─ mitt / EventBus
全局状态:
├─ Pinia / Vuex
├─ 全局属性(app.config.globalProperties)
// 1. props / emit
// 父
<Child :name="name" @update="handleUpdate" />
// 子
const props = defineProps<{ name: string }>()
const emit = defineEmits<{ update: [value: string] }>()
// 2. v-model(Vue 3 支持多个)
// 父
<Child v-model:name="name" v-model:age="age" />
// 子 —— Vue 3.4+ 用 defineModel
const name = defineModel<string>('name')
// 3. ref / defineExpose
const childRef = ref<InstanceType<typeof Child>>()
childRef.value?.someMethod()
// 子
defineExpose({ someMethod })
// 4. provide / inject
// 祖先
provide('theme', { color: 'blue' })
// 后代
const theme = inject('theme', { color: 'default' })
// 5. mitt —— 全局 EventBus
const bus = mitt<{ 'user-login': User; 'logout': void }>()
bus.emit('user-login', user)
onUnmounted(() => bus.all.clear()) // 记得清理
// 6. Pinia
const store = useUserStore()
store.login(user)
// 7. attrs
const attrs = useAttrs()
// 8. slots
const slots = useSlots()
3.5 路由守卫执行顺序
问题:完整的导航解析流程是什么?
答案(从 A 页面跳转到 B 页面):
1. 导航被触发
2. 在失活的组件里调用 beforeRouteLeave(A 组件)
3. 调用全局的 beforeEach
4. 在重用的组件里调用 beforeRouteUpdate(同一路由不同参数)
5. 调用路由配置里的 beforeEnter(B 路由)
6. 解析异步路由组件
7. 在激活的组件里调用 beforeRouteEnter(B 组件)
8. 调用全局的 beforeResolve
9. 导航被确认
10. 更新 DOM
11. 调用全局的 afterEach
12. beforeRouteEnter 中的 next 回调执行
实际项目使用:
// 全局:登录鉴权
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isLoggedIn()) return '/login'
})
// 路由级:页面权限
const routes = [{
path: '/admin',
beforeEnter: (to, from) => {
if (!hasRole('admin')) return '/403'
}
}]
// 组件内:表单未保存提醒
onBeforeRouteLeave((to, from) => {
if (formDirty.value) return window.confirm('有未保存的修改,确定离开吗?')
})
4. uni-app 专题
4.1 uni-app 的编译流程
问题:uni-app 的跨端原理是什么?
答案:
uni-app 就像是一个”翻译官”——你写的 Vue 代码是”中文”,它帮你翻译成”英语(H5)”、”日语(微信小程序)”、”韩语(App)”等各种语言。
编译流程:
你的源码(.vue / .ts / .js)
│
▼
webpack / vite 打包
│
▼
条件编译预处理(#ifdef / #ifndef)
│
▼
┌────────────────┬───────────────┬──────────────┐
│ 编译为 H5 │ 编译为小程序 │ 编译为 App │
│ (传统web目标) │ (自定义组件树) │ (weex/nvue) │
└────────────────┴───────────────┴──────────────┘
条件编译的原理:在编译阶段,uni-app 的 loader 会扫描代码中的 #ifdef / #ifndef 注释,根据当前编译目标(process.env.UNI_PLATFORM)删除不需要的代码块。这就是为什么条件编译的逻辑在注释里——它们在运行时不存在,而是构建时就被剔除。
各端差异的本质:
| 端 | 渲染引擎 | 组件系统 | API 层 |
|---|---|---|---|
| H5 | DOM | HTML 标签 | 浏览器 API |
| 微信小程序 | 小程序视图层 | 自定义组件 | wx API |
| App(vue) | weex/nvue | 原生组件 | plus API |
| App(uni-app x) | Swift/Kotlin 原生 | 原生组件 | uni API |
uni-app x 新方案:用 Swift(iOS)和 Kotlin(Android)直接渲染原生界面,编译时把 Vue 模板翻译成原生视图代码。实时性更好,但插件生态还在建设中。
4.2 小程序与 H5 的 API 差异处理
问题:如何处理各端 API 差异?
答案:
方案1:条件编译
// #ifdef MP-WEIXIN
wx.login({ success: (res) => { /* ... */ } })
// #endif
// #ifdef H5
// 使用 OAuth 或其他登录方式
// #endif
方案2:统一封装(推荐)
// utils/login.ts
export function login(): Promise<LoginResult> {
// #ifdef MP-WEIXIN
return new Promise((resolve, reject) => {
uni.login({
provider: 'weixin',
success: (res) => resolve(res),
fail: (err) => reject(err)
})
})
// #endif
// #ifdef H5
return new Promise((resolve) => {
// 跳转登录页
resolve(/* ... */)
})
// #endif
}
常见差异对照表:
| 功能 | H5 | 小程序 | App |
|---|---|---|---|
| 路由跳转 | uni.navigateTo |
同左 | 同左,但有动画差异 |
| 存储 | localStorage 或 uni.setStorageSync |
uni.setStorageSync(10MB) |
uni.setStorageSync 或 plus.storage |
| 网络请求 | uni.request(无跨域限制) |
uni.request(需配置合法域名) |
uni.request(需域名白名单) |
| 支付 | 扫码/支付宝H5 | 微信支付/H5支付 | 微信支付/支付宝支付 |
| 分享 | Share API | 小程序原生分享 | 分享到微信/系统 |
| 登录 | OAuth/短信 | wx.login | 微信登录/手机号 |
4.3 App 端 nvue vs vue 页面选择策略
问题:什么时候用 nvue,什么时候用 vue?
答案:
| 维度 | Vue 页面(WebView) | nvue 页面(原生渲染) |
|---|---|---|
| 渲染方式 | WebView 渲染 | Weex 原生渲染 |
| 性能 | 复杂 UI 卡顿 | 流畅,尤其滚动列表 |
| CSS 支持 | 全部 CSS | 子集(flexbox 为主) |
| 调试 | 浏览器 DevTools | 需要原生调试 |
| 开发效率 | 高 | 低(限制多) |
选择策略:
// ✅ 用 nvue 的场景
'pages/list/index': 'nvue', // 长列表、无限滚动
'pages/message/index': 'nvue', // 聊天记录、消息流
'pages/map/index': 'nvue', // 地图交互
// ✅ 用 vue 的场景
'pages/login/index': 'vue', // 简单表单
'pages/detail/index': 'vue', // 文章详情
'pages/webview/index': 'vue', // 就是 WebView
nvue 的局限性:
/* nvue 不支持的 CSS */
/* ❌ 不支持 */
position: fixed;
position: absolute;
border-radius: 50%;
box-shadow;
background-image: url();
/* ✅ 推荐使用 */
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 10px;
background-color: #fff;
4.4 分包加载策略
问题:如何优化小程序/App 的包体积?
答案:
就像搬家——不要把所有东西塞进同一个箱子(主包),而是按功能模块分箱。
分包配置:
{
"pages": [
{ "path": "pages/index/index" },
{ "path": "pages/login/index" }
],
"subPackages": [
{
"root": "pages-order",
"pages": [
{ "path": "list/list" },
{ "path": "detail/detail" }
]
},
{
"root": "pages-user",
"pages": [
{ "path": "profile/profile" },
{ "path": "setting/setting" }
],
"independent": true
}
],
"preloadRule": {
"pages/index/index": {
"network": "all",
"packages": ["pages-order"]
}
}
}
分包大小限制:
| 平台 | 主包限制 | 分包限制 |
|---|---|---|
| 微信小程序 | 2MB | 20MB |
| 支付宝小程序 | 2MB | 8MB |
| App(安卓) | 无硬限制 | 建议 ≤ 50MB |
| App(iOS) | 无硬限制 | 建议 ≤ 100MB |
分包策略经验:
主包(≤ 2MB):核心功能
├── 首页(必须有)
├── 登录注册
├── 公共组件(导航栏、TabBar)
├── 公共工具库
└── 基础样式
分包A:商品模块
├── 商品列表
├── 商品详情
└── 购物车
分包B:用户模块(独立分包)
├── 个人中心
├── 设置
└── 关于我们
4.5 上架经验(实战)
问题:你处理过哪些上架问题?
答案:
Android 各大应用商店差异:
华为:审核最严,隐私政策必须完整,权限声明要一一对应
小米:对 APK 体积敏感,超过 150MB 需要用扩展文件
OPPO:对推送服务有要求,建议用 OPPO Push
vivo:对 targetSdkVersion 有最低要求(Android 13+)
应用宝:量大,但审核周期长(3-7天),建议首发
常见被拒原因 & 解决:
// 1. 隐私政策问题(最高频)
onLaunch(() => {
showPrivacyDialog(() => {
initSDK()
getUserInfo()
})
})
// 2. 权限问题——使用时才申请
function takePhoto() {
uni.authorize({
scope: 'scope.camera',
success: () => { /* 打开相机 */ },
fail: () => { /* 提示用户去设置开启 */ }
})
}
// 3. targetSdkVersion 要求
{
"app-plus": {
"distribute": {
"google": { "targetSdkVersion": 33 }
}
}
}
iOS App Store 高频被拒:
// 1. 使用了私有 API —— uni-app 默认已用 WKWebView
// 2. 登录方式不合规 —— 有非苹果登录必须支持 Apple 登录
{
"app-plus": {
"oauth": {
"apple": { "enable": true }
}
}
}
// 3. 截图/预览图不是最新版本 —— 每次提审前更新截图
热更新方案对比:
| 方案 | 适用平台 | 是否需审核 | 更新内容 |
|---|---|---|---|
| wgt 包 | App | 不需要 | 前端资源(js/css) |
| 整包更新 | App | 需要重新上架 | 原生代码+资源 |
| 小程序 | 微信 | 需要审核 | 前端代码 |
经验总结:
– iOS 审核平均 1-3 天,最好避开周五下午提审
– 安卓商店优先提交华为和应用宝
– 保持一个”上架检查清单”,每次提审前逐项打勾
5. 性能优化
5.1 首屏白屏优化方案汇总
问题:前端首屏性能优化有哪些手段?
答案:
核心指标:FP(首次绘制)、FCP(首次内容渲染)、LCP(最大内容渲染)、INP(交互到下一次绘制)
1. 资源加载层面
<!-- 预加载关键资源 -->
<link rel="preload" href="font.woff2" as="font" crossorigin>
<link rel="preconnect" href="https://api.example.com">
<link rel="dns-prefetch" href="//cdn.example.com">
<!-- 预加载首屏图片 -->
<link rel="preload" href="hero.webp" as="image">
2. 代码层面
// 路由级懒加载(Vue 3)
const UserList = () => import('@/views/UserList.vue')
// 组件级懒加载
defineAsyncComponent(() => import('@/components/HeavyChart.vue'))
// 图片懒加载
const ImageComponent = {
mounted() {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
this.img.src = this.src
observer.disconnect()
}
})
observer.observe(this.$el)
}
}
3. 构建层面
// vite.config.ts —— 分包策略
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['element-plus', '@ant-design/icons-vue'],
}
}
},
// 开启 CSS 代码分割
cssCodeSplit: true,
}
})
4. SSR / SSG
// Nuxt 3 / VitePress 等框架支持
// SSR 直接返回渲染后的 HTML,白屏时间从 2s → 300ms
5. 骨架屏
<template>
<Skeleton v-if="loading" />
<RealContent v-else />
</template>
5.2 浏览器渲染流程与重排/重绘
问题:浏览器的渲染流程是什么?如何减少重排?
答案:
渲染流水线:
DOM Tree + CSSOM Tree → Render Tree → Layout(布局)→ Paint(绘制)→ Composite(合成)
- 重排(Reflow):改变元素的尺寸、位置、增删 DOM,触发布局重新计算
- 重绘(Repaint):改变颜色、背景、阴影等不影响布局的属性,跳过 Layout
- 合成(Composite):使用
transform/opacity变化,只触发合成层,性能最好
触发重排的操作:
// ❌ 触发重排
el.style.width = '100px' // 改变几何属性
el.offsetHeight // 读取布局属性也会强制重排
el.classList.add('new-class') // 可能改变布局
// ✅ 只触发合成
el.style.transform = 'translateX(100px)'
el.style.opacity = '0.5'
el.style.willTransform = 'transform' // 提升为合成层
最佳实践:
// ❌ 批量触发重排
for (let i = 0; i < 1000; i++) {
el.style.left = `${i}px` // 每次循环都重排
}
// ✅ 使用 transform
el.style.transform = `translateX(${i}px)`
// ✅ 使用 requestAnimationFrame 合并操作
requestAnimationFrame(() => {
el.style.width = '100px'
el.style.height = '200px'
})
5.3 虚拟列表实现
问题:实现一个虚拟列表(固定高度和动态高度)
答案:
核心原理:只渲染可视区域内的 DOM 节点,滚动时动态替换内容。
固定高度虚拟列表:
<template>
<div ref="container" class="virtual-list" @scroll="onScroll">
<!-- 撑开滚动条 -->
<div :style="{ height: totalHeight + 'px' }">
<!-- 可视区域内容偏移 -->
<div :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in visibleItems" :key="item.id" class="item">
{{ item.name }}
</div>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
items: { type: Array, required: true },
itemHeight: { type: Number, default: 50 },
bufferSize: { type: Number, default: 5 }
})
const container = ref(null)
const scrollTop = ref(0)
const totalHeight = computed(() => props.items.length * props.itemHeight)
const visibleRange = computed(() => {
const start = Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize)
const end = Math.min(
props.items.length,
start + Math.ceil(container.value?.clientHeight / props.itemHeight) + props.bufferSize * 2
)
return { start, end }
})
const visibleItems = computed(() => props.items.slice(visibleRange.value.start, visibleRange.value.end))
const offsetY = computed(() => visibleRange.value.start * props.itemHeight)
const onScroll = () => {
scrollTop.value = container.value.scrollTop
}
</script>
动态高度虚拟列表:核心变化是维护一个positions数组,记录每个元素的累积高度,用二分查找定位当前滚动位置对应的起始索引。
// 动态高度的核心数据结构
const positions = ref<{ height: number; top: number; bottom: number }[]>([])
function updatePositions() {
let top = 0
positions.value = props.items.map((item, index) => {
const height = estimateHeight(item) // 预估或实际测量
const entry = { height, top, bottom: top + height }
top += height
return entry
})
}
// 二分查找当前滚动位置对应的索引
function findIndex(scrollTop: number) {
let left = 0, right = positions.value.length - 1
while (left <= right) {
const mid = Math.floor((left + right) / 2)
if (positions.value[mid].top < scrollTop) {
left = mid + 1
} else {
right = mid - 1
}
}
return left
}
5.4 Webpack 5 vs Vite 核心差异
问题:Webpack 5 和 Vite 的核心区别是什么?
答案:
| 维度 | Webpack 5 | Vite |
|---|---|---|
| 开发服务器 | 全量打包后启动 | 按需编译,秒启动 |
| HMR 速度 | 修改一个文件可能重建整个 chunk | 只更新一个模块 |
| 打包方式 | 统一 bundle | 开发用 esbuild,生产用 Rollup |
| 配置复杂度 | 高(loader/plugin 体系复杂) | 低(开箱即用) |
| 语法支持 | 需配置 Babel | 原生 ESM,TS 开箱支持 |
Vite 为何快?
传统打包工具(Webpack):
你的源码 → 打包成一个 bundle → 启动服务器
Vite:
你的源码(ESM) ← 浏览器直接请求 ← 启动服务器
↓
按需编译(esbuild)
核心差异:
1. 开发环境:Vite 利用浏览器原生 ESM,不需要打包,只编译修改的文件
2. 预构建:Vite 用 esbuild(Go 编写)预构建 node_modules,比 Webpack 的 JS 打包快 10-100x
3. 生产构建:Vite 用 Rollup(更准确地处理 Tree-shaking),而 Webpack 的实现更复杂
// Vite 对比 Webpack 的配置差异
// webpack.config.js —— 需要 loader
module.exports = {
module: {
rules: [
{ test: /\.vue$/, use: 'vue-loader' },
{ test: /\.ts$/, use: 'ts-loader' },
]
}
}
// vite.config.ts —— 开箱即用
export default defineConfig({
plugins: [vue()] // Vue SFC 和 TS 都自动支持
})
什么时候选 Webpack?:
– 老项目迁移成本高
– 需要高度自定义的打包流程
– 复杂的 code splitting 策略
– 兼容低版本浏览器需要大量 polyfill
什么时候选 Vite?:
– 新项目
– 开发体验优先
– 现代浏览器目标
– 中小型项目(大型项目 Vite 首屏可能变慢,因为按需加载太多请求)
6. 工程化 & 架构
6.1 Monorepo 方案对比
问题:大型前端项目如何做 Monorepo 管理?
答案:
主流方案:
| 方案 | 包管理 | 优点 | 缺点 |
|---|---|---|---|
| pnpm workspace | pnpm | 最省磁盘、严格依赖隔离、速度快 | 生态略少 |
| turborepo | pnpm/yarn/npm | 缓存构建、并行任务、管道编排 | 需要额外学习 |
| nx | 任意 | 最全面(测试/构建/依赖图/缓存) | 配置重、学习曲线陡 |
| lerna | npm/yarn | 最老牌、简单 | 功能少、维护慢 |
推荐的 Monorepo 结构(pnpm + turborepo):
my-project/
├── packages/
│ ├── ui/ # 组件库
│ ├── utils/ # 工具函数
│ └── types/ # 共享类型定义
├── apps/
│ ├── web/ # H5 应用
│ ├── admin/ # 管理后台
│ └── mini-app/ # 小程序
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
// turbo.json —— 管道编排
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"], // 先构建依赖
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Monorepo 的好处:
– 代码共享:组件库、工具函数、类型定义统一管理
– 原子提交:跨包修改一次提交,版本一致
– CI 优化:只构建变更相关的包
6.2 微前端方案对比
问题:微前端方案有哪些?如何选型?
答案:
| 方案 | 技术栈 | 隔离方式 | 通信 | 适用场景 |
|---|---|---|---|---|
| qiankun | 任意 | JS sandbox + CSS scope | props / EventBus | 老项目迁移 |
| micro-app | 任意 | Web Components + Shadow DOM | 自定义事件 | 新项目 |
| wujie | 任意 | Web Components + iframe | props / EventBus | 安全要求高 |
| Module Federation | Webpack 5 | 运行时加载 | shared 机制 | 同一技术栈 |
| EMP | Webpack 5 | 类似 Module Federation | shared | 跨框架 |
qiankun 的核心原理:
主应用加载子应用 → 创建沙箱环境 → 执行子应用入口
↓
proxy window 对象
拦截全局变量/事件
↓
挂载到指定 DOM 节点
// 主应用注册子应用
registerMicroApps([
{
name: 'app-vue',
entry: '//localhost:7101',
container: '#sub-app',
activeRule: '/vue',
props: { userInfo, globalStore }
},
{
name: 'app-react',
entry: '//localhost:7102',
container: '#sub-app',
activeRule: '/react',
}
])
start({ sandbox: { experimentalStyleIsolation: true } })
选型建议:
老项目(jQuery/老Vue)需要逐步迁移 → qiankun
全新项目需要高隔离性 → micro-app / wujie
同一技术栈(全部 Vue 3)→ Module Federation
安全/隔离要求极高 → wujie(iframe 沙箱)
团队小、维护能力有限 → 慎用微前端,宁可做大 SPA
6.3 Vite HMR 原理
问题:Vite 的热更新为什么这么快?
答案:
传统 Webpack HMR 流程:
修改文件 → 重新编译该模块及其依赖 → 生成更新 chunk → WebSocket 通知浏览器
↓
每次修改需要重新编译整个 chunk(即使只改了一个文件)
Vite HMR 流程:
修改文件 → esbuild 编译(毫秒级)→ WebSocket 通知浏览器
↓
浏览器直接 import 该模块的 ESM 路径 → 浏览器自己处理依赖
关键差异:
- 原生 ESM:浏览器直接请求单个文件,不需要打包
- esbuild 预构建:node_modules 用 Go 写的 esbuild 预打包,比 js 打包快 10-100x
- 按需编译:只编译修改的文件,不编译无关模块
- 精确失效:Vite 通过 import 关系图,只让修改的模块及其直接引用者失效
// Vite HMR API(可以在组件中使用)
if (import.meta.hot) {
import.meta.hot.accept((newModule) => {
// 模块更新后的回调
})
import.meta.hot.dispose(() => {
// 模块被替换前的清理
})
}
Vite 的冷启动为什么也快?
Webpack:先扫描所有模块 → 打包成 bundle → 启动服务器
↑ 这个"先扫描所有模块"在大项目中可能需要 30s-60s
Vite:启动服务器(几乎瞬间)→ 浏览器请求时才编译
↑ 首次打开页面时,只编译首页需要的文件
6.4 前端 CI/CD 与 Docker 部署
问题:前端项目如何做 CI/CD 和 Docker 部署?
答案:
CI/CD 流程(GitHub Actions):
# .github/workflows/deploy.yml
name: Deploy Frontend
on:
push:
branches: [main, staging]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test
- run: pnpm build
- name: Deploy to OSS
uses: easingthemes/ssh-deploy@v4
with:
source: 'dist/'
target: '/var/www/html'
# 版本管理:带 hash 的资源名,不需要清理旧版本
# 回滚时只需修改 Nginx 的 index.html 指向
Docker 多阶段构建:
# Dockerfile
# 阶段1:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY pnpm-lock.yaml package.json ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# 阶段2:运行
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf —— 前端 SPA 路由配置
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html;
index index.html;
# 静态资源缓存(文件名带 hash)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA 路由回退
location / {
try_files $uri $uri/ /index.html;
}
# 开启 gzip
gzip on;
gzip_types text/css application/javascript image/svg+xml;
}
灰度发布策略:
蓝绿部署:两套环境(蓝/绿),切流量
金丝雀:先发布到 5% 用户,观察无问题后全量
A/B 测试:不同用户看到不同版本,用于功能验证
前端实现方案:
1. Nginx 根据 cookie/header 分流
2. 通过 CDN 灰度(如阿里云 CDN 的灰度功能)
3. 前端运行时拉取特性开关配置
7. HTTP / 网络 / 安全
7.1 HTTP/1.1 → HTTP/2 → HTTP/3 关键变化
问题:HTTP 各版本的核心变化是什么?
答案:
| 特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 传输 | 文本 | 二进制帧 | 基于 QUIC (UDP) |
| 多路复用 | ❌ 队头阻塞 | ✅ 一个连接多个流 | ✅ 多个流互不干扰 |
| 头部压缩 | ❌ | ✅ HPACK | ✅ QPACK |
| 队头阻塞 | 请求级 | 连接级(TCP) | 无(UDP) |
| 服务端推送 | ❌ | ✅ | ✅ |
| 连接 | 每个请求一个连接 | 多路复用单连接 | 0-RTT 连接 |
HTTP/1.1 的问题:
– 队头阻塞:一个请求慢,后面排队等
– 重复连接:一个域名通常开 6 个连接
– 头部冗余:每次请求都带完整的 cookie/user-agent 等
HTTP/2 解决了什么:
– 二进制分帧:所有请求在一个 TCP 连接上并发
– 头部压缩:HPACK 算法,动态表 + 静态表
– 服务器推送:服务器可以主动推送资源
HTTP/3 为什么用 QUIC:
– HTTP/2 的队头阻塞在 TCP 层仍然存在——丢包时整个连接阻塞
– QUIC 基于 UDP,多个流独立,丢包只影响一个流
– 0-RTT 连接:建立连接的同时发送数据
// Nginx 开启 HTTP/2
server {
listen 443 ssl http2; # 加上 http2
# HTTP/3(需要对应 Nginx 版本)
listen 443 quic reuseport;
}
7.2 WebSocket 断线重连 & 心跳
问题:如何实现 WebSocket 的稳定连接?
答案:
class WebSocketClient {
private ws: WebSocket | null = null
private url: string
private reconnectAttempts = 0
private maxReconnectAttempts = 10
private reconnectInterval = 1000 // 初始 1s,指数退避
private heartbeatTimer: NodeJS.Timer | null = null
private isManualClose = false
constructor(url: string) {
this.url = url
this.connect()
}
connect() {
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.reconnectAttempts = 0 // 连接成功重置
this.startHeartbeat()
console.log('WebSocket 已连接')
}
this.ws.onclose = () => {
if (!this.isManualClose) {
this.reconnect()
}
}
this.ws.onerror = (err) => {
console.error('WebSocket 错误', err)
this.ws?.close()
}
this.ws.onmessage = (event) => {
// 收到服务端消息,重置心跳(服务端可能返回 pong)
this.resetHeartbeat()
this.handleMessage(event.data)
}
}
private reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket 重连次数已达上限')
return
}
// 指数退避:1s, 2s, 4s, 8s, 16s...
const delay = this.reconnectInterval * Math.pow(2, this.reconnectAttempts)
this.reconnectAttempts++
console.log(`WebSocket 将在 ${delay}ms 后重连(第 ${this.reconnectAttempts} 次)`)
setTimeout(() => this.connect(), delay)
}
private startHeartbeat() {
this.heartbeatTimer = setInterval(() => {
this.ws?.send(JSON.stringify({ type: 'ping' }))
}, 30000) // 30s 发送一次心跳
}
private resetHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
}
this.startHeartbeat()
}
send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data))
} else {
console.warn('WebSocket 未连接,消息已缓存')
// 可以缓存消息,等重连后发送
}
}
close() {
this.isManualClose = true
this.heartbeatTimer && clearInterval(this.heartbeatTimer)
this.ws?.close()
}
private handleMessage(data: string) {
try {
const msg = JSON.parse(data)
// 业务消息处理
if (msg.type === 'pong') return // 心跳响应,不用处理
// 触发自定义事件
this.onMessage?.(msg)
} catch (e) {
console.error('消息解析失败', e)
}
}
onMessage: ((data: any) => void) | null = null
}
关键设计:
1. 指数退避重连:避免频繁重连消耗服务器资源
2. 心跳机制:检测死连接(30s ping,超时关闭)
3. 手动关闭标记:正常关闭不触发重连
4. 消息缓存:断线期间的消息可以缓存,重连后发送
7.3 XSS / CSRF 防护
问题:前端常见的安全攻击及防护措施
答案:
XSS(跨站脚本攻击)
类型:
– 反射型:URL 参数直接拼入 HTML
– 存储型:恶意代码存入数据库,其他用户访问时执行
– DOM 型:通过 DOM API 注入
<!-- ❌ 危险代码 —— 直接插入用户输入 -->
<div v-html="userInput"></div>
<!-- ✅ 安全用法 -->
<div>{{ userInput }}</div>
<div v-text="userInput"></div>
<!-- ✅ 有节制地使用 v-html -->
<div v-html="sanitizeHtml(userInput)"></div>
前端防护:
// 1. 输入过滤
function sanitizeHtml(str: string): string {
const div = document.createElement('div')
div.textContent = str // textContent 自动转义
return div.innerHTML
}
// 2. CSP 策略
// 在 HTTP 头或 meta 标签中配置
// Content-Security-Policy: default-src 'self'; script-src 'self' cdn.example.com
// 3. 设置 Cookie 的 HttpOnly
document.cookie = 'token=xxx; HttpOnly; Secure; SameSite=Strict'
// 4. 使用 DOMPurify 库(推荐)
import DOMPurify from 'dompurify'
const clean = DOMPurify.sanitize(dirtyHtml)
CSRF(跨站请求伪造)
攻击方式:用户在 A 网站的登录态(cookie)被 B 网站的恶意请求利用
防护:
// 1. SameSite Cookie(最简单有效)
// Set-Cookie: session=xxx; SameSite=Strict
// 2. CSRF Token
// 后端生成 token → 前端提交时带上 → 后端验证
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
axios.post('/api/transfer', { amount, to, _csrf: csrfToken })
// 3. 验证 Referer / Origin 头
// 后端检查请求的 Referer 是否来自本站
// 4. 自定义 Header
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
密码安全(前端):
// 即使 HTTPS,前端也可以先做哈希,防止明文传输
async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(password + salt) // 加盐
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
7.4 OAuth 2.0 / JWT 完整流程
问题:OAuth 2.0 和 JWT 的工作原理
答案:
OAuth 2.0 授权码模式(最安全,最常用):
用户 → 第三方应用 → 授权服务器 → 资源服务器
┌────────┐ 1. 跳转授权页 ┌──────────┐
│ 用户 │ ──────────────────→ │ 授权服务器 │
│ │ │ │
│ │ 2. 用户登录确认 │ │
│ │ ←────────────────── │ │
│ │ │ │
│ │ 3. 返回授权码 │ │
│ │ ←────────────────── │ │
└────────┘ └──────────┘
↓ ↑
4. 授权码给第三方 6. 返回 access_token
↓ ↑
┌──────────────────────────────────────────┐
│ 第三方应用后端 │
│ 5. 授权码 + client_secret → 换取 token │
└──────────────────────────────────────────┘
JWT(JSON Web Token)结构:
header.payload.signature
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{
"sub": "1234567890", // 用户唯一标识
"name": "John Doe", // 用户信息
"iat": 1516239022, // 签发时间
"exp": 1516242622, // 过期时间
"role": "admin" // 自定义字段
}
// signature = HMACSHA256(base64(header) + '.' + base64(payload), secret)
前端 JWT 处理:
// 存储 token
const TOKEN_KEY = 'access_token'
const REFRESH_KEY = 'refresh_token'
function setTokens(accessToken: string, refreshToken: string) {
// 前端存储:优先 httpOnly cookie,次选 localStorage
localStorage.setItem(TOKEN_KEY, accessToken)
localStorage.setItem(REFRESH_KEY, refreshToken)
}
// axios 拦截器自动处理
http.interceptors.request.use(config => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
// 刷新 token
http.interceptors.response.use(
res => res,
async err => {
const originalRequest = err.config
if (err.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const refreshToken = localStorage.getItem(REFRESH_KEY)
const { accessToken, refreshToken: newRefresh } =
await axios.post('/auth/refresh', { refreshToken })
setTokens(accessToken, newRefresh)
originalRequest.headers.Authorization = `Bearer ${accessToken}`
return http(originalRequest)
}
return Promise.reject(err)
}
)
JWT vs Session 对比:
| 维度 | JWT | Session |
|---|---|---|
| 存储位置 | 客户端 | 服务器 |
| 扩展性 | 天然支持分布式 | 需要共享 session 存储 |
| 安全性 | Token 泄露无法撤销 | 可以在服务器端主动失效 |
| 性能 | 验证快(无数据库查询) | 需要查 session 存储 |
| 长度 | 较长(每次请求携带) | 短(只有 sessionId) |
8. 软技能 & 场景题
8.1 描述你最有挑战性的项目
问题:描述一个你主导的最有挑战性的项目
回答框架(STAR 原则):
S(Situation)场景:什么项目,什么角色
T(Task)任务:你面临什么挑战
A(Action)行动:你做了什么决策和行动
R(Result)结果:最终效果如何
示例回答:
S(场景):
我之前负责一个电商 App 的从 0 到 1 开发,基于 uni-app,需要同时上线 H5、微信小程序、安卓 App、iOS App,首期要求 3 个月上线。
T(任务):
挑战在于:团队只有 4 个前端,但需要覆盖 4 个端 + 后台管理系统。不同端的登录、支付、分享逻辑完全不同,而且我在之前没有做过 iOS 和安卓上架。
A(行动):
- 架构决策:使用 uni-app + 条件编译,核心业务逻辑写一次,只在 API 和 UI 层做端差异处理
- 优先级管理:首期先上线 H5 + 微信小程序,一个月后发布 App,分阶段交付
- 组件化:抽离出 20+ 通用组件(商品卡片、订单列表、支付按钮),各端复用,差异通过 props 控制
- 上架策略:提前调研各商店要求,准备隐私政策、权限说明、截图素材,用检查清单确保不遗漏
- 自动化:用 GitHub Actions + 云打包,每次 push 自动生成各端安装包
R(结果):
- 3 个月如期上线,覆盖 H5、微信小程序、安卓、iOS
- 后续一个月补齐鸿蒙版本
- 上线后首月 DAU 达到 5 万,Crash 率低于 0.1%
- 这套架构后续被公司其他项目复用
8.2 产品需求和技术实现冲突时
问题:当产品需求和技术实现冲突时,你如何推动?
参考答案:
核心原则:不说不,而是给选项。
示例:
产品经理要求 App 里的商品列表实现”3D 翻转效果”和”粒子动画”首屏展示。
我的处理方式:
- 理解真实需求:先问为什么——原来是为了”吸引用户注意力,提升停留时长”
- 给出技术评估:
- 方案 A(3D 翻转 + 粒子):开发周期 5 天,低端手机卡顿,iOS 审核可能因”过度动画”被拒
- 方案 B(轮播大图 + 微动效):开发周期 1 天,所有手机流畅,同能达到吸引注意的效果
- 方案 C(A/B 测试):首期用方案 B 上线,上线后用数据说话,有必要再叠加方案 A
- 让产品做选择题:最终产品选择了方案 B + C
核心话术:
“我理解你想要的效果,目的是 X。这边有几个方案:
– A 能达到 100% 效果,但需要 N 天,且有风险 M
– B 能达到 90% 效果,只需要 N/2 天,没有风险
推荐先用 B,快速验证,后续迭代优化。”
8.3 如何做技术选型
问题:你是怎么做技术选型的?
回答框架:
Step 1:明确约束
团队熟悉度:团队主栈是什么?
项目规模:小应用还是大工程?
时间要求:多久上线?
维护周期:短期还是长期维护?
Step 2:列出候选方案
状态管理:Pinia vs Vuex vs 直接 provide/inject
组件库:Element Plus vs Ant Design Vue vs 自建
构建工具:Vite vs Webpack
Step 3:评估维度
┌────────────┬──────────┬──────────┬──────────┐
│ 维度 │ Pinia │ Vuex │ 自实现 │
├────────────┼──────────┼──────────┼──────────┤
│ 学习成本 │ 低 │ 中 │ 高 │
│ TypeScript │ 优秀 │ 一般 │ 自控 │
│ 社区活跃度 │ 高 │ 稳定 │ - │
│ 调试工具 │ 完善 │ 成熟 │ 自建 │
│ 性能 │ 好 │ 好 │ 最好 │
│ 团队倾向 │ 熟悉 │ 熟悉 │ 不熟悉 │
└────────────┴──────────┴──────────┴──────────┘
Step 4:推荐 + 备选
“我推荐用 Pinia,理由是:
1. 团队已经熟悉 Vue 3
2. Pinia 的 TS 支持比 Vuex 好
3. 社区趋势明显
备选是 Vuex,如果遇到 Pinia 无法满足的场景(概率很低)
先小范围试用一周,没问题就确定下来”
8.4 线上紧急 Bug 的处理流程
问题:线上突发紧急 Bug,你怎么处理?
参考答案:
标准流程:
发现 (发现者报告) → 评估 (严重程度) → 止血 (紧急修复/回滚) → 根因 (排查原因) → 彻底修复 → 复盘
具体步骤:
1. 评估严重程度
P0(紧急):所有用户无法使用、支付失败、白屏
P1(严重):核心功能不可用、数据错误
P2(一般):非核心功能异常
P3(轻微):UI 问题、文案错误
2. 止血(P0/P1 立即执行)
// 方案1:回滚
git revert <bad-commit>
git push
// 方案2:特性开关关闭问题功能
if (featureFlag.enableNewCheckout) {
// 新逻辑(有问题)
} else {
// 旧逻辑(稳定)
}
// 方案3:CDN 切换稳定版本
// 把 CDN 链接指向上一个版本的资源
3. 根因排查
浏览器 DevTools Network/Console → 错误日志 (Sentry) → 用户行为回放 → 代码审查 → 测试复现
4. 修复 + 测试
// 写回归测试,防止同样的问题再次出现
it('should not crash when amount is 0', () => {
expect(() => render(Checkout, { amount: 0 })).not.toThrow()
})
5. 复盘(5W1H 法)
Why:为什么会出现这个 Bug?
Why:为什么测试没发现?
Why:为什么 Code Review 没发现?
What:怎么防止再次发生?
Who:谁负责跟进?
When:什么时候完成改进?
8.5 如何带新人 / Code Review
问题:你如何带新人和做 Code Review?
参考答案:
带新人四步法:
1. 带熟悉项目:从 README 开始,跑通整个流程
2. 小任务入手:修一个 Bug、加一个简单页面
3. 结对编程:每周 1-2 次,写一些核心逻辑
4. 复盘 + 放手:review 代码时多问"为什么这么写",而非直接给答案
Code Review 关注点(按优先级):
P0:是否有 Bug、安全漏洞、性能问题
P1:是否有意义(这行代码真的需要吗?)
P2:可读性(变量命名、函数长度、注释)
P3:一致性(是否遵循了项目的代码规范)
Code Review 话术:
// ❌ 不好的 CR 评论
"这里写得不对,改成这样"
// ✅ 好的 CR 评论
"这里如果用 computed 代替 method,利用缓存可以减少重复计算。
而且这个逻辑抽出来放到 utils 里,其他页面也可以复用,你觉得呢?"
Code Review 原则:
– 一次 review 不超过 200-400 行,超过容易漏掉问题
– 先看大架构,再看小细节
– 用”建议”代替”命令”,用”你觉得呢”代替”应该这样”
8.6 2026 年前端技术趋势判断
问题:你怎么看 2026 年前端技术趋势?
个人判断:
1. Vite 全面替代 Webpack(但 Webpack 在老项目仍长期存在)
2. TypeScript 成为标配——连后端 Node.js 生态已经全面 TS
3. 跨端方案持续进化:
– uni-app x / Taro 4 等原生渲染方案成熟
– Flutter 和 React Native 在端上继续竞争
– 小程序生态会继续存在,但 H5 + App 的融合趋势加强
4. AI 辅助开发:
– Cursor / Copilot 插件深度融入日常开发
– AI 写组件、写测试、写样式逐渐变成常态
– 但架构设计、逻辑抽象、性能优化仍然是人的主要价值
5. Rust 进入前端工具链(esbuild / swc / Turbopack / oxc 已经验证)
6. Web 标准持续演进:
– CSS Container Queries / :has() 让 CSS 更强大
– View Transitions API 让页面过渡更原生
– WebGPU 进入成熟期
对面试者的建议:
– 基础能力(JS 原理、算法、网络)永远不过时
– 框架深度比广度重要——面试官更希望看到一个你真正吃透的框架
– 工程化思维——从”能写代码”到”能设计代码”的转变是 10 年经验的关键分水岭
最后想说的:10 年前端,你最大的资本是见过各种坑、踩过各种坑、填过各种坑的能力。面试时不要怕说”我不会”,但要说”我不会,但我知道怎么学”。祝你面试顺利!🎉

