Javacript 高级程序设计(系统学习)

以下为阅读 《Javacript 高级程序设计》部分笔记,待继续完善,后续会进行章节拆分。

第1章 什么是 javascript

  • 历史回顾
  • js 实现 / es / dom / bom
  • js 版本

javascript 最初为什么设计为单线程?

JavaScript 最初设计为单线程的主要原因是出于简单性和安全性考虑。

  1. 简单性: 单线程模型使得 JavaScript 编程更加简单直观。开发者无需考虑线程同步、锁、死锁等复杂的多线程编程问题,可以专注于编写逻辑代码而不是线程管理。

  2. 安全性: 多线程编程往往容易引发各种并发问题,如竞态条件、死锁、数据竞争等。单线程模型可以避免这些问题,从而提高代码的安全性和稳定性。

  3. 网络应用场景: JavaScript 最初主要用于浏览器端的脚本语言,用于增强网页交互和动态内容。在这样的场景下,单线程模型足够满足需求,因为浏览器本身也是单线程的,通过事件驱动的方式处理用户交互和网络请求即可满足大部分需求。

虽然 JavaScript 最初是为了浏览器端的脚本语言而设计的,但随着 Node.js 等服务器端 JavaScript 运行环境的出现,JavaScript 在服务器端的应用也逐渐增多。在服务器端环境中,JavaScript 也可以利用单线程模型来处理大量的并发请求,通过事件循环机制实现非阻塞 I/O,从而提高服务器的性能和吞吐量。

bom api?

用于支持访问和操作浏览器的窗口,但是直到 html5 才有正式的规范。

  • 移动缩放关闭窗口
window.moveTo(x, y);
window.moveBy(x, y);
window.resizeTo(width, height);
window.resizeBy(width, height);
window.close();
  • 弹出新窗口
const newWindow = window.open('https://www.example.com', 'NewWindowName', 'width=500,height=400');

请注意,弹出窗口可能会受到浏览器的弹出窗口阻止设置的影响。在现代浏览器中,为了提供更好的用户体验,浏览器通常会阻止不经用户许可就弹出的窗口。因此,最好的实践是确保弹出窗口是在用户交互的上下文中触发的,比如在点击按钮时。

const openButton = document.getElementById('openButton');
openButton.addEventListener('click', function() {
  let newWindow = window.open('https://www.example.com', 'NewWindowName');
});
  • 对 cookie 的支持
// get 
const allCookies = document.cookie;
// set
document.cookie = 'username=john';
const expires = new Date();
expires.setDate(expires.getDate() + 7);
document.cookie = 'username=john; expires=' + expires.toUTCString();

需要注意的是,使用 document.cookie 进行 Cookie 操作是相对简单,但它有一些限制,例如无法直接删除特定的 Cookie。对于更复杂的 Cookie 操作,可能需要使用服务器端代码或专门的 JavaScript 库。请注意,由于安全性和隐私考虑,现代浏览器对 Cookie 有一些限制,特别是在处理第三方 Cookie 时。在实际应用中,请确保你的 Cookie 操作符合相关的隐私和安全标准。

  • navigator 对象,提供关于浏览器的详尽信息
// user agent
const userAgent = navigator.userAgent; // 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36'
// browser name
const browserName = navigator.appName; // 'Netscape'
// browser version
const browserVersion = navigator.appVersion; // '5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36'
// os
const operatingSystem = navigator.platform; // 'MacIntel'
  • location 对象,提供浏览器加载页面的详尽信息
// location.href
// location.protocol
// location.host
// location.hostname
// location.port
// location.pathname
// location.search
// location.hash

location.href = 'http://'

请注意,直接修改 location 对象可能会导致页面的刷新或导航。在实际应用中,你可能更倾向于使用 window.location 或 document.location 以确保兼容性。

  • performance 对象,提供浏览器性能详尽信息
// timing
// 提供了页面加载各个阶段的时间戳信息,包括页面开始加载、各种重定向、域名查询、TCP 连接、HTTP 请求等
const timing = performance.timing;
const loadTime = timing.loadEventEnd - timing.navigationStart; // loadEventEnd, navigationStart

// now()
// 返回一个精确到微秒级别的时间戳,通常用于测量代码执行时间
var startTime = performance.now();
// exec...
var endTime = performance.now();
var executionTime = endTime - startTime;

// memory
// 提供了有关浏览器内存使用的信息
const memoryInfo = performance.memory;
const usedJSHeapSize = memoryInfo.usedJSHeapSize; // 获取已分配的js堆内存大小 单位为字节

注意,performance 对象包含了许多有用的属性和方法,经常用来开发者测量和监控页面加载、脚本执行等性能方面的指标。

  • XMLHttpRequest 对象
let getDataButton = document.getElementById('getDataButton');
let resultElement = document.getElementById('result');

getDataButton.addEventListener('click', function() {
  const xhr = new XMLHttpRequest();
  // configure xhr
  xhr.open('GET', 'https://jsonplaceholder.typicode.com/todos/1', true);
  // callback
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if (xhr.status === 200) {
        const res = JSON.parse(xhr.responseText);
        resultElement.innerHTML = `${res.title}`;
      } else {
        resultElement.innerHTML = `error: ${xhr.status}`;
      }
    }
  };
});
  • IE 的 ActiveXObject 对象

ActiveXObject 是一种用于在 Internet Explorer 浏览器中创建和操作 ActiveX 对象的 JavaScript 对象。ActiveX 是一种技术,它允许在 Web 页面上嵌入和执行可执行代码(通常是用于 Windows 操作系统的组件)。

ActiveXObject 主要用于支持 Internet Explorer 浏览器中的一些特定功能,通常涉及到AJAX请求、与客户端计算机上的本地资源进行交互,例如文件系统、数据库连接、COM 组件等。

需要注意的是,ActiveXObject 只在 Internet Explorer 浏览器中可用,而在现代浏览器(如 Chrome、Firefox、Edge 等)中是不被支持的。由于安全性和可移植性的考虑,推荐使用更现代的 Web 技术,如 XMLHttpRequest 或 Fetch API 来执行异步请求。

第2章 html 中的 js

  • script 元素 / 标签位置 / 推迟执行 / 异步执行 / 动态加载脚本
  • 行内代码与外部文件
  • 文档模式

script 标签的 integrity 属性?

由于 script 元素的一个最为强大、同时也备受争议的特性是,它可以包含来自外部域的 JavaScript 文件。尤其通过 get 请求获取其他域下资源,不受同源策略的影响。来自外部域的代码会被当成加载它的页面的一部分来加载和解释,这个能力可以让我们通过不同的域分发 JavaScript。不过,引用了放在别人服务器上的 JavaScript 文件时要格外小心,因为恶意的程序员随时可能替换这个文件。在包含外部域的 JavaScript 文件时,要确保该域是自己所有的,或者该域是一个可信的来源。script 标签的 integrity 属性是防范这种问题的一个武器。

integrity 属性,允许比对接收到的资源和指定的加密签名以验证子资源完整性(SRI,Subresource Integrity)。如果接收到的资源的签名与这个属性指定的签名不匹配,则页面会报错,脚本不会执行。这个属性可以用于确保内容分发网络(CDN,Content Delivery Network)不会提供恶意内容。

integrity 属性的值通常是一个包含哈希值的字符串,该哈希值是对应脚本文件的完整内容的加密摘要。浏览器在加载脚本文件时,会计算文件内容的哈希值,并与提供的哈希值进行比较,以验证文件的完整性。

以下是使用 integrity 属性的基本步骤:

  1. 生成哈希值:使用工具或在线服务生成脚本文件的 SHA-256 或其他哈希算法的摘要。例如,你可以使用 OpenSSL、Node.js 的 crypto 模块等。
openssl dgst -sha256 your-script.js
  1. 将哈希值添加到 integrity 属性: 在 <script> 标签中,将生成的哈希值添加到 integrity 属性中。同时,还需要在 crossorigin 属性中指定允许跨域访问的情况。
<script src="your-script.js" integrity="sha256-xxxxxxxxxx" crossorigin="anonymous"></script>

注意,integrity 属性的值格式是 algorithm-hash,其中 algorithm 是哈希算法的名称(如 sha256),hash 是生成的哈希值。 在浏览器中加载页面,浏览器将验证脚本文件的完整性。如果文件被篡改,浏览器将拒绝执行该脚本并报告错误。

注意,这个属性也不是所有浏览器都支持。

script 标签的 crossorigin 属性?

配置相关请求的 CORS 设置,默认不适用 CORS。

配置了 crossorigin="anonymous" 的 script 请求不必设置凭据标志;配置了 crossorigin="use-credentials" 设置凭据标志,标示出站请求会包含凭据

script 标签的 async 和 defer?异步脚本?延迟脚本?

现代网站中,js脚本往往比html要大,加载更久,因此对于浏览器我们如果把脚本放在页面前面加载,会导致:

  • 脚本不能访问到后面的dom元素,无法为他们添加处理程序
  • 脚本加载比较久,用户长时间看不到页面内容

如果把脚本放在页面底部,此时,它可以访问到它上面的元素,并且也不会阻塞页面显示内容。但会出现:

  • 浏览器只有在下载了完整的 HTML 文档之后才会注意到该脚本(并且可以开始下载它),对于长的 HTML 文档来说,这样可能会造成明显的延迟

async 和 defer 是用于控制 JavaScript 文件加载和执行的两个 HTML <script> 标签属性。它们的主要区别在于脚本的加载和执行时机。

async:

  • 使用 async 属性的 <script> 标签告诉浏览器立即异步加载脚本文件,而不会等待其他资源加载或页面解析。
  • 异步脚本会在加载完成后尽快执行,不会阻塞页面渲染,也不会阻止其他资源的加载。
  • 异步脚本的执行顺序不受控制,它们会在下载完成后立即执行。如果多个脚本文件都使用了 async,则它们的执行顺序可能会混乱。
<script src="script.js" async></script>

适用场景:将独立的第三方脚本集成到页面时,此时采用异步加载方式是非常棒的:计数器,广告等,因为它们不依赖于我们的脚本,我们的脚本也不应该等待它们。

defer:

  • 使用 defer 属性的 <script> 标签告诉浏览器要延迟加载脚本文件,直到页面解析完毕后再执行。
  • 延迟加载的脚本会按照它们在文档中的顺序执行,且会在 DOMContentLoaded 事件触发前执行。
  • 延迟脚本不会阻塞页面的渲染,但会按照它们在页面中的顺序执行,因此执行顺序可控。
<script src="script.js" defer></script>

适用场景:当我们需要先加载 JavaScript 库,然后再加载依赖于它的脚本时,这可能会很有用。

动态脚本加载?

除了 script 标签,还有其他方式可以加载脚本。

因为 JavaScript 可以使用 DOM API,所以通过 向 DOM 中动态添加 script 元素同样可以加载指定的脚本。

只要创建一个 script 元素并将其添加到 DOM 即可:

let script = document.createElement('script');
script.src = 'gibberish.js';
script.async = false;
script.onload = function() {};
script.onerror = function() {};
document.head.appendChild(script);

当然,在把 HTMLElement 元素添加到 DOM 且执行到这段代码之前不会发送请求。而当脚本被附加到文档时,脚本就会立即开始加载。

默认情况下,动态脚本的行为是“异步”的:类似 async。

不过这样做可能会有问题,因为所有浏览器都支持 createElement() 方法,但不是所有浏览器都支持 async 属性。因此,如果要统一动态脚本的加载行为,可以明确将其设置为同步加载,即显式地设置 script.async=false,则可以改变这个规则,然后脚本将按照脚本在文档中的顺序执行,就像 defer 那样。

注意,以这种方式获取的资源对浏览器预加载器是不可见的。这会严重影响它们在资源获取队列中的优先级,可能还会严重影响性能。可以让预加载器知道这些动态请求的文件的存在,即在文档头部显式声明:

<link rel="preload" href="gibberish.js" as="script">

preload vs prefetch?

preload 和 prefetch 都是浏览器预加载资源的机制,用于优化网页性能。

preload:

  • preload 是一种资源预加载机制,它用于告诉浏览器在当前页面加载时需要紧急获取并加载的资源。
  • preload 通常用于加载当前页面所需的关键资源,例如页面的主要 CSS 文件、字体文件或在页面加载后立即需要执行的 JavaScript 文件。
  • 使用 <link> 标签的 rel=“preload” 属性来定义需要预加载的资源,然后指定 as 属性来告诉浏览器资源的类型(例如 as=“style”、as=“font”、as=“script” 等)。
  • 预加载的资源会在浏览器发现它们时立即下载,不会等到引用它们的地方。
<link rel="preload" href="styles.css" as="style">

注意,<link rel="preload"> 不会阻塞文档的加载。相反,它可以帮助浏览器在文档加载过程中提前加载指定的资源,例如样式表、脚本文件、字体文件等,从而加快页面的渲染速度和性能。<link rel="preload"> 允许开发者指定需要在页面加载期间立即加载的关键资源,而不会等到浏览器解析到它们所在的位置时再去请求加载。这样可以在文档的其余部分加载完成之前提前加载必要的资源,提高了资源加载的并行性和效率。而且它不会阻塞文档的加载和渲染。因此,即使资源加载尚未完成,浏览器也会继续解析和渲染文档的其余部分,避免了因资源加载时间过长而导致的页面加载延迟。

prefetch:

  • prefetch 是一种资源预取机制,它用于告诉浏览器在将来可能会使用的资源。
  • prefetch 通常用于加载下一个页面可能需要的资源,例如下一页的图片、其他页面的 CSS 文件等。
  • 使用 <link> 标签的 rel=“prefetch” 属性来定义需要预取的资源。
  • 预取的资源会在浏览器有空闲时异步下载,不会影响当前页面的加载。
<link rel="prefetch" href="next-page.css">

文档模式?严格模式?

ECMAScript 5 引入了严格模式(Strict Mode),它是一种更加严格、规范化的 JavaScript 执行环境。启用严格模式后,一些原有的不安全或不规范的语法将会被禁用,同时一些常见的错误也会被抛出。因此 ECMAScript 3 的一些不规范写法在这种模式下会被处理,对于不安全的活动将抛出错误。

如何启用:

// 1. file start
"use strict";

// 2. function
function myFunction() {
    "use strict";
    // 函数体
}

以下是严格模式的一些特性和限制:

  1. 全局变量的显式声明: 在严格模式下,全局变量必须通过 var、let 或 const 显式声明,否则将会抛出一个引用错误。
  2. 禁止使用 with 语句: with 语句在严格模式下被禁止使用。
  3. 禁止删除变量: 在严格模式下,使用 delete 删除变量、函数、函数参数等是不允许的。注意,如果你的目标是删除对象的属性,那使用 delete 操作符是允许的
  4. 禁止对只读属性赋值: 在严格模式下,对只读属性赋值会引发一个类型错误。
  5. 禁止使用八进制字面量: 在严格模式下,八进制字面量将会引发一个语法错误。例如,0123 在严格模式下是不合法的。
  6. 函数中的 this: 在全局函数调用中,严格模式下的 this 是 undefined,而不是全局对象。即全局对象和全局函数调用中的 this 不再绑定,这有助于避免一些潜在的错误,并使代码在全局上下文中更安全。
  7. 禁止使用 arguments.callee 和 arguments.caller: 在严格模式下,这两个属性都被禁用。
  8. 函数参数的唯一性: 在严格模式下,函数参数的名称必须是唯一的,重复的参数名将会引发一个语法错误。
  9. eval 的影响: 在严格模式下,使用 eval 创建的变量不会影响周围的作用域。
  10. arguments 对象的限制: 在严格模式下,对 arguments 对象的修改不会影响对应的函数参数。
  11. 保留字的限制: 严格模式增加了一些保留字,不能用作变量名、函数名或形参名。例如,implements、interface、let、package、private、protected、public、static 和 yield 等。

第3章 语言基础

  • 语法
  • 关键字与保留字
  • 变量
  • 数据类型
  • 操作符
  • 语句
  • 函数

ECMAScript变量是松散型的?

变量可以用于保存任何类型的数据。每个变量只不过是一个用于保存任意值的命名占位符。

var、let、const的区别?

  • var
  1. 函数作用域。使用 var 操作符定义的变量会成为包含它的函数的局部变量,函数调用完毕即被销毁
  2. 去掉 var 操作符。可以将声明的变量变成全局变量(不推荐)
  3. 声明提升(hoist)。会默认把有变量声明都拉到函数作用域的顶部
  • let
  1. 块作用域。比如在if块里声明的变量,在外部无法使用
  2. 不允许重复声明。
  3. 暂时性死区。不会在作用域中被提升,所以let声明之前的执行逻辑会被称为暂时性死区,不能使用任何后面才声明的变量
  4. let声明的变量不会成为window对象的属性
  5. 解决了for循环定义的迭代变量问题
for (var i = 0; i < 5; ++i) {
  setTimeout(() => console.log(i), 0)
}
// 因素一 或者说前提条件:箭头函数特性
// 因素二 settimeout 异步机制
// 因素三 var 函数作用域特性

这段代码使用了 setTimeout,在每次循环迭代中输出变量 i 的值。然而,由于 JavaScript 的事件循环机制,这可能会导致意外的结果。在这个例子中,setTimeout 的回调函数是箭头函数,箭头函数具有词法作用域,它们捕获(绑定)了它们所在上下文的变量。在这种情况下,箭头函数捕获了包含它的 for 循环的上下文,其中的 i 是一个共享的变量。在传统的函数中,会创建一个新的执行上下文,其中包含一个新的变量 i,每次循环迭代都会创建一个新的变量。但箭头函数不同,它不会创建自己的执行上下文,而是捕获了它所在的上下文的变量。因此,在 setTimeout 中的箭头函数中引用的 i 实际上是对外部 for 循环中 i 的引用。并且,在 for 循环中使用 var 声明的变量具有函数作用域,而不是块作用域。这就意味着在整个循环中,只有一个 i 变量,并且在循环结束时,它的值是循环中最后一次迭代的值。因此,当 setTimeout 中的箭头函数被执行时,它会访问循环结束后的 i 的最终值,而不是每次迭代中的值。

至于为什么 setTimeout 会在循环结束再执行?原因是 setTimeout 中的回调函数是异步执行的,而且 JavaScript 的事件循环机制导致这些回调函数会在当前执行栈执行完毕之后才会被调度执行。在这个特定的场景中,整个 for 循环会在非常短的时间内完成,而多个 setTimeout 中的回调函数会被放入事件队列,并在当前代码执行完成后被调度执行。即循环迭代完成,JavaScript 引擎才将控制权交还给事件循环。事件循环检查是否有任务在事件队列中等待执行,发现了 setTimeout 中的回调函数。

具体来说,这段代码会输出 5 次值为 5 的结果,而不是期望的输出 0、1、2、3、4。为了解决这个问题,可以通过将 let 替换为 var,或者通过创建一个闭包来捕获每次迭代中的 i 值。

// let
for (let i = 0; i < 5; ++i) {
  setTimeout(() => console.log(i), 0);
}

// 闭包
for (var i = 0; i < 5; ++i) {
  (function (j) {
    setTimeout(() => console.log(j), 0);
  })(i);
}

而使用 let 声明迭代变量时,JavaScript 引擎在后台会为每个迭代循环声明一个新的迭代变量。每个 setTimeout 引用的都是不同的变量实例。即第一种方式利用了 let 在每次迭代中创建一个新的变量,而第二种方式通过创建一个闭包在每次迭代中捕获当前的 i 值。

  • const
  1. 与let基本相同,唯一区别就是:用它声明变量时必须同时初始化变量,且尝试修改 const 声明的变量会导致运行时错误
  2. 声明限制。只适用于指向的变量的引用,如果 const 变量引用的是一个对象, 那么修改这个对象内部的属性并不违反 const 的限制

数据类型?

基本数据类型(Primitive Types):

// undefined: 表示未定义或未初始化的值
// null: 表示空值或者没有对象值
// boolean: 表示逻辑值,即 true 或 false
// number: 表示数值,整数或浮点数
// bigint: 在 ECMAScript 2020 中引入,BigInt 是一种用于表示任意精度整数的新数据类型,允许存储超出 JavaScript Number 类型范围(>2^53 - 1)的整数
// symbol: 表示唯一的标识符,用于创建对象属性的唯一键

引用数据类型(Reference Types):

// object: 表示对象,用于存储复杂数据结构和功能
// array: 表示数组,是一种特殊的对象,用于按索引顺序存储值
// function: 表示函数,是一种特殊的对象,可以被调用

typeof object?typeof null?typeof function?

JavaScript 中的 typeof 运算符用于确定给定变量的数据类型。它返回一个表示变量类型的字符串。

typeof 的原理是检查变量的数据类型,并返回一个对应的字符串表示。

下面是一些常见数据类型的 typeof 返回值:

  • undefined: 如果变量未定义或已声明但未初始化,则返回 "undefined"
  • boolean: 如果变量是布尔值,则返回 "boolean"
  • number: 如果变量是数字,则返回 "number"
  • string: 如果变量是字符串,则返回 "string"
  • bigint: 如果变量是大整数,则返回 "bigint"
  • symbol: 如果变量是符号,则返回 "symbol"
  • function: 如果变量是函数,则返回 "function"
  • object: 如果变量是对象(包括数组、null 和普通对象),则返回 "object"

需要注意的是,typeof null 返回 "object",这是 JavaScript 中的一个历史遗留问题。此外,typeof 并不总是能够精确地区分所有的对象类型,例如,它无法区分数组、普通对象和 null,它们都会返回 "object"

那么 js 在底层是怎么存储数据的类型信息的?

JavaScript 在底层存储变量的类型信息是通过变量的机器码的低位 1-3 位来实现的。这些位存储了变量的类型信息,以便在运行时进行类型检查和转换。具体来说:

  • 对象:类型信息位为 000。
  • 浮点数:类型信息位为 010。
  • 字符串:类型信息位为 100。
  • 布尔:类型信息位为 110。
  • 整数:类型信息位以 1 开头。
  • null:所有机器码均为 0。这也是为什么 typeof null === 'object'
  • undefined:用 −2^30 整数来表示。

这样的存储方式使得 JavaScript 引擎可以快速地确定变量的数据类型,从而进行相应的操作。

此外,根据 ECMA-262 规范规定,任何实现内部 [[Call]] 方法的对象都应该在 typeof 检测时返回 “function”。

null?

  • 表示一个空对象指针,即对空对象的引用
  • 在定义将来要保存对象值的变量时,建议使用 null 来初始化,不要使用其他值
  • undefined 值是由 null 值派生而来的,因此console.log(null == undefined); // true。但是如果使用 === 为 false。因为前者会进行隐式转换

Boolean()转换规则?

  • String为false的场景:空字符串
  • Number为false的场景:0、NaN
  • Object为true的场景:任意对象
  • Object为false的场景:null

js八进制?

对于八进制字面量, 第一个数字必须是零(0),然后是相应的八进制数字(数值0-7)。如果字面量中包含的数字超出了应有的0~8范围,就会忽略前缀的零,后面的数字序列会被当成十进制数,如079会被当成79处理

js十六进制?

对于十六进制字面量,数值前缀必须为 0x(区分大小写),然后是十六进制数字(0-9 以及 A-F)。十六进制数字中的字母大小写均可。

js 的 Number 最值?

Number.MIN_VALUE,这个值在多数浏览器中是 5e-324( 5 乘以 10 的负 324 次方)。这个常量表示 JavaScript 中可以表示的最小正数,即最接近零的浮点数。

Number.MAX_VALUE,这个值在多数浏览器中是 1.797 693 134 862 315 7e+308。这个常量表示 JavaScript 中可以表示的最大正数,即最大的浮点数。

这些常量是 JavaScript 语言规范中定义的,并且它们分别代表了 IEEE 754 双精度浮点数的最大正数和最小正数。

请注意,JavaScript 中的浮点数表示是有限的,因此它们在表示极大或极小值时可能会失去精度。在处理需要非常大或非常小的数值时,要特别小心处理舍入误差和精度丢失。如果需要更高的精度,可以考虑使用 BigInt 类型或专门的数学库。

注意,如果超出表示范围,用一个特殊的 Infinity 值表示。

js 的 Number 整数最值?

在 JavaScript 中,有两个特殊的常量表示整数的最大和最小安全整数:

  1. Number.MAX_SAFE_INTEGER 这个常量表示 JavaScript 中可以被精确表示的最大整数。它的值为 2^53 - 1,即 9007199254740991
console.log(Number.MAX_SAFE_INTEGER);  // 输出 9007199254740991
  1. Number.MIN_SAFE_INTEGER 这个常量表示 JavaScript 中可以被精确表示的最小整数。它的值为 -2^53 + 1,即 -9007199254740991
console.log(Number.MIN_SAFE_INTEGER);  // 输出 -9007199254740991

这两个常量的命名中包含了 “SAFE” 一词,表示这是在 JavaScript 浮点数表示范围内的最大和最小整数。超过这个范围的整数可能会导致精度丢失。

如果你需要处理更大或更小的整数,可以考虑使用 BigInt 类型。 BigInt 类型没有上述整数范围的限制,因为它可以表示任意精度的整数。

js 的最小负数?

如果你想获取表示负无穷大的值,你可以使用 Number.NEGATIVE_INFINITY。这个常量表示负无穷大,即小于任何负数的值。

这个值在一些特定的数学运算中可能会出现,表示一个负无穷大的结果。在实际编程中,你可能会在处理负无穷大的情况时用到这个常量。

js如果两个大数相加超出最大正数,会发生什么?

在 JavaScript 中,如果两个大数相加导致结果超出了能够精确表示的最大正数范围,就会发生溢出(overflow)和精度丢失。JavaScript 中的数值类型是双精度浮点数,能够表示的范围是有限的。当进行超出这个范围的运算时,会导致结果不准确。

例如,如果你尝试将两个大于 Number.MAX_SAFE_INTEGER 的整数相加,结果可能会失去精度,而不是得到正确的值:

let result = Number.MAX_SAFE_INTEGER + 1;
console.log(result);  // 输出 Number.MAX_SAFE_INTEGER + 1,而不是实际的值

如果你需要处理非常大的整数,可以考虑使用 BigInt 类型,它能够更精确地表示和处理大整数:

let bigIntResult = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1);
console.log(bigIntResult);  // 输出实际的 BigInt 结果

使用 BigInt 类型,你可以更好地处理大整数的运算,而不会丢失精度。请注意,BigInt 类型是在 ECMAScript 2020 中引入的,因此需要确保你的运行环境支持该特性。

BigInt?

BigInt 可以存储非常大的整数,而不受常规 JavaScript 数字类型的限制,这是因为 BigInt 是一种新的数据类型,它不是基于 IEEE 754 浮点数标准的,而是表示任意精度的整数。

