JavaScript 基础理解一

38 阅读40分钟

变量

变量是可变的量。将编程思想转换为现实生活中的例子进行理解。可变的量存在一个容器中,就像一个苹果箱里面有着许多苹果,箱子的作用就是用于存放量,而里面的苹果就是实际的值。

如:var apples = 20

var: 相当于制作了一个空箱子

apples: 给这个空箱子贴上苹果的标签,用于识别里面存放的是什么

20: 箱子里放了20个苹果

apples = 30 把20个苹果拿走,换成30个(重新赋值)

整个过程就像是工厂制作好箱子贴上标签,放入苹果,等待客户过来订单拿走。由此可以理解变量的本质:计算机内存中一块有名字的存储空间(“箱子”),变量名是 “用于方便识别的标签”,变量值是 “箱子里的东西”;

var/let

制作空箱子的方式有两种var和let,这两个关键字来声明变量。通过var或let制作出一个空箱子,贴上用于识别的标签。

全局变量,浏览器需要在整个页面载入期间保存它们,局部变量只需要在函数执行期间被保存。

每个变量的声明都存在内存中,所以浏览器中要保存的变量越多,脚本运行时用的内存资源越多,运行越慢。

声明变量语法: var 变量名;  或 let 变量名; 

两者的区别:核心差异集中在作用域、变量提升、重复声明、全局绑定

1.作用域:var 是 “函数 / 全局作用域”,let 是 “块级作用域”。

var 无视块级作用域,只认 “函数” 或 “全局” 边界,会从块内 “泄露” 到外部,污染全局。

块级作用域:就是 {} 包裹的区域(比如 ifforwhile 或直接写的 {}),let 声明的变量只在当前块内有效,出了块就 “消失”,避免了全局污染。

  1. 变量提升:var 完全提升,let 提升但有 “暂时性死区”,变量提升:JS 引擎会把变量声明 “提前” 到作用域顶部,但初始化(赋值)还在原来的位置。
  • var:声明 + 初始化都被提升(提前造了箱子,还往里面放了 “空”),声明前访问不会报错,只会得到 undefined

  • let:只有声明被提升,但初始化未完成,声明前访问会报错(这个阶段叫 “暂时性死区”)—— 相当于 “提前说要造箱子,但箱子还没做好,不能用”。

  1. 重复声明:var 允许,let 禁止
  • var:同一作用域内可以重复声明同一个变量(相当于给同一个箱子反复贴标签,不会报错);

  • let:同一作用域内禁止重复声明(同一个区域不能有两个贴一样标签的箱子,会直接报错)。

  1. 全局作用域绑定:var 挂到 window,let 不挂,在全局作用域(函数外)声明变量时:
  • var 声明的变量会成为 window 对象的属性(相当于把箱子直接挂在 “房子” 墙上,所有人都能看到);
  • let 声明的变量不会绑定到 window(箱子放在房子的公共区域,但不挂墙,不属于房子的属性)。

1.作用域

// 用 let 声明(块级作用域)

{ 
   let apple1 = 10;
   console.log(apple1);   //输出10
}

console.log(apple1);  // 报错:apple1 is not defined

生活中的例子来理解:

块级作用域 = 超市的 “分区管理”

  • {} 就对应超市里的水果区(一个独立的块);

  • let apple1 = 10 就是 “水果区专属的苹果箱”,这个箱子被明确规定 “只能在水果区范围内”;

  • 出了水果区(也就是 } 之后),到蔬菜区 / 日用品区(块外部),自然找不到这个 “水果区专属箱子”,所以 console.log(apple1) 会报错。箱子里的苹果只能在水果区域。不能在蔬菜或其他日用品区域出现。进行了规定及区域限制。

再举一个例子:

// 只有上午10点-11点(条件满足),试吃区(块)才开放 
if (new Date().getHours() >= 10 && new Date().getHours() < 11) { 
    let trialApple = 1; 
    console.log(trialApple); // 试吃区能拿,输出1 
  } 
  // 过了11点离开试吃区,就拿不能拿到试吃的食物
  console.log(trialApple);  // 报错:trialApple is not defined

对比 var(无块级作用域)= 小卖部的随意摆放

{ 
    var apple2 = 20; 
    console.log(apple2); //输出20

 }

console.log(apple2) //输出20

var 声明的变量就像小卖部没有区域区分和限制,不管是水果区、日用品区,整个小卖部(函数 / 全局作用域)都能用到。

典型场景:for 循环

// var 版:循环结束后 i 会泄露,且所有循环体共享同一个 i 
for (var i = 0; i < 3; i++) { 
    setTimeout(() => console.log(i), 100);
    // 输出 333(因为共享一个i,最后i=3) 
 } 


// let 版:每次循环都会创建新的 i,块级作用域隔离 
for (let i = 0; i < 3; i++) { 
    setTimeout(() => console.log(i), 100); // 输出 012(每个循环有自己的i) 
}

进行分析var版:

  • 同步代码优先执行:for 循环是 “同步代码”,需要从头到尾跑完。

  • 异步代码延后执行setTimeout 是 “异步代码”,要等同步代码全部跑完,且等待 100ms 后才执行。

  • var 声明的 i 是 “共享的” :var 没有块级作用域,整个 for 循环里只有一个 i 变量(相当于一个公共的本子),循环中每次修改的都是这个本子上的数字。

步骤 1:初始化变量(同步)

执行 var i = 0:创建一个全局 / 函数作用域的变量 i,值为 0(公共本子上先写 0)。

步骤 2:第一次循环(同步)
  • 判断条件 i < 3:0 < 3,条件成立;
  • 执行循环体:调用 setTimeout,把回调函数 () => console.log(i) 放入 “异步任务队列”,此时回调函数还没执行
  • 执行 i++:把公共本子上的 i 改成 1。
步骤 3:第二次循环(同步)
  • 判断条件 i < 3:1 < 3,条件成立;
  • 执行循环体:再放一个回调函数到异步队列;
  • 执行 i++:公共本子上的 i 改成 2。
步骤 4:第三次循环(同步)
  • 判断条件 i < 3:2 < 3,条件成立;
  • 执行循环体:放第三个回调函数到异步队列;
  • 执行 i++:公共本子上的 i 改成 3。
步骤 5:循环结束(同步)
  • 判断条件 i < 3:3 < 3,条件不成立,for 循环彻底跑完;
  • 此时同步代码全部执行完毕,公共本子上的 i 固定为 3。
步骤 6:执行异步回调(延后)

