从0开始学前端(一)

3 阅读32分钟

1.关于浏览器

浏览器种类众多,如IE、谷歌Chrome、火狐Firefox等,因内核不同,过去在页面样式渲染上存在差异。

目前随着技术标准的统一,大部分情况下无需再专门考虑这些兼容性问题。

但浏览器渲染差异问题并未完全消失,虽然差异范围已显著收窄,传统IE兼容性问题基本退出历史舞台,而Safari/WebKit内核的特性差异及移动端WebView碎片化仍是当前主要兼容性挑战。

不同浏览器的差异

1. Safari/WebKit内核的独特限制

  • 新CSS特性的滞后支持

    • Safari对部分前沿CSS特性(如:has()选择器、CSS Nesting)支持明显晚于Blink/Gecko,需通过@supports条件判断或渐进增强策略处理。
    • 100vh在移动端的异常:iOS Safari中100vh会包含浏览器 UI 高度,导致内容被遮挡,需改用100dvh(动态视口单位)或JavaScript动态计算。
  • 私有前缀与渲染细节

    • -webkit-前缀在Safari中仍需保留(如-webkit-line-clamp实现多行文本截断),而其他浏览器已废弃。
    • 字体渲染差异:Safari默认启用-webkit-font-smoothing: antialiased,而Chrome依赖系统设置,可能导致文字清晰度不一致。

2. 移动端WebView的碎片化问题

  • 定制内核的兼容性风险

    • 国内安卓厂商的WebView(如华为X5、腾讯X5)修改了Blink内核,导致position: stickyinput[type="number"]等特性行为异常。
    • 同一Android机型因系统升级可能突变渲染逻辑(如Android 7.0 WebView基于旧版Blink,Android 8.0+升级为Chromium)。
  • iOS WKWebView的保守策略

    • scroll-snap-typeoverscroll-behavior等滚动特性支持较晚,且部分CSS动画需强制开启硬件加速transform: translateZ(0))才能流畅运行。

如今的大多数开发放弃了全浏览器无差别适配的思维,转而通过特性检测、工程化工具和针对性测试,高效解决真实用户场景中的关键差异

进程和线程

进程:

进程是操作系统进行资源分配和调度的基本单元,可以申请和拥有计算机资源,进程是程序的基本执行实体。

当启动某个程序时,就会创建一个进程来执行任务代码,同时为进程分配内存空间,这个程序的状态保持在内存中,关闭程序时,内存空间回收。

进程具备启动其他进程以协同执行任务的能力,且每个进程均拥有独立分配的内存空间。

这种内存隔离机制意味着,当两个进程需要传递数据时,必须依赖操作系统提供的通信管道(IPC,如管道、消息队列、共享内存或Socket)进行交互,无法直接共享内存区域。

正因进程间相互独立,任一进程因异常而卡死或无响应时,其影响会被严格限制在自身边界内,不会波及至其他进程。

例如,若浏览器某个渲染进程因页面脚本错误而崩溃,其他标签页的渲染进程仍能正常运行;

同理,若媒体播放器的音频解码进程持续工作,即便其图形界面进程因UI卡顿无响应,声音播放也不会中断。这种隔离机制为系统稳定性和资源保护提供了坚实基础。

线程:

线程是操作系统能够进行运算调度的最小单位,一个线程中可以并发多个线程,每条线程并行执行不同的任务。

进程可通过创建多个线程将任务分解为多个细粒度子任务并行执行,线程间因共享进程内存空间,可实现高效的数据共享与直接通信,但需通过同步机制(如互斥锁、信号量等)保障共享资源访问的安全性。

浏览中的进程

浏览器是多进程结构,当用户输入URL后,浏览器通过独立进程分工协作完成网络请求、资源解析与页面渲染,其中渲染进程将HTML/CSS/JS转换为像素

  • 浏览器主进程:唯一管理界面(地址栏、书签等)、协调其他进程,不直接渲染网页内容
  • 渲染进程:每个标签页或跨源iframe独立运行,负责解析HTML/CSS、执行JS、生成像素,被严格沙箱隔离以防止崩溃扩散。
  • 网络进程:统一处理所有HTTP请求、DNS解析、缓存管理,避免重复建立连接
  • GPU进程:全局唯一,专责图层合成与硬件加速渲染,确保动画/滚动流畅。

为何需要多进程?

  • 稳定性:单个页面崩溃(如JS死循环)仅影响对应渲染进程,其他标签页不受干扰。
  • 安全性:沙箱机制限制渲染进程直接访问系统资源,高危操作需经主进程IPC授权。
  • 性能平衡:进程过多会增加内存开销,浏览器会智能合并同源站点的进程以优化资源。