比如,在 JavaScript 中,常规的 Number 类型是基于浮点数的,遵循 IEEE 754 标准,它对整数有一个限制。JavaScript 中的数字使用 64 位双精度浮点数表示,其中包括一个符号位、11 位指数部分和 52 位尾数部分。这使得 JavaScript 能够精确表示的整数范围有上限,即 Number.MAX_SAFE_INTEGER 和 Number.MIN_SAFE_INTEGER。

而 BigInt 是在 ECMAScript 2020 引入的一种新的数据类型,它被设计用于表示任意精度的整数。BigInt 的存储不依赖于浮点数表示,而是使用可变长度的整数表示。这使得 BigInt 能够表示远超过常规整数范围的整数,而且不会有浮点数精度问题。

在理论上,BigInt 可以存储的整数大小是没有上限的,因为它不受浮点数的精度限制。在实际中,BigInt 的范围由计算机内存的限制所限制,也就是和说它可以动态地调整存储空间以容纳任意大的整数。

以下是一个简单的示例,演示了使用 BigInt 存储非常大的整数:

let largeNumber = BigInt("123456789012345678901234567890123456789012345678901234567890");
console.log(largeNumber);  // 输出 BigInt 类型的大整数

在这个示例中,largeNumber 是一个 BigInt 类型的变量,存储了一个非常大的整数。这使得 BigInt 成为处理需要更大范围整数的计算或应用的有用工具。

如何避免数值溢出?

避免数值溢出问题是一项重要的任务,特别是当处理大数值或使用计算机程序执行数学运算时。以下是一些通用的方法来防止数值溢出:

  1. 使用合适的数据类型: 确保选择合适的数据类型来存储你的数值。例如,在编程语言中,可以选择使用具有更大范围的整数或浮点数数据类型。

  2. 范围检查: 在进行任何数学运算之前,检查输入的数值是否在你的程序或系统可以处理的合理范围内。如果超过了范围,可以采取适当的措施,如报告错误或截断数值。

  3. 使用高精度库: 如果你的程序需要处理非常大或非常小的数值,可以考虑使用高精度数学库,这些库可以提供更大的数值范围和更高的精度。

  4. 分解大运算: 如果你执行大型运算(如乘法或指数运算),可以将其分解成更小的步骤,以减小中间结果的大小。

  5. 溢出检查: 在进行关键计算之前,进行溢出检查是一个好的实践。这可以通过检查运算结果是否超出了所使用数据类型的范围来完成。

  6. 使用饱和运算: 有些情况下,你可能希望在溢出发生时将结果饱和到数据类型的最大或最小值,而不是继续增加或减少。

  7. 使用无符号数据类型: 如果你知道数值永远不会为负,可以考虑使用无符号整数数据类型,它们具有比相同位数的有符号整数更大的正数范围。

  8. 异常处理: 在一些情况下,如果溢出是不可避免的,你可以通过捕获异常或采取适当的错误处理措施来处理这种情况。

每种情况都有其特定的解决方法,具体取决于你的应用程序和使用的编程语言。在处理数值时,了解数据类型的范围、运算的性质以及所使用的编程语言的特性是很重要的。

NaN?

表示本来要返回数值的操作,注意,不是抛出错误,而是返回了一个 NaN,表示计算没有得到数值而已

特性:

  • 任何涉及 NaN 的操作始终返回 NaN
  • NaN 不等于包括 NaN 在内的任何值

三种数值转换函数?

把非数值转换为数值:

  • Number(): 可用于任何数据类型。注意,一元加操作符应用到非数值也会执行一样的转换规则
  • parseInt(): 主要用于将字符串转换为数值
  • parseFloat(): 主要用于将字符串转换为数值

Number()数值转换规则?比如对象内部如何转换?

  • 布尔值

true 转换为 1,false 转换为 0

  • null

返回 0

  • undefined

返回 NaN

  • 字符串

如果字符串包含数值字符,包括数值字符前面带加、减号的情况,则转换为一个十进制数值,可忽略前面的零,如"011"为11;如果字符串包含有效的浮点值格式如"1.1",则会转换为相应的浮点值(同样,忽略前面的零);如果字符串包含有效的十六进制格式如"0xf",则会转换为与该十六进制值对应的十进制整数值;如果是空字符串(不包含字符),则返回 0;如果字符串包含除上述情况之外的其他字符,则返回 NaN

  • 对象(重点理解)

如果对象类型是一个 Date,使用 toString() 方法(因为Date类型的值的valueOf()方法会返回一个时间戳数值)。

其他的则先调用 valueOf() 方法,并按照上述规则转换返回的值,如下例:

let numObject = new Number(42);
typeof numObject // 'object'
let primitiveValue = numObject.valueOf(); // 42

如果转换结果是 NaN,则继续调用 toString() 方法,再按照转换字符串的规则转换。比如 Number(testObj),valueOf 返回 NaN,再用 toString(),返回 ‘[object Object]’,是一个字符串,且一样为 NaN

const a = { k: 'test' }
const primitiveValue = a.valueOf() // {k: 'test'}
primitiveValue.toString() // '[object Object]'
// 根据Number(字符串)的规则 结果为 NaN

为什么要另外引入 parseInt()?

考虑到用 Number() 函数转换字符串时相对复杂且有点反常规,通常在需要得到整数时可以优先使用 parseInt()函数。parseInt()函数更专注于字符串是否包含数值模式

parseInt() 数值转换规则?

字符串最前面的空格会被忽略,从第一个非空格字符开始转换。如果第一个字符不是数值字符、加号或减号,parseInt()立即返回 NaN。这意味着空字符串也会返回 NaN(这一点跟 Number()不一样,它返回 0)。如果第一个字符是数值字符、加号或减号,则继续依次检测每个字符,直到字符串末尾,或碰到非数值字符。比如, "1234blue"会被转换为 1234,因为"blue"会被完全忽略。类似地,"22.5"会被转换为 22,因为小数 6 点不是有效的整数字符

假设字符串中的第一个字符是数值字符,parseInt()函数也能识别不同的整数格式(十进制、八进制、十六进制)。换句话说,如果字符串以"0x"开头,就会被解释为十六进制整数。如果字符串以"0" 开头,且紧跟着数值字符,在非严格模式下会被某些实现解释为八进制整数

parseFloat() 数值转换规则?

  • 只解析十进制浮点值
  • 始终忽略字符串开头的零,如0908.5为908.5
  • 十六进制数值始终会返回 0,如0xf
  • 如果字符串表示整数(没有小数点或者小 数点后面只有一个零),则返回整数

字符串中的 \xnn\unnnn

  • \xnn

以十六进制编码nn表示的字符,如\x41表示A

  • \unnnn

以十六进制编码nnnn表示的 Unicode 字符,如\u03a3表示希腊字 符Σ

而且,字符串中的 \xnn\unnnn 仅表示一个字符,因此length也为1,不会多算

模板字符串?

在定义模板时特别有用,如html模板。因为可以保留换行符,可以跨行定义字符串。

在 JavaScript 中,使用模板字符串定义跨行内容时,可以在模板字符串中直接换行,而不需要使用特殊的字符或转义符。

const multiLineString = `
  这是一个
  跨行的
  字符串。
`;
console.log(multiLineString);

这种语法对于创建多行文本或在字符串中包含换行符很方便。这样的字符串保留了你在代码中的格式,并且在输出时保留了换行符和缩进。

Symbol 类型?

Symbol(符号)是原始值,且符号实例是唯一、不可变的。

符号的用途是确保对象属性使用唯一标识符,不会发生属性冲突的危险。符号没有字面量语法,这也是它们发挥作用的关键。

按照规范,你只要创建 Symbol() 实例并将其用作对象的新属性,就可以保证它不会覆盖已有的对象属性,无论是符号属性还是字符串属性。

使用全局符号(Symbol)注册表?

ES6引入了全局符号注册表(Global Symbol Registry),允许你在多个执行上下文中共享和重用 Symbol。

以下是如何在全局符号注册表中注册和获取 Symbol 的简单示例:

const mySymbol = Symbol.for('mySymbol'); // 在全局符号注册表中注册一个符号
const sameSymbol = Symbol.for('mySymbol'); // 在另一个执行上下文中获取相同的符号
console.log(mySymbol === sameSymbol); // 输出 true

const symbolDescription = Symbol.keyFor(mySymbol); // 通过 Symbol.keyFor 方法,你可以通过符号获取其描述信息(在这里是注册时使用的字符串键)
console.log(symbolDescription); // 输出 'mySymbol'

在上述示例中,Symbol.for 方法允许你使用一个字符串作为键在全局符号注册表中注册一个符号,如果该字符串已经存在,它将返回现有的符号。

全局符号注册表的好处之一是,它允许你在不同的执行上下文中共享和重用相同的 Symbol。这对于在不同的模块或代码文件中共享标识符非常有用。

为对象创建 Symbol 类型属性?

let s1 = Symbol('foo_one_key');
let s2 = Symbol('foo_two_key');
// 输出:Symbol(foo)

// 1. 使用对象字面量创建
let o = { [s1]: 'foo val one' };
// 2. 使用Object.defineProperty创建
Object.defineProperty(o, s2, { value: 'foo val two' });
// 3. 使用Object.defineProperties
Object.defineProperties(o, {
    [s1]: {value: 'foo val'},
    [s2]: {value: 'bar val'},
});

Object.getOwnPropertyNames(obj)?Object.getOwnPropertySymbols(obj)?Object.getOwnPropertyDescriptors(obj)?Reflect.ownKeys()?

  • Object.getOwnPropertyNames(obj)

返回一个数组,包含指定对象所有自身的(非继承的)可枚举属性的名称字符串。注意,是常规属性,不包含 Symbol 类型的属性

let s1 = Symbol('foo'), s2 = Symbol('bar');
let o = {
    [s1]: 'foo val',
    [s2]: 'bar val',
    baz: 'baz val',
    qux: 'qux val'
};
console.log(Object.getOwnPropertyNames(o)); // ["baz", "qux"]
  • Object.getOwnPropertySymbols(obj)

返回一个数组,包含指定对象所有自身的(非继承的)Symbol 类型属性的 Symbol 对象。注意,不包括字符串类型的属性

let s1 = Symbol('foo'), s2 = Symbol('bar');
let o = {
    [s1]: 'foo val',
    [s2]: 'bar val',
    baz: 'baz val',
    qux: 'qux val'
};
console.log(Object.getOwnPropertySymbols(o)); // [Symbol(foo), Symbol(bar)]
  • Object.getOwnPropertyDescriptors(obj)

返回一个对象,包含指定对象所有自身的(非继承的)属性的属性描述符。注意,包括可枚举和不可枚举的属性

const obj = { a: 1, b: 2 };
const descriptors = Object.getOwnPropertyDescriptors(obj);
console.log(descriptors);
// 输出:
// {
//   a: { value: 1, writable: true, enumerable: true, configurable: true },
//   b: { value: 2, writable: true, enumerable: true, configurable: true }
// }
  • Reflect.ownKeys()

返回一个数组,包含指定对象自身的所有键,包括字符串类型和 Symbol 类型。与 Object.getOwnPropertyNames 和 Object.getOwnPropertySymbols 的联合操作效果相同。

let s1 = Symbol('foo'), s2 = Symbol('bar');
let o = {
    [s1]: 'foo val',
    [s2]: 'bar val',
    baz: 'baz val',
    qux: 'qux val'
};
console.log(Reflect.ownKeys(o)); // ["baz", "qux", Symbol(foo), Symbol(bar)]

如何遍历对象的所有 Symbol 属性找到相应的属性键?

使用 Object.getOwnPropertySymbols() 方法配合 toString()

let o = {
      [Symbol('foo')]: 'foo val',
      [Symbol('bar')]: 'bar val'
};
// {Symbol(foo): "foo val", Symbol(bar): "bar val"}

const symbolKeys = Object.getOwnPropertySymbols(obj);
let matchingSymbolKey = symbolKeys.find((symbol) => symbol.toString().match(/bar/)); // Symbol(bar)
if (matchingSymbolKey) {
  const propertyName = matchingSymbolKey.description; // bar
  const propertyValue = obj[matchingSymbolKey]; // bar val
  console.log(`Symbol Key: ${propertyName}, Value: ${propertyValue}`);
} else {
  console.log('not found');
}

内置符号?

ECMAScript 6 也引入了一批常用内置符号(well-known symbol),用于暴露语言内部行为,开发者可以直接访问、重写或模拟这些行为。这些内置符号都以 Symbol 工厂函数字符串属性的形式存在。

这些内置符号最重要的用途之一是重新定义它们,从而改变原生结构的行为。

比如,我们知道 for-of 循环会在相关对象上使用 Symbol.iterator 属性,那么就可以通过在自定义对象上重新定义 Symbol.iterator 的值,来改变 for-of 在迭代该对象时的行为。

这些内置符号也没有什么特别之处,它们就是全局函数 Symbol 的普通字符串属性,指向一个符号的实例。所有内置符号属性都是不可写、不可枚举、不可配置的。

console.log(Object.getOwnPropertyDescriptors(Symbol))
// iterator: {value: Symbol(Symbol.iterator), writable: false, enumerable: false, configurable: false}

Symbol.iterator?

该符号作为一个属性,表示“一个方法,该方法返回对象默认的迭代器。由 for-of 语句使用”。换句话说,这个符号表示实现迭代器 API 的函数。

for-of 在执行迭代操作时,会默认调用指定对象中以 Symbol.iterator 为 key 的函数,并默认该函数返回一个实现迭代器 api 的对象:

class Emitter {
    constructor(max) {
        this.max = max;
        this.idx = 0;
    }
    * [Symbol.iterator]() {
        while(this.idx < this.max) {
            yield this.idx++;
        }
    }
}
function count() {
    let emitter = new Emitter(5);
    for (const x of emitter) {
        console.log(x);
    }
}
count();
// 0
// 1
// ...
// 4

用 @@ 表示 Symbol 属性?@@iterator?

如 @@iterator 等价于 Symbol.iterator

Object 类型都有哪些属性和方法?

  • constructor:用于创建当前对象的函数
  • hasOwnProperty(propertyName):用于判断当前对象实例(不是原型)上是否存在给定的属性
  • isPrototypeOf(object):用于判断当前对象是否为另一个对象的原型
  • propertyIsEnumerable()
  • toLocaleString()
  • toString():返回对象的字符串表示
  • valueOf():返回对象对应的字符串、数值或布尔值表示。通常与 toString() 的返回值相同

IEEE 754 64?浮点数精度问题?

JavaScript 使用 IEEE 754 浮点数标准来表示和处理浮点数。IEEE 754 定义了两种浮点数表示形式:单精度浮点数(32 位)和双精度浮点数(64 位)。在 JavaScript 中,所有数字都被存储为双精度浮点数。

具体来说,JavaScript 中的 Number 类型采用 IEEE 754 双精度浮点数标准,它占用 64 位(8 字节)内存。其中:

  • 1 位表示符号位(正负号)。
  • 11 位表示指数部分。
  • 52 位表示尾数(有效数字)。

这里简要说明一下浮点数的存储结构:

  1. 符号位(Sign Bit): 第一位表示符号位。0 表示正数,1 表示负数。

  2. 指数部分(Exponent): 接下来的 11 位表示指数部分。采用偏移码表示,即通过减去 1023 来表示实际的指数值。

  3. 尾数部分(Significand 或 Mantissa): 最后的 52 位表示尾数部分。它是浮点数的有效数字。

这个表示方式使得 JavaScript 可以表示非常大或非常小的数值,同时具有足够的精度。

然而,由于使用二进制表示浮点数,有些十进制小数无法精确表示,可能导致一些精度损失。例如,0.1 的二进制表示是一个无限循环小数,因此在计算机内部会存在一些精度误差。这是浮点数表示中的一个常见问题,称为浮点数精度问题。在需要高精度计算的场景中,可能需要使用特殊的库或方法来处理。

十进制小数浮点数精度问题?浮点数精度丢失问题?

我们在学习浮点数的存储结构时,提到了偏移码表示指数,即指数部分通过使用偏移码表示负数范围,但其实实际的指数值是通过阶码减去偏移码得到的。

具体解释,偏移码是指为了将实际指数值映射到一个整数范围而对指数进行的偏移。在 IEEE 754 浮点数标准中,双精度浮点数的偏移码是 1023,单精度浮点数的偏移码是 127。偏移码的作用是让浮点数的指数部分能够表示负数。通过将实际指数值加上一个偏移值,就可以计算得到并使用带符号的二进制整数来表示浮点数的指数。这个偏移值使得指数部分的零点位于全零的状态,且可以表示正负范围。例如,对于双精度浮点数,如果指数部分的二进制表示是 01111111111(全零除外),那么实际指数是 0,也就是阶码为0,因为 01111111111 - 1023 = 0,即表示 (1.x x 2 ^ 0),无需左移或者右移(下面会讲为什么需要左移右移来使得 11.011 变为 1.1011,由于规范化表示,尾数的整数部分总是 1)。

所以,阶码的实际值是通过用二进制表示的指数减去偏移码得到的,如上面的 01111111111 - 1023。而阶码会采用二进制补码表示。

示例:

// 9 (指数部分实际值为 接下来要加上偏移码)
// 9+1023=1032 (接下来转为二进制)
// 10000001000

// -9 (指数部分实际值为 接下来要加上偏移码)
// −9+1023=1014 (接下来转为二进制)
// 1111110110

明白了指数部分,我们接下来学习为什么十进制小数会出现精度丢失问题。

首先,如何将一个十进制小数转换为二进制表示?比如给定 3.375 十进制小数,它的完整 64 位二进制怎么表示?

要将十进制小数 3.375 转换为 IEEE 754 双精度浮点数的二进制表示,需要进行以下步骤:

  1. 将整数部分和小数部分分离

整数部分 3,小数部分 0.375

  1. 将整数部分转换为二进制

3 的二进制表示为 11

  1. 将小数部分转换为二进制

小数部分采用乘2取整法,即转换为二进制需要不断乘以 2,并将结果的整数部分取出,直到小数部分变为 0 或达到所需精度

0.375 x 2 = 0.75(取整数部分0), 0.75 x 2 = 1.5(取整数部分1), 0.5 x 2 = 1(取整数部分1),小数部分变为0停止。小数部分的二进制表示为 0.011

这里注意,为什么可以采用乘2取整法?

将小数乘以2相当于将小数部分乘以2的幂次方,即左移一位,而取整相当于将小数点右边的部分舍弃,达到将小数部分转换为整数法效果,将新的小数部分继续乘以2,得到新的整数部分,不断重复,直到小数变为0或达到所需的精度。这个方法的有效性来自于二进制是基于2的系统,乘以2相当于进行二进制的位移操作,通过不断进行这样的位移,可以逐步获得小数的二进制表示。

例如,将0.75进行乘2取整,0.75 x 2 = 1.5,第一位为 1,其权值为2^(-1),0.5 x 2 = 1,第二位为 1,其权值为2^(-2),因此 0.75 的二进制可以表示为 0.11。

  1. 合并整数部分和小数部分,得到二进制表示

11.011

接下来计算底层是如何存储的。

  1. 规范化科学计数法形式

IEEE 754 浮点数的尾数部分采用科学计数法表示,科学计数法形式为:m x 2 ^ n,m 为尾数,n为指数。其中尾数是浮点数的有效数字,总是以 1.xxx 的形式开始,xxx 表示剩余的二进制小数部分。

注意,由于规范化表示,尾数的整数部分总是 1,并且它并不存储这个 1,而是隐含在浮点数的定义中。例如,如果尾数部分的二进制表示为 1011000000000000000000000000000000000000000000000,那么它以规范化形式表示的有效数字是
1.1011,其中,第一位的 1 就是隐含的并不会在二进制中表示出来,只存储是小数部分 1011 的二进制。这样设计的目的是为了最大限度地提高浮点数的精度。通过规范化,可以表示更多的有效数字,从而提高了浮点数的精度和表示范围。

那么对于 11.011,首先将小数点右移,直到只剩下一个非零位,记录右移的位数。11.011 右移 1 位变为 1.1011,记录右移的位数为 1。即 (1.1011 x 2^1)

  1. 表示为 IEEE 754 双精度浮点数

首先符号位为 0(正数);指数部分因为右移了 1 位,为 1023 + 1 = 1024(实际值=偏移码1023+阶码1),即 10000000000;尾数部分为 1011000000000000000000000000000000000000000000000000

最终的 IEEE 754 双精度浮点数表示为:0 10000000000 1011000000000000000000000000000000000000000000000000。这个二进制表示包含符号位、指数部分和尾数部分。

学完了上述内容,就知道为什么 0.1 + 0.2 为什么不等于 0.3 了。因为一些十进制小数在转换为二进制表示时,会出现无限循环,比如 0.1 和 0.2,这就无法精确表示,而当将两个无限循环的二进制小数部分相加,再转换为十进制结果就会出现误差。

转换过程:

// 将小数乘以 2,取整数部分:
// 0.1×2=0.2(整数部分为 0)
// 0.2×2=0.4(整数部分为 0)
// 0.4×2=0.8(整数部分为 0)
// 0.8×2=1.6(整数部分为 1)
// 0.6x2=1.2(整数部分为 1)
// 0.2×2=0.4(整数部分为 0)
// ...
// 这一步骤一直进行下去,得到的二进制小数部分是0.00011001100110011...
// 由于 0.1 在二进制下是无限循环小数,计算机采用了截断,将其截断为 64 位的浮点数
// 因此,0.1 的近似二进制表示为 0.00011001100110011...但请注意,这个表示是近似的,而不是精确的,因为在二进制中无法准确表示 0.1

位操作符?

ECMAScript 12 中的所有数值都以 IEEE 754 64 位格式存储,但位操作并不直接应用到 64 位表示,而是先把操作数转换为 32 位带符号整数,再进行位操作,之后再把结果转换为 64 位。因此对开发者而言,就只需要考虑 32 位整数即可。

但是注意,虽然这个过程可以确保位操作符在 JavaScript 中的行为一致性,但因为 JavaScript 的 Number 类型使用 IEEE 754 双精度浮点数标准,它的整数部分最多能够精确表示 53 位,因此位操作过程限制了操作的整数范围,例如,如果你使用位操作符进行左移(<<)或右移(>>)运算,JavaScript 会将操作数视为一个 32 位整数,忽略高于 32 位的部分。这可能导致意外的结果,尤其是在处理大于 2^31 - 1 或小于 -2^31 的数值时。

如果你需要处理更大范围的整数,或者需要更精确的位操作,可能需要使用专门处理大整数的库或进行其他类型的处理。例如,一些 JavaScript 库提供了对大整数的支持,允许你执行位操作并处理超过 32 位的整数。

  • 有符号整数

第 32 位表示数值的符号,如 0 表示正,1 表示负。这一位称为符号位(sign bit)。

后面 31 位表示数值,这里要注意负数表示:以补码形式的二进制编码存储负数。

  • 即确定绝对值的二进制表示,即对-18先确定18的二进制表示
  • 然后得到数值的反码
  • 最后给结果+1
// -5 (接下来确定5的二进制表示)
// -> 00000000 00000000 00000000 00000101 (接下来取反码)
// -> 11111111 11111111 11111111 11111010 (接下来+1)
// -> 11111111 11111111 11111111 11111011 (最终存储形式)

位操作符有哪些?

JavaScript 提供了一组位操作符,用于在二进制表示的整数上执行位运算。这些操作符允许你直接操作数字的二进制位,进行一些底层的位运算。这些位操作符通常用于一些底层的数学运算或性能优化,例如在图形处理或加密算法中。在一般的业务代码中,使用这些位操作符的场景相对较少。在使用时要小心,确保你理解它们的作用,并注意位操作可能导致的溢出或其他问题。

  • 按位与(AND):&
let result = 5 & 3; // 0101 & 0011 = 0001
console.log(result); // 输出 1
  • 按位或(OR):|
let result = 5 | 3; // 0101 | 0011 = 0111
console.log(result); // 输出 7
  • 按位异或(XOR):^
let result = 5 ^ 3; // 0101 ^ 0011 = 0110
console.log(result); // 输出 6
  • 按位非:~。最终效果是对数值取负值并-1,如-25按位非为-26
let result = ~5; // ~0101 = 1010
console.log(result); // 输出 -6
  • 左移:<<
let result = 5 << 1; // 0101 左移1位变成 1010
console.log(result); // 输出 10

看上去就是 5*2^1

保留符号位,将数值的其他所有位向左移动,如2左移5位,即2*2^5,为64。注意,左移后空位会出现在右侧,这些空位会用0来填充。

  • 有符号右移:>>
let result = 5 >> 1; // 0101 右移1位变成 0010
console.log(result); // 输出 2

保留符号位,将数值的其他所有位向右移动,其实是左移的逆运算,64右移5位,为2。注意,右移后空位会出现在左侧,且在符号位之后,这些空位会用符号位的值来填充,如正数补0,负数补1。

  • 无符号右移:>>>
let result = -5 >>> 1; // 11111111111111111111111111111011 无符号右移1位变成 01111111111111111111111111111101
console.log(result); // 输出 2147483645

将数值的所有位(32)向右移动。对于正数,与有符号右移一致;对于负数,无符号右移后还是会给空位补0,而不管符号位是什么,所以负数的无符号右移会与有符号右移差异很大。

在业务中,有哪些常见的位操作符使用场景?

  1. 权限控制: 位操作符常用于表示和检查权限。每个权限可以用一个位来表示,用户的权限集合可以通过按位与、按位或等操作进行组合和检查。
const READ = 1; // 0001
const WRITE = 2; // 0010
const EXECUTE = 4; // 0100

let userPermissions = READ | WRITE; // 0011
let hasReadPermission = (userPermissions & READ) !== 0; // 检查是否有读权限
  1. 状态管理: 位操作符有时用于表示多个状态的组合。每个状态可以用一个位来表示,状态的组合可以通过按位或操作进行设置,通过按位与操作进行检查。
const ACTIVE = 1; // 0001
const ADMIN = 2; // 0010
const MODERATOR = 4; // 0100

let userStatus = ACTIVE | ADMIN; // 0011 处于激活的管理员状态
let isAdmin = (userStatus & ADMIN) !== 0; // 检查用户是否是管理员
  1. 位掩码: 位操作符可用于创建位掩码,用于在某些设置中进行开关或标记。
const FLAG_A = 1; // 0001
const FLAG_B = 2; // 0010
const FLAG_C = 4; // 0100

let flags = 0; // 初始化为0000
flags |= FLAG_A; // 打开FLAG_A
flags &= ~FLAG_B; // 关闭FLAG_B
  1. 数据结构设计: 在一些高性能的数据结构中,位操作符可能用于优化存储和检索过程。
const SWITCH_1 = 1; // 0001
const SWITCH_2 = 2; // 0010
const SWITCH_3 = 4; // 0100
let switches = 0; // 初始化为 0000
switches |= SWITCH_1; // 打开 SWITCH_1

虽然上述场景中可以使用位操作符,但在实际应用中,要确保代码的可读性和可维护性。在不牺牲代码清晰度的前提下,才考虑使用位操作符进行优化。

