JavaScript之单线程和异步的理解

JavaScript 最初是设计用来在网页上和用户互动,操作 DOM 的语言,因为用途比较简单,所以被作者设计成了单线程语言。什么是单线程呢?就是同一时间只能做一件事。一般情况下,程序执行的时候是按照顺序一行行的来,不能同时执行多个程序。

目录
  1. 单线程
  2. 异步
  3. 异步场景
  4. 回调函数
  5. 事件轮询
  6. Promise
  7. Generator
  8. Async/Await
  9. 总结

单线程#

JavaScript 最初是设计用来在网页上和用户互动,操作 DOM 的语言,因为用途比较简单,所以被作者设计成了单线程语言。什么是单线程呢?就是同一时间只能做一件事。一般情况下,程序执行的时候是按照顺序一行行的来,不能同时执行多个程序。

console.log(0);
for (var i = 1; i < 10; i++) {
  console.log(i);
}
alert(20);

上面代码,先打印 0,再打印 1~9,最后弹出 20。这些都是同步程序,按照顺序排着队一步一步执行,就像去银行办理业务需要排队一样。

异步#

如果执行过程中,中间有个程序执行需要花费很长时间,那不是会把整个程序阻塞吗?导致它后面的程序无法执行,这样明显是不符合常理的,于是就有了异步,什么是异步呢?

console.log(100);
setTimeout(function () {
  console.log(200);
}, 1000);
console.log(300);

上面代码打印顺序是:100 300 200

为什么先打印 300 呢,因为第二个函数setTimeout是一个定时器,已经设置需要在 1000 毫米之后再执行打印,所以先执行它后面的程序打印出 300。

如果把后面的定时 1000 毫秒改成 0,不延时执行了,重新执行整个程序打印顺序会不会就变成100 200 300呢?你可以试一下,相信执行的结果还是和上面的打印顺序一样,还是100 300 200

为什么会这样呢?不是已经设置不延时执行了吗?因为setTimeout除了是一个定时器,本身也是一个异步函数。程序执行到异步函数的时候会先把它暂存起来,等所有的同步程序执行完毕后,最后再回过头了看下它是否可以执行,如果可以,再去执行它。

异步场景#

JavaScript 中有很多需要使用异步场景,特别是在 Node.js 中,有很多的异步程序。

  • 定时函数:setTimeoutsetInterval
  • 网络请求:ajaxget/post请求
  • IO 请求:Node.js 文件系统中的fs.readFilefs.readDir
  • DOM 事件
  • Promise

简单分析下上面提到的异步场景:

定时器是设置一定的时间,等时间到了之后再执行。

网络请求也是,因为发出请求和服务器处理请求并返回内容都需要时间去处理。如果你不想等待浪费时间的话,先不去管它,等数据返回之后再执行它。

IO 请求也是,打开文件需要一段加载内容的时间,加载好之后,才能看到文件内容,所以一般也是使用异步执行(当然如果文件小,加载时间慢,也可以用使用同步执行)。

DOM 事件绑定也是异步的,只是它的异步操作不是由系统主动调用的,而是在触发事件的时候调用的,什么时候触发,什么时候执行,所以也是异步的。

Promise 是 ES6 的异步解决方案。

回调函数#

在异步程序中,回调函数必不可少,无回调无异步。什么是回调函数?

// 定时器
setTimeout(function () {
  console.log("callback"); // callback
}, 1000);
 
// jquery ajax请求
$.ajax({
  method: GET,
  url: url,
  success: function (data) {
    console.log(data); // callback
  },
  fail: function (error) {
    console.log(error); // callback
  },
});
 
// nodejs读取文件
fs.readFile("data1.json", function (err, data) {
  console.log(data); // callback
});

上面代码标记callback的地方就是回调函数

JS 引擎执行到异步程序的时候,会把异步程序先暂存起来,等到所有的同步程序执行完毕后,再回过头来执行异步程序。

后面到底是怎么执行异步程序了?就是靠回调函数(callback)来执行,调用回调函数执行其中的代码。

所以异步程序一定不能缺少回调函数,不然后面“回调的时候”无法执行。

事件轮询#

再来详细说明一下上面说的 JS 程序运行异步函数的方法:

  1. 所用的同步任务都在主线上按照顺序执行,形成一个“执行桟”。
  2. 主线程之外,还存在一个“任务队列”。只要任务队列有了运行结果,就在“任务队列”中放置一个事件。
  3. 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
  4. 主线程不断重复上面的第三步。

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环/事件轮询)

