第一部分:JavaScript 基础
1. 说一下 JavaScript 的数据类型?
标准答案:
JavaScript 的数据类型分为两大类:基本类型和引用类型。
基本类型有 7 种:string、number、boolean、null、undefined、symbol、bigint。这些类型的值存在栈内存中,赋值时是复制值。
引用类型主要是 object,包括对象、数组、函数、日期、正则等。引用类型的值存在堆内存中,栈里存的是指向堆的地址,赋值时复制的是地址。
这里有个小细节:typeof null 返回 “object”,这是 JavaScript 的历史 bug,实际上 null 是基本类型。
判断类型的方法有几种:typeof 可以判断基本类型,但对引用类型都返回 “object”;instanceof 可以判断对象的具体类型,原理是检查构造函数的 prototype 是否在对象的原型链上;最准确的是 Object.prototype.toString.call(),可以返回类似 “[object Array]” 这样的字符串。
2. 说说 var、let、const 的区别?
标准答案:
这三个都是声明变量的关键字,但有很多区别。
首先是作用域。var 是函数作用域,在函数外声明就是全局变量;let 和 const 是块级作用域,在 {} 里声明的只在 {} 里有效。
然后是变量提升。var 声明的变量会提升到作用域顶部,但赋值不提升,所以提前访问会返回 undefined;let 和 const 虽然也会提升,但在声明前访问会报错,这叫暂时性死区。
还有重复声明。var 允许重复声明,后面的会覆盖前面的;let 和 const 不允许重复声明,会报错。
const 还有一个特殊点,它声明的是常量,不能重新赋值。但注意,如果 const 的是对象,对象的属性是可以修改的,只是不能重新赋值成别的对象。
实际开发中,现在基本不用 var 了,优先用 const,需要重新赋值的时候再用 let。
3. 什么是闭包?有什么作用?有什么缺点?
标准答案:
闭包是指一个函数能够访问它外部函数作用域中的变量,即使外部函数已经执行完毕。
形成闭包需要三个条件:函数嵌套、内部函数引用外部变量、外部函数返回内部函数。
闭包的主要作用有三个:
第一,实现数据私有化。比如可以用闭包创建私有变量,外部无法直接访问,只能通过特定方法操作。
第二,模块化。可以用闭包封装代码,只暴露需要的接口。
第三,函数柯里化和防抖节流等高级技巧都会用到闭包。
闭包的缺点是可能造成内存泄漏。因为闭包引用的外部变量不会被垃圾回收,如果大量使用闭包且不及时清理,会占用过多内存。
举个例子,常见的防抖函数就是闭包的应用:
function debounce(fn, delay) {
let timer = null; // 这个变量被闭包引用,不会被回收
return function() {
clearTimeout(timer);
timer = setTimeout(fn, delay);
}
}
4. this 指向有哪些情况?箭头函数的 this 有什么特殊?
标准答案:
this 的指向有四种规则:
第一种是默认绑定。函数独立调用时,this 指向 window,严格模式下是 undefined。
第二种是隐式绑定。作为对象方法调用时,this 指向调用它的对象,比如 obj.fn() 里的 this 就是 obj。
第三种是显式绑定。用 call、apply、bind 可以强制指定 this 指向,第一个参数就是 this 的值。
第四种是 new 绑定。用 new 调用构造函数时,this 指向新创建的实例。
箭头函数的 this 比较特殊,它没有自己的 this,而是继承外层作用域的 this。这个 this 在箭头函数定义时就确定了,之后用 call、apply 也改变不了。
比如:
const obj = {
name: \’obj\’,
fn: function() {
console.log(this.name); }, // this 是 obj
arrow: () => {
console.log(this.name); } // this 是 window
};
实际开发中,箭头函数常用于回调函数,可以避免 this 指向混乱的问题。
5. 原型和原型链是什么?
标准答案:
每个函数都有一个 prototype 属性,指向它的原型对象 这个是显式原型。这个原型对象上有一个 constructor 属性,指回构造函数本身。
每个对象都有一个 proto 属性 隐式原型,指向它的构造函数的prototype属性。比如实例对象的 proto 指向构造函数的 prototype。
原型链就是对象查找属性时的一条链路。当访问对象的属性时,如果对象本身没有,就会去它的 proto 上找,还没有就继续往 proto 的 proto 上找,一直找到 Object.prototype,Object.prototype 的 proto 是 null,到这里就结束了。
原型的作用是共享属性和方法。
原型链的作用是实现继承。JavaScript 的对象可以继承原型上的属性和方法,比如数组可以调用 push、pop 这些方法,就是因为数组实例的原型链上有 Array.prototype。
判断原型关系可以用 instanceof,它检查的是构造函数的 prototype 是否在对象的原型链上。
6. 说说 JavaScript 的继承方式?
标准答案:
JavaScript 的继承方式主要有几种:
第一种是原型链继承。让子类的原型指向父类的实例。缺点是父类的引用类型属性会被所有子实例共享,一个实例修改会影响其他实例。
第二种是构造函数继承。在子类构造函数里用 call 调用父类构造函数。缺点是无法继承父类原型上的方法。
第三种是组合继承,结合前两种。用构造函数继承属性,用原型链继承方法。缺点是会调用两次父类构造函数。
第四种是寄生组合继承,这是最常用的方式。用 Object.create 创建一个中间对象作为子类原型,避免调用两次父类构造函数。
现在 ES6 的 class 和 extends 是寄生组合继承的语法糖,写起来更简洁,但底层原理是一样的。
7. 什么是 Event Loop?宏任务和微任务有什么区别?
标准答案:
Event Loop 是 JavaScript 的异步执行机制。因为 JavaScript 是单线程的,不能同时做多个事,所以用 Event Loop 来协调同步和异步代码的执行。
执行顺序是这样的:
首先执行同步代码,这是主线程直接执行的。
同步代码执行完后,检查微任务队列,把所有微任务都执行完。微任务包括 Promise.then、MutationObserver、queueMicrotask 等。
微任务清空后,执行一个宏任务。宏任务包括 setTimeout、setInterval、I/O 操作等。
然后再次检查微任务队列,清空所有微任务。
这样循环往复,就是 Event Loop。
关键点:微任务优先级高于宏任务,每个宏任务执行前都要先清空微任务队列。还有,async/await 本质上也是 Promise,所以 await 后面的代码会作为微任务执行。
8. Promise 是什么?解决了什么问题?
标准答案:
Promise 是 JavaScript 的异步编程解决方案,用来处理异步操作。
Promise 有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦改变就不可再次改变。
Promise 解决了回调地狱的问题。以前用回调函数处理异步,多层嵌套时代码很难维护。Promise 用链式调用的方式,让异步代码看起来像同步代码,更易读。
Promise 的主要方法:
- then:注册成功和失败的回调,返回新 Promise 支持链式调用
- catch:捕获错误,相当于 then(null, onRejected)
- finally:无论成功失败都会执行
- all:等所有 Promise 都成功才成功,一个失败就失败
- race:返回最快完成的那个 Promise
- allSettled:等所有 Promise 都完成,不管成功失败
async/await 是 Promise 的语法糖,让异步代码写起来更像同步代码。
9. 深拷贝和浅拷贝有什么区别?怎么实现深拷贝?
标准答案:
浅拷贝只复制对象的第一层属性,如果属性是引用类型,复制的是地址,新旧对象会共享引用类型的数据。
深拷贝是递归复制所有层级,新旧对象完全独立,互不影响。
浅拷贝的方法:Object.assign、展开运算符 {…obj}、数组的 slice、concat 等。
深拷贝的方法:
最简单的是 JSON.parse(JSON.stringify(obj)),但有局限性:会丢失函数、undefined、Symbol,Date 会变成字符串,不能处理循环引用。
完整的深拷贝需要递归实现:
function deepClone(obj, map = new WeakMap()) {
if (obj === null || typeof obj !== \’object\’) return obj;
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
// 处理循环引用
if (map.has(obj)) return map.get(obj);
const clone = Array.isArray(obj) ? [] : {
};
map.set(obj, clone);
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
clone[key] = deepClone(obj[key], map);
}
}
return clone;
}
实际项目中常用 lodash 的 cloneDeep 方法。
10. 手写防抖和节流
标准答案:
防抖是连续触发时只执行最后一次,常用于搜索框输入、窗口 resize。
function debounce(fn, delay) {
let timer = null;
return function(…args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
核心逻辑:每次触发先清除上一次的定时器,重新设置新的定时器,只有最后一次触发后 delay 时间内没有新触发,才会执行。
节流是固定时间间隔内只执行一次,常用于滚动加载、拖拽。
// 时间戳版(立即执行)
function throttle(fn, delay) {
let last = 0;
return function(…args) {
const now = Date.now();
if (now – last >= delay) {
last = now;
fn.apply(this, args);
}
};
}
// 定时器版(延迟执行)
function throttle(fn, delay) {
let timer = null;
return function(…args) {
if (!timer) {
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
}
};
}
区别:防抖关注\”停止触发后执行\”,节流关注\”固定间隔执行\”。
第二部分:Vue 系列
11. Vue2 的响应式原理是什么?有什么局限性?
标准答案:
Vue2 使用 Object.defineProperty 实现响应式。
具体过程是:初始化时会遍历 data 对象的所有属性,用 Object.defineProperty 把每个属性转换成 getter/setter。在 getter 中收集依赖(哪些组件用到了这个属性),在 setter 中派发更新(通知用到这个属性的组件重新渲染)。
依赖收集是通过 Dep 和 Watcher 实现的。每个响应式属性对应一个 Dep 对象,负责管理依赖这个属性的 Watcher。组件渲染时会创建 Watcher,访问响应式属性时就把 Watcher 添加到 Dep 中。
Vue2 的局限性主要有三个:
第一,无法检测对象属性的新增和删除。因为 Object.defineProperty 只能劫持已有属性,新增的属性没有 getter/setter,不是响应式的。需要用 Vue.set 和 Vue.delete 来处理。
第二,无法检测数组索引和长度的变化。虽然 Vue 重写了数组的 7 个方法(push、pop、shift、unshift、splice、sort、reverse),但直接通过索引修改数组或修改 length 是无法检测的。
第三,需要递归遍历对象的所有属性来劫持,如果对象层级很深,初始化性能会有影响。
12. Vue3 为什么用 Proxy 替代 Object.defineProperty?
标准答案:
Vue3 用 Proxy 替代 Object.defineProperty 主要是为了解决 Vue2 的局限性。
Proxy 是 ES6 的新特性,可以代理整个对象,拦截对象的各种操作,包括 get、set、deleteProperty、has、ownKeys 等 13 种。
相比 Object.defineProperty,Proxy 的优势有:
第一,可以检测对象属性的新增和删除。Proxy 的 set 和 deleteProperty 拦截器可以捕获这些操作。
第二,对数组的支持更好。Proxy 可以拦截数组的索引访问和 length 修改,不需要像 Vue2 那样重写数组方法。
第三,性能更好。Proxy 是懒代理,只有访问到嵌套对象时才会递归代理,不像 Object.defineProperty 那样一开始就递归遍历所有属性。
第四,代码更简洁。不需要对数组做特殊处理,统一用 Proxy 拦截。
但 Proxy 也有缺点,就是不兼容 IE11 及以下浏览器,这也是 Vue3 放弃 IE 支持的原因之一。
13. Vue2 和 Vue3 的生命周期有什么区别?
标准答案:
Vue2 和 Vue3 的生命周期变化主要是 Options API 和 Composition API 的区别。
在 Options API 中,Vue3 的生命周期名字有变化:
- beforeDestroy 改成 beforeUnmount
- destroyed 改成 unmounted
- 其他钩子基本一样
在 Composition API 中,使用 onXxx 形式的函数,需要从 vue 中导入:
- setup 函数替代了 beforeCreate 和 created,在组件初始化时执行
- onBeforeMount 替代 beforeMount
- onMounted 替代 mounted
- onBeforeUpdate 替代 beforeUpdate
- onUpdated 替代 updated
- onBeforeUnmount 替代 beforeDestroy
- onUnmounted 替代 destroyed
还有一个变化是 Vue3 新增了调试用的生命周期钩子:onRenderTracked 和 onRenderTriggered,用于追踪响应式依赖。
执行顺序上,setup 是在 beforeCreate 之前执行的,这时候组件实例还没创建,所以 setup 里不能用 this。
14. computed 和 watch 有什么区别?
标准答案:
computed 和 watch 都是用来监听数据变化的,但使用场景不同。
computed 是计算属性,有缓存。它根据依赖的响应式数据计算出一个值,只有依赖变化时才会重新计算。适合用于派生数据,比如根据 firstName 和 lastName 计算 fullName。
computed 必须返回一个值,在模板中可以像普通属性一样使用,不需要加括号。
watch 是侦听器,没有缓存。它监听某个数据的变化,变化时执行回调函数。适合用于数据变化后需要做副作用操作的场景,比如数据变化后发请求、操作 DOM 等。
watch 可以监听一个或多个数据源,可以配置 immediate(立即执行一次)、deep(深度监听)等选项。
简单来说:需要计算值用 computed,需要执行操作(尤其是异步操作)用 watch。
15. v-if 和 v-show 有什么区别?
标准答案:
v-if 和 v-show 都是控制元素显示隐藏的指令,但实现方式不同。
v-if 是真正的条件渲染,会根据条件决定是否创建或销毁元素。条件为 false 时元素不存在于 DOM 中。切换时有更高的切换开销,因为涉及创建和销毁。
v-show 只是通过 CSS 的 display 属性控制显示隐藏,元素始终存在于 DOM 中。切换时开销小,只是改样式。
使用场景:
- 如果切换频繁,用 v-show,避免频繁创建销毁
- 如果条件很少改变,用 v-if,初始渲染开销小
- 如果需要在条件为 false 时完全不渲染(比如权限控制),用 v-if
另外,v-if 可以和 v-else、v-else-if 配合使用,v-show 不行。
16. Vue 组件通信有哪些方式?
标准答案:
Vue 组件通信方式有很多,按场景选择:
父子组件通信:
- props:父传子,单向数据流
- $emit:子传父,子组件触发自定义事件
- $refs:父组件直接调用子组件方法
- p a r e n t / parent/ parent/children:直接访问父子实例(不推荐)
跨层级通信:
- provide/inject:祖先组件提供数据,后代组件注入使用,适合深层嵌套
- eventBus:全局事件总线,Vue3 中不推荐
- Vuex/Pinia:全局状态管理
其他方式:
- slot:插槽,父组件向子组件传递内容
- v-model:语法糖,本质是 props + $emit
- attrs/listeners:Vue2 中传递属性和事件,Vue3 中合并到 attrs
实际项目中,简单的父子通信用 props 和 $emit,深层嵌套用 provide/inject,全局状态用 Pinia(Vue3 推荐)。
17. 说说 Vue Router 的 hash 模式和 history 模式?
标准答案:
Vue Router 有两种路由模式:hash 模式和 history 模式。
hash 模式使用 URL 的 hash 部分(#后面的内容)来模拟完整 URL。hash 的变化不会触发页面刷新,也不会发送给服务器,所以不需要服务端配置。
history 模式使用 HTML5 的 History API(pushState、replaceState)来实现。URL 看起来更正常,没有 #,但需要服务端配置,把所有路由都指向同一个 HTML 文件,否则刷新会 404。
对比:
- hash 模式兼容性好,包括 IE,但 URL 有 # 不够美观
- history 模式 URL 美观,但需要服务端支持,且兼容性要求 IE10+
实际项目中,如果不需要兼容 IE,推荐用 history 模式。
18. Vuex 和 Pinia 有什么区别?
标准答案:
Vuex 是 Vue2 官方推荐的状态管理库,Pinia 是 Vue3 官方推荐的状态管理库。
主要区别:
API 设计:
- Vuex 使用 Options API 风格,有 state、getter、mutation、action、module 五个核心概念
- Pinia 使用 Composition API 风格,只有 state、getter、action,没有 mutation
TypeScript 支持:
- Vuex 对 TS 支持一般,需要写很多类型声明
- Pinia 原生支持 TS,类型推断更好
代码量:
- Vuex 修改状态必须通过 mutation,写起来比较繁琐
- Pinia 直接修改 state 或使用 action,更简洁
模块化:
- Vuex 模块化需要配置 namespaced,比较麻烦
- Pinia 每个 store 自动模块化,不需要额外配置
体积:
- Pinia 更轻量,约 1KB
Vue3 项目推荐用 Pinia,Vue2 项目可以继续用 Vuex 或迁移到 Pinia。
第三部分:React 系列
19. React 的生命周期有哪些?
标准答案:
React 的生命周期分为三个阶段:挂载、更新、卸载。
挂载阶段:
- constructor:构造函数,初始化 state 和绑定方法
- static getDerivedStateFromProps:根据 props 更新 state,返回新 state 或 null
- render:渲染组件,返回 JSX
- componentDidMount:挂载完成,可以发请求、操作 DOM
更新阶段:
- static getDerivedStateFromProps:同上
- shouldComponentUpdate:控制是否重新渲染,返回 false 阻止更新
- render:重新渲染
- getSnapshotBeforeUpdate:获取更新前的 DOM 状态,返回值传给 componentDidUpdate
- componentDidUpdate:更新完成
卸载阶段:
- componentWillUnmount:卸载前,清理定时器、订阅等
React16 废弃了三个生命周期:componentWillMount、componentWillReceiveProps、componentWillUpdate,因为它们可能被滥用导致问题。
函数组件使用 Hooks 替代生命周期:useEffect 可以模拟 componentDidMount、componentDidUpdate、componentWillUnmount。
20. React Hooks 是什么?有哪些常用 Hooks?
标准答案:
React Hooks 是 React16.8 引入的特性,让函数组件可以使用状态和生命周期等特性。
常用 Hooks:
useState: 定义状态,返回 [state, setState]。setState 是异步的,多次调用会合并。
useEffect: 处理副作用,替代生命周期。第二个参数是依赖数组,空数组表示只在挂载和卸载时执行,有依赖表示依赖变化时执行,返回的清理函数在卸载或依赖变化前执行。
useContext: 消费 Context,避免 props drilling。
useReducer: 用于复杂状态逻辑,类似 Redux。
useCallback: 缓存函数,依赖不变返回同一引用,用于子组件 props 优化。
useMemo: 缓存计算值,避免重复计算。
useRef: 获取 DOM 引用或保存不触发渲染的可变值。
useLayoutEffect: 类似 useEffect,但在浏览器绘制前同步执行,可能阻塞渲染。
21. useEffect 的依赖数组有什么作用?
标准答案:
useEffect 的第二个参数是依赖数组,控制 effect 的执行时机。
空数组 []:
- effect 只在组件挂载时执行一次
- 返回的清理函数只在组件卸载时执行
- 相当于 componentDidMount + componentWillUnmount
有依赖 [a, b]:
- effect 在挂载时执行一次
- 之后每当依赖变化时,先执行清理函数,再执行 effect
- 相当于 componentDidMount + componentDidUpdate
不写依赖数组:
- 每次组件渲染后都执行 effect
- 很少这样用,容易导致死循环
注意事项:
- 依赖必须包含 effect 中用到的所有响应式值
- 如果依赖是对象或函数,要用 useMemo 或 useCallback 保持引用稳定
- 空数组时要小心闭包陷阱,获取的可能是旧值
22. 为什么 Hooks 不能放在 if 或循环里?
标准答案:
Hooks 不能放在 if、for、while 等条件或循环语句中,必须放在函数组件的最顶层。
原因是 React 靠 Hooks 调用的顺序来对应状态。React 内部用链表结构存储 Hooks 状态,每次渲染按顺序取出对应的状态。
如果放在 if 里,条件变化时 Hooks 的调用次数可能变化,导致顺序错乱,React 就对应不上正确的状态了。
比如:
// ❌ 错误
if (condition) {
const [state, setState] = useState(0);
}
// ✅ 正确
const [state, setState] = useState(0);
if (condition) {
// 使用 state
}
这条规则也适用于自定义 Hooks,它们也必须遵守同样的规则。
23. React 的 Diff 算法是什么原理?
标准答案:
Diff 算法是 React 用来比较新旧虚拟 DOM,找出最小变更的算法。
React 的 Diff 策略基于三个假设:
1. 同级比较,不跨层级
- 只比较同一层级的节点,如果节点类型不同,直接销毁重建
- 这样算法复杂度从 O(n³) 降到 O(n)
2. 不同类型直接替换
- 如果节点类型不同(比如 div 变成 span),直接销毁旧节点及其子树,创建新节点
- 不会尝试复用
3. 用 key 优化列表
- 列表渲染时,用 key 标识节点,帮助 React 识别哪些节点可以复用
- key 最好是稳定唯一的标识,不要用 index
Diff 过程:
- 对比新旧节点的类型
- 类型相同则对比属性,更新变化的属性
- 递归对比子节点
- 对子节点列表用 key 优化,尽量复用已有节点
24. React 性能优化有哪些方法?
标准答案:
React 性能优化可以从几个方面入手:
减少不必要的渲染:
- React.memo:函数组件版 PureComponent,props 不变不重新渲染
- useMemo:缓存计算结果
- useCallback:缓存回调函数,避免子组件因函数引用变化而渲染
- shouldComponentUpdate:类组件中控制是否更新
优化渲染过程:
- 避免在 render 中创建新对象或函数
- 用 useMemo 缓存复杂计算
- 虚拟列表:只渲染可视区域,react-window、react-virtualized
代码分割:
- React.lazy + Suspense:按需加载组件
- 路由懒加载
状态管理:
- 状态尽量靠近使用它的组件,避免不必要的层级传递
- 用 Context 或状态管理库避免 props drilling
React18 新特性:
- useTransition:标记非紧急更新,避免阻塞 UI
- useDeferredValue:延迟更新某个值
- 自动批处理:减少渲染次数
第四部分:浏览器与网络
25. 输入 URL 到页面显示发生了什么?
标准答案:
输入 URL 到页面显示的过程大致分为以下几个阶段:
1. DNS 解析
- 浏览器缓存 → 操作系统缓存 → hosts 文件 → DNS 服务器
- 把域名解析成 IP 地址
2. 建立 TCP 连接
- 三次握手建立连接
- 如果是 HTTPS,还要进行 SSL/TLS 握手
3. 发送 HTTP 请求
- 构建请求报文(请求行、请求头、请求体)
- 发送到服务器
4. 服务器处理请求
- 服务器接收请求,处理业务逻辑
- 返回响应报文
5. 浏览器接收响应
- 解析响应头,判断是否需要缓存
- 解析响应体
6. 解析 HTML 构建 DOM 树
- 词法分析、语法分析
- 遇到 script 会阻塞解析(除非 defer/async)
7. 解析 CSS 构建 CSSOM 树
- CSS 解析不会阻塞 HTML 解析,但会阻塞渲染
8. 合并成 Render Tree
- DOM + CSSOM,只包含可见节点
9. Layout(布局/重排)
- 计算元素的位置和大小
10. Paint(绘制/重绘)
- 绘制像素到屏幕上
11. Composite(合成)
- 将图层合成最终页面
26. 浏览器缓存机制是什么?
标准答案:
浏览器缓存分为强缓存和协商缓存。
强缓存:
- 不会发送请求到服务器,直接使用本地缓存
- 通过 Cache-Control(HTTP1.1)和 Expires(HTTP1.0)控制
- Cache-Control: max-age=3600 表示缓存1小时
协商缓存:
- 会发送请求问服务器缓存是否可用
- 通过 Last-Modified/If-Modified-Since 或 ETag/If-None-Match
- 如果缓存有效,服务器返回 304 Not Modified
Last-Modified vs ETag:
- Last-Modified 是文件最后修改时间,精度秒级,可能不准确
- ETag 是文件内容的哈希,更精确,优先级更高
缓存优先级: 强缓存 > 协商缓存 > 重新请求
实际应用:
- HTML 文件用协商缓存或不缓存,保证最新
- JS/CSS 文件用强缓存,文件名加 hash,更新时 URL 变化
27. 什么是跨域?怎么解决?
标准答案:
跨域是指浏览器的同源策略限制,协议、域名、端口三者有一个不同就是跨域。
同源策略限制:
- AJAX 请求不能跨域
- DOM 操作不能跨域(iframe)
- Cookie/LocalStorage 不能跨域读取
解决方案:
1. CORS(跨域资源共享)
- 服务端设置响应头 Access-Control-Allow-Origin
- 简单请求直接发,非简单请求先发 OPTIONS 预检请求
- 最标准的解决方案
2. JSONP
- 利用 script 标签不受同源限制
- 只支持 GET 请求
- 需要服务端配合返回回调函数调用
3. 代理
- 开发环境:webpack devServer.proxy
- 生产环境:nginx 反向代理
- 同源策略是浏览器的限制,服务器之间没有
4. postMessage
- 跨窗口通信,如 iframe
5. document.domain
- 设置相同域名实现跨子域
- 已逐渐废弃
实际项目中,开发用代理,生产用 CORS 或 nginx 代理。
28. XSS 和 CSRF 是什么?怎么防御?
标准答案:
XSS(跨站脚本攻击):
- 攻击者注入恶意脚本,在其他用户浏览器执行
- 类型:存储型(存数据库)、反射型(URL参数)、DOM型(前端JS导致)
XSS 防御:
- 输入过滤:对用户输入进行转义
- 输出编码:显示时转义特殊字符
- CSP(内容安全策略):限制可执行的脚本来源
- HttpOnly Cookie:防止 JS 读取 Cookie
CSRF(跨站请求伪造):
- 攻击者诱导用户访问恶意网站,利用用户已登录的身份发起请求
- 比如用户登录了银行网站,访问恶意网站时自动发起转账请求
CSRF 防御:
- CSRF Token:服务端生成随机 Token,请求时验证
- SameSite Cookie:设置 Cookie 的 SameSite 属性,限制跨站发送
- 验证 Referer:检查请求来源
- 二次确认:敏感操作需要再次输入密码或验证码
29. HTTP 和 HTTPS 有什么区别?
标准答案:
HTTP 是超文本传输协议,明文传输,不安全。
HTTPS 是 HTTP + SSL/TLS,加密传输,安全。
主要区别:
安全性:
- HTTP 明文传输,容易被窃听、篡改
- HTTPS 加密传输,防止中间人攻击
端口:
- HTTP 默认 80 端口
- HTTPS 默认 443 端口
性能:
- HTTPS 需要 SSL 握手,首次连接稍慢
- 但 HTTP2 只能在 HTTPS 上使用,多路复用反而更快
SEO:
- 搜索引擎优先收录 HTTPS 网站
HTTPS 握手过程:
第五部分:工程化
30. Webpack 的核心概念是什么?
标准答案:
Webpack 是前端模块打包工具,核心概念有五个:
Entry(入口):
- 打包的起点,指定从哪个文件开始构建依赖图
- 可以配置单入口或多入口
Output(输出):
- 打包后的文件输出配置
- 包括输出路径 path、文件名 filename、公共路径 publicPath
Loader(加载器):
- 处理非 JavaScript 文件
- 把各种类型文件转换为 Webpack 能处理的模块
- 常用:babel-loader(转译ES6+)、css-loader(解析CSS)、style-loader(注入样式)、file-loader(处理文件)
Plugin(插件):
- 扩展 Webpack 功能
- 在构建生命周期的各个钩子中执行
- 常用:HtmlWebpackPlugin(生成HTML)、CleanWebpackPlugin(清理输出目录)、DefinePlugin(定义全局常量)
Mode(模式):
- development:开发模式,启用调试、热更新
- production:生产模式,启用代码压缩、Tree Shaking 等优化
Webpack 的工作流程:从入口开始,递归解析依赖,用 Loader 转换文件,用 Plugin 处理,输出到指定目录。
31. Webpack 有哪些优化手段?
标准答案:
Webpack 优化可以从构建速度和产物体积两方面入手。
构建速度优化:
缩小搜索范围
- resolve.alias:配置路径别名,减少查找
- resolve.extensions:明确后缀,减少尝试
- module.noParse:不解析某些库(如 jQuery)
多线程打包
- thread-loader:把耗时 Loader 放在单独线程
缓存
- babel-loader 的 cacheDirectory
- Webpack5 的持久化缓存 cache: { type: ‘filesystem’ }
预编译
- DllPlugin:把第三方库预编译,加速构建
产物体积优化:
代码分割
- splitChunks:提取公共代码、第三方库、动态导入
Tree Shaking
- 删除未使用代码,需要 ES Module 和 production 模式
压缩
- TerserPlugin:JS 压缩
- CssMinimizerPlugin:CSS 压缩
懒加载
- import() 动态导入,按需加载
externals
- 排除某些包,用 CDN 引入
32. Vite 和 Webpack 有什么区别?
标准答案:
Vite 和 Webpack 都是前端构建工具,但设计理念不同。
开发环境:
- Webpack 是先打包再启动,把所有模块打包成 bundle,启动慢
- Vite 利用浏览器原生 ES Module,不打包,直接按请求编译,启动快(秒开)
热更新:
- Webpack 修改后重新编译打包,更新慢
- Vite 基于 ESM,精准更新修改的模块,更新快
生产构建:
- Webpack 用自己打包
- Vite 用 Rollup 打包,产物更优
配置:
- Webpack 配置复杂,功能强大
- Vite 配置简单,开箱即用
生态:
- Webpack 生态成熟,插件丰富
- Vite 生态在快速发展,Vue 官方推荐
选择建议:
- 新项目、Vue3 项目推荐 Vite
- 需要复杂配置、大量自定义 Loader 的项目用 Webpack
第六部分:手写题
33. 手写 Promise.all
标准答案:
Promise.all = function(promises) {
return new Promise((resolve, reject) => {
// 处理空数组
if (promises.length === 0) {
resolve([]);
return;
}
const results = [];
let completedCount = 0;
promises.forEach((promise, index) => {
// 用 Promise.resolve 包装,处理非 Promise 值
Promise.resolve(promise).then(value => {
results[index] = value;
completedCount++;
// 所有 Promise 都完成,按顺序返回结果
if (completedCount === promises.length) {
resolve(results);
}
}).catch(reject); // 有一个失败就整体失败
});
});
};
关键点:
- 返回新 Promise
- 用数组保存结果,保持顺序
- 有一个失败就 reject
- 用 Promise.resolve 包装,支持非 Promise 值
34. 手写 new 操作符
标准答案:
function myNew(Constructor, …args) {
// 1. 创建空对象,绑定原型
const obj = Object.create(Constructor.prototype);
// 2. 执行构造函数,绑定 this
const result = Constructor.apply(obj, args);
// 3. 如果构造函数返回对象,返回该对象;否则返回新对象
return result instanceof Object ? result : obj;
}
new 操作符做了三件事:
35. 手写 call、apply、bind
标准答案:
call:
Function.prototype.myCall = function(context, …args) {
// 默认上下文是 window
context = context || window;
// 将函数作为 context 的方法
const fn = Symbol(\’fn\’);
context[fn] = this;
// 执行函数
const result = context[fn](…args);
// 删除临时方法
delete context[fn];
return result;
};
apply:
Function.prototype.myApply = function(context, args) {
context = context || window;
const fn = Symbol(\’fn\’);
context[fn] = this;
const result = context[fn](…args);
delete context[fn];
return result;
};
bind:
Function.prototype.myBind = function(context, …args1) {
const fn = this;
return function(…args2) {
// 支持 new 调用
if (this instanceof fn) {
return new fn(…args1, …args2);
}
return fn.apply(context, […args1, …args2]);
};
};
36. 手写 instanceof
标准答案:
function myInstanceof(obj, Constructor) {
// 基本类型直接返回 false
if (obj === null || typeof obj !== \’object\’) {
return false;
}
// 获取对象的原型
let proto = Object.getPrototypeOf(obj);
// 沿原型链查找
while (proto) {
if (proto === Constructor.prototype) {
return true;
}
proto = Object.getPrototypeOf(proto);
}
return false;
}
原理:检查构造函数的 prototype 是否在对象的原型链上。
37. 手写浅拷贝和深拷贝
标准答案:
浅拷贝:
function shallowCopy(obj) {
if (typeof obj !== \’object\’ || obj === null) {
return obj;
}
const result = Array.isArray(obj) ? [] : {
};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = obj[key];
}
}
return result;
}
// 或者用 ES6
const shallowCopy2 = obj => ({
…obj});
const shallowCopy3 = obj => Array.isArray(obj) ? […obj] : {
…obj};
深拷贝:
function deepClone(obj, map = new WeakMap()) {
// 处理基本类型和 null
if (obj === null || typeof obj !== \’object\’) {
return obj;
}
// 处理日期
if (obj instanceof Date) {
return new Date(obj);
}
// 处理正则
if (obj instanceof RegExp) {
return new RegExp(obj);
}
// 处理循环引用
if (map.has(obj)) {
return map.get(obj);
}
// 创建新对象/数组
const result = Array.isArray(obj) ? [] : {
};
map.set(obj, result);
// 递归拷贝
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = deepClone(obj[key], map);
}
}
return result;
}
38. 手写数组扁平化
标准答案:
// 方法1:ES6 flat
function flat(arr, depth = 1) {
return arr.flat(depth);
}
// 方法2:递归 reduce
function flat(arr, depth = 1) {
if (depth === 0) return arr;
return arr.reduce((acc, cur) => {
return acc.concat(Array.isArray(cur) ? flat(cur, depth – 1) : cur);
}, []);
}
// 方法3:迭代(栈)
function flat(arr) {
const stack = […arr];
const result = [];
while (stack.length) {
const next = stack.pop();
if (Array.isArray(next)) {
stack.push(…next);
} else {
result.push(next);
}
}
return result.reverse();
}
39. 手写数组去重
标准答案:
// 方法1:Set(最简单)
const unique = […new Set(arr)];
// 方法2:filter + indexOf
const unique = arr.filter((item, index) => arr.indexOf(item) === index);
// 方法3:reduce
const unique = arr.reduce((acc, cur) => {
return acc.includes(cur) ? acc : […acc, cur];
}, []);
// 方法4:Map(处理对象去重)
function unique(arr) {
const map = new Map();
return arr.filter(item => !map.has(item) && map.set(item, true));
}
40. 手写发布订阅模式
标准答案:
class EventEmitter {
constructor() {
this.events = {
};
}
// 订阅事件
on(event, callback) {
if (!this.events[event]) {
this.events[event] = [];
}
this.events[event].push(callback);
// 返回取消订阅函数
return () => this.off(event, callback);
}
// 订阅一次
once(event, callback) {
const wrapper = (…args) => {
callback(…args);
this.off(event, wrapper);
};
this.on(event, wrapper);
}
// 取消订阅
off(event, callback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter(cb => cb !== callback);
}
// 发布事件
emit(event, …args) {
if (!this.events[event]) return;
this.events[event].forEach(callback => {
callback(…args);
});
}
}
// 使用
const emitter = new EventEmitter();
const unsubscribe = emitter.on(\’click\’, (data) => console.log(data));
emitter.emit(\’click\’, \’hello\’);
unsubscribe(); // 取消订阅
第七部分:场景题与开放题
41. 怎么实现一个大文件上传?
标准答案:
大文件上传需要考虑切片、断点续传、并发控制。
方案:
文件切片
- 用 Blob.slice 把大文件切成多个小片(如每片 1MB)
- 给每个切片计算 hash(如 spark-md5),用于标识和秒传
秒传检查
- 上传前先发 hash 列表给服务端,询问哪些切片已存在
- 只上传不存在的切片
并发上传
- 控制并发数(如同时上传 6 个切片)
- 用 Promise.all 或 p-limit 控制
断点续传
- 上传前检查已上传的切片列表
- 只上传未成功的切片
合并切片
- 所有切片上传完后,通知服务端合并
- 服务端按顺序拼接切片成完整文件
额外考虑:
- 上传进度:每个切片上传后更新总进度
- 错误重试:单个切片失败可重试
- 取消上传:AbortController 取消请求
42. 怎么实现一个虚拟列表?
标准答案:
虚拟列表只渲染可视区域的元素,适合大数据量列表。
原理:
实现要点:
// 计算
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = startIndex + visibleCount;
const offsetY = startIndex * itemHeight;
// 渲染
<div style={
{
paddingTop: offsetY, paddingBottom: totalHeight – offsetY – visibleHeight }}>
{
data.slice(startIndex, endIndex).map(renderItem)}
</div>
优化:
- 预估高度:如果高度不固定,先预估,渲染后更新
- 缓存高度:记录每行实际高度,精确计算
- 使用库:react-window、react-virtualized、vue-virtual-scroller
43. 前端怎么做性能监控?
标准答案:
前端性能监控分为性能指标采集和错误监控。
性能指标:
Web Vitals
- LCP(Largest Contentful Paint):最大内容绘制,< 2.5s 优秀
- FID(First Input Delay):首次输入延迟,< 100ms 优秀
- CLS(Cumulative Layout Shift):累积布局偏移,< 0.1 优秀
Navigation Timing
- DNS 查询时间、TCP 连接时间、首字节时间、DOM 解析时间等
- 通过 performance.getEntriesByType(‘navigation’) 获取
Resource Timing
- 各资源加载时间
- performance.getEntriesByType(‘resource’)
错误监控:
JS 错误
- window.onerror 捕获同步错误
- window.addEventListener(‘error’) 捕获资源加载错误
- window.addEventListener(‘unhandledrejection’) 捕获 Promise 错误
接口监控
- 拦截 XMLHttpRequest 和 fetch,记录请求时间、成功率
上报方式:
- 图片 ping(1×1 像素图)
- sendBeacon(页面关闭时也能发送)
- 普通 AJAX
常用服务: Sentry、阿里云 ARMS、腾讯云 APM
44. 怎么设计一个组件库?
标准答案:
设计组件库需要考虑可用性、可维护性、可扩展性。
目录结构:
src/
components/ # 组件
Button/
index.tsx # 组件代码
style.less # 样式
demo.tsx # 示例
test.tsx # 测试
styles/ # 公共样式、变量
utils/ # 工具函数
index.ts # 入口
技术选型:
- 框架:React/Vue
- 语言:TypeScript(类型支持)
- 样式:CSS Modules / CSS-in-JS / 预处理器
- 构建:Rollup(库打包)/ Vite
- 文档:Storybook / VitePress
- 测试:Jest + React Testing Library
设计原则:
发布:
- 版本管理:语义化版本
- 分包:支持按需引入
- 类型文件:提供 .d.ts
45. 从 10 万条数据里找出重复项
标准答案:
// 方法1:Set + filter(内存占用大)
function findDuplicates(arr) {
const seen = new Set();
const duplicates = new Set();
for (const item of arr) {
if (seen.has(item)) {
duplicates.add(item);
} else {
seen.add(item);
}
}
return […duplicates];
}
// 方法2:排序后相邻比较(内存小,改变原数组)
function findDuplicates(arr) {
arr.sort();
const duplicates = [];
for (let i = 1; i < arr.length; i++) {
if (arr[i] === arr[i – 1]) {
duplicates.push(arr[i]);
// 跳过相同的
while (arr[i] === arr[i + 1]) i++;
}
}
return duplicates;
}
// 方法3:分批处理(数据量太大时)
async function findDuplicatesBatch(arr, batchSize = 10000) {
const seen = new Set();
const duplicates = new Set();
for (let i = 0; i < arr.length; i += batchSize) {
const batch = arr.slice(i, i + batchSize);
for (const item of batch) {
if (seen.has(item)) {
duplicates.add(item);
} else {
seen.add(item);
}
}
// 让出主线程,避免卡顿
await new Promise(resolve => setTimeout(resolve, 0));
}
return […duplicates];
}
优化思路:
- 数据量太大时考虑分批处理或使用 Web Worker
- 如果数据在服务端,用 SQL 的 GROUP BY + HAVING 更高效
- 如果内存不够,可以用布隆过滤器做初步筛选
附录:面试答题技巧
如何组织答案?
结构:
示例(闭包):
- 定义:函数能访问外部作用域的变量
- 原理:作用域链、垃圾回收机制
- 优缺点:数据私有化 vs 内存泄漏
- 应用:防抖节流、模块化
- 代码:给个简单例子
遇到不会的问题怎么办?
项目介绍怎么讲?
用 STAR 法则:
- Situation:项目背景,为什么做
- Task:你的任务是什么
- Action:你做了什么(技术方案、难点解决)
- Result:结果如何(数据量化)
第八部分:JavaScript 进阶(15题)
46. 什么是事件委托?有什么优点?
标准答案:
事件委托是利用事件冒泡机制,把子元素的事件监听委托给父元素来处理。
原理: 事件冒泡是指事件从目标元素向上传播到 document。比如点击一个按钮,点击事件会先触发按钮的点击处理,然后冒泡到父元素、祖父元素,一直到 document。
事件委托就是在父元素上监听事件,通过 event.target 判断具体是哪个子元素触发的。
优点:
示例:
// 不好的做法:给每个 li 绑定
document.querySelectorAll(\’li\’).forEach(li => {
li.addEventListener(\’click\’, handleClick);
});
// 好的做法:委托给 ul
document.querySelector(\’ul\’).addEventListener(\’click\’, (e) => {
if (e.target.tagName === \’LI\’) {
handleClick(e.target);
}
});
适用场景: 列表、表格等有大量相似子元素的场景。
47. 什么是事件循环中的微任务和宏任务?执行顺序是什么?
标准答案:
JavaScript 是单线程的,用事件循环来处理异步任务。异步任务分为宏任务和微任务。
宏任务(Macrotask):
- script 整体代码
- setTimeout、setInterval
- I/O 操作
- UI 渲染
- setImmediate(Node.js)
微任务(Microtask):
- Promise.then、Promise.catch、Promise.finally
- MutationObserver
- queueMicrotask
- process.nextTick(Node.js,优先级最高)
执行顺序:
关键点:
- 微任务优先级高于宏任务
- 每个宏任务执行前,必须先清空微任务队列
- async/await 本质上是 Promise,所以 await 后面的代码是微任务
示例:
console.log(\’1\’);
setTimeout(() => console.log(\’2\’), 0);
Promise.resolve().then(() => console.log(\’3\’));
console.log(\’4\’);
// 输出:1 4 3 2
48. 什么是垃圾回收机制?
标准答案:
垃圾回收是 JavaScript 自动管理内存的机制,回收不再使用的内存。
主要算法:
1. 标记清除(Mark-Sweep)
- 这是现代浏览器主要使用的算法
- 分为两个阶段:标记阶段和清除阶段
- 标记阶段:从根对象(全局对象)开始,遍历所有能访问到的对象,标记为\”可达\”
- 清除阶段:回收没有标记的对象(不可达)
- 优点:简单有效,能处理循环引用
2. 引用计数(Reference Counting)
- 记录每个对象被引用的次数
- 引用次数为 0 时回收
- 缺点:无法处理循环引用(早期 IE 浏览器用,已废弃)
V8 引擎的优化:
分代回收:
- 新生代:存放存活时间短的对象,使用 Scavenge 算法,快速回收
- 老生代:存放存活时间长的对象,使用标记清除 + 标记整理
增量标记:
- 把标记过程拆分成小步骤,穿插在 JavaScript 执行中,避免长时间停顿
内存泄漏的常见原因:
- 意外的全局变量
- 闭包引用未释放
- DOM 引用未清除
- 定时器未清除
- 事件监听未移除
49. 什么是函数柯里化?
标准答案:
柯里化是把一个多参数函数转换成一系列单参数函数的技术。
示例:
// 普通函数
function add(a, b, c) {
return a + b + c;
}
// 柯里化版本
function curryAdd(a) {
return function(b) {
return function(c) {
return a + b + c;
};
};
}
// 使用
curryAdd(1)(2)(3); // 6
// 或者用箭头函数
const curry = fn => a => b => c => fn(a, b, c);
通用柯里化函数:
function curry(fn) {
return function curried(…args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
} else {
return function(…args2) {
return curried.apply(this, args.concat(args2));
};
}
};
}
// 使用
const curriedAdd = curry((a, b, c) => a + b + c);
curriedAdd(1)(2)(3); // 6
curriedAdd(1, 2)(3); // 6
curriedAdd(1)(2, 3); // 6
应用场景:
const multiply = curry((a, b) => a * b);
const triple = multiply(3);
triple(4); // 12
延迟执行:先传入部分参数,等后续再执行
函数组合:配合 compose 使用
优点: 提高函数复用性,支持函数式编程风格。
50. 什么是函数组合(Compose)?
标准答案:
函数组合是把多个函数组合成一个新函数,数据从右向左流动。
实现:
// 从右向左执行
function compose(…fns) {
return function(x) {
return fns.reduceRight((acc, fn) => fn(acc), x);
};
}
// 从左向右执行(pipe)
function pipe(…fns) {
return function(x) {
return fns.reduce((acc, fn) => fn(acc), x);
};
}
示例:
const add5 = x => x + 5;
const multiply2 = x => x * 2;
const toString = x => String(x);
const composed = compose(toString, multiply2, add5);
composed(3); // \”16\” (3+5=8, 8*2=16, \”16\”)
const piped = pipe(add5, multiply2, toString);
piped(3); // \”16\” (3+5=8, 8*2=16, \”16\”)
应用场景:
- 数据处理流水线
- 中间件实现(如 Redux、Koa)
- 函数式编程
结合柯里化:
const compose = (…fns) => x => fns.reduceRight((v, f) => f(v), x);
const curry = fn => a => b => fn(a, b);
const map = curry((fn, arr) => arr.map(fn));
const filter = curry((fn, arr) => arr.filter(fn));
const process = compose(

