一位初级进阶中级 JavaScript 工作者的自我修养(二)

6,878 阅读22分钟

前言

最近的前端面试已经的飞起了😂,从计算机原理、编译原理、数据结构、算法、设计模式、编程范式编译工具、格式工具、Git、NPM、单元测试、Nginx、PM2、CI / CD 了解和使用

这随便挑选一个部分,知识点都可以深入挖掘,深不见底那种。

前两天发布了 JS 基础系列第一篇文章,得到了同学们比较好的反馈。❤️❤️❤️

让我们继续学习这个系列其他有意思的内容,希望可以给大家带来一点点🤏帮助。

温馨提示:本文适用于前端入门的同学和最近在准备想要系统化温习 JS 基础的朋友。已经工作多年的中高级前端大佬可以直接跳过本文哈~

相关文章:

第四章 「重学 JavaScript」执行机制

一、try 和 finally

为何 try 里面放 return,finally 还会执行,理解其内部机制

1.1 Completion 类型

// return 执行了但是没有立即返回,而是先执行了 finally
function kaimo() {
  try {
    return 0;
  } catch (err) {
    console.log(err);
  } finally {
    console.log("a");
  }
}

console.log(kaimo()); // a 0
// finally 中的 return 覆盖了 try 中的 return。
function kaimo() {
  try {
    return 0;
  } catch (err) {
    console.log(err);
  } finally {
    return 1;
  }
}

console.log(kaimo()); // 1

Completion Record Completion Record 用于描述异常、跳出等语句执行过程。表示一个语句执行完之后的结果,它有三个字段。

[[type]]:表示完成的类型,有 break、continue、return、throw、normal 几种类型

[[value]]:表示语句的返回值,如果语句没有,则是 empty

[[target]]:表示语句的目标,通常是一个 JavaScript 标签

JavaScript 使用 Completion Record 类型,控制语句执行的过程。

1.2 普通语句

在 JavaScript 中,把不带控制能力的语句称为普通语句。种类可以参考引言的图片。

1、这些语句在执行时,从前到后顺次执行(这里先忽略 var 和函数声明的预处理机制),没有任何分支或者重复执行逻辑。

2、普通语句执行后,会得到 [[type]] 为 normal 的 Completion Record,JavaScript 引擎遇到这样的 Completion Record,会继续执行下一条语句。

3、在 Chrome 控制台输入一个表达式,可以得到结果,但是在前面加上 var,就变成了 undefined。Chrome 控制台显示的正是语句的 Completion Record 的 [[value]]。

1.3 语句块

语句块就是拿大括号括起来的一组语句,它是一种语句的复合结构,可以嵌套。

语句块内部的语句的 Completion Record 的 [[type]] 如果不为 normal,会打断语句块后续的语句执行。

1.3.1 内部为普通语句的一个语句块

// 在每一行的注释中为 Completion Record
{
  var i = 1; // normal, empty, empty
  i++; // normal, 1, empty
  console.log(i); //normal, undefined, empty
} // normal, undefined, empty

在这个 block 中都是 normal 类型的话,该程序会按顺序执行。

1.3.2 加入 return

// 在每一行的注释中为 Completion Record
{
  var i = 1; // normal, empty, empty
  return i; // return, 1, empty
  i++;
  console.log(i);
} // return, 1, empty

在 block 中产生的非 normal 的完成类型可以穿透复杂的语句嵌套结构,产生控制效果。

1.4 控制型语句

控制型语句带有 if、switch 关键字,它们会对不同类型的 Completion Record 产生反应。

控制类语句分成两部分:

对其内部造成影响:如 if、switch、while/for、try。 对外部造成影响:如 break、continue、return、throw。

穿透就是去上一层的作用域或者控制语句找可以消费 break,continue 的执行环境,消费就是在这一层就执行了这个 break 或者 continue

这两类语句的配合,会产生控制代码执行顺序和执行逻辑的效果。

1.5 带标签的语句

1、任何 JavaScript 语句是可以加标签的,在语句前加冒号即可:。

firstStatement: var i = 1;

2、类似于注释,基本没有任何用处。唯一有作用的时候是:与完成记录类型中的 target 相配合,用于跳出多层循环。

