关于 PWA 更新模式 关于 PWA 更新模式

2025-06-11

关于 PWA 更新模式

关于 PWA 更新模式

关于 PWA 更新模式

2020年了。服务人员的研发仍是一门火箭科学。人类仍未搞定服务人员的更新。探索仍在继续。

TL;DR: 跳至代码。

没人有时间做这个,别等了!

Service Worker 更新是一个尚未深入探讨的课题。许多网站每当新的 Service Worker 接管页面时,都会重新加载,这让很多用户感到懊恼。这种模式通常被称为“skipWaiting 模式”, Dean Hume 的这篇博文对此进行了精彩的描述。在您最喜欢的开发者论坛上,经常 提及。

skipWaiting 模式适用于大多数内容网站,如博客或文档网站,但如果做错了,也会令人沮丧;

跳过等待

即使你拥有极简状态,比如带有可扩展菜单项的菜单,也可能发生这种情况:用户访问你的页面,展开菜单项,然后新的 Service Worker 接管,跳过等待,刷新页面……菜单项又被关闭了。这对用户来说可不是什么好事。

菜单

幸运的是,我们可以使用sessionStorage API来解决这个问题。以下是我们在open-wc.org上解决这个问题的方法:

  if (sessionStorage.getItem('openItems')) {
    const openItems = JSON.parse(sessionStorage.getItem('openItems'));
    const menuItems = [...document.querySelectorAll('section > p.sidebar-heading')];
    if (openItems.length === menuItems.length) {
      menuItems.forEach((item, i) => {
        if (openItems[i]) {
          item.click();
        }
      });
      sessionStorage.removeItem('openItems');
    }
  }

  let refreshing;
  navigator.serviceWorker.addEventListener('controllerchange',
    function() {
      if (refreshing) return;
      refreshing = true;
      const openItems = [...document.querySelectorAll('section > p.sidebar-heading')]
        .map(item => item.classList.contains('open'));
      sessionStorage.setItem('openItems', JSON.stringify(openItems));
      window.location.reload();
    }
  );
Enter fullscreen mode Exit fullscreen mode

让我们一步一步来:

  • 当有新的 Service Worker 可用时,controllerchange将触发该事件
  • 但在重新加载页面之前,我们会检查用户是否与菜单进行了交互,并将其保存到会话存储中
  • 然后我们重新加载页面
  • 在页面加载时,我们检查 sessionStorage 中是否有任何条目,如果有,我们就恢复状态。

您可能想知道为什么我们使用 sessionStorage 来实现这种功能,而不是使用 localStorage 之类的存储方式。原因是 sessionStorage 的数据仅与当前会话或标签页相关,并且会话关闭后数据就会过期。在这种情况下,使用 sessionStorage 更合理,因为在新的 Service Worker 接管之前,用户很可能只与当前标签页进行了交互。

现在,当用户在服务工作线程更新之前已经与菜单进行过交互时,页面仍然会重新加载,但它会恢复用户状态,使得重新加载几乎不引人注意,并使用户的体验变得有趣而不是令人沮丧。

但还有更多...

我们最近遇到了这个模式的另一个怪癖。在对网站进行一些更新时,我们注意到我们的灯塔评分在“最佳实践”部分出现了问题:

灯塔

要理解为什么会发生这种情况,我们必须仔细查看 skipWaiting 模式的客户端代码:

  let refreshing;
  navigator.serviceWorker.addEventListener('controllerchange', function () {
    if (refreshing) return;
    window.location.reload();
    refreshing = true;
  });
Enter fullscreen mode Exit fullscreen mode

这里发生的情况是,每当事件触发时controllerchange,页面都会重新加载。当你已经有一个 Service Worker,并且希望新的 Service Worker 接管时,这很好;但这也意味着页面会在用户首次访问时重新加载,从而导致 Lighthouse 出现错误。

请考虑以下情形:

  • 用户首次访问你的页面,此时还没有活跃的控制 Service Worker
  • Service Worker 将开始安装并预缓存您的所有资产
  • 由于当前没有服务工作者,服务工作者将激活并触发controllerchange事件
  • 该页面现在会完全重新加载,但由于所有资产都已缓存,因此几乎察觉不到

诚然,Lighthouse 出现错误似乎是Lighthouse 本身的一个bug ,但在用户第一次访问你的页面后立即重新加载似乎也有点奇怪。

