前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Node理论笔记:异步编程

Node理论笔记:异步编程

原创
作者头像
Ashen
修改2020-06-01 14:41:20
9490
修改2020-06-01 14:41:20
举报
文章被收录于专栏:Ashenの前端技术

一、函数式编程

在JavaScript中,函数是一等公民,使用非常自由,无论是调用它,或者作为参数,或者作为返回值均可。

1.1 高阶函数

通常的语言中,函数的参数只接收基本数据类型或对象引用,返回值也是基本数据类型或对象引用。

高阶函数则是可以把函数作为参数,或是将函数作为返回值的函数。

对于程序编写,高阶函数要比普通函数灵活很多,除了通常意义的函数调用返回外,还形成了一种后续传递风格的结果接收方式,而非单一的返回值形式。

代码语言:javascript
复制
function foo(x,bar){
  return bar(x);
}

上例,对于相同的foo()函数,传入的bar参数不同,则可以得到不同的结果。

代码语言:javascript
复制
const events = require("events");
const emitter = new events.EventEmitter();
emitter.on("event_foo",()=>{
  //TODO
});

可以看到,事件的处理方式正是基于高阶函数的特性来完成的。

在ECMA2015中,forEach()、map()、reduce()、filter()、every()、some()都是高阶函数。

1.2 偏函数用法

偏函数定义比较绕,所以直接看这个示例:

代码语言:javascript
复制
const toString =Object.prototype.toString;
const isString = function(obj){
  return toString.call(obj) === "[object String]";
};
const isFunction = function(obj){
  return toString.call(obj) === "[object Function]";
};

这个函数不复杂,但存在的问题是需要重复定义一些相似的函数,为了解决代码冗余的问题,需要引入一个新函数:

代码语言:javascript
复制
const isType = function(type){
  return function(obj){
    return toString.call(obj) === `[object ${type}]`;
  }
};
const isString = isType("String");
const isFunction = isType("Function");

这种通过指定部分参数来产生一个新的定制函数的形式就是偏函数。

二、异步编程的优势与难点

2.1 优势

node带来的最大特性莫过于基于事件驱动的非阻塞I/O模型。

由于事件循环模型需要应对海量请求,海量请求同时作用在单线程上,这就需要防止任何一个计算耗费过多的CPU时间片。至于是计算密集型还是I/O密集型,只要计算不影响异步I/O的调度,那就不构成问题。建议对CPU的耗时不超过10ms,或者将大量的计算分解为诸多的小量计算,通过setImmediate()进行调度。只要合理利用node的异步模型与V8的高性能,就可以充分发挥CPU和I/O资源的优势。

2.2 难点

1、异常处理

try…catch通常用于捕获异常,但对于异步编程却不那么适用。异步I/O的实现包含2个阶段:提交请求和处理结果。这2个阶段中间有事件循环的调度,彼此不关联,所以try/catch的功效便不会发生作用。

代码语言:javascript
复制
const async = function(callback){
  process.nextTick(callback);
};
try{
  async(callback);
}catch (e) {
  //TODO
}

调用async方法后,callback被存放起来,直到下一个事件循环Tick才会取出来执行。尝试对异步方法进行try/catch操作只能捕获当次事件循环内的异常,对callback执行时抛出的异常则无能为力。

所以,node在处理异常上形成了一种约定,将异常作为回调函数的第一个实参传回,如果为空值,则表明异步调用没有异常抛出。这就是node错误优先原则。

所以在自行编写的异步方法上,也需要遵循这样一些规则:

  • 必须执行调用者传入的回调函数
  • 正确传递回调异常供调用者判断

示例:

代码语言:javascript
复制
const async = function(callback){
  process.nextTick(function(){
    const result = "something";
    if(err){
      return callback(err);
    }
    return callback(null,result);
  });
};

在编写异步方法时,只要将异常正确的传递给用户的回调方法即可,无须过多处理。

2、函数嵌套过深

在node中,事物存在多个异步调用的场景很多。比如再网页渲染过程中,数据、模板、资源文件三者并不依赖,但最终渲染结果则缺一不可,可能最终的样子是这样的:

代码语言:javascript
复制
fs.readFile(template_path,"utf8",function(err,file){
  db.query(sql,function(err,data){
    l10n.get(function(err,resources){
      //TODO
    });
  });
});

这在结果上没有问题,但并没有利用好异步I/O带来的并行优势。

3、阻塞代码

JavaScript并没有类似PHP中sleep()这样的函数来让线程沉睡,可能会这么模拟:

代码语言:javascript
复制
const start = new Date();
while(new Date() - start < 1000){
  //TODO
}
//需要阻塞的代码

但事实上却是很糟糕的,这段代码会持续占用CPU进行判断,与真正的线程沉睡相去甚远,完全破坏了事件循环的调度。有与node单线程的原因,CPU资源全部会用于为这段代码服务,导致其余任何请求都会得不到响应。

对于这类需求 ,统一规划好业务逻辑后,调用setTimeout()的效果会更好。

4、多线程编程

node借鉴了前端web workers的模式,child_process是其基础API,cluster模块是更深层次的应用。事实上前端极少会用到web workers,以后要更多面临跨线程编程,这也是一个挑战。

5、异步转同步

习惯了异步编程,偶尔出现的同步需求会因为没有同步API让人无所适从,对于异步调用,借助一些ES2015的Promise、Async等,也可以实现良好的流程控制。

三、异步编程解决方案

异步编程的主要解决方案有以下3种:

  • 发布/订阅模式
  • Promise/Deferred模式
  • 流程控制库

3.1 发布/订阅模式