2.浏览器如何运行

1.输入处理与请求发起

  • UI线程解析输入类型
    当用户在地址栏输入内容时,UI线程首先判断输入是URL还是搜索关键词。若为URL,启动网络请求;若为关键词,则调用默认搜索引擎。

  • 网络线程接管请求流程
    UI线程通知网络线程执行DNS解析、建立TCP/TLS连接,并发送HTTP请求至服务器。

    • 优化机制:浏览器会预启动渲染进程(与网络请求并行),以缩短后续渲染延迟。

安全校验与响应处理

当网络线程获取到数据之后,会检查站点,检查是否是恶意站点

  • 关键安全检查
    网络线程在接收响应时,会执行多重校验

    • MIME类型验证:确认Content-Type是否为HTML(若为application/zip等则转交下载管理器)。
    • 安全浏览检查(Safe Browsing) :比对已知恶意站点库,拦截高风险页面
    • 跨源读取防护(CORB)阻止敏感跨站数据进入渲染进程,防范信息泄露。
  • 用户干预选项
    若检测到安全风险,浏览器会强制中断导航并显示警告页,但允许用户选择“继续访问”(需明确风险确认)。

2.渲染准备:进程协作与数据传递

网络线程在完成数据接收与安全性校验后,向UI线程发送“数据就绪”通知。UI线程随即触发渲染进程的创建(若未启用站点隔离,可能复用同源进程),并通过进程间通信(IPC)管道将HTML数据流及其他关键资源高效传递至渲染进程,以驱动页面的解析与渲染工作。

 渲染进程的创建与复用

  • 进程分配策略

    • 默认为每个标签页分配独立渲染进程,但若新页面与当前页面同源(协议+根域名相同) ,则复用父页面的渲染进程以节省资源。
    • 启用站点隔离(Site Isolation)  时,跨源iframe会强制分配独立进程,彻底阻断XSS攻击链

 IPC数据传递与提交确认

  • 高效数据传输
    浏览器进程通过IPC管道将HTML数据流传递给渲染进程,而非等待完整下载(支持流式解析)。

  • 提交(Commit)流程

    1. 网络线程通知UI线程“数据就绪”。
    2. UI线程激活预启动的渲染进程,发送“提交导航”IPC指令。
    3. 渲染进程接收数据后返回“确认提交”,此时地址栏更新、会话历史记录被写入磁盘

3.浏览器如何渲染页面

渲染进程接收HTML数据后,通过渲染流水线将HTML、CSS、JavaScript及图片等资源解析并转化为可交互的Web页面。

其中,CSS与图片资源的加载不会阻塞HTML解析进程,二者仅影响页面的样式不影响DOM树生成。

当解析器遇到JavaScript脚本时,将暂停HTML解析,这是因为浏览器无法预知JavaScript执行是否会修改当前页面的HTML结构。

若JavaScript中包含DOM修改操作,提前构建的DOM树可能因结构变更而失效,需重新解析。

这种解析暂停机制本质是一种性能优化策略,通过避免无效解析与重复构建,确保资源处理的精准性与渲染效率。

从URL输入开始,导航阶段:建立连接与获取资源

  • DNS解析:将域名转为IP地址,优先查询浏览器缓存→操作系统缓存→根DNS服务器,耗时通常10-100ms。
  • TCP三次握手 + TLS协商(HTTPS):建立安全连接,HTTP/2或HTTP/3可减少延迟。
  • HTTP请求与响应:浏览器发送请求头(含Cookie、User-Agent),服务器返回HTML内容及关键安全头(如CSP)。

渲染流水线:代码到像素的转换

(1)构建DOM树

  • 解析HTML字节流为结构化节点树,遇到<script>标签默认阻塞解析(除非使用async/defer)。
  • 预加载扫描器会提前请求<img><link>等资源,避免等待DOM完成。

(2)构建CSSOM树

  • 解析CSS生成样式规则树阻塞渲染(因样式缺失会导致布局错乱),但不阻塞DOM解析

(3)生成渲染树(Render Tree)

  • 合并DOM与CSSOM,剔除不可见元素(如display: none),保留visibility: hidden(因仍占布局空间)。

(4)布局(Layout/重排)

  • 计算每个可见元素的精确几何信息(位置、尺寸),自上而下递归进行。频繁触发会严重卡顿