乘性操作符?

  • 乘法
  • 除法。注意0/0为NaN,非0/0为Infinity或者-Infinity
  • 取模(余数)操作符:%

加性操作符?

  • 加法
  • 减法

在 ECMAScript 中,加法和减法这两个操作符拥有一些特殊的行为,会进行数据类型转换。

加法操作符类型转换规则?

  • 如果两个操作数都是数值
  • 如果有任意一个操作数是字符串:将其他操作数转换为字符串进行拼接。如果有任意一个操作数是对象、数值或布尔值,先调用 toString() 方法获取字符串。注意,对于 undefined 和 null,则调用 String() 函数,分别获取 “undefined"和"null”,因为这两个类型没有 toString 方法
  • 如果有一个操作数是对象,它将转换为原始值(string、number、boolean)再进行运算,比如1+testObj='1[object Object]'testObj+testObj='[object Object][object Object]'[1,3,5] + 1="1,3,51"

这里对象的转换规则:

  • 如果对象类型是一个 Date,使用 toString() 方法(因为Date类型的值的valueOf()方法会返回一个时间戳数值)
  • 在其它情况下使用 valueOf() 方法,它将返回一个原始值
  • 如果 valueOf() 方法不能将它返回一个原始值,再使用toString()方法进行转换

减法操作符类型转换规则?

  • 如果有任一操作数是字符串、布尔值、null 或 undefined,则先在后台使用 Number() 将其转换为数值,然后再根据前面的规则执行数学运算。如果转换结果是 NaN,则减法计算的结果是 NaN
  • 如果有任一操作数是对象,则调用其 valueOf() 方法取得表示它的数值。如果该值是 NaN,则减法计算的结果是 NaN,比如 1-testObj=NaN。如果对象没有 valueOf() 方法,则调用其 toString()方法,然后再将得到的字符串转换为数值

使用关系操作符有哪些要注意的现象?

  • 对于两个字符串,关系操作符会按顺序比较字符串中对应每个字符的编码,而这些编码是数值,比如 "Brick" < "alphabet",因为大写B编码值<小写a编码值
  • 对于一个操作数为数值,另一个为字符串,比如"23"<3,这是因为只要一个操作数是数值,则将另一个操作数转换为数值,执行数值比较,如果不能转换成数值,则返回false,比如1>'z','z’会被转换成成NaN
  • 任何关系操作符在涉及比较 NaN 时都返回 false

相等操作符有哪四个?

  • 等于
  • 不等于
  • 全等
  • 不全等

等于和不等于的类型转换规则?强制类型转换?

  • 如果任一操作数是布尔值,则将其转换为数值再比较是否相等。false 转换为 0,true 转换为 1
  • 如果一个操作数是字符串,另一个操作数是数值,则尝试将字符串转换为数值,再比较是否相等
  • 如果一个操作数是对象,另一个操作数不是,则调用对象的 valueOf() 方法取得其原始值,再进行比较
  • 如果两个操作数都是对象,则比较它们是不是同一个对象

null == undefined?

对的。

与 NaN 执行等于和不等于?

如果有任一操作数是 NaN,则相等操作符返回 false,不相等操作符返回 true。注意,即使两个操作数都是 NaN,相等操作符也会返回 false。

全等和不全等不进行操作数类型转换?

对的。不仅要比较值,还要比较类型。

null !== undefined?

对的,两者类型不同。

for-in 和 for-of?

  • for-in

for-in 语句是一种严格的迭代语句,可用于枚举对象中的非符号(Symbol)属性(key)。

注意,ECMAScript 中对象的属性在遍历时是一般是无序的,自ECMAScript 2015规范以来,对象确实保留了字符串和Symbol键的创建顺序,在只有字符串键的对象上进行迭代将按插入顺序产生键。(在不同浏览器遍历对象属性时表现不一样,有的浏览器按序,有的浏览器按特定规则,比如chrome),因此 for-in 语句不能保证返回对象属性的顺序。换句话说,如果想按照定义的次序遍历对象属性,需要针对各浏览器编写特殊代码。由于对象的输出是无序的,建议如果有需要根据属性顺序遍历的需求,可以使用Map或者数组。

遍历对象:

var s = { a: 1, b: 2, c: 3 };
for(let prop in s) {
  if(s.hasOwnProperty(prop)) {
    console.log(prop);
    console.log(s[prop]);
  }
}

遍历数组:

Object.prototype.objCustom = function() {}; 
Array.prototype.arrCustom = function() {};
const iterable = [3, 5, 7];
iterable.foo = 'hello';

for (const i in iterable) {
  console.log(i); // 0, 1, 2, "foo", "arrCustom", "objCustom"
}
for (const i in iterable) {
  if (iterable.hasOwnProperty(i)) {
    console.log(i); // 0, 1, 2, "foo"
  }
}
  • for-of

for-of 语句是一种严格的迭代语句,用于遍历可迭代对象的元素。注意,是可迭代对象。

遍历对象:

var s = { a: 1, b: 2, c: 3 };
for(let prop of s){
    console.log(prop); // Uncaught TypeError: s is not iterable 
}
// 对象字面量声明的对象不可迭代
// 需要结合Object.keys()使用
for(let prop of Object.keys(s)){
  console.log(prop);
  console.log(s[prop]);
}

遍历数组:

Object.prototype.objCustom = function() {}; 
Array.prototype.arrCustom = function() {};
const iterable = [3, 5, 7];
iterable.foo = 'hello';

for (const i of iterable) {
  console.log(i); // 3, 5, 7
}

ECMAScript 中所有函数的参数都是按值传递的?

对的。

在 ECMAScript(JavaScript 的规范化标准)中,函数参数的传递方式被描述为 “按值传递”。这是因为在函数调用时,实际上是将参数的值传递给函数,而不是参数本身。这里的关键是理解 JavaScript 中变量的本质以及它们在内存中的存储方式。

在 JavaScript 中,有两种基本的数据类型:基本数据类型(Primitive types)和引用数据类型(Reference types)。

当将基本数据类型的值传递给函数时,实际上是将这个值的副本传递给了函数。在函数内对参数的任何修改都不会影响到原始值,因为这只是原始值的副本。

function example(value) {
  value = 42;
}
let x = 10;
example(x);
console.log(x); // 输出 10,因为 x 的值没有被修改

当将引用数据类型的值传递给函数时,实际上是将指向对象在内存中存储位置的引用的副本传递给了函数。因此,在函数内对参数进行修改会影响到原始对象,因为原值和副本它们引用的是同一个对象。但不妨碍理解为所有函数的参数传递都是值传递。

function example(obj) {
  obj.property = 'modified';
}

let myObj = { property: 'original' };
example(myObj);
console.log(myObj.property); // 输出 'modified',因为 myObj 的属性被修改了

总体而言,JavaScript 中的函数参数传递方式是按值传递,只是对于引用类型,传递的是引用的副本,使得在函数内部对引用对象的修改能够影响原始对象。这种行为与一些其他编程语言中的 “按引用传递” 或 “按指针传递” 不同,换句话说,按引用传递在其他编程语言中存在,但不适用于JavaScript,因此在 JavaScript 中通常称之为 “按值传递”。

typeof 用于检测函数时为什么可以返回 ‘function’?

根据 ECMA-262 规范规定,任何实现内部 [[Call]] 方法的对象都应该在 typeof 检测时返回 “function”。

第4章 变量、作用域与内存

  • 原始值与引用值 / 动态属性 / 复制值 / 传递参数 / 确定类型
  • 执行上下文与作用域 / 作用域链增强 / 变量声明
  • 垃圾回收 / 标记清理 / 引用计数 / 性能 / 内存管理

如何理解执行上下文?以及作用域链?

变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上

  • 全局上下文取决于es实现的宿主环境,如浏览器的window
  • 上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)
  • 每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上。在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文
  • 上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序

函数的活动对象(非函数称为变量对象)?activation object

每个上下文都有一个关联的变量对象(variable object),如果上下文是函数,则其活动对象(activation object)用作变量对象。

函数的活动对象最初只有一个定义变量:arguments

作用域链增强?

某些方式会导致在作用域链前端临时添加一个上下文:

  • try/catch中的catch块

  • with语句

  • javascript垃圾回收?

JavaScript 是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。Javascript 通过自动内存管理实现内存分配和闲置资源回收。

思路:

  • 确定哪个变量不会再使用,然后释放其占用的内存
  • 周期性执行垃圾回收程序
  • 标记可被回收的变量:标记清理、引用计数

垃圾回收标记策略:标记清理?mark and sweep

先标记所有变量,再去掉当前引用到的变量的标记,仍有标记的变量就会在下一次垃圾回收程序中被清理

垃圾回收标记策略:引用计数?reference counting?循环引用的问题?

回收引用数为0的值的内存

注意,如果两个对象相互作为内部属性引用,就会导致循环引用的问题,两个变量引用数永远不会变成0,而且如果函数被多次调用,则会导致大量内存永远不会被释放

事实上,引用计数策略的问题还不止于此

为什么一些浏览器js引擎已经采用了标记清理策略,还是有循环引用的现象?如何避免?

这是因为,在 IE8 及更早版本的 IE 中,并非所有对象都是原生 JavaScript 对象。BOM 和 DOM 中的对象是 C++ 实现的组件对象模型(COM,Component Object Model)对象,而 COM 对象使用引用计数实现垃圾回收。因此,即使这些版本 IE 的 JavaScript 引擎使用标记清理,JavaScript 存取的 COM 对象依旧使用引用计数。换句话说,只要涉及 COM 对象,就无法避开循环引用问题。下面这个简单的例子展示了涉及 COM 对象的循环引用问题:

let element = document.getElementById("some_element");
let myObject = new Object();
myObject.element = element;
element.someObject = myObject;

这个例子在一个 DOM 对象(element)和一个原生 JavaScript 对象(myObject)之间制造了循环引用。由于存在循环引用,因此 DOM 元素的内存永远不会被回收,即使它已经被从页面上删除了也是如此。

避免方案:在确定不是用的情况下,切断原生 js 对象和 dom 元素之间的连接。

myObject.element = null;
element.someObject = null;

注意,为了补救这一点,IE9 把 BOM 和 DOM 对象都改成了 JavaScript 对象,这同时也避免了由于存在两套垃圾回收算法而导致的问题,还消除了常见的内存泄漏现象。

性能优化:垃圾回收的性能问题?如何优化?

垃圾回收程序会周期性运行,如果内存中分配了很多变量,并且不及时回收,则可能造成性能损失;如果频繁执行垃圾回收程序,也会明显影响性能,比如在内存有限的移动设备上,导致明显拖慢渲染速度。

优化方案:动态修改触发垃圾回收程序的阈值

为什么分配给浏览器的内存通常比较少,尤其是移动端?

更多出于安全考虑,为了避免运行大量js的网页耗尽系统内存,导致系统崩溃。

性能优化:如何进行内存管理,或者说如何将内存占用量保持在一个较小的值?

为了让页面性能更好,开发需要考虑到将内存占用量保持在一个较小的值,优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据,对于不必要的将它设置为null从而释放其引用(解除引用)。

适合场景:适合全局变量和全局对象的属性

性能优化:v8引擎的隐藏类?

V8 在将解释后的 JavaScript 代码编译为实际的机器码时会利用“隐藏类”。运行期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。如果多个对象能够共享相同隐藏类,性能会更好:

function Article() {
    this.title = 'Inauguration Ceremony Features Kazoo Band';
    this.author = 'Jake';
}
let a1 = new Article();
let a2 = new Article();

注意,如果对a1或者a2进行新属性添加、delete操作,都会导致两个对象不再共享一个隐藏类。

对于delete导致的问题,最佳实践是把不想要的属性设置为null,这样可以保持隐藏类不变和继续共享,同时也能达到删除引用值供垃圾回收程序回收的效果

性能优化:如何避免内存泄漏?

内存泄漏场景:

  • 垃圾回收中引用计数DOM/BOM对象和js对象循环引用(IE旧版本有这个问题)
  • 闭包
  • 在局部作用域内意外声明全局变量(不使用var等声明):最常见也最容易修复的场景
  • 定时器的回调通过闭包引用了外部变量,只要定时器一直运行,变量就会一直占用内存

JavaScript 中的内存泄漏大部分是由不合理的引用导致的,比如:

let name = 'Jake';
setInterval(() => {
  console.log(name);
}, 100);
  • 使用js闭包,如下代码,调用outer()会导致分配给name的内存被泄漏,只要返回的函数调用一直存在,就不能清理name。假如name占用内存很大,就问题很大
let outer = function() {
    let name = 'Jake';
    return function() {
        return name;
    };
};

性能优化:避免压榨浏览器?静态分配?使用对象池?过早优化?

为了提升 JavaScript 性能,最后要考虑的一点往往就是压榨浏览器了。此时,一个关键问题就是如何减少浏览器执行垃圾回收的次数。开发者无法直接控制什么时候开始收集垃圾,但可以间接控制触发垃圾回收的条件。理论上,如果能够合理使用分配的内存,同时避免多余的垃圾回收,那就可以保住因释放内存而损失的性能。

场景:浏览器决定何时运行垃圾回收程序的一个标准就是对象更替的速度

function addVector(a, b) {
    let resultant = new Vector();

    resultant.x = a.x + b.x;
    resultant.y = a.y + b.y;

    return resultant;
}

在调用addVector()时,会在堆上创建一个新Vector对象,然后函数返回后被清理。如果这个对象的生命周期很短,而且这个函数频繁被调用,那么垃圾回收程序就会发现这个对象更替的速度很快,从而导致更频繁的安排垃圾回收程序。

解决方案:对于频繁修改的对象,不要动态创建新对象,而是使用一个已有的对象:

function addVector(a, b, resultant) {
    resultant.x = a.x + b.x;
    resultant.y = a.y + b.y;
    return resultant;
}

优化方案:使用对象池

在哪里创建需要被复用的对象,才可以不让垃圾回收程序盯上呢?使用对象池。

在初始化的某一时刻,可以创建一个对象池,用来管理一组可回收的对象。

应用程序可以向这个对象池请求一个对象、设置其属性、使用它,然后在操作完成后再把它还给对象池。由于没发生对象初始化,垃圾回收探测就不会发现有对象更替,因此垃圾回收程序就不会那么频繁地运行

实现的伪代码:

// vectorPool 是已有的对象池
let v1 = vectorPool.allocate();
let v2 = vectorPool.allocate();
let v3 = vectorPool.allocate();

v1.x = 10;
v1.y = 5;
v2.x = -3;
v2.y = -6;

addVector(v1, v2, v3); // v3 = {7, -1}

vectorPool.free(v1);
vectorPool.free(v2);
vectorPool.free(v3);

// 如果对象有属性引用了其他对象
// 则这里也需要把这些属性设置为null
v1 = null;
v2 = null;
v3 = null;

如果对象池只按需分配矢量(在对象不存在时创建新的,在对象存在时则复用存在的),那么这个实现本质上是一种贪婪算法,有单调增长但为静态的内存。

注意,静态分配是优化的一种极端形式。如果你的应用程序被垃圾回收严重地拖了后腿,可以利用它提升性能。但这种情况并不多见。大多数情况下,这都属于过早优化,因此不用考虑。

第5章 基本引用类型

  • 5.1 Date / 继承的方法 / 日期格式化方法 / 日期/时间组件方法
  • 5.2 RegExp / RegExp 实例属性 / RegExp 实例方法 / RegExp 构造函数属性 / 模式局限
  • 5.3 原始值包装类型
  • 5.4 单例内置对象 / Global / Math

RegExp?正则表达式?

ECMAScript 通过 RegExp 类型支持正则表达式。

创建:let expression = /pattern/flags;

  • pattern/模式
  • flags/标记。每个正则表达式可以带零个或多个 flags(标记),用于控制正则表达式的行为

正则表达式有哪些flags?标记?

  • g:全局模式,表示查找字符串的全部内容,而不是找到第一个匹配的内容就结束
  • i:不区分大小写,表示在查找匹配时忽略 pattern 和字符串的大小写
  • m:多行模式,表示查找到一行文本末尾时会继续查找
  • y:粘附模式,表示只查找从 lastIndex 开始及之后的字符串
  • u:Unicode 模式,启用 Unicode 匹配
  • s:dotAll 模式,表示元字符.匹配任何字符(包括\n 或\r)

正则表达式元字符?([{\^$|)]}?*+.

与其他语言中的正则表达式类似,所有元字符在模式中如果要作为字符使用,必须使用反斜杠来转义。

// 匹配第一个"bat"或"cat",忽略大小写
let pattern1 = /[bc]at/i;
// 匹配第一个"[bc]at",忽略大小写
let pattern2 = /\[bc\]at/i;

字面量形式定义正则表达式?RegExp 构造函数定义正则表达式?

字面量形式创建:let expression = /pattern/flags;

RegExp 构造函数创建:接收两个参数,模式字符串和(可选的)标记字符串。let expression = new RegExp("[bc]at", "i");

使用RegExp()的二次转译问题?

注意,RegExp 构造函数的两个参数都是字符串。因为 RegExp 的模式参数是字符串,所以在某些情况下需要二次转义。所有元字符都必须二次转义,包括转义字符序列,如\n(其中\转义后的字符串是\\,在正则表达式字符串中则要写成\\\\)

// 匹配第一个"[bc]at"
/\[bc\]at/
new RegExp("\\[bc\\]at")

// 匹配第一个".at"
/\.at/
new RegExp("\\.at")

RegExp实例方法 - exec()?配合全局标记?

  • exec()
let expression = new RegExp("[bc]at", "i")
let matchs = expression.exec('bat');

console.log(matchs)

// 0: "bat"
// groups: undefined
// index: 0
// input: "bat"
// length: 1

exec() 主要用于配合捕获组使用。这个方法只接收一个参数,即要应用模式的字符串。如果找到了匹配项,则返回包含第一个匹配信息的数组,如果没找到匹配项,则返回 null。

exec() 返回的数组虽然是 Array 的实例,但包含两个额外的属性: indexinput。index 是字符串中匹配模式的起始位置,input 是要查找的字符串。这个数组的第一个元素是匹配整个模式的字符串,其他元素是与表达式中的捕获组匹配的字符串。如果模式中没有捕获组,则数组只包含一个元素。

  • exec() 配合全局标记
let text = "cat, bat, sat, fat";
let pattern = /.at/;
let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex); // 0


matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex); // 0

// 上面例子中的模式没有设置全局标记,因此调用 exec()只返回第一个匹配项("cat")。lastIndex 在非全局模式下始终不变。如果在这个模式上设置了 g 标记,则每次调用 exec()都会在字符串中向前搜索下一个匹配项,如下面的例子所示:

let text = "cat, bat, sat, fat";
let pattern = /.at/g;
let matches = pattern.exec(text);
console.log(matches.index); // 0
console.log(matches[0]); // cat
console.log(pattern.lastIndex);  // 3

matches = pattern.exec(text);
console.log(matches.index); //5
console.log(matches[0]); // bat
console.log(pattern.lastIndex); // 8

// 注意模式的 lastIndex 属性每次都会变化。在全局匹配模式下,每次调用 exec()都会 更新 lastIndex 值,以反映上次匹配的最后一个字符的索引。

RegExp 实例方法 - test()?

接收一个字符串参数,如果输入的文本与模式匹配,则参数 返回 true,否则返回 false。这个方法适用于只想测试模式是否匹配

正则表达式捕获组?

let text = "mom and dad and baby";
let pattern = /mom( and dad( and baby)?)?/gi;
let matches = pattern.exec(text);

console.log(matchs)

// 0: "mom and dad and baby"
// 1: " and dad and baby"
// 2: " and baby"
// groups: undefined
// index: 0
// input: "mom and dad and baby"
// length: 3

在这个例子中,模式包含两个捕获组:最内部的匹配项" and baby",以及外部的匹配项" and dad" 或" and dad and baby"。调用 exec()后找到了一个匹配项。因为整个字符串匹配模式成功,所以 matchs 数组的 index 属性就是 0,数组的第一个元素是匹配的整个字符串,第二个元素是匹配第一个捕获组的字符串,第三个元素是匹配第二个捕获组的字符串。

正则表达式的 toString() 和 valueOf()

  • 正则表达式的valueOf()方法返回正则表达式本身
  • 无论正则表达式是怎么创建的(字面量或者构造函数),继承的方法 toLocaleString()和 toString()都返回正则表达 式的字面量表示

单例内置对象?

ECMA-262 对内置对象的定义是“任何由 ECMAScript 实现提供、与宿主环境无关,并在 ECMAScript 程序开始执行时就存在的对象”。这就意味着,开发者不用显式地实例化内置对象,因为它们已经实例化好了,比如Object、Array、String、Global、Math等。

Global对象?

Global 对象是 ECMAScript 中最特别的对象,因为代码不会显式地访问它。ECMA-262 规定 Global 对象为一种兜底对象,它所针对的是不属于任何对象的属性和方法。事实上,不存在全局变量或全局函数这种东西。在全局作用域中定义的变量和函数都会变成 Global 对象的属性。

Global 对象和 window 对象?

虽然 ECMA-262 没有规定直接访问 Global 对象的方式,但浏览器将 window 对象实现为 Global 对象的代理。因此,所有全局作用域中声明的变量和函数都变成了 window 的属性

注意 window 对象在JavaScript中远不止实现了ECMAScript的Global对象那么简单

Math对象?

ECMAScript 提供了 Math 对象作为保存数学公式、信息和计算的地方。Math 对象提供了一些辅助 计算的属性和方法。Math对象上提供的计算要比直接在JavaScript实现的快得多,因为Math对象上的计算使用了 JavaScript 引擎中更高效的实现和处理器指令。

注意,使用 Math 计算的问题是精度会因浏览器、操作系统、指令集和硬件而异

Math.min() 和 Math.max()?

min() 和 max() 方法用于确定一组数值中的最小值和最大值。

let max = Math.max(3, 54, 32, 16);

let values = [1, 2, 3, 4, 5, 6, 7, 8];
let max = Math.max(...val);

Math.ceil()?Math.floor()?Math.round()?

  • Math.ceil():始终向上舍入为最接近的整数
  • Math.floor():始终向下舍入为最接近的整数
  • Math.round():执行四舍五入。大于等于0.5向上取

URL编码?encodeURI() / encodeURIComponent()?

encodeURI() 和 encodeURIComponent() 方法用于编码统一资源标识符(URI),以便传给浏览器。
有效的 URI 不能包含某些字符,比如空格。使用 URI 编码方法来编码 URI 可以让浏览器能够理解它们,同时又以特殊的 UTF-8 编码替换掉所有无效字符。

  • ecnodeURI()

用于对整个 URI 进行编码

  • encodeURIComponent()

用于编码 URI 中单独的组件。比如www.wrox.com/illegal value.js中的illegal value.js

  • 主要区别

encodeURI() 不会编码属于 URL 组件的特殊字符,比如冒号、斜杠、问号、井号,而 encodeURIComponent() 会编码它发现的所有非标准字符。

let uri = "http://www.wrox.com/illegal value.js#start";
console.log(encodeURI(uri));
// "http://www.wrox.com/illegal%20value.js#start"

console.log(encodeURIComponent(uri));
// "http%3A%2F%2Fwww.wrox.com%2Fillegal%20value.js%23start" 

一般来说,使用encodeURIComponent()应该比使用encodeURI()的频率更高,这是因为编码 query 字符串参数比编码基准 URI 的次数更多。

escape() 和 unescape() 为什么会被 ecnodeURI 等替代?

URI 方法 encodeURI()、encodeURIComponent()、decodeURI() 和 decodeURIComponent() 取代了 escape()和 unescape()方法,后者在 ECMA-262 第 3 版中就已经废弃了。URI 方法始终是首选方法,因为它们对所有 Unicode 字符进行编码,而原来的方法只能正确编码 ASCII 字符。

第六章 集合引用类型

  • Object
  • Array / 创建数组 / 数组空位 / 数组索引 / 检测数组 / 迭代器方法 / 复制和填充方法 / 转换方法 / 栈方法 / 队列方法 / 排序 / 操作 / 搜索和位置 / 迭代 / 归并
  • 定型数组 / 历史 / ArrayBuffer / DataView
  • Map / 基本api / 顺序和迭代 / 选择 object 还是 map
  • WeakMap / 基本api / 弱key / 不可迭代key / 使用弱映射
  • Set / 基本api / 顺序与迭代 / 定义正式集合操作
  • WeakSet / 基本api / 弱值 / 不可迭代值 / 使用弱集合

定型数组?typed array?WebGL?

定型数组(typed array)是 ECMAScript 新增的结构,目的是提升向原生库(如WebGL)传输数据的效率。实际上,JavaScript 并没有“TypedArray”类型,它所指的其实是一种特殊的包含数值类型的数组

WebGL?3D 图形?

随着浏览器的流行,不难想象人们会满怀期待地通过它来运行复杂的 3D 应用程序。早在 2006 年, 5 Mozilla、Opera 等浏览器提供商就实验性地在浏览器中增加了用于渲染复杂图形应用程序的编程平台,无须安装任何插件。其目标是开发一套 JavaScript API,从而充分利用 3D 图形 API 和 GPU 加速,以便在<canvas>元素上渲染复杂的图形。

最后的 JavaScript API 是基于 OpenGL ES(OpenGL for Embedded Systems) 2.0 规范的。OpenGL ES 7 是 OpenGL 专注于 2D 和 3D 计算机图形的子集。这个新 API 被命名为 WebGL(Web Graphics Library),于 2011 年发布 1.0 版。有了它,开发者就能够编写涉及复杂图形的应用程序,它会被兼容 WebGL 的浏览器原生解释执行。

Javascript 和 WebGL 之间存在的问题?

但是在 WebGL 的早期版本中,因为 JavaScript 数组与原生数组之间不匹配,所以出现了性能问题:图形驱动程序 API 通常不需要以 JavaScript 默认双精度浮点格式传递给它们的数值,而这恰恰是 JavaScript 数组在内存中的格式。因此,每次 WebGL 与 JavaScript 运行时之间传递数组时,WebGL 绑定都需要在目标环境分配新数组,以其当前格式迭代数组,然后将数值转型为新数组中的适当格式,而这些要花费很多时间。

Mozilla 为解决这个问题而实现了 CanvasFloatArray。这是一个提供 JavaScript 接口的、C 语言风格的浮点值数组。JavaScript 运行时使用这个类型可以分配、读取和写入数组。这个数组可以直接传给底层图形驱动程序 API,也可以直接从底层获取到。最终,CanvasFloatArray 变成了 Float32Array,也就是今天定型数组中可用的第一个“类型”。

ArrayBuffer?存储二进制数据?视图?

Float32Array 实际上是一种“视图”,可以允许 JavaScript 运行时访问一块名为 ArrayBuffer 的预分配内存。

ArrayBuffer 是所有定型数组及视图引用的基本单位。ArrayBuffer() 是一个普通的 JavaScript 构造函数,可用于在内存中分配特定数量的字节空间:

constbuf = new ArrayBuffer(16); //在内存中分配16字节
alert(buf.byteLength); // 16

注意,不能仅通过对 ArrayBuffer 的引用就读取或写入其内容。要读取或写入 ArrayBuffer,就必须
通过视图。视图有不同的类型,比如Float32Array,但引用的都是 ArrayBuffer 中存储的二进制数据。

允许读写ArrayBuffer的视图有哪些?

  • 定型数组,如Float32Array或者Int32Array。该视图特定于一种 ElementType 且遵循系统原生的字节序,相应地,定型数组提供了适用面更广的api和更高的性能, 设计定型数组的目的就是提高与webgl等原生库交换二进制数据等效率。由于定型数组的二进制表示对操作系统而言是一个容易使用的格式,javascript引擎可以重度优化算术运算、按位运算和其他常见操作,因此速度极快
  • DataView。该视图专为文件i/o和网络i/o设计,其api支持对buffer数据的高度控制,但相比其他类型的视图性能也差一些

ArrayBuffer 与 C++的 malloc() 的区别?

  • malloc()在分配失败时会返回一个 null 指针。ArrayBuffer 在分配失败时会抛出错误
  • malloc()可以利用虚拟内存,因此最大可分配尺寸只受可寻址系统内存限制。ArrayBuffer 分配的内存不能超过 Number.MAX_SAFE_INTEGER(2^53-1)字节
  • malloc()调用成功不会初始化实际的地址。声明 ArrayBuffer 则会将所有二进制位初始化为 0
  • 通过 malloc()分配的堆内存除非调用 free()或程序退出,否则系统不能再使用。而通过声明ArrayBuffer 分配的堆内存可以被当成垃圾回收,不用手动释放

SharedArrayBuffer 和 ArrayBuffer 的区别?

SharedArrayBuffer 是 ArrayBuffer 的一个变体,可以无须复制就在执行上下文间传递它。

Map?映射?键/值存储机制?与Object区别?Object vs Map?

作为 ECMAScript 6 的新增特性,Map 是一种新的集合引用类型,为这门语言带来了真正的键/值存储机制。

与Object区别:

  • Map 默认情况不包含任何 key,只包含显式插入的 key(Object还有原型属性)
  • Map 的 key 可以是任意值,包括函数、对象或任意基本类型(Object属性只能为数值、字符串或者符号属性)
  • Map 中的 key 是有序的,即 Map 实例会维护键/值对的插入顺序。因此,当迭代的时候,一个 Map 对象以插入的顺序返回键/值(一个 Object 的键是并不难保证是有序的)
  • Map 的 key 对个数可以轻易地通过 size 属性获取(Object 的键/值对个数只能手动计算)
  • Map 是 iterable 的,所以可以直接被迭代(迭代一个 Object 需要以某种方式获取它的 key 然后才能迭代,如Object.keys()
  • 在频繁增删键/值对的场景下表现更好

选择Object还是Map:

  • 内存占用

Object和Map的工程级实现在不同浏览器间存在明显差异,但存储单个键/值对所占用的内存数量都会随着key的数量线性增加。批量添加或删除键/值对则取决于各浏览器对该类型内存分配的工程实现。不同浏览器情况不同,但给定固定大小的内存,Map大约可以比Object多存储50%的键/值对

  • 插入性能

向 Object 和 Map 中插入新键/值对的消耗大致相当,不过插入 Map 在所有浏览器中一般会稍微快。对这两个类型来说,插入速度并不会随着键/值对数量而线性增加。如果代码涉及大量插入操作,那么显然 Map 的性能更佳

  • 查找速度

与插入不同,从大型 Object 和 Map 中查找键/值对的性能差异极小,但如果只包含少量键/值对, 则 Object 有时候速度更快。而且在把 Object 当成数组使用的情况下(比如使用连续整数作为属性),浏览器引擎可以进行优化,在内存中使用更高效的布局。这对 Map 来说是不可能的。对这两个类型而言,查找速度不会随着键/值对数量增加而线性增加。如果代码涉及大量查找操作,那么某些情况下可能选择 Object 更好一些

  • 删除性能

使用 delete 删除 Object 属性的性能一直以来饱受诟病,目前在很多浏览器中仍然如此。为此, 出现了一些伪删除对象属性的操作,包括把属性值设置为undefined或null。但很多时候,这都是一种讨厌的或不适宜的折中。而对大多数浏览器引擎来说,Map 的 delete() 操作都比插入和查找更快。如果代码涉及大量删除操作,那么毫无疑问应该选择 Map

Map基本使用?

// 使用 new 关键字和 Map 构造函数可以创建一个空映射
const m0 = new Map();

// 如果想在创建的同时初始化实例,可以给 Map 构造函数传入一个可迭代对象,需要包含键/值对数组
const m1 = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);

// 使用自定义迭代器初始化映射
const m2 = new Map({
  [Symbol.iterator]: function* () {
    yield ["key1", "val1"];
    yield ["key2", "val2"];
    yield ["key3", "val3"];
} });

// 可 以通过 size 属性获取映射中的键/值对的数量
alert(m2.size); // 3

// 使用 get()和 has()进行查询
const m = new Map();
alert(m.has("firstName"));  // false
alert(m.get("firstName"));  // undefined

// 可以使用 set()方法再添加键/值对
const m = new Map().set("firstName", "Matt").set("lastName", "Frisbie"); // set()方法返回映射实例,因此可以把多个操作连缀起来,包括初始化声明
alert(m.has("firstName")); // true
alert(m.get("firstName")); // Matt

// 可以使用 delete()和 clear()删除值
m.delete("firstName");
m.clear();

Map的key可以使用任何数据类型?SameValueZero 比较操作?

Map 内部使用 SameValueZero 比较操作(ECMAScript 规范内部定义,语言中不能使用),基本上相当于使用严格对象相等的标准来检查键的匹配性。SameValueZero 比较操作是ECMAScript规范新增的相等性比较算法。关于ECMAScript的相等性比较,可以参考 MDN 文档中的文章“Equality Comparisons and Sameness”。

const m = new Map();
const functionKey = function() {};
const symbolKey = Symbol();
const objectKey = new Object();

m.set(functionKey, "functionValue");
m.set(symbolKey, "symbolValue");
m.set(objectKey, "objectValue");
alert(m.get(functionKey));  // functionValue
alert(m.get(symbolKey));    // symbolValue
alert(m.get(objectKey));    // objectValue

// SameValueZero 比较意味着独立实例不冲突
alert(m.get(function() {})); // undefined

Map的迭代?

可以通过 entries() 方法(或者 Symbol.iterator 属性,它引用 entries())取得这个迭代器

// entries()
const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);
console.log(m.entries === m[Symbol.iterator]); // true
for (let pair of m.entries()) {
  console.log(pair);
}
for (let pair of m[Symbol.iterator]()) {
  console.log(pair);
}
// [key1,val1]
// [key2,val2]
// [key3,val3]

// for-of
for(let [key, value] of m) {
  console.log(key, value);
}
// forEach
m.forEach((value, key) => {
  console.log(key, value);
})
// key1 val1
// key2 val2
// key3 val3

// keys
for (let key of m.keys()) {
  console.log(key);
}
for (let value of m.values()) {
  console.log(value);
}

Map迭代过程中修改key,Map实例内部的引用无法修改?对象类型的key?

// 1
const m1 = new Map([
  ["key1", "val1"]
]);
for (let key of m1.keys()) {
  key = "newKey";
  console.log(key);

  console.log(m1.get(key));
  console.log(m1.get('key1'));
}
// newKey
// undefined
// val1

// 2
// 修改了作为键的对象的属性,但对象在映射内部仍然引用相同的值
const keyObj = {id: 1};
const m = new Map([
  [keyObj, "val1"]
]);
for (let key of m.keys()) {
  key.id = "newKey";
  console.log(key);

  console.log(m.get(key));
  console.log(m.get(keyObj));
}
// {id: 'newKey'}
// val1
// val1

Map转换为数组?

因为 entries() 是默认迭代器,所以可以直接对Map实例使用扩展操作,把Map转换为数组:

const m = new Map([
  ["key1", "val1"],
  ["key2", "val2"],
  ["key3", "val3"]
]);
console.log([...m]); // [[key1,val1],[key2,val2],[key3,val3]]

WeakMap?弱映射?增强的键/值对存储机制?

ECMAScript 6 新增的“弱映射”(WeakMap)是一种新的集合引用类型,为这门语言带来了增强的键/值对存储机制。

WeakMap 是 Map 的“兄弟”类型,其 API 也是 Map 的子集。WeakMap 中的“weak”, 描述的是 JavaScript 垃圾回收程序对待“弱映射”中键的方式。这里提一下,Vue3底层使用了WeakMap来存储响应式数据的effect副作用函数列表,也是因为WeakMap相比Map在垃圾回收程序这块有一定好处。

WeakMap和Map区别?垃圾回收程序?

WeakMap里的key不属于正式的引用,不会阻止垃圾回收。但要注意的是,弱映射中值的引用可不是弱引用的。只要key存在,键/值对就会存在于WeakMap中,并被当作对值的引用,因此就不会被当作垃圾回收

// 1
const wm = new WeakMap();
wm.set({}, "val");

// 上述代码中,set()方法初始化了一个新对象并将它用作一个字符串的键。因为没有指向这个对象的其他引用, 所以当这行代码执行完成后,这个对象键就会被当作垃圾回收。然后,这个键/值对就从弱映射中消失了,使其成为一个空映射。在这个例子中,因为值也没有被引用,所以这对键/值被破坏以后,值本身也会成为垃圾回收的目标

// 2
const wm = new WeakMap();
const container = {
  key: {}
};
wm.set(container.key, "val");
function removeReference() {
  container.key = null;
}

// 上述代码中,container 对象维护着一个对弱映射key的引用,因此这个对象键不会成为垃圾回收的目的。不过,如果调用了 removeReference(),就会摧毁键对象的最后一个引用,垃圾回收程序就可以把这个键/值对清理掉

MapWeakMap 都是 JavaScript 中用于存储键值对的数据结构,它们之间有一些重要的区别:

  1. 持久性:

    • Map 中的键是持久化的,即使键没有被其他引用所引用,键值对也会保留在 Map 中。
    • WeakMap 中的键是弱引用的,如果键没有被其他引用所引用,键值对会被垃圾回收,从而释放内存。
  2. 键的类型:

    • Map 中,键可以是任意类型的值,包括原始类型和对象引用。
    • WeakMap 中,键只能是对象引用,而不能是原始类型的值。
  3. 迭代和大小:

    • Map 实例有 size 属性,可以直接获取其键值对的数量,并且可以使用 forEach 方法等迭代方法遍历键值对。
    • WeakMap 没有 size 属性,也没有提供类似 forEach 方法的迭代方法,因为无法确保键是否已经被垃圾回收。
  4. 性能和内存管理:

    • 由于 WeakMap 的键是弱引用的,它们对垃圾回收机制更友好,适用于需要临时存储键值对且键的生命周期与值相同的场景。但是,由于键是弱引用的,WeakMap 没有提供像 Map 那样的迭代方法,也无法直接获取大小。
    • Map 则更适用于需要长期存储键值对的场景,因为它可以更好地管理内存,且提供了更多的迭代和操作方法。

总的来说,Map 适用于大多数常规的键值对存储需求,而 WeakMap 则适用于需要临时存储键值对且键的生命周期短暂的场景。

WeakMap的key只能是Object或者继承自Object的类型?为什么?

对的。尝试使用非对象设置键会抛出 TypeError。而值的类型没有限制。

WeakMap 实例之所以限制只能用对象作为键,是为了保证只有通过键对象的引用才能取得值。如果允许原始值,那就没办法区分初始化时使用的字符串字面量和初始化之后使用的一个相等的字符串了

WeakMap基本使用?

// 可以使用 new 关键字实例化一个空的 WeakMap
const wm = new WeakMap();

// 初始化
const key1 = {id: 1}, key2 = {id: 2}, key3 = {id: 3};
const wm1 = new WeakMap([
  [key1, "val1"],
  [key2, "val2"],
  [key3, "val3"]
]);
// 注意,初始化是全有或者全无的操作,只要有一个key无效就会抛出错误,导致整个初始化失败

// 原始值可以先包装成对象再用作键
const stringKey = new String("key1");
const wm3 = new WeakMap([
  stringKey, "val1"
]);

WeakMap没有clear()方法,因为WeakMap没有提供迭代其键/值对的能力。

WeakMap没有提供迭代其键/值对的能力?

因为 WeakMap 中的键/值在任何时候都可能被销毁,所以没必要提供迭代其键/值对的能力。因为不可能迭代, 所以也不可能在不知道对象引用的情况下从弱映射中取得值。即便代码可以访问 WeakMap 实例,也没办法看到其中的内容。

WeakMap使用场景?

  • 私有变量

弱映射造就了在 JavaScript 中实现真正私有变量的一种新方式。前提很明确:私有变量会存储在弱映射中,以对象实例为键,以私有成员的字典为值。

const wm = new WeakMap();

class User {
  constructor(id) {
    this.idProperty = Symbol('id')
    this.setId(id);
  }

  setPrivate(property, value) {
    const privateMembers = wm.get(this) || {};
    privateMembers[property] = value;

    // { Symbol('id'): 123 }
    // 以对象实例为键,以私有成员的字典为值
    wm.set(this, privateMembers);
  }
  getPrivate(property) {
    return wm.get(this)[property];
  }
  setId(id) {
    this.setPrivate(this.idProperty, id);
  }
  getId() {
    return this.getPrivate(this.idProperty);
  }
}

const user = new User(123);
console.log(user.getId()); // 123
user.setId(321);
console.log(user.getId()); // 321

// 并不是真正私有的
console.log(wm.get(user)[user.idProperty]) // 321

上述代码存在一个问题,外面代码只需要拿到对象实例的引用和弱映射,就可以取得私有变量。为了避免这种访问,可以加一个闭包,把WeakMap包装起来,这样就可以把弱映射和外界完全隔离开:

const User = (() => {
  const wm = new WeakMap();

  class User {
    constructor(id) {
      this.idProperty = Symbol('id')
      this.setId(id);
    }

    setPrivate(property, value) {
      const privateMembers = wm.get(this) || {};
      privateMembers[property] = value;
      wm.set(this, privateMembers);
    }
    getPrivate(property) {
      return wm.get(this)[property];
    }
    setId(id) {
      this.setPrivate(this.idProperty, id);
    }
    getId() {
      return this.getPrivate(this.idProperty);
    }
  }

  return User;
})();

const user = new User(123);
console.log(user.getId()); // 123
user.setId(321);
console.log(user.getId()); // 321

这样,拿不到弱映射wm中的键,也就无法取得弱映射中对应的值。注意,虽然这防止了前面提到的访问,但整个代码也完全陷入了 ES6 之前的闭包私有变量模式。

  • DOM节点元数据

因为 WeakMap 实例不会妨碍垃圾回收,所以非常适合保存关联元数据。

const m = new Map();
const loginButton = document.querySelector('#login');
// 给这个节点关联一些元数据
m.set(loginButton, {disabled: true});

假设在上面的代码执行后,页面被 JavaScript 改变了,原来的登录按钮从 DOM 树中被删掉了。但由于映射中还保存着按钮的引用,所以对应的 DOM 节点仍然会逗留在内存中,除非明确将其从映射中删除或者等到映射本身被销毁。

如果这里使用的是WeakMap,当节点从 DOM 树中被删除后,垃圾回收程序就可以立即释放其内存(假设没有其他地方引用这个对象)

Set?集合?加强的Map?SameValueZero比较操作?

Set 在很多方面都像是加强的 Map,这是因为它们的大多数 API 和行为都是共有的。Set 与 Map 类似,Set 可以包含任何 JavaScript 数据类型作为值。集合也使用 SameValueZero 比较操作(ECMAScript 内部定义,无法在语言中使用),基本上相当于使用严格对象相等的标准来检查值的匹配性。

Set基本使用?

// 使用 new 关键字和 Set 构造函数可以创建一个空集合
const s = new Set();

// 如果想在创建的同时初始化实例,则可以给 Set 构造函数传入一个可迭代对象,其中需要包含插入到新集合实例中的元素
const s1 = new Set(["val1", "val2", "val3"]);

// 使用自定义迭代器初始化集合
const s2 = new Set({
  [Symbol.iterator]: function* () {
    yield "val1";
    yield "val2";
  }
})

// 通过 size 取得元素数
console.log(s2.size);

// 用 add()增加值
s.add("Matt").add("Frisbie");

// 使用 has()查询
s.has("Matt")

// 用 delete() 和 clear()删除元素
s.delete("Matt");
s.clear();

Set 的迭代?

Set 会维护值插入时的顺序,因此支持按顺序迭代。可以通过 values() 方法(或者 Symbol.iterator 属性,它引用 values())取得这个迭代器

// values()
const s = new Set(["val1", "val2", "val3"]);
console.log(m.values === m[Symbol.iterator]); // true
for (let value of m.values()) {
  console.log(value);
}
for (let value of s[Symbol.iterator]()) {
  console.log(value);
}

// forEach
s.forEach((value) => {
  console.log(value);
})

Set转换为数组?

因为 values() 是默认迭代器,所以可以直接对Set实例使用扩展操作,把Set转换为数组:

const s = new Set(["val1", "val2", "val3"]);
console.log([...s]); // ["val1", "val2", "val3"]

定义正式集合操作?

从各方面来看,Set 跟 Map 都很相似,只是 API 稍有调整。唯一需要强调的就是集合的 API 对自身的简单操作。很多开发者都喜欢使用 Set 操作,但需要手动实现:或者是子类化 Set,或者是定义一个实用函数库。要把两种方式合二为一,可以在子类上实现静态方法,然后在实例方法中使用这些静态方法。

WeakSet?

类似WeakMap。

WeakSet使用场景?

相比于 WeakMap 实例,WeakSet 实例的用处没有那么大。不过,弱集合在给对象打标签时还是有价值的。暂时忽略。

集合引用类型使用迭代器和扩展操作符?

ECMAScript 6 新增这些新特性让集合引用类型之间相互操作、复制和修改变得异常方便。

定义了默认迭代器的:Array、所有定型数组、Map、Set

  • 支持顺序迭代,都可以传入for-of
  • 兼容扩展操作符。扩展操作符在对可迭代对象执行浅复制时特别有用
  • 对于接收可迭代对象的构造函数,如Map,可以传入一个可迭代对象就可以实现复制
// 复制数组
let arr1 = [1, 2, 3];
let arr2 = [...arr1];

// 也可以构建数组的部分元素
let arr1 = [1, 2, 3];
let arr2 = [0, ...arr1, 4, 5];

// 传入一个可迭代对象就实现复制
// 1 把数组复制到映射
let arr1 = [[1, 2], [3, 4]];
let map2 = new Map(arr1);

// 2 把数组复制到映射
let arr1 = [1, 2, 3];
let map = new Map(arr1.map((x) => [x, 'val ' + x]));
console.log(map); // Map {1 => 'val 1', 2 => 'val 2', 3 => 'val 3'}

// 3 把映射复制到映射
let map1 = new Map([[1, 2], [3, 4]]);
let map2 = new Map(map1);

// 4 把数组复制到集合
let arr1 = [1, 2, 3];
let set = new Set(arr1);
console.log(set); // Set {1, 2, 3}

// 5 把集合复制到数组
let arr = [...set];

第7章 迭代器与生成器

迭代?iteration?迭代器?iterator?迭代发展?迭代器模式?

  • for循环
let collection = ['foo', 'bar', 'baz'];
for (let index = 0; index < collection.length; ++index) {
  console.log(collection[index]);
}

// 这种循环并不理想:
// 1. 迭代之前需要知道如何使用数据结构。数组的每一项都只能先通过引用取得数组对象,然后再通过[]操作符取得特定索引位置的项。但这并不适用于所有数据结构
// 2. 遍历顺序并不是数据结构固有的。通过递增索引来访问数据时特定于数组类型的,并不适用于其他具有隐式顺序的数据结构
  • forEach

ES5 新增了 Array.prototype.forEach()方法,向通用迭代需求迈进了一步

let collection = ['foo', 'bar', 'baz'];
collection.forEach((item) => console.log(item));

//这种循环也不够理想:
// 虽然解决了单独记录索引和通过数组对象取得值的问题,但没有办法标识迭代何时终止,因此这个方法只适用于数组,而且回调结构比较笨拙
  • 迭代器模式

在 ECMAScript 较早的版本中,执行迭代必须使用循环或其他辅助结构。随着代码量增加,代码会变得越发混乱。

很多语言都通过原生语言结构解决了这个问题,开发者无须事先知道如何迭代就能实现迭代操作,这个解决方案就是迭代器模式。Python、Java、C++,还有其他很多语言都对这个模式提供了完备的支持。JavaScript 在 ECMAScript 6 以后也支持了迭代器模式。

迭代器模式?可迭代对象?iterable?Iterable?Iterator?

JavaScript 在 ECMAScript 6 以后支持了迭代器模式。

迭代器模式,描述了一个方案,即可以把有些结构称为“可迭代对象”(iterable),因为它们实现了正式的 Iterable 接口,而且可以通过迭代器 Iterator “消费”。

可迭代对象是一种抽象的说法。基本上,可以把可迭代对象理解成数组或映射这样的集合引用类型的对象。它们包含的元素都是有限的,而且都具有无歧义的遍历顺序。

不过,可迭代对象不一定是集合引用类型的对象,也可以是仅仅具有类似数组行为的其他数据结构。比如计数循环:

// 该循环生成的值是暂时性的,但循环本身是在执行迭代,也具有可迭代对象的行为
for (let i = 1; i <= 10; ++i) {
  console.log(i);
}

Iterable 和 Iterator 概念分离?

任何实现 Iterable 接口的数据结构,都可以被实现 Iterator 接口的结构“消费”(consume)。迭代器 Iterator 是按需创建的一次性对象,每个迭代器都会关联一个可迭代对象 iterable,而迭代器会暴露迭代其关联可迭代对象的 api,迭代器无需了解与其关联的可迭代对象的结构,只需要知道如何取得连续的值。

可迭代协议?实现 Iterable 接口?

实现 Iterable 接口(可迭代协议)要求具备两种能力:

  • 支持迭代的自我识别能力
  • 创建实现 Iterator 接口的对象的能力

在ECMAScript中,这意味着必须暴露一个属性,作为“默认迭代器”,比如 Map 的entries,Set 的values,而且这个属性必须使用特殊的 Symbol.iterator 作为key。这个默认迭代器属性,必须引用一个迭代器工厂函数,调用这个工厂函数,必须返回一个新迭代器。

实现了 Iterable 接口的内置类型

  • 字符串
  • 数组
  • Map
  • Set
  • arguments对象
  • NodeList等DOM集合引用类型
let str = 'abc';
let arr = ['a', 'b', 'c'];
let map = new Map().set('a', 1).set('b', 2).set('c', 3);
let set = new Set().add('a').add('b').add('c');
let els = document.querySelectorAll('div');

// 这些类型都实现了迭代器工厂函数
console.log(str[Symbol.iterator]); // f values() { [native code] }
console.log(arr[Symbol.iterator]); // f values() { [native code] }
console.log(map[Symbol.iterator]); // f values() { [native code] }
console.log(set[Symbol.iterator]); // f values() { [native code] }
console.log(els[Symbol.iterator]); // f values() { [native code] }

// 调用这个工厂函数会生成一个迭代器
console.log(str[Symbol.iterator]()); // StringIterator {}
console.log(arr[Symbol.iterator]()); // ArrayIterator {}
console.log(map[Symbol.iterator]()); // MapIterator {}
console.log(set[Symbol.iterator]()); // SetIterator {}
console.log(els[Symbol.iterator]()); // ArrayIterator {}

在实际编码过程中,不需要显式调用这个工厂函数来生成迭代器,实现可迭代协议的所有类型都会自动兼容接收可迭代对象的任何语言特性,包括:

  • for-of 循环
  • 数组解构
  • 扩展操作符
  • Array.from()
  • 创建 Map
  • 创建 Set
  • Promise.all() 接收由promise组成的可迭代对象
  • Promise.race() 接收由promise组成的可迭代对象
  • yield* 操作符,在生成器中使用

上述原生语言特性每次使用会在后台调用提供的可迭代对象的工厂函数,创建一个新迭代器。

迭代器协议?next()?

迭代器是一种一次性使用的对象,用于迭代与其关联的可迭代对象。

迭代器 api 使用next()方法在可迭代对象中遍历数组,每次成功调用next(),都会返回一个 IteratorResult 对象,其中包含迭代器返回的下一个值,若不调用next(),则无法知道迭代器的当前位置。

next() 返回的 IteratorResult 包含两个属性:

  • done。表示是否还可以再次调用next()取得下一个值
  • value。包含可迭代对象的下一个值
// 可迭代对象
let arr = ['foo', 'bar'];
// 迭代器工厂函数
console.log(arr[Symbol.iterator]); // f values() { [native code] }
// 创建迭代器
let iter = arr[Symbol.iterator]();
console.log(iter); // ArrayIterator {}
// 执行迭代
console.log(iter.next()); // { done: false, value: 'foo' }
console.log(iter.next()); // { done: false, value: 'bar' }
console.log(iter.next()); // { done: true, value: undefined }

对于迭代器,它并不知道怎么从可迭代对象中取得下一个值,也不知道可迭代对象有多大。只要迭代器到达 done: true 状态, 后续调用 next() 就一直返回同样的值了,即 undefined

注意,迭代器并不与可迭代对象某个时刻的快照绑定,而仅仅是使用游标来记录遍历可迭代对象的历程。如果可迭代对象在迭代期间被修改了,那么迭代器也会反映相应的变化

let arr = ['foo', 'bar'];
let iter = arr[Symbol.iterator]();
console.log(iter.next()); // { done: false, value: 'foo' }

arr.splice(1, 0, 'bar');
console.log(iter.next()); // { done: false, value: 'bar' }
console.log(iter.next()); // { done: false, value: 'baz' }
console.log(iter.next()); // { done: true, value: undefined }

实现一个返回自定义迭代器的迭代器工厂函数?如何让一个可迭代对象能够创建多个迭代器?

class Counter {
  // Counter 的实例应该迭代 limit 次
  construtor(limit) {
    this.count = 1;
    this.limit = limit;
  }
  [Symbol.iterator]() {
    // 返回一个迭代器工厂函数
    return {
      next() {
        if (this.count <= this.limit) {
          return  { done: false, value: this.count++ };
        } else {
          return { done: true, value: undefined };
        }
      }
    }
  }
}
let countor = new Counter();
console.log(countor[Symbol.iterator]());
// { next: ƒ next() }

for (let i of counter) {
  console.log(i);
}
// 1
// 2
// 3

// 目前实现还不太理想,因为每个实例只能被迭代一次
// 为了让一个可迭代对象能够创建多个迭代器,必须每创建一个迭代器就对应一个新计数器