事件监听器是回调函数的事件化,又称发布/订阅模式。

node自身提供了events模块,这个模块相比浏览器大量DOM事件要简单,不存在冒泡、preventDefault()、stopPropagation()和stopImmediatePropagation()等控制事件传递的方法。

events模块它具有addListener/on()、once()、removeListerner()、removeAllListeners()和emit()等基本的事件监听模式的方法实现。

代码语言:javascript
复制
const events = require("events");
const emitter = new events.EventEmitter();
emitter.on("event",(message)=>{
  console.log(message);
});
emitter.emit("event","hello world");

可以看到,订阅事件就是一个高阶函数的应用,事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数也称为事件监听器。通过emit()发布事件后,消息会立即传递给当前事件的所有监听器执行。监听器可以灵活的增加/删除,使得事件和具体处理逻辑之间可以轻松关联与解耦。

注意:事件发布/订阅模式自身并无同步和异步调用的问题(注意下例)。但在node中,emit()多半是伴随事件循环而异步触发的,所以发布/订阅模式广泛应用于异步编程。

代码语言:javascript
复制
const events = require("events");
const emitter = new events.EventEmitter();
emitter.on("event",(message)=>{
  console.log(message);
});
emitter.emit("event","hello world");
console.log("立即执行");

//执行结果
hello world
立即执行

node对事件发布/订阅的机制做了额外一些处理:

  1. 如果对一个事件添加了超过10个监听器,将会得到一条警告。可以调用emitter.setMaxListeners(0)来取消限制。
  2. 为了处理异常,EventEmitter对象对error事件进行了特殊对待。如果运行期间的错误触发了error事件,EventEmitter会检查是否有对error事件进行了特殊对待。如果添加了错误将会交由该监听器处理,否则作为异常抛出,如果外部没有捕获这个异常将导致线程退出。

1、继承events模块

代码语言:javascript
复制
const events = require("events");
const util = require("util");

function Stream(){
  events.EventEmitter.call(this);
}
util.inherits(Stream,events.EventEmitter);

const emitter = new Stream();
emitter.on("event",function(message){
  console.log(message);
});
emitter.emit("event","hello world");

2、利用事件队列解决雪崩问题

在事件订阅/发布模式中,通常也有一个once()方法,通过它添加的监听器只会执行一次,执行之后就会将它于事件的关联解除。利用这个特性可以处理一些重复性的事件响应。

雪崩问题就是在高访问量、大并发量的情况下缓存失效的情景,此时大量的请求涌入数据库,数据库无法同时承受如此大的查询请求,进而影响网站整体的响应速度。

以下是一条数据库查询语句的调用:

代码语言:javascript
复制
const select = function(callback){
  db.select("SQL",function(results){
    callback(results);
  });
};

如果站点刚启动,这时缓存中是不存在数据的,如果访问量巨大,同一条SQL会被发送到数据库中反复查询,会影响服务的整体性能。

一种改进方案是添加一个状态锁:

代码语言:javascript
复制
let status = "ready";
const select = function(callback){
  if(status === "ready"){
    status = "pending";
    db.select("SQL",function(results){
      status = "ready";
      callback(results);
    });
  }
};

这种情景下,连续多次调用select()时,只有第一次调用是生效的,后续的select()是没有数据服务的,这个时候可以引入事件队列:

代码语言:javascript
复制
const events = require("events");
const proxy = new events.EventEmitter();

let status = "ready";
const select = function(callback){
  proxy.once("selected",callback);
  if(status === "ready"){
    status = "pending";
    db.select("SQL",function(results){
      proxy.emit("selected",results);
      status = "ready";
    });
  }
};

利用once()方法将所有请求压入事件队列中,利用其执行一次的特点,保证每一个回调只会被执行一次。对于相同的SQL语句,保证每一个开始到结束的过程永远只有一次。SQL在进行查询时,新到来的相同调用只需在队列中等待数据就绪即可,一旦查询结束,得到的结果就可以被这个调用共同使用。

此处可能需要调用setMaxListeners(0)来移除警告。

3、多异步之间的协作方案

一般而言,事件与监听器的关系是一对多的,但在异步编程中,也会出现事件与监听器的关系是多对一的情况,也就是说一个业务逻辑可能依赖两个通过回调或事件传递的结果。之前提及的回调嵌套过深的原因就是如此。

代码语言:javascript
复制
let count = 0;
const results = {};
const done = function(key,value){
  results[key] = value;
  count++;
  if(count === 3){
    render(results);
  }
};
fs.readFile(template_path,"utf8",function(err,template){
  done("template",template);
});
db.query(SQL,function(data){
  done("data",data);
});
l10n.get(function(err,resources){
  done("resources",resources);
});

多个异步场景中回调函数的执行并不能保证顺序,且回调函数之间彼此没有任何交集,所以需要借助一个第三方函数和第三方变量来处理异步协作的结果。通常,这个用于检测次数的变量叫做哨兵变量。结合之前的偏函数模式,改进后的代码如下:

代码语言:javascript
复制
const after = function (times,callback) {
  let count = 0;
  const results = {};
  return function(key,value){
    results[key] = value;
    count++;
    if(count === times){
      callback(results);
    }
  }
};
const done = after(times,callback);

上述方案实现了多对一的目的。对于多对多的模式稍做改造即可:

代码语言:javascript
复制
const emitter = new events.EventEmitter();
const done = after(times,callback);
emitter.on("done",done);
emitter.on("done",other);

这种方案结合了前者用简单的偏函数完成多对一的收敛和事件订阅/发布模式中一对多的发散。

