【PWA】PWA入门到进阶

最近在使用某款运动APP,使用过程中我发现一个很便捷的功能,就是你在跑步页,App会提示你添加“便捷”功能至桌面,添加后桌面会有一个APP图标,点进去它其实是一个web,然后通过web调用APP方法能直接进入APP跑步。我想了想,这难不成是PWA。由此原因,我们来看看PWA是怎么操作的 ·>_·>

1、Service Worker 引入

PWA的核心就是Service Worker。所以,我们不得不先对它进行介绍!

谷歌Jeff曾经这么描述它:“如果将你的网络请求想象成飞机起飞,那么Service Worker就是路由请求的空中交通管制员,它可以通过网络加载,甚至通过缓存加载。”

作为空管员,Service Worker 能让你全权控制网站发起的每个请求,这为许多不同的使用场景开辟了可能性。例如,空管员可以将飞机重定向到另一个机场,甚至命令飞机延迟降落。而Service Worker也能如此。

Service Worker 有几个特点:

  • 运行在它自己的全局脚本上下文
  • 不绑定到具体的网页
  • 无法修改网页中的元素,因为它无法访问DOM
  • 只能使用HTTPS

我们用一个图解释Service Worker是如何工作的:

【PWA】PWA入门到进阶_第1张图片
Service Worker 运行在Worker上下文中,这意味这它无法访问DOM,它运行在Worker线程中,是完全异步,因此不会被阻塞。但是,你也因此无法使用XHR、localStorage之类的功能。

Service Worker生命周期

我们从用户访问页面时,发生的解析过程阐述SW的生命周期。如下图:
【PWA】PWA入门到进阶_第2张图片
首先,用户首次访问URL时,服务器会返回响应的网页。当调用register()函数时,SW开始下载。在注册过程中,浏览器会下载、解析并执行SW。如果在此步骤出现错误,register()返回的Promise会执行reject操作,并且SW会被废弃。

一旦SW成功执行,安装事件就会激活。SW是基于事件驱动的,这意味着你可以进入这些事件中的任意一个。你可以通过进入不同事件来监听任何网络请求。

一旦完成安装,SW就会激活,并控制在其范围内的一切。如果生命周期中的所有事件都成功,SW就随时可供使用。

  • 简单理解SW生命周期——交通信号灯

你可能会觉得SW的生命周期不好记?
这样,你可以把SW生命周期当作一组交通信号灯。在注册过程中,SW处于红灯状态,因为它还需要下载和解析。接下来,它处于黄灯状态,因为它正在执行,还没有完全准备好。如果红灯、黄灯都执行了,SW就进入到绿灯状态,随时可以使用

当第一次加载页面时,SW还没有激活,所以它不会处理任何请求。只有当它安装和激活后,才能控制其范围内的一切。所以,只有刷新页面或导航到另一个页面,SW内的逻辑才会启动。

SW示例

准备工作

为了安全考虑,SW可能用于恶意用途。例如,如果有人在你的网页上注册一个恶意的SW,它能劫持连接并将其重定向到恶意端点。为了避免这种情况,你只能通过HTTPS提供服务的网页上注册SW,确保网页在通过网络传输的过程中没有被纂改。

但是,作为开发者,你可以在本地localhost中测试SW并调试。

如果你要将PWA发布到网上,你也可以先使用一些免费的HTTPS服务,例如 https://letsencrypt.org ,或者使用GitHub Pages来测试你的PWA。

下面,我们通过一个简单的例子,带你了解SW。

//index.html



    
    
    
    Document
    
    //注意,在头部添加“清单文件”。这个文件提供Web应用信息,如名称、作者、图标和描述。它使得浏览器能将web应用安装到设备主屏幕上,以便为用户提供更快捷的访问。
    


    
    
    


前面提到,SW是基于事件驱动的,所以我们可以通过监听事件,执行SW操作。例如fetch事件。当一个资源发起fetch事件时,你可以决定如何继续进行。可以将发出的HTTP请求或接收到的HTTP响应更改成任何内容。

//sw.js
self.addEventListener('fetch',function(event){ //为fetch事件添加事件监听器
	if(/\.jpg$/.test(event.request.url)){//检查传入的HTTP请求URL是否是以.jpg结尾的文件
		event.responseWith(fetch('/images/logo.jpg')); //尝试获取logo.jpg图片,并用它作为替代图片来响应请求
	}
})

2、PWA引入

PWA并不需要我们从头开始把项目再重新做一遍。例如,每当你发现某个新功能对用户有益并且能提升他们体验时,就可以试试添加这个功能。

这里有一个工具:Lighthouse,你也可以在Chrome扩展插件上安装它。
它能提供与Web应用相关的有用的性能分析和审核信息。

曾有一段时间,网络上讨论PWA与原生应用之间的矛盾,形成一种对立。我认为这应该根据用户的需要来选择。作为开发者,我们应该不断探索提升用户体验的方法,不应该把心思放在不休止的争论上。

PWA架构方式

PWA其实是一个外壳。想象一下,你第一次启动某个下载的App时,在内容加载前,你会看到一个空的UI外壳,包括了头部和导航。

依照这样的方式,PWA借助SW正可以实现这样的体验。例如使用SW缓存应用的UI外壳。

这里简单对UI外壳做个解释即用户界面所必需的最小化HTML、CSS、Javascript。它可能会是类似网站头部、底部和导航这样没有任何动态内容的部分。如果可以加载UI外壳并对其进行缓存,则可以稍后讲动态内容加载到页面中。

例如,当你第一次或重新刷新访问GMail时,首先会显示网站的UI外壳,这让用户及时获取到反馈,让用户认为网站的速度很快,即使是在感官上。一旦外壳加载完成,网站就会使用javascript来获取并加载动态内容。

即,当用户首次访问网站时,SW会开始下载并自行安装。在安装阶段可以进入这个事件并缓存UI外壳所需要的所有资源,即基础的HTML和CSS、javascript资源文件。当这些资源文件都已添加到SW缓存中,这些资源的HTTP请求再也不需要发送给服务器。一旦用户导航到另一个页面,用户将立即看到UI外壳。

