PWA初探

Heero.Luo发表于6年前,已被查看1823次

HTML 5 曾被认为是移动应用的明天,却被原生App在性能和功能上轻易战胜,Web逐渐成为App的附属。然而,马云“爸爸”告诉我们:“梦想还是要有的,万一实现了呢?”如今,我们离梦想又近了一步。

PWA,全称「Progressive Web App」,是Google提出的为Web提供App般使用体验的一系列技术方案。它优势主要体现在:

  • 可在离线或网络较差的环境下正常打开页面。
  • 安全(HTTPS)。
  • 保持最新(及时更新)。
  • 支持安装(添加到主屏幕)和消息推送
  • 向下兼容,在不支持相关技术的浏览器中仍可正常访问。

本文将逐一讲述PWA涉及的主要技术方案。

CacheStorage

CacheStorage是一种新的本地存储,它的存储结构是这样的:

  • 每个域有若干个存储模块,每个模块内可以存储若干个键值对。
  • 它的键是网络请求(Request),值是请求对应的响应(Response)。

CacheStorage的接口集中在全局变量「caches」中,且仅在HTTPS协议(或localhost:*域)下可用,调用前要检查兼容性。以下是一段实现加载资源并写入缓存的代码示例:

if (typeof caches !== 'undefined') {
    // 要缓存资源的URL
    const URL = 'https://s3.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-192x192.png';
    // 存储模块名
    const CACHE_KEY = 'v1';

    fetch(URL, {
        mode: 'no-cors'
    }).then((response) => {
        // 打开存储模块后往里面添加缓存
        caches.open(CACHE_KEY).then((cache) => {
            cache.put(url, response);
        });
    });
}

其中用到了 Fetch API 去请求资源,这个API的目标是取代XMLHttpRequest。

除了写入缓存,自然还有匹配缓存和删除缓存的接口:

// 在所有存储模块中匹配资源
caches.match(URL).then((response) => {
    console.log(response);
});

// 在单个存储模块中匹配资源
caches.open(CACHE_KEY).then((cache) => {
    cache.match(URL).then((response) => {
        console.log(response);
    });
});
// 删除整个存储模块
caches.delete(CACHE_KEY).then((flag) => {
    console.log(flag);
});

// 删除存储模块中的某个存储项
caches.open(CACHE_KEY).then((cache) => {
    if (cache) {
        cache.delete(url).then((flag) => {
            console.log(flag)
        });
    }
});

虽然可以独立调用,但 CacheStorage 一般会搭配下文所说的 Service worker 一起使用。

Service worker

随着Web承载的任务变得越来越复杂,浏览器也为JavaScript提供了多线程能力——Web worker。Web worker允许一段JavaScript程序运行在主线程之外的另外一个线程中。但是基于线程安全的考虑:

  • Worker线程不能操作主线程的某些对象(如DOM)。
  • Worker线程与主线程不共享数据,只能通过消息机制(postMessage)传递数据。

Service worker也是一种Web Worker,只是它的能力比一般的Web worker要强大得多,这主要体现在:

  • 一旦被安装,就永远存在,除非注销;
  • 用到的时候唤醒,闲置的时候睡眠;
  • 可以作为代理拦截请求和响应;
  • 离线状态下也可用。

能力越大,责任也越大,所以 Service worker 仅在HTTPS协议(或localhost:*域)下可用。

注册

一个新的 Service worker 要经过注册安装激活这三个步骤,才可以对页面生效。第一步是把脚本文件注册为 Service worker :

function setupSW() {
    var serviceWorker = window.navigator.serviceWorker;
    if (!serviceWorker || typeof fetch !== 'function') {
        return;
    }
    serviceWorker.register('/sw.js').then(function(reg) {
        console.info('[SW]: Registered at scope "' + reg.scope + '"');
    });
}

window.addEventListener('load', setupSW, false);

