渐进式 Web 应用程序的详尽指南

已发表: 2022-03-10
快速总结↬在本文中,我们将探讨浏览旧的非 PWA 网站的用户的痛点,以及 PWA 承诺让网络变得更好。 您将学习制作酷 PWA 的大部分重要技术,例如服务工作者、Web 推送通知和 IndexedDB。

那是我爸爸的生日,我想为他订一个巧克力蛋糕和一件衬衫。 我前往谷歌搜索巧克力蛋糕并点击搜索结果中的第一个链接。 有几秒钟的空白屏幕; 我不明白发生了什么。 耐心地凝视了几秒钟后,我的手机屏幕上充满了看起来很美味的蛋糕。 当我点击其中一个查看其详细信息时,我得到了一个丑陋的胖弹窗,要求我安装一个 Android 应用程序,以便在订购蛋糕时获得如丝般顺滑的体验。

那令人失望。 我的良心不允许我点击“安装”按钮。 我想做的只是点一个小蛋糕然后上路。

我单击了弹出窗口最右侧的十字图标以尽快退出。 但随后安装弹出窗口位于屏幕底部,占据了四分之一的空间。 对于易碎的 UI,向下滚动是一个挑战。 我不知何故设法点了一个荷兰蛋糕。

在这次可怕的经历之后,我的下一个挑战是为我爸爸订购一件衬衫。 和以前一样,我在 Google 上搜索衬衫。 我点击了第一个链接,眨眼间,整个内容就在我面前。 滚动很流畅。 没有安装横幅。 我感觉好像在浏览一个原生应用程序。 有一段时间,我糟糕的互联网连接中断了,但我仍然能够看到内容,而不是恐龙游戏。 即使我的互联网很糟糕,我还是设法为我父亲订购了一件衬衫和牛仔裤。 最令人惊讶的是,我收到了有关我的订单的通知。

我称之为丝般顺滑的体验。 这些人做的事情是对的。 每个网站都应该为他们的用户做这件事。 它被称为渐进式网络应用程序。

正如 Alex Russell 在他的一篇博文中所说:

“在网络上时不时会发生强大的技术在没有营销部门或精美包装的情况下出现。 他们在外围徘徊和成长,成为一小群人的老帽子,而其他人几乎看不见。 直到有人给他们起名字。”

Web 上如丝般流畅的体验,有时被称为渐进式 Web 应用程序

渐进式 Web 应用程序 (PWA) 更像是一种方法论,它涉及组合技术以制作强大的 Web 应用程序。 随着用户体验的改善,人们将在网站上花费更多时间并看到更多广告。 他们倾向于购买更多,并且通过通知更新,他们更有可能经常访问。 《金融时报》在 2011 年放弃了其原生应用程序,并使用当时可用的最佳技术构建了一个 Web 应用程序。 现在,该产品已经成长为一个成熟的 PWA。

但是,为什么,经过这么长时间,当原生应用程序做得足够好时,你会构建一个 Web 应用程序呢?

让我们看看 Google IO 17 中共享的一些指标。

跳跃后更多! 继续往下看↓

50 亿台设备连接到网络,使网络成为计算史上最大的平台。 在移动网络上,每月有 1140 万独立访问者访问排名前 1000 的网络资产,400 万访问排名前 100 的应用程序。 移动网络获得的用户数量大约是本地应用程序的四倍。 但在参与度方面,这个数字急剧下降。

用户在原生应用上平均花费 188.6 分钟,而在移动网络上仅花费 9.3 分钟。 本机应用程序利用操作系统的强大功能发送推送通知,为用户提供重要更新。 与浏览器中的网站相比,它们提供了更好的用户体验和更快的启动速度。 用户无需在网络浏览器中输入 URL,只需点击主屏幕上的应用程序图标即可。

