聊聊 JavaScript 的异步

JavaScript 中的异步

程序中现在运行的部分将来运行的部分之间的关系就是异步编程的核心。 ——《你不知道的 JavaScript》

所以,异步就是要处理好将来运行部分代码。通常来说 JavaScript 使用回调函数来处理异步。

异步带来的问题

异步编程在任何编程语言中都非常重要,但异步编程也会遇到一些问题。

并行

并行是指多个异步行为在发生中的状态下。

var a = 1;
var b = 2;

function foo() {
  a++;
  b = b * a;
  a = b + 3;
}

function bar() {
  b--;
  a = 8 + b;
  b = a * 2;
}
// ajax(..)是某个库中提供的某个Ajax函数
ajax("http://some.url.1", foo);
ajax("http://some.url.2", bar);

上面的代码中,同时执行了两个 ajax 异步行为,当两个行为都没有做出反应时他们就是并行的。

那么问题就来了,异步响应的时间是不确定的,从而导致响应的回调函数的顺序也是不确定的。可能是 foo 函数先执行也可能是 bar 函数先执行。

由于回调函数使用了全局变量 a 和 b,所以执行循序的不确定性带来了程序逻辑上的不确定性,这必然不合理。

并发

并发是指短时间内发出多个异步请求,如页面滚动监听 onscroll 函数。

如果我滚动的足够快,可能短时间内就能发出大量异步请求,于是会出现请求和响应交替出现的情况。又由于多任务异步执行,会产生并行执行,所以响应的顺序也得不到保障。

onscroll, 请求1
onscroll, 请求2 响应1 
onscroll, 请求3 响应2 
响应3
onscroll, 请求4
onscroll, 请求5
onscroll, 请求6 响应4 
onscroll, 请求7

响应6 
响应5 
响应7

如何解决这个问题呢?

  • 如果多个异步的响应事件没有关联,并发执行异步其实无所谓,拿到结果就好。
  • 如果多个异步的响应事件有关联,比如共同操作一个全局变量。那么就需要做好交互协调。

回调函数

最常用的处理异步逻辑的方式就是回调函数。回调函数是指提前定义好未来要执行的函数,在接受到异步响应后执行回调函数。

回调函数表示方式

一个最简单的回调就是我们常用的 setTimeout 函数,其中的第一个参数就是回调函数。

setTimeout(function () {
  console.log('hello world');
}, 100);

100 ms 后浏览器将打印出 hello world 字符串结果。

什么是回调地狱

如果一个异步行为依赖于另一个异步行为的回调函数的值,就是嵌套函数。如果这种关系层层嵌套,那么麻烦就来了。看下如下代码:

doA(function () {
  doB();
  doC(function () {
    doD();
  });
  doE();
});
doF();

这段代码进行了多次异步函数的嵌套,它输出的结果会是如何的呢?

  • doA()
  • doF()
  • doB()
  • doC()
  • doE()
  • doD()

但是如果 doA 或 doC 函数是同步的,执行顺序是否就不一样了,这是嵌套函数的问题之一。

所以,回调地狱是指什么呢?

  • 嵌套函数让代码逻辑变得很跳跃,以此这种代码变得难以阅读和理解。
  • 嵌套函数最大的问题是通过嵌套严密制定了行为的执行路径,而且遇到如报错这样的情况还需要手工硬编码来解决。手工硬编码(即使包含了硬编码的出错处理)回调的脆弱本性可就远没有这么优雅了。一旦你指定(也就是预先计划)了所有的可能事件和路径,代码就会变得非常复杂, 以至于无法维护和更新。

回调函数的信任问题

回调函数并非总是像我们所想的那样触发,它可能会出现如下问题导致回调出现问题。

  1. 调用回调过早;
  2. 调用回调过晚(或不被调用);
  3. 调用回调次数过少或过多;
  4. 未能传递所需的环境和参数;
  5. 吞掉可能出现的错误和异常。

