关键要点
- PWA 核心三要素:Service Worker、Web App Manifest 和 HTTPS 是构建 PWA 的基础
- next-pwa 配置简化:使用 next-pwa 插件自动生成 Service Worker 和处理缓存策略
- Chart.js 响应式图表:创建适配各种屏幕尺寸的健康数据可视化
- 离线数据存储:使用 IndexedDB 或本地存储管理离线健康数据
- 后台同步策略:使用 Background Sync API 在网络恢复时自动同步数据
渐进式 Web 应用(PWA)结合了 Web 和原生应用的优点。对于健康数据追踪应用,PWA 的离线能力让用户可以随时随地记录健康指标,并在有网络时自动同步。
前置条件:
- Next.js 14 基础
- Chart.js 了解
- Service Worker 基本概念
步骤 1:项目初始化
code
npx create-next-app@latest health-pwa --typescript --tailwind --app
cd health-pwa
npm install chart.js react-chartjs-2 next-pwa
npm install @types/chart.js
npm install date-fns clsx tailwind-merge
Code collapsed
步骤 2:配置 PWA
创建 next.config.js:
code
// next.config.js
const withPWA = require('next-pwa')({
dest: 'public',
register: true,
skipWaiting: true,
disable: process.env.NODE_ENV === 'development',
buildExcludes: ['middleware-manifest.json'],
});
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = withPWA(nextConfig);
Code collapsed
创建 Manifest 文件 public/manifest.json:
code
{
"name": "康心伴 - 健康数据追踪",
"short_name": "康心伴",
"description": "追踪您的健康数据,包括心率、步数、睡眠等指标",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3b82f6",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
],
"categories": ["health", "fitness", "medical"],
"screenshots": [
{
"src": "/screenshots/home.png",
"sizes": "540x720",
"type": "image/png"
}
]
}
Code collapsed
步骤 3:创建离线存储服务
code
// lib/healthStorage.ts
import { openDB } from 'idb';
import { format, subDays, startOfDay } from 'date-fns';
export interface HealthMetric {
id: string;
userId: string;
type: 'heart_rate' | 'steps' | 'weight' | 'sleep' | 'calories';
value: number;
unit: string;
timestamp: number;
synced: boolean;
}
export interface DailyStats {
date: string;
steps: number;
calories: number;
activeMinutes: number;
}
const DB_NAME = 'health-pwa-db';
const DB_VERSION = 1;
class HealthStorageService {
private db: any;
async init() {
this.db = await openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// 健康指标存储
if (!db.objectStoreNames.contains('metrics')) {
const metricsStore = db.createObjectStore('metrics', {
keyPath: 'id',
});
metricsStore.createIndex('userId', 'userId');
metricsStore.createIndex('type', 'type');
metricsStore.createIndex('timestamp', 'timestamp');
metricsStore.createIndex('synced', 'synced');
}
// 每日统计缓存
if (!db.objectStoreNames.contains('dailyStats')) {
const statsStore = db.createObjectStore('dailyStats', {
keyPath: 'date',
});
statsStore.createIndex('userId', 'userId');
}
// 同步队列
if (!db.objectStoreNames.contains('syncQueue')) {
const syncStore = db.createObjectStore('syncQueue', {
keyPath: 'id',
autoIncrement: true,
});
}
},
});
}
// 保存健康指标
async saveMetric(metric: Omit<HealthMetric, 'id' | 'synced'>): Promise<string> {
await this.init();
const id = `${metric.type}_${metric.timestamp}_${Date.now()}`;
const newMetric: HealthMetric = {
...metric,
id,
synced: false,
};
await this.db.put('metrics', newMetric);
// 添加到同步队列
await this.addToSyncQueue({
type: 'metric',
action: 'create',
data: newMetric,
});
return id;
}
// 获取指定时间范围的指标
async getMetrics(
userId: string,
type: string,
startDate: Date,
endDate: Date
): Promise<HealthMetric[]> {
await this.init();
return await this.db.getAllFromIndex(
'metrics',
'userId',
IDBKeyRange.only(userId),
(metric: HealthMetric) =>
metric.type === type &&
metric.timestamp >= startDate.getTime() &&
metric.timestamp <= endDate.getTime()
);
}
// 获取未同步的指标
async getUnsyncedMetrics(): Promise<HealthMetric[]> {
await this.init();
return await this.db.getAllFromIndex(
'metrics',
'synced',
IDBKeyRange.only(false)
);
}
// 标记为已同步
async markAsSynced(id: string): Promise<void> {
await this.init();
const metric = await this.db.get('metrics', id);
if (metric) {
metric.synced = true;
await this.db.put('metrics', metric);
}
}
// 保存每日统计
async saveDailyStats(stats: DailyStats): Promise<void> {
await this.init();
await this.db.put('dailyStats', stats);
}
// 获取每日统计
async getDailyStats(date: string): Promise<DailyStats | undefined> {
await this.init();
return await this.db.get('dailyStats', date);
}
// 获取本周统计
async getWeeklyStats(userId: string): Promise<DailyStats[]> {
await this.init();
const today = startOfDay(new Date());
const stats: DailyStats[] = [];
for (let i = 6; i >= 0; i--) {
const date = format(subDays(today, i), 'yyyy-MM-dd');
const dayStats = await this.getDailyStats(date);
stats.push(
dayStats || {
date,
steps: 0,
calories: 0,
activeMinutes: 0,
}
);
}
return stats;
}
// 同步队列管理
private async addToSyncQueue(item: any): Promise<void> {
await this.init();
await this.db.put('syncQueue', item);
}
async getSyncQueue(): Promise<any[]> {
await this.init();
return await this.db.getAll('syncQueue');
}
async clearSyncQueue(): Promise<void> {
await this.init();
await this.db.clear('syncQueue');
}
}
export const healthStorage = new HealthStorageService();
Code collapsed
步骤 4:创建 Chart.js 组件
code
// components/HealthLineChart.tsx
'use client';
import React from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { format } from 'date-fns';
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
);
interface HealthLineChartProps {
data: Array<{ date: string; value: number }>;
label: string;
color?: string;
unit?: string;
fill?: boolean;
}
export function HealthLineChart({
data,
label,
color = '#3b82f6',
unit = '',
fill = false,
}: HealthLineChartProps) {
const chartData = {
labels: data.map((d) => format(new Date(d.date), 'M/d')),
datasets: [
{
label,
data: data.map((d) => d.value),
borderColor: color,
backgroundColor: fill
? `${color}20`
: color,
borderWidth: 2,
tension: 0.4,
fill: fill,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: color,
pointBorderColor: '#fff',
pointBorderWidth: 2,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top' as const,
},
tooltip: {
callbacks: {
label: function (context: any) {
return `${context.dataset.label}: ${context.parsed.y} ${unit}`;
},
},
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
},
},
interaction: {
intersect: false,
mode: 'index' as const,
},
};
return (
<div className="relative h-64 w-full">
<Line data={chartData} options={options} />
</div>
);
}
Code collapsed
code
// components/HealthBarChart.tsx
'use client';
import React from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Bar } from 'react-chartjs-2';
import { format } from 'date-fns';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
interface HealthBarChartProps {
data: Array<{ date: string; value: number }>;
label: string;
color?: string;
unit?: string;
goal?: number;
}
export function HealthBarChart({
data,
label,
color = '#22c55e',
unit = '',
goal,
}: HealthBarChartProps) {
const chartData = {
labels: data.map((d) => format(new Date(d.date), 'M/d')),
datasets: [
{
label,
data: data.map((d) => d.value),
backgroundColor: data.map((d) =>
goal && d.value >= goal ? color : `${color}80`
),
borderRadius: 6,
borderSkipped: false,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
tooltip: {
callbacks: {
label: function (context: any) {
let label = `${context.dataset.label}: ${context.parsed.y} ${unit}`;
if (goal) {
const percentage = Math.round((context.parsed.y / goal) * 100);
label += ` (${percentage}%)`;
}
return label;
},
},
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
x: {
grid: {
display: false,
},
},
},
};
return (
<div className="relative h-64 w-full">
<Bar data={chartData} options={options} />
</div>
);
}
Code collapsed
步骤 5:创建 PWA 提示组件
code
// components/PWAInstallPrompt.tsx
'use client';
import React, { useEffect, useState } from 'react';
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function PWAInstallPrompt() {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
setShowPrompt(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
};
}, []);
const handleInstall = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === 'accepted') {
setShowPrompt(false);
}
setDeferredPrompt(null);
};
const handleDismiss = () => {
setShowPrompt(false);
};
if (!showPrompt || !deferredPrompt) {
return null;
}
return (
<div className="fixed bottom-4 left-4 right-4 md:left-auto md:right-4 md:w-96 bg-white rounded-xl shadow-lg p-4 z-50 border border-gray-200">
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
</div>
</div>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">安装应用</h3>
<p className="text-sm text-gray-600 mt-1">
安装康心伴到您的设备,获得更好的体验
</p>
</div>
<button
onClick={handleDismiss}
className="text-gray-400 hover:text-gray-600"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<button
onClick={handleInstall}
className="w-full mt-4 bg-blue-600 text-white py-2 rounded-lg font-medium hover:bg-blue-700 transition"
>
立即安装
</button>
</div>
);
}
Code collapsed
步骤 6:创建同步状态组件
code
// components/SyncStatus.tsx
'use client';
import React, { useEffect, useState } from 'react';
type SyncStatus = 'synced' | 'syncing' | 'error' | 'offline';
export function SyncStatus() {
const [status, setStatus] = useState<SyncStatus>('synced');
const [pendingCount, setPendingCount] = useState(0);
useEffect(() => {
// 监听在线状态
const handleOnline = () => {
setStatus('syncing');
syncPendingData();
};
const handleOffline = () => {
setStatus('offline');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// 检查初始状态
if (!navigator.onLine) {
setStatus('offline');
}
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const syncPendingData = async () => {
try {
// 获取待同步数据
const unsyncedMetrics = await getUnsyncedMetrics();
setPendingCount(unsyncedMetrics.length);
// 同步到服务器
for (const metric of unsyncedMetrics) {
await syncMetricToServer(metric);
await markAsSynced(metric.id);
}
setStatus('synced');
setPendingCount(0);
} catch (error) {
console.error('Sync failed:', error);
setStatus('error');
}
};
const statusConfig = {
synced: {
icon: '✓',
text: '已同步',
color: 'text-green-600',
bgColor: 'bg-green-50',
},
syncing: {
icon: '⟳',
text: '同步中...',
color: 'text-blue-600',
bgColor: 'bg-blue-50',
},
error: {
icon: '✕',
text: '同步失败',
color: 'text-red-600',
bgColor: 'bg-red-50',
},
offline: {
icon: '⚠',
text: `离线 (${pendingCount} 条待同步)`,
color: 'text-yellow-600',
bgColor: 'bg-yellow-50',
},
};
const config = statusConfig[status];
return (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm ${config.bgColor} ${config.color}`}>
<span className="animate-pulse" style={{ animationDuration: status === 'syncing' ? '1s' : '0s' }}>
{config.icon}
</span>
<span>{config.text}</span>
</div>
);
}
// 辅助函数(实际实现中需要从 storage 模块导入)
async function getUnsyncedMetrics() {
return [];
}
async function syncMetricToServer(metric: any) {
// 实现同步逻辑
}
async function markAsSynced(id: string) {
// 实现标记逻辑
}
Code collapsed
步骤 7:创建主页面
code
// app/page.tsx
import { HealthLineChart } from '@/components/HealthLineChart';
import { HealthBarChart } from '@/components/HealthBarChart';
import { PWAInstallPrompt } from '@/components/PWAInstallPrompt';
import { SyncStatus } from '@/components/SyncStatus';
import { healthStorage } from '@/lib/healthStorage';
import { format, subDays } from 'date-fns';
async function getHealthData() {
const today = new Date();
const lastWeek = subDays(today, 7);
const heartRateData = await healthStorage.getMetrics(
'user-1',
'heart_rate',
lastWeek,
today
);
const stepsData = await healthStorage.getWeeklyStats('user-1');
return {
heartRate: heartRateData.map((d) => ({
date: new Date(d.timestamp).toISOString(),
value: d.value,
})),
steps: stepsData,
};
}
export default async function HomePage() {
const data = await getHealthData();
return (
<div className="min-h-screen bg-gray-50">
{/* PWA 安装提示 */}
<PWAInstallPrompt />
{/* 头部 */}
<header className="bg-white shadow-sm sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">康心伴</h1>
<SyncStatus />
</div>
</header>
{/* 主内容 */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<MetricCard
title="今日步数"
value={data.steps[6]?.steps.toLocaleString() || '0'}
unit="步"
goal={10000}
color="green"
/>
<MetricCard
title="消耗卡路里"
value={data.steps[6]?.calories.toLocaleString() || '0'}
unit="kcal"
goal={2000}
color="orange"
/>
<MetricCard
title="活动时间"
value={data.steps[6]?.activeMinutes || '0'}
unit="分钟"
goal={60}
color="blue"
/>
</div>
{/* 图表 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<ChartCard
title="本周心率趋势"
chart={
<HealthLineChart
data={data.heartRate}
label="心率 (BPM)"
color="#ef4444"
unit="BPM"
fill
/>
}
/>
<ChartCard
title="本周步数统计"
chart={
<HealthBarChart
data={data.steps.map((d) => ({
date: d.date,
value: d.steps,
}))}
label="步数"
color="#22c55e"
unit="步"
goal={10000}
/>
}
/>
</div>
</main>
</div>
);
}
function MetricCard({
title,
value,
unit,
goal,
color,
}: {
title: string;
value: string;
unit: string;
goal: number;
color: string;
}) {
const currentValue = parseInt(value.replace(/,/g, '')) || 0;
const percentage = Math.min((currentValue / goal) * 100, 100);
const colorClasses = {
green: 'bg-green-50 text-green-600',
orange: 'bg-orange-50 text-orange-600',
blue: 'bg-blue-50 text-blue-600',
};
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium text-gray-600">{title}</h3>
<div className={`w-10 h-10 rounded-lg ${colorClasses[color as keyof typeof colorClasses]} flex items-center justify-center`}>
{/* Icon */}
</div>
</div>
<div className="mb-2">
<span className="text-3xl font-bold text-gray-900">{value}</span>
<span className="text-sm text-gray-500 ml-1">{unit}</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="h-2 rounded-full transition-all"
style={{
width: `${percentage}%`,
backgroundColor: color === 'green' ? '#22c55e' : color === 'orange' ? '#f59e0b' : '#3b82f6',
}}
/>
</div>
<p className="text-xs text-gray-500 mt-2">目标: {goal.toLocaleString()} {unit} ({Math.round(percentage)}%)</p>
</div>
);
}
function ChartCard({
title,
chart,
}: {
title: string;
chart: React.ReactNode;
}) {
return (
<div className="bg-white rounded-xl shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
{chart}
</div>
);
}
Code collapsed
步骤 8:配置 Layout
code
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: '康心伴 - 健康数据追踪',
description: '追踪您的健康数据,包括心率、步数、睡眠等指标',
manifest: '/manifest.json',
themeColor: '#3b82f6',
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: '康心伴',
},
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false,
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<head>
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/icons/icon-192x192.png" />
</head>
<body className={inter.className}>{children}</body>
</html>
);
}
Code collapsed
步骤 9:添加后台同步
code
// lib/backgroundSync.ts
import { healthStorage } from './healthStorage';
export class BackgroundSyncService {
private swRegistration: ServiceWorkerRegistration | null = null;
async init() {
if ('serviceWorker' in navigator) {
this.swRegistration = await navigator.serviceWorker.ready;
}
}
async registerSync() {
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
try {
await this.swRegistration?.sync.register('health-data-sync');
} catch (error) {
console.error('Background sync registration failed:', error);
}
}
}
async syncPendingData() {
const unsyncedMetrics = await healthStorage.getUnsyncedMetrics();
if (unsyncedMetrics.length === 0) {
return;
}
try {
// 批量同步数据
await fetch('/api/health/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ metrics: unsyncedMetrics }),
});
// 标记为已同步
for (const metric of unsyncedMetrics) {
await healthStorage.markAsSynced(metric.id);
}
} catch (error) {
console.error('Sync failed:', error);
throw error;
}
}
}
export const backgroundSync = new BackgroundSyncService();
Code collapsed
步骤 10:Service Worker 事件处理
在 public/sw.js 中添加后台同步处理:
code
// public/sw.js
self.addEventListener('sync', (event) => {
if (event.tag === 'health-data-sync') {
event.waitUntil(
(async () => {
try {
// 从 IndexedDB 获取待同步数据
const pendingData = await getPendingSyncData();
// 发送到服务器
await fetch('/api/health/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(pendingData),
});
// 清除同步队列
await clearSyncQueue();
// 通知客户端同步完成
self.clients.matchAll().then((clients) => {
clients.forEach((client) => {
client.postMessage({
type: 'SYNC_COMPLETE',
data: pendingData.length,
});
});
});
} catch (error) {
console.error('Background sync failed:', error);
throw error;
}
})()
);
}
});
async function getPendingSyncData() {
// 实现从 IndexedDB 获取待同步数据
return [];
}
async function clearSyncQueue() {
// 实现清除同步队列
}
Code collapsed
总结
通过本教程,你学会了如何构建一个完整的健康数据 PWA:
- 使用 next-pwa 配置 Service Worker
- 创建 Web App Manifest
- 使用 IndexedDB 存储离线数据
- 集成 Chart.js 创建响应式图表
- 实现后台同步功能
扩展功能
- 添加推送通知提醒
- 实现数据导出功能
- 集成可穿戴设备 API
- 添加数据分享功能