pwa学习记录

618 阅读6分钟

参考资料

PWA的特点

  • 可安装
  • 资源在线加载且离线可用
  • 推送通知
  • 符合 PWA 安装条件的网站,浏览器会触发事件来提示用户安装

Web 应用清单

Web 应用清单是您创建的一种文件,用于告知浏览器您希望自己的 Web 内容在操作系统中如何显示为应用。该清单可以包含基本信息,例如应用名称、图标和主题颜色;高级偏好设置,例如所需的屏幕方向和应用快捷方式;以及目录元数据,例如屏幕截图。

每个 PWA 都应为每个应用包含一个清单,该清单通常托管在根文件夹中,并在可以从所有可安装 PWA 的 HTML 页面上提供相应链接。其官方扩展名为 .webmanifest

网站变成pwa的条件

官方pwa网站必备条件

  • 项目根目录下添加app.webmanifest
{
   "name": "My First Application"
}
<html lang="en">
<title>This is my first PWA</title>
<link rel="manifest" href="/app.webmanifest">
  • 安装的网站环境
https || localhost || file 协议
  • service worker(官网说是必需,但实际并不是必须的)
google浏览器发现这不是必要的捏

安装的方式与相关代码

不同操作系统安装pwa的方式

android安装方式

当网站加载了app.webmanifest应用清单,那么浏览器会自动弹窗提示用户是否安装该网页

设备和浏览器都会影响PWA的安装,PWA 可能会以 WebAPK快捷方式QuickApp 的形式进行安装。

浏览器安装的提示图可能有两种,左跟右 image.png

WebAPK

适用于安装了 Google 移动服务 (GMS) 的设备的 Google Chrome 和三星互联网浏览器,但仅限于三星制造的设备。

特点:

快捷键

网站快捷方式

特点:

  • 在主屏幕上显示带有浏览器标记的图标(请参阅以下示例)。
  • 启动器中或设置、应用中没有图标。
  • 无法使用任何需要安装的功能。
  • 无法更新其图标和应用元数据。
  • 可安装多次,即使使用同一个浏览器也可以;发生这种情况时,所有实例都将指向同一实例,并使用相同的存储空间。
QuickApp

当您的 PWA 作为 QuickApp 安装时,用户获得的体验与使用快捷方式时的体验类似,但带有一个带有 QuickApps 图标的图标(闪电图片)

商店上架

ios安装

在 iOS 和 iPadOS 上没有浏览器提示安装 PWA,必须通过 Safari 中才提供的菜单,将这些应用手动添加到主屏幕。只能通过Safari安装PWA。

特点:

  • 显示在主屏幕、特别关注的搜索、Siri 建议和应用库搜索中。
  • 不会显示在应用程序库的类别文件夹中。
  • 缺乏对标志和应用快捷方式等功能的支持。

相关疑问解答

如何开发环境下调试

如果想要测试安装与调试,则将访问地址改成localhost:端口号即可,同时点击此处即可。如图 image.png

如果想要查看清单的配置属性是否正确,如图 image.png

如何通过点击事件唤起安装弹窗?

整体思路是:google浏览器会在用打开网站的时候自动触发beforeinstallprompt事件,此时进行拦截beforeinstallprompt事件的默认发生并把对象存储起来,在需要唤起安装弹窗的时候调用对应的方法。

let deferredPrompt;
window.addEventListener('beforeinstallprompt', (e) => {
  // Prevents the default mini-infobar or install dialog from appearing on mobile
  e.preventDefault();
  // Save the event because you'll need to trigger it later.
  deferredPrompt = e;
});

// 在需要手动触发的时机触发此对象方法
deferredPrompt.prompt()

如何监听是否安装或者取消

// Gather the data from your custom install UI event listener
installButton.addEventListener('click', async () => {
  // deferredPrompt is a global variable we've been using in the sample to capture the `beforeinstallevent`
  deferredPrompt.prompt();
  // Find out whether the user confirmed the installation or not
  const { outcome } = await deferredPrompt.userChoice;
  // Act on the user's choice
  if (outcome === 'accepted') {
    console.log('User accepted the install prompt.');
  } else if (outcome === 'dismissed') {
    console.log('User dismissed the install prompt');
  }
});

如何判断是否在pwa环境中

let displayMode = 'browser';
const mqStandAlone = '(display-mode: standalone)';
if (navigator.standalone || window.matchMedia(mqStandAlone).matches) {
    displayMode = 'standalone';
}

如何区分安装(广告)来源?

{
    "name": "Pooke",
    "short_name": "Pooke",
    "icons": [
        {
           "src": "public/imgs/512.png",
           "type": "image/png",
           "sizes": "512x512"
        }
     ],
     "theme_color": "#232227",
     "background_color": "#232227",
     "start_url": "./?op=2",
     "display": "standalone"
 }

将start_url添加上对应的参数即可。