【PWA】PWA入门到进阶_第3张图片
由此,我们总结PWA一般具备下列几点特征:

  • 响应式——适应不同尺寸屏幕
  • 连接无关——SW缓存,可以离线工作
  • 应用式交互——使用UI外壳架构进行构建
  • 始终保持最新——由于SW的更新,它会不断更新
  • 安全——基于HTTPS
  • 可搜索——搜索引擎可爬取到它
  • 可安装——使用清单文件mainfest.json 可以进行安装
  • 可链接——通过URL轻松分享、访问

SW缓存技术

回想一下,当你做火车进入一个山洞隧道,是不是会出现手机信号衰减或无信号状态,导致你正在网上浏览时加载出现问题。而,SW缓存能处理这个问题。

我们知道,Web服务器可以使用Expires响应头来通知web客户端去使用未过期的资源副本,指定指定的“过期时间”。反过来,浏览器可以缓存此资源,并且只有在有效期满后才会再次检查新版本。如下图所示:当浏览器发起一个资源的HTTP请求时,服务器会发送一个包含该资源相关有用信息的HTTP响应头。

【PWA】PWA入门到进阶_第4张图片
但是,使用HTTP缓存存在一个缺陷,即客户端要依赖于服务器来告知何时缓存资源以及资源何时过期。如果内容具有相关性,任何更新都可能导致服务器发送的到期日期变得很容易不同步,以至于影响网站。

SW缓存

SW缓存,不同于HTTP缓存,SW无须由服务器告知浏览器资源要缓存多久。而是,SW能自己控制资源如何缓存。所以,SW缓存是对HTTP缓存的增强,并可以与之配合使用。

现在我们先看个例子:


    
    
    

  • 重点:
//创建缓存资源
var cacheName = 'hello'; //缓存名称
self.addEventListener('install',event=>{//进入SW的安装事件
    event.waitUntil(
        caches.open(cacheName)//使用指定的缓存名称来打开缓存
        .then(cache=>cache.addAll([//把JS和hello.png添加到缓存中
            '/js/script.js'
            '/images/hello.jpeg'
        ]))
    );
});

  • 解析:

  • install事件,它发生在浏览器安装并注册SW时,它是把后面阶段可能会用到的资源添加到缓存中的绝佳时间。例如,我们缓存了script.js这个文件,那么,另一个引用了此文件的网页,在后面的阶段就可以轻松地从缓存中获取它。

  • cacheName:字符串,用于设置缓存的名称。可以为每个缓存取不同的名称,甚至可以拥有一个缓存的多个不同的副本,每个新的字符串对应唯一的缓存。

  • event.waitUntil() 使用Promise来知晓安装所需的时间以及是否安装成功。

  • 需要知道的一点,如果所有的文件都成功缓存,那么SW便是安装成功。但是,如果其中之一的文件缓存失败,那么安装过程也会随之失败。所以,一个很长的缓存列表,会增加缓存失败的概率,多一个文件便多一份风险,从而导致SW无法安装。

OK,前面我们把缓存准备好了。现在我们要读取缓存。

//利用fetch事件,读取缓存。fetch事件会监听URL请求,
//如果在SW缓存中,就从SW中取;如果不在,就通过网络从服务器中取。
self.addEventListener('fetch',function(event){
	event.respondWith(
		caches.match(event.request)//检查传入的请求URL是否匹配当前缓存中存在的任何内容
			.then(function(response){
				if(response){return response;}//SW有,则返回
				return fetch(event.request);//SW没有,通过网络从服务器中取
		});
	);
});

现在,交给你一个任务:打开devTool->Network,刷新页面,看看资源的获取方式是否真的发生变化。同时,打开Applicaion-》Cache Storage看看SW缓存了哪些文件。

拦截并缓存

前面介绍到的SW缓存,是在install阶段进行的,通常称作“预缓存”。

But,当你的资源是动态的时,该怎么进行缓存呢?

Don’t Worry. SW能够拦截HTTP请求,所以这是发起请求然后将响应存储在缓存中的绝佳机会。那么,这样以后将首先请求资源,然后立即将其缓存起来。这对于同样资源发起下一次HTTP请求时,就可以立即将其从Service Worker缓存中取出。

现在我们再通过示例说明:


    
    
    
    Document
    
    


    
    

//注意到,我们添加了字体引用,即我们需要对该字体资源在请求时进行SW缓存

var cacheName = 'hello';
self.addEventListener('fetch',function(event){
	event.responseWith(
		caches.match(event.request)
			.then(function(response){
					return response;
			})
			var resquestToCache = event.request.clone();//复制请求。请求是一个流,只能使用一次
			return fetch(requestToCache).then(//按照预期发起原始的HTTP请求
                    function(response){
                        if(!response||response.status!==200){//请求失败或服务器错误,返回错误消息
                            return response;
                        }
                        var responseToCache = response.clone();//再次复制响应,因为你需要将其添加到缓存中,而且它还将用于最终返回响应
                        caches.open(cacheName)//打开名称为hello的缓存
                        .then(function(cache){
                            cache.put(resquestToCache,responseToCache);//将响应添加到缓存中
                        });
                        return response;
                    }
            )
	)
})
  • 解析:
  1. event.request.clone() :复制请求。请求是一个流,它只能使用一次。如果你已经通过缓存使用了一次请求,接下来发起HTTP请求还要再使用一次,所以你需要在此时复制请求。
  2. response.clone():响应式一个流,它只能使用一次。因为你想要浏览器和缓存都能够使用响应,所以你需要复制它,这样就有了两个流。
  3. cache.put(resquestToCache,responseToCache) :使用响应,并将其添加到缓存中,以便下一次再次使用它。如果用户刷新页面或访问网站上另一个请求了这些资源的页面,它会立即从缓存中获取资源,而不再是通过网络获取。

应用:试想一下,我们可以通过预缓存技术来确保Web应用的重复访问时能够立即加载。还可以假定用户会单击链接并阅读新闻的完整内容。如果在安装SW后缓存这些内容,对于用户而言,他们就能快速看到下个页面的内容。

我们可以使用WebPagetest这个工具来测试Web应用在使用了SW缓存后的性能变化。

进一步使用SW缓存

  • 版本控制/缓存破坏
    SW的优点在于,每当对SW文件本身做出更改时,都会自动触发SW的更新流程。当用户导航至你的网站时,浏览器会尝试在后台重新下载SW。即使下载的SW文件与当前的相比只有一个字节的差别,浏览器也会认为它是新的