网络上的大多数访问者不太可能回来,因此开发人员想出了一种解决方法,向他们展示横幅以安装本机应用程序,以保持他们的深度参与。 但是,用户将不得不经历安装本机应用程序二进制文件的繁琐过程。 强迫用户安装应用程序很烦人,并进一步降低了他们首先安装它的机会。 网络的机会是显而易见的。

推荐阅读Native 和 PWA:选择,而不是挑战者!

如果 Web 应用程序具有丰富的用户体验、推送通知、离线支持和即时加载,它们就可以征服世界。 这就是渐进式 Web 应用程序所做的。

PWA 提供了丰富的用户体验,因为它具有以下几个优势:

  • 快速地
    用户界面不是片状的。 滚动很流畅。 该应用程序可以快速响应用户交互。

  • 可靠的
    一个正常的网站迫使用户等待,什么也不做,而它正忙于访问服务器。 与此同时,PWA 会从缓存中即时加载数据。 即使在 2G 连接上,PWA 也能无缝工作。 每个获取资产或数据的网络请求都通过服务工作者(稍后会详细介绍),它首先验证特定请求的响应是否已经在缓存中。 当用户几乎立即获得真实内容时,即使在连接不佳的情况下,他们也会更加信任该应用程序并认为它更可靠。

  • 引人入胜
    PWA 可以在用户的​​主屏幕上获得一席之地。 它通过提供全屏工作区来提供类似原生应用程序的体验。 它利用推送通知来保持用户的参与度。

既然我们知道了 PWA 带来了什么,那么让我们来详细了解一下是什么让 PWA 比原生应用程序更具优势。 PWA 是使用服务工作者、Web 应用程序清单、推送通知和用于缓存的 IndexedDB/本地数据结构等技术构建的。 让我们详细研究一下。

服务人员

Service Worker 是一个在后台运行的 JavaScript 文件,不会干扰用户的交互。 对服务器的所有 GET 请求都通过 service worker。 它就像一个客户端代理。 通过拦截网络请求,它可以完全控制发送回客户端的响应。 PWA 会立即加载,因为服务人员通过缓存中的数据响应来消除对网络的依赖。

Service Worker 只能拦截其范围内的网络请求。 例如,根范围的服务工作者可以拦截来自网页的所有获取请求。 服务工作者作为事件驱动系统运行。 它在不需要时进入休眠状态,从而节省内存。 要在 Web 应用程序中使用服务工作者,我们首先必须使用 JavaScript 在页面上注册它。

 (function main () { /* navigator is a WEB API that allows scripts to register themselves and carry out their activities. */ if ('serviceWorker' in navigator) { console.log('Service Worker is supported in your browser') /* register method takes in the path of service worker file and returns a promises, which returns the registration object */ navigator.serviceWorker.register('./service-worker.js').then (registration => { console.log('Service Worker is registered!') }) } else { console.log('Service Worker is not supported in your browser') } })()

我们首先检查浏览器是否支持服务工作者。 为了在 web 应用程序中注册 service worker,我们将其 URL 作为参数提供给register函数,在navigator.serviceWorker中可用( navigator是一个 web API,允许脚本注册自己并执行它们的活动)。 服务工作者只注册一次。 注册不会在每次页面加载时发生。 仅当现有激活的 service worker 与较新的 service worker 之间存在字节差异或其 URL 已更改时,浏览器才会下载 service worker 文件 ( ./service-worker.js )。

上面的 service worker 将拦截所有来自根 ( / ) 的请求。 为了限制服务工作者的范围,我们将传递一个可选参数,其中一个键作为范围。

 if ('serviceWorker' in navigator) { /* register method takes in an optional second parameter as an object. To restrict the scope of a service worker, the scope should be provided. scope: '/books' will intercept requests with '/books' in the url. */ navigator.serviceWorker.register('./service-worker.js', { scope: '/books' }).then(registration => { console.log('Service Worker for scope /books is registered', registration) }) }

上面的 service worker 将拦截 URL 中包含/books的请求。 例如,它不会拦截带有/products的请求,但它可以很好地拦截带有/books/products的请求。