有一个问题,如果需要一个网站区分多个渠道,那么只能动态添加引入不同的app.webmanifest文件,理由是我们是通过start_url这个值来区分的,而这个值写死在app.webmanifest里面。

仔细看会发现每个webmanifest文件唯一的区分也就是start_url不同,能不能通过前端自己生成blob来动态修改呢?不行,pwa的安装不认识blob协议,只能通过http协议。故此,只能由服务端同学支持达到最好的动态引入效果

export const importPwaConfig = () => {

  let href = '/app.webmanifest'

  // 如果存在op(pixId),则通过js动态生成json文件。
  if (query['op']) {
      // 无效,pwa不认识blob协议
    const stringData = JSON.stringify({
      "name": "Pooke",
      "short_name": "Pooke",
      "icons": [
        {
          "src": "public/imgs/512.png",
          "type": "image/png",
          "sizes": "512x512"
        }
      ],
      "theme_color": "#232227",
      "background_color": "#232227",
      "start_url": `./?op=${query['op']}`,
      "display": "standalone"
    })
    const blob = new Blob([stringData], {
      type: 'application/octet-stream'
    })
    // 换成服务的协议,注意这里有同源策略的限制,对于部署不同的服务器可走nginx处理。
    href = '/askWebmanifest?op=12'
  }


  let link = document.createElement('link')
  link.setAttribute('rel', 'manifest')
  link.setAttribute('href', href)
  document.getElementsByTagName('head')[0].appendChild(link)


  window.addEventListener('beforeinstallprompt', (e: any) => {
    // Prevents the default mini-infobar or install dialog from appearing on mobile
    e.preventDefault();
    // Save the event because you'll need to trigger it later.
    window.deferredPrompt = e;
  });
}

node express关键代码

// 动态生成webmanifest文件,根据参数进行动态变化。
app.get('/askWebmanifest',(req,res)=>{
  const stringData = JSON.stringify({
    name: "Pooke2",
    short_name: "Pooke",
    icons: [
      {
        src:"https://upload.wikimedia.org/wikipedia/commons/5/5c/Bml_x_512_y_512_p_31_iterated_32000.png",
        type: "image/png",
        sizes: "512x512"
      }
   ],
     theme_color: "#232227",
     background_color: "#232227",
     start_url: `./?op=${req.query['op']}`,
     display: "standalone"
 })
  res.setHeader('Content-Type', 'application/manifest+json');
  res.send(stringData);
})

如何卸载pwa

打开pwa应用

image.png

浏览器的兼容性情况

移动端

测试机器: 苹果15 系统17.3.1

  • safari 只能以快捷方式的形式添加

红米13 andorid系统 11 RKQ1.211001.001

  • google 浏览器可以
  • edge 不行
  • 火狐 不行

荣耀v20 HarmonyOs: 4.0.0

  • google 浏览器可以
  • edge 不行
  • 火狐 只能以快捷方式的形式添加

清单的属性说明

web.dev/learn/pwa/w…

{
    "name": "My First Application",
    "short_name": "Application",
    "icons": [
        {
           "src": "icons/512.webp",
           "type": "image/webp",
           "sizes": "512x512"
        }
     ],
     "start_url": "/",
     "display": "standalone"
 }

Service Worker

相关资料

service worker 的功能类似于代理服务器,允许你去修改请求和响应,将其替换成来自其自身缓存的项目。只有将网站部署在https下或者localhost访问才能启动Service Worker

工作流程

当给网站写入Service worker相对应的代码后(注册),第一次加载网站时,会触发Service worker的两个钩子installactivate,可以在install这个钩子里面缓存本地文件。

更新serive workder

// 要求立即激活新的sw代码,如果不这么做的话,网页会一直使用旧的sw代码直到浏览器关闭重新打开,这是更新sw的关键。
skipWaiting()

注册

    const registerServiceWorker = async () => {
        if ("serviceWorker" in navigator) {
            try {
                const registration = await navigator.serviceWorker.register("/sw.js", {
                    scope: "/",
                })
                if (registration.installing) {
                    console.log("正在安装 Service worker")
                } else if (registration.waiting) {
                    console.log("已安装 Service worker installed")
                } else if (registration.active) {
                    console.log("激活 Service worker")
                }
            } catch (error) {
                console.error(`注册失败:${error}`)
            }
        }
    }

    registerServiceWorker();

install(缓存对应文件)

const addResourcesToCache = async (resources) => {
    const cache = await caches.open("v1")
    await cache.addAll(resources)
    console.log('缓存数据成功!')
}
// install 事件会在注册成功完成之后触发. install 事件通常会这样用,将离线运行 app 产生的资源放置在浏览器离线缓存的空间。
self.addEventListener("install", event => {
    console.log("Service worker installed")
    event.waitUntil(
        // 很傻逼,如果里面的路径有一个是错误的,那么里面的所有缓存都会失效。
        addResourcesToCache([
            // "/index.html",
            "/icons/512.webp",
            "/icons/512.jpg",
        ]),
    )
})