上面的步骤和概念来自阮一峰老师的博客。JavaScript 运行机制详解:再谈 Event Loop

Promise#

Promise 一种规范,是 ES6 中异步的解决方案。

// 用promise对象实现ajax
const PAjax = function (url) {
  const promise = new Promise((resolve, reject) => {
    var xhr = new XMLHttpRequest();
    xhr.open("GET", url, true);
    xhr.send();
    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        if (xhr.status === 200) {
          resolve(JSON.parse(xhr.responseText));
        } else {
          reject("error: " + xhr.statusText);
        }
      }
    };
  });
  return promise;
};
 
// then的第一个参数函数获取成功结果,第二个参数函数获取异常结果
// 也可以用catch方法来捕获异常,建议用catch方法,不用then的第二个参数函数
PAjax(url)
  .then((data) => {
    console.log(data);
  })
  .catch((err) => {
    console.log(err);
  });

上面的代码是一个用 Promise 对象实现 ajax 的实例。

Promise 对象异步操作有 3 种状态:pending(进行中)、fulfilled(已成功)和rejected(已失败)。

Promise 构造函数创建实例时接受一个函数作为参数,这个函数同时接收两个参数resolvereject。这两个参数也是函数,由 JS 引擎来提供,不需要自己部署。

  • resolve函数表示将 promise 对象的状态由“进行中”变成“已成功”,在异步操作成功时调用这个函数,把成功的结果作为函数的参数传入。
  • reject函数表示将 promise 对象的状态由“进行中”变成“已失败”,在异步操作失败时调用这个函数,把失败的结果作为函数的参数传入。

Promise对象生成后可以使用 then 方法分别定制resolved状态和rejected状态的回调函数,也可以使用catch方法来捕获异常状态(rejected状态),建议使用catch方法,这样代码更加清晰简洁。

Generator#

Generator 是 ES6 中异步的解决方案。

function* Gen() {
  yield 100;
  yield 200;
  return 300;
}
 
const g = Gen();
console.log(typeof g); // object
console.log(g.next()); // { value: 100, done: false }
console.log(g.next()); // { value: 200, done: false }
console.log(g.next()); // { value: 300, done: true }
console.log(g.next()); // { value: undefined, done: true }

Generator 函数和普通函数有两个不一样的特征:

  • function 关键字后面带有一个*号。
  • 函数内部使用 yield 表达式。

Generator 函数调用后和普通函数也不一样,函数并不执行,也不返回函数结果,而是一个指向内部状态改的指针对象,也就是遍历器对象。

使用时调用对象内部的next方法,使得指针移向下一个状态。

  • 当指针遇到 yield 表达式时,next 返回一个对象,对象的值是当前 yield 表达式的值,done 属性是false(表示遍历未完成)。
  • 当指针遇到 return 表达式时,next 方法返回的对象的值是 return 表达式的 value 属性, done 属性是true(表示遍历已完成)。
  • 遍历完成后,再调用 next 方法返回的对象的值是 undefined,done 属性的值是true
const fs = require("fs");
const Q = require("q");
const co = require("co");
 
const readFilePromise = Q.denodeify(fs.readFile);
 
const gen = function* () {
  const r1 = yield readFilePromise("data1.json");
  console.log(r1.toString());
  const r2 = yield readFilePromise("data2.json");
  console.log(r2.toString());
};
 
co(gen);

上面代码是一个使用Generator异步读取两个 JSON 文件的实例。

实例用了 nodejs 的fs库,还有第三方库qcofs是 nodejs 的文件库,q是为了更快捷封装一个 promise 对象,co的作用是执行 Generator 函数。

Async/Await#

Async/Await是 ES2017 中的异步解决方案,非常推荐使用。著名的后端框架 Koa2 就是使用了这种异步方案。

const fs = require("fs");
const Q = require("q");
 
const readFilePromise = Q.denodeify(fs.readFile);
 
const readFileAsync = async function () {
  const r1 = await readFilePromise("data1.json");
  console.log(r1.toString());
  const r2 = await readFilePromise("data2.json");
  console.log(r2.toString());
};
 
const result = readFileAsync();
console.log(result);

是不是发现Async/Await和 Generator 非常相似,async function代替了function*await代替了yield,另外Async/Await已经不需要co库了,它可以自己执行,使用非常方便,强烈推荐使用。

总结#

JavaScript 是非阻塞单线程语言,所以异步是无法避免的。现在异步的方案也是越来越成熟,了解异步对于学习这门语言非常有帮助。