pwa-setup
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChinesePWA Setup
PWA 配置
Progressive Web App configuration for app-like browser experience.
实现类应用浏览器体验的渐进式Web应用(PWA)配置。
When to Use This Skill
何时使用该方案
- Users want to "install" your web app
- Mobile users want home screen access
- Need app-like behavior without native apps
- Supporting notched devices (iPhone, etc.)
- 用户希望“安装”你的Web应用
- 移动端用户希望将应用添加至主屏幕
- 无需开发原生应用即可实现类应用的交互体验
- 适配刘海屏设备(如iPhone等)
Core Concepts
核心概念
PWA requires:
- Web App Manifest - App metadata and icons
- Mobile meta tags - Viewport and theme configuration
- Safe areas - Handle notched devices
- Install prompt - Custom install experience
PWA 需要以下核心要素:
- Web App Manifest - 应用元数据与图标
- 移动端元标签 - 视口与主题配置
- 安全区域 - 适配刘海屏设备
- 安装提示 - 自定义安装体验
Implementation
实现方案
TypeScript (Next.js)
TypeScript(Next.js)
typescript
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My SaaS App",
short_name: "MySaaS",
description: "Your app description here",
start_url: '/dashboard',
display: 'standalone',
background_color: '#0f172a',
theme_color: '#14b8a6',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{ name: 'Dashboard', url: '/dashboard', description: 'Go to dashboard' },
{ name: 'Settings', url: '/settings', description: 'App settings' },
],
categories: ['productivity', 'utilities'],
};
}typescript
// app/manifest.ts
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: "My SaaS App",
short_name: "MySaaS",
description: "Your app description here",
start_url: '/dashboard',
display: 'standalone',
background_color: '#0f172a',
theme_color: '#14b8a6',
orientation: 'portrait-primary',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icons/icon-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
shortcuts: [
{ name: 'Dashboard', url: '/dashboard', description: 'Go to dashboard' },
{ name: 'Settings', url: '/settings', description: 'App settings' },
],
categories: ['productivity', 'utilities'],
};
}Root Layout Metadata
根布局元数据
typescript
// app/layout.tsx
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: "My SaaS App",
description: "Your app description",
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: "MySaaS",
},
applicationName: "MySaaS",
openGraph: {
title: "My SaaS App",
description: "Your app description",
type: 'website',
siteName: "MySaaS",
},
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: '#14b8a6',
viewportFit: 'cover',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-neutral-900 text-neutral-cream min-h-screen">
{children}
</body>
</html>
);
}typescript
// app/layout.tsx
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
title: "My SaaS App",
description: "Your app description",
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: "MySaaS",
},
applicationName: "MySaaS",
openGraph: {
title: "My SaaS App",
description: "Your app description",
type: 'website',
siteName: "MySaaS",
},
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
themeColor: '#14b8a6',
viewportFit: 'cover',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="bg-neutral-900 text-neutral-cream min-h-screen">
{children}
</body>
</html>
);
}Safe Area CSS
安全区域CSS
css
/* globals.css */
/* Safe area for bottom navigation (notched devices) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Safe area for top header */
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* Full safe area padding */
.safe-area-all {
padding-top: env(safe-area-inset-top, 0);
padding-right: env(safe-area-inset-right, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
}css
/* globals.css */
/* Safe area for bottom navigation (notched devices) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Safe area for top header */
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
}
/* Full safe area padding */
.safe-area-all {
padding-top: env(safe-area-inset-top, 0);
padding-right: env(safe-area-inset-right, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
padding-left: env(safe-area-inset-left, 0);
}Install Prompt Hook
安装提示Hook
typescript
// hooks/useInstallPrompt.ts
'use client';
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useInstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const promptInstall = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
setInstallPrompt(null);
}
return outcome === 'accepted';
};
return {
canInstall: !!installPrompt && !isInstalled,
isInstalled,
promptInstall,
};
}typescript
// hooks/useInstallPrompt.ts
'use client';
import { useState, useEffect } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function useInstallPrompt() {
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e as BeforeInstallPromptEvent);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const promptInstall = async () => {
if (!installPrompt) return false;
await installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
if (outcome === 'accepted') {
setIsInstalled(true);
setInstallPrompt(null);
}
return outcome === 'accepted';
};
return {
canInstall: !!installPrompt && !isInstalled,
isInstalled,
promptInstall,
};
}Install Banner Component
安装横幅组件
typescript
// components/InstallBanner.tsx
'use client';
import { useInstallPrompt } from '@/hooks/useInstallPrompt';
export function InstallBanner() {
const { canInstall, promptInstall } = useInstallPrompt();
if (!canInstall) return null;
return (
<div className="fixed bottom-20 left-4 right-4 bg-primary-600 text-white p-4 rounded-lg shadow-lg md:hidden">
<p className="text-sm mb-2">Install our app for a better experience</p>
<button
onClick={promptInstall}
className="w-full py-2 bg-white text-primary-600 rounded font-medium"
>
Install App
</button>
</div>
);
}typescript
// components/InstallBanner.tsx
'use client';
import { useInstallPrompt } from '@/hooks/useInstallPrompt';
export function InstallBanner() {
const { canInstall, promptInstall } = useInstallPrompt();
if (!canInstall) return null;
return (
<div className="fixed bottom-20 left-4 right-4 bg-primary-600 text-white p-4 rounded-lg shadow-lg md:hidden">
<p className="text-sm mb-2">Install our app for a better experience</p>
<button
onClick={promptInstall}
className="w-full py-2 bg-white text-primary-600 rounded font-medium"
>
Install App
</button>
</div>
);
}Mobile Navigation with Safe Area
适配安全区域的移动端导航
typescript
// components/MobileNav.tsx
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 bg-neutral-800 border-t border-neutral-700 z-30 md:hidden safe-area-bottom">
<div className="flex justify-around py-2">
<NavItem href="/dashboard" icon={HomeIcon} label="Home" />
<NavItem href="/search" icon={SearchIcon} label="Search" />
<NavItem href="/settings" icon={SettingsIcon} label="Settings" />
</div>
</nav>
);
}typescript
// components/MobileNav.tsx
export function MobileNav() {
return (
<nav className="fixed bottom-0 left-0 right-0 bg-neutral-800 border-t border-neutral-700 z-30 md:hidden safe-area-bottom">
<div className="flex justify-around py-2">
<NavItem href="/dashboard" icon={HomeIcon} label="Home" />
<NavItem href="/search" icon={SearchIcon} label="Search" />
<NavItem href="/settings" icon={SettingsIcon} label="Settings" />
</div>
</nav>
);
}Icon Requirements
图标要求
public/
├── icons/
│ ├── icon-192.png # Standard icon
│ ├── icon-512.png # Large icon
│ └── icon-maskable.png # Maskable (with safe zone padding)
├── favicon.ico
└── apple-touch-icon.png # 180x180 for iOSpublic/
├── icons/
│ ├── icon-192.png # Standard icon
│ ├── icon-512.png # Large icon
│ └── icon-maskable.png # Maskable (with safe zone padding)
├── favicon.ico
└── apple-touch-icon.png # 180x180 for iOSMaskable Icon Safe Zone
可遮罩图标安全区域
┌─────────────────────┐
│ │
│ ┌───────────┐ │
│ │ LOGO │ │ ← Content in center 80%
│ └───────────┘ │
│ │
└─────────────────────┘┌─────────────────────┐
│ │
│ ┌───────────┐ │
│ │ LOGO │ │ ← Content in center 80%
│ └───────────┘ │
│ │
└─────────────────────┘Usage Examples
使用示例
Testing PWA
测试PWA
- Chrome DevTools → Application → Manifest
- Lighthouse → PWA audit
- Mobile → Add to Home Screen
- Chrome DevTools → Application → Manifest
- Lighthouse → PWA audit
- Mobile → Add to Home Screen
Detecting Standalone Mode
检测独立运行模式
typescript
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}typescript
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}Best Practices
最佳实践
- Use maskable icons with safe zone
- Set theme_color to match your brand
- Handle safe areas for notched devices
- Provide install prompt at appropriate time
- Test on actual mobile devices
- 使用带安全区域的可遮罩图标
- 设置与品牌匹配的theme_color
- 适配刘海屏设备的安全区域
- 在合适的时机展示安装提示
- 在真实移动设备上进行测试
Common Mistakes
常见错误
- Missing maskable icon (ugly on Android)
- No safe area handling (content under notch)
- Install prompt shown immediately (annoying)
- Wrong start_url (opens wrong page)
- Missing apple-touch-icon (iOS fallback)
- 缺少可遮罩图标(在Android上显示效果差)
- 未处理安全区域(内容被刘海遮挡)
- 立即展示安装提示(易引起用户反感)
- start_url 设置错误(打开错误页面)
- 缺少apple-touch-icon(iOS无备用图标)
Related Patterns
相关模式
- design-tokens - Consistent theming
- mobile-components - Responsive components
- design-tokens - 统一主题配置
- mobile-components - 响应式组件