(5)绘制(Paint)与合成(Composite)

  • 绘制:生成分层的像素绘制指令(背景、文本、边框等)。
  • 合成:将图层交由GPU进程独立处理动画属性(如transformopacity),避免重排重绘,实现60fps流畅动画。

3.关于JavaScript线程

众所周知,JavaScript 是单线程语言。那么,它为何采用单线程设计?这带来了哪些优点和缺点?让我们逐步探究。

首先,浏览器本身是多线程的,而 JavaScript 引擎线程只是它众多线程中的一条。

浏览器进程:

进程类型数量核心职责关键说明
Browser Process1(全局唯一)管理窗口/标签页生命周期、权限控制、导航协调所有标签页的“总控中心”,不直接参与页面渲染
Renderer ProcessN(每标签页1个)执行 HTML/CSS/JS、构建 DOM、渲染页面受站点隔离策略保护(不同站点强制分进程),崩溃隔离(单页崩溃不影响全局)
GPU Process1(全局唯一)处理 WebGL、3D 图形、合成图层(Compositing)通过 IPC 与 Renderer Process 通信
Network Process1(全局唯一)统一管理所有网络请求(缓存、Cookie、安全策略)替代传统多线程网络模块,提升安全性和性能
Plugin ProcessN(按需创建)运行第三方插件(如 PDF Viewer)沙箱化运行,崩溃不影响主进程

在浏览器的渲染进程中,主要包含以下线程:

主线程(Main Thread)—— 唯一 DOM 操作入口

