【PWA】打造一个可以离线访问的Web应用

1,071 阅读8分钟

前言

因为工作需要,这段时间接触到了PWA,所以突发奇想给自己的博客稍作下优化,运用PWA技术来给博客添加一些新功能(生成网页应用)与提升一些体验(离线访问),顺带深入学习下PWA。

pwaAPP.jpg

Progressive Web App (PWA)技术介绍

PWA并不是具体的某项技术,而是提升 Web App 的体验的一系列新方法,能给用户原生应用的体验,在安全、性能和体验三个方面都有很大提升,PWA 本质上是 Web App,借助一些新技术也具备了 Native App 的一些特性,兼具 Web App 和 Native App 的优点。

PWA所主要涉及的技术

  • Web app manifests: 通过 JSON 提供应用程序相关信息,目的是将应用程序安装到设备桌面;
  • Service Worker: 充当浏览器与服务端间的网络代理,提供离线访问能力
  • Push API: 从服务器向客户端推送消息,需要借助 service worker;
  • Notifications API: 允许网页或应用程序在系统级别发送在页面外部显示的通知;
  • Add to Home Screen: 将应用程序安装到设备桌面的关键,需要浏览器支持 beforeinstallprompt 事件;

在上面所涉猎的技术栈中, Web app manifest与Service Worker技术是PWA最关键的两项技术,本篇文章会着重介绍,而随着apple在iOS Safari中也开始支持PWA(其中的某些技术),PWA的舞台更大了。

Web app manifests

熟悉小程序的朋友想必都清楚manifest.json,在PWA中也有着一个manifest,我们称之为“功能清单”,它被用来定义应用的基本信息和行为。 范例:

{
    "id":"/",
    "name": "Waylon's Blog",
    "short_name": "waylon blog",
    "start_url": "/",
    "scope":"/",
    "orientation": "portrait-primary",
    "display": "standalone",
    "background_color": "#fff",
    "theme_color": "rgba(0, 0, 0, 0.8)",
    "description": "Waylon的博客,一起学习前端JavaScript,typeScript,Vue,React。",
    "icons": [{
      "src": "logo.png",
      "sizes": "144x144",
      "type": "image/png"
    },{
      "src": "logo192.png",
      "sizes": "192x192",
      "type": "image/png"
    }],
    "lang": "en-US",
    "related_applications": [{
      "id": "waylon blog.app",
      "url":".",
      "platform": "web"
    }]
  }
  

在上述代码中包含着许多的字段,这里着重介绍几个比较关键的:

  • name,short_name:应用的全称与简称,一般来说,当没有足够空间展示应用的name时,系统就会使用short_name

  • description:应用描述;

  • start_url: Web App首屏加载的URL;

  • diplay:display控制了应用的显示模式,它有四个值可以选择:fullscreenstandaloneminimal-uibrowser

    • fullscreen:全屏显示,会尽可能将所有的显示区域都占满;
    • standalone:独立应用模式,这种模式下打开的应用有自己的启动图标,并且不会有浏览器的地址栏。因此看起来更像一个Native App;
    • minimal-ui:与standalone相比,该模式会多出地址栏;
    • browser:一般来说,会和正常使用浏览器打开样式一致。
  • background_color,theme_color:background_color是指在应用的样式资源为加载完毕前的默认背景,因此会展示在开屏界面,theme_color则是应用程序主题颜色;

  • icons: 桌面应用的图标,icons本身是一个数组,每个元素包含三个属性:

    • sizes:图标的大小。通过指定大小,系统会选取最合适的图标展示在相应位置上。
    • src:图标的文件路径。注意相对路径是相对于manifest。
    • type:图标的图片类型。

    这里有个坑点,PC端icon尺寸至少需要144*144,否则无法生成桌面应用,笔者在这里卡了很久。

  • orientation:定义所有 Web 应用程序顶级的默认方向,在window操作系统中这个是必选项,否则无法安装(也是一个小坑)。

  • 其他字段:详见:developer.mozilla.org/zh-CN/docs/…

引用方式:

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

引入成功后,就可以在地址栏的右侧看到【添加到应用】的按钮,点击即可安装。

feaf190ad44519b077cc62cf0e34d78.png

但是作为一名有追求的开发,怎么能够忍受浏览器原生的功能,所以我选择在页面添加一个按钮引导【添加应用】,就是图中显示的这样。

downloadPWA.png 这里我们要用到浏览器的BeforeInstallPromptEvent事件,它会在应用被安装到设备前触发,我们可以通过它内置的prompt()方法触发安装提示框,并且通过userChoice对象获取用户点击了【安装】还是取消。值得一提的是,这个API目前还处于实验阶段,其兼容性如下:(caniuse.com/?search=Bef…) beforeInstallPromptEvent.jpg

具体代码实践