这个特点,使得我们有幸使用新文件更新缓存。在更新缓存时,可以使用两种方式。

  1. 可以更新用来存储缓存的名称。例如将cacheName的值由’hello’更改为’hello-2’,就会自动创建一个新缓存并开始从这个缓存中提供文件。之前的缓存将被孤立并不再使用。
  2. 这种方式可能更实用:对文件进行版本控制。这种技术称为“缓存破坏”。当缓存静态文件时,它可以存储很长一段时间,然后才到期。如果期间你对网站进行更新,这可能会造成困扰,因为文件的缓存版本存储在访问者的浏览器中,它们可能无法看到所做的更改。缓存破坏解决了这个问题:它通过使用一个唯一的文件版本标识符来告知浏览器该文件有新版本可用。例如:
 //通过在文件名末尾附加一个散列字符串

缓存破坏的原理就是每次更改文件时创建一个全新的文件名,以确保浏览器可以获取最新的内容。

  • 处理额外查询参数

如果对文件发起的HTTP请求附带了任意查询字符串,并且查询字符串会更改,这可能会导致一些问题。例如,如果你对一个先前匹配的URL发起的请求,则可能会发现由于查询字符串略有不同而导致该URL找不到。那么,SW中有这样的配置:当检查缓存时,可以忽略查询字符串,使用ignoreSearch属性并设置为true。示例:

