1. 从一个很朴素的需求说起:为什么要把 AI 管家塞进手机主屏
事情的起点特别简单。我在电脑上搭了一套 AI 管家,平时用浏览器打开一个本地页面,输入问题、触发任务、看流式回复,用着挺顺手。但问题来了——我不可能一直坐在电脑前。做饭的时候想让它帮我查个东西,躺床上想让它记一笔待办,出门在外想让它跑个定时任务,每次都要开电脑、开浏览器、输地址,这个链路太长了。
我想要的其实就一件事:像打开微信一样,点一下主屏上的图标,AI 管家就开了。不需要应用商店,不需要开发者账号,不需要上架审核,就是一个能全屏运行、有独立图标、能接收流式输出的"准原生"应用。
这个需求听起来像是要写一个 iOS App,但实际上完全不用。iOS 的 Safari 有一个被很多人忽略的能力:"添加到主屏幕"。把一个网页加到主屏之后,它会以独立窗口的形式启动,没有地址栏、没有底部工具栏,看起来和原生应用几乎没区别。再配合 PWA 的 manifest 配置,还能自定义图标、启动画面、主题色。对于个人自用的 AI 管家来说,这就是性价比最高的方案。
但"加到主屏"只是最后一步。真正让我折腾了好几个晚上的,是中间那一堆问题:AI 管家的后端跑在电脑上,手机怎么访问?流式输出(SSE)在移动网络下老是断怎么办?本地开发用 HTTP,iOS 加到主屏后有些能力受限怎么办?多个服务端口怎么用一个域名统一入口?这些问题的答案,最后都指向了同一个东西——nginx 反向代理。
所以这篇东西不是一篇"PWA 入门教程",而是我把一个跑在本地电脑上的 AI 管家,一步步变成 iPhone 主屏上一个独立应用的完整过程。中间踩的坑、试错的方案、最后跑通的配置,我都会写清楚。如果你也有类似的需求——不管是 AI 管家、自建笔记、家庭监控面板还是别的什么自托管服务——这套思路可以直接抄。
关键词里出现的 iPhone、nginx、SSE、REST、iOS,基本覆盖了这条链路上所有的技术点。下面我按实际操作的顺序来拆。
2. 先想清楚架构:手机、电脑、nginx 三者到底怎么连
2.1 为什么不能手机直连电脑上的服务
最直觉的做法是:AI 管家跑在电脑的 8000 端口,手机浏览器直接输http://192.168.x.x:8000不就行了?局域网内确实能通,我一开始也是这么干的。但很快就遇到几个问题。
第一,端口太多。我的 AI 管家不是一个单体服务,它至少包含:一个前端页面(比如 3000 端口)、一个后端 API(比如 8000 端口)、可能还有一个向量库或者任务队列的管理界面(比如 6333 端口)。手机上加三个主屏图标?太丑了,而且每个都要记 IP 和端口。
第二,HTTP 的坑。iOS 对非 HTTPS 的页面有一些限制,尤其是涉及 Service Worker、剪贴板、部分传感器权限的时候。虽然"添加到主屏"本身不强制 HTTPS,但一旦你想用离线缓存、想让它更像原生应用,HTTP 就会成为绊脚石。
第三,流式输出不稳定。AI 管家的核心体验是 SSE(Server-Sent Events)流式返回,一个字一个字往外蹦。手机在 WiFi 和蜂窝之间切换、锁屏、切后台的时候,连接很容易断。如果前端直连后端,断线重连的逻辑要自己写,而且跨域、超时这些问题都会冒出来。
所以结论很明确:需要一个统一入口,把多个服务聚合到一个地址下,同时处理 HTTPS、超时、重连这些脏活。这个角色,nginx 来当最合适。
2.2 nginx 在这套架构里到底干了什么
很多人对 nginx 的理解停留在"装个服务器放静态文件",其实它在自托管场景里是个多面手。在我这套方案里,它承担了四件事:
- 反向代理:手机只访问
https://ai.home这一个地址,nginx 根据路径把请求转发到不同的后端服务。/api/转给 8000,/转给 3000,/vector/转给 6333。 - TLS 终止:nginx 持有证书,对外提供 HTTPS,对内还是 HTTP。后端服务不用改任何代码。
- SSE 长连接保持:这是最关键的一点。nginx 默认会对上游连接做缓冲和超时控制,SSE 流式输出会被它"攒着"一起发,或者干脆超时断开。必须专门配置
proxy_buffering off和proxy_read_timeout。 - 统一域名:配合本地 DNS 或者 hosts,让
ai.home这个域名指向 nginx,手机和电脑都能用同一个地址访问。
用一张表把职责理清楚:
| 组件 | 职责 | 对外暴露 |
|---|---|---|
| AI 管家前端 | 页面渲染、SSE 接收 | 否,仅内网端口 |
| AI 管家后端 | REST API、任务执行 | 否,仅内网端口 |
| nginx | 反向代理、TLS、SSE 调优 | 是,443 端口 |
| iPhone Safari | 访问入口、添加到主屏 | 客户端 |
2.3 域名怎么解决:本地 DNS 还是直接 IP
这里有个现实问题:手机要访问https://ai.home,得先能解析这个域名。有两个思路。
思路一:改路由器 DNS。在路由器上把ai.home解析到电脑的内网 IP。优点是全家设备都能用,缺点是很多家用路由器不支持自定义 DNS 记录,或者藏得很深。
思路二:手机装一个能改 hosts 的工具。iOS 上比较麻烦,需要描述文件或者特定 App。不推荐,太重了。
思路三:直接用 IP + 自签证书。手机访问https://192.168.x.x,nginx 配一个自签证书。缺点是每次都要点"继续访问",而且自签证书在"添加到主屏"后体验不好。
我最后选的是思路一的变体:在电脑上跑一个轻量 DNS 服务(比如 dnsmasq),把ai.home指向本机,然后路由器 DHCP 把这个 DNS 下发给手机。这样手机不用做任何配置,输ai.home就能通。如果你嫌麻烦,退而求其次用 IP 也行,只是证书那块要接受自签的警告。
提示:如果你只是自己用,不想折腾 DNS,完全可以用
http://电脑IP:端口先跑通,等功能都验证完了再上 HTTPS 和域名。不要一上来就追求完美架构,容易卡在证书上出不来。
3. nginx 配置实战:让 SSE 流式输出在手机上不断线
3.1 一个能跑的最小配置长什么样
先给一个我实际在用的 nginx 配置骨架,后面再逐条解释为什么这么写。
server { listen 443 ssl; server_name ai.home; ssl_certificate /etc/nginx/certs/ai.home.crt; ssl_certificate_key /etc/nginx/certs/ai.home.key; # 前端页面 location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 后端 API + SSE location /api/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # SSE 关键配置 proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; chunked_transfer_encoding on; } }这个配置看起来平平无奇,但每一行都有讲究。下面拆开说。
3.2 proxy_buffering off:SSE 断流的头号元凶
我一开始遇到的现象是:AI 管家在电脑浏览器上流式输出正常,一个字一个字蹦;但手机通过 nginx 访问,要么半天不出字,要么一次性全出来,要么直接报stream disconnected before completion: idle timeout waiting for sse。
排查了半天,问题就出在 nginx 的proxy_buffering。nginx 默认会把上游(后端)返回的数据先缓冲起来,攒到一定大小或者上游关闭连接了,才发给客户端。对于普通网页这是优化,对于 SSE 这是灾难——因为 SSE 的本质就是"后端持续不断地推小片段",被 nginx 一缓冲,流式就变成了批处理。
proxy_buffering off;就是告诉 nginx:别攒了,上游来多少你就转发多少。这一行加上之后,手机上的流式输出立刻就正常了。
但光关缓冲还不够。还有一个隐藏的坑:nginx 的 chunked 编码。SSE 依赖 HTTP 的 chunked transfer encoding,如果 nginx 把响应重新编码了,前端可能收不到正确的分块。所以配合chunked_transfer_encoding on;更稳妥。
3.3 proxy_read_timeout:为什么默认 60 秒会要命
nginx 的proxy_read_timeout默认是 60 秒。意思是:如果 60 秒内上游没有发任何数据过来,nginx 就认为连接超时,主动断开。
问题在于,AI 管家在"思考"的时候,可能几十秒都不吐一个字。比如你问一个复杂问题,后端在调用模型、检索向量库、组装上下文,这期间 SSE 连接是静默的。60 秒一到,nginx 咔嚓一刀,前端就收到那个经典的idle timeout waiting for sse。
解决办法很简单:把超时时间拉长。我设的是 3600 秒(1 小时),足够覆盖绝大多数场景。如果你担心资源占用,设个 600 秒也够用。关键是要大于你后端最长的静默时间。
proxy_read_timeout 3600s; proxy_send_timeout 3600s;proxy_send_timeout是发送给上游的超时,一般和 read 保持一致就行。
3.4 Connection 头与 HTTP/1.1:SSE 的协议前提
SSE 是基于 HTTP/1.1 的长连接。nginx 默认对上游用 HTTP/1.0,而且会带上Connection: close,这会导致每个请求都新建连接,SSE 根本没法保持。
所以必须显式声明:
proxy_http_version 1.1; proxy_set_header Connection "";Connection ""这个写法看起来有点怪,它的作用是清空Connection 头,让 nginx 使用 keep-alive 长连接。这是 nginx 官方推荐的做法,比写Connection keep-alive更可靠,因为后者在某些版本里会和上游的 keep-alive 机制冲突。
3.5 实测对比:改配置前后的差异
我把改配置前后的表现整理成表,你可以对照自己的现象:
| 现象 | 改之前 | 改之后 |
|---|---|---|
| 流式输出 | 攒一批一起出 | 逐字实时输出 |
| 长思考后 | 60 秒断开 | 持续保持 |
| 锁屏再回来 | 连接已死 | 自动重连成功 |
| 切 WiFi/蜂窝 | 报错 | 基本无感 |
| 控制台报错 | idle timeout | 无 |
这里要特别说一句:SSE 的断线重连,前端也要配合。浏览器原生的 EventSource 自带重连机制,但重连间隔是固定的 3 秒,而且不会带上次的上下文。如果你的 AI 管家需要"断点续传",得在后端记录会话状态,前端重连时带上 session id。这部分不是 nginx 能解决的,但 nginx 把连接稳定性提上去之后,重连的频率会大幅降低。
4. 把网页变成 iPhone 主屏应用:manifest 与图标那些事
4.1 "添加到主屏幕"到底做了什么
在 Safari 里打开https://ai.home,点分享按钮,选"添加到主屏幕",你会得到一个图标。点这个图标启动,页面会以独立窗口打开——没有地址栏,没有底部导航,状态栏颜色可以自定义。这就是所谓的"Web App"模式。
它和真正的原生 App 的区别在于:没有 App Store 分发、不能调用全部系统 API、后台能力有限。但对于一个自用的 AI 管家来说,这些限制都不影响核心体验。你需要的"打开就能用、全屏、有图标",它全给了。
关键是要配一个manifest.json,告诉 iOS 这个网页"应该被当成应用来对待"。
4.2 manifest.json 里哪些字段 iOS 真的认
网上很多 PWA 教程列了一大堆字段,但 iOS 的支持是出了名的"选择性"。我把实际生效的字段列出来:
{ "name": "AI 管家", "short_name": "管家", "start_url": "/", "display": "standalone", "background_color": "#0f0f0f", "theme_color": "#0f0f0f", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }几个要点:
- display: standalone是核心。它决定了启动时是否隐藏 Safari 的界面。设成
fullscreen在 iOS 上支持不好,standalone最稳。 - icons至少给 192 和 512 两个尺寸。iOS 会自己缩放,但给足尺寸能避免模糊。
- theme_color影响状态栏颜色,配合
apple-mobile-web-app-status-bar-style效果更好。 - start_url建议设成
/,如果你有登录态,可以带上参数。
除了 manifest,还要在 HTML 的<head>里加几个 iOS 专属的 meta 标签:
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="AI 管家"> <link rel="apple-touch-icon" href="/icons/icon-192.png">apple-mobile-web-app-capable是老版本的写法,新版本靠 manifest 的 display 字段,但为了兼容还是加上。apple-touch-icon是 iOS 加主屏时用的图标,优先级比 manifest 里的 icons 还高,一定要配。
4.3 图标设计的几个实操细节
图标这块我踩过坑。iOS 加主屏时,如果图标有透明背景,会被自动填充成黑色或者白色,看起来很奇怪。所以图标最好是满幅的、不透明的方形图,iOS 会自己加圆角。
尺寸方面,我建议直接做一张 1024x1024 的源图,然后用工具生成 192、512 两个版本。别用 SVG,iOS 对 SVG 图标的支持不稳定。
还有一个细节:图标缓存。你换了图标之后,删掉主屏上的旧图标,重新添加,有时候还是显示旧的。这是因为 Safari 缓存了。解决办法是改一下图标文件名,或者在 URL 后面加个版本号,比如icon-192-v2.png。
4.4 启动画面:让打开体验更像原生
iOS 在 Web App 启动时会显示一个白屏,直到页面加载完。这个白屏可以通过apple-touch-startup-image替换成自定义的启动图。
<link rel="apple-touch-startup-image" href="/splash/splash-1170x2532.png">麻烦的地方在于,iPhone 的屏幕尺寸太多了,要为每个尺寸准备一张图。如果你不想折腾,可以跳过这一步,白屏也就一两秒的事。但如果你的 AI 管家加载比较慢(比如要初始化模型),启动图能显著提升体验。
我自己的做法是:只给主力机型准备一张,其他机型接受白屏。毕竟这是自用工具,不是要上架的产品。
5. 本地开发环境的那些坑:多端口、多站点、HTTPS
5.1 为什么本地开发也要上 nginx
有人会问:我本地开发的时候,直接localhost:3000不就行了,为什么要套一层 nginx?
原因是开发环境和手机访问环境要一致。如果你本地用localhost:3000开发,手机用https://ai.home访问,两边的行为可能不一样——跨域策略、Cookie 作用域、SSE 超时、HTTPS 限制,这些差异会让你在手机上遇到一堆本地复现不了的问题。
所以我的做法是:本地开发也走 nginx。电脑上访问https://ai.home,手机上也访问https://ai.home,两边环境完全一致。这样任何问题都能在电脑上先复现、先调试。
5.2 多站点自定义域名怎么配
如果你不止一个自托管服务,比如还有笔记、监控面板、下载器,可以用 nginx 的 server 块给每个服务配一个域名:
server { listen 443 ssl; server_name ai.home; # ... AI 管家配置 } server { listen 443 ssl; server_name note.home; # ... 笔记服务配置 } server { listen 443 ssl; server_name monitor.home; # ... 监控面板配置 }然后在本地 DNS 里把这些域名都指向电脑 IP。这样手机主屏上可以放好几个图标,每个对应一个服务,互不干扰。
如果你不想搞这么多域名,也可以用路径区分:
location /ai/ { proxy_pass http://127.0.0.1:8000/; } location /note/ { proxy_pass http://127.0.0.1:9000/; }但路径区分有个坑:前端页面里的静态资源路径、API 请求路径都要相应调整,否则会 404。域名区分就没这个问题,每个服务都以为自己在根路径下。
5.3 自签证书怎么让 iOS 信任
HTTPS 是绕不开的。用 Let's Encrypt 需要公网域名,内网用不了。所以只能自签。
自签证书的流程:
- 用 openssl 生成 CA 根证书和服务器证书。
- 把服务器证书配到 nginx。
- 把 CA 根证书装到 iPhone 上,并在"设置 - 通用 - 关于本机 - 证书信任设置"里手动信任。
第三步是关键。很多人装了描述文件但忘了去"证书信任设置"里打开开关,结果还是报证书错误。
# 生成 CA 私钥和证书 openssl genrsa -out ca.key 2048 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=Home CA" # 生成服务器私钥和 CSR openssl genrsa -out ai.home.key 2048 openssl req -new -key ai.home.key -out ai.home.csr -subj "/CN=ai.home" # 用 CA 签发服务器证书 openssl x509 -req -in ai.home.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out ai.home.crt -days 3650 -sha256生成之后,把ca.crt通过 AirDrop 或者邮件发到 iPhone,安装描述文件,然后去信任设置里打开。
注意:自签证书的 CN 必须和你访问的域名一致。如果你用
ai.home访问,证书 CN 就得是ai.home,否则会报net::ERR_CERT_COMMON_NAME_INVALID。如果要用 IP 访问,CN 得是 IP,而且现代浏览器对 IP 证书更严格,建议还是用域名。
5.4 开发时的热重载怎么和 nginx 共存
前端开发一般都有热重载(HMR),它依赖 WebSocket。nginx 代理 WebSocket 需要额外配置:
location /ws { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }Upgrade和Connection这两个头是 WebSocket 握手的关键。配好之后,你在电脑上改代码,手机上的页面也能自动刷新,开发效率高很多。
6. 从 REST 到 SSE:AI 管家的通信层设计
6.1 为什么 AI 管家要用 SSE 而不是普通 REST
普通的 REST 接口是"请求-响应"模式:你发一个请求,服务器处理完,一次性返回结果。对于 AI 管家来说,这个模式有个致命问题——等待时间太长。
你问一个问题,后端要调用模型、生成回答,可能需要 10 秒、30 秒甚至更久。如果用 REST,前端发完请求就只能干等,用户看到的是一个转圈的加载图标,体验很差。
SSE 解决的就是这个问题。它让后端可以边生成边推送,前端收到一个字就显示一个字。用户看到的是文字在"打字机"式地出现,感知上的等待时间大幅缩短。
SSE 和 WebSocket 的区别也值得说一下。WebSocket 是双向的,SSE 是单向的(服务器到客户端)。AI 管家的场景里,客户端只需要发一次请求,然后接收流式回复,不需要双向通信。所以 SSE 更轻量、更简单,而且基于 HTTP,天然兼容 nginx 和各种代理。
6.2 SSE 在移动网络下的特殊挑战
手机上的 SSE 比电脑上脆弱得多,原因有几个:
- 网络切换:WiFi 和蜂窝之间切换时,TCP 连接会断。
- 锁屏休眠:iOS 锁屏后,Safari 可能会挂起页面,SSE 连接被冻结。
- 后台限制:切到其他 App 后,Safari 的后台执行时间有限。
- 运营商中间设备:某些网络设备会对长连接做超时清理。
这些挑战里,nginx 能解决一部分(超时、缓冲),前端能解决一部分(重连、状态恢复),后端能解决一部分(会话保持、断点续传)。三者配合才能做到"基本无感"。
6.3 前端 EventSource 的重连策略
浏览器原生的EventSource自带重连,但它的行为是:连接断开后等 3 秒,然后重连,不带任何上下文。对于 AI 管家来说,这意味着重连后你收不到之前没传完的内容。
我的做法是:后端为每个会话维护一个消息队列。前端重连时带上session_id,后端从队列里把没发完的部分继续推。这样即使断了,用户也不会丢内容。
const es = new EventSource(`/api/chat/stream?session_id=${sessionId}`); es.onmessage = (event) => { const data = JSON.parse(event.data); appendToChat(data.content); }; es.onerror = () => { // EventSource 会自动重连,这里只做日志 console.warn('SSE 连接中断,等待自动重连'); };如果你需要更精细的控制,可以不用 EventSource,改用fetch+ReadableStream手动解析 SSE。这样能自定义重连逻辑、超时时间、错误处理。但代码复杂度会高不少,看你的需求。
6.4 后端如何优雅地处理客户端断开
后端在推 SSE 的时候,如果客户端断开了,继续推是浪费资源。Node.js 里可以监听req.on('close'),Python 里可以捕获ConnectionResetError,及时停止生成。
// Node.js 示例 app.get('/api/chat/stream', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); let aborted = false; req.on('close', () => { aborted = true; // 停止模型生成,释放资源 }); const stream = generateResponse(); stream.on('data', (chunk) => { if (aborted) return; res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`); }); stream.on('end', () => { res.write('data: [DONE]\n\n'); res.end(); }); });这里有个细节:SSE 的消息格式必须是data: xxx\n\n,两个换行表示消息结束。少一个换行,前端就解析不出来。这个坑我踩过,排查了半天才发现是格式问题。
7. 装进主屏之后:那些只有真机才暴露的问题
7.1 状态栏颜色和刘海遮挡
加到主屏之后,页面是全屏的,但 iPhone 顶部有刘海、底部有 Home Indicator。如果你的页面内容顶到最上面,会被刘海挡住。
解决办法是用env(safe-area-inset-*):
body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }配合viewport-fit=cover:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">这样内容会自动避开刘海和底部横条。状态栏颜色则通过apple-mobile-web-app-status-bar-style控制,black-translucent会让状态栏透明,页面内容延伸到状态栏下面,视觉效果最好。
7.2 输入框被键盘顶飞
手机上输入文字时,键盘弹出来会占据半屏。如果你的输入框在页面底部,键盘一弹,输入框就被顶到看不见的地方了。
这个问题在 Web App 模式下尤其明显,因为 Safari 的地址栏没了,视口高度计算和普通浏览器不一样。我的处理方式是:输入框固定在底部,键盘弹出时用 JS 监听视口变化,动态调整布局。
window.visualViewport.addEventListener('resize', () => { const offset = window.innerHeight - window.visualViewport.height; inputBar.style.transform = `translateY(-${offset}px)`; });visualViewport是专门为这种场景设计的 API,比监听resize事件准确得多。
7.3 添加到主屏后 Cookie 和登录态丢失
这是个很隐蔽的坑。Safari 里的登录态,加到主屏后打开,可能就没了。原因是 Web App 模式下的 Cookie 作用域和 Safari 不完全一样。
我的解决办法是:用 localStorage 存 token,而不是依赖 Cookie。前端每次请求带上Authorization头,后端从 header 里读 token。这样不管在 Safari 还是 Web App 模式下,登录态都能保持。
如果你非要用 Cookie,记得设置SameSite=None; Secure,并且确保域名一致。
7.4 后台切换后的状态恢复
iOS 对后台页面的限制很严。你切到微信回个消息,再切回 AI 管家,页面可能已经被"冻结"了,SSE 连接也断了。
我的处理是:监听visibilitychange事件,页面重新可见时,检查 SSE 连接状态,如果断了就重连,并拉取最新的会话状态。
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { checkAndReconnectSSE(); syncLatestMessages(); } });这样用户切回来的时候,看到的是最新的内容,而不是一个卡住的旧页面。
8. 一些让体验更顺滑的细节优化
8.1 离线兜底:没网的时候显示什么
Web App 模式下,如果没网,Safari 会显示一个默认的错误页,很难看。可以用 Service Worker 缓存一个离线页面,没网的时候显示"当前无网络,请检查连接"。
// service-worker.js self.addEventListener('fetch', (event) => { event.respondWith( fetch(event.request).catch(() => { return caches.match('/offline.html'); }) ); });Service Worker 在 HTTPS 下才能用,这也是前面强调要上 HTTPS 的原因之一。
8.2 触觉反馈:让点击有"实体感"
iOS 的 Safari 支持navigator.vibrate,但支持度一般。更可靠的方式是用 CSS 的:active状态做视觉反馈,配合-webkit-tap-highlight-color去掉默认的灰色高亮。
button { -webkit-tap-highlight-color: transparent; transition: transform 0.1s; } button:active { transform: scale(0.97); }这个小小的缩放效果,能让用户感觉按钮"被按下去"了,体验上更接近原生。
8.3 防止误触缩放和滚动穿透
Web App 里,双击缩放和滚动穿透是两个常见的烦人问题。双击缩放可以用touch-action: manipulation禁用,滚动穿透可以在弹窗打开时给 body 加overflow: hidden。
body { touch-action: manipulation; overscroll-behavior: none; }overscroll-behavior: none能防止页面在顶部或底部继续滚动时"带动"整个页面,这在聊天界面里特别有用。
8.4 性能:首屏加载和内存占用
AI 管家的前端如果是个 SPA,首屏加载可能比较慢。我的优化思路是:
- 代码分割:把聊天界面、设置界面拆成独立的 chunk,按需加载。
- 预加载关键资源:manifest、图标、字体这些用
<link rel="preload">提前加载。 - 避免内存泄漏:SSE 连接、定时器、事件监听在页面卸载时清理干净。
手机的内存比电脑紧张,长时间运行后如果内存泄漏,页面会变卡甚至崩溃。我一般会在开发阶段用 Safari 的 Web Inspector 连真机调试,看内存曲线。
9. 我踩过的几个印象深刻的坑
9.1 nginx 的 location 匹配顺序把我坑了
nginx 的location匹配有优先级规则:精确匹配=最高,其次是前缀匹配^~,然后是正则匹配~,最后是普通前缀匹配。我一开始写了个location /api/和一个正则location ~ \.php$,结果 API 请求被正则截胡了。
排查这个问题的方法是:在 nginx 配置里加add_header X-Debug-Location "api";,看响应头里到底是哪个 location 处理的。这个技巧很实用,推荐记住。
9.2 SSE 的data:后面少了个空格
SSE 的格式规范是data: 内容\n\n,冒号后面必须有一个空格。我一开始写成data:内容\n\n,前端死活解析不出来。这个坑很隐蔽,因为有些解析器容错,有些不容错。规范写法是带空格的,别省。
9.3 自签证书的 SAN 字段
现代浏览器(包括 iOS Safari)要求证书必须有SAN(Subject Alternative Name)字段,光有 CN 不够。生成证书的时候要加上:
openssl req -new -key ai.home.key -out ai.home.csr -subj "/CN=ai.home" \ -addext "subjectAltName=DNS:ai.home"少了这个,iOS 会报证书无效。这个坑我卡了整整一个晚上。
9.4 手机和电脑不在同一个网段
有次我在外面用手机连家里的服务,怎么都连不上。后来发现手机连的是访客 WiFi,和电脑不在同一个网段,自然访问不了。如果你需要外网访问,那是另一个话题了(涉及端口转发或者内网穿透),但要注意安全,别把服务直接暴露在公网上。
10. 写在最后:这套方案还能怎么扩展
这套"nginx + PWA + SSE"的组合,本质上是一个自托管服务的移动化方案。AI 管家只是其中一个应用场景,同样的思路可以套到很多地方:
- 自建笔记:加到主屏,随时记录,SSE 同步。
- 家庭监控面板:实时推送传感器数据,SSE 天然适合。
- 下载器管理:手机上看进度、加任务。
- 自动化脚本面板:一键触发常用任务。
扩展的方向也有几个:
- 多设备同步:用同一个 nginx 入口,电脑、手机、平板都能访问,会话状态通过后端同步。
- 推送通知:iOS 的 Web Push 支持在逐步完善,未来可以让 AI 管家主动推送提醒。
- 语音输入:Web Speech API 在 Safari 上可用,配合 AI 管家能实现语音对话。
我个人在实际操作中的体会是:不要追求一步到位。先把最基本的"手机能访问"跑通,再加 nginx,再上 HTTPS,再优化 SSE,最后加到主屏。每一步都验证通过再往下走,比一次性搭一个复杂架构然后到处 debug 要高效得多。
最后分享一个小技巧:如果你在调试 SSE 的时候不确定是 nginx 的问题还是后端的问题,可以先用curl -N https://ai.home/api/chat/stream直接看原始输出。-N参数会禁用 curl 的缓冲,让你看到实时的流。如果 curl 能看到流式输出,但浏览器看不到,那问题就在前端;如果 curl 也看不到,那问题就在 nginx 或后端。这个排查方法帮我省了很多时间。