将 React 应用程序转变为可安装的 PWA,具有离线检测、服务工作者和主题功能。

2025-05-24

将 React 应用程序转变为可安装的 PWA,具有离线检测、服务工作者和主题功能。

最近,我决定深入研究如何让我的 Web 应用实现渐进式开发。这样做的好处包括:出色的缓存、更快的页面加载速度,以及用户能够“原生”安装。

其中肯定存在一些陷阱和其他有趣的花絮,我将在下面介绍。

我使用的是 React,所以假设你也一样。如果你想直接查看代码,可以访问mixmello GitHub 仓库

让我们开始吧!

 

内容

 

设置 Service Worker

Create-react-app 提供了一些优秀的 Service Worker 文件来帮助我们入门。它们会自动配置很多实用功能,比如缓存 Webpack 的输出。它们几乎包含了我们 PWA 所需的一切。

您可以通过运行来获取这些文件npx create-react-app my-app --template cra-template-pwa

这将生成两个可移动到项目中的文件,serviceWorkerRegistration.js以及service-worker.js。将它们添加到/src项目中(或使用命令提供的新项目)。我今天不打算深入研究这些文件,因为它们的注释已经非常详细地记录了。

 
现在,我们需要在启动时注册 Service Worker。在你的应用index文件中,导入 Service Worker。



import { register as registerServiceWorker } from './serviceWorkerRegistration';


Enter fullscreen mode Exit fullscreen mode

现在只需使用 运行该函数即可registerServiceWorker();

 
完成的索引文件应如下所示:



import React from 'react';
import ReactDOM from 'react-dom';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

registerServiceWorker();


Enter fullscreen mode Exit fullscreen mode

除非特别启用(请参阅下方 extras 部分中的 create-react-app 文档),否则 Service Worker 只会在生产版本中注册/运行。这是因为热重载和 Service Worker 缓存不太兼容!这意味着你不会在 中看到 Service Worker 正在运行Dev tools > Application > Service Workers

 

离线检测和 UI/UX

离线检测并不是服务工作者/PWA 特有的功能,但是,PWA 是“离线优先”的,这意味着最好有代码来处理离线/在线状态。

在我的应用中,我决定添加一个从屏幕顶部向下延伸的小气泡来遮挡页面。效果如下(可能需要几秒钟加载):

 
离线 ux-gif

 
为了给此功能带来良好的用户和开发人员体验 -

  • 它应该是一个高阶组件,我们可以将它包裹在整个应用程序中,以实现单一职责并且不重复代码
  • 它应该阻止用户在打开时滚动
  • 它应该能够实时检测应用程序何时在线/离线
  • 应该清楚发生了什么

 

组件

我们来创建一个新文件夹,Offline。具体放在哪里由您决定。在我的应用中,它位于src/common/components。我使用的是 SCSS,但您可以继续使用您应用所使用的任何框架。

创建 3 个新文件,index.jsOffline.js_offline.scss

 
index.js为我们的组件提供默认导出:



export { default } from './Offline';


Enter fullscreen mode Exit fullscreen mode

 
Offline.js是我们的主要组件。该组件包含两个主要功能:1)用于处理网络状态变化的窗口事件处理程序;2)实际的 JSX/HTML 本身。这里我使用了 React 17 和 Hooks,但您可以根据需要将其改造为类组件。

让我们开始建造吧!

 



