将 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';
现在只需使用 运行该函数即可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();
除非特别启用(请参阅下方 extras 部分中的 create-react-app 文档),否则 Service Worker 只会在生产版本中注册/运行。这是因为热重载和 Service Worker 缓存不太兼容!这意味着你不会在 中看到 Service Worker 正在运行Dev tools > Application > Service Workers
。
离线检测和 UI/UX
离线检测并不是服务工作者/PWA 特有的功能,但是,PWA 是“离线优先”的,这意味着最好有代码来处理离线/在线状态。
在我的应用中,我决定添加一个从屏幕顶部向下延伸的小气泡来遮挡页面。效果如下(可能需要几秒钟加载):
为了给此功能带来良好的用户和开发人员体验 -
- 它应该是一个高阶组件,我们可以将它包裹在整个应用程序中,以实现单一职责并且不重复代码
- 它应该阻止用户在打开时滚动
- 它应该能够实时检测应用程序何时在线/离线
- 应该清楚发生了什么
组件
我们来创建一个新文件夹,Offline
。具体放在哪里由您决定。在我的应用中,它位于src/common/components
。我使用的是 SCSS,但您可以继续使用您应用所使用的任何框架。
创建 3 个新文件,index.js
、Offline.js
和_offline.scss
。
index.js
为我们的组件提供默认导出:
export { default } from './Offline';
Offline.js
是我们的主要组件。该组件包含两个主要功能:1)用于处理网络状态变化的窗口事件处理程序;2)实际的 JSX/HTML 本身。这里我使用了 React 17 和 Hooks,但您可以根据需要将其改造为类组件。
让我们开始建造吧!
export default function Offline({ children }) {
return (
<>
<div className="offline" />
{children}
</>
);
}
我们实例化了一个新组件并将其呈现在片段中,因为我们不想在应用程序的子组件上方添加额外的层/容器。
import cx from 'classnames';
import './_offline.scss';
export default function Offline({ children }) {
return (
<>
<div className="offline" />
<div className={cx('offline__overlay')} />
{children}
</>
);
}
现在我们已经导入了样式,并添加了一个用于淡出背景的叠加层 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}
</>
);
}
现在,我们要往这个小小的离线气泡中添加一些内容。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}
</>
);
}
我们添加了让它执行某些操作的逻辑!我们有两个状态变量,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}
</>
);
}
现在我们要用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}
</>
);
}
最后,同样重要的是,让我们锁定滚动。还记得之前的要求吗?当覆盖层和气泡打开时,用户不应该能够在后台滚动。为此,我们使用了一个名为 的库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;
}
}
}
值得讨论的部分是:
- 硬编码
right %
,而不是translate
。animate.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')
);
图标和启动画面
Manifest.json
清单文件用于告诉平台我们希望 PWA 如何运行。在文件夹中自动为我们create-react-app
创建一个文件。manifest.json
public
{
"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"
}
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 制作一些特殊的图标,并配上彩色背景(我的是白色的),如下所示:
<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" />
主题和主题颜色
如前所述,我们将通过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>
我们实际上可以扩展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>
附加功能
链接