重温前端三件套(三):JavaScript

数据类型和运算符

有哪些数据类型

原始类型

  • undefined

  • null,打印typeof()是object,因为在第一版JS中,变量的值被设计保存在一个32位内存单元中。该单元包含一个1或3位的类型标标志,和实际数据值。类型标志存储在单元的最后。包括以下2几种情况

    1. 000:object,数据为对象的引用
    2. 1:int,数据为 31 位的有符号整型
    3. 010:double,数据为一个双精度浮点数的引用
    4. 100:string,数据为一个字符串的引用
    5. 110:boolean,数据为布尔类型的值

    特殊情况:

    • undefined 负的 2 的 30 次方(超出当时整型取值范围的一个数)
    • null 空指针
      **null的存储单元最后三位(即 标志位)和object一样,所以被误判为Object
      与undefined的区别:
    • null聚焦业务层面,通常是开发者自己设置的
    • undefined聚焦系统层面,系统默认的
1
2
3
4
5
6
7
// 1. 宽松相等 (==)
console.log(null == undefined); // 输出: true
// 解释:在 JS 的底层规范中,它们俩在宽松比较时被认为是同一类“无值”状态,相互等价。

// 2. 严格相等 (===)
console.log(null === undefined); // 输出: false
// 解释:严格模式下不转换类型。一个是 Null 类型,一个是 Undefined 类型,当然不相等。
  • boolean
  • number
  • string
  • bigint,ES6新增
  • symbol,ES6新增,提供“绝对唯一”且自带“隐身特权”的标识符,
    • 作用如下:
      1. 防撞名(唯一性):只要调用 Symbol(),生成的永远是一个全世界独一无二的值。这从根本上解决了对象属性名冲突、全局常量/事件名冲突的问题。
      2. 防污染(不可枚举性):用 Symbol 作为对象的属性名时,它会自动在常规的遍历(for...inObject.keys())和 JSON.stringify() 中“隐身”,非常适合用来存储不该被暴露或传输的内部状态。
    • 经典例子:
      1. 前端数据的“隐形防伪标签”(状态隔离)
        • 场景:前端需要在后端传来的商品数据上加一个 isSelected(是否选中)的状态来做 UI 交互,但提交回后端时,后端严格要求不能包含多余字段。
        • 解法:使用 Symbol('isSelected') 作为键名存入对象。
        • 原理:因为 JSON.stringify() 天生会忽略所有的 Symbol 属性,所以前端不管怎么改这个标记,转成 JSON 发给后端时它都会自动消失,无需写代码手动去清理数据。
      2. React 框架底层的“镭射防伪印章”(防御 XSS 攻击)
        • 场景:黑客会在评论区输入恶意的 JSON 字符串,企图伪造出 React 组件结构,让网页执行恶意脚本。
        • 解法:React 在自己真正生成的每一个合法元素上,都悄悄加了一个特殊的标记 $$typeof: Symbol.for('react.element')
        • 原理:由于网络传输主要靠 JSON,而JSON 格式根本不支持描述 Symbol,因此黑客无论怎么伪造,传过来的假数据里都不可能有合法的 Symbol。React 只要校验这个印章不存在,就直接拦截报错,实现完美防御。
      3. 大型项目事件总线的“物理隔离”(防事件串台)
        • 场景:在几百人的大项目中,导航栏团队和商品列表团队不约而同地把刷新事件都命名为字符串 "refresh_data",导致用户点击刷新导航栏时,商品列表也跟着错误刷新。
        • 解法:抛弃普通字符串,强制要求大家分别使用 Symbol("refresh_data") 来定义各自的事件常量。
        • 原理:即使描述文字一模一样,底层生成的 Symbol 也是内存中两个绝对不相等的实体(Symbol("A") !== Symbol("A"))。系统在分发事件时,绝对不会把导航栏的指令发给商品列表,彻底杜绝“撞名”导致的 Bug。
    • Symbol() 与 Symbol.for() 的本质区别:这两者的核心区别在于**“是否使用全局注册表”**:
      • Symbol("暗号"):绝对利己的“造物主”
        • 行为:每次调用,必定在内存里打造一把全新的钥匙。哪怕暗号完全一样,两把钥匙也绝不相等。
        • 作用域:受限于普通的变量作用域。如果不把它主动 export 导出去,外部永远拿不到它。
        • 适用场景:用来创建模块内部绝对安全的私有属性或防撞名常量。
      • Symbol.for("暗号"):全局共享的“图书管理员”
        • 行为:调用时,会先去 JavaScript 引擎的全局登记册里查。如果这个暗号已经登记过,就把之前那同一个拿出来给你用(也就是跨处共享);如果没登记过,才会造一个新的并登记在册。
        • 作用域:无视文件隔离,甚至无视同一个页面里的不同 iframe。只要喊出相同的暗号,在系统的任何角落拿到的都是同一个 Symbol。
        • 适用场景:跨文件、跨系统甚至跨团队时,不需要互相导入代码,只要对上长字符串暗号(如 Symbol.for("公司名.模块名.初始化")),就能精准共享同一个标识符。

数据处理

parseInt 容错率高,Number 严谨,toFixed 用来处理展示格式。

  • 字符串转数字:严格派 vs 宽容派
    在把字符串变成数字时,JS 提供了两种截然不同的态度:

    1. 严格派:Number() —— “一粒沙子都揉不进”
      它是一个强制类型转换。它的原则是:只要字符串里包含了任何一个无法转换为数字的字符(哪怕只是结尾带了个字母),整个转换直接宣告失败,返回 NaN(Not a Number)。
    2. 宽容派:parseInt() 和 parseFloat() —— “能拿多少是多少”
      它们是解析函数。它们的原则是:从左往右一个字符一个字符地读,只要遇到读不懂的非数字字符,就立刻停下来,把前面已经读懂的数字返回给你。
    • parseInt(string, radix)(只认整数)
      • 极其重要的 radix(基数/进制):第二个参数决定了你按几进制来解析这个字符串。作为懂 C++ 的人,这个你肯定非常熟悉。如果不传,默认按 10 进制算;如果是 "0x" 开头,自动按 16 进制算。
    • parseFloat(string)(兼容小数): 它没有进制模式(永远按十进制),但它认识第一个小数点。
  • 数学运算:Math 家族的四把刀

    • Math.floor()(向下取整):地板。不管小数多大,一律抹掉。如果是负数,往下走会变得更小!
    • Math.ceil()(向上取整):天花板。只要有小数,整数部分直接加一。
    • Math.round()(四舍五入):最符合人类直觉的四舍五入。
    • Math.abs()(绝对值):抹除正负号,只看距离。
  • 数字转字符串:格式化与强制转换

    • String(value):简单粗暴的强制转换,长什么样就转成什么样。
    • num.toFixed(n)(四舍五入保留小数):这个方法在处理金额时极其常用!传入参数保留 n 位小数,多出来的四舍五入,不够的补零。
      • ⚠️ 超级大坑警告toFixed() 的返回值是一个“字符串”,不是数字!

引用类型

1. Object
2. Date
3. Array
4. RegExp

数据类型底层内存机制

栈(Stack)的真相:它就是为了函数而生的

在 JS 里,栈的主要任务是管理函数的调用关系

当你执行一个函数时,JS 引擎会为你创建一个**“执行上下文(Execution Context / 栈帧)”**,并把它压入栈中(Push)。 这一页里写了什么?

  1. 局部变量:你在这个函数里 let a = 10;,这个 10 就直接写在这个栈帧里。
  2. 指针(引用):你在这个函数里 let obj = { ... };,这个堆内存的门牌号(地址)也写在这个栈帧里。

为什么栈的速度那么快? 当这个函数执行完毕后,JS 引擎直接把这个“栈帧”从栈顶弹出(Pop)。滋溜一下,随着栈帧的销毁,写在这一页上的所有局部变量、堆内存指针,瞬间灰飞烟灭! 不需要任何复杂的计算,这就是栈内存极其高效、用完即焚的原因。

JS 和 C++ 内存布局的本质区别

C++ 内存模型非常经典:代码段 -> 数据段/BSS(全局/静态变量) -> 堆(向上生长) -> 栈(向下生长)。
JS 有什么区别? 最大的区别在于:**JavaScript 是一门跑在虚拟机(比如 V8 引擎)里的语言。而 V8 引擎本身,就是一个巨大的 C++ 程序!**也就是说,JS 的所谓“内存”,其实是 V8 这个 C++ 程序向操作系统申请来的一块堆内存,然后 V8 自己在内部又把它进行了重新划分。
站在 V8 引擎的视角,JS 的内存布局(大仓库)被划分得比 C++ 更加精细:

  1. 栈区(Call Stack):和 C++ 类似,存基本数据类型、指针、控制函数调用。
  2. 堆区(Heap):这是 JS 和 C++ 最大的不同。V8 把堆区切分成了好几个专门的区域:
    • 新生代(New Space):刚刚创建的对象都在这里,空间小,清理极其频繁(存活率低)。
    • 老生代(Old Space):在新生代里经历了多次清理都没死掉的“老油条”对象,会被扔到这里。全局变量、闭包变量通常也最终呆在这里。
    • 大对象区(Large Object Space):太大的对象(比如极大的数组),直接塞到这里,免得搬来搬去。
    • 代码区(Code Space):JIT(即时编译器)编译后的机器码存在这里(类似于 C++ 的 Text 段)。

最致命的区别:谁来打扫战场?

  • C++:你是上帝。你用 malloc/new 申请堆内存,用完必须手动 free/delete。你如果不清理,就会内存泄漏,程序崩溃。
  • JS:你是大爷。你只管 let obj = {} 疯狂制造垃圾。V8 引擎里有一个不知疲倦的垃圾回收器(Garbage Collector, GC)

GC 是怎么工作的? 它会定期去“调用栈”里看一眼。如果它发现栈里已经没有任何一个指针指向堆里的某个对象了(说明这个对象失联了),GC 就会像扫地机器人一样,直接把堆里的那个对象无情清空,把内存收回来。

判断数据类型

一、常见判断:typeof
typeof 目前能返回string,number,boolean,symbol,bigint,unfined,object,function这八种判断类型,但是注意 null 返回的是 Object 。而且对于引用类型返回的是 object 因为所有的对象的原型最终都是 Object。

二、 已知对象判断:instanceof

  • 用来 判断引用数据类型的,判断基本数据类型无效 ,如:Object,Function,Array,Date,RegExp等,instanceof主要的作用就是判断一个实例是否属于某种类型
  • instanceof也可以判断一个实例是否是其父类型或者祖先类型
  • instanceof原理实际上就是查找目标对象的原型链
    判断:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 完美应用场景:判断自定义类和继承关系
class Animal {}
class Dog extends Animal {}

const myDog = new Dog();

console.log(myDog instanceof Dog); // true (认得出自己的构造函数)
console.log(myDog instanceof Animal); // true (认得出自己的父类)
console.log(myDog instanceof Object); // true (万物皆对象,认得出老祖宗)

// 2. 盲区一:对基础数据类型无效
console.log('hello' instanceof String); // false (字面量字符串不是 String 实例)
console.log(123 instanceof Number); // false

// 3. 盲区二:无法识别 null 和 undefined
// console.log(null instanceof Null); // 直接报错:Right-hand side of 'instanceof' is not an object

手写实现一个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//手写实现 
function myInstance(L, R) {
//L代表instanceof左边,R代表右边
var RP = R.prototype
var LP = L.__proto__
while (true) {
if (LP == null) {
return false
}
if (LP == RP) {
return true
}
LP = LP.__proto__
}
}
console.log(myInstance({}, Object));

三 、对象原型链判断:Object.prototype.toString.call(这个是判断类型最准的方法)

  • 原理: call 的意思是借用 Object 顶层原型上的 toString 方法,读取并返回对象的内部具体类型,格式固定为 [object 类型](如 [object String][object Date])。
  • 为什么必须加 .call() 因为数组、日期等大部分对象为了满足自身需求,都重写了继承自 Object 的 toString 方法。如果不使用 .call() 强制将 this 绑定到当前检测的变量上,JS 引擎会直接调用对象自身重写后的 toString,从而导致判断失败。
  • 唯一缺点: 无法细分开发者自定义的类(比如你定义的 Person 类,检测结果统一都是 [object Object])。

类型转换

JavaScript 数据类型

  • 6 种基本类型 (Primitive): nullundefinednumberstring注:修正了笔记中的 stringify)、booleansymbol。(按值传递)
  • 1 种引用类型 (Reference): object(包含数组、函数等,按地址传递)。

对象转基本类型 (ToPrimitive)

当对象必须要变成基本类型时,它有两把武器:valueOf() 和 toString()

  1. 转字符串(明确场景): 优先拔出 toString(),不行再用 valueOf()
    • 例子:String(obj),如果 toString 返回的不是基本类型,再看 valueOf,都不是就报错。
  2. 转数字(默认场景): 优先拔出 valueOf(),不行再用 toString()
    • 💡 必考现实: 大多数普通对象的 valueOf() 返回的还是自己(对象),所以最终往往被迫走向 toString(),变成字符串后再往下走。
    • 特例:Object.create(null) 创建的纯净对象连这两把武器都没有,一碰转换直接报错。

显式强制类型转换

这是你通过代码明确告诉 JS 引擎要转换成什么:

目标类型 转换规则与表现
转字符串 String() 调用对象的 toString()
转布尔值 Boolean() 死记“假值七兄弟”: nullundefinedfalse+0-0NaN""。除了这7个,其他全是 true(包括空对象、空数组)。
转数字 Number() 严苛的数学频道: 必须长得像数字。

• Number('') ➡️ 0

• Number(null)/Number(false) ➡️ 0

• Number(true) ➡️ 1

• Number(undefined) ➡️ NaN

• Number([]) ➡️ [].toString() 变 "" ➡️ 0

隐式强制类型转换

当算式两边类型不一致时,JS 助理怕报错,会偷偷帮你统一类型:

  1. 偷偷转字符串(遇到 + 且有字符串):
    • + 号变成胶水。只要有一边是字符串,另一边不管是啥(哪怕是对象),都会被逼着转成字符串,然后拼接在一起。
    • 例子:obj + '' ➡️ 触发对象转数字逻辑(先 valueOf 后 toString),拿到基本值后再拼接。
  2. 偷偷转数字(遇到 -*/ 或一元 +):
    • 强行进入数学频道。如果不是纯数字长相,就变成 NaN
    • 例子:'x' - 0 ➡️ NaN
    • 例子:1 + + '2' ➡️ 后面的 + '2' 先被偷偷转成数字 2,然后 1 + 2 = 3
  3. 偷偷转布尔值(遇到 ifforwhile? :\|\|&&):
    • 充当保安。非布尔值来了,偷偷用“假值七兄弟”规则查验,不在黑名单里的统统放行(当做 true)。

== 和 === 比较

  • === (严格相等): 铁面无私,类型不同直接 false
  • == (宽松相等): 允许 JS 助理偷偷转换。它的核心法则是:“万物皆向数字看齐,一层一层扒皮”

== 的扒皮降级规则(优先级):

  1. 字符串 vs 数字: 字符串脱皮变成数字。
  2. 布尔值 vs 其他: 布尔值立刻脱皮变成数字(true=1false=0)。
  3. 对象 vs 非对象: 对象调用两把武器,变成基本类型后再比较。
  4. 特殊保底:
    • null == undefined 为 true(它俩穿一条裤子,跟别人都不等)。
    • NaN 六亲不认,NaN == NaN 也是 false
    • 两个对象比较,只看是不是同一个内存地址。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// 一、 转换为布尔值 (Boolean)
Boolean("0") // true
Boolean("") // false
Boolean([]) // true
Boolean(null) // false
Boolean({}) // true
Boolean(undefined) // false
Boolean(NaN) // false

// 二、 显式转换为数字/字符串 (Number / String)
Number("123a") // NaN
Number(null) // 0
Number(undefined) // NaN
Number("") // 0
Number(false) // 0
Number(true) // 1
String([1, 2, 3]) // "1,2,3"
Number([]) // 0
Number({}) // NaN

// 三、 隐式强制类型转换 (数学运算与拼接)
"5" - 2 // 3
"5" + 2 // "52"
"5" * "2" // 10
+ "10" // 10
1 + + "2" + "3" // "33"
[] + [] // ""
[1] + [2] // "12"
[] + {} // "[object Object]"

// 四、 自定义对象转换 (ToPrimitive)
/* let magic = {
valueOf: function() { return 10; },
toString: function() { return "hello"; }
};
*/
Number(magic) // 10
String(magic) // "hello"
magic + "" // "10"

// 五、 宽松相等 (==)
0 == "" // true
"0" == 0 // true
"0" == false // true
false == [] // true
0 == [] // true
[1] == 1 // true
null == undefined // true
NaN == NaN // false

浮点数求和步骤

  1. 对阶
  2. 求和
  3. 规格化

战前准备:为什么 0.1 和 0.2 会长成这样?

  • JS 的 Number 类型是 64 位双精度格式。 这 64 位被划分为:1 位符号位 + 11 位指数位(阶码) + 52 位尾数位(有效数字,加上默认的 1,实际精度是 53 位)。
  • 硬币拼凑理论: 计算机用 0.5,0.25,0.125… 去拼凑 0.1 和 0.2 时,陷入了无限循环。0.1 的纯二进制是 0.0001100110011...,0.2 是 0.001100110011...
  • 截断与排版: 计算机要求小数点前面必须是 1.(这就是规格化),而且尾数只能存 52 位。所以装不下的部分被“0舍1入”切掉了。

这就是参与运算的两个真实的初始数字:

符号位永远是 0(正数)。我们重点看指数(阶码)尾数。 (注意:下面尾数开头的 1. 就是计算机藏起来的那个数字)

  • 0.1 的指数是 -4 尾数是:1. 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 (结尾因为四舍五入变成了1010,-4就是由0.0001100110011...变成现在1.xxx开头的,相当于小数点往后移知道尾数开头1.)
  • 0.2 的指数是 -3 尾数是:1. 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

第一步:对阶(统一单位,让小数点对齐)

当前情况: 0.1 的指数 (-4) < 0.2 的指数 (-3)。单位不同,不能直接相加。 行动: 必须把 0.1 的指数变成 -3。指数变大了,说明单位变大了,那前面的数字就必须变小。 演算: 把 0.1 的尾数整体向右平移 1 位

1
2
3
4
5
0.1 原来的尾数 (指数-4):
1 . 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010

