- 一个通用request的封装
小程序内置了[代码]wx.request[代码],用于向后端发送请求,我们先来看看它的文档: wx.request(OBJECT) 发起网络请求。使用前请先阅读说明。 OBJECT参数说明: 参数名 类型 必填 默认值 说明 最低版本 url String 是 - 开发者服务器接口地址 - data Object/String/ArrayBuffer 否 - 请求的参数 - header Object 否 - 设置请求的 header,header 中不能设置 Referer。 - method String 否 GET (需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT - dataType String 否 json 如果设为json,会尝试对返回的数据做一次 JSON.parse - responseType String 否 text 设置响应的数据类型。合法值:text、arraybuffer 1.7.0 success Function 否 - 收到开发者服务成功返回的回调函数 - fail Function 否 - 接口调用失败的回调函数 - complete Function 否 - 接口调用结束的回调函数(调用成功、失败都会执行) - success返回参数说明: 参数 类型 说明 最低版本 data Object/String/ArrayBuffer 开发者服务器返回的数据 - statusCode Number 开发者服务器返回的 HTTP 状态码 - header Object 开发者服务器返回的 HTTP Response Header 1.2.0 这里我们主要看两点: 回调函数:success、fail、complete; success的返回参数:data、statusCode、header。 相对于通过入参传回调函数的方式,我更喜欢promise的链式,这是我期望的第一个点;success的返回参数,在实际开发过程中,我只关心data部分,这里可以做一下处理,这是第二点。 promisify 小程序默认支持promise,所以这一点改造还是很简单的: [代码]/** * promise请求 * 参数:参考wx.request * 返回值:[promise]res */ function requestP(options = {}) { const { success, fail, } = options; return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success: res, fail: rej, }, )); }); } [代码] 这样一来我们就可以使用这个函数来代替wx.request,并且愉快地使用promise链式: [代码]requestP({ url: '/api', data: { name: 'Jack' } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); [代码] 注意,小程序的promise并没有实现finally,Promise.prototype.finally是undefined,所以complete不能用finally代替。 精简返回值 精简返回值也是很简单的事情,第一直觉是,当请求返回并丢给我一大堆数据时,我直接resolve我要的那一部分数据就好了嘛: [代码]return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success(r) { res(r.data); // 这里只取data }, fail: rej, }, )); }); [代码] but!这里需要注意,我们仅仅取data部分,这时候默认所有success都是成功的,其实不然,wx.request是一个基础的api,fail只发生在系统和网络层面的失败情况,比如网络丢包、域名解析失败等等,而类似404、500之类的接口状态,依旧是调用success,并体现在[代码]statusCode[代码]上。 从业务上讲,我只想处理json的内容,并对json当中的相关状态进行处理;如果一个接口返回的不是约定好的json,而是类似404、500之类的接口异常,我统一当成接口/网络错误来处理,就像jquery的ajax那样。 也就是说,如果我不对[代码]statusCode[代码]进行区分,那么包括404、500在内的所有请求结果都会走[代码]requestP().then[代码],而不是[代码]requestP().catch[代码]。这显然不是我们熟悉的使用方式。 于是我从jquery的ajax那里抄来了一段代码。。。 [代码]/** * 判断请求状态是否成功 * 参数:http状态码 * 返回值:[Boolen] */ function isHttpSuccess(status) { return status >= 200 && status < 300 || status === 304; } [代码] [代码]isHttpSuccess[代码]用来决定一个http状态码是否判为成功,于是结合[代码]requestP[代码],我们可以这么来用: [代码]return new Promise((res, rej) => { wx.request(Object.assign( {}, options, { success(r) { const isSuccess = isHttpSuccess(r.statusCode); if (isSuccess) { // 成功的请求状态 res(r.data); } else { rej({ msg: `网络错误:${r.statusCode}`, detail: r }); } }, fail: rej, }, )); }); [代码] 这样我们就可以直接resolve返回结果中的data,而对于非成功的http状态码,我们则直接reject一个自定义的error对象,这样就是我们所熟悉的ajax用法了。 登录 我们经常需要识别发起请求的当前用户,在web中这通常是通过请求中携带的cookie实现的,而且对于前端开发者是无感知的;小程序中没有cookie,所以需要主动地去补充相关信息。 首先要做的是:登录。 通过[代码]wx.login[代码]接口我们可以得到一个[代码]code[代码],调用后端登录接口将code传给后端,后端再用code去调用微信的登录接口,换取[代码]sessionKey[代码],最后生成一个[代码]sessionId[代码]返回给前端,这就完成了登录。 [图片] 具体参考微信官方文档:wx.login [代码]const apiUrl = 'https://jack-lo.github.io'; let sessionId = ''; /** * 登录 * 参数:undefined * 返回值:[promise]res */ function login() { return new Promise((res, rej) => { // 微信登录 wx.login({ success(r1) { if (r1.code) { // 获取sessionId requestP({ url: `${apiUrl}/api/login`, data: { code: r1.code, }, method: 'POST' }) .then((r2) => { if (r2.rcode === 0) { const { sessionId } = r2.data; // 保存sessionId sessionId = sessionId; res(r2); } else { rej({ msg: '获取sessionId失败', detail: r2 }); } }) .catch((err) => { rej(err); }); } else { rej({ msg: '获取code失败', detail: r1 }); } }, fail: rej, }); }); } [代码] 好的,我们做好了登录并且顺利获取到了sessionId,接下来是考虑怎么把sessionId通过请求带上去。 sessionId 为了将状态与数据区分开来,我们决定不通过data,而是通过header的方式来携带sessionId,我们对原本的requestP稍稍进行修改,使得它每次调用都自动在header里携带sessionId: [代码]function requestP(options = {}) { const { success, fail, } = options; // 统一注入约定的header let header = Object.assign({ sessionId: sessionId }, options.header); return new Promise((res, rej) => { ... }); } [代码] 好的,现在请求会自动带上sessionId了; 但是,革命尚未完成: 我们什么时候去登录呢?或者说,我们什么时候去获取sessionId? 假如还没登录就发起请求了怎么办呢? 登录过期了怎么办呢? 我设想有这样一个逻辑: 当我发起一个请求的时候,如果这个请求不需要sessionId,则直接发出; 如果这个请求需要携带sessionId,就去检查现在是否有sessionId,有的话直接携带,发起请求; 如果没有,自动去走登录的流程,登录成功,拿到sessionId,再去发送这个请求; 如果有,但是最后请求返回结果是sessionId过期了,那么程序自动走登录的流程,然后再发起一遍。 其实上面的那么多逻辑,中心思想只有一个:都是为了拿到sessionId! 我们需要对请求做一层更高级的封装。 首先我们需要一个函数专门去获取sessionId,它将解决上面提到的2、3点: [代码]/** * 获取sessionId * 参数:undefined * 返回值:[promise]sessionId */ function getSessionId() { return new Promise((res, rej) => { // 本地sessionId缺失,重新登录 if (!sessionId) { login() .then((r1) => { res(r1.data.sessionId); }) .catch(rej); } } else { res(sessionId); } }); } [代码] 好的,接下来我们解决第1、4点,我们先假定:sessionId过期的时候,接口会返回[代码]code=401[代码]。 整合了getSessionId,得到一个更高级的request方法: [代码]/** * ajax高级封装 * 参数:[Object]option = {},参考wx.request; * [Boolen]keepLogin = false * 返回值:[promise]res */ function request(options = {}, keepLogin = true) { if (keepLogin) { return new Promise((res, rej) => { getSessionId() .then((r1) => { // 获取sessionId成功之后,发起请求 requestP(options) .then((r2) => { if (r2.rcode === 401) { // 登录状态无效,则重新走一遍登录流程 // 销毁本地已失效的sessionId sessionId = ''; getSessionId() .then((r3) => { requestP(options) .then(res) .catch(rej); }); } else { res(r2); } }) .catch(rej); }) .catch(rej); }); } else { // 不需要sessionId,直接发起请求 return requestP(options); } } [代码] 留意req的第二参数keepLogin,是为了适配有些接口不需要sessionId,但因为我的业务里大部分接口都需要登录状态,所以我默认值为true。 这差不多就是我们封装request的最终形态了。 并发处理 这里其实我们还需要考虑一个问题,那就是并发。 试想一下,当我们的小程序刚打开的时候,假设页面会同时发出5个请求,而此时没有sessionId,那么,这5个请求按照上面的逻辑,都会先去调用login去登录,于是乎,我们就会发现,登录接口被同步调用了5次!并且后面的调用将导致前面的登录返回的sessionId过期~ 这bug是很严重的,理论上来说,登录我们只需要调用一次,然后一直到过期为止,我们都不需要再去登录一遍了。 ——那么也就是说,同一时间里的所有接口其实只需要登录一次就可以了。 ——也就是说,当有登录的请求发出的时候,其他那些也需要登录状态的接口,不需要再去走登录的流程,而是等待这次登录回来即可,他们共享一次登录操作就可以了! 解决这个问题,我们需要用到队列。 我们修改一下getSessionId这里的逻辑: [代码]const loginQueue = []; let isLoginning = false; /** * 获取sessionId * 参数:undefined * 返回值:[promise]sessionId */ function getSessionId() { return new Promise((res, rej) => { // 本地sessionId缺失,重新登录 if (!sessionId) { loginQueue.push({ res, rej }); if (!isLoginning) { isLoginning = true; login() .then((r1) => { isLoginning = false; while (loginQueue.length) { loginQueue.shift().res(r1); } }) .catch((err) => { isLoginning = false; while (loginQueue.length) { loginQueue.shift().rej(err); } }); } } else { res(sessionId); } }); } [代码] 使用了isLoginning这个变量来充当锁的角色,锁的目的就是当登录正在进行中的时候,告诉程序“我已经在登录了,你先把回调都加队列里去吧”,当登录结束之后,回来将锁解开,把回调全部执行并清空队列。 这样我们就解决了问题,同时提高了性能。 封装 在做完以上工作以后,我们都很清楚的封装结果就是[代码]request[代码],所以我们把request暴露出去就好了: [代码]function request() { ... } module.exports = request; [代码] 这般如此之后,我们使用起来就可以这样子: [代码]const request = require('request.js'); Page({ ready() { // 获取热门列表 request({ url: 'https://jack-lo.github.io/api/hotList', data: { page: 1 } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); // 获取最新信息 request({ url: 'https://jack-lo.github.io/api/latestList', data: { page: 1 } }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); }, }); [代码] 是不是很方便,可以用promise的方式,又不必关心登录的问题。 然而可达鸭眉头一皱,发现事情并不简单,一个接口有可能在多个地方被多次调用,每次我们都去手写这么一串[代码]url[代码]参数,并不那么方便,有时候还不好找,并且容易出错。 如果能有个地方专门记录这些url就好了;如果每次调用接口,都能像调用一个函数那么简单就好了。 基于这个想法,我们还可以再做一层封装,我们可以把所有的后端接口,都封装成一个方法,调用接口就相对应调用这个方法: [代码]const apiUrl = 'https://jack-lo.github.io'; const req = { // 获取热门列表 getHotList(data) { const url = `${apiUrl}/api/hotList` return request({ url, data }); }, // 获取最新列表 getLatestList(data) { const url = `${apiUrl}/api/latestList` return request({ url, data }); } } module.exports = req; // 注意这里暴露的已经不是request,而是req [代码] 那么我们的调用方式就变成了: [代码]const req = require('request.js'); Page({ ready() { // 获取热门列表 req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); // 获取最新信息 req.getLatestList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); } }); [代码] 这样一来就方便了很多,而且有一个很大的好处,那就是当某个接口的地址需要统一修改的时候,我们只需要对[代码]request.js[代码]进行修改,其他调用的地方都不需要动了。 错误信息的提炼 最后的最后,我们再补充一个可轻可重的点,那就是错误信息的提炼。 当我们在封装这么一个[代码]req[代码]对象的时候,我们的promise曾经reject过很多的错误信息,这些错误信息有可能来自: [代码]wx.request[代码]的fail; 不符合[代码]isHttpSuccess[代码]的网络错误; getSessionId失败; … 等等的一切可能。 这就导致了我们在提炼错误信息的时候陷入困境,到底catch到的会是哪种[代码]error[代码]对象? 这么看你可能不觉得有问题,我们来看看下面的例子: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { console.log(err); }); [代码] 假如上面的例子中,我想要的不仅仅是[代码]console.log(err)[代码],而是想将对应的错误信息弹窗出来,我应该怎么做? 我们只能将所有可能出现的错误都检查一遍: [代码]req.getHotList({ page: 1 }) .then((res) => { if (res.code !== 0) { // 后端接口报错格式 wx.showModal({ content: res.msg }); } }) .catch((err) => { let msg = '未知错误'; // 文本信息直接使用 if (typeof err === 'string') { msg = err; } // 小程序接口报错 if (err.errMsg) { msg = err.errMsg; } // 自定义接口的报错,比如网络错误 if (err.detail && err.detail.errMsg) { msg = err.detail.errMsg; } // 未知错误 wx.showModal({ content: msg }); }); [代码] 这就有点尴尬了,提炼错误信息的代码量都比业务还多几倍,而且还是每个接口调用都要写一遍~ 为了解决这个问题,我们需要封装一个方法来专门做提炼的工作: [代码]/** * 提炼错误信息 * 参数:err * 返回值:[string]errMsg */ function errPicker(err) { if (typeof err === 'string') { return err; } return err.msg || err.errMsg || (err.detail && err.detail.errMsg) || '未知错误'; } [代码] 那么过程会变成: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch((err) => { const msg = req.errPicker(err); // 未知错误 wx.showModal({ content: msg }); }); [代码] 好吧,我们再偷懒一下,把wx.showModal也省去了: [代码]/** * 错误弹窗 */ function showErr(err) { const msg = errPicker(err); console.log(err); wx.showModal({ showCancel: false, content: msg }); } [代码] 最后就变成了: [代码]req.getHotList({ page: 1 }) .then((res) => { console.log(res); }) .catch(req.showErr); [代码] 至此,一个简单的wx.request封装过程便完成了,封装过的[代码]req[代码]比起原来,使用上更加方便,扩展性和可维护性也更好。 结尾 以上内容其实是简化版的[代码]mp-req[代码],介绍了[代码]mp-req[代码]这一工具的实现初衷以及思路,使用[代码]mp-req[代码]来管理接口会更加的便捷,同时[代码]mp-req[代码]也提供了更加丰富的功能,比如插件机制、接口的缓存,以及接口分类等,欢迎大家关注mp-req了解更多内容。 以上最终代码可以在这里获取:req.js。
2020-08-04 - Web打印探秘
概述 本文首先描述笔者所处的项目组的 Web 打印项目的需求背景,然后描述笔者在实践 Web 打印项目的过程中遇到了诸多问题,阐述 Web 打印的问题解决思路,最后给出了另外一种 Web 打印的需求解决方案,即使用[代码]Headless browser[代码]生成图片并打印的方案。预计阅读时间5 ~ 10分钟。 本文主要分下面几个方面: Web 构建打印模板需求 基本概念 打印设备接口 页面模型 引入打印样式 处理 Web打印 分页问题 去除浏览器默认的页眉页底 构建自定义的页眉页底 使用 Headless browser 生成图片的解决方案 Web 构建打印模板需求 产品经理小姐姐近期给笔者写了这样一个需求: 实现一个打印报告的模板页面,浏览器或客户端调用打印设备的接口打印出对应的报告。 对应报告支持报告模板配置,模板分几种,例如免费玩家用极简版、低保玩家用基本版、充值玩家用高级版、土豪玩家用顶配版。没错,充值才能变得更强。 需要实现分页功能,支持把对应的内容展示到对应的页。例如:内容A为基本信息,需要展示到第一页,低保玩家享受内容B,展示到第二页…土豪玩家享受所有的功能,展示到第n页。 展示产品配置的对应的页眉、页底。 于是,为了解决上述需求,笔者大概写了这样的一个模板,如下所示: [代码]<div class="page1"> <div>我是第一页1</div> <div>我是第一页2</div> <div>我是第一页3</div> </div> <div class="page2"> <div>我是第二页1</div> <div>我是第二页2</div> <div>我是第二页3</div> </div> <div class="page3"> <div>我是第三页1</div> <div>我是第三页2</div> <div>我是第三页3</div> </div> <div class="page4"> <div>我是第四页1</div> <div>我是第四页2</div> <div>我是第四页3</div> </div> [代码] 基本概念 打印设备接口 浏览器打印是一个很成熟的应用,最简单的打印直接调用[代码]window.print()[代码]或者是调用[代码]document.execCommand('print')[代码]。此时,浏览器会弹出打印预览的窗口,通过页面生成了[代码]pdf[代码]用于打印预览。如图所示,展示了谷歌首页的打印预览: [图片] 页面模型 和 CSS 盒子模型一样,页面盒子模型由外边距 (margin)、边框 (border)、内边距 (padding) 和 内容区域 (content area) 构成: [图片] 有以下两点可以注意: 打印页面时,只打印出页面的内容区域 页面默认有页眉页脚信息,展现到页面外边距范围 默认情况下,页面是从左到右、从上到下展示,如果需要更改打印设备的方向,可以通过设置根元素的 direction 和 writing-mode 属性来改变页面方向。 引入打印样式 可以通过三种方式引入打印样式: 使用 [代码]@media print[代码]: [代码]@media print { body { background-color:#FFFFFF; margin: 0mm; /* this affects the margin on the content before sending to printer */ } // ... } [代码] 内联样式使用[代码]media[代码]属性: [代码]<style type="text/css" media="print"> </style> [代码] 在 CSS 中使用 [代码]@import[代码]: [代码]@import url("print.css") print; [代码] HTML 中使用的[代码]link[代码]标签添加[代码]media[代码]属性: [代码]<link rel="stylesheet" media="print" href="print.css"> [代码] 处理 Web打印 分页问题 项目需求中首先遇到的问题是需要处理 Web打印 分页问题。即使该部分未占满一页纸的高度,也需要进行手动的分页。起初,我通过计算页面每个部分的高度,在对应页面部分的节点的高度下方预留一部分的外边距来实现,如下代码所示,通过查资料得知 A4纸的宽高比为 297 : 210 ,除去页面外边距(左右各 20mm )来算得每一部分需要预留的高度: [代码]const A4_HEIGHT_WIDTH_RATE = 297 / (210 - 2 * 13); // 打印区域长宽比:(A4纸高)比(A4纸宽减去左右侧20mm的边距) const PAGE_WIDTH = 680; // 页面宽度(像素值) const PAGE_HEIGHT = PAGE_WIDTH * A4_HEIGHT_WIDTH_RATE; // 页面高度 const $page1El = document.querySelector('.page1'); const page1Height = parseInt($page1El.clientHeight); // page1的高度是多少像素 const pageNum = Math.ceil(page1Height / PAGE_HEIGHT); // page1需要占多少页,超过1页的高度,就需要占2页,因此向上取整 const marginBottom = pageNum * PAGE_HEIGHT - page1Height; // 需要预留多少外边距 $page1El.style.marginBottom = `${marginBottom}px`; [代码] 但是,其实 CSS 早就支持了打印设备里的分页问题了,可以通过设置[代码]break-after: page;[代码] 或 [代码]page-break-after: always;[代码]实现在打印设备的分页: [代码].page1 { break-after: page; page-break-after: always; } // ... [代码] 去除浏览器默认的页眉页底 实现分页的效果后,发现页面打印会在页底出现当前页面的 url : [图片] 页面默认有页眉页脚信息,展现到页面外边距范围,通过去除 页面模型 的外边距,使得内容不会延伸到页面的边缘,再通过设置 [代码]body[代码] 元素的 [代码]margin[代码] 来保证 A4 纸打印出来的页面带有外边距: [代码]@media print { @page { margin: 0; } body { margin: 2cm; } } [代码] 现在打印出来的页面不再具有默认的页底: [图片] 构建自定义的页眉页底 通过将对应的页眉、页底元素的 [代码]position[代码] 设置为 [代码]fixed[代码] 可以固定对应节点到页面的任意一部分,它们也将在每个打印页面上重复。 [代码].header { position: fixed; top: 0; } .footer { position: fixed; bottom: 0; } [代码] 使用 Headless browser 生成图片的解决方案 上面说了那么多,都是在前端实现的 Web 打印的解决方案,但实际上,如果可以在后台直接通过 Web 页面,预先保存好的页面模板,通过拉取后台数据,并运行[代码]Headless browser[代码]生成一张截图,通过打印截图就可以解决这样的问题了,下面以 phantomjs 配合 pug为例,展示笔者使用[代码]Headless browser[代码]生成图片的简单解决方案: [代码]// 针对链接的截图服务 // 返回phantom实例的promise对象,为了获取对应的base64编码 function captureByUrl(url, data) { let instance; let page; const destroyInstance = () => { // 关闭页面 page.close(); // 退出实例 instance.exit(); }; return phantom.create() // 首先,创建phantom实例 .then((_instance) => { instance = _instance; return instance.createPage(); }) .then((_page) => { page = _page; if (data.width && data.height) { // 设置phantom截图页面的宽高值 page.property('viewportSize', { width: data.width, height: data.height }); } page.setting('userAgent', 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/37.0.2062.120 Safari/537.36'); return page.open(url); }) .then(() => { return page.renderBase64('PNG'); // 渲染对应图片,拿到base64字符串 }) .then((image) => { destroyInstance(); // 销毁phantom实例 return image; }, (error) => { destroyInstance(); // 销毁phantom实例 throw error; }); } [代码] 如上代码所示,使用 Headless browser 打开一个链接,通过[代码]renderBase64[代码]将对应页面的预览图截图生成base64字符串。 对应的,在服务端,可以通过读取预先写好的[代码]pug[代码]模板,传入对应数据生成对应页面预览图,再通过 Headless browser 生成截图保存到本地,即可实现 Web 打印在服务端的解决方案,如下代码所示,为服务端读取模板,并保存图片的部分代码: [代码]// 针对模板和数据的截图服务 function captureByTemplate(template, data) { const content = pug.compile(template)(Object.assign({ URL_PREFIX, }, data)); const contentInBase64 = new Buffer(content).toString('base64'); const url = `data:text/html;charset=utf8;base64,${contentInBase64}`; return captureByUrl(url, data); } captureByTemplate(fs.readFileSync('./print.pug', 'utf-8'), data) .then(base64Data => { fs.writeFile("out.png", base64Data, 'base64', function(err) { console.error(err); }); }) .catch(err => { console.error(err); }); [代码] 小结 本文为笔者在实践 Web 打印相关项目的项目总结,首先描述了 Web 打印项目一般需求,然后在打印设备下,页面模型的展现形式;然后描述了笔者在实践过程中遇到的一些常见问题,给出一些通用性的解决方案。最后,联想到 Headless browser 也可用于实现打印模板需求,笔者以 phantomjs 和 pug 模板为例进行了一个简单的实践。
2019-04-16