注册操作的实质是新开线程,有一定的开销(从注册到激活,实测iOS Safari和Chrome耗时70~100ms,UC浏览器和QQ浏览器的耗时都在200ms以上,均为内网测试结果,实际环境中还要算上sw.js的网络开销),所以最好是在页面加载完之后执行。

注册、安装、激活都完成之后, Service worker 就可以对作用域内的页面生效。这里说的作用域并不是变量的作用域,而是指 Service worker 脚本所在的目录。默认情况下, Service worker 可以作用于其脚本所在目录及其子目录下的所有页面。例如以「/a/sw.js」注册的Service worker可以作用于「/a/page1.html」、「/a/b/page2.html」,但无法作用于「/index.html」。不过,也可以通过参数指定作用域,比如:

serviceWorker.register('/a/sw.js', {
    scope: '/'
});

然而,这段代码运行的时候会出现异常:

Failed to register a ServiceWorker: The path of the provided scope ('/') is not under the max scope allowed ('/a/'). Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope.

原因就是,默认情况下作用域只能降低而不能提升。如果非得提升,就要给脚本文件增加一个HTTP响应头「Service-Worker-Allowed」。例如:

server {
    location /a/sw.js {
        add_header 'Service-Worker-Allowed' '/';
    }
}

此外, Service worker 脚本还必须与页面同域。为了避免作用域带来的麻烦,建议把该脚本文件放置于页面所在域的根目录下。

顺带一提,在实际应用中,建议给 Service worker 增加开关。因为它毕竟属于新特性,还不知道会不会有未知的坑,一旦出现大规模故障,需要有一种快速的方式让其失效。示例代码如下:

fetch('/sw-enable?' + Date.now()).then(
    // 200状态为开,其他状态为关
    function(res) { return res.status === 200 ? 1 : -1; },
    // 请求失败时不做任何操作
    function() { return 0; }
).then(function(flag) {
    if (flag === 1) {
        serviceWorker.register('/sw.js');
    } else if (flag === -1) {
        serviceWorker.getRegistration('/sw.js').then(function(reg) {
            if (reg) { reg.unregister(); }
        });
    }
});

需要特别注意的是,如果处于关闭状态,一定要注销 Service worker 。否则对于已注册 Service worker 的客户端而言,该worker还是存在的。

代理

Service worker 激活后就会成为页面跟浏览器之间的代理。它作用域内所有页面的所有HTTP请求(除了它自身)都会触发它的fetch事件。下面以WebP的兼容处理为例,说明 Service worker 的代理作用。

WebP是Google发布的图片文件格式。与JPG、PNG等格式相比,在质量相同的前提下,WebP格式的文件往往会更小。然而,微软和苹果尚未在自家浏览器中支持这种格式,所以在实际应用中需要处理兼容问题。

过往做兼容处理的方式,主要是检查兼容性后动态输出图片路径。但是这种方式需要在所有输出图片的地方做额外处理,并且对SEO不友好。而 Service worker 则可以通过拦截原图片(PNG、JPG)的请求并将其“修改”为对应的WebP请求。

// sw.js
self.addEventListener('fetch', (e) => {
    // accept: image/webp,image/apng,image/*,*/*;q=0.8
    const headers = e.request.headers;
    const supportsWebP = headers.has('accept') && headers.get('accept').includes('webp');

    const url = new URL(e.request.url);

    if (supportsWebP && url.host.includes('qiniu')) {
        url.search = '?imageMogr2/format/webp';
        e.respondWith(
            fetch(url.toString(), { mode: 'no-cors' })
        );
    }
});

以上代码通过监听fetch事件:

  • 检测浏览器对WebP的支持(支持WebP的浏览器,在accept这个请求头中,都会带有「image/webp」);
  • 倘若浏览器支持WebP,且图片的存储空间也支持WebP转换,则生成对应的WebP请求的URL,并通过 Fetch API 进行请求;
  • 通过事件对象的「respondWith」方法,使用 Fetch API 的响应作为本次请求的响应。