整体向右平移 1 位 (指数变成-3):
0 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 (最后掉出去的 0 被舍弃,发生末位进位)
  • 注意: 原本最前面的那个隐藏的 1.,被挤到了小数点后面,变成了 0.1100...。这就是“尾数最高位空出一位”。

第二步:尾数求和(列竖式做二进制加法)

现在 0.1 和 0.2 的指数都是 -3 了,我们可以把对齐后的尾数加起来了。 为什么会出现 10? 仔细看下面的竖式,逢 2 进 1,后面的小数部分加爆了,一直进位顶到了最前面!

1
2
3
4
  0.1 对齐后的尾数:    0 . 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101
+ 0.2 原来的尾数: 1 . 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010
-------------------------------------------------------------------------------------------
= 加出来的结果: 10 . 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111
  • 破案: 左边整数位的 0 + 1,再加上后面小数位一路传过来的进位 1,变成了 2。二进制没有 2,所以写成了 10

第三步:规格化和舍入

当前情况: 算出来的结果是 10.0110...。 触发警报: IEEE 754 的强迫症排版规定,小数点前面必须是 1.,绝对不能是 10.

行动 1:规格化(挪小数点) 为了合规,把小数点往左挪 1 位,变成 1.0011...。 因为数字整体缩小了,作为补偿,指数必须 +1(从 -3 变成了 -2)。

1
2
算出的结果:      10 . 0110 0110 ... 0110 0111
向右平移 1 位: 1 . 0011 0011 ... 0011 0011 (注意最后那个 1 被挤出去了!)

行动 2:舍入(0舍1入补偿) 最末尾的那个 1 被挤出去了,直接丢掉误差太大。计算机执行舍入规则:既然挤出去的是个 1,那就给剩下的尾巴最后一位加个 1 补回来。

1
2
3
4
平移后的尾巴:            ... 0011
补偿加 1: + 1
---------------------------------
最终敲定的尾巴: ... 0100

所以,最终符合所有强迫症规定的尾数是: 1 . 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100

最终大结局(见证奇迹的 0.3000…4)

