1-3 Promise 知识与使用

119 阅读19分钟

前置知识

JS 运行机制

JS 是单线程的,在同一时间只能做同一件事:协调事件、用户交互、脚本、UI渲染和网络处理等行为。

JS 为什么是单线程:因为 JS 可以操作 DOM,那如果是多个线程操作同一个 DOM,那如何判断优先级呢?所以为了避免这个问题,JS 就只能是单线程的

JS 的代码是一行行执行的,即同步执行

执行时会创建执行上下文,主要有全局、函数执行上下文

遇到函数被调用则创建一个新的函数执行上下文,函数的代码在里面被执行

那遇到异步任务时,不可能一直等待异步任务完成后再执行,因此会放入异步任务处理模块中执行,等其执行完毕后再将其回调函数放到事件队列中,然后由事件循环来处理(需要执行的就拿出来执行)

异步任务分为:微任务、宏任务,微任务优先级高于宏任务

常见的微任务包括 Promise 的 then 方法和 async/await 中的 await,宏任务包括定时器、事件处理、网络请求等

所以整个 JS 运行是由:[同步任务、异步任务(微任务、宏任务)] 不停的循环直到执行完毕

事件循环(Event Loop)

  1. 第一次进入
  2. 遇到同步代码,立即执行
  3. 遇到异步代码放入异步任务处理模块中执行
    1. 执行完成后,若为微任务,将其回调事件放入事件队列-微任务队列中
    2. 执行完成后,若为宏任务,将其回调事件放入事件队列-宏任务队列中
  1. 执行本轮的所有同步代码,直到执行完毕
  2. 进入微任务队列中,执行所有的微任务,直至清空所有的微任务
  3. 按顺序拿出一个宏任务,执行该宏任务,开始下一轮事件循环(重复步骤2)

进程、线程

  • 进程(Process) : 进程是计算机中的一个独立执行单位。一个进程可以包括多个线程,每个线程都在进程的上下文中运行。不同的进程之间是相互独立的,它们拥有各自的内存空间和资源。
  • 线程(Thread) : 线程是进程内的执行单元,它可以看作是进程的一个子任务。一个进程可以包含多个线程,这些线程共享进程的内存和资源,因此它们之间可以更容易地通信和协作。

大白话:一个餐厅就是一个进程,餐厅里面的厨师、端菜、收银等是线程

浏览器原理

浏览器进程、线程

参考:浏览器原理:进程与线程 - 掘金

浏览器渲染原理

浏览器的渲染进程专门来负责将HTML、CSS、JavaScript 转为可视化的页面。

要讲浏览器的渲染原理,那就不得不从源头讲起:浏览器地址栏输入地址到页面渲染,发生了什么?

a. 输入地址后,浏览器开始解析域名,通过 DNS(Domain Name System) 查询到对应的 IP(Internet Protocol)。

    1. 那为什么是 IP?
      1. 因为对计算机来说,IP 才是它能理解的。
    1. 那为什么要找 IP 呢?
      1. 因为我们的静态资源(HTML/CSS/JS/其他资源等)一定是放在某个服务器上的,而对计算机来说, 精确的找到服务器就需要 IP
    1. 域名与 IP 是啥关系?
      1. 域名(英语:Domain Name),是由一串用点分隔的名字组成的Internet上某一台计算机或计算机组的名称,用于在数据传输时标识计算机的电子方位
      2. IP地址:是一个32位的二进制数,通常被分割为4个“8位二进制数”(也就是4个字节)。是一种在Internet上的给主机编址的方式,为互联网上的每一个网络和每一台主机分配一个逻辑地址,以此来屏蔽物理地址的差异。
      3. IP地址和域名是一一对应的,这份域名地址的信息存放在一个叫域名服务器(DNS,Domain name server)的主机内,使用者只需了解易记的域名地址,其对应转换工作就留给了域名服务器。域名服务器就是提供IP地址和域名之间的转换服务的服务器。
      4. 域名的 IP 的别名,一个更容易记得别名
    1. 域名的组成?举例:www.example.com/path/to/fil…
      1. 协议(Protocol) :表示使用的通信协议,常见 HTTP、HTTPS;例子中的协议为:http:。浏览器中为location.protocol
      2. 域名(Domain Name) :是网站在互联网上的唯一标识。例子中的域名为:www.example.com,其中.com是顶级域名,example是主域名,www是子域名。浏览器中为location.host
      3. 端口号(Port) :访问网站的端口号,默认为80(可省略)。例子中的端口为:无,因为例子中没有明确展示端口。浏览器中为location.port
      4. 路径(Path) :表示网站上具体的文件或目录路径。例子中的路径为:path/to/file。浏览器中为location.pathname
      5. 参数(Query) :表示向服务器传递的参数,用于定制请求的内容。查询参数以"?"开头,多个参数之间使用"&"分隔。例子中的参数为:空。浏览器中为location.search
      6. 锚点(Anchor) :表示网页内部的定位点。锚点以"#"开头,用于跳转到网页的特定位置。例子中的锚点为:#section1。浏览器中为location.hash

