pwa-setup

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

PWA 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:
  1. Web App Manifest - App metadata and icons
  2. Mobile meta tags - Viewport and theme configuration
  3. Safe areas - Handle notched devices
  4. Install prompt - Custom install experience
PWA 需要以下核心要素:
  1. Web App Manifest - 应用元数据与图标
  2. 移动端元标签 - 视口与主题配置
  3. 安全区域 - 适配刘海屏设备
  4. 安装提示 - 自定义安装体验

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 iOS
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 iOS

Maskable Icon Safe Zone

可遮罩图标安全区域

┌─────────────────────┐
│                     │
│   ┌───────────┐     │
│   │   LOGO    │     │  ← Content in center 80%
│   └───────────┘     │
│                     │
└─────────────────────┘
┌─────────────────────┐
│                     │
│   ┌───────────┐     │
│   │   LOGO    │     │  ← Content in center 80%
│   └───────────┘     │
│                     │
└─────────────────────┘

Usage Examples

使用示例

Testing PWA

测试PWA

  1. Chrome DevTools → Application → Manifest
  2. Lighthouse → PWA audit
  3. Mobile → Add to Home Screen
  1. Chrome DevTools → Application → Manifest
  2. Lighthouse → PWA audit
  3. 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

最佳实践

  1. Use maskable icons with safe zone
  2. Set theme_color to match your brand
  3. Handle safe areas for notched devices
  4. Provide install prompt at appropriate time
  5. Test on actual mobile devices
  1. 使用带安全区域的可遮罩图标
  2. 设置与品牌匹配的theme_color
  3. 适配刘海屏设备的安全区域
  4. 在合适的时机展示安装提示
  5. 在真实移动设备上进行测试

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 - 响应式组件