outer: while (true) {
  console.log("outer");
  inner: while (true) {
    console.log("inner1");
    break outer;
    console.log("inner2");
  }
}
console.log("finished");
// outer  inner1  finished

二、宏任务和微任务

宏任务和微任务分别有哪些

宏任务主要有:script(整体代码)、setTimeout、setInterval、I/O、UI 交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)。

微任务主要有:Promise.then、 MutationObserver、 process.nextTick(Node.js 环境)。

三、异步编程

JavaScript 如何实现异步编程,可以详细描述 EventLoop 机制

在 js 中,任务分为宏任务(macrotask)和微任务(microtask),这两个任务分别维护一个队列,均采用先进先出的策略进行执行!同步执行的任务都在宏任务上执行。

具体的操作步骤如下:

  • 从宏任务的头部取出一个任务执行;
  • 执行过程中若遇到微任务则将其添加到微任务的队列中;
  • 宏任务执行完毕后,微任务的队列中是否存在任务,若存在,则挨个儿出去执行,直到执行完毕;
  • GUI 渲染;
  • 回到步骤 1,直到宏任务执行完毕;

前 4 步构成了一个事件的循环检测机制,即我们所称的 eventloop。

四、分析异步嵌套

可以快速分析一个复杂的异步嵌套逻辑,并掌握分析方法

可以先把复杂的异步写法转换为简单写法。比如 async、await 异步的这种写法,其原理就是回调函数。

然后按照事件的循环机制进行分析。

五、使用 Promise 实现串行

5.1 概述

最常用的队列操作就是 Array.prototype.reduce()

let result = [1, 2, 5].reduce((accumulator, item) => {
  return accumulator + item;
}, 0); // <-- Our initial value.

console.log(result); // 8

最后一个值 0 是起始值,每次 reduce 返回的值都会作为下次 reduce 回调函数的第一个参数,直到队列循环完毕,因此可以进行累加计算。

那么将 reduce 的特性用在 Promise 试试:

function runPromiseByQueue(myPromise) {
  myPromise.reduce(
    (previousPromise, nextPromise) => previousPromise.then(() => nextPromise()),
    Promise.resolve()
  );
}

当上一个 Promise 开始执行(previousPromise.then),当其执行完毕后再调用下一个 Promise,并作为一个新 Promise 返回,下次迭代就会继续这个循环。

const createPromise = (time, id) => () =>
  new Promise(
    setTimeout(() => {
      console.log("promise", id);
      solve();
    }, time)
  );

runPromiseByQueue([
  createPromise(3000, 1),
  createPromise(2000, 2),
  createPromise(1000, 3),
]);

5.2 精读

Reduce 是同步执行的,在一个事件循环就会完成,但这仅仅是在内存快速构造了 Promise 执行队列,展开如下:

new Promise((resolve, reject) => {
  // Promise #1

  resolve();
})
  .then((result) => {
    // Promise #2

    return result;
  })
  .then((result) => {
    // Promise #3

    return result;
  }); // ... and so on!

Reduce 的作用就是在内存中生成这个队列,而不需要把这个冗余的队列写在代码里!

5.3 更简单的方法

在 async/await 的支持下,runPromiseByQueue 函数可以更为简化:

async function runPromiseByQueue(myPromises) {
  for (let value of myPromises) {
    await value();
  }
}

多亏了 async/await,代码看起来如此简洁明了。

不过要注意,这个思路与 reduce 思路不同之处在于,利用 reduce 的函数整体是个同步函数,自己先执行完毕构造 Promise 队列,然后在内存异步执行;而利用 async/await 的函数是利用将自己改造为一个异步函数,等待每一个 Promise 执行完毕。

六、EventLoop

Node 与浏览器 EventLoop 的差异

6.1 与浏览器环境有何不同

在 node 中,事件循环表现出的状态与浏览器中大致相同。不同的是 node 中有一套自己的模型。node 中事件循环的实现是依靠的 libuv 引擎。我们知道 node 选择 chrome v8 引擎作为 js 解释器,v8 引擎将 js 代码分析后去调用对应的 node api,而这些 api 最后则由 libuv 引擎驱动,执行对应的任务,并把不同的事件放在不同的队列中等待主线程执行。 因此实际上 node 中的事件循环存在于 libuv 引擎中。

