Fork me on GitHub

tag-2021-08-15-tag

TL;DR

  • 早期的callback方式,使得代码变得难以维护。
  • promise出现,解决了异步代码编写的问题。
  • generatro函数使得代码更像同步代码,但是也存在问题。
  • async/await出现了彻底解决异步编码。

callback

接受任务

本篇文章我们来谈一谈JavaScript异步。为了理解Javascript异步发展的历史,我们用一个假设的需求来模拟JavaScript的异步编程的发展历程:我要通过四层人际关系,找到香港演员刘青云。现在将我的思路描述如下:我的朋友大强有一个广东的朋友阿深,阿深经常去香港找以前的古惑仔,现在从良的卖奶粉的刀疤仔买奶粉,而大家都知道,刀疤仔以前是跟陈浩南混铜锣湾的。陈浩南其实就是郑伊健扮演的,他自然认识刘青云喽。因此我们沿着关系依次查找:我,大强(name: dq),阿深(name:as),刀疤仔(name:dbz),陈浩南(name:chn),刘青云(name:lqy)。 现在,我只有大强一个人的名字,我需要编写一个函数,通过这些关系,来找到偶像刘青云。接下来的整篇文章都将围绕这个问题来寻找解决方案。

回调地狱

Javascript的语言特性可以让函数作为一个参数被传入另外一个函数中被调用,我们称这种函数为高阶函数(Higher-order function)。作为javascript中的一等公民,这种高阶函数被用到很多地方:实现原生的或者自的观察者模式:

const cb = () => { console.log('点击响应') };
document.addEventListener('click', cb);

或者被用来处理异步返回结果:

ajax('url', function(data){
	console.log('data received..', data);
});

我们都知道Javascript是一门单线程语言,它是按照代码的编写顺序一步步执行下去。遇到需要异步的时候,我们只需要传入高阶函数作为参数,把它推入延迟队列,然后在合适的时机推回到主函数栈中去执行。这种回调的方式很好地解决了在单线程上处理多线程任务的问题。但是一旦我们的程序依赖程度开始增加,代码的结构就会变得臃肿,并且难以维护。很长一段时间里面,解决寻找刘青云只的任务只能用如下方式编写实现:

//version 1: With Callback Hell
function findIdol(dq) {
  ajax('/getFindPerson?name=' + dq, function(as){
      ajax('/getUserId?name=' + as, function(dbz) {
          ajax('/getLotteryResult?name=' + dbz, function(chn){
             	ajax('/getLotteryResult?name=' + chn, function(lqy){
              		console.log('找到你了,lqy');
          		}) 
          })
      })
  })
}

//开始找人
findIdol('dq');

初期探索

很幸运,虽然早期的Javascript缺点很多,但社区无数的优秀开发者利用各种软件设计模式解决了它自身的缺陷,回调地狱便是其中一个被解决的问题。比如上面的代码我们可以用下面的方式来实现,尽量使我们在编码上的优雅且可维护:

//version2 : With Design Pattern;
var Puppy = function() {
	this.callstack = [];
  this.transverse = null;
}

Puppy.prototype.start = function(params) {
    this.transverse =  this.callstack.shift().call(this, params);
}

Puppy.prototype.next = function(p) {
	this.start(p);
}

Puppy.prototype.add = function(fn) {
	this.callstack.push(fn);
  return this;
}

//找到阿深
function findAs(dq) {
  var _self = this;
	ajax('/getFindPerson?name=' + dq, function(as){
    _self.next(as);
  });
}
//找到刀疤仔
function findDbz(as) {
  var _self = this;
	ajax('/getFindPerson?name=' + dq, function(dbz){
    _self.next(dbz);
  });
}
//找到陈浩南
function findChn(dbz) {
  var _self = this;
    ajax('/getFindPerson?name=' + dbz, function(chn){
      _self.next(chn);
    });
  }
//找到刘青云
function findIdol(chn) {
  var _self = this;
    ajax('/getFindPerson?name=' + dq, function(lqy){
       console.log('找到你了,lqy');
    });
  }
var fakePromise = new fakePromise();

fakePromise.add(findChn).add(findSbg).add(findChn).add(findIdol).start('dq');