等待 100ms 后,JS 依次执行异步队列里的 3 个回调函数:

  • 第一个回调:去查公共本子上的 i → 3,输出 3;
  • 第二个回调:还是查同一个公共本子 → 3,输出 3;
  • 第三个回调:依旧查这个本子 → 3,输出 3。

核心原因:var 声明的 i全局 / 函数作用域,整个循环只有一个 i,同步循环跑完后 i 已经变成 3

进行分析let版:

let 在 for 循环中有个特殊设计 ——每次循环迭代都会创建一个全新的、独立的 i 变量(而非共享同一个),每个 setTimeout 回调会 “绑定” 当前迭代的这个独立 i

步骤 1:第一次循环迭代(同步执行)
  1. 创建第一个独立的 i 变量(块级作用域),初始值为 0;
  2. 判断条件 i < 3(0 < 3,成立);
  3. 执行 setTimeout:把回调函数 () => console.log(i) 放入异步队列,这个回调会 “记住” 当前这个独立的 i=0
  4. 执行 i++:本次迭代的 i 变成 1(但这个变化只属于当前迭代的独立 i)。
步骤 2:第二次循环迭代(同步执行)
  1. 创建第二个独立的 i 变量(全新的,和上一个无关),初始值继承上一次的结果(1);
  2. 判断条件 i < 3(1 < 3,成立);
  3. 执行 setTimeout:回调 “记住” 当前这个独立的 i=1,放入异步队列;
  4. 执行 i++:本次迭代的 i 变成 2。
步骤 3:第三次循环迭代(同步执行)
  1. 创建第三个独立的 i 变量,初始值为 2;
  2. 判断条件 i < 3(2 < 3,成立);
  3. 执行 setTimeout:回调 “记住” 当前这个独立的 i=2,放入异步队列;
  4. 执行 i++:本次迭代的 i 变成 3。
步骤 4:循环终止(同步执行)

判断条件 i < 3(3 < 3,不成立),for 循环彻底跑完。

步骤 5:执行异步回调(延后执行)

100ms 后,JS 依次执行异步队列里的 3 个回调函数:

  • 第一个回调:调用 “记住” 的第一个 i=0 → 输出 0;
  • 第二个回调:调用 “记住” 的第二个 i=1 → 输出 1;
  • 第三个回调:调用 “记住” 的第三个 i=2 → 输出 2。

核心运行逻辑差异:

var 整个循环只有1 个共享的 i(函数 / 全局作用域),let 每次循环创建新的独立 i(块级作用域)。

var 无块级作用域,i 泄露到循环外,let 块级作用域,每个 i 仅限当前迭代使用。

var回调绑定 “唯一的共享 i”,最终取到 i=3。let绑定 “当前迭代的独立 i”,分别是 0/1/2。

2. 暂时性死区

// var 提升:提前造了箱子,里面是空的 
console.log(banana); // 输出 undefined(箱子存在但没装苹果) 
var banana = 15; // 声明+赋值 

// let 暂时性死区:箱子还没造好,不能用 
console.log(orange); // 报错:Cannot access 'orange' before initialization 
let orange = 25; // 声明+赋值

3.重复声明

// var 重复声明:没问题 
var pear = 5; 
var pear = 8; // 覆盖之前的值,不会报错 
console.log(pear); // 输出 8 

// let 重复声明:报错 
let grape = 6; 
let grape = 9; // 报错:Identifier 'grape' has already been declared

4.var 挂到 window,let 不挂

// 全局 
var mango = 30;
console.log(window.mango); // 输出 30(挂在window上) 

// 全局 
let cherry = 40; 
console.log(window.cherry); // 输出 undefined(不挂在window上)

var变量提升

代码是从上一行一行往下执行

//执行顺序声明一个变量num 并赋值为20 
var num = 20
console.log(num) //再打印输出这个变量的值


//根据代码从上往下执行,sun输出时没有声明变量,应该报错,但是输出的是undefined
console.log(sun)
var sun = 30   //是因为浏览器会将var sun 放到最顶部,变量提升

//如下形式
var sun;
console.log(sun);
sun = 30;

const

const 声明的变量,指向的内存地址不可变(简单说就是 “箱子不能换,但箱子里的内容可能能改”)

  1. 声明时必须初始化(不能造 “空箱子”)

const 声明变量时,必须立刻赋值(往箱子里放东西),不能像 let/var 那样先声明、后赋值,否则直接报错。

错误:const 不能声明空变量 const apple;

正确:声明时必须初始化 const apple = 10;

2.声明后不能给 const 变量重新赋值(相当于不能把整个箱子换成新的),否则报错

const total = 20; total = 30;// 错误:不能重新赋值(换箱子)

  1. 块级作用域(和 let 完全一致)

在所在的 {} 块内有效,出块即失效

4.引用类型(对象 / 数组):内容可改,指向不可改

const 绑定的是简单类型(数字、字符串、布尔值),因为值直接存在 “箱子” 里,指向不可变 = 值不可变;

如果绑定的是复杂类型(对象、数组),“箱子” 里装的是 “指向果篮的地址”,地址不可改(不能换果篮),但果篮里的内容可以改。

// 简单类型(值不可变) 
const num = 10; 
num = 20; // 报错(换箱子=改值) 

// 复杂类型(对象)—— 内容可改,指向不可改 
const fruitBasket = { red: 10, green: 5 }; // 可以改箱子里的内容(调整果篮里的苹果数量)  
fruitBasket.red = 15;
console.log(fruitBasket.red); // 输出 15 
fruitBasket = { orange: 8 }; //不能换箱子(不能改指向的地址) 报错

// 示复杂类型(数组)
const arr = [1, 2, 3]; 
arr.push(4); //可以改内容,输出 [1,2,3,4] 
arr = [5,6]; //  不能换数组(改指向),报错


  • const 核心是 “指向不可变”,而非 “值不可变”—— 简单类型值不可改,复杂类型内容可改、指向不可改;

  • const 声明必须初始化、不可重新赋值、有块级作用域,禁止重复声明;

数据类型

js中的数据类型,将编程思维变成生活中思维可以理解成归类,用于更加快捷,方便的区分,通过统一标签降低代码混乱,根据其特性进行使用。

比如将水果和蔬菜放在一个大筐里,想要从里面拿出一个苹果,需要在一堆各种各样的水果和蔬菜混装中找到,不方便。如果一次性要找出五个苹果,那么花费的时间更长。

但将水果放在一个大筐里,蔬菜单独放在一个大筐里,这样比较好找一些。如果再细分下,划分两个区域,一个区域放水果,苹果单独一筐,香蕉单独一筐。另一个区域放蔬菜,青菜单独一筐,胡萝卜单独一筐,这样既不混乱也方便找到需要的东西。

