变量
变量是可变的量。将编程思想转换为现实生活中的例子进行理解。可变的量存在一个容器中,就像一个苹果箱里面有着许多苹果,箱子的作用就是用于存放量,而里面的苹果就是实际的值。
如:var apples = 20
var: 相当于制作了一个空箱子
apples: 给这个空箱子贴上苹果的标签,用于识别里面存放的是什么
20: 箱子里放了20个苹果
apples = 30 把20个苹果拿走,换成30个(重新赋值)
整个过程就像是工厂制作好箱子贴上标签,放入苹果,等待客户过来订单拿走。由此可以理解变量的本质:计算机内存中一块有名字的存储空间("箱子"),变量名是 "用于方便识别的标签",变量值是 "箱子里的东西";
var/let
制作空箱子的方式有两种var和let,这两个关键字来声明变量。通过var或let制作出一个空箱子,贴上用于识别的标签。
csharp
声明变量语法: var 变量名; 或 let 变量名;
两者的区别:核心差异集中在作用域、变量提升、重复声明、全局绑定
1.作用域:var 是 "函数 / 全局作用域",let 是 "块级作用域"。
var 无视块级作用域,只认 "函数" 或 "全局" 边界,会从块内 "泄露" 到外部,污染全局。
块级作用域 :就是 {} 包裹的区域(比如 if、for、while 或直接写的 {}),let 声明的变量只在当前块内有效,出了块就 "消失",避免了全局污染。
- 变量提升:
var完全提升,let提升但有 "暂时性死区",变量提升:JS 引擎会把变量声明 "提前" 到作用域顶部,但初始化(赋值)还在原来的位置。
-
var:声明 + 初始化都被提升(提前造了箱子,还往里面放了 "空"),声明前访问不会报错,只会得到undefined; -
let:只有声明被提升,但初始化未完成,声明前访问会报错(这个阶段叫 "暂时性死区")------ 相当于 "提前说要造箱子,但箱子还没做好,不能用"。
- 重复声明:
var允许,let禁止
-
var:同一作用域内可以重复声明同一个变量(相当于给同一个箱子反复贴标签,不会报错); -
let:同一作用域内禁止重复声明(同一个区域不能有两个贴一样标签的箱子,会直接报错)。
- 全局作用域绑定:
var挂到 window,let不挂,在全局作用域(函数外)声明变量时:
var声明的变量会成为window对象的属性(相当于把箱子直接挂在 "房子" 墙上,所有人都能看到);let声明的变量不会绑定到window(箱子放在房子的公共区域,但不挂墙,不属于房子的属性)。
1.作用域
// 用 let 声明(块级作用域)
ini
{
let apple1 = 10;
console.log(apple1); //输出10
}
console.log(apple1); // 报错:apple1 is not defined
生活中的例子来理解:
块级作用域 = 超市的 "分区管理"
-
{}就对应超市里的水果区(一个独立的块); -
let apple1 = 10就是 "水果区专属的苹果箱",这个箱子被明确规定 "只能在水果区范围内"; -
出了水果区(也就是
}之后),到蔬菜区 / 日用品区(块外部),自然找不到这个 "水果区专属箱子",所以console.log(apple1)会报错。箱子里的苹果只能在水果区域。不能在蔬菜或其他日用品区域出现。进行了规定及区域限制。
再举一个例子:
javascript
// 只有上午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(无块级作用域)= 小卖部的随意摆放
javascript
{
var apple2 = 20;
console.log(apple2); //输出20
}
console.log(apple2) //输出20
var 声明的变量就像小卖部没有区域区分和限制,不管是水果区、日用品区,整个小卖部(函数 / 全局作用域)都能用到。
典型场景:for 循环
css
// var 版:循环结束后 i 会泄露,且所有循环体共享同一个 i
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
// 输出 3、3、3(因为共享一个i,最后i=3)
}
// let 版:每次循环都会创建新的 i,块级作用域隔离
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 输出 0、1、2(每个循环有自己的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:第一次循环迭代(同步执行)
- 创建第一个独立的
i变量(块级作用域),初始值为 0; - 判断条件
i < 3(0 < 3,成立); - 执行
setTimeout:把回调函数() => console.log(i)放入异步队列,这个回调会 "记住" 当前这个独立的i=0; - 执行
i++:本次迭代的i变成 1(但这个变化只属于当前迭代的独立i)。
步骤 2:第二次循环迭代(同步执行)
- 创建第二个独立的
i变量(全新的,和上一个无关),初始值继承上一次的结果(1); - 判断条件
i < 3(1 < 3,成立); - 执行
setTimeout:回调 "记住" 当前这个独立的i=1,放入异步队列; - 执行
i++:本次迭代的i变成 2。
步骤 3:第三次循环迭代(同步执行)
- 创建第三个独立的
i变量,初始值为 2; - 判断条件
i < 3(2 < 3,成立); - 执行
setTimeout:回调 "记住" 当前这个独立的i=2,放入异步队列; - 执行
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. 暂时性死区
javascript
// var 提升:提前造了箱子,里面是空的
console.log(banana); // 输出 undefined(箱子存在但没装苹果)
var banana = 15; // 声明+赋值
// let 暂时性死区:箱子还没造好,不能用
console.log(orange); // 报错:Cannot access 'orange' before initialization
let orange = 25; // 声明+赋值
3.重复声明
ini
// 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 不挂
javascript
// 全局
var mango = 30;
console.log(window.mango); // 输出 30(挂在window上)
// 全局
let cherry = 40;
console.log(window.cherry); // 输出 undefined(不挂在window上)
var变量提升
代码是从上一行一行往下执行
javascript
//执行顺序声明一个变量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 声明的变量,指向的内存地址不可变(简单说就是 "箱子不能换,但箱子里的内容可能能改")
- 声明时必须初始化(不能造 "空箱子")
const 声明变量时,必须立刻赋值(往箱子里放东西),不能像 let/var 那样先声明、后赋值,否则直接报错。
错误:const 不能声明空变量 const apple;
正确:声明时必须初始化 const apple = 10;
2.声明后不能给 const 变量重新赋值(相当于不能把整个箱子换成新的),否则报错
const total = 20; total = 30;// 错误:不能重新赋值(换箱子)
- 块级作用域(和
let完全一致)
在所在的 {} 块内有效,出块即失效
4.引用类型(对象 / 数组):内容可改,指向不可改
const 绑定的是简单类型(数字、字符串、布尔值),因为值直接存在 "箱子" 里,指向不可变 = 值不可变;
如果绑定的是复杂类型(对象、数组),"箱子" 里装的是 "指向果篮的地址",地址不可改(不能换果篮),但果篮里的内容可以改。
ini
// 简单类型(值不可变)
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.基本数据类型(原始类型)
基本数据类型:值直接存在变量指向的内存地址(箱子里直接装东西),箱子里直接装苹果、香蕉(值),拿取直接用
1.String 字符串
定义:文本内容,用单引号 / 双引号 / 反引号包裹;
ini
const fruitName = "苹果"; // 双引号
const desc = '红富士苹果'; // 单引号
const priceDesc = `苹果单价:8.99元`; // 反引号
- Number 数字
定义:包含整数、小数、特殊值(NaN、Infinity);
ini
const appleCount = 20; // 整数
const applePrice = 8.99; // 小数
const invalidNum = 10 / "苹果"; // NaN(Not a Number,非数字,注意:NaN 不等于任何值,包括自己)
const bigNum = 1 / 0; // Infinity(无穷大)
- Boolean 布尔值
定义 :只有两个值:true(真)、false(假),用于条件判断;
- Undefined 未定义
定义 :变量声明了但未赋值时的默认值;
javascript
let apple; // 只声明,没赋值
console.log(apple); // 输出 undefined
- Null 空值
定义:主动声明的 "空",表示变量指向的内存地址无内容;
csharp
const emptyBox = null; // 主动表示箱子是空的
- Symbol 符号
定义:唯一的、不可重复的值,用于创建唯一标识;
ini
const id1 = Symbol("apple");
const id2 = Symbol("apple");
console.log(id1 === id2); // 输出 false
- BigInt 大整数
定义 :解决 Number 的精度问题,处理超大整数,后缀加 n,不能和 Number 直接运算,需先转换;
ini
const bigNum = 9007199254740993n; // 大整数
const sum = bigNum + 1n; // 运算时也要加 n,输出 9007199254740994n
2.引用数据类型
- Object 普通对象
定义:键值对(key-value)集合,key 是字符串 / Symbol,value 可以是任意类型;
arduino
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
- Array 数组
定义:有序的集合,索引从 0 开始,本质是特殊的 Object;
perl
const fruits = ["苹果", "香蕉", "橙子"]; // 修改数组内容(允许)
fruits.push("葡萄"); // 新增元素
console.log(fruits); // 输出 ["苹果", "香蕉", "橙子", "葡萄"]
- Function 函数
定义:可执行的代码块,本质是特殊的 Object(可以作为参数、返回值);
- 其他引用类型
-
Date(日期):处理时间,
const now = new Date(); -
RegExp(正则):处理字符串匹配,
const reg = /apple/;
堆和栈
-
栈(Stack,执行栈 / 调用栈) :像取餐口 ------ 空间小、存取快、顺序先进后出,只能放固定大小的物品。
-
堆(Heap) :像仓库 ------ 空间大、能放大小不固定的物品,存取稍慢,物品位置无序,需要标记(地址)才能找到。
JS 引擎正是通过这两个空间的配合,完成所有数据的存储和管理。
堆
-
堆的内存不会自动释放,需要 JS 的垃圾回收机制(GC)定期清理无引用的对象;
-
堆中的数据没有固定顺序,每个数据会有一个「内存地址」(指针),通过这个地址才能找到数据。
ini// 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;
- 此时栈里
obj1和obj2都指向 0x123,相当于两个人拿着同一个门牌号,能找到同一个房子(堆里的对象)。
-
修改阶段 :
obj2.name = "李四"- 引擎先读取栈里
obj2的地址(0x123),然后根据这个地址找到堆里的对象; - 直接修改堆里这个对象的
name属性 ------ 因为房子只有一个,不管用哪个门牌号进去改,房子里的东西都会变。
- 引擎先读取栈里
-
访问阶段 :
console.log(obj1.name)- 引擎读取栈里
obj1的地址(0x123),找到堆里的对象,读取name属性 ------ 自然就是修改后的「李四」。
- 引擎读取栈里
代码2:
ini
let obj1 = { name: "张三" };
let obj2 = obj1; // 注意:这是给obj2重新赋值,不是修改属性 obj2 = { name: "王五" };
console.log(obj1.name); // 输出 张三(而非王五)
-
堆内存:有一个对象
{ name: "张三" },地址0x123; -
栈内存:
obj1 → 0x123、obj2 → 0x123(两个变量都指向同一个堆地址) -
JS 引擎看到你写了
{ name: "王五" }------ 这是一个「全新的对象字面量」,引擎会默认认为你需要一个新对象,因此会在堆里重新开辟一块新空间(比如地址0x456),并把{ name: "王五" }存入这个新地址; -
修改栈里的指针 :把栈中
obj2原来存储的地址0x123替换成新地址0x456; 此时obj1仍指向0x123(原堆对象),obj2指向0x456(新堆对象);堆里同时存在0x123和0x456两个独立的对象,互不影响。 -
JS 中只要写
{}/[]/function(){}等引用类型字面量 ,引擎就会在堆里新建一块空间存储这个新数据;所以obj2 = { name: "王五" }是 "赋值新对象",而非 "修改原对象",所以会先创建新堆地址,再更新栈里obj2的指针;
基本数据类型放在栈中
基本类型放在栈里,是 JS 引擎为了「性能最优」做的设计,栈的存取速度远高于堆,栈内存的核心特征之一是:只能存储「大小固定、已知」的数据。
栈是 "先进后出" 的线性结构,数据的存入(压栈)、取出(弹栈)只需要操作栈顶指针,不需要像堆那样遍历、查找内存地址,CPU 能直接缓存栈的连续内存,访问速度极快;
基本类型是 JS 中使用最频繁的数据(比如数字计算、布尔判断、简单字符串拼接),把它们放在最快的栈里,能最大程度减少内存访问耗时,提升代码执行效率。
如果把基本类型放堆里,每次访问都要先查栈里的指针,再找堆里的数据。
栈是一块连续的线性内存空间,像一排编号固定的小格子。7 种基本数据类型(Number、String、Boolean、Undefined、Null、Symbol、BigInt),它们的值在创建时大小就是固定的:
javascript
- Number:不管是 10 还是 100000,都占用 8 字节(JS 中统一用 64 位浮点数存储);
- Boolean:只有 true/false 两种可能,占用 1 字节;
- String:虽然看起来长度可变,但 JS 中字符串是「不可变的」
- Undefined/Null:占用极小且固定的内存空间。
引用类型(对象、数组等)大小不固定(比如数组可以无限 push 元素),无法提前确定占用多少字节,所以只能放在 "不限制大小、无序存储" 的堆里。
栈是自动释放:函数执行时,变量被压入栈;函数执行结束,对应的栈帧(包含变量)会立即被销毁,内存自动释放。
csharp
// 执行函数时,栈中创建栈帧,存入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共用一个线程。
2. 异步(Asynchronous):不等待,不阻塞线程
异步是为了解决「同步阻塞」问题设计的执行模式,核心规则是:耗时的异步任务不会阻塞主线程,JS 会先跳过它执行后面的同步代码,等异步任务有结果了(比如定时器到时间、网络请求返回),再回头执行对应的回调函数。
javascript
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 导致请求失败 / 数据错误。
javascript
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执行:请求订单,用户ID为 null ( 先完成的异步2先执行,拿到无效数据 )
异步1完成:拿到用户ID 1001
如何解决
让异步任务按「业务逻辑顺序」执行,而非「完成时间顺序」。异步执行顺序从 "时间驱动" 变回 "逻辑驱动";
方案 1:串行执行(依赖型异步,用 async/await)
"必须先 A 后 B" 的场景,让 B 等待 A 完成后再执行:
javascript
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)
适合 "需要所有数据到齐再处理" 的场景,不管谁先完成,都等全部完成后统一执行:
javascript
// 异步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)是为了自定义逻辑;
-
本质是 "参数" :回调函数只是一个 "以函数形式存在的参数",和数字、字符串参数没有本质区别,只是类型不同。
回调函数的同步 / 异步,由执行回调的主函数决定:
- 如果主函数在执行过程中立刻、无延迟 地调用回调函数 → 这是同步回调;
- 如果主函数先执行完,等某个异步操作(定时器、网络请求、文件读取)完成后延迟 调用回调函数 → 这是异步回调。
async/await
异步编程在一些业务逻辑下存在问题,async/await是一种解决方案。
核心作用是把 "回调式 / 链式" 的异步代码改写成 "同步风格" ,大幅提升异步代码的可读性、可维护性,同时简化错误处理和异步顺序控制。
- 回调地狱:多层异步嵌套(比如 "请求用户→请求订单→请求订单详情"),代码缩进层层嵌套,可读性极差;
- Promise.then 链 :虽然解决了回调地狱,但多步异步会形成长长的
.then链式调用,逻辑分散,依然不够直观; - 错误处理繁琐 :Promise 需要用
.catch单独捕获错误,多层异步的错误处理会分散在不同位置。
async/await正是为解决这些问题而生 ------ 让异步代码 "看起来像同步代码",同时保留异步非阻塞的特性。