b. 找到 IP 后,采用TCP(Transmission Control Protocol,传输控制协议)三次握手来确认连接

    1. TCP 三次握手:
      1. 第一次:浏览器向服务器发送一个 SYNC 消息,要求进行同步(同步意味着连接)
      2. 第二次:服务器将回复一个 SYNC-ACK 消息,由 SYNChronization(要求进行同步) 和 ACKnowledgement(确认消息) 组成
      3. 第三次:浏览器回复 ACK 消息
    1. TCP 三次握手的情景模拟
      1. 发送方:老铁,可以听得到我说话吗,老铁。
      2. 接收方:可以听到,你听得到吗?
      3. 发送方:听到了,那我开始说正事了。

c. 针对 HTTPs 协议的,还需要 TLS(Transport Layer Security,传输层安全) 协商握手,一种加密协议,用于确保通信的安全。

    1. TLS 握手的情景解释:发送方与接收方互发消息,然后让第三方参与见证,最终互相协商出一个本次会话的暗号。首先确保互相是互相要找的那个人,其次确保本次会话安全。

d. 获取资源,在建立连接后,浏览器会发起一个初始的 HTTP GET 请求,用于获取 HTML 文件。

    1. HTTP(HyperText Transfer Protocol,超文本传输协议) 请求组成
      1. 请求:请求行、请求头、请求体
        1. 请求行包含:请求方法、请求 URL、HTTP协议与版本
          1. 请求方法:GET、POST、DELETE、PUT、等
            1. GET、POST 的区别
              1. 参数:
                1. GET 的放在请求 URL 后面,不安全,并且 URL 的长度会限制参数大小,没有请求体
                2. POST 的放在请求体,较安全,请求数据大小没限制
              1. 缓存:GET 请求可以被缓存,POST 请求不会被缓存
            1. 常见 GET 请求:地址栏直接访问、<a href="xx">、<img src="xx">
        1. 请求头:通常以键值对 key:value 方式传递数据。
          1. 常见的有:
            1. Authorization: xxxxx
            2. Referer:表示这个请求是从哪个 url 跳过来的,直接访问的话就没有
              1. Referer:https://www.bing.com/
            1. Accept:告诉服务端,该请求所能支持的响应数据类型
              1. Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
            1. Cookie:给服务器用的
              1. Cookie: JSESSIONID=15982C27F7507C7FDAF0F97161F634B5
            1. User-Agent:浏览器通知服务器,客户端浏览器与操作系统相关信息
              1. User-Agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0
        1. 请求体:存放 POST/PUT 请求的参数
      1. 响应:响应行、响应头、响应体
        1. 响应行包含:HTTP协议与版本、状态码与描述
          1. 状态码:是由三位数组成,定义响应状态,第一位决定响应类别
            1. 1xx:指示信息,表示请求已接收,继续处理
            2. 2xx:成功,表示请求已被成功接受与处理
              1. 200 OK:请求成功
              2. 204 No Content:服务器处理成功,无返回内容
            1. 3xx:重定向
              1. 301:永久重定向
              2. 302:临时重定向
            1. 4xx:客户端报错
              1. 400:客户端请求有语法错误
              2. 401:客户端请求未经授权
              3. 403:服务器收到请求,但拒绝提供服务
              4. 404:请求资源不存在
            1. 5xx:服务端报错
              1. 500:服务器发生错误
              2. 503:服务器当前不能处理客户端的请求
        1. 响应头:通常以键值对 key:value 方式传递数据,服务器通过响应头来控制浏览器的行为,不同的头浏览器操作不同
        2. 响应体:服务器发给浏览器的数据,根据不同的 Content-Type,对应的数据类型也不一样