工作模式触发时机核心行为互斥性
JavaScript 执行脚本运行、事件回调、定时器触发- V8 引擎解析/执行 JS 代码 - 直接操作 DOM/CSSOM(如 document.createElement阻塞 GUI 渲染: JS 运行期间,页面完全冻结(无布局/绘制)
GUI 渲染JS 执行完毕且任务队列空闲时1. 样式计算(CSSOM) 2. 布局(Layout) 3. 绘制(Paint) 4. 合成(Compositing)阻塞 JS 执行: 渲染期间 JS 无法运行(但罕见,因渲染通常快于 JS)

辅助线程(独立操作系统线程)—— 仅支持特定非 DOM 任务

线程类型职责与主线程关系
Compositor Thread (合成线程)1. 管理图层(Layers) 2. 处理 transform/opacity 等合成动画 3. 生成合成树(Compositing Tree)- 绕过主线程执行动画(无需 JS 参与) - 结果直接送 GPU Process - 不操作 DOM
Raster Threads (光栅线程池)将图层(Layers)转换为 GPU 可用的位图(Rasterization)- 多线程并行工作(利用多核 CPU) - 位图结果送 Compositor Thread - 不操作 DOM
Timer/Network/Event Threads1. setTimeout 计时 2. 网络请求(fetch) 3. 事件监听(如点击)- 仅触发回调 - 回调函数推入主线程任务队列 - 不直接操作 DOM(必须经主线程)
Worker Threads (Web Worker)执行耗时计算(如图像处理、数据加密)- 独立线程(可能跨进程) - 无 DOM 访问权限 - 通过 postMessage 与主线程通信

常见误解

错误说法正确事实
“GUI 渲染线程和 JS 引擎线程是独立线程”两者是主线程的互斥工作状态,物理上为同一线程
“定时器线程直接执行回调”定时器线程仅触发事件,回调必须排队等待主线程空闲后执行
“Web Worker 能操作 DOM”Web Worker 禁止访问 DOM,需通过 postMessage 通知主线程操作
“长 JS 任务不影响合成动画”仅当动画使用 transform/opacity 且提前提升为合成图层时,合成线程可绕过主线程

JavaScript 引擎是单线程,指的是 同一时刻,只有一个任务在执行。既然只能执行一个任务,那么任务必须排队等待执行。

这就好比在商店排队结账:只有一个收银员,一次只能服务一位顾客。前一位顾客必须挑选完商品、打包并付清账单完全离开后,下一位顾客才能进入服务区。

那么这样执行效率就会很低,但为什么还要这样设计呢?

这背后的根本原因在于,JavaScript 对 DOM 的读写操作与 GUI 渲染线程会形成互斥。如果 JavaScript 是多线程的,多个线程同时操作同一个 DOM,就会引发不可预期的渲染结果和严重的数据竞争。这就好比一个收银员同时开了多个窗口,有的要结账,有的要买东西,有的要退货,整个场面就乱套了。

由此可以看出,不仅 JavaScript 引擎是单线程,GUI 渲染线程同样是单线程,其核心目的就是为了避免同时操作 DOM 造成渲染混乱。

至此,我们明白了 JavaScript 为何被设计为单线程。GUI 渲染线程与 JS 引擎线程的互斥设计,本质上,是浏览器为保证 DOM 操作的线程安全而采取的一种简化方案。

这种单线程主线程模型虽然牺牲了部分性能潜力,却从根本上避免了复杂的并发控制,让 Web 开发变得更加可预测和可靠。

4.关于同步

上一段内容了解到javaScript引擎是单线程的,执行代码时从上到下逐行同步执行。需要注意,在代码中混合JavaScript和HTML时,并不存在线程切换,而是主线程在不同任务间的协调调度。

具体执行流程如下:

场景执行顺序原因
长耗时 JS 任务JS 执行 → 等待 JS 结束 → 渲染 → 下一个宏任务JS 执行期间主线程被独占,渲染任务被强制延迟
JS 修改 DOM 后JS 执行 → 清空微任务 → 触发渲染 → 下一个宏任务DOM 变更后,浏览器在微任务清空后立即安排渲染(非任务队列中的任务)
requestAnimationFrameJS 执行 → 清空微任务 → 渲染前执行 rAF 回调 → 渲染 → 下一个宏任务rAF 回调在渲染阶段前插入执行,优先级高于普通宏任务但低于微任务

HTML 解析器暂停解析 → 主线程执行 JS 代码 → JS 执行完毕后恢复解析

<div>解析中...</div>
<script>console.log("JS 执行");</script> <!-- 此处阻塞 HTML 解析 --> 
<div>JS 执行后继续解析</div>

总结: 混合JavaScript与HTML时,主线程会依次处理解析、执行、渲染等任务。遇到 <script> 标签时,HTML 解析器暂停,主线程完全交由 JS 引擎执行,若被长耗时任务阻塞,页面将出现卡顿。

这样就解释了同步的含义:

JavaScript 中的同步是指代码严格按照书写顺序逐行执行,前一个任务必须完全完成后,下一个任务才能开始,且执行过程会阻塞主线程,导致后续任务必须等待。这是 JavaScript 单线程特性的直接体现,适用于简单、即时完成的操作,但长耗时同步任务会导致页面卡顿

通过代码看同步

代码从上至下逐行执行,前一行未完成,后一行不会开始

	<body>
	<button onclick="a()">按钮</button>
	<script>
	console.log("A");
	// 模拟耗时操作(阻塞主线程)
	for (let i = 0; i < 1e9; i++) {} 
	console.log("B");
	// 输出顺序:A → (等待数秒)→ B
	function a(){
		alert("A")
	}
	</script>
</body>

循环执行期间,用户无法滚动页面、点击按钮,因主线程被完全占用。

同步的问题

1. UI 完全无响应

  • 现象
    长耗时同步任务(如大循环、复杂计算)执行期间,页面滚动卡顿、按钮点击无反馈、输入框无法输入
  • 原因
    JavaScript 主线程同时负责执行代码、渲染页面、响应事件。同步任务执行时,渲染和事件处理被强制挂起
  1. 浏览器强制干预
  • 现象
    Chrome 会弹出  “脚本运行过长”警告A script on this page may be busy),Firefox 可能直接终止脚本。
  • 原因
    浏览器为避免页面永久卡死,设置主线程任务执行超时阈值(通常 5~10 秒)。
  • 技术影响
    用户操作被强制中断,且脚本终止后可能遗留未完成的 DOM 状态,导致页面功能异常。
  1. 性能层面
指标同步阻塞影响用户感知
FCP(首次内容绘制)HTML 解析被 <script> 阻塞,内容无法及时渲染白屏时间显著延长
LCP(最大内容绘制)关键资源加载被同步脚本延迟,核心内容渲染超时用户等待关键信息时间过长
INP(交互延迟)点击事件需等待同步任务结束才能触发,延迟可达数百毫秒操作“粘滞感”,体验卡顿

5.关于异步

知道了同步引发的问题,通过异步进行解决。

若所有操作同步执行,耗时任务(如网络请求)会完全阻塞主线程,导致页面卡死、无法响应用户交互。而异步的终极目标:在单线程限制下,通过任务调度实现非阻塞式执行,确保界面流畅性。

异步如何执行

首先,我们需要明确一个核心原则:JavaScript 中的异步任务会在所有同步任务执行完毕后才开始执行。

代码执行时,会按照顺序从上至下推进。当遇到同步任务时,会立即在主线程中执行;若遇到异步任务,则会将其「委托」给浏览器的其他线程(如定时器线程、网络线程等)独立处理,而主线程不会等待其结果,继续执行后续同步代码。