6.2 事件循环模型

下面是一个 libuv 引擎中的事件循环的模型:

   ┌───────────────────────┐
┌─>│       timers          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │    I/O callbacks      │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │    idle, prepare      │
│  └──────────┬────────────┘ ┌───────────────┐
│  ┌──────────┴────────────┐ │ incoming:     │
│  │        poll           │<──connections───│
│  └──────────┬────────────┘ │ data, etc.    │
│  ┌──────────┴────────────┐ └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘

注:模型中的每一个方块代表事件循环的一个阶段

这个模型是 node 官网上的一篇文章中给出的,我下面的解释也都来源于这篇文章。我会在文末把文章地址贴出来,有兴趣的朋友可以亲自与看看原文。

6.3 事件循环各阶段详解

从上面这个模型中,我们可以大致分析出 node 中的事件循环的顺序:

外部输入数据-->轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O 事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段...

以上各阶段的名称是根据我个人理解的翻译,为了避免错误和歧义,下面解释的时候会用英文来表示这些阶段。

这些阶段大致的功能如下:

  • timers: 这个阶段执行定时器队列中的回调如  setTimeout()  和  setInterval()。
  • I/O callbacks: 这个阶段执行几乎所有的回调。但是不包括 close 事件,定时器和 setImmediate()的回调。
  • idle, prepare: 这个阶段仅在内部使用,可以不必理会。
  • poll: 等待新的 I/O 事件,node 在一些特殊情况下会阻塞在这里。
  • check: setImmediate()的回调会在这个阶段执行。
  • close callbacks: 例如 socket.on('close', ...)这种 close 事件的回调。

下面我们来按照代码第一次进入 libuv 引擎后的顺序来详细解说这些阶段:

6.3.1 poll 阶段

当个 v8 引擎将 js 代码解析后传入 libuv 引擎后,循环首先进入 poll 阶段。poll 阶段的执行逻辑如下: 先查看 poll queue 中是否有事件,有任务就按先进先出的顺序依次执行回调。 当 queue 为空时,会检查是否有 setImmediate()的 callback,如果有就进入 check 阶段执行这些 callback。但同时也会检查是否有到期的 timer,如果有,就把这些到期的 timer 的 callback 按照调用顺序放到 timer queue 中,之后循环会进入 timer 阶段执行 queue 中的 callback。 这两者的顺序是不固定的,收到代码运行的环境的影响。如果两者的 queue 都是空的,那么 loop 会在 poll 阶段停留,直到有一个 i/o 事件返回,循环会进入 i/o callback 阶段并立即执行这个事件的 callback。

值得注意的是,poll 阶段在执行 poll queue 中的回调时实际上不会无限的执行下去。有两种情况 poll 阶段会终止执行 poll queue 中的下一个回调:1.所有回调执行完毕。2.执行数超过了 node 的限制。

6.3.2 check 阶段

check 阶段专门用来执行 setImmediate()方法的回调,当 poll 阶段进入空闲状态,并且 setImmediate queue 中有 callback 时,事件循环进入这个阶段。

6.3.3 close 阶段

当一个 socket 连接或者一个 handle 被突然关闭时(例如调用了 socket.destroy()方法),close 事件会被发送到这个阶段执行回调。否则事件会用 process.nextTick()方法发送出去。

6.3.4 timer 阶段

这个阶段以先进先出的方式执行所有到期的 timer 加入 timer 队列里的 callback,一个 timer callback 指得是一个通过 setTimeout 或者 setInterval 函数设置的回调函数。

6.3.5 I/O callback 阶段

如上文所言,这个阶段主要执行大部分 I/O 事件的回调,包括一些为操作系统执行的回调。例如一个 TCP 连接生错误时,系统需要执行回调来获得这个错误的报告。

6.4 推迟任务执行的方法

在 node 中有三个常用的用来推迟任务执行的方法:process.nextTick,setTimeout(setInterval 与之相同)与 setImmediate

这三者间存在着一些非常不同的区别:

process.nextTick()