<template>
    <div class="download-pwa">
        <a-popover placement="bottom" trigger="hover" overlayClassName="link-popover" :mouseEnterDelay="0">
            <template #content>
                <div class="popover-content">Download PWA</div>
            </template>
            <i class="waylon pwa" id="PWA-BTN" @click="download" />
        </a-popover>
    </div>
</template>

<script setup lang="ts">
import { ComputedRef, Ref } from 'vue';
import { useThemeStore } from 'store/theme';
import { useMessage } from '@/common/util/hook';
import { isSafari } from '@/common/util';
const { isBlack } = toRefs(useThemeStore());
const { message } = useMessage();
// PWA下载图标颜色
const pwaColor: ComputedRef<string> = computed(() =>
    isBlack.value ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.4)'
);
type PromptType = {
    prompt: Function;
    userChoice: Promise<{ outcome: string; platform: string }>;
} | null;
type Window = {
    navigator: {
        standalone?: boolean;
    };
};
const deferredPrompt: Ref<PromptType> = ref(null);
const download = () => {
    // 浏览器判断是否已在pwa环境
    const isPWASituation = isSafari
        ? (window as Window).navigator.standalone === true
        : window.matchMedia('(display-mode: standalone)').matches;

    if (isPWASituation) {
        message('info', '你现在已处于PWA应用中了~');
    } else {
        if (deferredPrompt.value) {
            deferredPrompt.value.prompt();
            deferredPrompt.value.userChoice.then(result => {
                if (result.outcome == 'dismissed') {
                    message('info', '取消安装!');
                } else if (result.outcome == 'accepted') {
                    console.log('用户接受了安装');
                    deferredPrompt.value = null;
                }
            });
        }
    }
};
// 获取Prompt对象
const catchPrompt = (e: any) => {
    // Chrome 67 及之前版本,会自动展现安装的 prompt
    // 为了版本统一及用户体验,我们禁止自动展现 prompt
    e.preventDefault();
    // 存放事件用于后续触发
    deferredPrompt.value = e as unknown as PromptType;
    console.warn('beforeinstallprompt', e);
};
// 安装成功的回调
const installedCallback = () => {
    deferredPrompt.value = null;
    console.log('应用安装');
};
onMounted(() => {
    // 仅浏览器支持且未安装该应用,以下事件才会触发
    window.addEventListener('beforeinstallprompt', catchPrompt);
    // 无论以何种方式安装 PWA 该事件都会触发
    // 因此这里可以用来做埋点
    window.addEventListener('appinstalled', installedCallback);
});
onUnmounted(() => {
    // 侦听器清除
    window.removeEventListener('beforeinstallprompt', catchPrompt);
    window.removeEventListener('appinstalled', installedCallback);
});
</script>

<style lang="scss" scoped>
.download-pwa {
    .pwa {
        color: v-bind(pwaColor);
        font-size: 30px;
        margin-right: 16px;
        cursor: pointer;
    }
}
</style>

Service Worker

Service Worker(下文称SW)服务是一个介于 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。

serviceWorker.jpg

Service Worker的基本特征

  • 无法操作DOM
  • 只能使用HTTPS以及localhost
  • 可以拦截全站请求从而控制你的应用
  • 与主线程独立不会被阻塞(不要再应用加载时注册sw)
  • 完全异步,无法使用XHR和localStorage
  • 一旦被 install,就永远存在,除非被 uninstall或者dev模式手动删除
  • 独立上下文
  • 响应推送
  • 后台同步

SW是事件驱动的worker,生命周期与页面无关。 关联页面未关闭时,它也可以退出,没有关联页面时,它也可以启动,其生命周期如下:

  • register 这个是由 client 端发起,注册一个 SW,这需要一个专门处理sw逻辑的文件;
  • parsed 注册完成,解析成功,尚未安装;
  • installing 注册中,此时 sw 中会触发 install 事件, 需知 sw 中都是事件触发的方式进行的逻辑调用,如果事件里有 event.waitUntil() 则会等待传入的 Promise 完成才会成功;
  • installed(waiting) 注册完成,但是页面被旧的 SW 脚本控制, 所以当前的脚本尚未激活处于等待中,可以通过 self.skipWaiting() 跳过等待;
  • activating 安装后要等待激活,也就是 activated 事件,只要 register 成功后就会触发 install ,但不会立即触发 activated,如果事件里有 event.waitUntil() 则会等待这个 Promise 完成才会成功,这时可以调用 Clients.claim() 接管所有页面;
  • activated 在 activated 之后就可以开始对 client 的请求进行拦截处理,sw 发起请求用的是 fetch api,XHR无法使用;
  • fetch 激活以后开始对网页中发起的请求进行拦截处理 terminate 这一步是浏览器自身的判断处理,当 sw 长时间不用之后,处于闲置状态,浏览器会把该 sw 暂停,直到再次使用;
  • update 浏览器会自动检测 sw 文件的更新,当有更新时会下载并 install,但页面中还是老的 sw 在控制,只有当用户新开窗口后新的 sw 才能激活控制页面;
  • redundant 安装失败, 或者激活失败, 或者被新的 SW 替代掉;