同时蔬菜和水果不能进行炒菜,这样也区分了特性。

基于上面的理解,那么js数据类型也可以分为两个大区域:基本类型和引用类型。为了更好使用分别又进行了划分,7种基本数据类型,引用类型Object

基本数据类型(原始类型)

基本数据类型:值直接存在变量指向的内存地址(箱子里直接装东西),箱子里直接装苹果、香蕉(值),拿取直接用

1.String 字符串

定义:文本内容,用单引号 / 双引号 / 反引号包裹;

const fruitName = "苹果"; // 双引号 
const desc = '红富士苹果'; // 单引号 
const priceDesc = `苹果单价:8.99元`; // 反引号

2. Number 数字

定义:包含整数、小数、特殊值(NaN、Infinity);

const appleCount = 20; // 整数 
const applePrice = 8.99; // 小数 
const invalidNum = 10 / "苹果"; // NaN(Not a Number,非数字,注意:NaN 不等于任何值,包括自己) 
const bigNum = 1 / 0; // Infinity(无穷大)

3. Boolean 布尔值

定义:只有两个值:true(真)、false(假),用于条件判断;

  1. Undefined 未定义

定义:变量声明了但未赋值时的默认值;

let apple; // 只声明,没赋值 
console.log(apple); // 输出 undefined

5. Null 空值

定义:主动声明的 “空”,表示变量指向的内存地址无内容;

const emptyBox = null; // 主动表示箱子是空的

6. Symbol 符号

定义:唯一的、不可重复的值,用于创建唯一标识;

const id1 = Symbol("apple"); 
const id2 = Symbol("apple"); 
console.log(id1 === id2); // 输出 false

7. BigInt 大整数

定义:解决 Number 的精度问题,处理超大整数,后缀加 n,不能和 Number 直接运算,需先转换;

const bigNum = 9007199254740993n; // 大整数 
const sum = bigNum + 1n; // 运算时也要加 n,输出 9007199254740994n

引用数据类型

  1. Object 普通对象

定义:键值对(key-value)集合,key 是字符串 / Symbol,value 可以是任意类型;

const fruit = { 
    name: "苹果", // key: name,value: 字符串
    price: 8.99, // key: price,value: 数字 
    hasStock: true // key: hasStock,value: 布尔值 
    }; 
    // 修改对象内容(允许,因为只是改地址指向的内容) 
    fruit.price = 7.99; 
    console.log(fruit.price); // 输出 7.99
    

2. Array 数组

定义:有序的集合,索引从 0 开始,本质是特殊的 Object;

const fruits = ["苹果", "香蕉", "橙子"]; // 修改数组内容(允许) 
fruits.push("葡萄"); // 新增元素 
console.log(fruits); // 输出 ["苹果", "香蕉", "橙子", "葡萄"]

3. Function 函数

定义:可执行的代码块,本质是特殊的 Object(可以作为参数、返回值);

  1. 其他引用类型
  • Date(日期):处理时间,const now = new Date();

  • RegExp(正则):处理字符串匹配,const reg = /apple/;

堆和栈

  • 栈(Stack,执行栈 / 调用栈) :像取餐口 —— 空间小、存取快、顺序先进后出,只能放固定大小的物品。

  • 堆(Heap) :像仓库 —— 空间大、能放大小不固定的物品,存取稍慢,物品位置无序,需要标记(地址)才能找到。

JS 引擎正是通过这两个空间的配合,完成所有数据的存储和管理。

  • 堆的内存不会自动释放,需要 JS 的垃圾回收机制(GC)定期清理无引用的对象;

  • 堆中的数据没有固定顺序,每个数据会有一个「内存地址」(指针),通过这个地址才能找到数据。

    // 1. 堆中创建对象本体:{ name: "张三" },分配地址(比如 0x123) 
    // 2. 栈中存储:obj1 → 0x123(指针指向堆的地址0x123) 
    let obj1 = { name: "张三" }; //将地址赋值给到变量,变量拿到的是地址而非真正的值
    // 3. 栈中拷贝指针:obj2 → 0x123(obj1和obj2指向堆中同一个对象) 
    let obj2 = obj1; 
    // 4. 通过obj2修改堆中的数据本体 
    obj2.name = "李四"; 
    // 5. obj1通过指针访问堆中同一数据,所以值也变了 
    console.log(obj1.name); // 输出 李四
    
  • 赋值阶段let obj1 = { name: "张三" }

  • JS 引擎先在堆内存里开辟一块空间,存入 { name: "张三" } 这个对象本体,并给这块空间分配唯一的内存地址(比如0x123); - 然后在栈内存里创建变量 obj1,并把「地址 0x123」这个指针赋值给 obj1 —— 所以 obj1 本身存的不是对象,而是指向对象的 “门牌号”。

  • 拷贝阶段let obj2 = obj1

    • 这一步并不是把堆里的对象复制一份,而是把栈里 obj1 存的地址(0x123)拷贝给 obj2
    • 此时栈里 obj1obj2 都指向 0x123,相当于两个人拿着同一个门牌号,能找到同一个房子(堆里的对象)。
  • 修改阶段obj2.name = "李四"

    • 引擎先读取栈里 obj2 的地址(0x123),然后根据这个地址找到堆里的对象;
    • 直接修改堆里这个对象的 name 属性 —— 因为房子只有一个,不管用哪个门牌号进去改,房子里的东西都会变。
  • 访问阶段console.log(obj1.name)

    • 引擎读取栈里 obj1 的地址(0x123),找到堆里的对象,读取 name 属性 —— 自然就是修改后的「李四」。

代码2:

  let obj1 = { name: "张三" }; 
  let obj2 = obj1; // 注意:这是给obj2重新赋值,不是修改属性 obj2 = { name: "王五" };
  console.log(obj1.name); // 输出 张三(而非王五)
  • 堆内存:有一个对象 { name: "张三" },地址 0x123

  • 栈内存:obj1 → 0x123obj2 → 0x123(两个变量都指向同一个堆地址)

  • JS 引擎看到你写了 { name: "王五" } —— 这是一个「全新的对象字面量」,引擎会默认认为你需要一个新对象,因此会在堆里重新开辟一块新空间(比如地址 0x456),并把 { name: "王五" } 存入这个新地址;

  • 修改栈里的指针:把栈中 obj2 原来存储的地址 0x123 替换成新地址 0x456; 此时 obj1 仍指向 0x123(原堆对象),obj2 指向 0x456(新堆对象);堆里同时存在 0x1230x456 两个独立的对象,互不影响。

  • JS 中只要写 {}/[]/function(){} 等引用类型字面量,引擎就会在堆里新建一块空间存储这个新数据;所以obj2 = { name: "王五" } 是 “赋值新对象”,而非 “修改原对象”,所以会先创建新堆地址,再更新栈里 obj2 的指针;