尽管没有提及,但是实际上 node 中存在着一个特殊的队列,即 nextTick queue。这个队列中的回调执行虽然没有被表示为一个阶段,当时这些事件却会在每一个阶段执行完毕准备进入下一个阶段时优先执行。当事件循环准备进入下一个阶段之前,会先检查 nextTick queue 中是否有任务,如果有,那么会先清空这个队列。与执行 poll queue 中的任务不同的是,这个操作在队列清空前是不会停止的。这也就意味着,错误的使用 process.nextTick()方法会导致 node 进入一个死循环。。直到内存泄漏。

那么合适使用这个方法比较合适呢?下面有一个例子:

const server = net.createServer(() => {}).listen(8080);

server.on("listening", () => {});

这个例子中当,当 listen 方法被调用时,除非端口被占用,否则会立刻绑定在对应的端口上。这意味着此时这个端口可以立刻触发 listening 事件并执行其回调。然而,这时候 on('listening)还没有将 callback 设置好,自然没有 callback 可以执行。为了避免出现这种情况,node 会在 listen 事件中使用 process.nextTick()方法,确保事件在回调函数绑定后被触发。

setTimeout()和 setImmediate() 在三个方法中,这两个方法最容易被弄混。实际上,某些情况下这两个方法的表现也非常相似。然而实际上,这两个方法的意义却大为不同。

setTimeout()方法是定义一个回调,并且希望这个回调在我们所指定的时间间隔后第一时间去执行。注意这个“第一时间执行”,这意味着,受到操作系统和当前执行任务的诸多影响,该回调并不会在我们预期的时间间隔后精准的执行。执行的时间存在一定的延迟和误差,这是不可避免的。node 会在可以执行 timer 回调的第一时间去执行你所设定的任务。

setImmediate()方法从意义上将是立刻执行的意思,但是实际上它却是在一个固定的阶段才会执行回调,即 poll 阶段之后。有趣的是,这个名字的意义和之前提到过的 process.nextTick()方法才是最匹配的。node 的开发者们也清楚这两个方法的命名上存在一定的混淆,他们表示不会把这两个方法的名字调换过来---因为有大量的 ndoe 程序使用着这两个方法,调换命名所带来的好处与它的影响相比不值一提。

setTimeout()和不设置时间间隔的 setImmediate()表现上及其相似。猜猜下面这段代码的结果是什么?

setTimeout(() => {
  console.log("timeout");
}, 0);

setImmediate(() => {
  console.log("immediate");
});

实际上,答案是不一定。没错,就连 node 的开发者都无法准确的判断这两者的顺序谁前谁后。这取决于这段代码的运行环境。运行环境中的各种复杂的情况会导致在同步队列里两个方法的顺序随机决定。但是,在一种情况下可以准确判断两个方法回调的执行顺序,那就是在一个 I/O 事件的回调中。下面这段代码的顺序永远是固定的:

const fs = require("fs");

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log("timeout");
  }, 0);
  setImmediate(() => {
    console.log("immediate");
  });
});

答案永远是:

immediate

timeout

因为在 I/O 事件的回调中,setImmediate 方法的回调永远在 timer 的回调前执行。

七、处理海量数据

如何在保证页面运行流畅的情况下处理海量数据

如果要在前端呈现大量的数据,一般的策略就是分页。前端要呈现百万数据,这个需求是很少见的,但是展示千条稍微复杂点的数据,这种需求还是比较常见,只要内存够,javascript 肯定是吃得消的,计算几千上万条数据,js 效率根本不在话下,但是 DOM 的渲染浏览器扛不住,CPU 稍微搓点的电脑必然会卡爆。

策略:显示三屏数据,其他的移除 DOM。

7.1 策略

下面是我简单勾画的一个草图,我们把一串数据放到一个容器当中,这串数据的高度(Data List)肯定是比 Container 的高度要高很多的,如果我们一次性把数据都显示出来,浏览器需要花费大量的时间来计算每个 data 的位置,并且依次渲染出来,整个过程中 JS 并没有花费太多的时间,开销主要是 DOM 渲染。

为了解决这个问题,我们让数据是显示一部分,这一部分是 Container 可视区域的内容,以及上下各一屏(一屏指的是 Container 高度所能容纳的区域大小)的缓存内容。如果 Container 比较高,也可是只缓存半屏,缓存的原因是,在我们滚动滚动条的时候,js 需要时间来拼凑字符串(或者创建 Node ),这个时候浏览器还来不及渲染,所以会出现临时的空白,这种体验是相当不好的。

