沿途的风景 - 前端技术
type: channel
link: https://inspirecl.asia/index.php/category/qdjs/
description: 前端技术
items: 让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战
items: AB-Admin：给 Typecho 后台换上 Material Design 3 新装
items: Vue 3 插槽深度实战：默认、具名与作用域插槽及自定义指令
items: Vue Router 4 路由实战：动态路由、导航守卫与懒加载
items: Vue 3 + TypeScript 类型化开发实战：从 Props 到泛型组件
items: Vue 3 组件通信全解：props、emit、v-model 与 provide/inject
items: Vue 3 组合式函数实战：Composables 封装与逻辑复用指南
items: Vue 3 + Vite 工程化实战：从零搭建企业级项目
items: Vue 3 组件进阶：性能优化与最佳实践
items: Pinia 状态管理实战：从 Vuex 迁移到 Pinia

让 YoduPlayer 切页不断歌：给 Typecho 博客定制 PJAX 的完整实战
type: item
date: 2026-08-18T10:30:00+08:00
description: 让 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.h…

AB-Admin：给 Typecho 后台换上 Material Design 3 新装
type: item
date: 2026-08-01T12:00:00+08:00
description: 前言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 风格的异步弹窗。同时也提供了…

Vue 3 插槽深度实战：默认、具名与作用域插槽及自定义指令
type: item
date: 2026-07-14T19:28:12+08:00
description: 如果说 props 是组件的"输入参数"，插槽就是组件的"内容参数"。写好一个可复用的布局组件、表格组件、弹窗组件，绕不开插槽；而自定义指令则是 DOM 级复用的利器。这两个主题合起来，是 Vue 中级开发者必须吃透的内容。一、插槽的三种形态1. 默认插槽父组件传入的内容替换子组件的 <slot> 占位： <slot></slot> <Card> 订单详情 共 3 件商品，合计 ¥299.00 </Card>2. 具名插槽：多区域布局组件有多个内容区域时，用 name 区分。典型场景：页头/页身/页脚的布局组件。 <slot name="header"></slot> <slot></slot> <slot name="footer"></slot> <PageLayout> 管理后台 这里是主内容区域 © 2026 My Corp </PageLayout>注意 #header 是 v-slot:header 的缩写，且 v-slot 只能写在 上（默认插槽的简写除外）。3. 作用域插槽：数据反向传递普通插槽的内容编译在父组件作用域，无法访问子组件内部数据。作用域插槽让子组件把数据"抛"给插槽内容： <slot :item="item" :index="index"> {{ item.name }} </slot> #{{ index + 1 }} {{ item.orderNo }} {{ statusText[item.status] }} 这是设计"无头组件"（Headless Component）的核心思想：组件负责数据和逻辑，父组件负责渲染结构。Element Plus 的 Table、el-select 的 option 都大量使用作用域插槽。实战：封装一个通用描述列表组件综合运用具名 + 作用域插槽，封装一个类似 Ant Design Descriptions 的组件： {{ item.label }} <slot :name="item.field" :item="item"> {{ item.value }} </slot> <DescList :items="[ { field: 'name', label: '姓名', value: user.name }, { field: 'avatar', label: '头像', value: user.avatar }, { field: 'status', label: '状态', value: user.status } ]"> <el-tag :type="item.value === 1 ? 'success' : 'danger'"> {{ item.value === 1 ? '正常' : '禁用' }} </el-tag> </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 = ' ' 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.__loa…