通过这些对这些技巧的运用,我们可以避免陷入回调地狱的陷阱,至少让代码现在是稍微好看一些。有很多优秀的开源作品就是通过类似的方式来避免问题的,如果你经历过那个年代,那么你应该多多少少地听过它们的名字:

promise

为何而来

为了改善javascript的异步编码风格,ES2015中引入promise概念。promise规范的制定是JavaScript在异步编程风格上的一次大进步。promise 其实在其他语言中早已有之,例如在语法方面与javascript极其相似的python, 就是采用的promise来处理自己的异步策略。 这种策略后来被引入进来,大大地促进了js异步编码:

const promise = new Promise((resolve, reject) => {
		const timer = setTimeout(() => {
    		resolve('done')
    }, 500)
});

promise.then(res1 => {
	console.log(res1);
  return res1;
}).then(res2 => {
	console.log(res2);
  return res2;
}).catch(ex => {
	console.log(ex);
});

Promise是一个拥有三种状态的状态机器,它的状态分别是:pendingfullfilled,以及rejected。我们初始化一个promise时它会一直保持这pending状态,一直等到resolve或者reject函数被调用,状态才得以改变。reslovereject抛出的值能在then函数中被访问。再经过处理或者直接return给下一个函数。then函数返回的是promise对象,这使得我们可以用链式的写法处理抛出的前面值,这就是promise值的穿透传递特性。
10.png

promise 的原理

promise实现原理并不复杂,它主要是对以下几点知识的运用和处理:

  • 变化的状态机:pendingfullfilledrejected
  • 延迟绑定执行:微任务
  • 值的穿透处理:全局值的存储和传递
  • 全局错误捕捉:try和catch

我们可以根据这些知识,来实现一个简易的promise:

//模拟微任务执行
const _MiroTask = (fn) => {
  var args;
  //mutationObserser 产生的是微任务
  const observer = new MutationObserver(() => {
  	fn.apply(this, args)
  });
  const div = document.createElement('div');
  observer.observe(div, {
    	attributes: true,
    	childList: true
  });
  return function() {
    args = Array.from(arguments);
  	div.id = Date.now();
  }
}

var _Promise = function(fn) {
   //内部状态
    var _status = 'pending';
    var _this = this;
   //then函数的存储队列
    this.thenCallstack = [];
    this.catch;
    this.error;
    this.value;
	
  	//reject 函数
    var _reject = _MiroTask(function(err) {
        _status = 'rejected';
        _this.error = err;
        _handle();
    })
		// resolve 产生的是微任务,此处用来产生微任务,并且延迟执行then中的方法
    var _resolve = _MiroTask(function(value) {
        _this.value = value;
        _status = 'resolved';
        _handle(); 
    })
		//代理函数,执行队列
    var _handle = function() {
        if(_status === 'resolved') {
            for(var i=0; i<_this.thenCallstack.length; i++) {
                try{
                  	//执行队列中最老的一个任务接受它的返回值
                    var new_value = _this.thenCallstack[i].call(_this, _this.value);
                    //返回值存储,方便内部传递,实现值的穿透
                    _this.value = new_value === undefined ? _this.value : new_value;
                }catch(ex) {
                  //捕获全局错误
                    return _reject(ex);
                   
                }
               
            }
        } else {
            _this.catch && _this.catch.call(_this, _this.error);
        }
        
    }
   

   fn.call(_this, _resolve, _reject)  
    
};

_Promise.prototype.then = function(fn) {
    this.thenCallstack.push(fn);
    return this;
}

_Promise.prototype.catch = function(fn) {
    this.catch = fn;
    return this;
}

var _p = new _Promise(function(resolve, reject) {
    setTimeout(function() {
        resolve('New promise works')
    }, 500);
});

_p.then(function(res) {
    return res + ' congratulation!!';
}).then(function(res) {
    console.log(res);
}).catch(ex => {
    console.log('error', ex);
})

promise方法

promise对象内置了很多方法,方便我们同时处理多个异步:

  • promise.all

当有多个promise时,可以使用此方法并处理所有的异步。promise.all的最终返回时间,是以时间最长的那个异步为结果通知的。all方法中无论哪个promise被reject了,整个状态都是failed的。所以建议将关联性较强的异步结果绑定在一起执行。