e. 解析 HTML,生成 DOM 树

当浏览器接受到服务器返回的 HTML 文件后,浏览器引擎开始 HTML 解析

    1. 浏览器引擎(不要与浏览器 JavaScript 引擎混淆哦)
      1. 浏览器引擎是每个主要浏览器的核心组件,它的主要作用是结合结构 (HTML) 和样式 (CSS),以便它可以在我们的屏幕上绘制网页。 
      2. 常见浏览器引擎:
        1. Webkit:由 Apple 为 Safari 开发,但在 iOS 上,包括 Firefox 和 Chrome 在内的所有浏览器也由 WebKit 提供支持,使用 C++ 书写的
        2. Gecko:由 Mozilla 为 Firefox 开发,目前仅少数浏览器(Firefox)还在使用它,使用 C++ 和 JavaScript 编写的,自 2016 年起,还用 Rust 编写。
        3. Blink:由 Google 为 Chrome 开发,是 Webkit 的一个分支,使用 C++ 书写的
        4. 可以通过navigator.userAgent查看
          1. Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0
          2. 其中的AppleWebKit代表浏览器引擎
    1. HTML 解析步骤:词法解析与树构造
      1. 词法解析:将服务器返回的 HTML 源代码(不是的话就要解析为 HTML 源代码),最后解析为令牌流(Token),这个流可以被进一步用于构建抽象语法树(AST)等后续解析和处理步骤
      2. 树构造:DOM(Document Object Model,文档对象模型),DOM 树用于描述 HTML 文档的内容
        1. 从根节点<html>开始构建
      1. 解析器是从上到下逐行工作。 
        1. 当解析器遇到非阻塞资源(例如图像)时,浏览器会向服务器请求这些图像并继续解析。 
        2. 另一方面,如果它遇到阻塞资源(CSS 样式表、在 HTML 的 部分添加的 Javascrpt 文件或从 CDN 添加的字体),解析器将停止执行,直到所有这些阻塞资源都被下载

f. 解析 CSS,生成 CSSOM 树

  1. 在解析 HTML 时,遇到 CSS 后,就开始解析 CSS,并生成 CSSOM 树
  2. CSS 解析也是从 词法解析 => 生成源代码 => 生成 Tokens => 构建节点 => 生成 CSSOM 树
  3. CSS 规则是从右到左阅读的,这样的代码:section p { color: blue; }, 浏览器将首先查找页面上的所有 p 标签,然后它会查看这些 p 标签中是否有一个 section 标签作为父标签。

g. 执行 JS,生成 AST 树

浏览器获取 Javascript 文件后,代码被解释、编译、解析和执行。

JS 引擎

是一种在浏览器中执行 JS 代码的软件,用来将 JS 代码翻译为计算机可以理解的东西。

由浏览器提供,不同浏览器供应商,有不同的 JS 引擎

  1. V8:Google 浏览器提供的 JS 引擎,由 C++ 编写
  2. JavaScriptCore:Safari 浏览器的 Webkit 内置的 JS 引擎
  3. Chakra:Edge 浏览器的 JS 引擎,由 C++ 编写
  4. SpiderMonkey:FireFox 浏览器的 JS 引擎,由 C++、Javascript 和 Rust 编写

额外知识:编译、解释、即时编译

编译:将 JS 代码一次性转换为机器代码,并创建一个目标文件,该文件可以在任何机器上运行