计算机把刚才算好的最终结果存进内存:

  • 符号位:0
  • 指数位:-2(加上偏移量后,存储的二进制是 011 1111 1101
  • 尾数位:去掉隐藏的 1.,只存后面的 0011 0011 ... 0100

当你打印这个数字时,计算机用数学公式: 1.0011…0100×2−2 将其强行翻译回十进制。 因为我们在对阶、舍入、规格化中,多次挤掉并修改了末尾的数字,导致它已经不再是完美的 0.3 了,翻译出来的结果就是:0.30000000000000004

实战四种解法

知道了底层运算过程如此“千疮百孔”,我们在业务中必须防备:

  1. 容错拦截法 (Number.EPSILON): 承认误差,只要差值小于 2−52 的极限极小值,就当它们相等。Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON
  2. 暴力斩断法 (toFixed(n)): 既然尾巴不准,直接四舍五入砍掉尾巴变字符串。适合展示金额。parseFloat((0.1 + 0.2).toFixed(10))
  3. 有效数字法 (toPrecision(n)): 限制整个数字的长度进行四舍五入。 parseFloat((0.1 + 0.2).toPrecision(12))
  4. 浮点数变整数计算: 躲开小数的坑,先把小数乘 1000 变成整数相加,算完再除回来。 (0.1 * 10 + 0.2 * 10) / 10

JavaScript “无值”状态与核心考点对照表

概念 物理状态 “买盒子”比喻 代码表现 🚨 核心面试考点与避坑指南
undeclared

(未声明)
内存中根本不存在 连盒子都没买 代码中根本没写过 let b; “双标”待遇:
1. 直接打印 console.log(b) ➡️ 直接崩溃报错 ReferenceError: b is not defined
2. 用 typeof b 探测 ➡️ 触发安全机制,不报错,而是伪装输出 "undefined"
undefined

(未定义)
存在,是系统给的默认空 买了盒子,但还没往里放东西 let a;

console.log(a);
与 null 的相爱相杀:

1. undefined == null ➡️ true (宽松相等,JS 认为它俩都代表“无”,偷偷放行)。

2. undefined === null ➡️ false (严格相等,类型不同,直接拒绝)。
null

(空值)
存在,是你人为指定的空 买了盒子,主动往里放了张**“空”纸条** let a = null;

console.log(a);
百年历史大 Bug:

typeof null ➡️ 输出 "object"

原因: 早期 JS 在 32 位系统为了性能,用二进制末尾 3 位存类型。对象的标记是 000,而 null 的底层二进制全是 0,导致 JS 引擎闭着眼误判为 Object。

JavaScript 特殊数值:NaN 与 Infinity

特殊数值 核心含义 数据类型 (typeof) 怎么产生的?(触发场景) 🚨 独一无二的奇葩特性 (必考)
NaN



(Not a Number)
“数学计算搞砸了”



代表一个计算错误或解析失败的值。
"number"



(虽然叫“不是数字”,但它被归类在数字类型里)
1. 强行转数字失败:Number("abc")



2. 违背数学常理:Math.sqrt(-1)



3. 0 / 0
六亲不认(非自反性):



它是 JS 中唯一一个不等于自己的值。



NaN === NaN 结果为 false



NaN !== NaN 结果为 true
Infinity / -Infinity



(无穷大/负无穷大)
“数字大到/小到爆表了”



代表超出了 JS 能表示的极限数值。
"number" 1. 任何正常数字除以 0:1 / 0 ➡️ Infinity



2. 负数除以 0:-1 / 0➡️ -Infinity



3. 数字超过了 Number.MAX_VALUE
符合极限数学逻辑:



无穷大加减正常数字,依然是无穷大。



Infinity + 1000 === Infinity 结果为 true

JavaScript 数值判定函数:严格 vs 宽松 对照表

探测函数 严格 / 宽松 核心工作逻辑(一句话看透本质)
isNaN(val)



(全局老函数)
宽松(Loose) 先强行转成数字。只要转换后的结果是 NaN,就返回 true。(所以会把不能转数字的字符串误判为 NaN
Number.isNaN(val)



(ES6 新增)
严格(Strict) 绝不转换类型。必须满足“类型是数字”且“值正好是 NaN 本尊”这两个条件,才返回 true
isFinite(val)



(全局老函数)
宽松(Loose) 先强行转成数字。只要转换后的结果是个正常的有限数字(不是 NaN 或无穷大),就返回 true
Number.isFinite(val)



(ES6 新增)
严格(Strict) 绝不转换类型。必须满足“类型本身就是数字”且“值是正常的有限数字”,才返回 true

JavaScript 逻辑运算符

最底层的精髓——短路特性(Short-circuiting)

在其他编程语言里,&& 和 || 可能只会死板地返回 true 或 false。但在 JavaScript 里,它们是非常**“偷懒且聪明”的,它们会直接返回原操作数的值**。

前提——“假值六兄弟”

在让 && 和 || 干活之前,JS 引擎会先对它们左右两边的值进行“真假鉴定”。 在 JS 中,只有这 6 个值会被判定为 false(也就是我们之前说的假值六兄弟,加上 false 本身其实是 7 个,但核心起作用的是这 6 个):0"" (空字符串)、nullundefinedNaNfalse (除了它们,其他所有的值,包括空数组 []、空对象 {}、字符串 "0",统统都是 true!)

核心法则:“保镖”与“找备胎”

1. 逻辑与 && —— “严苛的保镖”(遇假则停)

短路规则: 只要前面是假,直接拦截(返回前面的假值);前面是真,才放行看后面(返回后面的值)。

2. 逻辑或 || —— “找备胎”(遇真则停)

短路规则: 只要前面是真,直接拿来用(返回前面的真值);前面是假(废了),才去找备胎(返回后面的值)。

两个实战

绝招 1:用 || 给变量设置默认兜底值
1
let myObject = perferredObject || backupObject;
绝招 2:用 && 进行安全的对象属性访问(防报错)
1
{{ data.owner && data.owner.avatar }}

总结来说: && 和 || 的本质并不是用来做数学判断的,在实际开发中,它们是被当作**“控制代码执行流程的开关”**来用的。利用它们的短路特性,我们可以写出极其精简且健壮的代码。

数组

数组方法

创建数组

Array.from() 浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
console.log(Array.from("123", (v ,k) => +v+k)). // 接受可迭代对象
console.log(Array.from({length: 5}).fill(false)) // 接受ArrayLike,有length属性即可
console.log(Array.from({length: 3}, v => v = Array(3).fill(Infinity))) // 函数边转边改,箭头函数不写大括号返回的是表达式结果

// 输出
[ 1, 3, 5 ]
[ false, false, false, false, false ]
[
[ Infinity, Infinity, Infinity ],
[ Infinity, Infinity, Infinity ],
[ Infinity, Infinity, Infinity ]
]

Array.of()

1
2
3
4
5
console.log(Array(3))
console.log(Array.of(3))
// 输出
[ <3 empty items> ]
[ 3 ]

sort原理

1
2
3
4
5
console.log(['a', 10, 'Ccc', 1].sort())
console.log(['a', 10, 'Ccc', 1].sort((a, b) => (Number(a) || parseInt(a) || 0) - (Number(b) || parseInt(b) || b)))
// 输出
[ 1, 10, 'Ccc', 'a' ]
[ 'a', 'Ccc', 1, 10 ]
  • 返回负数 (< 0):意味着 a 应该排在 b 的前面
  • 返回 0:意味着 a 和 b 相等,位置不变(在某些浏览器实现中)。
  • 返回正数 (> 0):意味着 a 应该排在 b 的后面

copyWithin()

直接修改原数组,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。

  • target(必需):从该位置开始替换数据。如果为负值,表示倒数。
  • start(必需):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
  • end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。-1表示最后一个
  • start-end包括start,不包括end
1
2
3
console.log([1,2,3,4].copyWithin(0,-2,-1))
// 输出
[ 3, 2, 3, 4 ]

find()

find()用于找出第一个符合条件的数组成员

参数是一个回调函数,接受三个参数依次为当前的值、当前的位置和原数组

findIndex()

findIndex返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1

find和findIndex这两个方法都可以接受第二个参数,用来绑定回调函数的this对象。

1
2
console.log(haizeiwang.find(item => item.name ==='Zoro'))
console.log(haizeiwang.findIndex((_, index) => index === 0))

fill()

还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置. 与copyWithin 用法类似
注意,如果填充的类型为对象,则是浅拷贝.

1
2
3
console.log(Array(3).fill(false, -2))
// 输出
[ <1 empty item>, false, false ]

对原数组有影响

push(…items: T[]): number;

返回数组最新长度

unshift(…items: T[]): number;

从头部开始插入,返回新数组长度
… 表示打包、解构、简陋

splice

  • splice(start: number, deleteCount?: number): T[];
  • splice(start: number, deleteCount: number, ...items: T[]): T[];
    返回包含删除元素的数组,注意 start 的光标在哪里?
1
2
3
4
5
6
7
8
haizeiwang.unshift(0)
haizeiwang.push(0)
let els = ["Sanji", "Nami"]
haizeiwang.splice(0, 1)
haizeiwang.splice(-1, 1)
haizeiwang.splice(0, 0, '?')
console.log(haizeiwang)
haizeiwang.splice(-1, 0, ...els)

pop() 队尾出列

shift() 队头出列

对原数组无影响

concat()

创建一个副本,返回新构建的数组,以下效果一致:

1
2
3
haizeiwang = haizeiwang.concat("Chopper")
haizeiwang = haizeiwang.concat(["Chopper"])
haizeiwang = [...haizeiwang, "Chopper"]

slice()

创建一个包含原有数组中一个或多个元素的新数组

1
2
3
4
copied = onepiece.slice() // 浅拷贝
onepiece = onepiece.slice(1) // 提取下标1以后
onepiece = onepiece.slice(0, 5)
console.log(onepiece)

reduce

reduce() 方法不会改变原始数组。他的目的是把一个数组,通过某种逻辑,浓缩或转换成另外一个单一的数据结构(哪怕这个结果是一个复杂的对象或新数组)

1
2
3
4
5
6
7
8
9
10
onepiece.splice(-1,0,{name: 'Luffy'})
onepiece.unshift("Luffy")
let luffy_count = onepiece.reduce((acc, current) => {
if (typeof current === 'string' && current.toLowerCase().includes("luffy"))
acc++
else if (typeof current === 'object' && current.name.toLocaleLowerCase().includes("luffy"))
acc++
return acc
}, 0)
console.log(luffy_count)

filter

将所有元素进行判断,将满足条件的元素作为一个新的数组返回

some

将所有元素进行判断返回一个布尔值,如果存在元素都满足判断条件,则返回 true,若所有元素都不满足判断条件,则返回 false:

every

将所有元素进行判断返回一个布尔值,如果所有元素都满足判断条件,则返回 true,否则为 false:

求最大值

var a=[1,2,3,4]; Math.max.apply(null, a);

join

数组变字符串

1
console.log("onepiece".split("").reverse().join(""))

flat(),flatMap()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 这是一个三维数组(套了三层)
const nested = [1, 2, [3, 4, [5, 6]]];
// 1. 默认只拉平一层(剥去最外面那层括号)
console.log(nested.flat());
// 输出: [1, 2, 3, 4, [5, 6]] <-- 最里面的 [5,6] 还没平
// 2. 指定拉平两层
console.log(nested.flat(2));
// 输出: [1, 2, 3, 4, 5, 6] <-- 彻底平了!
// 3. 终极无脑作弊码:Infinity(无限大)
// 不管你嵌套了 10 层还是 100 层,直接一管到底,全压成一维!
console.log(nested.flat(Infinity));
// 输出: [1, 2, 3, 4, 5, 6]

// flatMap相当于先map后flat(1)

数组去重

Set

1
2
console.log([...new Set(repeated)])
console.log(Array.from(new Set(repeated)))

Map (对象去重)

1
2
3
4
5
6
7
8
9
10
11
12
const unique = (arr) => { 
const map = new Map();
const res = [];
for (let item of arr) {
// 去 Map 这个“登记册”里查一下,如果有这个人了,就跳过
if (!map.has(item)) {
map.set(item, true); // 登记:这个人来过了!
res.push(item); // 把他放进新队伍里
}
}
return res;
}

数组拍平

  1. ary = ary.flat(Infinity); //Infinity无穷大
  2. 递归

ES6新增的数组方法

find
findIndex
flat
flatMap()
**Array.at()**返回对应下标的值
Array.from()
Array.of()
Array.includes()
… 扩展运算符
copyWithin()
fill()
entries() 遍历键值对
keys() 遍历键名
values() 遍历键值
Array.proptype.sort() 的排序稳定性

判断是否存在某个值

  1. array.indexOf()
  2. array.find()
  3. array.findIndex()
  4. String.prototype.includes()
  5. includes() 方法用于判断一个字符串/数组是否包含在另一个字符串中,根据情况返回 true 或 false,方法的第二个参数表示搜索的起始位置,默认为0,参数为负数则表示倒数的位置。

类数组转数组

另一种对象类型,也叫类对象数组(类数组),有callee和length属性,如何转换为数组?

  • Array.prototype.slice.call()
  • Array.form
  • ES6扩展运算符
  • concat+apply or apply

对象

遍历对象属性

搜查员 (遍历方法) 自身属性



(自己买的)
继承属性



(老爹给的)
不可枚举属性



(墙壁暗格)
Symbol 属性



(外星隐形)
for...in
Object.keys()
(⭐ 99%日常用它)
Object.getOwnPropertyNames()
hasOwnProperty
Object.getOwnPropertySymbols()
Reflect.ownKeys()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const luffy_father = {
show: 1
}
const luffy = Object.create(luffy_father)
luffy["last_name"] = "Monkey"
luffy["first_name"] = "Luffy"
// 不可枚举
Object.defineProperty(luffy, "secret", {
value: "ace",
enumerable: false
})
// Symbol
const luffy_symbol = Symbol("YellowHat")
luffy[luffy_symbol] = "Uniqlo"
console.log(luffy.hasOwnProperty("secret")) // 有不可枚举
console.log(luffy.hasOwnProperty("YellowHat")) // 无Symbol
console.log(luffy.hasOwnProperty("show")) // 无父
for (let key in luffy) {
console.log(key);
}
console.log(Object.keys(luffy))
console.log(Object.getOwnPropertyNames(luffy))
console.log(Reflect.ownKeys(luffy))

// 输出
true
false
false
last_name
first_name
show
[ 'last_name', 'first_name' ]
[ 'last_name', 'first_name', 'secret' ]
[ 'last_name', 'first_name', 'secret', Symbol(YellowHat) ]

遍历对象的方法

🥚 彩蛋一:对象也有了 values 和 entries

还记得我们在数组篇刚讲过的三个迭代器兄弟吗?ES8(ECMAScript 2017)把它们完美移植到了对象上:

  • Object.keys():拿所有的键(Key)。
  • Object.values():拿所有的值(Value)。
  • Object.entries():拿所有的键值对([Key, Value])。

最爽的实战联动: 你笔记里写了 for (const [key, value] of Object.entries(obj1))。这就是结合了 entries() 和解构赋值的终极形态!以后遍历对象,再也不用傻乎乎地写 obj[key] 去取值了,一步到位,极其优雅。

🥚 彩蛋二:“带证警察”的实锤(暴露了隐藏的 length)

1
2
3
const str = 'hello'; 
console.log(Object.keys(str)); // ["0", "1", "2", "3", "4"]
console.log(Object.getOwnPropertyNames(str)); // ["0","1","2","3","4","length"]

看出猫腻了吗? 字符串在 JS 底层也是个对象,它自带一个 length 属性。

  • Object.keys()(本分的公证员)去查的时候,length 是不可枚举的(藏在暗格里),所以没查出来。
  • 但是当你用 Object.getOwnPropertyNames()(带搜查令的警察)去查的时候,连带着把底层的 length 都给生生扒出来了! 这完美印证了咱们上一关的“查户口”理论。

🥚 彩蛋三(重中之重):ES6 对象的“潜规则”排序法

很多人以为对象里的数据是“先写谁,谁就在前面”(像数组一样)。在 ES6 之前确实是无序的,但 ES6 之后,JS 引擎给对象的 Key 制定了极其严格的“三步走”排队规则

不管你以什么顺序把属性塞进对象,只要你遍历它(不论是用 keysvaluesentries 还是 for...in),它都会在底层偷偷按照以下顺序重新排队:

  1. 第一梯队:数字 Key(自然数 / 整数)
    • 只要你的 Key 看起来像是个正整数(比如 "100""2""7"),它们会被最优先揪出来。
    • 并且,它们会被强制从小到大(升序)排列
    • (对应你笔记里的结果:原本是 100, 2, 7,输出变成了 “2”, “7”, “100”)
  2. 第二梯队:普通的字符串 Key
    • 数字排完了,才轮到字母和普通字符串(比如 "a""d""hello")。
    • 它们严格按照你写进去(插入)的先后顺序排队。
  3. 第三梯队:Symbol Key
    • 永远排在最后面。
    • 也是按照插入的时间先后顺序排队。

🔥 面试防坑小测试: 如果面试官问你,下面这段代码 Object.keys(obj)Object.values(obj) 会输出什么?

1
2
3
4
5
6
const obj = {
"b": 1,
"2": 2,
"a": 3,
"1": 4
};

你现在就可以自信地秒答:["1", "2", "b", "a"]。(数字优先并升序排,字母按原始插入顺序排排后面)。

判断对象是否具有属性

核心诉求 🔍 定向查岗 (检测机制) 📝 拉取清单 (遍历机制) 它们共同的底线
第一派:宽松流

(连吃带拿,包含老爹)
'key' in obj

(哪怕是老爹的,也算你的)
for...in

(把你和老爹摆在明面上的全列出来)
只要在原型链上能找到,就算数!
第二派:严格流

(铁面无私,只查亲生)
Object.hasOwn(obj, 'key')

(老爹的绝对不算,必须是你自己买的)
Object.keys(obj)

(绝不列出老爹的,只列你自己摆在明面上的)
彻底切断原型链,只看对象自身!
1、Reflect.has()
2、hasOwnProperty()
3、Object.prototype.hasOwnProperty()
1
2
3
4
5
6
console.log(luffy.hasOwnProperty("secret"))
console.log(Object.hasOwn(luffy, "show"))
console.log("show" in luffy)
true
false
true

for in 和 for of 的区别

一句话总结:for … in是为遍历对象属性而构建的,遍历的是 index,而 for … of是为了遍历数组的,遍历的是 value

1
2
3
4
5
6
const arr = [1, 2, 4, 8, 9, 10];
Array.prototype.method = () => {}; // 给数组老爹(原型)加个方法
arr.name = "bababa"; // 给数组强行加个私有属性
Object.prototype.fun = () => {}; // 给所有对象的祖宗加个方法
for (const item in arr) { console.log(item) }
// 结果输出:0 1 2 3 4 5 name method fun
  • 可迭代的对象:数组、字符串、Set、Map等,普通对象是不能迭代的,也即不能用of
1
2
3
4
5
6
7
8
const arobj = { 100: 1, d: 2, a: 9... };
for (const item of arobj) { console.log(item) }
// 结果:Uncaught TypeError: arobj is not iterable (不可迭代)

// 完美配合:用 Object.entries 给对象装上传送带,再用 for...of 拿东西!
for (const [key, value] of Object.entries(arobj)) {
console.log(`键是 ${key},值是 ${value}`);
}

构造函数、原型对象、实例

在 ES6(2015年)引入 class 关键字之前,JS 里根本没有类的概念。想要批量创建对象,只能硬生生拿普通的函数来充当“类”。

1. 哪部分是构造函数?

在 JS 中,任何一个普通的函数,只要你用 new 关键字去调用它,它在这一瞬间就变成了“构造函数”。

为了区分普通函数,程序员们约定俗成:首字母大写的函数,就是用来当构造函数的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 【这就是构造函数!】
// 它看起来就是一个普普通通的函数,什么特殊语法都没有。
function Person(name, age) {
// 当你用 new 调用它时,JS 会在内存里偷偷创建一个空对象 {}
// 并把 this 指向这个空对象。
this.name = name;
this.age = age;
// 然后隐式地 return 这个对象
}

// 把它当普通函数调用(没用):this 指向全局 window,什么也得不到
Person("张三", 18);

// 用 new 关键字调用(质变):它作为“构造函数”启动了,生成了一个实例!
const p1 = new Person("张三", 18);

2. 哪部分是原型对象?

如果所有方法都写在构造函数里(比如 this.say = function(){}),那你每 new 一个实例,内存里就会重新复制一份这个方法,极其浪费内存。

为了解决这个问题,JS 引擎做了一个强行绑定:只要你写了一个函数,系统就会自动在内存里生成一个配套的“空对象”,并把它挂在这个函数的 .prototype 属性上。

这个空对象,就是原型对象(Prototype Object)

1
2
3
4
5
6
7
8
9
10
11
// 刚刚写完上面的 function Person() {...}
// 此时内存里已经自动存在了一个空对象:Person.prototype = {}

// 【这部分就是在操作原型对象!】
// 我们手动往这个公共的“原型对象”里添加方法
Person.prototype.sayHello = function() {
console.log("你好,我是 " + this.name);
};

// p1 自身没有 sayHello,但它会顺着 __proto__ 去原型对象里找来用
p1.sayHello();

总结: 在老 JS 里,由于没有 class 这个壳子把它们包起来,“构造函数”和“原型对象”在物理代码上是割裂的。你写一个 function 搞定属性,再在外面写一堆 .prototype.xxx = ... 搞定方法。

由于前端程序员(特别是从 Java 转过来的)实在受不了上面那种割裂、反人类的写法,官方在 ES6 终于推出了 class 关键字。

3. 什么是 class

核心真相:JS 里的 class 纯粹是“语法糖(Syntactic Sugar)”! 它底层依然是上面那套“构造函数 + 原型对象”的破机器,只是官方给你套了一个长得像 Java 的漂亮外壳。

看代码对比,这就是它奇怪的原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ES6 的类写法
class Animal {
// 【这部分才是真正的构造函数!】
// 必须叫 constructor 这个名字,专门用来初始化实例属性
constructor(name) {
this.name = name;
}

// 【这部分是原型对象上的方法!】
// 注意!你写在这里的方法,JS 引擎在底层会自动帮你挂到 Animal.prototype 上
eat() {
console.log(this.name + " 正在吃东西");
}
}

const dog = new Animal("小黑");

4. 为什么 JS 直接把类当构造函数了?

你觉得“把类当构造函数”很奇怪,是因为 JS 的 class 掩盖了一个事实:在 JS 的内存里,根本不存在一个叫 class 的数据类型!

我们可以用代码直接证明这件事:

1
2
3
4
class Animal { ... }

// 见证奇迹的时刻:打印 Animal 到底是个什么东西?
console.log(typeof Animal); // 输出: "function" !!!

看到了吗?你以为你写了一个名叫 Animal 的类(图纸),实际上你写的是一个名叫 Animal 的构造函数!

JS 引擎在解析 class Animal 时,底层做的事情就是:

  1. 把 constructor() { ... } 里面的代码,变成 function Animal() { ... }
  2. 把 eat() { ... } 里面的代码,变成 Animal.prototype.eat = function() { ... }
概念 传统面向对象 (Java/C++) JavaScript (本质) JS ES6 class 语法糖
类的本质 内存中的蓝图/图纸 JS 没有蓝图。 只有函数和对象。 本质还是一个函数 (Function)
构造函数 类里面的一个特殊方法,与类同名。 随便一个普通的函数,只要被 new 调用就是构造函数。 必须写在 class 内部,名字定死叫 constructor()
共享方法 定义在类里面的普通方法。 必须手动挂载到 函数名.prototype(原型对象)上。 写在 class 内部,底层会自动帮你挂到原型对象上。
  • prototype(属于构造函数):指向它配套的【原型对象】。
  • __proto__(属于一切对象/实例):指向创建它的那个构造函数的【原型对象】。(现代开发中,推荐将其理解为 Object.getPrototypeOf() 访问器)。
  • constructor(属于原型对象):反向指回对应的【构造函数】。

原型和原型链

什么是原型链?

当执行 obj.name 时:

  1. 先在 obj 自身内存里找。
  2. 找不到?顺着 obj.__proto__ 指针,去它的原型对象里找。
  3. 还找不到?因为原型对象也是普通对象,继续顺着 原型对象的 __proto__ 往上找。
  4. 终点:一直找到 Object.prototype。如果这里也没有,它的 __proto__ 指向 null,查找正式结束,返回 undefined

这条由 __proto__ 串联起来的单向回溯路径,就叫原型链

看 JS 引擎启动时的真实构建顺序:

  1. 第一元老诞生:先凭空开辟一块内存,叫 Object.prototype(这就是那只“鸡”)。它是所有普通对象的终极老祖宗,它的 __proto__ 是 null
  2. 第二元老诞生:基于第一元老,造出了 Function.prototype。因为它是普通对象,所以 Function.prototype.__proto__ === Object.prototype

为什么? Function.__proto__ === Function.prototype

  • Yes 的部分(运算层面):按照公式,实例的 __proto__ 指向构造它的函数的 prototype。一切函数都由Function构造。既然 Function自身是个函数对象,那它在逻辑上就是自己构造了自己,所以成立。这保证了 Function instanceof Function === true
  • No 的部分(物理层面):实际上 Function 是底层内置好的,并不是真的执行了一句 new Function() 把它生出来。这只是一种为了语言自洽而硬绑的底层指针规则。

其他注意事项:

  1. instanceof 的真面目A instanceof B 只是在算一笔账:顺着 A 的 __proto__ 链往上爬,能不能撞见 B.prototype 撞见就是 true,没撞见就是 false。
  2. 修改原型的危险性:你可以用 Object.setPrototypeOf() 动态改变对象的 __proto__,但这会严重破坏 JS 引擎的运行优化,极其消耗性能。
  3. this 的指向:当调用原型链上的方法时,函数内部的 this 永远指向当前调用该方法的实例对象,而不是原型对象。
  4. 无原型的孤儿Object.create(null) 创建的对象,其 __proto__ 被切断为 null。它没有原型,也不能调用 toString 等任何自带方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Luffy(name) {
if (name)
this.name = name
else
this.name = 'Monkey D. Luffy'
}
Luffy.prototype.Luffy_func = () => {}
let luffy = new Luffy()
console.log(luffy.__proto__ === Luffy.prototype)
console.log(Luffy.prototype.__proto__ === Object.prototype)
console.log(Object.prototype.__proto__ === null)
console.log(Function.__proto__ === Function.prototype)
console.log(typeof Array)
console.log(Object.__proto__ === Function.prototype)
console.log(Array.__proto__ === Function.prototype)
console.log(Object.getPrototypeOf(Object) === Function.prototype)
// 记住一句话:__proto__ 指向创建他的构造函数的原型对象

继承方式

继承方式 核心招式 (核心代码) 战利品 (优点) 致命弱点 (缺点)
1. 原型链继承 Child.prototype = new Parent() 父类原型上的方法可以复用。 1. 引用类型属性被所有实例共享(一人修改,全员遭殃)。

2. 创建子类实例时,无法向父类传参
2. 借用构造函数

(经典继承)
Parent.call(this, args) 1. 避免了引用类型属性共享。

2. 可以向父类传参
方法都在构造函数中定义,每次实例化都会重新创建,无法复用方法,浪费内存。
3. 组合继承 Parent.call(this)

+

Child.prototype = new Parent()
融合前两者的优点:属性不共享,方法可复用,还能传参。JS 最常用的传统模式。 父类构造函数被调用了两次,导致子类原型上多出了一份冗余的父类属性
4. 原型式继承 Object.create(obj) 无需写构造函数,直接基于现有对象克隆出新对象。极其轻量。 和原型链继承一样,引用类型属性会被共享
5. 寄生式继承 clone = Object.create(obj)

+

clone.say = function(){}
封装了继承过程,能在克隆的基础上给对象增强(添加新方法) 和借用构造函数一样,增强的新方法无法复用
6. 寄生组合式



(ES5终极Boss)
Parent.call(this)

+

Child.prototype = Object.create(Parent.prototype)
最完美。只调用一次父类构造函数,原型链干净无冗余,完美保留原型链特性。 ES5 原生写法稍显繁琐(需要封装 inheritPrototype 函数)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// 原型链继承,方法共享,属性可直接任意篡改
function Parent(name) {
this.name = name
}
Parent.prototype.getName = function() {console.log(this.name)} // 不能写箭头函数,this作用域function灵活一点

function Child() {}
Child.prototype = new Parent("luffy") // ES6 class不能写入prototype
var child1 = new Child()
child1.getName()


// 借用构造函数,方法不共享,属性独立

function Parent(name){
this.name = name;
}
function Child(name){
Parent.call(this, name); // ES6 class 必须与new关键字联合使用
}
console.log((new Child("luffy")).name)


// 组合继承,方法共享,属性独立

function Parent(name) {
this.name = name
}
Parent.prototype.getName = function() {console.log(this.name)}
function Child(name) {
Parent.call(this, name)
}
Child.prototype = new Parent("")
console.log((new Child("luffy")).name)



// 原型式继承

function createObj(o) {
function obj() {}
obj.prototype = o
return new obj()
}
// 缺点:引用还是引用
// 相当于Object.create()



// 寄生式继承

function createEnhancedObj(o) {
let obj = Object.create(o)
obj.getName = function(){}
return obj
}
// 缺点:方法getName不共享


// 寄生组合式继承,避免二次调用构造函数

function Parent(name) {
this.name = name
}
Parent.prototype.getName = function(){console.log(this.name)}
function Child(name) {
Parent.call(this, name)
}
function inheritProtoType(Child, Parent) { // 避开Parent二次调用构造函数
var prototype = Object.create(Parent.prototype)
prototype.constructor = Child
Child.prototype = prototype
}

ES6的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Parent{
constructor(name){
this.name = name
}

getName(){
console.log(this.name)
}
}

class Child extends Parent{
constructor(name, age){
super(name) // 执行父类的构造函数拿到this,他不像ES5子类自己会构造一个this
this.age = age
}
}

深浅拷贝

浅拷贝只能拷贝一层对象。

深拷贝能解决无限层级对象嵌套问题。

浅拷贝

  1. Object.assign()

拷贝的是对象属性的引用,而不是对象本身。

1
2
console.log(Object.assign([1, 2, 3], [4, 5])); 
// 输出4,5,3

assign方法可以用于处理数组,不过会把数组视为对象,比如这里会把目标数组视为是属性为0、1、2的对象,所以源数组的0、1属性的值覆盖了目标对象的值。

  1. concat浅拷贝数组
  2. slice浅拷贝
  3. …扩展运算符
  4. for…in

深拷贝

  1. JSON.parse(JSON.stringify());
  • 无法解决循环利用问题
  • 无法拷贝一条特殊对象 RegExp Date Set Map 等
  • 忽略undefined、symbol和函数
  1. 递归实现
  2. lodash第三方库实现

拷贝特殊对象,使用 Object.prototype.toString.call(obj)鉴别。

freeze属性

对象的“四把隐藏锁”(属性描述符)

在 JavaScript 里,当我们写下 obj.name = "大侠" 时,你以为仅仅是存了一个名字吗?错!引擎在底层其实悄悄为这个 name 属性配备了四个“开关”(官方叫特性/描述符)。

  1. [[Value]](数据锁): * 大白话:这里面装的就是真正的(比如 "大侠")。
  2. [[Writable]](修改锁):
    • 大白话:这个值能不能被重新赋值?如果把它关掉(false),你再写 obj.name = "菜鸟",引擎会直接无视(严格模式下会报错)。
  3. [[Enumerable]](隐身锁):
    • 大白话:这个属性能不能在 for...in 循环或 Object.keys() 中露脸?如果关掉(false),它就隐身了,别人遍历对象时根本看不见它,但你直接点名 obj.name 依然能拿到。
  4. [[Configurable]](终极管理员锁):
    • 大白话:这是最核心的锁。它决定了你能不能用 delete obj.name 删掉它,以及能不能去修改上面那三把锁的状态。一旦这个锁被关死(false),这个属性的规则就彻底焊死了,再也无法被重新配置。

怎么去拨动这些开关呢? 就是你提到的 Object.defineProperty() 这个超级 API。Vue 2.0 的响应式底层原理,就是靠这个 API 疯狂操纵这些开关来实现的。

Object.freeze() 到底干了什么?

明白了四把锁,我们再来看 Object.freeze()。当你对一个对象释放这个大招时,它其实在底层极其冷酷地打出了一套三连击

  1. 第一击:物理封锁(Object.preventExtensions
    • 直接没收该对象的“生育能力”。绝对禁止再往里面添加任何新属性
  2. 第二击:锁死数值([[Writable]]: false
    • 把对象里所有现有属性的“修改锁”全部关掉。绝对禁止修改现有属性的值
  3. 第三击:没收管理权([[Configurable]]: false
    • 把对象里所有现有属性的“终极管理员锁”全部拔掉。禁止删除属性,禁止把数据属性改成访问器属性(getter/setter),也禁止你再用 Object.defineProperty 把 Writable 改回 true

绝对不能在代码里直接这样写:obj.[[Writable]] = false。JS 引擎会直接给你报语法错误。这只是 ECMAScript 规范用来向开发者描述底层原理的一种书面符号。想修改它们,只能通过官方提供的接口(比如 Object.defineProperty 或 Object.freeze)去间接操作。

注意,Object.freeze 是浅冻结,这一点和 Object.assign 一样

函数

函数基本知识

函数的诞生与“提升(Hoisting)”特权

在 JS 中创建函数有两种主流方式,它们在 JS 引擎眼里的“地位”是完全不同的。

1. 函数声明(VIP 特权通道)

  • 长相: function foo() {}
  • 特权: 函数声明会被整体“提升”到代码的最顶部。 这意味着,你甚至可以在写这个函数之前,就提前调用它!

2. 函数表达式(普通打工人)

  • 长相: var foo = function() {} (把一个没有名字的函数赋值给变量)
  • 待遇: 依照变量声明的规则。只会提升变量名(值为 undefined),真正的函数体要在执行到这一行时才赋值。 如果你提前调用,会直接报错。

⚔️ 案发现场解析(你提供的文本中的经典面试题):

1
2
var foo = function () { console.log('我是表达式'); };
function bar() { console.log('我是声明'); };

在 JS 引擎真正运行前,它会把代码重新排版成这样:

1
2
3
4
5
6
7
// 1. VIP 先整体提升
function bar() { console.log('我是声明'); };
// 2. 普通变量只提升名字
var foo = undefined;

// --- 开始正式执行代码 ---
foo = function () { console.log('我是表达式'); }; // 到这里才真正赋值

🔥 终极冲突:当变量名和函数名同名时怎么办? 记住:函数声明的优先级永远高于 var 变量声明! 

函数的百变身份(匿名、回调、高阶)

函数不仅可以独立存在,还可以像普通变量一样传来传去(这就是文本里说的“第一等公民”)。

1. 匿名函数 & 回调函数 (Callback) 没有名字的函数就是匿名函数。当它被当作参数,塞进另一个函数里,等特定时机再执行时,它就成了回调函数

1
2
3
4
// 这里的 function() {} 就是匿名函数,同时也是 setTimeout 的回调函数
setTimeout(function() {
console.log("3秒后我才执行!");
}, 3000);

2. 高阶函数 (Higher-Order Function) 如果一个函数接收另一个函数作为参数,或者返回一个函数,那它就是高阶函数。JS 数组自带的 mapfilterreduce 都是典型的高阶函数。

1
2
3
4
5
6
let arr = [1, 2, 3];
// map 接收了一个回调函数,所以 map 是高阶函数
let newArr = arr.map(function(item) {
return item * 2;
});
// newArr 变成 [2, 4, 6]

瞬间爆发与“按值传递”的陷阱

1. 自执行函数 (IIFE) 有些函数我们只想让它运行一次(比如做一些初始化工作,又不想弄脏全局变量),就可以用括号把它包起来,直接执行:

1
2
3
4
(function() {
var secret = "我是局部秘密";
console.log("我一出生就执行了!");
})(); // 最后的 () 表示立即调用

2. 传参陷阱:按值传递 (Pass by Value) 这是文本中非常重要的一段!在 JS 中,所有函数的参数传递都是按值传递。如果是基本类型(数字、字符串),传的是复印件;如果是对象,传的是内存地址(指针)的复印件

⚔️ 案发现场解析(文本中的代码):

1
2
3
4
5
6
7
8
9
10
11
12
13
var person = { name: 'Nicholas', age: 20 }

function setName(obj) {
// 【关键点】:此时 obj 和 person 指向同一个内存地址
// 但是!下一行你直接把 obj 指向了一个全新的空对象 {}
obj = {};

// 此时修改的是新对象,跟外面的 person 已经毫无关系了!
obj.name = 'Greg';
}

setName(person);
console.log(person.name); // 输出: Nicholas (完美避开修改)

提示:如果函数里没有 obj = {} 这一句,直接写 obj.name = 'Greg',外面的 person 就会被改掉!

函数式编程 (Functional Programming) 与纯函数

这是一种非常优雅的编程思想(React 和 Redux 的核心基石)。它要求你把代码写成数学公式一样的体验。

  • 只用表达式,不用语句: 也就是每个函数最好都有 return 返回值,而不是仅仅在里面做一些操作(比如直接修改全局变量)。
  • 纯函数 (Pure Function): 终极要求!只要输入相同的参数,永远返回相同的结果,且绝对不产生副作用(不修改外面的变量)。

实战对比:

1
2
3
4
5
6
7
8
9
10
// ❌ 脏函数(依赖了外部变量,容易出 Bug)
var globalAge = 18;
function addAge(num) {
globalAge += num; // 偷偷改了外面的东西(副作用)
}

// ✅ 纯函数(极其稳定可靠)
function pureAddAge(currentAge, num) {
return currentAge + num; // 只依赖传入的参数,且有返回值
}

函数柯里化 (Currying) —— 参数收集术

核心原理: 柯里化就像是分期付款。原本一个函数需要一次性传入 3 个参数才能执行;经过柯里化包装后,你可以先传 1 个参数,它会返回一个新函数记住这个参数;你再传 1 个,它再返回新函数;直到参数凑齐了,它才真正发大招执行。

案发现场解析(破解你文本里的代码): 假设我们有一个需要两个参数的加法函数:function add(a, b) { return a + b; }

如果你用文本里提供的 curry1 把它包装一下,会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 假设这是普通调用(全款买房)
add(11, 1); // 马上执行,返回 12

// 这是柯里化调用(分期付款)
let addCurry = curry1(add);

// 第一期:什么都不传,返回一个继续等参数的函数
let step1 = addCurry();
// 第二期:传个 11,参数还没凑齐(需要2个),把 11 存起来,继续返回新函数
let step2 = step1(11);
// 第三期:传个 1,凑齐两个参数了!立刻召唤神龙,执行 add(11, 1)
let result = step2(1); // 返回 12

// 连起来写就是文本里的绝招:
console.log( addCurry()(11)(1) ); // 输出 12

为什么要有这个技术? 主要为了参数复用延迟执行。比如你有一个校验手机号的函数 check(正则, 字符串),你可以把它柯里化,先传入手机号的正则,生成一个专门校验手机号的新函数 checkPhone,以后在代码里只要调用 checkPhone(字符串) 就可以了,不用每次都传正则。

例如:动态生成配置化函数:

1
2
3
4
5
const curriedCheck = curry(check);
const checkIdCard = curriedCheck(/身份证正则/);
const checkBankCard = curriedCheck(/银行卡正则/);
const checkLicense = curriedCheck(/车牌号正则/);
// ... 一行代码造一个新函数,极其优雅。

函数的 length 属性 —— 虚假的形参个数

核心原理: 大家都以为 函数.length 就是括号里写了几个参数,它的值就是几。大错特错! 文本里揭示了 length 的核心铁律:它只统计必须要传入的参数个数

三大“不计入”规则现场:

  1. 正常情况(老实人): function f(a, b) {} 👉 length 是 2。
  2. 遇到“默认参数”就罢工(大坑!): 一旦参数有了默认值,length 就不算它了。更狠的是,它不仅不算自己,连排在它后面的所有参数,统统都不算了!
    1
    2
    3
    // age 设了默认值 22,导致后面的 gender 和 aaa 全被连累,不计入 length
    function fn5(name = '林三心', age, gender, aaa) {}
    console.log(fn5.length) // 输出 0!因为第一个参数就有默认值,直接罢工。
  3. “剩余参数”不算数: function f(name, ...args) {} 👉 length 是 1(只算 name,不算 args)。

💥 终极谜题揭晓:123['toString'].length + 123 = ?

  • 123['toString'] 其实就是取数字的 toString 方法。
  • JavaScript 原生的 Number.prototype.toString(radix) 接收 1 个参数(进制数,比如传 2 就是转成二进制)。
  • 所以这个函数的 length 就是 1
  • 答案:1 + 123 = 124

箭头函数 vs 普通函数

我们在前面的“支线副本”里简单聊过这个,这份文本把它们的底裤彻底扒光了。你只需要死死记住一点:箭头函数是一个“六根清净”的阉割版函数。

它为了保持轻量,抛弃了普通函数拥有的 6 样东西:

  1. 没有自己的 this 它的 this 是“白嫖”外层环境的,一旦绑定,终身不变。(这就解释了文本里说的:callapplybind 对箭头函数完全无效,根本掰不动它的 this)。
  2. 没有 arguments 你在它里面用 arguments 拿到的参数,其实是它外层普通函数的参数。
  3. 没有 constructor(构造器): 所以绝对不能对它使用 new 关键字,会直接报错。
  4. 没有 prototype(原型): 当不了构造函数,自然也就不配拥有原型对象。
  5. 没有 yield 关键字: 不能用作 Generator(生成器)函数。

🚨 对象大括号 {} 的伪装

1
2
3
4
5
6
var obj = {
name: "大侠",
// 很多新手以为这个箭头函数在 obj 里面,this 就会指向 obj
say: () => { console.log(this.name); }
};
obj.say(); // 结果:undefined(或者在浏览器里打印出全局的 window.name)

为什么? 因为文本里写了极其关键的一句话:“定义对象的大括号 {} 不是一个单独的执行环境!” 在 JS 引擎眼里,对象仅仅是个值。所以这个箭头函数其实是直接暴露在最外层(全局)的,它的 this 毫不犹豫地指向了全局对象 window,根本没把 obj 放在眼里。

1
2
3
4
5
(function() {
console.log(arguments[0])
;(()=>{ console.log(arguments[0]) })()
})("luffy")
// 注意加;进行括号阻隔,不然就连成一行报错

构造函数

什么是构造函数?

创建实例对象

new 到底干了什么?

这是这篇文本里最值钱的一段!面试官非常喜欢问:“当你 new 一个构造函数时,底层发生了哪四步?”

想象 new 是一台全自动组装机,当你按下启动键 let p = new Student() 时,它在零点几毫秒内完成了四件事:

  1. 打地基(创建空对象): 在内存里悄悄凭空造了一个干净的空对象 {}
  2. 接天线(链接原型链): 把这个空对象的隐式原型(__proto__)连接到构造函数的原型(Student.prototype)上。这样实例就能使用 sayHi 这个公共技能了。
  3. 装修房子(绑定 this 并执行): 引擎把构造函数里的 this 全部指向这个刚造出来的空对象。然后开始执行函数里的代码(比如 this.name = name),给这个空对象塞满属性。
  4. 交钥匙(隐式返回): 就算你的函数里没写 return,机器也会自动把这个塞满属性的房子(对象)返回给你,赋值给外面的变量 p

构造函数的禁忌 —— 乱写 return

构造函数里千万别瞎写 return

因为 new 机器在最后一步“交钥匙”时,有一个非常奇葩的判定规则:

  • 情况 A:你返回了一个基本数据类型(如字符串、数字) 机器判定:“这人脑子进水了,忽略它!”

    1
    2
    3
    4
    5
    6
    function Person(name) {
    this.name = name;
    return '啦啦啦啦'; // 机器:忽略这堆废话
    }
    let p1 = new Person("大侠");
    console.log(p1.name); // 输出: "大侠" (照常正常工作)
  • 情况 B:你返回了一个新的引用类型(如对象、数组) 机器判定:“好家伙,你要造反啊?那 new 白干了,听你的!”

    1
    2
    3
    4
    5
    6
    7
    function Person(name) {
    this.name = name;
    return { fakeName: "冒牌货" }; // 机器:丢弃造好的实例,直接把这个冒牌货扔出去!
    }
    let p2 = new Person("大侠");
    console.log(p2.name); // 输出: undefined (真正的实例被弄丢了)
    console.log(p2.fakeName); // 输出: "冒牌货"

万物皆有源(语法糖揭秘)

文本最后点破了一个小秘密。你在 JS 里天天写的简单代码,底层其实都在偷偷调 new

  • 你写 let arr = [1, 2],其实底层是 let arr = new Array(1, 2)
  • 你写 let obj = {a: 1},底层其实是 let obj = new Object()

🎬 全视角还原代码执行全生命周期

读懂一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var boss = "全局大老板";

function outer(agentName) {
var secret = "外层金库密码";
function inner() {
var weapon = "飞镖";
console.log(agentName + " 使用 " + weapon + ",破解了: " + secret);
console.log("指挥官: " + this.boss);
}
return inner;
}

var myObj = {
boss: "分公司小老板",
doMission: outer("特工007")
};

myObj.doMission();

🕒 第一阶段:脚本刚启动(创建大本营)

JS 引擎通电启动,第一件事就是创建一个**【全局执行上下文】,并且把它压入执行栈(深桶的最底层)**。 现在,桶里长这样: 执行栈:[ 全局上下文 ]

镜头切入【全局上下文】内部看看配置:

  • 变量对象 (VO){ boss: undefined, outer: function(){...}, myObj: undefined } (变量提升阶段)
  • 作用域链:只有它自己(顶级)。
  • this 指向window(全局对象)。

代码开始一行行执行,boss 被赋值为 "全局大老板"。接着遇到了 var myObj = { ... },但在赋值前,引擎发现里面需要执行 outer("特工007")

🕒 第二阶段:执行 outer("特工007")(搭建一号临时车间)

遇到函数调用,引擎立马创建一个**【outer 函数执行上下文】,并把它压入执行栈**。 现在,桶里长这样(叠起来了): 执行栈:[ outer上下文, 全局上下文 ]

镜头切入位于栈顶的【outer上下文】内部:

  • 变量对象 (VO/AO){ agentName: "特工007", secret: undefined, inner: function(){...} }
  • 作用域链[ outer的VO -> 全局的VO ] (图纸决定了通道怎么连)
  • this 指向window (因为是普通调用,没有对象点它)。

代码执行:secret 赋值为 "外层金库密码"。然后,把 inner 函数当做结果 return 出去。 💥 重点来了: outer执行完了!它的临时车间被拆除,从执行栈中弹出(出栈)。 现在,桶里又变回去了: 执行栈:[ 全局上下文 ]

(全局代码继续,把刚才 return 的 inner 函数,正式交给了 myObj.doMission 存起来。)

🕒 第三阶段:执行 myObj.doMission()(搭建二号临时车间)

代码走到最后一行,执行了 inner 函数。引擎再次创建一个崭新的**【inner 函数执行上下文】**,压入执行栈。 现在,桶里长这样: 执行栈:[ inner上下文, 全局上下文 ]

镜头切入此时处于栈顶的【inner上下文】内部:

  • 变量对象 (VO/AO){ weapon: undefined } (刚建好时只有这个)
  • 作用域链[ inner的VO -> outer的VO -> 全局的VO ]
  • this 指向myObj (因为是 myObj. 调用的!)。

🚀 开始顺着链子干活(执行 console.log):

  1. 找 weapon:在自己车间(inner的VO)的柜子里找到了 "飞镖"
  2. 找 agentName:自己柜子没有,顺着作用域链通道往外找,在 outer 的柜子里找到了 "特工007"
  3. 找 secret:自己没有,顺着通道在 outer 的柜子里找到了 "外层金库密码"
  4. 找 this.boss:当前车间的 this 绑的是 myObj,所以直接去 myObj 身上拿到了 "分公司小老板"

全部打印完毕。inner 函数执行结束。车间拆除,从执行栈中弹出(出栈)。 桶里最后剩下: 执行栈:[ 全局上下文 ] (直到你关闭浏览器网页,这个全局上下文才会被清空,深桶彻底归零)。

outer 的车间(执行上下文)明明都已经拆除、出栈了,按理说里面的东西应该灰飞烟灭,凭什么 inner 还能找到 agentName

绝对能找到!

为了解释这个“死而不僵”的灵异事件,我们必须揭开 JS 引擎内存管理的最后一块面纱:调用栈(Call Stack) 与 堆内存(Heap) 的分离。

在 JS 引擎的底层,“临时车间(执行上下文)” 和 “装变量的柜子(变量对象 VO/AO)” 其实是两码事。

  • 车间(执行上下文):由执行栈管理。非常无情,函数一执行完,立刻出栈,物理空间被抹除。
  • 柜子(变量对象):由堆内存管理。JS 的垃圾回收器(Garbage Collector)负责盯着堆内存。垃圾回收器有一个铁律:只要这个柜子还有人牵着一根线(被引用),就绝对不允许销毁它!
🕵️‍♂️ 闭包是怎么诞生的?

让我们回到 outer 函数即将出栈的那一瞬间,看看发生了什么暗箱操作:

  1. 孕育牵绊: 当 inner 函数在 outer 内部被定义(画图纸)的那一刻,JS 引擎给 inner 绑了一根看不见的脐带(内部属性 [[Scope]]),这根脐带死死地连着 outer 的柜子(里面装了 agentName 和 secret)。
  2. 成功潜逃: outer 执行到最后一句 return inner; 时,把 inner 函数交到了外面的世界,并被全局变量 myObj.doMission 给接盘了。
  3. 拆除车间与垃圾回收的博弈: outer 执行完毕,它的执行上下文(车间)确实被无情地弹出执行栈,彻底销毁了。 接着,垃圾回收器推着车过来,准备把 outer 留下的柜子(变量对象)也扔进焚烧炉。
  4. 刀下留人(闭包形成): 垃圾回收器正要动手,突然发现不对劲:“等等!外面的全局大佬 myObj 牵着 inner 函数,而 inner 函数的脐带竟然还连着 outer 的这个柜子!这条引用链没断!如果我把柜子烧了,inner 以后执行时就拿不到数据了!” 于是,垃圾回收器网开一面,把 outer 的柜子强行扣留在内存中,变成了不死之身。
🏆 终极结论:什么是闭包?

当以后你执行 myObj.doMission() 也就是执行 inner 的时候,虽然 outer 车间早就没了,但 inner 依然可以顺着那根没断的脐带,精准地在内存里找到那个被强行保留下来的 outer 柜子,拿出里面的 agentName 和 secret

这个“被保留下来的外部变量柜子” + “能够访问它的内部函数(inner)”,打包在一起,就叫作【闭包】!

🏆 究极大圆满总结

  • 执行栈(调用栈):决定了哪个上下文现在有资格运行(永远是压在最上面的那个在跑,跑完就踢出去)。
    • 变量对象 (VO):车间里的独立小金库,装自己的变量。
    • 作用域/作用域链:即使 outer 上下文已经被踢出栈销毁了,图纸(作用域)还在,通道(作用域链)还在,所以 inner 依然能跨越时空去访问 outer 当时的变量。
    • this 指向:车间建好被扔进栈里的那一瞬间,看看是谁发起的调用,当场发牌。
执行上下文总结

就是那个被扔进栈里的车间。当执行 JS 代码时,会产生三种执行上下文:

  • 全局执行上下文
  • 函数执行上下文
  • eval 执行上下文(可忽略)

在该执行上下文的创建阶段,变量对象、作用域链、闭包、this指向会分别被确定。对于每个执行上下文,都有三个重要属性:
- 变量对象 (VO):车间里的独立小金库,装自己的变量。
- 作用域/作用域链:即使 outer 上下文已经被踢出栈销毁了,图纸(作用域)还在,通道(作用域链)还在,所以 inner 依然能跨越时空去访问 outer 当时的变量。
- this 指向:车间建好被扔进栈里的那一瞬间,看看是谁发起的调用,当场发牌。

JS 执行过程总结

JavaScript属于解释型语言,JavaScript的执行分为:解释和执行两个阶段,这两个阶段所做的事并不一样:

1. 解释阶段

  • 词法分析
  • 语法分析
  • 作用域规则确定

2. 执行阶段

  • 创建执行上下文(调用栈管理)
  • 执行函数代码
  • 垃圾回收

作用域在定义时就确定,而不是在函数调用时确定,不会改变,但是执行上下文是在函数执行前创建的。随时可以改变,执行上下文最明显的就是this的指向在执行时确定. 而作用域访问的变量是编写代码的结构确定的。只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前, ECStack 最底部永远有个 全局执行上下文

作用域与作用域链总结
🆕 补充一:世界的第三极 —— 块级作用域 (Block Scope)

我们在上一关只提到了两种车间:全局车间函数临时车间。 在 ES5 时代(只有 var 时),确实只有这两种。这也导致了文本中提到的一个反直觉现象:

  • 过去的陷阱: if 语句和 for 循环的大括号 {} 不会创建新的作用域!你在 for(var i=0;...) 里面定义的 i,会直接泄漏到外层函数里。
  • ES6 的救赎(新补充): 文本引出了 let 和 const。只要配合大括号 {}(比如 iffor 里面),它们就能瞬间创造出一个**“块级作用域(安全屋)”**。安全屋里的变量,外面绝对进不来也拿不到,彻底解决了变量污染问题。
🆕 补充二:彻底拍死“动态作用域”的幻想(最经典的案发现场)

上一关我说过:“作用域是你写代码时大括号的位置决定的(谁包着谁)”。文本里给出了一个绝佳的实战例子来证明这个“词法作用域(静态作用域)”的铁律!

⚔️ 案发现场解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
var i = 1; 

// b 诞生在全局!它的老家(作用域链的上一级)永远是全局。
function b() {
console.log(i);
}

function a() {
var i = 2;
b(); // a 把 b 叫过来干活
}

a(); // 输出结果是:1
  • 动态作用域的错觉: 很多人第一眼觉得,b 是在 a 里面被调用的呀,那 b 找不到 i 时,不应该顺手拿 a里面的 i=2 吗?
  • 词法作用域的铁拳(新补充): 错!文本明确指出:“在写代码阶段作用域就已经确定了”。b 函数是在全局定义的,所以它的作用域链只有 [b的柜子 -> 全局的柜子]。它就算死,也是回全局找那个 i=1,绝对不会去拿调用者 a 里面的 i=2
  • 一句话总结:找变量,看你“出生”在哪,别看你“打工(被调用)”在哪!
🆕 补充三:隐式全局变量的炸弹

所有未定义直接赋值的变量自动声明为拥有全局作用域”。

我们在函数里写代码,如果忘了写 varlet 或者 const,直接写 weapon = "飞镖"。JS 引擎不会报错(非严格模式下),而是顺着作用域链找,一直找到全局都没找到,它就会自作主张地在全局 window 大老板身上挂一个 window.weapon = "飞镖"。这就是极其危险的变量泄漏

🆕 补充四:生命周期与可见性的精准定义

“作用域控制着变量和函数的可见性和生命周期。”

  • 全局变量:生命周期和页面等同(页面关了才死)。
  • 函数局部变量:生命周期随函数结束而结束销毁(除非!被我们上一关讲的“闭包”给强行保释扣留了!)。
闭包和内存泄漏
  •  闭包 = 函数 + 自由变量

    • 什么是自由变量? 既不是你这个函数自己的局部变量,也不是你接收的参数,但你却在代码里用了它。这就叫自由变量。(比如上一个例子里的 agentName 和 secret,对 inner 函数来说就是自由变量)。
    • 物理存放地大揭秘(堆内存): 闭包的变量存在“堆(Heap)”中。在 JS 里,普通的数字/字符串通常存在“栈(Stack)”里,函数执行完就弹栈销毁了。但 JS 引擎极其聪明,一旦它发现某个变量被内部函数引用了(形成闭包),它就会把这个变量打包,转移到寿命更长的“堆内存”里。这就是为什么车间拆了,柜子还在的底层物理原因!
  •  “只有内部函数访问了上层作用域链中的变量对象时,才会形成闭包。”

  • 闭包的应用:

    • 制造“私有变量”(数据加密)
    • 模拟块级作用域(套了个函数的壳子)
    • 防抖 (Debounce)
1
2
3
4
5
6
7
function createBank() {
var money = 1000; // 这是一个被闭包保护的私有变量,外面绝对拿不到!
return {
getMoney: function() { return money; },
addMoney: function(num) { money += num; } // 只能通过暴露的方法修改
}
}
1
2
3
4
5
6
7
// 文本里的例子:
(function () {
for (var i = 0; i < 5; i++) {
// 这里的 i 被死死锁在这个匿名函数的作用域里
}
})();
// 运行到这里,外面的世界依然干干净净,根本不知道有过 i 这个变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function debounce(fn, delay = 300) { 
// 【核心】:这个 timer 就是自由变量!它将被闭包永远锁在内存里!
let timer = null;

return function () {
// 每次用户点击,执行的其实是这个内部函数
// 如果发现之前还有没到时间的定时器,直接砸碎(清除)!
if (timer) {
clearTimeout(timer);
}
// 重新买一个新闹钟,开始倒计时
// 因为闭包的存在,下一次点击时,依然能找到同一个 timer 变量去清除!
timer = setTimeout(() => {
fn.apply(this, arguments);
}, delay);
};
}

this

牢记:在普通函数里,this 是个势利眼,谁最后按下了调用的开关,this 就指向谁!它是在执行的那一瞬间才决定的,跟代码写在哪里毫无关系。

第一层:势力划分 —— this 的四大绑定铁律

文本一上来就给出了武力值排行榜:new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。我们倒着往上看:

1. 默认绑定(野生散修,武力最低) 如果一个函数是孤零零地自己运行的,前面没有任何对象点它。

  • 规则: 势利眼 this 发现没人雇佣它,非严格模式下就认**全局大老板(window)**为主;严格模式下,大老板不管它,它就是 undefined
1
2
function wildFunc() { console.log(this); }
wildFunc(); // 前面没东西点它,默认输出 window (或者 undefined)

2. 隐式绑定(宗门打手) 函数被挂在一个对象身上,由这个对象发起了调用。

  • 规则: 谁点(.)了它,它就指向谁!
1
2
3
4
5
var zhangsan = {
name: "张三",
say: function() { console.log(this.name); }
};
zhangsan.say(); // 是 zhangsan 点的 say!所以 this 指向 zhangsan,输出 "张三"

3. 显式绑定(精神控制法术,强过隐式) 我们用 callapplybind 这三大法术,强行拿枪指着函数的脑袋,按头让它的 this 指向我们指定的对象。

文本里提到了非常核心的一句:fn() 其实是 fn.call() 的简写!

  • call 和 apply(立刻打工): 把函数拉过来立刻执行,并强行绑定 this。区别只在于传参方式(call 是一串逗号隔开,apply 是传一个数组)。
  • bind(发个打工证,等通知): 它不会立刻执行!而是复制出一个一模一样的新函数,并且把新函数的 this 永久焊死在你指定的对象上,等着以后再调用。
1
2
3
4
5
6
var lisi = { name: "李四" };
// zhangsan.say 本来是指向 zhangsan 的,被 call 强行扭转了!
zhangsan.say.call(lisi); // 输出 "李四"

let newFunc = zhangsan.say.bind(lisi)
newFunc()

4. new 绑定(创世神,武力最高) 就像我们在“构造函数”那关讲的,使用 new 关键字时,JS 引擎会凭空造一个全新的空对象,并把函数里的 this 死死绑定在这个新对象上。没有任何力量能扭转 new 里的 this

第二层:样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = 20; // 挂在 window.a 上
var obj = {
a: 10,
c: this.a + 20, // 坑点 1:这里的 this 是谁?
fn: function () {
return this.a; // 坑点 2:这里的 this 又是谁?
}
fn2: () => {
return this.a
}
}
console.log(obj.c); // 输出: 40
console.log(obj.fn()); // 输出: 10
console.log(obj.fn()); // 输出: undefined(上层作用域)

案发现场还原:

  • 为什么 obj.c 是 40? 文本解释得极其精辟:“单独的 {} 不会形成新的作用域”。当你写下 var obj = { ... } 时,你正在全局环境里定义一个对象。这个对象大括号里面的 this 毫无遮拦,直接暴露在全局风暴中,指向了 window。所以 this.a 就是外面的 window.a (20),20 + 20 = 40。
  • 为什么 obj.fn() 是 10? 这是一个标准的隐式绑定。因为你是通过 obj. 调用的 fn,所以 fn 内部的 this瞬间指向了 obj,拿到了 obj 内部的 a: 10

第三层:两大叛徒 —— 箭头函数与定时器

叛徒 1:箭头函数 这是 ES6 为了拯救 this 混乱而派来的杀手锏。它的脾气极其古怪:它天生没有自己的 this 无论谁调用它,用 call/bind 怎么逼迫它,它统统不理。它只认死理:我出生(定义)在哪个普通函数的肚子里,我就白嫖那个普通函数的 this。如果外面没有普通函数,那我就指向 window(这就是文本里说的“捕获其所在上下文的 this 固定不变”),也就是指向上层作用域。

叛徒 2:定时器 setTimeout / setInterval(系统暗箱操作) 定时器里的回调函数,它不是你调用的,是系统引擎在倒计时结束后偷偷摸摸帮你调用的。 既然是系统调的(野生散修),所以普通函数放在定时器里,this 会瞬间丢失,直接指向 window

⚔️ 终极对决(箭头函数拯救定时器):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var hero = {
name: "大侠",
// 错误示范:用普通函数
delaySay: function() {
setTimeout(function() {
// 系统调用的,this 变成了 window,找不到大侠了!
console.log("普通函数:" + this.name);
}, 1000);
},
// 正确示范:用箭头函数
arrowSay: function() {
setTimeout(() => {
// 箭头函数没有自己的 this,它向外抬头一看,
// 父级是 arrowSay 这个普通函数,而 arrowSay 是被 hero 调用的!
// 于是它成功拿到了 hero 的 this!
console.log("箭头函数:" + this.name);
}, 1000);
}
}

hero.delaySay(); // 输出:普通函数:undefined
hero.arrowSay(); // 输出:箭头函数:大侠

字符串

JavaScript 字符串 (String) 方法速查表

方法名 功能简述
🔍 查找与匹配 (找目标)
indexOf(str) 找 str 第一次出现的位置索引。找不到返回 -1
lastIndexOf(str) 找 str 最后一次出现的位置索引。找不到返回 -1
search(regex) 配合正则找位置,返回第一个匹配结果的索引。
match(regex) 配合正则找内容,把匹配到的字符串打包成数组返回。
✂️ 截取与分割 (切片子)
slice(start, end) 截取从 start 到 end(不含)的子串,支持负数从后往前数。
substring(start, end) 截取从 start 到 end(不含)的子串,不支持负数,但会自动调换大小参数。
substr(start, length) 从 start 开始,往后截取 length 个长度的字符(已被逐渐废弃)。
split(sep, limit) 碎尸万段:用给定的分隔符把字符串切开,变成一个数组
🛠️ 替换与格式化 (大改造)
replace(old, new) 替换目标字符串(配合正则和回调函数,是字符串最强的方法)。
toLowerCase() 将所有字母转换为小写
toUpperCase() 将所有字母转换为大写
trim() 去除字符串开头和结尾的所有空白字符(空格、换行等)。
concat(str1, str2...) 拼接多个字符串(实战中多直接用 + 或 `${a}${b}` 替代)。
🔬 字符与编码解析 (看本质)
charAt(index) 拿出指定位置长什么样的字符
charCodeAt(index) 拿出指定位置字符的 Unicode 编码数字(普通字符适用)。
codePointAt(index) ES6 新增:拿出指定位置字符的完整编码(专治 Emoji 和生僻字乱码)。
String.fromCharCode(num) 逆向操作:把 Unicode 数字变成字符
String.fromCodePoint(num) ES6 新增:逆向操作:把完整编码数字变成字符
⚙️ 基础通用方法
toString() 强制转换为字符串类型(比如把数字转成字符串)。
valueOf() 返回字符串最原始的值。

DOM和事件

事件流

我们把页面上的嵌套标签想象成一个**“俄罗斯套娃”**(比如最外层是 document,中间是 div,最里面是 button 按键)。
当你用鼠标点击最里面的 button 时,究竟是谁先感受到这次点击?这就是事件流要解决的问题。

“事件流的三大阶段”

在远古时期,两大浏览器厂商因为“点击事件的传递方向”打了一架:

  • 网景(Netscape)的【捕获流】: 认为应该从外向里传。大老板(document)先知道,最后才传给打工人(button)。
  • 微软(IE)的【冒泡流】: 认为应该从里向外传。打工人(button)挨了打,一层层向上级哭诉,最后大老板才知道。

最后,W3C 官方出来当和事佬:“别吵了,全缝在一起!” 于是,现代标准的 DOM 事件流被硬生生切成了三个阶段:

  1. 捕获阶段 (Capturing): 事件像一颗陨石,从 window 往下砸,穿过 documentbodydiv,一路寻找被点击的目标。此阶段主要是“探路”,默认不处理事件。
  2. 目标阶段 (Target): 陨石砸中真正的目标节点(button),触发绑定在它身上的事件。
  3. 冒泡阶段 (Bubbling): 事件像水里的泡泡一样,从 button 开始,一层层向上浮回 document 和 window我们平时写的点击事件,默认都是在这个阶段触发的。

三大事件模型

模型名称 绑定招式 (代码) 核心特点与致命缺陷
1. 原始模型



(DOM0 级)
btn.onclick = fun; 特点: 简单粗暴,绑定极快。

缺陷: 是个“单人间”。如果你绑定了第二次 onclick,就会把第一次的无情覆盖掉。且只支持冒泡。清除方式很简单:btn.onclick = null;
2. 标准模型



(DOM2 级)
btn.addEventListener



('click', fun, false);
特点: 现代前端的终极标准(大平层)。同一个事件可以绑定无数个函数,绝对不冲突!

绝招: 第三个参数 useCapture 是布尔值。传 false(默认)代表在冒泡阶段执行;传 true 代表在捕获阶段执行。

清除: 必须用 removeEventListener,而且参数必须完全一致。
3. IE 祖传模型 btn.attachEvent
('onclick', fun);
特点: 老 IE 浏览器的顽固设定(事件名必须加 on)。只支持冒泡,没有捕获! 现已基本被时代淘汰。

事件委托 (Event Delegation)

既然有了冒泡,那冒泡有什么用呢? 答案就是前端性能优化的神技:事件委托!

把一个元素响应事件(click、keydown……)的函数委托到另一个元素,在冒泡阶段完成

  • 节省内存,减少dom操作
  • 不需要给子节点注销事件
  • 动态绑定事件
    适合事件委托的事件有:click,mousedown,mouseup,keydown,keyup,keypress

实战案发现场: 假设你有一个购物车列表(ul),里面有 1000 个商品(li),每个商品都有一个“删除”按钮。

  • 没有冒泡的笨办法: 你必须用一个大循环,给这 1000 个 li 挨个绑定 onclick。极度浪费内存,而且新添加的商品还没绑定上!
  • 利用冒泡的“事件委托”: 你只需要给最外层的父元素 ul 绑定一个事件。当用户点击里面的任何一个 li时,点击事件会像泡泡一样往上冒,最终被 ul 捕获到。你只要在 ul 的事件里判断一下“刚才冒泡上来的目标是谁(e.target)”,就可以统一处理了!
    实战代码:
1
2
3
4
5
<ul id="parent-list">
<li>员工 1 号</li>
<li>员工 2 号</li>
<li>员工 3 号</li>
</ul>
1
2
3
4
5
6
7
8
9
10
11
12
const parentList = document.getElementById('parent-list');

// 我们只给爸爸这 1 个人绑定事件!省下了 9999 次绑定的内存!
parentList.addEventListener('click', (e) => {
// 爸爸接到警报后,用 e.target 揪出到底是谁触发了泡泡
const targetWorker = e.target;

// 严谨一点:确认冒泡上来的是员工 (li),而不是爸爸自己身上的空白处
if (targetWorker.tagName.toLowerCase() === 'li') {
console.log('发现被点击的员工:' + targetWorker.innerText);
}
});

**🚨 ** 并不是所有事件都有冒泡阶段!特意点名了 focus (获取焦点) 和 blur (失去焦点) 事件。它们是原地爆炸的,绝对不会向上冒泡。所以,你无法对这两个事件使用“事件委托”!可以通过 event.bubbles 属性可以判断该事件是否可以冒泡

事件 是否冒泡
click 可以
dbclick 可以
keydown 可以
keyup 可以
mousedown 可以
mousemove 可以
mouseout 可以
mouseover 可以
mouseup 可以
scroll 可以

概括来说,鼠标事件,和键盘事件,以及点击事件是支持冒泡的

事件 是否冒泡
blur 不可以
focus 不可以
resize 不可以
about 不可以
mouseenter 不可以
mouseleave 不可以
load 不可以
unload 不可以

而聚焦和失焦事件,加载事件,ui事件、鼠标移入移出事件是不支持的。

阻止冒泡

  • 阻止冒泡行为:非 IE 浏览器 stopPropagation(),IE 浏览器 window. event. cancelBubble = true
  • 阻止默认行为:非 IE 浏览器 preventDefault(),IE 浏览器 window. event. returnValue = false
1
2
3
4
5
6
7
8
child.addEventListener('click', (e) => { 
console.log('儿子被触发了!');

// 释放终极拦截技能:刺破泡泡!
e.stopPropagation();
// 阻止默认行为,例如a标签的跳转
e.preventDefault();
}, false);

DOM 节点与类数组集合速查表

概念名称 核心本质 (血缘关系) 里面装了什么?(内容) 动态 (Live) 还是静态? 常见的获取绝招 (来源) ☠️ 避坑指南与实战兵法
Node

(节点)
老祖宗 (基类)

DOM 树的基础单位。
网页上的一切

包含元素、文本、空格、注释等。
- - 它是底层的抽象概念,你平时操作的具体标签其实都是继承自它的子类。
Element

(元素)
Node 的子类

(类型代号:1)
纯正的 HTML 标签

如 <div><span><body>
- - 只有 Element 才有 idclass 等属性。它是实战中最常打交道的对象。
Text

(文本节点)
Node 的子类

(类型代号:3)
纯文字、换行符、空格 - - 隐形刺客!DOM 标签之间的回车和空格全算作文本节点,遍历时极容易误伤。
NodeList

(节点列表)
伪数组

(有 length,但无数组方法)
什么都装

包含各种类型的 Node (Element, Text, 注释全都有)。
视情况而定:

1. childNodes 是动态的。

2. querySelectorAll静态的。
1. document.querySelectorAll()

2. Node.childNodes
1. 严禁使用 for...in 遍历(会报错)。

2. 自带 forEach 遍历方法。

3. 建议用 Array.from() 转成真数组再操作。
HTMLCollection

(元素集合)
伪数组

(有 length,但无数组方法)
只装 Element

极其纯净,没有空格和换行符干扰。
永远是动态的!(Live)

DOM 树变了,它瞬间跟着变。
1. getElementsByTagName()

2. getElementsByClassName()

3. Element.children
1. 致命死循环:若一边循环它一边添加 DOM,会导致无限循环!

2. 独有绝招:可用 namedItem(id/name)抓取。

3. 修改 DOM 前必须转为真数组副本。

事件对象 e

当事件触发时,浏览器会传递一个事件对象 e。理解这两个属性,是掌握“事件委托”的基石:

  • e.target(肇事者): 真正触发事件的最底层的那个元素。你鼠标到底点在了谁身上,target 就是谁。
  • e.currentTarget(当前处理者): 真正绑定了监听器的那个元素。事件在冒泡或捕获途径它时,它正在处理这个事件。

页面生命周期

事件名称 触发时机与页面状态 资源等待机制 (JS / CSS) 常见应用场景
DOMContentLoaded DOM 树构建完成。

此时 HTML 已全部解析完毕,但外部资源(图片、纯 CSS 等)可能还没下载完。
1. 必须等待同步 JS 执行。

2. 不等待 async 异步 JS 和动态创建的 JS。

3. 间接等待排在同步 JS 前面的 CSS(因为 JS 可能要读取样式)。
查找 DOM 节点、初始化前端框架(如 Vue/React 挂载)、绑定事件监听器。
load

(window.onload)
页面所有资源彻底就绪。

不仅 DOM 树建好了,所有的图片、外部样式表、iframe 等都已下载并应用完毕。
等待所有一切。 没有任何特例,是最晚触发的事件。 需要获取图片的精准尺寸、需要等待所有外部依赖就绪后才执行的繁重计算。
beforeunload 用户试图离开页面前。

(点击刷新、关闭标签页、输入新网址等动作触发)。
不涉及资源等待。 弹窗拦截:“您有未保存的更改,确定要离开吗?”(浏览器通常会要求用户确认)。
unload 用户正在离开页面时。

页面即将被彻底卸载。
不涉及资源等待。 进行极少量的清理工作,或者无延迟的数据上报(如发送用户停留时长的统计数据)。
  1. 浏览器遇到 <link>,开始在后台下载 CSS。
  2. 浏览器遇到 <script>,准备执行 JS。
  3. 关键来了: 浏览器非常聪明,它知道 JS 脚本里可能会通过 window.getComputedStyle 之类的 API 去读取元素的样式。如果此时 CSS 还没下载并解析完(CSSOM 没建好),JS 读到的就是错的!
  4. 结果: 浏览器会强制暂停 JS 的执行,直到前面的 CSS 下载并解析完成(生成完 CSSOM)。
  5. 连锁反应: CSS 阻塞了 JS 执行 ➡️ JS 阻塞了 HTML 解析 ➡️ 最终导致 DOMContentLoaded 被延迟触发!
    💡 总结一句口诀: CSS 不会直接阻塞 DOM 解析,但 CSS 会阻塞其后面的 JS 执行,从而间接阻塞 DOM 解析和 DOMContentLoaded

此时 CSSOM 和布局树(Layout Tree)建好了吗?

结论:无法绝对保证,大概率没有。

  • DOM 树: 既然 DOMContentLoaded 触发了,说明 HTML 解析完了,DOM 树是一定已经构建好的
  • CSSOM 树: 除非遇到上面提到的“连环阻塞”情况(CSS 恰好在 JS 前面被强制要求建好),否则浏览器依然在后台默默下载和解析 CSS。所以 CSSOM 不一定构建好了。
  • 布局树(Layout Tree / 渲染树 Render Tree): 布局树的诞生公式是:DOM 树 + CSSOM 树 = 布局树。既然 CSSOM 都不一定准备好,布局树自然也不一定构建完成。此时页面上可能还是一片空白,或者处于没有样式的 FOUC(无样式内容闪烁)状态。

为什么要用 document.readyState

痛点: 如果你的 JS 代码由于某种原因(比如异步加载)执行得很晚,当它尝试去监听 DOMContentLoaded 时,这个事件可能早就已经触发过了。错过了就是错过了,你的回调函数将永远不会执行。

解法: 使用 document.readyState 属性。它就像是文档当前状态的“进度条指示器”。你可以在任何时候去检查它,从而决定是立即执行代码,还是继续等待事件。

readyState属性值 代表含义 对应的生命周期阶段
loading 加载中: HTML 文档正在被浏览器下载和解析。 页面刚开始渲染。
interactive 可交互: 文档已被全部读取完毕,DOM 树构建完成。 恰好在 DOMContentLoaded 事件触发前夕进入此状态。
complete 完全加载: 不仅文档读完了,所有外部资源(图片、CSS等)也都加载完毕了。 恰好在 load 事件触发前夕进入此状态。
1
2
3
4
5
6
7
8
9
10
11
function work() {
// 执行你的 DOM 操作
}

if (document.readyState == 'loading') {
// 如果还在加载中,就老老实实等事件触发
document.addEventListener('DOMContentLoaded', work);
} else {
// 如果已经是 interactive 或 complete,说明事件早就触发过了,直接执行!
work();
}

异步

为什么需要异步

  • 同步: 代码排队执行,容易堵车(阻塞)。
  • 异步: 遇到耗时任务(如网络请求 ajax、定时器 setTimeout、事件监听 addEventListener),主线程不干等,而是把它扔进**“任务队列”**。等主线程忙完了,再去队列里拿结果。这样保证了页面丝滑不卡顿。
  • 痛点演进: 为了拿到异步的结果,早期我们用回调函数(Callback),但这容易导致嵌套过深的“回调地狱”。为了解决这个问题,Promise 诞生了。

new Promise 到底在干嘛?

  1. 立刻同步执行的“车间”: 你说得对!new Promise((resolve, reject) => { ... }) 大括号里面的代码是立刻、同步执行的。它其实就是一个“封装车间”。当你 new 的那一瞬间,车间就开始运作了。
  2. 专门封装耗时任务: 没错!在这个同步车间里,我们通常会丢进去一个异步耗时任务(比如 setTimeout或网络请求)。
  3. 状态更新器(resolve 和 reject): 理解得非常透彻!这两个函数就是 JS 引擎塞进来的“状态遥控器”。耗时任务执行完后,必须调用它们中的一个,来把这个外部 Promise 对象的状态从 Pending(进行中)强行扭转为 Fulfilled(已成功) 或 Rejected(已失败)。状态一旦改变,终生不可逆。

Promise.resolve().then() 是什么鬼?

这里我们要纠正一个小概念,并解答你的疑问:

  1. Promise.resolve() 返回的不是“空” Promise: 它返回的是一个**状态已经变成 Fulfilled(成功)**的全新 Promise 对象。 如果你写 Promise.resolve('苹果'),它就包裹着结果“苹果”。如果你像代码里那样什么都不传 Promise.resolve(),它包裹的值就是 undefined。它相当于走后门,跳过耗时任务,直接造出一个“成功状态”的 Promise。

  2. .then() 返回的也是 Promise 吗? 百分之百是的! 每次你调用 .then(),JS 引擎都会默默地帮你新建并返回一个全新的 Promise 对象。 这就是为什么文本里强调:then 实现链式操作减低代码复杂度”。因为返回的是新 Promise,所以你可以无限 .then().then() 接龙下去。

Promise 的核心特性与方法总结

结合文本,我们把 Promise 的特性和三大并发方法浓缩一下:

1. 错误的“冒泡”性质

在长长的 .then().then().then() 链条中,任何一个环节出了错(变为 Rejected 状态),错误都会像水泡一样一直往后冒,直到遇到第一个 .catch() 被捕获。这极大地简化了错误处理。

(注:文本中有一段奇妙的代码演示了如果在 .then(null, err => ...) 里捕获了错误,且没有抛出新错误,后续的链条会重新回到成功的 Fulfilled 状态继续走 .then,这也印证了 then 每次都返回新 Promise 的机制。)

2. 三大并发处理利器 (Promise API)

方法名 传入参数 触发条件及表现 核心应用场景
Promise.all Promise 数组 全员成功才成功。只要有 1 个失败,立刻整体变 Rejected 并抛出错误。 需要等待多个接口数据全部回来,再一起渲染页面。
Promise.allSettled Promise 数组 只看完成率,不管对错。等所有人跑完,打包返回所有状态(纠正文本笔误:无论成功还是失败)。 批量上传 10 张图片,最后统计几张成功、几张失败。
Promise.race Promise 数组 赛马机制。谁第一个完成(无论成功还是失败),就直接采用谁的结果。 给网络请求设置超时机制(请求和 setTimeout 赛跑)。

Promise的几个状态:

  1. Pending(进行中): 你拿着小票等餐的时候,汉堡还没做好。这个时候你可以去旁边找个座位坐下、玩玩手机(异步执行,不阻塞你做其他事情)。
  2. Settled中的Fulfilled(已成功): 厨房把汉堡做好了,屏幕上打出了你的号码(调用 resolve)。你开心拿到了汉堡(触发 .then)。
  3. Settled中的Rejected(已失败): 厨房突然发现牛肉饼用完了,做不了了,通知你来退款(调用 reject)。你只能接受这个坏消息(触发 .catch)。

3. then 方法

当你写下 promise.then(callback) 时,发生了两件事:

  1. .then() 这个方法的调用:它是同步的。 浏览器执行到这一行时,会立刻把你的 callback(回调函数)拿过来,放进一个专门的“小本本”上记着。这个过程不等待,瞬间完成。
  2. callback(回调函数)的执行:它是微任务。 即使前面的 Promise 已经成功了,你的 callback 也不会立刻跑。它会被丢进 微任务队列 (Microtask Queue),等待当前所有的同步代码执行完后,才轮到它。
  3. then 和 resolve 都返回的是 promise 对象,then里面的返回结果都会自动包装成Promise.resolve(“?”),不返回里面的?就是undefined

💡 结论: .then() 就像是去银行排号。“取号”这个动作是飞快的(同步),但“叫号办业务”(回调执行)必须等柜台空闲(同步代码跑完),而且它是高贵的“微任务”,排在 setTimeout(宏任务)前面。

Iterator(迭代器)

以前,我们遍历数组用 for(let i=0),遍历对象用 for...in,规则各不相同。Iterator 的出现,就是为了给所有数据结构统一制定一个“发号取餐”的标准。

  • next() 方法: 相当于你按了一次发号机的按钮。
  • 返回值 { value, done } 机器吐出的小票。
    • value:你取到的号码(或者数据的值)。
    • done:机器里还有没有号。false 代表还有,true 代表号发完了(迭代结束)。

实际例子: JavaScript 原生给数组(Array)、字符串(String)、Set 等内置了这台“发号机”(也就是 Symbol.iterator 属性)。

1
2
3
4
5
6
7
8
let arr = ['苹果', '香蕉'];
// 1. 找到这台发号机
let iter = arr[Symbol.iterator]();

// 2. 开始按按钮取号
console.log(iter.next()); // { value: '苹果', done: false }
console.log(iter.next()); // { value: '香蕉', done: false }
console.log(iter.next()); // { value: undefined, done: true } (没东西了!)

💡 核心点: 你平时用的 for...of 循环,底层就是全自动地在帮你疯狂调用这台机器的 next() 方法,直到 done 变成 true 为止。

Generator(生成器):自带“暂停键”

既然 Iterator 这么好用,那我们怎么自己造一台“发号机”呢?以前手动写 next() 和维护内部指针非常痛苦。于是 ES6 推出了 Generator

Generator 就是一个用来批量生产 Iterator(发号机)的特殊工厂。 它的超能力是:可以暂停代码执行!

你可以把普通的函数看作是一部不能快进也不能暂停的电影,一旦开始执行,必须一口气播完(执行到一个 return)。 而 Generator 则是一个支持存档和暂停的电子游戏,由 yield 关键字来充当“存档点”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 1. 声明时加个星号 *
function* playVideoGame() {
console.log("游戏开始!");
yield '第一关通过'; // 遇到 yield,立刻暂停,并把后面的值交出去

console.log("继续打第二关...");
yield '第二关通过';

return '游戏通关,大结局!'; // 彻底结束
}

// 2. 调用它,注意:此时游戏并没有开始!它只是返还给你一个游戏机控制器(Iterator)
let controller = playVideoGame();

// 3. 使用 next() 按下播放键
console.log(controller.next());
// 打印:游戏开始!
// 返回:{ value: '第一关通过', done: false } -> 然后游戏画面静止(暂停)!

console.log(controller.next());
// 打印:继续打第二关...
// 返回:{ value: '第二关通过', done: false } -> 再次暂停!

console.log(controller.next());
// 返回:{ value: '游戏通关,大结局!', done: true } -> 游戏结束!

yield vs return 的直观对比

特性 yield(Generator 专属) return(普通函数通用)
核心行为 暂停并记住位置。 下次调用 next() 时,从这里继续往下走。 彻底终止执行。 直接退出函数,不再回头。
出现次数 一个函数里可以写无数个 yield 一个函数里一旦执行到 return,后面全废弃,只能生效一次
返回值表现 输出多个值组成的一个序列。 只能输出一个最终的单一值。
根据文本,使用 Generator 有两个绝对不能犯的错误:
  1. 箭头函数不能加星号: const gen = () => {} 是不合法的,Generator 必须用老式的 function* 语法。

  2. yield 不能跨越函数边界: 你不能在一个普通的内层回调函数里使用 yield

    1
    2
    3
    4
    5
    6
    function* errorGen(arr) {
    arr.forEach(function(item) {
    // ❌ 报错!这里的 function 是个普通函数,不带星号,里面绝对不能出现 yield
    yield item;
    });
    }

async/await

async/await 就像是结合了 Generator 的“暂停能力”和 Promise 的“链式规范”生出的完美结晶。

1. Generator 的进化版

我们刚才讲过,Generator (function*) 最大的问题是:你必须手动、疯狂地调用 iterator.next(),它才会一步步往下走。

  • async 对标 *号: 标志着这是一个异步函数。
  • await 对标 yield 标志着代码在这里“暂停”,等右边的结果。
  • 巨大的改进(内置执行器): 你再也不用写 next() 了!V8 引擎在底层帮你写好了一个“自动播放器”。遇到 await 它就暂停,等后面的 Promise 成功了,它会自动帮你按 next() 继续往下走。

2. Promise 的终结者(消灭了 .then 链条)

虽然 Promise 解决了“回调地狱”,但满屏的 .then(res => ...) 依然有层级嵌套,而且变量共享很麻烦(上一个 .then 里的变量,下一个 .then 拿不到)。

  • 同步的观感: await 直接把 Promise 包装盒里的“值”给你剥出来。你可以像写同步代码一样,用等号 let res = await fetch(...) 直接拿到结果。
  • 隐式的 Promise 返回: 就像文本里写的,任何被 async 修饰的函数,无论你 return 什么,它都会自动被包装成一个 Promise 对象返回出去(这和 .then() 的潜规则一模一样)。

3. await 的本质:微任务的终极插队

这句话极其关键!当你写下 await xxx 时:

  1. xxx 会立刻执行。
  2. await 下面的所有代码,会被整体打包,扔进微任务队列(Microtask Queue)中。
  3. 然后,当前这个 async 函数直接被“冻结”,把主线程的执行权交还给外面的同步代码。
  4. 等外面的同步代码跑完,且 xxx 的结果回来了,引擎才会把微任务队列里的这坨代码拿出来继续跑。

为什么定时器不可靠?

文本的后半部分揭示了前端开发中一个非常经典的坑:JS 的 setTimeout 和 setInterval 并不是绝对准时的!

1. 为什么时间不准?(被单线程拖累)

因为 JavaScript 是单线程的(同一时间只能干一件事)。 当你写下 setTimeout(fn, 1000) 时,意思是:“请在 1000 毫秒后,把 fn 这个任务扔进任务队列排队。

注意,是去排队,而不是立刻执行!如果此时主线程正在执行一个超级耗时的 for 循环(比如卡了 3 秒),那么你的 fn 即使早就到时间了,也必须乖乖在队列里等那 3 秒结束。所以,实际延迟时间必然 ≥ 你设置的时间

2. setInterval 的致命陷阱:“背靠背”无间隔执行

这是文本中提到的一个极其隐蔽的 Bug。 假设你设置了一个 setInterval(fn, 100)(每 100ms 执行一次),但是你的 fn 逻辑极其复杂,每次执行需要消耗 300ms

  • 灾难发生了: 当第 1 次 fn 还在艰难执行(耗时 300ms)时,100ms 和 200ms 的节点到了,浏览器会死板地往任务队列里塞入第 2 个和第 3 个 fn
  • 等第 1 次终于跑完,主线程一空闲,一看队列里已经挤满了排好队的 fn,于是立刻连续执行它们,中间根本没有你期望的 100ms 间隔!这就叫“背靠背”执行。

异步进化的四部曲

时代 方案 优点 致命缺点
远古 Callback (回调) 解决了异步非阻塞问题 回调地狱(嵌套深),难以捕获错误
古典 Promise 链式调用解耦,统一错误捕获机制 一大堆 .then,变量作用域割裂
过渡 Generator 可以暂停代码,用同步方式写异步 需要手动调用 next(),过于反人类
现代 Async / Await 终极形态。 结合了 Promise 和 Generator 的所有优点,代码极度清爽 容易让人忘记底层依然是 Promise 和微任务机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 这三个逻辑效果上一致

function demo1 () {
return new Promise((resolve) => {
console.log(1)
resolve(1)
})
}

function demo2 () {
console.log(1) // 万一报错不能把reject暴露出去
return Promise.resolve(1)
}

async function demo3 () {
console.log(1)
return 1
}

// 解构方法:await / then

内存管理GC

内存分配与基础 GC 算法

  • 数据存在哪?以及浏览器是如何找出没用的数据并清理掉的?

一、 数据的存放地:栈(Stack) vs 堆(Heap)

JavaScript 引擎(如 V8)在存放数据时,会通过逃逸分析(判断变量是否会被外部引用)将数据分别存放到两个地方:

存储位置 特点 回收机制(极其重要)
栈内存 (Stack) 存储基本数据类型(如数字、布尔值)和执行上下文。 全自动清理。 只要函数执行结束,指针(ESP)往下移动,栈里的局部变量就会被立刻销毁,不需要垃圾回收器操心。
堆内存 (Heap) 存储复杂的引用数据类型(如对象、数组)。空间大。 依赖 GC 回收。 因为不知道这些对象还在被谁引用,只有当外界对它的“所有引用”都断开时,才会被垃圾回收器(GC)盯上并清理。

二、 垃圾回收(GC)的通用三部曲

无论是什么牛逼的算法,本质上都在做这三件事:

  1. 找垃圾(标记): 区分出哪些是还在用的“活动对象”,哪些是没人要的“非活动对象”。
  2. 扔垃圾(清除): 把非活动对象占用的内存腾出来。
  3. 理房间(整理): (部分算法有)把零散的空闲内存拼到一起,解决“内存碎片”问题。

三、 两种基础的垃圾回收算法PK

文本中提到了两种最经典的算法,它们是现代 GC 机制的垫脚石:

1. 引用计数 (Reference Counting) —— 已经被淘汰的早期方案

  • 原理: 给每个对象发一个计数器。如果有变量指向它,计数 +1;如果变量不指向它了,计数 -1。当计数变为 0 时,直接当垃圾清理掉。
  • 致命缺点: 无法解决循环引用!如果对象 A 和对象 B 互相引用对方,哪怕外界根本不用它们了,它俩的计数器永远是 1,永远无法被回收,导致严重的内存泄漏。

2. 标记-清除 (Mark-Sweep) —— 现代 GC 的基石 为了解决循环引用,现在浏览器基本都采用这种策略:把“不再使用的对象”重新定义为**“从根部(Root)无法到达的对象”**。

  • 原理: * 标记阶段: 垃圾回收器从“根部”(比如全局的 window 对象)出发,顺藤摸瓜往下找。能顺着引用链找到的,打上“存活”标记(比如标记为 1)。
    • 清除阶段: 遍历整个内存,那些没有被打上标记的(也就是和根部断联的孤岛对象,哪怕它们内部循环引用),全部当垃圾清理掉。
  • 缺点: 会产生内存碎片。垃圾被清理后,留下来的空闲内存块是东一块西一块的(不连续)。如果这时突然来了一个特别大的对象,可能找不到一块完整的大空间来存放,导致分配速度变慢。

填平“内存碎片”的两种进阶算法

为了解决碎片化导致的大对象分配慢的问题,诞生了下面两种各有利弊的策略:

1. 复制算法 (Copying) —— “土豪”的折半方案

  • 核心逻辑: 直接把内存空间劈成相等的两半,一半叫 from,一半叫 to。平时只在 from 里存数据。当 from 满了,就把里面还活着的(有用的)对象,紧凑地排列复制到 to 空间。复制完后,直接清空整个 from,然后把 from 和 to 的名字互换。
  • 优点: 简单粗暴,绝对不会产生碎片(因为复制过去的时候是紧挨着排的),吞吐量极高。
  • 致命缺点: 太浪费了!你明明有 10G 内存,却只能当 5G 戴着镣铐跳舞(可用内存直接减半)。而且如果活着的对象很多,复制起来非常耗时。

2. 标记-整理 (Mark-Compact) —— “推土机”方案

  • 核心逻辑: 结合了标记清除和复制算法的优点。第一步还是“标记”活对象;第二步是“整理”,它就像一台推土机,把所有还活着的对象都往内存的一端推,让它们紧紧挨在一起。最后,直接把边界以外的废弃内存全部清理掉。
  • 优点: 既没有内存碎片,也不浪费一半的内存空间
  • 缺点: 极度消耗性能。要在整个堆内存中搜索并移动对象,堆越大,耗时越长,这期间程序会被完全卡住。

识别与预防“内存泄漏”

什么是内存泄漏? 简单来说就是:你向系统借了一块内存,用完后却因为种种原因没有还回去(或者无法被 GC 回收)。这块内存就永远变成了死水,越积越多,最后导致页面卡顿甚至崩溃。

1. 如何在 Chrome 中揪出泄漏?

一个实操技巧:

  1. 打开开发者工具的 Performance(性能) 面板。
  2. 勾选 Memory(内存),点击左上角的录制(Record)按钮。
  3. 在页面上疯狂操作一会儿,然后点击 Stop。
  4. 黄金法则: 看内存走势图。如果在多次垃圾回收(图表上的突然下降)之后,内存的最低点依然一次比一次高(呈现出一种不断向上的阶梯状),那就可以100%断定发生了内存泄漏!

2. 日常开发中的 5 大“泄漏元凶”

这些是你写代码时必须要避开的雷区:

  • 意外的全局变量: 在函数里忘记写 let/const 声明变量(例如直接写 name = 'Jack'),它会变成 window.name 永远驻留在全局,页面不关它不死。
  • 被遗忘的定时器: 设置了 setInterval,但是页面销毁时忘记调用 clearInterval。定时器内部引用的变量将永远无法释放。
  • 未移除的事件监听: 给 DOM 绑了 addEventListener,但在删除 DOM 前没有 removeEventListener
  • 脱离文档的 DOM 引用: 你用 JS 变量保存了一个 div 节点(const myDiv = document.getElementById('box'))。后来你把这个节点从 HTML 里删了,但你的 JS 变量还指着它,导致这个 DOM 节点变成了“游魂”,无法被回收。
  • 不当的闭包: 闭包内部引用了外部函数的巨大对象,导致这个巨大对象即使没用了,也因为闭包的牵连而无法释放。

V8 引擎的“分代式”垃圾回收机制

前面讲的算法各有优缺点,如果统一用一种算法处理所有对象,效率会极低。因为内存里的对象就像人类社会的居民,寿命长短差异巨大。
Google 的 V8 引擎极其聪明地引入了**“分代式垃圾回收(Generational GC)”**,把内存分成了两个截然不同的“社区”。

一、 为什么要“分代”?

  • 新生代(新、小、短命): 大部分在代码里创建的变量(比如函数里的局部变量)都是“朝生夕死”的,用完立刻就没用了。对付它们,需要高频、快速的清理。
  • 老生代(大、老、长寿): 比如全局变量、闭包里的变量、巨大的缓存对象。它们命很长,如果也跟着高频检查,纯属浪费时间。对付它们,需要低频、彻底的清理。

于是,V8 把堆内存劈成了两块:新生代区域 和 老生代区域,并派了两个不同的“保洁团队”(主、副垃圾回收器)分别打理。

二、 副垃圾回收器:打理“新生代”(Scavenge 算法)

新生代区域非常小(通常只有 1~8M),专门存放刚被 new 出来的小对象。它使用的就是我们上一批次提到的**“复制算法”(在这里具体叫 Scavenge 算法)**。

  1. 运作方式: 将这块小内存一分为二,一半叫 from-space(使用区),一半叫 to-space(空闲区)。新对象全扔进 from
  2. 回收时刻: 当 from 快满了,触发 GC。把 from 里还活着的对象,全部复制排好列到 to 里面(顺便消灭了内存碎片)。
  3. 角色反转: 清空 from,然后把 from 和 to 的名字对调。

💡 晋升机制(对象如何变成“老油条”?): 新生代空间太小了,如果有些对象一直不死,很快就会把这里塞满。所以 V8 规定了两种情况,对象会被直接晋升(移动)到老生代

  1. 熬过了一次 GC: 如果一个对象在新生代里经历过一次 Scavenge 复制依然活着,说明它命挺硬,送去老生代。
  2. to-space 占用超 25%: 在复制活对象到 to 空间时,如果发现 to 空间已经被占用了超过 25%,就会把这个对象直接送到老生代。(为什么是 25%?因为等下反转后,to 会变成新的 from 用来装新对象,如果初始占用太大,一下子又满了,会导致疯狂触发 GC。)

三、 主垃圾回收器:打理“老生代”(标记-清除 + 标记-整理)

老生代区域空间极大,存的都是大对象和长寿对象。这里绝对不能用复制算法(太浪费那一半的空间,且大对象复制起来极其耗时)。

  1. 首选常规武器:标记-清除 (Mark-Sweep)
    • 从根节点遍历,打标记。没被打标记的直接清理。
    • 因为老生代对象存活率高,死亡的只是一小部分,所以“清除”效率很高。
  2. 迫不得已的终极武器:标记-整理 (Mark-Compact)
    • “标记-清除”用久了,老生代里会产生大量的内存碎片
    • 当突然来了一个大对象,发现老生代里虽然总剩余空间够,但没有一块“连续”的空间能放下它时,就会触发“标记-整理”。像推土机一样,把活对象全推到一端,然后清理掉边界外的碎片。

四、 终极优化:V8 的“增量标记”(解决页面卡顿)

文本最后提到了一个前端面试极其高频的考点:全停顿(Stop-The-World)与增量标记。

痛点: JS 是单线程的!意味着执行 JS 代码执行垃圾回收必须在同一条跑道上。当老生代进行庞大的垃圾回收时,JS 代码必须完全暂停(这就叫全停顿)。如果 GC 耗时 500ms,用户的页面就会死死卡住 500ms(动画卡顿、点击没反应),体验极差。

V8 的解法:增量标记(Incremental Marking) 为了不让用户察觉到卡顿,V8 把一次超长的“标记”过程,切碎成了无数个微小的子标记任务

  • 这就好比打扫一个巨大的别墅,你不是一次性花一整天打扫完(期间啥也干不了),而是扫10分钟,休息一下去回个微信(执行 JS 逻辑),再扫10分钟,再看个剧…
  • 交替执行: 让 JS 应用逻辑和垃圾回收的微小任务交替运行。虽然打扫的总时间可能变长了,但用户的页面再也不会出现明显的长时间卡顿了!

其它

UTF-8、UTF-16 和 Unicode

名称 本质定位 特点 / 机制 日常应用场景
Unicode 字符集规则 全球字符统一身份证。 理论标准,统筹万物。
UTF-8 字符编码实现 可变长(1~4字节),英文省空间。 互联网代码文件、网页的主流编码。
UTF-16 字符编码实现 定长为主(通常2字节)。 JS 引擎在内存中存储字符串的默认格式。
Base64 数据编码方式 把二进制转成 64 种可见字符。 网页内嵌小图片(Data URL)、邮件附件传输。

中文是多少长度?

1. 前端 JavaScript 的视角(按“字”算)

正如文本所说,JavaScript 内存中字符串默认使用的是 UTF-16 编码(上一批次我们刚聊过!)。在 JS 眼里,不管是英文字母 a,还是汉字 ,绝大多数情况下都占 1 个编码单元。 所以,"你好呀大笨蛋".length 会毫不犹豫地输出 6。它数的是“字符个数”。

2. 后端/数据库的视角(按“字节 Byte”算)

数据库在存储时,关心的是这串字符到底占了硬盘上多少个“字节(Byte)”。这就是文本中提到的验证长度不一致的原因。

⚠️ 在现代开发中,绝大多数数据库(如 MySQL)都默认使用 UTF-8 编码。 在 UTF-8 中,一个普通的汉字通常要占用 3 个字节(生僻字或 Emoji 甚至占 4 个字节)。

input和object实现双向绑定(Vue2响应式原理)

Object.defineProperty()直接在一个对象上定义一个新的属性,或者修改一个已经存在的属性

Object.defineProperty(obj, props, desc), 其中:

  • obj: 需要定义属性的当前对象
  • props: 当前准备定义的属性名
  • desc: 对定义属性的描述
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!DOCTYPE html>
<html>
<head> </head>
<body>
<input id="input" placeholder="Please input..." />
<p id="display"></p>
</body>
</html>
<script>
let person = {};
// 修复 1:必须声明一个局部中介变量,用来真实存储被拦截的数据
let _name = "";

let ipt = document.querySelector("#input");
let display = document.querySelector("#display");

// 拦截数据
Object.defineProperty(person, "name", {
get: () => {
return _name;
},
set: (newV) => {
_name = newV;
// 修复 2:input 元素必须使用 .value 来更新
ipt.value = _name;
// 同步更新 p 标签显示
display.innerText = _name;
},
});

// 监听视图层变化,驱动数据变化
ipt.addEventListener("input", (e) => {
// 这里一赋值,就会瞬间触发上面的 set 函数
person.name = e.target.value;
});
</script>

JSON.stringify()

数据类型 / 场景 转换结果(对象属性中) 转换结果(数组元素中) 底层原因
undefinedFunctionSymbol 👻 直接被忽略(剔除) 🔄 变成 null JSON 标准不支持这些 JS 特有类型。数组中变成 null 是为了占位,防止数组长度和索引乱掉。
Symbol 作为键名(Key) 👻 直接被忽略 (无法作为数组元素) JSON 的键只能是字符串。
NaNInfinity 🔄 变成 null 🔄 变成 null JSON 标准里没有无穷大和非数字的概念。
MapSet 等复杂实例 📦 变成空对象 {} 📦 变成空对象 {} stringify 只能序列化普通的可枚举属性,Map 和 Set 的数据不是存在普通属性里的。
数据类型 / 场景 转换结果 底层原因
包含 toJSON() 的对象 👑 执行该方法,并序列化其返回值 toJSON 拥有最高优先级,它就是专门用来“自定义序列化”的后门。
Date 实例 📅 变成 ISO 标准格式的时间字符串 借了特权的光。因为 Date 对象的原型上天生自带一个 toJSON() 方法。
基本类型的包装对象

(如 new String('a'))
🪞 被打回原形(提取原始值) 剥离 JS 包装外壳,还原成纯 JSON 支持的字符串、数字或布尔值。
例如:
1
2
3
4
5
6
7
8
let obj = {      
x: 1,
y: 2,
toJSON: function () {
return 'a string create by toJSON'
}
}
console.log(JSON.stringify(obj)); //'a string create by toJSON'

Date的实例返回一个字符串实现toJSON()方法(和date.toISOString()——使用 ISO 标准返回 Date 对象的字符串格式相同)

遇到循环抛出TypeError(循环对象值)异常

1
2
3
const obj = {  a: 'aa' };  
obj.subObj = obj;
console.log(JSON.stringify(obj)); //VM357:5 Uncaught TypeError: Converting circular structure to JSON

encodeURI&encodeURIComponent

对比维度 encodeURI (列车长) encodeURIComponent (安检员)
最佳适用场景 编码 一整个完整的网址

(例如:处理用户直接粘贴的一长串网址)
编码 网址中的某个局部参数值

(例如:处理用户在搜索框里输入的内容)
不转码的保留字符



(核心区别)
; / ? : @ & = + $ #

(它认识这些是网址的“连接件”,所以绝对不碰)
统统转码!

(它极其严格,上面的符号全都会被转成 %XX)
共同不转码的字符 字母、数字 以及 - _ . ! ~ * ' ( ) 字母、数字 以及 - _ . ! ~ * ' ( )
实战栗子



(处理 a.com?q=C++ & Java)
a.com?q=C++%20&%20Java

❌ 危险: 放过了 &,会导致后端解析参数错乱。
对参数值处理:C%2B%2B%20%26%20Java

✅ 安全: & 被强行转成了 %26,后端能精准还原。
对应的解码配套 decodeURI() decodeURIComponent()
  • 实战一般手写http骨架,然后用户参数用encodeURIComponent编码

移动端点击事件延迟

移动端点击有 300ms 的延迟,因为移动端有双击缩放操作,浏览器在 click 之后要等待 300ms(JS捕获click事件的回调处理),看用户有没有下一次点击,判断这次操作是不是双击

有三种办法解决这个问题:

  1. meta 标签禁用网页的缩放
    <meta name="viewport" content="width=device-width user-scalable= 'no'">

  2. 更改默认视口宽度
    <meta name="viewport" content="width=device-width">
    如果能识别网站是响应式的网站,那么移动端浏览器就可以自动禁掉默认的双击缩放行为并去掉300ms的点击延迟

  3. 调用 js 库,比如 FastClick
    click 延时问题可能引起点击穿透,如在一个元素上注册了 touchStart 的监听事件,这个事件会将这个元素隐藏掉,发现当这个元素隐藏后,触发了这个元素下的一个元素的点击事件

移动端滚动穿透和溢出

滚动穿透

若页面超过一屏高度出现滚动条时,fixed定位的弹窗遮罩层上下滑动,下面的内容也会一起滑动——滚动穿透

1、默认情况,平移(滚动)和缩放手势由浏览器专门处理,但可通过 CSS 特性 touch-action 改变触摸手势的行为
2、
Step 1、监听弹窗最外层元素(popup)的 touchmove 事件并阻止默认行为来禁用所有滚动(包括弹窗内部的滚动元素)
Step 2、释放弹窗内的滚动元素,允许其滚动:同样监听 touchmove 事件,但是阻止该滚动元素的冒泡行为(stopPropagation),使得在滚动的时候最外层元素(popup)无法接收到 touchmove 事件

滚动溢出

弹窗内也含有滚动元素,在滚动元素滚到底部或顶部时,再往下或往上滚动,也会触发页面的滚动,这种现象称之为滚动链(scroll chaining), 也称为滚动溢出(overscroll)
借用 event.preventDefault 的能力,当组件滚动到底部或顶部时,通过调用 event.preventDefault 阻止所有滚动,从而页面滚动也不会触发了,而在滚动之间则不做处理

在如今的现代浏览器中,为了解决“滚动穿透”和“滚动溢出”,我们通常不需要再写那么一大长串恶心的 JS 计算逻辑了,CSS 已经推出了专门的属性来降维打击它们

  • 解决滚动穿透: 弹窗出现时,直接给 <body> 加上 overflow: hidden;,简单粗暴。(遮照默认就是加载body上的)
  • 解决滚动溢出(链): 给弹窗内部的滚动区域加上 CSS 新属性 overscroll-behavior: contain;。只要加了这一句,哪怕划到底部死命拖,滚动条也绝对不会把背景给拽下来!

ES6

ES6面试问题

Set 集合

Set 和数组非常像,但它有一个绝对不可跨越的底线:里面的每一个值都必须是唯一的

1. 核心特性

  • 天然去重: 你往里面 add(5) 两次,它里面也只有一个 5。这也是前端最常用的“数组去重”绝招:[...new Set([1,1,2,2])]
  • 判断对象的“唯一性”看内存地址: 文本里提到了一个经典陷阱:
    1
    2
    3
    mySet.add({a: 1}); 
    mySet.add({a: 1});
    // Set 里面会有两个 {a: 1}!
    为什么? 因为对于引用类型(对象),Set 只认内存地址。这两个长得一样的对象,在内存里存放在不同的地方,所以 Set 认为它们是不同的东西。

2. 数学运算神器

  • 并集 (Union): 两个人所有的朋友合在一起(自动去重)。
  • 交集 (Intersect): 找你们俩的共同好友。
  • 差集 (Difference): 找你有,但对方没有的好友。
1
2
3
4
5
6
7
let a = new Set([1, 2, 3]);  
let b = new Set([4, 3, 2]); // 并集
let union = new Set([...a, ...b]); // Set {1, 2, 3, 4}
// 交集
let intersect = new Set([...a].filter(x => b.has(x))); // set {2, 3}
// (a 相对于 b 的)差集
let difference = new Set([...a].filter(x => !b.has(x))); // Set {1}

关于遍历的方法,有如下:

  • keys():返回键名的遍历器
  • values():返回键值的遍历器
  • entries():返回键值对的遍历器
  • forEach():使用回调函数遍历每个成员

Map 字典

JS 已经有普通对象 {}(Object)来存键值对了,为什么还要发明 Map

核心区别:键(Key)的自由度

  • 普通 Object: 它的键只能是字符串(或者 Symbol)。如果你用数字 1 做键,它底层也会偷偷把你转成字符串 "1"
  • Map 字典: 它的键可以是任何数据类型!你可以用一个数组、一个函数、甚至一个 DOM 节点来当作键!

实战场景: 假设你想记录页面上三个 div 元素各自被点击的次数。以前你没法拿 DOM 元素当键,现在你可以直接 map.set(divElement, clickCount)

proxy

Vue3 与 Vue2 底层机制差异:精准狙击 vs 绝对结界

它们最大的差异,在于浏览器引擎(V8)赋予它们的拦截权限维度不同

1. Object.defineProperty:给“已存在”的房门配保安
  • 底层机制: 它操作的是对象的属性描述符(Property Descriptor)。你必须明确告诉 JS 引擎:“去看着 obj 上的 name 属性”。
  • 致命缺陷的根源: 因为它拦截的是具体的属性(Key),所以如果这个对象原本只有 a 和 b 两个门(属性),Vue2 在初始化时就会给这两个门配上保安(getter/setter)。
  • 为什么新增属性不生效? 如果你后来用代码强行在墙上砸出一个新门 obj.c = 3,这扇新门上是没有保安的!JS 引擎根本不知道这回事,自然无法通知页面更新。
2. Proxy:把整栋房子罩进“玻璃结界”
  • 底层机制: 它是 ES6 在 C++ 引擎层面 提供的一种全新机制。它操作的不再是属性,而是生成一个包裹着原对象的“虚拟壳子”
  • 降维打击的根源: 外界想要访问房子,无论是走旧门、新开一扇门(新增属性)、还是把门拆了(删除属性),都必须先穿过最外层的这层“玻璃结界”
  • 为什么完爆 Vue2? 因为引擎把“进入结界”这个动作本身交给了你拦截。不管你操作什么具体的 Key,Proxy 只关心“有人碰了这个对象”,从而实现 360 度无死角的监听。

Vue3 响应式核心:“依赖收集”

明白怎么“拦截”后,我们来解决最核心的问题:当你修改 user.name = "李四" 时,Vue 怎么知道该去更新组件 A 还是组件 B?

Vue3 并不是靠魔法,而是靠在底层维护了一个极其庞大、极其严密的**“三层账本”(数据结构)**。

这个账本的结构是:WeakMap -> Map -> Set

1. 第一层:WeakMap (找对象)
  • 记录什么: 整个应用里所有的响应式对象。
  • 逻辑: 它的键(Key)是目标对象本身(比如 user 对象),值(Value)是下一层账本。
  • 画外音:当 user.name 改变时,Vue 先在这个大账本里找到 user 这页。
2. 第二层:Map (找属性)
  • 记录什么: 这个对象里的具体属性。
  • 逻辑: 它的键是对象的属性名(比如 "name"),值又是下一层账本。
  • 画外音:在 user 这一页里,Vue 接着找到了 "name" 这个具体的条目。
3. 第三层:Set (找组件/函数)
  • 记录什么: 到底有哪些组件(或副作用函数 Effect)用到了这个属性。
  • 逻辑: 里面存放着一个个具体的更新函数Set 天然去重,保证一个组件不会因为一个属性变化而被重复更新。
  • 画外音:Vue 在 "name" 这个条目下,看到了“组件 A 的渲染函数”,于是立刻执行它,页面就更新了!

响应式全流程

我们用一个真实场景,看看 Proxy 和这个“三层账本”是怎么打配合的:

  1. 初始渲染(挖坑): 组件 A 开始渲染,它的代码里写了 <h1>{{ user.name }}</h1>
  2. 触发 Get(记账): 组件读取了 user.name。因为 user 是被 Proxy 包裹的,立刻触发了 get 拦截器。
  3. 收集依赖(入库): Proxy 的 get 拦截器迅速翻开账本,在 WeakMap(user) -> Map("name") 对应的 Set里,把组件 A 的更新函数塞了进去。
  4. 修改数据(拉响警报): 用户点击按钮,执行了 user.name = "李四"
  5. 触发 Set(查账): 再次撞上 Proxy 的结界,触发 set 拦截器。
  6. 派发更新(干活): Proxy 的 set 拦截器迅速顺着账本 WeakMap(user) -> Map("name") 找到那个 Set,发现里面存着组件 A 的更新函数,直接遍历执行它。组件 A 重新运行,屏幕上的名字变成了“李四”。

这就是 Vue3 响应式的全部底层真相:用 Proxy 在门外放哨,用 WeakMap 在门内记账。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// ==========================================
// 第一部分:Vue 的“终极大账本”和“当前干活的工人”
// ==========================================

// 1. 终极大账本:用来存所有响应式对象和它们的依赖关系
const targetMap = new WeakMap();

// 2. 当前正在执行的函数(比如当前正在渲染的组件)
let activeEffect = null;


// ==========================================
// 第二部分:两大核心动作(记账 与 查账通知)
// ==========================================

// 动作 A:收集依赖(记账)
function track(target, key) {
if (!activeEffect) return; // 如果没人读取,就不需要记账

// 找第一层账本:WeakMap (找对象)
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}

// 找第二层账本:Map (找属性)
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}

// 找第三层账本:Set (把当前正在干活的 activeEffect 登记在册!)
dep.add(activeEffect);
}

// 动作 B:派发更新(查账并通知)
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // 账本里没这个对象,不管它

const dep = depsMap.get(key);
if (dep) {
// 核心:遍历 Set 里的所有工人(函数),让他们重新干活!
dep.forEach(effectFn => effectFn());
}
}


// ==========================================
// 第三部分:给数据套上 Proxy 结界
// ==========================================
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
// 1. 先把真实数据拿出来
const result = Reflect.get(obj, key, receiver);

// 2. 核心魔法:有人读取了,赶紧记账!
track(obj, key);

return result;
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
// 1. 真实地修改数据
const result = Reflect.set(obj, key, value, receiver);

// 2. 核心魔法:值变了,赶紧查账并拉响警报!
if (oldValue !== value) {
trigger(obj, key);
}
return result;
}
});
}