Promise.all[promise1, promise2]).then(([result1, result2]) => {
		console.log(result1, result2); //执行时间以最长的异步为准。所有的promise必须被resolve。
})
  • promise.race

race故名思议,传入的多个promise,但是只返回最快处理的那个。这个可以用来处理一些超时任务或者定时任务。

Promise.race([promise1, promise2]).then((res) => {
		console.log(res); //以最快执行完成的异步为准
})
  • promise.allSettled

这个方法的用法与all相似,不同的是,使用allSettled,任一promise本被reject了都不会影响,相当于把每个promise隔离开来,不影响彼此。allSettled 标准还未制定完成,很多浏览器都是不支持的。不过你可以使用prollfill来在你的项目中使用它。

Promise.settled([promise1, promise2]).then(([result1, result2]) => {
	console.log(result1, result2); //执行时间以最长的异步为准。每个promise的状态不影响其他的成员。
})
  • promise.resolve

resolve方法产生的就是一个状态为fulfilled的promise。我们都知道resolve产生的是微任务,因此可以用这个方法来模拟实现微任务,提高函数执行的效率。

Promise.resolve(1)//Promise<fulfilled> 1

现在,我们可以用promise来解决寻找刘青云问题了,首先我们开始升级并且简化callback的回调方式,按照promise的异步方案来编写完成我们的任务:

type AsyncFuncLike<T = string> = {
    (name: T,
    callback: {
        (result: T): void
    }): void
}

type Promisify<T = string> = {
    (fn: AsyncFuncLike<T>) : {
        (params: T): Promise<T>
    }
}

const ajax:AsyncFuncLike = (name, callback) => {
   // 发送并且简单xhr请求
    xhr.send('/getFindPerson?name=' + name);
    xhr.on = callback;
}


const promisify:Promisify = asyncFunc => person => new Promise((resolve, reject) => {
    asyncFunc.apply(null, [person, (result) => {
        resolve(result) 
    }]);
});
const findSomeone = promisify(ajax); // ajax: Ajax

promisify 是node的内置util模块中的一个方法,用来使node的一些异步操作promise化,我们在浏览器中简单实现promisify方法实现将函数转化。后面我们会一直用到这个被封装后的函数findSomeOne来生产promise,简化我们的编码工作。当然,浏览器也提供原始的fetch功能来发送http请求,fetch返回的是promise

//version3 With Promise
findSomeone('dq') // 找到了阿深
  .then(findSomeone) //找到刀疤仔
  .then(findSomeone) //找到了陈浩南
  .then(console.log) //找到刘青云 'lqy'

有了上面的promisify封装方法,我们编写的代码变得异常的简单。promise无疑使得我们编写无疑使得我们编写方式变得优雅,写出来的代码叶更容易被人维护和阅读。但是当我们的异步处理变多时也会出现与callback相似的问题:不停的thencatch似乎又使得callback回到了老路上。

generator

yield关键字

后来ECMAScript6又推出了generator函数来解决这个问题。使用yeild关键字编写的方式异步,在语法上摆脱了异步回调或者then方法。generator 函数申明和调用时的语法已经有了很大的变化。申明时需要在function关键字后加上一个星号,在函数内部也有yield关键字来阻断内部程序的执行。如果需要函数继续执行,需要手动调用该函数的next方法。我们可以通过给next方法传入值,来给下一个阶段的定义变量复制,从而实现异步求值的返回。next函数返回了一个键值对象。我们用下面这段代码来说明generator函数执行的顺序:

let step = 0;
const gen = function* (params) { //申明一个generator 函数
  console.log(step++, ' line: 3', params)
  const result1 = yield 1;
  console.log(step++, ' line: 5', 'result1 is:', result1);
  const result2 = yield 2;
  console.log(step++, ' line: 7', 'result2 is:', result2);
}
console.log(step++,' line: 9');
const g = gen('initial params'); // 调用generator函数
console.log(step++, ' line: 11');
const fistValue = g.next();
console.log(step++, ' line: 13', fistValue);
const secondValue = g.next('Passing string to variable result1');
console.log(step++, ' line: 15', secondValue);
const lastValue = g.next();
console.log(step++, ' line: 17', lastValue);