// 方案:把计数器变量放到闭包里,然后通过闭包返回迭代器
class Counter {
  // Counter 的实例应该迭代 limit 次
  construtor(limit) {
    this.limit = limit;
  }
  [Symbol.iterator]() {
    let count = 1;
    let limit = this.limit;
    // 返回一个迭代器工厂函数
    return {
      next() {
        if (count <= limit) {
          return  { done: false, value: count++ };
        } else {
          return { done: true, value: undefined };
        }
      }
    }
  }
}

如何提前终止迭代器?return()?

迭代器可选的return()方法用于指定在迭代器提前关闭时执行的逻辑:

[Symbol.iterator]() {
  let count = 1;
  let limit = this.limit;
  return {
    next() {},
    return() {
      return { done: true }; // 必须返回一个有效的IteratorResult对象
    }
  }
}

注意,因为 return() 方法是可选的,所以并非所有迭代器都是可关闭的。要知道某个迭代器是否可关闭,可以测试这个迭代器实例的 return 属性是不是函数对象。

中途“关闭”迭代器,可能的情况包括:

  • for-of 循环通过 break、continue、return 或 throw 提前退出
  • 解构操作并未消费所有值
let counter1 = new Counter(5);
for (let i of counter1) {
  if (i > 2) { break; }
  console.log(i);
}
// 1
// 2

let counter2 = new Counter(5);
try {
  for (let i of counter2) {
    if (i > 2) {
      throw 'err';
    }
    console.log(i);
  }
} catch(e) {}
// 1
// 2

中止迭代器之后,还可以继续从上次离开的地方继续迭代?

let a = [1, 2, 3, 4, 5];
let iter = a[Symbol.iterator]();
for (let i of iter) {
  console.log(i);
  if (i > 2) {
    break
  }
}
// 1
// 2
// 3

// 内部记录的count状态还存在
for (let i of iter) {
  console.log(i);
}
// 4
// 5

生成器?generator?

生成器是 ECMAScript 6 新增的一个极为灵活的结构,拥有在一个函数块内暂停和恢复代码执行的能力。

这种新能力具有深远的影响,比如,使用生成器可以:

  • 自定义迭代器
  • 实现协程

协程?

协程本身是个函数,协程之间的切换本质是函数执行权的转移。

https://zhuanlan.zhihu.com/p/148152129
https://zhuanlan.zhihu.com/p/148462034

生成器函数?

只要是可以定义函数的地方,就可以定义生成器。但是注意,箭头函数不能用来定义生成器函数。

// 生成器函数声明
function* generatorFn() {}
// ƒ* generatorFn() {}

// 生成器函数表达式
let generatorFn = function* () {}

// 作为对象字面量方法的生成器函数
let foo = {
  * generatorFn() {}
}

// 作为类实例方法的生成器函数
class Foo {
  * generatorFn() {}
}

// 作为类静态方法的生成器函数
class Bar {
  static * generatorFn() {}
}

生成器对象?suspended?生成器对象实现了 Iterator 接口?

调用生成器函数会产生一个生成器对象。

生成器对象一开始处于暂停执行suspended的状态。与迭代器相似,生成器对象也实现了 Iterator 接口(而且它们默认的迭代器是自引用的),因此具有 next() 方法。调用这个 next() 方法会让生成器开始或恢复执行。

注意,生成器函数只会在初次调用 next() 方法后开始执行。next()的返回值类似于迭代器,有一个 done 属性和一个 value 属性,函数体为空的生成器函数中间不会停留,调用一次 next 就会到达{done: true}状态

function* generatorFn() {
  console.log('foo')
}
// ƒ* generatorFn() {}

const g = generatorFn();
// generatorFn {<suspended>}

console.log(g.next);
// ƒ next() { [native code] }

console.log(g.next());
// foo

yield?

yield 关键字可以让生成器停止和开始执行,也是生成器最有用的地方。

生成器函数在遇到 yield 关键字之前会正常执行。遇到这个关键字后,执行会停止,函数作用域的状态会被保留。停止执行的生成器函数只能通过在生成器对象上调用 next() 方法来恢复执行。

另外,yield 关键字退出的生成器函数会处在done: false状态;通过 return 关键字退出的生成器函数会处于done: true状态。

function* generatorFn() {
  yield;
}
let g = generatorFn();
console.log(g.next()); // { done: false, value: undefined } 这里停了一下
console.log(g.next()); // { done: true, value: undefined }

function* generatorFn() {
  yield 'foo';
  yield 'bar';
}
let g = generatorFn();
console.log(g.next()); // { done: false, value: 'foo' }
console.log(g.next());  // { done: false, value: 'bar' }
console.log(g.next()); // { done: true, value: undefind }

function* generatorFn() {
  yield 'foo';
  yield 'bar';
  return 'baz';
}
let g = generatorFn();
console.log(g.next()); // { done: false, value: 'foo' }
console.log(g.next());  // { done: false, value: 'bar' }
console.log(g.next()); // { done: true, value: 'baz' }
console.log(g.next()); // { done: true, value: undefind }

生成器对象作为可迭代对象?[Symbol.iterator]: function* (){}?将生成器作为默认迭代器?

在生成器对象上显式调用 next() 方法的用处并不大。其实,如果把生成器对象当成可迭代对象,那么使用起来会更方便:

// 比如,我们需要定义一个可迭代对象,而它会产生一个迭代器,这个迭代器会执行指定的次数。使用生成器,可以通过一个简单的循环来实现

// 定义生成器函数
function* nTimes(n) {
  while(n--) {
    yield;
  }
}
// nTimes(3)生成一个生成器对象,并作为可迭代对象
for(let _ of nTimes(3)) {
  console.log("foo")
}

同理,所以我们在前面也看到很多定义[Symbol.iterator]函数时使用了生成器[Symbol.iterator]: function* (){},即将生成器作为默认迭代器,这样就不用自己声明 next(),可以调用iter[Symbol.iterator]()可以生成一个生成器对象,作为可迭代对象。

如何提前终止生成器?return()?throw()?closed?

return() 和 throw() 方法都可以用于强制生成器进入关闭状态。

只要通过 return() 进入关闭状态,就无法恢复:

function* generatorFn() {
    for (const x of [1, 2, 3]) {
        yield x;
    }
}
const g = generatorFn();
console.log(g); // generatorFn {<suspended>}
console.log(g.return(4)); // { done: true, value: 4 }
console.log(g); // generatorFn {<closed>}

throw() 方法会在暂停的时候将一个提供的错误注入到生成器对象中。如果错误未被处理,生成器就会关闭;假如生成器函数内部处理了这个错误,那么生成器就不会关闭,而且还可以恢复执行,错误的处理会跳过对应的yield,因此会跳过一个值:

// 错误未被处理
function* generatorFn() {
    for (const x of [1, 2, 3]) {
        yield x;
    }
}
const g = generatorFn();
console.log(g); // generatorFn {<suspended>}
try {
    g.throw('foo')
} catch (e) {
    console.log(e); // foo
}
console.log(g); // generatorFn {<closed>}

// 错误被处理了
function* generatorFn() {
    for (const x of [1, 2, 3]) {
        try {
            yield x;
        } catch (e) {}
    }
}
const g = generatorFn();
console.log(g); // generatorFn {<suspended>}
console.log(g.next()); // { done: false, value: 1}
g.throw('foo')
console.log(g.next()); // { done: false, value: 3}

注意,如果生成器对象还没有开始执行,那么调用throw()抛出的错误不会在函数内部被捕获,因为这相当于在函数块外部抛出了错误。

使用yield实现输入和输出?

除了可以作为函数的中间返回语句,yield关键字还可以作为函数的中间参数使用。因为,生成器函数暂停的yield关键字会接收到传给next()的第一个值(非第一次next调用,因为第一次next调用接收到的值不会被使用,这第一次是为了开始执行生成器函数:

function* generatorFn(initial) {
    console.log(initial);
    console.log(yield);
    console.log(yield);
}
let generatorObject = generatorFn('foo');
generatorObject.next('test');  // foo 输入的bar不会被使用
generatorObject.next('baz');  // baz
generatorObject.next('qux');  // qux

另外,yield关键字还可以同时作为输入和输出:

function* generatorFn() {
    return yield 'foo';
}
let generatorObject = generatorFn();
console.log(generatorObject.next()); // { done: false, value: 'foo' }
console.log(generatorObject.next('bar')); // { done: true, value: 'bar' }

// 遇到yield关键字时暂停执行,并计算出要产生的值:"foo",下次调用next()传入了bar,作为交给同一个yield的值,这个值被确定为本次生成器函数要返回的值

yield*?使用星号增强yield的能力,让它可以迭代一个可迭代对象,从而一次产出一个值?

因为 yield* 实际上只是将一个可迭代对象序列化为一连串可以单独产出的值,所以这跟把 yield 放到一个循环里没什么不同。

// 等价
// function* generatorFn() {
//   for(const x of[1, 2, 3]){
//     yield x; 
//   }
// }
function* generatorFn() {
  yield* [1, 2, 3];
}
let generatorObject = generatorFn();
for (const x of generatorFn()) {
  console.log(x);
}
// 1
// 2
// 3

yield*?用yield*和递归生成器结构实现递归算法?

(p224)yield*最有用的地方是实现递归操作,此时生成器可以产生自身。

第8章 对象、类与面向对象编程

  • 理解对象
  • 创建对象
  • 继承

new 实现?

  1. 先利用 Object.create() 创建一个对象实例 createObj
  2. 再执行构造函数本身,createObj 作为 this 传入
new Object();
function new(fun) {
    const createObj = Object.create(fun.prototype);
    cosnt returnObj = fun.prototype.constructor.call(createObj);
    if(type returnObj === 'object') {
        return returnObj;
    } else {
        return createObj;
    }
}

function new(func) {
  const createObj = Object.create()
}

Object.create = function (prototypeObj) {
  return { '__proto__': prototypeObj };
}
// Object.create(proto[, propertiesObject]);
// - proto: the object which should be the prototype of the newly-created object
// - properties: Optional. If specified and not null, it defines additional properties to be added to the newly-created object

对象实例的__proto__在后面会讲。这是一个指针,指向构造函数的原型对象。

面向对象?Object-oriented?三大特性?

面向对象对语言,都有类的概念,而通过类,可以创建任意多个具有相同属性和方法的对象。ECMA-262 把对象定义为:无序属性的集合,其属性包含基本值、对象或者函数。每一个对象,都是基于一个引用类型创建的,这个引用类型可以是原生类型,也可以是开发人员定义的类型。

  • 封装

将对数据的操作细节隐藏起来,只暴露对外的接口,外界调用不需要知道细节,可以通过接口访问对象内部的数据,同时也保证了外界无法更改对象内部的数据

  • 继承

子类继承父类,子类拥有父类的所有特性,并且可以有自己一些更具体的特性

  • 多态

由继承产生了相关的不同类,对同一个方法可以有不同的响应,即继承同一个父类,但子类各自实现自己的方法

多态性?编译时多态和运行时多态?

  • 编译时多态:主要指方法的重载。但是,在 ECMAScript 中函数不具备重载。

  • 运行时多态:指程序中定义的对象引用所指向的具体类型在运行时期才确定。运行时多态有三个条件,继承、重写和向上转型。

函数声明和函数表达式?

// 使用函数声明语法
function sum(num1, num2) {
  return num1+num2;
}

// 使用函数表达式
var sum = function (num1, num2) {
  return num1+num2;
}

解析器对函数声明和函数表达式并非一视同仁。

首先,解析器会率先读取函数声明,并使其在执行任何代码之前可以访问到,这叫函数声明提升,function declaration hoisting,即即使声明函数的代码在调用它的代码后,JavaScript 引擎也能把函数声明提升到顶部。

其次,对于函数表达式,则必须等到解析器执行到它所在到代码行,才会真正被解释执行,因此,如果在用函数表达式定义某个函数之前,调用它就会产生错误。

如何根据对象属性排序对象数组?

function objComparisonFun(propertyName) {
    return function(obj1, obj2) {
        const val1 = obj1[propertyName];
        const val2 = obj2[propertyName];
        if(val1 < val2) {
            return -1;
        } else if(val1 > val2) {
            return 1;
        } else {
            return 0;
        }
    }
}
data.sort(objComparisonFun('age'));

函数有哪三个特殊的内置属性?

  • arguments
  • this
  • caller

函数arguments属性?arguments.callee?

arguments 是一个类数组对象,包含着传入函数中的所有参数。

arguments 有一个独特的属性:callee。该属性是一个指针,指向拥有这个 arguments 对象的函数。可用于递归调用某一个函数,如阶乘函数,解决代码内部和函数名紧密耦合现象,因为如果修改函数名,而函数体内部调用的函数名没有修改就会导致错误

注意,,在严格模式下,不能通过脚本访问 arguments.callee。可以使用命名函数表达式来避免:

var factorial = (function f(num) {
  if(num <= 1) {
    return 1;
  } else {
    return num * f(num-1);
  }
});

以上代码创建了一个名为 f() 的命名函数表达式,将其赋值给 factorial。这种方式在严格模式和非严格模式下都行得通。

函数的this属性?

this,引用的是函数的执行环境对象。

函数的caller属性?

ECMAScript 5 规范化了 caller 属性,该属性保存着调用当前函数的函数的引用。如果是在全局,其值为 null。

function outer() {
  inner();
}
function inner() {
  console.log(inner.caller);
}
outer();

上述代码,会打印出 outer() 函数的源代码,因为 outer 调用了 inner。

为了实现更松散的耦合,通过 arguments.callee.caller 来访问。

function outer() {
  inner();
}
function inner() {
  console.log(arguments.callee.caller);
}
outer();

函数的prototype属性?对象原型?[[proto]]__proto__

对于 ECMAScript 中的引用类型来说,prototype 是保存它们所有实例方法的真正所在。换句话说,诸如 toString()valueOf() 等方法实际上都保存在 prototype 里,只不过通过各自对象的实例访问罢了。

在 ECMAScript 5 中,prototype 属性是不可枚举的。

我们创建的每一个函数(一般是构造函数),都有一个 prototype 属性,这个属性是一个指针,指向一个对象。该对象的用途是:保存可以由特定类型的所有实例共享的属性和方法。换句话说,prototype 就是,通过调用构造函数而创建的那个对象实例的原型对象。

先看一个图:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如图,用 原型模式(后面会详细介绍)创建了自定义的构造函数 Person 后,其原型对象 prototype 默认会先取得 constructor 属性,该属性指向自定义的构造函数 Person。当调用构造函数创建一个新实例,即const person1 = new Person(),该实例 person1 的内部将包含一个指针(内部attribute,用双中括号表示,[[Prototype]]),指向构造函数的原型对象。

关于这个 [[Prototype]],虽然在脚本中没有标准的方式访问它,但 Firefox、Chrome 和 Safari 浏览器在每个对象上都支持一个 __proto__ 属性。而在其他实现中,该属性对脚本是完全不可见的。

可以通过 isPrototypeOf() 方法来确定对象之间的上述关系: Person.prototype.isPrototypeOf(person1) === true。另外,可以通过 Object.getPrototypeOf() 方法来获取 [[Prototype]],即获取对象的原型对象,如 Object.getPrototypeOf(person1) === Person.prototype

创建对象的发展历程?有哪些模式?组合构造函数模式和原型模式?

使用 Object 构造函数和对象字面量创建单个对象,有明显的缺点:需要使用同一个接口创建很多包含相似属性和方法的对象时,会产生大量重复代码。

为了解决上述问题,开始了一系列的发展。

  • 工厂模式

这种模式抽象了创建具体对象的过程,ECMAScript 实现:用一种函数来封装以特定接口创建对象的细节。

function createPerson(name, age, job) {
  var o = new Object(); // fun.prototype.constructor.call(createObj);

  o.name = name;
  o.age = age;
  o.job = job;
  
  o.sayName = function() {};

  return o;
}

var person1 = createPerson('', '', '')
var person2 = createPerson('', '', '')

存在的问题:工厂模式虽然解决了创建多个相似对象的问题,但却没有解决对象识别的问题,即怎么样知道一个对象的类型。

  • 构造函数模式

ECMAScript 中的构造函数可以用来创建特定类型的对象。像 Object 或 Array 这样的原生构造函数,在运行时会自动出现在执行环境中。此外,也可以创建自定义的构造函数,从而实现自定义对象类型的属性和方法。

// 按照惯例,构造函数始终都以一个大写字母开头,该做法借鉴于其他 OO 语言,主要是为了区别于 ECMAScript 的其他函数
function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job;
  
  this.sayName = function() {};
}

var person1 = new Person('', '', '')
var person2 = new Person('', '', '')

通过Person来new实例,可以很容易通过实例的__proto__知道其指向的构造函数的对象原型,然后通过__proto__.constructor获取构造函数本身。

这样,就可以使用 instanceof 判断当前对象类型:

person1 instanceof Object == true
person1 instanceof Person == true
person2 instanceof Person == true

注意,构造函数本身也是函数,只不过用来创建对象而已。

存在的问题:每个定义的方法,都会在每个实例上创建一遍。为什么?

从逻辑角度讲,构造函数里的方法也可以这样定义:this.sayName = new Function();,与上面的函数声明是等价的,而不要忘了 ECMAScript 中的函数其实也是对象,因此每定义一个函数,也就是实例化了一个对象。因此,每个 Person 实例其实都包含着一个不同的 Function 实例的本质,即不同实例上的同名函数是不相等的,person1.sayNam !== person2.sayName

问题就来了,创建两个完成同样任务的 Function 实例很明显是没有必要的。有没有办法能够通过一个共享Function实例来完成任务呢?

  • 原型模式

我们知道,对于 ECMAScript 中的引用类型来说,prototype 是保存它们所有实例方法的真正所在。换句话说,诸如 toString()valueOf() 等方法实际上都保存在 prototype 里,只不过通过各自对象的实例访问罢了。

因此,我们是不是也可以在构造函数模式基础上,将属性和方法定义在 prototype 属性里呢?很明显是可以的。

一般写法:

function Person() {}
Person.prototype.name = "t";
Person.prototype.age = 12;
Person.prototype.job = "car";
Person.prototype.sayName = function() {};

var person1 = new Person();
var person2 = new Person();
person1.sayName = person2.sayName

简单写法:

function Person() {}
Person.prototype = {
  name: "t",
  age: 12,
  job: "car",
  sayName: function() {}
}

var person1 = new Person();
var person2 = new Person();
person1.sayName = person2.sayName

但这样的简单写法有一个问题:因为每创建一个构造函数会自动创建 prototype 对象,该对象也会自动获得 constructor 属性,但简单写法的实现完全重写了默认的 prototype 对象,因此 constructor 不再指向 Person 构造函数,而是 Object 构造函数。

改进写法:

function Person() {}
Person.prototype = {
  name: "t",
  age: 12,
  job: "car",
  sayName: function() {}
}
Object.defineProperty(Person.prototype, "constructor", {
  enumerable: false, // 因为只是单纯地重设 constructor 属性会导致其 [[enumerable]] 特性被设置为true,因此需要使用 defineProperty 来设置
  value: Person
})

var person1 = new Person();
var person2 = new Person();
person1.sayName = person2.sayName

存在的问题:原型模式的重要性不仅体现在创建自定义对象类型方面,就连原生的引用类型,都是采用这种模式创建的。所有原生引用类型(Object, Array, String 等)都在其构造函数的原型上定义了方法。

通过原生对象的原型,不仅可以取得所有默认方法的引用,而且也可以定义新方法。但是注意,首先原型模式省略了为构造函数传递初始化参数,这导致所有实例在默认情况下都取得相同的属性值。其次,最大的问题,由于原型中很多属性被很多实例共享了,对于引用类型的变量,改变某个实例的属性就会影响到其他所有同类型的实例里的属性。

  • 组合构造函数模式和原型模式

目前最常用的是组合使用构造函数模式和原型模式,也是认可度最高的。

function Person(name, age, job) {
  this.name = name;
  this.age = age;
  this.job = job; 
  this.values = ['1', '2'];
}
Person.prototype.sayName = function() {};

var person1 = new Person('', '', '')
var person2 = new Person('', '', '')

分工:构造函数模式用于定义实例属性,原型模式用于定义方法和共享的属性。

这样,每个实例都会有自己的一份实例属性,也能共享着方法的引用,这最大限度节省了内存。而且,还支持向构造函数传递参数。

理解原型链?什么是原型链?

了解了什么是对象原型,接下来学习一下什么是原型链。

每一个构造函数都有一个 prototype 原型对象,原型对象都包含一个指向构造函数的指针 constructor,而实例中包含一个指向其原型对象的内部指针[[proto]]

那么,假如,让这个原型对象等于另一个类型的实例,结果会是如何?显然,此时的 prototype 原型对象 ,将包含一个指向另一个原型对象的指针 [[proto]]。又假如另一个原型对象又等于另一个类型的实例,那么如此层层递进,就形成了实例和原型的链,关系图如下:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如何实现原型链?

接口继承?实现继承?对象间的继承机制?

继承,是 OO 语言中最为人津津乐道的概念,许多 OO 语言都支持两种继承方式:接口继承和实现继承。接口继承,只继承方法签名。实现继承,继承实际的方法。

而在 ECMAScript 中,无法实现接口继承,因为函数没有签名。

四种主要的继承方法?

  • 基于原型链的继承
  • 基于构造函数的继承
  • 组合式继承(较完美的继承方法)
  • class extends 继承

预期效果:

son = {
  age: 18
  name: 'xxx'
  price: 1000
  __proto__: Father
    constructor: class Son
    showName: f showName()
    __proto__:
      constructor: class Father
      showName: f showName()
      __proto__: Object
}

组合式继承?

  • 使用原型链实现对父类原型属性和方法的继承
  • 借用构造函数实现对父类实例属性的继承
// 父类
function Father(name, age) {
  this.name = name;
  this.age = age;
}
Father.prototype.showName = function () {}

// 子类
function Son(name, age, price) {
  // 借用构造函数继承:实例属性
  Father.call(this, name, age);
  
  this.price = price;
  ths.setPrice = function() {}
}
// 原型链继承:原型属性和方法
Son.prototype = Object.create(Father.prototype); // Son.prototype.__proto__ = Father.prototype
Son.prototype.constructor = Son;

var son = new Son('xxx', 20, 1000);
son instanceof Son //true
son instanceof Person //true

class extends继承?

class Father {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  showName() {}
}
class Son extends Father {
  constructor(name, age, price) {
    super(name, age); // 调用父类的constructor(name, age)
    this.price = price;
  }
  showName () {}
}
let son = new Son('xx', 18, 1000);

//输出
son = {
  age: 18
  name: 'xxx'
  price: 1000
  __proto__: Father
    constructor: class Son
    showName: f showName()
    __proto__:
      constructor: class Father
      showName: f showName()
      __proto__: Object
}

很明显,比之前的组合式继承要清晰和方便很多。

组合式继承和class extends继承区别?

将 class extends 转化为 es5 代码后,可以发现 class extends 继承和组合式继承实现的子类结构是一样的。

根本上的区别在于:

  • 组合式继承:先创造子类的实例对象 this,然后在通过 call 或 apply 方法将父类属性和方法添加到子类 this 上面
  • class extends 继承:先创造父类的实例对象 this,即super(),然后再用子类的构造函数修改 this,即源码中return _this

类?p274

从如何只使用 ECMAScript 5 的特性来模拟类似于类(class-like)的行为。不难看出,各种策略都有自己的问题,也有相应的妥协。

正因为如此,实现继承的代码也显得非常冗长和混乱。为了解决这些问题,ECMAScript 6 新引入的 class 关键字具有正式定义类的能力。类(class)是 ECMAScript 中新的基础性语法糖结构,因此刚开始接触时可能会不太习惯。

虽然 ECMAScript 6 类表面上看起来可以支持正式的面向对象编程,但实际上它背后使用的仍然是原型和构造函数的概念。

类构造函数?

实例、原型和类成员?

第9章 代理与映射

Proxy?代理?Reflect?反射?

ECMAScript 6 新增的 Proxy 和 Reflect 为开发者提供了拦截并向基本操作嵌入额外行为的能力。具体地说,可以给目标对象定义一个关联的 Proxy 对象,而这个 Proxy 对象可以作为抽象的目标对象来使用。在对目标对象的各种操作影响目标对象之前,可以在 Proxy 对象中对这些操作加以控制。

在ES6之前,ECMAScript中并没有类似Proxy的特性。由于Proxy是一种新的基础性语言能力,很多转译程序都不能把Proxy行为转换为之前的 ECMAScript 代码,因为Proxy的行为实际上是无可替代的。为此,Proxy和Reflect只在百分之百支持它们的平台上有用。

Proxy()?创建代理?目标对象?处理程序对象?

Proxy() 接收两个参数:目标对象和处理程序对象。

目标对象既可以直接被操作,也可以通过代理来操作,但注意,直接操作目标对象会绕过代理施予的行为。

const target = {
    id: 'target'    
};
const handler = {};
const proxy = new Proxy(target, handler);

console.log(proxy.id); // target

// 给目标属性赋值会反映在两个对象上
target.id = 'foo';
console.log(proxy.id); // foo

// 给代理属性赋值会反映在两个对象上
proxy.id = 'bar';
console.log(target.id); // bar

// hasOwnProperty()方法在两个地方
console.log(target.hasOwnProperty('id')); // true
console.log(proxy.hasOwnProperty('id')); // true

// Proxy.prototype 是 undefined,因此不能使用 instanceof 操作符
console.log(target instanceof Proxy);
console.log(proxy instanceof Proxy);
// TypeError: Function has non-object prototype 'undefined' in instanceof check

// 严格相等可以用来区分代理和目标
console.log(target === proxy); // false

定义捕获器?trap?

捕获器就是在处理程序对象(Proxy()的第二个参数)中定义的“基本操作的拦截器”。

每个处理程序对象可以包含零个或多个捕获器,每个捕获器都对应一种基本操作,可以直接或间接在代理对象上调用。每次在代理对象上调用这些基本操作时,代理可以在这些操作传播到目标对象之前先调用捕获器函数,从而拦截并修改相应的行为。

捕获器参数?Reflect?Reflect API?

所有捕获器都可以访问相应的参数,基于这些参数可以重建被捕获方法的原始行为,比如get()捕获器会接收到目标对象、要查询的属性和代理对象三个参数。如后一个问题“get()捕获器?”所示代码。

所有捕获器都可以基于自己的参数,重建原始操作,但并非所有捕获器行为都像 get() 那么简单,因此,通过手动编码如法炮制是不现实的,实际上,开发者可以通过调用全局 Reflect 对象上的同名方法来轻松重建。

Reflect对象,封装了原始行为。

而且,处理程序对象中所有可以捕获的方法都有对应的 Reflect API 方法。这些方法与捕获器拦截的方法具有相同的名称和函数签名,而且也具有与被拦截方法相同的行为,因此,使用 Reflect API 也可以定义出 Proxy 对象

