首页
直播
壁纸
友链
搜索
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-07-08
Vue 3 组件通信全解:props、emit、v-model 与 provide/inject
组件通信是 Vue 中级开发的核心分水岭。小型项目里随意用 ref 传递数据也能跑起来,但一旦组件层级变深、业务变复杂,选错通信方式会让代码迅速腐化。本文系统梳理 Vue 3 中所有主流通信手段的适用场景、实现原理与常见陷阱。通信方式全景图按数据流向,Vue 3 的组件通信可以分为四类:类型手段适用关系父传子props / attrs单向数据流子传父emit / expose事件回调双向绑定v-model表单、弹窗跨层级provide / inject深层嵌套全局状态Pinia任意组件一个重要的原则:优先用简单的方向。能用 props 解决的不要上 provide,能局部的不要全局。一、props:父传子的标准姿势props 遵循单向数据流,父组件修改数据会自动向下流动,子组件不允许直接修改 props。<!-- 子组件 UserCard.vue --> <script setup> defineProps({ user: { type: Object, required: true }, showAvatar: { type: Boolean, default: true } }) </script> <template> <div class="user-card"> <img v-if="showAvatar" :src="user.avatar" /> <span>{{ user.name }}</span> </div> </template>运行时校验与类型推导纯 JS 项目用对象语法做运行时校验;TS 项目更推荐类型声明方式,编辑器推导更完整:<script setup lang="ts"> interface User { id: number name: string avatar?: string } const props = defineProps<{ user: User showAvatar?: boolean }>() // 带默认值时使用 withDefaults const props2 = withDefaults(defineProps<{ user: User showAvatar?: boolean }>(), { showAvatar: true }) </script>常见陷阱:直接修改 props子组件里改 props 对象的内部属性(如 props.user.name = 'x')虽然不报错(对象是引用传递),但会污染父组件状态,是最难排查的一类 bug。正确做法是通过 emit 通知父组件修改。二、emit:子传父的事件通道<!-- 子组件 ConfirmDialog.vue --> <script setup> const emit = defineEmits(['confirm', 'cancel']) function handleConfirm() { emit('confirm', { confirmedAt: Date.now() }) } </script><!-- 父组件 --> <ConfirmDialog @confirm="handleConfirm" @cancel="showDialog = false" />声明式 emit 的必要性很多同学习惯不声明直接 emit('xxx')。在 <script setup> 中不声明也能用,但显式声明有两个好处:文档化:组件的使用者一眼看清它对外暴露哪些事件事件校验:可以像 props 一样带校验逻辑const emit = defineEmits({ // 无校验 click: null, // 带校验 submit: (payload) => { if (!payload || typeof payload.email !== 'string') { console.warn('submit 事件必须携带 email 字段') return false } return true } })三、v-model:双向绑定的本质v-model 不是黑魔法,它是 props + emit 的语法糖。单值绑定<!-- 子组件 SearchInput.vue --> <script setup> const model = defineModel() // Vue 3.4+ 推荐写法 </script> <template> <input :value="model" @input="model = $event.target.value" /> </template><!-- 父组件 --> <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.vue --> <script setup> const start = defineModel('start') const end = defineModel('end') </script> <template> <div class="range-picker"> <input v-model="start" type="date" /> <span>至</span> <input v-model="end" type="date" /> </div> </template><!-- 父组件 --> <RangePicker v-model:start="query.start" v-model:end="query.end" />修饰符v-model 支持自定义修饰符,通过 defineModel 的返回值读取:<script setup> const [model, modifiers] = defineModel({ set(value) { // .trim 修饰符时自动去空格 if (modifiers.trim) return value.trim() return value } }) </script>四、provide / inject:跨层级注入当组件层级超过两层还硬用 props 层层传递(俗称"props 钻井"),就该用 provide/inject 了。<!-- 顶层组件 --> <script setup> import { provide, ref } from 'vue' const theme = ref('dark') const toggleTheme = () => { theme.value = theme.value === 'dark' ? 'light' : 'dark' } provide('theme', { theme, toggleTheme }) </script><!-- 任意深层后代组件 --> <script setup> import { inject } from 'vue' const { theme, toggleTheme } = inject('theme') </script> <template> <button :class="theme" @click="toggleTheme">切换主题</button> </template>响应性保持的关键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 上。封装组件时常用它透传原生属性:<!-- BaseButton.vue --> <template> <button class="btn" v-bind="$attrs"> <slot /> </button> </template>父组件写 <BaseButton type="submit" :disabled="loading"> 时,type 和 disabled 会自动透传到原生 button 上。注意 inheritAttrs: false 的设置场景:根元素有多个时需要手动绑定到正确元素。expose:受控暴露实例<script setup> 组件默认是"封闭"的,父组件通过 ref 拿不到内部任何东西,除非显式 expose:<!-- 子组件 FormModal.vue --> <script setup> import { ref, expose } from 'vue' const formRef = ref(null) const visible = ref(false) function open() { visible.value = true } function close() { visible.value = false } async function validate() { return await formRef.value.validate() } // 只暴露三个方法,内部状态不泄露 defineExpose({ open, close, validate }) </script><!-- 父组件 --> <script setup> const modalRef = ref(null) modalRef.value.open() </script>选型决策清单实际开发中按这个顺序决策:父子相邻 → props + emit,没有例外表单类、弹窗类组件 → v-model(defineModel)三层以上的共享配置(主题、国际化、当前用户) → provide/inject跨页面、跨模块的业务状态 → Pinia任何"绕过去"的冲动(操作 DOM、改 $parent、事件总线)→ 停下来重新设计总结props/emit 是骨架,v-model 是语法糖,provide/inject 是电梯,Pinia 是中枢defineModel() 是 Vue 3.4+ 双向绑定的标准答案provide 必须传 ref 本身才能保持响应性InjectionKey 让 inject 拥有完整类型推导expose 让组件对外 API 显式化,避免黑盒掌握这些通信模式后,你可以应对 90% 的组件设计场景。下一篇我们讲插槽与自定义指令——组件通信之外的另一半中级技能。
2026年07月08日
6 阅读
0 评论
0 点赞
2026-07-08
Vue 3 组合式函数实战:Composables 封装与逻辑复用指南
组合式函数(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 { data, loading, error, run } }使用起来非常顺手:<script setup> import { computed } from 'vue' const page = ref(1) const url = computed(() => `/api/orders?page=${page.value}`) const { data, loading, run } = useRequest(url) function nextPage() { page.value++ // url 变了会自动重新请求,也可以手动 run() } </script>几个设计细节值得注意:shallowRef 存 data:大列表数据不必深度响应式,浅层够用且省内存AbortController 防竞态:搜索框快速输入时,只有最后一次请求的结果会生效onUnmounted 清理:Composable 内注册生命周期钩子,自动绑定到调用它的组件上,这是 Composable 相比普通函数的核心优势实战二:useMouse —— 事件监听封装// composables/useMouse.ts import { ref, onMounted, onUnmounted } from 'vue' export function useMouse() { const x = ref(0) const y = ref(0) function update(e: MouseEvent) { x.value = e.pageX y.value = e.pageY } onMounted(() => window.addEventListener('mousemove', update)) onUnmounted(() => window.removeEventListener('mousemove', update)) return { x, y } }调用方代码极其干净:<script setup> const { x, y } = useMouse() </script> <template>鼠标位置:{{ x }}, {{ y }}</template>如果希望返回值可以直接当数字用,用 toValue 系列工具或返回 getter:import { computed } from 'vue' const { x, y } = useMouse() const pos = computed(() => `(${x.value}, ${y.value})`)实战三:useLocalStorage —— 持久化响应式状态// composables/useLocalStorage.ts import { ref, watch, type WatchCallback } from 'vue' export function useLocalStorage<T>(key: string, initialValue: T) { // 初始化:优先读缓存 const stored = localStorage.getItem(key) const state = ref<T>( stored ? JSON.parse(stored) : initialValue ) as Ref<T> // 监听变化自动写入,深度监听覆盖对象/数组 watch(state, (val) => { try { localStorage.setItem(key, JSON.stringify(val)) } catch (e) { console.warn('localStorage 写入失败', e) } }, { deep: true }) // 多标签页同步:storage 事件 if (typeof window !== 'undefined') { window.addEventListener('storage', (e) => { if (e.key === key && e.newValue) { state.value = JSON.parse(e.newValue) } }) } function remove() { localStorage.removeItem(key) state.value = initialValue } return { state, remove } }const { state: settings } = useLocalStorage('app-settings', { theme: 'dark', fontSize: 14 }) // 改 settings.value.theme 自动持久化,刷新页面不丢实战四:useToggle —— 布尔状态切换export function useToggle(initial = false) { const state = ref(initial) const toggle = (val?: boolean) => { state.value = val ?? !state.value } const setTrue = () => toggle(true) const setFalse = () => toggle(false) return { state, toggle, setTrue, setFalse } }看着简单,但弹窗、下拉、折叠面板全用得上,能把散落各处的 showXxx = !showXxx 收编成统一模式。实战五:useInterval —— 定时器管理import { ref, onUnmounted } from 'vue' export function useInterval(cb: () => void, delay = 1000, immediate = true) { const timer = ref<ReturnType<typeof setInterval> | null>(null) function start() { if (timer.value) return timer.value = setInterval(cb, delay) } function stop() { if (timer.value) { clearInterval(timer.value) timer.value = null } } if (immediate) start() onUnmounted(stop) // 组件销毁自动清理,杜绝定时器泄漏 return { start, stop } }倒计时验证码是典型场景:const seconds = ref(60) const { start, stop } = useInterval(() => { if (--seconds.value <= 0) stop() }, 1000)响应式输入:ref、getter 还是原始值?设计 Composable 的输入参数时,Vue 3.3+ 提供了统一的 toValue():import { toValue, type MaybeRefOrGetter } from 'vue' export function useFoo(source: MaybeRefOrGetter<string>) { const value = toValue(source) // ref / getter / 原始值通吃 }这样调用方想传什么就传什么,Composable 内部不需要关心:useFoo('静态字符串') useFoo(computed(() => dynamicUrl.value)) useFoo(() => props.someProp) // getter,保持响应性常见错误与修正错误一:返回响应式丢失// ❌ 返回解构后的值,丢失响应性 return { data: data.value, loading: loading.value } // ✅ 返回 ref 本身 return { data, loading }错误二:异步函数里调用 Composable// ❌ setup 已经执行完,onMounted 无法注册 async function setup() { const { x } = await Promise.resolve().then(() => useMouse()) } // ✅ 同步调用,内部再异步 const { x } = useMouse()错误三:忘记清理副作用事件监听、定时器、WebSocket 连接必须在 onUnmounted 中清理。判断标准很简单:你注册了什么,就要注销什么。总结Composables 用函数作用域取代 mixins 的属性合并,来源清晰、类型完整生命周期钩子可以在 Composable 中注册,自动绑定到调用组件——这是它管理副作用的杀手锏数据用 shallowRef、输入用 MaybeRefOrGetter + toValue(),是高阶设计模式useRequest/useMouse/useLocalStorage 是三个最值得亲手写一遍的练手项目VueUse 已经收录了 200+ 高质量 Composables,先读它的源码再动手,事半功倍把业务里的重复逻辑沉淀成 Composables,是 Vue 项目从"堆页面"走向"有架构"的第一步。
2026年07月08日
6 阅读
0 评论
0 点赞
2026-07-05
Vue 3 + Vite 工程化实战:从零搭建企业级项目
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 { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) return { plugins: [ vue(), // 自动导入 Vue API AutoImport({ imports: ['vue', 'vue-router', 'pinia'], resolvers: [ElementPlusResolver()], dts: 'src/auto-imports.d.js' }), // 组件自动注册 Components({ resolvers: [ElementPlusResolver()], dts: 'src/components.d.js' }) ], resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, css: { preprocessorOptions: { scss: { additionalData: `@use "@/styles/variables.scss" as *;` } } }, server: { port: 3000, open: true, proxy: { '/api': { target: env.VITE_API_BASE_URL, changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } }, build: { // 输出目录 outDir: 'dist', // 静态资源分包 rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'], 'vendor-utils': ['lodash-es', 'dayjs'] }, // 分包体积警告阈值 chunkFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash].js', assetFileNames: 'assets/[ext]/[name]-[hash].[ext]' } }, // chunk 大小警告 chunkSizeWarningLimit: 1000, // 生产环境移除 console minify: 'terser', terserOptions: { compress: { drop_console: env.VITE_DROP_CONSOLE === 'true', drop_debugger: true } } } } })四、环境变量配置# .env — 通用配置 VITE_APP_TITLE=企业管理系统 # .env.development VITE_API_BASE_URL=http://localhost:8080 VITE_DROP_CONSOLE=false # .env.production VITE_API_BASE_URL=https://api.example.com VITE_DROP_CONSOLE=true// src/config/index.js export const config = { title: import.meta.env.VITE_APP_TITLE, apiBaseUrl: import.meta.env.VITE_API_BASE_URL, tokenKey: 'access_token', tokenExpireKey: 'token_expire' }五、Axios 封装// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 15000 }) // 请求拦截器 service.interceptors.request.use( (config) => { const userStore = useUserStore() // 携带 token if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } // GET 请求防缓存 if (config.method === 'get') { config.params = { ...config.params, _t: Date.now() } } return config }, (error) => Promise.reject(error) ) // 响应拦截器 let isRefreshing = false let pendingRequests = [] service.interceptors.response.use( (response) => { const { code, data, message } = response.data // 业务状态码判断 switch (code) { case 200: return data case 401: handleUnauthorized() return Promise.reject(new Error(message)) default: ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) } }, async (error) => { const { response } = error if (response) { switch (response.status) { case 401: handleUnauthorized() break case 403: ElMessage.error('没有权限访问') break case 404: ElMessage.error('请求的资源不存在') break case 500: ElMessage.error('服务器错误') break default: ElMessage.error(response.data?.message || '请求失败') } } else if (error.code === 'ECONNABORTED') { ElMessage.error('请求超时') } else { ElMessage.error('网络异常') } return Promise.reject(error) } ) // Token 过期处理 async function handleUnauthorized() { const userStore = useUserStore() if (!isRefreshing) { isRefreshing = true try { await userStore.refreshToken() // 重放失败请求 pendingRequests.forEach(cb => cb()) pendingRequests = [] } catch { userStore.logout() router.push('/login') } finally { isRefreshing = false } } return new Promise((resolve) => { pendingRequests.push(() => resolve(service(error.config))) }) } export default service// src/api/modules/user.js import request from '@/utils/request' export const userApi = { login: (data) => request.post('/auth/login', data), logout: () => request.post('/auth/logout'), getProfile: () => request.get('/user/profile'), updateProfile: (data) => request.put('/user/profile', data), changePassword: (data) => request.put('/user/password', data), // 分页列表 getUsers: (params) => request.get('/users', { params }), createUser: (data) => request.post('/users', data), updateUser: (id, data) => request.put(`/users/${id}`, data), deleteUser: (id) => request.delete(`/users/${id}`) }六、路由与权限// src/router/index.js import { createRouter, createWebHistory } from 'vue-router' import { setupGuards } from './guards' // 基础路由 — 无需权限 export const constantRoutes = [ { path: '/login', name: 'Login', component: () => import('@/views/login/index.vue'), meta: { title: '登录' } }, { path: '/', component: () => import('@/layouts/DefaultLayout.vue'), redirect: '/dashboard', children: [ { path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/index.vue'), meta: { title: '仪表盘', icon: 'Odometer' } } ] }, { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') } ] const router = createRouter({ history: createWebHistory(), routes: constantRoutes }) setupGuards(router) export default router// src/router/guards.js import { useUserStore } from '@/stores/user' import { usePermissionStore } from '@/stores/permission' const whiteList = ['/login'] export function setupGuards(router) { router.beforeEach(async (to, from, next) => { document.title = `${to.meta.title || ''} - 系统名称` const userStore = useUserStore() if (userStore.token) { if (to.path === '/login') { next({ path: '/' }) } else { // 未加载用户信息和动态路由 if (!userStore.user) { try { // 获取用户信息 await userStore.getProfile() // 生成动态路由 const permissionStore = usePermissionStore() const accessRoutes = await permissionStore.generateRoutes() // 动态添加路由 accessRoutes.forEach(route => router.addRoute(route)) // 重新导航,确保路由已注册 next({ ...to, replace: true }) } catch (error) { userStore.resetToken() next(`/login?redirect=${to.path}`) } } else { next() } } } else { // 白名单直接放行 if (whiteList.includes(to.path)) { next() } else { next(`/login?redirect=${to.path}`) } } }) }// src/stores/permission.js import { defineStore } from 'pinia' import api from '@/api' // 视图模块映射 const viewModules = import.meta.glob('@/views/**/*.vue') export const usePermissionStore = defineStore('permission', () => { const routes = ref([]) async function generateRoutes() { // 从后端获取菜单权限 const menus = await api.menu.getUserMenus() // 转换后端菜单为路由 const asyncRoutes = transformRoutes(menus) routes.value = asyncRoutes return asyncRoutes } function transformRoutes(menus, parentPath = '') { return menus.map(menu => { const route = { path: menu.path.startsWith('/') ? menu.path : `${parentPath}/${menu.path}`, name: menu.name, meta: { title: menu.title, icon: menu.icon }, children: [] } // 组件解析 const componentPath = menu.component || 'layouts/DefaultLayout' route.component = viewModules[`/src/views/${componentPath}.vue`] if (menu.children?.length) { route.children = transformRoutes(menu.children, route.path) } return route }) } return { routes, generateRoutes } })七、Mock 数据// vite.config.js 增加 mock 插件 import { viteMockServe } from 'vite-plugin-mock' plugins: [ vue(), viteMockServe({ mockPath: 'mock', localEnabled: true }) ]// mock/user.js export default [ { url: '/api/user/profile', method: 'get', response: () => { return { code: 200, data: { id: 1, name: '张三', roles: ['admin'], permissions: ['system:user:list', 'system:user:create'] } } } }, { url: '/api/users', method: 'get', response: ({ query }) => { const { page = 1, pageSize = 10 } = query const list = Array.from({ length: 50 }, (_, i) => ({ id: i + 1, name: `用户${i + 1}`, email: `user${i + 1}@example.com`, status: i % 2 })) return { code: 200, data: { list: list.slice((page - 1) * pageSize, page * pageSize), total: list.length } } } } ]八、代码规范// eslint.config.js (Flat Config) import js from '@eslint/js' import vue from 'eslint-plugin-vue' import prettier from 'eslint-config-prettier' export default [ js.configs.recommended, ...vue.configs['flat/recommended'], prettier, { rules: { 'vue/multi-word-component-names': 'off', 'no-console': 'warn', 'no-debugger': 'warn' } } ]// .prettierrc { "semi": false, "singleQuote": true, "printWidth": 100, "trailingComma": "none", "arrowParens": "always" }九、Git 提交规范与 CI// commitlint.config.js export default { extends: ['@commitlint/config-conventional'], rules: { 'type-enum': [2, 'always', [ 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore' ]] } }# .github/workflows/ci.yml name: CI on: push: branches: [main, develop] pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install run: npm ci - name: Lint run: npm run lint - name: Test run: npm run test:unit - name: Build run: npm run build - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: dist path: dist十、小结企业级 Vue 项目的工程化核心:规范的目录结构让团队协作有章可循,统一的请求封装处理认证与错误,动态路由实现权限控制,Mock 让前后端并行开发,ESLint + CI 保证代码质量。这套脚手架可以直接作为新项目的起点。本文由 inspirecl.asia 原创,转载请注明出处。
2026年07月05日
4 阅读
0 评论
0 点赞
2026-07-04
Vue 3 组件进阶:性能优化与最佳实践
从"能跑"到"跑得快"是前端工程的分水岭。本文从渲染机制出发,系统讲解 Vue 3 的性能优化手段:虚拟列表、组件缓存、懒加载、防抖节流,以及性能分析工具的完整使用。一、性能优化总览性能优化层次 │ ├── 网络层 — 资源体积、加载策略 │ ├── 路由懒加载 │ ├── 组件异步加载 │ └── 图片/资源优化 │ ├── 渲染层 — 减少不必要的渲染 │ ├── v-show vs v-if │ ├── v-memo 缓存 │ ├── KeepAlive 组件缓存 │ └── 虚拟列表 │ ├── 计算层 — 减少计算量 │ ├── computed 缓存 │ ├── 防抖节流 │ └── Web Worker │ └── 监控层 — 持续度量 ├── Performance 面板 ├── Vue DevTools └── 自定义性能指标二、渲染优化v-if vs v-show<template> <!-- v-if:真正移除/创建 DOM,切换开销大 --> <HeavyComponent v-if="show" /> <!-- v-show:只切换 display,初始开销大 --> <HeavyComponent v-show="show" /> <!-- 选择原则: 频繁切换 → v-show 条件很少变化 → v-if --> </template>v-memo 缓存子树<script setup> import { ref } from 'vue' const items = ref([...10000 条数据]) const selectedId = ref(null) </script> <template> <!-- v-memo 依赖数组不变时跳过整个列表的 diff --> <div v-for="item in items" :key="item.id" v-memo="[item.id === selectedId]"> <p>{{ item.name }}</p> <p>{{ item.description }}</p> <!-- ... 复杂内容 ... --> </div> </template>KeepAlive 组件缓存<script setup> import { ref } from 'vue' import TabA from './TabA.vue' import TabB from './TabB.vue' import TabC from './TabC.vue' const currentTab = ref('A') const tabs = { A: TabA, B: TabB, C: TabC } </script> <template> <!-- 缓存组件实例,切换时保留状态,避免重新创建 --> <KeepAlive include="TabA,TabB" :max="10"> <component :is="tabs[currentTab]" /> </KeepAlive> </template><!-- TabA.vue --> <script setup> import { onActivated, onDeactivated } from 'vue' onActivated(() => { // 组件从缓存激活时(如刷新数据) refreshData() }) onDeactivated(() => { // 组件被缓存时(如清除定时器) clearInterval(timer) }) </script>异步组件与 Suspense<script setup> import { defineAsyncComponent } from 'vue' // 基本异步组件 const HeavyChart = defineAsyncComponent(() => import('./components/HeavyChart.vue') ) // 带加载和错误状态 const AdminPanel = defineAsyncComponent({ loader: () => import('./components/AdminPanel.vue'), loadingComponent: LoadingSpinner, errorComponent: ErrorDisplay, delay: 200, // 200ms 后显示 loading timeout: 10000 // 10s 超时显示错误 }) </script> <template> <Suspense> <template #default> <AsyncDashboard /> </template> <template #fallback> <LoadingSpinner /> </template> </Suspense> </template>虚拟列表(大数据量必备)npm install @tanstack/vue-virtual<script setup> import { ref } from 'vue' import { useVirtualizer } from '@tanstack/vue-virtual' const parentRef = ref(null) const rows = ref(Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }))) const virtualizer = useVirtualizer({ count: rows.value.length, getScrollElement: () => parentRef.value, estimateSize: () => 48, // 每行预估高度 overscan: 5, // 预渲染缓冲行数 }) </script> <template> <div ref="parentRef" style="height: 600px; overflow: auto;"> <div :style="{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }"> <div v-for="row in virtualizer.getVirtualItems()" :key="rows[row.index].id" :style="{ position: 'absolute', top: 0, left: 0, width: '100%', height: `${row.size}px`, transform: `translateY(${row.start}px)` }"> {{ rows[row.index].name }} </div> </div> </div> </template>三、计算与交互优化防抖与节流// 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) } })) }<script setup> import { useDebouncedRef } from '@/composables/useDebounce' // 搜索输入防抖 — 300ms 内的连续输入只触发一次请求 const keyword = useDebouncedRef('', 300) // watch keyword 触发搜索 watchEffect(async () => { if (keyword.value) { const results = await search(keyword.value) searchResults.value = results } }) </script> <template> <input v-model="keyword" placeholder="搜索..." /> </template>深度 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.value = e.data.result processing.value = false } function process(type, data) { processing.value = true worker.postMessage({ type, data }) } onUnmounted(() => worker.terminate()) return { result, processing, process } }四、加载优化路由懒加载// router/index.js import { createRouter } from 'vue-router' const routes = [ { path: '/', component: () => import('../views/Home.vue') // 懒加载 }, { path: '/admin', component: () => import(/* webpackChunkName: "admin" */ '../views/Admin.vue'), // Vite: 单独分包 // component: () => import('../views/Admin.vue'), meta: { requiresAuth: true } } ] // 按组分包(Vite) // vite.config.js export default { build: { rollupOptions: { output: { manualChunks: { 'vendor-vue': ['vue', 'vue-router', 'pinia'], 'vendor-ui': ['element-plus'], 'vendor-chart': ['echarts'] } } } } }图片优化<template> <!-- 懒加载图片 --> <img v-lazy="item.image" :alt="item.title" /> <!-- 响应式图片 --> <picture> <source media="(max-width: 768px)" :srcset="item.imageMobile"> <source media="(max-width: 1200px)" :srcset="item.imageTablet"> <img :src="item.imageDesktop" :alt="item.title" loading="lazy"> </picture> </template> <script setup> // 自定义图片懒加载指令 const vLazy = { mounted(el, binding) { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { el.src = binding.value observer.unobserve(el) } }) observer.observe(el) } } </script>骨架屏<template> <div v-if="loading" class="skeleton-card"> <div class="skeleton-avatar"></div> <div class="skeleton-lines"> <div class="skeleton-line w-80"></div> <div class="skeleton-line w-60"></div> <div class="skeleton-line w-40"></div> </div> </div> <div v-else class="content"> <!-- 真实内容 --> </div> </template> <style> .skeleton-card { display: flex; gap: 16px; padding: 16px; } .skeleton-avatar { width: 64px; height: 64px; border-radius: 50%; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; } .skeleton-line { height: 16px; border-radius: 4px; background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; margin-bottom: 8px; } .w-80 { width: 80%; } .w-60 { width: 60%; } .w-40 { width: 40%; } @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } </style>五、性能分析工具Vue DevTools 性能面板Vue DevTools → Timeline / Performance - 组件渲染耗时 - 事件处理耗时 - 响应式依赖追踪 Component Inspector: - 查看组件的 props、state、computed - 高亮重新渲染的组件Performance API 自定义测量// main.js — Vue 性能追踪 const app = createApp(App) // 开启性能追踪(开发模式) app.config.performance = true // 自定义性能标记 performance.mark('app-init-start') app.mount('#app') performance.mark('app-init-end') performance.measure('应用初始化', 'app-init-start', 'app-init-end') // 组件级测量 // MyComponent.vue onMounted(() => { performance.mark(`${instance.uid}-mounted`) }) onBeforeMount(() => { performance.mark(`${instance.uid}-before-mount`) })Web Vitals 监控// 监控核心 Web 指标 import { onLCP, onFID, onCLS, onINP, onTTFB } from 'web-vitals' function sendToAnalytics(metric) { const body = JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating, id: metric.id }) navigator.sendBeacon('/api/vitals', body) } onLCP(sendToAnalytics) // 最大内容绘制 onFID(sendToAnalytics) // 首次输入延迟 onCLS(sendToAnalytics) // 累积布局偏移 onINP(sendToAnalytics) // 交互到下一次绘制 onTTFB(sendToAnalytics) // 首字节时间渲染性能检查清单// 开发环境全局组件渲染追踪 // main.js if (import.meta.env.DEV) { app.config.warnHandler = (msg, instance, trace) => { console.warn(`[Vue warn]: ${msg}${trace}`) } } // 检测不必要的重新渲染 import { watch } from 'vue' // 在 DevTools 中使用 "Highlight updates" 功能六、优化检查清单类别手段收益首屏路由懒加载 + 组件分包减少首包体积 50%+首屏骨架屏感知性能提升列表虚拟列表万级数据流畅渲染列表v-memo跳过不必要的 diff组件KeepAlive切换零重建成本组件异步组件按需加载重组件交互防抖节流减少触发频率计算computed 缓存避免重复计算计算Web Worker主线程零阻塞图片懒加载 + 响应式节省带宽七、小结性能优化的核心是"度量驱动":先用 DevTools 和 Web Vitals 找到瓶颈,再对症下药。渲染层关注"减少不必要的更新"(v-memo、KeepAlive、虚拟列表),计算层关注"不阻塞主线程"(防抖、Worker),加载层关注"更快呈现"(懒加载、分包、骨架屏)。本文由 inspirecl.asia 原创,转载请注明出处。
2026年07月04日
5 阅读
0 评论
0 点赞
2026-06-28
Pinia 状态管理实战:从 Vuex 迁移到 Pinia
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 } })在组件中使用<script setup> import { useCounterStore } from '@/stores/counter' import { storeToRefs } from 'pinia' const counter = useCounterStore() // ⚠️ 解构会丢失响应式,必须用 storeToRefs const { count, double } = storeToRefs(counter) // action 可以直接解构(它是函数) const { increment } = counter // 也可以不解构直接用 console.log(counter.count) counter.increment() </script> <template> <div> <p>{{ counter.name }}: {{ count }}</p> <p>双倍: {{ double }}</p> <button @click="increment">+1</button> </div> </template>三、核心概念详解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(user) { this.user = user this.permissions = user.permissions }, // $patch 批量更新,性能更好 updateSettings(partial) { this.$patch({ settings: { ...this.settings, ...partial } }) // 函数式写法 this.$patch((state) => { state.settings.theme = 'dark' }) } } })Gettersexport const useCartStore = defineStore('cart', { state: () => ({ items: [] }), getters: { // 基础 getter itemCount: (state) => state.items.length, // 带参数的 getter(返回函数,无缓存优势) itemById: (state) => { return (id) => state.items.find(item => item.id === id) }, // 访问其他 getter subtotal() { return this.items.reduce((sum, item) => sum + item.price * item.qty, 0) }, // 访问其他 Store total() { const userStore = useUserStore() const discount = userStore.user?.discount ?? 1 return this.subtotal * discount } } })Actions// stores/products.js import { defineStore } from 'pinia' import api from '@/api' export const useProductStore = defineStore('products', () => { const products = ref([]) const loading = ref(false) const error = ref(null) // 获取产品列表 async function fetchProducts(params) { loading.value = true error.value = null try { const { data } = await api.get('/products', { params }) products.value = data } catch (e) { error.value = e.message } finally { loading.value = false } } // 跨 Store 调用 async function purchase(productId) { const cartStore = useCartStore() const product = products.value.find(p => p.id === productId) await api.post(`/cart/items`, { productId, qty: 1 }) cartStore.addItem(product) } return { products, loading, error, fetchProducts, purchase } })四、插件系统持久化插件// plugins/persist.js import { watch } from 'vue' export function createPersistPlugin() { return ({ store }) => { // 从 localStorage 恢复 const saved = localStorage.getItem(`pinia-${store.$id}`) if (saved) { store.$patch(JSON.parse(saved)) } // 订阅变化并保存 store.$subscribe((mutation, state) => { localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state)) }) } }// main.js import { createPinia } from 'pinia' import { createPersistPlugin } from './plugins/persist' const pinia = createPinia() pinia.use(createPersistPlugin())使用官方持久化插件npm install pinia-plugin-persistedstate// stores/user.js export const useUserStore = defineStore('user', { state: () => ({ user: null, token: '' }), // 持久化配置 persist: { key: 'my-app-user', storage: localStorage, // 或 sessionStorage // 只持久化部分字段 pick: ['token', 'user.id', 'user.name'] }, actions: { login(credentials) { /* ... */ }, logout() { this.user = null this.token = '' } } })自定义日志插件// plugins/logger.js export function createLoggerPlugin() { return ({ store }) => { store.$subscribe((mutation, state) => { console.log( `[Pinia] ${mutation.storeId} ${mutation.type}:`, mutation.type === 'patch' ? mutation.events : `${mutation.payload}` ) }) store.$onAction(({ name, args, after, onError }) => { const startTime = Date.now() console.log(`[Action] ${store.$id}/${name} 开始`, args) after((result) => { console.log(`[Action] ${store.$id}/${name} 完成 (${Date.now() - startTime}ms)`) }) onError((error) => { console.error(`[Action] ${store.$id}/${name} 失败:`, error) }) }) } }五、Store 组合与模块化Store 之间相互调用// stores/cart.js import { defineStore } from 'pinia' import { useUserStore } from './user' import { useProductStore } from './products' export const useCartStore = defineStore('cart', () => { const items = ref([]) const userStore = useUserStore() // 在 setup 内调用 const total = computed(() => { const discount = userStore.user?.discount ?? 1 return items.value.reduce( (sum, item) => sum + item.price * item.qty, 0 ) * discount }) return { items, total } })组合式 Store 设计模式// stores/composables/useEntityCRUD.js // 通用的 CRUD Store 工厂 export function createCrudStore(entityName, apiEndpoint) { return defineStore(entityName, () => { const items = ref([]) const current = ref(null) const loading = ref(false) async function fetchAll(params = {}) { loading.value = true try { const { data } = await api.get(apiEndpoint, { params }) items.value = data } finally { loading.value = false } } async function fetchOne(id) { const { data } = await api.get(`${apiEndpoint}/${id}`) current.value = data } async function create(payload) { const { data } = await api.post(apiEndpoint, payload) items.value.unshift(data) return data } async function update(id, payload) { const { data } = await api.put(`${apiEndpoint}/${id}`, payload) const index = items.value.findIndex(i => i.id === id) if (index > -1) items.value[index] = data return data } async function remove(id) { await api.delete(`${apiEndpoint}/${id}`) items.value = items.value.filter(i => i.id !== id) } return { items, current, loading, fetchAll, fetchOne, create, update, remove } }) } // 使用工厂创建具体的 Store export const usePostStore = createCrudStore('posts', '/api/posts') export const useCategoryStore = createCrudStore('categories', '/api/categories')六、从 Vuex 迁移对照转换// === Vuex 4 === // store/index.js export default createStore({ state: () => ({ count: 0 }), mutations: { increment(state) { state.count++ } }, actions: { incrementAsync({ commit }) { setTimeout(() => commit('increment'), 1000) } }, getters: { double: (state) => state.count * 2 } }) // === Pinia 等价写法 === export const useCounterStore = defineStore('counter', { state: () => ({ count: 0 }), // mutation 直接变成 action actions: { increment() { this.count++ }, incrementAsync() { setTimeout(() => this.increment(), 1000) } }, getters: { double: (state) => state.count * 2 } })// === 组件中使用对比 === // Vuex // this.$store.state.count // this.$store.commit('increment') // this.$store.dispatch('incrementAsync') // mapState('counter', ['count']) // mapGetters('counter', ['double']) // Pinia const counter = useCounterStore() counter.count counter.increment() // 同步异步统一调用 counter.incrementAsync() const { count } = storeToRefs(counter)渐进式迁移策略同时安装两个库,逐模块迁移从叶子模块开始(无其他模块依赖的)用组合函数桥接:// 桥接层:让旧 Vuex 代码访问新 Pinia // stores/bridge.js export function useCounterCompat() { const counter = useCounterStore() return { get count() { return counter.count }, increment: counter.increment } }迁移完成移除 Vuex七、SSR 中的注意事项// Nuxt 3 中使用 // stores/counter.js export const useCounterStore = defineStore('counter', () => { const count = ref(0) return { count } }) // 组件中(Nuxt 自动导入 Pinia) const counter = useCounterStore() // SSR 中跨请求状态污染防范 // ✅ 在 setup 内调用 useStore(),而不是模块顶层 // ❌ const store = useCounterStore() // 模块顶层会共享状态八、小结Pinia 以极简的 API 提供了完整的状态管理能力:去掉 mutation 减少样板代码,扁平 Store 天然模块化,完整的 TypeScript 和 DevTools 支持。从 Vuex 迁移成本低,新项目直接上 Pinia 是唯一正确选择。本文由 inspirecl.asia 原创,转载请注明出处。
2026年06月28日
4 阅读
0 评论
0 点赞
1
...
4
5
6
...
15
0:00