Promise

对于回调函数存在的种种弊端,ES6 中提出的 Promise 能够很好的解决它们。

基本用法

使用 new Promise() 定义一个 Promise对象,对象接受一个函数,函数参数为resolve和reject。resolve 为执行成功的方法,而 reject 为执行失败的方法。

const promise = new Promise(function(resolve, reject) {
  // ... some code

  if (/* 异步操作成功 */){
    resolve(value);
  } else {
    reject(error);
  }
});

基本调用方式

promise.then(function(value) {
  // success
}, function(error) {
  // failure
});

Promise 函数一旦用 new 指令创建,立即执行。并且数据为不可变。

let success = true
let name = 'jack'

const promise = new Promise((resolve, reject) => {
    console.log('create')
    if (success) {
        resolve(name)
    } else {
        reject('error promise')
    }
})

success = false
name = 'rose'
console.log('before then')

promise.then(value => {
    console.log(value)
}).catch(error => {
    console.log(error)
})

// create
// before then
// jack

由此可见,在定义了Promise之后,我们再去修改 name 返回的还是定义Promise时候的值。说明了Promise对象定义即执行,并且不可变。

Promise 推荐使用 promise.then().catch() 写法。

// bad
promise.then(value => {
    console.log(value)
}, error => {
    console.log(error)
})

// good
promise.then(value => {
    console.log(value)
}).catch(error => {
    console.log(error)
})

then方法链式写法表达,then方法的返回值可以传递给下一个then方法。

const promise = new Promise((resolve, reject) => {
    resolve('jack')
})

promise.then(value => {
    console.log(value)
    return 'violet' + value
}).then(value => {
    console.log(value)
    return 'welcome to ' + value
}).then(value => {
    console.log(value)
    return  value + ' blog'
}).then(value => {
    console.log(value)
})

// jack
// violetjack
// welcome to violetjack
// welcome to violetjack blog

catch方法用于捕获Promise对象的异常行为(可能是 reject 函数返回的错误,也可能是throw new Error('error'))。
Promise.all() 方法将多个Promise实例包装成一个Promise实例。如下示例,如果p1、p2、p3都执行成功,则执行then方法,返回的参数为三个实例的参数数组;如果有任意一个Promise实例报错,则在catch方法中返回该实例的错误信息。

const p1 = new Promise((resolve, reject) => {
    resolve('jack')
})

const p2 = new Promise((resolve, reject) => {
    // resolve('rose')
    reject('rose error')
})

const p3 = new Promise((resolve, reject) => {
    resolve('james')
})

Promise.all([p1, p2, p3]).then(values => {
    console.log(values)
}).catch(error => {
    console.log(error)
})

// ["jack", "rose", "james"]
// rose error

Promise.race() 方法用法与 all 方法一致,唯一不同点就是多个 Promise实例中只作用域最快有反映的Promise实例,并且返回该实例的正确或错误信息。如果多个Promise同时触发,按顺序返回第一个Promise实例。
Promise.resolve() 和 Promise.reject()

Promise.resolve('foo')
// 等价于
new Promise(resolve => resolve('foo'))

const p = Promise.reject('出错了');
// 等同于
const p = new Promise((resolve, reject) => reject('出错了'))

解决回调函数的信任问题

  1. 调用回调过早;

对一个 Promise 调用 then(..) 的时候,即使这个 Promise 已经决议,提供给 then(..) 的回调也总会被异步调用。所以 Promise 不用担心这个问题。

  1. 调用回调过晚

Promise 创建对象调用 resolve(..) 或 reject(..) 时,这个 Promise 的 then(..) 注册的观察回调就会被自动调度。可以确信,这些被调度的回调在下一个异步事件点上一定会被触发。

  1. 未调用回调

我们可以使用 Promise.race() 方法来为 Promise 设定超时行为来解决。

  1. 调用回调次数过少或过多;

