首页
直播
壁纸
友链
搜索
1
微信小程序支付全链路实战:JSAPI 下单、调起支付、回调验签与退款
266 阅读
2
微信小程序云开发实战:云函数、云数据库与云存储的正确使用姿势
256 阅读
3
微信小程序自定义 tabBar 实战:custom-tab-bar 从适配到深色模式
255 阅读
4
微信小程序 Skyline 渲染引擎实战:worklet 动画从原理到落地
253 阅读
5
微信小程序分包进阶:独立分包、预下载与分包异步化实战
246 阅读
服务器运维
后端技术
前端技术
梯子
数据库
小程序
登录
搜索
标签搜索
fastadmin
Redis
微信小程序
前端开发
RabbitMQ
Go
服务器
codex
buildadmin
小程序
mysql
Nginx
Docker
Vue3
Node.js
MySQL优化
Linux
TypeScript
JWT
PHP
沿途的风景
累计撰写
74
篇文章
累计收到
0
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
74
篇与
» admin
的结果
2026-08-04
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",而是一种不同的运行时模型。把进程模型、启动流程、常驻内存的边界刻进脑子,后面的中间件、连接池、协程实践才有意义。下一篇我们进入请求处理的核心链路:中间件洋葱模型与依赖注入容器的实战。
2026年08月04日
4 阅读
0 评论
0 点赞
2026-08-04
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 限制,大对象持久化要做节流和规模控制下一篇讲登录授权的"最后一公里"——手机号验证码、头像昵称填写这些微信持续收紧的接口怎么优雅应对。
2026年08月04日
5 阅读
0 评论
0 点赞
2026-08-01
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. 亮暗模式切换通过 <html> 标签的 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
2026年08月01日
5 阅读
0 评论
0 点赞
2026-08-01
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 绘制核心代码模板与画布初始化<template> <view class="poster-mask" v-if="visible" @click="visible = false"> <view class="poster-wrap" @click.stop> <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }" /> <view class="poster-actions"> <button class="action-btn" @click="saveToAlbum">保存到相册</button> </view> </view> </view> </template> <script setup> import { ref } from 'vue' const visible = ref(false) // 以 750x1200 设计稿为例,画布用 2 倍图保证清晰度 const DESIGN_WIDTH = 750 const DESIGN_HEIGHT = 1200 const canvasWidth = ref(0) const canvasHeight = ref(0) async function show(posterData) { visible.value = true // 按屏幕宽度等比缩放画布,但内部坐标系按设计稿 const { windowWidth } = uni.getSystemInfoSync() canvasWidth.value = windowWidth * 0.9 canvasHeight.value = canvasWidth.value * (DESIGN_HEIGHT / DESIGN_WIDTH) await draw(posterData) } defineExpose({ show }) </script>绘制函数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 标准海报功能链路长、坑密集,把本文的流程清单存好,下次实现能省一半时间。
2026年08月01日
5 阅读
0 评论
0 点赞
2026-07-31
uni-app WebSocket 即时通信实战:心跳保活、断线重连与消息列表
客服聊天、订单状态推送、实时协作——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 立即重连,配合服务端的离线消息补拉,体验反而更好。四、实战:聊天消息列表<script setup> import { ref, nextTick } from 'vue' const messages = ref([]) const inputText = ref('') const scrollTo = ref('') let offHandlers = [] onLoad() { const socket = uni.$socket // 订阅新消息 offHandlers.push( socket.on('chat:message', (msg) => { // 去重:服务端消息 ID if (messages.value.some(m => m.id === msg.id)) return messages.value.push({ id: msg.id, content: msg.content, fromMe: false, time: msg.time }) scrollToBottom() }), // 重连后补拉离线消息 socket.on('reconnected', async () => { const lastId = messages.value.length ? messages.value[messages.value.length - 1].id : null const offline = await request.get('/chat/messages', { afterId: lastId, limit: 50 }) // 按时间合并去重 const existIds = new Set(messages.value.map(m => m.id)) offline.filter(m => !existIds.has(m.id)) .forEach(m => messages.value.push({ id: m.id, content: m.content, fromMe: false, time: m.time })) scrollToBottom() }) ) } onUnload() { // 页面销毁必须解绑,否则消息处理器泄漏 offHandlers.forEach(off => off()) } async function send() { const text = inputText.value.trim() if (!text) return inputText.value = '' // 乐观更新:先上屏,发送失败再标记 const tempId = `temp_${Date.now()}` messages.value.push({ id: tempId, content: text, fromMe: true, time: Date.now(), sending: true }) scrollToBottom() try { await uni.$socket.send({ type: 'chat:send', content: text }) const msg = messages.value.find(m => m.id === tempId) if (msg) msg.sending = false } catch (e) { const msg = messages.value.find(m => m.id === tempId) if (msg) { msg.sending = false msg.failed = true // 标记失败,支持点击重发 } } } function scrollToBottom() { nextTick(() => { scrollTo.value = `msg-${messages.value.length - 1}` }) } </script> <template> <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"> <input v-model="inputText" confirm-type="send" @confirm="send" /> <button size="mini" @click="send">发送</button> </view> </template>几个体验细节:乐观更新 + 状态标记:消息立即上屏,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 的差异。
2026年07月31日
4 阅读
0 评论
0 点赞
1
2
3
...
15
0:00