至此,劫持原请求定向到另一个请求的功能就完成了。

与CacheStorage交互

我们还可以在 Service worker 脚本中与 CacheStorage 进行交互,实现资源的缓存和提取。

第一种缓存策略是预缓存。它的原理是在 Service worker 的安装事件中缓存一部分资源,并且在这些资源缓存成功之后再完成安装。

// sw.js
const CACHE_KEY = 'v1';
const cacheList = [
    '/js/jquery.js',
    '/style/reset.css'
];
self.addEventListener('install', (e) => {
    e.waitUntil(
        caches.open(CACHE_KEY).then((cache) => {
            return cache.addAll(cacheList);
        });
    );
});

这种策略的好处是:只要 Service worker 安装成功,就可以确保缓存可用(排除存储空间不足等因素)。然而,它的缺点也不可忽视:只要有一个预缓存的资源请求失败,就会导致 Service worker 安装失败。因此,预缓存的资源越少越好

预缓存成功后,就可以在fetch事件中匹配缓存里面的资源进行响应:

// sw.js
self.addEventListener('fetch', (e) => {
    e.respondWith(
        caches.match(e.request).then((response) => {
            if (response != null) {
                return response;
            } else {
                return fetch(e.request.url);
            }
        })
    );
});

第二种缓存策略是增量缓存,流程很简单:如果在缓存中匹配到请求的资源,则直接响应;否则发送请求,并把资源缓存下来后再响应。需要注意的是,不要去缓存异常状态(如HTTP状态码为404或500)的资源。代码实现如下:

// sw.js
self.addEventListener('fetch', (e) => {
    e.respondWith(
        caches.match(e.request).then((res) => {
            if (res != null) {
                return res;
            } else {
                return fetch(url).then((res) => {
                    if (res && (res.status === 200 || res.status === 304)) {
                        const resCache = res.clone();
                        caches.open(CACHE_KEY).then((cache) => {
                            cache.put(url, resCache);
                        });
                    }
                    return res;
                });
            }
        });
    );
});

在实际应用的时候,还需要排除一些特殊请求:

  • 浏览器允许在HTTPS协议的页面中通过HTML标签加载HTTP协议的图片、视频等资源。但是, Fetch API 不允许这么做。所以,不要用 Fetch API 发送HTTP协议的请求。
  • 第三方资源的请求不应缓存,如各种统计平台的资源。
  • 非GET请求不应缓存,因为它们大部分涉及提交数据到后端并让其执行某些操作。
  • Service worker 的开关接口不应缓存。

代码实现如下:

// sw.js
self.addEventListener('fetch', (e) => {
    let url = new URL(e.request.url);
    if (url.protocol === 'http:' ||
        (url.host !== location.host && url.host.includes('.abc-cdn.com')) ||
        e.request.method !== 'GET' ||
        url.pathname.indexOf('sw-enable') !== -1
    ) {
        return;
    }

    url = url.toString();
    e.respondWith(
        // ...
    );
});

更新

只要浏览器检查到 Service worker 脚本文件的内容有变化,就会安装新的 Service worker 。但是,在默认情况下,新的 Service worker 处于等待状态,得关闭所有跟旧 Service worker 有关联的页面,再重新打开,新的 Service worker 才会被激活。如果想新的 Service worker 马上生效,可以在安装事件中调用「self.skipWaiting」:

// sw.js
self.addEventListener('install', (e) => {
    e.waitUntil(
        caches.open(CACHE_KEY).then((cache) => {
            return cache.addAll(cacheList);
        }).then(() => {
            return self.skipWaiting();
        })
    );
});

需要特别注意的是, Service worker 脚本文件要设置为永不缓存(max-age: 0)。否则,即使它的内容有变化,浏览器也无法得知,也就无法更新了。事实上,浏览器也考虑到了缓存的情况,为了避免不良脚本长时间生效,Service worker脚本每24小时一定会被下载一次。

