PWA 落地实践

谷歌发的协议,苹果目前不支持,但是可以根据 manifest.json 文件能读出来部分配置

项目需求,需要在html5页面中实现,添加PC端桌面或者移动端桌面按钮的功能。经过调研,通过渐进式web应用pwa(Progressive Web Apps)实现添加到主屏幕中(Add to Home Screen,简称 A2HS),是现代智能手机浏览器中的一项功能,能够快捷的web页面添加到主屏幕中,通过快捷方式,单击快速访问。

# PWA 中两个角色

# 1. Service Worker

  • 功能:Service Worker 是一个在后台运行的脚本,主要用于控制网络请求、管理缓存、实现离线功能、推送通知等。它在用户关闭页面后依然可以运行,帮助 PWA 提供类似原生应用的体验,如离线支持和后台数据同步。
  • 工作原理:Service Worker 拦截网络请求,可以决定是使用缓存的数据,还是从网络获取更新的内容,确保应用能够在无网络连接时也正常工作。

# 2. Web App Manifest

  • 功能:Web App Manifest 是一个 JSON 文件,定义了 PWA 的基本元数据,如应用的名称、图标、启动 URL、主题颜色等。它用于配置 PWA 的外观和行为,特别是在用户将应用添加到主屏幕时,使其像原生应用一样运行。
  • 作用:通过 Web App Manifest,用户可以在设备上“安装”你的应用(例如,添加到手机的主屏幕上),并获得类似于原生应用的启动体验。

有这个文件,在pc端已经支持加桌功能,只不过没有离线,推送能功能。

# 原生PWA 配置

PWA 必须在 HTTPS 环境下运行,因为服务工作线程(Service Worker)等功能需要安全的环境。

# 1. manifest文件

manifest.json 内容如下:

{
  "background_color": "rgba(12, 12, 12, 1)",
  "theme_color": "rgba(12, 12, 12, 1)",
  "orientation": "any",
  "description": "web程序的一般描述",
  "display": "standalone", // fullscreen(全屏 没有电量条) 、standalone、browser
  "icons": [
    {
      "src": "/img/logo.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "screenshots": [
    {
      "src": "/img/wide-screen-pc.png",
      "sizes": "1442x927",
      "type": "image/png"
    },
    {
      "src": "/img/wide-screen.png",
      "sizes": "386x830",
      "type": "image/png",
      "form_factor": "wide"
    }
  ],
  "name": "桌面PWA",
  "short_name": "应用名称简称",
  "start_url": "/discover",
  "id": "/discover"
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

然后在head 中添加这个

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

# 2. 添加自定义加桌按钮

<button class="add_button_theme" style="display: block">
   Add To HomeScreen
</button>

.add_button_theme {
    display: none;
    position: fixed;
    bottom: 20px;
    left: 50%;
    z-index: 100;
    transform: translateX(-50%);
    background: #378ef5;
    color: #fff;
    text-decoration: none;
    padding: 10px 20px;
    font-size: 15px;
    line-height: 20px;
    border-radius: 20px;
    box-shadow: 0 4px 16px rgb(0 0 0 / 30%);
    border: none;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 3. 注册Service Worker

推荐使用 register-service-worker 插件,会集成一个钩子函数。

import { register } from 'register-service-worker';
if ('serviceWorker' in window.navigator) {
  register(`/service-worker.js`, {
    ready() {
      console.log(
        'App is being served from cache by a service worker.\n' + 'For more details, visit https://goo.gl/AFskqB'
      );
    },
    registered() {
      console.log('Service worker has been registered.');
    },
    cached() {
      console.log('Content has been cached for offline use.');
    },
    updatefound() {
      console.log('New content is downloading.');
    },
    updated() {
      console.log('New content is available; please refresh.');
    },
    offline() {
      console.log('No internet connection found. App is running in offline mode.');
    },
    error(error) {
      console.error('Error during service worker registration:', error);
    }
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

原生用法

if ('serviceWorker' in navigator) {
  navigator.serviceWorker
    .register('service-worker.js')
    .then(() => { console.log('Service Worker Registered'); });
}

1
2
3
4
5
6

其中 service-worker.js 文件内容如下,可以在离线状态调取对应数据

一共有三个方法 install 、activate、fetch

install 事件在 Service Worker 首次被安装时触发。通常在此事件中,开发者会预缓存一些静态资源,以确保应用可以在离线时使用。

典型操作:打开缓存并添加文件,如 HTML、CSS、JavaScript 和图像文件。

activate 事件在 Service Worker 被激活时触发,通常用于清理旧的缓存,或者更新缓存内容。当新的 Service Worker 取代旧的 Service Worker 时,这个事件会被触发。

典型操作:删除不再需要的旧缓存,确保新的缓存能够生效。

fetch 事件在页面发起网络请求时触发,开发者可以拦截这些请求,并根据策略(如缓存优先、网络优先等)返回缓存的资源或从网络获取资源。这个事件可以帮助实现离线功能。

典型操作:检查缓存中是否有请求的资源,如果有则返回缓存内容,否则通过网络请求资源。

const CURRENT_CACHE_NAME = 'm_web_dev_v1.1'; // 存储的key
const URLS = [];
const cacheFiles = [];

self.addEventListener('install', async () => {
  console.log(12123454544545, cacheFiles);
  console.log('install');
  const cache = await caches.open(CURRENT_CACHE_NAME);
  await cache.addAll(URLS);
  await self.skipWaiting();
});

self.addEventListener('activate', async () => {
  console.log('activate');
  const keys = await caches.keys();
  keys.forEach(key => {
    if (key !== CURRENT_CACHE_NAME) { // 新版本发布后  清除之前老版本
      caches.delete(key);
    }
  });
});

self.addEventListener('fetch', async function (e) {
  const req = e.request;
  const url = req.url;
  if (
    url.endsWith('.js') ||
    url.endsWith('.css') ||
    url.endsWith('.png') ||
    url.endsWith('.jpg') ||
    url.endsWith('.svg')
  ) {
    e.respondWith(cacheFirst(req));
  } else {
    e.respondWith(networkFirst(req));
  }
});

// 缓存优先
async function cacheFirst(req) {
  console.log('cacheFirst', req);
  const cache = await caches.open(CURRENT_CACHE_NAME);
  const cached = await cache.match(req);

  if (cached) {
    return cached;
  } else {
    const fresh = await fetch(req);
    cache.put(req, fresh.clone());
    return fresh;
  }
}

// 网络优先
// eslint-disable-next-line no-unused-vars
async function networkFirst(req) {
  console.log('networkFirst request', req);
  const cache = await caches.open(CURRENT_CACHE_NAME);
  try {
    const fresh = await fetch(req);
    console.log('networkFirst success', fresh);
    cache.put(req, fresh.clone());
    return fresh;
  } catch (e) {
    console.log('networkFirst fail', e);
    const cached = await cache.match(req);
    return cached;
  }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

# 4. 加载事件beforeinstallprompt

let deferredPrompt;
const addBtn = document.querySelector('.add_button_theme');
addBtn.style.display = 'none';

window.addEventListener('beforeinstallprompt', e => {
  e.preventDefault();
  deferredPrompt = e;
  addBtn.style.display = 'block';

  addBtn.addEventListener('click', () => {
    addBtn.style.display = 'none';
    deferredPrompt.prompt();
    deferredPrompt.userChoice.then(choiceResult => {
      if (choiceResult.outcome === 'accepted') {
        console.log('取消安装');
      } else {
        console.log('安装成功');
      }
      deferredPrompt = null;
    });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

# 判断是否加桌

beforeinstallprompt 事件本身不能直接判断当前应用是否已经被添加到桌面上。它的作用是捕获用户的 PWA 安装提示(通常是在浏览器判断 PWA 可以安装的时候触发)。如果你想判断应用是否已经被添加到桌面,通常需要结合其他方法来实现。

判断是否已被添加到桌面的常见方法:

  1. 使用 window.matchMedia() 可以通过检查显示模式来推测应用是否在桌面上运行。例如:

    if (window.matchMedia('(display-mode: standalone)').matches) {
        console.log('The app is running as a standalone PWA');
    } else {
        console.log('The app is not running as a standalone PWA');
    }
    
    1
    2
    3
    4
    5
  2. navigator.standalone(仅 iOS Safari 支持) 在 iOS Safari 中,你可以使用 navigator.standalone 来检查是否运行在独立模式中:

    if (window.navigator.standalone) {
        console.log('The app is running as a standalone PWA on iOS');
    } else {
        console.log('The app is not running as a standalone PWA on iOS');
    }
    
    1
    2
    3
    4
    5

这两种方法可以帮助你判断应用是否已被添加到桌面上运行。

# 5. 最终实现效果如下图所示:

image-20240908175350231

目前很多app 都会禁止生成PWA, 比例微信 facebook 飞书等。

如果需要,可以添加 Web Push 通知功能。使用 Push APINotification API 来实现推送通知。


# @vue/cli-plugin-pwa

https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-pwa

@vue/cli-plugin-pwa 是 Vue CLI 提供的一个插件,专门用于为 Vue 应用添加 PWA(Progressive Web App)功能。这个插件集成了 Web App Manifest、Service Worker 等核心功能,并提供了一些开箱即用的配置,方便开发者将 Vue 应用转换为 PWA。

主要功能:

自动生成 manifest.json 文件,包含应用名称、图标、启动 URL、主题颜色等信息。

可以通过 vue.config.js 配置来自定义 manifest 的内容。

配置 vue.config.js 文件:

module.exports = {
  pwa: {
    workboxPluginMode: 'InjectManifest',
    workboxOptions: {
      swSrc: './public/service-worker.js',
      swDest: 'service-worker.js',
      cacheId: 'my-app', // 给 Service Worker 的缓存命名 每次部署时,如果你希望确保缓存能够正确管理和更新,cacheId 应该在每个部署版本中保持唯一
    },
    name: 'My App',
    themeColor: '#4DBA87',
    msTileColor: '#000000',
    manifestOptions: {
      background_color: '#42B883'
    },
    workboxOptions: {
      // Workbox 选项,像是缓存策略等
    }
  }
};

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# Workbox 提供的两种模式

  1. GenerateSW (默认模式):
    • 在默认情况下,@vue/cli-plugin-pwa 使用 GenerateSW 模式。这种模式会自动生成一个完整的 Service Worker 文件,并根据你在 vue.config.js 中配置的选项(如缓存策略、预缓存文件等)来管理应用的缓存。开发者无需手动编写 Service Worker,插件会为你处理大部分的工作。
  2. InjectManifest (自定义模式):
    • 如果你需要完全控制 Service Worker 的逻辑,比如自定义缓存策略、处理后台同步、管理复杂的推送通知等,可以使用 InjectManifest 模式。
    • InjectManifest 模式下,你需要手动编写一个 Service Worker 文件(通常命名为 src/my-service-worker.js 或其他路径),然后 Workbox 会在构建过程中将一些基础的 Workbox 运行时代码注入到你编写的 Service Worker 中。这意味着你可以编写自己的 Service Worker 逻辑,同时利用 Workbox 的部分功能。

InjectManifest 模式下,@vue/cli-plugin-pwa 不会自动生成 service-worker.js 文件。相反,它允许你自己编写自定义的 service-worker.js 文件,然后通过 Workbox 将预缓存逻辑注入到你的自定义 Service Worker 中。

# InjectManifest 模式的工作原理

构建时,Workbox 会自动注入额外的代码,以实现预缓存和其他功能

  • 预缓存的文件列表
  • Workbox Runtime 代码注入
  • 使用的 Workbox 功能

self.__WB_MANIFEST 里面包括所有的静态资源

  1. 自定义 Service Worker 文件
    • 你需要手动编写 service-worker.js 文件。这个文件可以包含你自己定义的缓存策略、事件监听器等逻辑。
  2. Workbox 注入预缓存逻辑: 插件会继承很多功能
    • InjectManifest 模式会将 Workbox 的预缓存逻辑注入到你自定义的 service-worker.js 文件中。通过配置,你可以定义哪些文件需要预缓存(例如静态资源),但不会完全覆盖你的自定义逻辑。
  3. 手动注册 Service Worker
    • 在这种模式下,你仍然需要手动注册 Service Worker,确保浏览器能够找到并激活你的 service-worker.js 文件。

# Web Share API

延伸其他API

Web Share API 和 PWA 一样是一项由古哥提出的草案 (opens new window),现还未被纳入 W3C。通过 Web Share API,用户可以方便地将内容或数据分享到应用中。

不过,现在只有安卓 Chrome 55 以上支持 Web Share API。另外,要使用分享功能,还要满足以下几点:

满足了这些剩下的就很简单了,只需监听用户事件 (opens new window),然后将需要分享的内容传递给 Web Share API 就可以了。

// CommonService.js
export const isSupportShareAPI = () => !!navigator.share;

export const sharePage = () => {
    navigator
        .share({
            title: document.title,
            text: document.title,
            url: window.location.href
        })
        .then(() => console.info('Successful share.'))
        .catch(error => console.log('Error sharing:', error));
};
1
2
3
4
5
6
7
8
9
10
11
12
13