个人案例
- i管书
书籍借阅管理小程序
图书借阅管理小程序扫码体验
- 管书帮
定位于个人书籍借阅管理
掌上图书管理系统扫码体验
- i管书I管理版
适用于企事业单位及中小学幼儿园班级图书角图书借阅管理
图书借阅管理小程序扫码体验
- 微信小程序 一键下载多张图片和预览单张下载
js文件代码 Page({ data: { imglist: [ 'https://timgs.top1buyer.com/admin/special/special_img_20190301160008479.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160013201.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160015969.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160025498.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160031519.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160042689.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160108243.jpg', 'https://timgs.top1buyer.com/admin/special/special_img_20190301160111756.jpg' ], imgNew: [] }, // 点击一键下载图片按钮 DownloadImgList() { var that = this; if (that.data.imglist.length == 0) { // 判断数组是否有图片 wx.showToast({ title: '暂无图片下载', icon: 'none', duration: 2000 }); return false; } // 判断用户是否授权 wx.getSetting({ success: function (res) { if (res.authSetting["scope.writePhotosAlbum"] == false) { wx.showToast({ title: '您还未授权保存相册,请授权', icon: 'none', duration: 2000 }) wx.openSetting({ success: function (res) { if (res.authSetting["scope.writePhotosAlbum"] == true) { // 保存相册 that.dowImg(); } if (res.authSetting["scope.writePhotosAlbum"] == false) { wx.showToast({ title: '授权失dsds败!', icon: 'none', duration: 2000 }) } } }); } else { // 遍历所有图片 for (let i = 0; i < that.data.imglist.length; i++) { wx.downloadFile({ url: that.data.imglist[i], // 需下载的每一张图片路径 success: function (res) { wx.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: function (response) { if (response.errMsg == "saveImageToPhotosAlbum:ok") { wx.showToast({ title: '下载成功', icon: 'success', duration: 2000 }) } } }); }, fail: function () { wx.showToast({ title: '下载失败', icon: 'none', duration: 2000 }) } }); } } } }); }, // 图片预览和单独下载 aloneDow(e) { let src = e.currentTarget.dataset.src; //获取data-src let imgList = e.currentTarget.dataset.list; //获取data-list let that = this; imgList.forEach(function (item, index) { console.log(item); //这里的item就是从数组里拿出来的每一个每一组 that.setData({ imgNew: that.data.imgNew.concat(item) // 添加到新数组 }); }); wx.previewImage({ current: src, // 当前显示图片的http链接 urls: this.data.imgNew // 需要预览的图片http链接列表 }); } }); wxml文件: <view bindtap="DownloadImgList">下载</view> wxss文件: .dowimg{ margin: 0 0 50rpx 30rpx; width: 200rpx; height: 80rpx; line-height: 80rpx; text-align: center; color: #FFFFFF; background-color: #07c160; font-weight: 500; border-radius: 10rpx; } .imglist{ display: flex; flex-wrap: wrap; } .imgitem{ margin: 0 10rpx 10rpx; width: 230rpx; height: 230rpx; } image{ display: block; width: 100%; height: 100%; }
2020-03-16 - 小程序中如何实现并发控制?
小程序中如何实现并发控制? 一、性能之网络请求数 wx.request、wx.uploadFile、wx.downloadFile 的最大并发限制是 10 个; 小程序中,短时间内发起太多请求会触发小程序并发请求数量的限制同时太多请求也可能导致加载慢等问题,应合理控制请求数量,甚至做请求的合并等。 上传多图使用Promise.all并发处理,很容易触发限制,导致请求失败。 故需做并发控制。实现并发控制此处将对p-limit和async-pool进行研究 二、p-limit并发控制的实现 2.1 p-limit的使用 [代码]const limit = pLimit(1); const input = [ limit(() => fetchSomething('foo')), limit(() => fetchSomething('bar')), limit(() => doSomething()) ]; // 一次只运行一个promise const result = await Promise.all(input); console.log(result); [代码] pLimit(concurrency) 返回一个[代码]limit[代码]函数。 concurrency(Number): 表示并发限制,最小值为1,默认值为Infinity limit(fn, …args) 返回通过调用[代码]fn(...args)[代码]返回的promise fn(Function): 表示Promise-returning/async function args: 表示传递给fn的参数 limit.activeCount 当前正在运行的promise数量 limit.pendingCount 等待运行的promise数量(即它们的内部fn尚未被调用)。 limit.clearQueue() 丢弃等待运行的pending promises。 2.2 p-limit 的实现 要想理解p-limit必须先掌握浏览器中event-loop的执行顺序: [代码]console.log('script start') async function async1() { await async2() console.log('async1 end') } async function async2() { console.log('async2 end') } // 以上两个async函数可改写为以下代码 // new Promise((resolve, reject) => { // console.log('async2 end') // // Promise.resolve() 将代码插入微任务队列尾部 // // resolve 再次插入微任务队列尾部 // resolve(Promise.resolve()) // }).then(() => { // console.log('async1 end') // }) // 如果 await 后面跟着 Promise 的话,async1 end 需要等待三个 tick 才能执行到 async1() setTimeout(function() { console.log('setTimeout') }, 0) new Promise(resolve => { console.log('Promise') resolve() }) .then(function() { console.log('promise1') }) .then(function() { console.log('promise2') }) console.log('script end') // script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout [代码] 先介绍下宏任务和微任务具体有哪些内容, 微任务包括 [代码]process.nextTick[代码] ,[代码]promise[代码] ,[代码]MutationObserver[代码],其中 [代码]process.nextTick[代码] 为 Node 独有。 宏任务包括 [代码]script[代码] , [代码]setTimeout[代码] ,[代码]setInterval[代码] ,[代码]setImmediate[代码] ,[代码]I/O[代码] ,[代码]UI rendering[代码]。 Event Loop 执行顺序如下所示: 首先执行同步代码,这属于宏任务 当执行完所有同步代码后,执行栈为空,查询是否有异步代码需要执行 执行所有微任务 当执行完所有微任务后,如有必要会渲染页面 然后开始下一轮 Event Loop,执行宏任务中的异步代码,也就是 [代码]setTimeout[代码] 中的回调函数 [代码]function pLimit(concurrency) { if(!((Number.isInteger(concurrency)||concurrency===Number.POSITIVE_INFINITY)&&concurrency>0)) { throw new TypeError('Expected `concurrency` to be a number from 1 and up'); } // 用一个queue队列维护所有Promise异步函数 const queue=[]; let activeCount=0; const next=() => { // 某异步函数完成后,需要将activeCount-- activeCount--; if(queue.length>0) { // 再次从队列中出队并执行异步函数,activeCount维持在concurrency queue.shift()(); } }; const run=async (fn,resolve,args) => { // activeCount++; // 进一步将fn封装为异步函数并运行 const result=(async () => fn(...args))(); // 此处返回generator函数 的resolve值,即Promise.all resolve(result); try { // 等待result异步函数完成(例如某请求完成) await result; } catch {} next(); }; const enqueue=(fn,resolve,args) => { queue.push(run.bind(undefined,fn,resolve,args)); // setTimeout(()=>{ // // 正在运行的Promise数量activeCount始终不大于concurrency,从而达到控制并发的目的 // if(activeCount<concurrency&&queue.length>0) { // // 队列出队并执行改函数 // queue.shift()(); // } // },0); (async () => { // 这个函数需要等到下一个微任务再比较 `activeCount` 和 `concurrency` // 因为 `activeCount` 在 run 函数出列和调用时异步更新。 // if 语句中的比较也需要异步进行,以获取 `activeCount` 的最新值。 await Promise.resolve(); // 正在运行的Promise数量activeCount始终不大于concurrency,从而达到控制并发的目的 if (activeCount < concurrency && queue.length > 0) { // 队列出队并执行改函数 queue.shift()(); } })(); }; const generator = (fn,...args) => new Promise(resolve => { enqueue(fn,resolve,args); }); Object.defineProperties(generator,{ // 正在运行的Promise数量 activeCount: { get: () => activeCount, }, // 等待运行的Promise数量 pendingCount: { get: () => queue.length, }, // 清空queue队列中的异步函数 clearQueue: { value: () => { while(queue.length!=0) { pueue.shift(); } }, }, }); return generator; } [代码] 三、asyncPool并发控制的实现 async-pool 这个库提供了 ES7 和 ES6 两种不同版本的实现,在分析其具体实现之前,我们来看一下它如何使用。 3.1 asyncPool 的使用 [代码]function asyncPool(poolLimit, array, iteratorFn){ ... } [代码] 该函数接收 3 个参数: [代码]poolLimit[代码](Number):表示限制的并发数; [代码]array[代码](Array):表示任务数组; [代码]iteratorFn[代码](Function):表示迭代函数,用于实现对每个任务项进行处理,该函数会返回一个 Promise 对象或异步函数。 [代码]asyncPool[代码]在有限的并发池中运行多个promise-returning & async functions。一旦其中一个承诺被拒绝,它就会立即拒绝。当所有 Promise 完成时,它就会resolves。它尽快调用迭代器函数(在并发限制下)。例如: [代码]const timeout = i => new Promise(resolve => setTimeout(() => resolve(i), i)); await asyncPool(2, [1000, 5000, 3000, 2000], timeout); // Call iterator (i = 1000) // Call iterator (i = 5000) // Pool limit of 2 reached, wait for the quicker one to complete... // 1000 finishes // Call iterator (i = 3000) // Pool limit of 2 reached, wait for the quicker one to complete... // 3000 finishes // Call iterator (i = 2000) // Itaration is complete, wait until running ones complete... // 5000 finishes // 2000 finishes // Resolves, results are passed in given array order `[1000, 5000, 3000, 2000]`. [代码] 通过观察以上的注释信息,我们可以大致地了解 [代码]asyncPool[代码] 函数内部的控制流程 3.2 asyncPool 实现 [代码]async function asyncPool(poolLimit, array, iteratorFn) { const ret = []; // 存储所有的异步任务 const executing = []; // 存储正在执行的异步任务 for (const item of array) { // 调用iteratorFn函数创建异步任务 const p = Promise.resolve().then(() => iteratorFn(item, array)); ret.push(p); // 保存新的异步任务 // 当poolLimit值小于或等于总任务个数时,进行并发控制 if (poolLimit <= array.length) { // 当任务完成后,从正在执行的任务数组中移除已完成的任务 const e = p.then(() => executing.splice(executing.indexOf(e), 1)); executing.push(e); // 保存正在执行的异步任务 if (executing.length >= poolLimit) { await Promise.race(executing); // 等待较快的任务执行完成 } } } return Promise.all(ret); } [代码] 在以上代码中,充分利用了 [代码]Promise.all[代码] 和 [代码]Promise.race[代码] 函数特点,再结合 ES7 中提供的 [代码]async await[代码] 特性,最终实现了并发控制的功能。利用 [代码]await Promise.race(executing);[代码] 这行语句,我们会等待 正在执行任务列表 中较快的任务执行完成之后,才会继续执行下一次循环。 四、实践运用 使用p-limit [代码]const limit=pLimit(5); const fn=() => { return axios({ method: 'get', url: 'https://www.fastmock.site/mock/883c2b5177653e4ef705b7ffdc680af1/daily/story', }) .then(function(response) { return response.data; }); }; const input=[]; for(let i=0;i<50;i++) { input.push(limit(fn)) } Promise.all(input).then(res => { console.log('res',res) }); [代码] [图片] 使用asyncPool [代码]const fn=() => { return axios({ method: 'get', url: 'https://www.fastmock.site/mock/883c2b5177653e4ef705b7ffdc680af1/daily/story', }) .then(function(response) { return response.data; }); } const input=[]; for(let i=0;i<50;i++) { input.push(fn); } const timeout= f => new Promise(resolve => setTimeout(() => resolve(f()))); asyncPool(5,input,timeout).then(res => { console.log('res',res); }) [代码] [图片]
2021-12-09 - 插件最新版本,安卓机型不跳转评价页面
添加评价组件,使用最新版本1.0.3后,无法跳转评价页面,1.0.1版本可以
02-02 - 【小程序代码自查】小程序闪退-内存泄露导致
背景用户经常出现闪退的情况,并提示内存不足。根据用户操作场景,猜测页面存在内存泄露。 内存泄露是什么?内存泄露是程序运行过程中产生的内存变量会一直存在,不会被垃圾回收机制检测到,导致一直不会被销毁,内存占用会越来越大。 比如说: 我们在运行小程序的时候会产生一个页面,小程序会给这个页面创建一个实例,当这个页面销毁的时候,这个实例应该会被销毁。 但是如果我们有个定时器(setInterval),定时器里面对这个页面实例存在引用,那这个页面实例就不会被销毁,因为有被用到。 当存在内存泄露的情况,用户长期使用我们的小程序会导致小程序占用的内存越来越大,最后会导致小程序闪退(被微信强制销毁) 排查内存泄露用到的工具-weakSet先简单描述一下weakSet,让大家有个简单的认识,详细需要去看下文档。 weakSet 是一个可以存储唯一变量的集合,和Set不一样的是,weakSet存储的变量都是弱引用,就是不会影响垃圾回收,如果存储的变量被回收了,在这个集合里面就找不到。 所以weakSet不能被遍历,也没有长度的概念。但是我们可以通过控制台打印weakset的指向,知道里面有多少个元素。如下图: [图片] 通过展开,我们可以知道里面是哪个页面的实例,但是我们在控制台展开就意味着我们对这个页面实例存在引用,则无法被垃圾回收。所以在执行垃圾回收之前需要清空控制台的输出。 如何确定页面是否存在内存泄露如果页面存在内存泄露则不会销毁页面实例。我们只需要判断页面实例有没有被销毁即可。 我们在一开始就把页面实例加到weakSet里面,当执行多次跳转页面之后,会存在多个页面实例,最后回到首页,触发小程序的垃圾回收。 如果不存在内存泄露,那weakSet集合里面只会存在两个页面实例(当前页面实例+返回回来的页面实例),比如下图的页面A和页面B。 如果存在内存泄露,那weakSet集合里面会存在多个页面实例(当前页面实例+存在内存泄露的页面实例*n),比如下图的页面A、页面B、页面C和页面D. 具体如下图: [图片] 如何主动触发小程序的垃圾回收小程序没有api可以让我们触发小程序的垃圾回收,我们目前可以通过开发者工具的performance面板或memory的垃圾回收(collect garbage 垃圾桶图标)按钮。 [图片] [图片] 触发垃圾回收之后的结果如图: [图片] 这个需要手动触发才可以,我们在测试的时候需要手动点击,无法自动触发,所以我们想了个方案自动触发垃圾回收。 通过给内存塞很多数据,然后将这些数据标为无用的,当内存达到500m左右小程序就会触发垃圾回收。这个办法会导致我们内存一段时间激增,建议尽量在跳转页面的时候不要开启,只有在最后页面跳转回首页才进行。 // 主动触发垃圾回收 setInterval(()=>{ if(!global.startGC){ return } let a = [] for (let i = 0; i < 10000000; i++) { a.push({ name: "pling", age: Math.random() * 10000 }) } console.log("length", a.length) a = [] }, 3000) 如何定位页面内存泄露的原因内存泄露的情况举例: global.list = [] Page({ // ... onLoad() { // ... 省略其他代码 // 将页面实例挂载到全局对象,如没有清理,则页面实例会一直不被销毁 global.list.push(this) // 存在Interval计时器,则会一直存在对页面实例的引用 setInterval(() => { console.log("test", this.data) }, 5000); // 通过settimeout的循环调用,实现了类似于interval的效果也会导致页面实例不会被销毁 this.testLoop() const that = this function test(){ console.log(that.data) } // 将内部函数挂载到全局变量,则会导致函数的作用域链都会存在引用,不会被销毁 global.logThis = test }, testLoop(){ setTimeout(() => { this.testLoop() }, 10000); } }) 通过上面我们可以知道一般会有上面四种情况导致内存泄露。 将对象挂载到全局对象上,页面写在没有清楚通过暴露内部函数给外部对象,导致存在作用域的引用,页面卸载没有清楚内部函数存在定时执行的函数存在对页面实例的引用,页面销毁没有清除定时器通过延时执行的函数循环调用,并存在对页面实例的引用,页面销毁没有停止调用。第一第二种情况会比较少出现,目前暂时还没考虑如何去排查。 第三第四种都会对页面实例存在调用,所以我们在页面实例销毁之后对页面实例上的属性进行监听,如果一直存在调用则会有问题。 [图片] 具体实现代码: // 检查页面卸载后对页面实例调用 Page({ data: { test: "111" }, onLoad() { global.pageSet.add(this) setInterval(() => { console.log("test", this.__wxExparserNodeId__, this.data.test) }, 5000); }, // .... onUnload(){ console.log("unload"); const that = this // 获得可以枚举的属性列表 const keys = Object.keys(that) // 加入data 因为data 不是可以枚举的属性 keys.push("data") console.log(keys); keys.map(key=>{ // 获得原本的属性描述 const property = Object.getOwnPropertyDescriptor(that, key) // 保留原有的值 const origin = that[key]; // 获得属性的get方法 有可能没有 const getter = property && property.get // 获得属性的set方法 有可能没有 const setter = property && property.set const isFunction = typeof origin === "function" // 如果是function的话 需要绑定this if(isFunction){ origin.bind(that) } const newThis = {} // 拦截属性 Object.defineProperty(that, key, { get: function(){ console.log(`调用了this.${key}的getter`); // 有getter 调用getter if(getter){ return getter.call(that) } return newThis[key] || origin }, set: function(newVal){ console.log(`调用了this.${key}的setter`); if(setter){ return setter.call(that, newVal) } newThis[key] = newVal } }) }) } }) 测试demo我们在自己项目里面测试会比较麻烦,一开始可能会有干扰,所以我这边弄了个代码片段,先校验一下这个方法是否可行,如果可行再加到自己的项目里面。 小程序代码片段
2021-05-12 - 云开发之图片压缩裁剪(CloudBase图像处理扩展实战)
1、大约半年前在论坛里寻求云开发后端图片处理方案无果,无奈退而求其次使用小程序端canvas做图片处理: https://developers.weixin.qq.com/community/develop/doc/000c00a3d74758caca2a2b3ef5b400 (寻求方案发帖) 2、canvas做图片处理,代码量比较大,对手机性能要求比较高,而且如果一次处理图片多,还会偶现各种奇怪的不稳定问题。 3、最近iPhone微信更新到7.0.20更是直接不能使用了: https://developers.weixin.qq.com/community/develop/doc/000cc4b48a4378003b7b2f97d51400 (bug反馈发帖) 4、更换图片处理的方案刻不容缓,上次云开发峰会上陈宇明大佬分享案例中提到一嘴CloudBase的相关支持,于是翻到了相关文章,一步步跟着操作,在此感谢大佬指路: https://developers.weixin.qq.com/community/develop/article/doc/0004ec150708d0b57d5bd532a53413 (大佬文章) https://cloud.tencent.com/document/product/876/42103 (开发指南) 5、本人电商项目中有多处图片处理需求,比较典型的一个业务是上传商品主图,当用户任意上传一个图片后,自动居中裁剪生成一大一小两张正方形的图,大的用在详情页,小的用在列表页。CloudBase支持两种方式:获取图片时处理、持久化图像处理。本人业务采用后者。 6、代码示例: 小程序端选择图片,上传到云存储 wx.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'], success: res => { const tempFilePaths = res.tempFilePaths const tempFile = tempFilePaths[0] let pictureLarge = tempFile let fileName = pictureLarge.split('.') let format = fileName[fileName.length -1] let cloudPath = 'products/sellerId/original-' + (new Date()).valueOf() + (format.length < 5 ? '.' + format : '') wx.cloud.uploadFile({ cloudPath: cloudPath, filePath: pictureLarge, success: res => { const pictureOrignial = res.fileID wx.cloud.callFunction({ name: 'addProduct', data: { operation: 'addPicture', pictureOrignial, cloudPath } }).then(res => { if (res.result.errCode) { wx.showModal({ title: '主图处理失败', content: res.result.errMsg, showCancel: false, confirmColor: '#67ACEB' }) } else { //拿到云文件ID做后续处理 res.result.picture } }).catch(err => { console.error(err) wx.showModal({ title: '主图处理失败', content: '主图处理失败,请重试', showCancel: false, confirmColor: '#67ACEB' }) }) }, fail: err => { console.error(err) wx.showModal({ title: '主图上传失败', content: '主图上传失败,请重试', showCancel: false, confirmColor: '#67ACEB' }) } }) } }) 云函数端处理图片,先放大到最小边大于1125px,再分别裁剪出1125px和258px的两张图,存到同一目录下,返回云文件ID 先安装包: npm install --save @cloudbase/extension-ci@latest 云函数: // 云函数入口文件 const cloud = require('wx-server-sdk') const extCi = require("@cloudbase/extension-ci") const tcb = require("tcb-admin-node") cloud.init({ env: cloud.DYNAMIC_CURRENT_ENV }) tcb.init({ env: cloud.DYNAMIC_CURRENT_ENV }) tcb.registerExtension(extCi) // 云函数入口函数 exports.main = async (event) => { const wxContext = cloud.getWXContext() if (event.operation == 'addPicture') { return await addPicture(event.pictureOrignial, event.cloudPath) } else { } } async function addPicture(pictureOrignial, cloudPath) { //process picture const res = await process(cloudPath) if (res.errCode !== 0) { return { errCode: 100, errMsg: '商品主图处理失败' } } else { const pictureIDLarge = pictureOrignial.replace(/original/, 'large') const pictureID = pictureOrignial.replace(/original/, 'normal') return { errCode: 0, picture: { pictureIDLarge, pictureID } } } } async function process(cloudPath) { try { const opts = { //scale to 1125 rules: [ { fileid: '/' + cloudPath, // 处理结果的文件路径,如以’/’开头,则存入指定文件夹中,否则,存入原图文件存储的同目录 rule: "imageMogr2/thumbnail/!1125x1125r" // 处理样式参数,与下载时处理图像在url拼接的参数一致 } ] } await tcb.invokeExtension("CloudInfinite", { action: "ImageProcess", cloudPath: cloudPath, // 图像在云存储中的路径,与tcb.uploadFile中一致 operations: opts }) } catch (err) { return JSON.stringify(err, null, 4) } try { const opts = { rules: [ //crop large { fileid: '/' + cloudPath.replace(/original/, 'large'), rule: "imageView2/1/w/1125/h/1125/q/85" }, //crop normal { fileid: '/' + cloudPath.replace(/original/, 'normal'), rule: "imageView2/1/w/258/h/258/q/85" } ] } await tcb.invokeExtension("CloudInfinite", { action: "ImageProcess", cloudPath: cloudPath, // 图像在云存储中的路径,与tcb.uploadFile中一致 operations: opts }) } catch (err) { return JSON.stringify(err, null, 4) } return { "errCode": 0, "errMsg": "ok" } }
2022-04-26 - 将小程序原生异步函数promisify后,在async/await中使用
目前,小程序中支持使用async/await有三种模式: 1、不勾选es6转es5,不勾选增强编译;该模式是纯es7的async/await,需要基础库高版本。 2、勾选es6转es5,勾选增强编译;一般是因为调用了第三方的es5插件,通过增强编译支持async/await。 3、勾选es6转es5,不勾选增强编译;手工引入runtime.js支持async/await。 据最近更新情况,原生的函数已经大部分同时原生支持同步化了,不需要本方案转化了,直接加上await即可;比如wx.chooseImage、wx.showModal。。。具体有哪些,可以自己试。 如果只是wx.request的同步化,可参考: https://developers.weixin.qq.com/community/develop/article/doc/0004cc839407a069f77a416c056813 app.js代码: function promisify(api) { return (opt, ...arg) => { return new Promise((resolve, reject) => { api(Object.assign({}, opt, { success: resolve, fail: reject }), ...arg) }) } } App({ globalData: {}, chooseImage: promisify(wx.chooseImage), request: promisify(wx.request), getUserInfo: promisify(wx.getUserInfo), onLaunch: function () { }, }) 某page的index.js代码: const app = getApp() testAsync: async function(){ let res = await app.chooseImage() console.log(res) res = await app.request({url:'url',method:'POST',data:{x:0,y:1}}) console.log(res) }, [图片]
2020-10-20 - 云函数获取openid
代码如下: app.js: //如果担心openid的安全,就用这个函数 getCloudOpenid: async function () { return this.openid = this.openid || (await wx.cloud.callFunction({name: 'login'})).result.OPENID }, //最佳方案。 getOpenid: async function () { (this.openid = this.openid || wx.getStorageSync('openid')) || wx.setStorageSync('openid', await this.getCloudOpenid()) return this.openid }, 任何page: onLoad: async function () { console.log(this.openid = await getApp().getOpenid()) }, //在本page的其他函数里获得openid。 yourFunc: function(){ console.log(this.openid) } 云函数login: const cloud = require('wx-server-sdk') cloud.init() exports.main=async()=>{return cloud.getWXContext()}
2020-10-18 - 一个通用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