如前所述,服务工作者作为事件驱动系统运行。 它监听事件(安装、激活、获取、推送)并相应地调用相应的事件处理程序。 其中一些事件是服务工作者生命周期的一部分,服务工作者依次经历这些事件以被激活。

安装

一旦成功注册服务工作者,就会触发安装事件。 这是进行初始化工作的好地方,例如设置缓存或在 IndexedDB 中创建对象存储。 (一旦我们深入了解 IndexedDB 的详细信息,您会更了解它。现在,我们只能说它是一个键值对结构。)

 self.addEventListener('install', (event) => { let CACHE_NAME = 'xyz-cache' let urlsToCache = [ '/', '/styles/main.css', '/scripts/bundle.js' ] event.waitUntil( /* open method available on caches, takes in the name of cache as the first parameter. It returns a promise that resolves to the instance of cache All the URLS above can be added to cache using the addAll method. */ caches.open(CACHE_NAME) .then (cache => cache.addAll(urlsToCache)) ) })

在这里,我们缓存了一些文件,以便下一次加载是即时的。 self指的是服务工作者实例。 event.waitUntil让 service worker 等到它里面的所有代码都完成执行。

激活

一旦安装了 service worker,它还不能监听 fetch 请求。 相反,触发了一个activate事件。 如果在同一范围内的网站上没有活动的 Service Worker 正在运行,则安装的 Service Worker 会立即被激活。 但是,如果一个网站已经有一个活跃的 service worker,那么新 service worker 的激活会被延迟,直到旧 service worker 上的所有选项卡都关闭。 这是有道理的,因为旧的服务工作者可能正在使用现在在较新的服务工作者中修改的缓存实例。 因此,激活步骤是摆脱旧缓存的好地方。

 self.addEventListener('activate', (event) => { let cacheWhitelist = ['products-v2'] // products-v2 is the name of the new cache event.waitUntil( caches.keys().then (cacheNames => { return Promise.all( cacheNames.map( cacheName => { /* Deleting all the caches except the ones that are in cacheWhitelist array */ if (cacheWhitelist.indexOf(cacheName) === -1) { return caches.delete(cacheName) } }) ) }) ) })

在上面的代码中,我们正在删除旧的缓存。 如果缓存的名称与cacheWhitelist不匹配,则将其删除。 要跳过等待阶段并立即激活服务工作者,我们使用skip.waiting()

 self.addEventListener('activate', (event) => { self.skipWaiting() // The usual stuff })

一旦服务工作者被激活,它就可以监听获取请求和推送事件。

获取事件处理程序

每当网页通过网络触发对资源的 fetch 请求时,都会调用来自 service worker 的 fetch 事件。 fetch 事件处理程序首先在缓存中查找请求的资源。 如果它存在于缓存中,则它返回带有缓存资源的响应。 否则,它会向服务器发起 fetch 请求,当服务器用请求的资源发送回响应时,它会将其放入缓存中以供后续请求。

 /* Fetch event handler for responding to GET requests with the cached assets */ self.addEventListener('fetch', (event) => { event.respondWith( caches.open('products-v2') .then (cache => { /* Checking if the request is already present in the cache. If it is present, sending it directly to the client */ return cache.match(event.request).then (response => { if (response) { console.log('Cache hit! Fetching response from cache', event.request.url) return response } /* If the request is not present in the cache, we fetch it from the server and then put it in cache for subsequent requests. */ fetch(event.request).then (response => { cache.put(event.request, response.clone()) return response }) }) }) ) })

event.respondWith让服务工作者向客户端发送自定义响应。

离线优先现在是一回事。 对于任何非关键请求,我们必须从缓存中提供响应,而不是搭便车到服务器。 如果缓存中不存在任何资产,我们会从服务器获取它,然后将其缓存以供后续请求使用。

服务工作者只在 HTTPS 网站上工作,因为他们有权操纵任何获取请求的响应。 有恶意的人可能会篡改 HTTP 网站上请求的响应。 因此,必须在 HTTPS 上托管 PWA。 Service Worker 不会中断 DOM 的正常运行。 他们无法直接与网页通信。 要将任何消息发送到网页,它会使用发布消息。

网络推送通知

假设您正忙着在手机上玩游戏,并弹出一条通知,告诉您您最喜欢的品牌有 30% 的折扣。 事不宜迟,您单击通知并屏住呼吸。 获取板球或足球比赛的实时更新或获取重要的电子邮件和提醒作为通知对于吸引用户使用产品来说是一件大事。 在 PWA 出现之前,此功能仅在本机应用程序中可用。 PWA 利用 Web 推送通知来与原生应用程序提供的开箱即用的强大功能竞争。 即使 PWA 未在任何浏览器选项卡中打开,即使浏览器未打开,用户仍会收到 Web 推送通知。

Web 应用程序必须请求用户的许可才能向他们发送推送通知。

浏览器提示请求 Web 推送通知的权限
浏览器提示请求 Web 推送通知的权限。 (大预览)

一旦用户通过单击“允许”按钮进行确认,浏览器就会生成一个唯一的订阅令牌。 此令牌对于此设备是唯一的。 Chrome 生成的订阅令牌格式如下:

 { "endpoint": "https://fcm.googleapis.com/fcm/send/c7Veb8VpyM0:APA91bGnMFx8GIxf__UVy6vJ-n9i728CUJSR1UHBPAKOCE_SrwgyP2N8jL4MBXf8NxIqW6NCCBg01u8c5fcY0kIZvxpDjSBA75sVz64OocQ-DisAWoW7PpTge3SwvQAx5zl_45aAXuvS", "expirationTime": null, "keys": { "p256dh": "BJsj63kz8RPZe8Lv1uu-6VSzT12RjxtWyWCzfa18RZ0-8sc5j80pmSF1YXAj0HnnrkyIimRgLo8ohhkzNA7lX4w", "auth": "TJXqKozSJxcWvtQasEUZpQ" } }

上面令牌中包含的endpoint对于每个订阅都是唯一的。 在一个普通的网站上,成千上万的用户会同意接收推送通知,并且对于每个人来说,这个endpoint都是唯一的。 因此,在这个endpoint的帮助下,应用程序能够在未来通过向他们发送推送通知来定位这些用户。 expirationTime是订阅对特定设备有效的时间量。 如果expirationTime为 20 天,则表示用户的推送订阅将在 20 天后过期,用户将无法收到旧订阅的推送通知。 在这种情况下,浏览器将为该设备生成一个新的订阅令牌。 authp256dh密钥用于加密。

现在,为了在未来向这成千上万的用户发送推送通知,我们首先必须保存他们各自的订阅令牌。 向这些用户发送推送通知是应用程序服务器(后端服务器,可能是 Node.js 脚本)的工作。 这听起来就像使用请求负载中的通知数据向端点 URL 发出POST请求一样简单。 但是,应该注意的是,如果用户在服务器触发了针对他们的推送通知时不在线,那么一旦他们重新上线,他们仍然应该收到该通知。 服务器必须处理这种情况,并向用户发送数千个请求。 跟踪用户连接的服务器听起来很复杂。 因此,中间的东西将负责将 Web 推送通知从服务器路由到客户端。 这称为推送服务,每个浏览器都有自己的推送服务实现。 浏览器必须将以下信息告知推送服务才能发送任何通知:

  1. 活着的时间
    这是消息应该排队多长时间,以防它没有传递给用户。 一旦这个时间过去,消息将从队列中删除。
  2. 消息的紧迫性
    这样推送服务通过仅发送高优先级消息来节省用户的电池电量。

推送服务将消息路由到客户端。 因为客户端必须接收推送,即使其各自的 Web 应用程序未在浏览器中打开,推送事件必须由在后台持续监控的东西进行监听。 你猜对了:这是服务人员的工作。 Service Worker 监听推送事件并向用户显示通知。

所以,现在我们知道浏览器、推送服务、Service Worker 和应用服务器协同工作以向用户发送推送通知。 让我们看看实现细节。

网络推送客户端

请求用户的许可是一次性的事情。 如果用户已经授予接收推送通知的权限,我们不应该再次询问。 权限值保存在Notification.permission中。

 /* Notification.permission can have one of these three values: default, granted or denied. */ if (Notification.permission === 'default') { /* The Notification.requestPermission() method shows a notification permission prompt to the user. It returns a promise that resolves to the value of permission*/ Notification.requestPermission().then (result => { if (result === 'denied') { console.log('Permission denied') return } if (result === 'granted') { console.log('Permission granted') /* This means the user has clicked the Allow button. We're to get the subscription token generated by the browser and store it in our database. The subscription token can be fetched using the getSubscription method available on pushManager of the serviceWorkerRegistration object. If subscription is not available, we subscribe using the subscribe method available on pushManager. The subscribe method takes in an object. */ serviceWorkerRegistration.pushManager.getSubscription() .then (subscription => { if (!subscription) { const applicationServerKey = ' ' serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, // All push notifications from server should be displayed to the user applicationServerKey // VAPID Public key }) } else { saveSubscriptionInDB(subscription, userId) // A method to save subscription token in the database } }) } }) } /* Notification.permission can have one of these three values: default, granted or denied. */ if (Notification.permission === 'default') { /* The Notification.requestPermission() method shows a notification permission prompt to the user. It returns a promise that resolves to the value of permission*/ Notification.requestPermission().then (result => { if (result === 'denied') { console.log('Permission denied') return } if (result === 'granted') { console.log('Permission granted') /* This means the user has clicked the Allow button. We're to get the subscription token generated by the browser and store it in our database. The subscription token can be fetched using the getSubscription method available on pushManager of the serviceWorkerRegistration object. If subscription is not available, we subscribe using the subscribe method available on pushManager. The subscribe method takes in an object. */ serviceWorkerRegistration.pushManager.getSubscription() .then (subscription => { if (!subscription) { const applicationServerKey = ' ' serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true, // All push notifications from server should be displayed to the user applicationServerKey // VAPID Public key }) } else { saveSubscriptionInDB(subscription, userId) // A method to save subscription token in the database } }) } }) }

在上面的subscribe方法中,我们传递userVisibleOnlyapplicationServerKey来生成订阅令牌。 userVisibleOnly属性应该始终为 true,因为它告诉浏览器服务器发送的任何推送通知都将显示给客户端。 为了理解applicationServerKey的目的,让我们考虑一个场景。

如果有人掌握了您的数千个订阅令牌,他们很可能会向这些订阅中包含的端点发送通知。 端点无法链接到您的唯一身份。 为了为您的 Web 应用程序上生成的订阅令牌提供唯一身份,我们使用了 VAPID 协议。 使用 VAPID,应用程序服务器在发送推送通知时自愿向推送服务标识自己。 我们生成两个密钥,如下所示:

 const webpush = require('web-push') const vapidKeys = webpush.generateVAPIDKeys()

web-push 是一个 npm 模块。 vapidKeys将有一个公钥和一个私钥。 上面使用的应用服务器密钥是公钥。

网络推送服务器

Web 推送服务器(应用程序服务器)的工作很简单。 它将通知有效负载发送到订阅令牌。

 const options = { TTL: 24*60*60, //TTL is the time to live, the time that the notification will be queued in the push service vapidDetails: { subject: '[email protected]', publicKey: ' ', privateKey: ' ' } } const data = { title: 'Update', body: 'Notification sent by the server' } webpush.sendNotification(subscription, data, options) const options = { TTL: 24*60*60, //TTL is the time to live, the time that the notification will be queued in the push service vapidDetails: { subject: '[email protected]', publicKey: ' ', privateKey: ' ' } } const data = { title: 'Update', body: 'Notification sent by the server' } webpush.sendNotification(subscription, data, options) const options = { TTL: 24*60*60, //TTL is the time to live, the time that the notification will be queued in the push service vapidDetails: { subject: '[email protected]', publicKey: ' ', privateKey: ' ' } } const data = { title: 'Update', body: 'Notification sent by the server' } webpush.sendNotification(subscription, data, options)

它使用 web 推送库中的sendNotification方法。

服务人员

服务工作者向用户显示通知,如下所示:

 self.addEventListener('push', (event) => { let options = { body: event.data.body, icon: 'images/example.png', } event.waitUntil( /* The showNotification method is available on the registration object of the service worker. The first parameter to showNotification method is the title of notification, and the second parameter is an object */ self.registration.showNotification(event.data.title, options) ) })

到目前为止,我们已经看到了 Service Worker 如何利用缓存来存储请求并使 PWA 快速可靠,并且我们已经看到了 Web 推送通知如何让用户保持参与。

为了在客户端存储大量数据以供离线支持,我们需要一个巨大的数据结构。 让我们看看金融时报 PWA。 您必须亲眼见证这种数据结构的强大功能。 在浏览器中加载 URL,然后关闭 Internet 连接。 重新加载页面。 呸! 它还在工作吗? 它是。 (就像我说的,离线是新的黑色。)数据不是来自电线。 它是从房子里送来的。 前往 Chrome 开发者工具的“应用程序”选项卡。 在“存储”下,您会找到“IndexedDB”。

IndexedDB 将文章数据存储在金融时报 PWA 中
金融时报 PWA 上的 IndexedDB。 (大预览)

查看“Articles”对象存储,并展开任何项目以亲眼看看其中的魔力。 《金融时报》已存储此数据以供离线支持。 这种可以让我们存储大量数据的数据结构称为 IndexedDB。 IndexedDB 是一个基于 JavaScript 的面向对象的数据库,用于存储结构化数据。 我们可以在这个数据库中为各种目的创建不同的对象存储。 例如,正如我们在上图中看到的那样,“Resources”、“ArticleImages”和“Articles”被称为对象存储。 对象存储中的每条记录都用一个键唯一标识。 IndexedDB 甚至可以用于存储文件和 blob。

让我们尝试通过创建一个用于存储书籍的数据库来理解 IndexedDB。

 let openIdbRequest = window.indexedDB.open('booksdb', 1)

如果数据库booksdb尚不存在,则上面的代码将创建一个booksdb数据库。 open 方法的第二个参数是数据库的版本。 指定版本会处理将来可能发生的与架构相关的更改。 例如, booksdb现在只有一张表,但是当应用程序增长时,我们打算再添加两张表。 为了确保我们的数据库与更新的模式同步,我们将指定一个比前一个更高的版本。

调用open方法不会立即打开数据库。 这是一个返回IDBOpenDBRequest对象的异步请求。 该对象具有成功和错误属性; 我们必须为这些属性编写适当的处理程序来管理我们的连接状态。

 let dbInstance openIdbRequest.onsuccess = (event) => { dbInstance = event.target.result console.log('booksdb is opened successfully') } openIdbRequest.onerror = (event) => { console.log('There was an error in opening booksdb database') } openIdbRequest.onupgradeneeded = (event) => { let db = event.target.result let objectstore = db.createObjectStore('books', { keyPath: 'id' }) }

为了管理对象存储的创建或修改(对象存储类似于基于 SQL 的表——它们具有键值结构),在openIdbRequest对象上调用onupgradeneeded方法。 每当版本更改时,都会调用onupgradeneeded方法。 在上面的代码片段中,我们创建了一个以唯一键作为 ID 的书籍对象存储。

假设在部署这段代码之后,我们必须再创建一个对象存储,称为users 。 所以,现在我们数据库的版本将是2

 let openIdbRequest = window.indexedDB.open('booksdb', 2) // New Version - 2 /* Success and error event handlers remain the same. The onupgradeneeded method gets called when the version of the database changes. */ openIdbRequest.onupgradeneeded = (event) => { let db = event.target.result if (!db.objectStoreNames.contains('books')) { let objectstore = db.createObjectStore('books', { keyPath: 'id' }) } let oldVersion = event.oldVersion let newVersion = event.newVersion /* The users tables should be added for version 2. If the existing version is 1, it will be upgraded to 2, and the users object store will be created. */ if (oldVersion === 1) { db.createObjectStore('users', { keyPath: 'id' }) } }

我们已经在打开请求的成功事件处理程序中缓存dbInstance 。 要在 IndexedDB 中检索或添加数据,我们将使用dbInstance 。 让我们在书籍对象存储中添加一些书籍记录。

 let transaction = dbInstance.transaction('books') let objectstore = transaction.objectstore('books') let bookRecord = { id: '1', name: 'The Alchemist', author: 'Paulo Coelho' } let addBookRequest = objectstore.add(bookRecord) addBookRequest.onsuccess = (event) => { console.log('Book record added successfully') } addBookRequest.onerror = (event) => { console.log('There was an error in adding book record') }

我们使用transactions ,尤其是在对象存储上写入记录时。 事务只是围绕操作的包装,以确保数据完整性。 如果事务中的任何操作失败,则不会对数据库执行任何操作。

让我们用put方法修改一个书籍记录:

 let modifyBookRequest = objectstore.put(bookRecord) // put method takes in an object as the parameter modifyBookRequest.onsuccess = (event) => { console.log('Book record updated successfully') }

让我们使用get方法检索书籍记录:

 let transaction = dbInstance.transaction('books') let objectstore = transaction.objectstore('books') /* get method takes in the id of the record */ let getBookRequest = objectstore.get(1) getBookRequest.onsuccess = (event) => { /* event.target.result contains the matched record */ console.log('Book record', event.target.result) } getBookRequest.onerror = (event) => { console.log('Error while retrieving the book record.') }

在主屏幕上添加图标

既然 PWA 和本机应用程序之间几乎没有任何区别,那么为 PWA 提供主要位置是有意义的。 如果您的网站满足 PWA 的基本标准(托管在 HTTPS 上,与 service worker 集成并具有manifest.json )并且在用户在网页上花费了一些时间后,浏览器将在底部调用一个提示,询问用户将应用添加到他们的主屏幕,如下所示:

提示在主屏幕上添加金融时报 PWA
提示在主屏幕上添加金融时报 PWA。 (大预览)

当用户单击“将 FT 添加到主屏幕”时,PWA 将踏上主屏幕以及应用程序抽屉。 当用户在手机上搜索任何应用程序时,将列出与搜索查询匹配的任何 PWA。 它们还将在系统设置中看到,这使用户可以轻松管理它们。 从这个意义上说,PWA 的行为类似于原生应用程序。

PWA 使用manifest.json来提供此功能。 让我们看看一个简单的manifest.json文件。

 { "name": "Demo PWA", "short_name": "Demo", "start_url": "/?standalone", "background_color": "#9F0C3F", "theme_color": "#fff1e0", "display": "standalone", "icons": [{ "src": "/lib/img/icons/xxhdpi.png?v2", "sizes": "192x192" }] }

short_name出现在用户的主屏幕和系统设置中。 该name出现在 chrome 提示符和初始屏幕上。 启动屏幕是用户在应用程序准备启动时看到的内容。 start_url是应用程序的主屏幕。 这是用户点击主屏幕上的图标时所得到的。 background_color用于启动画面。 theme_color设置工具栏的颜色。 display模式的standalone值表示应用程序将在全屏模式下运行(隐藏浏览器的工具栏)。 当用户安装 PWA 时,其大小仅为千字节,而不是原生应用程序的兆字节。

服务工作者、网络推送通知、IndexedDB 和主屏幕位置弥补了离线支持、可靠性和参与度。 应该注意的是,Service Worker 并不是在第一次加载时就开始工作的。 在缓存所有静态资产和其他资源之前,第一次加载仍然很慢。 我们可以实施一些策略来优化第一次加载。

捆绑资产

所有资源,包括 HTML、样式表、图像和 JavaScript,都将从服务器获取。 文件越多,获取它们所需的 HTTPS 请求就越多。 我们可以使用 WebPack 之类的捆绑器来捆绑我们的静态资产,从而减少对服务器的 HTTP 请求数量。 WebPack 通过使用诸如代码拆分(即仅捆绑当前页面加载所需的那些文件,而不是将所有文件捆绑在一起)和树抖动(即删除重复的依赖项或已导入但未在代码中使用的依赖项)。

减少往返

网络速度慢的主要原因之一是网络延迟。 一个字节从 A 传输到 B 所需的时间因网络连接而异。 例如,Wi-Fi 上的特定往返在 3G 连接上需要 50 毫秒和 500 毫秒,但在 2G 连接上需要 2500 毫秒。 这些请求是使用 HTTP 协议发送的,这意味着当一个特定的连接被用于一个请求时,它不能用于任何其他请求,直到前一个请求的响应被提供。 一个网站一次可以发出六个异步 HTTP 请求,因为一个网站可以使用六个连接来发出 HTTP 请求。 一个网站平均发出大约 100 个请求; 因此,最多有六个可用连接,用户可能最终在单次往返中花费大约 833 毫秒。 (计算为833 毫秒 - 1006 = 1666。我们必须将 1666 除以 2,因为我们正在计算往返所花费的时间。)使用 HTTP2,周转时间大大减少。 HTTP2 不会阻塞连接头,因此可以同时发送多个请求。

大多数 HTTP 响应都包含last-modifiedetag标头。 last-modified标头是文件最后一次修改的日期, etag是基于文件内容的唯一值。 只有当文件的内容改变时才会改变。 如果缓存版本已经在本地可用,则这两个标头都可用于避免再次下载文件。 如果浏览器在本地有此文件的版本,它可以在请求中添加这两个标头中的任何一个,如下所示:

添加 ETag 和 Last-Modified 标头以防止下载有效的缓存资产
ETag 和 Last-Modified 标头。 (大预览)

服务器可以检查文件的内容是否发生了变化。 如果文件的内容没有改变,那么它会返回一个状态码 304(未修改)。

If-None-Match Header to prevent downloading of valid cached assets
If-None-Match Header. (大预览)

This indicates to the browser to use the locally available cached version of the file. By doing all of this, we've prevented the file from being downloaded.

Faster responses are in now place, but our job is not done yet. We still have to parse the HTML, load the style sheets and make the web page interactive. It makes sense to show some empty boxes with a loader to the user, instead of a blank screen. While the HTML document is getting parsed, when it comes across <script src='asset.js'></script> , it will make a synchronous HTTP request to the server to fetch asset.js , and the whole parsing process will be paused until the response comes back. Imagine having a dozen of synchronous static asset references. These could very well be managed just by making use of the async keyword in script references, like <script src='asset.js' async></script> . With the introduction of the async keyword here, the browser will make an asynchronous request to fetch asset.js without hindering the parsing of the HTML. If a script file is required at a later stage, we can defer the downloading of that file until the entire HTML has been parsed. A script file can be deferred by using the defer keyword, like <script src='asset.js' defer></script> .

结论

We've learned a lot of many new things that make for a cool web application. Here's a summary of all of the things we've explored in this article:

  1. Service workers make good use of the cache to speed up the loading of assets.
  2. Web push notifications work under the hood.
  3. We use IndexedDB to store a massive amount of data.
  4. Some of the optimizations for instant first load, like using HTTP2 and adding headers like Etag , last-modified and If-None-Match , prevent the downloading of valid cached assets.

就是这样,伙计们!