当所有同步任务执行完毕,主线程空闲时,事件循环机制便会启动,从任务队列中取出已就绪的异步任务回调函数,推入调用栈执行。

任务队列是存放异步任务回调函数的队列,而执行异步操作的实际上是浏览器中的其他线程。这些线程并行处理异步任务(如发起网络请求、计时等),不占用主线程资源,从而实现了非阻塞式的多线程协作效果。

例如,当遇到网络接口请求这类异步任务时,主线程会将其委托给浏览器的网络线程独立处理,随后立即继续执行后续同步代码。

网络线程负责发起请求并异步等待响应,待服务器返回数据后(无论成功或失败),网络线程会将携带结果或错误信息的回调函数放入任务队列中。

当主线程执行完所有同步任务并进入空闲状态时,事件循环机制便会从任务队列中取出该回调函数,将其推入调用栈执行,从而完成整个异步流程的闭环。

需要特别注意:任务队列中存放的并非原始的异步任务本身,而是其他线程处理完成后得到的回调函数。当其他线程完成异步任务(无论成功或失败,只要任务状态明确),就会将对应的回调函数(携带结果或错误信息)放入任务队列中等待调用。

因此,主线程并非从任务队列中「取出异步任务重新处理」,而是直接调用队列中已就绪的回调函数,获取其他线程处理好的结果并执行后续逻辑。

总结来说,只有异步任务在其他线程中执行完毕(无论成功或失败),其回调函数才会进入任务队列;若任务仍在其他线程中处理但未完成(例如网络请求超时仍未响应),则不会触发回调入队。

这里要说下调用栈是主线程中用于执行同步任务和调度异步回调的核心机制,如同程序执行的统一出口和中央调度中心——无论是同步代码的逐行推进,还是任务队列中就绪的异步回调函数,最终都会经由调用栈完成执行

异步执行顺序

异步任务会被依次按顺序入队到任务队列中,任务队列遵循先进先出(First-In-First-Out, FIFO)的原则,确保先入队的任务优先被主线程执行。

这里需要在了解一个知识点就是,浏览器会通过网络进程(非单一"网络线程")并行处理多个网络请求,每个请求的响应结果会触发对应的回调函数,这些回调函数被统一推入任务队列。主线程在空闲时,会按队列顺序逐个取出回调函数并执行。

多个网络请求能够并行发起,但回调函数的执行顺序取决于响应到达时间。若两个接口存在业务依赖关系(例如B接口需要A接口的返回数据作为参数),而B接口的响应速度比A更快,会导致B的回调函数先于A进入任务队列并被主线程调用。

此时,由于缺乏A的结果,B接口处理的数据将存在错误,从而影响业务逻辑的正确性。

循环事件

同步代码执行完毕后,主线程通过事件循环机制持续监测任务队列,当检测到异步操作的回调任务就绪时,会将其按优先级调度执行,从而确保在同步流程结束后,异步逻辑也能被及时、有序地处理。

简单来说:事件循环的作用就是让 JS 通过一个“循环”不断检查并执行任务,从而在不阻塞的情况下完成并发,由主线程空闲时自动调度

宏任务和微任务

异步任务在底层处理完成并“就绪”后,并不会进入一个笼统的队列,而是会被分发到两类独立的任务队列中:即宏任务队列微任务队列

“谁先有结果谁先进入任务队列”是基础,微任务和宏任务的区别在于:如果两者同时存在队列中,调用栈优先调用微任务。

但如果宏任务先入队且微任务队列为空,那宏任务就会先被执行。

  1. 入队看时机:异步操作谁先处理完,谁就先乖乖进各自对应的队列排队。
  2. 出队看特权:只要微任务队列里有“插队”的,主线程就必须先处理完它们,才肯去宏任务队列叫号。

假设宏任务队列里已经排了 10 个 setTimeout 回调(早就有结果了),而微任务队列一开始是空的。

  • 主线程一闲下来,发现微任务队列没人插队,就会立刻去宏任务队列叫第 1 个号。
  • 但是!  如果在执行这第 1 个宏任务的过程中,突然产生了一个新的 Promise 微任务(被扔进了微任务队列)。
  • 那么,等这第 1 个宏任务刚执行完,主线程绝对不会去叫第 2 个宏任务的号,而是会立刻转头先把刚插进来的这个微任务给办了。

一句话概括就是:主线程每次准备去宏任务队列“叫号”之前,都必须先回头确认一眼微任务队列是不是真的空了。