根据定义,回调被调用的正确次数应该是 1。“过少”的情况就是调用 0 次,和前面解释过的“未被”调用是同一种情况。
“过多”的情况很容易解释。Promise 的定义方式使得它只能被决议一次。如果出于某种 原因,Promise 创建代码试图调用 resolve(..) 或 reject(..) 多次,或者试图两者都调用, 那么这个 Promise 将只会接受第一次决议,并默默地忽略任何后续调用。

  1. 未能传递所需的环境和参数;

参数的传递方式上,Promise 的 resolve() 和 reject() 函数都只接收一个参数,多个参数的传递可使用将参数封装在对象或数组中来实现。

环境值的保存方式上,可以使用闭包来持久化保存变量。

  1. 吞掉可能出现的错误和异常。

代码逻辑上返回错误可以使用 reject() 函数和 catch 来捕获。
JavaScript 异常发生时,promise 决议行为将被拒绝。
所以,可以捕获各种错误和异常。

错误处理

可以对 promise 使用 catch() 来捕获异步中出现的各种错误和异常。

局限性

  1. 顺序错误处理 —— 在链式 Promise 中使用 catch() 捕获错误无法了解中间步骤。
  2. 单一值 —— Promise 的 resolve 和 reject 函数只能传递一个值。
  3. 单决议 —— Promise 只能继续一次决议,之后就不会在改变。
  4. 无法取消 —— 单独的 Promise 不应该被取消。
  5. 性能开销 —— 添加 Promise 必然会有一些性能开销,但是相比收益来说是值得的。

Generator

生成器函数可以一次或多次启动和停止,并不一定非得要完成。这让异步行为显示出同步的样子。

用法

一种异步解决方案。函数执行返回一个对象,而函数中的数据只有在对象使用 next() 方法才会返回下一个用 yield 或者 return 定义的数据,否则对象状态就凝固在那里。

function* helloGenerator() {
    yield 'hello'
    yield 'world'
    return 'generator'
}

var h = helloGenerator()
console.log(h.next())
console.log(h.next())
console.log(h.next())
console.log(h.next())

// { value: 'hello', done: false}
// { value: 'world', done: false}
// { value: 'generator', done: true}
// { value: undefined, done: true}

next() 方法传值—— next() 方法返回的是 yield 表达式的计算结果。如果 next(value) 方法中传入value参数,则参数将替换上一个 yield 数据。如下示例中,12 替换了(yield (x + 1))13 替换了yield (y / 3),最后得到结果为42。

function* foo(x) {
  var y = 2 * (yield (x + 1));
  // value  = 5 + 1
  var z = yield (y / 3);
  // y = 2 * 12 value = 24 / 3
  return (x + y + z);
  // z = 13 y = 24 z = 13 value = 5 + 24 + 13
}

var a = foo(5);
a.next() // Object{value:6, done:false}
// 如果不传递数据,则y=NaN
a.next() // Object{value:NaN, done:false}
a.next() // Object{value:NaN, done:true}

var b = foo(5);

b.next() // { value:6, done:false }
b.next(12) // { value:8, done:false }
b.next(13) // { value:42, done:true }

throw方法用于捕捉错误,return方法类似于 Genterator 函数的 return xxx 返回某个值,随后再使用next方法返回的都是 undefined
对于 next、throw、return,引用书上的解释更清晰点。

next() 是将 yield 表达式替换成一个值。
throw() 是将 yield 表达式替换成一个 throw 语句。
return() 是将 yield 表达式替换成一个 return 语句。

yield* 用于将其他 Generator 函数合并到当前函数中,用法如下:

function* bar() {
    yield 'a'
    yield 'b'
}

function* foo() {
    yield 'x'
    yield* bar()
    yield 'y'
}

for (let v of foo()){
    console.log(v)
}

// x
// a
// b
// y

Generator 函数不能直接用 new 指令实例化对象,需要包装为普通函数再 new