7.2 Demo

<title>百万数据前端快速流畅显示</title>
<style type="text/css">
#box {position: relative; height: 300px; width: 200px; border:1px solid #CCC; overflow: auto}
#box div { position: absolute; height: 20px; width: 100%; left: 0; overflow: hidden; font: 16px/20px Courier;}
</style>

<div id="box"></div>

<script type="text/javascript">
var total = 1e5
  , len = total
  , height = 300
  , delta = 20
  , num = height / delta
  , data = [];

for(var i = 0; i < total; i++){
    data.push({content: "item-" + i});
}

var box = document.getElementById("box");
box.onscroll = function(){
    var sTop = box.scrollTop||0
      , first = parseInt(sTop / delta, 10)
      , start = Math.max(first - num, 0)
      , end = Math.min(first + num, len - 1)
      , i = 0;

    for(var s = start; s <= end; s++){
        var child = box.children[s];
        if(!box.contains(child) && s != len - 1){
            insert(s);
        }
    }

    while(child = box.children[i++]){
        var index = child.getAttribute("data-index");
        if((index > end || index < start) && index != len - 1){
            box.removeChild(child);
        }
    }

};

function insert(i){
    var div = document.createElement("div");
    div.setAttribute("data-index", i);
    div.style.top = delta * i + "px";
    div.appendChild(document.createTextNode(data[i].content));
    box.appendChild(div);
}

box.onscroll();
insert(len - 1);
</script>

7.3 算法说明

  • 计算 start 和 end 节点

    image Container 可以容纳的 Data 数目为 num = height / delta,Container 顶部第一个节点的索引值为

    var first = parseInt(Container.scrollTop / delta);
    

    由于我们上下都有留出一屏,所以

    var start = Math.max(first - num, 0);
    var end = Math.min(first + num, len - 1);
    
  • 插入节点

    通过上面的计算,从 start 到 end 将节点一次插入到 Container 中,并且将最后一个节点插入到 DOM 中。

    // 插入最后一个节点
    insert(len - 1);
    // 插入从 start 到 end 之间的节点
    for (var s = start; s <= end; s++) {
      var child = Container.children[s];
      // 如果 Container 中已经有该节点,或者该节点为最后一个节点则跳过
      if (!Container.contains(child) && s != len - 1) {
        insert(s);
      }
    }
    

    这里解释下为什么要插入最后一个节点,插入节点的方式是:

    function insert(i){
    var div = document.createElement("div");
    div.setAttribute("data-index", i);
    div.style.top = delta \* i + "px";
    div.appendChild(document.createTextNode(data[i].content));
    Container.appendChild(div);
    }
    

    可以看到我们给插入的节点都加了一个 top 属性,最后一个节点的 top 是最大的,只有把这个节点插入到 DOM 中,才能让滚动条拉长,让人感觉放了很多的数据。

  • 删除节点

    为了减少浏览器的重排(reflow),我们可以隐藏三屏之外的数据。我这里为了方便,直接给删除掉了,后续需要再重新插入。

    while ((child = Container.children[i++])) {
      var index = child.getAttribute("data-index");
      // 这里记得不要把最后一个节点给删除掉了
      if ((index > end || index < start) && index != len - 1) {
        Container.removeChild(child);
      }
    }
    

    当 DOM 加载完毕之后,触发一次 Container.onscroll(),然后整个程序就 OK 了。

第五章 「重学 JavaScript」语法和 API

一、ECMAScript 和 JavaScript

理解 ECMAScript 和 JavaScript 的关系

一个常见的问题是,ECMAScript 和 JavaScript 到底是什么关系?

要讲清楚这个问题,需要回顾历史。1996 年 11 月,JavaScript 的创造者 Netscape 公司,决定将 JavaScript 提交给标准化组织 ECMA,希望这种语言能够成为国际标准。次年,ECMA 发布 262 号标准文件(ECMA-262)的第一版,规定了浏览器脚本语言的标准,并将这种语言称为 ECMAScript,这个版本就是 1.0 版。

