最近在学习koa的使用, 由于koa是相当基础的web框架,所以一个完整的web应用所需要的东西大都以中间件的形式引入,比如koa-router, koa-view等
异步建议结合上一篇:任务队列一起看
往事回顾谈异步 基本概念 所谓”异步”,简单说就是一个任务不是连续完成的,可以理解成该任务被人为分成两段,先执行第一段,然后转而执行其他任务,等做好了准备,再回过头执行第二段。
比如,有一个任务是读取文件进行处理,任务的第一段是向操作系统发出请求,要求读取文件。然后,程序执行其他任务,等到操作系统返回文件,再接着执行任务的第二段(处理文件)。这种不连续的执行,就叫做异步。
相应地,连续的执行就叫做同步。由于是连续执行,不能插入其他任务,所以操作系统从硬盘读取文件的这段时间,程序只能干等着。可以理解成阻塞了,而如果我们通过异步就可以跳到下一个块去执行任务,等执行完返回阻塞块,好像更加迷糊了😅
进阶理解 如果在函数返回结果的时候,调用者能够拿到预期的结果(就是函数计算的结果),那么这个函数就是同步的.
如果函数是同步的,即使调用函数执行任务比较耗时,也会一致等待直到得到执行结果。如下面的代码:
1 2 3 4 5 6 7 function wait ( ) { var time = (new Date ()).getTime(); while ((new Date ()).getTime() - time < 5000 ){} console .log('5秒过去了' ); } wait(); console .log('慢死了' );
上面代码中,函数wait是一个耗时程序,持续5秒,在它执行的这漫长的5秒中,下面的console.log()函数只能等待,这就是同步。
如果在函数返回的时候,调用者还不能购得到预期结果,而是将来通过一定的手段得到(例如回调函数),这就是异步。例如ajax操作。 如果函数是异步 的,发出调用之后,马上返回 ,但是不会马上返回预期结果。调用者不必主动等待,当被调用者得到结果之后会通过回调函数主动通知调用者。
下面以AJAX请求为例,来看一下同步和异步的区别:
主线程: “你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
AJAX线程: “好的,主线程。我马上去发,但可能要花点儿时间呢,你可以先去忙别的。”
主线程: :“谢谢,你拿到响应后告诉我一声啊。”
(接着,主线程做其他事情去了。一顿饭的时间后,它收到了响应到达的通知。)
主线程: “你好,AJAX线程。请你帮我发个HTTP请求吧,我把请求地址和参数都给你了。”
AJAX线程: “……”
主线程: :“喂,AJAX线程,你怎么不说话?”
AJAX线程: “……”
主线程: :“喂!喂喂喂!”
AJAX线程: “……”
(一炷香的时间后)
主线程: :“喂!求你说句话吧!”
AJAX线程: “主线程,不好意思,我在工作的时候不能说话。你的请求已经发完了,拿到响应数据了,给你。”
了解完同步和异步之后,我们再来看看我们的问题:单线程又怎么会有异步呢? JavaScript其实就是一门语言,说是单线程还是多线程得结合具体运行环境。众所周知,js的运行环境就是浏览器,具体由js引擎取解析和执行。下面我们来了解下浏览器。
消息队列与事件循环 一个浏览器通常由以下几个常驻的线程:
渲染引擎线程,负责页面的渲染
js引擎线程,负责js的解析和执行
定时触发器线程,处理setInterval和setTimeout
事件触发线程,处理DOM事件
异步http请求线程,处理http请求
故浏览器中多个线程的合作完成了异步的操作,那么异步的回调函数又是怎样完成执行的呢?
如上图所示,左边的栈存储的是同步任务,就是那些能立即执行、不耗时的任务,如变量和函数的初始化、事件的绑定等等那些不需要回调函数的操作都可归为这一类。
右边的堆用来存储声明的变量、对象。下面的队列就是消息队列,一旦某个异步任务有了响应就会被推入队列中。如用户的点击事件、浏览器收到服务的响应和setTimeout中待执行的事件,每个异步任务都和回调函数相关联。
JS引擎线程用来执行栈中的同步任务,当所有同步任务执行完毕后,栈被清空,然后读取消息队列中的一个待处理任务,并把相关回调函数压入栈中,单线程开始执行新的同步任务。
JS引擎线程从消息队列中读取任务是不断循环的,每次栈被清空后,都会在消息队列中读取新的任务,如果没有新的任务,就会等待,直到有新的任务,这就叫事件循环。
面试题实战 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 setTimeout(function ( ) { for (var i = 0 ; i < 100000000 ; i++){} console .log('timer a' ); }, 0 ) for (var j = 0 ; j < 5 ; j++){ console .log(j); } setTimeout(function ( ) { console .log('timer b' ); }, 0 ) function waitFiveSeconds ( ) { var now = (new Date ()).getTime(); while (((new Date ()).getTime() - now) < 5000 ){} console .log('finished waiting' ); } document .addEventListener('click' , function ( ) { console .log('click' ); }) console .log('click begin' );waitFiveSeconds();
要想了解上述代码的输出结果,首先介绍下定时器。
setTimeout 的作用是在间隔一定的时间后,将回调函数插入消息队列中,等栈中的同步任务都执行完毕后,再执行。因为栈中的同步任务也会耗时, 所以间隔的时间一般会大于等于指定的时间 。
setTimeout(fn, 0) 的意思是,将回调函数fn立刻插入消息队列,等待执行,而不是立即执行。看一个例子:
1 2 3 4 5 6 7 8 setTimeout (function() { console.log("a" ) }, 0 ) for (let i=0 ; i<10000 ; i++) {}console.log("b" ) b a
下面来解释一下面试题吧。
先执行同步任务,for循环,然后是console.log(‘click begin’) 最后是waitFiveSeconds函数
在同步任务执行的期间,‘timera’,‘timerb’对应的回调和click事件的回调先后入队列。
同步任务结束后,js引擎线程空闲后会线查看是否有事件可执行,接着在处理其他异步任务,因此会有下面的输出:
1 2 3 4 5 6 7 8 9 10 11 0 1 2 3 4 click begin finished waiting 2 click timer atimer b2 click
ES6 诞生以前,异步编程的方法,大概有下面四种。
回调函数
事件监听
发布/订阅
Promise 对象
而ES6引入了generator跟async函数
回调函数 JavaScript 语言对异步编程的实现,就是回调函数。所谓回调函数,就是把任务的第二段单独写在一个函数里面,等到重新执行这个任务的时候,就直接调用这个函数。回调函数的英语名字callback
,直译过来就是”重新调用”。
读取文件进行处理,是这样写的。
1 2 3 4 fs.readFile('/etc/passwd' , 'utf-8' , function (err, data ) { if (err) throw err; console .log(data); });
上面代码中,readFile
函数的第三个参数,就是回调函数,也就是任务的第二段。等到操作系统返回了/etc/passwd
这个文件以后,回调函数才会执行。
一个有趣的问题是,为什么 Node 约定,回调函数的第一个参数,必须是错误对象err
(如果没有错误,该参数就是null
)?
原因是执行分成两段,第一段执行完以后,任务所在的上下文环境就已经结束了。在这以后抛出的错误,原来的上下文环境已经无法捕捉,只能当作参数,传入第二段。
Promise 回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A
文件之后,再读取B
文件,代码如下。
1 2 3 4 5 fs.readFile(fileA, 'utf-8' , function (err, data ) { fs.readFile(fileB, 'utf-8' , function (err, data ) { }); });
不难想象,如果依次读取两个以上的文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。因为多个异步操作形成了强耦合,只要有一个操作需要修改,它的上层回调函数和下层回调函数,可能都要跟着修改。这种情况就称为”回调函数地狱”(callback hell)。
Promise 对象就是为了解决这个问题而提出的。它不是新的语法功能,而是一种新的写法,允许将回调函数的嵌套,改成链式调用。采用 Promise,连续读取多个文件,写法如下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var readFile = require ('fs-readfile-promise' );readFile(fileA) .then(function (data ) { console .log(data.toString()); }) .then(function ( ) { return readFile(fileB); }) .then(function (data ) { console .log(data.toString()); }) .catch(function (err ) { console .log(err); });
上面代码中,我使用了fs-readfile-promise
模块,它的作用就是返回一个 Promise 版本的readFile
函数。Promise 提供then
方法加载回调函数,catch
方法捕捉执行过程中抛出的错误。
可以看到,Promise 的写法只是回调函数的改进,使用then
方法以后,异步任务的两段执行看得更清楚了,除此以外,并无新意。
Promise 的最大问题是代码冗余,原来的任务被 Promise 包装了一下,不管什么操作,一眼看去都是一堆 then
,原来的语义变得很不清楚。
那么,有没有更好的写法呢?
generator 从原生到promise到generator,我们来看三段代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 let url1 = 'http://xxx.xxx.1' ;let url2 = 'http://xxx.xxx.2' ;let url3 = 'http://xxx.xxx.3' ;$.ajax({ url:url1, error:function (error ) {}, success:function (data1 ) { console .log(data1); $.ajax({ url:url2, data:data1, error:function (error ) {}, success:function (data2 ) { console .log(data2); $.ajax({ url:url3, data, error:function (error ) {}, success:function (data3 ) { console .log(data3); } }); } }); } });
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function request (url,data = {} ) { return new Promise ((resolve,reject )=> { $.ajax({ url, data, success:function (data ) { resolve(data); }, error:function (error ) { reject(error); } }) }); } let url1 = 'http://xxx.xxx.1' ;let url2 = 'http://xxx.xxx.2' ;let url3 = 'http://xxx.xxx.3' ;request(url1) .then((data )=> { console .log(data); return request(url2,data) }) .then((data )=> { console .log(data); return request(url3,data) }) .then((data )=> { console .log(data) }) .catch((error )=> { console .log(error); });
发现每次调用下一个块,比如申请完url1,申请url2都要return一层,略显麻烦(其实我并不怎么觉得)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 var fs = require ('fs' );var readFile = function (fileName ) { return new Promise (function (resolve, reject ) { fs.readFile(fileName, function (error, data ) { if (error) return reject(error); resolve(data); }); }); }; var gen = function * ( ) { var f1 = yield readFile('/etc/fstab' ); var f2 = yield readFile('/etc/shells' ); console .log(f1.toString()); console .log(f2.toString()); }; var g = gen();g.next().value.then(function (data ) { g.next(data).value.then(function (data ) { g.next(data); }); });
通过yield把调用顺序定制好,接下来只需要靠next()来走流程即可
传统的编程语言,早有异步编程的解决方案(其实是多任务的解决方案)。其中有一种叫做”协程”(coroutine),意思是多个线程互相协作,完成异步任务。
协程有点像函数,又有点像线程。它的运行流程大致如下。
第一步,协程A
开始执行。
第二步,协程A
执行到一半,进入暂停,执行权转移到协程B
。
第三步,(一段时间后)协程B
交还执行权。
第四步,协程A
恢复执行。
这即是中间件的思路
上面流程的协程A
,就是异步任务,因为它分成两段(或多段)执行。
举例来说,读取文件的协程写法如下。
1 2 3 4 5 function * asyncJob ( ) { var f = yield readFile(fileA); }
上面代码的函数asyncJob
是一个协程,它的奥妙就在其中的yield
命令。它表示执行到此处,执行权将交给其他协程。也就是说,yield
命令是异步两个阶段的分界线。
协程遇到yield
命令就暂停,等到执行权返回,再从暂停的地方继续往后执行。它的最大优点,就是代码的写法非常像同步操作,如果去除yield
命令,简直一模一样。
开门见山谈使用 身边逐渐都在使用Koa而淘汰Express,express有很多中间件,所以使用express的人很多。
express基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 var express = require ('express' )var app = express()var mid = function (req,res,next ) { req.body='mark' next() res.send(req.body + 'done' ) } app.use(mid) app.use(function (req,res,next ) { req.body='saved' next(); }) app.listen(3000 )
// 当一个http请求进来后,首先会进入到mid这个中间件来,然后对body设置一个初始的字符串,然后调用next(),就会进入到下一个代码的单元,也就是新的一个use,就是saved那里,再调用next(),这是没有其他代码单元,就会回到mid里面去,去执行send,所以等代码运行结束后,我们在浏览器收到的内容为:mark saved done
以上即是express中间件的一个形态
express基本使用-异步 如若我需要在中间件内加入异步代码:比如查一段数据,对远端api的调用,我们这里设置一个延时的函数,只要它是一个异步的任务,我们利用callback的形式,来控制中间件的控制权。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 var express = require ('express' )var app = express()var asyncIO = function (cb) { setTimeout(function () { cb() },500 ) } var mid = function (req,res,next) { req.body='mark' next() res.send(req.body + 'done' ) } app.use (mid) app.use (function (req,res,next) { asyncIO(function () { req.body='saved' next(); }) }) app.listen(3000 )
如果异步的代码多了,尤其是并发的异步,层层的回调会让测试跟维护代码很复杂,特别是异常处理,都交给了下一层不透明,所以express的异步回调机制有点靠不住,再加上js越来越强,所以koa就横空出世 了,那么它的中间件怎么来实现呢?我们传进去的function不再是普通function,而是一个generator函数
koa基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 var koa = require ('koa' )var app = koa();var asyncIO = function ( ) { return new Promise (function (resolve ) { setTimeout(function ( ) { resolve() },500 ) }) } var mid = function ( ) { return function *(next ) { this .body = 'mark' yeild next this .body += 'done' } } app.listen(3000 ) app.use(mid) app.use(function *(next ) { yeild asyncIO() this .body += 'saved' yield next })
结果跟express差不多,但是至少没有了回调的过程,扁平化了
koa2基本使用 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const Koa = require ('koa' )const app = new Koa() const asyncIO = () => { return new Promise (resolve => setTimeout(resolve,500 )) } const mid = () => async (ctx,next) => { ctx.body = 'mark' await next() ctx.body = ctx.body + 'done' } app.use(mid()) app.use(async (ctx,next)=> { await asyncIO() ctx.body += 'saved' await next() } ) app.listen(3000 )
express 提供了next模式,可以在中间件做异步操作,但是仅限于异步完成后通知下一个中间件。而该中间件本身是无法执行异步完成之后的逻辑,next仅仅是一个同步函数,,执行完就结束了。koa用到了async await ,所有中间件本身的异步逻辑完成之后还可以完成后该中间件的后置逻辑,一次请求会往返该中间件两次,很多逻辑就会只变得异常简单,代码易于维护。、 异常处理在express 中其实挺坑爹的,但是koa中异常处理非常简单。不管是同步还是异步,用promise包装之后都能被捕获。
回归正题谈区别 异步流程控制 Express 采用 callback 来处理异步,Koa v1 采用 generator,Koa v2 采用 async/await。 下面分别对 js 当中 callback、promise、generator、async/await 这四种异步流程控制进行了对比, generator 和 async/await 使用同步的写法来处理异步,明显好于 callback 和 promise,async/await 在语义化上又要比 generator 更强。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 var api1 = 'https://anapioficeandfire.com/api/characters/583' var api2 = 'https://anapioficeandfire.com/api/characters/584' function fetchData ( ) { $.ajax({ type: 'GET' , url: api1, dataType: 'json' , success: function (data1 ) { $.ajax({ type: 'GET' , url: api2, dataType: 'json' , success: function (data2 ) { console .log(`${data1.name} and ${data2.name} are two characters in Game of Thrones` ) } }) } }) } fetchData()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var api1 = 'https://anapioficeandfire.com/api/characters/583' var api2 = 'https://anapioficeandfire.com/api/characters/584' function fetchData ( ) { fetch(api1).then(res1 => { res1.json().then(data1 => { fetch(api2).then(res2 => { res2.json().then(data2 => console .log(`${data1.name} and ${data2.name} are two characters in Game of Thrones` )) }) }) }) } fetchData()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 var api1 = 'https://anapioficeandfire.com/api/characters/583' var api2 = 'https://anapioficeandfire.com/api/characters/584' function *fetchData ( ) { var name1 = yield request(api1) var name2 = yield request(api2) console .log(`${name1} and ${name2} are two characters in Game of Thrones` ) } function request (url ) { fetch(url).then(res => res.json()).then(data => it.next(data.name)) } var it = fetchData()it.next()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 var api1 = 'https://anapioficeandfire.com/api/characters/583' var api2 = 'https://anapioficeandfire.com/api/characters/584' async function fetchData ( ) { var name1 = await request(api1) var name2 = await request(api2) console .log(`${name1} and ${name2} are two characters in Game of Thrones` ) } function request (url ) { return fetch(url).then(res => res.json()).then(data => data.name) } fetchData()
错误处理 Express 使用 callback 捕获异常,对于深层次的异常捕获不了, Koa 使用 try catch,能更好地解决异常捕获。
1 2 3 4 5 app .use (function (err , req, res, next) { console.error (err .stack ) res.status(500).send('Something broke!') })
1 2 3 4 5 6 7 8 9 10 app.use(function *(next ) { try { yield next } catch (err) { this .status = err.status || 500 this .body = { message : err.message } this .app.emit('error' , err, this ) } })
1 2 3 4 5 6 7 8 9 10 app .use (async (ctx, next) => { try { await next() } catch (err ) { ctx.status = err .status || 500 ctx.body = { message: err .message } ctx.app .emit('error ', err , this) } })
Hello World 两者创建一个基础的 Web 服务都非常简单,写法也基本相同,最大的区别是路由处理 Express 是自身集成的,而 Koa 需要引入中间件。
1 2 3 4 5 6 7 8 9 var express = require ('express' )var app = express()app.get('/' , function (req, res ) { res.send('Hello World!' ) }) app.listen(3000 )
1 2 3 4 5 6 7 8 9 10 11 var koa = require ('koa' )var route = require ('koa-route' )var app = koa()app.use(route.get('/' , function *( ) { this .body = 'Hello World' })) app.listen(3000 )
Views Express 自身集成了视图功能,提供了 consolidate.js 功能,支持几乎所有 JavaScript 模板引擎,并提供了视图设置的便利方法。 Koa 需要引入 co-views 中间件。
1 2 3 4 5 6 7 8 9 10 11 12 var express = require('express' )var app = express()app.set ('views' , __dirname + '/views' ) app.set ('view engine' , 'jade' ) app.get ('/' , function (req, res) { res.render('index' , { title: 'bilibili' }) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 var koa = require ('koa' )var route = require ('koa-route' )var views = require ('co-views' )var render = views(__dirname + '/views' , { default : "jade" }) var app = koa()app.use (route.get('/' , function *() { this.body = yield render('index' , { title: 'bilibili' }) }))
HTTP Request 两个框架都封装了HTTP Request对象,有一点不同是 Koa v1 使用 this 取代 Express 的 req、res。
1 2 3 4 5 6 7 8 9 10 11 12 13 var app = require ('express' )()app.get('/room/:id' , function (req, res ) { console .log(req.params) }) var bodyParser = require ('body-parser' )app.use(bodyParser.json()) app.post('/sendgift' , function (req, res ) { console .log(req.body) })
1 2 3 4 5 6 7 8 9 10 11 12 13 14 var app = require ('koa' )()var route = require ('koa-route' )app.use(route.get('/room/:id' , function *( ) { console .log(this .req.query) })) var parse = require ('co-body' )app.use(route.post('/sendgift' , function *( ) { var post = yield parse(this .request) console .log(post) }))
总结 Express 优点:线性逻辑,通过中间件形式把业务逻辑细分、简化,一个请求进来经过一系列中间件处理后再响应给用户,清晰明了。 缺点:基于 callback 组合业务逻辑,业务逻辑复杂时嵌套过多,异常捕获困难。
Koa 优点:首先,借助 co 和 generator,很好地解决了异步流程控制和异常捕获问题。其次,Koa 把 Express 中内置的 router、view 等功能都移除了,使得框架本身更轻量。 缺点:社区相对较小。