但上面的这种方法有个麻烦的地方就是,开发者需要去准备这个done()函数,以及在回调函数中需要从结果中把数据一个一个取出来,再进行处理。

3.2 Promise/Deferred模式

Promise/Deferred模式在JavaScript中最早出现于Dojo中,被广泛所知是来自jQuery1.5版本,该模式在2009年被抽象为一个提议草案,发布在CommonJS规范中。CommonJS草案目前被抽象出了Promises/A、Promises/B、Promises/D这样典型的异步Promise/Deferred模型。

1、Promises/A

Promise/A提议对单个异步操作作出了以下的抽象定义:

  • Promise操作只会出现在3种状态的一种:未完成态、完成态、失败态。
  • Promise的状态只会出现从未完成态向完成态或失败态转化,不能逆反。完成态和失败态不能互相转化。
  • Promise的状态一旦转化,将不能被更改。

Promises/A的提议比较简单,一个Promise对象只要具备then()方法即可。对于then()方法,有以下简单的要求:

  • 接受完成态、错误态的回调方法。在操作完成或出现错误时,将会调用对应方法。
  • 可选的支持progress事件回调作为第三个方法。
  • then()方法只接受function对象,其余对象将被忽略。
  • then()方法继续返回Promise对象,以实现链式调用。

then()方法具体定义:

代码语言:javascript
复制
then(fulfilledHandler,errorHandler,progressHandler);

一个简单实现:

代码语言:javascript
复制
const Promise = function(){
  events.EventEmitter.call(this);
};
util.inherits(Promise,events.EventEmitter);
Promise.prototype.then = function (fulfilledHandler,errorHandler,progressHandler) {
  if(typeof fulfilledHandler === "function"){
    this.once("success",fulfilledHandler);
  }
  if(typeof errorHandler === "function"){
    this.once("error",errorHandler);
  }
  if(typeof progressHandler === "function"){
    this.once("progress",progressHandler);
  }
  return this;
};

then()方法将回调函数存起来,为了完成整个流程,还需要触发执行这些回调函数的地方,实现这些功能的对象通常被称为Deferred,即延迟对象。示例代码如下:

代码语言:javascript
复制
const Deferred = function(){
  this.state = "unfulfilled";
  this.promise = new Promise();
};
Deferred.prototype.resolve = function(obj){
  this.state = "fulfilled";
  this.promise.emit("success",obj);
};
Deferred.prototype.reject = function(err){
  this.state = "failed";
  this.promise.emit("error",err);
};
Deferred.prototype.progress = function(data){
  this.promise.emit("progress",data);
};

如果我们要实现类似这样的效果:

代码语言:javascript
复制
res.then(()=>{
  //Done
},(err)=>{
  //Error
},(chunk)=>{
  console.log("BODY:"+chunk);
});

只需要将Deferred与Promise结合起来,如下:

代码语言:javascript
复制
const promisify = function(res){
  const deferred = new Deferred();
  let result = "";
  res.on("data",function(chunk){
    result+=chunk;
    deferred.progress(result);
  });
  res.on("end",function(){
    deferred.resolve();
  });
  res.on("error",function(err){
    deferred.reject(err);
  });
  return deferred.promise;
};

最后返回deferred.promise是为了不让外部调用resolve()和reject()方法,更改内部状态的行为交由定义者处理。以下是调用示例:

代码语言:javascript
复制
promisify(res).then(()=>{
  //Done
},(err)=>{
  //Error
},(chunk)=>{
  //progress
  console.log("body",chunk);
});

可以看Promise和Deferred的区别,Deferred主要用于内部,用于维护异步模型的状态;Promise则作用于外部,通过then()方法暴露给外部以添加自定义逻辑。

四、Promise

Promise是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更加强大合理。Promise最早由社区提出和实现,现在ES6已经写入了标准,统一了用法,浏览器端和node原生提供了Promise对象。

4.1 Promise的含义

Promise对象有以下2个特点:

  1. 对象的状态不受外界影响。Promise对象代表一个异步操作,有三种状态:pending、fulfilled、rejected,只有异步操作的结果可以绝对当前处于哪个状态,任何其它操作都无法改变这个状态。
  2. 一旦状态改变就不会再变。正如上一章所述,pending->fulfilled、pending->rejected,至此状态改变结束。

有了Promise对象,就可以将异步操作以同步操作的流程表达出来,避免了层层嵌套的回调函数。此外,Promise对象提供统一的接口,使得控制异步操作更加容易。

Promise也有一些缺点。首先,无法取消Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise内部抛出的错误,不会反应到外部。第三,当处于pending状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。

4.2 基本用法

代码语言:javascript
复制
const fs = require("fs");
const promise = new Promise((resolve, reject)=>{
  fs.readFile("file_path","utf8",(err,result)=>{
    if(err){
      reject(err);
    }else{
      resolve(result);
    }
  });
});

Promise的构造函数接收一个函数作为参数,该函数的2个参数分别为resolve和reject,这依然是2个函数,由JavaScript提供。

resolve()函数的作用用于将Promise从pending的状态变为fulfilled,异步操作成功时调用该方法,并将成功结果传出去。

reject()函数的作用用于将Promise从pending的状态变为rejected,异步操作失败时调用该方法,并将异常信息传出去。

Promise实例生成以后,可以用then方法分别指定fulfilled状态和rejected状态的回调函数。如:

代码语言:javascript
复制
const next = promise.then((...values)=>{
  console.log(values);
},(...errors)=>{
  console.log(errors);
});

当Promise变为fulfilled时则调用第一个回调,变为rejected则调用第二个回调。第二个回调函数时可选的。

调用then()方法以后返回的依然是promise,所以next可以继续调用then()方法。