该标准从一开始就是针对 JavaScript 语言制定的,但是之所以不叫 JavaScript,有两个原因。一是商标,Java 是 Sun 公司的商标,根据授权协议,只有 Netscape 公司可以合法地使用 JavaScript 这个名字,且 JavaScript 本身也已经被 Netscape 公司注册为商标。二是想体现这门语言的制定者是 ECMA,不是 Netscape,这样有利于保证这门语言的开放性和中立性。

因此,ECMAScript 和 JavaScript 的关系是,前者是后者的规格,后者是前者的一种实现(另外的 ECMAScript 方言还有 JScript 和 ActionScript)。日常场合,这两个词是可以互换的。

二、ES6

熟练运用 es5、es6 提供的语法规范

JavaScript 教程

ECMAScript 6 入门

三、setInterval

setInterval 需要注意的点,使用 settimeout 实现 setInterval

3.1 setInterval 需要注意的点

在使用 setInterval 方法时,每一次启动都需要对 setInterval 方法返回的值做一个判断,判断是否是空值,若不是空值,则要停止定时器并将值设为空,再重新启动,如果不进行判断并赋值,有可能会造成计时器循环调用,在同等的时间内同时执行调用的代码,并会随着代码的运行时间增加而增加,导致功能无法实现,甚至占用过多资源而卡死奔溃。因此在每一次使用 setInterval 方法时,都需要进行一次判断。

let timer = setInterval(func, 1000);
// 在其他地方再次用到setInterval(func, 1000)
if (timer !== null) {
  clearInterval(timer);
  timer = null;
}
timer = setInterval(func, 1000);

3.2 使用 settimeout 实现 setInterval

setIntervalFunc = () => {
  console.log(1); //使用递归
  setTimeout(setIntervalFunc, 1000);
};
setInterval();

四、正则表达式

JavaScript 提供的正则表达式 API、可以使用正则表达式(邮箱校验、URL 解析、去重等)解决常见问题

RegExp 对象

五、错误处理

JavaScript 异常处理的方式,统一的异常处理方案

当 JavaScript 引擎执行 JavaScript 代码时,有可能会发生各种异常,例如是语法异常,语言中缺少的功能,由于来自服务器或用户的异常输出而导致的异常。

而 Javascript 引擎是单线程的,因此一旦遇到异常,Javascript 引擎通常会停止执行,阻塞后续代码并抛出一个异常信息,因此对于可预见的异常,我们应该捕捉并正确展示给用户或开发者。

5.1 Error 对象

throw 和 Promise.reject() 可以抛出字符串类型的异常,而且可以抛出一个 Error 对象类型的异常。

一个 Error 对象类型的异常不仅包含一个异常信息,同时也包含一个追溯栈这样你就可以很容易通过追溯栈找到代码出错的行数了。

所以推荐抛出 Error 对象类型的异常,而不是字符串类型的异常。

创建自己的异常构造函数

function MyError(message) {
  var instance = new Error(message);
  instance.name = "MyError";
  Object.setPrototypeOf(instance, Object.getPrototypeOf(this));
  return instance;
}

MyError.prototype = Object.create(Error.prototype, {
  constructor: {
    value: MyError,
    enumerable: false,
    writable: true,
    configurable: true,
  },
});

if (Object.setPrototypeOf) {
  Object.setPrototypeOf(MyError, Error);
} else {
  MyError.__proto__ = Error;
}

export default MyError;

在代码中抛出自定义的异常类型并捕捉

try {
  throw new MyError("some message");
} catch (e) {
  console.log(e.name + ":" + e.message);
}

5.2 Throw

throw expression;

throw 语句用来抛出一个用户自定义的异常。当前函数的执行将被停止(throw 之后的语句将不会执行),并且控制将被传递到调用堆栈中的第一个 catch 块。如果调用者函数中没有 catch 块,程序将会终止。

try {
  console.log("before throw error");
  throw new Error("throw error");
  console.log("after throw error");
} catch (err) {
  console.log(err.message);
}

// before throw error
// throw error

5.3 Try / Catch

try {
try_statements
}
[catch (exception) {
catch_statements
}][finally {
  finally_statements
}]

try/catch 主要用于捕捉异常。try/catch 语句包含了一个 try 块, 和至少有一个 catch 块或者一个 finally 块,下面是三种形式的 try 声明:

  • try...catch
  • try...finally
  • try...catch...finally

