🚂🚂🚂 ServiceWorker -> PWA的基石,在线离线都能玩!

1,966 阅读8分钟

PWAProgressive Web App的缩写,翻译过来就是渐进式网络应用,它是一种新的网络应用模式,它结合了Web AppNative App的优点,它可以让用户像使用Native App一样使用Web App,并且它可以在离线状态下使用。

PWA的简介

PWA是一种新的网络应用模式,它结合了Web AppNative App的优点,它可以让用户像使用Native App一样使用Web App,并且它可以在离线状态下使用。

PWA并不是只用一种技术实现的,它表达的是一种网络应用模式,它代表了构建 Web 应用程序的新理念,一个 PWA 应该具有以下特点:

  • 可发现:通过 URL 可以访问,可以被搜索引擎抓取到
  • 可安装:可以添加到桌面,可以在离线状态下使用
  • 可链接:可以通过 URL 进行分享
  • 独立与网络:可以在离线状态或者是在网速很差的情况下运行
  • 渐进式:适配老版本的浏览器,在新版本的浏览器中可以使用更多的新特性
  • 可重入:无论何时有内容更新,都可以及时更新
  • 响应式:可以适配不同的屏幕尺寸
  • 安全:通过 HTTPS 进行传输,保证用户的数据安全

上面的简介重要的是最后一点,PWA是通过HTTPS进行传输的,所以我们在使用PWA的时候,需要在本地配置一个HTTPS的服务;

开发环境下的HTTPS

网上虽然有很多关于证书如何申请和配置的文章,但是这些文章都是在生产环境下的,需要有服务器,域名,固定的IP等等;

但是我们开发怎么办?肯定得尽量和生产环境保持一致,而且PWA应用是一定需要HTTPS的,所以我们需要在本地配置一个HTTPS的开发环境。

1. 生成证书

证书我们可以使用mkcert这个包来生成,这个包在npm上就有,但是这里生成的证书是不受信任的,不过它有一个衍生的exe文件;

可以通过这里下载证书生成程序,根据自己电脑环境下载对应的版本;

我的电脑是Windows,所以我下载的是mkcert-v1.4.3-windows-amd64.exe

下载完成之后,在你需要生成证书的目录下,打开命令行工具,然后将这个文件拖到命令行中,然后后面跟上 -install:

mkcert-v1.4.3-windows-amd64.exe -install

image.png

接着再将这个文件拖到命令行中,然后后面跟上 localhost 127.0.0.1:

mkcert-v1.4.3-windows-amd64.exe localhost 127.0.0.1

image.png

这样就会在当前目录下生成两个文件,一个是/localhost+1.pem,一个是localhost+1-key.pem

由于我这里使用node环境来启动的服务,所以还需要设置一下NODE_EXTRA_CA_CERTS环境变量,这个环境变量是用来指定额外的证书的,这样我们就可以在本地使用HTTPS了;

set NODE_EXTRA_CA_CERTS="$(mkcert -CAROOT)/rootCA.pem"

上述方案参考自:使用mkcert 为(nodejs)本地后端服务器的https安装ssl证书

2. 启动服务

完成之后,就需要使用node来启动服务了,完整代码如下:

import express from 'express';
import * as https from "https";
import path from "path";
import fs from "fs";

const app = express();
app.use(express.json())
app.use(express.urlencoded({extended: false}))

const __dirname = path.resolve();
app.use('/', express.static(__dirname + '/public'));

app.all('/getData', (req, res) => {
  // 百万级数据
  const data = [];
  for (var i = 0; i < 1000000; i++) {
    data.push({
      name: 'name' + i,
      age: i
    });
  }
  res.send(data);
})

//为https请求添加ssl证书
const httpsOption = {
  key: fs.readFileSync("./cert/key.pem"),
  cert: fs.readFileSync("./cert/cert.pem"),
}

//https请求
https.createServer(httpsOption, app).listen(443, () => {
  const hostname = 'localhost';
  const port = 443;
  console.log(`Server running at https://${hostname}:${port}/`);
});