基本数据类型放在栈中

基本类型放在栈里,是 JS 引擎为了「性能最优」做的设计,栈的存取速度远高于堆,栈内存的核心特征之一是:只能存储「大小固定、已知」的数据

栈是 “先进后出” 的线性结构,数据的存入(压栈)、取出(弹栈)只需要操作栈顶指针,不需要像堆那样遍历、查找内存地址,CPU 能直接缓存栈的连续内存,访问速度极快;

基本类型是 JS 中使用最频繁的数据(比如数字计算、布尔判断、简单字符串拼接),把它们放在最快的栈里,能最大程度减少内存访问耗时,提升代码执行效率。

如果把基本类型放堆里,每次访问都要先查栈里的指针,再找堆里的数据。

栈是一块连续的线性内存空间,像一排编号固定的小格子。7 种基本数据类型(Number、String、Boolean、Undefined、Null、Symbol、BigInt),它们的值在创建时大小就是固定的:

-   Number:不管是 10 还是 100000,都占用 8 字节(JS 中统一用 64 位浮点数存储);
-   Boolean:只有 true/false 两种可能,占用 1 字节;
-   String:虽然看起来长度可变,但 JS 中字符串是「不可变的」
-   Undefined/Null:占用极小且固定的内存空间。

引用类型(对象、数组等)大小不固定(比如数组可以无限 push 元素),无法提前确定占用多少字节,所以只能放在 “不限制大小、无序存储” 的堆里。

栈是自动释放:函数执行时,变量被压入栈;函数执行结束,对应的栈帧(包含变量)会立即被销毁,内存自动释放。

// 执行函数时,栈中创建栈帧,存入a、b(固定大小,快速分配)
    function add() { 
        let a = 10; // 栈:a → 10 
        let b = 20; // 栈:b → 20 
        return a + b; 
    } 
add(); // 函数执行结束,栈帧被立即销毁,a、b的内存自动释放,无残留


同步和异步

  • 同步:像去奶茶店排队买奶茶 —— 必须等前面的人都买完、你拿到奶茶,才能做下一件事,一步等一步,完全按顺序来;

  • 异步:像点外卖 —— 下单后不用等外卖送到,你可以先去看电视,等外卖到了(有结果了),再处理收外卖这件事,不用全程等待。

同步(Synchronous):按顺序执行,阻塞线程

同步是 JS 代码的「默认执行模式」,核心规则是:代码严格按照书写顺序依次执行,前一行代码执行完成(不管是简单计算、函数调用),后一行代码才会开始执行

在执行同步代码时,JS 的主线程会被「阻塞」—— 直到当前同步任务完成,才能处理下一个任务。

为何要使用同步,是因为JS可修改DOM结构,JS和DOM共用一个线程。

异步(Asynchronous):不等待,不阻塞线程

异步是为了解决「同步阻塞」问题设计的执行模式,核心规则是:耗时的异步任务不会阻塞主线程,JS 会先跳过它执行后面的同步代码,等异步任务有结果了(比如定时器到时间、网络请求返回),再回头执行对应的回调函数

console.log('1. 主线程开始执行'); // 异步任务:定时器(延迟1秒执行回调)
setTimeout(() => {
    console.log('2. 异步定时器回调执行'); 
}, 1000); // 不会等定时器,直接执行这行同步代码 
console.log('3. 主线程继续执行,不等异步任务');

1. 主线程开始执行
3. 主线程继续执行,不等异步任务 
2. 异步定时器回调执行 // 1秒后才输出

梳理

  • JS 是单线程:同一时间确实只能执行一个任务;

  • 执行优先级:先同步,后异步:同步任务全部执行完,才会处理异步任务;

  • 异步不会 “插队”:哪怕异步任务先 “准备好”(比如定时器设 0 秒),也得等同步任务全执行完才会运行。

  • 如果没有异步,单线程的 JS 面对任何耗时操作(比如网络请求、定时器)都会卡死,而异步的核心好处就是「不阻塞主线程,让程序 / 页面始终可交互,同时高效利用资源」。

  • 比如:点击 “加载数据” 按钮后,用异步请求数据,用户依然可以滚动页面、点击其他按钮,不会出现 “卡死”;页面加载时异步加载图片 / 数据,用户能先看到页面骨架,再逐步加载内容,而非白屏等待。

  • 同步模式下,CPU 会在耗时操作(比如网络请求)期间 “空等”(因为要等服务器返回数据,CPU 没事可做);

  • 异步模式下,CPU 会把耗时操作交给浏览器 / Node 的异步模块(比如网络线程、定时器线程)处理,自己继续执行其他任务,直到异步任务完成后再回调 ——CPU 始终在干活,不会闲置

  • JS 是单线程,但异步能让 JS “看起来像同时处理多个任务”(伪并发)

  • 同时发起 3 个网络请求(用户信息、商品列表、分类列表),异步模块会并行处理这 3 个请求,谁先完成谁先回调,总耗时≈最慢的那个请求的时间(而非 3 个请求时间相加);如果是同步,总耗时 = 请求 1 + 请求 2 + 请求 3,效率极低

调用栈(同步任务区) :奶茶店的「制作台」—— 只能做一杯奶茶(单线程),按顺序做完一个,才能接下一个;JS 引擎扫描代码,把所有同步任务(比如变量赋值、console.log、普通函数)依次推入「调用栈」,逐个执行。

任务队列(异步任务区, 队列结构,先进先出) :奶茶店的「取餐叫号机」—— 异步任务(比如外卖单)不会直接进制作台,而是先在叫号机排队,等制作台空了(同步任务做完),再按顺序叫号处理;

事件循环(协调者) :奶茶店的「店员」—— 不停检查制作台(调用栈)是否空,空了就去叫号机(任务队列)取一个异步单来做。

同步任务在「调用栈」执行,异步回调在「任务队列」排队,由「事件循环」协调执行。

执行异步任务:只有当「调用栈为空」(所有同步任务都执行完),事件循环才会把任务队列里的异步回调函数逐个推入调用栈执行 —— “同步执行结束后,找到异步执行”。

思考的问题:当异步任务未完成是否影响到下一个异步任务。