以下是我们解决这个问题的方法:

  async function handleUpdate() {
    if ("serviceWorker"in navigator) {
      let refreshing;

      // check to see if there is a current active service worker
      const oldSw = (await navigator.serviceWorker.getRegistration())?.active?.state;

      navigator.serviceWorker.addEventListener('controllerchange', async () => {
        if (refreshing) return;

        // when the controllerchange event has fired, we get the new service worker
        const newSw = (await navigator.serviceWorker.getRegistration())?.active?.state;

        // if there was already an old activated service worker, and a new activating service worker, do the reload
        if(oldSw === 'activated' && newSw === 'activating') {
          refreshing = true;
          window.location.reload();
        }
      });
    }
  }

  handleUpdate();
Enter fullscreen mode Exit fullscreen mode

现在我们的灯塔分数正如我们预期的那样:

灯塔2

并且,我们消除了用户首次访问我们页面时不必要的重新加载。

有可用更新!

另一种流行的模式是每当有更新可用时显示一个提示,如下所示。

更新

这种模式不太理想,原因有很多,首先是……似乎没人喜欢 Toast,无论是开发者还是用户。我的意思是,谁会喜欢浏览网页时弹出弹窗呢?难道我们就不能做得更好吗?此外,Toast 的可访问性也很难做到完美。

正如Jad Joubran在他的演讲《类原生 PWA 的秘密》(时间戳:25:49)中所解释的那样,或许“更新可用模式”的一个更微妙、更用户友好的实现方式是,在有更新可用时显示一个非常微妙的指示。这样不太可能让用户措手不及,也不需要用醒目的弹窗来刺激用户。

pwa-helper-components提供了一个addPwaUpdateListener辅助函数,当有更新可用时,它会执行回调,这样您就可以轻松地向用户显示一个微妙的指示。

更新3

如果您有兴趣了解更多相关内容,我在之前的博客文章中对这种模式进行了更详细的介绍。

但“更新可用”模式的第二个问题可能更为严重;更新本身就是一个黑匣子。用户点击更新按钮时,根本不知道自己注册了什么。他们只知道,一旦点击更新按钮,整个 Web 应用可能会完全改变。你喜欢​​巨大的、意想不到的变化吗?我知道我不喜欢!

你不觉得这有点反常吗?我们难道不应该给用户一个好的体验,让他们提前知道注册了什么吗?

变更日志更新模式

变更日志

相反,我们为什么不向用户提供完全透明的信息,而只向他们展示更新日志呢?这样,用户就能确切地知道他们选择的是哪种更新。这种模式比在页面上添加 6 行代码片段稍微复杂一些,但我们会逐步讲解。

想直接跳到代码?没问题。你可以在GitHub上找到代码仓库。

步骤1:

让我们首先使用 open-wc 生成一个新项目:

npm init @open-wc
Enter fullscreen mode Exit fullscreen mode

第 2 步:

为了使这种模式发挥作用,我们需要以某种方式在客户端维护 PWA 的当前版本号,因此我们要做的第一件事就是version.js在我们的文件夹中创建一个模块src/

./src/version.js

export default 'dev';
Enter fullscreen mode Exit fullscreen mode

在构建时,我们将从version中提取属性package.json,并将其重写'dev'为该版本号。我们还需要预缓存此模块,稍后再讲解。

步骤3:

'dev'让我们实现将我们的重写version.js为当前版本号的逻辑package.json

package.json在您的中导入rollup.config.js,并获取我们模块的路径version.js

rollup.config.js

import packageJson from './package.json';
const versionModulePath = require.resolve('./src/version.js');
Enter fullscreen mode Exit fullscreen mode

现在,在plugins我们的数组中rollup.config.js,我们将添加一些逻辑来重写版本号:

{
  name: 'rewrite-version-number',
  load(id) {
    // replace the version module with a live version from the package.json
    if (id === versionModulePath) {
      return `export default '${packageJson.version}'`;
    }
  },
},
Enter fullscreen mode Exit fullscreen mode

每当 rollup 加载我们的version.js文件时,我们只需将文件的内容重写为export default '${packageJson.version}'

步骤4:

接下来,我们将把我们的CHANGELOG.md,转换成 JSON 文件,以便我们可以轻松地使用它并在前端比较变化。

为此,我们需要安装开发依赖项:

npm i -D md-2-json
Enter fullscreen mode Exit fullscreen mode

并将其导入到我们的rollup.config.js

import md2json from 'md-2-json';
Enter fullscreen mode Exit fullscreen mode