const target = {
    foo: 'bar'
};
const handler = {
    get() {
        return Reflect.get(...arguments);
    },
    // 简洁写法
    get: Reflect.get()
};
const proxy = new Proxy(target, handler);
proxy.foo;


// 示例
const target = {
    foo: 'bar'
};
const handler = {
    get(trapTarget, property, receiver) {
        let decoration = '';
        if(property === 'foo) {
            decoration = '!!!';
        }

        return Reflect.get(...arguments) + decoration;
    },
    // 简洁写法
    get: Reflect.get()
};
const proxy = new Proxy(target, handler);
proxy.foo;

// 事实上,如果真想创建一个可以捕获所有方法,然后将每个方法转发给对应 Reflect API 的空代理,那么甚至不需要定义处理程序对象
const target = {
    foo: 'bar'
};
const proxy = new Proxy(target, Reflect);

总而言之,Reflect API为开发者提供了样板代码,在此基础上开发者可以用最少的代码修改捕获的方法。

get()捕获器?

get()捕获器在如下操作发生在代理对象上时会被触发:

  • proxy[property]
  • proxy.property
const target = {
    foo: 'bar'
};
const handler = {
    // 捕获器在处理程序对象中以方法名为键
    // get()捕获器会接收到目标对象、要查询的属性和代理对象三个参数
    get(trapTarget, property, receiver) {
        console.log(trapTarget === target); // true
        console.log(property); // foo
        console.log(receiver === proxy); // true
        return 'handler override';
    }
};
const proxy = new Proxy(target, handler);
proxy.foo;

p296

第10章 函数

  • 箭头函数
  • 函数名
  • 理解参数
  • 没有重载
  • 默认参数值
  • 参数扩展与收集 / 扩展参数 / 收集参数
  • 函数声明与函数表达式
  • 函数作为值
  • 函数内部 / arguments / this / caller / new.target
  • 函数属性与方法
  • 函数表达式
  • 递归
  • 尾调用优化
  • 闭包 / this对象 / 内存泄漏
  • 立即调用的函数表达式
  • 私有变量 / 静态私有变量 / 模块模式 / 模块增强模式

尾调用?tail call?调用帧?call frame?调用栈?call stack?

指某个函数的最后一步是调用另一个函数。如

function f(x) {
  if(x>0) {
    return m(x)
  }
  return n(x)
}

函数调用会在内存形成一个“调用记录”,又称调用帧,call frame。一个记录会保存调用位置和内部变量等信息,如果函数A调用了函数B,在A的调用记录上方会形成一个B的调用记录,等到B运行结束,B的结果返回给A,B的调用记录消失,如果B内部还调用C,就会形成一个调用栈。

而对于尾调用来说,由于是函数的最后一个操作,所以不需要保留外层函数的调用记录,因为其信息都不会再用到,只要直接用内层函数的调用记录,取代外层函数的调用记录就可以。

尾调用优化?

如下示例代码:

function f() {
  let m = 1;
  let n = 2;
  return g(m+n)
}
f()

// ===

function f() {
  return g(3)
}
f()

// ===
g(3)

如果函数g不是尾调用,函数f就需要保存内部变量m和n以及g的调用位置等信息,但由于调用g之后,f就结束了,所以执行到最后一步完全可以删除f的调用记录,只保留g(3)的调用记录。

这就是尾调用优化,tail call optimization,即只保留内层函数的调用记录,如果能做到所有函数都是尾调用,那么完全可以做到每次执行时调用记录只有哦一项,这将大大节省内存。

常见的一个场景就是尾递归。

尾递归?

递归非常耗费内存,因为需要保存很多个调用记录,很容易发生栈溢出,stack overflow。

如果使用尾递归,由于只存在一个调用记录,就不会产生栈溢出问题。

比如计算阶乘:

function factorial(n) {
  if(n === 1) return 1
  return n * factorial(n-1)
}
factorial(5)

// ===>

function factorial(n, total) {
  if(n === 1) return total
  return factorial(n-1, n*total)
}
factorial(5, 1)

// 这样做有一个问题是多了一个参数,不太美观

// 方案一:利用es参数默认值 
function factorial(n, total = 1) {
  if(n === 1) return total
  return factorial(n-1, n*total)
}
factorial(5)

// 方案二:利用柯里化

function currying(fn, n) {
  return function(m) {
    fn.call(this, m, n)
  }
}
function tailFactorial(n, total) {
  if(n === 1) return total
  return factorial(n-1, n*total)
}
const factorial = currying(tailFactorial, 1)
factorial(5)

空间复杂度从O(n)变为O(1),即只需要保存1个调用记录

由此可见,尾调用优化对递归意义重大,一些函数式编程语言将其写入语言规范,ES6也是如此,第一次明确规定所有ES的实现,必须部署尾调用优化。

尾递归和循环?

递归本质上是一个循环操作,对于支持尾递归优化的现代编译器(开启尾递归优化后会将其优化为循环,所以复杂度和循环一样)。

如何将一个递归转化为尾递归?

一般是把所有用到的内部变量改写成函数的参数,比如 factorial 需要用到一个中间变量 n * factorial(n-1),即 total,将其改为参数形式。

严格模式下的尾调用优化?

ES6的尾调用优化只在严格模式下开启,正常模式下时无效的。因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈:

  • arguments
  • func.caller

尾调用优化发生时,函数的调用栈会改写,会导致上面两个变量发生错误。严格模式下会禁用这两个变量,所以尾调用模式仅在严格模式下可以生效。

闭包?closure?

引用另一个函数作用域中的变量,通常是在嵌套函数中实现的。

function createComparisonFunction(propertyName) {
    // 该函数活动对象包含 this arguments caller 等属性

    return function(object1, object2) {
        let value1 = object1[propertyName];
        let value2 = object2[propertyName];

        if (value1 < value2) {
            return -1;
        } else if (value1 > value2) {
            return 1;
        } else {
            return 0;
        }
    };
}
const closure = createComparisonFunction('key1');

value1, value2 位于内部函数(匿名函数)中,引用了外部函数的变量 propertyName

在这个内部函数被返回并在其他地方被使用后,它仍然引用着那个 propertyName 变量,这是因为内部函数的作用域链包含 createComparisonFunction() 函数的作用域。而且因为闭包的作用域链中仍然有对它的引用,createComparisonFunction() 的活动对象并不能在它执行完毕后销毁,只会销毁其执行上下文的作用域链,它本身的活动对象仍然保存在内存中,直到匿名函数被销毁才一起被销毁。

闭包引起内存泄漏?

避免引起内存泄漏:

// 创建比较函数
let compareNames = createComparisonFunction('name');
// 调用函数
let result = compareNames({ name: 'Nicholas' }, { name: 'Matt' });
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;

因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。

过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。

V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。

this指向问题?

this 的指向并不是在函数定义的时候绑定的,而是在运行时绑定的,即只有函数执行的时候才能确定 this 到底指向谁,取决于函数的调用方式。

  • 默认情况

当一个函数作为独立函数调用,即没有被明确的对象调用的时候,将对函数的 this 使用默认绑定:绑定到全局 window 对象。

下面几个例子,函数被调用的时候都没有明确的调用对象。

// 例1
function fire () {
    console.log(this === window)
}
fire(); // true

// 例2
function fire () {
    function innerFire() {
        console.log(this === window)
    }
    innerFire();
}
fire(); // true

// 例3
var obj = {
    fire: function () {
        function innerFire() {
            console.log(this === window)
        }
        innerFire();
    }
}
obj.fire(); //输出 true
  • 有明确的调用对象
// 例1
var obj = {
    a: 1,
    fire: function () {
        console.log(this.a)
    }
}
obj.fire(); // 1

// 例2
function fire () {
    console.log(this.a)
}
var obj = {
    a: 1,
    fire: fire
}
obj.fire(); // 1

注意一个场景:this 传递丢失

// 例1
var obj = {
    a: 1,
    fire: function () {
        console.log(this.a)
    }
}
var a = 2;
var fireInGrobal = obj.fire;  
fireInGrobal(); // 2

// 例2
var a = 2;
var obj = {
    a: 1,
    fire: function () {
        console.log(this.a)
    }
}  
function otherFire (fn) {
    fn();
}  
otherFire(obj.fire); // 2
  • 通过 new 创建的对象

通过 new 创建的对象,this 指向 new 出来的实例对象

new Object();

function new(fun) {
    const createObj = Object.create(fun.prototype);
    cosnt returnObj = fun.prototype.constructor.call(createObj);
    if(type returnObj === 'object') {
        return returnObj
    } else {
        return createObj
    }
}

Object.create = function (prototypeObj) {
  return { '__proto__': prototypeObj };
}
  • bind
Function.prototype.bind = function(scope) {
  var fn = this; // 指向调用 bind 的函数本身
  return function() {
    return fn.apply(scope); // apply 会立即执行
  }
}
  • apply 和 call

可以改变this指向,强制绑定到一个对象上。

function foo() {
    console.log(this.a);
}
var obj = { a: 2 };
function bar() {
    foo.call(obj);
}
bar(); // 2
setTimeout(bar, 100); // 2

第11章 期约与异步函数

  • 异步编程 / 同步与异步

异步编程?

异步行为,是为了优化因计算量大而时间长的操作。

如果在等待其他操作完成的同时,即使运行其他指令,系统也能保持稳定。重要的是,异步操作并不一定计算量大或要等很长时间。只要你不想为等待某个操作而阻塞线程执行,那么任何时候都可以使用异步。

同步与异步?

同步行为对应内存中顺序执行的处理器指令。

相对地,异步行为类似于系统中断,即当前进程外部的实体可以触发代码执行。异步操作是必要的,如果强制进程等待一个长时间的操作很明显是不可行的,如果代码要访问一些高延迟的资源,比如向远程服务器发送请求并等待响应,那么就会出现长时间的等待。

早期的异步编程模式:深度嵌套的回调函数,回调地狱?

let x = 3;
setTimeout(() => x = x + 4, 1000);

在上述代码中,() => x = x + 4代码会产生一个中断,而什么时候会触发这个中断(对x重新赋值),这对于js运行时来说是一个黑盒,因此实际上无法预知,基本没办法知道系统状态何时变化。

那么为了让后续代码能够使用x变量,异步执行的函数需要在更新x之后通知其他代码,当然如果后续程序不需要这个值,那么就可以尽管继续执行,不必等待这个结果。

设计一个能够知道 x 什么时候可以读取的系统是非常难的。

早期的异步编程模式:深度嵌套的回调函数,回调地狱。

在早期的 JavaScript 中,只支持定义回调函数来表明异步操作完成。串联多个异步操作是一个常见的问题,通常需要深度嵌套的回调函数(俗称“回调地狱”)来解决。

function double(value) {
    setTimeout(() => setTimeout(console.log(value * 2), 1000))
}
double(3);

在上述代码中,setTimeout 可以定义一个在指定时间之后会被调度执行的回调函数。对这个例子而言,1000 毫秒之后,JavaScript 运行时会把回调函数推到自己的事件处理队列上去等待执行。推到队列之后,回调什么时候出列被执行对 JavaScript 代码就完全不可见了。

假设 setTimeout 操作会返回一个有用的值。如何将这个值传给需要它的地方?广泛接受的一个策略是给异步操作提供一个回调:

function double(value, callback) {
    setTimeout(() => callback(value * 2), 1000);
}
double(3, (x) => console.log(`I was given: ${x}`));

当然,还要考虑到失败场景的处理:

function double(value, success, failure) {
    setTimeout(() => {
        try {
            if (typeof value !== 'number') {
                throw 'Must provide number as first argument';
            }
            success(2 * value);
        } catch(e) {
            failure(e);
        }
    }, 1000);
}
const successCallback = (x) => console.log(`Success: ${x}`);
const failureCallback = (e) => console.log(`Failure: ${e}`);
double(3, successCallback, failureCallback);

但是这种模式有一个很明显的问题,必须在 初始化异步操作时 定义回调,而异步函数的返回值只在短时间内存在,只有预备好将这段时间内存在的值作为参数的回调才能接受到它。

另外,如果涉及到嵌套的异步回调需求:


function double(value, success, failure) {
    setTimeout(() => {
        try {
            if (typeof value !== 'number') {
                throw 'Must provide number as first argument';
            }
            success(2 * value);
        } catch (e) {
            failure(e);
        }
    }, 1000);
}
const successCallback = (x) => {
    double(x, (y) => console.log(`Success: ${y}`));
};
const failureCallback = (e) => console.log(`Failure: ${e}`);
double(3, successCallback, failureCallback);

显然,嵌套的异步回调需求下代码会越来越复杂,回调策略很明显不具备扩展性。

Promise?期约?2012年 ECMAScript Promises/A+ 规范?

ECMAScript 6 新增了正式的 Promise(期约) 引用类型,支持优雅地定义和组织异步逻辑。

Promise 是对尚不存在结果的一个替身。

早期的期约机制在 jQuery 和 Dojo 中是以 Deferred API 的形式出现的。到了 2010 年,CommonJS 项目实现的 Promises/A 规范日益流行起来。Q 和 Bluebird 等第三方 JavaScript 期约库也越来越得到社区认可,虽然这些库的实现多少都有些不同。

为弥合现有实现之间的差异,2012 年 Promises/A+ 组织分叉(fork)了 CommonJS 的 Promises/A 建议,并以相同的名字制定了 Promises/A+ 规范。这个规范最终成为了 ECMAScript 6 规范实现的范本。

Promise 基础使用?执行器函数参数?三种状态?

ECMAScript 6 新增的引用类型 Promise,可以通过 new 操作符来实例化。

创建新期约时需要传入执行器(executor)函数作为参数:

let p = new Promise((resolve, reject) => {});

三种状态:

  • pending。最初始状态,在pending状态下,Promise可以落定(settled)为代表成功的resolved状态,或者代表失败的reject状态
  • fulfilled/resolved。只要状态切换为 resolved,就会有一个私有的内部值(value)
  • rejectd。只要状态切换为rejected,就会有一个私有的内部理由(reason)

注意,无论落定(settled)为哪种状态都是不可逆的。

重要的是,期约的状态是私有的,不能直接通过 JavaScript 检测到。这主要是为了避免根据读取到的期约状态,以同步方式处理期约对象。

另外,期约的状态也不能被外部 JavaScript 代码修改。这与不能读取该状态的原因是一样的:期约故意将异步行为封装起来,从而隔离外部的同步代码。

执行器函数:可以保证期约的状态是私有的。执行期函数主要有两个职责:

  • 初始化期约的异步行为
  • 控制状态的最终转换

注意,执行器函数是同步执行的:

new Promise(() => setTimeout(console.log('executor'), 0));
setTimeout(console.log('promise initialized'), 0);
// executor
// promise initialized

这是因为执行器函数是期约的初始化程序。

Promise的静态方法?Promise.resolve()?

期约并非一开始就必须处于待定状态,然后通过执行器函数才能转换为落定(settled)状态。

通过调用 Promise.resolve() 静态方法,可以实例化一个resolved状态的期约:

let p1 = new Promise((resolve, reject) => resolve(1));
// Promise {<fulfilled>: 1}

let p2 = Promise.resolve(3);
// Promise {<fulfilled>: 2}

p1和p2的值(value)对应着传给Promise.resolve()的第一个参数。而且,Promise.resolve()可以把任何值都转换为一个期约。

注意,对于Promise.resolve()这个静态方法而言,如果传入的参数本身是一个期约,那它的行为就类似于一个空包装。因此,Promise.resolve()可以说是一个幂等方法:

// 这个幂等性会保留传入期约的状态
let p = new Promise(() => {});
setTimeout(console.log, 0, p); // Promise <pending>
setTimeout(console.log, 0, Promise.resolve(p)); // Promise <pending>
setTimeout(console.log, 0, p === Promise.resolve(p)); // true

let p = Promise.resolve(7);
console.log(p === Promise.resolve(p)); // true

Promise的静态方法?Promise.reject()?

通过调用 Promise.resolve() 静态方法,可以实例化一个rejected状态的期约,并抛出一个异步错误。

注意,这里的这个异步错误,不能通过try/catch捕获,只能通过拒绝处理程序捕获:

let p1 = new Promise((resolve, reject) => reject());
let p2 = Promise.reject(3);
p2.then(null, (e) => { console.log(e) }) // 3

p1和p2的理由(reason)对应着传给Promise.reject()的第一个参数。这个reason会传给后续的拒绝处理程序。

注意,Promise.reject()并没有照搬 Promise.resolve() 的幂等逻辑。如果给它传一个期约对象,则这个期约会成为它返回的拒绝期约的理由:

setTimeout(console.log, 0, Promise.reject(Promise.resolve()));
// Promise <rejected>: Promise <resolved>

同步和异步执行的二元性?

下面那个例子抛出错误,因为它没有通过异步模式捕获错误。

try {
    throw new Error('foo');
} catch (e) {
    console.log(e); // Error: foo
}

try {
    Promise.reject(new Error('bar'));
} catch(e) {
    console.log(e); // nothing
}
// Uncaught (in promise) Error: bar

从这里可以看出Promise真正的异步特性:它们是同步对象,在同步执行模式中使用,但也是一步执行模式的媒介,即内部包装了异步逻辑。

Promise的实例方法?

期约实例的方法是连接外部同步代码与内部异步代码之间的桥梁。

这些方法可以访问异步操作返回的数据,处理期约成功和失败的结果,连续对期约求值,或者添加只有期约进入终止状态时才会执行的代码。

  • Promise.prototype.then()
  • Promise.prototype.catch()
  • Promise.prototype.finally()

Promise.prototype.then()?

在 ECMAScript 暴露的异步结构中,任何对象都有一个 then() 方法。这个方法被认为实现了 Thenable 接口:

class MyThenable {
  then() {}
}

ECMAScript 的 Promise 类型实现了 Thenable 接口:Promise.prototype.then()

Promise.prototype.then()是为期约实例添加处理程序(完成/onResolved处理程序、拒绝/onReject处理程序)的主要方法,通过传入两个可选的参数,然后在期约进入resolved和rejected状态时执行:

function onResolved(id) {
    setTimeout(console.log, 0, id, 'resolved');
}
function onRejected(id) {
    setTimeout(console.log, 0, id, 'rejected');
}
let p1 = new Promise((resolve, reject) => setTimeout(resolve, 3000));
let p2 = new Promise((resolve, reject) => setTimeout(reject, 3000));

p1.then(() => onResolved('p1'), () => onRejected('p1'));
p2.then(() => onResolved('p2'), () => onRejected('p2'));

// p1 resolved
// p2 rejected

如果想只提供 onRejected 参数,那就要在 onResolved 参数的位置上传入 null。这样有助于避免在内存中创建多余的对象。

Promise.prototype.then() 方法会返回一个新的期约实例?

这个新期约实例基于 onResolved 处理程序(或onReject,后面会讲)的返回值构建,换句话说,该处理程序的返回值会通过 Promise.resolve() 包装来生成新期约:

  • 如果没有提供这个 onResolved 处理程序,则包装上一个期约resolve之后的值
  • 如果有显式的返回值,则 Promise.resolve() 会包装这个值
  • 如果没有显式地返回语句,则 Promise.resolve() 会包装默认的返回值 undefined
let p1 = Promise.resolve('foo');

// 1. 如果没有提供这个 onResolved 处理程序,则包装上一个期约resolve之后的值
let p2 = p1.then(); // Promise {<fulfilled>: 'foo'}

// 2. 如果有显式的返回值,则 Promise.resolve() 会包装这个值
let p6 = p1.then(() => 'bar'); // Promise {<fulfilled>: 'bar'}

// Promise.resolve()保留返回的期约
let p8 = p1.then(() => new Promise(() => {})); // Promise <pending>
let p9 = p1.then(() => Promise.reject()); // Promise <rejected>: undefined
// Uncaught (in promise): undefined

// 抛出异常会返回拒绝的期约
let p10 = p1.then(() => { throw 'baz'; });  // Promise {<rejected>: 'baz'}
// Uncaught (in promise) baz

// 注意,返回错误值不会触发上面的拒绝行为,而会把错误对象包装在一个解决的期约中
let p11 = p1.then(() => Error('qux'));
setTimeout(console.log, 0, p11); // Promise <resolved>: Error: qux

// 3. 如果没有显式地返回语句,则Promise.resolve()会包装默认的返回值 undefined
let p3 = p1.then(() => undefined); // Promise {<fulfilled>: undefined}
let p3 = p1.then(() => {}); // Promise {<fulfilled>: undefined}
let p3 = p1.then(() => Promise.resolve()); // Promise {<fulfilled>: undefined}

onRejected 处理程序的返回值与上述类似,onRejected 处理程序返回的值也会被 Promise.resolve() 包装。

这里乍一看会有点违反直觉,then()返回的不应该是rejected状态的期约吗,但其实onRejected处理程序本身就是用来捕获异步错误的,因此,拒绝处理程序在捕获错误后不抛出异常是符合期约的行为,应该返回一个resolveed期约,看下面代码第2点:

let p1 = Promise.reject('foo');

// 1. 如果没有提供这个 onRejected 处理程序,则包装上一个期约reject之后的值
let p2 = p1.then(); // // Promise <rejected>: foo
// Uncaught (in promise) foo

// 2. 如果有显式的返回值,则 Promise.resolve() 会包装这个值
let p6 = p1.then(null, () => 'bar'); // Promise <resolved>: bar

// 与上述一样,会保留返回的期约,以及抛出异常会返回拒绝的期约
// ...

// 3. 如果没有显式地返回语句,则Promise.reject()会包装默认的返回值 undefined
let p3 = p1.then(null, () => undefined); // Promise {<fulfilled>: undefined}
let p4 = p1.then(null, () => {}); // Promise {<fulfilled>: undefined}
let p5 = p1.then(null, () => Promise.resolve()); // Promise {<fulfilled>: undefined}

Promise.prototype.catch()

用于给期约添加拒绝处理程序。这个方法只接收一个参数:onRejected 处理程序。

事实上,这个方法就是一个语法糖,调用它就相当于调用 Promise.prototype.then(null, onRejected);

let p = Promise.reject();
let onRejected = function(e) {
    setTimeout(console.log, 0, 'rejected');
};

// 等价
p.then(null, onRejected);
p.catch(onRejected);

Promise.prototype.catch()也会返回一个新的期约实例,其行为和Promise.prototype.then()的onRejecte处理程序事一样的。

Promise.prototype.finally()

方法用于给期约添加onFinally处理程序,这个处理程序在期约转换为解决或拒绝状态时都会执行。

这个方法可以避免 onResolved 和 onRejected 处理程序中出现冗余代码。

但 onFinally 处理程序没有办法知道期约的状态是解决还是拒绝,所以这个方法主要用于添加清理代码。

注意,Promise.prototype.finally() 方法也会返回一个新的期约实例,但是这个新期约实例不同于 then() 或 catch() 方式返回的实例。因为 onFinally 被设计为一个状态无关的方法,所以在大多数情况下它将表现为父期约的传递。对于已解决状态和被拒绝状态都是如此。

非重入期约方法?non-reentrancy?

当期约进入落定(settled)状态时,与该状态相关的处理程序仅仅会被排期,而非立即执行。

跟在添加这个处理程序的代码之后的同步代码一定会在处理程序之前先执行。即使期约一开始就是与附加处理程序关联的状态,执行顺序也是这样的。

这个特性由 JavaScript 运行时保证,被称为“非重入”(non-reentrancy) 特性:

let p = Promise.resolve();
p.then(() => console.log('onResolved handler'));
console.log('after p.then')

// after p.then
// onResolved handler

先添加处理程序后解决期约也是一样的:即先添加onResolved处理程序,再同步调用resolve(),处理程序也不会进入同步线程执行。

let synchronousResolve;

let p = new Promise((resolve) => {
    synchronousResolve = function() {
        console.log('1: invoking resolve()');
        resolve();
        console.log('2: resolve() returns');
    }
})
p.then(() => console.log('4: then() handler executes'));

// synchronousResolve();
console.log('3: synchronousResolve() returns');

// 1: invoking resolve()
// 2: resolve() returns
// 3: synchronousResolve() returns
// 4: then() handler executes

邻近处理程序的执行顺序?

邻近处理程序的执行顺序如果给期约添加了多个处理程序,当期约状态变化时,相关处理程序会按照添加它们的顺序依次执行。无论是 then()catch() 还是 finally() 添加的处理程序都是如此:

let p1 = Promise.resolve();
let p2 = Promise.reject();
p1.then(() => setTimeout(console.log, 0, 1));
p1.then(() => setTimeout(console.log, 0, 2));
// 1
// 2

p2.then(null, () => setTimeout(console.log, 0, 3));
p2.then(null, () => setTimeout(console.log, 0, 4));
// 3
// 4

拒绝期约的错误处理

拒绝期约类似于 throw() 表达式,因为它们都代表一种程序状态,即需要中断或者特殊处理。

在期约的执行器函数或处理程序中抛出错误会导致拒绝,对应的错误对象会成为拒绝的理由:

let p1 = new Promise((resolve, reject) => reject(Error('foo')));
// promise <rejected>: Error: foo

let p2 = new Promise((resolve, reject) => { throw Error('foo'); });
// promise <rejected>: Error: foo

let p3 = Promise.resolve().then(() => { throw Error('foo'); });
// promise <rejected>: Error: foo

let p4 = Promise.reject(Error('foo'));
// Promise <rejected>: Error: foo

期约可以以任何理由拒绝,包括 undefined,但最好统一使用错误对象。这样做主要是因为创建错误对象可以让浏览器捕获错误对象中的栈追踪信息,而这些信息对调试是非常关键的:

Uncaught (in promise) Error: foo
    at Promise (test.html:5)
    at new Promise (<anonymous>)
    at test.html:5

Uncaught (in promise) Error: foo
    at Promise (test.html:6)
    at new Promise (<anonymous>)
    at test.html:6

Uncaught (in promise) Error: foo
    at test.html:8

Uncaught (in promise) Error: foo
    at Promise.resolve.then (test.html:7)

注意,Promise.resolve().then() 的错误最后才出现,这是因为它需要在运行时消息队列中添加处理程序。也就是说,在最终抛出未捕获错误之前它还会创建另一个期约。

异步错误的副作用?不会阻止继续执行后续同步指令?

通常情况下,在通过 throw() 关键字抛出错误时,JavaScript 运行时的错误处理机制会停止执行抛出错误之后的任何指令。但是,在期约中抛出错误时,因为错误实际上是从消息队列中异步抛出的,所以并不会阻止运行时继续执行同步指令。

throw Error('foo');
console.log('bar'); // 这一行不会执行
// Uncaught Error: foo

Promise.reject(Error('foo'));
console.log('bar');
// bar
// Uncaught (in promise) Error: foo

then() 和 catch() 的 onRejected 处理程序在语义上相当于 try/catch。出发点都是捕获错误之后将其隔离,同时不影响正常逻辑执行。

对比同步错误处理和异步错误处理?