解释:逐行检查 JS 代码并立即执行。JS 是解释型语言(不需要编译)

即时编译:JS 代码在执行时(在运行时)被编译,目前大多数浏览器都是用它来运行 JS 代码

JS 代码是如何处理的?

  1. JS 代码进入到 JS 引擎后,开始逐行解析并转为 AST(Abstract Syntax Tree,抽象语法树) 的数据结构
    1. JS 转 AST 工具:AST explorer
  1. 构建 AST 后,然后使用即时编译来执行代码

h. 创建可访问(无障碍)树

可访问性指:尽可能开发处易于访问的内容,无论个人的身体和认知能力以及他们如何访问网络 (ACT-Accessibility Conformance Testing)

ACT:专门测试与评估是否符合无障碍标准的方法

可访问树:是基于 DOM 创建的,在 DOM 树基础上增加一些额外信息,确保更好的无障碍体验,并且使无障碍辅助技术(屏幕辅助阅读、放大镜等)能更好的解释页面内容,并且可访问树与 DOM 树是保持同步更新的

增强可访问性的方法:语义化 HTML、键盘导航、适当描述(alt)等

i. 创建渲染树,并显示到页面上

渲染树是确保页面内容以正确的顺序绘制元素,由 DOM 与 CSSOM 结合而成。

如何创建渲染树呢?

从 DOM 树根节点,遍历可见节点,并在 CSSOM 树里面找到对应规则,最终结合为渲染树(一个包含所有可见节点、内容和样式的树,但不包含节点的尺寸与位置)

渲染树创建完后,进行 Layout(布局)

从渲染树根节点开始,基于设备视口计算每个节点的尺寸与位置。

每次更改节点的尺寸与位置都会触发布局,也就是重排

Layout 后,进行绘制

当 Layout 完后,就可以在屏幕上绘制节点了。

每次更改节点的样式都会触发绘制,也就是重绘

浏览器 tab

浏览器不同 Tab 之间是属于进程,那若想进行通信,可以使用如下方式:

  1. localStorage
    1. 基于同源+事件监听
  1. cookie
    1. 基于同源+定时器
  1. websocket
    1. 基于后端服务实现,提供了发送、接受事件
  1. sharedworker
    1. 类似于websocket,提供了发送、接受事件

浏览器 Storage 与 Cookie

浏览器 StoragelocalStoragesessionStoragecookie
作用在客户端存储值
主要用于在不同页面或会话之间进行数据的持久化和共享主要用于在客户端和服务器之间存储状态信息,以便在不同的 HTTP 请求之间传递数据
存储时间持久存储,除非手动删除或清除缓存临时存储,会话结束数据删除有过期时间不设置过期则属于临时存储,会话结束数据删除
存储大小5MB4KB
跨标签页共享支持不支持支持
使用场景需要长期存储的数据,如用户首选项、主题选择等适合用于临时保存在用户会话期间需要共享的数据,如购物车内容、会话令牌等。主要用于在客户端和服务器之间传递数据,例如用户身份验证、会话管理和跟踪用户活动等。
语法代码xxStorage.setItem("x", xx));xxStorage.getItem("x", xx));xxStorage.removeItem("xx");xxStorage.clear();document.cookie = "xx"

Promise 前世今生

什么是 Promise?

是 JS 处理异步的一种方式,用来解决回调地域,将本应该嵌套的东西处理为链式调用

那什么是异步?

那就要从 JS 说起,JS 是单线程的

为啥 JS 是单线程呢?

因为 JS 是可以操作 DOM 的,那如果 JS 是多线程,那同时对同一个 DOM 进行操作时,那如何确认优先级?

所以为了避免该情况,JS 就被设计为单线程,代码只能一行行执行,执行完当前代码才继续执行,如果未执行完就一直等待,就会发生阻塞,如果执行报错,则中断执行。

所以也就有了同步代码与异步代码

那既然是单线程,那如何处理定时器、Ajax 请求等事件呢?