try 块中放入可能会产生异常的语句或函数

catch 块中包含要执行的语句,当 try 块中抛出异常时,catch 块会捕捉到这个异常信息,并执行 catch 块中的代码,如果在 try 块中没有异常抛出,这 catch 块将会跳过。

finally 块在 try 块和 catch 块之后执行。无论是否有异常抛出或着是否被捕获它总是执行。当在 finally 块中抛出异常信息时会覆盖掉 try 块中的异常信息。

try {
  try {
    throw new Error("can not find it1");
  } finally {
    throw new Error("can not find it2");
  }
} catch (err) {
  console.log(err.message);
}

// can not find it2

如果从 finally 块中返回一个值,那么这个值将会成为整个 try-catch-finally 的返回值,无论是否有 return 语句在 try 和 catch 中。这包括在 catch 块里抛出的异常。

function test() {
  try {
    throw new Error("can not find it1");
    return 1;
  } catch (err) {
    throw new Error("can not find it2");
    return 2;
  } finally {
    return 3;
  }
}

console.log(test()); // 3

Try / Catch 性能

有一个大家众所周知的反优化模式就是使用 try/catch。

在 V8(其他 JS 引擎也可能出现相同情况)函数中使用了 try/catch 语句不能够被 V8 编译器优化。

5.4 window.onerror

通过在 window.onerror 上定义一个事件监听函数,程序中其他代码产生的未被捕获的异常往往就会被 window.onerror 上面注册的监听函数捕获到。并且同时捕获到一些关于异常的信息。

window.onerror = function(message, source, lineno, colno, error) {};
  • message:异常信息(字符串)
  • source:发生异常的脚本 URL(字符串)
  • lineno:发生异常的行号(数字)
  • colno:发生异常的列号(数字)
  • error:Error 对象(对象)

注意:Safari 和 IE10 还不支持在 window.onerror 的回调函数中使用第五个参数,也就是一个 Error 对象并带有一个追溯栈

try/catch 不能够捕获异步代码中的异常,但是其将会把异常抛向全局然后 window.onerror 可以将其捕获。

try {
  setTimeout(() => {
    throw new Error("some message");
  }, 0);
} catch (err) {
  console.log(err);
}
// Uncaught Error: some message
window.onerror = (msg, url, line, col, err) => {
  console.log(err);
};
setTimeout(() => {
  throw new Error("some message");
}, 0);
// Error: some message

在 Chrome 中,window.onerror 能够检测到从别的域引用的 script 文件中的异常,并且将这些异常标记为 Script error。如果你不想处理这些从别的域引入的 script 文件,那么可以在程序中通过 Script error 标记将其过滤掉。然而,在 Firefox、Safari 或者 IE11 中,并不会引入跨域的 JS 异常,即使在 Chrome 中,如果使用 try/catch 将这些讨厌的代码包围,那么 Chrome 也不会再检测到这些跨域异常。

在 Chrome 中,如果你想通过 window.onerror 来获取到完整的跨域异常信息,那么这些跨域资源必须提供合适的跨域头信息。

5.5 Promise 中的异常

  • Promise 中抛出异常

    new Promise((resolve, reject) => {
      reject();
    });
    Promise.resolve().then((resolve, reject) => {
      reject();
    });
    Promise.reject();
    throw expression;
    
  • Promise 中捕捉异常

    promiseObj.then(undefined, (err) => {
      catch_statements;
    });
    promiseObj.catch((exception) => {
      catch_statements;
    });
    

    在 JavaScript 函数中,只有 return / yield / throw 会中断函数的执行,其他的都无法阻止其运行到结束的。

    在 resolve / reject 之前加上 return 能阻止往下继续运行。

    without return:

    Promise.resolve()
      .then(() => {
        console.log("before excute reject");
        reject(new Error("throw error"));
        console.log("after excute reject");
      })
      .catch((err) => {
        console.log(err.message);
      });
    
    // before excute reject
    // throw error
    // after excute reject
    

    use return:

    Promise.resolve()
      .then(() => {
        console.log("before excute reject");
        return reject(new Error("throw error"));
        console.log("after excute reject");
      })
      .catch((err) => {
        console.log(err.message);
      });
    
    // before excute reject
    // throw error
    
  • Throw or Reject

    无论是 try/catch 还是 promise 都能捕获到的是“同步”异常

    reject 是回调,而 throw 只是一个同步的语句,如果在另一个异步的上下文中抛出,在当前上下文中是无法捕获到的。

    因此在 Promise 中使用 reject 抛出异常。否则 catch 有可能会捕捉不到。

    Promise.resolve()
      .then(() => {
        setTimeout(() => {
          throw new Error("throw error");
        }, 0);
      })
      .catch((err) => {
        console.log(err);
      });
    
    // Uncaught Error: throw error
    
    Promise.resolve()
      .then(() => {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            reject(new Error("throw error"));
          }, 0);
        });
      })
      .catch((err) => {
        console.log(err);
      });
    
    // Error: throw error
    