// ==========================================
// 第四部分:模拟 Vue 的组件渲染 (Effect)
// ==========================================
function effect(fn) {
activeEffect = fn; // 1. 认领身份:我就是现在正在干活的工人
fn(); // 2. 马上干活(这会触发里面变量的 get 拦截器去记账!)
activeEffect = null; // 3. 干完活,卸下身份
}

模块化

为什么需要模块化

在没有模块化的“远古时代”,所有 JS 资源都靠 <script> 标签从上到下死板地加载。这带来了一场灾难,而模块化的出现就是为了当救世主:

  • 消灭全局污染与命名冲突: 以前大家都在全局作用域写代码,你写个 var name = 'A',我写个 var name = 'B',全乱套了。模块化让每个文件都有了自己独立的私有作用域
  • 按需加载与依赖管理: 彻底理清了“谁依赖谁”,避免了大型多人协作项目中代码互相牵连、牵一发而动全身的噩梦。
  • 高内聚与高复用: 把复杂逻辑封装成黑盒,只暴露出特定接口给外部使用。

CommonJS (CJS) —— 服务端与“死值拷贝”

1. 核心运行机制
  • 同步加载,跑到了才执行: 因为服务器读本地文件极快,所以它是同步阻塞的。代码只有运行到 require() 这一行,才会去加载对应模块。
  • 单例与缓存: 模块只要被执行过一次,它的结果就会被死死缓存下来。同一个文件里你 require 一万次,拿到的也是第一次缓存的那个结果。
