- Service Worker学习与实践(三)——消息推送
在上一篇文章Service Worker学习与实践(二)——PWA简介中,已经讲到[代码]PWA[代码]的起源,优势与劣势,并通过一个简单的例子说明了如何在桌面端和移动端将一个[代码]PWA[代码]安装到桌面上,这篇文章,将通过一个例子阐述如何使用[代码]Service Worker[代码]的消息推送功能,并配合[代码]PWA[代码]技术,带来原生应用般的消息推送体验。 Notification 说到底,[代码]PWA[代码]的消息推送也是服务端推送的一种,常见的服务端推送方法,例如广泛使用的轮询、长轮询、[代码]Web Socket[代码]等,说到底,都是客户端与服务端之间的通信,在[代码]Service Worker[代码]中,客户端接收到通知,是基于Notification来进行推送的。 那么,我们来看一下,如何直接使用[代码]Notification[代码]来发送一条推送呢?下面是一段示例代码: [代码]// 在主线程中使用 let notification = new Notification('您有新消息', { body: 'Hello Service Worker', icon: './images/logo/logo152.png', }); notification.onclick = function() { console.log('点击了'); }; [代码] 在控制台敲下上述代码后,则会弹出以下通知: [图片] 然而,[代码]Notification[代码]这个[代码]API[代码],只推荐在[代码]Service Worker[代码]中使用,不推荐在主线程中使用,在[代码]Service Worker[代码]中的使用方法为: [代码]// 添加notificationclick事件监听器,在点击notification时触发 self.addEventListener('notificationclick', function(event) { // 关闭当前的弹窗 event.notification.close(); // 在新窗口打开页面 event.waitUntil( clients.openWindow('https://google.com') ); }); // 触发一条通知 self.registration.showNotification('您有新消息', { body: 'Hello Service Worker', icon: './images/logo/logo152.png', }); [代码] 读者可以在MDN Web Docs关于[代码]Notification[代码]在[代码]Service Worker[代码]中的相关用法,在本文就不浪费大量篇幅来进行较为详细的阐述了。 申请推送的权限 如果浏览器直接给所有开发者开放向用户推送通知的权限,那么势必用户会受到大量垃圾信息的骚扰,因此这一权限是需要申请的,如果用户禁止了消息推送,开发者是没有权利向用户发起消息推送的。我们可以通过serviceWorkerRegistration.pushManager.getSubscription方法查看用户是否已经允许推送通知的权限。修改[代码]sw-register.js[代码]中的代码: [代码]if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(function (swReg) { swReg.pushManager.getSubscription() .then(function(subscription) { if (subscription) { console.log(JSON.stringify(subscription)); } else { console.log('没有订阅'); subscribeUser(swReg); } }); }); } [代码] 上面的代码调用了[代码]swReg.pushManager[代码]的[代码]getSubscription[代码],可以知道用户是否已经允许进行消息推送,如果[代码]swReg.pushManager.getSubscription[代码]的[代码]Promise[代码]被[代码]reject[代码]了,则表示用户还没有订阅我们的消息,调用[代码]subscribeUser[代码]方法,向用户申请消息推送的权限: [代码]function subscribeUser(swReg) { const applicationServerKey = urlB64ToUint8Array(applicationServerPublicKey); swReg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey }) .then(function(subscription) { console.log(JSON.stringify(subscription)); }) .catch(function(err) { console.log('订阅失败: ', err); }); } [代码] 上面的代码通过serviceWorkerRegistration.pushManager.subscribe向用户发起订阅的权限,这个方法返回一个[代码]Promise[代码],如果[代码]Promise[代码]被[代码]resolve[代码],则表示用户允许应用程序推送消息,反之,如果被[代码]reject[代码],则表示用户拒绝了应用程序的消息推送。如下图所示: [图片] [代码]serviceWorkerRegistration.pushManager.subscribe[代码]方法通常需要传递两个参数: [代码]userVisibleOnly[代码],这个参数通常被设置为[代码]true[代码],用来表示后续信息是否展示给用户。 [代码]applicationServerKey[代码],这个参数是一个[代码]Uint8Array[代码],用于加密服务端的推送信息,防止中间人攻击,会话被攻击者篡改。这一参数是由服务端生成的公钥,通过[代码]urlB64ToUint8Array[代码]转换的,这一函数通常是固定的,如下所示: [代码]function urlB64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/\-/g, '+') .replace(/_/g, '/'); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; ++i) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } [代码] 关于服务端公钥如何获取,在文章后续会有相关阐述。 处理拒绝的权限 如果在调用[代码]serviceWorkerRegistration.pushManager.subscribe[代码]后,用户拒绝了推送权限,同样也可以在应用程序中,通过Notification.permission获取到这一状态,[代码]Notification.permission[代码]有以下三个取值,: [代码]granted[代码]:用户已经明确的授予了显示通知的权限。 [代码]denied[代码]:用户已经明确的拒绝了显示通知的权限。 [代码]default[代码]:用户还未被询问是否授权,在应用程序中,这种情况下权限将视为[代码]denied[代码]。 [代码]if (Notification.permission === 'granted') { // 用户允许消息推送 } else { // 还不允许消息推送,向用户申请消息推送的权限 } [代码] 密钥生成 上述代码中的[代码]applicationServerPublicKey[代码]通常情况下是由服务端生成的公钥,在页面初始化的时候就会返回给客户端,服务端会保存每个用户对应的公钥与私钥,以便进行消息推送。 在我的示例演示中,我们可以使用[代码]Google[代码]配套的实验网站web-push-codelab生成公钥与私钥,以便发送消息通知: [图片] 发送推送 在[代码]Service Worker[代码]中,通过监听[代码]push[代码]事件来处理消息推送: [代码]self.addEventListener('push', function(event) { const title = event.data.text(); const options = { body: event.data.text(), icon: './images/logo/logo512.png', }; event.waitUntil(self.registration.showNotification(title, options)); }); [代码] 在上面的代码中,在[代码]push[代码]事件回调中,通过[代码]event.data.text()[代码]拿到消息推送的文本,然后调用上面所说的[代码]self.registration.showNotification[代码]来展示消息推送。 服务端发送 那么,如何在服务端识别指定的用户,向其发送对应的消息推送呢? 在调用[代码]swReg.pushManager.subscribe[代码]方法后,如果用户是允许消息推送的,那么该函数返回的[代码]Promise[代码]将会[代码]resolve[代码],在[代码]then[代码]中获取到对应的[代码]subscription[代码]。 [代码]subscription[代码]一般是下面的格式: [代码]{ "endpoint": "https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E", "expirationTime": null, "keys": { "p256dh": "BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU", "auth": "XGWy-wlmrAw3Be818GLZ8Q" } } [代码] 使用[代码]Google[代码]配套的实验网站web-push-codelab,发送消息推送。 [图片] web-push 在服务端,使用web-push-libs,实现公钥与私钥的生成,消息推送功能,Node.js版本。 [代码]const webpush = require('web-push'); // VAPID keys should only be generated only once. const vapidKeys = webpush.generateVAPIDKeys(); webpush.setGCMAPIKey('<Your GCM API Key Here>'); webpush.setVapidDetails( 'mailto:example@yourdomain.org', vapidKeys.publicKey, vapidKeys.privateKey ); // pushSubscription是前端通过swReg.pushManager.subscribe获取到的subscription const pushSubscription = { endpoint: '.....', keys: { auth: '.....', p256dh: '.....' } }; webpush.sendNotification(pushSubscription, 'Your Push Payload Text'); [代码] 上面的代码中,[代码]GCM API Key[代码]需要在Firebase console中申请,申请教程可参考这篇博文。 在这个我写的示例[代码]Demo[代码]中,我把[代码]subscription[代码]写死了: [代码]const webpush = require('web-push'); webpush.setVapidDetails( 'mailto:503908971@qq.com', 'BCx1qqSFCJBRGZzPaFa8AbvjxtuJj9zJie_pXom2HI-gisHUUnlAFzrkb-W1_IisYnTcUXHmc5Ie3F58M1uYhZU', 'g5pubRphHZkMQhvgjdnVvq8_4bs7qmCrlX-zWAJE9u8' ); const subscription = { "endpoint": "https://fcm.googleapis.com/fcm/send/cSEJGmI_x2s:APA91bHzRHllE6tNoEHqjHQSlLpcQHeiGr7X78EIa1QrUPFqDGDM_4RVKNxoLPV3_AaCCejR4uwUawBKYcQLmLpUrCUoZetQ9pVzQCJSomB5BvoFZBzkSnUb-ALm4D1lqwV9w_uP3M0E", "expirationTime": null, "keys": { "p256dh": "BDOx1ZTtsFL2ncSN17Bu7-Wl_1Z7yIiI-lKhtoJ2dAZMToGz-XtQOe6cuMLMa3I8FoqPfcPy232uAqoISB4Z-UU", "auth": "XGWy-wlmrAw3Be818GLZ8Q" } }; webpush.sendNotification(subscription, 'Counterxing'); [代码] 交互响应 默认情况下,推送的消息点击后是没有对应的交互的,配合clients API可以实现一些类似于原生应用的交互,这里参考了这篇博文的实现: [代码]Service Worker[代码]中的[代码]self.clients[代码]对象提供了[代码]Client[代码]的访问,[代码]Client[代码]接口表示一个可执行的上下文,如[代码]Worker[代码]或[代码]SharedWorker[代码]。[代码]Window[代码]客户端由更具体的[代码]WindowClient[代码]表示。 你可以从[代码]Clients.matchAll()[代码]和[代码]Clients.get()[代码]等方法获取[代码]Client/WindowClient[代码]对象。 新窗口打开 使用[代码]clients.openWindow[代码]在新窗口打开一个网页: [代码]self.addEventListener('notificationclick', function(event) { event.notification.close(); // 新窗口打开 event.waitUntil( clients.openWindow('https://google.com/') ); }); [代码] 聚焦已经打开的页面 利用[代码]cilents[代码]提供的相关[代码]API[代码]获取,当前浏览器已经打开的页面[代码]URLs[代码]。不过这些[代码]URLs[代码]只能是和你[代码]SW[代码]同域的。然后,通过匹配[代码]URL[代码],通过[代码]matchingClient.focus()[代码]进行聚焦。没有的话,则新打开页面即可。 [代码]self.addEventListener('notificationclick', function(event) { event.notification.close(); const urlToOpen = self.location.origin + '/index.html'; const promiseChain = clients.matchAll({ type: 'window', includeUncontrolled: true }) .then((windowClients) => { let matchingClient = null; for (let i = 0; i < windowClients.length; i++) { const windowClient = windowClients[i]; if (windowClient.url === urlToOpen) { matchingClient = windowClient; break; } } if (matchingClient) { return matchingClient.focus(); } else { return clients.openWindow(urlToOpen); } }); event.waitUntil(promiseChain); }); [代码] 检测是否需要推送 如果用户已经停留在当前的网页,那我们可能就不需要推送了,那么针对于这种情况,我们应该怎么检测用户是否正在网页上呢? 通过[代码]windowClient.focused[代码]可以检测到当前的[代码]Client[代码]是否处于聚焦状态。 [代码]self.addEventListener('push', function(event) { const promiseChain = clients.matchAll({ type: 'window', includeUncontrolled: true }) .then((windowClients) => { let mustShowNotification = true; for (let i = 0; i < windowClients.length; i++) { const windowClient = windowClients[i]; if (windowClient.focused) { mustShowNotification = false; break; } } return mustShowNotification; }) .then((mustShowNotification) => { if (mustShowNotification) { const title = event.data.text(); const options = { body: event.data.text(), icon: './images/logo/logo512.png', }; return self.registration.showNotification(title, options); } else { console.log('用户已经聚焦于当前页面,不需要推送。'); } }); }); [代码] 合并消息 该场景的主要针对消息的合并。比如,当只有一条消息时,可以直接推送,那如果该用户又发送一个消息呢? 这时候,比较好的用户体验是直接将推送合并为一个,然后替换即可。 那么,此时我们就需要获得当前已经展示的推送消息,这里主要通过[代码]registration.getNotifications() API[代码]来进行获取。该[代码]API[代码]返回的也是一个[代码]Promise[代码]对象。通过[代码]Promise[代码]在[代码]resolve[代码]后拿到的[代码]notifications[代码],判断其[代码]length[代码],进行消息合并。 [代码]self.addEventListener('push', function(event) { // ... .then((mustShowNotification) => { if (mustShowNotification) { return registration.getNotifications() .then(notifications => { let options = { icon: './images/logo/logo512.png', badge: './images/logo/logo512.png' }; let title = event.data.text(); if (notifications.length) { options.body = `您有${notifications.length}条新消息`; } else { options.body = event.data.text(); } return self.registration.showNotification(title, options); }); } else { console.log('用户已经聚焦于当前页面,不需要推送。'); } }); // ... }); [代码] [图片] 小结 本文通过一个简单的例子,讲述了[代码]Service Worker[代码]中消息推送的原理。[代码]Service Worker[代码]中的消息推送是基于[代码]Notification API[代码]的,这一[代码]API[代码]的使用首先需要用户授权,通过在[代码]Service Worker[代码]注册时的[代码]serviceWorkerRegistration.pushManager.subscribe[代码]方法来向用户申请权限,如果用户拒绝了消息推送,应用程序也需要相关处理。 消息推送是基于谷歌云服务的,因此,在国内,收到[代码]GFW[代码]的限制,这一功能的支持并不好,[代码]Google[代码]提供了一系列推送相关的库,例如[代码]Node.js[代码]中,使用web-push来实现。一般原理是:在服务端生成公钥和私钥,并针对用户将其公钥和私钥存储到服务端,客户端只存储公钥。[代码]Service Worker[代码]的[代码]swReg.pushManager.subscribe[代码]可以获取到[代码]subscription[代码],并发送给服务端,服务端利用[代码]subscription[代码]向指定的用户发起消息推送。 消息推送功能可以配合[代码]clients API[代码]做特殊处理。 如果用户安装了[代码]PWA[代码]应用,即使用户关闭了应用程序,[代码]Service Worker[代码]也在运行,即使用户未打开应用程序,也会收到消息通知。 在下一篇文章中,我将尝试在我所在的项目中使用[代码]Service Worker[代码],并通过[代码]Webpack[代码]和[代码]Workbox[代码]配置来讲述[代码]Service Worker[代码]的最佳实践。
2019-04-10 - Service Worker学习与实践(二)——PWA简介
在上一篇文章Service Worker学习与实践(一)——离线缓存中,已经讲到[代码]Service Worker[代码]的生命周期、如何创建、激活、更新[代码]Web[代码]应用程序的[代码]Service Worker[代码],并且给出了一个简单的示例来说明使用[代码]Service Worker[代码]来实现离线缓存的原理,在这篇文章里,主要是对[代码]Service Worker[代码]实现原生应用程序的能力做出一定解析,下一篇文章,将通过一个例子阐述如何使用[代码]Service Worker[代码]实现消息推送功能。 Progressive Web Apps(PWA) [代码]Progressive Web App[代码], 简称 PWA,是提升[代码]Web App[代码]的体验的一种新方法,能给用户原生应用的体验。 [代码]PWA[代码]能做到原生应用的体验不是靠特指某一项技术,而是经过应用一些新技术进行改进,在安全、性能和体验三个方面都有很大提升,[代码]PWA[代码]本质上是[代码]Web App[代码],借助一些新技术也具备了[代码]Native App[代码]的一些特性,兼具[代码]Web App[代码]和[代码]Native App[代码]的优点。 [代码]PWA[代码]的主要特点包括下面三点: 可靠 - 即使在不稳定的网络环境下,也能瞬间加载并展现 体验 - 快速响应,并且有平滑的动画响应用户的操作 粘性 - 像设备上的原生应用,具有沉浸式的用户体验,用户可以添加到桌面 [代码]PWA[代码]本身强调渐进式,并不要求一次性达到安全、性能和体验上的所有要求,开发者可以通过PWA Checklist查看现有的特征。 通过上面的[代码]PWA Checklist[代码],总结起来,[代码]PWA[代码]大致有以下的优势: 体验:通过[代码]Service Worker[代码]配合[代码]Cache Storage API[代码],保证了[代码]PWA[代码]首屏的加载效率,甚至配合本地存储可以支持离线应用; 粘性:[代码]PWA[代码]是可以安装的,用户点击安装到桌面后,会在桌面创建一个 PWA 应用,并且不需要从应用商店下载,可以借助[代码]Web App Manifest[代码]提供给用户和[代码]Native App[代码]一样的沉浸式体验,可以通过给用户发送离线通知,让用户回流; 渐进式:适用于大多数现代浏览器,随着浏览器版本的迭代,其功能是渐进增强的; 无版本问题:如[代码]Web[代码]应用的优势,更新版本只需要更新应用程序对应的静态文件即可,[代码]Service Worker[代码]会帮助我们进行更新; 跨平台:[代码]Windows[代码]、[代码]Mac OSX[代码]、[代码]Android[代码]、[代码]IOS[代码],一套代码,多处使用; 消息推送:即使用户已经关闭应用程序,仍然可以对用户进行消息推送; 总的说来,只要[代码]Web[代码]应用支持的功能,对于[代码]PWA[代码]而言,基本都支持,此外,还提供了原生能力。 使用[代码]PWA manifest[代码]添加桌面入口 注意这里说的[代码]manifest[代码]不是指的[代码]manifest[代码]缓存,这个[代码]manifest[代码]是一个[代码]JSON[代码]文件,开发者可以利用它控制在用户想要看到应用的区域(例如移动设备主屏幕)中如何向用户显示网络应用或网站,指示用户可以启动哪些功能,以及定义其在启动时的外观。 [代码]manifest[代码]提供了将网站书签保存到设备主屏幕的功能。当网站以这种方式启动时: 它具有唯一的图标和名称,以便用户将其与其他网站区分开来。 它会在下载资源或从缓存恢复资源时向用户显示某些信息。 它会向浏览器提供默认显示特性,以避免网站资源可用时的过渡过于生硬。 下面是我的博客网站的[代码]manifest.json[代码]文件,作为桌面入口配置文件的示例: [代码]{ "name": "Counterxing", "short_name": "Counterxing", "description": "Why did you encounter me?", "start_url": "/index.html", "display": "standalone", "orientation": "any", "background_color": "#ACE", "theme_color": "#ACE", "icons": [{ "src": "/images/logo/logo072.png", "sizes": "72x72", "type": "image/png" }, { "src": "/images/logo/logo152.png", "sizes": "152x152", "type": "image/png" }, { "src": "/images/logo/logo192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/images/logo/logo256.png", "sizes": "256x256", "type": "image/png" }, { "src": "/images/logo/logo512.png", "sizes": "512x512", "type": "image/png" }] } [代码] 上面的字段含义也不用多解释了,大致就是启动的[代码]icon[代码]样式,应用名称、简写名称与描述等,其中必须确保有[代码]short_name[代码]和[代码]name[代码]。此外,最好设定好[代码]start_url[代码],表示启动的根页面路径,如果不添加,则是使用当前路径。 [代码]display[代码]为[代码]standalone[代码],则会隐藏浏览器的[代码]UI[代码]界面,如果设置[代码]display[代码]为[代码]browser[代码],则启动时保存浏览器的[代码]UI[代码]界面。 [代码]orientation[代码]表示启动时的方向,横屏、竖屏等,具体参数值可参考文档。 [代码]background_color[代码]和[代码]theme_color[代码]表示应用程序的背景颜色和主题颜色。 在创建好[代码]manifest.json[代码]后,将、使用[代码]link[代码]标签添加到应用程序的所有页面上,[代码]<link rel="manifest" href="/manifest.json">[代码] 安装到桌面 桌面端(以[代码]Mac OSX[代码]为例) 只有注册、激活了[代码]Service Worker[代码]的网站才能够安装到桌面,在[代码]Chrome 70[代码]版本之前,需要手动开启实验性功能,步骤如下: 进入[代码]chrome://flags[代码] 找到[代码]Desktop PWAs[代码],选择[代码]Enabled[代码] [图片] 此时,进入一个支持[代码]PWA[代码]的网站(例如Google I/O),在[代码]Chrome[代码]浏览器右上角,点击安装。即可安装到桌面。这里以我的博客为例: 可以到awesome-pwa查找目前支持[代码]PWA[代码]的网站列表 [图片] 接着点击安装: [图片] 这样,一个[代码]PWA[代码]应用就安装到你的机器上了,这里我的操作系统为[代码]Mac OSX[代码],应用程序可以通过[代码]Launchpad[代码]打开,在[代码]Windows[代码]也是同理的,会被安装到桌面上,可通过开始菜单找到应用程序。 [图片] 打开应用程序,发现其与原始应用几乎没有任何差距: [图片] Windows与上述方法类似,这里就不做过多阐述 [代码]Windows 10[代码]下安装[代码]PWA[代码]效果图: [图片] 移动端(以[代码]IOS[代码]为例) 由于当初苹果推出[代码]PWA[代码]时,并没有一个统一的[代码]manifest[代码]的规范,最开始的设计是通过[代码]meta[代码]和[代码]link[代码]标签来设定应用的对应参数的,所以,在移动端上的[代码]PWA[代码]应用,为了兼容[代码]Windows Phone[代码]和[代码]iPhone[代码],需要在所有页面的[代码]HTML[代码]的[代码]head[代码]中添加以下的标签: [代码]<meta name="msapplication-TileImage" content="./images/logo/logo152.png"> <meta name="msapplication-TileColor" content="#2F3BA2"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-title" content="Counterxing"> <link rel="apple-touch-icon" href="./images/logo/logo152.png"> [代码] 添加好后,就可以体验我们的[代码]PWA[代码]了! [代码]IOS[代码]在[代码]11.3[代码]版本之后也支持[代码]PWA[代码]了,知道这一消息的我,卸载了手机上很多软件,立刻体验上了[代码]PWA[代码]。 这里以豆瓣移动端为例使用[代码]Safiri[代码]浏览器打开一个网站,点击下方分享图标,选择添加到主屏幕。 [图片] [图片] 然后在新弹出的一个浏览器页面,选择添加: [图片] 就以上简短的步骤,移动端上的一个[代码]PWA[代码]桌面应用就添加好了,赶紧体验吧! 小结 本文是笔者写的[代码]Service Worker[代码]学习与实践系列文章的第二篇,主要讲述的是配合[代码]Service Worker[代码]使用的[代码]PWA[代码]的优势,如何配置[代码]manifest.json[代码]文件来实现将[代码]PWA[代码]安装到桌面,并通过[代码]Mac OSX[代码]和[代码]IOS[代码]如何安装[代码]PWA[代码]到桌面的详细步骤,阐述了如何配置[代码]PWA[代码],使其方便地安装到桌面上。 下一篇文章中,主要讲述[代码]Service Worker[代码]在[代码]PWA[代码]实践中的重要能力:[代码]Web Push[代码]。
2019-04-10 - Service Worker学习与实践(一)——离线缓存
什么是[代码]Service Worker[代码] [代码]Service Worker[代码]本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步[代码]API[代码]。 [代码]Service Worker[代码]的本质是一个[代码]Web Worker[代码],它独立于[代码]JavaScript[代码]主线程,因此它不能直接访问[代码]DOM[代码],也不能直接访问[代码]window[代码]对象,但是,[代码]Service Worker[代码]可以访问[代码]navigator[代码]对象,也可以通过消息传递的方式(postMessage)与[代码]JavaScript[代码]主线程进行通信。 [代码]Service Worker[代码]是一个网络代理,它可以控制[代码]Web[代码]页面的所有网络请求。 [代码]Service Worker[代码]具有自身的生命周期,使用好[代码]Service Worker[代码]的关键是灵活控制其生命周期。 [代码]Service Worker[代码]的作用 用于浏览器缓存 实现离线[代码]Web APP[代码] 消息推送 [代码]Service Worker[代码]兼容性 [代码]Service Worker[代码]是现代浏览器的一个高级特性,它依赖于[代码]fetch API[代码]、[代码]Cache Storage[代码]、[代码]Promise[代码]等,其中,[代码]Cache[代码]提供了[代码]Request / Response[代码]对象对的存储机制,[代码]Cache Storage[代码]存储多个[代码]Cache[代码]。 [图片] 示例 在了解[代码]Service Worker[代码]的原理之前,先来看一段[代码]Service Worker[代码]的示例: [代码]self.importScripts('./serviceworker-cache-polyfill.js'); var urlsToCache = [ '/', '/index.js', '/style.css', '/favicon.ico', ]; var CACHE_NAME = 'counterxing'; self.addEventListener('install', function(event) { self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME) .then(function(cache) { return cache.addAll(urlsToCache); }) ); }); self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { if (response) { return response; } return fetch(event.request); }) ); }); self.addEventListener('activate', function(event) { var cacheWhitelist = ['counterxing']; event.waitUntil( caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) ); }); [代码] 下面开始逐段逐段地分析,揭开[代码]Service Worker[代码]的神秘面纱: [代码]polyfill[代码] 首先看第一行:[代码]self.importScripts('./serviceworker-cache-polyfill.js');[代码],这里引入了Cache API的一个polyfill,这个[代码]polyfill[代码]支持使得在较低版本的浏览器下也可以使用[代码]Cache Storage API[代码]。想要实现[代码]Service Worker[代码]的功能,一般都需要搭配[代码]Cache API[代码]代理网络请求到缓存中。 在[代码]Service Worker[代码]线程中,使用[代码]importScripts[代码]引入[代码]polyfill[代码]脚本,目的是对低版本浏览器的兼容。 [代码]Cache Resources List[代码] And [代码]Cache Name[代码] 之后,使用一个[代码]urlsToCache[代码]列表来声明需要缓存的静态资源,再使用一个变量[代码]CACHE_NAME[代码]来确定当前缓存的[代码]Cache Storage Name[代码],这里可以理解成[代码]Cache Storage[代码]是一个[代码]DB[代码],而[代码]CACHE_NAME[代码]则是[代码]DB[代码]名: [代码]var urlsToCache = [ '/', '/index.js', '/style.css', '/favicon.ico', ]; var CACHE_NAME = 'counterxing'; [代码] [代码]Lifecycle[代码] [代码]Service Worker[代码]独立于浏览器[代码]JavaScript[代码]主线程,有它自己独立的生命周期。 如果需要在网站上安装[代码]Service Worker[代码],则需要在[代码]JavaScript[代码]主线程中使用以下代码引入[代码]Service Worker[代码]。 [代码]if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').then(function(registration) { console.log('成功安装', registration.scope); }).catch(function(err) { console.log(err); }); } [代码] 此处,一定要注意[代码]sw.js[代码]文件的路径,在我的示例中,处于当前域根目录下,这意味着,[代码]Service Worker[代码]和网站是同源的,可以为当前网站的所有请求做代理,如果[代码]Service Worker[代码]被注册到[代码]/imaging/sw.js[代码]下,那只能代理[代码]/imaging[代码]下的网络请求。 可以使用[代码]Chrome[代码]控制台,查看当前页面的[代码]Service Worker[代码]情况: [图片] 安装完成后,[代码]Service Worker[代码]会经历以下生命周期: 下载([代码]download[代码]) 安装([代码]install[代码]) 激活([代码]activate[代码]) 用户首次访问[代码]Service Worker[代码]控制的网站或页面时,[代码]Service Worker[代码]会立刻被下载。之后至少每[代码]24[代码]小时它会被下载一次。它可能被更频繁地下载,不过每[代码]24[代码]小时一定会被下载一次,以避免不良脚本长时间生效。 在下载完成后,开始安装[代码]Service Worker[代码],在安装阶段,通常需要缓存一些我们预先声明的静态资源,在我们的示例中,通过[代码]urlsToCache[代码]预先声明。 在安装完成后,会开始进行激活,浏览器会尝试下载[代码]Service Worker[代码]脚本文件,下载成功后,会与前一次已缓存的[代码]Service Worker[代码]脚本文件做对比,如果与前一次的[代码]Service Worker[代码]脚本文件不同,证明[代码]Service Worker[代码]已经更新,会触发[代码]activate[代码]事件。完成激活。 如图所示,为[代码]Service Worker[代码]大致的生命周期: [图片] [代码]install[代码] 在安装完成后,尝试缓存一些静态资源: [代码]self.addEventListener('install', function(event) { self.skipWaiting(); event.waitUntil( caches.open(CACHE_NAME) .then(function(cache) { return cache.addAll(urlsToCache); }) ); }); [代码] 首先,[代码]self.skipWaiting()[代码]执行,告知浏览器直接跳过等待阶段,淘汰过期的[代码]sw.js[代码]的[代码]Service Worker[代码]脚本,直接开始尝试激活新的[代码]Service Worker[代码]。 然后使用[代码]caches.open[代码]打开一个[代码]Cache[代码],打开后,通过[代码]cache.addAll[代码]尝试缓存我们预先声明的静态文件。 监听[代码]fetch[代码],代理网络请求 页面的所有网络请求,都会通过[代码]Service Worker[代码]的[代码]fetch[代码]事件触发,[代码]Service Worker[代码]通过[代码]caches.match[代码]尝试从[代码]Cache[代码]中查找缓存,缓存如果命中,则直接返回缓存中的[代码]response[代码],否则,创建一个真实的网络请求。 [代码]self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { if (response) { return response; } return fetch(event.request); }) ); }); [代码] 如果我们需要在请求过程中,再向[代码]Cache Storage[代码]中添加新的缓存,可以通过[代码]cache.put[代码]方法添加,看以下例子: [代码]self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request) .then(function(response) { // 缓存命中 if (response) { return response; } // 注意,这里必须使用clone方法克隆这个请求 // 原因是response是一个Stream,为了让浏览器跟缓存都使用这个response // 必须克隆这个response,一份到浏览器,一份到缓存中缓存。 // 只能被消费一次,想要再次消费,必须clone一次 var fetchRequest = event.request.clone(); return fetch(fetchRequest).then( function(response) { // 必须是有效请求,必须是同源响应,第三方的请求,因为不可控,最好不要缓存 if (!response || response.status !== 200 || response.type !== 'basic') { return response; } // 消费过一次,又需要再克隆一次 var responseToCache = response.clone(); caches.open(CACHE_NAME) .then(function(cache) { cache.put(event.request, responseToCache); }); return response; } ); }) ); }); [代码] 在项目中,一定要注意控制缓存,接口请求一般是不推荐缓存的。所以在我自己的项目中,并没有在这里做动态的缓存方案。 [代码]activate[代码] [代码]Service Worker[代码]总有需要更新的一天,随着版本迭代,某一天,我们需要把新版本的功能发布上线,此时需要淘汰掉旧的缓存,旧的[代码]Service Worker[代码]和[代码]Cache Storage[代码]如何淘汰呢? [代码]self.addEventListener('activate', function(event) { var cacheWhitelist = ['counterxing']; event.waitUntil( caches.keys().then(function(cacheNames) { return Promise.all( cacheNames.map(function(cacheName) { if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName); } }) ); }) ); }); [代码] 首先有一个白名单,白名单中的[代码]Cache[代码]是不被淘汰的。 之后通过[代码]caches.keys()[代码]拿到所有的[代码]Cache Storage[代码],把不在白名单中的[代码]Cache[代码]淘汰。 淘汰使用[代码]caches.delete()[代码]方法。它接收[代码]cacheName[代码]作为参数,删除该[代码]cacheName[代码]所有缓存。 sw-precache-webpack-plugin sw-precache-webpack-plugin是一个[代码]webpack plugin[代码],可以通过配置的方式在[代码]webpack[代码]打包时生成我们想要的[代码]sw.js[代码]的[代码]Service Worker[代码]脚本。 一个最简单的配置如下: [代码]var path = require('path'); var SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin'); const PUBLIC_PATH = 'https://www.my-project-name.com/'; // webpack needs the trailing slash for output.publicPath module.exports = { entry: { main: path.resolve(__dirname, 'src/index'), }, output: { path: path.resolve(__dirname, 'src/bundles/'), filename: '[name]-[hash].js', publicPath: PUBLIC_PATH, }, plugins: [ new SWPrecacheWebpackPlugin( { cacheId: 'my-project-name', dontCacheBustUrlsMatching: /\.\w{8}\./, filename: 'service-worker.js', minify: true, navigateFallback: PUBLIC_PATH + 'index.html', staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/], } ), ], } [代码] 在执行[代码]webpack[代码]打包后,会生成一个名为[代码]service-worker.js[代码]文件,用于缓存[代码]webpack[代码]打包后的静态文件。 一个最简单的示例。 [代码]Service Worker Cache[代码] VS [代码]Http Cache[代码] 对比起[代码]Http Header[代码]缓存,[代码]Service Worker[代码]配合[代码]Cache Storage[代码]也有自己的优势: 缓存与更新并存:每次更新版本,借助[代码]Service Worker[代码]可以立马使用缓存返回,但与此同时可以发起请求,校验是否有新版本更新。 无侵入式:[代码]hash[代码]值实在是太难看了。 不易被冲掉:[代码]Http[代码]缓存容易被冲掉,也容易过期,而[代码]Cache Storage[代码]则不容易被冲掉。也没有过期时间的说法。 离线:借助[代码]Service Worker[代码]可以实现离线访问应用。 但是缺点是,由于[代码]Service Worker[代码]依赖于[代码]fetch API[代码]、依赖于[代码]Promise[代码]、[代码]Cache Storage[代码]等,兼容性不太好。 后话 本文只是简单总结了[代码]Service Worker[代码]的基本使用和使用[代码]Service Worker[代码]做客户端缓存的简单方式,然而,[代码]Service Worker[代码]的作用远不止于此,例如:借助[代码]Service Worker[代码]做离线应用、用于做网络应用的推送(可参考push-notifications)等。 甚至可以借助[代码]Service Worker[代码],对接口进行缓存,在我所在的项目中,其实并不会做的这么复杂。不过做接口缓存的好处是支持离线访问,对离线状态下也能正常访问我们的[代码]Web[代码]应用。 [代码]Cache Storage[代码]和[代码]Service Worker[代码]总是分不开的。[代码]Service Worker[代码]的最佳用法其实就是配合[代码]Cache Storage[代码]做离线缓存。借助于[代码]Service Worker[代码],可以轻松实现对网络请求的控制,对于不同的网络请求,采取不同的策略。例如对于[代码]Cache[代码]的策略,其实也是存在多种情况。例如可以优先使用网络请求,在网络请求失败时再使用缓存、亦可以同时使用缓存和网络请求,一方面检查请求,一方面有检查缓存,然后看两个谁快,就用谁。 优化方向:目前我所负责的DICOM项目,虽然还没有用上[代码]Service Worker[代码],但前面经过不断地优化迭代,通过从增加http层的缓存、无损压缩图像的替换、有损压缩图像的渐进加载、更换DICOM解压缩策略、使用indexed DB缓存CT图像、首屏可见速度已经从20多秒降低到5秒左右,内存占用从700M以上降低到250M左右。后期还会一直深挖这一块。主要方向之一就是service worker的替换,全站缓存静态资源。此外,高优先级的则是DICOM无损图像解压算法的最优选择与优化、cornerstone的jpg图像展示。 项目优化还在继续,力求极致性能和用户体验~
2019-04-11 - 一个通用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