我们逐行来分析以上的代码:

  1. 第一行定义一个全局变量step来计算执行顺序
  2. 2~8行,我们申明了一个generator函数
  3. 第九行首先执行打印操作line 9
  4. 接着调用了generator函数,并且将其返回值赋值给变量g
  5. generator函数并没有执行,而是执行了打印 line 11 操作
  6. 12行调用g的next方法,这时,才开始执行generatro函数,打印line3,以及之前调用是传入的参数
  7. 程序在函数里面继续向下执行,遇到了yield 1,返回一个值给fisrtvalue,函数内的代码停止执行
  8. 到了13行 打印firstvalue
  9. 我们接着执行next方法,并且传递了一个字符串进去
  10. 这时程序开始从第5行开始执行,并且打印了result1 为
  11. 然后程序yield 2,暂停函数执行,回到外面执行打印secondeValue 值为
  12. g.next再次去往函数中执行打印result2 为undefined
  13. 最后执行了打印line 17

最终执行的顺序如下图所示:
Screen Shot 2020-11-24 at 6.41.15 PM.png
执行这段代码,我们可以看到程序不停的在函数内外切换执行,它看起与callback的方式原理一样,遇到要执行的函数便跳入到该函数中去执行,只不过,在generator函数中,程序通过了另外一种全新语法糖来做到这个。使用这种语法使用很大程度上能避免代码混乱的问题,并且使用协程来区别callback函数在主线程上执行的方式。

协程Coroutie

协程是运行在线程上的更小单位,正如线程运行在进程上一样。一个线程上可以存在许多协程,但只能同时跑一个协程,上文提到的gen函数就是对协程的运用,我们逐步对以上代码进行分析可以总结出一下规律:

  • const g = gen()启动了一个协程。
  • g.next()将主线程控制权交给协程,暂停主线程执行。
  • 引擎遇到yield关键字暂停了这协程的执行,将控制权交还给主线程。
  • 主线程代码执行到next的时候,控制权又交给了gen协程继续执行gen函数中的任务。
  • 最后gen函数执行完所有的yield,便退出了内存,将控制权交给了主线程。

11.png
上面这张图表示了程序的流程示意图,协程就是这样不停地通过next方法和yield关键字在主线程上和协程上来回的切换,以保证我们的代码按照预期的方式执行。

解决异步问题

generator函数也是可以用来解决异步问题的。通过向next中传入参数,并且手动调用next的方法,我们实现了异步非阻塞流程,与之前的ajax或者promise相比,无论是语法上或者阅读体验上都是比较顺畅的。

const _g = function* () {
	const firstReturn = yield new Promise((resolve, reject) => {
  	setTimeout(() => {
    	resolve('Hello')
    })
  });
  
  const secondReturn =  yield new Promise((resolve, reject) => {
  	setTimeout(() => {
    	resolve(firstReturn + ' javascript')
    })
  })
  
    const lastReturn yield new Promise((resolve, reject) => {
  	setTimeout(() => {
    	resolve(firstReturn + ' async');
    })
  });
  
  console.log('Add result is:', lastReturn);
}

const result = _g();
const f = _g().next();
const s = _g().next(f);
const l = _g().next(s);
_g().next();

co frame

generator函数的写法以及很接近我们编写同步代码的方式了。但是每次运行程序都需要手动调用next,使得这样的编写方式大打折扣。为了解决这个问题,聪明的开发者也想到了各种解决方案。

function runGen(generator) {
  const _g = generator();
	let result = _g.next();
  if(typeof p.then === "function")
  	result.value.then(_g.next)
  else
    _g.next(result.value)
}

上面我们封装了一个函数,可以让gen函数自动执行,它的实现非常简单,代码也非常少。github上也有很多类似的框架来帮我们实现自动调用next函数。如著名的co框架,co框架的代码很少,但是却很好的解决了我们的问题,让gen自动执行。最后,让我们继续用generator完成寻找刘青云的任务:

// version4 With generator
const findIdol = function* (person) {
  const as = yield findSomeone(persion);
  const dbz = yield findSomeone(as);
  const chn = yield findSomeone(dbz);
  const lqy = yield findSomeone(chn);
  return lqy;
}

