webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化
webman 生产部署实战：进程守护、平滑发布、监控告警与微服务化把 webman 写出来是一回事，让它安稳跑在生产上是另一回事。没有 PHP-FPM 和 supervisord 的成熟套路兜底，常驻内存服务的部署、发布、监控都有一套自己的打法。这篇是 webman 系列收官：从部署形态、平滑发布到监控告警体系，最后聊聊什么时候该拆微服务。一、部署形态：三种选择1. 裸机/虚拟机 + systemd（中小项目首选）# /etc/systemd/system/webman.service [Unit] Description=webman app After=network.target mysql.service redis.service [Service] Type=forking WorkingDirectory=/var/www/app ExecStart=/usr/bin/php start.php start -d ExecReload=/usr/bin/php start.php reload ExecStop=/usr/bin/php start.php stop Restart=always RestartSec=3 [Install] WantedBy=multi-user.targetsystemctl daemon-reload systemctl enable --now webman systemctl reload webman # 平滑发布用 reload要点：Type=forking（start.php -d 是 daemonize 模式）、Restart=always 兜底进程崩溃、ExecReload 挂 reload 实现无感发布。2. Docker 容器化FROM php:8.2-cli-alpine RUN apk add --no-cache libpq libzip-dev && docker-php-ext-install pdo_mysql opcache COPY . /app WORKDIR /app RUN composer install --no-dev --optimize-autoloader EXPOSE 8787 # 容器内前台运行（容器本身就是守护者，不需要 -d） CMD ["php", "start.php", "start"]容器化要点：容器里必须前台运行（去掉 -d），让 PID 1 就是 master 进程，容器崩溃重启策略（restart: always）接管守护职责。K8s 场景用 readiness 探针指向健康检查接口。3. Supervisor 托管（多语言混合部署环境）老牌方案，配置直观，适合还有 Python/Go 混跑的机器。二、平滑发布：让 reload 真正"无感"php start.php reload 的机制是逐个重启 worker（上一篇讲过），但要真正做到无感发布，还有四个坑要填：坑一：reload 不加载的东西reload 只重新加载 worker，master 进程内存里的东西不会更新：路由配置文件、config/ 目录、bootstrap 钩子。改了这些必须 restart（master 重建，有感知）。发布脚本要区分：#!/bin/bash # 简易发布脚本 git pull composer install --no-dev --optimize-autoloader php start.php reload # 常规发布 # 改了 config/route/bootstrap 时：php start.php restart坑二：发布瞬间的连接抖动worker 逐个重启期间，正在处理中的请求要等它完成（超过 max_wait 会被强杀）。发布时把 max_wait_time 临时调大、避开高峰期，长任务（导出、批处理）走独立进程不随 worker 重启。坑三：静态文件缓存webman 自带静态文件支持时，浏览器缓存的旧 JS 配合新 HTML 可能白屏——资源文件名带 hash（app.a3f8c2.js）彻底解决。坑四：发布失败的回滚# 用软链接做版本切换，回滚 = 切回旧链接 ln -sfn /var/www/releases/20260823-0200 /var/www/app_current php start.php restart # 软链指向变了，restart 生效保留最近 5 个版本目录，回滚 10 秒完成。没有回滚方案的发布都是赌博。三、监控告警体系：四个层次常驻内存服务必须主动监控——它出问题的方式是"安静地僵死"，不是崩溃报错。层次一：进程存活（最基础）# 心跳脚本（cron 每分钟） process_num=$(pgrep -f "start.php" | wc -l) [ "$process_num" -lt 1 ] && curl -s "https://alert.example.com?msg=webman进程消失"systemd/Docker 的 restart 能自动拉起，但频繁重启本身就是告警信号——监控 restart 次数。层次二：业务健康检查// 健康检查接口：不只返回 200，要真的探测依赖 Route::get('/healthz', function () { try { Db::query('SELECT 1'); $redis = Redis::connection(); $redis->ping(); return json(['status' => 'ok']); } catch (\Throwable $e) { return json(['status' => 'degraded', 'error' => $e->getMessage()], 503); } });K8s 的 liveness/readiness、负载均衡的健康剔除都挂这个接口。层次三：业务指标埋点在全局中间件里做请求级指标采集，按分钟聚合推到 Prometheus Pushgateway（或直接暴露 /metrics）：// 指标维度：路由 × 状态码 → count / 耗时分布 $metricKey = $request->path() . ':' . $response->getStatusCode(); Metrics::tick($metricKey, microtime(true) - $start);核心告警规则参考：告警阈值说明错误率5xx 占比 > 1%（5 分钟窗口）最核心的质量指标P99 延迟> 500ms体验底线QPS 突降环比跌 50%流量异常或半死状态内存水位worker RSS > 256M内存泄漏前兆重启频率10 分钟内重启 > 3 次崩溃循环worker 内存监控：php start.php status 能看到每个 worker 的内存，采集脚本每分钟解析一次，单独盯内存曲线——常驻内存服务的专属指标，FPM 时代不存在这个告警维度。层次四：日志聚合日志按天/大小切割，错误日志单独通道。EFK/Loki 都行，关键是错误日志要能按 trace-id 串起完整调用链——中间件生成 trace-id 写入 header 和日志上下文，跨服务传递。四、日志与排障生产排障三板斧：php start.php status # 进程/连接/请求分布 php start.php connections # 当前连接明细 tail -f runtime/logs/*.log # 慢请求日志、错误日志两个必开配置：// config/log.php：慢请求单独通道 'slow' => [ 'handler' => ..., 'formatter' => ..., ], // config/app.php 'default_timezone' => 'Asia/Shanghai', // 别让日志时间戳漂移OOM 被 kill 的进程：dmesg | grep -i kill 确认是内核 OOM killer 干的，然后查内存泄漏（系列第一篇的水位监控法）。五、微服务化：什么时候拆、怎么拆webman 常被用作微服务基座，但拆分要跟着业务痛点走：该拆的信号发布互相拖累：营销活动改一行，核心交易要陪着全量回归资源冲突：批量任务把 CPU 打满，拖垮在线接口团队协作：多组改一个仓库，merge 冲突不断不该拆的时候单体 + 分层（Controller / Service / Dao）+ 自定义进程隔离（在线/离线分开跑）能解决 80% 的问题。微服务是运维成本换研发效率的交易，团队没有 3 人以上运维能力前，慎拆。webman 微服务的标准拼图├── API 网关（webman，路由聚合/鉴权/限流） ├── 业务服务（webman，HTTP 或 JSON-RPC 内部通信） ├── 注册发现（consul / nacos / redis） ├── 异步解耦（Redis 流 / RabbitMQ，webman 的自定义进程消费） └── 链路追踪（trace-id 贯穿 + 日志聚合）服务间通信的务实选择：初期用 HTTP + JSON（简单直观，好排查），流量大了再换 gRPC 或原生 TCP 协议。webman 的 workerman/http-client 支持连接池复用，内部调用开销可控。进程内优先原则：同一个 webman 实例里，用"多应用目录"（app/api、app/admin）做逻辑隔离，比拆进程便宜得多。拆物理服务的唯一理由是上面的三个信号，而不是"架构图好看"。六、上线 Go/No-Go 清单发布前最后一遍：[ ] systemd / Docker 的自动拉起与重启告警就位[ ] healthz 探测 DB + Redis，负载均衡已接健康检查[ ] 发布脚本区分 reload/restart，回滚软链方案演练过[ ] 错误率 / P99 / 内存 / 重启频率四条告警全部在线[ ] 慢请求日志与 trace-id 链路打通[ ] 压测报告：目标 QPS × 2 压过，P99 达标[ ] 数据库连接预算核算（worker × 池上限 < max_connections × 0.8）[ ] 灰度方案：先切 10% 流量观察 30 分钟写在最后webman 系列到这里完整闭环：架构模型（常驻内存的边界）→ 工程骨架（中间件与容器）→ 数据层（连接与事务）→ 性能（协程与压测）→ 生产化（部署监控与演进）。常驻内存框架的一切实践都围绕同一个心法——你运维的不是"每次请求"，而是"活着的进程"。把这个视角建立起来，webman 就是你手里性价比极高的高并发利器。
让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战
让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战博客装了 YoduPlayer 这款背景音乐播放器后，一直有个很破坏体验的问题：音乐正放着，点进一篇文章，页面一刷新，歌就断了。访客每看一篇新文章就要重新点一次播放，背景音乐的"背景"两个字完全名存实亡。这篇文章记录我把这个问题彻底解决的全过程：从原理分析，到方案选型，再到自己动手写一个约 200 行的轻量 PJAX，最后处理掉一串切页后才会暴露的隐藏 Bug。完整代码都在文中，可以直接抄走用。一、先搞清楚：歌为什么会断浏览器里的音频播放依赖一个 DOM 元素 （YoduPlayer 里就是全局的 yaudio 对象）。而传统页面跳转的本质是：点击链接 → 浏览器销毁整个文档 → 请求新页面 → 重新解析 HTML/CSS/JS文档销毁的瞬间，挂在文档里的 元素跟着被销毁，播放自然中断。新页面里插件重新输出了一套播放器 HTML 和 JS，但那是一个全新的 yaudio，播放进度、当前曲目全部归零。所以问题的根源不在插件，而在于整页刷新这种导航方式本身。想让音乐不断，就不能销毁承载播放器的那份文档。二、方案选型：为什么是 PJAX解决思路业界已经很成熟了——局部刷新：导航时只替换页面的内容区域，头部、底部、播放器所在的 DOM 保持不动。实现方式常见的有三种：方案原理缺点iframe把整站套进框架页URL 不变、SEO 灾难、移动端体验差SPA 改造前端框架接管路由Typecho 主题基本要重写，成本过高PJAXAJAX 拉取新页面 + History API 改地址只需主题小幅配合PJAX（PushState + AJAX）的原理一句话就能说清：拦截链接点击 → preventDefault 阻止跳转 → XHR 拉取新页面 HTML → 用 DOMParser 解析 → 只取内容区域替换进当前文档 → pushState 更新地址栏整个过程文档从未销毁， 一直活着，音乐自然不断。同时 URL 会真实变化、浏览器前进后退可用、对搜索引擎完全透明——这是它碾压 iframe 的地方。YoduPlayer 的 README 里也写了"需要主题支持 pjax 或 instantclick"，说明作者早就预留了这条路，缺的只是主题侧的实现。我的主题是 Joe，直接引现成的 PJAX 库和主题代码有各种兼容性小毛病，所以干脆自己写了一个定制版。三、动手实现3.1 确定内容容器边界第一步是划分"哪些 DOM 切页时要换，哪些不能动"。看 Joe 主题的 index.php 结构： <?php $this->footer(); ?> 很清晰：#Joe 是内容容器，切页时替换它的 innerHTML；播放器、脚本初始化代码都在容器外，天生不受影响。如果你的主题结构不同，把这层边界划对是第一件事。3.2 核心 PJAX 脚本在主题里新建 assets/lib/pjax/pjax.js，核心不到 200 行：(function () { 'use strict'; var CONTAINER = '#Joe'; var NO_INSTANT = 'data-no-instant'; var xhr; history.replaceState({ url: location.href }, '', location.href); // 1. 拦截站内链接点击 document.addEventListener('click', function (e) { if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; var link = e.target.closest('a'); if (!link) return; if (link.target === '_blank' || link.hasAttribute('download')) return; var href = link.getAttribute('href'); if (!href || href.charAt(0) === '#' || href.indexOf('javascript:') === 0) return; var url; try { url = new URL(link.href); } catch (err) { return; } if (url.origin !== location.origin) return; // 外链放行 if (url.pathname.indexOf('/admin/') === 0) return; // 后台放行 if (url.href.split('#')[0] === location.href.split('#')[0]) return; // 沿 DOM 向上检查 data-no-instant，标记了就走原生跳转 var el = link; while (el && el !== document.documentElement) { if (el.hasAttribute && el.hasAttribute(NO_INSTANT)) return; el = el.parentNode; } e.preventDefault(); loadPage(url.href, true); }); // 2. 前进 / 后退 window.addEventListener('popstate', function (e) { loadPage((e.state && e.state.url) || location.href, false); }); // 3. 拉取新页面 function loadPage(url, push) { if (xhr) xhr.abort(); xhr = new XMLHttpRequest(); xhr.open('GET', url); xhr.timeout = 15000; xhr.onload = function () { var ct = xhr.getResponseHeader('Content-Type') || ''; if (xhr.status >= 200 && xhr.status < 400 && ct.indexOf('text/html') >= 0) { applyPage(xhr.responseText, url, push); } else { location.href = url; // 兜底：非 HTML 直接真跳转 } }; xhr.onerror = function () { location.href = url; }; xhr.ontimeout = function () { location.href = url; }; xhr.send(); } // 4. 替换内容 + 重执行脚本 function applyPage(html, url, push) { var newDoc = new DOMParser().parseFromString(html, 'text/html'); document.title = newDoc.title; var oldC = document.querySelector(CONTAINER); var newC = newDoc.querySelector(CONTAINER); if (!oldC || !newC) { location.href = url; return; } oldC.innerHTML = newC.innerHTML; // 关键：innerHTML 插入的 3.3 与 YoduPlayer 的分工做完上面这步，音乐其实已经不会断了。剩下的工作是让播放器的 UI 在切页后依然正常。YoduPlayer 的加载分两部分，正好对应两种处理方式：// Plugin.php footer() 中的关键输出 // 音频引擎 + 核心函数：带 data-no-instant，切页不重执行 // UI 层：不带标记，切页后随容器重建 分工非常清晰：player.js + 内联配置：持有 yaudio、musicArr 这些全局状态和 playbtu()、next() 这些核心函数，标记为 data-no-instant，一次加载终身有效；prpr.js：负责播放器界面，重新生成歌单列表 DOM、把播放/切歌按钮绑定到还活着的 yaudio、同步当前曲目和封面。它不带标记，每次 PJAX 切页后都会重新执行一遍，相当于给新 DOM"接上"旧引擎。如果你在给其他播放器做类似改造，把"状态"和"UI"拆开、状态部分标记为不重执行，就是最核心的思路。四、不断歌之后，才轮到真正的坑音乐连续了只是第一步。整页刷新被干掉后，一堆原本"刷新后自然解决"的问题全部浮出水面：4.1 主题初始化不再触发Joe 主题把文章列表懒加载、轮播、各种事件绑定全放在一个 DOMContentLoaded 监听器里。PJAX 换完内容后这个事件不会自己触发，页面看着是换了，交互全是死的。解法就是上面代码里的那句：try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {}手动补发一次，主题的初始化逻辑就会对新内容重跑一遍。4.2 评论表单的安全令牌失效这是最隐蔽的一个。上线后发现：PJAX 切页后发的第一条评论永远失败，报"评论发表失败"。排查后发现 Typecho 有个防spam机制：页面 head 里有一段内联脚本，会在评论表单里动态注入一个 name="_" 的隐藏字段作为令牌，服务端校验它。这段脚本在 里，PJAX 只替换内容容器根本不会碰它，于是新页面里表单是新的、令牌是旧的，校验必然失败。解法是在 applyPage 里补一段：解析新页面 head 中的内联脚本，把包含 name = '_'（防spam）和 TypechoComment（评论回复逻辑，它的 respondId 是每篇文章独有的）的挑出来手动执行：var headInline = newDoc.head.querySelectorAll('script:not([src])'); for (var hi = 0; hi < headInline.length; hi++) { var hcode = headInline[hi].textContent; if (hcode.indexOf("name = '_'") !== -1 || hcode.indexOf('TypechoComment') !== -1) { var hns = document.createElement('script'); hns.textContent = hcode; document.head.appendChild(hns); document.head.removeChild(hns); } }4.3 评论重复提交评论能发之后，又发现每条评论提交了两次。原因是主题的 joe.global.min.js 在补发的 DOMContentLoaded 里又绑定了一次 AJAX 评论处理器，和 PJAX 脚本里的处理器叠加了。解法是绑定评论事件时用 e.stopImmediatePropagation() 抢占，并在每次切页后 $form.off('submit') 清掉旧绑定再重绑，保证表单上永远只有一个处理器。4.4 收起状态记忆失效播放器有个收起/展开状态存在 localStorage 里。切页后新 DOM 是初始展开状态，需要在 PJAX 完成后重新读取并应用。YoduPlayer 的 prpr.js 开头有现成逻辑：if (localStorage.getItem("yoduplayer_collapsed") === "1") { document.getElementById('bgmplayer').classList.remove("bgmon"); }这也侧面验证了前面"UI 层每次重执行"设计的正确性——这类状态同步逻辑放在 UI 层，天然会在每次切页后自动跑一遍。五、总结回头看，整件事的技术含量不在 PJAX 本身（核心逻辑不到 200 行），而在于理解"整页刷新"一直在默默帮你处理什么，并在干掉它之后把这些事情一一接手：脚本重执行、主题重初始化、评论令牌重注入、事件重绑定。总结几条可复用的经验：容器边界划分是 PJAX 改造的第一步，播放器必须在容器外；状态与 UI 分离，状态脚本标 data-no-instant，UI 脚本每次重建后重执行；补发 DOMContentLoaded 让主题的初始化逻辑重跑；异常兜底真跳转，任何失败路径都不能让用户白屏；切页后评论、表单类功能务必手动测一遍，令牌和事件绑定最容易漏。现在博客的背景音乐从进站到离开可以一路连续播放，切页只换内容不换"灵魂"。如果你也在用 Typecho + YoduPlayer，希望这篇能帮你少踩几个坑。插件地址：YoduPlayer - GitHub，感谢作者 Jrotty 的开源贡献。
webman 高并发进阶：协程实战、连接池调优与压测方法论
webman 高并发进阶：协程实战、连接池调优与压测方法论webman 裸奔就能跑出很高的 QPS，但"框架快"不等于"业务快"——一个同步 HTTP 调用就能让 worker 的事件循环卡住，吞吐瞬间跌到个位数。这篇讲 webman 高并发的三板斧：协程化改造、连接池调优、压测方法论。这是 webman 中高级的分水岭。一、先理解瓶颈：worker 事件循环是怎么被卡死的webman 的 worker 是单进程单线程事件循环（Reactor 模型）。在同一个 worker 里，请求是串行处理的：worker-1 事件循环： 事件：请求A到达 → 处理A（其中调用外部API耗时 200ms，同步阻塞！） （这 200ms 内，分配给 worker-1 的所有其他请求全部排队） 事件：请求B到达 → 处理B推论：平均响应时间 = 排队时间 + 处理时间。 只要处理路径上有阻塞调用，worker 的吞吐上限就是 1 / 平均阻塞时长。200ms 的外部调用意味着单个 worker 每秒最多 5 个请求，16 个 worker 也就 80 QPS——和框架本身的几十万 QPS 上限毫无关系。解法只有两条：消灭阻塞（异步/协程化），或者增加并行度（协程）。二、协程：webman 的三种姿势1. 内置 Swoole/Swow 协程驱动webman 支持通过配置切换协程运行时：// config/server.php 'settings' => [ 'event_loop' => \Workerman\Events\Swoole::class, // 或 Swow ],开启后，worker 内自动协程化：遇到 IO（MySQL、Redis、HTTP）时挂起当前协程，让出事件循环处理其他请求——同一个 worker 从串行变成并发。Swoole 与 Swow 的选择：Swoole 生态成熟但与 PHP 原生扩展偶有兼容性问题（hook 覆盖不全的扩展会退化为阻塞）；Swow 是 PHP 官方系方案更干净但生态年轻。生产选型建议：现有业务全是标准 PDO/cURL 的选 Swoole 一把梭；有奇怪扩展依赖的先在预发验证 Swow。2. 协程客户端协程化后最大的红利是并发聚合调用：use Swoole\Coroutine; use function Swoole\Coroutine\go; use function Swoole\Coroutine\waitGroup; // 聚合页：并行拉取用户/商品/优惠券，总耗时 = max 而不是 sum $wg = new waitGroup(); $results = []; go(function () use ($wg, &$results) { $wg->add(); $results['user'] = $this->userApi->get($uid); $wg->done(); }); go(function () use ($wg, &$results) { $wg->add(); $results['item'] = $this->itemApi->get($itemId); $wg->done(); }); $wg->wait(3.0); // 总超时 3s // 原来 200+150+100=450ms，现在 max(200,150,100)=200msBFF/聚合接口的标准优化手段，P99 延迟直接砍半。3. 协程的三个纪律纪律一：协程切换点后不信任局部状态。 yield 期间其他协程可能改了共享数据。请求级数据老老实实走 $request 属性，全局状态放 Redis。纪律二：连接必须绑协程。 协程 A 半截的事务，切换后协程 B 接着用同一个连接 = 事务串台。Swoole 的 PDO hook + webman/database 的连接池已处理绑定关系，但你自己 new PDO 存静态属性再跨协程用，必炸。纪律三：CPU 密集任务协程救不了。 协程只解决 IO 并发，CPU 密集计算照样卡死事件循环。重计算丢给自定义进程或队列，别在 worker 里跑。三、连接池：协程时代的正确配置协程化后并发量上来了，单连接成了新瓶颈，必须配连接池（webman 通过 webman/database/webman/redis 的连接池实现，Swoole 驱动下生效）：// config/database.php（连接池相关参数） 'pool' => [ 'max_connections' => 32, // 每进程最大连接数 'min_connections' => 2, // 保底连接 'connect_timeout' => 3, // 取连接超时 'wait_timeout' => 3, // 等待池中连接的超时 'heartbeat' => -1, // 心跳检测间隔（配合 wait_timeout 用负数自动探测） 'max_idle_time' => 60, // 空闲连接回收 ],容量估算公式：每进程需要的连接数 ≈ 协程并发度（同时进行中的查询数） 总连接 = worker 数 × max_connections 例：16 worker × 32 连接 = 512 连接 < MySQL max_connections(1000) ✓常见翻车：池配了 32，MySQL max_connections 只有 100，另一台应用共用它——高峰期互相挤兑连接，报错 connection timeout 却以为是网络问题。跨应用共享数据库时，连接预算要全局规划。池的监控指标每个指标都应该有告警：指标健康值异常含义池利用率< 80%持续 100% = 池太小或查询太慢等待取连接耗时< 1ms高 = 池不够用活跃连接数平稳锯齿状抖动 = 连接泄漏慢查询数趋近 0高 = 先治慢查询再扩池四、压测方法论：别让数据骗了你压测的正确姿势预热：先压 1 分钟丢弃数据（连接懒加载、JIT、文件缓存都要热身）阶梯加压：并发 10 → 50 → 100 → 200，每档 3-5 分钟，找到拐点看 P99 而不是平均值：平均值 50ms、P99 3s 的服务，用户体感是灾难压测端别成为瓶颈：用独立机器跑 wrk/vegeta，本机压本机数据不可信# wrk 示例：16 线程 200 并发，持续 3 分钟 wrk -t16 -c200 -d180s -s post.lua http://10.0.0.5:8787/api/orders拐点诊断表现象第一嫌疑验证手段CPU 打满 100%业务计算 / 序列化开销perf top 看热点函数CPU 很低但延迟飙升阻塞 IO（同步调用没协程化）strace 看 worker 阻塞在哪个 syscall连接池等待耗时高池容量不足或慢查询慢查询日志 + 池监控MySQL CPU 高缺索引 / 慢 SQLexplain 逐条过延迟毛刺reload / GC / 心跳任务对齐时间线看毛刺时刻发生了什么一个容易被忽略的真相：压测时开着 Xdebug、开着 debug 日志、没开 opcache/jit，得出的"webman 不行"结论全是冤案。压测环境配置必须对齐生产：php start.php start -d（daemonize）、opcache 开启、日志降级。webman 侧的性能自检php start.php status # 每个worker的连接数、请求统计关注 total_request（累计请求数）和 request_per_second。如果各 worker 请求分布严重不均，检查是否用了 sticky 逻辑（比如按 uid hash 到特定连接）。五、一个真实调优案例场景：订单列表接口，压测到 800 QPS 后延迟从 40ms 涨到 2s。排查过程：status 显示 worker CPU 只有 30% → 不是 CPU 瓶颈strace 发现 worker 大量时间阻塞在 recvfrom（MySQL 等待）→ IO 等待慢查询日志：一条 ORDER BY created DESC LIMIT 20 OFFSET 5000 没有覆盖索引，深分页扫全表 1.2s修复：游标分页（WHERE id < last_id LIMIT 20）+ 联合索引复测：5000 QPS 稳定，P99 = 65ms教训：框架层的优化（协程/连接池）解决的是"传导"问题，SQL 层的慢查询是"源头"问题。源头不治，协程只是让请求更快地堵在数据库门口。六、优化优先级清单按投入产出比排序，从上往下做：1. 慢 SQL 治理（索引、深分页、N+1） ← 90% 的性能问题在这 2. 聚合接口并行化（协程并发调用） 3. 热点数据缓存（Redis，注意缓存一致性） 4. 协程运行时 + 连接池配置 5. opcache / JIT / preload 6. worker 数与压测验证写在最后高并发的本质是让每个 worker 的每一毫秒都在干活：协程消灭 IO 空转，连接池消灭建连开销，压测验证每一项优化真实生效。记住那条优先级清单——先治 SQL，再谈协程。最后一篇我们讲生产化：部署、进程守护、监控告警和微服务化实践。
webman 数据库实战：连接池、ORM 选型、事务边界与大数据处理
webman 数据库实战：连接池、ORM 选型、事务边界与大数据处理数据库是 webman 与传统 FPM 框架差异最大的环节。FPM 下每个请求开新连接、请求结束关闭，天然隔离；webman 的每个 worker 进程持有自己的连接并跨请求复用——连接管理从"框架的事"变成了"你的事"。这篇讲透连接池配置、think-orm 与 Eloquent 的选型、事务的正确边界，以及千万级数据的处理姿势。一、常驻内存下的连接模型先看清楚 webman 的数据库连接结构：worker-1 ─── 连接A（MySQL）──┐ worker-2 ─── 连接B ├── MySQL max_connections 上限（如 1000） worker-3 ─── 连接C │ ...（16 个 worker） │ └── Redis 连接同理推论一：最大连接数 = worker 进程数 × 每进程连接数。 16 个 worker、每个用了 2 个连接配置（读写分离），就是 32 个常驻连接。上线前先算这笔账，别和 MySQL 的 max_connections 打架。推论二：连接是懒建立的。 进程启动时不连库，第一次查询才建立。所以刚 reload 完的进程有冷启动延迟，压测时记得先预热。推论三：连接会"死"。 MySQL wait_timeout（默认 8 小时）内没有活动，服务端单方面断开，webman 侧还握着死连接，下一个请求直接 MySQL has gone away。二、断线重连：三层配置兜底以 think-orm（webman 默认集成）为例：// config/thinkorm.php return [ 'default' => 'mysql', 'connections' => [ 'mysql' => [ 'type' => 'mysql', 'hostname' => '127.0.0.1', 'database' => 'app', 'username' => 'root', 'password' => 'root', 'charset' => 'utf8mb4', 'break_reconnect' => true, // ① 断线自动重连 'fields_cache' => true, // ② 字段信息缓存（常驻内存的红利） ], ], ];三层兜底缺一不可：break_reconnect => true：ORM 层检测断连自动重连心跳保活：定时进程每 5 分钟发一条 SELECT 1，让连接永不超时（webman 的 config/process.php 里加自定义进程实现）重试幂等：断线重连只对查询安全，写操作重试必须保证幂等，否则可能双写// 心跳进程示例 return [ 'db-keepalive' => [ 'handler' => function () { while (true) { Db::query('SELECT 1'); sleep(300); } }, ], ];三、ORM 选型：think-orm 还是 Laravel Eloquent？webman 官方两者都支持，选型看团队基因：维度think-ormEloquent（illuminate/database）上手成本ThinkPHP 系团队零成本Laravel 系团队零成本模型功能基础够用关联预加载、访问器、软删更丰富常驻内存适配fields_cache 等针对性优化需要自己注意静态缓存生态迁移TP 项目平移Laravel 项目平移中大型项目的建议：ORM 只做"查询构造 + 结果映射"，复杂查询直接裸 SQL 或查询构造器。模型层越薄，常驻内存下的心智负担越小。一个 Eloquent 用户必须知道的区别：FPM 下 DB::connection() 每请求新建，webman 下连接跨请求驻留——事务没有提交/回滚，下一个请求会接着在这个事务里跑，数据不一致的幽灵bug就是这么来的。think-orm 的 Db::startTrans() 同理。// ❌ 事故现场：异常没回滚，连接带着事务回到池里 try { Db::startTrans(); // ... 写操作 throw new \RuntimeException('boom'); // 没有 catch 回滚！ } finally { Db::commit(); // finally 里 commit，异常时也 commit 了半截数据？ }四、事务的正确姿势铁律一：事务边界必须完整闭合try { Db::startTrans(); $this->deductStock($skuId, $num); // 扣库存 $this->createOrder($uid, $skuId); // 建订单 Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; // 异常继续抛，别吞 }铁律二：事务里禁止外部 IO事务持锁期间调外部 HTTP / 发 MQ / 写 Redis，一旦外部服务慢，数据库连接和行锁被拖住，并发一上来就是雪崩。// ❌ 事务里调第三方风控接口（300ms），行锁拖 300ms Db::startTrans(); $stock = Stock::lock(true)->find($skuId); // SELECT ... FOR UPDATE RiskApi::check($uid); // 外部调用！ $stock->num -= 1; $stock->save(); Db::commit(); // ✅ 外部检查前置，事务只做纯 DB 操作 RiskApi::check($uid); // 先调外部 Db::startTrans(); $stock = Stock::lock(true)->find($skuId); if ($stock->num < 1) throw new OutOfStock(); $stock->num -= 1; $stock->save(); Db::commit(); PublishOrderEvent::dispatch($order); // 后置异步动作铁律三：事务尽量短，锁尽量准悲观锁（lock(true)）只锁必要的行，锁定前先校验数据存在的条件能用唯一索引防重（幂等）就别用"先查再插 + 锁"补充：嵌套事务与 savepointthink-orm 支持 startTrans 嵌套（底层转 savepoint），但事务边界只写在 Service 层一处，不要 Controller 包一层、Service 再包一层——嵌套事务的回滚语义很容易把人绕晕。五、大数据量：分批处理的三个工具1. chunk 分批读User::where('status', 1)->chunkById(1000, function ($users) { foreach ($users as $user) { // 逐批处理 } });用 chunkById 而不是 chunk：处理过程中如果更新了 offset 相关字段，chunk（基于 offset/limit）会漏数据或死循环，chunkById（基于主键游标）不会。2. 慎用 ORM 批量，必要时裸 SQLORM 逐条 save 在万级数据下慢得无法接受：// ❌ 10 万条逐条 update：几十分钟 foreach ($rows as $row) { Model::where('id', $row['id'])->update($row); } // ✅ 事务 + 批量拼接（注意分批，每批 ≤ 1000） Db::startTrans(); foreach (array_chunk($rows, 500) as $batch) { Db::table('users')->upsert($batch, ['id'], ['name', 'status']); // 批量 upsert } Db::commit();3. 超大导出：游标 + 流式输出百万行导出别 ORM ->get() 全量载入（内存直接爆），用游标逐行读、边读边写 CSV 流：$fp = fopen('php://output', 'w'); User::where('created', '>', $start)->select()->cursor()->each(function ($u) use ($fp) { fputcsv($fp, $u->toArray()); });cursor() 用 PDO 的 unbuffered 模式，内存占用恒定。六、读写分离与多库配置'connections' => [ 'mysql' => [ /* 主库写 */ ], 'mysql_read' => [ /* 从库读 */ ], ],think-orm/Eloquent 都支持配置读写分离自动路由，但强制主读（写后立读的场景：下单后立即查详情）必须显式指定主库连接，否则撞上主从延迟会查到旧数据：// 写后立刻要读的，强制走主库 $inDb = Order::on('mysql')->find($orderId);七、上线前检查清单[ ] 最大连接数核算：worker 数 × 每进程连接数 < MySQL max_connections × 0.8[ ] break_reconnect 开启 + 心跳进程就位[ ] 事务全部 try/catch/rollback 完整闭合（全局搜 startTrans 逐个人工审计）[ ] 长事务里无外部 IO（APM 看事务平均持锁时长）[ ] 大数据任务用 chunkById / cursor，无全量 ->get()[ ] ORM 字段缓存开启，生产关掉 SQL 日志写在最后数据库层的所有坑都指向同一个根因：连接的生命周期从"请求级"变成了"进程级"。把连接当进程级资源来管理（池化、心跳、重连），把事务当危险品来对待（短小、闭合、无 IO），webman 的数据库层就稳了。下一篇进入性能深水区：协程、连接池调优与压测。
webman 中间件与依赖注入实战：洋葱模型、PHP-DI 容器与 AOPwebman 的请求处理链路看似简单（路由 → 中间件 → 控制器），但中间的洋葱模型和 PHP-DI 容器藏着不少中级开发者没吃透的细节。这篇文章把中间件的执行顺序、全局/路由级/方法级三种挂载方式、依赖注入的边界一次性讲清楚，最后再聊一个常驻内存框架特有的话题——AOP。一、洋葱模型：代码顺序与执行顺序是两回事class AuthMiddleware implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { // ── 前置逻辑（进入时执行）── $userId = $this->jwt->parseToken($request); $request->userId = $userId; $response = $handler($request); // ← 下一层洋葱 // ── 后置逻辑（返回时执行）── $response->withHeader('X-Trace-Id', $request->header('x-trace-id')); return $response; } }三层中间件的执行顺序：请求 → [A前置] → [B前置] → [C前置] → Controller 响应 ← [A后置] ← [B后置] ← [C后置] ←最容易踩的坑：异常发生时的执行路径。 如果控制器抛异常，中间件的后置代码默认不会执行——除非你在中间件里 try/catch 包住 $handler($request)。跨域中间件经常因此"时灵时不灵"：异常响应没带 CORS 头，浏览器报的不是后端错误而是跨域错误。所以 CORS 中间件要这样写：try { $response = $handler($request); } catch (\Throwable $e) { $response = response($e->getMessage(), 500); } return $response->withHeaders([ 'Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Headers' => 'Content-Type, Authorization', ]);二、三种挂载粒度1. 全局中间件（config/middleware.php）return [ // 每个请求都会经过 app\middleware\AccessLog::class, app\middleware\Cors::class, ];适合：日志、CORS、限流。注意全局中间件每个请求都会跑，别放重逻辑。2. 路由/应用级中间件// config/route.php Route::group('/admin', function () { Route::resource('/users', app\admin\controller\UserController::class); })->middleware([ app\middleware\AdminAuth::class, ]); // 或整个应用目录生效：app/admin/config/middleware.php return [ app\middleware\AdminAuth::class, ];多应用（多入口目录）模式下，app/{应用名}/config/middleware.php 只对该应用生效——这是做多租户后台、API 版本隔离的天然工具。3. 控制器方法级（中间件别名）// config/middleware.php 里注册别名 return [ 'auth' => [app\middleware\Auth::class], ]; // 控制器里按方法指定 class OrderController { #[Middleware(['auth'])] // 整个控制器生效 public function index() {} #[Middleware(['auth', 'throttle:60,1'])] public function create() {} }执行优先级：全局 → 应用级 → 路由级 → 方法级。同层内按注册顺序。三、实战：三个高频中间件的正确写法限流中间件（基于 Redis 滑动窗口）class Throttle implements MiddlewareInterface { public function __construct(private Redis $redis) {} public function process(Request $request, callable $handler): Response { $key = 'throttle:' . $request->getRealIp() . ':' . date('YmdHi'); $current = $this->redis->incr($key); if ($current === 1) { $this->redis->expire($key, 60); } if ($current > 120) { return json(['code' => 429, 'msg' => 'too many requests'], 429); } return $handler($request); } }注意 incr 后再 expire 的写法：只在第一次设置过期，避免每请求都重置 TTL。请求日志（含慢请求标记）class AccessLog implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $start = microtime(true); $response = $handler($request); $cost = round((micro(true) - $start) * 1000, 2); Log::info('access', [ 'ip' => $request->getRealIp(), 'path' => $request->path(), 'cost' => $cost, 'slow' => $cost > 500 ? '⚡' : '', ]); return $response; } }JWT 认证 + 用户上下文注入class Auth implements MiddlewareInterface { public function process(Request $request, callable $handler): Response { $token = $request->header('authorization', ''); try { $payload = JWT::decode(trim(str_replace('Bearer ', '', $token)), new Key(config('jwt.secret'), 'HS256')); } catch (\Throwable $e) { return json(['code' => 401, 'msg' => 'unauthorized'], 401); } // 挂到 request 上传递（请求级隔离，不用静态属性） $request->uid = $payload->uid; return $handler($request); } }控制器里 $request->uid 直接取——请求结束自动销毁，没有跨请求污染。四、依赖注入：webman 的 PHP-DI 容器webman 内置 PHP-DI。最常见的三个用法：1. 构造器注入（推荐）class OrderController { public function __construct( private OrderService $orderService, // 自动解析 private LoggerInterface $logger, ) {} }常驻内存的特性让注入几乎零成本：控制器和依赖都在进程启动阶段实例化一次，之后每个请求直接复用。这也意味着——注入的依赖必须是"无状态"或"请求安全"的。Service 里存了请求相关的属性就会串数据。2. 接口绑定// config/container.php return [ PayInterface::class => \DI\create(WechatPay::class), ]; // 之后注入 PayInterface 拿到的就是 WechatPay切支付渠道只改这一行。多环境（沙箱/生产）也可以在这里做条件绑定。3. 自动装配的边界PHP-DI 按类型提示递归解析依赖，但碰到标量参数就无能为力，需要显式定义：// config/container.php return [ \DI\create(SmsClient::class) ->constructor(\DI\value(config('sms.appkey'))), ];五、AOP：常驻内存框架的专属福利AOP（面向切面编程）在 FPM 下性能代价大，但 webman 的进程常驻让织入成本只付一次。webman 通过 php-di/AOP 代理实现，最常见的场景是缓存注解：#[Cacheable(prefix: 'user:', ttl: 300)] public function getUserProfile(int $uid): array { return $this->userDao->findWithRelations($uid); }切面拦截方法调用：先查 Redis，命中直接返回；未命中执行原方法并写缓存。类似的还有 #[Transactional]（自动事务包裹）、#[Retry]（失败重试）。两个注意点：AOP 只对容器管理的对象生效——new OrderService() 出来的裸对象没有代理拦截逻辑发生在方法级，粒度比中间件（请求级）细，适合业务横切（缓存/事务/重试），不适合请求横切（认证/日志）六、一张图理清请求全链路请求 → 全局中间件（日志/CORS/限流） → 应用/路由中间件（认证） → 方法级中间件（权限） → 中间件层容器解析控制器（构造器注入，进程级单例） → 方法内 AOP 切面（缓存/事务） → Service（业务逻辑，保持无状态） → Model/Dao每一层解决不同粒度的横切问题：请求级用中间件，业务级用 AOP，对象组装用容器——三者各司其职，别把认证写进 Service，也别把缓存注解挂在中间件上。写在最后中间件和依赖注入是 webman 工程化的骨架。写中间件时时刻记住两件事：异常会跳过后置逻辑、常驻内存下禁止请求级状态进静态属性；用容器时记住注入对象是进程级复用的，Service 必须无状态。下一篇讲数据库层——连接池怎么配、事务怎么包、大数据怎么跑，那是常驻内存框架最容易翻车的第二现场。
webman 架构解析：常驻内存进程模型与 PHP-FPM 的本质差异
webman 架构解析：常驻内存进程模型与 PHP-FPM 的本质差异webman 是基于 Workerman 构建的高性能 PHP 框架，官方基准 QPS 常态化压到十几万。但很多人把 webman 当"换个写法的 Laravel"来用，结果内存泄漏、全局状态污染这些经典问题轮番上演。要真正用好 webman，必须先理解它与 PHP-FPM 在架构上的本质差异——这是所有后续实践的地基。一、两种执行模型：请求级生命周期 vs 常驻内存PHP-FPM：每次请求都是新生浏览器 → Nginx → PHP-FPM(worker) → 加载框架 → 初始化 → 处理请求 → 返回 → 销毁一切FPM 模式下，每个请求结束都会释放全部内存、销毁所有变量。代价是每次请求都要重新加载框架（几十毫秒的 bootstrap），但好处是状态绝对隔离——你再怎么写全局变量，下一个请求都是白纸。webman：一次加载，永久服务浏览器 → webman(worker 进程) → 直接路由分发 → 处理请求 → 返回（框架和对象留在内存）webman 启动时把框架、路由表、配置全部加载进内存，之后每个请求只是"触发一次回调"。QPS 高的根源就在这：省掉了每请求一次的框架 bootstrap 开销。这个差异带来两个直接推论：性能提升的本质是"摊销"：初始化成本只付一次，请求越多收益越大内存不自动释放：请求之间共享进程内存，全局状态会"存活"——这是福也是祸二、进程模型：master / worker 的分工master 进程（不处理业务） ├── 监听端口、管理 worker ├── fork 出 N 个 worker 进程 └── 收到 reload 信号时逐个平滑重启 worker │ ├── worker-1 ← 每个进程独立事件循环，可同时 hold 住数千连接 ├── worker-2 ├── ... └── worker-N（默认 = CPU 核数）三个关键认知：1. worker 数不是越多越好。 webman 是多进程 + 每进程事件循环（非阻塞 IO）模型，worker 数建议等于 CPU 核数。如果你的业务有阻塞调用（同步 HTTP 客户端、同步 RPC），连接会"卡住"事件循环，这时才需要加进程数来对冲——但更好的解法是消灭阻塞调用（后面协程篇细讲）。2. 进程间不共享内存。 你在 worker-1 里写的静态变量，worker-2 看不见。跨进程共享状态要用 Redis 或进程间通信，别指望 static 属性全站生效。3. request 对象请求间隔离，但对象池里的组件不是。 框架层面的组件（DB 连接、日志实例）跨请求复用，这是性能来源；你自己的类如果注册成了单例，它同样跨请求存活。三、启动流程：从 public/index.php 说起// public/index.php（简化） require_once __DIR__ . '/../vendor/autoload.php'; support\App::run();App::run() 内部做了什么：1. 加载 .env 与 config/*.php（此后修改配置需重启） 2. 扫描 app/controller 生成路由表（注解路由另算） 3. 初始化容器（webman 基于 PHP-DI，支持构造器注入） 4. 加载 bootstrap/*.php（进程启动时执行一次的钩子） 5. 初始化数据库/Redis 连接（懒加载，首次使用时建立） 6. master fork workers → 每个 worker 进入事件循环等待请求bootstrap 目录是理解 webman 的钥匙：放在这里的代码每个进程只执行一次。数据库连接的建立、连接池的初始化、Swoole 协程运行时的开启，都在这个阶段。官方的 webman/database、webman/redis 等插件都是通过 bootstrap 文件挂载的。四、常驻内存的四宗罪与解法罪一：静态属性 / 全局变量跨请求污染// ❌ 经典事故：上一单用户的数据泄漏给下一单 class OrderService { public static ?array $currentUser = null; } // 请求A设置了 currentUser，请求B进来时它还活着！解法：请求级数据一律走 Request 对象传递，或用 $request->xxx 属性挂载。静态属性只允许存"进程级不变量"（配置、编译好的正则、容器实例）。罪二：内存泄漏每请求增长的数组、未释放的大结果集、监听器越挂越多……FPM 下这些泄漏随请求结束被清零，webman 下会累积到 memory_limit 进程崩溃。排查手段：// 在中间件里记录内存水位 public function process(Request $request, callable $handler): Response { $before = memory_get_usage(); $response = $handler($request); $this->logger->info('mem delta', [ 'delta' => memory_get_usage() - $before, 'peak' => memory_get_peak_usage(), ]); return $response; }内存水位持续爬升就是泄漏信号。常见元凶：往静态数组里 push 不清理、查询返回百万行、日志 handler 无限累积。罪三：数据库连接断开（MySQL gone away）worker 空闲超过 wait_timeout（默认 8 小时）后，MySQL 单方面断开，而 webman 还握着死连接。解法：配置连接心跳 + 断线重连。用 ORM 插件时开启：// think-orm 配置 'break_reconnect' => true,更彻底的方案是连接池（数据库篇细讲），池化组件自带健康检查。罪四：代码改了不生效FPM 的肌肉记忆是"保存即生效"，webman 下路由、配置、类定义都在内存里，必须 restart 或 reload。开发环境用 php start.php restart 太重，webman 有监视文件变自动 reload 的方案（workerman/webman-framework 配合 monitor 进程，dev 模式自动开启）——但注意生产环境要关掉。五、平滑重启的原理与边界php start.php reload # 平滑重启：逐个重启 worker php start.php restart # 硬重启：master 也重启 php start.php stop # 停止 php start.php status # 查看进程状态reload 的机制：master 收到信号后，先通知一个 worker 停止接收新请求，等它处理完当前请求再杀掉重建，依次轮转。这意味着：reload 只更新"启动时加载"的代码：controller 文件是每次请求 include 的吗？——webman 中 controller 默认支持 reload 更新（优化级别 support/App 的 reloadable 路径），但 config/、bootstrap/、路由定义的改动必须 restartreload 期间正在执行的长任务会被等待，但超过 $maxWaitTime（可配）会被强杀，注意保护长任务六、与 FPM 共存：渐进式迁移策略存量 Laravel/TP 项目不必推倒重来。实战迁移路径：新接口用 webman 写，旧接口留在 FPM，Nginx 按路径分流复用原有 Model 层：webman 支持 think-orm 和 Laravel Eloquent（laravel/database 插件），业务模型代码几乎平移逐步搬迁高频接口：把 QPS 最高、逻辑最薄的接口先迁过去，验证稳定性后再迁复杂业务分流配置示例：location /api/v2/ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://127.0.0.1:8787; # webman } location /api/ { try_files $uri /index.php?$query_string; # 旧 FPM 应用 }写在最后webman 不是"更快的 Laravel"，而是一种不同的运行时模型。把进程模型、启动流程、常驻内存的边界刻进脑子，后面的中间件、连接池、协程实践才有意义。下一篇我们进入请求处理的核心链路：中间件洋葱模型与依赖注入容器的实战。
uni-app 状态管理实战：Pinia 集成、持久化适配与登录态设计
页面通信篇里简单提过 Pinia，但真实项目的状态管理远不止"装个库"。多端 storage 差异、持久化插件适配、登录态设计、模块划分，每一步都有坑。本文把 uni-app 状态管理的完整方案一次性讲透。一、为什么 uni-app 项目需要状态管理先看反例——不用状态管理时的典型代码：// 每个页面都要重复拉用户信息 onShow() { const userId = uni.getStorageSync('user_id') const res = await request.get(`/users/${userId}`) this.user = res }问题：重复请求、数据不同步（A 页改了昵称 B 页还是旧的、storage 读写散落各处。状态管理的本质是把跨页面共享的响应式数据收口到一个地方。哪些数据该进全局 store：数据类型示例方案登录态token、用户信息store + 持久化业务共享状态购物车、选中的收货地址store页面间临时传值列表页→详情页参数URL / EventChannel单页面私有状态表单数据data / ref判断标准：两个以上无父子关系的页面需要读同一份数据 → 上 store；只在跳转链上传递 → 用页面通信方案。不要为了"显得规范"把所有东西塞进 store。二、Pinia 集成// main.js import { createSSRApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' export function createApp() { const app = createSSRApp(App) app.use(createPinia()) return { app } }// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ token: '', userInfo: null }), getters: { isLoggedIn: (state) => !!state.token, displayName: (state) => state.userInfo?.nickname || '未登录' }, actions: { async login(code) { const res = await request.post('/auth/wx-login', { code }) this.token = res.accessToken this.userInfo = res.user }, logout() { this.token = '' this.userInfo = null uni.reLaunch({ url: '/pages/login/login' }) } } })// 任意页面使用 import { useUserStore } from '@/stores/user' const userStore = useUserStore() userStore.isLoggedIn // getter，响应式 await userStore.login(code) // action三、持久化：storage 适配是关键直接持久化的问题小程序端没有 localStorage，Pinia 官方的 pinia-plugin-persistedstate 默认走 localStorage，在小程序端直接报错。两个解决路径：方案一：手写持久化（推荐，无依赖）// utils/persist.js import { watch, toRaw } from 'vue' /** * 将 store state 持久化到 uni storage * @param {Store} store pinia store 实例 * @param {string} key 存储键名 */ export function persistStore(store, key) { // 启动时恢复 const saved = uni.getStorageSync(key) if (saved) { store.$patch(JSON.parse(saved)) } // 变更时保存（deep watch） store.$subscribe((mutation, state) => { try { uni.setStorageSync(key, JSON.stringify(toRaw(state))) } catch (e) { console.warn('持久化失败', e) } }) }// stores/index.js —— 统一初始化 import { createPinia } from 'pinia' import { persistStore } from '@/utils/persist' import { useUserStore } from './user' import { useCartStore } from './cart' const pinia = createPinia() // 在 App.vue onLaunch 里调用，确保 uni storage 可用 export function initStores() { persistStore(useUserStore(), 'app:user') persistStore(useCartStore(), 'app:cart') } export default pinia// App.vue export default { onLaunch() { initStores() } }$subscribe 是 Pinia 内置的订阅机制，任何 mutation 都会触发，比手动 watch 更可靠。方案二：persist 插件 + 自定义 storage如果坚持用 pinia-plugin-persistedstate，给它传入适配 uni storage 的 driver：// main.js import { createPinia } from 'pinia' import { createPersistedState } from 'pinia-plugin-persistedstate' const pinia = createPinia() pinia.use(createPersistedState({ storage: { getItem: (key) => uni.getStorageSync(key) || null, setItem: (key, value) => uni.setStorageSync(key, value), // uni storage 没有.removeItem 语义？有：uni.removeStorageSync } })) export function createApp() { const app = createSSRApp(App) app.use(pinia) return { app } }持久化的粒度控制不是所有 state 都值得持久化。用户偏好（主题、字体大小）全量持久化；购物车持久化但要注意登录后与服务器合并；临时 UI 状态（弹窗开关）绝不持久化。用插件的话按 store 配置 paths 字段挑选。四、登录态设计：完整方案登录态是小程序里最重要的全局状态，完整链路：静默登录 → 检查有效期 → 请求拦截 → 失效处理。// stores/user.js 完整版 import { defineStore } from 'pinia' import request from '@/utils/request' export const useUserStore = defineStore('user', { state: () => ({ token: '', refreshToken: '', userInfo: null, wxSessionKey: '' }), getters: { isLoggedIn: (state) => !!state.token }, actions: { /** * 静默登录：App 启动即调用，用户无感知 * 小程序 wx.login 换 code → 后端换 openid → 绑定/注册用户 → 发 token */ async silentLogin() { if (this.token) return try { const [err, res] = await uni.login({ provider: 'weixin' }) if (err) return const data = await request.post('/auth/silent-login', { code: res.code }) this.token = data.accessToken this.refreshToken = data.refreshToken } catch (e) { // 静默失败不弹窗，需要用户信息时再引导 console.warn('静默登录失败', e) } }, /** 强制登录：访问需要身份的页面时调用 */ async ensureLogin() { if (this.isLoggedIn) return true await this.silentLogin() if (this.isLoggedIn) return true // 静默失败（未注册），跳登录页 const pages = getCurrentPages() const current = pages[pages.length - 1] uni.navigateTo({ url: `/pages/login/login?redirect=/${current.route}` }) return false }, async fetchProfile() { this.userInfo = await request.get('/me') }, logout() { this.$reset() // 重置到初始 state uni.reLaunch({ url: '/pages/login/login' }) } } })// App.vue export default { onLaunch() { initStores() useUserStore().silentLogin() } }五、store 模块划分中大型项目的目录建议：stores/ ├── index.js # pinia 实例 + initStores ├── user.js # 登录态、用户信息 ├── cart.js # 购物车 ├── app.js # 全局配置：主题、系统信息、定位城市 └── address.js # 收货地址列表划分原则：按业务域拆，不按页面拆（订单相关的状态跟订单 store，即使被 5 个页面用）store 之间可以互相引用：useCartStore 内部调用 useUserStore 检查登录态，Pinia 支持这种组合action 里做业务编排：加入购物车 = 检查登录 → 调接口 → 更新 state → toast 提示，这一串逻辑写进 action 而不是页面里，页面只管调用// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' import request from '@/utils/request' export const useCartStore = defineStore('cart', { state: () => ({ items: [] }), actions: { async add(goods, count = 1) { const userStore = useUserStore() // store 组合 const ok = await userStore.ensureLogin() if (!ok) return // 本地乐观更新 + 服务端同步 const local = this.items.find(i => i.goodsId === goods.id) if (local) { local.count += count } else { this.items.push({ ...goods, goodsId: goods.id, count }) } try { await request.post('/cart/add', { goodsId: goods.id, count }) } catch (e) { uni.showToast({ title: '同步失败', icon: 'none' }) } } } })六、多端差异注意点storage 大小限制：单 key 上限 1MB，总上限 10MB（微信）。购物车这类大对象要控制规模，必要时只存 ID 列表，详情进 store 后从接口拉App 端可以直用 localStorage：但为了代码统一，全端都用 uni.setStorageSyncstorage 是同步 API：批量写入会阻塞逻辑层，高频写入（如实时输入内容）做节流// 写入节流 let saveTimer = null store.$subscribe(() => { clearTimeout(saveTimer) saveTimer = setTimeout(() => { uni.setStorageSync(key, JSON.stringify(toRaw(store.$state))) }, 300) })总结跨页面共享的响应式数据进 store，跳转传参用页面通信，不要过度集中小程序端持久化必须适配 uni storage：手写 $subscribe 方案零依赖最稳登录态标准链路：onLaunch 静默登录 → ensureLogin 强制拦截 → action 内编排业务store 按业务域划分，action 做业务编排，store 之间可组合storage 有 1MB 单 key 限制，大对象持久化要做节流和规模控制下一篇讲登录授权的"最后一公里"——手机号验证码、头像昵称填写这些微信持续收紧的接口怎么优雅应对。
AB-Admin：给 Typecho 后台换上 Material Design 3 新装
前言Typecho 的默认后台界面，怎么说呢——功能齐全，但长相确实停留在上个时代的审美。白底蓝链接、紧凑的表格、没有圆角没有过渡动画，每次写文章都像在填 Excel 表格。如果你也有同感，那 AB-Admin（Admin Beautify） 可能正是你在找的东西。这是一款基于 Material Design 3 设计体系的 Typecho 后台美化增强插件，号称"可能是史上颜值最高的 Typecho 后台美化插件"。本文结合我博客上实际安装使用的 v2.1.43 版本，从功能介绍、安装配置到实际体验，完整聊聊这个插件。插件概览项目信息插件名AB-Admin（原名 Admin Beautify）作者LHL当前版本v2.1.43设计体系Material Design 3（MD3）Typecho 版本基于 1.3.0 开发，兼容 1.2.1GitHubgithub.com/lhl77/Typecho-Plugin-AdminBeautify配套插件AB Store（AdminBeautifyStore）插件仓库核心功能1. MD3 主题色系统AB-Admin 不是简单换个 CSS 皮，而是注入了一套完整的 Material Design 3 设计 Token 变量。打开插件代码可以看到，它定义了几十组 CSS 自定义属性：:root { --md-primary: #6750A4; --md-on-primary: #FFFFFF; --md-surface: #FFFBFF; --md-on-surface: #1C1B1F; --md-outline: #79747E; /* ... 几十个变量 */ }暗色模式也有一套对应变量（--md-dark-primary、--md-dark-surface 等），切换时自动生效。这意味着插件不仅仅改了配色，而是建立了一套可被其他插件引用的设计系统。插件内置了多种预设配色方案（紫色、红色、蓝色等），也可以自定义主题色。颜色值会根据亮/暗模式自动生成对应的明暗变体。2. 亮暗模式切换通过 标签的 data-theme="light" 或 data-theme="dark" 属性手动切换，不依赖系统偏好。这比 @media (prefers-color-scheme: dark) 更灵活——你可以白天用亮色、晚上切暗色，不管系统设置是什么。对于插件开发者来说，暗色样式建议用 [data-theme="dark"] 选择器覆盖，而不是依赖媒体查询：/* 正确写法 */ .my-card { background: var(--md-surface-container-low, #f7f2fa); } [data-theme="dark"] .my-card { background: var(--md-surface-container-low, #1d1b20); }3. 响应式设计内置移动端适配，在手机上自动切换为顶部折叠菜单模式。后台管理不再只能坐在电脑前操作——手机上也能舒舒服服地审阅评论、管理文章。4. 自定义登录页背景支持设置登录页背景图片 URL，并可调整虚化方式和虚化大小。告别千篇一律的默认登录页，给博客一个有辨识度的后台入口。5. 双布局模式支持侧边栏布局和顶部导航布局两种后台布局，根据个人习惯自由切换。侧边栏模式更传统，顶部导航模式则更接近现代 Web 应用的风格。6. AJAX 页面导航后台页面切换采用 AJAX 方式，不完整重载页面。每次导航后会派发 ab:pageload 事件，让兼容脚本知道页面变了、该重新执行修复了。// 监听 AB-Admin 的 AJAX 导航事件 document.addEventListener('ab:pageload', function(e) { var url = e.detail.url; console.log('页面已切换到:', url); // 在这里重新绑定 DOM 事件、注入样式等 });这个设计直接影响了兼容脚本的工作方式——稍后详细说。开发者 APIAB-Admin 不只是"好看"，它还提供了一套面向插件开发者的 API。横幅通知：showNotice()if (typeof AdminBeautify !== 'undefined') { AdminBeautify.showNotice('操作成功！', { type: 'success' }); }调用前记得判断 AdminBeautify 是否存在，避免插件未启用时报错。对话框 API：alert / confirm / promptAB-Admin 覆写了原生的 alert、confirm、prompt，将它们替换为 MD3 风格的异步弹窗。同时也提供了 Promise 化的 API：// 异步确认框 const result = await AdminBeautify.confirm('确定删除这篇文章？'); if (result) { // 用户点了确认 } // 异步输入框 const name = await AdminBeautify.prompt('请输入分类名称：');踩坑提醒：覆写后，原生 confirm() 和 prompt() 不再阻塞——它们会立即返回 false / null。如果你的老代码依赖同步返回值，逻辑会被打断，需要改用 Promise API。兼容性脚本系统这是 AB-Admin 最有特色的设计之一。插件目录下有个 assets/compat/ 文件夹，专门存放用于修复其他插件排版问题的 JS 脚本：assets/compat/ ├── FuckAdComment.js # 反广告评论增强 ├── Links.js # Links Plus 友链插件兼容 ├── Mirages.js # Mirages 主题兼容 ├── Notice.js # Notice 通知插件兼容 ├── TelegramNotice.js # Telegram 推送插件兼容 ├── TeStore.js # TeStore 插件仓库兼容 ├── Typecho121.js # Typecho 1.2.1 版本兼容 └── README.md # 开发文档工作原理AB-Admin 自动扫描 compat/ 目录下所有 .js 文件每个脚本的元数据（名称、简介、适用插件）展示在设置页面用户可单独启用/禁用每个脚本支持一键从 GitHub 同步最新兼容脚本支持添加外部 JS 链接加载额外兼容脚本脚本开发规范每个兼容脚本需要在头部注释中声明元数据：/** * @name MyPlugin 兼容 * @description 修复 MyPlugin 在 AB-Admin 下的排版问题 * @plugins MyPlugin * @version 1.0.0 * @author YourName */关键注意事项：必须判断页面：通过 URL 或 DOM 确认是否为目标页面，不要影响其他页面监听 ab:pageload：由于 AJAX 导航，脚本只在首次加载执行一次，不监听此事件则跳转后修复不生效保持幂等：修复函数可能被多次调用，避免重复注入；离开目标页面时需清理已注入的样式兼容暗色模式：同时处理 [data-theme="dark"] 下的显示效果AB Store：配套插件仓库AB-Admin 还有一个配套插件 AdminBeautifyStore（AB Store），它是一个 Typecho 插件仓库，可以在后台直接搜索、安装、管理其他插件，不需要再手动下载上传。AB Store 的主要功能：在后台浏览插件市场一键安装/更新插件支持开发者投稿与 AB-Admin 深度集成，安装的插件自动适配兼容脚本两个插件配合使用，Typecho 的插件管理体验直接拉满，从"手动 FTP 时代"进入了"应用商店时代"。安装方法方法一：手动安装前往 GitHub Releases 下载最新版本解压后将 AdminBeautify 文件夹上传至 /usr/plugins/进入后台 → 控制台 → 插件 → 启用 AdminBeautify清除浏览器缓存（重要！）方法二：通过 AB Store 安装如果你已经装了 AB Store，可以直接在插件市场搜索安装。安装后配置启用插件后进入设置页面，可以配置：主题色：选择预设方案或自定义颜色布局模式：侧边栏 / 顶部导航登录页背景：填入图片 URL，调整虚化参数兼容脚本：逐个启用/禁用字体：可选加载 Noto Sans SC外部 JS：添加额外兼容脚本链接实际体验我博客上实际安装的就是 v2.1.43 版本。几个直观感受：界面：MD3 风格确实好看很多，圆角卡片、层次分明的阴影、流畅的过渡动画，写文章时心情都好了。暗色模式在夜间使用非常舒适，不是简单的黑白反转，而是有一套完整的暗色色板。性能：主 JS 文件约 200KB，CSS 约 197KB，体积不算小但可以接受。AJAX 导航让后台切换页面几乎无感，比原生整页刷新快不少。兼容性：内置的兼容脚本覆盖了常见的 Typecho 插件（Notice、Links Plus、TelegramNotice 等），基本开箱即用。如果你用的是不太常见的插件，可能需要自己写兼容脚本，但文档写得很清楚，门槛不高。稳定性：从 2.1.4x 版本起完全不再加密 JS 代码，透明度很高。作者更新也比较勤快，GitHub 上的 issue 响应及时。注意事项清除缓存：启用后如果样式异常，先清浏览器缓存异步弹窗陷阱：confirm() / prompt() 不再阻塞，老代码需要适配1.2.1 兼容性：Typecho 1.2.1 下导航栏可能有 CSS 问题，需在设置中开启 1.2.1 兼容脚本CSS 冲突：如果之前装过其他后台美化插件（如 SimpleAdmin），可能存在样式冲突弹窗 z-index：第三方插件的全局弹窗建议 z-index 设为 9999 以上（AB 侧边栏约 1000）字体继承：自定义组件建议用 font-family: inherit 跟随页面字体总结AB-Admin 是目前 Typecho 生态里完成度最高的后台美化插件，没有之一。它不只是换了一套皮肤，而是建立了一套完整的设计系统——从 MD3 色彩变量到开发者 API，从兼容脚本机制到 AJAX 导航，每个环节都考虑到了。如果你还在用 Typecho 默认后台，强烈建议试试。装完之后你会觉得：原来 Typecho 后台也可以这么好看。GitHub：github.com/lhl77/Typecho-Plugin-AdminBeautifyAB Store：github.com/lhl77/Typecho-Plugin-AdminBeautifyStore作者博客：blog.lhl.one
uni-app Canvas 海报生成实战：绘制、二维码、保存相册与分享
生成推广海报是营销类小程序的高频需求：商品详情页"分享海报"、邀请好友"专属邀请码"。要在 Canvas 上绘制图片、文字、二维码，再保存到相册或转发，每一步都有跨端陷阱。本文完整实现一个生产级海报生成方案。一、需求拆解一张典型推广海报包含四层：背景图（设计师出图，固定模板）商品图/用户头像（网络图片，动态）文案文字（标题、价格、邀请语）小程序码/二维码（后端生成或前端绘制）┌─────────────────┐ │ 背景图模板 │ │ ┌───────┐ │ │ │ 商品图 │ │ │ └───────┘ │ │ 商品标题 │ │ ¥99.00 │ │ ┌────┐ 长按识别 │ │ │二维码│ │ │ └────┘ │ └─────────────────┘二、踩坑第一关：网络图片必须先下载Canvas 的 drawImage 在小程序端不能直接绘制网络图片——必须先 uni.getImageInfo 下载到本地临时路径：async function loadImages(urls) { const promises = urls.map(url => new Promise((resolve, reject) => { uni.getImageInfo({ src: url, success: resolve, fail: reject }) }) ) return Promise.all(promises) }两个前置条件：图片域名要加入 downloadFile 合法域名（小程序后台配置），否则真机直接失败开发者工具勾选"不校验合法域名"只在开发阶段有效，上线前必须配置好三、Canvas 绘制核心代码模板与画布初始化 <view class="poster-mask" v-if="visible" @click="visible = false"> <view class="poster-wrap" @click.stop> <view class="poster-actions"> 保存到相册 </view> </view> </view> 绘制函数async function draw(data) { const scale = canvasWidth.value / DESIGN_WIDTH // 微信新版 canvas 2d 接口更清晰，旧 canvas-id 接口在新基础库中表现不稳 const ctx = uni.createCanvasContext('posterCanvas') // 1. 预下载所有网络图片 const [bg, goodsImg, avatar, qrCode] = await loadImages([ data.bgUrl, data.goodsUrl, data.avatarUrl, data.qrUrl ]) ctx.drawImage(bg.path, 0, 0, DESIGN_WIDTH, DESIGN_HEIGHT) // 2. 商品图（圆角矩形裁剪） drawRoundImage(ctx, goodsImg.path, 40, 200, 670, 500, 16) // 3. 标题：手动换行 drawWrappedText(ctx, data.title, { x: 40, y: 740, maxWidth: 670, lineHeight: 44, maxLines: 2, fontSize: 34, color: '#333' }) // 4. 价格 ctx.setFillStyle('#e93b3d') ctx.setFontSize(28) ctx.fillText('¥', 40, 860) ctx.setFontSize(44) ctx.fillText(data.price, 70, 860) // 5. 头像 + 昵称 drawRoundImage(ctx, avatar.path, 40, 1000, 72, 72, 36) ctx.setFillStyle('#666') ctx.setFontSize(26) ctx.fillText(`${data.nickname} 邀请你`, 130, 1045) // 6. 二维码 ctx.drawImage(qrCode.path, 560, 980, 160, 160) ctx.setFillStyle('#999') ctx.setFontSize(22) ctx.fillText('长按识别', 570, 1175) // 7. 绘制（scale 到实际画布大小） ctx.scale(scale, scale) ctx.draw(false, () => { // 绘制完成回调，可以导出了 posterReady.value = true }) }圆角图片裁剪Canvas 1.0 接口没有原生圆角，用路径裁剪实现：function drawRoundImage(ctx, path, x, y, w, h, r) { ctx.save() ctx.beginPath() // 圆角矩形路径 ctx.moveTo(x + r, y) ctx.arcTo(x + w, y, x + w, y + h, r) ctx.arcTo(x + w, y + h, x, y + h, r) ctx.arcTo(x, y + h, x, y, r) ctx.arcTo(x, y, x + w, y, r) ctx.closePath() ctx.clip() // 裁剪 ctx.drawImage(path, x, y, w, h) ctx.restore() }文字自动换行Canvas 1.0 的 fillText 不换行，手动按宽度切分：function drawWrappedText(ctx, text, { x, y, maxWidth, lineHeight, maxLines, fontSize, color }) { ctx.setFillStyle(color) ctx.setFontSize(fontSize) const chars = text.split('') let line = '' let lines = [] for (const char of chars) { const test = line + char if (ctx.measureText(test).width > maxWidth) { lines.push(line) line = char if (lines.length === maxLines) break } else { line = test } } if (lines.length < maxLines && line) lines.push(line) // 最后一行超限时加省略号 if (lines.length === maxLines && line) { let last = lines[maxLines - 1] while (ctx.measureText(last + '...').width > maxWidth) { last = last.slice(0, -1) } lines[maxLines - 1] = last + '...' } lines.forEach((l, i) => { ctx.fillText(l, x, y + i * lineHeight) }) }四、导出与保存相册async function saveToAlbum() { if (!posterReady.value) { return uni.showToast({ title: '海报生成中', icon: 'none' }) } // 1. 导出画布为图片 const [err, res] = await new Promise((resolve) => { uni.canvasToTempFilePath({ canvasId: 'posterCanvas', // 重要：destWidth/Height 用设计稿尺寸，解决导出模糊问题 destWidth: DESIGN_WIDTH * 2, destHeight: DESIGN_HEIGHT * 2, success: resolve, fail: resolve }) }) if (err) { return uni.showToast({ title: '导出失败', icon: 'none' }) } // 2. 保存到相册（需要授权） const [saveErr] = await new Promise((resolve) => { uni.saveImageToPhotosAlbum({ filePath: res.tempFilePath, success: () => resolve([null]), fail: (e) => resolve([e]) }) }) if (saveErr) { // 授权被拒的处理 if (saveErr.errMsg.includes('auth deny') || saveErr.errMsg.includes('authorize')) { showModalThenOpenSetting() } else { uni.showToast({ title: '保存失败', icon: 'none' }) } return } uni.showToast({ title: '已保存到相册', icon: 'success' }) } function showModalThenOpenSetting() { uni.showModal({ title: '提示', content: '需要相册权限才能保存海报，请在设置中开启', success: (res) => { if (res.confirm) { uni.openSetting({}) } } }) }保存相册授权被拒是必踩的坑：第一次拒绝后，后续调用 saveImageToPhotosAlbum 会直接 fail，必须引导用户去 openSetting 手动开启。这段兜底代码一定要有。五、小程序码的两种来源方式一：后端生成（推荐）带参小程序码必须由后端调 wxacode.getUnlimited 接口生成：// 前端只要传参 const qrUrl = await request.post('/qrcode/generate', { scene: `uid=${userStore.userInfo.id}`, // 最大 32 字符 page: 'pages/goods/detail' })scene 参数会出现在 onLoad 的 options.query 里，注意 32 字符上限和仅支持数字/字母/部分符号。方式二：前端绘制普通二维码不需要小程序码（H5 分享场景）时，可以纯前端生成。引一个小巧的 QR 库（如 weapp-qrcode），把矩阵点画到 canvas 上即可，零网络请求。六、新版 Canvas 2D 接口（进阶）微信基础库 2.9+ 提供 Canvas 2D 接口，类型对齐 Web 标准，清晰度控制更好：async function drawWithCanvas2d(data) { // 获取 canvas 节点 const query = uni.createSelectorQuery() query.select('#posterCanvas').fields({ node: true, size: true }) const { node, width } = await new Promise(r => query.exec(res => r(res[0]))) const dpr = uni.getSystemInfoSync().pixelRatio node.width = width * dpr // 物理像素 node.height = canvasHeight.value * dpr const ctx = node.getContext('2d') ctx.scale(dpr, dpr) // 之后就是标准 Web Canvas API：roundRect、drawImage(Image 实例)... const img = node.createImage() img.src = bgTempPath await new Promise(r => { img.onload = r }) ctx.drawImage(img, 0, 0, width, canvasHeight.value) }新版接口的图片要用 node.createImage() 创建 Image 实例加载，支持 ctx.roundRect() 原生圆角，长望建议迁移。旧接口（canvas-id + createCanvasContext）目前仍广泛使用，本文两种都给了方案。七、完整流程串联用户点击"生成海报" → show(posterData) 打开弹层 → loadImages 预下载（失败 toast + 埋点） → ctx 绘制各图层 → draw 回调标记 ready → 用户点"保存" → canvasToTempFilePath 导出（destWidth 2倍防模糊） → saveImageToPhotosAlbum（授权被拒 → openSetting 引导） → toast 成功总结网络图片绘制前必须 getImageInfo 下载，域名要配白名单导出防模糊：destWidth/destHeight 设为逻辑尺寸 × 2Canvas 1.0 圆角靠 clip 裁剪，文字换行靠 measureText 手动切保存相册被拒授权后必须 openSetting 引导，这是审核与差评高发点带参小程序码后端生成，scene 限 32 字符新项目直接上 Canvas 2D 接口，对齐 Web 标准海报功能链路长、坑密集，把本文的流程清单存好，下次实现能省一半时间。
客服聊天、订单状态推送、实时协作——WebSocket 是小程序实时通信的主力方案。但 uni.connectSocket 只是一根裸管道：弱网下说断就断、切后台被系统杀掉、消息去重和顺序全要自己做。本文实现一套健壮的 Socket 管理层，并以聊天消息列表为落地场景。一、原生 API 的局限// 裸用 uni.connectSocket 的典型代码 uni.connectSocket({ url: 'wss://api.example.com/ws' }) uni.onSocketMessage((res) => { console.log(res.data) // 然后呢？ })生产环境马上会遇到的问题：连接会静默断开：网络抖动、服务器超时、手机切后台，断了没人通知业务层没有重连机制：断开后必须用户手动刷新页面消息没有可靠性保障：发送方不知道对方收没收到，重复消息无法识别所以要封装的核心能力：心跳检测 + 指数退避重连 + 消息 ACK。二、Socket 管理类实现// utils/socket.js class SocketManager { constructor(options = {}) { this.url = options.url this.heartbeatInterval = options.heartbeatInterval || 30000 this.reconnectMaxDelay = options.reconnectMaxDelay || 30000 this.task = null // SocketTask this.isConnected = false this.manualClosed = false // 主动关闭标记，区分意外断开 this.heartbeatTimer = null this.reconnectTimer = null this.reconnectCount = 0 // 重连次数（用于退避） this.messageHandlers = new Map() // 按消息类型分发 this.pendingQueue = [] // 未连接时的待发送队列 this.seq = 0 // 消息序号，用于 ACK this.pendingAcks = new Map() // seq -> { resolve, timer } } // ========== 连接管理 ========== connect() { if (this.isConnected) return this.manualClosed = false // uni-app 返回 SocketTask，用任务级监听而非全局监听（支持多连接） this.task = uni.connectSocket({ url: this.url, success: () => {}, fail: () => this.scheduleReconnect() }) this.task.onOpen(() => { this.isConnected = true this.reconnectCount = 0 this.startHeartbeat() // 重连成功后：重新拉取离线消息（关键！） this.emit('reconnected') // 冲积积压消息 while (this.pendingQueue.length) { const payload = this.pendingQueue.shift() this.task.send({ data: JSON.stringify(payload) }) } }) this.task.onMessage((res) => this.handleMessage(res)) this.task.onClose(() => { this.isConnected = false this.stopHeartbeat() if (!this.manualClosed) { this.scheduleReconnect() // 意外断开才重连 } }) this.task.onError(() => { this.isConnected = false this.stopHeartbeat() if (!this.manualClosed) { this.scheduleReconnect() } }) } close() { this.manualClosed = true this.stopHeartbeat() clearTimeout(this.reconnectTimer) this.task && this.task.close({}) } // ========== 指数退避重连 ========== scheduleReconnect() { if (this.manualClosed) return if (this.reconnectTimer) return // 已有重连任务 this.reconnectCount++ if (this.reconnectCount > 10) { this.emit('dead') // 放弃重连，通知上层 return } // 指数退避：1s 2s 4s 8s...封顶 30s，加随机抖动防止雪崩 const delay = Math.min( 1000 * Math.pow(2, this.reconnectCount - 1), this.reconnectMaxDelay ) + Math.random() * 1000 this.reconnectTimer = setTimeout(() => { this.reconnectTimer = null this.connect() }, delay) } // ========== 心跳保活 ========== startHeartbeat() { this.stopHeartbeat() this.heartbeatTimer = setInterval(() => { // 期望 5 秒内收到 pong，超时视为假死 this.sendWithTimeout({ type: 'ping' }, 5000) .catch(() => { // pong 没回来：连接假死，强制重建 this.task && this.task.close({}) this.isConnected = false this.scheduleReconnect() }) }, this.heartbeatInterval) } stopHeartbeat() { clearInterval(this.heartbeatTimer) this.heartbeatTimer = null } // ========== 消息处理 ========== handleMessage(res) { let msg try { msg = JSON.parse(res.data) } catch (e) { return } // 心跳响应单独处理 if (msg.type === 'pong') { this.emit('_pong', msg) return } // ACK 响应：匹配待确认消息 if (msg.type === 'ack' && this.pendingAcks.has(msg.ackFor)) { const pending = this.pendingAcks.get(msg.ackFor) clearTimeout(pending.timer) this.pendingAcks.delete(msg.ackFor) pending.resolve() return } // 业务消息按类型分发 this.emit(msg.type, msg) } // ========== 发送（带 ACK 确认）========== send(data, { ack = true } = {}) { if (!this.isConnected) { // 未连接先入队，连接成功后冲积 this.pendingQueue.push(data) return Promise.resolve() } if (!ack) { return new Promise((resolve, reject) => { this.task.send({ data: JSON.stringify(data), success: resolve, fail: reject }) }) } // 带 seq 的可靠发送：超时未 ACK 视为失败 this.seq++ const payload = { ...data, seq: this.seq } return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pendingAcks.delete(this.seq) reject(new Error('消息未确认')) }, 10000) this.pendingAcks.set(this.seq, { resolve, timer }) this.task.send({ data: JSON.stringify(payload) }) }) } sendWithTimeout(data, timeout) { return Promise.race([ this.send(data), new Promise((_, reject) => setTimeout(reject, timeout)) ]) } // ========== 事件订阅 ========== on(event, handler) { if (!this.messageHandlers.has(event)) { this.messageHandlers.set(event, new Set()) } this.messageHandlers.get(event).add(handler) return () => this.off(event, handler) // 返回取消函数 } off(event, handler) { this.messageHandlers.get(event)?.delete(handler) } emit(event, payload) { this.messageHandlers.get(event)?.forEach(fn => fn(payload)) } } export default SocketManager三、与生命周期联动Socket 生命周期必须挂到 App 和页面上：// App.vue import SocketManager from '@/utils/socket' import { useUserStore } from '@/stores/user' let socket = null export default { onLaunch() { socket = new SocketManager({ url: `wss://api.example.com/ws?token=${useUserStore().token}` }) uni.$socket = socket }, onShow() { // 从后台回前台：直接重连（后台时连接多半已被杀） uni.$socket.connect() }, onHide() { // 切后台：主动断开省电省流量，回前台再连 uni.$socket.close() } }切后台策略：小程序切后台后 WebSocket 会在数秒内被系统挂起，与其等它假死，不如 onHide 主动 close、onShow 立即重连，配合服务端的离线消息补拉，体验反而更好。四、实战：聊天消息列表 <scroll-view scroll-y class="chat-list" :scroll-into-view="scrollTo"> <view v-for="(msg, index) in messages" :key="msg.id" :id="`msg-${index}`" class="msg-row" :class="{ mine: msg.fromMe }" > <view class="bubble"> <text>{{ msg.content }}</text> <text v-if="msg.sending" class="status">…</text> <text v-else-if="msg.failed" class="status failed" @click="resend(msg)">!</text> </view> </view> </scroll-view> <view class="input-bar"> 发送 </view> 几个体验细节：乐观更新 + 状态标记：消息立即上屏，sending 三点、failed 感叹号可点重发scroll-into-view 滚到底：比手动计算 scroll-top 简单可靠onUnload 解绑：on() 返回的取消函数必须调用，否则页面销毁后 handler 仍在跑五、服务端配合要点前端这层机制需要后端按约定配合，联调前对齐协议：// 客户端 ping → 服务端 pong { "type": "ping" } { "type": "pong" } // 客户端带 seq 发送 → 服务端回 ack { "type": "chat:send", "content": "hello", "seq": 1001 } { "type": "ack", "ackFor": 1001 } // 服务端推送（带全局唯一 id，客户端用于去重） { "type": "chat:message", "id": "srv_888888", "content": "hi", "time": 1724360000000 }服务端还需要：消息落库 + 离线消息接口（客户端重连后按 afterId 补拉）、单连接的 token 校验（URL 带 token 或首条消息鉴权）、同一账号多端登录的踢下线策略。六、小程序特殊限制wss 必须：正式环境只能连 wss，且域名要配到 socket 合法域名并发连接数：微信小程序同时最多 2 个 WebSocket 连接，多路复用靠消息 type 分发而不是开多条连接后台挂起：切后台约 5 秒后收不到消息，所以离线补拉机制不是可选项而是必选项App 端差异：App 端没有 2 连接限制，但同样受省电策略影响，onHide/onShow 的断连重连逻辑全端通用总结SocketManager 四件套：心跳保活、指数退避重连（带随机抖动）、发送队列、ACK 确认onHide 主动断、onShow 立即连、reconnected 事件触发离线补拉消息三要素：全局唯一 ID 去重、乐观更新上屏、失败可重发小程序最多 2 条并发连接，靠 type 分发复用单连接与后端先对齐协议（ping/ack/id）再动工，能省掉大量联调返工这套 Socket 层写一次可以用在所有项目里，聊天、推送、协作编辑都只是消息 type 的差异。