PWA 原生体验细节
用 Web 技术做原生体验,差距往往不出在大功能上,反而出在一串很碎的小地方:标题栏有没有多余文字,滚动到顶时会不会漏白,鼠标指针像不像 App,文字是不是到处都能选中,图标装到不同系统后会不会糊,路由切换是不是像网页后退栈,桌面标题栏有没有把可用空间吃掉。
这些问题单看都不大,叠在一起就很像“网页套壳感”。这一篇不讲 PWA 基础,只把几条最常见、最容易补的细节拆开说。
两类官方资料的分工
PWA standalone / display-mode / manifest / icons
文档:https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Create_a_standalone_app 文档:https://developer.mozilla.org/en-US/docs/Web/CSS/@media/display-mode 文档:https://developer.mozilla.org/en-US/docs/Web/Manifest 文档:https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Define_app_icons 文档:https://developer.mozilla.org/docs/Web/Progressive_web_apps/Manifest/Reference/icons
这一组主要解决安装形态、专属样式和图标问题。
window controls overlay / router.replace
文档:https://web.dev/articles/window-controls-overlay?hl=en 文档:https://developer.mozilla.org/en-US/docs/Web/API/Window_Controls_Overlay_API 文档:https://nextjs.org/docs/app/api-reference/functions/use-router
这一组更偏桌面体验和路由手感。
1. PWA standalone 模式下,把标题收干净
空标题 / 简标题
文档:https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Create_a_standalone_app
很多 PWA 安装到桌面以后,第一眼还像网页,常见原因就是窗口标题栏还挂着一串网页标题。尤其是把站点名、栏目名、文章名全拼在一起时,桌面窗口会立刻有浏览器味。
作用
- 减少标题栏噪音
- 让桌面窗口标题更干净
- 给后面的 window controls overlay 留出更干净的空间
代码位置
常见位置有两个:
- HTML 的
<title> - 框架的页面级 head / metadata 配置
如果你的应用在 standalone 下根本不需要显示页面标题,可以在运行时只保留一个很短的 app 名,或者干脆把动态页面标题收掉。
例如在普通 HTML 里,可以在 standalone 时改写标题:
<script>
if (window.matchMedia('(display-mode: standalone)').matches) {
document.title = ''
}
</script>如果你不想完全留空,更稳一点的做法是只保留应用名:
<script>
if (window.matchMedia('(display-mode: standalone)').matches) {
document.title = 'Ain'
}
</script>兼容性注意
- 这类处理只在 已安装 且以 standalone 打开时才有意义。
- MDN 的说明里也强调了:manifest 里的 display 只对安装后的应用生效,普通浏览器标签页不会吃这套逻辑。
- 完全空标题在不同桌面系统上的表现可能不同,实际项目里通常“短标题”比“真空标题”更稳。
2. 用 display-mode 写 PWA 专属样式
display-mode
文档:https://developer.mozilla.org/en-US/docs/Web/CSS/@media/display-mode 文档:https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Create_a_standalone_app
如果你想让安装后的 PWA 和浏览器标签页长得不一样,最直接的入口就是 display-mode。这是官方给的专门开关。
作用
- 给安装后的 PWA 单独加样式
- 区分 browser / standalone / fullscreen / window-controls-overlay 等显示形态
- 把“浏览器里需要的 UI”和“App 里需要的 UI”分开
代码位置
通常放在全局样式文件里,比如:
app.cssglobal.csstheme.css
示例:
@media (display-mode: standalone) {
.browser-only-header {
display: none;
}
.app-shell {
padding-top: 0;
}
}
@media (display-mode: browser) {
.standalone-only-tabbar {
display: none;
}
}如果你要在 JavaScript 里判断,也可以:
const isStandalone = window.matchMedia('(display-mode: standalone)').matches兼容性注意
- MDN 明确写了,这个媒体特性可以判断当前顶层上下文的 display mode。
- 你在 manifest 里写了
display: "standalone",最终实际生效的 mode 仍然可能受浏览器支持情况影响。 - 桌面安装、移动端添加到主屏幕、普通浏览器标签页,三者不一定表现一致,最好分别测一遍。
3. 取消滚动溢出,别让页面顶部漏出一条缝
很多 Web 应用一滚到最顶,尤其在移动端或套壳环境里,会出现“继续下拉露出背景色”或者顶部弹性回弹的感觉。这种细节会把页面往浏览器手感那边拉。
作用
- 减少顶部/底部回弹带来的割裂感
- 避免外层背景漏白
- 让独立窗口或全屏容器更稳定
代码位置
通常放在全局样式和根容器样式里:
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}
.app-scroll {
height: 100%;
overflow-y: auto;
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
}如果你是 App Shell 布局,常见做法是:
body不滚- 内容区单独滚
兼容性注意
overscroll-behavior在现代浏览器支持已经比较好,但不同 WebView 和系统手势环境下,体验仍可能有差异。- 全局直接
overflow: hidden时,要确认弹窗、长列表、抽屉组件还有自己的滚动容器,不然会把内容锁死。 - iOS 系统的橡皮筋回弹感不一定能被完全消掉,通常只能尽量收敛。
4. 鼠标指针设成默认 default
桌面 PWA 很容易露馅的地方,还有一项是指针。很多地方明明只是信息展示区,却保留了网页上常见的文本选择光标、奇怪的 hover 状态,整个壳立刻像网站。
作用
- 让非输入区域少一点网页味
- 减少“这里是一整页网页”的暗示
- 让交互层级更清楚
代码位置
建议从全局基础层开始收:
body,
button,
[role='button'],
.nav-item,
.card,
.toolbar {
cursor: default;
}
input,
textarea,
[contenteditable='true'] {
cursor: text;
}
a,
button,
[role='button'] {
cursor: pointer;
}实际项目里不要一刀切全设成 default,而是把:
- 普通展示区域设成
default - 可点击控件保留
pointer - 可输入区域保留
text
兼容性注意
- 这条主要影响桌面环境,移动端几乎感觉不到。
- 如果你把链接和按钮也都设成
default,会把可点击反馈一起抹掉,适得其反。
5. 禁止用户随手选中文本
“禁止用户选择”,这条在工具型 PWA 里非常常见。原生 App 里的标题、导航、工具栏、卡片,通常不会让你随手拖蓝。
作用
- 减少“网页文本被选中”的感觉
- 避免桌面拖拽时误选标题和菜单
- 让导航栏、工具栏、卡片少一点网页手感
代码位置
通常只对非正文区做限制:
.app-chrome,
.sidebar,
.tabbar,
.toolbar,
.nav-item,
.button-like {
user-select: none;
-webkit-user-select: none;
}正文区、输入框和代码区一般不要关掉:
article,
input,
textarea,
pre,
code {
user-select: text;
-webkit-user-select: text;
}兼容性注意
- 不建议全站一把梭
user-select: none,那会把复制、搜索、调试体验一起弄坏。 - 这条更适用于导航、工具栏、标签栏、标题区,不适合文章页和文档页。
6. 不同系统分别准备应用图标
manifest 图标与多系统图标
文档:https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/How_to/Define_app_icons 文档:https://developer.mozilla.org/docs/Web/Progressive_web_apps/Manifest/Reference/icons 文档:https://web.dev/add-manifest/ 文档:https://web.dev/maskable-icon/
这部分最容易被低估。浏览器标签页图标能看,安装到 Android、Windows、macOS、iOS 之后未必还能看。不同平台对图标裁切、圆角、遮罩、尺寸都有自己的习惯。
作用
- 安装后的图标不糊、不被裁歪
- 适配不同平台的桌面、任务栏、主屏幕和启动器
- 减少“一看就是随便拿 favicon 顶上去”的感觉
代码位置
第一层是 manifest:
{
"name": "Ain",
"short_name": "Ain",
"display": "standalone",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}第二层是页面 head,给 iOS 这类场景补 apple-touch-icon:
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png" />多系统要注意什么
- Android:优先准备
maskable图标,不然在自适应图标蒙版里容易被裁掉。 - Windows:manifest 里的多尺寸图标尽量准备齐,任务栏和开始菜单看起来会更稳。
- macOS / 桌面安装:图标会直接影响 dock 和窗口入口观感,别只交一个低分辨率 favicon。
- iOS:很多时候更依赖
apple-touch-icon,不能只指望 manifest。
兼容性注意
- MDN 的
icons文档明确建议声明多个图标文件和尺寸。 - web.dev 的 maskable icon 文档强调了 Android adaptive icons 的裁切问题,这一条实际影响很大。
- iOS 对 Web App Manifest 的支持一直不算完整,所以
apple-touch-icon这类补充项仍然值得保留。
![]()
7. 平级导航里,router.replace 往往比 push 更顺
router.replace
文档:https://nextjs.org/docs/app/api-reference/functions/use-router
很多 Web 应用切页面时有很强的网页味,一个原因就是路由历史栈太像浏览网页。点一次 tab、开一次筛选、切一次内部视图,结果全都进了历史栈,用户一按返回就像在倒带网页轨迹。
作用
- 不把“中间态页面”都塞进 history stack
- 减少桌面/移动端返回时的网页味
- 让 tab 切换、列表筛选、内部状态切换更接近日常应用里的返回逻辑
代码位置
如果你用 Next.js App Router,常见位置是:
- 顶部 tab 导航
- 左侧导航
- 列表筛选和 query 参数切换
- onboarding / 登录完成后的落地跳转
示例:
'use client'
import { useRouter } from 'next/navigation'
export function AppTabBar() {
const router = useRouter()
return (
<nav>
<button onClick={() => router.replace('/inbox')}>收件箱</button>
<button onClick={() => router.replace('/calendar')}>日历</button>
<button onClick={() => router.replace('/settings')}>设置</button>
</nav>
)
}如果你还想切换时别自动滚到顶部,可以把 scroll 一起传进去:
router.replace('/feed?tab=following', { scroll: false })兼容性注意
- Next.js 官方文档写得很明确:
router.push()会新增一条 history entry,router.replace()不会。 - 这条适合用在“平级导航”和“状态切换”;如果用户确实需要回到上一步内容,还是要保留
push。 - 别把所有跳转都换成
replace。详情页、编辑页、支付流这类需要明确返回路径的页面,通常还是push更合理。
8. 桌面端别浪费标题栏:用 window controls overlay
window controls overlay
文档:https://web.dev/articles/window-controls-overlay?hl=en 文档:https://developer.mozilla.org/en-US/docs/Web/API/Window_Controls_Overlay_API 文档:https://developer.mozilla.org/docs/Web/Progressive_web_apps/Manifest/Reference/display_override
这条是最像“原生桌面 App”的细节。普通桌面 PWA 安装后,标题栏上面那一条区域经常空着,只留几个系统按钮。Window Controls Overlay 的思路就是把默认标题栏藏掉,让内容延伸进去,把关闭、最小化、最大化按钮变成 overlay。
web.dev 那篇《Customize the window controls overlay of your PWA's title bar》就是这一条的核心参考。
作用
- 回收桌面标题栏空间
- 让工具栏、搜索栏、标签栏更接近桌面应用
- 在安装后的 PWA 窗口里减少浪费的顶部高度
代码位置
第一步先在 manifest 里声明 display_override:
{
"display": "standalone",
"display_override": [
"window-controls-overlay"
]
}第二步在样式里按 display mode 单独处理:
@media (display-mode: window-controls-overlay) {
.titlebar {
padding-left: env(titlebar-area-x, 0);
padding-top: env(titlebar-area-y, 0);
padding-right: env(titlebar-area-width, 0);
height: env(titlebar-area-height, 48px);
}
}第三步做 feature detection:
const hasWco = 'windowControlsOverlay' in navigator
const isWco = window.matchMedia('(display-mode: window-controls-overlay)').matches如果你要在几何变化时更新布局,还可以监听:
if ('windowControlsOverlay' in navigator) {
navigator.windowControlsOverlay.addEventListener('geometrychange', () => {
document.documentElement.dataset.wco = 'on'
})
}兼容性注意
- MDN 明确标了 Experimental,而且不是 Baseline,生产环境一定要做特性检测和回退。
- 这条只对 桌面端已安装的 PWA 有意义,普通标签页和大多数移动端根本不会进入这个模式。
- 你即便在 manifest 里声明了
window-controls-overlay,也要靠display_override的回退链处理不支持的浏览器。 - 落地时,至少要准备:
- 支持 WCO:标题栏吃进去
- 不支持 WCO:退回普通 standalone

怎么把这几条落到项目里
如果你正在做一个现成项目,最省事的落地顺序通常是:
- 补 manifest、图标和
display。 - 写
@media (display-mode: standalone)的一层全局样式。 - 把滚动、指针、
user-select这三条放进 App Shell 和导航容器。 - 把 tab / 侧栏 / 筛选这种平级导航,逐个检查是否该从
push改成replace。 - 看桌面端有没有必要上
window-controls-overlay。
影响观感的,往往就是这些边角。前面几条如果都没收,用户一打开就会觉得“这是网页”。这些地方一条条收紧后,PWA 的原生感会明显上来。