首页
直播
壁纸
友链
搜索
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
条评论
首页
栏目
服务器运维
后端技术
前端技术
梯子
数据库
小程序
页面
直播
壁纸
友链
搜索到
15
篇与
» 前端技术
的结果
2026-08-18
让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战
让 YoduPlayer 切页不断歌:给 Typecho 博客定制 PJAX 的完整实战博客装了 YoduPlayer 这款背景音乐播放器后,一直有个很破坏体验的问题:音乐正放着,点进一篇文章,页面一刷新,歌就断了。访客每看一篇新文章就要重新点一次播放,背景音乐的"背景"两个字完全名存实亡。这篇文章记录我把这个问题彻底解决的全过程:从原理分析,到方案选型,再到自己动手写一个约 200 行的轻量 PJAX,最后处理掉一串切页后才会暴露的隐藏 Bug。完整代码都在文中,可以直接抄走用。一、先搞清楚:歌为什么会断浏览器里的音频播放依赖一个 DOM 元素 <audio>(YoduPlayer 里就是全局的 yaudio 对象)。而传统页面跳转的本质是:点击链接 → 浏览器销毁整个文档 → 请求新页面 → 重新解析 HTML/CSS/JS文档销毁的瞬间,挂在文档里的 <audio> 元素跟着被销毁,播放自然中断。新页面里插件重新输出了一套播放器 HTML 和 JS,但那是一个全新的 yaudio,播放进度、当前曲目全部归零。所以问题的根源不在插件,而在于整页刷新这种导航方式本身。想让音乐不断,就不能销毁承载播放器的那份文档。二、方案选型:为什么是 PJAX解决思路业界已经很成熟了——局部刷新:导航时只替换页面的内容区域,头部、底部、播放器所在的 DOM 保持不动。实现方式常见的有三种:方案原理缺点iframe把整站套进框架页URL 不变、SEO 灾难、移动端体验差SPA 改造前端框架接管路由Typecho 主题基本要重写,成本过高PJAXAJAX 拉取新页面 + History API 改地址只需主题小幅配合PJAX(PushState + AJAX)的原理一句话就能说清:拦截链接点击 → preventDefault 阻止跳转 → XHR 拉取新页面 HTML → 用 DOMParser 解析 → 只取内容区域替换进当前文档 → pushState 更新地址栏整个过程文档从未销毁,<audio> 一直活着,音乐自然不断。同时 URL 会真实变化、浏览器前进后退可用、对搜索引擎完全透明——这是它碾压 iframe 的地方。YoduPlayer 的 README 里也写了"需要主题支持 pjax 或 instantclick",说明作者早就预留了这条路,缺的只是主题侧的实现。我的主题是 Joe,直接引现成的 PJAX 库和主题代码有各种兼容性小毛病,所以干脆自己写了一个定制版。三、动手实现3.1 确定内容容器边界第一步是划分"哪些 DOM 切页时要换,哪些不能动"。看 Joe 主题的 index.php 结构:<div id="Joe"> <!-- 文章列表 / 正文 / 侧边栏 / footer 都在这里面 --> </div> <?php $this->footer(); ?> <!-- YoduPlayer 的播放器 HTML 输出在这里 --> </body>很清晰:#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 插入的 <script> 不会执行,手动重建 var scripts = oldC.querySelectorAll('script'); for (var i = 0; i < scripts.length; i++) { var s = scripts[i]; if (s.hasAttribute(NO_INSTANT)) continue; // 带标记的跳过 var ns = document.createElement('script'); for (var j = 0; j < s.attributes.length; j++) { ns.setAttribute(s.attributes[j].name, s.attributes[j].value); } ns.textContent = s.textContent; s.parentNode.replaceChild(ns, s); } if (push) history.pushState({ url: url }, '', url); window.scrollTo(0, 0); // 重新触发 DOMContentLoaded,让主题的初始化逻辑跑一遍 try { document.dispatchEvent(new Event('DOMContentLoaded')); } catch (err) {} // 广播事件,其他脚本可监听 document.dispatchEvent(new CustomEvent('pjax:complete', { detail: { url: url } })); } })();几个容易踩坑的点单独说一下:innerHTML 里的脚本不执行。这是浏览器的安全设计,所以必须手动 createElement('script') 重建节点。而 data-no-instant 标记的脚本(比如插件的初始化配置)跳过不执行——它们已经在页面上活着了,再执行一遍等于重置播放器。一定要有兜底。请求失败、超时、返回的不是 HTML、新页面找不到容器,任何一种异常情况都直接 location.href 真跳转,宁可断歌也不能白屏。popstate 处理前进后退。pushState 时带上的 state.url 在这里取出来用,否则回退会没有反应。最后在 public/include.php 里引入,注意自身要带 data-no-instant,防止被未来的自己重复执行:<script src="<?php _getAssets('assets/lib/pjax/pjax.js'); ?>" data-no-instant></script>3.3 与 YoduPlayer 的分工做完上面这步,音乐其实已经不会断了。剩下的工作是让播放器的 UI 在切页后依然正常。YoduPlayer 的加载分两部分,正好对应两种处理方式:// Plugin.php footer() 中的关键输出 // 音频引擎 + 核心函数:带 data-no-instant,切页不重执行 <script data-no-instant> var yaudio = new Audio(); var musicArr = [{title:"xxx", artist:"xxx", mp3:"http:xxx", cover:"xxx"},]; var sj = musicArr[0]; yaudio.src = sj.mp3; </script> <script src=".../js/player.js" data-no-instant></script> // UI 层:不带标记,切页后随容器重建 <script src=".../js/prpr.js"></script>分工非常清晰: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="_" 的隐藏字段作为令牌,服务端校验它。这段脚本在 <head> 里,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 的开源贡献。
2026年08月18日
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-07-14
Vue 3 插槽深度实战:默认、具名与作用域插槽及自定义指令
如果说 props 是组件的"输入参数",插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件,绕不开插槽;而自定义指令则是 DOM 级复用的利器。这两个主题合起来,是 Vue 中级开发者必须吃透的内容。一、插槽的三种形态1. 默认插槽父组件传入的内容替换子组件的 <slot> 占位:<!-- Card.vue --> <template> <div class="card"> <div class="card-body"> <slot></slot> </div> </div> </template><!-- 使用 --> <Card> <h3>订单详情</h3> <p>共 3 件商品,合计 ¥299.00</p> </Card>2. 具名插槽:多区域布局组件有多个内容区域时,用 name 区分。典型场景:页头/页身/页脚的布局组件。<!-- PageLayout.vue --> <template> <div class="page"> <header class="page-header"> <slot name="header"></slot> </header> <main class="page-main"> <slot></slot> <!-- 默认插槽 --> </main> <footer class="page-footer"> <slot name="footer"></slot> </footer> </div> </template><!-- 使用:template + v-slot 指定区域 --> <PageLayout> <template #header> <h1>管理后台</h1> </template> <p>这里是主内容区域</p> <template #footer> <span>© 2026 My Corp</span> </template> </PageLayout>注意 #header 是 v-slot:header 的缩写,且 v-slot 只能写在 <template> 上(默认插槽的简写除外)。3. 作用域插槽:数据反向传递普通插槽的内容编译在父组件作用域,无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容:<!-- DataList.vue --> <script setup> const props = defineProps({ items: { type: Array, required: true } }) </script> <template> <ul class="data-list"> <li v-for="(item, index) in items" :key="item.id"> <slot :item="item" :index="index"> <!-- 后备内容:父组件不传插槽时的默认渲染 --> {{ item.name }} </slot> </li> </ul> </template><!-- 父组件决定每一行怎么渲染 --> <DataList :items="orders"> <template #default="{ item, index }"> <div class="order-row"> <span>#{{ index + 1 }}</span> <span>{{ item.orderNo }}</span> <span :class="item.status">{{ statusText[item.status] }}</span> </div> </template> </DataList>这是设计"无头组件"(Headless Component)的核心思想:组件负责数据和逻辑,父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。实战:封装一个通用描述列表组件综合运用具名 + 作用域插槽,封装一个类似 Ant Design Descriptions 的组件:<!-- DescList.vue --> <script setup> defineProps({ items: { type: Array, required: true } }) </script> <template> <dl class="desc-list"> <template v-for="item in items" :key="item.field"> <dt>{{ item.label }}</dt> <dd> <!-- 命名插槽接管某个字段的渲染,否则显示原始值 --> <slot :name="item.field" :item="item"> {{ item.value }} </slot> </dd> </template> </dl> </template><!-- 使用方 --> <DescList :items="[ { field: 'name', label: '姓名', value: user.name }, { field: 'avatar', label: '头像', value: user.avatar }, { field: 'status', label: '状态', value: user.status } ]"> <template #avatar="{ item }"> <img :src="item.value" class="avatar" /> </template> <template #status="{ item }"> <el-tag :type="item.value === 1 ? 'success' : 'danger'"> {{ item.value === 1 ? '正常' : '禁用' }} </el-tag> </template> </DescList>一个组件同时满足了通用性和定制性——这就是插槽设计的精髓。二、自定义指令当复用的是 DOM 行为而不是组件结构时,用指令。指令只在底层操作 DOM,不关心业务。注册与钩子// main.js 全局注册 const app = createApp(App) app.directive('focus', { mounted(el) { el.focus() } })Vue 3 指令的钩子与组件生命周期对齐:钩子触发时机created元素属性和事件监听器设置之前beforeMount挂载之前mounted挂载到 DOM 后beforeUpdate更新前updated更新后beforeUnmount卸载前unmounted卸载后绝大多数指令只需要 mounted 和 updated,可以用简写函数形式:app.directive('color', (el, binding) => { el.style.color = binding.value })实战指令一:v-loading// directives/loading.js export const vLoading = { mounted(el, binding) { const mask = document.createElement('div') mask.className = 'v-loading-mask' mask.innerHTML = '<div class="v-loading-spinner"></div>' el.style.position = el.style.position || 'relative' el.appendChild(mask) el.__loadingMask = mask toggle(el, binding.value) }, updated(el, binding) { toggle(el, binding.value) }, unmounted(el) { el.__loadingMask?.remove() } } function toggle(el, show) { el.__loadingMask.style.display = show ? 'flex' : 'none' }<div class="panel" v-loading="fetching"> <p v-for="row in rows" :key="row.id">{{ row.name }}</p> </div>实战指令二:v-debounce按钮防抖是高频需求,每个地方手写 setTimeout 太啰嗦:export const vDebounce = { mounted(el, binding) { const [fn, delay = 300] = binding.value instanceof Array ? binding.value : [binding.value, 300] let timer = null el.__debounceHandler = (event) => { if (timer) clearTimeout(timer) timer = setTimeout(() => fn(event), delay) } el.addEventListener('click', el.__debounceHandler) }, unmounted(el) { el.removeEventListener('click', el.__debounceHandler) } }<button v-debounce="[saveOrder, 500]">提交订单</button>实战指令三:v-permission 权限控制import { useUserStore } from '@/stores/user' export const vPermission = { mounted(el, binding) { const userStore = useUserStore() const required = binding.value // 'order:delete' const modifiers = binding.modifiers // { some: true } → v-permission.some let hasPermission if (modifiers.some) { // 任一权限即可 hasPermission = [].concat(required).some(p => userStore.permissions.includes(p)) } else { hasPermission = userStore.permissions.includes(required) } if (!hasPermission) { el.parentNode?.removeChild(el) } } }<button v-permission="'order:delete'">删除订单</button> <button v-permission.some="['a', 'b']">复合操作</button><script setup> 中局部注册指令也支持在 SFC 内直接定义,变量名以 v 开头即可自动注册:<script setup> // 变量名 vFocus 自动映射为 v-focus 指令 const vFocus = { mounted: (el) => el.focus() } </script> <template> <input v-focus /> </template>插槽 vs 指令:选型原则维度插槽指令复用内容结构 + 样式DOM 行为典型场景布局、列表渲染定制权限、防抖、拖拽、埋点作用对象组件原生 DOM 元素灵活性高(父级完全接管渲染)中(操作属性和事件)一个简单的判断:需求是"换个样子"用插槽,需求是"加点行为"用指令。总结作用域插槽 = 子组件向插槽内容"回传数据",是无头组件的基石具名插槽支撑多区域布局,#name 是标准缩写自定义指令只在需要直接操作 DOM 时使用,能用组件解决的别用指令指令钩子与组件生命周期对齐,简写形式覆盖 mounted + updatedv-loading、v-debounce、v-permission 是三个最值得收进工具箱的指令把插槽和指令用好,你封装的组件会从"能用"进化到"好用"。下一篇文章我们聊聊组合式函数(Composables)——Vue 3 逻辑复用的终极形态。
2026年07月14日
5 阅读
0 评论
0 点赞
2026-07-12
Vue Router 4 路由实战:动态路由、导航守卫与懒加载
任何多页面 SPA 都绕不开 Vue Router。但很多项目对它的使用停留在"能跳转"——路由懒加载没配、守卫写得一团乱麻、动态路由权限方案稀里糊涂。本文以 Vue Router 4(对应 Vue 3)为准,把这几个中级必考点一次讲透。基础配置与懒加载路由懒加载是必选项打包工具默认会把所有页面组件打进一个 bundle,首屏加载动辄几 MB。动态 import() 让每个页面按需加载:// router/index.ts import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: () => import('@/views/Home.vue'), children: [ { path: 'orders', component: () => import('@/views/order/List.vue') }, { path: 'orders/:id', component: () => import('@/views/order/Detail.vue'), props: true // 把路由参数作为 props 传入组件 } ] } ] })配合 Vite 的手动分包,可以进一步把公共依赖抽出来:// vite.config.ts export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'] } } } } }props 解耦:别在组件里读 $route<!-- ❌ 与路由强耦合,组件无法复用 --> <script setup> import { useRoute } from 'vue-router' const route = useRoute() const id = route.params.id </script> <!-- ✅ props: true 后,组件像普通组件一样接收参数 --> <script setup> defineProps({ id: String }) </script>函数模式更灵活:{ path: 'orders/:id', component: OrderDetail, props: route => ({ id: Number(route.params.id), tab: route.query.tab }) }动态路由与权限系统后台管理系统的经典需求:不同角色看到不同菜单。标准方案是前置白名单 + 动态添加路由:第一步:定义静态与动态路由// router/index.ts import { createRouter, createWebHistory } from 'vue-router' // 静态路由:任何人都能访问 export const constantRoutes = [ { path: '/login', component: () => import('@/views/Login.vue') }, { path: '/404', component: () => import('@/views/404.vue') } ] // 动态路由:按角色分配,meta.roles 记录可访问角色 export const asyncRoutes = [ { path: '/', component: () => import('@/views/Layout.vue'), children: [ { path: 'dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '工作台', roles: ['admin', 'operator'] } }, { path: 'system', component: () => import('@/views/system/Index.vue'), meta: { title: '系统管理', roles: ['admin'] } } ] } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes })第二步:登录后过滤并挂载// stores/permission.ts import { defineStore } from 'pinia' import { asyncRoutes, constantRoutes } from '@/router' function hasPermission(roles, route) { return route.meta?.roles ? roles.some(role => route.meta.roles.includes(role)) : true // 没声明 roles 默认放行 } export function filterRoutes(routes, roles) { return routes.reduce((acc, route) => { const tmp = { ...route } if (hasPermission(roles, tmp)) { if (tmp.children) { tmp.children = filterRoutes(tmp.children, roles) } acc.push(tmp) } return acc }, []) } export const usePermissionStore = defineStore('permission', { state: () => ({ accessibleRoutes: [] }), actions: { generateRoutes(roles) { this.accessibleRoutes = [...constantRoutes, ...filterRoutes(asyncRoutes, roles)] return this.accessibleRoutes } } })第三步:addRoute 动态注册// 登录成功后 const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(user.roles) routes.forEach(route => router.addRoute(route))注意:addRoute 后必须用 router.replace 或返回新 location 触发一次重新导航,否则匹配不到刚添加的路由。导航守卫体系Vue Router 4 的守卫分三类,执行顺序必须烂熟于心:导航触发 → beforeEach(全局前置) → beforeEnter(路由独享) → beforeRouteUpdate / beforeRouteEnter(组件内) → afterEach(全局后置)全局前置守卫:登录鉴权标准模板const WHITE_LIST = ['/login', '/404'] router.beforeEach(async (to, from) => { const userStore = useUserStore() // 1. 白名单直接放行 if (WHITE_LIST.includes(to.path)) return true // 2. 未登录跳登录页,带上 redirect 参数 if (!userStore.token) { return { path: '/login', query: { redirect: to.fullPath } } } // 3. 已登录但还没拉取用户信息/动态路由 if (!userStore.userInfo) { try { await userStore.fetchUserInfo() const permissionStore = usePermissionStore() const routes = permissionStore.generateRoutes(userStore.roles) routes.forEach(r => router.addRoute(r)) // 关键:addRoute 后重新进入当前路由 return { ...to, replace: true } } catch { userStore.logout() return { path: '/login', query: { redirect: to.fullPath } } } } // 4. 正常放行 return true })守卫返回值语义Vue Router 4 的守卫返回值规则:返回值效果undefined / true放行false取消导航路由地址对象 / 字符串重定向Promiseresolve 上述值,reject 则取消并报错afterEach:动态修改页面标题router.afterEach((to) => { document.title = to.meta?.title ? `${to.meta.title} - 管理系统` : '管理系统' })组件内守卫:离开确认表单页防止用户误关,用 onBeforeRouteLeave:<script setup> import { ref } from 'vue' import { onBeforeRouteLeave } from 'vue-router' const dirty = ref(false) onBeforeRouteLeave(() => { if (dirty.value) { return window.confirm('表单未保存,确定离开?') } }) </script>滚动行为与过渡动画const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) return savedPosition // 前进后退恢复位置 if (to.hash) return { el: to.hash, behavior: 'smooth' } // 锚点 return { top: 0 } // 默认回到顶部 } })路由切换配合 <transition> 做页面动画:<template> <router-view v-slot="{ Component, route }"> <transition name="fade" mode="out-in"> <component :is="Component" :key="route.path" /> </transition> </router-view> </template> <style> .fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease; } .fade-enter-from, .fade-leave-to { opacity: 0; } </style>useRoute 与 useRouter<script setup> 中通过组合式 API 获取路由:import { useRoute, useRouter } from 'vue-router' const route = useRoute() // 当前路由信息(响应式):params、query、meta const router = useRouter() // 路由实例:push、replace、go // 带参数跳转 router.push({ name: 'order-detail', params: { id: 123 }, query: { tab: 'logs' } }) // query 变化时重新拉数据(同一路由复用时 watch) watch(() => route.query.tab, (tab) => { fetchList(tab) })易错点:route.params 不是深度响应式的可靠来源,路径参数变化而组件复用时(如 /orders/1 → /orders/2),要 watch route.params.id 或给 router-view 加 :key。常见坑速查刷新 404:动态路由方案里,刷新后路由表被重置,必须在守卫里重新 addRoute(见上文模板第 3 步)通配符路由位置:{ path: '/:pathMatch(.*)*' } 必须放在动态路由 addRoute 之后,否则先匹配到 404history 模式 404:服务器需配置所有路径回落到 index.html(Nginx 的 try_files $uri $uri/ /index.html;)循环重定向:守卫里 next() 与返回值混用会导致逻辑混乱,Vue Router 4 统一用返回值风格keep-alive 失效:配合动态路由时 include 需要组件 name,确保组件显式声明了 name(defineOptions({ name: 'OrderList' }))总结懒加载是标配,props: true 让组件与路由解耦权限路由三步走:静态/动态路由分离 → 登录后按角色过滤 → addRoute + 重新导航守卫统一用返回值风格,beforeEach 模板可以直接抄走onBeforeRouteLeave 处理离开确认,scrollBehavior 处理滚动恢复刷新 404 和通配符路由顺序是动态路由方案最常踩的两个坑路由系统是后台管理项目的骨架,把这套方案吃透,遇到再复杂的权限场景都能从容拆解。
2026年07月12日
5 阅读
0 评论
0 点赞
2026-07-11
Vue 3 + TypeScript 类型化开发实战:从 Props 到泛型组件
Vue 3 是用 TS 重写的,类型支持是其核心卖点。但很多项目只是"用了 TS",模板里的类型断言满天飞、组件 Props 无类型、第三方库全是 any。本文覆盖 Vue 3 + TS 的核心类型工具,从基础用法一路讲到泛型组件,帮你把类型系统真正用起来。一、Props 类型化的三个层次层次一:运行时校验(纯 JS 风格)defineProps({ id: [Number, String], items: { type: Array, required: true } })有运行时警告,但没有编辑器类型推导,TS 项目不推荐。层次二:类型声明(类型推导最佳)<script setup lang="ts"> interface Order { id: number title: string status: 'pending' | 'paid' | 'closed' } const props = defineProps<{ order: Order showActions?: boolean }>() // props.order.status 自动推导为联合类型 // 模板中尝试比较错误状态会有类型提示 </script>层次三:withDefaults 提供默认值类型声明语法不支持直接给默认值,需要 withDefaults:const props = withDefaults( defineProps<{ items: Order[] pageSize?: number labels?: Record<string, string> }>(), { pageSize: 20, labels: () => ({}) // 对象/数组默认值必须用工厂函数 } )从接口自动生成 Props(进阶)interface Props { userId: number; compact?: boolean } // 响应式解包后仍是响应式的 const props = defineProps<Props>()props 是被 reactive 包装的对象,解构会丢失响应性。需要解构时用 Vue 3.5+ 的响应式 Props 解构:const { userId, compact = false } = defineProps<Props>() // 3.5+ 编译器自动保持响应性二、emit 与 ref 的类型化emit 类型const emit = defineEmits<{ (e: 'change', value: string): void (e: 'select', id: number, item: Order): void }>() // Vue 3.3+ 更简洁的具名元组语法 const emit2 = defineEmits<{ change: [value: string] select: [id: number, item: Order] }>()父组件在模板上监听时,回调参数类型自动校验。ref 的类型// 基础:自动推导 const count = ref(0) // Ref<number> // 初始值为 null 时必须显式标注 const el = ref<HTMLInputElement | null>(null) onMounted(() => el.value?.focus()) // 复杂对象推荐接口先行 interface FormData { name: string tags: string[] } const form = ref<FormData>({ name: '', tags: [] })模板引用组件实例import FormModal from './FormModal.vue' const modalRef = ref<InstanceType<typeof FormModal> | null>(null) onMounted(() => { modalRef.value?.open() // expose 的方法有完整类型 })InstanceType<typeof Component> 会读取 defineExpose 暴露的成员类型,这是父组件调用子组件方法的类型安全姿势。三、computed 与 watch 的类型const orderList = ref<Order[]>([]) const pendingCount = computed(() => orderList.value.filter(o => o.status === 'pending').length ) // 自动推导 ComputedRef<number> // watch 回调参数类型自动对应数据源 watch( () => props.order.status, (newStatus, oldStatus) => { // newStatus: 'pending' | 'paid' | 'closed' console.log(`${oldStatus} → ${newStatus}`) } ) // watchEffect 不需要指定类型,内部自动收集依赖四、provide / inject 的类型安全// symbols/keys.ts —— 集中管理 InjectionKey import type { InjectionKey, Ref } from 'vue' export interface UserContext { user: Ref<{ id: number; name: string }> refresh: () => Promise<void> } export const UserKey: InjectionKey<UserContext> = Symbol('user')// 祖先组件 provide(UserKey, { user, refresh }) // 后代组件:完整类型推导 + 缺省兜底 const ctx = inject(UserKey) if (!ctx) throw new Error('UserKey 未在祖先组件提供') ctx.user.value.name // string,自动推导五、泛型组件:类型跟着数据走普通组件的 Props 类型是固定的,泛型组件让类型由调用方决定。最典型的场景是列表组件和选择器组件。普通写法的困境// ❌ items 只能声明成 any[],丢失元素类型 defineProps<{ items: any[]; modelValue: any }>()泛型组件写法<!-- SelectList.vue --> <script setup lang="ts" generic="T extends { id: number }"> defineProps<{ items: T[] modelValue: T['id'] | null labelField?: keyof T }>() const emit = defineEmits<{ 'update:modelValue': [id: T['id'] | null] select: [item: T] }>() </script> <template> <ul class="select-list"> <li v-for="item in items" :key="item.id" :class="{ active: item.id === modelValue }" @click="emit('update:modelValue', item.id); emit('select', item)" > <slot :item="item">{{ item[labelField ?? 'id'] }}</slot> </li> </ul> </template>generic="T extends { id: number }" 声明泛型参数,调用方传 Order[] 时所有相关类型自动实例化为 Order:<SelectList v-model="selectedOrderId" :items="orders" label-field="title" @select="(order) => console.log(order.status)" // order: Order,类型完整 />泛型 composable:类型化的 useListexport function useList<T>(initial: T[] = []) { const list = ref<T[]>([...initial]) function add(item: T) { list.value.push(item) } function remove(predicate: (item: T) => boolean) { list.value = list.value.filter(i => !predicate(i)) } function find(predicate: (item: T) => boolean): T | undefined { return list.value.find(predicate) } return { list, add, remove, find } } // 使用:所有方法参数和返回值都有精确类型 const { list, add, find } = useList<Order>([]) add({ id: 1, title: 'x', status: 'pending' }) // OK add({ id: 2, title: 'y' }) // 报错:缺 status六、SFC 与 TS 工程细节defineComponent 与组件类型<script setup> 的组件是匿名的,需要显式 name 时(keep-alive include、devtools 显示):<script setup lang="ts"> defineOptions({ name: 'OrderList' }) </script>外部类型文件的组织src/ ├── types/ │ ├── api.d.ts # 后端接口类型(可由 OpenAPI 生成) │ ├── models.ts # 业务模型 Order、User 等 │ └── global.d.ts # 全局类型扩展 ├── components/api.d.ts 建议用工具从后端 Swagger/OpenAPI 规范生成,杜绝手抄接口字段。常用工具类型速查import type { Ref, ComputedRef, MaybeRef, UnwrapRef } from 'vue' // MaybeRef<T>:参数既可以是 T 也可以是 Ref<T> function useX(source: MaybeRef<string>) { /* ... */ } // UnwrapRef<T>:ref 的解包类型 const state = ref({ nested: { count: 0 } }) // state.value.nested.count 类型是 number(自动解包) // ExtractPropTypes:从运行时 props 选项提取类型 import type { ExtractPropTypes } from 'vue' const propsSchema = { title: String, count: { type: Number, default: 0 } } type Props = ExtractPropTypes<typeof propsSchema>vue-tsc 做模板类型检查vue-tsc 能检查模板中的表达式类型,接入 CI:// package.json { "scripts": { "type-check": "vue-tsc --noEmit" } }模板里 {{ order.statu }} 这类错误会在构建前暴露,而不是上线后白屏。七、别过度类型化类型是工具不是目的,几个克制的建议:联合类型优先于枚举:'pending' | 'paid' 比 enum 更利于 tree-shaking后端接口类型交给代码生成,手写注定跟不上变化第三方库无类型时的最小兜底:declare module 'xxx',而不是到处 any断言 as 只用于"我确信类型系统不知道的事",当作逃生舱而非常规操作总结Props 用类型声明语法,默认值交给 withDefaultsdefineEmits 用具名元组语法,参数类型双向校验InstanceType<typeof Comp> 是引用子组件的规范类型InjectionKey<T> 让 provide/inject 摆脱字符串裸奔泛型组件 generic="T" 是封装列表/选择器类组件的杀手锏vue-tsc 进 CI,模板类型错误提前拦截类型系统用到位后,重构敢下手、接口变更立刻报错、新人看类型就能懂用法——这 defensive 能力正是中级向高级进阶的分水岭。
2026年07月11日
6 阅读
0 评论
0 点赞
1
2
3
0:00