再次,在您rollup.config.jsplugins数组中添加以下代码:

{
  name: 'generate-changelog-json',
  writeBundle() {
    const changelog = fs.readFileSync('./CHANGELOG.md', 'utf8');
    fs.writeFileSync(
      './dist/CHANGELOG.json',
      JSON.stringify(md2json.parse(changelog))
    );
  },
},
Enter fullscreen mode Exit fullscreen mode

这将变成CHANGELOG.md一个CHANGELOG.json,看起来有点像:

{"Changelog":{"1.0.2":{"raw":"- Added the thing\n\n"},"1.0.1":{"raw":"- Fixed bug in keyboard control for dark mode toggle\n- Responsive styling fix\n\n"},"1.0.0":{"raw":"- Initial release\n\n\n"}}}
Enter fullscreen mode Exit fullscreen mode

步骤5:

太好了,到目前为止,我们已经找到了一种方法来维护我们的 PWA 的当前版本号,并将其转变CHANGELOG.md为我们可以在前端轻松使用的东西。

现在,我们需要对我们的workbox配置进行一些更改rollup.config.js

我们要做的是:将skipWaitingclientsClaim设置为false,并将其添加CHANGELOG.json到我们的globIgnores。之所以将 添加到CHANGELOG.jsonglobIgnores是因为我们希望始终能够从网络获取最新的更改。我们还将 选项设置injectServiceWorkertrue。这会自动将 Service Worker 注册代码添加到你的index.html

const baseConfig = createSpaConfig({
  // ...
  workbox: {
    globIgnores: ['./CHANGELOG.json'],
    skipWaiting: false,
    clientsClaim: false,
  },
  injectServiceWorker: true,
  // ...
});
Enter fullscreen mode Exit fullscreen mode

步骤6:

太棒了!构建时的修改已经完成,接下来开始实现前端。我们将为此安装三个(非常小的)依赖项:

npm i -S pwa-helper-components @thepassle/generic-components es-semver
Enter fullscreen mode Exit fullscreen mode
  • pwa-helper-components:将为我们提供一个addPwaUpdateListener函数,每当有新的服务工作者可用时,该函数就会触发回调
  • generic-components:将为我们提供一个可访问的对话框组件来显示我们的更新
  • es-semver:将为我们提供一个 ES 模块版本,以便将当前应用程序版本号与变更日志中的条目进行比较

addPwaUpdateListener让我们首先在您的文件夹中添加src/,找到您的应用程序组件并添加以下代码:

import { LitElement, html, css } from 'lit-element';
import { openWcLogo } from './open-wc-logo.js';
import { addPwaUpdateListener } from 'pwa-helper-components';

export class ChangelogUpdatePattern extends LitElement {
  static get properties() {
    return {
      updateAvailable: { type: Boolean },
    };
  }

  constructor() {
    super();
    this.updateAvailable = false;
  }

  connectedCallback() {
    super.connectedCallback();
    addPwaUpdateListener((updateAvailable) => {
      this.updateAvailable = updateAvailable;
    });
  }

  static get styles() {/** omitted to brevity */}