4.3 Promise.prototype.then()

Promise实例的then方法是定义在原型上的,then()方法调用以后会返回一个新的Promise实例,注意是新的。

既然可以继续调用then()方法,如:

代码语言:javascript
复制
next.then((result)=>{
  console.log("fulfilled",result);
},(error)=>{
  console.log("rejected",error);
});

fulfilled回调函数的参数来自于上一个fulfilled或rejected函数的返回值。来自fulfilled还好理解,为什么会有可能来自rejected函数的返回值呢?看这个例子:

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  reject("rejected");
});
const next = promise.then((result)=>{
  return result;
},(error)=>{
  return error;
});
next.then((result)=>{
  console.log("fulfilled",result);
},(error)=>{
  console.log("rejected",error);
});
//打印结果
fulfilled rejected

但是如果是这样的:

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  reject("rejected");
});
const next = promise.then((result)=>{
  return result;
});
next.then((result)=>{
  console.log("fulfilled",result);
},(error)=>{
  console.log("rejected",error);
});
//打印结果
rejected rejected

常量promise的状态是rejected且没有定义rejected回调函数,那么next的状态就是rejected;如果promise定义了rejected回调,无论promise常量本身是fulfilled还是rejected,调用then返回的新的promise——next的状态都是fulfilled。

对于Promise实例,异常如果没有被捕获会一层层传递下去,直到被捕获。

then()的参数回调除了返回常规的值,也可以返回Promise,只有这个Promise的状态为fulfilled或rejected才会触发下一个then()方法回调,利用这一点可以很容易实现链式调用:

代码语言:javascript
复制
new Promise((resolve, reject)=>{
  resolve("fulfilled");
}).then((result)=>{
  return new Promise((resolve, reject)=>{
    setTimeout(()=>{
      reject(result);
    },1000);
  })
}).then(null,(err)=>{
  console.log(err);//fulfilled
});

4.4 Promise.prototype.catch()

catch()方法专门用来捕获异常的,执行之后返回的依然是Promise,且catch回调函数的返回值会传入到下一个Promise的fulfilled回调函数中。

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  reject("rejected");
}).catch(err=>{
  console.log(err);
  return "异常被捕获"
}).then(result=>{
  console.log(result);
});

catch除了可以捕获rejected状态以外,还可以捕获代码抛出的异常,比如打印一个未定义的变量:

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  console.log(abc)
}).catch(err=>{
  console.log(err);
  return "异常被捕获"
}).then(result=>{
  console.log(result);
});

当然catch抛出的异常也可以被下一个catch捕获:

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  reject("rejected");
}).catch(err=>{
  return abc;
}).catch(err=>{
  console.log(err);
});

4.5 Promise.prototype.finally()

finally方法用于指定不管 Promise 对象最后状态如何,都会执行的操作,返回依然是Promise。

该方法是 ES2018 引入标准的,经测试目前node(v8)并不支持,所以以下均来自于Chrome浏览器的测试。

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  reject("rejected");
}).then(result=>{
  return result;
}).finally((result)=>{
  console.log("finally1",result);
  return "finally执行1次";
}).finally((result)=>{
  console.log("finally2",result);
  return "finally执行2次";
}).then(result=>{
  console.log(result);
});

可以看到,finally并不接收参数,返回值也不会传递给下一个promise。经finally返回的promise无论如何都处于fulfilled状态。

这表明,finally方法里面的操作,应该是与状态无关的,不依赖于 Promise 的执行结果。

4.6 Promise.all()

Promise.all()方法用于将多个 Promise 实例,包装成一个新的 Promise 实例。

该方法接收一个数组作为参数,每一个数组元素必须是Promise,如果不是就会调用Promise.resolve()方法将其转化为fulfilled状态的Promise。

只有Promise.all()的每一个Promise都为fulfilled才返回fulfilled状态,否则返回rejected。如果返回fulfilled状态,那么所有Promise的resolve()值会按顺序压入一个数组作为Promise.all()的fulfilled回调函数的参数。如果返回rejected,则第一个reject()值会作为rejected回调函数的参数。

代码语言:javascript
复制
const promiseA = new Promise((resolve, reject)=>{
  resolve("promiseA");
});
const promiseB = "promiseB";
const promiseC = new Promise((resolve, reject)=>{
  resolve("promiseC");
});
Promise.all([promiseA,promiseB,promiseC]).then((result)=>{
  console.log(result);//[ 'promiseA', 'promiseB', 'promiseC' ]
},(error)=>{
  console.log(error);
});

Promise.all()方法解决的是多个异步操作并行执行的问题

4.7 Promise.race()

参数依然是数组,不同的是,只要子Promise实例率先改变了状态,那么整体的Promise状态就随着改变,第一个改变状态的Promise的返回值作为整体Promise对应回调的参数。

代码语言:javascript
复制
const promiseA = new Promise((resolve, reject)=>{
  setTimeout(()=>{
    resolve("promiseA");
  },100);
});
const promiseB = new Promise((resolve, reject)=>{
  setTimeout(()=>{
    resolve("promiseA");
  },200);
});
const promiseC = new Promise((resolve, reject)=>{
  setTimeout(()=>{
    reject("promiseC");
  },50);
});

Promise.race([promiseA,promiseB,promiseC]).then((result)=>{
  console.log("fulfilled",result);
}).catch(err=>{
  console.log("rejected",err);
});

4.8 Promise.resolve()

该方法用于将目标参数转化为Promise实例。该方法的参数分为4种情况:

1、参数是一个Promise实例