const lqy = runGen(fiondIdol('dq'));

console.log('找到你了,lyq');

async/await 方案

进步的脚步没有因此停下,虽然generator写出来的代码已经很接近异步了,但毕竟是需要手动调用才能执行的。因此,在ECMAScript7中又引入了异步的最终方案async/await。我们很容易用它编写出更好的代码:

const a = async () => {
  
	const s = await new Promise((resolve, reject) => {
  		setTimeout(() => {
      	resolve('done')
      })
  }, 1000);
  
  // 1s later
  console.log(s)// 'done'
}

设计思想

async/await 实现了在特定的作用域函数内部用同步的方式编写异步执行程序。它最大的特点就是采用隐式promise这个中间状态机器方式来实现链式调用。所谓隐式返回promise,就是无论你函数返回值是什么,都会强制将其作为一个promise的对象抛出值。我们可以用普通函数做类比,一个普通函数如果没有return关键字,那么它也会返回一个隐示的undefined。用async标记的函数也一样,如果没有return关键字,会返回一个Promise {: undefined}。​

const normal = (() => { });
console.log(normal) // undefined

const _async = (async () => { return false})();
console.log(_async)// Promise {<fulfilled>: false}

我们可以返回任何类型的值出去:一个数字,字符串或者干脆就是undefined,我们同时也可以也可以返回一个await表达式(因为await表达式隐示创建的就是promise,所以相当于我们直接返回了promise对象)。不同之处在于返回字符串或者数字的时候promise的状态是fulfilled,而返回await表达式或者promise的时候要依据这个函数体内最后一个Promise的状态的具体状态而定。正是因为有了这种promise的创建、返回、传递,我们就可以把一个它们按照顺序的方式编写异步代码,并且实现无缝连接调用。

var PromiseA = Promise_B = new Promise((r) => {

    setTimeout(() => {
        r(1)
    }, 2000);

})

const async_A = async () => {
	const a = await PromiseA; // 等待PromiseA的状态改变,并且隐示返回Promise<fulfilled>
  return await Promise_B //返回的是一个Promise<pending>: 
}

const async_B = async() => {
	const b = await async_A();// 等待Promise的状态改变
    console.log(b) //回的是一个Promise<pending>: undefined
}

const p = async_B();
console.log(p)// Promise<pending>

p.then(res => {
	console.log(res);// undefined
  console.log(p)// Promise<fulfilled>
});

与generator的异同

  • 采用协程的原来去实现程序在一个线程上自动切换执行。
  • 都是采用同步的代码方式,来书写代码。使得代码变得简介易懂。
  • 最大的不同一个是需要迭代执行,另外一个是自己执行。
  • 前者返回值的方式更加灵活易懂,前者则有理解难度。
  • 前者async/await生产出来的是一个promise,后者则是一个键值对象。
  • 前者自己维护异步的状态,后者需要程序去实现。后者更像一个迭代器。

我们可以看到async方法写出来的代码基本上就是同步代码了。相信这样的代码一定会领你满意。如果你用过puppter这样的需要大量异步操作的自动测试框架,那么你会觉得async/await函数的出现,简直就是来拯救你的福音。老样子,我们用async/await函数来寻找刘青云:

// final version with async/await
const findIdol = async (person) => {
	const as = await findSomeone(person);
  const dbz = await findSomeone(as);
  const chn = await findSomeone(dbz);
  const lqy = await findSomeone(chn);
  console.log('找到你了,lyq');
}

findIdol('dq');

总结

以上便是目前为止JavaScript在异步编程风格方面经历过的几个阶段。JavaScript是一门高级语言,它发明出来理应遵循高级语言的一些特性,比如利用更少的代码编写程序,屏蔽底层操作,以及我们今天提到的:写起来更符合人类(至少是程序员)的直觉。从最初的设计技巧,到最终的标准实现,我们也越来越能用简单的方式来实现以前需要用大量代码去实现的特性了。

参考文档

  1. Working with Promises
  2. Writing Promise-Using Specifications
posted on 2021-10-31 13:53  chen·yan  阅读(516)  评论(0编辑  收藏  举报