  render() {
    return html`
      <main>
        <div class="logo">${openWcLogo}</div>
        <h1>My app</h1>

        <p>Welcome to my app!</p>
      </main>

      <p class="app-footer">
        🚽 Made with love by
        <a
          target="_blank"
          rel="noopener noreferrer"
          href="https://github.com/open-wc"
          >open-wc</a
        >.
      </p>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

正如您所见,我们导入了addPwaUpdateListener,并在中注册了它connectedCallback

步骤7:

太好了,现在只要有新的服务人员可用,我们就会收到通知,但我们还没有真正对此做出反应;是时候实现对话了!

我们将从 导入对话框@thepassle/generic-components,在方法中添加一些条件渲染render,并向openDialog类中添加一个方法。以下是此时的代码:

import { LitElement, html, css } from 'lit-element';
import { render } from 'lit-html';
import { addPwaUpdateListener } from 'pwa-helper-components';
import { dialog } from '@thepassle/generic-components/generic-dialog/dialog.js';
import { openWcLogo } from './open-wc-logo.js';
import './update-dialog.js';

export class ChangelogUpdatePattern extends LitElement {
  static get properties() {
    return {
      updateAvailable: { type: Boolean },
    };
  }

  constructor() {
    super();
    this.updateAvailable = false;
  }

  connectedCallback() {
    super.connectedCallback();
    addPwaUpdateListener((updateAvailable) => {
      this.updateAvailable = updateAvailable;
    });
  }

  static get styles() {/** */}

  openDialog(e) {
    dialog.open({
      invokerNode: e.target,
      content: dialogNode => {
        dialogNode.id = 'dialog';
        render(html`<update-dialog></update-dialog>`, dialogNode);
      },
    });
  }

  render() {
    return html`
      <main>
        ${this.updateAvailable
          ? html`
            <button @click=${this.openDialog} class="update button">
              Hey!
            </button>`
          : ''

        }
        <div class="logo">${openWcLogo}</div>
        <h1>My app</h1>

        <p>Welcome to my app!</p>
      </main>

      <p class="app-footer">
        🚽 Made with love by
        <a
          target="_blank"
          rel="noopener noreferrer"
          href="https://github.com/open-wc"
          >open-wc</a
        >.
      </p>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

步骤8:

还在吗?太棒了,你做得真棒。比在你的代码里加个 6 行代码片段稍微复杂一点index.html,嗯?唉,都 2020 年了,服务人员还是个高深的学问。

现在是时候update-dialog.js在你的文件夹中创建一个新文件了src/。该update-dialog组件将包含用于比较版本号差异的逻辑和 UI。

我们首先创建两个实用函数getChangedskipWaiting

  • getChanged:将从CHANGELOG.json网络获取最新信息,并将所有新更新编译到数组中
  • skipWaiting:将获得对新的等待服务人员的引用,并告诉它跳过等待。
async function getChanged(version) {
  const { Changelog } = await (await fetch('./CHANGELOG.json')).json();
  return Object.keys(Changelog)
    .filter(item => satisfies(item, `>${version}`))
    .map(
      item => html`
        <li>
          <h2>${item}</h2>
          <div class="changelog">${Changelog[item].raw}</div>
        </li>
      `
    );
}

async function skipWaiting() {
  const reg = await navigator.serviceWorker.getRegistration();
  reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}
Enter fullscreen mode Exit fullscreen mode

最后,我们的update-dialog组件如下所示:

import { html, LitElement } from 'lit-element';
import { dialog } from '@thepassle/generic-components/generic-dialog/dialog.js';
import version from './version.js';

class UpdateDialog extends LitElement {
  static get properties() {
    return {
      changed: { type: Array },
    };
  }

  constructor() {
    super();
    this.changed = [];
  }

  createRenderRoot() {
    return this;
  }

  async connectedCallback() {
    super.connectedCallback();
    this.changed = await getChanged(version);
  }

  render() {
    return html`
      <button @click=${() => dialog.close()} class="close button">
        x
      </button>
      <h1>There's an update available!</h1>
      <p>Here's what's changed:</p>
      <ul>
        ${this.changed}
      </ul>
      <div class="dialog-buttons">
        <button class="button" @click=${skipWaiting}>Install update</button>
        <button class="button" @click=${() => dialog.close()}>Close</button>
      </div>
    `;
  }
}

customElements.define('update-dialog', UpdateDialog);
Enter fullscreen mode Exit fullscreen mode

步骤 9(可选):

这种模式的缺点是,它不是很容易实现,而且对于你所做的每一个改变,你都需要相应地调整你CHANGELOG.mdpackage.json

为了让大家更轻松一些,我创建了一个 Github Action,它会在每次拉取请求时检查你是否更改了你的CHANGELOG.mdand package.json。你可以创建一个.github/workflows/目录并添加以下工作流程来使用它:

package-json-changelog-enforcer.yml

name: package-json-changelog-enforcer

on: [pull_request]

jobs:
  package-json-changelog-enforcer:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
        with:
          fetch-depth: '0'
      - uses: thepassle/package-json-enforcer@0.0.10
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

Enter fullscreen mode Exit fullscreen mode

结论

就是这样。如果你一步一步地跟着做,那你就成功了。如果你没有,而是直接跳到 Github 仓库;再说一次,我不会责怪你。

我想用开头的方式结束这篇博文:2020年了。Service Worker 仍然是个火箭科学。人类仍然没有搞定 Service Worker 的更新。探索仍在继续。

鏂囩珷鏉ユ簮锛�https://dev.to/thepassle/on-pwa-update-patterns-4fgm
PREV
Postman 有哪些好用的替代 API 客户端?以下是我推荐的 15 款
NEXT
CSS Grid 和 Flexbox:简要对比