5.6 window.onunhandledrejection

window.onunhandledrejection 与 window.onerror 类似,在一个 JavaScript Promise 被 reject 但是没有 catch 来捕捉这个 reject 时触发。并且同时捕获到一些关于异常的信息。

window.onunhandledrejection = (event) => {
  console.log(event.reason);
};

event 事件是 PromiseRejectionEvent 的实例,它有两个属性:

  • event.promise:被 rejected 的 JavaScript Promise
  • event.reason:一个值或 Object 表明为什么 promise 被 rejected,是 Promise.reject() 中的内容。

5.7 window.rejectionhandled

因为 Promise 可以延后调用 catch 方法,若在抛出 reject 时未调用 catch 进行捕捉,但稍后再次调用 catch,此时会触发 rejectionhandled 事件。

window.onrejectionhandled = (event) => {
  console.log("rejection handled");
};

let p = Promise.reject(new Error("throw error"));

setTimeout(() => {
  p.catch((e) => {
    console.log(e);
  });
}, 1000);

// Uncaught (in promise) Error: throw error
// 1 秒后输出
// Error: throw error
// rejection handled

5.8 统一异常处理

代码中抛出的异常,一种是要展示给用户,一种是展示给开发者。

对于展示给用户的异常,一般使用 alert 或 toast 展示;对于展示给开发者的异常,一般输出到控制台。

在一个函数或一个代码块中可以把抛出的异常统一捕捉起来,按照不同的异常类型以不同的方式展示,对于。

需要点击确认的异常类型:

ensureError.js

function EnsureError(message = "Default Message") {
  this.name = "EnsureError";
  this.message = message;
  this.stack = new Error().stack;
}
EnsureError.prototype = Object.create(Error.prototype);
EnsureError.prototype.constructor = EnsureError;

export default EnsureError;

弹窗提示的异常类型:

toastError.js

function ToastError(message = "Default Message") {
  this.name = "ToastError";
  this.message = message;
  this.stack = new Error().stack;
}
ToastError.prototype = Object.create(Error.prototype);
ToastError.prototype.constructor = ToastError;

export default ToastError;

提示开发者的异常类型:

devError.js

function DevError(message = "Default Message") {
  this.name = "ToastError";
  this.message = message;
  this.stack = new Error().stack;
}
DevError.prototype = Object.create(Error.prototype);
DevError.prototype.constructor = DevError;

export default DevError;

异常处理器:

抛出普通异常时,可以带上 stackoverflow 上问题的列表,方便开发者查找原因。

errorHandler.js

import EnsureError from "./ensureError.js";
import ToastError from "./toastError.js";
import DevError from "./devError.js";
import EnsurePopup from "./ensurePopup.js";
import ToastPopup from "./toastPopup.js";

function errorHandler(err) {
  if (err instanceof EnsureError) {
    EnsurePopup(err.message);
  } else if (err instanceof ToastError) {
    ToastPopup(err.message);
  } else if (err instanceof DevError) {
    DevError(err.message);
  } else {
    error.message += `https://stackoverflow.com/questions?q=${encodeURI(
      error.message
    )}`;
    console.error(err.message);
  }
}

window.onerror = (msg, url, line, col, err) => {
  errorHandler(err);
};

window.onunhandledrejection = (event) => {
  errorHandler(event.reason);
};

export default errorHandler;