“前端知识点总结”——一看到这个题目,我就想起最近带过的几场技术评审。很多人把前端知识当成面试题集合:闭包背定义、Vue 响应式背八股,可一旦被追问到“项目里哪里用到了?当时是怎么解决的?”就很容易卡壳。我写这篇,并不是给你列一份考点清单,而是把前端开发和近年面试里反复出现的高频知识点,重新串成一条可回溯的链路:JS 运行机制、框架设计、工程化、浏览器网络、性能优化,再加一点我对 AI 与测试趋势的观察。基础一般的朋友可以拿它当索引查漏补缺,有几年经验的同学也可以借它做一轮系统性复盘。
1. JavaScript 核心基础:会写和讲明白差在哪
1.1 闭包与作用域:定义之外更有价值的追问
闭包的定义很简单:函数能够记住并访问自己定义时的词法作用域,即使这个函数是在作用域之外执行的。很多人能把这句话背出来,但问到“闭包到底捕获了什么”,就开始模糊了。闭包捕获的是变量引用,而不是变量当时的快照。我经常用计数器举例:
javascript复制function createCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
返回的那个匿名函数,在 createCounter 执行结束之后仍然能访问 count,因为它的作用域链上保留了 createCounter 的变量对象。这里有个容易忽略的点:只要这个匿名函数还活着,count 就不会被垃圾回收。说得直白一点,闭包不是“把值存起来”,而是“把整个存活的作用域拽在手里”,这既是它强大的原因,也是内存泄漏风险的来源。
面试里高频追问是:闭包会不会造成内存泄漏?我的建议是不要回答“会”或“不会”这种绝对结论。闭包本身不是问题,问题在于闭包是否被长期不必要地持有。最典型的一个坑,是把带闭包的函数绑定到事件监听器上,组件销毁时却忘了移除监听。再比如下面这种:
javascript复制const leak = [];
document.getElementById('btn').addEventListener('click', function onClick() {
const largeData = new Array(100000).fill('x');
leak.push(largeData);
});
leak 数组一直存活,onClick 也一直存活,那么每次点击产生的数据都不会被释放。正确的做法是:在合适的生命周期里移除事件监听,或者把不再需要的大对象手动置为 null。如果业务里需要缓存对象但不想影响回收,可以优先考虑 WeakMap 和 WeakRef。
再有就是 var 和 let 在循环里创建异步任务这个经典场景。用 var 声明的循环变量只有一个,循环结束之后所有 setTimeout 回调拿到的都是同一个最终值;用 let 每次迭代都会绑定一个新的词法环境,所以回调能拿到正确的 i。很多人把它当背题,但它的底层依据其实就是作用域和闭包的建立方式。理解到这一层,遇到类似问题就不需要死记了。
提示:面试时被问“闭包的应用场景”,别只说防抖节流。可以补上:缓存函数、私有变量、偏函数、事件处理中的一次性逻辑。这些都能说明你是真的在项目里用过闭包,而不是只看了概念。
1.2 事件循环:宏任务、微任务与渲染时机
事件循环是前端面试里最容易被“变体题”淘汰的知识点之一。背过输出顺序不难,难的是解释为什么。先看一个最经典的例子:
javascript复制console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
});
console.log('script end');
输出顺序是:script start → script end → promise1 → promise2 → setTimeout。关键点在于:当前同步代码执行完后,JS 引擎不会立刻去执行宏任务,而是先把微任务队列清空。Promise 的 then 回调是微任务,setTimeout 的回调是宏任务,所以微任务一定在下一个宏任务之前执行。
加了 async/await 之后就会复杂一些,因为很多人不知道 await 后面的代码本质上也是通过类似 then 的机制注册的。看这个变体:
javascript复制async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
async1();
new Promise((resolve) => {
console.log('promise');
resolve();
}).then(() => {
console.log('promise then');
});
输出顺序是:async1 start → async2 → promise → async1 end → promise then。为什么会这样?await async2() 让代码先同步执行完 async2,再把后面的 console.log('async1 end') 注册为微任务;紧接着继续执行同步代码,打印 promise,并把它的 then 也注册为微任务。微任务队列遵循先进先出,所以 async1 end 先于 promise then 输出。
如果你想在浏览器里验证,打开 DevTools 的 Sources 面板逐步执行,比空想直观得多。有一个值得留意的细节:浏览器并不是执行完一个宏任务就立刻渲染,而是会在合适的时机插入渲染步骤。如果一段同步任务占用主线程超过一两百毫秒,用户就会感觉页面卡住。这引出了另一个常见面试题:setTimeout 为什么不适合做动画? 因为宏任务之间是否渲染由浏览器决定,时间不稳定;而 requestAnimationFrame 和浏览器帧率对齐,能避免掉帧。
我建议把事件循环拆成三层来理解:调用栈负责执行同步代码,微任务队列在每次调用栈清空后立刻清空,宏任务队列由浏览器或 Node 环境按一定节奏调度。这样遇到任何题目,先在脑子里给代码按“同步 / 微任务 / 宏任务”分类,输出顺序基本不会错。
1.3 JSON.stringify 与前端性能优化的隐藏关联
老实说,JSON.stringify 这个知识点能成为面试高频,是因为它在实际业务里的出场率实在太高了。最常见的用途是深拷贝、localStorage 存储、把对象转成字符串做缓存 key。但它有一堆隐藏规则,不清楚的话很容易写出隐蔽故障。
目前最常见的场景是一行深拷贝:
javascript复制const copy = JSON.parse(JSON.stringify(original));
这个写法在数据结构简单时没有问题,但只要对象里面出现 undefined、函数、Symbol,这些值会被直接忽略;出现 NaN 或 Infinity 会被转成 null;Date 会被转成 ISO 字符串;RegExp、Map、Set 会被转成空对象。更麻烦的是循环引用会直接抛错:
javascript复制const obj = {};
obj.self = obj;
JSON.stringify(obj); // Uncaught TypeError: Converting circular structure to JSON
还有 BigInt,字符串化时直接抛 TypeError,而不是自动转成字符串。所以我在带新人时总会强调:JSON 深拷贝是“低配工具”,不是万能方案。如果你的需求只是处理普通对象,那它可以一行解决;如果数据结构复杂,建议用 structuredClone 或者自己写一个支持 Map、Set、日期和循环引用的深拷贝函数。
再说说 JSON.stringify 和性能优化之间的关系。后台管理系统里很常见的一个操作,是把接口返回的大数组拼成字符串塞进 localStorage 做缓存。数据量小的时候没问题,但数据一旦达到几万条,序列化和反序列化都会占用明显的主线程时间。我踩过的一个坑是列表搜索功能:用户每次改变筛选条件,我都调用一次 JSON.stringify 把整个筛选对象转成字符串,再拿这个字符串去比对缓存。当时没在意,后来数据量大了才发现,一次字符串化就要几十毫秒,频繁切换筛选条件时页面明显卡顿。
比较好的做法是只提取关键字段来做缓存 key,并且按固定顺序拼接,避免对象属性顺序不一致导致 key 失效:
javascript复制function createCacheKey(params) {
const keys = Object.keys(params).sort();
return keys.map((key) => `${key}=${JSON.stringify(params[key])}`).join('&');
}
这样字符串更短,序列化成本也更低。另一个容易被忽略的点是:如果需要序列化一个几十 MB 的日志对象,建议按字段分批做字符串拼接,避免一次性生成一个巨大的中间字符串导致内存峰值过高。还有个小技巧:在 Vue 或 React 里对比较复杂的对象做 watch 时,不要轻易写 JSON.stringify(obj) 作为比较依据,它会在每次变更时产生完整的新字符串,性能开销很大。能细化到具体字段就细化,不能细化就考虑用 immutable 的思路维护数据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Vue 框架底层:从设计动机理解组件化
2.1 响应式原理:Vue2 到 Vue3 为什么要重构
Vue 的响应式原理是面试必问题,但我建议别去背“defineProperty 拦截属性、Proxy 拦截对象”这种一句话,而是理解设计动机。Vue2 用 Object.defineProperty 拦截对象的属性读写,初始化时需要递归遍历 data 里的每个属性,把每个 key 都变成 getter/setter。这种方案有三个天然短板:新增属性无法被拦截,删除属性无法被拦截,通过下标修改数组元素也无法被拦截。所以 Vue2 才设计了 Vue.set、Vue.delete,并且对数组方法做了拦截重写。
Vue3 换成 Proxy 后,拦截的粒度从“属性”上升到“对象”,新增属性、删除属性、修改数组下标都能被感知。另一个变化是初始化性能:Proxy 代理的是整个对象,配合 get 时的惰性处理,可以只在数据真正被读取时才去做深层响应式转换,省掉了初始化阶段的大规模递归。
Vue3 里为什么还会有 ref?因为 Proxy 只能代理对象,而 string、number、boolean 这类原始类型没有对象结构可以代理。ref 的做法是在内部创建 { value: 原始值 } 这样的包装对象,把原始值塞进对象属性里再交给响应式系统管理。模板中 ref 会自动解包,所以写起来感觉像在操作一个普通变量,这也导致很多新人没搞明白背后发生过什么。
如果面试官继续追问响应式依赖收集的流程,可以这样回答:访问响应式数据时触发 get 拦截,收集当前正在执行的副作用函数(effect);修改数据时触发 set 拦截,找到该属性的所有依赖并触发执行。Vue3 中还做了依赖清理和高效的数据结构来避免重复收集。说到这里,再看 computed 和 watch 的区别就很清晰了:computed 是有缓存的计算属性,数据依赖不变时不会重新计算,适合做派生数据;watch 则用于执行副作用,筛选条件变化后请求接口这种场景就应该放在 watch 或手动调用中实现。
注意:很多人面试时会说“Vue3 比 Vue2 快”,这其实不够准确。Proxy 带来的收益更多是可拦截能力更全面和初始化开销更低,并不是所有运行场景都无脑变快。用数据说话、分场景分析,比一句简单结论更有说服力。
2.2 组件通信与状态管理:别一上来就上 Pinia
组件通信是所有 Vue 项目都绕不开的问题。最基础的父子通信是 props 往下传、emit 往上抛,这个组合能覆盖大概 70% 的简单场景。如果只是子组件要修改父组件传入的某个值,可以用 v-model 的语法糖,本质上是把事件和 prop 绑定到同一个属性上。Vue3.4 之后又推出了 defineModel,让自定义组件支持 v-model 变得更简洁。
跨多层组件时用 provide/inject 很顺手,但要知道它有一个很重要的限制:默认不是响应式的。如果你 provide 一个普通对象,子组件里拿到的不会自动更新。需要响应式时,要 provide 一个 ref 或 reactive 对象。我见过有团队把 provide/inject 当成全局状态管理用,组件里直接改 inject 进来的对象,导致数据流难以追踪。这里我的个人看法是:provide/inject 适合注入主题配置、国际化资源、通用方法这类“不太容易变”的内容,不适合承载频繁变化的业务状态。
EventBus 是另一个需要谨慎使用的方案。它在小项目里确实很方便,两个无关组件之间广播一个事件就能通信,但项目规模变大以后,事件的触发方和监听方散落在各处,出了问题非常难定位。有一次我们排查线上 bug,发现一个全局事件同时在三个组件里被监听,其中一个组件的监听器没有销毁,导致页面切换后重复触发,最后只能靠全局搜索事件名才找到问题。如果想保留事件通信的便利性,可以用 mitt 这类轻量库,同时约定事件命名规范,并保证销毁时移除监听器。
对于真正复杂的跨页面共享状态,我建议直接上 Pinia。它的模块化方式比 Vuex 更简单,TypeScript 支持也更友好。判断“该不该用状态管理库”有一个很实用的标准:如果这个状态只存在于某个页面内、且不会跨组件被修改,那就用组件内状态或 composable 封装;如果它会被多个路由页面或兄弟组件复用,而且修改逻辑需要统一收敛,再引入 Pinia。提前做技术选型不是坏事,但给每一个小状态都开一个 store,项目最后会变得又重又难维护。
2.3 Virtual DOM 与 Diff:Key 为什么不能乱写
虚拟 DOM 不是为了让 DOM 操作“永远最快”,而是为了在复杂视图变化时,用声明式的方式描述 UI,再通过 Diff 算法找出最小化的真实 DOM 更新路径。如果没有虚拟 DOM,我们需要手动维护每一步 DOM 操作,代码复杂度会直线上升。Vue 的模板编译成渲染函数后,每次组件更新都会重新执行渲染函数得到一棵新的虚拟 DOM 树,然后和旧树对比,只把差异应用到真实 DOM。
关于 Diff 算法,面试里其实不需要你背 Vue3 源码里每一行 patchKeyedChildren 的逻辑,但一定要说得清楚:新旧节点在同一层级对比,不会跨层级移动;比较通过 tag 和 key 判断节点是否可复用;移动节点时用双端指针或最长递增子序列来减少移动次数。Key 的作用就是告诉 Diff:“这两个节点是同一个人,可以移动位置复用,而不是销毁重建。”
我在代码 review 里看见最多的一个坑,就是使用 index 作为 key。看这个例子:
html复制<div v-for="(item, index) in list" :key="index">
<input v-model="item.name" />
</div>
在列表头部插入一条新数据时,数组中所有元素的 index 都会改变。Vue 会认为 key 相同的节点是同一个组件实例,于是直接复用了原来的 DOM,导致 input 里保留的内部状态跑到错误的数据行上。如果你在项目里遇到“明明绑对数据了,输入框的值却串行”的诡异问题,优先检查 v-for 的 key。
可以这样理解:key 是节点的学号,编号唯一且稳定,才能让 diff 算法认准人。index 相当于座位号,学生换座位后座位号会变,老师就认错人了。业务数据的 id 通常是最佳选择;如果数据确实没有唯一字段,也要组合多个字段生成一个稳定的复合 key,而不是偷懒用 index。
3. 前端工程化:路由、构建与定位源码
3.1 路由体系:hash、history 与部署适配
前端路由是面试和实际开发双高频的知识点。核心要理解的是:为什么前端能实现“页面切换不发新请求”?两种模式给出了不同答案。
hash 模式利用的是 URL 中 # 之后的内容变化不会触发浏览器向服务器发请求,同时会触发 hashchange 事件。实现成本很低,部署时基本不需要额外配置,但 URL 里始终带一个 #,不够美观,也不利于分享时的语义表达。
history 模式基于 HTML5 的 History API,用 pushState 或 replaceState 改变 URL,地址栏看起来和普通网站没有区别。但它的代价是:用户直接访问一个深层路由时,服务器会按照路径去找对应文件,找不到就返回 404。所以生产环境必须把所有路由都 fallback 到 index.html,由前端 JS 再根据当前 URL 渲染对应页面。Nginx 里常见的配置是 try_files:
nginx复制location / {
try_files $uri $uri/ /index.html;
}
配置要注意静态资源的路径,否则会出现刷新后页面能打开、但 js/css 路径全部变成相对路径 404 的问题。我建议在项目里都用绝对路径或 publicPath 来控制资源指向,避免 history 模式下资源地址错乱。
Vue Router 或 React Router 都提供了路由懒加载能力,也就是只在访问某个路由时才去加载对应组件。这个优化对首屏性能影响非常大,尤其是后台系统可能挂了几百个页面。一个最基本的配置长这样:
javascript复制{
path: '/user',
name: 'User',
component: () => import('@/views/system/User.vue')
}
很多人把懒加载当成“按需”,却忽略了它还会自动创建独立 chunk。配合浏览器缓存,用户二次访问时这些 chunk 可以直接命中缓存,加载速度会明显提升。但要提醒一下:如果懒加载的页面太大,切换路由时会出现短暂白屏。可以考虑给顶层路由加一个 loading 状态,或者在浏览器空闲时预取某些高频页面的 chunk。
3.2 如何根据前端路由快速找到对应源码文件
不少朋友问过我:接到一个需求,描述是“某个页面某个按钮有问题”,可打开项目后面对几百个文件夹完全不知道从哪下手。其实根据路由找源码有非常固定的套路。
第一步永远是从路由配置入口找。Vue 项目的入口通常在 src/router/index.js 或 src/router/routes.ts,找到当前页面对应的路由记录,看它的 component 字段。如果用了动态 import,路径会直接告诉你文件位置。在 VSCode 或 WebStorm 里可以按着 Ctrl 或 Cmd 键点击路径跳转,非常快。
有些项目使用了约定式路由,文件目录本身和路由一一对应,那么 /system/user 大概率对应 src/pages/system/user 或 src/views/system/user。这时你只要对着目录层级逐层找就行。如果路由配置和目录结构不一致,还有一个兜底方案:打开页面,在 DevTools 的 Sources 面板搜索页面上的关键中文文案,基本能把组件文件定位出来;或者用全局搜索搜页面中某个按钮的唯一文字,命中后顺着往上找父组件。
我给新项目定规范时会特别强调:路由 name 要语义化