代码语言:javascript
复制
const promise = new Promise((resolve, reject)=>{
  resolve("promise");
});
const next = Promise.resolve(promise);
next.then((result)=>{
  console.log(result)
});
console.log(next === promise);//true

Promise.resolve()将不会做任何操作,直接返回。

2、参数是一个thenable对象

thenable对象指的是用于then属性方法的对象。

代码语言:javascript
复制
const next = Promise.resolve({
  then:function(resolve,reject){
    resolve("fulfilled");
  }
});
next.then(null,(result)=>{
  console.log(result)
});

Promise.then()方法会将这个thenable对象转换为Promise,并立即执行里面的then()方法。

3、参数不是具有then方法的对象,或根本就不是对象

这种情况,Promise.resolve()方法会将目标直接转化为fulfilled状态的Promise。fulfilled回调函数的参数便是Promise.resolve()的参数。

代码语言:javascript
复制
const next = Promise.resolve({
  say:()=>{
    console.log("hello world");
  }
});
next.then((result)=>{
  result.say();
});

4、不带任何参数

代码语言:javascript
复制
const next = Promise.resolve();
next.then((result)=>{
  console.log(result) //undefined
});

4.9 Promise.reject()

同Promise.resolve()相反,但是存在一些参数上的区别。对于Promise.reject(),其参数会原封不动的作为rejected的回调函数的参数。

代码语言:javascript
复制
const thenable = {
  then(resolve, reject) {
    reject('出错了');
  }
};

Promise.reject(thenable).catch(e => {
  console.log(e === thenable) //true
});

这一点要特别注意。

4.10 Promise与事件循环

Promise无论是fulfilled还是rejected的回调,毫无疑问不会在本轮事件循环执行。

代码语言:javascript
复制
Promise.resolve("promise执行").then((result)=>{
  console.log(result);
});
setImmediate(()=>{
  console.log("setImmediate执行");
});
setTimeout(()=>{
  console.log("setTimeout执行");
},0);
process.nextTick(()=>{
  console.log("nextTick执行");
});
console.log("立即执行");

执行结果:

代码语言:javascript
复制
立即执行
nextTick执行
promise执行
setTimeout执行
setImmediate执行

立即Promise执行仅次于nextTick的执行,这一点格外重要。

4.11 总结

不论是前端开发还是node开发,使用Promise一定要添加rejected回调或catch回调来捕获异常,这一点在node格外重要,对于单线程没有捕获的异常会导致线程退出。

五、Generator

Generator函数也是ES6提供的一种异步编程解决方案,其语法行为与传统函数完全不同。Generator函数是一个状态机,里面有很多种状态,执行Generator函数会返回一个遍历器对象,该遍历器可依次遍历Generator内部的所有状态。

5.1 基本使用

Generator的定义需要在function关键字与函数名之间加一个*号(不需要考虑空格问题),函数内部使用yield表达式。

执行Generator函数返回的是一个指向内部状态的指针,调用遍历器对象上的next方法来让指针指向下一个状态,直到遇到yield表达式为止,并将yield表达式后边的值作为value返回;下一次调用next方法则从上一次停止的位置继续向下执行。

代码语言:javascript
复制
function*helloWorld(){
  yield "hello";
  yield "world";
  return "end";
}
const hw = helloWorld();
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
console.log(hw.next());
//打印结果
{ value: 'hello', done: false }
{ value: 'world', done: false }
{ value: 'end', done: true }
{ value: undefined, done: true }

每次的返回值都是一个对象,对象包含value和done2个属性,value为yield表达式后边的值,done代表遍历是否结束。

5.2 yield表达式

由于 Generator 函数返回的遍历器对象,只有调用next方法才会遍历下一个内部状态,所以其实提供了一种可以暂停执行的函数。yield表达式就是暂停标志。

需要注意的是,yield表达式后面的表达式,只有当调用next方法、内部指针指向该语句时才会执行,这是一种“惰性求值”(Lazy Evaluation)的语法功能。

代码语言:javascript
复制
function*helloWorld(){
  let a = 1;
  yield function(){
    return a;
  };
  yield ++a;
  yield function(){
    return a;
  };
  return "end";
}
const hw = helloWorld();
console.log(hw.next().value());
console.log(hw.next());
console.log(hw.next().value());
//打印结果
1
{ value: 2, done: false }
2

如果Generator函数内部没有yield表达式,那这个函数就是只是普通的暂缓函数,通过调用next()方法执行。

yield表达式也只能出现在Generator函数中,负责会抛出异常。如:

代码语言:javascript
复制
function*helloWorld(){
  const arr = [1,2,3,4,5,6];
  arr.forEach((num)=>{
    yield num;
  });
}

这是错误的,forEach的回调只是普通函数。但可以用for循环:

代码语言:javascript
复制
function*helloWorld(){
  const arr = [1,2,3,4,5,6];
  for(let i=0;i<arr.length;i++){
    yield arr[i];
  }
}

yield表达式如果用在另一个表达式中,则必须加括号:

代码语言:javascript
复制
function*helloWorld(){
  console.log("hello world" + (yield));
  console.log("hello world" + (yield 2));
}

yield表达式用作函数参数或放在赋值表达式的右边,可以不加括号。

代码语言:javascript
复制
function a(val){
  console.log(val); //undefined
}
function*helloWorld(){
  let yie = yield;
  a(yield 2);
}

yield表达式本身不返回值,所以a()函数打印的是undefined。

5.3 与Iterator接口

普通对象是没有Iterator接口的,通过Symbol.iterator可以为其拓展功能:

代码语言:javascript
复制
const obj = {
  a:1,
  b:2
};
obj[Symbol.iterator] = function*(){
  for(let i in this){
    yield {[i]:this[i]};
  }
};
console.log([...obj]);