Service Worker实战

首先,明确我们需要使用SW做什么?首先,我们使用manifest.json的功能清单注册了一个桌面应用,但是作为一个桌面应用,它必须具备一些离线访问的能力,所以在这里,我们需要使用SW来缓存一些站点必要的资源,以便于在离线的情况下访问应用。 SW本质上就是一个JS脚本,在这个内置的脚本中,我们可以使用addEventListener侦听器去侦听其特有的事件,在SW中内置了许多API,详细可见MDN,这里我们会用到:

  • self对象:在Service Worker中,"self"是一个特殊的全局对象,它表示当前Service Worker线程的上下文,可以使用"self"对象来注册事件监听器、发送网络请求、缓存资源等。
  • install事件,当当前SW脚本被下载并安装后触发的事件,一般用来添加需要缓存的资源。
  • fetch事件,它会在每一个HTTP请求发出时触发,可用于实现自己的缓存策略,请求拦截等。
  • caches对象,用于缓存和管理资源。

编码

创建SW

首先,我们需要创建一个sw.js文件作为SW。

// packages\waylon-blog-pages\public\initSw.js
// 缓存key
const CACHE_KEY = 'WAYLON_BLOG_CACHE';
// 需要缓存的资源 - 固定资源
const CACHE_LIST = [
    '/',
    '/pixel-block-font.woff2',
    '/logo.svg',
    '/gem.json',
    '/manifest.json',
    '/logo.png',
    '/api/sdk/monokai-sublime.min.css',
    '/sw.js',
    '/initSw.js',
    '/api/sdk/three.min.js',
    '/api/sdk/marked.min.js',
    '/api/sdk/highlight.min.js',
    '/api/sdk/echarts.min.js',
    '/api/sdk/echarts-wordcloud.min.js',
];
// 侦听器
self.addEventListener('install', event => {
    console.warn('Service Worker install success');
    // 添加缓存
    event.waitUntil(
        caches.open(CACHE_KEY).then(cache => {
            return cache.addAll(CACHE_LIST);
        })
    );
});
// 监听sw是否活跃
self.addEventListener('activate', event => {
    console.log('Service Worker activate');
});

// 在 fetch 事件中返回缓存的资源
self.addEventListener('fetch', event => {
    try {
        event.respondWith(
            caches.match(event.request).then(response => {
                return response || fetch(event.request);
            })
        );
    } catch (e) {
        console.error('service worker fetch error:', e);
    }
});
注册SW
// packages\waylon-blog-pages\public\initSw.js
if ('serviceWorker' in navigator) {
    navigator.serviceWorker
        .register('/sw.js')
        .then(res => console.warn('service worker registered'))
        .catch(err => console.error('service worker not registered', err));
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <title>Waylonの树洞 | Waylon blog.</title>
  <meta name="description" content="Waylon的博客,一起学习前端JavaScript,typeScript,Vue,React。" />
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
  <meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,shrink-to-fit=no">
  <!-- 预加载字体文件 -->
  <link rel="preload" as="font" href="/pixel-block-font.woff2" type="font/woff2" crossorigin="anonymous">
  <!-- 无色icon -->
  <link rel=" stylesheet" href="//at.alicdn.com/t/c/font_3856002_u7n6feqprti.css">
  <!-- 带颜色的icon -->
  <link rel="stylesheet" href="//at.alicdn.com/t/c/font_3862335_a12e2rys0xa.css">
  <!-- 字体 -->
  <link rel="stylesheet" href="/src/assets/style/init.css">
  <!-- 百度验证 -->
  <meta name="baidu-site-verification" content="codeva-vL92OUhjoa" />
  <!-- PWA 配置 -->
  <link rel="manifest" href="/manifest.json">
  <!-- PWA 初始化 defer防止阻塞页面渲染 -->
  <script defer src="/initSw.js"></script>
</head>

<body>
  <div id="app" />
  <script type="module" src="/src/main.ts"></script>
</body>

</html>
查看效果

可以在【控制台】【应用】【Service Worker】查看已启动的SW,并且可勾选【离线】进入off-line模式debug。

offline.png 并且可以在【缓存空间】中查看我们刚刚设置的缓存。 SWcache.png

结束

自此,我们就打造了一款支持离线访问的PWA应用~但是我目前我也仅仅只是对必要的静态资源进行了SW缓存,仅仅保证页面在断网的情况下可访问,样式正常,并没有对数据层做缓存,后续会继续深入学习。

参考文献