讲到这,其实只实现了 Service worker 自身的更新,但如何进一步更新 CacheStorage 中的资源缓存呢?前文有提及, CacheStorage 是按模块存储的,利用这个存储结构,就可以实现每发布一次代码就更换一个存储模块。由于新的存储模块内是空的,根据增量缓存的机制,浏览器会通过网络或者HTTP缓存获取这个资源。代码如下:

// sw.js
const CACHE_KEY = 'v2'; // 下次发布时改成v3
caches.keys().then(function(keys) {
    keys.forEach(function(key) {
        if (key !== CACHE_KEY) {
            caches.delete(key);
        }
    });
});

生命周期

讲到这,其实已经接触到 Service worker 生命周期中的绝大部分环节,下面通过一张生命周期图进行归纳:

Service worker 生命周期

性能对比

实现了增量缓存之后,相当于页面只要打开过一次就可以离线浏览了。下面对两种缓存方案(Service worker + CacheStorage、HTTP缓存)做性能对比。首先是正常网速下的对比:

正常网速下的HTTP缓存
HTTP缓存 (正常网速)

正常网速下的Service worker
Service worker + CacheStorage (正常网速)

可以发现,没有太大的区别。其实这也很好理解,被缓存的资源,无论是CacheStorage还是HTTP缓存,本质上要么存在磁盘、要么已经被浏览器调入内存,既然来源是一样的,读取的速度自然也大致相同。

下面再看一下慢速3G网络下的情况:

慢速3G网络下的HTTP缓存
HTTP缓存 (3G慢速)

慢速3G网络下的Service worker
Service worker + CacheStorage (3G慢速)

可以发现,HTML文档的请求速度有较大差异。在 Service worker + CacheStorage 方案中,HTML文档已经被缓存下来了;而在HTTP缓存方案中,HTML文档的状态码为304,说明浏览器向服务器发出了请求。而这一次HTTP请求在网络较慢的情况下耗时较长。

如果给HTML文档设置过期时间(max-age),让浏览器将其缓存起来,这个差异是否就不存在呢?实际情况没有这么简单:

  • 即使设置了过期时间,某些浏览器仍然会请求服务器,例如PC和Android平台的Chrome。
  • 没有好的办法可以在代码变更时告知浏览器清除缓存。
  • 传统后端渲染的应用中,HTML文档数量太多(例如网易的每篇新闻都是一个HTML文档),全部缓存下来会占用大量存储空间。

所以,一般不会给HTML文档设置缓存时间,或者只设一个很短的缓存时间。然而,HTML文档作为页面的入口,缓存下来的意义是非常大的。自从了有了 Service worker ,可以做到:

  • 拦截HTML文档的请求,检查 CacheStorage 后再决定是否请求服务器;
  • 通过修改 Service worker 脚本及时清理缓存。

此外,前端渲染模式可以实现一个HTML文档对应多份同类内容;基于Vue.js、React、Angular等框架开发的单页应用甚至只有一个HTML文档。

综上所述,在前端渲染模式下通过 Service worker 和 CacheStorage 缓存HTML文档,可以有效提高网络不稳定时页面的加载速度。而因为静态资源本身有HTTP缓存,所以不必在 CacheStorage 中缓存所有静态资源(只缓存关键的部分)。

小结

最后我们必须搞清楚一个问题: Service worker + CacheStorage 的缓存机制与 HTTP缓存 其实是比较相似的,为什么需要两种相似的缓存?

  • 其一,HTTP缓存则是由服务器(响应头)控制的,且缓存过期前,服务器无法通知浏览器清理缓存;
  • 其二, Service worker 可以在浏览器端实现对缓存的有效控制,包括缓存策略缓存清理
  • 其三, Service worker 支持离线运行,在离线或网络不好的情况下可以快速响应,这一点对信号不稳定的移动网络来说尤其重要。