function* gen() {
  this.a = 1;
  yield this.b = 2;
  yield this.c = 3;
}

function F() {
  return gen.call(gen.prototype);
}

var f = new F();

f.next();  // Object {value: 2, done: false}
f.next();  // Object {value: 3, done: false}
f.next();  // Object {value: undefined, done: true}

f.a // 1
f.b // 2
f.c // 3

自动执行所有Generator函数的方法:

function run(fn) {
  var gen = fn();

  function next(err, data) {
    var result = gen.next(data);
    if (result.done) return;
    result.value(next);
  }

  next();
}

function* g() {
  // ...
}

run(g);

以上自动执行器还可以使用 co 模块来实现。

Async

async函数用于处理异步操作,它是对Generator函数的改进。它相比于Generator有以下几个优点:

  • 内置执行器:相比于Generator 要自定义或者用 co 模块来实现自动执行器效果,async函数自带自动执行器。
  • 更好的语义:async 和 await,比起 * 和 yield,语义更清楚了。async表示函数里有异步操作,await表示紧跟在后面的表达式需要等待结果。
  • 更广的适用性:co模块约定,yield命令后面只能是 Thunk 函数或 Promise 对象,而async函数的await命令后面,可以是 Promise 对象和原始类型的值(数值、字符串和布尔值,但这时等同于同步操作)。
  • 返回Promise:async函数的返回值是 Promise 对象,这比 Generator 函数的返回值是 Iterator 对象方便多了。你可以用then方法指定下一步的操作。

我个人对async的感觉是写法方便、代码理解简单、代码写法也符合逻辑、操作异步行为方便。
下面写了 Generator 函数和 async 函数实现异步的代码的对比。

const readFile = function (fileName) {
    return new Promise(function (resolve, reject) {
        setTimeout(() => {
            console.log(`reading ${fileName}`)
            resolve(fileName)
        }, 1000)
    });
};

// Generator 写法
const gen = function* () {
    const f1 = yield readFile('/etc/fstab');
    const f2 = yield readFile('/etc/shells');
    console.log(f1.toString());
    console.log(f2.toString());
};

var g = gen()
g.next().value.then(value => {
    g.next(value).value.then(value => {
        g.next(value)
    })
})

// async 写法
async function gan() {
    const f1 = await readFile('/etc/fstab');
    const f2 = await readFile('/etc/shells');
    console.log(f1.toString());
    console.log(f2.toString());
}

gan()

两种函数的实现结果是一样的。
但从上面的例子中可以看出,Generator 函数需要不断调用next方法,并且将上一个next方法的结果传递给当前next方法当做参数。而async函数直接调用函数本身就会自动往下执行。Generator多了一步执行的过程。
另外,async await的语义很清晰,就算没学过ES6的大致都能看懂是什么意思啦~

最后

综上,推荐使用 Promise、Generator 和 Async 来处理 JavaScript 中异步。

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

推荐阅读更多精彩内容

  • 弄懂js异步 讲异步之前,我们必须掌握一个基础知识-event-loop。 我们知道JavaScript的一大特点...
    DCbryant阅读 2,712评论 0 5
  • 一. Callback (回调函数) 1.定义:把函数当作变量传到另一个函数里,传进去之后执行甚至返回等待之后的...
    hutn阅读 1,532评论 0 2
  • JavaScript语言的执行环境是‘单线程’的(也就是说,执行后续的代码之前必须完成前面的任务,也就是采用的是阻...
    kim_jin阅读 575评论 0 0
  • 前言 编程语言很多的新概念都是为了更好的解决老问题而提出来的。这篇博客就是一步步分析异步编程解决方案的问题以及后续...
    李向_c52d阅读 1,068评论 0 2
  • 我是一名大一新生,怀着对未来的无限期望来到我的大学。经过短暂的军训后我们开始学习新的课程。 ...
    我老帅啦阅读 999评论 0 3