需要注意一个小知识点:初始宏任务(整体script代码)开始执行**,主线程从上往下执行同步代码,遇到异步任务就交给浏览器的其他线程处理。初始宏任务执行完毕:当主线程的同步代码全部跑完,第一个宏任务就算结束了。

事件循环开始工作:在去宏任务队列里叫下一个号之前,主线程必须先检查并清空当前的微任务队列执行下一个宏任务:等微任务队列彻底空了,主线程才会去宏任务队列里取出最早的一个任务来执行。循环往复:每执行完一个宏任务,都要回头去检查并清空微任务队列,然后再去取下一个宏任务……如此反复。

关于Promise

Promise 其实就是 JS 里专门用来处理异步操作的“大管家”,它的核心作用就是把以前那种层层嵌套的“回调地狱”变成清晰易懂的链式调用。

为什么会有回调地狱,在异步章节说过一个问题:在异步操作中,如果两个接口存在数据依赖(B 需要 A 的数据),但它们是独立并行的,那么“响应速度”就决定了“执行顺序”。一旦 B 比 A 快,B 就会先拿到主线程的执行权,这时候 A 的数据还没回来,B 就会因为缺少参数而报错。

使用妥协的解法:为了规避上述风险,最直接的方案就是“串行化”——把 B 请求写在 A 请求的回调函数内部。这样,B 的触发权就被 A 握在了手里,只有 A 跑完了,B 才有机会开始。虽然这解决了顺序问题,但随着依赖链条变长(A->B->C->D...),代码的嵌套层级就会越来越深,最终形成了难以维护的“回调地狱”。

回调地狱的本质确实就是嵌套过深导致代码的可读性和可维护性崩塌。如果你想调整一下 B 和 C 的顺序,或者在中间插入一个新逻辑,就需要小心翼翼地搬运一大坨代码,极易因为少了一个括号或分号而引发难以排查的 Bug。

异常处理的“重复劳动”:在回调地狱中,每一层异步操作都可能失败。这意味着你不得不在每一层回调里都写一遍 if (err) { ... } 来处理错误,导致代码里充斥着大量重复的冗余逻辑。

如何使用Promise

Promis有三种状态,而且状态一旦改变就不会再变。

用配送快递来举例:

  • Pending(进行中) :快递还在路上,不知道是顺利送达还是中途丢了。
  • Fulfilled(无论成功或者失败都执行) / Resolved(已成功) :快递顺利送达,你拿到了包裹(拿到了异步操作的成功结果)。
  • Rejected(已失败) :快递中途丢了或破损了(异步操作出错了)。

基础语法与使用

// 1. 创建一个 Promise
const myPromise = new Promise((resolve, reject) => {
  // 模拟一个异步操作(比如发网络请求)
  let isSuccess = true; 

  setTimeout(() => {
    if (isSuccess) {
      resolve('操作成功!这是返回的数据'); // 状态变为 Fulfilled
    } else {
      reject('操作失败!这是报错信息');   // 状态变为 Rejected
    }
  }, 1000);
});

// 2. 接收 Promise 的结果
myPromise.then((result) => {
    // 对应 resolve,处理成功的情况
    console.log('成功啦:', result); 
  })
  .catch((error) => {
    // 对应 reject,处理失败的情况
    console.log('失败啦:', error);
  })
  .finally(() => {
    // 无论成功还是失败,最后都会执行
    console.log('Promise 流程彻底结束');
  });

结果是:

成功啦: 操作成功!这是返回的数据

Promise 流程彻底结束

链式调用

这一步就是解决回调地狱的重要步骤,使用.then()

Promise 最强大的地方在于 .then() 可以一直连下去。

只要在 .then() 里 return 一个新的 Promise,下一个 .then() 就会等这个新的 Promise 出结果。

// 模拟:先获取用户信息,再根据用户信息获取订单,最后获取订单详情
function getUser() {
  return new Promise(resolve => setTimeout(() => resolve('用户ID: 1001'), 1000));
}
function getOrders(userId) {
  return new Promise(resolve => setTimeout(() => resolve(userId + ' 的订单列表'), 1000));
}

//用户信息
getUser()
  .then(userId => {
    //接收到传入的参数
    console.log('第一步拿到:', userId);
    //将参数传入
    return getOrders(userId); // 返回一个新的 Promise
  })
  .then(orders => { 
  
    console.log('第二步拿到:', orders);
  })
  .catch(err => {
    console.log('中间任何一步出错,都会直接跳到 catch:', err);
  });

运行结果:

第一步拿到: 用户ID: 1001

第二步拿到: 用户ID: 1001 的订单列表

详细解释Promise