JS 的任务队列是「先进先出」的独立队列,每个异步任务的回调都是独立排队、独立执行的。异步任务只要 “有结果了(不管是好结果还是坏结果)”,对应的回调就会被放进任务队列,等调用栈空了执行;只有异步任务 “没完成”(比如网络请求还在 pending、定时器还在计时),回调才不会入队。

一个异步回调执行失败(比如报错),JS 引擎只会终止当前这个回调的执行,调用栈清空后,依然会继续执行任务队列里的下一个异步回调;

一号顾客的奶茶做砸了(回调报错),只会重新给一号做(如果处理了错误),但二号、三号顾客的奶茶依然会按顺序做,不会因为一号砸了就停。所以单个异步的成功 / 失败(或回调报错),不会影响任务队列里其他异步回调的执行。

当 JS 主线程遇到多个异步任务时,会把它们分别交给对应的异步模块,这些异步模块是多线程的,能同时处理多个任务(比如一个定时器线程计时的同时,另一个网络线程发请求);

每个异步任务只有自己 “完成”(成功 / 失败)后,才会把回调放进任务队列;未完成的异步任务,只是在自己的线程里 “等待”,不会占用主线程,也不会阻止其他异步模块的工作。

多个异步任务的执行顺序,由它们各自完成的时间决定(谁先完成谁先入队执行)。

问题思考:多个异步任务按 “完成时间先到先得” 的方式执行,在需要「有序逻辑」的场景下是否造成影响

如果业务逻辑依赖固定执行顺序(比如先查用户、再查订单),会导致逻辑混乱、数据错误;如果业务逻辑不依赖顺序(比如同时加载两张无关的图片)。

先请求 “用户信息”(拿到用户 ID),再用用户 ID 请求 “用户订单”。如果订单请求网络更快,先完成入队执行,就会因为没有用户 ID 导致请求失败 / 数据错误。

let userId = null;

 // 异步1:请求用户信息(假设网络慢,2秒完成) 
setTimeout(() => {
    userId = 1001; // 拿到用户ID
    console.log('异步1完成:拿到用户ID', userId); 
}, 2000); 

// 异步2:请求用户订单(依赖userId,假设网络快,1秒完成) 
setTimeout(() => { 
    console.log('异步2执行:请求订单,用户ID为', userId); // 此时userId还是null  
}, 1000);

异步2执行:请求订单,用户IDnull ( 先完成的异步2先执行,拿到无效数据 )
异步1完成:拿到用户ID 1001

如何解决

让异步任务按「业务逻辑顺序」执行,而非「完成时间顺序」。异步执行顺序从 “时间驱动” 变回 “逻辑驱动”;

方案 1:串行执行(依赖型异步,用 async/await)

“必须先 A 后 B” 的场景,让 B 等待 A 完成后再执行:

 async function f() { 

     let userId = null; 
     
     await new Promise((resolve) => { 
         setTimeout(() => { 
             userId = 1001; 
             console.log('异步1完成:拿到用户ID', userId);
             resolve(); // 标记异步1完成 
         }, 2000); 
     }); 

     // 异步2:等异步1完成后再执行
     await new Promise((resolve) => { 
         setTimeout(() => { 
             console.log('异步2执行:请求订单,用户ID为', userId); // 此时ID=1001 
             resolve(); 
             }, 1000); 
         }); 
     } 

 f();
 

方案 2:并行等待(需要所有异步完成,用 Promise.all)

适合 “需要所有数据到齐再处理” 的场景,不管谁先完成,都等全部完成后统一执行:

 // 异步1:请求商品列表(2秒完成) 
 const fetchGoods = new Promise((resolve) => { 
     setTimeout(() => { resolve(['商品1', '商品2']); }, 2000); 
  });

 // 异步2:请求分类列表(1秒完成) 
 const fetchCate = new Promise((resolve) => { 
     setTimeout(() => { resolve(['分类1', '分类2']); }, 1000); 
 }); // 等待所有异步完成,再统一处理 

 Promise.all([fetchGoods, fetchCate]).then(([goods, cate]) => { 
     console.log('所有数据到齐:', { goods, cate }); // 这里渲染页面,数据完整 
 });
 
 所有数据到齐: { goods: ['商品1', '商品2'], cate: ['分类1', '分类2'] }
 
 
 
 

promise

callback hell 回调地狱,在了解回调地狱时先了解下什么是回调。

回调 & 回调函数到底是什么?

你去蛋糕店定一个蛋糕,跟店员说:“蛋糕做好了叫我一声,我过来取”。

  • 这里的你就是程序主逻辑,而 “叫我一声” 这个动作就是回调函数,这件事情交给了店员(执行异步操作的函数);

  • 店员不用一直等蛋糕做好,忙别的事(异步执行),蛋糕做好了才会 “回头调用”,执行“叫你” 这个动作;

  • 这个 “被交给别人、等时机到了再执行的动作”,就是回调函数;“回头调用” 这个动作本身,就是回调

  • 回调(Callback) :指 “回头调用” 的行为 —— 一个函数执行完成后,“回头” 调用另一个函数的过程。

  • 回调函数:被作为参数传递给另一个函数(我们称这个函数为 “主函数”),并由主函数在合适的时机(同步 / 异步操作完成后)调用执行的函数。

回调函数的本质是:把函数当作参数传递,让其他函数决定它的执行时机。

通过上面例子可以理解:

  • “我把函数给你,你用完了再叫我” :回调函数的执行权不在自己手里,而是交给了接收它的主函数;

  • 同步 / 异步都能用:异步场景(定时器、AJAX)是为了等结果,同步场景(forEach、sort)是为了自定义逻辑;

  • 本质是 “参数” :回调函数只是一个 “以函数形式存在的参数”,和数字、字符串参数没有本质区别,只是类型不同。

回调函数的同步 / 异步,由执行回调的主函数决定:

  • 如果主函数在执行过程中立刻、无延迟地调用回调函数 → 这是同步回调
  • 如果主函数先执行完,等某个异步操作(定时器、网络请求、文件读取)完成后延迟调用回调函数 → 这是异步回调。、

在 Promise 出现前,异步操作靠回调函数实现,多层嵌套会形成「回调地狱」

// 回调地狱:多层异步嵌套(比如先请求用户→再请求订单→再请求商品) 
getUserInfo(() => { 
    getOrderList(() => { 
        getGoodsDetail(() => { // 业务逻辑 
        }, err => console.log(err)); 
    }, err => console.log(err)); 
}, err => console.log(err));