Generator 函数执行后,返回一个遍历器对象。该对象本身也具有Symbol.iterator属性,执行后返回自身。

代码语言:javascript
复制
function*gen(){}
const g = gen();
console.log(g[Symbol.iterator]() === g); //true

5.4 next()方法的参数

yield表达式本身是没有返回值的,相当于返回的是undefined。next()方法的参数,会作为上一个yield表达式的返回值。

代码语言:javascript
复制
function*gen(){
  const yieldValue = yield 1;
  return yield yieldValue+1;
}
const g = gen();
console.log(g.next());
console.log(g.next(2));
console.log(g.next(3));

//打印结果
{ value: 1, done: false }
{ value: 3, done: false }
{ value: 3, done: true }

因为next()方法的参数,会作为上一个yield表达式的返回值,所以第一次调用next()的传参是无效的。

Generator 函数从暂停状态到恢复运行,它的上下文状态(context)是不变的。通过next方法的参数,就有办法在 Generator 函数开始运行之后,继续向函数体内部注入值。也就是说,可以在 Generator 函数运行的不同阶段,从外部向内部注入不同的值,从而调整函数行为。

5.5 for…of语句

for…of可以遍历Generator运行生成的Iterator对象。

代码语言:javascript
复制
function*gen(){
  yield 1;
  yield 2;
  yield 3;
  yield 4;
  yield 5;
  return 6;
}
for(let i of gen()){
  console.log(i);
}
//打印结果
//1、2、3、4、5

这里需要注意,一旦next方法的返回对象的done属性为true,for…of循环就会中止,且不包含该返回对象,所以上面代码的return语句返回的6,不包括在for…of循环之中。可以看到使用for…of不需要调用next()方法。

除了for…of循环以外,扩展运算符(…)、解构赋值和Array.from方法内部调用的,都是遍历器接口。这意味着,它们都可以将 Generator 函数返回的 Iterator 对象,作为参数。

代码语言:javascript
复制
function*numbers(){
  yield 1;
  yield 2;
  yield 3;
  return 4
}
console.log([...numbers()]);
console.log(Array.from(numbers()));
const [x,y,z] = numbers();
console.log(x,y,z);
//打印结果
[ 1, 2, 3 ]
[ 1, 2, 3 ]
1 2 3

5.6 Generator.prototype.throw()

Generator调用之后返回的遍历器对象有一个throw方法,可以在函数体外抛出异常,然后在Generator函数内部捕获。

代码语言:javascript
复制
function*gen(){
  while (true){
    try{
      yield;
    }catch (e) {
      console.log(e);
    }
  }
}
const g = gen();
console.log(g.next());//undefined
g.throw("hello");//hello
g.throw("world");//world

throw()方法的参数会传递给catch语句,建议是Error对象实例。

注意throw()方法和throw命令是不同的,throw命令抛出的异常只能被函数体外的catch语句捕获。

代码语言:javascript
复制
function*gen(){
  try{
    yield;
  }catch (e) {
    console.log(e);
  }
}
const g = gen();
try{
  g.next();
  throw new Error("some error");
}catch (e) {
  console.log("error",e);
}

如果throw()方法抛出的异常没有被Generator函数捕获,那么异常会向外传递,可被try…catch捕获:

代码语言:javascript
复制
function*gen(){
  yield;
}
const g = gen();
try{
  g.throw("some error");
}catch (e) {
  console.log(e);
}

如果外部也没有捕获异常,那么程序将会报错,中断执行。

如果throw()方法抛出的异常想被Generator函数内部捕获,则至少要先调用一次next()方法,如果没有调用next(),异常将被外部的catch语句捕获。

throw()方法抛出的异常被捕获后,会顺带执行下一条yield语句:

代码语言:javascript
复制
function*gen(){
  try{
    yield 1;
  }catch (e) {
    console.log(e);
  }
  yield 2;
  return 3;
}
const g = gen();
console.log(g.next());// {value:1,done:false}
console.log(g.throw("error"));// error {value:2,done:false}
console.log(g.next());// {value:3,done:true}

这种函数体内捕获错误的机制,大大方便了对错误的处理。多个yield表达式,可以只用一个try…catch代码块来捕获错误。

如果调用throw()方法抛出的异常没有在Generator内部捕获,那么无论外部是否捕获,继续调用遍历器对象的next()方法,返回的永远是:{value:undefined,done:true},JavaScript引擎会认为Generator遍历器函数已经遍历结束:

代码语言:javascript
复制
function*gen(){
  yield 1;
  yield 2;
  return 3;
}
const g = gen();
console.log(g.next());// {value:1,done:false}
try{
  g.throw("error");
}catch (e) {
  console.log(e);// error
}
console.log(g.next());// {value:undefined,done:true}

最后,throw()方法是没有返回值的,也就是返回undefined。

5.7 Generator.prototype.return()

Generator函数执行后返回的遍历器对象,还有一个return()方法,调用该方法将终结遍历Generator函数。

代码语言:javascript
复制
function*gen(){
  yield 1;
  yield 2;
  return 3;
}
const g = gen();
console.log(g.next());// {value:1,done:false}
console.log(g.return("end"));// {value:'end',done:true}
console.log(g.next());// {value:undefined,done:true}

return()方法的参数会作为value的属性值原样返回。

如果Generator函数内部有try…finally语句,且try语句正在执行,那么会等待finally语句里的代码执行完毕,再终结Generator函数的遍历。

