离线PWA与交互式地图:Next.js与Leaflet实战#
大多数Next.js的PWA教程都止步于待办事项列表或新闻流:文本很容易缓存,"离线模式"往往只是"展示已经加载过的内容"。而交互式地图完全是另一个难度级别。Leaflet在模块被导入的那一刻就会访问window和document;地图瓦片动辄几十兆,轻松超出iOS上Cache Storage的配额;路线状态还必须扛得住彻底断网——比如在完全没有信号的树林里跑步时。
我在开发RunCalculator Pro时完整地经历了这一切——这是一个免费的跑步路线规划工具,地址是runcalculator.pro。在地图上点击几下,应用就会生成路线、海拔剖面图,并计算卡路里、步数、配速和用时;它还能按指定距离生成随机路线,并将结果导出为GPX。这一切完全在客户端运行,可以像应用一样安装,断网之后依然能用。接下来讲的是具体的技术决策,而不是"PWA很棒"这种空泛的说辞。
为什么离线地图是一个罕见又棘手的场景#
一个典型的PWA通常只有一个明显的数据源——需要缓存的API。而地图有三个。首先是Leaflet本身的JS包,它绝不能出现在服务端渲染里;其次是OpenStreetMap的瓦片——一屏就可能产生成百上千个细小的PNG/WebP请求;最后是用户输入——那些点击构成了路线,必须在本地持久化。如果不提前设计好架构,而是事后打补丁,这三者会各自以不同的方式出问题。
几乎每个把Leaflet搬进Next.js的人都会踩到同一个坑:构建阶段或首次服务端渲染时,库直接报错ReferenceError: window is not defined崩溃。这不是Leaflet的bug——它从一开始就是为浏览器写的,从不掩饰这一依赖。react-leaflet也有类似的问题:即便用dynamic包裹,MapContainer组件在卸载再重新挂载时(比如应用内标签页快速切换)有时也会抛出容器重复初始化的错误,因为Leaflet把内部状态直接挂在DOM节点上。对于交互密集的地图——点击、可拖拽的标记、自定义标注面板——直接使用Leaflet自身的命令式API,往往比react-leaflet的JSX封装更简单、更可预测,后者更适合留给简单的静态地图。
SSR与Leaflet:如何绕开"window is not defined"#
解决方案在Next.js里是标准做法,但有一个细节要注意:ssr: false必须包裹导入Leaflet的组件,而不仅仅是消费其props的组件。如果leaflet的导入语句位于某个文件的顶层,而这个文件又恰好落入了服务端的依赖图,错误会在dynamic()真正起作用之前就冒出来。
// components/map/InteractiveMap.tsx
'use client';
import dynamic from 'next/dynamic';
// Leaflet touches window at module import time,
// so ssr: false is not an optimization — it's the only option that works.
const MapClient = dynamic(
() => import('./MapClient').then((mod) => mod.MapClient),
{
ssr: false,
loading: () => <MapSkeleton />,
}
);
export function InteractiveMap({ className }: { className?: string }) {
return <MapClient className={className} />;
}MapClient本身是一个单独的文件,顶部写着'use client',到这里才可以安全地直接导入Leaflet:
// components/map/MapClient.tsx (simplified)
'use client';
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
export function MapClient({ className }: { className?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, {
center: [55.75, 37.6],
zoom: 13,
preferCanvas: true, // canvas renders faster than DOM for tracks with hundreds of points
});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19,
}).addTo(map);
mapRef.current = map;
setIsReady(true);
// Leaflet measures the container once, at init time. If the map
// was hidden (inactive tab, an expanding panel animation), the
// size will be zero — recalculate once layout has settled.
requestAnimationFrame(() => map.invalidateSize());
return () => {
map.remove();
mapRef.current = null;
};
}, []);
return <div ref={containerRef} className={className} style={{ minHeight: 400 }} />;
}实践中,单靠一次requestAnimationFrame往往不够:如果地图是在展开中的面板内打开,或者紧跟在CSS动画之后,容器在初始化那一刻的高度可能是零。真正管用的做法是不要依赖单独一帧——给容器挂一个ResizeObserver,每次尺寸变化都重新调用invalidateSize(),再加上几次带递增延迟的初始化重试,以应对容器还没准备好的情况。这听起来像是过度防御,直到你收到一条真实用户的bug反馈,只说了一句"我手机上地图是灰的"。
Service Worker:该缓存什么,不该缓存什么#
@ducanh2912/next-pwa是原版next-pwa包的一个维护良好的分支,它在next build之上生成一个基于Workbox的Service Worker。关键决策是不要用一种策略缓存所有东西,而是按资源的性质分开处理:
// next.config.ts
import withPWAInit from '@ducanh2912/next-pwa';
const withPWA = withPWAInit({
dest: 'public',
disable: process.env.NODE_ENV === 'development',
cacheOnFrontEndNav: true,
fallbacks: { document: '/_offline' },
workboxOptions: {
runtimeCaching: [
{
// Map tiles are the heaviest and most stable resource: the same
// tile almost never changes, so CacheFirst is the right call.
urlPattern: /^https:\/\/[abc]\.tile\.openstreetmap\.org\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'osm-tiles',
expiration: {
maxEntries: 1000,
maxAgeSeconds: 60 * 60 * 24 * 30,
},
cacheableResponse: { statuses: [0, 200] },
},
},
{
urlPattern: ({ request }) => request.mode === 'navigate',
handler: 'NetworkFirst',
options: { cacheName: 'pages', networkTimeoutSeconds: 10 },
},
],
},
});
export default withPWA({
output: 'standalone',
});瓦片使用CacheFirst并配上严格的maxEntries——没有上限的话,瓦片缓存会无限增长,因为探索一座城市可能在一次会话里就拉取几千张不重复的瓦片。页面使用带超时的NetworkFirst:如果网络正常,内容会更新;如果不正常,Service Worker会在10秒内返回最近一次缓存的版本。单独的fallbacks.document指向离线页面——即/_offline路由,它必须存在于应用中,且不依赖任何服务端数据。
值得单独说明一下,为什么选的是@ducanh2912/next-pwa,而不是shadowwalker维护的原版next-pwa:后者实际上已经停止维护,和App Router以及较新版本的Next.js相处得并不好,而这个分支正积极修复的就是这类冲突。还有一个几乎所有人都会踩到的细节:生成的Service Worker在开发模式下默认是禁用的(disable: process.env.NODE_ENV === 'development')——离线模式在本地next dev下根本无法验证,只能对着构建产物next build && next start或生产环境测试。验证离线模式应该用DevTools里Application→Service Workers标签下的"Offline"开关,而不是单纯关掉Wi-Fi——浏览器可能会从普通的HTTP缓存里返回页面,制造出离线模式在生效的假象,而Service Worker其实根本没有参与。
客户端状态:用Zustand取代后端#
真正让离线模式变得轻而易举、而不是需要英雄式努力的架构决策是:这个应用的核心流程根本没有后端。路线、路线上的点、活动类型——所有这些状态都存在Zustand里,并与localStorage同步。不需要考虑离线同步队列,也不需要处理版本冲突——因为根本没有什么需要同步。
// stores/routeStore.ts
import { create } from 'zustand';
interface RoutePoint {
lat: number;
lng: number;
elevation: number | null;
}
interface RouteState {
points: RoutePoint[];
addPoint: (point: RoutePoint) => void;
removePoint: (index: number) => void;
undoLastPoint: () => void;
clearRoute: () => void;
}
// The route lives only in the browser: no backend, no sessions,
// no risk of losing points on a dropped connection — state is local.
export const useRouteStore = create<RouteState>((set) => ({
points: [],
addPoint: (point) =>
set((state) => ({ points: [...state.points, point] })),
removePoint: (index) =>
set((state) => ({
points: state.points.filter((_, i) => i !== index),
})),
undoLastPoint: () =>
set((state) => ({ points: state.points.slice(0, -1) })),
clearRoute: () => set({ points: [] }),
}));这个应用唯一的网络依赖,是一个负责把路线贴合到实际道路、而不是直接穿越障碍的外部路由服务。它并不关键:如果请求失败或不可达,应用会在各点之间画一条虚线直线,并老老实实用haversine公式计算距离,而不是给用户展示一个错误和空白屏幕。这就是"优雅降级"和"直接崩溃"的区别。
无需服务器的GPX与海拔剖面#
把轨迹导出为GPX既不需要服务器,也不需要临时文件——这种格式足够简单,可以直接在浏览器里拼成一段XML字符串,再通过Blob和URL.createObjectURL交出去:
// lib/gpx/export.ts
interface TrackPoint {
lat: number;
lng: number;
elevation: number | null;
}
// GPX is assembled as a string on the client — no API route, no server.
export function buildGpx(points: TrackPoint[], name: string): string {
const trkpts = points
.map((p) => {
const ele = p.elevation !== null ? `<ele>${p.elevation}</ele>` : '';
return `<trkpt lat="${p.lat}" lon="${p.lng}">${ele}</trkpt>`;
})
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="RunCalculator Pro">
<trk>
<name>${name}</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>`;
}
export function downloadGpx(xml: string, filename: string) {
const blob = new Blob([xml], { type: 'application/gpx+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}这之所以能离线工作,原因只有一个:它不发起任何网络请求。数据早已经在标签页的内存里,而Blob是浏览器自带的机制,连下载文件都不需要服务器参与。
2026年iOS上的PWA:哪些真的能用#
只按Android的思路设计离线模式,却从不在iPhone上验证——这是收获"iPhone上不能用"工单的稳妥办法。Safari的限制并没有消失,但已经发生了变化:
| 能力 | iOS Safari(2026) | Android Chrome |
|---|---|---|
| 添加到主屏幕 | 以WebClip形式运行,不是完整的PWA容器 | 完整安装,独立进程 |
| Cache Storage配额 | 每个源大约50MB,长期不用可能被清空 | 几乎没有上限(几百MB甚至更多) |
| 后台Service Worker | 能运行,但进程存活时间被压缩 | 运行稳定,包括Background Sync |
| 推送通知 | iOS 16.4+起可用(欧盟以外);Safari 18.4加入了Declarative Web Push | 早已全面支持 |
| 独立Web应用模式 | iOS 26起,添加到主屏幕的站点默认启用 | 通过manifest里的display控制 |
对地图来说,这里的实际结论是:Service Worker配置里maxEntries: 1000这个上限,不只是磁盘卫生问题,更是对iOS可能整体清空缓存(如果用户几周没打开应用)的一种直接防护。iOS上的离线模式最好被设计成"给活跃用户的加分项",而不是一个承诺——而且必须在真机iPhone上测试,而不只是模拟器,因为部分WebClip行为模拟器根本不会复现。
还有一个容易被忽略的细节:从iOS 26开始,添加到主屏幕的站点默认就会以独立Web应用模式打开——没有Safari地址栏,而以前需要显式声明apple-mobile-web-app-capable才行。这对PWA给人"真正的应用"的感觉是个好消息,但同时也意味着,以前部分由浏览器chrome承担的返回导航和系统手势处理,现在要自己接过来。具体到地图上,就是全屏模式下的"返回"入口,以及在带刘海设备上正确处理safe-area-inset。
小结:上线前的检查清单#
在把一个应用称为"离线PWA"之前,值得过一遍这份简短清单:
- Leaflet以及任何会碰
window的库,只在被dynamic(..., { ssr: false })包裹的'use client'组件内部导入。 - 地图瓦片用
CacheFirst加上明确的expiration.maxEntries来缓存——没有上限的话缓存会不受控制地增长。 - 配置了
fallbacks.document,并且离线页面已经在DevTools的"Offline"模式下真正测试过,而不只是理论上假设可行。 - 核心用户流程不依赖服务器:状态存在Zustand/
localStorage里,而不是后端的会话中。 - 任何外部API(路由、海拔)在网络失败时都有明确的兜底方案,而不是一块空白屏幕。
- 行为已经在真机iPhone上验证过:缓存配额、WebClip的行为、Service Worker的生命周期,都和Android以及桌面版Chrome不一样。
RunCalculator Pro把这份清单上的每一项都走了一遍——不是作为教学案例,而是作为在runcalculator.pro上运行的真实产品:地图、离线模式、本地状态和GPX导出都不是假设,而是已经在为真实用户服务的东西。这个应用完全免费,不需要注册,核心流程也没有后端——这不是营销话术,而是上述架构的直接结果:当状态除了用户自己的浏览器之外无处可同步时,离线模式就不再是一个需要"加上去"的功能,而是从一开始就正确划分了客户端与服务器职责所带来的自然结果。