Vue Router 4 路由实战：动态路由、导航守卫与懒加载
type: item
date: 2026-07-12T15:37:01+08:00
description: 任何多页面 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 函数模式更灵活：{ 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 // 没声明 r…

Vue 3 + TypeScript 类型化开发实战：从 Props 到泛型组件
type: item
date: 2026-07-11T15:58:41+08:00
description: Vue 3 是用 TS 重写的，类型支持是其核心卖点。但很多项目只是"用了 TS"，模板里的类型断言满天飞、组件 Props 无类型、第三方库全是 any。本文覆盖 Vue 3 + TS 的核心类型工具，从基础用法一路讲到泛型组件，帮你把类型系统真正用起来。一、Props 类型化的三个层次层次一：运行时校验（纯 JS 风格）defineProps({ id: [Number, String], items: { type: Array, required: true } })有运行时警告，但没有编辑器类型推导，TS 项目不推荐。层次二：类型声明（类型推导最佳） 层次三：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 —— 集中管理 …

Vue 3 组件通信全解：props、emit、v-model 与 provide/inject
type: item
date: 2026-07-08T08:04:55+08:00
description: 组件通信是 Vue 中级开发的核心分水岭。小型项目里随意用 ref 传递数据也能跑起来，但一旦组件层级变深、业务变复杂，选错通信方式会让代码迅速腐化。本文系统梳理 Vue 3 中所有主流通信手段的适用场景、实现原理与常见陷阱。通信方式全景图按数据流向，Vue 3 的组件通信可以分为四类：类型手段适用关系父传子props / attrs单向数据流子传父emit / expose事件回调双向绑定v-model表单、弹窗跨层级provide / inject深层嵌套全局状态Pinia任意组件一个重要的原则：优先用简单的方向。能用 props 解决的不要上 provide，能局部的不要全局。一、props：父传子的标准姿势props 遵循单向数据流，父组件修改数据会自动向下流动，子组件不允许直接修改 props。 {{ user.name }} 运行时校验与类型推导纯 JS 项目用对象语法做运行时校验；TS 项目更推荐类型声明方式，编辑器推导更完整： 常见陷阱：直接修改 props子组件里改 props 对象的内部属性（如 props.user.name = 'x'）虽然不报错（对象是引用传递），但会污染父组件状态，是最难排查的一类 bug。正确做法是通过 emit 通知父组件修改。二、emit：子传父的事件通道 <ConfirmDialog @confirm="handleConfirm" @cancel="showDialog = false" />声明式 emit 的必要性很多同学习惯不声明直接 emit('xxx')。在 <SearchInput v-model="keyword" />等价于老写法：// Vue 3.0 - 3.3 的写法 const props = defineProps(['modelValue']) const emit = defineEmits(['update:modelValue']) // 子组件更新时 emit('update:modelValue', newValue)多值绑定：具名 v-model一个组件可以绑定多个 v-model，通过参数名区分： 至 <RangePicker v-model:start="query.start" v-model:end="query.end" />修饰符v-model 支持自定义修饰符，通过 defineModel 的返回值读取： 四、provide / inject：跨层级注入当组件层级超过两层还硬用 props 层层传递（俗称"props 钻井"），就该用 provide/inject 了。 切换主题 响应性保持的关键provide 的值如果是 ref，inject 拿到的就是同一个 ref，响应性天然保持。但如果你这样写：// ❌ 错误示范：解构后再 provide，丢失响应性 const theme = ref('dark') provide('theme', theme.value) // 传入的是普通字符串后代组件拿到的是快照值，不会更新。务必传 ref 本身或包含 ref 的对象。只读注入：防止子组件乱改子组件可以直接改注入的 ref，这会破坏数据流向。更安全的模式是只暴露方法：provide('theme', { theme: readonly(theme), // 只读代理 toggleTheme // 修改必须走方法 })子组件试图改 theme.value 会收到警告。TS 类型安全注入provide/inject 的 key 是字符串或 Symbol，容易写错且无类型提示。用 InjectionKey 解决：// keys.ts import type { InjectionKey, Ref } from 'vue' export interface ThemeContext { theme: Ref<'dark' | 'light'> toggleTheme: () => void } export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// 提供方 provide(ThemeKey, { theme, toggleTheme }) // 注入方：自动推导类型，且默认值类型也被约束 const ctx = inject(ThemeKey) if (!ctx) throw new Error('ThemeKey 未提供')五、attrs 与 expose：两个容易忽略的通道$attrs：透传属性没被声明为 props 的属性会落到 $attrs 上。封装组件时常用它透传原生属性： <slot /> 父组件写 <BaseButton type="…

Vue 3 组合式函数实战：Composables 封装与逻辑复用指南
type: item
date: 2026-07-08T02:57:45+08:00
description: 组合式函数（Composables）是 Vue 3 逻辑复用的官方答案。它取代了 Vue 2 时代的 mixins，解决了命名冲突、来源不清、类型推导弱三大顽疾。本文从零封装五个高频 Composable，讲透设计要点与避坑细节。为什么是 Composables 而不是 Mixins先看 mixins 的三大原罪：来源不透明：组件里用了一个 this.fetchData()，根本不知道是组件自己的还是某个 mixin 的命名冲突：两个 mixin 定义了同名属性，静默覆盖类型黑洞：TS 无法推导 mixin 注入的内容Composables 是普通函数，数据来源明确（const { data } = useFetch(url)），返回值命名完全由调用方决定，天然解决以上所有问题：const { data: orderList, loading } = useFetch('/api/orders') const { data: userList, loading: userLoading } = useFetch('/api/users') // 命名冲突？重命名就行Composable 的三条铁律命名以 use 开头，这是社区强约定只在 setup 上下文中同步调用，不能在回调、事件处理器里调用（因为内部可能依赖 onMounted 等生命周期钩子，它们要求在 setup 同步调用期间注册）返回值用普通对象包 ref，而不是数组里混 ref 和普通值，方便调用方解构重命名实战一：useRequest —— 通用请求管理这是使用频率最高的 Composable，覆盖请求、竞态、手动触发：// composables/useRequest.ts import { ref, shallowRef, onUnmounted, type Ref, unref, watchEffect } from 'vue' interface UseRequestOptions<T> { immediate?: boolean // 是否立即执行 debounce?: number // 防抖毫秒数 } export function useRequest<T>( url: () => string | Ref<string>, options: UseRequestOptions<T> = {} ) { const { immediate = true, debounce } = options const data = shallowRef<T | null>(null) const loading = ref(false) const error = ref<Error | null>(null) let abortController: AbortController | null = null let timer: ReturnType<typeof setTimeout> | null = null async function execute() { // 竞态处理：取消上一次未完成的请求 abortController?.abort() abortController = new AbortController() loading.value = true error.value = null try { const res = await fetch(unref(url), { signal: abortController.signal }) if (!res.ok) throw new Error(`HTTP ${res.status}`) data.value = await res.json() } catch (e: any) { if (e.name !== 'AbortError') { error.value = e } } finally { loading.value = false } } function run() { if (debounce) { timer && clearTimeout(timer) timer = setTimeout(execute, debounce) } else { execute() } } // url 是响应式 ref 时，自动重新请求 watchEffect(() => { unref(url) if (immediate) run() }) // 组件卸载时取消进行中的请求，防止内存泄漏 onUnmounted(() => { abortController?.abort() timer && clearTimeout(timer) }) return { dat…

Vue 3 + Vite 工程化实战：从零搭建企业级项目
type: item
date: 2026-07-05T18:54:16+08:00
description: Vite 已成为 Vue 项目的标配构建工具。本文从零搭建一个包含目录规范、请求封装、权限路由、Mock 数据、环境配置和 CI/CD 的企业级 Vue 3 项目。一、项目初始化创建项目# 创建 Vue 3 项目 npm create vue@latest my-app # 交互选择： # ✓ TypeScript # ✓ JSX Support # ✓ Router # ✓ Pinia # ✓ Vitest # ✓ ESLint # ✓ Prettier cd my-app npm install安装核心依赖# UI 组件库（按需选择） npm install element-plus npm install -D unplugin-vue-components unplugin-auto-import # HTTP 请求 npm install axios # 实用工具 npm install dayjs lodash-es @vueuse/core # 图表（按需） npm install echarts二、目录结构规范my-app/ ├── public/ # 静态资源（不参与构建） ├── src/ │ ├── api/ # API 接口定义 │ │ ├── modules/ │ │ │ ├── user.js │ │ │ ├── order.js │ │ │ └── product.js │ │ └── index.js # 统一导出 │ ├── assets/ # 静态资源（参与构建） │ │ ├── images/ │ │ └── styles/ │ ├── components/ # 全局公共组件 │ │ ├── AppTable/ │ │ │ └── index.vue │ │ └── AppDialog/ │ ├── composables/ # 组合式函数 │ │ ├── useAuth.js │ │ ├── usePagination.js │ │ └── useRequest.js │ ├── config/ # 项目配置 │ │ └── index.js │ ├── constants/ # 常量定义 │ │ └── index.js │ ├── directives/ # 自定义指令 │ │ ├── permission.js │ │ └── lazy.js │ ├── layouts/ # 布局组件 │ │ ├── DefaultLayout.vue │ │ └── BlankLayout.vue │ ├── plugins/ # 插件配置 │ │ └── elementPlus.js │ ├── router/ # 路由 │ │ ├── index.js │ │ ├── guards.js │ │ └── routes/ │ │ ├── modules/ # 路由模块 │ │ └── index.js │ ├── stores/ # Pinia Store │ │ ├── user.js │ │ ├── permission.js │ │ └── app.js │ ├── styles/ # 全局样式 │ │ ├── variables.scss │ │ ├── mixins.scss │ │ └── index.scss │ ├── utils/ # 工具函数 │ │ ├── request.js # axios 封装 │ │ ├── storage.js │ │ └── format.js │ ├── views/ # 页面组件 │ │ ├── login/ │ │ ├── dashboard/ │ │ └── system/ │ ├── App.vue │ └── main.js ├── .env # 所有环境通用 ├── .env.development # 开发环境 ├── .env.production # 生产环境 ├── .env.staging # 预发布环境 ├── vite.config.js └── package.json三、Vite 配置详解// vite.config.js import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import { Elemen…

Vue 3 组件进阶：性能优化与最佳实践
type: item
date: 2026-07-04T23:53:41+08:00
description: 从"能跑"到"跑得快"是前端工程的分水岭。本文从渲染机制出发，系统讲解 Vue 3 的性能优化手段：虚拟列表、组件缓存、懒加载、防抖节流，以及性能分析工具的完整使用。一、性能优化总览性能优化层次 │ ├── 网络层 — 资源体积、加载策略 │ ├── 路由懒加载 │ ├── 组件异步加载 │ └── 图片/资源优化 │ ├── 渲染层 — 减少不必要的渲染 │ ├── v-show vs v-if │ ├── v-memo 缓存 │ ├── KeepAlive 组件缓存 │ └── 虚拟列表 │ ├── 计算层 — 减少计算量 │ ├── computed 缓存 │ ├── 防抖节流 │ └── Web Worker │ └── 监控层 — 持续度量 ├── Performance 面板 ├── Vue DevTools └── 自定义性能指标二、渲染优化v-if vs v-show <HeavyComponent v-if="show" /> <HeavyComponent v-show="show" /> v-memo 缓存子树 {{ item.name }} {{ item.description }} KeepAlive 组件缓存 <KeepAlive include="TabA,TabB" :max="10"> <component :is="tabs[currentTab]" /> </KeepAlive> 异步组件与 Suspense <Suspense> <AsyncDashboard /> <LoadingSpinner /> </Suspense> 虚拟列表（大数据量必备）npm install @tanstack/vue-virtual {{ rows[row.index].name }} 三、计算与交互优化防抖与节流// composables/useDebounce.js import { customRef } from 'vue' // 防抖 ref export function useDebouncedRef(value, delay = 300) { let timeout return customRef((track, trigger) => ({ get() { track() return value }, set(newValue) { clearTimeout(timeout) timeout = setTimeout(() => { value = newValue trigger() }, delay) } })) } 深度 watch 的替代方案// ❌ 深度监听大对象 — 开销大 watch(bigObject, handler, { deep: true }) // ✅ 精确监听目标属性 watch(() => bigObject.targetProp, handler) // ✅ 用 shallowRef + 整体替换 const list = shallowRef([]) list.value = [...list.value, newItem] // 触发更新 // 修改内部属性不触发（性能需要时） // ✅ 数组方法触发 list.value.push(item) // 会触发（Vue 3 拦截数组方法）Web Worker 处理重计算// workers/dataProcessor.js self.onmessage = (e) => { const { data, type } = e.data let result switch (type) { case 'SORT': result = heavySort(data) break case 'STATS': result = calculateStats(data) break } self.postMessage({ result }) } function heavySort(arr) { return [...arr].sort((a, b) => a.value - b.value) }// composables/useWorker.js export function useDataProcessor() { const worker = new Worker( new URL('../workers/dataProcessor.js', import.meta.url), { type: 'module' } ) const result = ref(null) const processing = ref(false) worker.onmessage = (e) => { result…

Pinia 状态管理实战：从 Vuex 迁移到 Pinia
type: item
date: 2026-06-28T03:10:05+08:00
description: Pinia 是 Vue 官方推荐的状态管理库，以其简洁的 API 和完整的 TypeScript 支持取代了 Vuex。本文系统讲解 Pinia 的核心概念、模块化设计、持久化方案以及从 Vuex 迁移的完整指南。一、为什么选择 PiniaPinia vs Vuex特性Vuex 4PiniaAPI 风格Mutation + Action只有 Action（同步异步统一）TypeScript支持较弱完整类型推导模块化嵌套模块 + namespace扁平 Store，天然模块化代码量较多样板代码极简组合式 API适配一般原生设计DevTools支持支持（时间旅行更佳）SSR支持支持热更新部分完整支持Pinia 移除了什么Mutations：直接修改 state 或在 action 中修改嵌套模块：每个 Store 独立扁平命名空间：Store 天然隔离辅助函数：mapState/mapGetters 不再必要（组合式写法）二、快速上手安装与初始化// main.js import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' const app = createApp(App) app.use(createPinia()) app.mount('#app')定义第一个 Store// stores/counter.js import { defineStore } from 'pinia' // 选项式写法（类似组件的 data/computed/methods） export const useCounterStore = defineStore('counter', { state: () => ({ count: 0, name: '计数器' }), getters: { double: (state) => state.count * 2, doublePlusOne() { // 通过 this 访问其他 getter return this.double + 1 } }, actions: { increment() { this.count++ }, async incrementAsync() { // 直接写异步逻辑，无需 dispatch await new Promise(r => setTimeout(r, 1000)) this.count++ } } })组合式写法（推荐）// stores/counter.js — Setup Store 写法 import { ref, computed } from 'vue' import { defineStore } from 'pinia' export const useCounterStore = defineStore('counter', () => { // state const count = ref(0) const name = ref('计数器') // getters const double = computed(() => count.value * 2) const doublePlusOne = computed(() => double.value + 1) // actions function increment() { count.value++ } async function incrementAsync() { await new Promise(r => setTimeout(r, 1000)) count.value++ } // 必须返回所有使用的属性 return { count, name, double, doublePlusOne, increment, incrementAsync } })在组件中使用 {{ counter.name }}: {{ count }} 双倍: {{ double }} +1 三、核心概念详解State// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ user: null, token: '', permissions: [], settings: { theme: 'light', language: 'zh-CN' } }), actions: { // 重置整个 state 到初始值 resetState() { this.$reset() }, // 批量修改（替代 Vuex 的 mutation） setUser…