2. 解答你的核心疑问:导出值到底能不能变?

在 CommonJS 中,导出(module.exports)的本质是浅拷贝

  • 如果导出的是基础类型(比如数字 count = 1): 导出的那一瞬间,就给外部发了一个“克隆体”。原模块怎么变,外部拿到的永远是旧的克隆体;外部强行改了克隆体,原模块也不受影响。这就是所谓的“断联”。如果导出的是对象,不会断联
1
2
3
4
5
6
7
8
9
10
11
// 📦 a.js (导出方)
let count = 1;

function add() {
count++; // 这里改变的是 a.js 内部的 count 变量
console.log("a.js 内部的 count 变成了:", count);
}

// 导出那一瞬间,count 的值是 1。
// CommonJS 相当于把 1 这个死数字装进了导出的对象里。
module.exports = { count, add };
1
2
3
4
5
6
7
8
9
10
11
12
// 🏠 b.js (导入方)
let a = require('./a.js');

console.log(a.count); // 输出: 1

// 调用 a.js 的内部方法,让它内部的 count + 1
a.add(); // 打印: "a.js 内部的 count 变成了: 2"

// 🚨 见证断联的时刻:
console.log(a.count);
// 依然输出: 1 !!!
// 因为 a.count 只是当初那个 "1" 的克隆体,原模块里的变量怎么变,克隆体根本不知道。

