关于 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();
    }
  );
让我们一步一步来:
- 当有新的 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;
  });
这里发生的情况是,每当事件触发时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();
现在我们的灯塔分数正如我们预期的那样:
并且,我们消除了用户首次访问我们页面时不必要的重新加载。
有可用更新!
另一种流行的模式是每当有更新可用时显示一个提示,如下所示。
这种模式不太理想,原因有很多,首先是……似乎没人喜欢 Toast,无论是开发者还是用户。我的意思是,谁会喜欢浏览网页时弹出弹窗呢?难道我们就不能做得更好吗?此外,Toast 的可访问性也很难做到完美。
正如Jad Joubran在他的演讲《类原生 PWA 的秘密》(时间戳:25:49)中所解释的那样,或许“更新可用模式”的一个更微妙、更用户友好的实现方式是,在有更新可用时显示一个非常微妙的指示。这样不太可能让用户措手不及,也不需要用醒目的弹窗来刺激用户。
pwa-helper-components提供了一个addPwaUpdateListener辅助函数,当有更新可用时,它会执行回调,这样您就可以轻松地向用户显示一个微妙的指示。
 
如果您有兴趣了解更多相关内容,我在之前的博客文章中对这种模式进行了更详细的介绍。
但“更新可用”模式的第二个问题可能更为严重;更新本身就是一个黑匣子。用户点击更新按钮时,根本不知道自己注册了什么。他们只知道,一旦点击更新按钮,整个 Web 应用可能会完全改变。你喜欢巨大的、意想不到的变化吗?我知道我不喜欢!
你不觉得这有点反常吗?我们难道不应该给用户一个好的体验,让他们提前知道注册了什么吗?
变更日志更新模式
相反,我们为什么不向用户提供完全透明的信息,而只向他们展示更新日志呢?这样,用户就能确切地知道他们选择的是哪种更新。这种模式比在页面上添加 6 行代码片段稍微复杂一些,但我们会逐步讲解。
想直接跳到代码?没问题。你可以在GitHub上找到代码仓库。
步骤1:
让我们首先使用 open-wc 生成一个新项目:
npm init @open-wc
第 2 步:
为了使这种模式发挥作用,我们需要以某种方式在客户端维护 PWA 的当前版本号,因此我们要做的第一件事就是version.js在我们的文件夹中创建一个模块src/:
./src/version.js:
export default 'dev';
在构建时,我们将从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');
现在,在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}'`;
    }
  },
},
每当 rollup 加载我们的version.js文件时,我们只需将文件的内容重写为export default '${packageJson.version}'。
步骤4:
接下来,我们将把我们的CHANGELOG.md,转换成 JSON 文件,以便我们可以轻松地使用它并在前端比较变化。
为此,我们需要安装开发依赖项:
npm i -D md-2-json
并将其导入到我们的rollup.config.js:
import md2json from 'md-2-json';
再次,在您rollup.config.js的plugins数组中添加以下代码:
{
  name: 'generate-changelog-json',
  writeBundle() {
    const changelog = fs.readFileSync('./CHANGELOG.md', 'utf8');
    fs.writeFileSync(
      './dist/CHANGELOG.json',
      JSON.stringify(md2json.parse(changelog))
    );
  },
},
这将变成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"}}}
步骤5:
太好了,到目前为止,我们已经找到了一种方法来维护我们的 PWA 的当前版本号,并将其转变CHANGELOG.md为我们可以在前端轻松使用的东西。
现在,我们需要对我们的workbox配置进行一些更改rollup.config.js。
我们要做的是:将skipWaiting和clientsClaim设置为false,并将其添加CHANGELOG.json到我们的globIgnores。之所以将 添加到CHANGELOG.json,globIgnores是因为我们希望始终能够从网络获取最新的更改。我们还将 选项设置injectServiceWorker为true。这会自动将 Service Worker 注册代码添加到你的index.html。
const baseConfig = createSpaConfig({
  // ...
  workbox: {
    globIgnores: ['./CHANGELOG.json'],
    skipWaiting: false,
    clientsClaim: false,
  },
  injectServiceWorker: true,
  // ...
});
步骤6:
太棒了!构建时的修改已经完成,接下来开始实现前端。我们将为此安装三个(非常小的)依赖项:
npm i -S pwa-helper-components @thepassle/generic-components es-semver
- 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>
    `;
  }
}
正如您所见,我们导入了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>
    `;
  }
}
步骤8:
还在吗?太棒了,你做得真棒。比在你的代码里加个 6 行代码片段稍微复杂一点index.html,嗯?唉,都 2020 年了,服务人员还是个高深的学问。
现在是时候update-dialog.js在你的文件夹中创建一个新文件了src/。该update-dialog组件将包含用于比较版本号差异的逻辑和 UI。
我们首先创建两个实用函数getChanged:skipWaiting
- 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' });
}
最后,我们的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);
步骤 9(可选):
这种模式的缺点是,它不是很容易实现,而且对于你所做的每一个改变,你都需要相应地调整你CHANGELOG.md的package.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 }}
结论
就是这样。如果你一步一步地跟着做,那你就成功了。如果你没有,而是直接跳到 Github 仓库;再说一次,我不会责怪你。
我想用开头的方式结束这篇博文:2020年了。Service Worker 仍然是个火箭科学。人类仍然没有搞定 Service Worker 的更新。探索仍在继续。
鏂囩珷鏉ユ簮锛�https://dev.to/thepassle/on-pwa-update-patterns-4fgm 后端开发教程 - Java、Spring Boot 实战 - msg200.com
            后端开发教程 - Java、Spring Boot 实战 - msg200.com
          