try {
    throw Error('foo');
} catch(e) {
    console.log('caught error', e);
}
console.log('continue synchronous execution');

 new Promise((resolve, reject) => {
    console.log('begin asynchronous execution');
    reject(Error('bar'));
}).catch((e) => {
    console.log('caught error', e);
}).then(() => { // onRejected处理程序会返回一个resolved状态期约(被Promise.resolve()封装)
    console.log('continue asynchronous execution');
});

链式Promise?期约连锁?异步任务串行化?

多个期约组合在一起可以构成强大的代码逻辑。

期约连锁,把期约逐个地串联起来是一种非常有用的编程模式。

之所以可以这样做,是因为每个期约实例的方法(then()、catch()和 finally())都会返回一个新的期约对象,而这个新期约又有自己的实例方法。这样连缀方法调用就可以构成所谓的“期约连锁”。

异步任务串行化:让每个执行器函数都返回一个期约实例,这样就可以让每个后续期约实例都等待之前的期约

let p1 = new Promise((resolve, reject) => {
    console.log('p1 executor');
    setTimeout(resolve, 1000);
});
p1.then(() => new Promise((resolve, reject) => {
        console.log('p2 executor');
        setTimeout(resolve, 1000);
    }))
    .then(() => new Promise((resolve, reject) => {
        console.log('p3 executor');
        setTimeout(resolve, 1000);
    }))
    .then(() => new Promise((resolve, reject) => {
        console.log('p4 executor');
        setTimeout(resolve, 1000); 
    }));

// p1 executor(1 秒后)
// p2 executor(2 秒后)
// p3 executor(3 秒后)
// p4 executor(4 秒后)

// 把生成期约的代码提取到一个工厂函数中
 function delayedResolve(str) {
    return new Promise((resolve, reject) => {
        console.log(str);
        setTimeout(resolve, 1000);
    });
}
delayedResolve('p1 executor')
    .then(() => delayedResolve('p2 executor'))
    .then(() => delayedResolve('p3 executor'))
    .then(() => delayedResolve('p4 executor'))

还记得前面讲的回调地狱吗?如果要实现上面的效果:

function delayedExecute(str, callback = null) {
    setTimeout(() => {
        console.log(str);
        callback && callback();
    }, 1000)
}
delayedExecute('p1 callback', () => {
    delayedExecute('p2 callback', () => {
        delayedExecute('p3 callback', () => {
            delayedExecute('p4 callback');
        });
    });
});

期约图?

因为一个期约可以有任意多个处理程序,所以期约连锁可以构建有向非循环图的结构。
外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

由于期约的处理程序是先添加到消息队列,然后才逐个执行,因此构成了层序遍历。

树只是期约图的一种形式。考虑到根节点不一定唯一,且多个期约也可以组合成一个期约,所以有向非循环图是体现期约连锁可能性的最准确表达。

期约合成?Promise.all()?Promise.race()?

多个期约组合在一起可以构成强大的代码逻辑。Promise 类提供两个将多个期约实例组合成一个期约的静态方法:Promise.all()Promise.race()

  • Promise.all()

Promise.all() 静态方法创建的期约会在一组期约全部解决之后再落定(settled)。这个静态方法接收一个可迭代对象,返回一个新期约

let p = Promise.all([
    Promise.resolve(),
    new Promise((resolve, reject) => setTimeout(resolve, 1000))
]);
setTimeout(console.log, 0, p);
p.then(() => setTimeout(console.log, 0, 'all() resolved!'));
// Promise <pending>
// all() resolved!(大约 1 秒后)

// 可迭代对象的元素会通过 Promise.resolve() 转换为期约
let p2 = Promise.all([1, 2]);

// 空的可迭代对象等价于Promise.resolve()
let p3 = Promise.all([]);

(1)如果至少有一个包含的期约处于pending,则合成的期约也会pending

(2)如果有一个包含的期约rejected,则合成的期约也会rejected。而且,第一个拒绝的期约会将自己的reason作为合成期约的reason,之后再拒绝的期约不会影响最终期约的reason。不过,这并不影响所有包含期约正常的拒绝操作,合成的期约会静默处理所有包含期约的拒绝操作,如下所示:

// 虽然只有第一个期约的拒绝理由会进入拒绝处理程序,第二个期约的拒绝也会被静默处理,不会有错误跑掉
let p = Promise.all([
    Promise.reject(3),
    new Promise((resolve, reject) => setTimeout(reject, 1000))
]);
p.catch((reason) => setTimeout(console.log, 0, reason)); // 3
// Promise {<rejected>: 3}

(3)如果所有期约都成功落定(settled)为resolved,则合成期约返回的value就是所有包含期约返回的value的数组,按照迭代器顺序:

let p = Promise.all([
    Promise.resolve(3),
    Promise.resolve(),
    Promise.resolve(4)
]);
p.then((values) => setTimeout(console.log, 0, values));
// [3, undefined, 4]
  • Promise.race()

Promise.race() 静态方法返回一个包装期约,是一组集合中最先解决或拒绝的期约的镜像。这个方法接收一个可迭代对象,返回一个新期约

let p1 = Promise.race([
  Promise.resolve(),
  Promise.resolve()
]);
// 可迭代对象中的元素会通过Promise.resolve()转换为期约
let p2 = Promise.race([3, 4]);

// 空的可迭代对象等价于new Promise(() => {})
let p3 = Promise.race([]);

Promise.race() 不会对解决或拒绝的期约区别对待。无论是解决还是拒绝,只要是第一个落定的期约,Promise.race() 就会包装其解决值或拒绝理由并返回新期约

串行期约合成?提炼出一个通用函数?

到目前为止,我们讨论期约连锁一直围绕期约的串行执行,忽略了期约的另一个主要特性:异步产生值并将其传给处理程序。基于后续期约使用之前期约的返回值来串联期约是期约的基本功能。这很像函数合成,即将多个函数合成为一个函数,比如:

// 使用 3 个函数基于一个值合成为一个函数
function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}

function addTen(x) {
    return addFive(addTwo(addThree(x)));
}
console.log(addTen(7)); // 17

类似的,使用串行期约合成,渐进的消费一个值:

function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}

function addTen(x) {
    return Promise.resolve(x)
        .then(addTwo)
        .then(addThree)
        .then(addFive);
}
addTen(8).then(console.log); // 18

// 简洁写法
function addTen(x) {
    return [addTwo, addThree, addFive]
        .reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
}
addTen(8).then(console.log); // 18

上述模式可以提炼出一个通用函数,可以把任意多个函数作为处理程序合成一个连续传值的期约连锁:

function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}

function compose(...fns) {
    return (x) => fns
        .reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
        // reduce((previousValue, currentValue) => {}, initialValue)
        // 所以,上一次执行返回一个promise作为previousValue,下一个fn作为currentValue
}
let addTen = compose(addTwo, addThree, addFive);
addTen(8).then(console.log); // 18

期约扩展?期约取消?进度追踪?

ES6 期约实现是很可靠的,但它也有不足之处。比如,很多第三方期约库实现中具备而 ECMAScript 规范却未涉及的两个特性:期约取消和进度追踪。

ES6不支持取消期约和进度通知,一个主要原因就是这样会导致期约连锁和期约合成过度复杂化。很多后续操作无法确定或者说应该发生什么完全说不清楚。

async/await?异步函数?异步任务串行化?

异步函数,也称为“async/await”(语法关键字),是 ES6 期约模式在 ECMAScript 函数中的应用。该特性从行为和语法上都增强了JavaScript,让异步逻辑可以以同步方式编写。

在之前:任何需要访问期约所产生值的代码,都需要以处理程序的方式来接收,也就是说,代码照样还是要放在处理程序里。

function handler(x) { console.log(x); }
let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
p.then(handler); // 3

ES8 为此提供了 async/await 关键字:

function handler(x) { console.log(x); }
async foo() {
    let p = new Promise((resolve, reject) => setTimeout(resolve, 1000, 3));
    const res = await p;
    handler(res); // 3
}

async 关键字?

async 关键字用于声明异步函数。

使用 async 关键字可以让函数具有异步特征,但总体上其代码仍然是同步求值的。