顺带一提, HTML 5 中的 Application Cache (离线缓存)因为实际应用的时候灵活性不足,已不再建议使用,该标准也已经被废弃。

在Vue.js项目中接入Service worker

Service worker 所带来的好处让我迫不及待地想将其接入到项目中,下面以一个典型的Vue.js项目为例,讲一下接入过程。

第一步是注册 Service worker 脚本,为了尽可能在页面组件加载完后再执行这一步,可以把这片代码放到Vue.js根实例(main.js)的mounted钩子中执行:

// main.js
new Vue({
    mounted() {
        // 本地开发时不启用Service worker
        if (['test', 'pre', 'prod'].indexOf(env) === -1) { return; }

        const serviceWorker = window.navigator.serviceWorker;
        if (!serviceWorker || typeof fetch !== 'function') { return; }
        fetch('/sw-enable?' + Date.now()).then(
            (res) => { return res.status === 200 ? 1 : -1; },
            () => { return 0; }
        ).then((flag) => {
            if (flag === 1) {
                serviceWorker.register('/sw.js');
            } else if (flag === -1) {
                serviceWorker.getRegistration('/sw.js').then((reg) => {
                    if (reg) { reg.unregister(); }
                });
            }
        });
    });
});

Service worker 脚本的内容跟前文提及的大致上一样(此处只做了预缓存):

// 缓存模块(版本号)
const CACHE_KEY = 'v$REV';
// 要预缓存的资源列表
const cacheList = [
    '/index.html',
    'https://abc-cdn.com/polyfill.min.js'
];

self.addEventListener('install', (e) => {
    e.waitUntil(
        caches.keys().then((keys) => {
            // 清理旧缓存
            keys.forEach((key) => {
                if (key !== CACHE_KEY) { caches.delete(key); }
            });
        }).then(() => {
            // 预缓存
            return caches.open(CACHE_KEY)
                .then((cache) => { return cache.addAll(cacheList); })
        }).then(() => {
            // 跳过等待
            return self.skipWaiting();
        });
    );
});

self.addEventListener('fetch', (e) => {
    const url = new URL(e.request.url);
    if (url.protocol === 'http:' ||
        url.pathname.includes('sw-enable') ||
        e.request.method !== 'GET' ||
        (url.host !== location.host && cacheList.indexOf(e.request.url) === -1)
    ) {
        return;
    }

    // 判断是否HTML文档的请求
    const isHTMLDoc = e.request.headers.has('accept') &&        
        e.request.headers.get('accept').includes('text/html') &&
        (url.pathname.endsWith('.html') || !/\.\w+$/.test(url.pathname));

    // 基于Vue.js的单页应用只有一个HTML文档,所有HTML文档的请求可以全部指向一个文件
    const request = isHTMLDoc ? new Request('/index.html') : e.request;

    e.respondWith(
        caches.match(request).then((res) => {
            if (res != null) {
                return res;
            } else {
                return fetch(url.toString());
            }
        })
    );
});

需要特别提一下的是:

  • 「$REV」是个占位符,要在Webpack构建流程中将其替换为具体的版本号;
  • 预缓存资源中第一项为HTML文档(单页应用只有一个HTML文档,只缓存这个就行了),第二项是关键的静态资源(ES6的polyfill);
  • 当前域下所有HTML文档的请求其实都是指向同一个请求(index.html)。

最后,在Webpack构建流程中增加一个步骤,把 Service worker 脚本的「$REV」替换成新版本号(时间戳),并拷贝到index.html所在路径下(保证他们同域):

new CopyWebpackPlugin([
    {
        from: path.resolve(__dirname, '../src/sw.js'),
        to: path.dirname(config.build.index),  // index.html所在路径
        transform(content, path) {
            return content.toString().replace('$REV', Date.now());
        }
    }
])