// 模拟:先获取用户,再根据用户ID获取订单
function getUser() {
  return new Promise(resolve => setTimeout(() => resolve({id: 1001, name: '张三'}), 1000));
}
function getOrders(userId) {
  return new Promise(resolve => setTimeout(() => resolve(['订单A', '订单B']), 1000));
}

getUser()
  .then(user => {
    console.log('第一步拿到用户:', user);
    return getOrders(user.id); // 返回一个新的 Promise,下一个 .then 会等待它
  })
  .then(orders => {
    console.log('第二步拿到订单:', orders);
    return orders; // 返回普通值,会自动被包装成成功的 Promise
  })
  .then(firstOrder => {
    console.log('第三步拿到第一个订单:', firstOrder);
  })
  .catch(err => {
    // 链路上任何一步出错(reject 或报错),都会直接跳到最近的 catch
    console.error('出错了:', err);
  });

运行结果:

第一步拿到用户: {id: 1001, name: '张三'}

第二步拿到订单: (2) ['订单A', '订单B']

第三步拿到第一个订单: (2) ['订单A', '订单B']

getUser().then的动作含义

把 getUser().then(...) 这个动作拆解成两个阶段

  1. getUser() :这是发起请求。它立刻返回了一个 Promise 对象,
  2. .then(user => { ... }) :这是订阅结果。它的意思是:“等刚才那个 Promise 成功出结果了,请立刻把结果传给我,并执行我这里面的回调函数。”

所以,.then 里的 user 参数,就是 getUser 里 resolve({id: 1001, name: '张三'}) 传递出来的那个对象。

resolve表示成功,它的作用就是把异步操作成功拿到的数据(比如从服务器请求回来的用户信息)“打包”好,然后传递给下一个环节。

如果 resolve 里面放的是普通的数据(比如对象、字符串、数字),它会直接把这份数据交给紧接着的 .then 里的回调函数作为参数

  new Promise((resolve) => {
    // resolve 充当发货员,把数据发出去
    resolve({ id: 1001, name: '张三' }); 
  })
  .then(data => {
    // .then 成功接收到了 resolve 传递过来的数据
    console.log(data.name); // 输出:张三
  });

如果传递另一个 Promise(触发链式调用),比如代码里的 return getOrders(user.id))。这时候,当前的 Promise 会 “等待” 这个新的 Promise 执行完毕,拿到新 Promise 的 resolve 结果后,再把这个最终结果传递给下一个 .then

new Promise((resolve) => {
  // resolve 里面放入了另一个 Promise
  resolve(getOrders(1001)); 
})
.then(orders => {
  // 这里的 orders 并不是 getOrders 这个函数本身,
  // 而是 getOrders 内部 resolve 出来的 ['订单A', '订单B']
  console.log(orders); 
});

resolve 的使命就是把异步任务的成功结果“抛”出来。它抛给谁呢?抛给后面紧跟着的 .then。如果它抛出来的是另一个 Promise,那就会一直等那个 Promise 有了结果,再交给后面的 .then

finally 的核心作用就是 “兜底收尾” 。因为它的回调函数无论前面的 Promise 是成功(resolve)还是失败(reject),都一定会被执行。

在实际开发中,我们通常把那些 “不管结果如何,都必须做的清理工作” 放在 finally 里。

如:

隐藏加载动画(Loading 状态)。

关闭或释放资源,防止内存泄漏。重置按钮状态(防重复点击),当用户点击“提交”按钮后,你通常会禁用这个按钮防止重复提交。

等接口请求结束(无论成功还是失败),你都需要重新启用这个按钮,让用户能继续操作。

记录操作日志或上报埋点。

const startTime = Date.now();

someAsyncTask()
  .then(res => console.log('任务成功'))
  .catch(err => console.log('任务失败'))
  .finally(() => {
    const duration = Date.now() - startTime;
    // 无论成败,上报这次操作耗时
    reportLog('任务结束,耗时:' + duration + 'ms'); 
  });

静态方法(通过 Promise 类直接调用)