代码语言:javascript
复制
function*gen(){
  while(true){
    try{
      yield 1;
    }finally {
      console.log("hello");
      console.log("world");
    }
  }
}
const g = gen();
console.log(g.next());
try{
  g.throw("error");
}catch (e) {
  console.log(e);
}
console.log(g.next());
console.log(g.next());
//打印结果
{ value: 1, done: false }
hello
world
error
{ value: undefined, done: true }
{ value: undefined, done: true }

5.8 yield*表达式

在一个Generator函数内部调用另外一个Generator函数通常是没有效果的,如下:

代码语言:javascript
复制
function*foo() {
  yield "foo1";
  yield "foo2";
}

function*bar(){
  yield "bar1";
  foo();
  yield "bar2";
}
const b = bar();
for(let i of b){
  console.log(i);
}
//打印结果
bar1
bar2

而yield*表达式就是用来解决这个问题的:

代码语言:javascript
复制
function*foo() {
  yield "foo1";
  yield "foo2";
}
function*bar(){
  yield "bar1";
  yield* foo();
  yield "bar2";
}
const b = bar();
for(let i of b){
  console.log(i);
}
//打印结果
bar1
foo1
foo2
bar2

所以可以看到yield*表达式效果等价于:

代码语言:javascript
复制
function*bar(){
  yield "bar1";
  for(let i of foo()){
    yield i;
  }
  yield "bar2";
}

所以yield*后边的表达式可以是Generator函数执行返回的遍历器对象,也可以是其它具有Iterator接口的变量。

5.9 作为对象属性的Generator函数

可以将Generator函数作为对象的方法:

代码语言:javascript
复制
const obj = {
  gen1:function*(){

  },
  *gen2(){

  }
};

5.10 Generator函数的this

代码语言:javascript
复制
function*gen(){}
gen.prototype.sayHello = function(){
  console.log("hello world");
};
const g = gen();
console.log(g instanceof gen);//true
g.sayHello(); // hello world

Generator 函数总是返回一个遍历器,ES6 规定这个遍历器是 Generator 函数的实例,所以该实例可以调用Generator函数原型上的方法。

代码语言:javascript
复制
function*gen(){
  this.name = "gen";
}
const g = gen();
console.log(g.name);//undefined

不可以把Generator函数当作普通构造函数,g返回的是遍历器对象,而非this。同时,既然不是构造函数,当然也不可以使用new操作符。

5.11 Generator的意义

协程是一种程序运行方式,可以理解为”协作的线程“或”协作的函数“,协程可以由单线程实现也可以由多线程实现,前者是一种特殊的子例程,后者是一种特殊的线程。

1、协程与子例程的差异

传统的“子例程”(subroutine)采用堆栈式“后进先出”的执行方式,只有当调用的子函数完全执行完毕,才会结束执行父函数。协程与其不同,多个线程(单线程情况下,即多个函数)可以并行执行,但是只有一个线程(或函数)处于正在运行的状态,其他线程(或函数)都处于暂停态(suspended),线程(或函数)之间可以交换执行权。也就是说,一个线程(或函数)执行到一半,可以暂停执行,将执行权交给另一个线程(或函数),等到稍后收回执行权的时候,再恢复执行。这种可以并行执行、交换执行权的线程(或函数),就称为协程。

从实现上看,在内存中,子例程只使用一个栈(stack),而协程是同时存在多个栈,但只有一个栈是在运行状态,也就是说,协程是以多占用内存为代价,实现多任务的并行。

2、协程与普通线程的差异

不难看出,协程适合用于多任务运行的环境。在这个意义上,它与普通的线程很相似,都有自己的执行上下文、可以分享全局变量。它们的不同之处在于,同一时间可以有多个线程处于运行状态,但是运行的协程只能有一个,其他协程都处于暂停状态。此外,普通的线程是抢先式的,到底哪个线程优先得到资源,必须由运行环境决定,但是协程是合作式的,执行权由协程自己分配。

由于 JavaScript 是单线程语言,只能保持一个调用栈。引入协程以后,每个任务可以保持自己的调用栈。这样做的最大好处,就是抛出错误的时候,可以找到原始的调用栈。不至于像异步操作的回调函数那样,一旦出错,原始的调用栈早就结束。

Generator 函数是 ES6 对协程的实现,但属于不完全实现。Generator 函数被称为“半协程”(semi-coroutine),意思是只有 Generator 函数的调用者,才能将程序的执行权还给 Generator 函数。如果是完全执行的协程,任何函数都可以让暂停的协程继续执行。

如果将 Generator 函数当作协程,完全可以将多个需要互相协作的任务写成 Generator 函数,它们之间使用yield表达式交换控制权。

3、Generator与上下文

JavaScript代码运行时会产生一个全局的上下文环境,包含了当前所有的变量和函数。然后执行函数或代码块的时候,会在当前上下文环境的上层产生一个函数运行的上下文,变成当前的上下文,由此形成一个上下文环境的堆栈。

这个堆栈是”后进先出“的数据结构,最后产生的上下文环境首先执行完成,退出堆栈,然后再执行它下层的上下文,直至所有的代码执行完成,清空堆栈。

但Generator函数不是这样的,执行产生的上下文环境一旦遇到yield命令就会暂时退出堆栈,但是并不消失,里面所有的变量和对象都处于冻结状态。等到调用next命令,这个上下文环境又重新加入调用栈,冻结的变量和对象恢复执行。


Generator本质上还是用于流程控制,yield*表达式可以实现诸如递归、数组数据展开等操作,但单纯的Generator处理异步问题还需要编写自执行函数,async函数则可以完美解决这个问题。

六、async函数

async是再ES2017引入的,本质上属于Generator的语法糖。

6.1 含义

用Generator和Promise来读取2个文件:

代码语言:javascript
复制
const fs = require("fs");
function readFile(filePath){
  return new Promise((resolve, reject)=>{
    fs.readFile(filePath,"utf8",function(err,result){
      if(!err){
        resolve(result);
      }else{
        reject(err);
      }
    });
  });
}
function* gen(){
  const f1 = yield readFile("./file/test1.txt");
  const f2 = yield readFile("./file/test2.txt");
}

Generator函数yield表达式的返回值是下一次调用next()方法的传参,而readFile又是异步函数,所以想实现串行执行,最终调用next()时还是需要回调嵌套:

代码语言:javascript
复制
const g = gen();
g.next().value.then((result)=>{
  console.log(result);
  return g.next().value;
}).then((result)=>{
  console.log(result);
});

如果改成async函数,就简单多了:

代码语言:javascript
复制
async function gen(){
  const f1 = await readFile("./file/test1.txt");
  console.log(f1);
  const f2 = await readFile("./file/test2.txt");
  console.log(f2);
  return {f1,f2}
}
gen().then((result)=>{
  console.log(result);
});

不需要手动调用next()方法,async函数会自动执行。

6.2 async的使用

1、await后边的表达式是一个Promise,否则会自动调用Promise.resolve()方法将其转化为Promise。

代码语言:javascript
复制
async function fn(){
  return await "hello world";
}
fn().then(result=>{
  console.log(result);
});

2、async函数执行后立即返回一个Promise,fulfilled回调函数的参数就是async函数内部的返回值。

代码语言:javascript
复制
async function fn(){
  return {
    text1:await "hello world",
    text2:await "hello async function"
  }
}
fn().then(result=>{
  console.log(result);
});

3、async函数内部的await是串行执行的。

代码语言:javascript
复制
async function fn(){
  return {
    text1:await new Promise(resolve=>{
      setTimeout(()=>{
        resolve("hello world");
      },3000)
    }),
    text2:await new Promise(resolve=>{
      const text = "hello async function";
      console.log(text);
      resolve(text);
    })
  }
}
fn().then(result=>{
  console.log(result);
});

4、当某个await表达式的Promise为rejected状态,那么async函数返回的Promise也为rejected,且不再执行后续的await。

代码语言:javascript
复制
async function gen(){
  const f1 = await readFile("./file/test1.txt");
  console.log(f1);
  const f2 = await readFile("./file/test2.txtz");
  console.log(f2);
  return {f1,f2}
}

async function fn(){
  return {
    text1:await Promise.reject("hello world"),
    text2:await new Promise(resolve=>{
      const text = "hello async function";
      console.log(text);
      resolve(text);
    })
  }
}
fn().then(result=>{
  console.log(result);
}).catch(err=>{
  console.log(err);
});

5、await表达式整体返回Promise调用resolve()方法传入的参数。

如果该Promise是reject状态,那么就停止执行后续的代码了,所以也无从得出返回值是什么了。

6、async函数的多种声明方式。

代码语言:javascript
复制
const asyncFn = async function(){};
const asyncFn = async ()=>{};
const obj = {
  async asyncFn(){}
};
const obj = {
  asyncFn:async function(){}
};
const obj = {
  asyncFn:async ()=>{}
};
class AsyncClass{
  async asyncFn(){}
}
//node暂不支持class这种方法定义,Chrome支持
class AsyncClass{
  asyncFn= async()=>{}
}

7、async函数内部抛出的错误会被外部的rejected回调函数或catch捕获。

代码语言:javascript
复制
const asyncFn = async function(){
  throw new Error("error");
};
asyncFn().then(null,(err)=>{
  console.log(err);
});

本章End~

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、函数式编程
    • 1.1 高阶函数
      • 1.2 偏函数用法
      • 二、异步编程的优势与难点
        • 2.1 优势
          • 2.2 难点
            • 1、异常处理
            • 2、函数嵌套过深
            • 3、阻塞代码
            • 4、多线程编程
            • 5、异步转同步
        • 三、异步编程解决方案
          • 3.1 发布/订阅模式
            • 1、继承events模块
            • 2、利用事件队列解决雪崩问题
            • 3、多异步之间的协作方案
          • 3.2 Promise/Deferred模式
            • 1、Promises/A
        • 四、Promise
          • 4.1 Promise的含义
            • 4.2 基本用法
              • 4.3 Promise.prototype.then()
                • 4.4 Promise.prototype.catch()
                  • 4.5 Promise.prototype.finally()
                    • 4.6 Promise.all()
                      • 4.7 Promise.race()
                        • 4.8 Promise.resolve()
                          • 1、参数是一个Promise实例
                          • 2、参数是一个thenable对象
                          • 3、参数不是具有then方法的对象,或根本就不是对象
                          • 4、不带任何参数
                        • 4.9 Promise.reject()
                          • 4.10 Promise与事件循环
                            • 4.11 总结
                            • 五、Generator
                              • 5.1 基本使用
                                • 5.2 yield表达式
                                  • 5.3 与Iterator接口
                                    • 5.4 next()方法的参数
                                      • 5.5 for…of语句
                                        • 5.6 Generator.prototype.throw()
                                          • 5.7 Generator.prototype.return()
                                            • 5.8 yield*表达式
                                              • 5.9 作为对象属性的Generator函数
                                                • 5.10 Generator函数的this
                                                  • 5.11 Generator的意义
                                                    • 1、协程与子例程的差异
                                                    • 2、协程与普通线程的差异
                                                    • 3、Generator与上下文
                                                • 六、async函数
                                                  • 6.1 含义
                                                    • 6.2 async的使用
                                                    领券
                                                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档