如果导出的 count 是包在一个对象里的,情况就完全反转了!因为在 JavaScript 里,对象的赋值是共享内存地址的。

1
2
3
4
5
6
7
8
9
// 📦 a.js (导出方)
let state = { count: 1 };

function add() {
state.count++;
}

// 导出的不再是一个死数字,而是一个指向 state 对象的“钥匙(内存地址)”
module.exports = { state, add };
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 🏠 b.js (导入方)
let a = require('./a.js');

console.log(a.state.count); // 输出: 1

a.add(); // 触发原模块修改对象内部的值

// 🚨 见证连通的时刻:
console.log(a.state.count);
// 输出: 2 !!!
// 因为大家手里拿的都是同一把钥匙,看的是同一个对象。

// 同理,如果你在这里强行改变它:
a.state.count = 999;
// 那么原模块 a.js 以及其他所有 require 了 a.js 的文件,再去读的时候,都会变成 999!
  • 💡 黑客解法(如何在 CJS 里拿到最新值): 如果你非要在老项目中拿到原模块最新的基础类型值,可以导出包着它的对象,或者导出一个函数(Getter)

    1
    2
    3
    4
    5
    6
    // 📦 导出方
    let count = 1;
    module.exports = {
    // 每次调用都能去源头拿最新值
    getCount: () => count
    };