如果异步函数使用了 return 返回了值(如果没有 return 则会返回 undefined,这个值会被 Promise.resolve() 包装成一个期约对象,换句话说,异步函数始终会返回期约对象

async function foo() {
    console.log(1);
    return 3;
}
foo().then(console.log);
console.log(2);
// 1
// 2
// 3

注意,在异步函数抛出错误会返回拒绝期约,但是异步函数里的拒绝期约的错误不会被异步函数捕获:

async function foo() {
    console.log(1);
    throw 3;
}
// 给返回的期约添加一个拒绝处理程序
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

async function foo() {
    console.log(1);
    Promise.reject(3);
}
// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise) 3

await 关键字?

因为异步函数主要针对不会马上完成的任务,所以自然需要一种暂停和恢复执行的能力。

使用 await 关键字可以暂停异步函数代码的执行,等待期约解决。

注意,await 关键字会暂停执行异步函数后面的代码,让出 JavaScript 运行时的执行线程。这个行为与生成器函数中的 yield 关键字是一样的。

// await 关键字期待(但实际上并不要求)一个实现 thenable 接口的对象
async function foo() {
    console.log(await Promise.resolve('foo'));
}
foo(); // foo

// 当然,常规的值也可以
async function foo() {
    console.log(await 'foo');
}
foo(); // foo

从前面我们知道,单独使用 async 关键字,异步函数里的拒绝期约的错误不会被异步函数捕获,会抛出未捕获错误,但是,对拒绝的期约使用 await,则会将拒绝期约返回:

async function foo() {
    console.log(1);
    Promise.reject(3);
}
// Attach a rejected handler to the returned promise
foo().catch(console.log);
console.log(2);
// 1
// 2
// Uncaught (in promise) 3

async function foo() {
    console.log(1);
    await Promise.reject(3);
}
foo().catch(console.log);
console.log(2);
// 1
// 2
// 3

停止和恢复执行?

异步函数如果不包含 await 关键字,其执行基本上跟普通函数没有什么区别。

async function foo() {
  console.log(2);
}
console.log(1);
foo();
console.log(3);
// 1
// 2
// 3

思考一下为什么下面的顺序是相反的:

async function foo() {
    console.log(await Promise.resolve('foo'));
}
async function bar() {
    console.log(await 'bar');
}
async function baz() {
    console.log('baz');
}
foo();
bar();
baz();

// baz
// bar
// foo

// 在新版浏览器
// baz
// foo
// bar
// 原因在后面 TC39 会解释

要完全理解 await 关键字,必须知道它并非只是等待一个值可用那么简单。

JavaScript 运行时在碰到 await 关键字时,会记录在哪里暂停执行。等到 await 右边的值可用了,JavaScript 运行时会向消息队列中推送一个任务,这个任务会恢复异步函数后续的执行。

因此,即使 await 后面跟着一个立即可用的值,函数的其余部分也会被异步求值:


async function foo() {
    console.log(2);
    await null;
    console.log(4);
}
console.log(1);
foo();
console.log(3);

// 1
// 2
// 3
// 4

上述例子很好地解释了运行时的工作过程:

  1. 打印1
  2. 调用异步函数foo
  3. (在 foo()中)打印 2
  4. (在 foo()中)await 关键字暂停执行,为立即可用的值 null 向消息队列中添加一个任务
  5. foo()退出
  6. 打印 3
  7. 同步线程的代码执行完毕
  8. JavaScript 运行时从消息队列中取出任务,恢复异步函数执行
  9. (在 foo()中)恢复执行,await 取得 null 值(这里并没有使用)
  10. (在 foo()中)打印 4
  11. foo()返回

而且,如果 await 后面是一个期约,则问题会复杂一些。此时,为了执行异步函数,实际上会有两个任务被添加到消息队列并被异步求值:

async function foo() {
    console.log(2);
    console.log(await Promise.resolve(8));
    console.log(9);
}
async function bar() {
    console.log(4);
    console.log(await 6);
    console.log(7);
}
console.log(1);
foo();
console.log(3);
bar();
console.log(5);

注意,TC39(技术委员会39,是一个推动 JavaScript 发展的技术委员会) 对 await 后面是期约的情况如何处理做过一次修改,修改后,Promise.resolve(8) 只会生成一个异步任务,因此在新版浏览器中,这个示例的输出结果为 123458967。时机开发中,对于并行的异步操作我们通常更关注结果,而不依赖执行顺序。

  1. 打印 1
  2. 调用异步函数 foo()
  3. (在 foo()中)打印 2
  4. (在 foo()中)await 关键字暂停执行,向消息队列中添加一个期约在落定之后执行的任务
  5. 期约立即落定,把给 await 提供值的任务添加到消息队列
  6. foo()退出
  7. 打印 3
  8. 调用异步函数 bar()
  9. (在 bar()中)打印 4
  10. (在 bar()中)await 关键字暂停执行,为立即可用的值 6 向消息队列中添加一个任务
  11. bar()退出
  12. 打印 5
  13. 同步线程的代码执行完毕
  14. JavaScript 运行时从消息队列中取出解决 await 期约的处理程序,并将解决的值 8 提供给它
  15. JavaScript 运行时向消息队列中添加一个恢复执行 foo() 函数的任务
  16. JavaScript 运行时从消息队列中取出恢复执行 bar() 的任务及值 6
  17. (在 bar()中)恢复执行,await 取得值 6
  18. (在 bar()中)打印 6
  19. (在 bar()中)打印 7
  20. bar()返回
  21. 异步任务完成,JavaScript 从消息队列中取出恢复执行 foo()的任务及值 8
  22. (在 foo()中)打印 8
  23. (在 foo()中)打印 9
  24. foo()返

面试题来了:

// 1
async function async1() {
    console.log('async1 start')
    await async2();
    console.log('async1 end')
}
setTimeout(function () {
   console.log('setTimeout')
}, 0)
async function async2() {
    console.log('async2')
}
console.log('script start')
async1()
new Promise(function (resolve) {
    console.log('promise1')
    resolve()
}).then(function () {
    console.log('promise2')
})
console.log('script end')

// script start
// async1 start
// async2
// promise1
// script end
// async1 end
// promise2
// setTimeout

// 2
console.log('script start')
async function async1() {
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 end')
}
async1()
setTimeout(function() {
  console.log('setTimeout')
}, 0)
new Promise(resolve => {
  console.log('Promise')
  resolve()
})
  .then(function() {
    console.log('promise1')
  })
  .then(function() {
    console.log('promise2')
  })
console.log('script end')

// script start
// async2 end
// Promise
// script end
// async1 end
// promise1
// promise2
// setTimeout

// 3
setTimeout(()=>console.log(2))
Promise.resolve().then(()=>console.log(4))
function f1() {
 return new Promise((resolve) => {
   console.log(5);
   resolve();
   console.log(6)
 })
}

async function f2() {
 console.log(7);
 console.log(await f1());
 console.log(8);
}
// 4
// 2
// 7
// 5
// 6
// 8

异步函数策略?实现 sleep()

因为简单实用,所以异步函数很快成为 JavaScript 项目使用最广泛的特性之一。不过,在使用异步 函数时,还是有些问题要注意。

很多人在刚开始学习 JavaScript 时,想找到一个类似 Java 中 Thread.sleep()之类的函数,好在程 序中加入非阻塞的暂停。以前,这个需求基本上都通过 setTimeout()利用 JavaScript 运行时的行为来 实现的。

有了异步函数之后:

async function sleep(delay) {
    return new Promise((resolve) => setTimeout(resolve, delay));
}
async function foo() {
    const t0 = Date.now();
    await sleep(1500);
    console.log(Date.now() - t0);
}
foo();
  • 异步函数策略?利用平行执行?早执行早返回?

如果使用 await 时不留心,则很可能错过平行加速的机会。

async function randomDelay(id) {
    // 延迟 0~1000 毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}
async function foo() {
    const t0 = Date.now();
    await randomDelay(0);
    await randomDelay(1);
    await randomDelay(2);
    await randomDelay(3);
    await randomDelay(4);
    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed

用一个for循环重写:

async function randomDelay(id) {
    // 延迟 0~1000 毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}
async function foo() {
    const t0 = Date.now();
    for(let i = 0; i< 5; i++) {
        await randomDelay(i);
    }
    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 0 finished
// 1 finished
// 2 finished
// 3 finished
// 4 finished
// 877ms elapsed

从上述代码可知,就算这些期约之间没有依赖,但异步函数会依次暂停,等待每个超时完成,这样可以保证执行顺序,但总执行时间会变长。

如果顺序不是必需保证的,那么可以先一次性初始化所有期约,然后再分别等待它们的结果:

async function randomDelay(id) {
    // 延迟 0~1000 毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}
async function foo() {
    const t0 = Date.now();
    const p0 = randomDelay(0);
    const p1 = randomDelay(1);
    const p2 = randomDelay(2);
    const p3 = randomDelay(3);
    const p4 = randomDelay(4);
    await p0;
    await p1;
    await p2;
    await p3;
    await p4;
    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 1 finished
// 4 finished
// 3 finished
// 0 finished
// 2 finished
// 877ms elapsed

用一个for循环重写:

async function randomDelay(id) {
    // 延迟 0~1000 毫秒
    const delay = Math.random() * 1000;
    return new Promise((resolve) => setTimeout(() => {
        console.log(`${id} finished`);
        resolve();
    }, delay));
}
async function foo() {
    const t0 = Date.now();

    const promises = Array(5).fill(null).map((_, i) => randomDelay(i));
    for (const p of promises) {
        console.log(`awaited ${await p}`);
    }
    console.log(`${Date.now() - t0}ms elapsed`);
}
foo();
// 4 finished
// 2 finished
// 1 finished
// 0 finished
// 3 finished

// awaited 0
// awaited 1
// awaited 2
// awaited 3
// awaited 4

// 877ms elapsed

虽然期约没有按照顺序执行,但 await 按顺序收到了每个期约的值。

  • 异步函数策略?串行执行期约?

前面我们已经实现了如何串行执行期约并把值传给后续的期约:

function addTwo(x) {return x + 2;}
function addThree(x) {return x + 3;}
function addFive(x) {return x + 5;}

function addTen(x) {
    return Promise.resolve(x)
        .then(addTwo)
        .then(addThree)
        .then(addFive);
}
addTen(8).then(console.log); // 18

// 简洁写法
function addTen(x) {
    return [addTwo, addThree, addFive]
        .reduce((promise, fn) => promise.then(fn), Promise.resolve(x));
}
addTen(8).then(console.log); // 18

而通过使用 async/await,期约连锁会变得很简单:

async function addTen(x) {
    for (const fn of [addTwo, addThree, addFive]) {
        x = await fn(x);
    }
    return x; 
}
addTen(9).then(console.log); // 19

这里,await 直接传递了每个函数的返回值,结果通过迭代产生。

当然,这个例子并没有使用期约, 如果要使用期约,则可以把所有函数都改成异步函数。这样它们就都返回期约了:

async function addTwo(x) {return x + 2;}
async function addThree(x) {return x + 3;}
async function addFive(x) {return x + 5;}
async function addTen(x) {
    for (const fn of [addTwo, addThree, addFive]) {
        x = await fn(x);
    }
    return x;
}
addTen(9).then(console.log); // 19
  • 异步函数策略?栈追踪与内存管理?

期约与异步函数的功能有相当程度的重叠,但它们在内存中的表示则差别很大。看看下面的例子,它展示了拒绝期约的栈追踪信息:

function fooPromiseExecutor(resolve, reject) {
    setTimeout(reject, 1000, 'bar');
}
function foo() {
    new Promise(fooPromiseExecutor);
}
foo();

// Uncaught (in promise) bar
//  setTimeout
//  setTimeout (async)
//  fooPromiseExecutor
//  foo

栈追踪信息应该相当直接地表现 JavaScript 引擎当前栈内存中函数调用之间的嵌套关系。在超时处理程序执行时和拒绝期约时,我们看到的错误信息包含嵌套函数的标识符,那是被调用以创建最初期约实例的函数。可是,我们知道这些函数已经返回了,因此栈追踪信息中不应该看到它们。

答案很简单,这是因为 JavaScript 引擎会在创建期约时尽可能保留完整的调用栈。在抛出错误时,调用栈可以由运行时的错误处理逻辑获取,因而就会出现在栈追踪信息中。当然,这意味着栈追踪信息会占用内存,从而带来一些计算和存储成本。

如果在前面的例子中使用的是异步函数,那又会怎样呢?比如:

function fooPromiseExecutor(resolve, reject) {
    setTimeout(reject, 1000, 'bar');
}
async function foo() {
    await new Promise(fooPromiseExecutor);
}
foo();

// Uncaught (in promise) bar
//  foo
//  async function (async)
//  foo

这样一改,栈追踪信息就准确地反映了当前的调用栈。fooPromiseExecutor()已经返回,所以它不在错误信息中。但 foo()此时被挂起了,并没有退出。JavaScript 运行时可以简单地在嵌套函数中存储指向包含函数的指针,就跟对待同步函数调用栈一样。这个指针实际上存储在内存中,可用于在出错时生成栈追踪信息。这样就不会像之前的例子那样带来额外的消耗,因此在重视性能的应用中是可以优先考虑异步函数的。

第12章 BOM

第13章 客户端检测

  • 能力检测 / 安全能力检测 / 基于能力检测进行浏览器分析
  • 用户代理检测 / 用户代理历史 / 浏览器分析
  • 软件与硬件检测 / 识别浏览器与操作系统 / 浏览器元数据 / 硬件

第14章 DOM

对于 DOM 树的理解?

第15章 dom扩展

第16章 DOM2和DOM3

第17章 事件

javascript 事件?观察者模式?IE?Netscape Navigator?DOM2?

JavaScript 与 HTML 的交互是通过事件实现的,事件代表文档或浏览器窗口中某个有意义的时刻。 可以使用仅在事件发生时执行的事件监听器(也叫事件处理程序)订阅事件。在传统软件工程领域,这个模型叫 “观察者模式”,其能够做到页面行为(在 JavaScript 中定义)与页面展示(在 HTML 和 CSS 中定义)的分离。

事件最早是在 IE3 和 Netscape Navigator 2 中出现的,当时的用意是把某些表单处理工作从服务器转移到浏览器上来。到了 IE4 和 Netscape Navigator 3 发布的时候,这两家浏览器都提供了类似但又不同的 API,而且持续了好几代。

DOM2 开始尝试以符合逻辑的方式来标准化 DOM 事件 API。目前所有现代 浏览器都实现了 DOM2 Events 的核心部分。IE8 是最后一个使用专有事件系统的主流浏览器。

事件流?冒泡流?事件捕获流?

在第四代 Web 浏览器(IE4 和 Netscape Communicator 4)开始开发时,开发团队碰到了一个有意思的问题:页面哪个部分拥有特定的事件呢?要理解这个问题,可以在一张纸上画几个同心圆。把手指放 到圆心上,则手指不仅是在一个圆圈里,而且是在所有的圆圈里。两家浏览器的开发团队都是以同样的方式看待浏览器事件的。当你点击一个按钮时,实际上不光点击了这个按钮,还点击了它的容器以及整个页面。

所以,事件流描述了页面接收事件的顺序。

结果非常有意思,IE 和 Netscape 开发团队提出了几乎完全相反的事件流方案。IE 将支持事件冒泡流,而 Netscape Communicator 将支持事件捕获流。

冒泡和捕获?使用哪个事件流?如何阻止冒泡?

  • 冒泡

IE事件流也称事件冒泡。事件被定义为从最具体的元素(文档树中最深的节点)开始触发,然后向上传播至没有那么具体的元素(document,现代浏览器会冒泡到window)

  • 捕获

Netscape Communicator 团队提出了另一种名为事件捕获的事件流。事件捕获的意思是最不具体的节点(现代浏览器会从window开始)应该最先收到事件,而最具体的节点应该最后收到事件。事件捕获实际上是为了在事件到达最终目标前拦截事件。

  • 推荐使用冒泡

由于旧版本浏览器不支持捕获事件流,因此实际当中几乎不会使用事件捕获。通常建议使用事件冒泡,特殊情况下可以使用事件捕获。

DOM 事件流?DOM2 Events 规范?

DOM2 Events 规范规定事件流分为 3 个阶段:事件捕获、到达目标和事件冒泡。

事件捕获最先发生,为提前拦截事件提供了可能。然后,实际的目标元素接收到事件。最后一个阶段是冒泡,最迟要在这个 阶段响应事件。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

从上图看,在 DOM 事件流中,实际的目标(<div>元素)在捕获阶段不会接收到事件。这是因为捕获阶段从 document 到<html>再到<body>就结束了。下一阶段,即会在<div>元素上触发事件的“到达目标” 阶段,通常在事件处理时被认为是冒泡阶段的一部分。然后,冒泡阶段开始,事件反向传播至文档。

但是,大多数支持 DOM 事件流的浏览器实现了一个小小的拓展。虽然 DOM2 Events 规范明确捕获阶段不命中事件目标,但现代浏览器都会在捕获阶段在事件目标上触发事件。最终结果是在事件目标上有两个机会来处理事件。

事件监听器?事件处理程序?

事件意味着用户或浏览器执行的某种动作。比如,单击(click)、加载(load)、鼠标悬停 (mouseover)。为响应事件而调用的函数被称为事件处理程序(或事件监听器)。

事件处理程序的名字以"on"开头,因此 click 事件的处理程序叫作 onclick,而 load 事件的处理程序叫作 onload。有很多方式可以指定事件处理程序。

html 事件处理程序?event?this?

特定元素支持的每个事件都可以使用事件处理程序的名字以 HTML 属性的形式来指定。此时属性的值必须是能够执行的 JavaScript 代码。


// <input type="button" value="Click Me" οnclick="showMessage()"/>

function showMessage() {
  console.log("Hello world!");
}

但是以这种方式指定的事件处理程序有一些特殊的地方,首先,会创建一个函数来封装属性的值,这个函数有一个特殊的全局变量 event,其中保存的就是 event 对象:

// <input type="button" value="Click Me" οnclick="showMessage(event.type)"/>

function showMessage(type) {
  console.log(type); // button
}

有了这个 event 对象,开发者不需要另外定义其他变量,也不用从包装函数的参数列表中去取了。

另外,在这个包装函数中,this 相当于事件的目标元素:

// <input type="button" value="Click Me" οnclick="console.log(this.value)">
// Click Me

这个动态创建的包装函数还有一个特别有意思的地方,就是其作用域链被扩展了。在这个函数中, document 和元素自身的成员都可以被当成局部变量来访问。这是通过使用 with 实现的:

function() {
  with(document) {
    with(this) {
      // 属性值
    }
  }
}

这也意味着事件处理程序可以更方便的访问自己的属性:

// <input type="button" value="Click Me" οnclick="console.log(value)">
// Click Me

如果这个元素是一个表单输入框,则作用域链中还会包含表单元素:

function() {
  with(document) {
    with(this.form) {
      with(this) {
        // 属性值
      }
    }
  }
}

本质上,经过这样的扩展,事件处理程序就可以不必引用表单元素,而是可以直接访问同一个表单中的其他成员了

<form method="post">
  <input type="text" name="username" value="">
  <input
    type="button"
    value="Echo Username"
    onclick="console.log(username.value)"
  >
</form>

在上述代码中,点击按钮会显示处文本框中包含的文本。

html 事件处理程序的问题?

  1. 时机

有可能 HTML 元素已经显示在页面上,用户都与其交互了,而事件处理程序的 js 代码还无法执行。比如在前面的例子中,如果 用户在 showMessage() 函数被定义之前点击按钮时,就会发生错误。为此,大多数 HTML 事件处理程序会封装在 try/catch 块中, 以便在这种情况下静默失败,如下面的例子所示:

<input type="button" value="Click Me" onclick="try{showMessage();}catch(ex) {}">
  1. 对事件处理程序作用域链的扩展在不同浏览器中可能导致不同的结果

不同 JavaScript 引擎中标识符解析的规则存在差异,因此访问无限定的对象成员可能导致错误

  1. HTML 与 JavaScript 强耦合

如果需要修改事件处理程序,则必须在两个地方,即 HTML 和 JavaScript 中,修改代码。这也是很多开发者不使用 HTML 事件处理程序,而使用 JavaScript 指定事件处理程序的主要原因。

DOM0 事件处理程序?

传统方式是把一个函数赋值给 dom 元素的一个事件处理程序属性,这也是在第四代 Web 浏览器中开始支持的事件处理程序赋值方法,直到现在所有现代浏览器仍然都支持此方法,主要原因是简单。

要使用 JavaScript 指定事件处理程序,必须先取得要操作对象的引用。每个元素(包括 window 和 document)都有通常小写的事件处理程序属性,比如 onclick。

// <a href="#" id="hash"></a> 
var btn = document.getElementById('hash');
btn.onclick = function() {
  alert('1');
};

像这样使用 DOM0 方式为事件处理程序赋值时,所赋函数被视为元素的方法。因此,事件处理程序会在元素的作用域中运行,即 this 等于元素。

注意,以这种方式添加事件处理程序是注册在事件流的冒泡阶段的。

另外,通过将事件处理程序属性的值设置为 null,可以移除通过 DOM0 方式添加的事件处理程序:

btn.onclick = null;

DOM2 事件处理程序?

DOM2 Events 为事件处理程序的赋值和移除定义了两个方法:addEventListener()removeEventListener()

它们接收 3 个参数:事件名、事件处理函数和一个布尔值,true 表示在捕获阶段调用事件处理程序,false(默认值)表示在冒泡阶段调用事件处理程序

// <a href="#" id="hash"></a> 

var btn = document.getElementById('hash');
btn.addEventListener('click',function(){
  alert('1')
}, false);

注意,通过 addEventListener() 添加的事件处理程序只能使用 removeEventListener() 并传入与添加时同样的参数来移除,这里同样的参数要注意:

let btn = document.getElementById("myBtn");

btn.addEventListener("click", () => {
  console.log(this.id);
 }, false);
btn.removeEventListener("click", function() {
  console.log(this.id);
}, false);
// 没有效果!

let handler = function() {
  console.log(this.id);
};
btn.addEventListener("click", handler, false);
// 其他代码
btn.removeEventListener("click", handler, false);
// 有效果!

与 DOM0 方式类似,这个事件处理程序同样在被附加到的元素的作用域中运行。

注意,以这种方式添加事件处理程序是注册在事件流的冒泡阶段的,主要原因是跨浏览器兼容性好。把事件处理程序注册到捕获阶段通常用于在事件到达其指定目标之前拦截事件。如果不需要拦截,则不要使用事件捕获。

dom level 0 vs dom level 2

使用 DOM2 方式的主要优势是可以为同一个事件添加多个事件处理程序

// <a href="#" id="hash" οnclick="fn1();"></a>

// dom0
var btn = document.getElementById('hash');
btn.onclick = function() {
  alert('1');
};
btn.onclick = function() {
  alert('2');
};
// 注意,绑定同一个元素的 onclick 事件,后面的会覆盖前面的

// dom2
var btn = document.getElementById('hash');
btn.addEventListener('click',function(){
  alert('1')
},false);
btn.addEventListener('click',function(){
  alert('2')
},false);
// 注意,绑定同一事件不会相互覆盖,按顺序执行

跨浏览器事件处理程序?如何实现?

IE 实现了与 DOM 类似的方法,即 attachEvent()detachEvent()。因为 IE8 及更早版本只支持事件冒泡,所以使用 attachEvent() 添加的事件处理程序会添加到冒泡阶段。

值得注意是是 IE 事件处理程序和 DOM0/2 事件处理事件的差异:

  • 使用 DOM0 方式时,事件处理程序中的 this 值等于目标元素。而使用 attachEvent()时,事件处理程序是在全 19 局作用域中运行的,因此 this 等于 window
  • 与 DOM 方法不同,IE 事件处理程序会以添加它们的顺序反向触发

为了以跨浏览器兼容的方式处理事件,很多开发者会选择使用一个 JavaScript 库,其中抽象了不同 浏览器的差异。

有些开发者也可能会自己编写代码,以便使用最合适的事件处理手段。自己编写跨浏览器事件处理代码也很简单,主要依赖能力检测。要确保事件处理代码具有最大兼容性,只需要让代码在冒泡阶段运行即可。

// 根据需要分别使用 DOM0, DOM2, ie
var EventUtil = {
  addHandler: function(element, type, handler) {
    if (element.addEventListener) {
      element.addEventListener(type, handler, false)
    } else if (element.attachEvent) {
      element.attachEvent("on" + type, handler);
    } else {
      element["on" + type] = handler;
    }
  }, 

  removeHandler: function(element, type, handler) {
    if (element.removeEventListener) {
      element.removeEventListener(type, handler, false);
    } else if (element.detachEvent) {
      element.detachEvent("on" + type, handler);
    } else {
      element["on" + type] = null;
    }
  }
}

let btn = document.getElementById("myBtn")
let handler = function() {
  console.log("Clicked");
};
EventUtil.addHandler(btn, "click", handler);
EventUtil.removeHandler(btn, "click", handler);

这里的 addHandler()removeHandler()方法并没有解决所有跨浏览器一致性问题,比如 IE 的作用域问题、多个事件处理程序执行顺序问题等。不过,这两个方法已经实现了跨浏览器添加和移除事件处理程序。另外也要注意,DOM0 只支持给一个事件添加一个处理程序。好在 DOM0 浏览器已经很少有人使用了,所以影响应该不大

第17章 17.3 事件对象

第18章 动画与Canvas图形
第19章 表单脚本

第20章 js api

  • Atomics / SharedArrayBuffer
  • 跨上下文消息
  • Encoding API
  • File API 与 Blob API
  • 媒体元素
  • 原生拖放
  • Notifications API
  • Page Visibility API
  • Streams API
  • 计时 API
  • Web 组件
  • Web Cryptography API

第21章 错误处理与调试

第23章 JSON

第24章 网络请求与远程资源

  • XMLHttpRequest 对象 / 使用xhr / http 头部 / get 请求 / post 请求 / XMLHttpRequest level 2
  • 进度事件 / load / progress
  • 跨域资源共享 / 预检请求 / 凭据请求
  • 替代性跨域技术
  • fetch api
  • beacon api
  • web socket

XMLHttpRequest?
fetch?
web socket?

第25章 客户端存储

  • cookie / 限制 / cookie 的构成 / js 的 cookie / 子cookie / 使用cookie的注意事项
  • web storage / storage 类型 / sessionStorage 对象 / localStorage 对象 / 存储事件 / 限制
  • IndexDB / 数据库 / 对象存储 / 事务 / 插入对象 / 通过游标查询 / 键范围 / 设置游标方向 / 索引 / 并发问题 / 限制

IndexedDB?Indexed Database API?对象存储?

IndexedDB 是浏览器存储结构化数据的一个方案。

IndexedDB 是类似于 MySQL 或 Web SQL Database 的数据库。与传统数据库最大的区别在于, IndexedDB 使用对象存储而不是表格保存数据。IndexedDB 数据库就是在一个公共命名空间下的一组对象存储,类似于 NoSQL 风格的实现。

let db, request, version = 1;
request = indexDB.open('admin', version);
request.onerror = (event) => {}
request.onsuccess = (event) => {
  db = event.target.result;
}
// 在两个事件处理程序中,event.target 都指向 request

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  db.createObjectStore("users", { keyPath: "username" }); // 表示应该用作键的存储对象的属性名
}

数据库的版本决定了数据库模式,包括数据库中的对象存储和这些对象存储的结构。如果数据库还不存在,open() 操作会创建一个新数据库,然后触发 upgradeneeded 事件。可以为这个事件设置处理程序,并在处理程序中创建数据库模式。如果数据库存在,而你指定了一个升级版的版本号,则会立即触发 upgradeneeded 事件,因而可以在事件处理程序中更新数据库模式。

IndexedDB 事务?

创建了对象存储之后,剩下的所有操作都是通过事务完成的。事务要通过调用数据库对象的 transaction() 方法创建。任何时候,只要想要读取或修改数据,都要通过事务把所有修改操作组织起来。

// 指定要访问的对象存储的名称 users,可以确保在事务期间只加载 users 对象存储的信息。如果想要访问多个对象存储,可以给第一个参数传入一个字符串数组
let transaction = db.transaction("users"); 

// 如果不指定第二个参数,如前所述,则对数据库中所有的对象存储有只读权限,即每个事务都以只读方式访问数据
// 要修改访问模式,可以传入第二个参数。这个参数应 该是下列三个字符串之一 "readonly","readwrite","versionchange"
let transaction = db.transaction("users", "readwrite"); // 可读写

const store = transaction.objectStore("users"), request = store.get('001');
request.onerror = (event) => alert("Did not get the object!");
request.onsuccess = (event) => alert(event.target.result.firstName);

IndexDB 并发问题?

IndexedDB 虽然是网页中的异步 API,但仍存在并发问题。

如果两个不同的浏览器标签页同时打开了同一个网页,则有可能出现一个网页尝试升级数据库而另一个尚未就绪的场景。

有问题的操作是设置数据库为新版本,而版本变化只能在浏览器只有一个标签页使用数据库时才能完成。因此,第一次打开数据库时,添加 onversionchange 事件处理程序非常重要。当另一个同源标签页将数据库打开到新版本时,将执行此回调。对这个事件最好的处理是立即关闭数据库,以便完成版本升级。

let request, database;
request = indexDb.open('admin', 1);
request.onsuccess = (event) => {
  database = event.target.result;
  database.onversionchange = () => database.close();
}

应该在每次成功打开数据库后都指定 onversionchange 事件处理程序。通过始终都指定这些事件处理程序,可以保证 Web 应用程序能够更好地处理与 IndexedDB 相关的 并发问题。

IndexedDB 限制问题?

IndexedDB 的很多限制实际上与 Web Storage 一样。首先,IndexedDB 数据库是与页面源(协议、域 和端口)绑定的,因此信息不能跨域共享。

其次,每个源都有可以存储的空间限制。当前 Firefox 的限制是每个源 50MB,而 Chrome 是 5MB。 移动版 Firefox 有 5MB 限制,如果用度超出配额则会请求用户许可。

第26章 模块

  • 理解模块模式 / 模块标识符 / 模块依赖 / 模块加载 / 入口 / 异步依赖 / 动态依赖 / 静态分析 / 循环依赖
  • 凑合的模块系统
  • 使用ES6之前的模块加载器 / CommonJS / 异步模块定义 / 通用模块定义 / 模块加载器终将没落
  • 使用ES6模块 / 模块标签即定义 / 模块加载 / 模块行为 / 模块导出 / 模块转移导出 / 工作者模块 / 向后兼容

第27章 工作者线程

  • 工作者线程 / 工作者线程类型 / WorkerGlobalSCope
  • 专用工作者线程 / 隐式 MessagePorts / 生命周期 / 配置 Worker / 在 js 行内创建工作者线程 / 在工作者线程中动态执行脚本 / 委托任务到子工作者线程 / 处理工作者线程错误 / 与专用工作者线程通信 / 工作者线程数据传输 / 线程池
  • 共享工作者线程 / 生命周期
  • 服务工作者线程 / 缓存 / 服务工作者线程客户端 / 一致性 / 生命周期 / 控制反转与服务工作者线程持久化 / 通过 updateViaCache 管理服务文件缓存 / 强制性服务工作者线程操作 / 服务工作者线程消息 / 拦截 fetch 事件 / 推送通知

工作者线程?传统并发结构?

js 单线程就意味着不能像多线程语言那样把工作委托给独立的线程或进程去做。JavaScript 的单线程 可以保证它与不同浏览器 API 兼容。假如 JavaScript 可以多线程执行并发更改,那么像 DOM 这样的 API 就会出现问题。

因此,POSIX 线程或 Java 的 Thread 类等传统并发结构都不适合 JavaScript。而这也正是工作者线程的价值所在:允许把主线程的工作转嫁给独立的实体,而不会改变现有的单线程模型。

使用工作者线程,浏览器可以在原始页面环境之外再分配一个完全独立的二级子环境。这个子环境不能与依赖单线程交互的 API(如 DOM) 互操作,但可以与父环境并行执行代码。

工作者线程 vs 线程?

  • 工作者线程是以实际线程实现的。例如,Blink 浏览器引擎实现工作者线程的 WorkerThread 就 对应着底层的线程
  • 工作者线程并行执行。虽然页面和工作者线程都是单线程 JavaScript 环境,每个环境中的指令则可以并行执行
  • 工作者线程可以共享某些内存。工作者线程能够使用 SharedArrayBuffer 在多个环境间共享内容。线程会使用锁实现并发控制,但 JavaScript 使用 Atomics 接口(原子操作)实现并发控制
  • 工作者线程不共享全部内存。在传统线程模型中,多线程有能力读写共享内存空间。对于工作者线程来说,除了 SharedArrayBuffer 外,从工作者线程进出的数据需要复制或转移
  • 工作者线程不一定在同一个进程里。通常,一个进程可以在内部产生多个线程。根据浏览器引擎的实现,工作者线程可能与页面属于同一进程,也可能不属于。例如,Chrome 的 Blink 引擎对共享工作者线程和服务工作者线程使用独立的进程
  • 创建工作者线程的开销更大。工作者线程有自己独立的事件循环、全局对象、事件处理程序和其他 JavaScript 环境必需的特性

无论形式还是功能,工作者线程都不是用于替代线程的。HTML Web 工作者线程规范是这样说的:工作者线程相对比较重,不建议大量使用。例如,对一张 400 万像素的图片,为每个像素都启动一个工作者线程是不合适的。通常,工作者线程应该是长期运行的,启动成本比较高, 每个实例占用的内存也比较大。

工作者线程的类型?

  • 专用工作者线程 / web Worker

专用工作者线程,通常简称为工作者线程、Web Worker 或 Worker,是一种实用的工具,可以让脚本单独创建一个 JavaScript 线程,以执行委托的任务。专用工作者线程,顾名思义,只能被创建它的页面使用。

  • 共享工作者线程

共享工作者线程与专用工作者线程非常相似。主要区别是共享工作者线程可以被多个不同的上下文使用,包括不同的页面。任何与创建共享工作者线程的脚本同源的脚本,都可以向共享工作者线程发送 消息或从中接收消息

  • 服务工作者线程

服务工作者线程与专用工作者线程和共享工作者线程截然不同。它的主要用途是拦截、重定向和修改页面发出的请求,充当网络请求的仲裁者的角色

第28章 最佳实践

  • 可维护性 / 编码规范 / 松散耦合 / 编码惯例
  • 性能 / 作用域意识 / 选择正确的方法 / 语句最少化 / 优化 dom 交互
  • 部署 / 构建流程 / 验证 / 压缩

最佳实践?性能?

JavaScript 一开始就是一门解释型语言,因此执行速度比编译型语言要慢一些。Chrome 是第一个引入优化引擎将 JavaScript 编译为原生代码的浏览器。

即使到了编译 JavaScript 时代,仍可能写出运行慢的代码。不过,如果遵循一些基本模式,就能保证写出执行速度很快的代码。

性能最佳实践?作用域意识?

随着作用域链中作用域数量的增加,访问当前作用域外部变量所需的时间也会增加。访问全局变量始终比访问局部变量慢,因为必须遍历作用域链。任何可以缩短遍历作用域链时间的举措都能提升代码性能。

  1. 避免全局查找
  2. 不使用with语句
  • 避免全局查找

一个经验规则就是,只要函数中有引用超过两次的全局对象,就应该把这个对象保存为一个局部变量。

function updateUI() {
  let imgs = document.getElementsByTagName("img");
  for (let i = 0, len = imgs.length; i < len; i++) {
    imgs[i].title = '${document.title} image ${i}';
  }
  let msg = document.getElementById("msg");
  msg.innerHTML = "Update complete.";
}

// 优化
function updateUI() {
  let doc = document;
  let imgs = doc.getElementsByTagName("img");
  for (let i = 0, len = imgs.length; i < len; i++) {
    imgs[i].title = '${doc.title} image ${i}';
  }
  let msg = doc.getElementById("msg");
  msg.innerHTML = "Update complete.";
}
  • 不使用with语句

在性能很重要的代码中,应避免使用 with 语句。与函数类似,with 语句会创建自己的作用域, 因此也会加长其中代码的作用域链。

性能最佳实践?选择正确的方法?

与其他语言一样,影响性能的因素通常涉及算法或解决问题的方法。

  1. 避免不必要的属性查找
  2. 优化循环
  3. 展开循环
  4. 避免重复解释
  5. 尽可能使用原生方法 / 使用 c 或 c++ 等编译型语言写的
  6. switch 语句其实很快 / 可以把概率大的值放前面进一步提升性能
  7. 位操作很快 / 执行数学运算操作时位操作一定比其他计算更快
  • 避免不必要的属性查找

JavaScript 中常见算法的类型有:

  1. O(1) / 常量 / 无论多少值,执行时间都不变
  2. O(logn) / 对数 / 执行时间随着值的增加而增加,但算法完成不需要读取每个值 / 二分查找
  3. O(n) / 线性 / 执行时间与值的数量直接相关 / 迭代数组的所有元素
  4. O(n^2) / 二次方 / 执行时间随着值的增加而增加,而且每个值至少要读取 n 次 / 插入排序

在 javascript 中,使用变量和数组相比访问对象属性的效率更高,访问对象数组的算法复杂度为 O(n),而且查找属性名还要搜索原型链。简单来说,查找的属性越多,执行时间就越长。

let values = { first: 5, second: 10 };
let sum = values.first + values.second;
console.log(sum);

上述代码使用2次属性查找来计算,一两次属性查找可能不会有明显的性能问题,但几百上千次则绝对会拖慢执行速度。

另外,特别要注意的是避免通过多次查找获取一个值。例如,看下面的例子:

let query = window.location.href.substring(window.location.href.indexOf("?"));

// 优化
let url = window.location.href;
let query = url.substring(url.indexOf("?"));

上述代码使用了6次属性查找,这种代码效率非常低。然后改进之后只有 4 次属性查找,比之前节省了约 33%,在大型脚本中如果能这样优化,可能就会明显改进性能。

还是那句话,只要使用某个 object 属性超过一次,就应该将其保存在局部变量中。第一次仍然要用 O(n)的复杂度去访问这个属性,但后续每次访问就都是 O(1)。

  • 优化循环

优化循环的基本步骤如下:

(1) 简化终止条件。因为每次循环都会计算终止条件,所以它应该尽可能地快。这意味着要避免属性查找或其他 O(n) 操作。
(2) 简化循环体。循环体是最花时间的部分,因此要尽可能优化。要确保其中不包含可以轻松转移到循环外部的密集计算。
(3) 使用后测试循环。最常见的循环就是 for 和 while 循环,这两种循环都属于先测试循环。do-while 就是后测试循环,避免了对终止条件初始评估,因此应该会更快。

示例:

for (let i = 0; i < values.length; i++) {
  process(values[i]);
}

// 这里终止条件为 values.length,属性查找为 O(n)

// 优化
for (let i = values.length-1; i >= 0; i--) {
  process(values[i]);
}
// 这里终止条件为 0,复杂度为 O(1)

// 优化
// 将整个循环改成后测试循环
let i = values.length-1;
if (i > -1) {
  do {
    process(values[i]);
  } while (--i >= 0);
}
// 这里主要的优化是将终止条件和递减操作符合并成了一条语句

注意,使用后测试循环时一定是至少有一个值需要处理一次。如果这里的数组是空的,那么会浪费一次循环,而先测试循环就可以避免这种情况。

  • 展开循环

如果循环的次数是有限的,那么通常抛弃循环而直接多次调用函数会更快。因为可以节省创建循环、计算终止条件的消耗,反而让代码运行更快。

// 假设 values.length 为 3
for (let i = 0; i < values.length; i++) {
  process(values[i]);
}

// 此时使用直接多次调用函数会更快
process(values[0]);
process(values[1]);
process(values[2]);

如果不能提前预知循环的次数,或许可以使用一种叫作达夫设备(Duff’s Device)的技术。该 技术是以其发明者 Tom Duff 命名的,他最早建议在 C 语言中使用该技术。在 JavaScript 实现达夫设备的 人是 Jeff Greenberg。

达夫设备的基本思路是以 8 的倍数作为迭代次数从而将循环展开为一系列语句。例如,假设数组有 10 个元素,则第一次循环只会调用2次,后续每次循环都会调用8次,这样展开之后,能够加快大数据集的处理速度。

展开循环对于大型数据集可以节省很多时间,但对于小型数据集来说,则可能不值得。因为实现同样的任务需要多写很多代码,所以如果处理的数据量不大,那么显然没有必要。

  • 避免重复解释

重复解释的问题存在于 JavaScript 代码尝试解释 JavaScript 代码的情形。在使用 eval() 函数或 Function 构造函数,或者给 setTimeout() 传入字符串参数时会出现这种情况。

// 对代码求值
eval("console.log('Hello world!')");
// 创建新函数
let sayHi = new Function("console.log('Hello world!')");
// 设置超时函数
setTimeout("console.log('Hello world!')", 500);

在上面所列的每种情况下,都需要重复解释包含 JavaScript 代码的字符串。这些字符串在初始解析 阶段不会被解释,因为代码包含在字符串里。这意味着在 JavaScript 运行时,必须启动新解析器实例来 解析这些字符串中的代码。实例化新解析器比较费时间,因此这样会比直接包含原生代码慢。

性能最佳实践?语句最少化?

JavaScript 代码中语句的数量影响操作执行的速度。一条可以执行多个操作的语句,比多条语句中 每个语句执行一个操作要快。那么优化的目标就是寻找可以合并的语句,以减少整个脚本的执行时间。

  1. 多个变量声明合并为一行
  2. 插入迭代性值 / 比如let name = values[i++];
  3. 使用数组和对象字面量 / 构造函数始终会比字面量更多的语句

但是注意,减少代码中的语句量是很不错的目标,但不是绝对的法则。一味追求语句最少化, 可能导致一条语句容纳过多逻辑,最终难以理解。

性能最佳实践?优化 dom 交互?

在所有 JavaScript 代码中,涉及 DOM 的部分无疑是非常慢的。DOM 操作和交互需要占用大量时间, 因为经常需要重新渲染整个或部分页面。此外,看起来简单的操作也可能花费很长时间,因为 DOM 中携带着大量信息。

在现代前端发展中,不得不提 vdom,可以很大程度上提升这个dom操作导致的性能问题。那在之前,如何优化 DOM 交互来提升脚本运行速度呢?

  1. 实时更新最小化 / 文档片段
  2. 使用 innerHTML
  3. 使用事件委托
  4. 注意HTML Collection
  • 实时更新最小化 / 文档片段

实时更新是指访问 DOM 时,只要访问的部分是显示页面的一部分。之所以称其为实时更新,是因为涉及需要立即(实时)更新页面的显示,让用户看到。

每次这样的更新,无论是插入一个字符还是删除页面上的一节内容,都会导致性能损失。这是因为浏览器需要为此重新计算数千项指标,之后才能执行更新。实时更新的次数越多,执行代码所需的时间也越长。

let list = document.getElementById("myList"), item;
for (let i = 0; i < 10; i++) {
  item = document.createElement("li");
  list.appendChild(item);
  item.appendChild(document.createTextNode('Item ${i}');
}

以上代码向列表中添加了 10 项。每添加 1 项,就会有两次实时更新:一次添加<li>元素,一次为它添加文本节点。因为要添加 10 项,所以整个操作总共要执行 20 次实时更新

为了减少实时更新的次数,可以有两种解决方案:

(1)从 页面中移除列表,执行更新,然后再把列表插回页面中相同的位置。这个办法并不可取,因为每次更新时页面都会闪烁
(2)使用文档片段构建 DOM 结构,然后一次性将它添加到 list 元素。这 个办法可以减少实时更新,也可以避免页面闪烁

let list = document.getElementById("myList"), item;
let fragment = document.createDocumentFragment();

for (let i = 0; i < 10; i++) {
  item = document.createElement("li");
  fragment.appendChild(item);
  item.appendChild(document.createTextNode('Item ${i}');
}
list.appendChild(fragment);

这样修改后,完成同样的操作只会触发一次实时更新。这是因为更新是在添加完所有列表项之后一次性完成的。只要是必须更新 DOM,就尽量考虑使用文档片段来预先构建 DOM 结构,然后再把构建好的 DOM 结构实时更新到文档中。

  • 使用 innerHTML

在页面中创建新 DOM 节点的方式有两种:使用 DOM 方法如 createElement() 和 appendChild(), 以及使用 innerHTML。对于少量 DOM 更新,这两种技术区别不大,但对于大量 DOM 更新,使用 innerHTML 要比使用标准 DOM 方法创建同样的结构快很多。

在给 innerHTML 赋值时,后台会创建 HTML 解析器,然后会使用原生 DOM 调用而不是 JavaScript 的 DOM 方法来创建 DOM 结构。原生 DOM 方法速度更快,因为该方法是执行编译代码而非解释代码。

let list = document.getElementById("myList"), html = "";
for (let i = 0; i < 10; i++) {
  html += '<li>Item ${i}</li>';
}
list.innerHTML = html;

使用innerHTML可以提升性能,但也会暴露巨大的XSS攻击面。无论何时使用它 填充不受控的数据,都有可能被攻击者注入可执行代码。此时必须要当心。

  • 使用事件委托

一个页面中事件处理程序的数量与页面响应用户交互的速度有直接关系。为了减少对页面响应的影响,应该尽可能使用事件委托。

事件委托利用了事件的冒泡。任何冒泡的事件都可以不在事件目标上,而在目标的任何祖先元素上 处理。基于这个认知,可以把事件处理程序添加到负责处理多个目标的高层元素上。只要可能,就应该 在文档级添加事件处理程序,因为在文档级可以处理整个页面的事件。

  • 注意 HTMLCollection

可忽略

  • 9
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值