export default function Offline({ children }) {
  return (
    <>
      <div className="offline" />
      {children}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

我们实例化了一个新组件并将其呈现在片段中,因为我们不想在应用程序的子组件上方添加额外的层/容器。

 



import cx from 'classnames';
import './_offline.scss';

export default function Offline({ children }) {
  return (
    <>
      <div className="offline" />
      <div className={cx('offline__overlay')} />
      {children}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

现在我们已经导入了样式,并添加了一个用于淡出背景的叠加层 div。我使用了一个名为classnames“链接类”的库,但您不必使用它。稍后,我们将根据在线/离线状态有条件地更改叠加层样式。

 



import cx from 'classnames';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  return (
    <>
      <div className="offline">
                <div className="offline__content">
                    <OfflineLogo />
                    <div className="offline__text">
                        <Text subHeading className="mt-0 mb-5">You're not online</Text>
                        <Text className="mt-0 mb-0">Check your internet connection.</Text>
                    </div>
                </div>
      <div className={cx('offline__overlay')} />
      {children}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

现在,我们要往这个小小的离线气泡中添加一些内容。Text它是一个用于包装文本元素的组件,例如<p>。我为离线组件创建了一个专用的 SVG 徽标,但您可以使用任何您喜欢的元素来代替它。mt-x辅助类用于边距,我在我的另一篇文章中对此进行了介绍。

 



import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
    const previousOnline = usePrevious(online);

    useEffect(() => {
        window.addEventListener('online', setOnline);
        window.addEventListener('offline', setOffline);

        return () => {
            window.removeEventListener('online', setOnline);
            window.removeEventListener('offline', setOffline);
        };
    }, []);

  return (
    <>
      <div className="offline">
                <div className="offline__content">
                    <OfflineLogo />
                    <div className="offline__text">
                        <Text subHeading className="mt-0 mb-5">You're not online</Text>
                        <Text className="mt-0 mb-0">Check your internet connection.</Text>
                    </div>
                </div>
      <div className={cx('offline__overlay')} />
      {children}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

我们添加了让它执行某些操作的逻辑!我们有两个状态变量,online它们将反映我们的网络状态(布尔值),并previousOnline允许我们防止覆盖层在首次加载时出现(我们稍后会进行设置)。

useEffect钩子仅运行一次(首次渲染时),用于设置窗口事件监听器。返回的函数将在页面卸载时运行,并清除相同的监听器。这是WebrixuseBooleanState提供的一个钩子,用于布尔操作,简单便捷。

 



import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
    const previousOnline = usePrevious(online);

    useEffect(() => {
        window.addEventListener('online', setOnline);
        window.addEventListener('offline', setOffline);

        return () => {
            window.removeEventListener('online', setOnline);
            window.removeEventListener('offline', setOffline);
        };
    }, []);

  return (
    <>
     <div
            className={cx(
                    'offline',
                    'animate__animated',
                    'animate__faster',

                // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
                    'animate__${online ? 'slideOutUp' : 'slideInDown'}'
                )}
                style={previousOnline === online && online ? { display: 'none' } : void 0}
        >
                <div className="offline__content">
                    <OfflineLogo />
                    <div className="offline__text">
                        <Text subHeading className="mt-0 mb-5">You're not online</Text>
                        <Text className="mt-0 mb-0">Check your internet connection.</Text>
                    </div>
                </div>
            <div className={cx('offline__overlay', { 'offline__overlay--visible': !online })} />
      {children}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

现在我们要用online变量来做一些很酷的事情了!首先,我们给覆盖层添加一个条件类,稍后我们会给它添加样式。

接下来,我们用动画让它更炫酷一些!我使用了animate.css来让气泡在屏幕内滑进滑出。它提供了一些可用的动画类名。

最后,我们给容器添加了一个条件样式,用于覆盖连接时的初始加载。这可以防止气泡出现后立即滑出视图。

 



import cx from 'classnames';
import { useEffect } from 'react';
import { useBooleanState, usePrevious } from 'webrix/hooks';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
import { ReactComponent as OfflineLogo } from 'assets/images/logo-offline-icon.svg';
import Text from '../Text';
import './_offline.scss';

export default function Offline({ children }) {
  const { value: online, setFalse: setOffline, setTrue: setOnline } = useBooleanState(navigator.onLine);
    const previousOnline = usePrevious(online);

  useEffect(() => {
        if (!online) { return void disableBodyScroll(document.body); }

        enableBodyScroll(document.body);
    }, [online]);

    useEffect(() => {
        window.addEventListener('online', setOnline);
        window.addEventListener('offline', setOffline);

        return () => {
            window.removeEventListener('online', setOnline);
            window.removeEventListener('offline', setOffline);
        };
    }, []);

  return (
    <>
     <div
            className={cx(
                    'offline',
                    'animate__animated',
                    'animate__faster',

                // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
                    'animate__${online ? 'slideOutUp' : 'slideInDown'}'
                )}
                style={previousOnline === online && online ? { display: 'none' } : void 0}
        >
                <div className="offline__content">
                    <OfflineLogo />
                    <div className="offline__text">
                        <Text subHeading className="mt-0 mb-5">You're not online</Text>
                        <Text className="mt-0 mb-0">Check your internet connection.</Text>
                    </div>
                </div>
            <div className={cx('offline__overlay', { 'offline__overlay--visible': !online })} />
      {children}
    </>
  );
}


Enter fullscreen mode Exit fullscreen mode

最后,同样重要的是,让我们锁定滚动。还记得之前的要求吗?当覆盖层和气泡打开时,用户不应该能够在后台滚动。为此,我们使用了一个名为 的库body-scroll-lock,并在新的钩子中简单地切换锁定useEffect

 

造型

SCSS 中的样式设置非常简单。以下是实现上述结果的方法:



@import 'vars';

.offline {
  position: fixed;
  top: 0;
  z-index: 4;
  left: calc(50% - 200px);
  width: 400px;
  padding-top: 40px;

  @media only screen and (max-width: $mobile-width) {
    padding-top: 20px;
  }

  @media only screen and (max-width: 500px) {
    padding-top: 20px;
    width: calc(100% - 40px);
    left: 20px;
  }

  &__content {
    padding: 15px 20px;
    background: white;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 6px;

    > svg {
      height: 50px;
      width: auto;
      margin-right: 20px;
    }
  }

  &__overlay {
    position: fixed;
    z-index: 3;
    background: rgba(0, 0, 0, 0.8);
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    opacity: 0;
    transition: opacity 0.5s ease-in-out;
    pointer-events: none;

    &--visible {
      opacity: 1;
      pointer-events: unset;
    }
  }
}


Enter fullscreen mode Exit fullscreen mode

值得讨论的部分是:

  • 硬编码right %,而不是translateanimate.css使用变换来制作动画,所以我们需要一种不同的方法将其水平居中。
  • @import 'vars'- 这只是一个充满 SCSS 变量的文件。媒体查询变量只是一个像素值。
  • padding: top而不是实际top值 -在滑出容器时animate.css使用transform: translateY(-100%)。如果我们使用 top 值,组件将不会完全滑出视图。如果我们改为使用 padding,则会增大组件的大小,因此组件将完全滑出视图,但仍然与屏幕顶部有间隙。

 

在我们的应用程序中使用它

你可以在任何地方使用该组件,但我建议尽可能地高一些。在我的应用中,它位于 appindex文件中:



ReactDOM.render(
  <React.StrictMode>
    <Offline>
        <App />
    </Offline>
  </React.StrictMode>,
  document.getElementById('root')
);


Enter fullscreen mode Exit fullscreen mode

 

图标和启动画面

Manifest.json

清单文件用于告诉平台我们希望 PWA 如何运行。在文件夹中自动为我们create-react-app创建一个文件manifest.jsonpublic



{
  "short_name": "name",
  "name": "name",
  "description": "description",
  "icons": [
    {
      "src": "/icons/icon-72x72.png",
      "sizes": "72x72",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-96x96.png",
      "sizes": "96x96",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-128x128.png",
      "sizes": "128x128",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-144x144.png",
      "sizes": "144x144",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-152x152.png",
      "sizes": "152x152",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-384x384.png",
      "sizes": "384x384",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": ".",
  "display": "standalone",
  "background_color": "#fff"
}


Enter fullscreen mode Exit fullscreen mode

short_name- 显示在较小区域(例如主屏幕)的标题

name- 应用程序的完整标题

description- 应用程序描述

icons- 这些图标用于 Android 主屏幕或桌面上的 PWA 桌面应用。iOS PWA 不使用它们(请参阅下文的陷阱)

start_url- 应用程序的入口点。对于标准 React 应用,该入口点是 root 权限,或者.

display- 你的应用应该如何在 PWA 容器中显示?standalone将全屏呈现并提供更原生的体验

background_color- 加载屏幕背景颜色(例如启动画面)。这不是应用加载时的背景颜色。

theme_color- 这决定了应用程序顶部状态栏的颜色,但我选择只使用主题<meta>标签,index.html因为我可以动态地更改它(请参阅下面的主题)。

对于我的应用程序,我采用了应用程序的徽标并将其变成了 macOS 风格的圆形图标,例如:

之前-之后图标

 
该文件的完整细节可在此处manifest.json找到。您的文件应链接到此清单,并包含类似于以下内容的一行index.html<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />

 

iOS 和陷阱

iOS仍然无法很好地处理 PWA。除了告诉 iOS 您支持 PWA 之外,您的清单文件几乎会被忽略。目前 PWA 仅通过Safari 浏览器支持。

iOS不支持图标透明。如果图标是 png 格式,背景会是黑色。你应该为 iOS 制作一些特殊的图标,并配上彩色背景(我的是白色的),如下所示:

iOS预览版

<link rel="apple-touch-icon" href="%PUBLIC_URL%/icons/ios-touch-icon.png">要使用它,我们需要文件中的链接index.html

 

启动画面

要在 iOS 上显示应用加载时的启动画面,您需要在 中编写一系列 html 代码行index.html。遗憾的是,您需要根据支持的分辨率使用不同大小的图像:



<link href="%PUBLIC_URL%/splash/iphone5_splash.png" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphone6_splash.png" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphoneplus_splash.png" media="(device-width: 621px) and (device-height: 1104px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonex_splash.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonexr_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/iphonexsmax_splash.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipad_splash.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro1_splash.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro3_splash.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />
<link href="%PUBLIC_URL%/splash/ipadpro2_splash.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2)" rel="apple-touch-startup-image" />


Enter fullscreen mode Exit fullscreen mode

 

主题和主题颜色

如前所述,我们将通过index.html而不是 使用 来控制主题。点击此处manifest.json了解更多信息theme-color以及实际效果

 

静态主题颜色

静态主题颜色很简单。只需在index.html文件中添加此行即可<meta name="theme-color" content="#ffffff" />create-react-app默认情况下会提供此功能。

 

动态主题颜色

在你的应用中,你可能会使用不同的页面颜色。例如,在我的应用中,主页是绿色,但其余页面是白色。我希望主题颜色能够根据我当前所在的位置进行更改。当模态窗口打开时,主题颜色会变为黑色。

为此,你需要一个名为 的库。Helmet 允许我们在组件内部react-helmet修改文档的 。太棒了!<head>

 
为此,只需将<Helmet>元素包含在任何组件中:



<Helmet><meta name="theme-color" content="#000000" /></Helmet>


Enter fullscreen mode Exit fullscreen mode

 
我们实际上可以扩展Offline.js我们之前构建的组件,使状态栏变黑:



<div
    className={cx(
        'offline',
        'animate__animated',
        'animate__faster',

    // This should be backticks, but the syntax highlighting gets confused so I've made it single quotes
        'animate__${online ? 'slideOutUp' : 'slideInDown'}'
    )}
    style={previousOnline === online && online ? { display: 'none' } : void 0}
>

  // The line below changes the theme dynamically, but only when we're offline
    {!online && <Helmet><meta name="theme-color" content="#000000" /></Helmet>}

    <div className="offline__content">
        <OfflineLogo />
        <div className="offline__text">
            <Text subHeading className="mt-0 mb-5">You're not online</Text>
            <Text className="mt-0 mb-0">Check your internet connection.</Text>
        </div>
    </div>
</div>


Enter fullscreen mode Exit fullscreen mode

 

附加功能

链接

 

感谢阅读!欢迎留下反馈🚀

喜欢我的文章,想了解更多?快来Medium关注我吧

文章来源:https://dev.to/alexgurr/turning-a-react-app-into-a-pwa-with-offline-detection-service-workers-and-theming-142i
PREV
SVG 的优点 背景优点 可扩展性:像素完美风格化 性能 不仅仅是图像 摘要 快乐编码🚀
NEXT
从 React Conf 2021 中学习