当第二次打开网站的时候,会触发fetch事件时,拦截请求,将让sw去找是否有缓存数据,没有则发网络请求获取

const cacheFirst = async (request) => {
    const responseFromCache = await caches.match(request)
    if (responseFromCache) {
        console.log('来自本地缓存的数据', request.url)
        return responseFromCache
    }
    console.log('请求网络的数据', request.url)
    return fetch(request)
}
// 每次获取 service worker 控制的资源时,都会触发 fetch 事件,这些资源包括了指定的作用域内的文档,和这些文档内引用的其他任何资源
self.addEventListener("fetch", event => {
    event.respondWith(cacheFirst(event.request))
})

webpack自动化插件

npm i workbox-webpack-plugin

// webpack
const { GenerateSW, InjectManifest } = require('workbox-webpack-plugin')
module.exports = {
    plugins:[
    ...,
    new GenerateSW({
        clientsClaim: true, // 快速启用服务
        skipWaiting: true,
        maximumFileSizeToCacheInBytes: 4 * 1024 * 1024
    }),
    ]
}

构建完成后会自动生成一个service-worker.js在dist文件夹,然后我们在html上进行引用。

<script>
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', async () => {
        console.log('page load...')
        let res = await navigator.serviceWorker.register('/service-worker.js')
        console.log(res, 'serviceWorker res')
        if (res) {
            console.log('register success!')
        } else {
            console.log('register fail!')
        }
        })
    }
</script>

遇到的坑

  1. workbox-webpack-plugin版本7以上用不了,跟node环境有关系,我本地的node环境为14.18.2,将版本换成6就好了,选择这个版本的原因是通过查看workbox-webpack-plugin的下载量来确定的。
  2. 由于生成的service-worker.js是一个固定的名称,在cdn上就无法进行更新,因为运维同学会默认将所有静态资源在线上环境进行cdn处理,会导致用户无法更新资源。故此为了解决这个问题,我们可以写一个webpack插件,当代码编译完成后,会这个文件赋值一个新的时间戳来达到server-worker.js动态更新的效果。
const fs = require('fs')
const path = require('path')

module.exports = class {
  apply(compiler) {
    compiler.hooks.done.tap('ChangeWorkerServiceName', (stats) => {
      // 查找dist文件夹里面的html文件与service-worker.js文件,
      // 0、获取当前时间戳后8位。
      // 1、遍历所有的html文件内容,如果内容有里面service-worker.js这段字符串,则将字符串替换成service-worker.js + 后8位时间戳,
      // 2、将dist目录下service-worker.js文件名称替换为 service-worker.js + 后8位时间戳。
      const distPath = path.resolve(compiler.options.output.path)
      const timestamp = Date.now().toString().slice(-8)

      // 递归遍历目录
      function traverseDirectory(dirPath) {
        fs.readdir(dirPath, (err, files) => {
          if (err) {
            console.error('Error reading directory:', err)
            return
          }

          files.forEach(file => {
            const filePath = path.join(dirPath, file)

            fs.stat(filePath, (err, stats) => {
              if (err) {
                console.error('Error getting file stats:', err)
                return
              }

              if (stats.isDirectory()) {
                // 递归处理子目录
                traverseDirectory(filePath)
              } else if (path.extname(file) === '.html') {
                // 处理 HTML 文件
                fs.readFile(filePath, 'utf8', (err, data) => {
                  if (err) {
                    console.error(`Error reading file ${filePath}:`, err)
                    return
                  }
                  if(data.indexOf('service-worker\.js') < 0) {
                    return
                  }

                  // 替换 service-worker.js
                  const updatedData = data.replace(/service-worker\.js/g, `service-worker${timestamp}.js`)

                  fs.writeFile(filePath, updatedData, 'utf8', (err) => {
                    if (err) {
                      console.error(`Error writing file ${filePath}:`, err)
                    } else {
                      console.log(`Updated ${filePath}`)
                    }
                  })
                })
              }
            })
          })
        })
      }

      // 重命名 service-worker.js 文件
      function renameServiceWorkerFile(dirPath) {
        const swFilePath = path.join(dirPath, 'service-worker.js')
        const newSwFilePath = path.join(dirPath, `service-worker${timestamp}.js`)

        fs.rename(swFilePath, newSwFilePath, (err) => {
          if (err) {
            console.error(`Error renaming service-worker.js to ${newSwFilePath}:`, err)
          } else {
            console.log(`Renamed service-worker.js to ${newSwFilePath}`)
          }
        })
      }
      // 重命名 service-worker.js 文件
      renameServiceWorkerFile(distPath);
      // 开始遍历 dist 目录
      traverseDirectory(distPath);

    })
  }
}