因为定时器、ajax 请求等事件是需要等待,然后又为了避免出现一直等待,导致浏览器阻塞,并且空闲的资源被浪费,就设计了事件循环来解决这种问题。

这种遇到了不立马执行,而是需要等待后再执行的,就被统称为“异步事件”

那啥是事件循环?

事件循环是指:JS 先执行完执行栈中代码,然后通过轮询的方式从任务队列里面拿可执行的任务,并执行。

那是啥任务队列?

任务队列:里面存放的是“异步”事件执行完成后的回调函数,根据其放入的顺序,逐渐形成一个队列

里面又分为:微任务队列、宏任务队列

常见微任务:Promise.then 里面的代码、await 之后的代码等,队列由 JS 引擎线程维护

常见宏任务:

所以 Promise 是 ES6 提供的一种处理异步的方式

Promise 基础

基础使用语法

new Promise((resolve,reject) => {
  const a = 1;
  resolve(true)
}).then(res => {  
  console.log(res) // true
})

规范

Promise 的状态有哪些?

pending | fulfilled | rejected

Promise 的默认状态?

pending

状态如何流转?

pending => fulfilled

pending => rejected

Promise的返回值是什么?

是个 then 方法:接收 onFulfilled 和 onRejected

手写 Promise

function newPromise(executor) {
  const pending = "pending";
  const fulfilled = "fulfilled";
  const rejected = "rejected";

  // 1. 默认状态-pending
  this.state = pending;

  // 2. 内部变量
  this.value = undefined;
  this.reason = undefined;

  // 新增两个变量来存储成功和失败的回调函数
  this.resolveCallbacks = [];
  this.rejectedCallbacks = [];

  // 3. 成功的回调
  const resolve = (value) => {
    // 状态单向流转控制
    if (this.state === pending) {
      this.state = fulfilled;
      this.value = value;
      this.resolveCallbacks.forEach((cb) => cb(this.value));
    }
  };

  // 4. 成功的回调
  const reject = (reason) => {
    // 状态单向流转控制
    if (this.state === pending) {
      this.state = rejected;
      this.reason = reason;
      this.rejectedCallbacks.forEach((cb) => cb(this.reason));
    }
  };

  executor(resolve, reject);

  this.then = function (onFulfilled, onRejected) {
    if (this.state === fulfilled) {
      onFulfilled(this.value);
    }
    if (this.state === rejected) {
      onRejected(this.reason);
    }

    if (this.state === pending) {
      this.resolveCallbacks.push(() => onFulfilled(this.value));
      this.rejectedCallbacks.push(() => onRejected(this.reason));
    }
  };

  this.catch = function (onRejected) {
    if (this.state === rejected) {
      onRejected(this.reason);
    }
  };
}

const p = new newPromise((resolve, reject) => {
  console.log(1);
  setTimeout(() => {
    resolve("xxx");
  }, 1000);
});

p.then((res) => {
  console.log("[ then ] >", res);
});

补充知识

async/await

基于 Promise 机制,将异步代码的编写变得更同步化。

本质上就是个“语法糖”

面试题

1. 同步、异步基础

console.log(1)
setTimeout(()=>{
  console.log(2)
},0)
console.log(3)
function fn(){
  console.log(4)

  setTimeout(()=>{
    console.log(5)
  },0)
}
fn()

// 问题:打印结果

// 答案:
// 1
// 3
// 4
// 2
// 5

// 原因分析:
// 先创建了全局执行上下文,里面要执行的代码分为:同步、异步
// 全局执行上下文---start
// 然后一行行执行,所以打印 1
// 然后遇到了 setTimeout,是异步任务,则将其回调函数放到事件队列中
// 继续一行行执行,所以打印 3
// 然后又遇到 函数的声明,这仅仅是声明所以继续往下执行
// 最后遇到了函数的调用,则创建函数执行上下文,并执行函数
// 函数执行上下文---start
// 进入函数内,也是一步步执行,所以先打印 4
// 然后遇到了 setTimeout,是异步任务,则将其回调函数放到事件队列中
// 然后函数内的同步代码就执行完毕了
// 开始进入事件队列中,根据先进先出原则,所以先执行全局里面定义的 setTimeout
// 所以先打印 2
// 之后再执行函数里面定义的 setTimeout,所以打印 5

