- 可能是目前最全的koa源码解析指南
本文将按照以下顺序讲解koa,通过初读到精读的方式,一步一步讲解koa涉及的相关知识。 通过阅读完本文,你将了解以下内容: koa框架核心 类继承在koa中的应用 co的实现原理,是如何将generator转为async函数的 洋葱模型中间件实现原理 koa的统一错误处理机制 委托模式在koa中的应用 一、koa是什么 koa是一个精简的node框架,它主要做了以下事情: 基于node原生req和res为request和response对象赋能,并基于它们封装成一个context对象。 基于async/await(generator)的中间件洋葱模型机制。 koa1和koa2在源码上的区别主要是于对异步中间件的支持方式的不同。 koa1是使用generator、yield)的模式。 koa2使用的是async/await+Promise的模式。下文主要是针对koa2版本源码上的讲解。 二、初读koa源码 如果你看了koa的源码,会发现koa源码其实很简单,共4个文件。 [代码]── lib ├── application.js ├── context.js ├── request.js └── response.js [代码] 这4个文件其实也对应了koa的4个对象: [代码]── lib ├── new Koa() || ctx.app ├── ctx ├── ctx.req || ctx.request └── ctx.res || ctx.response [代码] 下面,我们先初步了解koa的源码内容,读懂它们,可以对koa有一个初步的了解。 2.1 application.js application.js是koa的入口(从koa文件夹下的package.json的main字段(lib/application.js)中可以得知此文件是入口文件),也是核心部分。 下面对核心代码进行了注释。 [代码]/** * 依赖模块,包括但不止于下面的,只列出核心需要关注的内容 */ const response = require('./response'); const compose = require('koa-compose'); const context = require('./context'); const request = require('./request'); const Emitter = require('events'); const convert = require('koa-convert'); /** * 继承Emitter,很重要,说明Application有异步事件的处理能力 */ module.exports = class Application extends Emitter { constructor() { super(); this.middleware = []; // 该数组存放所有通过use函数的引入的中间件函数 this.subdomainOffset = 2; // 需要忽略的域名个数 this.env = process.env.NODE_ENV || 'development'; // 通过context.js、request.js、response.js创建对应的context、request、response。为什么用Object.create下面会讲解 this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); } // 创建服务器 listen(...args) { debug('listen'); const server = http.createServer(this.callback()); //this.callback()是需要重点关注的部分,其实对应了http.createServer的参数(req, res)=> {} return server.listen(...args); } /* 通过调用koa应用实例的use函数,形如: app.use(async (ctx, next) => { await next(); }); 来加入中间件 */ use(fn) { if (isGeneratorFunction(fn)) { fn = convert(fn); // 兼容koa1的generator写法,下文会讲解转换原理 } this.middleware.push(fn); // 将传入的函数存放到middleware数组中 return this; } // 返回一个类似(req, res) => {}的函数,该函数会作为参数传递给上文的listen函数中的http.createServer函数,作为请求处理的函数 callback() { // 将所有传入use的函数通过koa-compose组合一下 const fn = compose(this.middleware); const handleRequest = (req, res) => { // 基于req、res封装出更强大的ctx,下文会详细讲解 const ctx = this.createContext(req, res); // 调用app实例上的handleRequest,注意区分本函数handleRequest return this.handleRequest(ctx, fn); }; return handleRequest; } // 处理请求 handleRequest(ctx, fnMiddleware) { // 省略,见下文 } // 基于req、res封装出更强大的ctx createContext(req, res) { // 省略,见下文 } }; [代码] 从上面代码中,我们可以总结出application.js核心其实处理了这4个事情: 1. 启动框架 2. 实现洋葱模型中间件机制 3. 封装高内聚的context 4. 实现异步函数的统一错误处理机制 2.2 context.js [代码]const util = require('util'); const createError = require('http-errors'); const httpAssert = require('http-assert'); const delegate = require('delegates'); const proto = module.exports = { // 省略了一些不甚重要的函数 onerror(err) { // 触发application实例的error事件 this.app.emit('error', err, this); }, }; /* 在application.createContext函数中, 被创建的context对象会挂载基于request.js实现的request对象和基于response.js实现的response对象。 下面2个delegate的作用是让context对象代理request和response的部分属性和方法 */ delegate(proto, 'response') .method('attachment') ... .access('status') ... .getter('writable') ...; delegate(proto, 'request') .method('acceptsLanguages') ... .access('querystring') ... .getter('origin') ...; [代码] 从上面代码中,我们可以总结出context.js核心其实处理了这2个事情: 1. 错误事件处理 2. 代理response对象和request对象的部分属性和方法 2.3 request.js [代码]module.exports = { // 在application.js的createContext函数中,会把node原生的req作为request对象(即request.js封装的对象)的属性 // request对象会基于req封装很多便利的属性和方法 get header() { return this.req.headers; }, set header(val) { this.req.headers = val; }, // 省略了大量类似的工具属性和方法 }; [代码] request对象基于node原生req封装了一系列便利属性和方法,供处理请求时调用。 所以当你访问ctx.request.xxx的时候,实际上是在访问request对象上的赋值器(setter)和取值器(getter)。 2.4 response.js [代码]module.exports = { // 在application.js的createContext函数中,会把node原生的res作为response对象(即response.js封装的对象)的属性 // response对象与request对象类似,基于res封装了一系列便利的属性和方法 get body() { return this._body; }, set body(val) { // 支持string if ('string' == typeof val) { } // 支持buffer if (Buffer.isBuffer(val)) { } // 支持stream if ('function' == typeof val.pipe) { } // 支持json this.remove('Content-Length'); this.type = 'json'; }, } [代码] response对象与request对象类似,就不再赘述。 值得注意的是,返回的body支持Buffer、Stream、String以及最常见的json,如上示例所示。 三、深入理解koa源码 通过上面的阅读,相信对koa有了一个初步认识,但毕竟是走马观花,本着追根问底的学术精神,还需要对大量细节进行揣摩,下文会从初始化、启动应用、处理请求等的角度,来对这过程中比较重要的细节进行讲解及延伸,如果彻底弄懂,会对koa以及ES6、generator、async/await、co、异步中间件等有更深一步的了解。 3.1 初始化 koa实例化: [代码]const Koa = require('koa'); const app = new Koa(); [代码] koa执行源码: [代码]module.exports = class Application extends Emitter { constructor() { super(); this.proxy = false; this.middleware = []; this.subdomainOffset = 2; this.env = process.env.NODE_ENV || 'development'; this.context = Object.create(context); //为什么要使用Object.create? 见下面原因 this.request = Object.create(request); this.response = Object.create(response); if (util.inspect.custom) { this[util.inspect.custom] = this.inspect; } } } [代码] 当实例化koa的时候,koa做了以下2件事: 继承Emitter,具备处理异步事件的能力。然而koa是如何处理,现在还不得而知,这里打个问号。 在创建实例过程中,有三个对象作为实例的属性被初始化,分别是context、request、response。还有我们熟悉的存放中间件的数组mddleware。这里需要注意,是使用Object.create(xxx)对this.xxx进行赋值。 Object.create(xxx)作用: 根据xxx创建一个新对象,并且将xxx的属性和方法作为新的对象的proto。 举个例子,代码在demo02: [代码]const a = { name: 'rose', getName: function(){ return 'rose' } }; const b = Object.create(a); console.log('a is ', a); console.log('b is ', b); [代码] 结果如下: [图片] 可以看到,a的属性和方法已经挂载在b的原型(proto)下了。 所以,当执行完 [代码]this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); [代码] 的时候,以context为例,其实是创建一个新对象,使用context对象来提供新创建对象的proto,并且将这个对象赋值给this.context,实现了类继承的作用。为什么不直接用this.context=context呢?我的理解是,这样会导致两者指向同一片内存,而不是实现继承的目的。 3.2 启动应用及处理请求 在实例化koa之后,接下来,使用app.use传入中间件函数, [代码]app.use(async (ctx,next) => { await next(); }); [代码] koa对应执行源码: [代码] use(fn) { if (isGeneratorFunction(fn)) { fn = convert(fn); } this.middleware.push(fn); return this; } [代码] 当我们执行app.use的时候,koa做了这2件事情: 判断是否是generator函数,如果是,使用koa-convert做转换(koa3将不再支持generator)。 所有传入use的方法,会被push到middleware中。 这里做下延伸讲解,如何将generator函数转为类async函数。 如何将generator函数转为类async函数 koa2处于对koa1版本的兼容,中间件函数如果是generator函数的话,会使用koa-convert进行转换为“类async函数”。(不过到第三个版本,该兼容会取消)。 那么究竟是怎么转换的呢? 我们先来想想generator和async有什么区别? 唯一的区别就是async会自动执行,而generator每次都要调用next函数。 所以问题变为,如何让generator自动执行next函数? 回忆一下generator的知识:每次执行generator的next函数时,它会返回一个对象: [代码]{ value: xxx, done: false } [代码] 返回这个对象后,如果能再次执行next,就可以达到自动执行的目的了。 看下面的例子: [代码]function * gen(){ yield new Promise((resolve,reject){ //异步函数1 if(成功){ resolve() }else{ reject(); } }); yield new Promise((resolve,reject){ //异步函数2 if(成功){ resolve() }else{ reject(); } }) } let g = gen(); let ret = g.next(); [代码] 此时ret = { value: Promise实例; done: false};value已经拿到了Promise对象,那就可以自己定义成功/失败的回调函数了。如: [代码]ret.value.then(()=>{ g.next(); }) [代码] 现在就大功告成啦。我们只要找到一个合适的方法让g.next()一直持续下去就可以自动执行了。 所以问题的关键在于yield的value必须是一个Promise。那么我们来看看co是如何把这些都东西都转化为Promise的: [代码]function co(gen) { var ctx = this; // 把上下文转换为当前调用co的对象 var args = slice.call(arguments, 1) // 获取参数 // we wrap everything in a promise to avoid promise chaining, // 不管你的gen是什么,都先用Promise包裹起来 return new Promise(function(resolve, reject) { // 如果gen是函数,则修改gen的this为co中的this对象并执行gen if (typeof gen === 'function') gen = gen.apply(ctx, args); // 因为执行了gen,所以gen现在是一个有next和value的对象,如果gen不存在、或者不是函数则直接返回gen if (!gen || typeof gen.next !== 'function') return resolve(gen); // 执行类似上面示例g.next()的代码 onFulfilled(); function onFulfilled(res) { var ret; try { ret = gen.next(res); // 执行每一个gen.next() } catch (e) { return reject(e); } next(ret); //把执行得到的返回值传入到next函数中,next函数是自动执行的关键 } function onRejected(err) { var ret; try { ret = gen.throw(err); } catch (e) { return reject(e); } next(ret); } /** * Get the next value in the generator, * return a promise. */ function next(ret) { // 如果ret.done=true说明迭代已经完毕,返回最后一次迭代的value if (ret.done) return resolve(ret.value); // 无论ret.value是什么,都转换为Promise,并且把上下文指向ctx var value = toPromise.call(ctx, ret.value); // 如果value是一个Promise,则继续在then中调用onFulfilled。相当于从头开始!! if (value && isPromise(value)) return value.then(onFulfilled, onRejected); return onRejected(new TypeError('You may only yield a function, promise, generator, array, or object, ' + 'but the following object was passed: "' + String(ret.value) + '"')); } }); } [代码] 请留意上面代码的注释。 从上面代码可以得到这样的结论,co的思想其实就是: 把一个generator封装在一个Promise对象中,然后再这个Promise对象中再次把它的gen.next()也封装出Promise对象,相当于这个子Promise对象完成的时候也重复调用gen.next()。当所有迭代完成时,把父Promise对象resolve掉。这就成了一个类async函数了。 以上就是如何把generator函数转为类async的内容。 好啦,我们继续回来看koa的源码。 当执行完app.use时,服务还没启动,只有当执行到app.listen(3000)时,程序才真正启动。 koa源码: [代码]listen(...args) { const server = http.createServer(this.callback()); return server.listen(...args); } [代码] 这里使用了node原生http.createServer创建服务器,并把this.callback()作为参数传递进去。可以知道,this.callback()返回的一定是这种形式:(req, res) => {}。继续看下this.callback代码。 [代码]callback() { // compose处理所有中间件函数。洋葱模型实现核心 const fn = compose(this.middleware); // 每次请求执行函数(req, res) => {} const handleRequest = (req, res) => { // 基于req和res封装ctx const ctx = this.createContext(req, res); // 调用handleRequest处理请求 return this.handleRequest(ctx, fn); }; return handleRequest; } handleRequest(ctx, fnMiddleware) { const res = ctx.res; res.statusCode = 404; // 调用context.js的onerror函数 const onerror = err => ctx.onerror(err); // 处理响应内容 const handleResponse = () => respond(ctx); // 确保一个流在关闭、完成和报错时都会执行响应的回调函数 onFinished(res, onerror); // 中间件执行、统一错误处理机制的关键 return fnMiddleware(ctx).then(handleResponse).catch(onerror); } [代码] 从上面源码可以看到,有这几个细节很关键: 1. compose(this.middleware)做了什么事情(使用了koa-compose包)。 2. 如何实现洋葱式调用的? 3. context是如何处理的?createContext的作用是什么? 4. koa的统一错误处理机制是如何实现的? 下面,来进行一一讲解。 koa-compose和洋葱式调用 先看第一、二个问题。 看看koa-compose的精简源码: [代码]module.exports = compose function compose(middleware) { return function (context, next) { //略 } } [代码] compose函数接收middleware数组作为参数,middleware中每个对象都是async函数,返回一个以context和next作为入参的函数,我们跟源码一样,称其为fnMiddleware。 在外部调用this.handleRequest的最后一行,运行了中间件: [代码]fnMiddleware(ctx).then(handleResponse).catch(onerror); [代码] 我们来看下fnMiddleware究竟是怎么实现的: [代码]function compose (middleware) { return function (context, next) { let index = -1 return dispatch(0) function dispatch (i) { if (i <= index) return Promise.reject(new Error('next() called multiple times')) index = i let fn = middleware[i] if (i === middleware.length) fn = next if (!fn) return Promise.resolve() try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err) } } } } [代码] 解释之前,我们通过一个例子来理解,假设加入了两个中间件。源码在demo01: [代码]const Koa = require('koa'); const app = new Koa(); app.use(async (ctx,next) => { console.log("1-start"); await next(); console.log("1-end"); }); app.use(async (ctx, next) => { console.log("2-start"); await next(); console.log("2-end"); }); app.listen(3000); [代码] 我们逐步执行, 0:fnMiddleware(ctx)运行; 0:执行dispatch(0); 0:进入dispatch函数,下图是各个参数对应的值。 [图片] 从参数的值可以得知,最终会执行这段代码: [代码]return Promise.resolve(fn(context, function next() { return dispatch(i + 1) })) [代码] 此时,fn就是第一个中间件,它是一个async函数,async函数会返回一个Promise对象,Promise.resolve()中若传入一个Promise对象的话,那么Promise.resolve将原封不动地返回这个Promise对象。 0:进入到第一个中间件代码内部,先执行“console.log(“1-start”)” 0:然后执行“await next()”,并开始等待next执行返回 1:进入到next函数后,执行的是dispatch(1),于是老的dispatch(0)压栈,开始从头执行dispatch(1),即把第二个中间件函数交给fn,然后开始执行,这就完成了程序的控制权从第一个中间件到第二个中间件的转移。下图是执行dispatch(1)时函数内变量的值: [图片] 1:进入到第二个中间件代码内部,先执行“console.log(“2-start”)”。然后执行“await next()”并等待next执行返回 2:进入next函数后,主要执行dispatch(2),于是老的dispatch(1)压栈,从头开始执行dispatch(2)。下图是执行dispatch(2)时函数内变量的值: [图片] 所以返回Promise.resolve(),此时第二个中间件的next函数返回了。 2:所以接下来执行“console.log(“2-end”)” 1:由此第二个中间件执行完成,把程序控制权交给第一个中间件。第一个中间件执行“console.log(“1-end”)” 0:终于完成了所有中间件的执行,如果中间没有异常,则返回Promise.resolve(),执行handleResponse回调;如有异常,则返回Promies.reject(err),执行onerror回调。 建议用上面例子进行调试源码来理解,会更加清晰。 至此,回答了上面提到的2个问题: compose(this.middleware)做了什么事情(使用了koa-compose包)。 如何实现洋葱式调用的? 单一context原则 接下来,我们再来看第3个问题context是如何处理的?createContext的作用是什么? context使用node原生的http监听回调函数中的req、res来进一步封装,意味着对于每一个http请求,koa都会创建一个context并共享给所有的全局中间件使用,当所有的中间件执行完后,会将所有的数据统一交给res进行返回。所以,在每个中间件中我们才能取得req的数据进行处理,最后ctx再把要返回的body给res进行返回。 请记住句话:每一个请求都有唯一一个context对象,所有的关于请求和响应的东西都放在其里面。 下面来看context(即ctx)是怎么封装的: [代码]// 单一context原则 createContext(req, res) { const context = Object.create(this.context); // 创建一个对象,使之拥有context的原型方法,后面以此类推 const request = context.request = Object.create(this.request); const response = context.response = Object.create(this.response); context.app = request.app = response.app = this; context.req = request.req = response.req = req; context.res = request.res = response.res = res; request.ctx = response.ctx = context; request.response = response; response.request = request; context.originalUrl = request.originalUrl = req.url; context.state = {}; return context; } [代码] 本着一个请求一个context的原则,context必须作为一个临时对象存在,所有的东西都必须放进一个对象,因此,从上面源码可以看到,app、req、res属性就此诞生。 请留意以上代码,为什么app、req、res、ctx也存放在了request、和response对象中呢? 使它们同时共享一个app、req、res、ctx,是为了将处理职责进行转移,当用户访问时,只需要ctx就可以获取koa提供的所有数据和方法,而koa会继续将这些职责进行划分,比如request是进一步封装req的,response是进一步封装res的,这样职责得到了分散,降低了耦合度,同时共享所有资源使context具有高内聚的性质,内部元素互相能访问到。 在createContext中,还有这样一行代码: [代码]context.state = {}; [代码] 这里的state是专门负责保存单个请求状态的空对象,可以根据需要来管理内部内容。 异步函数的统一错误处理机制 接下来,我们再来看第四个问题:koa的统一错误处理机制是如何实现的? 回忆一下我们如何在koa中统一处理错误,只需要让koa实例监听onerror事件就可以了。则所有的中间件逻辑错误都会在这里被捕获并处理。如下所示: [代码]app.on('error', err => { log.error('server error', err) }); [代码] 这是怎么做到的呢?核心代码如下(在上面提到的application.js的handleRequest函数中): [代码]handleRequest(ctx, fnMiddleware) { const res = ctx.res; res.statusCode = 404; // application.js也有onerror函数,但这里使用了context的onerror, const onerror = err => ctx.onerror(err); const handleResponse = () => respond(ctx); onFinished(res, onerror); // 这里是中间件如果执行出错的话,都能执行到onerror的关键!!! return fnMiddleware(ctx).then(handleResponse).catch(onerror); } [代码] 这里其实会有2个疑问: 1.出错执行的回调函数是context.js的onerror函数,为什么在app上监听onerror事件,就能处理所有中间件的错误呢? 请看下context.js的onerror: [代码]onerror(err) { this.app.emit('error', err, this); } [代码] 这里的this.app是对application的引用,当context.js调用onerror时,其实是触发application实例的error事件 。该事件是基于“Application类继承自EventEmitter”这一事实。 2.如何做到集中处理所有中间件的错误? 我们再来回顾一下洋葱模型的中间件实现源码: [代码]function compose (middleware) { return function (context, next) { let index = -1 return dispatch(0) function dispatch (i) { if (i <= index) return Promise.reject(new Error('next() called multiple times')) index = i let fn = middleware[i] if (i === middleware.length) fn = next if (!fn) return Promise.resolve() try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err) } } } } [代码] 还有外部处理: [代码]// 这里是中间件如果执行出错的话,都能执行到onerror的关键!!! return fnMiddleware(ctx).then(handleResponse).catch(onerror); [代码] 主要涉及这几个知识点: async函数返回一个Promise对象 async函数内部抛出错误,会导致Promise对象变为reject状态。抛出的错误会被catch的回调函数(上面为onerror)捕获到。 await命令后面的Promise对象如果变为reject状态,reject的参数也可以被catch的回调函数(上面为onerror)捕获到。 这样就可以理解为什么koa能实现异步函数的统一错误处理了。 委托模式 最后讲一下koa中使用的设计模式——委托模式。 当我们在使用context对象时,往往会这样使用: ctx.header 获取请求头 ctx.method 获取请求方法 ctx.url 获取请求url 这些对请求参数的获取都得益于context.request的许多属性都被委托在context上了 [代码]delegate(proto, 'request') .method('acceptsLanguages') ... .access('method') ... .getter('URL') .getter('header') ...; [代码] 又比如, ctx.body 设置响应体 ctx.status 设置响应状态码 ctx.redirect() 请求重定向 这些对响应参数的设置都得益于koa中的context.response的许多方法都被委托在context对象上了: [代码]delegate(proto, 'response') .method('redirect') ... .access('status') .access('body') ...; [代码] 至于delegate的使用和源码就不展开了,有兴趣的同学可以看这里 以上就是对koa源码所涉及的所有知识点的解析啦,初次看可能会有点晕,建议结合源码和例子一起多看几次,就会领会到koa框架的简洁和优雅之美所在了~ 参考文章: https://koajs.com https://zhuanlan.zhihu.com/p/34797505 https://zhuanlan.zhihu.com/p/24559011 https://juejin.im/entry/59e747f0f265da431c6f668e https://www.jianshu.com/p/45ec555a6c83 https://juejin.im/post/5b9339136fb9a05d3634ba13 https://www.jianshu.com/p/feb98591a1e5
2019-03-14 - PWA 实践之路
注:本文需要有一定的 PWA 基础 1. 什么是 PWA? 要知道一个东西是什么,我们通常可以从它的名字入手 因此我们看下 PWA 的全称是: Progressive Web App 回答 what 这种问题,重点在于名词,因此 PWA 是一个 APP,一个独立的、增强的、Web 实现的 APP 要达到这样的目的,PWA 提供了一系列的技术 & 标准,如下图所示: [图片] 具体每一项技术是什么就不再赘述了,感兴趣的同学自行网上搜索! 下面有一个简单的 demo 可以简单体会一下: [图片] 以后我们的 web 站点可以像 app 一样,这难道不是一个令人兴奋的事情吗? 所以 PWA 是值得我们前端开发者一直关注的技术! 按照目前的兼容性和环境来看,大家应用最多的还是 Service Worker,因此接下来我们也是把重点放在 SW 上面 那什么是 Service Worker ? 大家都知道就不卖关子了,其实就是一个 Cache 说到 Cache,就一定会想到性能优化了,请看我们的第二部分 2. 首屏优化 2.1. 静态资源优化 如何利用 Cache 来进行优化?这个基本套路应该无人不知了: [图片] 那么首次加载怎么办呢?首次加载是没有缓存资源的,所以会走到线上,所以等于没有任何优化 答案就是 Cache 的第二种常用技巧: precache(预加载) 预加载的意思就是在某个地方或特定时机预先把需要用到的资源加载并缓存 我们的做法如下图所示: [图片] 构建的时候,把整个项目用到的资源输出到一个 list 中,然后 inline 到 sw.js 里面 当 sw install 时,就会把这个 list 的资源全部请求并进行缓存 这样做的结果就是,无论用户第一次进入到我们站点的哪个页面,我们都会把整个站点所有的资源都加载回来并缓存 当用户跳转另外一个页面的时候,Cache 里面就有相应的资源了! 这是我们辅导课堂页面接入 sw 之后的首屏优化效果: [图片] 2.2. 动态数据优化 除了静态资源之外,我们还能缓存其他的内容吗? 答案肯定是可以的,我们还可以缓存 cgi 数据! [图片] 缓存 cgi 数据的流程和缓存静态资源的流程主要有2个差别,上图标红的地方: 需要添加一个开关功能,因为不是所有 cgi 都需要缓存的! 页面需要展示最新的数据,因此在返回缓存结果之后,还需要请求线上最新的数据,更新缓存,并且返回给页面展示,也就是说页面需要展示2次 页面展示2次需要考虑页面跳动的体验问题,选择权在于业务本身! 这是我们辅导上课页接入该功能后的首屏优化效果: [图片] 动态数据缓存是否有意义还需要额外的逻辑来判断,这块暂时是没有做的,后续会补上相关统计 2.3. 直出html优化 还能缓存什么?我们想到了直出的 html 这里就不展开讲了,因为我们的 jax 同学已经分享了一篇优秀的文章《企鹅辅导课程详情页毫秒开的秘密 - PWA 直出》,可以去看看哈~ 3. 替代离线包 PWA 与离线包本质上是一样的,都是离线缓存 正好,我们 PC 客户端的离线包系统年久失修,在这个契机下,我们启动了使用 PWA 替换离线包的方案! 核心流程不变,基本和缓存静态资源的流程是一致的 但是离线包系统是非常成熟的系统,要完全替换掉它还需要考虑许多方面的问题。 3.1. 更新机制 离线包有个自动更新的机制,每隔一段时间就会去请求离线包管理系统是否有更新,有的话就把最新的离线包拉下来自动更新替换,这样只需要1次跳转就能展示最新的页面。 SW 没有自动更新的逻辑,它需要在页面加载(一次跳转)之后才会去请求 sw.js,判断有变化才会进行更新,更新完了要等到下一次页面跳转(二次跳转)才能展示最新的页面。 这里有两个方案: 参考离线包的更新机制,也给 SW 实现一个自动更新的逻辑,借用 update 接口是可以做到主动去执行 SW 更新的。但是非常遗憾,我们的客户端 webkit 内核版本太低,并不支持这个接口 在第一次跳转之后更新 sw,然后检测 sw 状态,发现如果有更新,就用一定的策略来进行页面的刷新 我们使用第2个方案,部分代码如下: [图片] 在检测到 sw 更新之后,我们可以选择强刷,或者提示用户手动刷新页面,具体实现页面可以通过监听事件来处理 更多详细的实现方案可以参考这篇文章哈:How to Fix the Refresh Button When Using Service Workers 3.2. 首次打开问题 一般离线包是打进 app 的安装包一起发布的,在用户下载安装之后,离线包就已经存在于本地,因此第一次打开就能享受到离线包的缓存。 但是 sw 没有这个能力,同样我们也有两个方案: 在 app 安装的时候,添加一步,通过创建 webview 加载页面,页面执行 SW 的初始化工作,并展示相应的进度提示,在安装完成后需要把 webview 的 SW Cache 底层文件 copy 到相应的安装位置。这个方案太复杂,衡量下来没有采用。 在 app 启动时,创建一个隐藏的 webview,加载空页面去加载 SW。 我们使用第2个方案,因为我们的 app 启动会先让用户登录,如下图所示: [图片] 注:这里 app 不是移动端 app,是 pc 的客户端 app 3.3. 屏蔽机制 有时候我们不想使用离线缓存能力,比如在我们开发的时候 在离线包系统,通常会有一个开发者选项是【屏蔽离线包】 SW 也是需要这种能力的,这个方案就比较简单了,在 sw.js 的逻辑里有一个全局的开关,当开关关闭时,就不会走缓存逻辑 因此,我们可以在 dev 环境下把开关关闭即可达到屏蔽的作用 [图片] 3.4. 降级方案 当我们发布了一个错误代码的时候,我们需要快速降级容错的能力 在离线包系统里面有个过期的功能,可以把某个版本设置过期,也就是废弃掉: [图片] 我们利用之前提到的全局开关,通过一个管理接口来设置开关的起开和关闭,即可达到快速降级的目的: [图片] 整个流程大致是这样: 发布了错误代码,并且用户本地 sw 已经更新缓存了错误代码 在管理端关闭使用缓存开关,让用户走线上 快速修复代码并发布,到这里页面就已经恢复正常 在管理端开启使用缓存开关,恢复 SW 功能 请求管理接口是轮询的,这里后续有计划会改成 push,这样会更加及时,当然还要详细评估方案之后才能落实。 4. 如何方便接入? 我们把上述功能集成到了一个 webpack 插件当中,在构建的时候就自动输出 sw.js 并把相关内容注入到 html 文件中,该插件正准备开源哈~ 5. 未来 未来对于 PWA 还能做些什么?笔者以为有以下 2 个方面 5.1. 业务深耕 目前通用的能力已经基本挖掘完成,但是 SW 因为它独特的特性,它能做的事情太多了,但是具体要不要这么做也是因业务而异,而且这些内容可能会很复杂,所以我称为业务深耕。 SW 什么特性?请看下面 2 张图就可以理解了 [图片] [图片] 这种架构相信已经能够看出来了,没错,SW 有间件(层)的特性,那它能做的东西就太多了(虽然 SW 是用户端本地中间层) 简单举几个例子: server 负载控制:当发现 server 端高负载时,SW 可以丢弃请求,或者缓存请求,合并/延迟发送。 预请求:SW 能预缓存的资源是可以构建出来的资源,但是我们还有许多资源是不能在构建阶段知道的,比如图片,第三方资源等,SW 在返回资源请求(比如HTML、cgi 数据)之后,可以扫描资源里面的内容,如果发现包含了其他资源的 url,那说明页面很有可能待会就会请求这些资源,这时 SW 可以提前去请求这些资源;再比如翻页的数据 缓存自动更新:通过与 server 建立联系,当数据有变化时,server 直接把最新的数据 push 到 SW 进行缓存的更新 5.2. 关注 PWA 回到最开始,PWA 是一项令人兴奋的技术,但是浏览器兼容有限,因此期待并关注 PWA 技术的发展是很有必要的! 当然,能推动就更好了!比如推动我们的 X5 内核尽快支持新特性。 关注我们 IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。 我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂 及 企鹅辅导 两大产品。 社区官网: http://imweb.io/ 加入我们: https://hr.tencent.com/position_detail.php?id=45616 [图片] 扫码关注 IMWeb前端社区 公众号,获取最新前端好文 微博、掘金、Github、知乎可搜索 IMWeb 或 IMWeb团队 关注我们。
2019-03-12 - 深入解析JS的异步机制
1. JavaScript定义 JavaScript 是一种单线程编程语言,这意味着同一时间只能完成一件事情。也就是说,JavaScript 引擎只能在单一线程中处理一次语句。 优点:单线程语言简化了代码编写,因为你不必担心并发问题,但这也意味着你无法在不阻塞主线程的情况下执行网络请求等长时间操作。 缺点:当从 API 中请求一些数据。根据情况,服务器可能需要一些时间来处理请求,同时阻塞主线程,让网页无法响应。 2. 异步运行机制 CallBack,setTimeOut,ajax 等都是通过**事件循环(event loop)**实现的。 2.1 什么是Event Loop? 主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。 2.2 流程整体示意图 [图片] 2.3 总结异步运行到整体机制 主线程在运行的时候,将产生堆(heap)和栈(stack),栈中的代码会调用各种外部API,它们将在"任务队列"中根据类型不同,分类加入到相关任务队列中,如各种事件等。只要栈中的代码执行完毕,主线程就会去读取"任务队列",根据任务队列的优先级依次执行那些事件所对应的回调函数。这就是整体的事件循环。 2.4 任务队列的优先级 微任务队列中的所有任务都将在宏队列中的任务之前执行。也就是说,事件循环将首先在执行宏队列中的任何回调之前清空微任务队列。 ** 举例: ** [代码] console.log('Script start'); setTimeout(() => { console.log("setTimeout 1"); }, 0); setTimeout(() => { console.log("setTimeout 2"); }, 0); new Promise ((resolve, reject) => { resolve("Promise 1 resolved"); }) .then(res => console.log(res)) .catch(err => console.log(err)); new Promise ((resolve, reject) => { resolve("Promise 2 resolved"); }) .then(res => console.log(res)) .catch(err => console.log(err)); console.log('Script end'); [代码] 运行结果是: Script start Script end Promise 1 Promise 2 setTimeout 1 setTimeout 2 通过上述例子可以看到无论宏队列的位置在何方,只要微队列尚未清空,一定会先清空微队列后,在去执行宏队列。下面介绍微队列任务中比较典型的几个API,通过相关举例,让你更深入理解JS的异步机制。 3. 微任务队列 3.1 Promise(ES6) Promise,就是一个对象,用来传递异步操作的消息。 3.1.1 基础用法: [代码] var promise = new Promise(function(resolve, reject) { //异步处理逻辑 //处理结束后,调用resolve返回正常内容或调用reject返回异常内容 }) promise.then(function(result){ //正常返回执行部分,result是resolve返回内容 }, function(err){ //异常返回执行部分,err是reject返回内容 }) .catch(function(reason){ //catch效果和写在then的第二个参数里面一样。另外一个作用:在执行resolve的回调时,如果抛出异常了(代码出错了),那么并不过报错卡死JS,而是会进入到这个catch方法中,所以一般用catch替代then的第二个参数 }); [代码] 缺点: 无法取消 Promise,一旦新建它就会立即执行,无法中途取消。其次,如果不设置回调函数,Promise 内部抛出的错误,不会反应到外部。再次,当处于 Pending 状态时,无法得知目前进展到哪一个阶段(刚刚开始还是即将完成)。 优点: Promise能够简化层层回调的写法,Promise的精髓是“状态”,用维护状态、传递状态的方式来使得回调函数能够及时调用,它比传递callback函数要简单、灵活的多。 3.1.2 用法注意点 - 顺序: [代码] new Promise((resolve, reject) => { resolve(1); console.log(2); }).then(r => { console.log(r); }); [代码] 运行结果是: 2 1 说明: 立即 resolved 的 Promise 是在本轮事件循环的末尾执行,总是晚于本轮循环的同步任务。也就是resolve(1)和console.log(2)是属于同步任务,需要全部执行完同步任务后,再去循环到resolve的then中。 3.1.3 用法注意点 - 状态: [代码] const p1 = new Promise(function (resolve, reject) { setTimeout(() => reject(new Error('fail')), 3000); }); const p2 = new Promise(function (resolve, reject) { setTimeout(() => resolve(p1), 1000); }); const p3 = new Promise(function (resolve, reject) { setTimeout(() => resolve(new Error('fail')), 1000); }); p2 .then(result => console.log("1:", result)) .catch(error => console.log("2:",error)); p3 .then(result => console.log("3:", result)) .catch(error => console.log("4:",error)); [代码] 运行结果是: 3: Error: fail at setTimeout (async.htm:182) 2: Error: fail at setTimeout (async.htm:174) 说明: p1是一个 Promise,3 秒之后变为rejected。p2和p3的状态是在 1 秒之后改变,p2 resolve方法返回的是 p1, p3 resolve方法返回的是 抛出异常。但由于p2返回的是另一个 Promise,导致p2自己的状态无效了,由p1的状态决定p2的状态。所以后面的then语句都变成针对后者(p1)。又过了 2 秒,p1变为rejected,导致触发catch方法指定的回调函数。 而p3返回的是自身的resolve,所以触发then中指定的回调函数。 3.1.4 用法注意点 - then链的处理: [代码] var p1 = p2 = new Promise(function (resolve){ resolve(100); }); p1.then((value) => { return value*2; }).then((value) => { return value*2; }).then((value) => { console.log("p1的执行结果:",value) }) p2.then((value) => { return value*2; }) p2.then((value) => { return value*2; }) p2.then((value) => { console.log("p2的执行结果:",value) }) [代码] 运行结果是: p2的执行结果: 100 p1的执行结果: 400 说明: p2写法中的 then 调用几乎是在同时开始执行的,而且传给每个 then 方法的 value 值都是 100。而p1中写法则采用了方法链的方式将多个 then 方法调用串连在了一起,各函数也会严格按照 resolve → then → then → then 的顺序执行,并且传给每个 then 方法的 value 的值都是前一个promise对象通过 return 返回的值。 ###3.1.4 用法注意点 - catch的处理: [代码] var p1 = new Promise(function (resolve, reject){ reject("test"); //throw new Error("test"); 效果同reject("test"); //reject(new Error("test")); 效果同reject("test"); resolve("ok"); }); p1 .then(value => console.log("p1 then:", value)) .catch(error => console.log("p1 error:", error)); p2 = new Promise(function (resolve, reject){ resolve("ok"); reject("test"); }); p2 .then(value => console.log("p2 then:", value)) .catch(error => console.log("p2 error:", error)); [代码] 运行结果是: p2 then: ok p1 error: test 说明: Promise 的状态一旦改变,就永久保持该状态,不会再变了。不会即抛异常又会正常resolve。 3.2 async/await(ES7) 3.2.1 async基础用法: async 用于申明一个 function 是异步的,返回的是一个 Promise 对象。 [代码] async function testAsync() { return "hello async"; } var result = testAsync(); console.log("1:", result); testAsync().then(result => console.log("2:", result)); async function mytest() { //"hello async"; } var result1 = mytest(); console.log("3:", result1); [代码] 运行结果是: 1: Promise {<resolved>: “hello async”} 3: Promise {<resolved>: undefined} 2: hello async 说明: async返回的是一个Promise对象,可以用 then 来接收,如果没有返回值的情况下,它会返回 Promise.resolve(undefined),所以在没有 await 的情况下执行 async 函数,它会立即执行,并不会阻塞后面的语句。这和普通返回 Promise 对象的函数并无二致。 3.2.2 await基础用法: await 只能出现在 async 函数中,用于等待一个异步方法执行完成(实际等的是一个返回值,强调 await 不仅仅用于等 Promise 对象,它可以等任意表达式的结果)。 [代码] function getMyInfo() { return Promise.resolve("hello 2019!"); } async function testAsync() { return "hello async"; } async function mytest() { return Promise.reject("hello async"); } async function test() { try { const v1 = await getMyInfo(); console.log("getV1"); const v2 = await testAsync(); console.log("getV2"); const v3 = await mytest(); console.log(v1, v2, v3); } catch (error) { console.log("error:", error); } } test(); [代码] 运行结果是: getV1 getV2 error: hello async 说明: await等到的如果是一个 Promise 对象,await 就忙起来了,它会阻塞后面的代码,等着 Promise 对象 resolve,然后得到 resolve 的值,作为 await 表达式的运算结果。 放心,这就是 await 必须用在 async 函数中的原因。async 函数调用不会造成阻塞,它内部所有的阻塞都被封装在一个 Promise 对象中异步执行。 3.2.3 async/await的优势: 很多情况下,执行下一步操作是需要依赖上一步的返回结果的,如果当嵌套层次较多的时候,(举例3层的时候): [代码] const getRequest = () => { return promise1().then(result1 => { //do something return promise2(result1).then(result2 => { //do something return promise3(result1, result2) }) }) } [代码] 从上例可以看到嵌套内容太多。此时如果用async写法,可写成如下: [代码] const getRequest = async () => { const result1 = await promise1(); const result2 = await promise2(result1); return promise3(result1, result2); } [代码] 说明: async / await 使你的代码看起来像同步代码,它有效的消除then链,让你的代码更加简明,清晰。 总结:以上就是对JS的异步机制及相关应用的整体总结,如有需要欢迎交流~
2019-03-12