3. 基本语法
  • 导出: module.exports = { ... } 或 exports.xxx = value
  • 导入: const xxx = require('路径或包名')

ES6 Modules (ESM) —— 现代前端

这是浏览器和现代 Node.js 共用的终极方案。它的设计思想是“尽量静态化”,一切为了极致的性能优化(如 Tree-Shaking)。

1. 核心运行机制
  • 编译时加载(静态编译): 你的代码还没开始跑,JS 引擎在编译阶段就已经把模块之间的依赖关系梳理清楚了。
  • 变量提升: 因为是静态编译,所以 import 语句无论你写在文件的哪里,都会被强制提升到文件最顶部优先执行。
  • 活绑定(Live Binding): 导出的值和导入的值,共享同一块内存地址。就像拉了一根实时连线,原模块里的值一旦改变,引入方拿到的值会瞬间跟着变
  • 导入值是只读常量: 为了防止逻辑混乱,ES6 规定你 import 进来的变量全部是只读的,你绝对不能在当前文件去重新赋值修改它。但是注意,你导入的ref变量为什么能修改value值?因为对象内部的突变是允许的!但是你针对对象重新赋值。同理,如果你导入的是普通的基本类型,那你肯定改不了!ES6 在导入的时候给你做了一个默认const的声明
2. 核心语法与特性
  • 别名导出: 名字冲突了?用 as 换个马甲。

    1
    export { name as myName }; 
  • 单例模式: 无论你 import 多少次同一个文件,它也只会执行一次。

  • 默认导出与命名导出:

    • export default xxx (一个文件只能有一个,引入时不需要 {},名字随便起)。
    • export const xxx (可以有无数个,引入时必须用 {},名字必须严格对应)。
