Javascript 异步编程

所谓"异步",简单说就是一个任务分成两段,先执行第一段,然后转而执行其他任务,当第一段有了执行结果之后,再回过头执行第二段。JavaScript采用异步编程原因有两点,一是JavaScript是单线程,二是为了提高CPU的利用率。在提高CPU的利用率的同时也提高了开发难度,尤其是在代码的可读性上。

console.log(1);

setTimeout(function () {
  console.log(2);
});

console.log(3);
JavaScript异步执行示意图

callback

最开始我们在处理异步的时候,采用的是callback回调函数的方式

asyncFunction(function(value){
    // todo
})

在一般简单的情况下,这种方式是完全够用的,但是如果碰到稍微复杂的场景,就有些力不从心,例如当异步嵌套过多的时候。

回调金字塔

但是当我们的异步操作比较多,而且都依赖于上一步的异步的执行结果,那么我们就会产生回调金字塔,难于阅读

step1(function (value1) {
    step2(function(value2) {
        step3(function(value3) {
            step4(function(value4) {
                // Do something with value4
            });
        });
    });
});

当然为了改进这种层层嵌套的写法,我们有几种方式
1 命名函数

function fun1 (params) {
  // todo
  asyncFunction(fun2);
}

function fun2 (params) {
  // todo
  asyncFunction(fun3)
}

function fun3 (params) {
  // todo
  asyncFunction(fun4)
}

function fun4 (params) {
  // todo
}

asyncFunction(fun1)

2 基于事件消息机制的写法

eventbus.on("init", function(){
    operationA(function(err,result){
        eventbus.dispatch("ACompleted");
    });
});
 
eventbus.on("ACompleted", function(){
    operationB(function(err,result){
        eventbus.dispatch("BCompleted");
    });
});
 
eventbus.on("BCompleted", function(){
    operationC(function(err,result){
        eventbus.dispatch("CCompleted");
    });
});
 
eventbus.on("CCompleted", function(){
    // do something when all operation completed
});

当然也可以利用模块化来处理,使得代码易于阅读。以上这三种方式都只是在代码的可读性上面做了改进,但是并没有解决另外一个问题就是异常捕获。

错误栈

function a () {
    b();
}

function b () {
    c();
}

function c () {
    d();
}

function d () {
    throw new Error('出错啦');
}

a();
Node错误打印

从上面的图我们可以看到有一个比较清晰的错误栈信息,a调用b - b调用c - c调用d ,在d中抛出了一个异常。也就是说在JavaScript中在执行一个函数的时候首先会压入执行栈中,执行完毕后会移除执行栈,FILO的结构。我们可以很方便的从错误信息中定位到出错的地方。

function a() {
    b();
}

function b() {
    c(cb);
}

function c(callback) {
    setTimeout(callback, 0)
}

function cb() {
    throw new Error('出错啦');
}

a();
包含异步的错误栈

从上图我们可以看到只打印出了是在一个setTimeout中的回调函数中出现了异常,执行顺序是跟踪不到的。

异常捕获

回调函数中的异常是不能够捕捉到的,因为是异步的,我们只能在回调函数中使用try catch捕获,也就是我注释的部分。

function a() {
    setTimeout(function () {
        // try{
            throw new Error('出错啦');
        // } catch (e) {
        
        // }
        
    }, 0);
}

try {
    a();
} catch (e) {
    console.log('捕捉到异常啦,好高兴哦');
}

但是try catch只能捕捉到同步的错误,不过在回调中也有一些比较好的错误处理模式,例如error-first的代码风格约定,这种风格在node.js中广泛被使用 。

function foo(cb) {
  setTimeout(() => {
    try {
      func();
      cb(null, params);
    } catch (error) {
      cb(error);
    }
    
  }, 0);
}

foo(function(error, value){
    if(error){
        // todo
    }
    // todo
});

但是这么做也很容易陷入恶魔金字塔中。

Promise

规范简述

  • promise 是一个拥有 then 方法的对象或函数。
  • 一个promise有三种状态 pending, rejected, resolved 状态一旦确定就不能改变,且只能够由pending状态变成rejected或者resolved状态,reject和resolved状态不能相互转换。
  • 当promise执行成功时,调用then方法的第一个回调函数,失败时调用第二个回调函数。
  • promise实例会有一个then方法,这个then方法必须返回一个新的promise。