Web App Manifest

这一节介绍的是一个简单的JSON配置文件,示例代码如下(manifest.json):

{
    "name": "贝聊官网",
    "short_name": "贝聊官网",
    "start_url": "/",
    "display": "standalone",
    "background_color": "#fff",
    "theme_color": "#fff",
    "orientation": "portrait",
    "description": "中国幼儿园家长工作平台",
    "icons": [{
        "src": "https://s2.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-192x192.png",
        "type": "image/png",
        "sizes": "192x192"
    }, {
        "src": "https://s2.imgbeiliao.com/assets/imgs/icons/app/1.0.0/parent-512x512.png",
        "type": "image/png",
        "sizes": "512x512"
    }]
}

比较关键的几个配置项包括:

  • name:应用的名字。
  • short_name:应用简称,用于在空间不那么充足的位置显示,如桌面图标。
  • start_url:启动页路径。
  • display:显示模式,一共有四种,分别是fullscreen(占全屏)、standalone(占状态栏以外的空间)、minimal-ui(有浏览器的导航菜单)、browser(使用浏览器打开)。
  • icons:指定各种尺寸的图标。

编写好这样一个配置文件之后,还需要通过link标签在HTML文档中引用它:

<link rel="manifest" href="/manifest.json" />

在此基础上,如果还符合以下条件:

  • Manifest文件配置了以下项目:
    • short_name;
    • name;
    • start_url;
    • 192×192的png图标。
  • 页面使用HTTPS协议,且注册了Service worker。
  • 被访问至少两次,且两次访问至少间隔五分钟

使用Chrome浏览器打开页面后就会弹出「添加到主屏幕」的横幅(下文简称为「A2HS横幅」)。而点击主屏幕图标进入应用后,会先出现一个启动屏(注意:配置了512x512以上尺寸的图标才会显示到此),然后才进入到App的启动页。

添加到主屏幕

支持A2HS横幅的浏览器有Chrome、UC浏览器、小米浏览器,均在Android平台下。对于其他浏览器而言,只能手动找到功能菜单或按钮,再添加到主屏幕。

最后再说一下Manifest文件的一些问题:

  • 修改Manifest文件后,必须重新添加到主屏幕才能生效。
  • iOS下的问题:
    • 启动屏为白屏;
    • 丢失上下文,每次进入应用(包括重新启动、回到主屏幕再进入)都会回到启动页,这是最严重的问题。
  • 部分配置项无效,包括background_color、theme_color、orientation、icons。其中icons可以通过标签配置:
    <link rel="apple-touch-icon" sizes="192x192" href="..." />

现状

PWA的现状可以用这么一句经典的话来概括:

前途是光明的,道路是曲折的

先看一张兼容性方面的图:

PWA相关技术兼容性

可见:

  • 对PWA支持最为完美的只有Chrome,但它在国内的市场占有率不高,而且部分服务不可用。
  • Service Worker 和 CacheStorage 的可用度较高;
  • 推送通知的可用度较低(故而本文没有进行介绍);
  • 国内厂商的浏览器都没有「添加到桌面」的功能菜单;如果A2HS横幅被关闭,就无法通过其他方式把应用添加到桌面。

此外,iOS Safari从iOS 11.3起支持PWA大部分特性,但存在较严重的体验问题——每次离开PWA都会丢失上下文。

综上所述,目前对大部分企业来说,做一个完整的PWA应用并不是明智的选择。然而,通过支持度较高的 Service worker 和 CacheStorage 改善用户体验,却是很有意义的。另一方面,虽然Web跟原生App存在竞争关系,但更多情况下,它们是相互合作的——大部分App都内嵌了网页去实现部分功能。所以,可以考虑在App的WebView中支持上述技术,为Web提供支援。

评论 (0条)

发表评论

(必填)

(选填,不公开)

(选填,不公开)

(必填)