2. 打印顺序1

setTimeout(() => console.log(1));

new Promise((resolve) => {
  console.log(2);
  resolve();
})
  .then(() => console.log(3))
  .then(() => console.log(4))
  .then(() => console.log(5))
  .then(() => console.log(6))
  .then(() => console.log(7))

console.log(8);

Promise.resolve(true)
  .then(() => console.log(9))
  .then(() => console.log(10))
  .then(() => console.log(11))
  .then(() => console.log(12))

// 问:以上代码打印顺序

// 答:
// 2
// 8
// 3
// 9
// 4
// 10
// 5
// 11
// 6
// 12
// 7
// 1

3. 打印顺序2

setTimeout(() => console.log(1), 0);

new Promise((resolve) => {
  console.log(2);

  setTimeout(() => {
    console.log(3);

    new Promise((resolve) => {
      console.log(4);

      setTimeout(() => {
        console.log(5);

        resolve(true);
      }, 0);
    }).then(() => console.log(6));

    resolve(true);
  }, 0);
})
  .then(() => console.log(7))
  .then(() => console.log(8))
  .then(() => {
    setTimeout(() => {
      console.log(9);
    }, 0);
  });

console.log(10);

Promise.resolve(true).then(() => console.log(11));

setTimeout(() => {
  console.log(12);

  Promise.resolve(true)
    .then(() => console.log(13))
    .then(() => console.log(14))
    .then(() => console.log(15));
}, 0);

// 问:以上代码打印顺序

// 答:
// 2
// 10
// 11
// 1
// 3
// 4
// 7
// 8
// 12
// 13
// 14
// 15
// 5
// 6
// 9

4. 打印顺序3

let a = () => {
  setTimeout(() => {
    console.log('任务队列函数1')
  }, 100)
  for (let i = 0; i < 5000; i++) {
    console.log('a的for循环')
  }
  new Promise((resolve)=>{
      console.log('a事件的Promise')
      resolve()
  }).then(()=>{
      console.log('a事件的Promise.then')
  })
  console.log('a事件执行完')
}
let b = () => {
  setTimeout(() => {
    console.log('任务队列函数2')
  }, 20)
  for (let i = 0; i < 5000; i++) {
    console.log('b的for循环')
  }
  new Promise((resolve)=>{
      console.log('b事件的Promise')
      setTimeout(()=>{
          console.log('b事件的Promise的setTimeout')
          new Promise(r=>r()).then(()=>console.log('b事件的Promise的Promise.then'))
          resolve()
      },0)
  }).then(()=>{
      console.log('b事件的Promise.then')
  })
  console.log('b事件执行完')
}
let c = () => {
  setTimeout(() => {
    console.log('任务队列函数3')
  }, 0)
  for (let i = 0; i < 5000; i++) {
    console.log('c的for循环')
  }
  new Promise((resolve)=>{
      console.log('c事件的Promise')
      setTimeout(()=>{
          console.log('c事件的Promise的setTimeout')
          resolve()
      },100)
  }).then(()=>{
      console.log('c事件的Promise.then')
  })
  console.log('c事件执行完')
}
a();
b();
c();

// 打印结果:
// 5000次 a的for循环
// a事件的Promise
// a事件执行完
// 5000次 b的for循环
// b事件的Promise
// b事件执行完
// 5000次 c的for循环
// c事件的Promise
// c事件执行完
// a事件的Promise.then
// b事件的Promise的setTimeout
// b事件的Promise的Promise.then
// b事件的Promise.then
// 任务队列函数3
// 任务队列函数2
// 任务队列函数1
// c事件的Promise的setTimeout
// c事件的Promise.then

排版更好:《1-3 Promise 知识与使用》

参考:浏览器工作原理 - 掘金

参考:浏览器原理:进程与线程 - 掘金