promise它能把异步操作的 “成功 / 失败结果” 和 “处理逻辑” 分离开,解决了传统回调函数嵌套(回调地狱)导致的代码混乱问题,让异步代码更易读、易维护。

Promise 对象有且只有三种状态:

  • pending(待定) :初始状态,异步操作还未完成;
  • fulfilled(已兑现 / 成功) :异步操作成功完成,会触发then的成功回调;
  • rejected(已拒绝 / 失败) :异步操作失败,会触发catch的失败回调。

状态只能从 pending → fulfilledpending → rejected,无法反向或跨状态改变。

Promise 是 JavaScript 中的一个内置构造函数(类),需要new实例化后才能使用。无需 new:使用 Promise.resolve/reject/all/race 等静态方法(底层自动创建实例);

基本语法:

// executor接收两个参数:resolve(成功回调)、reject(失败回调)
const promise = new Promise((resolve, reject) => { 

setTimeout(() => { 
    const isSuccess = true; // 模拟操作结果 
        if (isSuccess) { 
            resolve("操作成功!"); // 状态:pending → fulfilled,传递成功结果 
        } else {
            eject("操作失败!"); // 状态:pending → rejected,传递失败原因 
        } 
  }, 1000); 
});

例子:

    function loadImg(src){
        let promise = new Promise(function (resolve,reject){
            let img = document.createElement('img')
            img.onload = function (){
                resolve(img)
            }
            
            img.onerror = function(){
                reject('图片加载失败')
            }
            
            img.src=src
        
        })
        return promise
    
    }
    
    let src = 'xxxxxx.jpg'
    
    let result = loadImg(src)
    
    result.then(function (img) {
        return img
    }).then(function (img){
        alert('图片')
    })

then ():处理成功 / 失败结果

then接收两个可选参数:onFulfilled(成功回调)、onRejected(失败回调),且返回新的 Promise(支持链式调用)。

promise.then( 
    (successMsg) => { 
        // 成功:输出「操作成功!」
        console.log(successMsg); 
    }, 
    (failMsg) => {
        // 失败:输出「操作失败!」
        console.log(failMsg); 
    }
);

catch ():专门处理失败结果

.then((successMsg) => { console.log(successMsg); }) 
.catch((failMsg) => { console.log(failMsg); }); // 捕获失败/异常

finally ():无论成功 / 失败都会执行

常用于清理操作(比如关闭 loading),不接收参数,返回新 Promise:

.then((res) => { console.log(res); }) 
.catch((err) => { console.log(err); }) 
.inally(() => { console.log("异步操作结束,清理资源"); });

解决异步回调地狱:

// 封装异步操作为
function getUserInfo() { 
    return new Promise((resolve) => { 
        setTimeout(() => resolve("用户信息"), 500); 
    }); 
} 

function getOrderList() { 
        return new Promise((resolve) => { 
            setTimeout(() => resolve("订单列表"), 500); 
         }); 
     } 

function getGoodsDetail() { 
        return new Promise((resolve) => { 
            setTimeout(() => resolve("商品详情"), 500);
         });
  } 

// 链式调用:线性执行,解决回调地狱
getUserInfo() 
    .then(() => getOrderList()) 
    .then(() => getGoodsDetail()) 
    .then((res) => { 
        console.log(res); 
     }) // 输出:商品详情 
     .catch((err) => { console.log(err); });
     
     

需要注意的是:Promise 链式调用的核心规则——then 回调的参数,永远是上一个 then 中返回值(或 Promise 兑现值)

步骤 1:执行 getUserInfo()

getUserInfo() // 返回 Promise,500ms 后 resolve("用户信息")

步骤 2:第一个 then —— then(() => getOrderList())

(这个 then 的回调没有接收上一步的 “用户信息”,放弃“用户信息”,等待新 Promise 兑现)

步骤 3:第二个 then:() => getGoodsDetail() → 返回 Promise(resolve("商品详情"))

回调函数没有接收参数(放弃了上一步的“订单列表”)(放弃“订单列表”,等待新 Promise 兑现)

步骤 4:第三个 then:(res) => console.log(res) → res = “商品详情”

想获取前面的 “用户信息 / 订单列表”:

如果要拿到所有值,需要在每个 then 里接收参数,并把需要的值传递下去(比如返回数组 / 对象):

getUserInfo()
.then((userInfo) => {
    // 接收“用户信息”,并返回「订单列表 Promise + 用户信息」 
    return getOrderList().then(orderList => [userInfo, orderList]);
  })  
.then(([userInfo, orderList]) => {
    // 接收前两个值,返回「商品详情 Promise + 前两个值」 
    return getGoodsDetail().then(goodsDetail => [userInfo, orderList, goodsDetail]);
})
.then(([userInfo, orderList, goodsDetail]) => {
    console.log(userInfo); // 输出:用户信息
    console.log(orderList); // 输出:订单列表
    console.log(goodsDetail); // 输出:商品详情
  });
  

把原本 “纵向嵌套” 的异步代码,变成 “横向链式” 的线性代码,从根本上摆脱嵌套的臃肿和难维护。Promise 的 pending→fulfilled/rejected 不可逆状态,保证了一个异步操作的结果只会被确定一次,且能被后续的 then 可靠捕获

Promise.all

并行请求多个接口:一次性请求多个无依赖的接口,提升效率 等待所有 Promise 都成功,才返回成功数组;只要有一个失败,立即返回失败,全部成功才成功,一个失败就失败。