self.addEventListener('fetch',function(event){
	event.respondWith(
		caches.match(event.request,{ignoreSearch:true})
		.then(function(response){
				return response || fetch(event.request)
		}
	)
})

除此之外,还有ignoreMethod选项,它会忽略请求参数的方法,POST请求可以匹配缓存中的GET项。ignoreVary选项会忽略已缓存响应中的vary响应头。

  • SW缓存容量

SW的内存空间,取决于你设备的存储情况。

  • Workbox
    Workbox 是一个SW辅助库,能帮助你快速创建SW。

fetch 事件

从前面的讲述中,我们SW能拦截浏览器发出的任何HTTP请求。因此,属于此SW作用域内的每个HTTP作用域内的每个HTTP请求都将触发fetch事件。
但是,通常情况下,只有当用户刷新页面时,SW才会被激活并开始拦截请求。这样的话,并不是很理想。我想,我们更希望SW能立即开始工作,而不是等待用户跳转至网站的其他页面或刷新页面。

还好,SW中提供了一个小技巧,能立即激活SW:

self.addEventListener('install',function(event){
	event.waitUntil(self.skipWaiting());
})

skipWaiting()函数,最终会触发activate事件,并告知SW立即开始工作,而无须等待用户跳转或刷新页面。因为skipWaiting()强制等待中的SW成为激活的SW。除此之外,skipWaiting()还可以和self.clients.claim()一起使用,确保底层SW的更新立即生效。例如:

self.addEventListener('activate',function(event){
	event.waitUntil(self.clients.claim());
})

上述两段代码同时使用,能立即激活SW。

进一步了解fetch事件

  • 处理webp图片格式兼容性问题

这里,我们用一个示例说明fetch的应用。通常情况下,加载大体积图片会导致下载缓慢,导致页面加载慢。在网络环境差的情况下,更是令人灰心。

WebP图片格式横空出世,它的体积相比PNG和JPEG分别减少了26%和25%~34%,更重要的是图片质量不会受到影响。支持WebP格式的浏览器会在每个HTTP请求中添加accept:image/webp请求头来告知服务器它支持webp格式。

但是,截止目前(2019.5.1),浏览器对webp的支持还不完全。如图:

【PWA】PWA入门到进阶_第5张图片
但是,别担心,有了SW,你就能处理这类问题:SW它可以拦截请求,根据浏览器对webp的支持情况,将webp格式的图片返回给能够渲染给它们的浏览器。

示例:


    
    

// 为支持WebP图片的浏览器返回此格式的图片

self.addEventListener('fetch',function(event){
	//为支持webp格式的浏览器返回webp格式的图片
    if(/\.jpg$|.png$/.test(event.request.url)){
        var supportWebp = false;
        if(event.request.headers.has('accept')){
            //检测accept请求头是否支持webp
            supportWeb = event.request.headers
            .get('accept')
            .includes('webp');
        }
        if(supportWebp) {
            var req = event.request.clone();
            var returnUrl = req.url.substr(0,req.url.lastIndexOf("."))+".webp";//创建返回url
            event.responseWith(
                fetch(returnUrl,{
                    mode:'no-cors'
                })
            )
        }
    }
})

如果你请求的是图片,就可以根据传递的HTTP请求头来返回最合适的内容。上述代码中,我们通过检查每个请求头并寻找image/webp的mime类型,一旦知道请求头的值,就能判断出浏览器是否支持WebP图片并返回相应的WebP图片。如果浏览器不支持Webp图片,它不会再HTTP请求头中声明支持,SW会忽略该请求并继续正常工作。

  • save-data请求头

通常情况下,在移动端浏览器的设置里有一个选项:“开启节省流量\智能无图”等类似的功能按钮。一旦设置启用后,每个发送到服务器的HTTP请求都会包含Sava-Data请求头。使用开发者工具,在Request Headers中,你看到的是Sava-Data: on。开启节省流量,就可以使用几种不同的技术来将数据返回给用户。因为每个HTTP请求都会发送到服务器,所以可以直接根据服务器端代码中的Sava-Data请求头提供不同的内容。

self.addEventListener('fetch',function(event){
	    //检查save-data HTTP请求头
    if(event.request.headers.get('save-data')){
        //开启节省流量功能,限制fonts.googleapis.com
        if(event.request.url.includes('fonts.googleapis.com')){
            //不返回任何内容,417状态码表示服务器无法满足Expect请求头字段的要求
            event.responseWith(new Response('',{status:417,statusText:'Ignore fonts to save data.'}));
        }
    }
})

417状态码:表示服务器无法满足Expect请求头字段的要求

是不是很不错,这项技术能减少页面的整体下载量,确保用户节省了任何不必要的数据。

PWA的增强

前面,我们大都说的是PWA的功能,但是要创建吸引人的应用,我们还需要专注PWA的视觉能力。下面,我们开始介绍怎么让PWA表现的更加丰富。

回到开始的那刻,我提到在使用跑步APP时,有一项添加到屏幕的快捷功能,能让我马上开启APP的跑步功能,截图为证:
【PWA】PWA入门到进阶_第6张图片
现在我们就来尝试这个功能。

添加到主屏幕

首先,我们需要知道web应用清单这个东西。它是一个JSON文件,名为manifest.json,它能让用户将Web应用安装到设备的主屏幕上,并允许开发者自定义启动页面、模板颜色、打开的URL等。

示例:

//manifest.json
{
    "name":"Progressive web app",
    "short_name": "Progressive App",
    "start_url": "/index.html",
    "display":"standalone",
    "background_color": "#FFDF00",
    "icons": [{
        "src": "images/homescreen-128.png",
        "type": "image/png",
        "sizes": "128x128"
      }, {
        "src": "images/homescreen-152.png",
        "type": "image/png",
        "sizes": "152x152"
      }, {
        "src": "images/homescreen-144.png",
        "type": "image/png",
        "sizes": "144x144"
      }, {
        "src": "images/homescreen-192.png",
        "type": "image/png",
        "sizes": "192x192"
      }]
 }
//然后再页面中引入,如果你想在每个页面中使用,每个页面都要引入

  • 解释
  1. name:提示用户安装应用时出现的文本
  2. short_name:用作当应用安装后出现在用户主屏幕上的文本
  3. start_url:决定了当用户从设备的主屏幕上开启Web应用时出现的第一个页面,基础路径是mainfest.json所在的路径
  4. display:表示开发者希望他们的应用如何向用户展示。有几种方式:
    • fullscreen:打开web应用并占用整个可用的显示区域
    • standalone: URL地址栏等浏览器UI元素将被排除
    • minimal-ui:为用户提供可访问的最小UI元素集合,如后退按钮、前进按钮等
    • browser:使用OS内置的标准浏览器来开发web应用(默认值
  5. theme_color:对浏览器的地址栏进行着色。
  6. icons:当把web应用添加到设备主屏幕上时所显示的图标。

添加到主屏幕,也称为Web应用安装操作栏。这是一种允许用户在浏览器中快速无缝将Web应用添加到主屏幕上的绝佳方法。通常,我们会通过在页面上增加提示,提示用户添加到主屏幕。一般需要满足几个条件:

  1. 需要manifest.json文件
  2. manifest.json文件中需要设置启动URL字段:start_url
  3. 需要144x144像素的PNG图标
  4. 网站必须使用通过HTTPS运行的SW
  5. 用户需要至少访问过网站2次,并且2次访问间隔大于5min(重要)

你可能会问,为什么要2次访问间隔大于5min(重要)提示才会出现?这样做的原因是确保这项功能不会让人反感。同时,这个约束是浏览器内置的,因此开发者无法进行控制。

  • 但是,你可能不希望显示添加到主屏幕操作栏
window.addEventListener('beforeinstallprompt',event=>{
	event.preventDefault();//阻止添加到主屏幕的操作栏出现
    return false;
});
  • 或者,你希望看看用户是否接受添加到主屏幕操作栏的情况:
window.addEventListener('beforeinstallprompt',event=>{
    event.userChoice.then(function(result){//判断用户选择,使用userChoice对象
        console.log(result.outcome);
        if(result.outcome==='dismissed'){//不接受
            //发送数据进行分析
            event.preventDefault();//阻止添加到主屏幕的操作栏出现
            return false;
        }else {//接受
            //发送数据进行分析
        }
    })
    
})
  • 调试manifest.json

manifest.json只是简单的JSON文本,容易错误,不容易直观看出设置效果。

Don’t Worry! Chrome开发者工具提供了调试:在Application->Manifest
在这里插入图片描述
同时,你还可以在manifest-validator.appspot.com中进行验证(需科学上网)。

添加推送

大部分现代Web应用都需要具备定期更新和与用户沟通的能力。沟通渠道如邮件、应用通知等,但它们并不总是能引起用户的注意,尤其是当他们离开网站时。
推送通知最大的优点是即使用户没有浏览你的网站也能收到通知,这种体验类似原生应用,而且即使浏览器没有运行也可以工作。例如天气应用。

当然,一旦用户接收或屏蔽推送通知提示,提示就不会再次出现。重要的是要注意:只有当站点通过HTTPS运行时,同时有一个注册过的SW,并且已经为其编好了代码,才会出现提示。

这里的推送,我们是基于Web推送标准push-api,目前的支持情况(2019.5.1)如图:

【PWA】PWA入门到进阶_第7张图片

发送推送通知需要三个步骤:

  1. 向服务器发送订阅服务
  2. 保存订阅细节
  3. 在需要时发送推送通知

首先,浏览器会显示一个提示以询问用户是否愿意接受通知。如果接受,可以将用户的订阅详细信息保存在服务器上,稍后会使用它来发送通知。因为这些订阅细节对于每个用户、设备和浏览器来说是唯一的,所以如果一个用户使用多个设备登录你的网站,那么每台设备都会提醒该用户是否接受通知。

//先引入清单文件

订阅通知

let endpoint,
		key,
		authSecret;
		let vapidPublicKey = 'BAyb_WagR0L0poDaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY';//客户端和服务端之间通信的公钥,确保消息是加密过的

		//将公钥从base64字符串转换成Unit8数组,因为这是VAPID协议规范要求的
		function urlBase64ToUnit8Array(base64String) {
			const padding = '='.repeat((4-base64String.length % 4) % 4);
			const base64 = (base64String + padding)
				.replace(/\-g/g,'+')
				.replace(/_/g,'/');
			const rawData = window.atob(base64);
			const outputArray = new Unit8Array(rawData.length);

			for(let i = 0;i < rawData.length; ++i) {
				outputArray[i] = rawData.charCodeAt(i);
			}
			return outputArray;
		}
		if('serviceWorker' in navigator) {
            //注册
            navigator.serviceWorker.register('/sw.js')
            .then(function(registration){
                //注册成功
                return registration.pushManager.getSubscription()//获取任何已存在的订阅
                	.then(function(subscription){
                		if(subscription) {//如果订阅过,则无需再注册
                			return;
                		}
                		return registration.pushManager.subscribe({
                			userVisibleOnly: true,
                			applicationServerKey: urlBase64ToUnit8Array(vapidPublicKey)
                		})
                		.then(function(subscription){
                			let rawKey = subscription.getKey ?
                			subscription.getKey('p256dh'): '';//从订阅对象中获取密钥和authSecret
                			key = rawKey ? btoa(String.fromCharCode.apply(null,new Unit8Array(rawKey))) : '';
                			let rawAuthSecret = subscription.getKey ?
                			subscription.getKey('auth'): '';
                			authSecret = rawAuthSecret ?
                			btoa(String.fromCharCode.apply(null,new Unit8Array(rawAuthSecret))): '';
                			endpoint = subscription.endpoint;

                			return fetch('./register',{//将详细信息发送给服务器已注册用户
                				method:'post',
                				headers: new Headers({
                					'content-type': 'application/json'
                				}),
                				body: JSON.stringify({
                					endpoint: subscription.endpoint,
                					key: key,
                					authSecret: authSecret
                				}),
                			});
                		});
                	});
            }).catch(err=>{
                //注册失败
                console.log('Error:',err);
            })
        }
  • 从上面的代码,我们知道,要发送通知,需要使用VAPID协议:是自主应用服务器表示的简称。它是一个规范,本质上定义了应用服务器和推送服务器之间的握手,并允许推送服务器确认哪个站点正在发送消息。这点是重要的,因为这意味着应用服务器能够包含其自身相关的附加信息,这些信息可用于联系应用服务器的操作人员。

  • 其次,你要知道,每个订阅对象包含一个订阅ID,对于每台机器,它是唯一的。这对于保护用户隐私很有帮助,因为你不会了解用户的任何信息,而只是一个唯一的ID。

  • 用户还没有订阅前,使用pushManager.subscribe()函数来提示用户订阅,该函数使用VAPID公钥识别自己。在提示用户之前,需要包含VAPID公钥并确保已将其转换为UInt8Array。将它转换成UInt8Array发送是因为规范只接受此类型。如果用户接受浏览器给出的Web推送提示,那么subscribe函数便返回包含订阅对象的Promise,可以从这个对象中提取所需要的密钥authSecret,以便在订阅时将其发送给服务器。

OK,现在我们需要写nodejs服务器发送通知的代码。

发送通知

const webpush = require('web-push');
const express = require('express');
let bodyParser = require('body-parser');
const app = express();

//设置VAPID详情
webpush.setVapidDetails(
	'mailto:[email protected]',
	'BAyb_WagR0L0poDaR7wWkxJi__tWbM1MPBymyRDFEGjtDCWeRYS9EF7yGoCHLdHJi6hikYdg4MuYaK0XoD0qnoY',//公钥与前端保持一致
	'p6YVD7t8HkABoez1CVJ5b17BnEdKUu5bSyVjyxMBh0'
);
//监听指向'/register'的POST请求
app.post('/register',function(req,res){
	let endPoint = req.body.endPoint;
	saveRegistrationDetails(endPoint, key, authSecret);//保存用户注册详情,这样可以在稍后阶段向他们发送消息
	//构建pushSubscription对象
	const pushSubscription = {
		endPoint: req.body.endPoint,
		keys: {
			auth: req.body.authSecret,
			p256dh: req.body.key
		}
	};
	let body = 'Thank you for registering';
	let iconUrl = './images/homescreen-144.png';
	//发送Web推送消息
	webpush.sendNotification(pushSubscription,
		JSON.stringify({
			msg: body,
			url: 'http://localhost:8081',
			icon: iconUrl
		}))
		.then(result => res.sendStatus(201))
		.catch(err=>{
			console.log(err);
		});
});
app.listen(8081,function(){
	console.log('web push app listening on port 8081');
});

接收通知

//sw.js
self.addEventListener('push',function(event){
    //检查服务器端是否发送了任何有效载荷数据
    let payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
    let title = 'Progressive';
    //使用提供的信息来显示Web推送通知
    event.waitUntil(
        self.registration.showNotification(title, {
            body: payload.msg,
            url: payload.url,
            icon: payload.icon
        })
    )
})
  • 监听push事件并读取来自服务器端的有效载荷数据。有个有效载荷数据,就可以使用showNotification来显示通知。

处理用户与推送通知的交互

self.addEventListener('notificationclick',function(event){
    event.notification.close();//一旦单击了通知提示,便会关闭
    //检查当前窗口是否已经打开,如果打开,则切换至当前窗口
    event.waitUntil(
        clients.matchAll({
            type: "window"
        })
        .then(function(clientList){
            for(let i=0;i

能够推送通知是Web应用的一大进步,但是基本的推送只允许用户单击消息或完全关闭消息。为了将推送提升一个等级,可以使用通知操作来真正与用户互动。使用通知操作,可以定义用户可以调用并与之交互的情景操作。

为通知添加振动模式

例如,为通知添加振动模式振动模式,可以是数字数组,也可以是单个数字,但它会被看做单个数字的数组,数组中的值表示以毫秒为单位的时间:

  • 索引为偶数的数字表示振动的时间;
  • 索引为奇数的数字表示在下一次振动之前暂停多久。
self.addEventListener('push',function(event){
    //检查服务器端是否发送了任何有效载荷数据
    let payload = event.data ? JSON.parse(event.data.text()) : 'no payload';
    let title = 'Progressive';
    //使用提供的信息来显示Web推送通知
    event.waitUntil(
        self.registration.showNotification(title, {
            body: payload.msg,
            url: payload.url,
            icon: payload.icon,
            actions: [//会出现在通知中的操作
                {action:'voteup',title:'Vote up'},
                {action:'votedown',title:'Vote down'}
            ],
            vibrate: [300,100,400] //振动300ms,暂停100ms,再暂停400ms
        })
    )
})

//处理通知的单击动作 event.action

self.addEventListener('notificationclick',function(event){
    event.notification.close();
    if(event.action === 'voteup') {//确定用户选择了哪个操作
        clients.openWindow('http://localhost:/voteup');
    }else {
        clients.openWindow('http://localhost:/votedown');
    }
},false);

取消订阅

用户可能想取消订阅,我们可以为用户提供一个按钮。

...

...


    ...
  • 第三方推送通知服务
  1. OneSignal
  2. Roost
  3. Aimtell

构建离线应用

SW可以检查任何失败的请求,然后返回用户要查看的页面的缓存版本。实际上,一切都在SW的掌握中,你可以返回返回缓存中存在的任何内容。基于此,你可以构建Web离线应用。

开始

假设当用户处在离线时,我们提供一个离线页面:

//sw.js
const cacheName = 'offline-cache';//离线缓存的名称
const offlineUrl = 'offline-page.html';//要存储在离线缓存中的离线网页的URL
self.addEventListener('install',event=>{
    event.waitUntil(
        caches.open(cacheName).then(function(cache){
            return cache.addAll([
                offlineUrl
            ])
        })
    )
})
//当用户没有连接时提供离线页面
self.addEventListener('fetch',event=>{
    if(event.request.method==='GET' &&
        //检查是否是GET请求并且请求的资源类型是'text/html'
        event.request.headers.get('accept').includes('text/html')) {
        event.respondWith(
            fetch(event.request.url).catch(error=>{
                return caches.match(offlineUrl);//返回离线页面
            })
        )
    }else {
        event.respondWith(fetch(event.request));//返回正常响应
    }
})

  • 我们可以通过Chrome开发者工具提供的Offline来测试。
    在这里插入图片描述
  • 通常情况,我们可以把离线页面放在需要换成的资源的最后。如果缓存中没有用户需要的资源,则会降级成默认的“离线”页面。如:
const cacheName = 'offline-cache';//离线缓存的名称
const offlineUrl = 'offline-page.html';//要存储在离线缓存中的离线网页的URL
self.addEventListener('install',event=>{
    event.waitUntil(
        caches.open(cacheName).then(function(cache){
            return cache.addAll([
            	... //资源1
            	...//资源2
            	...
                offlineUrl
            ])
        })
    )
});
self.addEventListener('fetch',function(event){
	event.respondWith(
        caches.match(event.request,{ignoreSearch:true})
            .then(function(response){
                if(response){
                    return response;
                }
                var requestToCache = event.request.clone();//复制请求。请求是一个流,只能使用一次
                return fetch(requestToCache).then(//按照预期发起原始的HTTP请求
                    function(response){
                        if(!response||response.status!==200){//请求失败或服务器错误,返回错误消息
                            return response;
                        }
                        var responseToCache = response.clone();//再次复制响应,因为你需要将其添加到缓存中,而且它还将用于最终返回响应
                        caches.open(cacheName)//打开名称为hello的缓存
                        .then(function(cache){
                            cache.put(event.request,responseToCache);//将响应添加到缓存中
                        });
                        return response;
                    }).catch(error=>{
							if(event.request.method==='GET' &&
						        //检查是否是GET请求并且请求的资源类型是'text/html'
						        event.request.headers.get('accept').includes('text/html')) {
						                return caches.match(offlineUrl);//返回离线页面
						        )
						    }
					})
            })
    );
})

虽然说PWA能让你构建离线应用,但是通常情况下,如果整个网站还不到500KB,那么缓存全部内容或许是有意义的。如果超过10MB,那就没什么意义了。同时,当提供离线功能时,请考虑用户需求及用户量。用户是如何访问你的网站?他们是否需要一次下载整个网站,或者当他们访问每个新页面时再去获取?
这些问题,有助于你制定缓存策略。

额外功能

根据用户的连接情况显示UI通知

  • 监听online&offline
let offlineNotification = document.getElementById('offline');
function showNotification() {
    offlineNotification.innerHTML = '当前离线';
    offlineNotification.className = 'showOfflineNotification';
}
function hideNotification() {
    offlineNotification.className = 'hideOfflineNotification';
}
window.addEventListener('online',hideNotification);
window.addEventListener('offline',showNotification);

跟踪离线使用情况

对于用户是否处在离线情况,我们常常需要知道,然后制定策略。**俗话说,如果你无法衡量它,就不能改善它。**因为如果用户处在离线状态,你无法使用传统的Web分析方法来跟踪它们。在没有网络连接的情况下,分析请求将无法发出,用户的操作行为将会丢失。

有一个SW Helper辅助库,可以帮助你。其中有一个Offline Google Analytics可以帮助你分析用户的离线行为。在用户离线时,这个库会将所有分析请求放入队列中等候,一旦用户重新连接网络,它会将队列中的请求发送到分析服务器。

构建弹性应用

即使你构建的网站再漂亮、运行再快,它也始终需要通过网络来获取资源,而获取过程本身就有可能失败。这就是构建快速、吸引人的、富有弹性的网站如此重要的原因。

下面,讨论web开发时网络方面存在2个挑战:lie-fi单点故障

lie-fi和单点故障

  • lie-fi:手机信号满格,但无法下载任何东西。会导致用户体验非常差,因为浏览器一直尝试下载,而不是在放弃的时候选择放弃并使用备用方案。这比离线更糟糕。
  • 单点故障(SPOF):SPOF是”如果系统中一点失效“将导致停止整个系统的工作。例如,如果第三方脚本没有正确实施和部署,那么对于托管它们的网站将构成重大的风险。(你可以使用WebPagetest.org进行测试,它有提供SPOF选项卡,进行模拟测试。)

使用SW构建备用方案

  • 如果第三方服务器宕机或长时间没有响应,就取消请求。
//在网速慢时,返回408响应的SW
//超时函数
function timeout(delay) {
    return new Promise(function(resolve,reject)=>{
        setTimeout(function(){
            resolve(new Response('',{
                status:408,
                statusText: 'Request timed out.'
            }));
        },delay);
    })
}
self.addEventListener('fetch',function(event){
    if(/googleapis/.test(event.request.url)) {
        //使用Promise.race作为条件来同时触发timeout和fetch函数
        event.respondWith(Promise.race([timeout(3000),fetch(event.request.url)]));
    }else {
        event.respondWith(fetch(event.request))
    }
})

上面的代码,构建了一个自定义的HTTP响应,它返回HTTP状态码408和自定义消息。如果请求太久没有响应,将触发这个HTTP响应。

根据研究显示,10s是保持哦用户注意力的临界值。应该将超时限制控制在10秒内。

同样,你可以使用Chrome开发者工具,测试网站在低网速下的状况。
在这里插入图片描述

使用Workbox处理网络超时

importScripts('workbox-sw.prod.v1.1.0.js');

const workboxSW = new self.WorkboxSW();
workboxSW.router.registerRoute('https://fonts.googleapis.com/(.*',//选择缓存的资源
    workboxSW.strategies.cacheFirst({//使用缓存优先策略来缓存资源
        cacheName:'googleapis',
        newWorkTimeoutSeconds: 4//如果网络请求4s还没有响应,降级至缓存版本
}))

数据同步

  • 后台同步(即将发布)
  • 定期同步

本节有一节新的API要介绍:BackgroundSync(后台同步)。它允许用户在离线工作时对需要发送到服务器的数据进行排队,一旦用户再次上线,它会将排队中的数据发送到服务器。例如,联想一下,你在用某个桌面级笔记应用,你能在离线的时候写笔记,当再次连上网络的时候,应用会进行线上同步。

后台同步(即将发布)

后台同步,可以使你推迟操作,直到用户具备稳定的连接,这使得它非常适合用来确保无论用户想发送什么都能在恢复连接时发送出去。例如,电子邮箱的客户端。邮件在发件箱中排队,一旦有连接,就将它们一一发送出去。

//注册后台同步




	


	
	
	
	
	
	
	
	



  • idb-keyval库
  • IndexedDB:用户客户端存储大量结构化数据的底层API,包括文件或二进制大对象(Blob)。适用在小数据量的存储上。
  • contact-email:作为标签名注册同步。它只是一个自定义字符串,用于识别事件。可以把这些同步标签当做不同操作的标签,想要多少就有多少。
  • 使用registration对象并提供一个用来识别的标签注册同步。每个同步都必须有唯一的标签名,因为如果使用的标签名与等待中的同步同名,它们就会进行合并。例如,如果使用相同的标签名,用户在离线期间发送了5条消息,当重新连接时,它们指挥触发一个同步。如果你想触发5次,那么需要使用5个唯一的标签名

响应同步事件

上一节,我们将数据保存在IndexedDB中了,现在,在SW中监听同步事件。

importScripts('./js/idb-keyval.js');
self.addEventListener('sync',(event)=>{
    if(event.tag === 'contact-email') {//检查标签名
        event.waitUntil(
            idbKeyval.get('sendMessage').then(value=>{//从IndexedDB中获取有效载荷值
                fetch('/sendMessage/',{//发起POST请求
                    method: 'POST',
                    headers: new Headers({'content-type': 'application/json'}),
                    body: JSON.stringify(value)//将从IndexedDB中获取的有效载荷值作为参数传递给服务器
                }).then(response=>{
                    if(response.status >=200 && response.status < 300) {
                        idbKeyval.delete('sendMessage');//从IndexedDB中移除有效载荷值
                    }
                })
            })
        )
    }
})
  • sync事件:只会在浏览器认为用户连接到网络时触发。

下图,解释了后台同步的逻辑流程
【PWA】PWA入门到进阶_第8张图片

  • 测试:先把wifi连接禁用,然后提交数据,然后再重启连接WiFi(你可以在开发者工具中看到排队的请求发送到了服务器)。

更进一步

虽然,我们将用户离线编辑的数据在用户上线时发送了,但是用户其实并不知道发生了什么。所以,我们有必要向用户提供反馈,让他们知道消息已放入等待队列,当他们再次上线时这些消息会发送出去。

所以,在前面的代码中,我们添加这么一个功能。

//显示通知和提示用户消息状态
function displayMessageNotification(notificationText) {
	let messageNotification = document.getElementById('message');
	messageNotification.innerHTML = notificationText;
	messageNotification.className = 'showMessageNotification';
}
...
...
idbKeyval.set('sendMessage',payload);//从页面中取得payload数据并将其保存到IndexedDB中
displayMessageNotification('Message queued'); //显示消息已经加入队列,等待发送

定期同步

当打开应用时,无论是否离线,最新的消息都会呈现。这个功能称为”定期同步“。它允许你在预定的时间内安排同步。

navigator.serviceWorker.ready.then(function(registration){
    registration.periodicSync.register({
        tag: 'get-latest-news', //同步事件的标签名
        minPeriod: 12*60*60*1000, //两次成功同步事件之间的最小时间间隔,单位毫秒。
        powerState: 'avoid-draining'//确定同步的电池需求,['auto','avoid-draining']
        networkState: 'avoid-cellular'//确定同步的网络需求,['online(默认)','avoid-cellular','any']
    }).then(function(periodicSyncReg){
        //成功
    },function(){
        //失败
    })
})
  • minPeriod:单位毫秒,设置为0,浏览器可以按照自己意愿频繁触发事件

**注意:**定期同步并不意味它是一个精确的计时器。尽管该API接受毫秒为单位的属性,但这并不代表会准时进行同步。很多因素都会影响它,如网络环境、电池状况、当前设备的设置等。由于定期同步需要设备的支持,因此需要得到用户的授权。

WebStream

web stream 允许你以流的方式向用户发送数据。例如,假设你要在网页上显示一张图片。如果不使用流,浏览器需要进行下列步骤:

  1. 通过网络获取图片资源
  2. 处理数据并将其解压为原始像素数据
  3. 将结果数据渲染到页面中

这是显示一张图片的关键步骤,但为什么要等整个图片下载完才能开始这些步骤呢?如果可以一块块地处理数据,无须等待整个图片下载完成呢?如果不使用流,你需要等待下载完全部内容,才能进行响应。但是,使用流,就可以一块块地返回下载结果并进行处理,这使得渲染更快。你可以并行获取及处理数据。

同时,使用流还有一些好处:如能够减少大型资源所占用的内存空间。例如:如果需要下载、处理一个大文件,并将其保存在内存中,这可能会导致问题。如果使用流,就可以减少大型资源占用的内存空间,因为数据时一块块处理的,这个特性称为”流量控制“

使用流量控制,可以使用解码器来检测你是否正在以比读取速度更快的速度生成解码帧,这使得我们可以减少网络流量和下载频率。线上视频就是很好的应用例子。

除此之外,WebStream还能:

  • 知道流的开始与结束
  • 缓存尚未读取的值
  • 用管道将流组合成一个异步序列
  • 发生的任何错误都将沿着管道传播
  • 可以取消流并将其传回管道中

可读流 (ReadableStream)

可读流,是WS的核心概念。表示可以冲中读取数据的数据源,可读流只允许数据留出,不允许流入。

使用的两种数据源的类型:

  • 推送源(push):将数据推送给你,不管你是否请求他们的数据,提供了暂停和恢复数据流的机制
  • 拉取源(pull):需要你请求或手动拉取数据。例如文件句柄:它允许你读取指定数量的数据或寻找文件中的特定位置。

可读流,是一种将推送源和拉取源同时包装在一个易于理解的接口中的简单方法。

let stream = new ReadableStream({
	start(controller) {},
	pull(controller) {},
	cancel(reason) {}
},queuingStrategy);
  • start(controller) : 立即调用它并用它来设置任何基础数据源,如推送源或拉取源。只有当成功后才会调用pull(controller)
  • pull(controller):当流的缓存区未满时,会调用该方法,而且会重复调用,直到缓冲区满为止。只有当前这个pull成功,才会调用下一个pull。
  • cancel(reason):取消任何基础数据源
  • queuingStrategy:一个对象。决定了流如何根据内部队列的状态来发出过载信号。

示例

2016:The Year of web Stream“这篇文章,深入介绍了WS及其应用。
下面我们通过示例介绍一个”可读流并故意减慢数据流向浏览器的速度,从而使页面逐渐渲染“:

self.addEventListener('fetch',event=>{
    event.respondWith(htmlStream());
})
function htmlStream() {
    const html = 'html goes here...'
    const stream = new ReadableStream({//创建一个可读流
        start: controller => {
            const encoder = new TextEncoder();//使用TextEncoder将文本转换成字节
            let pos = 0;
            let chunkSize = 1;
            //将结果推送到WS中
            function push() {
                if(pos >= html.length) {//检查是否超出HTML的长度,超出则关闭controller
                    controller.close();
                    return;
                }
                //将下一个HTML块编码并放入队列
                controller.enqueue(
                    encoder.encode(html.slice(pos,pos+chunkSize))
                );
                pos += chunkSize;
                setTimeout(push,50);//延迟50ms,降低渲染速度
            }
            push();//开始推送流

        }
    });
    //返回流的结果作为新的Response对象
    return new Response(stream,{
        headers: {
            'Content-Type': 'text/html'
        }
    });
}

现在,我们可以把Service Worker 和 WebStream结合起来,提升Web性能。例如,我们可以使用SW流获取页面不同部分,然后将它们组合成一个流。

  • 添加资源到缓存
const cacheName = 'lastestNews-v1';
self.addEventListener('install',event=>{
    self.skipWaiting();
    event.waitUntil(
        .then(cache=>cache.addAll([
            './js/main.js',
            './images/newspaper.svg',
            './css/app.css',
            './header.html',
            './footer.html',
            'offline-page.html'
        ]))
    )
})
self.addEventListener('activate',event=>{
    self.clients.claim();
})
  • 在web stream中拼装HTML
//从查询字符串中获取指定字段的值
function getQueryString(field,url=window.location.href) {
    const reg = new RegExp( '[?&]' + field + '=([^&#]*)', 'i');
}
self.addEventListener('fetch',event=>{
    const url = new URL(event.request.url);
    //是否是请求article的路由
    if(url.pathname.endsWith('/article.html')) {
        //获取id
        const articleId = getQueryString('id');
        //建立URL
        const articleUrl = 'data-${articleId}';
        //使用流结果进行响应
        event.respondWith(streamArticle(articleUrl));
    }
})
  • 最后一步:在web stream响应中拼装HTML
function streamArticle(url) {
    try {
        new ReadableStream({});//检查当前浏览器是否支持web stream api
    }catch(e) {
        return new Response('Streams not supported');
    }
    const stream = new ReadableStream({
        start(controller) {
            const startFetch = caches.match('header.html');//从缓存中去header.html
            const bodyData = fetch('data/${url}.html')//获取页面主体部分
            .catch(()=>new Response('Body fetch failed'));
            const endFetch = caches.match('footer.html');//从缓存中去footer.html

            function pushStream(stream) {
                const reader = stream.getReader();
                function read() {
                    return reader.read().then(result=>{
                        if(result.done) return;
                        controller.enqueue(result.value);
                        return read();
                    });
                }
                return read();
            }
        startFetch
            .then(response=>pushStream(response.body))//将数据推送到流中
            .then(()=>bodyData)
            .then(response=>pushStream(response.body))
            .then(()=>endFetch)
            .then(response=>pushStream(response.body))
            .then(()=>controller.close());
        }
    });
    return new Response(stream,{
        headers: {'Content-Type':'text/html'}
    });
}

流最棒的一点是它们可以像管道一样传输数据。可读流可以直接传输到可写流,也可以先传输一个或多个转换流。以这种方式连接在一起的一组流被称为”管道链“。在管道链中,原始来源是链中第一个可读流的基础来源,最终流向链中最后一个写入流的结果。

一般地,我们可以将流形容为”管道“和”水槽“,因为这表现出数据像水一样,从一个或多个流传输到下一个流。

PWA常见问题

用户清除了Chrome缓存,PWA的缓存也会被清除

因为PWA是由Chrome等浏览器提供支持的,所以目前存储是共享的。用户清楚了缓存,PWA的缓存也会被清除。

SW中添加了缓存资源,更改资源后,缓存没有更新,刷新后也是旧版本?

  • 办法1:如果你需要确保在更改时始终更新文件,那么可能需要考虑对文件进行版本控制并进行重命名。例如:main-v2.js
    每次文件更新后,都更新版本,这样就会重新下载。
  • 办法2:在SW更新后的激活阶段删除当前的缓存项。通过SW的activate事件,清除缓存。

查看PWA使用存储的情况

navigator.storage.estimate("temporary").then(info=>{
	console.log(info.quota);//总的存储空间,单位-字节
	console.log(info.usage);//到目前使用了多少数据,单位-字节
})

参考学习资料

  • Mozilla
  • 谷歌开发者
  • Opera开发者博客
  • github
  • Awesome PWA
  • W3C_service-workers

你可能感兴趣的:(综合)