第十二关:JS 垃圾回收机制 (GC)
既然我们聊到了“小本本”存函数,那这些东西如果不清理,内存就爆了。
面试题: JavaScript 是如何判断一个变量该不该被回收的?你听说过**“引用计数”或者“标记清除”**吗?
JS 垃圾回收机制是什么?然后它有两个垃圾回收机制,一个是引用计数,一个是标记清除。
然后标记清除是什么,就是假设,你可以想象从 JS 根对象开始遍历,然后如果被使用到了,就打上标记,如果没有被使用到的,就没有打上标记,然后就是通过标记来确定是否要进行垃圾回收。
然后引用计数就是,假如有两个变量 a 和变量 b,然后 a 引用了 b,然后这相当于 b 就被引用了一次,然后计数就为 1,如果被引用了两次,就计数为 2,然后这叫引用计数。
但是引用计数它有个问题,就是如果变量 a 和变量 b 它们之间进行循环引用,所以说它们循环引用的话就会有问题,所以说现在 JS 垃圾回收机制用引用计数比较少了,然后基本上都是用的标记清除。
1. 标记清除(现代 JS 主流)
核心流程:
- 标记阶段:JS 引擎从全局对象(window/global)这个 “根” 出发,遍历所有可访问的对象 / 变量,给它们打上 “存活” 标记;
- 清除阶段:遍历堆内存中所有对象,没有 “存活” 标记的对象会被判定为垃圾,占用的内存会被释放。
优势:完美解决循环引用问题,比如:
javascript
运行
function fn() {
let a = {};
let b = {};
a.b = b; // a引用b
b.a = a; // b引用a(循环引用)
}
fn(); // 函数执行完,a、b从根对象无法访问,标记清除会回收它们的内存
2. 引用计数(已淘汰 / 仅兼容场景)
核心流程:
- 每个对象都有一个 “引用计数器”,记录被引用的次数;
- 引用次数为 0 时,触发垃圾回收,释放内存;
- 循环引用时,计数器永远 > 0,内存永远无法释放(内存泄漏)。
缺点示例:
javascript
运行
function fn() {
let a = {};
let b = {};
a.b = b;
b.a = a;
}
fn(); // 函数执行完,a、b循环引用,引用计数都为1,无法被回收→内存泄漏
总结
- 标记清除:现代 JS 引擎的主流方案,从 “根” 遍历标记存活对象,清除未标记的垃圾,解决了循环引用问题;
- 引用计数:早期方案,通过计数判断是否回收,但存在循环引用导致内存泄漏的问题,现在几乎不用;
- 核心目的:垃圾回收机制自动释放不再使用的内存,避免内存泄漏,保证 JS 运行的性能和稳定性。
第十三关:浏览器存储(必考题)
既然聊到了内存,我们也得聊聊数据的“持久化”。
面试题: 请说出 Cookie、LocalStorage 和 SessionStorage 的区别。
- 谁存得大?
- 谁会跟着请求发给服务器?
- 谁在关掉浏览器后就消失了?
所以说数据持久化存储的话就有 cookie,然后 localStorage 以及 sessionStorage。一般来讲,在 localStorage 和 sessionStorage 没有出现之前,我们都是用 cookie 来存数据的,但是 cookie 存数据的话,它的存储空间比较小,好像只有几 KB,所以一般存不了多大的数据。
同时因为 cookie 存数据的这个局限性,后来就出现了 localStorage 和 sessionStorage。localStorage 存数据的话,空间要大一点,同时也更持久。那 localStorage 和 sessionStorage 的区别是什么?sessionStorage 是如果这个标签页被关闭,里面的数据就直接被清空掉了,但是 localStorage 的数据不会被清空。
同时 localStorage 和 sessionStorage,一般来讲都是通过 setItem 和 getItem 来操作数据。
既然说到 cookie 了,我们就来说一下登录相关的。之前很多人会问到这个知识点:比如一般登录鉴权的信息会写在哪里?我们一般都会放在 cookie 里面,然后加上一个 HTTP only 只读属性。
那除了这三个存储方式,还有什么其他的存储选项?比如还有 IndexedDB(你说的 next DB),它相当于浏览器里的数据库,也就是前端的数据库,这个一般用得比较少,通常是在网页需要离线模式使用的场景下才会用到 IndexedDB。
还有一种存储数据的方式,可能是用 Node 做一些中间层,然后来存数据。
1. 三种存储方式的“大白话”对比表
| 特性 | Cookie | LocalStorage | SessionStorage |
|---|---|---|---|
| 容量 | 非常小 (约 4KB) | 很大 (5MB - 10MB) | 很大 (5MB - 10MB) |
| 有效期 | 跟着你设置的时间走 | 永久有效,除非手动删 | 随关随走 (标签页关了就没了) |
| 和服务器通信 | 每次请求都带上 (浪费带宽) | 不参与通信 | 不参与通信 |
| 主要用途 | 存 SessionID、登录令牌 | 存用户偏好设置、缓存数据 | 存临时表单数据、单次访问状态 |
2. 深入探讨:关于登录鉴权 (Authentication)
你提到的 HttpOnly 是点睛之笔。
- 为什么加 HttpOnly? 因为如果 Cookie 没加这个属性,坏人可以通过 JavaScript 代码(
document.cookie)直接偷走你的登录令牌(这就是 XSS 攻击)。加了之后,JS 就读不到了,只有浏览器发请求时会自动带上,安全等级直接拉满。
3. 除了这三个,还有什么存储选项?
你提到了 IndexedDB,我们可以再补充两个更冷门但显得你很资深的:
-
IndexedDB(前端数据库) :
- 特点:它是非关系型数据库(类似 MongoDB),可以存成千上万条数据,支持索引查询。
- 场景:比如离线记账软件、在线文档编辑器(如语雀、飞书),在没网的时候把内容存在 IndexedDB 里。
-
Cache API (配合 Service Worker) :
- 场景:这是 PWA(渐进式 Web 应用) 的核心。它不仅存数据,还存整个资源文件(JS、CSS、图片)。有了它,你的网页在断网时也能像 App 一样打开。
请描述一下从用户输入 URL 到页面显示的过程中,有哪些环节可以优化?请至少说出 3 个具体的方案。
-
路上跑快点(网络层面) :
- 减少体积:就像把大件拆成小件。比如:压缩代码(Uglify)、图片压缩。
- 少跑几趟:利用缓存。比如:你刚才说的 Cookie、LocalStorage,或者更高级的 强缓存/协商缓存(HTTP 缓存)。
- 找近路:用 CDN(把资源放在离用户最近的服务器上)。
-
卸货快一点(渲染层面) :
- 懒加载(Lazy Load) :不看的图片先别加载,等用户滚到了再加载。
- 回流与重绘:尽量少改动页面的布局(比如改宽高),多改颜色等不影响位置的属性。
-
代码写好点(运行层面) :
- 防抖/节流:别让 JS 跑得太累。
- 分包处理:不要一次性加载一个 10MB 的 JS 文件,按需加载。
你会怎么处理网页里成百上千张大图的加载?
1. 核心大招:懒加载(Lazy Load)
大白话:只有当图片进入到你的手机屏幕窗口时,才去请求真实的图片地址。
-
做法:
- 先把图片的
src设为一个很小的占位图(或者空白)。 - 把真实的图片地址存在一个自定义属性里(比如
data-src)。 - 监听滚动:利用浏览器自带的
IntersectionObserver(这个 API 性能最好),它会自动告诉你图片什么时候出现在视野里,到时候再把data-src换给src。
- 先把图片的
2. 使用响应式图片(别拿大炮打蚊子)
大白话:如果你手机屏幕只有 400 像素宽,就没必要加载一张 4000 像素的高清大图。
- 做法:使用 HTML 的
<picture>标签或srcset属性,让浏览器根据设备屏幕的大小,自动选择一张合适尺寸的图片去下载。
3. 图片格式优化(瘦身)
大白话:同样的清晰度,有的格式就是体积小。
- 做法:优先使用 WebP 或 AVIF 格式。它们比传统的 JPG、PNG 小很多(甚至能小 30% 以上)。
- 技巧:如果怕老浏览器不支持,可以用
<picture>标签做兼容处理。
4. 骨架屏或低质量预览(体验优化)
大白话:图片还没出来时,先给用户看个灰色的方块或者模糊的虚影,让他觉得页面没死。
- 做法:LQIP (Low Quality Image Placeholders) 。先加载一张只有几 KB 的极小缩略图并拉大模糊,等原图下载完了再替换。
5. 图片雪碧图(Sprite)或小图内联
大白话:如果有很多几 KB 的小图标,别发几十次请求。
- 做法:把小图标合成一张大图,用 CSS 背景定位去切;或者直接转成 Base64 编码嵌在代码里。
HTTP 状态码(职场暗号)
优化聊完了,面试官可能会考考你的“常识”,看看你能不能跟后端配合好。
面试题: 你在控制台看请求时,经常会看到 200, 301, 404, 500 之类的数字。
- 401 和 403 有什么区别?(这是关于登录最常考的)
- 304 为什么是性能优化的关键?
不知道 这个不好记 我怎么记忆呢
第一步:先记首位数字(分类)
- 1xx:等会儿(正在处理)。
- 2xx:成功了(没问题,干得漂亮)。
- 3xx:你去那(重定向,你要找的东西搬家了)。
- 4xx:你错了(前端传参错了、没登录、地址写错了)。
- 5xx:他错了(后端服务器炸了、数据库挂了)。
💡 第二步:记最常考的几个“职场暗号”
1. 登录相关的(最爱考):401 vs 403
- 401 (Unauthorized) : “你是谁?” —— 你没登录,或者你的 Token 过期了,警察不认识你。
- 403 (Forbidden) : “你不能进!” —— 你登录了,你是普通员工,但你非要进财务办公室。你没这个权限。
2. 性能相关的(重点):304
- 304 (Not Modified) : “没变,用旧的!”
- 大白话:你去店里买东西,老板说:“你手里那个就是最新的,我这没更新,你直接拿回去用吧。”
- 意义:浏览器不需要重新下载图片或 JS,直接用缓存,速度起飞。
3. 找不着的:404 vs 301
- 404 (Not Found) : “没这人。” —— 你的 URL 路径写错了,服务器翻遍了也找不到这个页面。
- 301 (Moved Permanently) : “搬家了。” —— 永久重定向。比如以前是
a.com,现在永久换成b.com了。
4. 后端崩溃:500 vs 502
- 500 (Internal Server Error) : “代码写 Bug 了。” —— 后端代码报错。
- 502 (Bad Gateway) : “网线断了。” —— 通常是服务器网关或者 Nginx 没配置好,没连上后台。
第十七关:深拷贝 vs 浅拷贝
这道题考察的是你对内存引用的理解。
面试题:
- 什么是浅拷贝?什么是深拷贝?
- 给你一个复杂的对象(里面嵌套了好几层对象和数组),如果你想完整复制一份,改了副本不影响原件,你会怎么做?
💡 不专业的“大白话”解释:
-
浅拷贝(复制钥匙) :
- 就像你把房门钥匙复制了一把给朋友。虽然你们有两把钥匙,但开的是同一个房门。他在屋里刷了墙,你回去看墙也变色了。
- 代码:
Object.assign()或者...展开运算符。
-
深拷贝(复制房子) :
-
就像你照着原来的房子,在别的地方又盖了一座一模一样的。现在是两座房子,他在那间屋里放火,你的房子完全没影响。
-
代码做法:
- 偷懒法:
JSON.parse(JSON.stringify(obj))。(但这个法子不能复制函数和正则)。 - 专业法:用
lodash库的cloneDeep,或者自己写递归。
- 偷懒法:
-
什么是浅拷贝?什么是深拷贝?
浅拷贝的话,我可以这么理解:比如有一个很复杂的对象,这个对象的属性嵌套了底层对象。把这个对象命名为 a,将 a 浅拷贝成 b,这时候有 a 和 b 两个变量,你修改 b 对象里的属性值,a 也会跟着变 —— 因为浅拷贝下,这两个对象还是指向同一个引用地址。
而深拷贝就不会出现这种情况:你修改 b 对象里的属性值,a 完全不会变。
我一般如果需要深拷贝,会用 lodash 函数库里的方法,比如 cloneDeep 这个函数来实现深拷贝;也可以用 JSON.parse 和 JSON.stringify 来做深拷贝,但是 JSON.parse 和 JSON.stringify 会有一些问题。
关于 JSON.parse(JSON.stringify(obj)) 的“坑”,面试官如果追问你:“它到底有什么问题?” 你可以准备好这三点:
函数会丢失:对象里的函数会被直接删掉。
正则会变空:正则对象会变成空对象 {}。
循环引用会报错:如果 a 引用了 b,b 又引用了 a,它会直接卡死报错。
第十八关:变量提升(Hoisting)练习
你还没回答我刚才那个代码题,咱们来看看你对 JS 执行顺序 的直觉:
JavaScript
console.log(a);
var a = 10;
问题: 这里的 console.log(a) 会打印出什么?
- 报错
- 10
- undefined
💡 你的答案是?顺便说说为什么?
先把刚才那道代码题的悬念揭晓:
JavaScript
console.log(a); // 结果是 undefined
var a = 10;
为什么? 因为 JS 引擎在执行时,会把代码看成这样:
var a;(先把名字提上去,这时候还没赋值,所以是undefined)console.log(a);a = 10;(这行才开始赋值) 这就是变量提升。如果是用let或const,第一行就会直接报错(暂时性死区),这就是为什么现在大家都推荐用let的原因。
第十九关:数组去重(实战题) 假设面试官给你一个数组:const arr = [1, 1, 2, 3, 3, 4]。 面试题: 请用最简单的一行代码,把它变成 [1, 2, 3, 4]。
💡 提示(不专业的“大白话”):
ES6 新武器:有一个东西叫 Set。
Set 的特点:它是个“眼里不容沙子”的集合,重复的东西放进去,它会自动把多余的扔掉。
你可以试着写写看,或者说说 Set 是怎么配合“三个点(展开运算符)”用的?
第十九关:数组去重(实战题) 面试题: 假设有一个数组 const arr = [1, 1, 2, 3, 3, 4],请用最简单、最现代的方式把它变成 [1, 2, 3, 4]。
正确答案(一行代码):
JavaScript
const uniqueArr = [...new Set(arr)]; 💡 为什么这么写?(大白话原理解析) 这一行代码其实结合了 ES6 的两个大杀器:
new Set(arr):
大白话:Set 是 ES6 新出的一个集合类型。它最大的特点就是**“眼里容不下沙子”**——它里面不允许有重复的值。
过程:当你把 [1, 1, 2] 丢进 Set 的时候,它会自动把重复的 1 踢出去。
注意:这时候它还是一个 Set 对象,长得像这样 {1, 2, 3, 4},不是数组。
...(展开运算符):
大白话:就像咱们之前说的“拆包装”。
过程:... 把 Set 里的东西一个个掏出来,然后外面套上 [],它们就乖乖地重新变成了数组。
第十九关:模块化(CommonJS vs ES6 Module)
面试题: 你在项目里肯定用过 import 和 require。
- 它们两个有什么区别?
- 为什么现在的工程化项目(比如 Vite/Webpack)更推荐使用
export/import?
这一关聊的是前端工程化的基础。在面试中,这不仅是考语法,更是考你对性能优化和现代工具链的理解。
我们用**“静态 vs 动态”**这个核心眼光来拆解这两者的区别:
1. 它们的主要区别(大白话版)
| 特性 | require (CommonJS) | import (ES6 Module) |
|---|---|---|
| 出身 | Node.js 社区的老标准。 | 浏览器官方的国际标准。 |
| 什么时候加载 | 运行时加载。代码运行到这一行,才去拉取模块。 | 编译时加载。代码还没跑,打包工具就已经知道谁引用了谁。 |
| 传递的是什么 | 值的拷贝。导出的值被复刻了一份,原件改了,副本不变。 | 值的引用。像是一个实时的“快捷方式”,原件改了,你拿到的也是最新的。 |
| 位置要求 | 随便放。可以写在 if 判断里。 | 只能放在文件顶层。不能写在逻辑判断里(除非用动态 import)。 |
2. 为什么现在的项目(Vite/Webpack)更推荐 import?
这是面试官最想听到的核心原因:
① Tree Shaking(摇树优化)—— 最重要的原因
- 大白话:现代打包工具(如 Webpack, Rollup, Vite)有一种功能叫“摇树”。因为
import是静态的,工具在打包前就能确定:你到底用了哪个函数,没用哪个。 - 结果:没用到的代码(枯树叶)会被直接删掉,不会打进最终的包里。而
require是动态的,工具不敢随便删,导致打包体积变大。
② 更好的异步加载
import()可以作为函数使用,支持按需加载。比如:用户点击了某个按钮,才去下载对应的 JS 文件。这对提升首屏加载速度非常关键。
③ 浏览器原生支持
- 现在的现代浏览器已经可以直接识别
<script type="module">了。这意味着在开发环境(比如 Vite)下,我们可以不经过复杂的打包,直接跑代码,速度快得惊人。
💡 面试官可能会挖的“小坑”:
“如果我在 ES6 模块里改了导出的变量,外部引用它的地方会变吗?”
- 你的回答:会变。 因为
import导出的是“只读引用”(Live Bindings)。它就像是连通器,两端的数据是同步的。 - 补充回答:但在 CommonJS 里,它是“值的拷贝”,一旦
require进来,两边就失去联系了。