process.on('uncaughtException', (e) => {
  console.error(e); // Error: uncaughtException
  // do something: 释放相关资源(例如文件描述符、句柄等)
  // process.exit(1); // 手动退出进程
});

PWA的实现

PWA的实现并不一定需要使用ServiceWorker,但是ServiceWorker可以提供网络能力,如果在断网的情况下,我们想要让PWA能够正常运行,那么就需要使用ServiceWorker了。

使用PWA很简单,只需要在HTML中的head中使用link标签引用一个manifest.json文件即可,这个文件就是PWA的配置文件,它的内容如下:

{
  "name": "my-pwa-app",
  "short_name": "pwa-app",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#fff",
  "theme_color": "#fff",
  "description": "PWA demo",
  "icons": [
    {
      "src": "/images/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    }
  ]
}

先来看看配置的含义:

  • name:应用的名称
  • short_name:应用的简称
  • start_url:应用的启动页
  • display:应用的显示模式,有以下几种模式:
    • fullscreen:全屏模式
    • standalone:独立模式
    • minimal-ui:最小化模式
    • browser:浏览器模式
  • background_color:应用的背景颜色
  • theme_color:应用的主题颜色
  • description:应用的描述
  • icons:应用的图标,可以配置多个图标,每个图标都有自己的尺寸和类型
    • src:图标的路径
    • sizes:图标的尺寸
    • type:图标的类型

manifest.json文件中有很多属性配置,通常情况下只需要提供name和一组icons属性就好了,但是尽可能多的提供一些属性,可以让PWA在不同的设备上有更好的表现。

更多的manifest.json的配置可以参考:developer.mozilla.org/zh-CN/docs/…

PWA的使用

上面说到了PWA的实现就是通过link标签引用一个manifest.webmanifest文件,在我们之前写到的ServiceWorker的文章中,直接加上link标签即可:

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

根据规范,manifest.json这个文件的后缀应该是.webmanifest,内容依旧是JSON格式;

所以很多网站的引用的是manifest.webmanifest,还有一些历史的后缀名.webapp

所以按照规范来说,我们应该使用.webmanifest作为后缀名。

一个PWA的应用的manifest.webmanifest必须包含以下属性,才能有效的被浏览器识别:

  • name:应用的名称
  • short_name:应用的简称
  • start_url:应用的启动页
  • display:应用的显示模式
  • icons:正确的图标
  • background_color:应用的背景颜色

图标需要遵守Google Play 图标设计规范,这样才能在不同的设备上有更好的表现,当然图标并不是一定要完全遵守,但是大小还是会识别的。

除此之外,还需要有ServiceWorker的支持,并且ServiceWorker必须监听了fetch事件,所以才说ServiceWorkerPWA的基石。

PWA的实际应用

上面说了这么多,我们来实际看看PWA的应用启动好了会是什么样子;

根据我上面提供的HTTPS启动方案,然后再加上正确的manifest.webmanifest文件,接着就是安装一个ServiceWorker,然后就可以启动了。

  • index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="manifest" href="manifest.webmanifest" />
</head>
<body>
<script>
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/service-worker.js', {
            scope: '/'
        }).then(function (registration) {
            console.log('ServiceWorker registration successful with scope: ', registration.scope);
        });
    }
