本节摘要:应用清单是浏览器理解"这个网站想成为怎样的应用"的唯一结构化信源——它不参与逻辑运算,却是安装流程的触发器、界面渲染的蓝图、跨平台适配的元数据中枢。本节讲字段语义、安装判定的启发式条件、外壳模型,以及动态生成清单等反模式。(素材映射:原 2.2 节全部内容。)
阅读完本节,你应当能够:
先写清单,再谈原理。在页面头部的头部区域声明链接,指向一份结构化文档:
{ "name": "星尘书屋", "short_name": "星尘", "description": "专注人文社科的深度阅读平台", "start_url": "/?utm_source=pwa", "scope": "/", "id": "/", "display": "standalone", "orientation": "portrait", "background_color": "#f5f5f5", "theme_color": "#333333", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } ] }
部署到 HTTPS 环境后,打开开发者工具的应用面板,清单标签页无报错,且存在已激活的 Service Worker——满足这些条件后,浏览器开始酝酿一次安装机会。这份十几行的文档就是应用的"数字身份证"。
它的本质值得先澄清:清单是一份纯前端声明式契约,由开发者提供、浏览器动态解析、用户行为间接激活。它不注册监听器、不调用接口、不修改文档对象——它的全部价值在于把离散的界面与安装语义,映射为浏览器可识别、操作系统可接纳、用户可感知的标准化信号。即使清单缺失,网页仍可正常访问;一旦它存在且字段齐备,安装的种子便埋下了。
清单回答三个根本问题。你是谁——名称、短名称、图标定义身份标识。你希望如何被使用——显示模式、方向、主题色描述交互范式。你承诺怎样的初始体验——启动地址与作用域界定入口契约与边界。
几个关键字段的深层含义常被忽略。启动地址与作用域共同划定导航边界:作用域为根路径表示清单控制整个域名;启动地址带上来源参数,则每次从主屏启动都可被统计分析追踪。显示模式为独立窗口,是向操作系统发出的明确声明:请把我作为独立应用运行,隐藏地址栏与导航控件。方向字段并非强制锁定,而是向系统申请优先布局,系统可依设备能力协商调整。图标数组中的每个条目,都是向不同平台提交的适配规格——系统据此生成启动器图标、任务视图缩略图乃至桌面快捷方式。
规范演进中有个值得注意的细节:2021 年引入的标识字段,解决了"同一应用多实例"问题。用户从不同路径安装同一应用时,若无显式标识,部分浏览器会视为两个独立应用,导致图标重复、通知隔离、存储隔离。标识字段让不同入口路径归并到同一应用实体下——这已超越静态元数据,进入应用生命周期管理的语义层。此外,描述字段在即时应用等分发场景中已成关键审核项,分类字段虽暂未用于界面渲染,却已被部分应用商店用于分类索引。清单正从浏览器专属契约,演变为跨渠道分发的事实元数据标准。
清单的存在本身不触发安装,它只让安装成为可能。真正的安装提示,是浏览器基于清单完整性、用户行为模式与运行时环境综合判定的结果。以 Chrome 为例,触发安装提示需同时满足一组条件:清单有效且返回正常状态码;名称与短名称齐备且长度合理;图标数组至少包含一个尺寸不小于 192 像素的位图;页面通过 HTTPS 服务(本地环境例外);用户已停留足够时间并发生至少两次导航;近期未拒绝过该站点的安装提示;且页面已注册有效的 Service Worker。
最后一条最容易被忽略,也最深刻:清单与 Service Worker 构成安装的"双因子认证"——清单声明"我想成为应用",Service Worker 证明"我有能力离线运行"。缺其一,浏览器都认为该站点尚未准备好承担应用职责。
浏览器也不会贸然弹出模态框。现代安装提示采用渐进式引导:首次满足条件时只在地址栏右侧显示一个加号小图标,用户点击后才展示完整横幅;再次忽略则需更长的等待或更多交互后才重新出现。这种克制源于对"安装疲劳"的反思——有开发者工具厂商的调研显示,超过六成用户曾因频繁突兀的安装弹窗对站点产生负面印象,其中四成直接关闭了标签页。
对产品团队的启示是把握提示时机:电商用户刚完成商品筛选、新闻用户读完首篇文章、工具用户刚保存第一个文档时弹出提示,不是打扰,而是顺势交付一个更专注的使用通道。
如果说清单是应用的出生证明,外壳模型就是它的成长纲领。传统单页应用把全部界面框架与业务内容混合渲染,每次跳转都要重新下载解析执行大量脚本,首屏白屏时间长、离线即瘫痪。外壳模型主张把界面骨架与动态内容彻底分离:
清单通过启动地址与作用域为这套模型提供运行时契约保障。启动地址必须指向能立即渲染完整外壳的页面——若返回四零四或重定向链过长,安装失败或体验断裂。作用域定义管辖范围:范围内的跳转在应用上下文中完成、复用外壳状态;范围外部的链接必然跳出至外部浏览器。
显示模式与外壳深度协同:独立窗口模式隐藏地址栏后,外壳必须自行提供返回、刷新、分享等导航控件,否则用户将陷入"无路可退"的困境;极简界面模式保留最小控制栏,外壳可选择性地把分享按钮映射到系统分享接口;全屏模式则需主动处理方向锁定、键盘遮挡等极端场景。
⚠️ 常见坑一:动态生成清单。为支持多语言多租户,用服务端模板渲染清单、地址带查询参数、响应头禁止缓存——这严重违反清单的静态资源本质。主流浏览器要求清单可被 HTTP 缓存(建议至少二十四小时)且禁止重定向,动态清单会导致图标无法预加载、安装提示永不触发。
⚠️ 常见坑二:图标不可达。图标数组中任一地址返回四零四、超时或跨域错误,整个图标集被浏览器弃用,回退到页面元信息图标或默认站点图标;启动地址若重定向到非同源域,安装直接被阻止。清单因此成为前端资源治理的宪法性文件——它迫使你为图标、启动屏、核心页面建立零失效的静态资源发布流程。
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 动态渲染清单 | 安装提示永不触发 | 静态文件加长缓存 |
| 图标地址不可达 | 整组图标被弃用 | 发布流水线校验资源可达 |
| 启动地址指向重定向 | 安装失败 | 指向可直出的外壳页 |
| 独立窗口却无返回控件 | 用户被困在页面内 | 外壳自带导航组件 |
💡 关键直觉:清单不是给开发者看的,而是给操作系统集成层解析的。它让 Web 应用第一次具备了与原生应用平权的"系统公民身份"——写清单时多花的每一分钟,都在为安装后的每一次启动买单。
清单写完只是第一步,界面适配的细节决定了安装后的"原生感"成色。这份清单值得在上线前逐项核对。图标要给全:除了常规尺寸,务必提供带可遮罩用途的版本——安卓的自适应图标会裁切边缘,没留安全区的图标会被切掉关键内容。主题色要测深浅两种模式:主题色会染到地址栏、任务栏、状态栏,深色模式下对比度不足会让界面看起来"脏"。启动屏是拼出来的:系统用背景色、主题色、图标、名称自动合成启动画面——背景色与页面初渲染背景不一致时,启动到页面的瞬间会闪一下,这个闪动是卸载率的无声杀手。独立窗口要自带导航:隐藏地址栏后用户没有"后退"可用,外壳必须提供返回、关闭、分享控件,否则深层页面就是死胡同。方向声明要与布局匹配:声明竖屏就意味着你的横向布局可以不做完整适配,反之亦然——声明了却没适配,比不声明更糟。
再补一个进阶话题:清单的国际化。名称与描述需要多语言时,正确做法是按语言提供多份静态清单文件、页面按语言声明对应的链接——而不是服务端动态渲染一份。这与前文的反模式一脉相承:清单的世界里,静态是美德,动态是负债。
图标声明不完整是安装率低的第一诱因。purpose 字段决定系统如何着色,两档都要给:
{ "icons": [ { "src": "/icons/any-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "shortcuts": [ { "name": "新建笔记", "url": "/notes/new", "icons": [{ "src": "/icons/new-96.png", "sizes": "96x96" }] } ] }
装完后用一段脚本核对浏览器实际读到了什么,比肉眼查字段可靠:
const link = document.querySelector('link[rel="manifest"]'); const data = await fetch(link.href).then(r => r.json()); const required = ['name', 'short_name', 'start_url', 'display', 'icons']; const missing = required.filter(k => !data[k]); if (missing.length) console.warn('清单缺失字段:', missing); console.log('图标尺寸:', data.icons.map(i => i.sizes).join(', '));
身份有了,基座还得够硬。下一节讲 HTTPS:为什么它是 PWA 的存在性公理,以及证书、兼容性与安全头的工程实践。