// 模拟三个接口请求的
const p1 = Promise.resolve("接口1成功"); 
const p2 = Promise.resolve("接口2成功"); 
const p3 = Promise.resolve("接口3成功"); // 并行执行,全部成功后返回结果数组 
Promise.all([p1, p2, p3])
.then((resArr) => {
    console.log(resArr); // 输出:["接口1成功", "接口2成功", "接口3成功"] 
}) 
.catch((err) => { console.log(err); // 只要有一个失败,就执行这里 });

Promise.race([p1,p2,p3])

只要有一个 Promise 完成(成功 / 失败),就返回该结果。谁快听谁的。

Promise.allSettled([p1,p2,p3])

等待所有 Promise 都完成(无论成败),返回每个 Promise 的结果(含状态),全部完成才返回,不区分成败。

async/await

异步编程在一些业务逻辑下存在问题,async/await是一种解决方案。

核心作用是把 “回调式 / 链式” 的异步代码改写成 “同步风格” ,大幅提升异步代码的可读性、可维护性,同时简化错误处理和异步顺序控制。

  1. 回调地狱:多层异步嵌套(比如 “请求用户→请求订单→请求订单详情”),代码缩进层层嵌套,可读性极差;
  2. Promise.then 链:虽然解决了回调地狱,但多步异步会形成长长的.then链式调用,逻辑分散,依然不够直观;
  3. 错误处理繁琐:Promise 需要用.catch单独捕获错误,多层异步的错误处理会分散在不同位置。

async/await正是为解决这些问题而生 —— 让异步代码 “看起来像同步代码”,同时保留异步非阻塞的特性。

// 原Promise链式调用(已解决回调地狱,但仍有多层then)
getUserInfo()
  .then(() => getOrderList())
  .then(() => getGoodsDetail())
  .then(res => console.log(res))
  .catch(err => console.log(err));

// async/await改写后(线性同步风格,无then嵌套)
async function getInfo() {
  try {
    // 加入await 之后,只有在getUserInfo和getOrderList 运行完成后才能往继续往执行代码
    await getUserInfo();
    await getOrderList();
    const res = await getGoodsDetail();
    console.log(res); // 输出:商品详情
  } catch (err) {
    console.log(err);
  }
}
getInfo();

1. async:修饰函数,让函数 “异步化”

  • 加在函数前(async function fn() / const fn = async () => {});
  • 作用 1:让函数返回一个 Promise(无论函数内部 return 什么,都会被包装成Promise.resolve(返回值));
  • 作用 2:允许函数内部使用await关键字(await不能单独使用,必须在async函数里)。

2. await:等待 Promise 兑现,暂停函数执行

  • 只能用在async函数内部;

  • 作用:等待右侧的 Promise 完成(状态变为fulfilledrejected),暂停当前 async 函数的执行,直到 Promise 有结果;

  • 结果:

    • 如果 Promise 兑现(fulfilled),await返回 Promise 的 resolve 值;
    • 如果 Promise 拒绝(rejected),await会抛出错误(需用try/catch捕获)

class

class是 ES6 为 JavaScript 引入的面向对象编程(OOP)语法糖(本质还是基于 JS 原型链机制),一个事物的属性(数据)和方法(行为)封装在一个类中,避免全局变量污染,同时隐藏内部细节、暴露公共接口,让代码更模块化。

所以class 类可以比成 “公共组件” ,class 最核心是“封装” 和 “复用” 特性 —— 再精准一点:class 更像 “可定制的公共组件模板”

  • 封装:把相关的属性(比如用户的姓名、年龄)和方法(比如用户的打招呼、修改信息)打包在一起,像公共组件一样 “聚合成一个整体”;

  • 复用:一次封装后,在项目任何地方都能 “调用” 这个类,避免重复写相同的代码,和公共组件 “一次开发、多处使用” 的逻辑完全一致。

ES5 原型写法 vs ES6 class 写法

// ES5 原型写法(繁琐) 
function User(name, age) { 
    this.name = name; 
    this.age = age; 
} 
User.prototype.sayHello = function() {
   console.log(`我是${this.name},今年${this.age}岁`); 
}; 

// ES6 class写法(简洁,语义化) 
class User { 
    // 构造函数:初始化实例属性 
    constructor(name, age) {
        this.name = name; // 实例属性 
        this.age = age;
    } 
    // 实例方法(挂载到原型上) 
    sayHello() {
        console.log(`我是${this.name},今年${this.age}岁`); 
    } 
} 
// 实例化使用 
const user1 = new User('张三', 25); 
user1.sayHello(); // 输出:我是张三,今年25岁

通过extends关键字可以轻松实现类的继承,子类能复用父类的属性和方法,还能扩展自己的功能,解决了 ES5 原型继承需要手动处理prototypeconstructor的繁琐问题

通过new关键字可以基于类创建多个独立的实例,每个实例拥有自己的属性,同时共享类的原型方法(节省内存),适合创建大量同类型的对象(如用户、商品、组件)

class的固定结构(constructor、实例方法、静态方法、get/set)让代码逻辑更清晰,团队协作时更容易理解和维护

  • class是 ES6 对 JS 原型链的语法封装,让面向对象编程更简洁、语义化;

  • 核心价值是封装(模块化)、继承(代码复用)、实例化(批量创建对象)

  • 规范了代码结构,降低大型项目的理解成本和维护成本,是前端开发中构建复杂逻辑(如组件、业务模型)的核心方式。

创建实例

 let zhang = new People('zhang',20)

 zhang.eat()

 let li = new People('wang',18)

 li.eat()

class类就像是一个模板,当需要时套用模板实现功能,new People就像而是基于同一个 People 模板,创建出具体可使用的成品。即模板是唯一的,而成品是可以多个的。

用蛋糕来形容

  • class Cake = 一个 “蛋糕模具”(只有 1 个,定死了蛋糕的形状、大小,还有 “能吃饭” 的功能);

  • new Cake(...) = 用这个模具烤出具体的蛋糕(cm是草莓味蛋糕,ql是巧克力味蛋糕),传入的参数定了蛋糕大小,颜色,图案;

  • cm.eat()/ql.eat() = 具体的蛋糕能执行 “吃饭” 这个功能(模具本身不能吃饭,只有烤出来的蛋糕能)。

继承

子类继承父类

比如创建了一个class类,就像创建了一个模板A。这时又创建了一个class类,新的模板B。发现A模板的一些功能,B模板需要用到,所以公共功能部分便可以继承,这进一步提高了类的复用性即扩展性。

class 继承(extends)的核心价值 —— 把多个模板(类)的公共功能抽离到父模板(父类)中,子模板(子类)通过继承直接复用,不用重复写相同代码,同时还能在子模板里扩展自己的专属功能,既提升了复用性,又保证了扩展性。

创建基础模板 A(父类)

    class People { 
    
        constructor(name, age) {
            this.name = name; // 公共属性:姓名 
          } 
          
          eat() { // 公共功能:吃饭 
              console.log(`${this.name}正在吃饭`); 
          } 

创建模板 B(子类)—— 继承模板 A 的公共功能,再扩展专属功能

    // 模板B:Student,继承模板A的所有公共功能 
    class Student extends People { 
        // 构造函数:先继承父模板的属性,再添加自己的专属属性
        constructor(name, grade) { 
            // 调用父模板A的构造函数,复用name的初始化逻辑 
            super(name);
            // 模板B的专属属性:年级
            this.grade = grade; 

        } 
    
        study() { 
            // 模板B的专属功能:学习(模板A没有) 
            console.log(`${this.name}在${this.grade}年级学习`); 
        } 
  }
  
  // 基于模板B创建实例(学生)
   const stu = new Student('小明', 6); 
  // 复用模板A的公共功能 
  stu.eat(); // 输出:小明正在吃饭
  // 使用模板B的专属功能 
  stu.study(); // 输出:小明在6年级学习
  • 复用性:模板 B 不用重复写name属性、eat方法,直接继承模板 A 的代码,减少冗余;如果后续要修改 “吃饭” 逻辑,只需要改模板 A,模板 B 会自动同步,不用改多处。

  • 扩展性:模板 B 在复用 A 的基础上,能自由添加自己的专属属性(grade)和方法(study),不会影响模板 A,也不会破坏公共功能,实现 “基于公共、扩展专属”。

如果后续再创建模板 C(Teacher,老师模板),同样需要模板 A 的nameeat,只需要继承 A,再扩展teach()方法即可,不用重复写公共代码:

父类 = 公共模板,子类 = 基于公共模板定制的专属模板

封装

数据的权限和保密:只有typescript支持

三个关键字:

public 完全开放

private 对自己开放

protected 对子类开放

减少耦合,不该外露的不外露,增加安全性,利于数据权限管

多态

同一接口不同实现

原型和原型链

原型

原型是 “共享属性 / 方法的池子”,原型链是 “找这些共享内容的路径”。

为什么需要原型?

直接创建多个同类型对象,每个对象都存相同的方法,会浪费大量内存,且冗余无法达到复用。

// 无原型:每个对象都有独立的eat方法,内存占用高 
const person1 = { name: "张三", eat: () => console.log("吃饭") };
const person2 = { name: "李四", eat: () => console.log("吃饭") }; 

console.log(person1.eat === person2.eat); // false(两个不同的函数)

原型的核心作用是:把多个对象的公共属性 / 方法抽离到一个共享对象里,所有对象都能访问这个共享对象,节省内存

属性所属对象作用
prototype函数是一个对象,存放该构造函数创建的所有实例的「共享属性 / 方法」
__proto__所有对象(除 null)是一个指针,指向创建该对象的「构造函数的 prototype」(原型链的核心)
constructorprototype对象指向「原构造函数」(反向关联,比如 Person.prototype.constructor === Person)
// 1. 构造函数(相当于“对象模具”)
function Person(name) {
  this.name = name; // 实例独有的属性(每个实例都不一样)
}

// 2. 给构造函数的prototype添加共享方法(所有实例共用)
Person.prototype.eat = function() {
  console.log(`${this.name}吃饭`);
};

// 3. 创建实例(基于模具造对象)
const zhang = new Person("张三");
const li = new Person("李四");

// 4. 核心关联:实例的__proto__ 指向 构造函数的prototype
console.log(zhang.__proto__ === Person.prototype); // true
console.log(li.__proto__ === Person.prototype); // true

// 5. 实例调用共享方法(从原型中取)
zhang.eat(); // 张三吃饭
li.eat(); // 李四吃饭
console.log(zhang.eat === li.eat); // true(共用同一个方法,节省内存)

// 6. constructor反向关联
console.log(Person.prototype.constructor === Person); // true

prototype关键字是构造函数的一个专属对象属性(相当于 “公用方法 / 属性仓库”) —— 你把需要共用的方法挂载到这个 “仓库” 里,所有通过该构造函数 new 出来的实例,能通过原型链找到并使用这个 “仓库” 里的方法,核心目的是复用代码、节省内存

构造函数 = 模具,prototype = 模具的 “公用配件库”

  • 构造函数 Person 是 “造人模具”,this.name 是每个实例(张三、李四)独有的 “定制属性”(比如每个人的名字不一样);

  • Person.prototype 是这个模具的 “公用配件库”,eat 方法是 “配件”(所有人都会吃饭,不用给每个人单独造一个 “吃饭功能”);

  • 所有用这个模具造出来的实例(张三、李四),都能共享 “配件库” 里的 eat 方法,不用每个实例都存一份,节省内存。

实例之所以能用到 prototype 里的方法,是因为实例有一个隐藏的 __proto__ 属性,指向构造函数的 prototype(即 zhang.__proto__ === Person.prototype)。当你调用 zhang.eat() 时,JS 会先找 zhang 自身有没有 eat,找不到就通过 __proto__Person.prototype 里找 —— 这就是原型链的 “自动查找”

原型链

当访问一个对象的属性 / 方法时,JS 会按以下规则查找:

  1. 先找对象自身的属性 / 方法,找到则直接使用;
  2. 找不到则通过 __proto__ 找「原型对象(构造函数的 prototype)」的属性 / 方法;
  3. 还找不到则继续找「原型对象的__proto__」(即原型的原型),直到找到或到链的顶端 null

对象访问属性 / 方法时的 “层层查找链条”,顶端是 Object.prototype.proto = null,是 JS 实现继承的核心

class 与原型:class 是原型的语法糖,底层仍基于原型链实现封装、继承。

总结:

每个实例都有隐式原型_proto,实例的隐式原型_proto_指向对应的class 的 prototype。

__proto__ 确实是原型链的「链路指针」,实现 “一层一层往上查找属性 / 方法” 的核心逻辑,而是 JS 中所有对象(除 null)天生就处在原型链中(默认继承 Object),哪怕你没手动写任何继承代码,原型链查找依然会发生

简单说:继承关系分两种 ——「JS 内置的默认继承」(所有对象都有)和「你手动写的显式继承」(比如 class extends / 修改 prototype),原型链查找在这两种情况下都会生效。

__proto__ 的核心作用就是指向原型链的上一级,查找规则是:

  1. 访问对象的属性 / 方法时,先找自身;
  2. 找不到就通过 __proto__ 找「上一级原型对象」;
  3. 还找不到就继续找「上一级原型对象的 proto」;
  4. 直到找到或到原型链顶端 null

JS 中所有对象天生就有默认的原型链(继承自 Object.prototype),哪怕只创建一个空对象,也能通过原型链找到 toStringhasOwnProperty 等方法 —— 这是 JS 内置的 “隐式继承”

// 1. 创建一个空对象,没有手动写任何继承代码
const obj = {};

// 2. 访问 obj.toString():obj 自身没有 toString 方法
console.log(obj.toString()); // [object Object]

// 3. 查找链路(原型链自动生效):
// obj → obj.__proto__ (指向 Object.prototype) → 找到 toString 方法
console.log(obj.__proto__ === Object.prototype); // true
console.log(Object.prototype.toString === obj.toString); // true

// 4. 原型链顶端:Object.prototype.__proto__ = null
console.log(Object.prototype.__proto__); // null