</script>
</body>
</html>
  • manifest.webmanifest
{
  "name": "my-pwa-app",
  "short_name": "pwa-app",
  "description": "这是我的第一个PWA应用",
  "start_url": "/index.html",
  "background_color": "purple",
  "display": "fullscreen",
  "icons": [
    {
      "src": "/icons/like192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ]
}
  • service-worker.js
self.addEventListener('install', function (event) {
    caches.open('v1').then(function (cache) {
        return cache.addAll([
            '/index.html',
            '/manifest.webmanifest'
        ]);
    });

})

self.addEventListener('fetch', function (event) {
    event.respondWith(
        caches.match(event.request).then(function (response) {
            if (response) {
                return response;
            }
            return fetch(event.request);
        })
    );
});
  • icons/like192.png

like192.png

图标可以在这里生成,我这里就放上了我使用的图标。

一切准备就绪,我们就可以启动了,如果浏览器正常识别之后,会在地址栏上出现一个安装的按钮,点击之后就可以安装了,如下图所示:

image.png

现在我们就可以把这个应用添加到桌面了,大家可以试试,我这里就不截图了。

手动安装PWA

除了可以通过浏览器自动识别之后,通过点击图标安装之外,我们还可以通过代码的方式来安装PWA,这样就可以在我们的应用中自动安装了。

这里通过监听beforeinstallprompt事件来实现,代码如下:

window.addEventListener('beforeinstallprompt', (e) => {
  e.prompt();
});

这个事件是在浏览器识别到可以安装之后触发的,我们可以在这个事件中调用prompt()方法来安装;

通常情况下考虑用户体验,我们会在用户点击某个按钮之后再安装,这样就可以避免用户不知道这个应用是什么的情况。

<button id="install" style="display: none;">安装</button>

<script>
  window.addEventListener('beforeinstallprompt', (e) => {
    // 防止 Chrome 67 及更早版本自动显示安装提示
    e.preventDefault();
    
    const installBtn = document.getElementById('install');

    installBtn.addEventListener('click', () => {
      // 隐藏显示 A2HS 按钮的界面
      installBtn.style.display = 'none';
      // 显示安装提示
      e.prompt();
      // 等待用户反馈
      e.userChoice.then((choiceResult) => {
        if (choiceResult.outcome === 'accepted') {
          console.log('User accepted the A2HS prompt');

          installBtn.partentNode.removeChild(installBtn);
        } else {
          console.log('User dismissed the A2HS prompt');
        }
      });
    });
  });
</script>

这里最开始隐藏install按钮是因为防止目标浏览器不支持PWA

A2HS

说到安装PWA应用还是有必要了解一下A2HS的概念;

A2HS全称是Add to Home Screen,即添加到主屏幕,这个是PWA的一个特性,可以让我们的应用在安装之后,可以像原生应用一样,可以在桌面上显示图标,可以在应用列表中显示,可以在应用列表中卸载等等。

A2HS只是将PWA应用安装到桌面,但是并不会将应用程序的资源文件下载到本地,而是通过浏览器的缓存手段来实现的,这些缓存手段包括但不限于indexedDBlocalStorageService Worker等等。

实战

上面讲了这么多,还是实战最好玩,这里依然用最开始Web Worker的百万级数据计算的例子来实现一个PWA应用。

这里只需要稍微修改一下index.html文件,然后再在service-worker.js中添加一些缓存就好了:

  • index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="manifest" href="manifest.webmanifest"/>
    <style>
        #install {
            position: absolute;
            left: 0;
            top: 0;
        }

        .table-wrapper {
            margin-top: 40px;
            height: 800px;
            overflow: auto;
            width: 200px;
        }

        table {
            width: 100%;
        }

        thead tr {
            position: sticky;
            left: 0;
            top: 0;
            background: #fff;
        }
    </style>
</head>
<body>

<button id="install" style="display: none;">安装</button>

<div class="table-wrapper">
    <table border id="table">
        <thead>
        <tr>
            <th>姓名</th>
            <th>年龄</th>
        </tr>
        </thead>

        <tbody></tbody>
    </table>
</div>

<script src="./axios.js"></script>
<script>
    if ('serviceWorker' in navigator) {
        navigator.serviceWorker.register('/service-worker.js', {
            scope: '/'
        }).then(function (registration) {
            console.log('ServiceWorker registration successful with scope: ', registration.scope);
        });
    }

    window.addEventListener('beforeinstallprompt', (e) => {
        // 防止 Chrome 67 及更早版本自动显示安装提示
        e.preventDefault();

        const installBtn = document.getElementById('install');
        installBtn.style.display = 'block';

        installBtn.addEventListener('click', (e) => {
            // 显示安装提示
            e.prompt();
            // 等待用户反馈
            e.userChoice.then((choiceResult) => {
                if (choiceResult.outcome === 'accepted') {
                    console.log('User accepted the A2HS prompt');
                    installBtn.parentNode.removeChild(installBtn);
                } else {
                    console.log('User dismissed the A2HS prompt');
                }
            });
        });
    });

    const table = document.getElementById('table');
    const tbody = table.getElementsByTagName('tbody')[0];
    let result = [];
    axios.get('/getData').then(function (response) {
        result = response.data;

        result.slice(0 , 300).forEach(function (item) {
            const tr = document.createElement('tr');
            const td1 = document.createElement('td');
            const td2 = document.createElement('td');
            td1.innerText = item.name;
            td2.innerText = item.age;
            tr.appendChild(td1);
            tr.appendChild(td2);
            tbody.appendChild(tr);
        });
    });

    // 虚拟滚动
    const tableWrapper = document.querySelector('.table-wrapper');
    let scrollTop = tableWrapper.scrollTop;
    let firstPage = 1, lastPage = 3;
    tableWrapper.addEventListener('scroll', function (e) {
        const _scrollTop = e.target.scrollTop;
        if (_scrollTop - scrollTop > 0) {
          // 向下滚动
            if ((_scrollTop + tableWrapper.clientHeight + 10) >= tableWrapper.scrollHeight) {
                if (lastPage === result.length / 100) return;

                // 滚动到底部
                firstPage++;
                lastPage++;
                const data = result.slice(lastPage * 100, (lastPage + 1) * 100);
                appendToTBody(data, 'bottom');
                removeTr('top');

            }
        } else {
          // 向上滚动
            if (_scrollTop <= 10) {
                // 滚动到顶部
                if (firstPage === 1) return;

                firstPage--;
                lastPage--;
                const data = result.slice(firstPage * 100, (firstPage + 1) * 100);
                appendToTBody(data, 'top');
                removeTr('bottom');

                // 移除头部需要重新定位
                const tr = table.getElementsByTagName('tr')[0];
                tableWrapper.scrollTop = tr.clientHeight * 100;
            }
        }
    });

    const appendToTBody = (data, direction = 'bottom') => {
        const fragment = document.createDocumentFragment();
        data.forEach((item) => {
            const tr = document.createElement('tr');
            const td1 = document.createElement('td');
            const td2 = document.createElement('td');
            td1.innerText = item.name;
            td2.innerText = item.age;
            tr.appendChild(td1);
            tr.appendChild(td2);
            fragment.appendChild(tr);
        });
        if (direction === 'bottom') {
            tbody.appendChild(fragment);
        } else {
            tbody.insertBefore(fragment, tbody.firstChild);
        }
    }

    const removeTr = (direction = 'top') => {
        const tr = tbody.getElementsByTagName('tr');
        let removeNum = 100;
        while (removeNum--) {
            if (direction === 'top') {
                tbody.removeChild(tr[0]);
            } else {
                tbody.removeChild(tr[tr.length - 1]);
            }
        }
    }

</script>
</body>
</html>
  • service-worker.js
self.addEventListener('install', function (event) {
    caches.open('v1').then(function (cache) {
        return cache.addAll([
            '/index.html',
            '/manifest.webmanifest',
            '/axios.js',
            '/getData',
        ]);
    });

})

self.addEventListener('fetch', function (event) {
    event.respondWith(
        caches.match(event.request).then(function (response) {
            if (response) {
                return response;
            }
            return fetch(event.request);
        })
    );
});

上面的代码都弄好了之后,先刷新一下页面,然后再切断网络,再刷新一下页面,就可以看到效果了。

总结

本篇已经了解了PWA应用如何工作在浏览器中,需要做哪些前置工作,需要满足哪些必要条件,以及如何实现一个简单的PWA应用。

PWA应用实现起来还是挺简单的,只需要一个配置文件,以及启动一个Service Worker,然后就可以实现离线缓存,桌面安装等功能了。

PWA只是一个概念,目前也有很多工具可以帮助我们实现PWA,比如Workboxsw-precache等,这些工具可以帮助我们自动化的生成Service Worker,以及缓存策略等。

历史章节和预告

本文正在参加「金石计划 . 瓜分6万现金大奖」