js 10道面试题

40 阅读15分钟

第十二关: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,无法被回收→内存泄漏

总结

  1. 标记清除:现代 JS 引擎的主流方案,从 “根” 遍历标记存活对象,清除未标记的垃圾,解决了循环引用问题;
  2. 引用计数:早期方案,通过计数判断是否回收,但存在循环引用导致内存泄漏的问题,现在几乎不用;
  3. 核心目的:垃圾回收机制自动释放不再使用的内存,避免内存泄漏,保证 JS 运行的性能和稳定性。

第十三关:浏览器存储(必考题)

既然聊到了内存,我们也得聊聊数据的“持久化”。

面试题: 请说出 CookieLocalStorageSessionStorage 的区别。

  • 谁存得大?
  • 谁会跟着请求发给服务器?
  • 谁在关掉浏览器后就消失了?

所以说数据持久化存储的话就有 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. 三种存储方式的“大白话”对比表

特性CookieLocalStorageSessionStorage
容量非常小 (约 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. 图片格式优化(瘦身)

大白话:同样的清晰度,有的格式就是体积小。

  • 做法:优先使用 WebPAVIF 格式。它们比传统的 JPG、PNG 小很多(甚至能小 30% 以上)。
  • 技巧:如果怕老浏览器不支持,可以用 <picture> 标签做兼容处理。

4. 骨架屏或低质量预览(体验优化)

大白话:图片还没出来时,先给用户看个灰色的方块或者模糊的虚影,让他觉得页面没死。

  • 做法LQIP (Low Quality Image Placeholders) 。先加载一张只有几 KB 的极小缩略图并拉大模糊,等原图下载完了再替换。

5. 图片雪碧图(Sprite)或小图内联

大白话:如果有很多几 KB 的小图标,别发几十次请求。

  • 做法:把小图标合成一张大图,用 CSS 背景定位去切;或者直接转成 Base64 编码嵌在代码里。

HTTP 状态码(职场暗号)

优化聊完了,面试官可能会考考你的“常识”,看看你能不能跟后端配合好。

面试题: 你在控制台看请求时,经常会看到 200, 301, 404, 500 之类的数字。

  • 401403 有什么区别?(这是关于登录最常考的)
  • 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 浅拷贝

这道题考察的是你对内存引用的理解。

面试题:

  1. 什么是浅拷贝?什么是深拷贝
  2. 给你一个复杂的对象(里面嵌套了好几层对象和数组),如果你想完整复制一份,改了副本不影响原件,你会怎么做?

💡 不专业的“大白话”解释:

  1. 浅拷贝(复制钥匙)

    • 就像你把房门钥匙复制了一把给朋友。虽然你们有两把钥匙,但开的是同一个房门。他在屋里刷了墙,你回去看墙也变色了。
    • 代码Object.assign() 或者 ... 展开运算符。
  2. 深拷贝(复制房子)

    • 就像你照着原来的房子,在别的地方又盖了一座一模一样的。现在是两座房子,他在那间屋里放火,你的房子完全没影响。

    • 代码做法

      • 偷懒法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) 会打印出什么?

  1. 报错
  2. 10
  3. undefined

💡 你的答案是?顺便说说为什么?

先把刚才那道代码题的悬念揭晓:

JavaScript

console.log(a); // 结果是 undefined
var a = 10;

为什么? 因为 JS 引擎在执行时,会把代码看成这样:

  1. var a; (先把名字提上去,这时候还没赋值,所以是 undefined)
  2. console.log(a);
  3. a = 10; (这行才开始赋值) 这就是变量提升。如果是用 letconst,第一行就会直接报错(暂时性死区),这就是为什么现在大家都推荐用 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)

面试题: 你在项目里肯定用过 importrequire

  1. 它们两个有什么区别?
  2. 为什么现在的工程化项目(比如 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 进来,两边就失去联系了。