直接通过 Promise.xxx() 来调用,主要用于控制多个并发异步任务的执行逻辑。

  • Promise.resolve(value)
    快速返回一个已成功(fulfilled) 的 Promise 对象。当你手里有一个现成的值,但业务要求必须返回一个 Promise 时,用它非常方便。

  • Promise.reject(reason)
    快速返回一个已失败(rejected) 的 Promise 对象。

  • Promise.all([p1, p2, ...])
    “同生共死” 它接收一个 Promise 数组,等待所有 Promise 都成功,才返回一个包含所有结果的数组;只要其中任意一个失败,就会立刻返回失败。常用于需要所有接口数据都就绪才能渲染页面的场景。

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

  • “竞速模式” 谁最先改变状态(无论成功还是失败),就返回谁的结果。最经典的用途是实现

    接口请求超时控制(比如把真实请求和一个 5 秒后失败的定时器放在一起 race)。

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

  • “全员总结” 。它会等待数组中所有的 Promise 都结束(不管成功还是失败),最后返回一个包含每个任务状态和结果的数组。适合那些不在乎个别失败,只想拿到所有任务最终报告的批量操作。

  • Promise.any([p1, p2, ...])
    “择优录取” 。只要数组中任意一个 Promise 成功,就立刻返回那个成功的结果;只有当所有 Promise 都失败时,它才会返回失败。常用于多个备用接口的场景(比如从多个 CDN 节点请求资源,哪个快用哪个)。

方法核心逻辑什么时候返回成功?什么时候返回失败?
Promise.all同生共死全部成功只要有 1 个失败
Promise.race竞速模式第 1 个完成且成功第 1 个完成且失败
Promise.allSettled全员总结全部结束(永远成功)不会失败
Promise.any择优录取只要有 1 个成功全部失败

Promise怎么实现超时控制?

如果定时器先跑完,说明请求超时了,我们就主动抛出一个错误来终止操作;如果请求先完成,那就正常返回数据。

利用 Promise.race()

// 封装一个带超时控制的请求函数
function fetchWithTimeout(url, timeout = 5000) {
  // 1. 真实的网络请求
  const realRequest = fetch(url).then(res => res.json());

  // 2. 创建一个代表“超时”的 Promise
  const timeoutPromise = new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('请求超时了!'));
    }, timeout);
  });

  // 3. 让两者进行“竞速”
  return Promise.race([realRequest, timeoutPromise]);
}

// 实际使用
fetchWithTimeout('https://jsonplaceholder.typicode.com/todos/1', 3000)
  .then(data => {
    console.log('请求成功,拿到数据:', data);
  })
  .catch(err => {
    // 无论是网络报错,还是上面的超时 reject,都会走到这里
    console.error('出错了:', err.message); 
  });
  • 如果 fetch 在 3000 毫秒内成功拿到了数据,realRequest 就会率先 resolve,Promise.race 就会立刻返回请求结果,定时器那边虽然还在走,但结果已经被忽略了。
  • 如果 fetch 的网络非常慢,超过了 3000 毫秒还没响应,timeoutPromise 就会率先触发 rejectPromise.race 就会立刻判定为失败,并在 .catch 中抛出“请求超时了!”的错误。

使用的是 axios 这样的 HTTP 请求库,它其实已经在底层帮你封装好了超时控制。你只需要在配置里传入 timeout 参数即可,无需手动写 Promise.race

问题

但是会发现.then() 链写长了还是有点绕,ES7 推出的 async/await 就是专门来拯救这个的。

它让你能用写同步代码的方式去写异步,但底层原理依然是 Promise 和微任务

async/await

async/await 就是 Promise 的“更优雅写法”

  • async 函数自动返回 Promise
  • await 只能在 async 函数内使用,等待 Promise 完成后再继续执行(像“暂停”一样)

async/await 写法(像同步代码一样清晰)

    async function handleData() {
      try {
        const data1 = await fetchData();     // 等待 fetchData 完成
        const data2 = await fetchMore(data1.id); // 等待 fetchMore 完成
        return process(data2);               // 直接返回处理结果
      } catch (err) {
        console.error("出错了", err);        // 自动捕获前面所有 await 的错误
        throw err; // 可选:继续向上抛出
      }
    }

    // 调用
    handleData().then(result => console.log(result));

await 会阻塞当前 async 函数内部的代码,但不会阻塞全局

async function test() {
  console.log("开始");
  await fetch("/api"); // 阻塞:下面的代码要等 fetch 完成才执行
  console.log("结束"); // 这行会延迟执行
}
test();
console.log("全局代码"); // 立刻执行!不会被 await 阻塞

输出顺序: 开始 全局代码 结束 (等 fetch 完成后才输出)

错误必须用 try/catch 捕获

 async function fetchData() {
  try {
    const res = await fetch("/invalid-url");
    return res.json();
  } catch (err) {
    console.error("请求失败:", err); // 这里捕获所有 await 抛出的错误
    return null; // 可返回默认值
  }
}

需要并行请求时,不能直接 await 多个 Promise

const [a, b] = await Promise.all([fetchA(), fetchB()]);