规范更多细节请看这里

基本用法

// 异步操作放在Promise构造器中
const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('hello');
    }, 1000);
});

// 得到异步结果之后的操作
promise1.then(value => {
  console.log(value, 'world');
}, error =>{
  console.log(error, 'unhappy')
});

异步代码,同步写法

asyncFun()
    .then(cb)
    .then(cb)
    .then(cb)

promise以这种链式写法,解决了回调函数处理多重异步嵌套带来的回调地狱问题,使代码更加利于阅读,当然本质还是使用回调函数。

异常捕获

前面说过如果在异步的callback函数中也有一个异常,那么是捕获不到的,原因就是回调函数是异步执行的。我们看看promise是怎么解决这个问题的。

asyncFun(1).then(function (value) {
    throw new Error('出错啦');
}, function (value) {
    console.error(value);
}).then(function (value) {

}, function (result) {
  console.log('有错误', result);
});

其实是promise的then方法中,已经自动帮我们try catch了这个回调函数,实现大致如下。

Promise.prototype.then = function(cb) {
    try {
        cb()
    } catch (e) {
       // todo
       reject(e)
    }
}

then方法中抛出的异常会被下一个级联的then方法的第二个参数捕获到(前提是有),那么如果最后一个then中也有异常怎么办。

Promise.prototype.done = function (resolve, reject) {
    this.then(resolve, reject).catch(function (reason) {
        setTimeout(() => {
           throw reason;
        }, 0);
    });
};
 asyncFun(1).then(function (value) {
     throw new Error('then resolve回调出错啦');
 }).catch(function (error) {
     console.error(error);
     throw new Error('catch回调出错啦');
 }).done((reslove, reject) => {});

我们可以加一个done方法,这个方法并不会返回promise对象,所以在此之后并不能级联,done方法最后会把异常抛到全局,这样就可以被全局的异常处理函数捕获或者中断线程。这也是promise的一种最佳实践策略,当然这个done方法并没有被ES6实现,所以我们在不适用第三方Promise开源库的情况下就只能自己来实现了。为什么需要这个done方法。

const asyncFun = function (value) {
  return new Promise(function (resolve, reject) {
    setTimeout(function () {
      resolve(value);
    }, 0);
  })
};


asyncFun(1).then(function (value) {
  throw new Error('then resolve回调出错啦');
});

(node:6312) UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: then resolve回调出错啦
(node:6312) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code
我们可以看到JavaScript线程只是报了一个警告,并没有中止线程,如果是一个严重错误如果不及时中止线程,可能会造成损失。

局限

promise有一个局限就是不能够中止promise链,例如当promise链中某一个环节出现错误之后,已经没有了继续往下执行的必要性,但是promise并没有提供原生的取消的方式,我们可以看到即使在前面已经抛出异常,但是promise链并不会停止。虽然我们可以利用返回一个处于pending状态的promise来中止promise链。

const promise1 = new Promise((resolve) => {
    setTimeout(() => {
        resolve('hello');
    }, 1000);
});

promise1.then((value) => {
    throw new Error('出错啦!');
}).then(value => {
    console.log(value);
}, error=> {
    console.log(error.message);
    return result;
}).then(function () {
    console.log('DJL箫氏');
});

特殊场景

  • 当我们的一个任务依赖于多个异步任务,那么我们可以使用Promise.all
  • 当我们的任务依赖于多个异步任务中的任意一个,至于是谁无所谓,Promise.race

上面所说的都是ES6的promise实现,实际上功能是比较少,而且还有一些不足的,所以还有很多开源promise的实现库,像q.js等等,它们提供了更多的语法糖,也有了更多的适应场景。

核心代码

var defer = function () {
    var pending = [], value;
    return {
        resolve: function (_value) {
            value = _value;
            for (var i = 0, ii = pending.length; i < ii; i++) {
                var callback = pending[i];
                callback(value);
            }
            pending = undefined;
        },
        then: function (callback) {
            if (pending) {
                pending.push(callback);
            } else {
                callback(value);
            }
        }
    }
};

