重温前端三件套(三):JavaScript
数据类型和运算符
有哪些数据类型
原始类型
undefined
null,打印
typeof()是object,因为在第一版JS中,变量的值被设计保存在一个32位内存单元中。该单元包含一个1或3位的类型标标志,和实际数据值。类型标志存储在单元的最后。包括以下2几种情况- 000:object,数据为对象的引用
- 1:int,数据为 31 位的有符号整型
- 010:double,数据为一个双精度浮点数的引用
- 100:string,数据为一个字符串的引用
- 110:boolean,数据为布尔类型的值
特殊情况:
- undefined 负的 2 的 30 次方(超出当时整型取值范围的一个数)
- null 空指针
**null的存储单元最后三位(即 标志位)和object一样,所以被误判为Object
与undefined的区别: - null聚焦业务层面,通常是开发者自己设置的
- undefined聚焦系统层面,系统默认的
1 | |
- boolean
- number
- string
- bigint,ES6新增
- symbol,ES6新增,提供“绝对唯一”且自带“隐身特权”的标识符,
- 作用如下:
- 防撞名(唯一性):只要调用
Symbol(),生成的永远是一个全世界独一无二的值。这从根本上解决了对象属性名冲突、全局常量/事件名冲突的问题。 - 防污染(不可枚举性):用
Symbol作为对象的属性名时,它会自动在常规的遍历(for...in、Object.keys())和JSON.stringify()中“隐身”,非常适合用来存储不该被暴露或传输的内部状态。
- 防撞名(唯一性):只要调用
- 经典例子:
- 前端数据的“隐形防伪标签”(状态隔离)
- 场景:前端需要在后端传来的商品数据上加一个
isSelected(是否选中)的状态来做 UI 交互,但提交回后端时,后端严格要求不能包含多余字段。 - 解法:使用
Symbol('isSelected')作为键名存入对象。 - 原理:因为
JSON.stringify()天生会忽略所有的Symbol属性,所以前端不管怎么改这个标记,转成 JSON 发给后端时它都会自动消失,无需写代码手动去清理数据。
- 场景:前端需要在后端传来的商品数据上加一个
- React 框架底层的“镭射防伪印章”(防御 XSS 攻击)
- 场景:黑客会在评论区输入恶意的 JSON 字符串,企图伪造出 React 组件结构,让网页执行恶意脚本。
- 解法:React 在自己真正生成的每一个合法元素上,都悄悄加了一个特殊的标记
$$typeof: Symbol.for('react.element')。 - 原理:由于网络传输主要靠 JSON,而JSON 格式根本不支持描述
Symbol,因此黑客无论怎么伪造,传过来的假数据里都不可能有合法的Symbol。React 只要校验这个印章不存在,就直接拦截报错,实现完美防御。
- 大型项目事件总线的“物理隔离”(防事件串台)
- 场景:在几百人的大项目中,导航栏团队和商品列表团队不约而同地把刷新事件都命名为字符串
"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 提供了两种截然不同的态度:- 严格派:
Number()—— “一粒沙子都揉不进”
它是一个强制类型转换。它的原则是:只要字符串里包含了任何一个无法转换为数字的字符(哪怕只是结尾带了个字母),整个转换直接宣告失败,返回NaN(Not a Number)。 - 宽容派:
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)。 这一页里写了什么?
- 局部变量:你在这个函数里
let a = 10;,这个10就直接写在这个栈帧里。 - 指针(引用):你在这个函数里
let obj = { ... };,这个堆内存的门牌号(地址)也写在这个栈帧里。
为什么栈的速度那么快? 当这个函数执行完毕后,JS 引擎直接把这个“栈帧”从栈顶弹出(Pop)。滋溜一下,随着栈帧的销毁,写在这一页上的所有局部变量、堆内存指针,瞬间灰飞烟灭! 不需要任何复杂的计算,这就是栈内存极其高效、用完即焚的原因。
JS 和 C++ 内存布局的本质区别
C++ 内存模型非常经典:代码段 -> 数据段/BSS(全局/静态变量) -> 堆(向上生长) -> 栈(向下生长)。
JS 有什么区别? 最大的区别在于:**JavaScript 是一门跑在虚拟机(比如 V8 引擎)里的语言。而 V8 引擎本身,就是一个巨大的 C++ 程序!**也就是说,JS 的所谓“内存”,其实是 V8 这个 C++ 程序向操作系统申请来的一块堆内存,然后 V8 自己在内部又把它进行了重新划分。
站在 V8 引擎的视角,JS 的内存布局(大仓库)被划分得比 C++ 更加精细:
- 栈区(Call Stack):和 C++ 类似,存基本数据类型、指针、控制函数调用。
- 堆区(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 | |
手写实现一个:
1 | |
三 、对象原型链判断: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):
null、undefined、number、string(注:修正了笔记中的 stringify)、boolean、symbol。(按值传递) - 1 种引用类型 (Reference):
object(包含数组、函数等,按地址传递)。
对象转基本类型 (ToPrimitive)
当对象必须要变成基本类型时,它有两把武器:valueOf() 和 toString()。
- 转字符串(明确场景): 优先拔出
toString(),不行再用valueOf()。- 例子:
String(obj),如果toString返回的不是基本类型,再看valueOf,都不是就报错。
- 例子:
- 转数字(默认场景): 优先拔出
valueOf(),不行再用toString()。- 💡 必考现实: 大多数普通对象的
valueOf()返回的还是自己(对象),所以最终往往被迫走向toString(),变成字符串后再往下走。 - 特例:
Object.create(null)创建的纯净对象连这两把武器都没有,一碰转换直接报错。
- 💡 必考现实: 大多数普通对象的
显式强制类型转换
这是你通过代码明确告诉 JS 引擎要转换成什么:
| 目标类型 | 转换规则与表现 |
|---|---|
转字符串 String() |
调用对象的 toString()。 |
转布尔值 Boolean() |
死记“假值七兄弟”: null, undefined, false, +0, -0, NaN, ""。除了这7个,其他全是 true(包括空对象、空数组)。 |
转数字 Number() |
严苛的数学频道: 必须长得像数字。 • Number('') ➡️ 0• Number(null)/Number(false) ➡️ 0• Number(true) ➡️ 1• Number(undefined) ➡️ NaN • Number([]) ➡️ [].toString() 变 "" ➡️ 0 |
隐式强制类型转换
当算式两边类型不一致时,JS 助理怕报错,会偷偷帮你统一类型:
- 偷偷转字符串(遇到
+且有字符串):+号变成胶水。只要有一边是字符串,另一边不管是啥(哪怕是对象),都会被逼着转成字符串,然后拼接在一起。- 例子:
obj + ''➡️ 触发对象转数字逻辑(先 valueOf 后 toString),拿到基本值后再拼接。
- 偷偷转数字(遇到
-、*、/或一元+):- 强行进入数学频道。如果不是纯数字长相,就变成
NaN。 - 例子:
'x' - 0➡️NaN。 - 例子:
1 + + '2'➡️ 后面的+ '2'先被偷偷转成数字2,然后1 + 2 = 3。
- 强行进入数学频道。如果不是纯数字长相,就变成
- 偷偷转布尔值(遇到
if、for、while、? :、\|\|、&&):- 充当保安。非布尔值来了,偷偷用“假值七兄弟”规则查验,不在黑名单里的统统放行(当做
true)。
- 充当保安。非布尔值来了,偷偷用“假值七兄弟”规则查验,不在黑名单里的统统放行(当做
== 和 === 比较
===(严格相等): 铁面无私,类型不同直接false。==(宽松相等): 允许 JS 助理偷偷转换。它的核心法则是:“万物皆向数字看齐,一层一层扒皮”。
== 的扒皮降级规则(优先级):
- 字符串 vs 数字: 字符串脱皮变成数字。
- 布尔值 vs 其他: 布尔值立刻脱皮变成数字(
true=1,false=0)。 - 对象 vs 非对象: 对象调用两把武器,变成基本类型后再比较。
- 特殊保底:
null == undefined为true(它俩穿一条裤子,跟别人都不等)。NaN六亲不认,NaN == NaN也是false。- 两个对象比较,只看是不是同一个内存地址。
1 | |
浮点数求和步骤
- 对阶
- 求和
- 规格化
战前准备:为什么 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 | |
- 注意: 原本最前面的那个隐藏的
1.,被挤到了小数点后面,变成了0.1100...。这就是“尾数最高位空出一位”。
第二步:尾数求和(列竖式做二进制加法)
现在 0.1 和 0.2 的指数都是 -3 了,我们可以把对齐后的尾数加起来了。 为什么会出现 10? 仔细看下面的竖式,逢 2 进 1,后面的小数部分加爆了,一直进位顶到了最前面!
1 | |
- 破案: 左边整数位的
0 + 1,再加上后面小数位一路传过来的进位1,变成了2。二进制没有2,所以写成了10。
第三步:规格化和舍入
当前情况: 算出来的结果是 10.0110...。 触发警报: IEEE 754 的强迫症排版规定,小数点前面必须是 1.,绝对不能是 10.。
行动 1:规格化(挪小数点) 为了合规,把小数点往左挪 1 位,变成 1.0011...。 因为数字整体缩小了,作为补偿,指数必须 +1(从 -3 变成了 -2)。
1 | |
行动 2:舍入(0舍1入补偿) 最末尾的那个 1 被挤出去了,直接丢掉误差太大。计算机执行舍入规则:既然挤出去的是个 1,那就给剩下的尾巴最后一位加个 1 补回来。
1 | |
所以,最终符合所有强迫症规定的尾数是: 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
实战四种解法
知道了底层运算过程如此“千疮百孔”,我们在业务中必须防备:
- 容错拦截法 (
Number.EPSILON): 承认误差,只要差值小于 2−52 的极限极小值,就当它们相等。Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON - 暴力斩断法 (
toFixed(n)): 既然尾巴不准,直接四舍五入砍掉尾巴变字符串。适合展示金额。parseFloat((0.1 + 0.2).toFixed(10)) - 有效数字法 (
toPrecision(n)): 限制整个数字的长度进行四舍五入。parseFloat((0.1 + 0.2).toPrecision(12)) - 浮点数变整数计算: 躲开小数的坑,先把小数乘 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 ➡️ Infinity2. 负数除以 0: -1 / 0➡️ -Infinity3. 数字超过了 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、"" (空字符串)、null、undefined、NaN、false (除了它们,其他所有的值,包括空数组 []、空对象 {}、字符串 "0",统统都是 true!)
核心法则:“保镖”与“找备胎”
1. 逻辑与 && —— “严苛的保镖”(遇假则停)
短路规则: 只要前面是假,直接拦截(返回前面的假值);前面是真,才放行看后面(返回后面的值)。
2. 逻辑或 || —— “找备胎”(遇真则停)
短路规则: 只要前面是真,直接拿来用(返回前面的真值);前面是假(废了),才去找备胎(返回后面的值)。
两个实战
绝招 1:用 || 给变量设置默认兜底值
1 | |
绝招 2:用 && 进行安全的对象属性访问(防报错)
1 | |
总结来说: && 和 || 的本质并不是用来做数学判断的,在实际开发中,它们是被当作**“控制代码执行流程的开关”**来用的。利用它们的短路特性,我们可以写出极其精简且健壮的代码。
数组
数组方法
创建数组
Array.from() 浅拷贝
1 | |
Array.of()
1 | |
sort原理
1 | |
- 返回负数 (< 0):意味着
a应该排在b的前面。 - 返回 0:意味着
a和b相等,位置不变(在某些浏览器实现中)。 - 返回正数 (> 0):意味着
a应该排在b的后面。
copyWithin()
直接修改原数组,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。
- target(必需):从该位置开始替换数据。如果为负值,表示倒数。
- start(必需):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
- end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。-1表示最后一个
- start-end包括start,不包括end
1 | |
find()
find()用于找出第一个符合条件的数组成员
参数是一个回调函数,接受三个参数依次为当前的值、当前的位置和原数组
findIndex()
findIndex返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1
find和findIndex这两个方法都可以接受第二个参数,用来绑定回调函数的this对象。
1 | |
fill()
还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置. 与copyWithin 用法类似
注意,如果填充的类型为对象,则是浅拷贝.
1 | |
对原数组有影响
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 | |
pop() 队尾出列
shift() 队头出列
对原数组无影响
concat()
创建一个副本,返回新构建的数组,以下效果一致:
1 | |
slice()
创建一个包含原有数组中一个或多个元素的新数组
1 | |
reduce
reduce() 方法不会改变原始数组。他的目的是把一个数组,通过某种逻辑,浓缩或转换成另外一个单一的数据结构(哪怕这个结果是一个复杂的对象或新数组)
1 | |
filter
将所有元素进行判断,将满足条件的元素作为一个新的数组返回
some
将所有元素进行判断返回一个布尔值,如果存在元素都满足判断条件,则返回 true,若所有元素都不满足判断条件,则返回 false:
every
将所有元素进行判断返回一个布尔值,如果所有元素都满足判断条件,则返回 true,否则为 false:
求最大值
var a=[1,2,3,4]; Math.max.apply(null, a);
join
数组变字符串
1 | |
flat(),flatMap()
1 | |
数组去重
Set
1 | |
Map (对象去重)
1 | |
数组拍平
- ary = ary.flat(Infinity); //Infinity无穷大
- 递归
ES6新增的数组方法
find
findIndex
flat
flatMap()
**Array.at()**返回对应下标的值
Array.from()
Array.of()
Array.includes()
… 扩展运算符
copyWithin()
fill()
entries() 遍历键值对
keys() 遍历键名
values() 遍历键值
Array.proptype.sort() 的排序稳定性
判断是否存在某个值
- array.indexOf()
- array.find()
- array.findIndex()
- String.prototype.includes()
- 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 | |
遍历对象的方法
🥚 彩蛋一:对象也有了 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 | |
看出猫腻了吗? 字符串在 JS 底层也是个对象,它自带一个 length 属性。
Object.keys()(本分的公证员)去查的时候,length是不可枚举的(藏在暗格里),所以没查出来。- 但是当你用
Object.getOwnPropertyNames()(带搜查令的警察)去查的时候,连带着把底层的length都给生生扒出来了! 这完美印证了咱们上一关的“查户口”理论。
🥚 彩蛋三(重中之重):ES6 对象的“潜规则”排序法
很多人以为对象里的数据是“先写谁,谁就在前面”(像数组一样)。在 ES6 之前确实是无序的,但 ES6 之后,JS 引擎给对象的 Key 制定了极其严格的“三步走”排队规则。
不管你以什么顺序把属性塞进对象,只要你遍历它(不论是用 keys, values, entries 还是 for...in),它都会在底层偷偷按照以下顺序重新排队:
- 第一梯队:数字 Key(自然数 / 整数)
- 只要你的 Key 看起来像是个正整数(比如
"100","2","7"),它们会被最优先揪出来。 - 并且,它们会被强制从小到大(升序)排列!
- (对应你笔记里的结果:原本是 100, 2, 7,输出变成了 “2”, “7”, “100”)
- 只要你的 Key 看起来像是个正整数(比如
- 第二梯队:普通的字符串 Key
- 数字排完了,才轮到字母和普通字符串(比如
"a","d","hello")。 - 它们严格按照你写进去(插入)的先后顺序排队。
- 数字排完了,才轮到字母和普通字符串(比如
- 第三梯队:Symbol Key
- 永远排在最后面。
- 也是按照插入的时间先后顺序排队。
🔥 面试防坑小测试: 如果面试官问你,下面这段代码 Object.keys(obj) 或 Object.values(obj) 会输出什么?
1 | |
你现在就可以自信地秒答:["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 | |
for in 和 for of 的区别
一句话总结:for … in是为遍历对象属性而构建的,遍历的是 index,而 for … of是为了遍历数组的,遍历的是 value
1 | |
- 可迭代的对象:数组、字符串、Set、Map等,普通对象是不能迭代的,也即不能用of
1 | |
构造函数、原型对象、实例
在 ES6(2015年)引入 class 关键字之前,JS 里根本没有类的概念。想要批量创建对象,只能硬生生拿普通的函数来充当“类”。
1. 哪部分是构造函数?
在 JS 中,任何一个普通的函数,只要你用 new 关键字去调用它,它在这一瞬间就变成了“构造函数”。
为了区分普通函数,程序员们约定俗成:首字母大写的函数,就是用来当构造函数的。
1 | |
2. 哪部分是原型对象?
如果所有方法都写在构造函数里(比如 this.say = function(){}),那你每 new 一个实例,内存里就会重新复制一份这个方法,极其浪费内存。
为了解决这个问题,JS 引擎做了一个强行绑定:只要你写了一个函数,系统就会自动在内存里生成一个配套的“空对象”,并把它挂在这个函数的 .prototype 属性上。
这个空对象,就是原型对象(Prototype Object)。
1 | |
总结: 在老 JS 里,由于没有 class 这个壳子把它们包起来,“构造函数”和“原型对象”在物理代码上是割裂的。你写一个 function 搞定属性,再在外面写一堆 .prototype.xxx = ... 搞定方法。
由于前端程序员(特别是从 Java 转过来的)实在受不了上面那种割裂、反人类的写法,官方在 ES6 终于推出了 class 关键字。
3. 什么是 class?
核心真相:JS 里的 class 纯粹是“语法糖(Syntactic Sugar)”! 它底层依然是上面那套“构造函数 + 原型对象”的破机器,只是官方给你套了一个长得像 Java 的漂亮外壳。
看代码对比,这就是它奇怪的原因:
1 | |
4. 为什么 JS 直接把类当构造函数了?
你觉得“把类当构造函数”很奇怪,是因为 JS 的 class 掩盖了一个事实:在 JS 的内存里,根本不存在一个叫 class 的数据类型!
我们可以用代码直接证明这件事:
1 | |
看到了吗?你以为你写了一个名叫 Animal 的类(图纸),实际上你写的是一个名叫 Animal 的构造函数!
JS 引擎在解析 class Animal 时,底层做的事情就是:
- 把
constructor() { ... }里面的代码,变成function Animal() { ... }。 - 把
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 时:
- 先在
obj自身内存里找。 - 找不到?顺着
obj.__proto__指针,去它的原型对象里找。 - 还找不到?因为原型对象也是普通对象,继续顺着 原型对象的
__proto__往上找。 - 终点:一直找到
Object.prototype。如果这里也没有,它的__proto__指向null,查找正式结束,返回undefined。
这条由 __proto__ 串联起来的单向回溯路径,就叫原型链。
看 JS 引擎启动时的真实构建顺序:
- 第一元老诞生:先凭空开辟一块内存,叫
Object.prototype(这就是那只“鸡”)。它是所有普通对象的终极老祖宗,它的__proto__是null。 - 第二元老诞生:基于第一元老,造出了
Function.prototype。因为它是普通对象,所以Function.prototype.__proto__ === Object.prototype。
为什么? Function.__proto__ === Function.prototype
- Yes 的部分(运算层面):按照公式,实例的
__proto__指向构造它的函数的prototype。一切函数都由Function构造。既然Function自身是个函数对象,那它在逻辑上就是自己构造了自己,所以成立。这保证了Function instanceof Function === true。 - No 的部分(物理层面):实际上
Function是底层内置好的,并不是真的执行了一句new Function()把它生出来。这只是一种为了语言自洽而硬绑的底层指针规则。
其他注意事项:
instanceof的真面目:A instanceof B只是在算一笔账:顺着 A 的__proto__链往上爬,能不能撞见B.prototype? 撞见就是 true,没撞见就是 false。- 修改原型的危险性:你可以用
Object.setPrototypeOf()动态改变对象的__proto__,但这会严重破坏 JS 引擎的运行优化,极其消耗性能。 this的指向:当调用原型链上的方法时,函数内部的this永远指向当前调用该方法的实例对象,而不是原型对象。- 无原型的孤儿:
Object.create(null)创建的对象,其__proto__被切断为 null。它没有原型,也不能调用toString等任何自带方法。
1 | |
继承方式
| 继承方式 | 核心招式 (核心代码) | 战利品 (优点) | 致命弱点 (缺点) |
|---|---|---|---|
| 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 | |
ES6的写法:
1 | |
深浅拷贝
浅拷贝只能拷贝一层对象。
深拷贝能解决无限层级对象嵌套问题。
浅拷贝
- Object.assign()
拷贝的是对象属性的引用,而不是对象本身。
1 | |
assign方法可以用于处理数组,不过会把数组视为对象,比如这里会把目标数组视为是属性为0、1、2的对象,所以源数组的0、1属性的值覆盖了目标对象的值。
- concat浅拷贝数组
- slice浅拷贝
- …扩展运算符
- for…in
深拷贝
- JSON.parse(JSON.stringify());
- 无法解决循环利用问题
- 无法拷贝一条特殊对象 RegExp Date Set Map 等
- 忽略undefined、symbol和函数
- 递归实现
- lodash第三方库实现
拷贝特殊对象,使用 Object.prototype.toString.call(obj)鉴别。
freeze属性
对象的“四把隐藏锁”(属性描述符)
在 JavaScript 里,当我们写下 obj.name = "大侠" 时,你以为仅仅是存了一个名字吗?错!引擎在底层其实悄悄为这个 name 属性配备了四个“开关”(官方叫特性/描述符)。
[[Value]](数据锁): * 大白话:这里面装的就是真正的值(比如"大侠")。[[Writable]](修改锁):- 大白话:这个值能不能被重新赋值?如果把它关掉(
false),你再写obj.name = "菜鸟",引擎会直接无视(严格模式下会报错)。
- 大白话:这个值能不能被重新赋值?如果把它关掉(
[[Enumerable]](隐身锁):- 大白话:这个属性能不能在
for...in循环或Object.keys()中露脸?如果关掉(false),它就隐身了,别人遍历对象时根本看不见它,但你直接点名obj.name依然能拿到。
- 大白话:这个属性能不能在
[[Configurable]](终极管理员锁):- 大白话:这是最核心的锁。它决定了你能不能用
delete obj.name删掉它,以及能不能去修改上面那三把锁的状态。一旦这个锁被关死(false),这个属性的规则就彻底焊死了,再也无法被重新配置。
- 大白话:这是最核心的锁。它决定了你能不能用
怎么去拨动这些开关呢? 就是你提到的 Object.defineProperty() 这个超级 API。Vue 2.0 的响应式底层原理,就是靠这个 API 疯狂操纵这些开关来实现的。
Object.freeze() 到底干了什么?
明白了四把锁,我们再来看 Object.freeze()。当你对一个对象释放这个大招时,它其实在底层极其冷酷地打出了一套三连击:
- 第一击:物理封锁(
Object.preventExtensions)- 直接没收该对象的“生育能力”。绝对禁止再往里面添加任何新属性。
- 第二击:锁死数值(
[[Writable]]: false)- 把对象里所有现有属性的“修改锁”全部关掉。绝对禁止修改现有属性的值。
- 第三击:没收管理权(
[[Configurable]]: false)- 把对象里所有现有属性的“终极管理员锁”全部拔掉。禁止删除属性,禁止把数据属性改成访问器属性(getter/setter),也禁止你再用
Object.defineProperty把Writable改回true。
- 把对象里所有现有属性的“终极管理员锁”全部拔掉。禁止删除属性,禁止把数据属性改成访问器属性(getter/setter),也禁止你再用
绝对不能在代码里直接这样写: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 | |
在 JS 引擎真正运行前,它会把代码重新排版成这样:
1 | |
🔥 终极冲突:当变量名和函数名同名时怎么办? 记住:函数声明的优先级永远高于
var变量声明!
函数的百变身份(匿名、回调、高阶)
函数不仅可以独立存在,还可以像普通变量一样传来传去(这就是文本里说的“第一等公民”)。
1. 匿名函数 & 回调函数 (Callback) 没有名字的函数就是匿名函数。当它被当作参数,塞进另一个函数里,等特定时机再执行时,它就成了回调函数。
1 | |
2. 高阶函数 (Higher-Order Function) 如果一个函数接收另一个函数作为参数,或者返回一个函数,那它就是高阶函数。JS 数组自带的 map、filter、reduce 都是典型的高阶函数。
1 | |
瞬间爆发与“按值传递”的陷阱
1. 自执行函数 (IIFE) 有些函数我们只想让它运行一次(比如做一些初始化工作,又不想弄脏全局变量),就可以用括号把它包起来,直接执行:
1 | |
2. 传参陷阱:按值传递 (Pass by Value) 这是文本中非常重要的一段!在 JS 中,所有函数的参数传递都是按值传递。如果是基本类型(数字、字符串),传的是复印件;如果是对象,传的是内存地址(指针)的复印件。
⚔️ 案发现场解析(文本中的代码):
1 | |
提示:如果函数里没有 obj = {} 这一句,直接写 obj.name = 'Greg',外面的 person 就会被改掉!
函数式编程 (Functional Programming) 与纯函数
这是一种非常优雅的编程思想(React 和 Redux 的核心基石)。它要求你把代码写成数学公式一样的体验。
- 只用表达式,不用语句: 也就是每个函数最好都有
return返回值,而不是仅仅在里面做一些操作(比如直接修改全局变量)。 - 纯函数 (Pure Function): 终极要求!只要输入相同的参数,永远返回相同的结果,且绝对不产生副作用(不修改外面的变量)。
实战对比:
1 | |
函数柯里化 (Currying) —— 参数收集术
核心原理: 柯里化就像是分期付款。原本一个函数需要一次性传入 3 个参数才能执行;经过柯里化包装后,你可以先传 1 个参数,它会返回一个新函数记住这个参数;你再传 1 个,它再返回新函数;直到参数凑齐了,它才真正发大招执行。
案发现场解析(破解你文本里的代码): 假设我们有一个需要两个参数的加法函数:function add(a, b) { return a + b; }
如果你用文本里提供的 curry1 把它包装一下,会发生什么?
1 | |
为什么要有这个技术? 主要为了参数复用和延迟执行。比如你有一个校验手机号的函数 check(正则, 字符串),你可以把它柯里化,先传入手机号的正则,生成一个专门校验手机号的新函数 checkPhone,以后在代码里只要调用 checkPhone(字符串) 就可以了,不用每次都传正则。
例如:动态生成配置化函数:
1 | |
函数的 length 属性 —— 虚假的形参个数
核心原理: 大家都以为 函数.length 就是括号里写了几个参数,它的值就是几。大错特错! 文本里揭示了 length 的核心铁律:它只统计必须要传入的参数个数。
三大“不计入”规则现场:
- 正常情况(老实人):
function f(a, b) {}👉length是 2。 - 遇到“默认参数”就罢工(大坑!): 一旦参数有了默认值,
length就不算它了。更狠的是,它不仅不算自己,连排在它后面的所有参数,统统都不算了!1
2
3// age 设了默认值 22,导致后面的 gender 和 aaa 全被连累,不计入 length
function fn5(name = '林三心', age, gender, aaa) {}
console.log(fn5.length) // 输出 0!因为第一个参数就有默认值,直接罢工。 - “剩余参数”不算数:
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 样东西:
- 没有自己的
this: 它的this是“白嫖”外层环境的,一旦绑定,终身不变。(这就解释了文本里说的:call、apply、bind对箭头函数完全无效,根本掰不动它的this)。 - 没有
arguments: 你在它里面用arguments拿到的参数,其实是它外层普通函数的参数。 - 没有
constructor(构造器): 所以绝对不能对它使用new关键字,会直接报错。 - 没有
prototype(原型): 当不了构造函数,自然也就不配拥有原型对象。 - 没有
yield关键字: 不能用作 Generator(生成器)函数。
🚨 对象大括号 {} 的伪装
1 | |
为什么? 因为文本里写了极其关键的一句话:“定义对象的大括号 {} 不是一个单独的执行环境!” 在 JS 引擎眼里,对象仅仅是个值。所以这个箭头函数其实是直接暴露在最外层(全局)的,它的 this 毫不犹豫地指向了全局对象 window,根本没把 obj 放在眼里。
1 | |
构造函数
什么是构造函数?
创建实例对象
new 到底干了什么?
这是这篇文本里最值钱的一段!面试官非常喜欢问:“当你 new 一个构造函数时,底层发生了哪四步?”
想象 new 是一台全自动组装机,当你按下启动键 let p = new Student() 时,它在零点几毫秒内完成了四件事:
- 打地基(创建空对象): 在内存里悄悄凭空造了一个干净的空对象
{}。 - 接天线(链接原型链): 把这个空对象的隐式原型(
__proto__)连接到构造函数的原型(Student.prototype)上。这样实例就能使用sayHi这个公共技能了。 - 装修房子(绑定
this并执行): 引擎把构造函数里的this全部指向这个刚造出来的空对象。然后开始执行函数里的代码(比如this.name = name),给这个空对象塞满属性。 - 交钥匙(隐式返回): 就算你的函数里没写
return,机器也会自动把这个塞满属性的房子(对象)返回给你,赋值给外面的变量p。
构造函数的禁忌 —— 乱写 return
构造函数里千万别瞎写 return!
因为 new 机器在最后一步“交钥匙”时,有一个非常奇葩的判定规则:
情况 A:你返回了一个基本数据类型(如字符串、数字) 机器判定:“这人脑子进水了,忽略它!”
1
2
3
4
5
6function Person(name) {
this.name = name;
return '啦啦啦啦'; // 机器:忽略这堆废话
}
let p1 = new Person("大侠");
console.log(p1.name); // 输出: "大侠" (照常正常工作)情况 B:你返回了一个新的引用类型(如对象、数组) 机器判定:“好家伙,你要造反啊?那
new白干了,听你的!”1
2
3
4
5
6
7function 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 | |
🕒 第一阶段:脚本刚启动(创建大本营)
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):
- 找
weapon:在自己车间(inner的VO)的柜子里找到了"飞镖"。 - 找
agentName:自己柜子没有,顺着作用域链通道往外找,在outer的柜子里找到了"特工007"。 - 找
secret:自己没有,顺着通道在outer的柜子里找到了"外层金库密码"。 - 找
this.boss:当前车间的this绑的是myObj,所以直接去myObj身上拿到了"分公司小老板"。
全部打印完毕。inner 函数执行结束。车间拆除,从执行栈中弹出(出栈)。 桶里最后剩下: 执行栈:[ 全局上下文 ] (直到你关闭浏览器网页,这个全局上下文才会被清空,深桶彻底归零)。
outer 的车间(执行上下文)明明都已经拆除、出栈了,按理说里面的东西应该灰飞烟灭,凭什么 inner 还能找到 agentName?
绝对能找到!
为了解释这个“死而不僵”的灵异事件,我们必须揭开 JS 引擎内存管理的最后一块面纱:调用栈(Call Stack) 与 堆内存(Heap) 的分离。
在 JS 引擎的底层,“临时车间(执行上下文)” 和 “装变量的柜子(变量对象 VO/AO)” 其实是两码事。
- 车间(执行上下文):由执行栈管理。非常无情,函数一执行完,立刻出栈,物理空间被抹除。
- 柜子(变量对象):由堆内存管理。JS 的垃圾回收器(Garbage Collector)负责盯着堆内存。垃圾回收器有一个铁律:只要这个柜子还有人牵着一根线(被引用),就绝对不允许销毁它!
🕵️♂️ 闭包是怎么诞生的?
让我们回到 outer 函数即将出栈的那一瞬间,看看发生了什么暗箱操作:
- 孕育牵绊: 当
inner函数在outer内部被定义(画图纸)的那一刻,JS 引擎给inner绑了一根看不见的脐带(内部属性[[Scope]]),这根脐带死死地连着outer的柜子(里面装了agentName和secret)。 - 成功潜逃:
outer执行到最后一句return inner;时,把inner函数交到了外面的世界,并被全局变量myObj.doMission给接盘了。 - 拆除车间与垃圾回收的博弈:
outer执行完毕,它的执行上下文(车间)确实被无情地弹出执行栈,彻底销毁了。 接着,垃圾回收器推着车过来,准备把outer留下的柜子(变量对象)也扔进焚烧炉。 - 刀下留人(闭包形成): 垃圾回收器正要动手,突然发现不对劲:“等等!外面的全局大佬
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。只要配合大括号{}(比如if、for里面),它们就能瞬间创造出一个**“块级作用域(安全屋)”**。安全屋里的变量,外面绝对进不来也拿不到,彻底解决了变量污染问题。
🆕 补充二:彻底拍死“动态作用域”的幻想(最经典的案发现场)
上一关我说过:“作用域是你写代码时大括号的位置决定的(谁包着谁)”。文本里给出了一个绝佳的实战例子来证明这个“词法作用域(静态作用域)”的铁律!
⚔️ 案发现场解析:
1 | |
- 动态作用域的错觉: 很多人第一眼觉得,
b是在a里面被调用的呀,那b找不到i时,不应该顺手拿a里面的i=2吗? - 词法作用域的铁拳(新补充): 错!文本明确指出:“在写代码阶段作用域就已经确定了”。
b函数是在全局定义的,所以它的作用域链只有[b的柜子 -> 全局的柜子]。它就算死,也是回全局找那个i=1,绝对不会去拿调用者a里面的i=2。 - 一句话总结:找变量,看你“出生”在哪,别看你“打工(被调用)”在哪!
🆕 补充三:隐式全局变量的炸弹
“所有未定义直接赋值的变量自动声明为拥有全局作用域”。
我们在函数里写代码,如果忘了写 var、let 或者 const,直接写 weapon = "飞镖"。JS 引擎不会报错(非严格模式下),而是顺着作用域链找,一直找到全局都没找到,它就会自作主张地在全局 window 大老板身上挂一个 window.weapon = "飞镖"。这就是极其危险的变量泄漏。
🆕 补充四:生命周期与可见性的精准定义
“作用域控制着变量和函数的可见性和生命周期。”
- 全局变量:生命周期和页面等同(页面关了才死)。
- 函数局部变量:生命周期随函数结束而结束销毁(除非!被我们上一关讲的“闭包”给强行保释扣留了!)。
闭包和内存泄漏
闭包 = 函数 + 自由变量
- 什么是自由变量? 既不是你这个函数自己的局部变量,也不是你接收的参数,但你却在代码里用了它。这就叫自由变量。(比如上一个例子里的
agentName和secret,对inner函数来说就是自由变量)。 - 物理存放地大揭秘(堆内存): 闭包的变量存在“堆(Heap)”中。在 JS 里,普通的数字/字符串通常存在“栈(Stack)”里,函数执行完就弹栈销毁了。但 JS 引擎极其聪明,一旦它发现某个变量被内部函数引用了(形成闭包),它就会把这个变量打包,转移到寿命更长的“堆内存”里。这就是为什么车间拆了,柜子还在的底层物理原因!
- 什么是自由变量? 既不是你这个函数自己的局部变量,也不是你接收的参数,但你却在代码里用了它。这就叫自由变量。(比如上一个例子里的
“只有内部函数访问了上层作用域链中的变量对象时,才会形成闭包。”
闭包的应用:
- 制造“私有变量”(数据加密)
- 模拟块级作用域(套了个函数的壳子)
- 防抖 (Debounce)
1 | |
1 | |
1 | |
this
牢记:在普通函数里,this 是个势利眼,谁最后按下了调用的开关,this 就指向谁!它是在执行的那一瞬间才决定的,跟代码写在哪里毫无关系。
第一层:势力划分 —— this 的四大绑定铁律
文本一上来就给出了武力值排行榜:new绑定 > 显式绑定 > 隐式绑定 > 默认绑定。我们倒着往上看:
1. 默认绑定(野生散修,武力最低) 如果一个函数是孤零零地自己运行的,前面没有任何对象点它。
- 规则: 势利眼
this发现没人雇佣它,非严格模式下就认**全局大老板(window)**为主;严格模式下,大老板不管它,它就是undefined。
1 | |
2. 隐式绑定(宗门打手) 函数被挂在一个对象身上,由这个对象发起了调用。
- 规则: 谁点(
.)了它,它就指向谁!
1 | |
3. 显式绑定(精神控制法术,强过隐式) 我们用 call、apply、bind 这三大法术,强行拿枪指着函数的脑袋,按头让它的 this 指向我们指定的对象。
文本里提到了非常核心的一句:fn() 其实是 fn.call() 的简写!
call和apply(立刻打工): 把函数拉过来立刻执行,并强行绑定this。区别只在于传参方式(call是一串逗号隔开,apply是传一个数组)。bind(发个打工证,等通知): 它不会立刻执行!而是复制出一个一模一样的新函数,并且把新函数的this永久焊死在你指定的对象上,等着以后再调用。
1 | |
4. new 绑定(创世神,武力最高) 就像我们在“构造函数”那关讲的,使用 new 关键字时,JS 引擎会凭空造一个全新的空对象,并把函数里的 this 死死绑定在这个新对象上。没有任何力量能扭转 new 里的 this!
第二层:样例
1 | |
案发现场还原:
- 为什么
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 | |
字符串
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 事件流被硬生生切成了三个阶段:
- 捕获阶段 (Capturing): 事件像一颗陨石,从
window往下砸,穿过document、body、div,一路寻找被点击的目标。此阶段主要是“探路”,默认不处理事件。 - 目标阶段 (Target): 陨石砸中真正的目标节点(
button),触发绑定在它身上的事件。 - 冒泡阶段 (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 | |
1 | |
**🚨 ** 并不是所有事件都有冒泡阶段!特意点名了 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 | |
DOM 节点与类数组集合速查表
| 概念名称 | 核心本质 (血缘关系) | 里面装了什么?(内容) | 动态 (Live) 还是静态? | 常见的获取绝招 (来源) | ☠️ 避坑指南与实战兵法 |
|---|---|---|---|---|---|
Node(节点) |
老祖宗 (基类) DOM 树的基础单位。 |
网页上的一切。 包含元素、文本、空格、注释等。 |
- | - | 它是底层的抽象概念,你平时操作的具体标签其实都是继承自它的子类。 |
Element (元素) |
Node 的子类(类型代号:1) |
纯正的 HTML 标签。 如 <div>, <span>, <body>。 |
- | - | 只有 Element 才有 id、class 等属性。它是实战中最常打交道的对象。 |
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 |
用户正在离开页面时。 页面即将被彻底卸载。 |
不涉及资源等待。 | 进行极少量的清理工作,或者无延迟的数据上报(如发送用户停留时长的统计数据)。 |
- 浏览器遇到
<link>,开始在后台下载 CSS。 - 浏览器遇到
<script>,准备执行 JS。 - 关键来了: 浏览器非常聪明,它知道 JS 脚本里可能会通过
window.getComputedStyle之类的 API 去读取元素的样式。如果此时 CSS 还没下载并解析完(CSSOM 没建好),JS 读到的就是错的! - 结果: 浏览器会强制暂停 JS 的执行,直到前面的 CSS 下载并解析完成(生成完 CSSOM)。
- 连锁反应: 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 | |
异步
为什么需要异步
- 同步: 代码排队执行,容易堵车(阻塞)。
- 异步: 遇到耗时任务(如网络请求
ajax、定时器setTimeout、事件监听addEventListener),主线程不干等,而是把它扔进**“任务队列”**。等主线程忙完了,再去队列里拿结果。这样保证了页面丝滑不卡顿。 - 痛点演进: 为了拿到异步的结果,早期我们用回调函数(Callback),但这容易导致嵌套过深的“回调地狱”。为了解决这个问题,Promise 诞生了。
new Promise 到底在干嘛?
- 立刻同步执行的“车间”: 你说得对!
new Promise((resolve, reject) => { ... })大括号里面的代码是立刻、同步执行的。它其实就是一个“封装车间”。当你new的那一瞬间,车间就开始运作了。 - 专门封装耗时任务: 没错!在这个同步车间里,我们通常会丢进去一个异步耗时任务(比如
setTimeout或网络请求)。 - 状态更新器(
resolve和reject): 理解得非常透彻!这两个函数就是 JS 引擎塞进来的“状态遥控器”。耗时任务执行完后,必须调用它们中的一个,来把这个外部 Promise 对象的状态从 Pending(进行中)强行扭转为 Fulfilled(已成功) 或 Rejected(已失败)。状态一旦改变,终生不可逆。
Promise.resolve().then() 是什么鬼?
这里我们要纠正一个小概念,并解答你的疑问:
Promise.resolve()返回的不是“空” Promise: 它返回的是一个**状态已经变成 Fulfilled(成功)**的全新 Promise 对象。 如果你写Promise.resolve('苹果'),它就包裹着结果“苹果”。如果你像代码里那样什么都不传Promise.resolve(),它包裹的值就是undefined。它相当于走后门,跳过耗时任务,直接造出一个“成功状态”的 Promise。.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的几个状态:
- Pending(进行中): 你拿着小票等餐的时候,汉堡还没做好。这个时候你可以去旁边找个座位坐下、玩玩手机(异步执行,不阻塞你做其他事情)。
- Settled中的Fulfilled(已成功): 厨房把汉堡做好了,屏幕上打出了你的号码(调用
resolve)。你开心拿到了汉堡(触发.then)。 - Settled中的Rejected(已失败): 厨房突然发现牛肉饼用完了,做不了了,通知你来退款(调用
reject)。你只能接受这个坏消息(触发.catch)。
3. then 方法
当你写下 promise.then(callback) 时,发生了两件事:
.then()这个方法的调用:它是同步的。 浏览器执行到这一行时,会立刻把你的callback(回调函数)拿过来,放进一个专门的“小本本”上记着。这个过程不等待,瞬间完成。callback(回调函数)的执行:它是微任务。 即使前面的 Promise 已经成功了,你的callback也不会立刻跑。它会被丢进 微任务队列 (Microtask Queue),等待当前所有的同步代码执行完后,才轮到它。- 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 | |
💡 核心点: 你平时用的
for...of循环,底层就是全自动地在帮你疯狂调用这台机器的next()方法,直到done变成true为止。
Generator(生成器):自带“暂停键”
既然 Iterator 这么好用,那我们怎么自己造一台“发号机”呢?以前手动写 next() 和维护内部指针非常痛苦。于是 ES6 推出了 Generator。
Generator 就是一个用来批量生产 Iterator(发号机)的特殊工厂。 它的超能力是:可以暂停代码执行!
你可以把普通的函数看作是一部不能快进也不能暂停的电影,一旦开始执行,必须一口气播完(执行到一个 return)。 而 Generator 则是一个支持存档和暂停的电子游戏,由 yield 关键字来充当“存档点”。
1 | |
yield vs return 的直观对比
| 特性 | yield(Generator 专属) |
return(普通函数通用) |
|---|---|---|
| 核心行为 | 暂停并记住位置。 下次调用 next() 时,从这里继续往下走。 |
彻底终止执行。 直接退出函数,不再回头。 |
| 出现次数 | 一个函数里可以写无数个 yield。 |
一个函数里一旦执行到 return,后面全废弃,只能生效一次。 |
| 返回值表现 | 输出多个值组成的一个序列。 | 只能输出一个最终的单一值。 |
| 根据文本,使用 Generator 有两个绝对不能犯的错误: |
箭头函数不能加星号:
const gen = () => {}是不合法的,Generator 必须用老式的function*语法。yield不能跨越函数边界: 你不能在一个普通的内层回调函数里使用yield。1
2
3
4
5
6function* 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 时:
xxx会立刻执行。await下面的所有代码,会被整体打包,扔进微任务队列(Microtask Queue)中。- 然后,当前这个
async函数直接被“冻结”,把主线程的执行权交还给外面的同步代码。 - 等外面的同步代码跑完,且
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 | |
内存管理GC
内存分配与基础 GC 算法
- 数据存在哪?以及浏览器是如何找出没用的数据并清理掉的?
一、 数据的存放地:栈(Stack) vs 堆(Heap)
JavaScript 引擎(如 V8)在存放数据时,会通过逃逸分析(判断变量是否会被外部引用)将数据分别存放到两个地方:
| 存储位置 | 特点 | 回收机制(极其重要) |
|---|---|---|
| 栈内存 (Stack) | 存储基本数据类型(如数字、布尔值)和执行上下文。 | 全自动清理。 只要函数执行结束,指针(ESP)往下移动,栈里的局部变量就会被立刻销毁,不需要垃圾回收器操心。 |
| 堆内存 (Heap) | 存储复杂的引用数据类型(如对象、数组)。空间大。 | 依赖 GC 回收。 因为不知道这些对象还在被谁引用,只有当外界对它的“所有引用”都断开时,才会被垃圾回收器(GC)盯上并清理。 |
二、 垃圾回收(GC)的通用三部曲
无论是什么牛逼的算法,本质上都在做这三件事:
- 找垃圾(标记): 区分出哪些是还在用的“活动对象”,哪些是没人要的“非活动对象”。
- 扔垃圾(清除): 把非活动对象占用的内存腾出来。
- 理房间(整理): (部分算法有)把零散的空闲内存拼到一起,解决“内存碎片”问题。
三、 两种基础的垃圾回收算法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 中揪出泄漏?
一个实操技巧:
- 打开开发者工具的 Performance(性能) 面板。
- 勾选 Memory(内存),点击左上角的录制(Record)按钮。
- 在页面上疯狂操作一会儿,然后点击 Stop。
- 黄金法则: 看内存走势图。如果在多次垃圾回收(图表上的突然下降)之后,内存的最低点依然一次比一次高(呈现出一种不断向上的阶梯状),那就可以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 算法)**。
- 运作方式: 将这块小内存一分为二,一半叫
from-space(使用区),一半叫to-space(空闲区)。新对象全扔进from。 - 回收时刻: 当
from快满了,触发 GC。把from里还活着的对象,全部复制排好列到to里面(顺便消灭了内存碎片)。 - 角色反转: 清空
from,然后把from和to的名字对调。
💡 晋升机制(对象如何变成“老油条”?): 新生代空间太小了,如果有些对象一直不死,很快就会把这里塞满。所以 V8 规定了两种情况,对象会被直接晋升(移动)到老生代:
- 熬过了一次 GC: 如果一个对象在新生代里经历过一次 Scavenge 复制依然活着,说明它命挺硬,送去老生代。
to-space占用超 25%: 在复制活对象到to空间时,如果发现to空间已经被占用了超过 25%,就会把这个对象直接送到老生代。(为什么是 25%?因为等下反转后,to会变成新的from用来装新对象,如果初始占用太大,一下子又满了,会导致疯狂触发 GC。)
三、 主垃圾回收器:打理“老生代”(标记-清除 + 标记-整理)
老生代区域空间极大,存的都是大对象和长寿对象。这里绝对不能用复制算法(太浪费那一半的空间,且大对象复制起来极其耗时)。
- 首选常规武器:标记-清除 (Mark-Sweep)
- 从根节点遍历,打标记。没被打标记的直接清理。
- 因为老生代对象存活率高,死亡的只是一小部分,所以“清除”效率很高。
- 迫不得已的终极武器:标记-整理 (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 | |
JSON.stringify()
| 数据类型 / 场景 | 转换结果(对象属性中) | 转换结果(数组元素中) | 底层原因 |
|---|---|---|---|
undefined、Function、Symbol |
👻 直接被忽略(剔除) | 🔄 变成 null |
JSON 标准不支持这些 JS 特有类型。数组中变成 null 是为了占位,防止数组长度和索引乱掉。 |
| Symbol 作为键名(Key) | 👻 直接被忽略 | (无法作为数组元素) | JSON 的键只能是字符串。 |
NaN、Infinity |
🔄 变成 null |
🔄 变成 null |
JSON 标准里没有无穷大和非数字的概念。 |
Map、Set 等复杂实例 |
📦 变成空对象 {} |
📦 变成空对象 {} |
stringify 只能序列化普通的可枚举属性,Map 和 Set 的数据不是存在普通属性里的。 |
| 数据类型 / 场景 | 转换结果 | 底层原因 |
|---|---|---|
包含 toJSON() 的对象 |
👑 执行该方法,并序列化其返回值 | toJSON 拥有最高优先级,它就是专门用来“自定义序列化”的后门。 |
Date 实例 |
📅 变成 ISO 标准格式的时间字符串 | 借了特权的光。因为 Date 对象的原型上天生自带一个 toJSON() 方法。 |
| 基本类型的包装对象 (如 new String('a')) |
🪞 被打回原形(提取原始值) | 剥离 JS 包装外壳,还原成纯 JSON 支持的字符串、数字或布尔值。 |
| 例如: |
1 | |
Date的实例返回一个字符串实现toJSON()方法(和date.toISOString()——使用 ISO 标准返回 Date 对象的字符串格式相同)
遇到循环抛出TypeError(循环对象值)异常
1 | |
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事件的回调处理),看用户有没有下一次点击,判断这次操作是不是双击
有三种办法解决这个问题:
meta 标签禁用网页的缩放
<meta name="viewport" content="width=device-width user-scalable= 'no'">更改默认视口宽度
<meta name="viewport" content="width=device-width">
如果能识别网站是响应式的网站,那么移动端浏览器就可以自动禁掉默认的双击缩放行为并去掉300ms的点击延迟调用 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
Set 集合
Set 和数组非常像,但它有一个绝对不可跨越的底线:里面的每一个值都必须是唯一的。
1. 核心特性
- 天然去重: 你往里面
add(5)两次,它里面也只有一个5。这也是前端最常用的“数组去重”绝招:[...new Set([1,1,2,2])]。 - 判断对象的“唯一性”看内存地址: 文本里提到了一个经典陷阱: 为什么? 因为对于引用类型(对象),
1
2
3mySet.add({a: 1});
mySet.add({a: 1});
// Set 里面会有两个 {a: 1}!Set只认内存地址。这两个长得一样的对象,在内存里存放在不同的地方,所以Set认为它们是不同的东西。
2. 数学运算神器
- 并集 (Union): 两个人所有的朋友合在一起(自动去重)。
- 交集 (Intersect): 找你们俩的共同好友。
- 差集 (Difference): 找你有,但对方没有的好友。
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 和这个“三层账本”是怎么打配合的:
- 初始渲染(挖坑): 组件 A 开始渲染,它的代码里写了
<h1>{{ user.name }}</h1>。 - 触发 Get(记账): 组件读取了
user.name。因为user是被 Proxy 包裹的,立刻触发了get拦截器。 - 收集依赖(入库): Proxy 的
get拦截器迅速翻开账本,在WeakMap(user) -> Map("name")对应的Set里,把组件 A 的更新函数塞了进去。 - 修改数据(拉响警报): 用户点击按钮,执行了
user.name = "李四"。 - 触发 Set(查账): 再次撞上 Proxy 的结界,触发
set拦截器。 - 派发更新(干活): Proxy 的
set拦截器迅速顺着账本WeakMap(user) -> Map("name")找到那个Set,发现里面存着组件 A 的更新函数,直接遍历执行它。组件 A 重新运行,屏幕上的名字变成了“李四”。
这就是 Vue3 响应式的全部底层真相:用 Proxy 在门外放哨,用 WeakMap 在门内记账。
1 | |
模块化
为什么需要模块化
在没有模块化的“远古时代”,所有 JS 资源都靠 <script> 标签从上到下死板地加载。这带来了一场灾难,而模块化的出现就是为了当救世主:
- 消灭全局污染与命名冲突: 以前大家都在全局作用域写代码,你写个
var name = 'A',我写个var name = 'B',全乱套了。模块化让每个文件都有了自己独立的私有作用域。 - 按需加载与依赖管理: 彻底理清了“谁依赖谁”,避免了大型多人协作项目中代码互相牵连、牵一发而动全身的噩梦。
- 高内聚与高复用: 把复杂逻辑封装成黑盒,只暴露出特定接口给外部使用。
CommonJS (CJS) —— 服务端与“死值拷贝”
1. 核心运行机制
- 同步加载,跑到了才执行: 因为服务器读本地文件极快,所以它是同步阻塞的。代码只有运行到
require()这一行,才会去加载对应模块。 - 单例与缓存: 模块只要被执行过一次,它的结果就会被死死缓存下来。同一个文件里你
require一万次,拿到的也是第一次缓存的那个结果。
2. 解答你的核心疑问:导出值到底能不能变?
在 CommonJS 中,导出(module.exports)的本质是浅拷贝。
- 如果导出的是基础类型(比如数字
count = 1): 导出的那一瞬间,就给外部发了一个“克隆体”。原模块怎么变,外部拿到的永远是旧的克隆体;外部强行改了克隆体,原模块也不受影响。这就是所谓的“断联”。如果导出的是对象,不会断联
1 | |
1 | |
如果导出的 count 是包在一个对象里的,情况就完全反转了!因为在 JavaScript 里,对象的赋值是共享内存地址的。
1 | |
1 | |
💡 黑客解法(如何在 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 | |
总结与对比
- 时机不同: CommonJS 是代码运行到那一刻才去加载(运行时);ES6 是代码还没跑就已经把依赖查清楚了(编译时)。
- 指针不同: CommonJS 导出的是死拷贝(基础类型彻底断联);ES6 导出的是活绑定(实时指向同一内存地址)。
- 环境不同: CommonJS 是同步的,专门为服务端(Node.js)设计;ES6 既支持静态,又支持用
import()异步,通吃浏览器和服务器。
Ajax、Fetch和Axios
Ajax 与 XHR (老祖宗,刀耕火种时代)
Ajax 其实不是一门具体的技术,而是 2005 年提出的一个概念(异步 JavaScript 和 XML)。它的核心目的是:在不刷新整个网页的情况下,偷偷向服务器请求数据,然后只更新网页的一小部分。
在浏览器底层,实现 Ajax 概念的真面目叫 XMLHttpRequest (简称 XHR)。
实例体验(痛苦的回忆):
1 | |
- 痛点总结: 架构极其老旧,配置繁琐。最致命的是它基于事件回调,如果有多个接口互相依赖(比如先查用户 ID,再查订单),就会陷入恐怖的“回调地狱(Callback Hell)”。现在的现代开发中,绝对不会再有人手写原生 XHR 了。
Fetch (浏览器亲儿子,现代原生派)
随着 ES6 Promise 的普及,浏览器官方终于看不下去了,原生内置了一个全新的 API:Fetch。
它是真正用来替代 XHR 的底层 API,天生基于 Promise,并且可以直接配合 async/await 使用,代码瞬间清爽。
实例体验(优雅的原生):
1 | |
- 优势总结: 浏览器原生支持,不需要安装任何第三方包(库);语法优雅。
- 致命踩坑点(文本提到的缺陷): 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. 自动转换 JSON 使用 Axios,你永远不需要写 JSON.parse() 或者 JSON.stringify(),它底层全自动帮你搞定。
3. 最强杀手锏:拦截器 (Interceptors) 这是 Axios 封神的功能。它就像是在请求发出去之前,和响应回来之后,设立了两个“海关安检站”。
请求拦截器: 比如每个接口都要传 Token,你不需要在每个请求里写一遍,直接在拦截器里统一塞进去:
1
2
3
4
5instance.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 攻击 | ❌ 无 | ❌ 无 | ✅ 自带防御机制 |
| 当前地位 | 已进博物馆 | 适合轻量级、不装包的原生页面 | 企业级重型项目的首选标配 |