3. 性能杀手锏:动态加载 import()

普通的 import 只能写在最顶层。如果你想在用户点击按钮时再按需异步下载某个沉重的图表模块,就要用 import() 函数。它返回一个 Promise,这正是现代前端框架实现路由懒加载的底层原理。

1
2
3
4
5
const el = document.querySelector('.btn')
el.addEventListener('click', async (e) => {
let content = await import('...??')
console.log(content)
})

总结与对比

  1. 时机不同: CommonJS 是代码运行到那一刻才去加载(运行时);ES6 是代码还没跑就已经把依赖查清楚了(编译时)。
  2. 指针不同: CommonJS 导出的是死拷贝(基础类型彻底断联);ES6 导出的是活绑定(实时指向同一内存地址)。
  3. 环境不同: CommonJS 是同步的,专门为服务端(Node.js)设计;ES6 既支持静态,又支持用 import()异步,通吃浏览器和服务器。

Ajax、Fetch和Axios

Ajax 与 XHR (老祖宗,刀耕火种时代)

Ajax 其实不是一门具体的技术,而是 2005 年提出的一个概念(异步 JavaScript 和 XML)。它的核心目的是:在不刷新整个网页的情况下,偷偷向服务器请求数据,然后只更新网页的一小部分。

在浏览器底层,实现 Ajax 概念的真面目叫 XMLHttpRequest (简称 XHR)

实例体验(痛苦的回忆):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 1. 实例化一个大管家
const xhr = new XMLHttpRequest();
// 2. 告诉大管家去哪里,用什么方式
xhr.open('GET', 'https://api.example.com/data', true);
// 3. 配置请求头
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
// 4. 派大管家出发
xhr.send(null);

// 5. 痛苦的开始:死死盯着大管家的状态
xhr.onreadystatechange = function () {
// 状态码必须是 4(请求完成),且 HTTP 状态是 200(成功)
if (xhr.readyState === 4 && xhr.status === 200) {
console.log(JSON.parse(xhr.responseText)); // 还要手动解析 JSON
}
};
  • 痛点总结: 架构极其老旧,配置繁琐。最致命的是它基于事件回调,如果有多个接口互相依赖(比如先查用户 ID,再查订单),就会陷入恐怖的“回调地狱(Callback Hell)”。现在的现代开发中,绝对不会再有人手写原生 XHR 了

Fetch (浏览器亲儿子,现代原生派)

随着 ES6 Promise 的普及,浏览器官方终于看不下去了,原生内置了一个全新的 API:Fetch

它是真正用来替代 XHR 的底层 API,天生基于 Promise,并且可以直接配合 async/await 使用,代码瞬间清爽。

实例体验(优雅的原生):

1
2
3
4
5
6
7
8
9
10
async function getData() {
try {
// 直接 await,代码像同步一样清晰
const response = await fetch('https://api.example.com/data');
const data = await response.json(); // 自带解析方法
console.log(data);
} catch (error) {
console.log("请求失败", error);
}
}
  • 优势总结: 浏览器原生支持,不需要安装任何第三方包(库);语法优雅。
  • 致命踩坑点(文本提到的缺陷): 1. 不带 Cookie: 默认情况下,Fetch 发送请求时是不带 Cookie 的,必须手动配置 credentials: 'include'。 2. 对错误太宽容: 只要服务器给回应了(哪怕是 404 找不到,或者 500 服务器崩溃),Fetch 的 Promise 都会认为是“成功(Resolved)”,只有断网时才会走 Catch。这极其容易引发 Bug!

Axios (企业级瑞士军刀,绝对的行业标杆)

Axios 是目前 Vue 和 React 生态中,地位最不可动摇的 HTTP 客户端。 本质: 在浏览器端,它底层依然是对老祖宗 XMLHttpRequest 的深度封装;在 Node.js 端,它使用原生的 http 模块。

之所以大家都用它,是因为它把前端日常开发需要的所有痛点,全给解决了。

企业级实战(核心功能大揭秘):

1. 环境自动切换 (BaseURL) 开发时请求测试服,上线时请求正式服。Axios 可以结合 Node 环境变量自动处理前缀:

1
2
3
4
const instance = axios.create({
baseURL: process.env.NODE_ENV === 'development' ? 'http://dev.api.com' : 'http://prod.api.com',
timeout: 5000 // 贴心的超时断开功能(Fetch 默认没有超时机制)
});

2. 自动转换 JSON 使用 Axios,你永远不需要写 JSON.parse() 或者 JSON.stringify(),它底层全自动帮你搞定。

3. 最强杀手锏:拦截器 (Interceptors) 这是 Axios 封神的功能。它就像是在请求发出去之前,和响应回来之后,设立了两个“海关安检站”。

  • 请求拦截器: 比如每个接口都要传 Token,你不需要在每个请求里写一遍,直接在拦截器里统一塞进去:

    1
    2
    3
    4
    5
    instance.interceptors.request.use(config => {
    // 在发送请求前,给所有请求头统一挂上 Token
    config.headers['Authorization'] = localStorage.getItem('token');
    return config;
    });
  • 响应拦截器: 比如后端返回状态码 401(Token 过期),你可以在拦截器里直接拦截,并强制跳转到登录页,完全不需要在每个业务代码里写判断逻辑。

对比总结

特性 / 痛点 Ajax (原生 XHR) Fetch Axios
底层实现 XMLHttpRequest 浏览器底层新原生 API 浏览器:XHR

Node端:http
异步方案 回调函数 (Callback) Promise Promise
JSON 自动转换 ❌ 需要手动解析 ❌ 需要调 .json() ✅ 全自动处理
超时设置 ❌ 极其麻烦 ❌ 默认不支持,需配合 AbortController ✅ 配置项 timeout 一键搞定
请求/响应拦截 ❌ 无 ❌ 无(需自己封) ✅ 极其强大且好用
防御 CSRF 攻击 ❌ 无 ❌ 无 ✅ 自带防御机制
当前地位 已进博物馆 适合轻量级、不装包的原生页面 企业级重型项目的首选标配

重温前端三件套(三):JavaScript
http://example.com/2026/03/21/重温前端三件套-三-:JavaScript/
作者
Lingkai Shi
发布于
2026年3月21日
许可协议