一. 手写call,apply,bind
1、 手写 call
原理:
call改变函数执行时的this指向。- 将函数临时添加为目标对象(context)的一个属性。
- 执行这个属性函数(此时
this自然指向了 context)。 - 删除临时属性。
/**
* 手写 myCall
* @param {Object} context - 需要绑定的 this 上下文
* @param {...any} args - 传递给函数的参数列表(逗号分隔)
*/
Function.prototype.myCall = function(context, ...args) {
// 1. 边界判断:如果调用 myCall 的不是一个函数,抛出错误
if (typeof this !== 'function') {
throw new TypeError('Error: myCall must be called on a function');
}
// 2. 处理 context 上下文
// 如果传入 null 或 undefined,默认指向全局对象 (window 或 global)
// 如果传入原始值 (如 1, "string"),需要用 Object() 转换为对象
context = (context === null || context === undefined) ? window : Object(context);
// 3. 创建一个唯一的 Key
// 使用 Symbol 防止覆盖 context 上已有的同名属性
const fnKey = Symbol('fn');
// 4. 将当前函数 (this) 挂载到 context 上
// 此时 context[fnKey] 就是当前我们要执行的函数
context[fnKey] = this;
// 5. 执行函数
// 使用 spread 操作符 (...) 将 args 数组展开作为参数传递
// 此时函数的 this 指向就是 context
const result = context[fnKey](...args);
// 6. 删除临时添加的属性,保持 context 干净
delete context[fnKey];
// 7. 返回函数执行结果
return result;
};
2、 手写 apply
原理:
逻辑与 call 几乎完全一致,唯一的区别在于 参数的处理。call 接收参数列表,apply 接收参数数组。
/**
* 手写 myApply
* @param {Object} context - 需要绑定的 this 上下文
* @param {Array} argsArr - 传递给函数的参数数组
*/
Function.prototype.myApply = function(context, argsArr) {
// 1. 边界判断:确保调用者是函数
if (typeof this !== 'function') {
throw new TypeError('Error: myApply must be called on a function');
}
// 2. 处理 context (同 call)
context = (context === null || context === undefined) ? window : Object(context);
// 3. 创建唯一 Key (同 call)
const fnKey = Symbol('fn');
// 4. 挂载函数
context[fnKey] = this;
// 5. 执行函数并获取结果
let result;
// 判断是否有参数数组传入
if (argsArr && Array.isArray(argsArr)) {
result = context[fnKey](...argsArr); // 有参数则展开
} else {
result = context[fnKey](); // 无参数直接调用
}
// 6. 删除临时属性
delete context[fnKey];
// 7. 返回结果
return result;
};
3、 手写 bind (重难点)
原理:
bind 不会立即执行函数,而是返回一个新的函数。
核心考点:
- 柯里化 (Currying):支持预传参数。比如
bind(obj, 1)后,调用时再传(2),实际参数是(1, 2)。 - 构造函数 (new):如果返回的绑定函数被
new调用,bind指定的context会失效,this应该指向new创建的实例。
/**
* 手写 myBind
* @param {Object} context - 绑定的 this 上下文
* @param {...any} args1 - 初始预设参数
*/
Function.prototype.myBind = function(context, ...args1) {
// 1. 边界判断
if (typeof this !== 'function') {
throw new TypeError('Error: myBind must be called on a function');
}
// 保存当前的函数对象,因为下方返回的函数里 this 会变
const self = this;
// 2. 返回一个新的函数
const fBound = function(...args2) {
// 3. 核心逻辑:判断是否是通过 'new' 调用的
// 如果是 new 调用,this 指向实例对象 (instanceof fBound 为 true)
// 此时忽略传入的 context,将 this 指向实例
// 如果是普通调用,this 指向 window (或 undefined),此时使用传入的 context
const isNew = this instanceof fBound;
// 4. 执行原函数
// 使用 apply 来改变指向
// 参数合并:将 bind 时的参数 args1 和 调用时的参数 args2 合并
return self.apply(
isNew ? this : context,
[...args1, ...args2]
);
};
// 5. 维护原型链 (非常重要!)
// 如果原函数有 prototype (箭头函数没有 prototype),需要继承
// 这样 new fBound() 出来的实例才能继承原函数原型链上的属性
if (self.prototype) {
// 使用 Object.create 创建一个空对象作为中介,避免直接修改 fBound.prototype 影响原函数
fBound.prototype = Object.create(self.prototype);
}
return fBound;
};
4、 面试高分技巧(解释要点)
在写代码时,或者写完后,向面试官解释以下细节,可以大幅加分:
- Symbol 的作用:在
call和apply中,我使用了Symbol作为 key。这是为了防止context对象上原本就有个叫fn的属性,直接覆盖会导致原数据丢失。Symbol保证了属性名的唯一性。 - 基本类型包装:我处理了
context传1或"abc"的情况,使用Object()将其转为对象,这是符合原生 API 行为的。 - Bind 的
new操作符:这是bind最难的地方。我会判断this instanceof fBound。如果是new调用的,由于 JS 机制,this会指向新创建的实例,这时候我们不能强行把this改为context,否则构造函数就失效了。 - 原型链丢失问题:在
bind中,我通过Object.create继承了原函数的原型,确保new出来的实例能访问原函数原型上的方法。
这份笔记整理了面试中关于模块化的核心考点,涵盖了 CommonJS vs ES6 模块的区别、底层机制对比 以及 require 的源码级原理。可以直接复制到你的笔记软件(Notion/Obsidian)中复习。
二. CommonJS 与 ES6 Module 核心考点
1、 CommonJS vs ES6 Module 核心区别
这是面试必考题,建议先背熟表格,再深入理解“值的拷贝”与“值的引用”。
1.1 对比总结表
| 维度 | CommonJS (CJS) | ES6 Module (ESM) |
|---|---|---|
| 输出机制 | 值的拷贝 (Value Copy) | 值的引用 (Live Reference) |
| 加载时机 | 运行时加载 (Runtime) | 编译时输出接口 (Static Analysis) |
| 对象形态 | 导出是一个对象 | 导出不是对象,是静态定义 |
| this 指向 | 指向当前模块对象 | undefined |
| 主要场景 | Node.js 服务端 | 浏览器、现代构建工具 (Webpack/Vite) |
| Tree Shaking | 不支持 | 支持 (因为是静态分析) |
1.2 深度解析:值的拷贝 vs 值的引用 (必考代码题)
这是最能体现你理解深度的点。
- CommonJS (拷贝):模块输出的是一个值的复制。一旦输出,模块内部的变化不会影响到外部引用的值(基本数据类型)。
- ES6 Module (引用):
import是一个只读引用(Live Binding)。脚本执行时,会去被加载的模块中取值,模块内部变了,外部也会跟着变。
代码示例:
// --- counter.js ---
let count = 1;
function add() { count++; }
// CJS 导出
// module.exports = { count, add };
// ESM 导出
// export { count, add };
// --- main.js ---
// 1. 如果是 CommonJS:
const { count, add } = require('./counter');
console.log(count); // 1
add();
console.log(count); // 1 (依然是1!因为 require 出来的是一份缓存拷贝)
// 2. 如果是 ES6 Module:
import { count, add } from './counter';
console.log(count); // 1
add();
console.log(count); // 2 (变成了2!因为 import 指向的是内存中的引用)
2、 模块引入与执行机制的区别
2.1 CommonJS (require)
- 同步加载:代码执行到
require这一行时,才会去加载文件、执行代码。 - 动态路径:支持动态引入,如
require('./' + fileName)。 - 缓存优先:第二次加载同一个模块,直接从缓存
Module._cache读取,不会重复执行。
2.2 ES6 Module (import)
- 静态分析:在代码运行前(编译/解析阶段),JS 引擎就会扫描
import语句,构建依赖关系图。 - 变量提升:
import语句会自动提升到文件顶部优先执行。 - 位置限制:只能写在顶层,不能写在
if块或函数中(除非使用import()动态导入语法)。
3、 require 的原理详细解析 (手写原理)
require 本质上是一个函数,Node.js 内部通过 fs 读取文件并包裹执行。
3.1 核心五步流程
当执行 require('./a.js') 时:
- 路径分析 (Path Resolving)
- 将相对路径转为绝对路径。
- 自动补全后缀(.js, .json, .node)。
- 缓存检查 (Caching)
- 检查
Module._cache是否有该文件的缓存。 - 有 -> 直接返回
module.exports。 - 无 -> 进入下一步。
- 检查
- 创建模块 (Module Creation)
new Module(filename),初始化一个对象,包含id,exports = {}等属性。
- 编译执行 (Compile & Execute)
- 包裹 (Wrapper): 读取文件内容,将其包裹在一个函数中(为了隔离作用域)。
- 执行: 运行这个包裹函数,将
module.exports的控制权交给用户。
- 返回结果
- 返回
module.exports。
- 返回
3.2 什么是“包裹函数” (Wrapper)?
这就是为什么你在 Node.js 文件里可以直接用 __dirname, __filename, exports 的原因。它们不是全局变量,而是函数的参数。
// Node 内部会将你的代码变成这样:
(function (exports, require, module, __filename, __dirname) {
// --- 你的代码在这里 ---
const a = 1;
module.exports = a;
// -------------------
});
3.3 手写精简版 require (笔记专用)
const path = require('path');
const fs = require('fs');
const vm = require('vm');
function myRequire(filename) {
// 1. 获取绝对路径
const absPath = path.resolve(__dirname, filename);
// 2. 缓存检查
if (myRequire.cache[absPath]) {
return myRequire.cache[absPath].exports;
}
// 3. 定义模块对象
const module = {
id: absPath,
exports: {}
};
// 4. 读取文件内容
const code = fs.readFileSync(absPath, 'utf8');
// 5. 核心:函数包裹 (隔离作用域,注入变量)
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n})'
];
const wrappedCode = wrapper[0] + code + wrapper[1];
// 6. 运行代码
// 使用 vm.runInThisContext 将字符串变成可执行函数
const script = new vm.Script(wrappedCode);
const compiledWrapper = script.runInThisContext();
// 7. 执行函数,传入参数 (exports, require, module...)
// 这里的 module.exports 被传入,用户在内部修改它
compiledWrapper.call(
module.exports, // this
module.exports, // exports
myRequire, // require
module, // module
absPath, // __filename
path.dirname(absPath) // __dirname
);
// 8. 写入缓存
myRequire.cache[absPath] = module;
// 9. 返回结果
return module.exports;
}
// 初始化缓存
myRequire.cache = {};
3.4 一句话记忆总结
CommonJS 是运行时加载对象,输出的是值的拷贝(即使内部变了外部也不变);ES6 Module 是编译时静态引用,输出的是值的引用(内部变了外部跟着变),且支持 Tree Shaking。Require 的原理就是读取文件 -> 包裹函数 -> 注入 exports/module 参数 -> 执行并返回 module.exports。
三. 代码的执行顺序
// Promise 1: 直接返回 Promise.resolve() -> 慢两拍
Promise.resolve().then(() => {
console.log(0);
// 这里返回了一个 Promise,导致后续的 then 被推迟
return Promise.resolve(4);
}).then((res) => {
console.log(res);
});
// Promise 2: 只有空的 then -> 标准速度
Promise.resolve().then(() => {
console.log(1);
}).then(() => {
console.log(2);
}).then(() => {
console.log(3);
}).then(() => {
console.log(5);
}).then(() => {
console.log(6);
});
执行顺序解析:
-
第一轮(同步代码) :
- P1 的第一个 then 入队。
- P2 的第一个 then 入队。
-
微任务队列处理 - 第 1 轮:
- P1 执行:打印 0。发现返回了 Promise,产生额外微任务 A(入队)。
- P2 执行:打印 1。正常链式调用,产生 P2 的下一个 then(入队)。
-
微任务队列处理 - 第 2 轮:
- 执行“额外微任务 A”:准备拆包。产生额外微任务 B(入队)。
- P2 执行:打印 2。正常链式调用,产生 P2 的下一个 then(入队)。
-
微任务队列处理 - 第 3 轮:
- 执行“额外微任务 B”:拆包完成,把 4 传给 P1 外部。P1 的最终 log 入队。
- P2 执行:打印 3。
-
微任务队列处理 - 第 4 轮:
- P1 终于执行:打印 4。
- P2 执行:打印 5。
最终输出结果:
0, 1, 2, 3, 4, 5, 6
四. Pnpm的出现解决了什么问题
Pnpm (Performant NPM) 是目前前端社区非常流行的包管理工具。简单来说,它主要解决了 NPM(以及 Yarn v1)存在的磁盘空间浪费、安装速度慢以及**依赖安全隐患(幽灵依赖)**这三大核心问题。
以下是详细的解析,面试时可以按照这三个维度来回答:
4.1 解决了“磁盘空间浪费”问题
核心机制:Content-Addressable Storage (CAS) + 硬链接 (Hard Links)
-
NPM 的问题: NPM 的安装机制是“复制”。如果你有 10 个项目都用了
Vue,NPM 会在硬盘上把Vue的文件复制 10 次。这导致磁盘空间被大量重复文件占用。 -
Pnpm 的解法: Pnpm 引入了全局存储(Store)。
- 所有项目的依赖都物理存储在硬盘的同一个位置(
~/.pnpm-store)。 - 当你在项目中
pnpm install时,Pnpm 并不是把文件拷贝过来,而是通过**硬链接(Hard Link)**将项目中的node_modules直接指向全局 Store 中的文件。 - 结果:即使你有 100 个项目使用了同一个版本的
Vue,硬盘上只存了一份物理文件。
- 所有项目的依赖都物理存储在硬盘的同一个位置(
4.2 解决了“幽灵依赖 (Phantom Dependencies)”问题
核心机制:非扁平化的 node_modules 结构 + 符号链接 (Symlinks)
这是 Pnpm 在工程化安全方面最大的贡献。
-
NPM 的问题(扁平化带来的弊端): NPM v3 之后,为了解决“嵌套层级过深”的问题,采用了**扁平化(Hoisting)**策略。
- 场景:你的项目依赖了
A,A依赖了B。 - NPM 做法:把
A和B都提升到node_modules的顶层。 - 隐患:你的代码可以直接
import B,即使你并没有在package.json里声明B。这就是幽灵依赖。 - 后果:某天
A升级了,不再依赖B,你的项目因为找不到B而直接报错崩溃,但你却一脸懵逼(因为你以为A只是小版本升级)。
- 场景:你的项目依赖了
-
Pnpm 的解法: Pnpm 恢复了严格的树状结构,但利用了**符号链接(软链接)**来解决路径过深问题。
- 在 Pnpm 的管理下,如果你的
package.json里只有A,那么你的node_modules下就只有A,没有B。你无法非法访问B。 A实际上是一个软链,指向.pnpm目录下的真实位置,而那个位置里,A的node_modules下才有B。- 结果:彻底杜绝了非法访问未声明依赖的可能,让项目更健壮。
- 在 Pnpm 的管理下,如果你的
4.3 解决了“安装速度”问题
核心机制:三阶段并发 + 链接操作
-
NPM 的问题: 需要大量的 I/O 操作(文件复制),不仅慢,而且对硬盘读写损耗大。
-
Pnpm 的解法:
- 由于不需要物理复制文件,只需要创建“链接”(Link),这个操作在操作系统层面是非常快的(毫秒级)。
- Pnpm 的安装过程分为:解析 (Resolution) -> 获取 (Fetch) -> 链接 (Link)。这也是高度并行化的。
- 结果:冷安装速度通常比 NPM 快 2-3 倍,热安装(有缓存)几乎是瞬间完成。
4.4 解决了“NPM Doppelgangers(依赖分身)”问题
-
NPM 的问题: 在扁平化算法中,如果不同的包依赖了同一个库的不同版本(例如
lib-v1和lib-v2),由于顶层只能放一个版本,另一个版本不得不被嵌套在深层。复杂的依赖关系会导致同一个版本的包在node_modules中出现多次。 -
Pnpm 的解法: 由于 Pnpm 使用的是基于内容寻址的 Store,相同的包永远只存储一份。在项目结构中,它通过软链精准地组织目录,不会产生重复的物理文件。
4.5 面试回答总结模板
"Pnpm 主要解决了 NPM 的三个核心痛点:
- 磁盘空间效率:NPM 对每个项目都进行文件复制,导致大量占用磁盘。Pnpm 使用硬链接机制,让所有项目共享同一个全局 Store 中的物理文件,极大节省了空间。
- 安全性(幽灵依赖):NPM 的扁平化机制导致我们可以访问未在
package.json中声明的依赖,容易造成项目不稳定。Pnpm 默认采用非扁平化的node_modules结构,配合软链接,严格限制了源码只能访问声明过的依赖。- 安装速度:得益于文件链接机制,Pnpm 省去了大量的文件 I/O 复制操作,安装速度远快于 NPM。"
五. js中的垃圾回收机制
1. JavaScript 内存管理与垃圾回收机制深度解析
1.1 核心概念:什么是垃圾回收?
在 C 或 C++ 等底层语言中,开发者必须手动管理内存(使用 malloc() 和 free())。这给予了极大的控制权,但也容易导致内存泄漏(忘记释放)或程序崩溃(提前释放)。
JavaScript 是一门自动垃圾回收的语言。JS 引擎(如 Chrome 的 V8)有一个后台进程称为“垃圾回收器”,它会监控所有对象的内存分配,并自动回收那些不再需要的内存。
1.2 判定标准:可达性 (Reachability)
垃圾回收的核心算法依据是**“可达性”**。
- 根(Roots):全局变量(Window/Global)、当前执行栈的局部变量等被视为“根”。
- 可达:如果一个对象可以通过引用链从“根”访问到,它就是可达的(活的)。
- 不可达:如果从“根”出发无法找到该对象,它就被视为垃圾。
2. 内存的数据结构:栈与堆
在了解 GC 之前,必须先明确数据存在哪里:
-
栈内存 (Stack)
- 存储基础数据类型(Number, String, Boolean, null, undefined, Symbol)和引用类型的指针。
- 特点:空间小,系统自动分配释放,遵循先进后出(LIFO)。
- 注意:栈内存不在 GC 的主要管理范围内,因为它随函数执行结束自动清理。
-
堆内存 (Heap)
- 存储引用数据类型(Object, Array, Function)。
- 特点:空间大,动态分配。GC 主要治理的就是堆内存。
3. 垃圾回收策略的演进
3.1 引用计数法 (Reference Counting) —— 已被淘汰
这是最初级的垃圾回收算法(IE6/7 曾使用)。
- 原理:记录每个值被引用的次数。
- 被赋值给变量:引用次数 +1。
- 变量被覆盖或销毁:引用次数 -1。
- 当次数为 0 时:回收。
- 致命缺陷:循环引用 (Circular Reference)
这就是为什么现代浏览器不再使用此算法的原因。function cycle() { let objA = {}; let objB = {}; objA.friend = objB; // B 引用 +1 objB.friend = objA; // A 引用 +1 return; // 函数结束,但 A 和 B 的引用次数依然是 1,永远无法回收! }
3.2 标记-清除算法 (Mark-and-Sweep) —— 现代标准
这是目前主流浏览器(包括 V8)的核心算法逻辑。
- 原理:
- 标记阶段 (Mark):垃圾回收器从“根”开始遍历,把所有能遍历到的对象标记为“活动对象”。
- 清除阶段 (Sweep):遍历整个堆内存,把所有没被标记的对象(即不可达对象)直接销毁,释放内存。
- 优点:完美解决了循环引用问题(因为虽然 A 和 B 互相引用,但从 Root 出发找不到它们,依然会被回收)。
- 缺点:内存碎片化。回收后的内存空间是不连续的,像蜂窝煤一样,如果要分配一个大对象,可能找不到足够大的连续空间。
4. V8 引擎的高效垃圾回收机制 (重点)
V8 并不只使用单一的算法,而是采用了分代式垃圾回收 (Generational GC)。
V8 基于一个**“代际假说” (The Generational Hypothesis)**:
大部分对象在内存中存活的时间很短(朝生夕死),只有少部分对象会长久存活。
基于此,V8 将堆内存分为两块:新生代 (New Space) 和 老生代 (Old Space)。
4.1 新生代 (New Space)
- 特点:对象存活时间短,空间小(通常 1MB - 8MB)。
- 算法:Scavenge 算法(基于 Cheney 算法)。
- 工作流程:
- 将新生代空间一分为二:From-Space (使用区) 和 To-Space (空闲区)。
- 新对象都分配在 From-Space。
- GC 开始时,检查 From-Space 里的存活对象。
- 复制:将存活对象复制到 To-Space(复制过程中自动整理内存,无碎片)。
- 清空:直接清空 From-Space。
- 交换:From-Space 和 To-Space 角色对调。
- 晋升 (Promotion): 如果一个对象在新生代中经历了多次(通常是 2 次)回收依然存活,或者 To-Space 空间占用超过 25%,它就会被移入老生代。
4.2 老生代 (Old Space)
- 特点:对象存活时间长,占用空间大。
- 算法:标记-清除 (Mark-Sweep) + 标记-整理 (Mark-Compact)。
- 工作流程:
- 标记-清除:主要负责回收垃圾。
- 标记-整理:为了解决内存碎片问题。当碎片过多时,V8 会把所有存活对象往内存的一端移动,然后清理掉边界外的内存。
5. V8 的性能优化:如何避免全停顿 (Stop-the-World)
垃圾回收运行是需要占用主线程的。如果 GC 正在运行,JS 脚本就必须暂停,这被称为 "Stop-the-World" (全停顿)。如果堆内存很大,GC 可能导致页面卡顿几百毫秒。
为了解决这个问题,V8 引入了名为 Orinoco 的优化架构:
-
增量标记 (Incremental Marking)
- 将一口气完成的标记任务,拆分成很多个小任务。
- JS 执行一会儿 -> 标记一会儿 -> JS 执行一会儿。
- 让 GC 和应用逻辑交替进行,减少肉眼可见的卡顿。
-
惰性清理 (Lazy Sweeping)
- 标记完成后,不需要立刻清除所有垃圾。垃圾回收器会根据内存需要,稍微清理一部分,甚至延迟清理。
-
并发回收 (Concurrent GC)
- 利用多核 CPU,让 GC 辅助线程在后台进行标记和整理,完全不阻塞主线程 JS 的执行。
6. 常见的内存泄漏场景 (面试必考)
理解了 GC,还要知道哪些代码会导致 GC 无法回收内存:
- 意外的全局变量
function foo() { bar = "this is global"; // 没有声明 var/let/const,挂载到了 window 上,常驻内存 } - 被遗忘的定时器
setInterval(() => { let node = document.getElementById('node'); // 如果 node 节点被移除了,但定时器还在跑,node 就无法回收 }, 1000); - 闭包 (Closures)
- 不合理的闭包会持有外部函数作用域的变量,导致无法释放。
- 脱离 DOM 的引用
let btn = document.getElementById('button'); document.body.removeChild(btn); // 页面上没了 // 但是 btn 变量还引用着这个 DOM 对象,所以内存里还没被回收 btn = null; // 手动释放
7. 总结笔记 (Cheat Sheet)
- 核心思想:可达性(Reachability)。从根出发,找得到的留着,找不到的删掉。
- 栈 vs 堆:栈存基础类型(系统管理);堆存对象(GC 管理)。
- 分代回收 (V8):
- 新生代 (New Space):存活短,用 Scavenge 算法(复制+交换),速度快但空间换时间。
- 老生代 (Old Space):存活长,用 Mark-Sweep (清除) + Mark-Compact (整理),空间利用率高。
- 对象晋升:新生代里的老油条会被踢到老生代。
- 优化手段:增量标记、并发标记(为了减少页面卡顿)。
六. 为什么vue3支持树摇 vue2不支持
这是一个非常经典的架构设计问题,也是 Vue 3 重构的核心驱动力之一。
简单来说,核心区别在于 API 的设计结构:
- Vue 2 是基于 “类/对象” (Class/Object-based) 的单例设计,导致“牵一发而动全身”。
- Vue 3 是基于 “函数” (Function-based) 的模块化设计,实现了“按需引入”。
下面详细解析:
6.1 根本原因:Tree Shaking 的工作原理
首先你要理解 Tree Shaking(摇树优化)依赖于 ES6 Module (ESM) 的静态分析。
- 打包工具(Webpack/Rollup)在编译时分析
import和export。 - 它需要确定:“这个函数被引入了,那个函数没被引入”。
- 关键点:静态分析很难处理对象属性。如果你引入了一个大对象
Obj,打包工具很难判断你到底用了Obj.a还是Obj.b,为了安全起见,它只能把整个Obj都打包进去。
6.2 Vue 2 为什么不行?(全家桶模式)
Vue 2 的设计思想是“基于对象”的。所有的功能都挂载在 Vue 这个全局对象(或者它的原型 prototype)上。
代码示例:
import Vue from 'vue';
// 即使你只用了 nextTick
Vue.nextTick(() => { ... });
// 但是!
// Vue 对象的定义里包含了:
// Vue.component
// Vue.directive
// Vue.mixin
// Vue.util ...
问题所在:
当你 import Vue from 'vue' 时,你引入的是一个巨大的单例对象。
- 挂载在类上:Vue 2 的源码里,功能是通过
extend方式一个个挂载到Vue构造函数上的(比如initUse(Vue),initMixin(Vue))。 - 无法拆分:打包工具看到你引入了
Vue对象,它不敢把Vue.component或Vue.use删掉,因为 JavaScript 的对象属性是动态的,打包工具无法保证你在代码的其他地方没有通过Vue['use']这种方式去调用它。
结果: 哪怕你只写了一个 Hello World,整个 Vue 2 的运行时(包括你没用到的指令、组件系统、工具函数)都会被打包进去。
6.3 Vue 3 为什么可以?(按需自助模式)
Vue 3 在重构时,把所有的 API 都抽取成了独立的函数(Top-Level Function Exports)。
代码示例:
// 你只引入了 nextTick 和 onMounted
import { nextTick, onMounted } from 'vue';
nextTick(() => { ... });
优化机制:
- 独立导出:
nextTick、computed、watch这些现在都是独立的函数,不再是Vue对象上的属性。 - 静态分析生效:打包工具(如 Webpack)在分析 AST(语法树)时,清清楚楚地看到:
- 你引入了
nextTick-> 保留。 - 你没引入
reactive-> 删除(Shake 掉)。 - 你没引入
KeepAlive组件 -> 删除。
- 你引入了
- 编译器配合:Vue 3 的模板编译器在编译 HTML 模板时,生成的代码也是导入特定函数。
- 比如模板里用了
v-model,编译后的 JS 代码就会自动import { vModelText } from 'vue'。如果你没用v-model,相关的处理代码压根不会出现在最终包里。
- 比如模板里用了
6.4 总结对比
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| API 挂载方式 | 挂载在 Vue 全局对象或原型上 | 作为独立的 ES 模块导出 |
| 引用方式 | import Vue from 'vue' | import { ref, nextTick } from 'vue' |
| 打包工具视角 | 这是一个整体对象,不可拆分 | 这是一堆独立函数,没用的扔掉 |
| 结果 | All in One(体积大) | Pay as you go(按需打包,体积更小) |
6.5 面试回答模板
"Vue 2 无法进行 Tree Shaking 的主要原因是其 Global API 设计是基于对象的。所有的全局 API(如
nextTick,use,component)都作为属性挂载在Vue构造函数或原型上。由于 JS 对象的动态特性,打包工具无法在静态分析阶段安全地剔除未使用的属性,导致引入 Vue 就等于引入了整个包。而 Vue 3 进行了架构重构,采用了模块化设计。所有的 API(包括 Composition API 和内部辅助函数)都重写为了独立的顶层导出函数。当我们
import { nextTick } from 'vue'时,构建工具可以明确知道我们只使用了这一个函数,从而利用 ES Module 的静态分析能力,安全地移除其他未引用的代码。这就是 Vue 3 能实现 Tree Shaking 的核心原因。"
七. 前端的攻击手段有哪些,如何预防?
这是一个非常经典且高频的前端面试题。前端安全是工程化中不可忽视的一环。
以下是前端常见的攻击手段及其防御方案的详细总结,适合你做笔记和背诵。
7.1 XSS (Cross-Site Scripting) - 跨站脚本攻击
这是前端最常见、危害最大的攻击方式。
1. 攻击原理: 攻击者通过在网页中注入恶意的 JavaScript 脚本,当用户浏览该网页时,脚本自动执行。
- 后果:窃取 Cookie(Session ID)、监听用户键盘输入、重定向到钓鱼网站、破坏页面结构。
2. 常见类型:
- 存储型 (Stored):恶意脚本被存到了数据库中(比如评论区)。所有看评论的人都会中招。
- 反射型 (Reflected):恶意脚本在 URL 参数中(比如搜索框)。攻击者给用户发个链接,用户点开就中招。
- DOM 型:前端 JS 代码逻辑有问题,直接把不可信的输入插入到了 DOM 中(如
innerHTML)。
3. 防御手段 (记重点):
- 输入过滤,输出转义 (Escaping):
- 永远不要信任用户的输入。
- 在输出到 HTML 之前,把
<变成<,>变成>等。 - 现代框架(Vue/React)默认在数据绑定时(如
{{}})会自动转义,除非你使用了v-html或dangerouslySetInnerHTML(尽量少用这两者)。
- HttpOnly Cookie:
- 后端设置 Cookie 时加上
HttpOnly属性,这样 JS 脚本(document.cookie)就无法读取 Cookie,防止 Token 被盗。
- 后端设置 Cookie 时加上
- CSP (Content Security Policy) 内容安全策略:
- 最硬核的防御。通过 HTTP 响应头
Content-Security-Policy告诉浏览器:只允许加载哪里的脚本(比如只允许本域名)。 - 禁止内联脚本(
<script>...</script>)执行。
- 最硬核的防御。通过 HTTP 响应头
- 输入长度限制:限制输入长度,增加攻击难度。
7.2 CSRF (Cross-Site Request Forgery) - 跨站请求伪造
1. 攻击原理: 攻击者诱导用户进入一个第三方网站,然后冒用用户的身份(利用浏览器会自动携带 Cookie 的机制),向被攻击网站发送操作请求(如转账、点赞、发邮件)。
- 比喻:攻击者借你的手(Cookie),发了一封你并不想发的信。
2. 防御手段:
- Cookie 的 SameSite 属性 (最重要的防御):
- 设置
SameSite=Strict或Lax。告诉浏览器,如果是第三方发起的请求,不要带上 Cookie。
- 设置
- CSRF Token:
- 用户打开页面时,服务器下发一个随机 Token(通常在 HTML 表单隐藏域或 Header 中)。
- 提交请求时必须带上这个 Token,服务器验证。攻击者拿不到这个 Token,所以请求失败。
- 同源检测:
- 后端检查 HTTP 请求头中的
Origin和Referer,确保请求是从合法的源发出的。
- 后端检查 HTTP 请求头中的
7.3 Clickjacking - 点击劫持
1. 攻击原理:
攻击者搞一个透明的 iframe,覆盖在一个诱导性的按钮上。
- 用户以为自己点的是“领取奖品”,实际上点的是透明 iframe 里的“银行转账”按钮。
2. 防御手段:
- X-Frame-Options 响应头:
DENY:不允许任何网站通过 iframe 嵌入该页面。SAMEORIGIN:只允许同源网站嵌入。
- CSP 的 frame-ancestors 指令:
- 功能类似
X-Frame-Options,但更灵活。
- 功能类似
7.4 MITM (Man-in-the-Middle) - 中间人攻击
1. 攻击原理: 攻击者在用户和服务器的通信链路中间(比如公共 WiFi),拦截、窃听或篡改数据。
2. 防御手段:
- 全站 HTTPS:使用 TLS/SSL 加密传输,确保数据无法被窃听。
- SRI (Subresource Integrity) 子资源完整性:
- 针对 CDN 资源。在
<script>标签中加integrity属性(hash 值)。 - 如果 CDN 被劫持,文件被改了,Hash 对不上,浏览器就会拒绝执行该脚本。
- 针对 CDN 资源。在
7.5 SQL 注入 (虽然是后端,但前端需配合)
1. 攻击原理:
在输入框输入 SQL 命令(如 ' OR 1=1 --),骗过后端数据库执行。
2. 前端防御:
- 虽然核心防御在后端(使用预编译语句),但前端应该对输入内容进行类型校验和正则过滤(如禁止输入特殊字符)。
7.6 总结
如果面试官问:“你知道哪些前端安全问题?”,你可以这样有条理地回答:
"前端主要的安全问题有 XSS、CSRF 和点击劫持。
- XSS(跨站脚本攻击):
- 本质:恶意脚本注入。
- 解决:核心是不信任用户输入。利用框架自动转义、开启 CSP、设置 Cookie HttpOnly。
- CSRF(跨站请求伪造):
- 本质:冒用用户身份(Cookie)。
- 解决:核心是验证请求来源。利用 Cookie 的 SameSite 属性、使用 CSRF Token、检查 Referer。
- 点击劫持:
- 本质:透明 iframe 覆盖。
- 解决:设置 X-Frame-Options 响应头禁止被嵌入 iframe。
此外,还有中间人攻击,主要靠 HTTPS 和 SRI 来防御。"
根据你的目录结构(接上文的“七、前端安全”),这里为你整理了一个新的章节。手写 instanceof 是考察 JS 原型链 最直接的代码题。
八. 手写 instanceof 原理与实现
instanceof 运算符用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。
8.1 核心原理
- 右边:获取构造函数的显式原型
prototype。 - 左边:获取实例对象的隐式原型
__proto__(推荐使用Object.getPrototypeOf())。 - 比对过程:
- 判断左边的原型是否等于右边的
prototype。 - 如果不等,就沿着左边的原型链继续往上找 (
proto = proto.__proto__)。 - 直到找到相等(返回
true),或者找到原型链顶端null(返回false)。
- 判断左边的原型是否等于右边的
8.2 代码实现 (标准面试版)
/**
* 手写 instanceof
* @param {Object} left - 实例对象 (如 [1, 2])
* @param {Function} right - 构造函数 (如 Array)
* @returns {Boolean}
*/
function myInstanceof(left, right) {
// 1. 边界判断:基础数据类型直接返回 false
// instanceof 只能用于判断对象(引用类型), 'abc' instanceof String 为 false
if ((typeof left !== 'object' && typeof left !== 'function') || left === null) {
return false;
}
// 2. 获取右边构造函数的 prototype
const prototype = right.prototype;
// 3. 获取左边实例的隐式原型
// 推荐使用 Object.getPrototypeOf(left),它等价于 left.__proto__
let proto = Object.getPrototypeOf(left);
// 4. 循环遍历原型链
while (true) {
// 如果走到了原型链尽头 (null),说明没找到
if (proto === null) return false;
// 如果找到了相同的原型对象
if (proto === prototype) return true;
// 继续沿着原型链向上查找
proto = Object.getPrototypeOf(proto);
}
}
8.3 测试用例
你可以用以下代码在面试官面前验证:
class Parent {}
class Child extends Parent {}
const c = new Child();
console.log(myInstanceof(c, Child)); // true
console.log(myInstanceof(c, Parent)); // true (继承关系)
console.log(myInstanceof(c, Object)); // true (万物皆对象)
console.log(myInstanceof([], Array)); // true
console.log(myInstanceof(123, Number)); // false (基础类型)
8.4 面试高分技巧(补充说明)
- 关于基础类型:原生的
instanceof对于基础类型(如1 instanceof Number)会返回false,只有通过new Number(1)创建的对象才返回true。我的代码中开头加了typeof判断,完美模拟了这一行为。 - 关于 API 选择:虽然
left.__proto__写起来更方便,但它是非标准的(虽然浏览器都支持)。在生产环境或严格的面试中,使用Object.getPrototypeOf(left)是更规范、更专业的写法。
这是一个非常精准且考验底层原理的问题。
九.是否有不触发冒泡或者捕获的事件
结论先行:
- 不冒泡的事件:非常多,属于常见现象。
- 不捕获的事件:几乎没有(除了事件源本身就是最顶层的
window对象)。即便是不冒泡的事件(如focus),也依然会经历捕获阶段。
9.1、 没有“冒泡”的事件(很常见)
大部分事件默认是冒泡的,但为了性能或逻辑正确性,以下事件默认不冒泡(即 event.bubbles 为 false):
9.1.1. 焦点相关
focus:获得焦点。blur:失去焦点。- 注意:如果你想用事件委托监听焦点,不能用这两个。要用
focusin和focusout(这两个是专门设计来支持冒泡的)。
- 注意:如果你想用事件委托监听焦点,不能用这两个。要用
9.1.2. 鼠标移入移出
mouseenter:鼠标移入。mouseleave:鼠标移出。- 原理:为了避免鼠标在父子元素间移动时频繁触发,它们不冒泡。对应的冒泡版本是
mouseover和mouseout。
- 原理:为了避免鼠标在父子元素间移动时频繁触发,它们不冒泡。对应的冒泡版本是
9.1.3. 资源加载相关
load:资源加载完成(如<img>,<script>)。unload:资源卸载。error:加载失败。abort:加载中止。- 解释:比如
img的load事件不应该冒泡到document,否则页面里任何一张小图加载完都会触发整个页面的onload逻辑,这显然不合理。
- 解释:比如
9.1.4. UI 滚动与尺寸
resize:元素尺寸改变。scroll:滚动条滚动。- 注意:
scroll在普通元素上触发时不冒泡;但在document上触发时会“冒泡”到window(这是浏览器的特殊行为,为了方便监听全屏滚动)。
- 注意:
9.1.5. 媒体相关(Video/Audio)
play,pause,ended,volumechange等大部分媒体事件都不冒泡。- 技巧:想在父容器监听所有视频的播放,必须用捕获。
9.2、 没有“捕获”的事件(极少)
这是面试中的陷阱题。
很多人认为“因为 focus 不冒泡,所以它也不捕获”,这是错的。
绝大多数 DOM 事件都会经历捕获阶段。 即使是上面提到的 focus、blur、load,它们虽然不冒泡,但依然可以被捕获。
唯一的例外:
只有当事件目标(Event Target)本身就是 DOM 树的最顶层时,才不存在“捕获阶段”这个说法。
- 场景:事件直接发生在
window对象上。 - 例子:
window.onresize。 - 原因:捕获是从
window -> document -> ... -> target。如果target也就是window,那起点就是终点,没有“从上往下传”的过程,所以不存在捕获阶段。
9.3、 如何验证?
我们可以写一段代码来验证:focus 事件虽然不冒泡,但能被捕获。
const parent = document.getElementById('parent');
const input = document.getElementById('myInput');
// 1. 监听冒泡阶段 (默认 false)
parent.addEventListener('focus', () => {
console.log('冒泡:父元素收到了 focus');
}, false);
// 2. 监听捕获阶段 (true)
parent.addEventListener('focus', () => {
console.log('捕获:父元素收到了 focus');
}, true);
// 结果:
// 当你点击 input 时:
// 控制台只会打印:“捕获:父元素收到了 focus”
// 不会打印冒泡的那句。
9.4、总结(面试满分回答)
"关于事件流:
- 不冒泡的事件有很多:比如
focus/blur(焦点)、mouseenter/mouseleave(鼠标)、load/error(资源)、以及video/audio的媒体事件。这些事件的event.bubbles属性为false,不会向上传播。- 不捕获的事件几乎没有:在 DOM 树中,即使是不冒泡的事件(如
focus),依然会完整地走完捕获阶段(Window -> Target)。我们完全可以通过addEventListener(..., true)在父节点上监听到这些事件。- 例外:唯一的例外是事件源本身就是
window对象(如 window 的 resize),因为它是顶层,不存在父级,所以没有捕获路径。"
十、手写深拷贝函数
/**
* 深拷贝函数
* @param {any} obj - 要拷贝的对象
* @param {WeakMap} hash - 用于解决循环引用的缓存
*/
function deepClone(obj, hash = new WeakMap()) {
// 1. 边界判断:如果是 null 或者不是对象(是基本类型),直接返回
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 2. 特殊对象处理:Date 和 RegExp
if (obj instanceof Date) return new Date(obj);
if (obj instanceof RegExp) return new RegExp(obj);
// 3. 解决循环引用
// 如果这个对象已经被拷贝过,直接返回之前拷贝的结果,不再递归,防止栈溢出
if (hash.has(obj)) {
return hash.get(obj);
}
// 4. 初始化容器
// 使用 constructor 可以保留对象的原型链(支持 Array、Map、Set、Object)
const cloneObj = new obj.constructor();
// 记录到缓存中(注意:必须在递归之前记录)
hash.set(obj, cloneObj);
// 5. 处理 Map
if (obj instanceof Map) {
obj.forEach((value, key) => {
cloneObj.set(key, deepClone(value, hash));
});
return cloneObj;
}
// 6. 处理 Set
if (obj instanceof Set) {
obj.forEach((value) => {
cloneObj.add(deepClone(value, hash));
});
return cloneObj;
}
// 7. 处理对象和数组的通用属性
// 使用 Reflect.ownKeys 可以获取到 Symbol 类型的 key,也可以遍历不可枚举属性
// 如果面试官觉得太偏,可以用 for...in + hasOwnProperty 替代
Reflect.ownKeys(obj).forEach(key => {
cloneObj[key] = deepClone(obj[key], hash);
});
return cloneObj;
}
这是为您整理的 Markdown 格式笔记文档,结构清晰,包含原理、字段详解及 Nginx 配置示例,可以直接复制到笔记软件中。
十一、 浏览器缓存 (Browser Caching)
浏览器缓存是性能优化的重要一环。它主要分为两大类:强缓存和协商缓存。
11.1 缓存分类概览
| 缓存类型 | HTTP 状态码 | 是否发送请求到服务器 | 读取位置 | 控制字段 |
|---|---|---|---|---|
| 强缓存 | 200 (from memory/disk cache) | 否 (直接读取本地) | 内存或硬盘 | Expires, Cache-Control |
| 协商缓存 | 304 (Not Modified) | 是 (询问服务器) | 内存或硬盘 | Last-Modified, ETag |
11.2 强缓存 (Strong Caching)
浏览器加载资源时,首先检查强缓存。如果命中,直接使用,不会与服务器发生交互。
11.2.1. Expires (HTTP/1.0)
- 语法:
Expires: Wed, 22 Oct 2025 08:41:00 GMT - 原理:服务器返回一个绝对时间。如果当前客户端时间早于这个时间,则命中缓存。
- 缺点:受限于客户端本地时间。如果用户修改了电脑时间,缓存可能失效或死循环。
- 现状:已被
Cache-Control取代,仅作兼容。
11.2.2. Cache-Control (HTTP/1.1) —— 核心
- 语法:
Cache-Control: max-age=3600 - 原理:指定一个相对时间(秒)。例如
max-age=3600代表资源在 1 小时内有效。 - 优先级:高于
Expires。 - 常见属性值:
max-age=xxx:缓存有效时长(秒)。no-cache:注意坑点。它不是“不缓存”,而是跳过强缓存,直接进入协商缓存阶段(每次都去服务器问一下)。no-store:真正的“不缓存”,绝对禁止存储任何副本。public:客户端和代理服务器(CDN)都能缓存。private:只能被客户端(浏览器)缓存。
11.3 协商缓存 (Negotiated Caching)
当强缓存失效(超过 max-age)或设置了 no-cache 时,浏览器会向服务器发起请求,验证资源是否有更新。
11.3.1. Last-Modified / If-Modified-Since (基于时间)
- 流程:
- 首次请求,Server 返回
Last-Modified: Fri, 27 Oct 2023...(文件最后修改时间)。 - 再次请求,Browser 自动带上
If-Modified-Since: Fri, 27 Oct 2023...。 - Server 对比时间:
- 时间一致 -> 返回 304 (直接用旧的)。
- 时间不一致 -> 返回 200 + 新资源 + 新的
Last-Modified。
- 首次请求,Server 返回
- 缺点:
- 精度只能到秒级。如果 1 秒内文件修改了多次,无法感知。
- 如果文件被“摸”了一下(保存了但内容没变),修改时间变了,也会导致缓存失效。
11.3.2. ETag / If-None-Match (基于内容指纹) —— 更精准
- 流程:
- 首次请求,Server 计算文件 Hash 值,返回
ETag: "abc-123456"。 - 再次请求,Browser 自动带上
If-None-Match: "abc-123456"。 - Server 对比 Hash 值:
- Hash 一致 -> 返回 304。
- Hash 不一致 -> 返回 200 + 新资源 + 新的
ETag。
- 首次请求,Server 计算文件 Hash 值,返回
- 优点:非常精准,只要内容变了 ETag 必变。
- 缺点:服务器计算 Hash 需要消耗少量 CPU 性能。
- 优先级:高于
Last-Modified。
11.4 缓存判断流程图
- 浏览器发起请求。
- 检测强缓存 (
Cache-Control/Expires):- ✅ 命中 -> 直接使用 (200 OK)。
- ❌ 未命中 -> 进入下一步。
- 发送请求到服务器 (携带
If-None-Match或If-Modified-Since)。 - 检测协商缓存:
- ✅ 命中 (服务器判定未修改) -> 返回 304 (浏览器读取本地缓存)。
- ❌ 未命中 (服务器判定已修改) -> 返回 200 (下载新资源)。
11.5 如何设置?(Nginx 配置示例)
前端通常不直接写缓存策略,而是在服务器(如 Nginx)上配置。
最佳实践策略:
- HTML 文件:使用协商缓存(
no-cache)。保证用户每次刷新都能拿到最新的 HTML,避免发版后用户还在用旧页面。 - 静态资源 (JS/CSS/Img):使用强缓存(
max-age设得很大,如 1 年)。因为现代打包工具(Webpack/Vite)会给文件名加上 Hash(如app.a1b2.js),文件变了文件名就变了,不会有缓存冲突。
Nginx 配置代码:
server {
# 1. 对于 HTML 页面:不做强缓存,只做协商缓存
location ~ .*\.(html|htm)$ {
# no-cache 表示让浏览器每次都去服务器询问(协商)
add_header Cache-Control "no-cache";
}
# 2. 对于静态资源 (js, css, 图片):开启强缓存,过期时间设为 1 年
# 因为文件名带 Hash,内容变了文件名也会变,所以放心缓存
location ~ .*\.(js|css|png|jpg|gif)$ {
# 31536000s = 365天
add_header Cache-Control "max-age=31536000";
}
}
十二、代码的执行顺序(事件循环)
// 选手 A:resolve 里面直接包一个值(现金)
new Promise((resolve) => {
console.log('A 启动');
resolve('A 成功');
}).then((res) => {
console.log(res);
});
// 选手 B:resolve 里面包了一个 Promise(支票)
new Promise((resolve) => {
console.log('B 启动');
// 关键点在这里!传入了一个已经成功的 Promise
resolve(Promise.resolve('B 成功'));
}).then((res) => {
console.log(res);
});
// 选手 C:用来计时的参照物
Promise.resolve()
.then(() => console.log('C1 (Tick 1)'))
.then(() => console.log('C2 (Tick 2)'))
.then(() => console.log('C3 (Tick 3)'));
结论先行
当你在 resolve() 中传入一个 Promise 时,外层的 Promise 状态不会立即变成成功。
它会说:“哦,你给了我一个 Promise?那我得等待这个 Promise 状态改变,并且我要拆开它拿到里面的值,才能算我成功。”
这个“等待 + 拆包”的过程,在 V8 引擎中会产生 2 个微任务的延迟(和在 .then 里 return Promise 的效果是一模一样的)。
为了好理解,我们可以把这个代码简化成这样理解
// 选手 A:resolve 里面直接包一个值(现金)
new Promise((resolve) => {
console.log('A 启动');
resolve('A 成功');
}).then((res) => {
console.log(res);
});
// 选手 B:resolve 里面包了一个 Promise(支票)
new Promise((resolve) => {
console.log('B 启动');
// 关键点在这里!传入了一个已经成功的 Promise
Promise.resolve().then(() => { // 【第二拍:拆包】
// 引擎必须确保 P_In 的状态变化了,并且拿到值 P_In就是Promise.resolve('B 成功')
P_In.then((val) => { // 【终局:按下开关】
// 只有到了这里,总开关才被按下
// P_Out 的状态才从 pending 变成 fulfilled
resolve(val);
})
});
}).then((res) => {
console.log(res);
});
// 选手 C:用来计时的参照物
Promise.resolve()
.then(() => console.log('C1 (Tick 1)'))
.then(() => console.log('C2 (Tick 2)'))
.then(() => console.log('C3 (Tick 3)'));
执行结果
A 启动
B 启动
A 成功 <-- A 没有任何延迟,直接出来
C1 (Tick 1)
C2 (Tick 2)
B 成功 <-- B 竟然排到了 C2 后面!(慢了两拍)
C3 (Tick 3)
十三、说一下你对cookie和localSrorage、session、indexDB 的理解
13.1. 核心对比总结表 (必背)
| 特性 | Cookie | localStorage | sessionStorage | IndexedDB |
|---|---|---|---|---|
| 数据生命周期 | 可设置失效时间,默认是关闭浏览器失效 | 永久有效,除非手动删除 | 仅在当前 Tab 页有效,关闭标签页即消失 | 永久有效,除非手动删除 |
| 存储大小 | 很小,约 4KB | 较大,约 5MB | 较大,约 5MB | 巨大,通常 > 250MB (取决于硬盘) |
| 与服务器通信 | 自动携带在 HTTP 请求头中 (浪费流量) | 不参与服务器通信 (纯前端) | 不参与服务器通信 (纯前端) | 不参与服务器通信 (纯前端) |
| 易用性 (API) | 难用,需自己封装 (document.cookie) | 简单 (setItem, getItem) | 简单 (setItem, getItem) | 复杂 (基于事件的异步 API) |
| 访问权限 | 任意窗口 | 同源的所有窗口共享 | 同源且同一窗口 (Tab) | 同源的所有窗口共享 |
| 数据类型 | 只能存字符串 | 只能存字符串 (需 JSON.stringify) | 只能存字符串 | 支持二进制、对象、文件等复杂类型 |
| 操作过程 | 同步 | 同步 (阻塞主线程) | 同步 (阻塞主线程) | 异步 (不阻塞主线程) |
13.2. 详细解析与应用场景
13.2.1. Cookie
老前辈,主要为了“维持状态”。 因为 HTTP 是无状态的,Cookie 的诞生是为了让服务器知道“你是谁”。
- 优点:可以设置
HttpOnly防止 XSS 攻击读取;可以设置过期时间。 - 缺点:容量太小;每次请求都会带上,浪费带宽;API 操作繁琐。
- 核心场景:
- 身份认证 (Token/SessionId)。
- 广告追踪 (第三方 Cookie)。
13.2.2. localStorage
持久化的本地仓库。
- 特点:除非用户主动清理缓存或代码手动删除,否则数据永远存在。
- 核心场景:
- 用户偏好设置 (如:深色模式、字体大小)。
- 长期存储的数据 (如:购物车数据、搜索历史)。
- 注意:不要存敏感数据 (密码),容易被 XSS 攻击窃取。
13.2.3. sessionStorage
临时会话仓库。
- 特点:生命周期最短,仅限于当前标签页。
- 注意:你在 A 标签页存了 sessionStorage,打开同源的 B 标签页是访问不到的!(localStorage 可以)。
- 特例:通过
window.open或链接打开的新标签页,会复制一份 sessionStorage,但之后互不影响。
- 核心场景:
- 表单分步填写 (防止刷新丢失,但又不希望关了浏览器还留着)。
- 一次性登录信息 (某些安全性要求高的银行网站)。
13.2.4. IndexedDB
浏览器里的 NoSQL 数据库。
- 特点:
- 容量大:几乎没有上限。
- 异步:读写数据不会阻塞页面渲染(性能最好)。
- 支持事务、索引。
- 存万物:可以存图片 (Blob)、文件 (ArrayBuffer)、JS 对象。
- 核心场景:
- 离线应用 (PWA):缓存大量 API 数据以便离线访问。
- 编辑器/工具类应用:如 Figma、VSCode Web 版,需要存储大量本地文件或状态。
- 性能优化:存储大量数据供 Web Worker 使用。
13.3. 面试高分回答逻辑
如果面试官问:“它们有什么区别?”,你可以这样回答:
- 总述:它们都是浏览器端的存储方案,但在容量、生命周期和应用场景上有很大区别。
- Web Storage (Local/Session):
- 它是为了解决 Cookie 存储空间不足(4KB vs 5MB)和不必要的网络传输问题而设计的。
localStorage是持久化的,适合存用户配置;sessionStorage是会话级的,关闭标签页就没了,适合存表单临时数据。
- Cookie:
- 它的本质是维持服务器状态,每次请求都会带上,所以不适合存大量数据。主要用于身份验证(Token)。
- IndexedDB:
- 当需要存储大量数据(如几百 MB)或者二进制文件/结构化数据时,Web Storage 就不够用了,这时候要用 IndexedDB。它是异步的,性能更好,适合做离线应用。
十四、this的指向问题
14.1 代码执行结果
User
var name = 'name'
const obj = {
name:'objname',
getName(){
console.log(this.name)
},
getName2:()=>{
console.log(this.name)
},
getName3(){
return function () {
console.log(this.name)
}
},
getName4(){
return ()=>{
console.log(this.name)
}
}
}
obj.getName()
obj.getName2()
obj.getName3()() //这种写法其实就是下面的写法的合并
const fn = obj.getName3()
fn()
obj.getName4()()
结果:
objname
name
name
name
objname
十五、Es6新特性
15.1 变量声明:let 和 const
-
特性:
- 块级作用域:只在 {} 内有效,解决了 var 变量提升和污染全局变量的问题。
- 不存在变量提升:存在“暂时性死区”(TDZ),必须先声明后使用。
- 不可重复声明。
- const 用于声明常量(引用的地址不可变,但对象内部属性可变)。
15.2 函数增强:箭头函数 (Arrow Functions)
特性:
- 语法简洁:const add = (a, b) => a + b;
- this 指向不同(重点) :箭头函数没有自己的 this,它的 this 是**继承自上层作用域(词法作用域)**的。这完美解决了回调函数中 this 指向 window 的痛点。
- 不能作为构造函数:不能使用 new。
- 没有 arguments 对象:需使用剩余参数 ...args 代替
15.3 解构赋值 (Destructuring)
- 数组解构:const [a, b] = [1, 2];
- 对象解构:const { name, age } = person;
- 应用场景:交换变量值、提取接口返回的数据、函数参数定义。
15.4 字符串扩展:模板字符串 (Template Literals)
-
特性:使用反引号 `。
-
功能:
- 支持多行字符串(保留换行和空格)。
- 支持变量插值:${variable},不再需要繁琐的 + 号拼接。
15.5 参数与运算符扩展
- 默认参数:function foo(x = 1) {}。
- 剩余参数 (Rest) :function foo(...args) {},args 是一个真数组,替代了 arguments。
- 展开运算符 (Spread) :...,用于数组合并 [...arr1, ...arr2]、对象浅拷贝 {...obj}、将数组转为参数序列。
15.6 面向对象:Class 类
- 特性:JS 原型继承的语法糖,让写法更像 Java/C++ 等传统 OOP 语言。
- 关键字:class, constructor(构造函数), extends(继承), super(调用父类), static(静态方法)。
15.7 新的数据结构:Map 和 Set
- Set:类似数组,但成员值都是唯一的(常用于数组去重 [...new Set(arr)])。
- Map:类似对象,但键(Key)可以是任意类型(对象、函数等),而不仅限于字符串。
15.8 新的数据类型:Symbol
- 特性:表示独一无二的值。
- 应用:通常用于定义对象的唯一属性名,防止属性名冲突;或用于实现私有属性。
15.9 对象的操作、语法糖
语法糖:让写法更简单
-
属性简写:
如果属性名和变量名一样,可以简写。codeJavaScript
const name = 'Tom'; // 旧写法 const obj = { name: name }; // 新写法 const obj = { name }; -
方法简写:
不再需要写 function 关键字。
codeJavaScript
```
const obj = {
// 旧写法
run: function() { ... },
// 新写法
run() { ... }
};
```
- 属性名表达式(动态 Key) :
允许用表达式作为属性名(放在方括号 [] 内)。这在 React/Vue 动态修改表单数据时非常有用。
codeJavaScript
```
const key = 'age';
const obj = {
[key]: 18, // 相当于 age: 18
['user_' + key]: 20 // 相当于 user_age: 20
};
```
-
对象的扩展运算符 ... (ES2018)
**对象浅拷贝**:
```
const obj = { a: 1, b: 2 };
const cloneObj = { ...obj }; // 也就是把 obj 里的东西“展开”放进去
```
**合并对象**:
```
const obj1 = { a: 1 };
const obj2 = { b: 2 };
const merged = { ...obj1, ...obj2 }; // { a: 1, b: 2 }
```
### 新增的常用静态方法 (API)
-
Object.assign(target, ...sources) :
- 作用:将源对象的所有可枚举属性复制到目标对象。
- 场景:在扩展运算符 ... 出来之前,这是合并对象的主流方式。
- 区别:Object.assign 是修改目标对象,而 ... 通常用于创建新对象。
-
Object.keys(obj) / Object.values(obj) / Object.entries(obj) :
- 作用:分别返回对象的 键数组、值数组、 [键, 值] 数组。
- 场景:当你需要遍历对象渲染列表时(比如 Vue 的 v-for="(val, key) in obj" 底层逻辑)。
codeJavaScript
const obj = { a: 1, b: 2 }; Object.keys(obj); // ['a', 'b'] Object.values(obj); // [1, 2] Object.entries(obj);// [['a', 1], ['b', 2]]-
Object.is(value1, value2) :
-
作用:比较两个值是否严格相等。
-
面试考点:它和 === 的区别?
- Object.is(+0, -0) 为 false(=== 认为是 true)。
- Object.is(NaN, NaN) 为 true(=== 认为是 false)。
-
Object.fromEntries(iterable) (ES2019):
- 作用:是 Object.entries 的逆操作。把键值对数组转回对象。
- 场景:配合 Map 使用,或者过滤对象属性。