当调用then的时候,把所有的回调函数存在一个队列中,当调用resolve方法后,依次将队列中的回调函数取出来执行

var ref = function (value) {
    if (value && typeof value.then === "function")
        return value;
    return {
        then: function (callback) {
            return ref(callback(value));
        }
    };
};

这一段代码实现的级联的功能,采用了递归。如果传递的是一个promise那么就会直接返回这个promise,但是如果传递的是一个值,那么会将这个值包装成一个promise。

generator

基本用法

function * gen (x) {
    const y = yield x + 2;
    // console.log(y);  // 猜猜会打印出什么值
}

const g = gen(1);
console.log('first', g.next());  //first { value: 3, done: false }
console.log('second', g.next()); // second { value: undefined, done: true }

通俗的理解一下就是yield关键字会交出函数的执行权,next方法会交回执行权,yield会把generator中yield后面的执行结果,带到函数外面,而next方法会把外面的数据返回给generator中yield左边的变量。这样就实现了数据的双向流动。

generator实现异步编程

我们来看generator如何是如何来实现一个异步编程(*)

const fs = require('fs');

function * gen() {
    try {
        const file = yield fs.readFile;
        console.log(file.toString());
    } catch(e) {
        console.log('捕获到异常', e);
    }
}

// 执行器
const g = gen();

g.next().value('./config1.json', function (error, value) {
  if (error) {
    g.throw('文件不存在');
  }
  g.next(value);
});

那么我们next中的参数就会是上一个yield函数的返回结果,可以看到在generator函数中的代码感觉是同步的,但是要想执行这个看似同步的代码,过程却很复杂,也就是流程管理很复杂。那么我们可以借用TJ大神写的co。

generator 配合 co

下面来看看如何使用:

const fs = require('fs');
const utils = require('util');
const readFile = utils.promisify(fs.readFile);
const co = require('co');

function * gen(path) {
    try {
        const file = yield readFile('./basic.use1.js');
        console.log(file.toString());
    } catch(e) {
        console.log('出错啦');
    }
}

co(gen());

我们看到使用co这个执行器配合generator和promise会非常方便,非常类似同步写法,而且异步中的错误也能很容易被try catch到。这里之所以要使用utils.promisify这个工具函数将普通的异步函数转换成一个promise,是因为co may only yield a chunk, promise, generator, array, or object。使用co 配合generator最大的一个好处就是错误可以try catch 到。

async/await

先来看一段async/await的异步写法

const fs = require('fs');
const utils = require('util');
const readFile = utils.promisify(fs.readFile);
async function readJsonFile() {
    try {
        const file = await readFile('../generator/config.json');
        console.log(file.toString());
    } catch (e) {
        console.log('出错啦');
    }

}

readJsonFile();

我们可以看到async/await的写法十分类似于generator,实际上async/await就是generator的一个语法糖,只不过内置了一个执行器。并且当在执行过程中出现异常,就会停止继续执行。当然await后面必须接一个promise,而且node版本必须要>=7.6.0才可以使用,当然低版本也可以采用babel。

补充

在开发过程中我们常常手头会同时有几个项目,那么node的版本要求很有可能是不同的,那么我们就需要安装不同版本的node,并且管理这些不同的版本,这里推荐使用nvm下载好nvm,安装,使用nvm list 查看node版本列表。使用nvm use 版本号 进行版本切换。

在Node.js中捕获漏网之鱼

process.on('uncaughtException', (error: any) => {
    logger.error('uncaughtException', error)
})

在浏览器环境中捕获漏网之鱼

window.addEventListener('onrejectionhandled', (event: any) => {
    console.error('onrejectionhandled', event)
})

参考文章

Promise中文迷你书
剖析Promise内部结构,一步一步实现一个完整的、能通过所有Test case的Promise类
深入理解Promise实现细节
DJL箫氏的个人博客

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,802评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,109评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,683评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,458评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,452评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,505评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,901评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,550评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,763评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,556评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,629评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,330评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,898评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,897评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,140评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,807评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,339评论 2 342

推荐阅读更多精彩内容