普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月3日首页

Vue 2 vs React 18 深度对比指南

2025年12月3日 10:36

Vue 2 vs React 18 深度对比指南

本文档面向熟练使用 Vue 2 的开发者,帮助快速理解 React 18 的核心概念与差异。


目录

  1. 核心设计哲学
  2. 组件定义
  3. 响应式原理
  4. 模板 vs JSX
  5. 生命周期对比
  6. 计算属性 vs useMemo
  7. 侦听器 vs useEffect
  8. 父子通信
  9. 跨层级通信
  10. 插槽 vs children / render props
  11. 虚拟 DOM 与 Diff 算法
  12. React 18 新特性
  13. 性能优化对比
  14. 总结对照表
  15. 迁移建议

1. 核心设计哲学

维度 Vue 2 React 18
定位 渐进式框架(框架帮你做更多) UI 库(你自己组合生态)
模板 模板语法 + 指令 JSX(JS 的语法扩展)
响应式 自动依赖追踪(getter/setter) 手动声明更新(setState)
心智模型 "数据变了,视图自动变" "调用更新函数,触发重新渲染"

2. 组件定义

Vue 2:选项式 API

<template>
  <div>{{ count }}</div>
</template>

<script>
export default {
  data() {
    return { count: 0 }
  },
  methods: {
    increment() {
      this.count++
    }
  }
}
</script>

React 18:函数组件 + Hooks

import { useState } from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  
  const increment = () => setCount(c => c + 1)
  
  return <div>{count}</div>
}

原理差异

  • Vue 2:组件是一个"配置对象",Vue 内部实例化并管理生命周期。this 指向组件实例,data 会被 Vue 用 Object.defineProperty 转成响应式。
  • React 18:组件就是一个"纯函数",每次渲染都会重新执行。状态通过 Hooks 保存在 React 内部的 Fiber 节点上,而不是组件实例上。

3. 响应式原理

Vue 2:基于 Object.defineProperty 的依赖追踪

数据变化流程:
data → defineProperty(getter/setter)
       ↓
   getter 收集依赖(Watcher)
       ↓
   setter 触发依赖更新
       ↓
   Watcher 通知组件重新渲染
核心代码逻辑(简化)
// Vue 2 响应式核心
function defineReactive(obj, key, val) {
  const dep = new Dep() // 依赖收集器
  
  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) {
        dep.depend() // 收集当前 Watcher
      }
      return val
    },
    set(newVal) {
      if (newVal === val) return
      val = newVal
      dep.notify() // 通知所有 Watcher 更新
    }
  })
}

局限性:

  • 无法检测属性的添加/删除(需要 Vue.set
  • 无法检测数组索引赋值(需要用 splice 等变异方法)

React 18:不可变数据 + 调度更新

状态变化流程:
setState(newValue)
    ↓
React 调度器标记组件需要更新
    ↓
批量处理更新(Batching)
    ↓
重新执行函数组件
    ↓
Diff 虚拟 DOM → 更新真实 DOM

核心机制:

// React 状态更新(简化)
function useState(initialValue) {
  // 状态存储在 Fiber 节点的 memoizedState 链表上
  const hook = mountWorkInProgressHook()
  hook.memoizedState = initialValue
  
  const dispatch = (action) => {
    // 创建更新对象,加入更新队列
    const update = { action, next: null }
    enqueueUpdate(hook.queue, update)
    // 调度更新
    scheduleUpdateOnFiber(fiber)
  }
  
  return [hook.memoizedState, dispatch]
}

React 18 新特性:自动批处理(Automatic Batching)

// React 17:只有事件处理函数内会批处理
// React 18:所有更新都会自动批处理

// 以下三次 setState 只会触发一次重渲染
setTimeout(() => {
  setCount(c => c + 1)
  setFlag(f => !f)
  setName('new')
}, 1000)

4. 模板 vs JSX

Vue 2:模板 + 指令

<template>
  <div>
    <!-- 条件渲染 -->
    <span v-if="show">显示</span>
    <span v-else>隐藏</span>
    
    <!-- 列表渲染 -->
    <ul>
      <li v-for="item in list" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
    
    <!-- 双向绑定 -->
    <input v-model="text" />
    
    <!-- 事件 -->
    <button @click="handleClick">点击</button>
  </div>
</template>

原理:

  • 模板在编译阶段被转换为渲染函数(render function
  • 指令(v-ifv-for)是编译时的语法糖
  • 编译器可以做静态分析优化(标记静态节点)

React 18:JSX

function MyComponent({ show, list, text, setText }) {
  return (
    <div>
      {/* 条件渲染 */}
      {show ? <span>显示</span> : <span>隐藏</span>}
      
      {/* 列表渲染 */}
      <ul>
        {list.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
      
      {/* 受控组件(双向绑定) */}
      <input value={text} onChange={e => setText(e.target.value)} />
      
      {/* 事件 */}
      <button onClick={handleClick}>点击</button>
    </div>
  )
}

原理:

  • JSX 是 React.createElement() 的语法糖
  • 编译后:<div>hello</div>React.createElement('div', null, 'hello')
  • 没有指令,一切都是 JS 表达式

语法对照表

特性 Vue 2 模板 React JSX
条件 v-if / v-else {condition && ...} 或三元
循环 v-for array.map()
双向绑定 v-model 受控组件(value + onChange)
事件 @click onClick
样式 :class / :style className / style={{}}

5. 生命周期对比

Vue 2 生命周期

beforeCreate → created → beforeMount → mounted
                              ↓
                        beforeUpdate → updated
                              ↓
                        beforeDestroy → destroyed
export default {
  created() {
    // 实例创建完成,data/methods 可用,DOM 未挂载
    // 常用于:初始化数据、发起请求
  },
  mounted() {
    // DOM 已挂载
    // 常用于:操作 DOM、初始化第三方库
  },
  updated() {
    // 数据变化导致 DOM 更新后
  },
  beforeDestroy() {
    // 销毁前,清理定时器、事件监听等
  }
}

React 18:useEffect 统一处理

import { useEffect, useLayoutEffect } from 'react'

function MyComponent() {
  // 相当于 mounted + updated
  useEffect(() => {
    console.log('组件挂载或更新后')
    
    // 相当于 beforeDestroy
    return () => {
      console.log('清理:组件卸载前 或 下次 effect 执行前')
    }
  }) // 无依赖数组:每次渲染后都执行
  
  // 相当于 mounted(只执行一次)
  useEffect(() => {
    console.log('只在挂载时执行')
    return () => console.log('只在卸载时执行')
  }, []) // 空依赖数组
  
  // 相当于 watch
  useEffect(() => {
    console.log('count 变化了')
  }, [count]) // 依赖 count
  
  return <div>...</div>
}

原理:

  • useEffect 的回调在 DOM 更新后异步执行(不阻塞渲染)
  • useLayoutEffectDOM 更新后同步执行(阻塞渲染,用于测量 DOM)
  • 依赖数组决定何时重新执行 effect

生命周期对照表

Vue 2 React 18
created 函数体顶部(但要注意 SSR)
mounted useEffect(() => {}, [])
updated useEffect(() => {})useEffect(() => {}, [deps])
beforeDestroy useEffect 返回的清理函数
watch useEffect(() => {}, [watchedValue])

6. 计算属性 vs useMemo

Vue 2:computed

export default {
  data() {
    return { firstName: 'John', lastName: 'Doe' }
  },
  computed: {
    fullName() {
      return `${this.firstName} ${this.lastName}`
    }
  }
}

原理:

  • computed 是一个惰性求值的 Watcher
  • 只有依赖变化时才重新计算
  • 有缓存:多次访问不会重复计算

React 18:useMemo

function MyComponent({ firstName, lastName }) {
  const fullName = useMemo(() => {
    return `${firstName} ${lastName}`
  }, [firstName, lastName])
  
  return <div>{fullName}</div>
}

原理:

  • useMemo 在依赖数组不变时返回缓存值
  • 依赖数组变化时重新执行计算函数
  • 必须手动声明依赖(Vue 是自动追踪)

关键区别

特性 Vue 2 computed React useMemo
依赖追踪 自动 手动声明
缓存
用途 派生状态 派生状态 + 避免重复计算

7. 侦听器 vs useEffect

Vue 2:watch

export default {
  data() {
    return { query: '' }
  },
  watch: {
    query: {
      handler(newVal, oldVal) {
        this.search(newVal)
      },
      immediate: true, // 立即执行
      deep: true       // 深度监听
    }
  }
}

React 18:useEffect

function SearchComponent() {
  const [query, setQuery] = useState('')
  
  useEffect(() => {
    // 没有 oldVal,需要自己用 useRef 保存
    search(query)
  }, [query])
  
  return <input value={query} onChange={e => setQuery(e.target.value)} />
}

获取旧值的方式:

function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>()
  useEffect(() => {
    ref.current = value
  })
  return ref.current
}

// 使用
const prevQuery = usePrevious(query)

8. 父子通信

Vue 2

<!-- 父组件 -->
<template>
  <Child :msg="message" @update="handleUpdate" />
</template>

<!-- 子组件 -->
<template>
  <div @click="$emit('update', newValue)">{{ msg }}</div>
</template>

<script>
export default {
  props: ['msg']
}
</script>

React 18

// 父组件
function Parent() {
  const [message, setMessage] = useState('')
  
  return (
    <Child 
      msg={message} 
      onUpdate={(newValue) => setMessage(newValue)} 
    />
  )
}

// 子组件
interface ChildProps {
  msg: string
  onUpdate: (value: string) => void
}

function Child({ msg, onUpdate }: ChildProps) {
  return <div onClick={() => onUpdate('new')}>{msg}</div>
}

通信方式对照

特性 Vue 2 React 18
父→子 props props
子→父 $emit 回调函数 props
双向绑定 v-model / .sync 受控组件模式

9. 跨层级通信

Vue 2:provide / inject

// 祖先组件
export default {
  provide() {
    return {
      theme: this.theme
    }
  }
}

// 后代组件
export default {
  inject: ['theme']
}

注意: Vue 2 的 provide/inject 不是响应式的(除非 provide 一个响应式对象)。


React 18:Context

// 创建 Context
const ThemeContext = createContext<string>('light')

// 祖先组件
function App() {
  const [theme, setTheme] = useState('dark')
  
  return (
    <ThemeContext.Provider value={theme}>
      <Child />
    </ThemeContext.Provider>
  )
}

// 后代组件
function DeepChild() {
  const theme = useContext(ThemeContext)
  return <div>当前主题:{theme}</div>
}
原理
  • Provider 的 value 变化时,所有消费该 Context 的组件都会重新渲染
  • 这是 React 的一个性能陷阱:Context 变化会导致所有消费者重渲染,即使它们只用了 Context 的一部分

优化方式:

  • 拆分 Context(读写分离)
  • 使用 useMemo 包裹 value
  • 或使用状态管理库(Redux/Zustand)

10. 插槽 vs children / render props

Vue 2:插槽

<!-- 父组件 -->
<Card>
  <template #header>标题</template>
  <template #default>内容</template>
  <template #footer="{ data }">{{ data }}</template>
</Card>

<!-- Card 组件 -->
<template>
  <div>
    <header><slot name="header" /></header>
    <main><slot /></main>
    <footer><slot name="footer" :data="footerData" /></footer>
  </div>
</template>

React 18:children + render props

// 父组件
<Card
  header={<span>标题</span>}
  footer={(data) => <span>{data}</span>}
>
  内容
</Card>

// Card 组件
interface CardProps {
  header?: ReactNode
  footer?: (data: string) => ReactNode
  children: ReactNode
}

function Card({ header, footer, children }: CardProps) {
  const footerData = 'some data'
  
  return (
    <div>
      <header>{header}</header>
      <main>{children}</main>
      <footer>{footer?.(footerData)}</footer>
    </div>
  )
}

插槽对照

Vue 2 React 18
默认插槽 <slot /> children
具名插槽 <slot name="x" /> 具名 props(如 header
作用域插槽 render props(函数作为 props)

11. 虚拟 DOM 与 Diff 算法

Vue 2 Diff

  • 双端比较算法:同时从新旧节点列表的两端向中间比较
  • 优化:静态节点标记,跳过不变的节点
旧: [A, B, C, D]
新: [D, A, B, C]

双端比较:
1. 旧头(A) vs 新头(D) ❌
2. 旧尾(D) vs 新尾(C) ❌
3. 旧头(A) vs 新尾(C) ❌
4. 旧尾(D) vs 新头(D) ✅ → 移动 D 到最前

React 18 Diff

  • 单向遍历 + key 映射
  • 只从左到右遍历,通过 key 建立映射
旧: [A, B, C, D]
新: [D, A, B, C]

1. 遍历新列表,D 在旧列表中找到,但位置不对
2. 标记需要移动的节点
3. 最小化 DOM 操作

关键:key 的作用

// ❌ 错误:用 index 作为 key
{list.map((item, index) => <Item key={index} />)}

// ✅ 正确:用唯一标识作为 key
{list.map(item => <Item key={item.id} />)}

12. React 18 新特性

12.1 并发渲染(Concurrent Rendering)

React 18 最大的变化:渲染可以被中断。

import { useTransition, useDeferredValue } from 'react'

function SearchResults() {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()
  
  const handleChange = (e) => {
    // 紧急更新:输入框立即响应
    setQuery(e.target.value)
    
    // 非紧急更新:搜索结果可以延迟
    startTransition(() => {
      setSearchResults(search(e.target.value))
    })
  }
  
  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending ? <Spinner /> : <Results />}
    </>
  )
}

原理:

  • React 18 引入了优先级调度
  • startTransition 标记的更新是低优先级的,可以被高优先级更新打断
  • 用户输入等交互是高优先级,数据渲染是低优先级

12.2 Suspense 数据获取

// 配合 React Query / SWR / Relay 等
function ProfilePage() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfileDetails />
      <Suspense fallback={<PostsSpinner />}>
        <ProfilePosts />
      </Suspense>
    </Suspense>
  )
}

12.3 自动批处理

// React 18:所有更新自动批处理
setTimeout(() => {
  setCount(c => c + 1)  // 不会立即渲染
  setFlag(f => !f)      // 不会立即渲染
  // 只触发一次渲染
}, 1000)

13. 性能优化对比

Vue 2 性能优化

// 1. v-once:只渲染一次
<span v-once>{{ staticContent }}</span>

// 2. v-memo(Vue 3.2+,Vue 2 没有)

// 3. computed 自带缓存

// 4. keep-alive 缓存组件
<keep-alive>
  <component :is="currentComponent" />
</keep-alive>

React 18 性能优化

// 1. React.memo:组件级别缓存
const MemoizedComponent = React.memo(function MyComponent(props) {
  return <div>{props.value}</div>
})

// 2. useMemo:值缓存
const expensiveValue = useMemo(() => compute(a, b), [a, b])

// 3. useCallback:函数缓存
const handleClick = useCallback(() => {
  doSomething(a, b)
}, [a, b])

// 4. 懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'))

<Suspense fallback={<Loading />}>
  <LazyComponent />
</Suspense>

优化方式对照

优化点 Vue 2 React 18
组件缓存 自动(响应式追踪) 手动(React.memo
计算缓存 computed(自动依赖) useMemo(手动依赖)
函数缓存 不需要(方法绑定在实例上) useCallback(避免子组件重渲染)
组件保活 <keep-alive> 无内置,需第三方库

14. 总结对照表

特性 Vue 2 React 18
组件定义 选项式对象 函数 + Hooks
响应式 自动(defineProperty) 手动(setState)
模板 模板 + 指令 JSX
状态 data() useState
计算属性 computed useMemo
侦听 watch useEffect
生命周期 多个钩子函数 useEffect 统一
父子通信 props + $emit props + 回调
跨层级 provide/inject Context
插槽 slot children / render props
性能优化 框架自动优化多 开发者手动优化多
并发 Concurrent Mode
学习曲线 平缓 陡峭(Hooks 心智模型)

15. 迁移建议

从 Vue 2 转 React 18,重点转变思维:

15.1 从"响应式"到"不可变"

// Vue:直接修改
this.list.push(item)

// React:创建新数组
setList([...list, item])

15.2 从"自动依赖"到"手动声明"

// Vue:computed 自动追踪依赖
computed: {
  fullName() {
    return this.firstName + this.lastName
  }
}

// React:useMemo 必须手动写依赖数组
const fullName = useMemo(() => {
  return firstName + lastName
}, [firstName, lastName])

15.3 从"实例方法"到"闭包函数"

// Vue:this.handleClick 始终是同一个函数
methods: {
  handleClick() { ... }
}

// React:每次渲染 handleClick 都是新函数(需要 useCallback 优化)
const handleClick = useCallback(() => {
  doSomething(a, b)
}, [a, b])

15.4 从"模板指令"到"JS 表达式"

Vue 2 React 18
v-if="show" {show && <Component />}
v-for="item in list" {list.map(item => ...)}
v-model="value" value={value} onChange={...}

附录:常用 Hooks 速查

Hook 用途 Vue 2 对应
useState 状态管理 data
useEffect 副作用处理 mounted / watch / beforeDestroy
useMemo 计算缓存 computed
useCallback 函数缓存 无(Vue 不需要)
useRef 引用 DOM / 保存可变值 $refs / 实例属性
useContext 跨层级状态 inject
useReducer 复杂状态逻辑 Vuex mutations
useLayoutEffect 同步副作用 mounted(同步部分)
useTransition 并发更新
useDeferredValue 延迟更新

AI取名大师 | 使得 uni-app 兼容 vue3 同名简写语法糖的 vite 插件

作者 集成显卡
2025年12月3日 10:18

关于 AI 取名大师

借助豆包通义千问DeepSeek 等 AI 大模型,为您的宝宝、宠物、店铺、网名、笔名、项目、产品、服务、文章等取一个专业、有意义的名字😄。


开源地址:👉GitCode(国内友好)👈、👉GitHub👈 技术组合:Bun.jsElysia.jsuni-app 体验地址:AI取名大师(H5版)、小程序搜索取名大师


特别注明:本系列文章仅为实战经验分享,并记录开发过程中碰到的问题😄,如有不足之处欢迎随时留言提出。


📣 同名简写语法

这是 vue 3.4+ 后更新的语法糖,如果属性名变量名完全一致,Vue 允许你简写,详见官方文档

写法 等价
v-bind:text="text" 完整写法
:text="text" v-bind 简写
:text 同名简写(implicit value)

示例:

<script setup>
const message = "Hello";
const active = true;
</script>

<template>
  <Comp :message :active />
<!--甚至可以这样写-->
<Comp v-bind="{ message, active }" />
</template>

😔 在小程序中不支持

同名简写语法,用过就觉得很香,习惯后怎么舍得改回来。可惜小程序(尤其是微信小程序)不支持该语法糖,使得编译不通过,只能一个个把同名简写改成传统模式😔。

🔧 搞一个插件

功能

专门为 uni-app 修补 Vue3 同名简写,就是 Vue SFC 进入 uni-app 小程序编译之前,用一个自定义 Vite 插件把 :text 自动转换为 :text="text"

原理简介

  • Vue 编译器对 :text 是合法的,因为它是 Vue3 的同名简写;
  • uni-app 的小程序编译器(特别是微信)不支持,会报错;
  • 我们可以在 Vite 阶段 预处理 .vue 文件的 template 源码:
    • 找到所有 :<attr> 且没有 = 的情况;
    • 转换成 :<attr>="<attr>"

设计原则

  • ✔ 只处理

AI取名大师 | uni-app 微信小程序打包 v-bind、component 动态组件问题

作者 集成显卡
2025年12月3日 10:18

关于 AI 取名大师

借助豆包通义千问DeepSeek 等 AI 大模型,为您的宝宝、宠物、店铺、网名、笔名、项目、产品、服务、文章等取一个专业、有意义的名字😄。


开源地址:👉GitCode(国内友好)👈、👉GitHub👈 技术组合:Bun.jsElysia.jsuni-app 体验地址:AI取名大师(H5版)、小程序搜索取名大师


特别注明:本系列文章仅为实战经验分享,并记录开发过程中碰到的问题😄,如有不足之处欢迎随时留言提出。


v-bind / 属性绑定

直接上代码:

<template>
<view :title />
</template>

<script setup>
let title = "标题"
</script>

这是一个再简单不过的 Vue3 示例,使用了绑定属性缩写,非常简洁清爽!编译为 h5 完全没问题,但是编译成微信小程序就会报错:

正在编译中...
✗ Build failed in 1.02s
[vite:vue] v-bind is missing expression.

2  |      <view @click="create" class="inline">
3  |          <slot>
4  |              <wd-button :icon="icon" :size="size" :type>创建积分券</wd-button>
   |                                         ^^^^^
5  |          </slot>
6  |      </view>

简单说就是微信小程序环境下不支持绑定缩写😔,只能一个个修给出:title="title"的形式,心累。如果需要多端支持,写代码时就得注意。

不支持 dblclick

打包为小程序时,如果使用了@dblclick会报错[vite:vue] v-bind is missing expression

这不是 @dblclick 自身的问题,因为打包为 H5 是完成没问题的,而是 小程序不支持 dblclick 事件,导致 Vue 在编译阶段将 @dblclick 解析为未知指令,间接触发 v-bind 报错。

我们可以通过 click 事件模拟双击,双击本质是两次 点击 之间时间间隔足够短

<template>
<wd-text @click="onClick" size="12px" />
</template>

<script setup>
let lastTime = 0
const onClick = ()=>{
    const now = Date.now()
    // 250ms 是常见阈值,可以按需求调整
    if(now - lastTime < 250){
        //处理双击
    }
    lastTime = now
}
</script>

component is not supported

原始代码:

<template>
<component :is='buildSVG(bean)' />
</template>

<script setup>
const buildSVG = item=>{
        let { color, svg, fill } = item.icon || {}
        if(svg && svg.startsWith("<svg "))
            return h('view', {class:"icon", innerHTML: svg })

        return h(
            item.id == 'baobao'? BabySVG:
            item.id == 'dianpu'? ShopSVG:
            item.id == 'chongwu'? DogSVG:
            item.id == 'wangming'? VestSVG:
            item.id == 'biming'? EditSVG:
            item.id == 'zuopin'? CreationSVG:
            item.id == 'wenzhang'? TitleSVG:
            null,
            { clazz:'icon', fill: fill || color, size: props.iconSize }
        )
    }
</script>

由于·微信小程序·的自定义组件系统不支持 <component is="">,也不支持 Vue 的动态组件渲染。

所以只能改成条件判断。

<template>
    <template v-if="inited">
        <view v-if="custom" class="icon" :innerHTML="bean.icon.svg" />
        <BabySVG v-if="bean.id=='baobao'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <ShopSVG v-else-if="bean.id=='dianpu'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <DogSVG v-else-if="bean.id=='chongwu'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <VestSVG v-else-if="bean.id=='wangming'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <EditSVG v-else-if="bean.id=='biming'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <CreationSVG v-else-if="bean.id=='zuopin'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <TitleSVG v-else-if="bean.id=='wenzhang'" :fill="fill" :color="color" :size="size" clazz='icon' />
        <view v-else />
    </template>
</template>

<script setup>
    import BabySVG from '@SVG/baby.vue'
    import ShopSVG from '@SVG/shop.vue'
    import DogSVG from '@SVG/dog.vue'
    import EditSVG from '@SVG/edit.vue'
    import CreationSVG from '@SVG/creation.vue'
    import VestSVG from '@SVG/vest.vue'
    import TitleSVG from '@SVG/title.vue'

    const props = defineProps({
        bean:{type:Object, default:{}},
        size:{type:Number, default:48}
    })

    let inited = ref(false)
    let fill
    let color
    let custom = false

    onMounted(() => {
        let { svg } = props.bean.icon || {}
        if(svg && svg.startsWith("<svg "))
            custom = true

        fill = props.bean.icon?.fill
        color = props.bean.icon?.color

        inited.value = true
    })
</script>

Invalid pattern

Invalid pattern "../node-modules/wot-design-uni/components/wd-navbar/wd-navbar.js" for "output.chunkFileNames", patterns can be neither absolute nor relative paths. If you want your files to be stored in a subdirectory, write its name without a leading slash like this: subdirectory/pattern.

原因不明,删除 node_modules后,重新bun i就能正常打包😔。

Vue 的 keep-alive 生命周期钩子全解析:让你的组件“起死回生”

作者 北辰alk
2025年12月3日 08:25

前言:为什么需要 keep-alive?

在 Vue 应用开发中,我们经常会遇到这样的场景:用户在表单页面填写了大量数据,不小心切换到其他页面,再返回时发现所有数据都清空了!这种糟糕的用户体验,正是 Vue 的 keep-alive 组件要解决的痛点。

keep-alive 是 Vue 的内置组件,能够缓存不活动的组件实例,而不是销毁它们。今天我们就来深入探讨它的生命周期钩子,让你的应用性能飞起!

一、keep-alive 基础用法

1.1 基本使用方式

<template>
  <div id="app">
    <!-- 基本用法 -->
    <keep-alive>
      <component :is="currentComponent"></component>
    </keep-alive>
    
    <!-- 包含/排除特定组件 -->
    <keep-alive :include="['Home', 'About']" :exclude="['Contact']">
      <router-view></router-view>
    </keep-alive>
    
    <!-- 最大缓存实例数 -->
    <keep-alive :max="10">
      <router-view></router-view>
    </keep-alive>
  </div>
</template>

<script>
export default {
  data() {
    return {
      currentComponent'HomePage'
    }
  }
}
</script>

1.2 keep-alive 的工作原理

为了更好理解,我们先来看一下 keep-alive 的工作流程:

是

否

是

否

组件首次加载正常生命周期created → mounted进入缓存是否被激活?执行 activated返回缓存状态执行 deactivated保持缓存缓存是否超出限制?LRU算法淘汰

二、keep-alive 专属的生命周期钩子

2.1 activated - 组件激活时调用

当组件被 keep-alive 缓存后再次显示时触发。

<template>
  <div class="user-dashboard">
    <h2>用户仪表板</h2>
    <p>最后更新时间: {{ lastUpdateTime }}</p>
    <div v-if="loading">加载中...</div>
    <div v-else>
      <ul>
        <li v-for="user in users" :key="user.id">
          {{ user.name }} - {{ user.email }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  name'UserDashboard',
  data() {
    return {
      users: [],
      loadingfalse,
      lastUpdateTimenull,
      // 定时器ID
      refreshTimernull
    }
  },
  
  activated() {
    console.log('UserDashboard 组件被激活了!')
    
    // 自动刷新数据
    this.refreshData()
    
    // 启动定时刷新
    this.startAutoRefresh()
    
    // 更新最后刷新时间
    this.lastUpdateTime = new Date().toLocaleString()
    
    // 发送统计事件
    this.trackComponentActivation()
  },
  
  methods: {
    async refreshData() {
      this.loading = true
      try {
        // 模拟API调用
        const response = await fetch('/api/users')
        this.users = await response.json()
      } catch (error) {
        console.error('数据刷新失败:', error)
      } finally {
        this.loading = false
      }
    },
    
    startAutoRefresh() {
      // 每30秒自动刷新一次
      this.refreshTimer = setInterval(() => {
        this.refreshData()
        console.log('自动刷新用户数据...')
      }, 30000)
    },
    
    trackComponentActivation() {
      // 发送分析事件
      console.log('组件激活统计已发送')
    }
  }
}
</script>

2.2 deactivated - 组件停用时调用

当组件被 keep-alive 缓存并隐藏时触发。

<template>
  <div class="video-player">
    <video ref="videoPlayer" controls width="600">
      <source src="/sample-video.mp4" type="video/mp4">
    </video>
    <div class="player-controls">
      <button @click="togglePlay">{{ isPlaying ? '暂停' : '播放' }}</button>
      <button @click="toggleFullscreen">全屏</button>
    </div>
  </div>
</template>

<script>
export default {
  name'VideoPlayer',
  data() {
    return {
      isPlayingfalse,
      currentTime0,
      volume1.0,
      // 保存播放状态
      savedStatenull
    }
  },
  
  mounted() {
    console.log('VideoPlayer 组件挂载完成')
    this.initializePlayer()
  },
  
  deactivated() {
    console.log('VideoPlayer 组件被停用,进入缓存')
    
    // 保存当前播放状态
    this.savePlaybackState()
    
    // 暂停视频播放
    this.pauseVideo()
    
    // 清理事件监听
    this.cleanupEventListeners()
    
    // 停止数据上报
    this.stopAnalyticsTracking()
    
    // 释放资源
    this.releaseResources()
  },
  
  methods: {
    initializePlayer() {
      const video = this.$refs.videoPlayer
      video.addEventListener('play'() => {
        this.isPlaying = true
        this.startPlaybackAnalytics()
      })
      video.addEventListener('pause'() => {
        this.isPlaying = false
      })
    },
    
    savePlaybackState() {
      const video = this.$refs.videoPlayer
      this.savedState = {
        currentTime: video.currentTime,
        volume: video.volume,
        playbackRate: video.playbackRate,
        isPlaying: !video.paused
      }
      console.log('播放状态已保存:'this.savedState)
    },
    
    pauseVideo() {
      const video = this.$refs.videoPlayer
      if (!video.paused) {
        video.pause()
        console.log('视频已暂停')
      }
    },
    
    cleanupEventListeners() {
      // 清理自定义事件监听器
      window.removeEventListener('resize'this.handleResize)
      document.removeEventListener('keydown'this.handleKeyPress)
    },
    
    startPlaybackAnalytics() {
      console.log('开始播放数据分析...')
    },
    
    stopAnalyticsTracking() {
      console.log('停止数据分析')
    },
    
    releaseResources() {
      // 释放视频解码器等资源
      const video = this.$refs.videoPlayer
      video.src = ''
      console.log('视频资源已释放')
    },
    
    togglePlay() {
      const video = this.$refs.videoPlayer
      if (video.paused) {
        video.play()
      } else {
        video.pause()
      }
    }
  }
}
</script>

三、完整生命周期流程图

让我们通过一个完整的流程图来理解包含 keep-alive 的组件生命周期:

组件首次加载beforeCreatecreatedbeforeMountmountedactivated切换到其他组件deactivated组件被缓存再次切换到该组件activated使用缓存状态缓存被销毁beforeDestroydestroyed

四、实际应用场景示例

4.1 表单数据持久化

<template>
  <div class="multi-step-form">
    <!-- 步骤指示器 -->
    <div class="steps">
      <span v-for="step in 3" :key="step" 
            :class="{ active: currentStep === step }">
        步骤 {{ step }}
      </span>
    </div>
    
    <!-- 使用 keep-alive 缓存各个步骤 -->
    <keep-alive>
      <component :is="currentStepComponent" 
                 :form-data="formData"
                 @update="handleFormUpdate">
      </component>
    </keep-alive>
    
    <!-- 导航按钮 -->
    <div class="form-navigation">
      <button v-if="currentStep > 1" @click="prevStep">上一步</button>
      <button v-if="currentStep < 3" @click="nextStep">下一步</button>
      <button v-if="currentStep === 3" @click="submitForm">提交</button>
    </div>
  </div>
</template>

<script>
// 步骤1:基本信息
const Step1 = {
  name'Step1',
  props: ['formData'],
  data() {
    return {
      localData: {}
    }
  },
  activated() {
    console.log('Step1 激活 - 恢复表单数据')
    // 恢复组件状态
    if (this.formData.step1) {
      this.localData = { ...this.formData.step1 }
    }
  },
  deactivated() {
    console.log('Step1 停用 - 保存表单数据')
    // 保存数据到父组件
    this.$emit('update', { step1datathis.localData })
  },
  template`
    <div class="step step1">
      <h3>基本信息</h3>
      <input v-model="localData.name" placeholder="姓名">
      <input v-model="localData.email" placeholder="邮箱">
      <input v-model="localData.phone" placeholder="电话">
    </div>
  `
}

// 步骤2:地址信息
const Step2 = {
  name'Step2',
  props: ['formData'],
  activated() {
    console.log('Step2 激活')
    // 可以在这里重新初始化地图等昂贵组件
    this.initializeMap()
  },
  deactivated() {
    console.log('Step2 停用')
    // 清理地图资源
    this.cleanupMap()
  },
  methods: {
    initializeMap() {
      console.log('初始化地图组件...')
    },
    cleanupMap() {
      console.log('清理地图资源...')
    }
  },
  template`
    <div class="step step2">
      <h3>地址信息</h3>
      <input placeholder="省份">
      <input placeholder="城市">
      <input placeholder="详细地址">
    </div>
  `
}

// 步骤3:支付信息
const Step3 = {
  name'Step3',
  activated() {
    console.log('Step3 激活 - 初始化支付组件')
    // 初始化支付SDK
    this.initializePayment()
  },
  deactivated() {
    console.log('Step3 停用 - 清理支付组件')
    // 清理支付SDK
    this.cleanupPayment()
  },
  template`
    <div class="step step3">
      <h3>支付信息</h3>
      <input placeholder="卡号">
      <input placeholder="有效期">
      <input placeholder="CVV">
    </div>
  `
}

export default {
  components: { Step1Step2Step3 },
  data() {
    return {
      currentStep1,
      formData: {
        step1null,
        step2null,
        step3null
      }
    }
  },
  computed: {
    currentStepComponent() {
      return `Step${this.currentStep}`
    }
  },
  methods: {
    handleFormUpdate({ step, data }) {
      this.formData[`step${step}`] = data
      console.log(`步骤${step}数据已保存:`, data)
    },
    nextStep() {
      if (this.currentStep < 3) {
        this.currentStep++
      }
    },
    prevStep() {
      if (this.currentStep > 1) {
        this.currentStep--
      }
    },
    submitForm() {
      console.log('提交完整表单:'this.formData)
      alert('表单提交成功!')
    }
  }
}
</script>

4.2 标签页内容缓存

<template>
  <div class="tabbed-interface">
    <!-- 标签页头部 -->
    <div class="tabs-header">
      <button v-for="tab in tabs" 
              :key="tab.id"
              :class="{ active: activeTab === tab.id }"
              @click="switchTab(tab.id)">
        {{ tab.title }}
        <span v-if="tab.hasUpdates" class="update-badge">!</span>
      </button>
    </div>
    
    <!-- 使用 keep-alive 缓存标签页内容 -->
    <keep-alive :include="cachedTabs">
      <div class="tabs-content">
        <component :is="activeComponent" 
                   v-bind="activeTabProps">
        </component>
      </div>
    </keep-alive>
  </div>
</template>

<script>
// 实时数据监控组件
const RealTimeMonitor = {
  name'RealTimeMonitor',
  data() {
    return {
      metrics: [],
      connectionnull,
      updateIntervalnull
    }
  },
  activated() {
    console.log('监控面板激活 - 建立WebSocket连接')
    this.connectToWebSocket()
    this.startMetricsCollection()
  },
  deactivated() {
    console.log('监控面板停用 - 断开连接节省资源')
    this.disconnectFromWebSocket()
    this.stopMetricsCollection()
  },
  methods: {
    connectToWebSocket() {
      // 模拟WebSocket连接
      console.log('连接到实时数据源...')
      this.connection = {
        send() => console.log('发送数据'),
        close() => console.log('关闭连接')
      }
    },
    startMetricsCollection() {
      this.updateInterval = setInterval(() => {
        this.metrics.push({
          timenew Date().toISOString(),
          valueMath.random() * 100
        })
        console.log('收集性能指标...')
      }, 1000)
    },
    disconnectFromWebSocket() {
      if (this.connection) {
        this.connection.close()
        this.connection = null
      }
    },
    stopMetricsCollection() {
      if (this.updateInterval) {
        clearInterval(this.updateInterval)
        this.updateInterval = null
      }
    }
  },
  template`
    <div class="monitor">
      <h3>实时监控</h3>
      <div v-for="(metric, index) in metrics.slice(-5)" 
           :key="index">
        {{ metric.time }}: {{ metric.value.toFixed(2) }}
      </div>
    </div>
  `
}

// 日志查看器组件
const LogViewer = {
  name'LogViewer',
  activated() {
    console.log('日志查看器激活 - 加载最新日志')
    this.loadLogs()
  },
  deactivated() {
    console.log('日志查看器停用 - 暂停自动刷新')
    this.pauseAutoRefresh()
  },
  template`
    <div class="log-viewer">
      <h3>系统日志</h3>
      <p>日志内容...</p>
    </div>
  `
}

export default {
  components: { RealTimeMonitorLogViewer },
  data() {
    return {
      activeTab'monitor',
      tabs: [
        { id'monitor'title'实时监控'component'RealTimeMonitor' },
        { id'logs'title'系统日志'component'LogViewer' }
      ]
    }
  },
  computed: {
    activeComponent() {
      const tab = this.tabs.find(t => t.id === this.activeTab)
      return tab ? tab.component : null
    },
    cachedTabs() {
      // 只缓存这些组件
      return ['RealTimeMonitor''LogViewer']
    }
  },
  methods: {
    switchTab(tabId) {
      this.activeTab = tabId
    }
  }
}
</script>

五、注意事项和最佳实践

5.1 常见问题及解决方案

// 1. 数据不同步问题
export default {
  data() {
    return {
      // 问题:直接使用 props 数据
      // localData: this.externalData
      
      // 解决方案:在 activated 中同步
      localDatanull
    }
  },
  props: ['externalData'],
  activated() {
    // 每次激活时同步最新数据
    this.localData = { ...this.externalData }
  }
}

// 2. 内存泄漏问题
export default {
  data() {
    return {
      timers: [],
      listeners: []
    }
  },
  deactivated() {
    // 必须清理定时器和事件监听器
    this.timers.forEach(timer => clearInterval(timer))
    this.timers = []
    
    this.listeners.forEach(({ element, event, handler }) => {
      element.removeEventListener(event, handler)
    })
    this.listeners = []
  }
}

5.2 性能优化建议

  1. 1. 合理使用 include/exclude
<keep-alive :include="['Home', 'UserProfile']" :exclude="['LiveStream']">
  <router-view></router-view>
</keep-alive>
  1. 2. 设置最大缓存数
<keep-alive :max="5">
  <router-view></router-view>
</keep-alive>
  1. 3. 组件命名必须
export default {
  name: 'ImportantComponent'// 必须设置 name 属性
  // ...
}

六、总结

keep-alive 的 activated 和 deactivated 钩子为我们提供了精细控制缓存组件的能力。通过合理使用这两个生命周期钩子,我们可以:

  1. 1. 提升用户体验:保持表单数据、滚动位置等状态
  2. 2. 优化性能:避免重复渲染和重复请求
  3. 3. 资源管理:及时清理定时器、事件监听器和网络连接
  4. 4. 数据同步:确保缓存数据与最新数据的一致性

记住,能力越大,责任越大。在使用 keep-alive 时,一定要注意内存管理和状态同步,避免产生内存泄漏和数据不一致的问题。

【RuoYi-SpringBoot3-ElementPlus】:若依前端增强版 —— 功能扩展优化

作者 undsky
2025年12月3日 06:39

【RuoYi-SpringBoot3-ElementPlus】:若依前端增强版 —— 功能扩展优化

一、项目定位

RuoYi-SpringBoot3-ElementPlus这是一个基于 RuoYi-Vue3 的增强版前端项目,100% 保留原有功能,同时新增大量实用组件和开发工具,显著提升开发效率和用户体验。

核心特点:

  • ✅ 保留若依所有功能,无缝替换
  • ✅ 新增 8+ 实用业务组件
  • ✅ 集成主流工具库和插件
  • ✅ 开发工具链完善
  • ✅ 支持三级等保安全标准

二、新增功能

2.1 省市区级联选择器 —— 中国行政区划完美方案

痛点: 项目中经常需要用户选择地址,Element Plus 原生组件需要自己准备数据源,数据量大且不易维护。

解决方案: 内置 RegionCascader 组件,开箱即用。

<template>
  <!-- 三级联动(省-市-区) -->
  <RegionCascader v-model="region" placeholder="请选择省市区" />
  
  <!-- 只需要省市两级 -->
  <RegionCascader v-model="region" :level="2" />
  
  <!-- 只需要省份 -->
  <RegionCascader v-model="region" :level="1" />
</template>

<script setup>
import { ref } from 'vue'
const region = ref([]) // 返回: ['山东省', '青岛市', '市南区']
</script>

核心特性:

  • 支持 1-3 级灵活配置
  • 数据懒加载,性能优秀
  • 全局注册,直接使用
  • 数据来自后端接口,实时更新

2.2 千分位输入框 —— 金额输入不再繁琐

应用场景: 电商订单、财务报表、数据统计等涉及金额的场景。

<template>
  <InputNumberQianfen 
    v-model="amount" 
    :digits="2" 
    placeholder="请输入金额" 
  />
  <!-- 用户输入: 1234567.89 -->
  <!-- 显示效果: 1,234,567.89 -->
  <!-- 实际值: 1234567.89 (纯数字) -->
</template>

核心特性:

  • 自动千分位格式化
  • 支持小数位数控制
  • 输入时自动解析数字
  • 支持负数和小数

实现原理: 基于 xe-utilscommafy 函数,轻量且高效。

2.3 图片上传增强 —— 拖拽、视频、大文件全支持

这是对原版 ImageUpload 组件的全面重构,功能提升 10 倍以上。

8 大核心升级:

① 拖拽上传

<ImageUpload v-model="images" :drag="true" />
<!-- 直接拖拽文件到上传区域 -->

② 视频和音频支持

<ImageUpload 
  v-model="video"
  :file-type="['mp4', 'webm']"
  accept="video/*"
/>
<!-- 支持上传视频并预览 -->

③ 大文件支持

  • 原版:5MB 限制
  • 增强版:50MB 限制(可配置到 100MB)

④ 图片懒加载

<!-- 自动添加 loading="lazy" 属性 -->
<!-- 大量图片时显著提升性能 -->

⑤ 专业图片预览器

  • 集成 el-image-viewer 组件
  • 支持放大、缩小、旋转
  • 支持键盘快捷键(← → ESC)
  • 支持图片列表浏览

⑥ 移动端完美适配

  • 自动识别设备类型
  • 移动端预览全屏显示
  • 触摸手势支持

⑦ 多种数据格式

// 支持 3 种数据格式
form.images = 'img1.jpg,img2.jpg'  // 逗号分隔字符串
form.video = ['video1.mp4']        // 数组
form.album = '["p1.jpg","p2.jpg"]' // JSON 字符串

⑧ 防重复删除

  • 添加状态标记,防止快速点击导致的重复删除
  • 确保数据一致性

功能对比:

功能 RuoYi-Vue3 增强版
拖拽上传
视频支持
图片懒加载
专业预览器
移动端适配 基础 完美
文件大小 5MB 50MB
JSON 格式

2.4 UEditor 富文本编辑器

为什么需要两个富文本编辑器?

  • Vue Quill:轻量级,适合简单场景(如文章摘要、评论)
  • UEditor:功能强大,适合复杂场景(如文章编辑、新闻发布)

UEditor 优势:

  • 支持图片、视频、音频上传
  • 支持表格编辑
  • 支持公式编辑(数学公式、化学式)
  • 支持代码高亮
  • 支持全屏编辑

三、开发工具增强

3.1 代码检查器 —— 浏览器中直接定位代码

痛点: 看到浏览器页面效果,想修改代码,需要手动在项目中查找对应文件。

解决方案: 集成 code-inspector-plugin

使用方法:

  1. 按住 Ctrl + Shift(Mac 用 Cmd + Shift
  2. 点击页面任意元素
  3. IDE 自动打开对应的源代码文件

效果: 开发效率提升 50% 以上,告别手动查找文件。

3.2 Prettier 代码格式化

统一团队代码风格,避免代码评审时的格式争议。

// prettier.config.cjs 配置示例
module.exports = {
  semi: false,            // 不使用分号
  singleQuote: true,      // 使用单引号
  tabWidth: 2,            // 缩进 2 空格
  trailingComma: 'none',  // 不使用尾随逗号
  printWidth: 120         // 每行最多 120 字符
}

集成效果:

  • 保存时自动格式化
  • 支持 Vue、JS、CSS、HTML 等文件
  • 全团队统一风格

3.3 FTP/SFTP 自动部署

痛点: 每次构建后需要手动上传文件到服务器,繁琐且容易出错。

解决方案: 内置部署脚本,构建完成自动上传。

配置文件(ftp/ftp.js):

const config = {
  user: "admin",                  // FTP 用户名
  password: "password123",         // FTP 密码
  host: "example.com",            // 服务器地址
  port: 21,                       // FTP 端口
  localRoot: "./dist",            // 本地构建目录
  remoteRoot: "/www/admin"        // 远程目标路径
}

使用方法:

npm run build:prod
# 构建完成后自动上传到服务器,无需手动操作

四、工具库生态

4.1 dayjs —— 轻量级日期处理

替代臃肿的 moment.js,体积仅 2KB。

import dayjs from 'dayjs'

// 格式化
dayjs().format('YYYY-MM-DD HH:mm:ss')  // 2024-11-28 14:30:00

// 相对时间
dayjs().from(dayjs('2024-01-01'))      // 10 个月前

// 日期计算
dayjs().add(7, 'day')                  // 7 天后
dayjs().subtract(1, 'month')           // 1 个月前

4.2 xe-utils —— 300+ 实用函数

涵盖日常开发常见场景的工具函数库。

import { commafy, clone, isEmpty, throttle } from 'xe-utils'

// 千分位格式化
commafy(1234567.89)  // "1,234,567.89"

// 深拷贝
const obj2 = clone(obj1)

// 判空(比自己写更严谨)
isEmpty(null)      // true
isEmpty('')        // true
isEmpty([])        // true
isEmpty({})        // true

// 节流函数
const handleScroll = throttle(() => {
  console.log('滚动事件')
}, 300)

4.3 mitt —— 事件总线

Vue 3 推荐的组件通信方案,替代 Vue 2 的 EventBus。

// 创建事件总线
import mitt from 'mitt'
const emitter = mitt()

// 组件 A:触发事件
emitter.emit('refresh', { id: 123 })

// 组件 B:监听事件
emitter.on('refresh', (data) => {
  console.log('收到刷新事件', data.id)
})

4.4 sortablejs —— 拖拽排序

强大的拖拽库,支持列表、表格、卡片等场景。

<template>
  <div ref="listRef">
    <div v-for="item in list" :key="item.id">
      {{ item.name }}
    </div>
  </div>
</template>

<script setup>
import Sortable from 'sortablejs'
import { onMounted, ref } from 'vue'

const listRef = ref()
const list = ref([
  { id: 1, name: '项目一' },
  { id: 2, name: '项目二' }
])

onMounted(() => {
  Sortable.create(listRef.value, {
    animation: 150,  // 动画效果
    onEnd: (evt) => {
      // 拖拽完成,更新数组顺序
      const item = list.value.splice(evt.oldIndex, 1)[0]
      list.value.splice(evt.newIndex, 0, item)
    }
  })
})
</script>

4.5 Vant —— 移动端 UI

轻量、可靠的移动端组件库,适配移动端场景。

<template>
  <van-button type="primary">按钮</van-button>
  <van-field v-model="username" placeholder="请输入用户名" />
  <van-picker :columns="columns" @confirm="onConfirm" />
</template>

<script setup>
import { Button, Field, Picker } from 'vant'
</script>

五、安全合规增强

三级等保 —— 用户无操作自动登出

法规要求: 根据《信息安全技术 网络安全等级保护基本要求》(GB/T 22239-2019),三级等保系统需要支持"用户长时间无操作自动登出"功能。

实现原理:

// 监听用户的键盘、鼠标、滚动操作
// 每次操作重置计时器
// 超时自动退出登录

配置方法(.env.production):

# 30 分钟无操作自动登出
VITE_LOGOUT_LIMIT=1800000

# 60 分钟无操作自动登出
VITE_LOGOUT_LIMIT=3600000

# 不启用自动登出
VITE_LOGOUT_LIMIT=0

监控的操作类型:

  • 🖱️ 鼠标移动、点击
  • ⌨️ 键盘按键
  • 📜 页面滚动

六、快速开始

6.1 环境准备

Node.js >= 20
npm 或 yarn 或 pnpm

6.2 安装启动

# 1. 克隆项目
git clone https://github.com/undsky/RuoYi-SpringBoot3-ElementPlus.git
cd RuoYi-SpringBoot3-ElementPlus

# 2. 安装依赖(使用国内镜像加速)
npm install --registry=https://registry.npmmirror.com

# 3. 启动开发服务器
npm run dev

# 4. 浏览器访问
http://localhost:80

6.3 构建部署

# 构建生产环境(自动部署到服务器)
npm run build:prod

# 构建测试环境
npm run build:stage

# 本地预览构建结果
npm run preview

七、与原版对比

功能 RuoYi-Vue3 增强版 说明
省市区选择器 三级联动,开箱即用
千分位输入 金额场景适用
UEditor 功能更强大
图片上传 基础 增强 拖拽、视频、大文件
日期处理 dayjs 轻量级,仅 2KB
事件总线 mitt 组件通信
PDF 预览 pdf-vue3 在线预览
拖拽排序 sortablejs 拖拽功能
移动端 UI Vant 移动端适配
工具函数 xe-utils 300+ 函数
代码检查器 效率提升 50%
代码格式化 Prettier 统一风格
自动部署 FTP/SFTP 一键部署
三级等保 自动登出

八、项目结构

RuoYi-SpringBoot3-ElementPlus/
├── ftp/                          # 🆕 FTP/SFTP 部署
│   ├── ftp.js                   # FTP 脚本
│   └── sftp.js                  # SFTP 脚本
├── public/
│   └── UEditorPlus/             # 🆕 UEditor 资源
├── src/
│   ├── api/
│   │   └── biz/                 # 🆕 业务接口
│   │       └── Region.js        # 省市区接口
│   ├── components/
│   │   ├── ImageUpload/         # 🆕 图片上传增强
│   │   ├── InputNumberQianfen/  # 🆕 千分位输入
│   │   ├── RegionCascader/      # 🆕 省市区选择
│   │   └── UEditorPlus/         # 🆕 UEditor 富文本
│   ├── views/
│   │   └── login2.vue           # 🆕 第二种登录页
│   └── App.vue                   # 🆕 自动登出逻辑
├── .prettierignore              # 🆕 格式化配置
├── prettier.config.cjs          # 🆕 格式化规则
└── vite.config.js               # 代码检查器配置

🆕 = 新增功能

九、配置说明

9.1 后端接口地址

// vite.config.js
const baseUrl = 'http://localhost:8087' // 修改为实际后端地址

9.2 自动登出配置

# .env.production
VITE_LOGOUT_LIMIT=1800000  # 30分钟(建议值)

9.3 FTP 部署配置

// ftp/ftp.js
const config = {
  user: "your-username",
  password: "your-password",
  host: "your-server.com",
  port: 21,
  localRoot: "./dist",
  remoteRoot: "/www/admin"
}

十一、总结

RuoYi-SpringBoot3-ElementPlus 是一个生产级别的前端项目,它不是简单的组件堆砌,而是经过深思熟虑的增强和优化。

核心价值:

  1. 开箱即用的业务组件

    • 省市区选择、千分位输入等常见需求
    • 无需重复造轮子,节省开发时间
  2. 完善的开发工具链

    • 代码检查器、格式化、自动部署
    • 提升开发效率和团队协作
  3. 丰富的工具库生态

    • dayjs、xe-utils、mitt 等主流库
    • 覆盖日常开发 90% 的场景
  4. 符合安全合规要求

    • 三级等保自动登出
    • 满足政企项目需求
  5. 持续维护和更新

    • 跟随 Vue 3 和 Element Plus 更新
    • 持续添加实用功能

如何实现UniApp登录拦截?

2025年12月2日 22:41

UniApp 实现登录拦截:CS 小白入门指南

登录拦截是前端权限控制的基础功能,核心目的是:未登录用户无法访问需要权限的页面(如个人中心、订单列表),同时拦截未授权的接口请求,避免敏感数据泄露或无效操作。

对于 CS 小白而言,UniApp 登录拦截的核心逻辑可概括为「状态校验 + 行为拦截」:通过 token 标识登录状态,拦截非法的页面跳转和接口请求,引导未登录用户前往登录页。

前置知识:什么是 Token?

token 是用户登录成功后,后端返回的「身份凭证」,相当于登录后的「电子门票」。

后续访问需要权限的页面或接口时,前端需携带 token 证明「已登录」身份,后端验证 token 有效则允许访问,无效则拒绝。

token 需持久化存储(如本地存储),否则 App 重启或页面刷新后会丢失,导致用户需重新登录。

核心实现一:路由拦截(控制页面跳转)

UniApp 中实现路由拦截的核心 API 是 uni.addInterceptor,专门用于拦截路由跳转行为(如 uni.navigateTouni.redirectTo 等)。

关键 API 解释

uni.addInterceptor:UniApp 内置拦截器 API,支持拦截路由、请求、上传 / 下载等行为。

  • 通过 invoke 回调在拦截行为执行前触发自定义逻辑(重点掌握);
  • 通过 success 回调在拦截行为成功后处理结果;
  • 小白只需关注 invoke 回调即可实现基础拦截。

基础路由拦截

路由拦截需在全局生效,建议在 main.js 中初始化(项目入口文件,确保所有页面加载前执行):

 // 路由拦截初始化:拦截所有路由跳转
 uni.addInterceptor('navigateTo', {
   invoke(options) {
     // 1. 获取本地存储的 token(后续讲解如何存储)
     const token = uni.getStorageSync('userToken');
     // 2. 判断 token 是否存在:不存在则拦截跳转,跳转至登录页
     if (!token) {
       // 使用 redirectTo 跳转,避免用户通过返回键回到原页面
       uni.redirectTo({ url: '/pages/login/login' });
       // 阻止原跳转行为(关键!否则会同时跳转到目标页和登录页)
       return false;
     }
     // 3. token 存在:允许跳转(无需额外操作,默认继续执行原跳转)
   }
 });

扩展:拦截所有路由类型

上述代码仅拦截 navigateTo 跳转,实际开发中需覆盖所有路由方式(如 switchTab 切换底部标签、reLaunch 重启应用),可通过循环简化代码:

 // 需拦截的路由类型数组(覆盖 UniApp 所有路由 API)
 const routeTypes = ['navigateTo', 'redirectTo', 'switchTab', 'reLaunch', 'navigateBack'];
 
 // 循环为所有路由类型添加拦截器
 routeTypes.forEach(type => {
   uni.addInterceptor(type, {
     invoke(options) {
       const token = uni.getStorageSync('userToken');
       // 关键:排除登录页本身,避免死循环
       if (!token && !options.url.includes('/pages/login/login')) {
         uni.redirectTo({ url: '/pages/login/login' });
         return false;
       }
     }
   });
 });

避坑提醒 🚨

必须排除登录页:如果不排除,未登录时跳转登录页会被自身拦截,陷入「跳转 - 拦截 - 再跳转」的死循环,这是小白最容易踩的坑。

核心实现二:本地存储 Token(持久化登录状态)

登录成功后,需将后端返回的 token 存入本地,以便路由拦截、请求拦截时获取,核心 API 是 uni.setStorageSyncuni.getStorageSync

关键 API 解释

uni.setStorageSync(key, value):UniApp 同步本地存储 API。

  • 以键值对形式存入本地(类似浏览器的 localStorage);
  • 同步执行:代码会等待存储完成后再继续,无需处理异步回调,小白友好;
  • 适合存储简单数据(如 token、用户昵称、ID)。

uni.getStorageSync(key):同步读取本地存储数据。

  • 根据 key 读取对应 value,无数据则返回 nullundefined
  • 读取速度快,适合频繁获取的场景(如拦截器中获取 token)。

uni.removeStorageSync(key):同步删除本地存储的指定 key 数据,用于退出登录时清除 token。

Token 存储与删除

1. 登录成功后存储 Token(登录页逻辑)
 // 登录页面:用户输入账号密码后点击登录按钮的逻辑
 async function login() {
   // 1. 获取用户输入的账号密码(假设已通过表单绑定)
   const { username, password } = this.userForm;
   
   // 2. 调用后端登录接口(后续请求拦截会补充接口逻辑)
   const res = await uni.request({
     url: 'https://api.example.com/login', // 后端登录接口地址
     method: 'POST',
     data: { username, password } // 传递账号密码
   });
   
   // 3. 处理后端返回结果(假设后端约定:code=200 为成功)
   if (res.data.code === 200) {
     // 4. 存储 token 到本地,key 建议语义化(如 'userToken')
     uni.setStorageSync('userToken', res.data.data.token);
     
     // 5. 存储成功后跳转至首页或目标页面
     uni.redirectTo({ url: '/pages/index/index' });
   } else {
     // 登录失败:提示用户(UniApp 内置提示 API)
     uni.showToast({ title: res.data.msg || '登录失败', icon: 'none' });
   }
 }
2. 退出登录时删除 Token
 // 退出登录逻辑(如个人中心的退出按钮)
 function logout() {
   // 1. 清除本地存储的 token
   uni.removeStorageSync('userToken');
   
   // 2. 跳转至登录页,关闭所有页面避免返回
   uni.reLaunch({ url: '/pages/login/login' });
 }

注意事项 📌

本地存储的 token 是明文存储(如 H5 端可通过浏览器开发者工具查看),切勿存储密码等敏感信息,仅存储 token 等非敏感凭证。

核心实现三:请求拦截与响应拦截(控制接口访问)

路由拦截能阻止未登录用户访问页面,但无法阻止用户通过抓包工具或直接调用接口发起请求,因此需要「请求拦截 + 响应拦截」形成双重保障。

核心逻辑

  • 请求拦截:为所有需要权限的接口自动添加 token 到请求头,让后端验证身份;
  • 响应拦截:处理接口返回的错误状态(如 token 过期、未授权),强制跳转登录页。

关键 API 解释

uni.addInterceptor('request'):拦截所有通过 uni.request 发起的接口请求。

  • invoke 回调:请求发送前执行(用于添加请求头 token);
  • success 回调:请求响应后执行(用于处理 401 等错误状态);
  • fail 回调:请求失败时执行(如网络错误)。

1. 请求拦截:添加 Token 到请求头

 // 请求拦截:自动为接口添加 token
 uni.addInterceptor('request', {
   invoke(options) {
     // 1. 获取本地存储的 token
     const token = uni.getStorageSync('userToken');
     
     // 2. 如果 token 存在,添加到请求头(需与后端约定字段名)
     if (token) {
       // 初始化请求头(避免 options.header 为 undefined)
       options.header = options.header || {};
       // 常见格式:Bearer + 空格 + token(后端约定为准,也可能直接传 token)
       options.header.Authorization = `Bearer ${token}`;
     }
     
     // 3. 无需阻止请求,仅添加配置,默认继续发送
   }
 });

2. 响应拦截:处理接口错误状态

 // 响应拦截:处理接口返回的未授权、token 过期等状态
 uni.addInterceptor('request', {
   success(res) {
     // 1. 假设后端约定状态码:401=未授权(token 无效/过期),403=权限不足
     const { code, msg } = res.data;
     
     // 2. 处理 401 未授权:清除无效 token,跳转登录页
     if (code === 401) {
       // 清除本地无效 token,避免下次请求仍携带
       uni.removeStorageSync('userToken');
       
       // 提示用户登录过期
       uni.showToast({ title: msg || '登录已过期,请重新登录', icon: 'none' });
       
       // 跳转登录页(关闭所有页面,避免返回)
       uni.reLaunch({ url: '/pages/login/login' });
     }
     
     // 3. 处理 403 权限不足:仅提示,不跳转
     if (code === 403) {
       uni.showToast({ title: msg || '暂无权限访问', icon: 'none' });
     }
   },
   fail(err) {
     // 处理网络错误等请求失败场景
     uni.showToast({ title: '网络错误,请稍后重试', icon: 'none' });
   }
 });

避坑提醒 📌

请求头的 token 字段名需与后端严格约定:大多数后端使用 Authorization 作为字段名,格式为 Bearer + 空格 + token;若后端要求直接传 token(如 header: { token: 'xxx' }),需按后端规则修改,否则后端无法识别。

核心实现四:页面级权限校验(兜底方案)

路由拦截和请求拦截已能覆盖大部分场景,但存在特殊情况(如 H5 端页面刷新后路由拦截未触发),因此需要在页面加载时添加「兜底校验」。

关键生命周期解释

onLoad:UniApp 页面生命周期函数,页面加载时触发(仅触发一次)。

  • 适合在页面初始化时执行权限校验;
  • 即使路由拦截失效,页面级校验仍能生效,避免未登录用户停留在权限页面。

页面级校验

 // 需权限的页面(如个人中心 pages/mine/mine.vue)
 export default {
   // 页面加载时执行校验
   onLoad() {
     // 1. 获取本地 token
     const token = uni.getStorageSync('userToken');
     
     // 2. 无 token 则跳转登录页,关闭当前页面
     if (!token) {
       uni.redirectTo({ url: '/pages/login/login' });
     }
   }
 };

适用场景 🚀

  • H5 端:用户按 F5 刷新页面后,路由拦截可能未重新执行,onLoad 校验会生效;
  • 小程序端:页面被分享后,用户打开时可能跳过路由拦截,需通过页面级校验兜底。

进阶优化:小白也能掌握的实用技巧

基础登录拦截实现后,可通过以下优化让功能更稳定,应对实际开发中的复杂场景。

1. 白名单配置(简化拦截逻辑)

部分页面 / 接口无需登录即可访问(如首页、注册页、登录接口本身),可通过「白名单」统一管理,避免在拦截器中写大量判断。

 // 1. 页面白名单:无需登录即可访问的页面路径
 const pageWhiteList = [
   '/pages/login/login', // 登录页
   '/pages/register/register', // 注册页
   '/pages/index/index' // 首页
 ];
 
 // 2. 接口白名单:无需 token 即可访问的接口地址
 const apiWhiteList = [
   '/login', // 登录接口
   '/register', // 注册接口
   '/home/banner' // 首页轮播图接口
 ];
 
 // 3. 路由拦截中使用页面白名单
 routeTypes.forEach(type => {
   uni.addInterceptor(type, {
     invoke(options) {
       const token = uni.getStorageSync('userToken');
       // 校验逻辑:不在白名单 + 无 token → 跳转登录页
       if (!pageWhiteList.includes(options.url) && !token) {
         uni.redirectTo({ url: '/pages/login/login' });
         return false;
       }
     }
   });
 });
 
 // 4. 请求拦截中使用接口白名单
 uni.addInterceptor('request', {
   invoke(options) {
     const token = uni.getStorageSync('userToken');
     // 校验逻辑:不在白名单 + 有 token → 添加 token 到请求头
     if (!apiWhiteList.some(api => options.url.includes(api)) && token) {
       options.header = options.header || {};
       options.header.Authorization = `Bearer ${token}`;
     }
   }
 });

2. 避免重复跳转登录页(锁机制)

当用户快速点击多个需要权限的按钮时,可能触发多次路由拦截,导致多次跳转登录页,需通过「锁机制」避免。

 // 定义全局锁:标记是否已在跳转登录页
 let isRedirecting = false;
 
 // 路由拦截中添加锁逻辑
 routeTypes.forEach(type => {
   uni.addInterceptor(type, {
     invoke(options) {
       const token = uni.getStorageSync('userToken');
       if (!token && !options.url.includes('/pages/login/login')) {
         // 未在跳转中才执行跳转
         if (!isRedirecting) {
           isRedirecting = true; // 上锁
           uni.redirectTo({
             url: '/pages/login/login',
             success() {
               isRedirecting = false; // 跳转成功后解锁
             }
           });
         }
         return false;
       }
     }
   });
 });

3. Token 过期处理(刷新 Token)

token 有有效期(如 2 小时),过期后需重新登录,影响用户体验,可通过「刷新 token」优化:后端返回两个 token(短期访问令牌 accessToken + 长期刷新令牌 refreshToken),到期前自动用 refreshToken 换取新的 accessToken

 // 登录成功后存储两个 token
 if (res.data.code === 200) {
   const { accessToken, refreshToken, expiresIn } = res.data.data;
   uni.setStorageSync('accessToken', accessToken); // 短期访问令牌(2小时)
   uni.setStorageSync('refreshToken', refreshToken); // 长期刷新令牌(7天)
   // 存储过期时间(当前时间 + 有效期毫秒数)
   uni.setStorageSync('tokenExpireTime', Date.now() + expiresIn * 1000);
 }
 
 // 请求拦截中添加刷新 token 逻辑
 uni.addInterceptor('request', {
   async invoke(options) {
     const accessToken = uni.getStorageSync('accessToken');
     const refreshToken = uni.getStorageSync('refreshToken');
     const expireTime = uni.getStorageSync('tokenExpireTime');
     
     // 跳过白名单接口
     if (apiWhiteList.some(api => options.url.includes(api))) return;
     
     // token 即将过期(剩余 5 分钟)且有刷新令牌,自动刷新
     if (accessToken && expireTime - Date.now() < 5 * 60 * 1000 && refreshToken) {
       const refreshRes = await uni.request({
         url: 'https://api.example.com/refreshToken', // 后端刷新接口
         method: 'POST',
         data: { refreshToken }
       });
       
       if (refreshRes.data.code === 200) {
         // 刷新成功:更新本地 token 和过期时间
         uni.setStorageSync('accessToken',</doubaocanvas>

解锁Vue组件通信新姿势:provide/inject深度解析

作者 北辰alk
2025年12月2日 21:32

前言:为什么需要跨层级组件通信?

在日常Vue开发中,我们经常遇到这样的场景:多层嵌套的组件需要共享某些数据或方法。比如:

  • 主题配置需要从根组件传递到深层的子组件
  • 用户登录信息需要在多个层级中共享
  • 多语言配置需要在整个应用中使用

传统的解决方案:props层层传递,复杂又繁琐!今天给大家介绍一个更优雅的解决方案:provide/inject

一、什么是provide/inject?

基本概念

provide(提供)和inject(注入)是Vue提供的一对API,允许祖先组件向所有子孙组件注入依赖,无论组件层次有多深。

祖先组件 (provide数据)
      ↓
   子孙组件 (inject数据)

与props对比

特性 props传递 provide/inject
数据流向 父→子(单向) 祖先→子孙(跨级)
使用复杂度 每层都需要声明 一次提供,随处注入
组件耦合度 父子紧耦合 祖先与子孙解耦
适用场景 直接父子通信 深层嵌套组件通信

二、基本使用方式

2.1 基础语法

// 祖先组件 - 提供数据
export default {
  provide() {
    return {
      // 提供静态数据
      siteName: 'Vue技术博客',
      // 提供响应式数据需要特殊处理
      theme: this.currentTheme,
      // 提供方法
      changeTheme: this.updateTheme
    }
  },
  data() {
    return {
      currentTheme: 'light'
    }
  },
  methods: {
    updateTheme(newTheme) {
      this.currentTheme = newTheme
    }
  }
}
// 子孙组件 - 注入数据
export default {
  // 数组形式
  inject: ['siteName', 'theme', 'changeTheme'],
  
  // 对象形式(推荐)
  inject: {
    // 基本注入
    blogName: 'siteName',
    
    // 带默认值
    theme: {
      from: 'theme',
      default: 'light'
    },
    
    // 重命名
    switchTheme: {
      from: 'changeTheme'
    }
  },
  
  methods: {
    handleThemeChange() {
      this.switchTheme('dark')
      console.log(`当前主题:${this.theme}`)
    }
  }
}

2.2 实际开发案例

让我们通过一个实际案例来理解provide/inject的强大之处:

<!-- 根组件:App.vue -->
<template>
  <div :class="`app ${theme}`">
    <Header />
    <div class="content">
      <Sidebar />
      <MainContent />
    </div>
    <SettingsPanel />
  </div>
</template>

<script>
export default {
  name: 'App',
  provide() {
    return {
      // 提供主题配置
      appTheme: this.theme,
      switchTheme: this.handleThemeChange,
      
      // 提供用户信息
      currentUser: this.user,
      
      // 提供国际化函数
      t: this.translate,
      
      // 提供全局配置
      appConfig: {
        apiBaseUrl: process.env.VUE_APP_API_URL,
        version: '2.0.0'
      }
    }
  },
  
  data() {
    return {
      theme: 'light',
      user: {
        id: 1,
        name: '张三',
        role: 'admin'
      },
      locale: 'zh-CN'
    }
  },
  
  methods: {
    handleThemeChange(newTheme) {
      this.theme = newTheme
      localStorage.setItem('app-theme', newTheme)
    },
    
    translate(key) {
      // 简化版翻译函数
      const dictionaries = {
        'zh-CN': { welcome: '欢迎', logout: '退出登录' },
        'en-US': { welcome: 'Welcome', logout: 'Logout' }
      }
      return dictionaries[this.locale][key] || key
    }
  }
}
</script>
<!-- 深层嵌套组件:UserAvatar.vue -->
<template>
  <div class="user-avatar">
    <img :src="avatarUrl" :alt="userName" />
    <span>{{ userName }}</span>
    <button @click="logout">{{ t('logout') }}</button>
  </div>
</template>

<script>
export default {
  name: 'UserAvatar',
  
  // 注入需要的数据和方法
  inject: {
    currentUser: {
      from: 'currentUser',
      default: () => ({ name: 'Guest' })
    },
    t: {
      from: 't',
      default: () => (key) => key
    }
  },
  
  computed: {
    userName() {
      return this.currentUser.name
    },
    
    avatarUrl() {
      return `https://avatar.com/${this.currentUser.id}`
    }
  },
  
  methods: {
    logout() {
      // 调用注入的方法
      // 这里可以添加自己的逻辑
      console.log('用户退出登录')
    }
  }
}
</script>

三、高级使用技巧

3.1 提供响应式数据

默认情况下,provide提供的不是响应式数据。如果需要响应式,需要特殊处理:

// 方法一:提供计算属性
export default {
  data() {
    return {
      user: {
        name: '张三',
        age: 25
      }
    }
  },
  
  provide() {
    return {
      // 使用计算属性保持响应式
      reactiveUser: Vue.computed(() => this.user),
      
      // 或者使用响应式API(Vue 2.6+)
      reactiveData: Vue.observable({
        count: 0,
        increment: () => {
          this.reactiveData.count++
        }
      })
    }
  }
}

3.2 使用Symbol作为键名

在大型项目中,为了避免命名冲突,可以使用Symbol作为provide的键名:

// constants.js - 定义Symbol常量
export const ThemeSymbol = Symbol('theme')
export const UserSymbol = Symbol('user')
export const ConfigSymbol = Symbol('config')

// 祖先组件
import { ThemeSymbol, UserSymbol } from './constants'

export default {
  provide() {
    return {
      [ThemeSymbol]: this.theme,
      [UserSymbol]: this.user
    }
  }
}

// 子孙组件
import { ThemeSymbol, UserSymbol } from './constants'

export default {
  inject: {
    theme: { from: ThemeSymbol },
    user: { from: UserSymbol }
  }
}

3.3 组合式API中的使用

Vue 3的组合式API中,provide/inject的使用更加简洁:

// 祖先组件
import { provide, ref, reactive } from 'vue'

export default {
  setup() {
    // 创建响应式数据
    const theme = ref('light')
    const user = reactive({
      name: '李四',
      role: 'user'
    })
    
    // 提供数据
    provide('theme', theme)
    provide('user', user)
    provide('updateTheme', (newTheme) => {
      theme.value = newTheme
    })
    
    return {
      theme,
      user
    }
  }
}

// 子孙组件
import { inject } from 'vue'

export default {
  setup() {
    // 注入数据
    const theme = inject('theme', 'light') // 第二个参数是默认值
    const user = inject('user')
    const updateTheme = inject('updateTheme')
    
    // 如果确定数据存在,可以使用非空断言
    const requiredData = inject('someRequiredData')!
    
    return {
      theme,
      user,
      changeTheme: updateTheme
    }
  }
}

四、最佳实践和注意事项

4.1 什么时候使用provide/inject?

适合使用的情况:

  • 开发组件库(如表单、配置类组件)
  • 全局状态管理(小项目替代Vuex)
  • 主题/样式配置传递
  • 多语言支持
  • 用户权限传递

不建议使用的情况:

  • 简单的父子组件通信(用props)
  • 应用核心状态管理(大型应用用Vuex/Pinia)
  • 组件间强耦合的场景

4.2 常见问题解决方案

问题1:数据不是响应式的

// 错误做法
provide() {
  return {
    user: this.user // 失去响应式
  }
}

// 正确做法
provide() {
  return {
    // Vue 2使用计算属性
    user: Vue.computed(() => this.user),
    
    // 或者提供修改方法
    getUser: () => this.user,
    updateUser: this.updateUserMethod
  }
}

问题2:命名冲突

// 使用命名空间
provide() {
  return {
    'app:theme': this.theme,
    'app:user': this.user,
    'app:config': this.config
  }
}

inject: {
  theme: 'app:theme',
  user: 'app:user'
}

4.3 性能优化建议

  1. 按需提供:只提供必要的数据,避免提供大量不必要的数据
  2. 使用只读数据:对于不需要修改的数据,提供只读版本
  3. 避免深层嵌套:合理设计组件结构,避免过度嵌套
  4. 使用工厂函数:对于需要计算的数据,使用工厂函数延迟计算
provide() {
  return {
    // 工厂函数,按需计算
    getUserPermissions: () => this.calculatePermissions(this.user.role),
    
    // 只读数据
    readOnlyConfig: Object.freeze({ ...this.config })
  }
}

五、实战:构建一个主题切换系统

让我们用一个完整的例子来展示provide/inject的强大功能:

<!-- ThemeProvider.vue -->
<template>
  <div :class="`theme-provider ${currentTheme}`">
    <slot></slot>
  </div>
</template>

<script>
import { ThemeSymbol, UpdateThemeSymbol } from './symbols'

export default {
  name: 'ThemeProvider',
  
  provide() {
    return {
      [ThemeSymbol]: Vue.computed(() => this.currentTheme),
      [UpdateThemeSymbol]: this.updateTheme
    }
  },
  
  data() {
    return {
      currentTheme: localStorage.getItem('theme') || 'light'
    }
  },
  
  methods: {
    updateTheme(theme) {
      this.currentTheme = theme
      localStorage.setItem('theme', theme)
      document.documentElement.setAttribute('data-theme', theme)
    }
  }
}
</script>

<style>
.theme-provider.light {
  --bg-color: #ffffff;
  --text-color: #333333;
}

.theme-provider.dark {
  --bg-color: #1a1a1a;
  --text-color: #ffffff;
}
</style>
<!-- ThemedButton.vue -->
<template>
  <button 
    :class="['themed-button', `theme-${theme}`]"
    @click="handleClick"
  >
    <slot></slot>
  </button>
</template>

<script>
import { ThemeSymbol, UpdateThemeSymbol } from './symbols'

export default {
  name: 'ThemedButton',
  
  inject: {
    theme: {
      from: ThemeSymbol,
      default: 'light'
    },
    updateTheme: {
      from: UpdateThemeSymbol
    }
  },
  
  methods: {
    handleClick() {
      if (this.updateTheme) {
        const newTheme = this.theme === 'light' ? 'dark' : 'light'
        this.updateTheme(newTheme)
      }
      this.$emit('click')
    }
  }
}
</script>

<style scoped>
.themed-button {
  padding: 10px 20px;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.theme-light {
  background-color: var(--bg-color, #ffffff);
  color: var(--text-color, #333333);
  border: 1px solid #ddd;
}

.theme-dark {
  background-color: var(--bg-color, #333333);
  color: var(--text-color, #ffffff);
  border: 1px solid #555;
}
</style>

六、总结

provide/inject是Vue中一个强大但容易被忽视的特性。它提供了一种优雅的跨层级组件通信方式,特别适用于:

  1. 组件库开发:提供全局配置和能力
  2. 功能封装:如主题切换、多语言等
  3. 状态共享:在中小型应用中替代状态管理库
  4. 解耦组件:减少组件间的直接依赖

记住这些关键点:

  • 默认不是响应式的,需要特殊处理
  • 适合跨多层组件通信,但不适合简单父子通信
  • 使用Symbol或命名空间避免命名冲突
  • 在Vue 3的组合式API中更好用

掌握provide/inject,让你的Vue应用架构更加清晰、组件更加解耦、代码更加优雅!

昨天 — 2025年12月2日首页

Vue3 - runtime-core的渲染器初始化流程

作者 7ayl
2025年12月2日 17:42

前言

在创建一个 Vue 3 项目时,通常会看到三个核心文件: main.js:应用入口 image.png index.html:页面入口 image.png App.vue: 根组件 image.png 本文将以这三个文件为例,简述 Vue 应用的初始化流程

流程

在 main.js 中,我们导入了 createApp 函数和根组件 App.vue

一、从入口开始:createApp 与 mount

createApp(App).mount('#app')

createApp(App)调用createApp传入根组件,生成它专属的mount方法

.mount('#app')让createApp(App)这个应用实例挂载到根容器(id为app的盒子),

  • mount函数内部会基于根组件App.vue生成一个虚拟节点vnode
  • 调用render函数进行渲染,负责将虚拟DOM渲染到真实DOM image.png

二、创建虚拟节点:vnode 的结构

基于根组件来创建虚拟节点vnode

创建出来的虚拟节点vnode属性如下: image.png

三、渲染入口:render 与 patch

调用 render 函数

  1. render函数只是一个渲染器的入口,负责接收接收虚拟节点和容器,开启渲染过程

image.png

可以看见render函数内部也主要是调用patch函数,

  1. patch()主要会根据vnode.type以及shapeFlag去判断节点属于什么类型,进而调用相应类型的处理方法processxxxx()

这里App是组件类型,所以用processComponent处理

image.png

四、处理组件:processComponent 与 mountComponent

  1. 不管是什么类型的节点,都会在这个时候判断,这个节点之前是否存在,是选择初始化节点mountxxx(),还是更新节点

由于这是组件首次渲染,调用patch传下来的第一个参数应该是null,即没有n1,

所以到达processComponent之后,会先进行mountComponent

image.png

五、组件实例的创建与设置

  1. 然后进行相应的流程 mountxxx()/更新节点

mountComponent会先去创建 component instance对象,再调用setupComponent设置组件实例,最后调用setupRenderEffect设置渲染效果。 image.png

ps:也是可以粗略的看看instance对象的属性 image.png

全局重复接口取消&重复提示

2025年12月2日 10:46

重复接口取消

思路

我们创建了一个pendingMap对象,它用于存储每个请求的标识(请求路径、请求方式、请求参数3个维度唯一标识)和取消函数。当请求被发出时,我们将其添加到pendingMap中。再次调用pendingMap存在此请求,则取消请求,另外再此接口返回时,同样取消请求。

实现

1、创建AxiosCanceler类,定义了addPending、removeAllPending和removePending三个方法。addPending方法用于将请求添加到pendingMap中。removeAllPending方法可以用于取消所有请求,而removePending方法可以用于取消单个请求。

import type { AxiosRequestConfig } from 'axios'
import { generateRequestCode } from './index'

// 用于存储每个请求的标识和取消函数
const pendingMap = new Map<string, AbortController>()

export class AxiosCanceler {
  /**
   * 添加请求
   * @param config 请求配置
   */
  public addPending(config: AxiosRequestConfig): void {
    // 立刻移除重复请求
    this.removePending(config)
    // 请求唯一标识code
    const requestCode = generateRequestCode(config)
    // 取消请求对象
    const controller = new AbortController()
    config.signal = controller.signal
    if (!pendingMap.has(requestCode)) {
      // 如果当前请求不在等待中,将其添加到等待中
      pendingMap.set(requestCode, controller)
    }
  }

  /**
   * 清除所有等待中的请求
   */
  public removeAllPending(): void {
    pendingMap.forEach((abortController) => {
      if (abortController) {
        abortController.abort()
      }
    })
    this.reset()
  }

  /**
   * 移除请求
   * @param config 请求配置
   */
  public removePending(config: AxiosRequestConfig): void {
    const requestCode = generateRequestCode(config)
    if (pendingMap.has(requestCode)) {
      // 如果当前请求在等待中,取消它并将其从等待中移除
      const abortController = pendingMap.get(requestCode)
      if (abortController) {
        abortController.abort(requestCode)
      }
      pendingMap.delete(requestCode)
    }
  }

  /**
   * 重置
   */
  public reset(): void {
    pendingMap.clear()
  }
}

2、创建获取唯一标识请求code的方法generateRequestCode,通过url、method、data、params来唯一标识

import type { AxiosRequestConfig } from 'axios'

/**
 * 标准化参数对象
 * @param {Record<string, any> | null | undefined} params - 需要标准化的参数
 * @returns {Record<string, any>} - 标准化后的参数对象
 */
function normalizeParams(params?: Record<string, any> | null | undefined): Record<string, any> {
  // 处理undefined和未传参的情况
  if (arguments.length === 0 || params === undefined) {
    return {}
  }

  // 处理null和其他空值情况
  if (params === null) {
    return {}
  }

  // 如果是字符串,尝试解析为JSON对象
  if (typeof params === 'string') {
    try {
      const parsed = JSON.parse(params)
      if (typeof parsed === 'object' && parsed !== null) {
        return sortObjectDeep(parsed)
      }
    } catch (e) {
      // 解析失败,返回空对象
    }
    return {}
  }

  // 如果不是对象类型,返回空对象
  if (typeof params !== 'object') {
    return {}
  }

  // 如果是数组,返回空对象
  if (Array.isArray(params)) {
    return {}
  }

  // 检查是否为空对象
  if (Object.keys(params).length === 0) {
    return {}
  }

  // 对非空对象进行深度排序
  return sortObjectDeep(params)
}

/**
 * 深度排序对象
 * @template T - 输入对象类型
 * @param {T} obj - 需要排序的对象
 * @returns {T} - 排序后的对象
 */
function sortObjectDeep<T>(obj: T): T {
  if (typeof obj !== 'object' || obj === null) {
    return obj
  }

  if (Array.isArray(obj)) {
    return obj.map(sortObjectDeep).sort() as T
  }

  return Object.keys(obj as Record<string, any>)
    .sort()
    .reduce((result: Record<string, any>, key: string) => {
      result[key] = sortObjectDeep((obj as Record<string, any>)[key])
      return result
    }, {}) as T
}

/**
 * 生成请求唯一编码
 * @param {AxiosRequestConfig} config - Axios请求配置
 * @returns {string} - 唯一编码
 */
export function generateRequestCode(
  config: AxiosRequestConfig,
): string {
  // 确保config存在
  if (!config) {
    throw new Error('请求配置为必填参数')
  }

  // 确保url和method存在
  if (!config.url || !config.method) {
    throw new Error('URL和method为必填参数')
  }

  // 处理params的特殊情况
  const normalizedParams = normalizeParams(config.params)

  const normalizedData = normalizeParams(config.data)
  // 拼接字符串
  const stringToHash = `${config.url.toLowerCase()}|${config.method.toUpperCase()}|${JSON.stringify(normalizedParams)}|${JSON.stringify(normalizedData)}`
  
  return stringToHash
}

3、修改请求拦截器

instance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    ... 
    axiosCanceler.addPending(config)
    return config
  },
  (error) => {
    return Promise.reject(error.data.error.message)
  },
)

// 响应拦截器
instance.interceptors.response.use(
  function(response) {
    ....
    // 移除请求
    axiosCanceler.removePending(response.config)
    return Promise.resolve(response)
  },
  function(error) {
    // 请求被取消时,返回错误提示
    if (isCancel(error)) {
      return Promise.reject('重复请求,已取消')
    }
    // 移除请求
    const { response } = error
    if (response && response.config) {
      axiosCanceler.removePending(response.config)
    } else if (error.config) {
      // 处理请求被取消的情况
      axiosCanceler.removePending(error.config)
    }
    if (response) {
      return Promise.reject(response)
    } else {
      /*
       * 处理断网的情况
       * eg:请求超时或断网时,更新state的network状态
       * network状态在app.vue中控制着一个全局的断网提示组件的显示隐藏
       * 后续增加断网情况下做的一些操作
       */
      // 断网情况下,清除所有请求
      axiosCanceler.removeAllPending()
    }
  },
)
// 只需要考虑单一职责,这块只封装axios
export default instance

重复提示

这里我是通过直接二次封装element的消息提示方法,我这里采用的是vue3,统一入口方式去做的,如果是vue2,可以在main.js将$message挂载到this之前做一下重写

代码

import { ElMessage, MessageHandler } from 'element-plus'

// 防止重复弹窗
let messageInstance: MessageHandler | null = null

interface MessageOptions {
  message: string
  type?: 'success' | 'warning' | 'info' | 'error'
  [key: string]: any
}

const mainMessage = (options: MessageOptions | string): MessageHandler => {
  // 如果弹窗已存在先关闭
  if (messageInstance) {
    messageInstance.close()
  }

  const messageOptions = typeof options === 'string'
    ? { message: options }
    : options

  messageInstance = ElMessage(messageOptions)
  return messageInstance
}


const extendedMainMessage: any = mainMessage

const arr: Array<'success' | 'warning' | 'info' | 'error'> = ['success', 'warning', 'info', 'error']
arr.forEach((type) => {
  extendedMainMessage[type] = (options: MessageOptions | string) => {
    const messageOptions = typeof options === 'string'
      ? { message: options }
      : { ...options }

    messageOptions.type = type
    return mainMessage(messageOptions)
  }
})

// message消息提示
export const $success = (msg: string) => {
  mainMessage({
    message: msg || '操作成功',
    type: 'success',
  })
}
export const $warning = (msg: string) => {
  mainMessage({
    message: msg || '操作失败',
    type: 'warning',
  })
}
export const $error = (msg: string) => {
  mainMessage({
    message: msg || '操作失败',
    type: 'error',
  })
}

export const $info = (msg: string) => {
  mainMessage({
    message: msg,
    type: 'info',
  })
}

使用

此时连续调用始终只会有一个消息体出现(successsuccess、error、warningwarning、info均共用同一个消息体)

import { $error, $success } from '@/hooks/index'

$error('错误提示1')
$error('错误提示2')

Arco Design Layout 中使用 ResizeBox 实现可拖拽侧边栏

作者 好_快
2025年12月2日 10:01

Arco Design Layout 中使用 ResizeBox 实现可拖拽侧边栏

问题

a-layout-sider 虽然有 width 属性可以设置宽度,但没有拖拽事件来动态调整宽度。直接使用 a-resize-box 替换会导致布局混乱。

解决方案

核心要点:必须使用 <a-layout :has-sider="true"> 包裹 a-resize-box

<a-layout :has-sider="true">
  <a-resize-box
    :directions="['right']"
    :width="sidebarWidthImmediate"
    @moving="handleSidebarResize"
    @moving-end="handleSidebarResizeEnd"
  >
    <div>侧边栏内容</div>
  </a-resize-box>
  <a-layout-content>
    主内容区
  </a-layout-content>
</a-layout>

性能优化:双变量设计

问题

如果只用一个变量,拖拽过程中会频繁触发子组件更新和计算属性重新计算,导致卡顿。

方案

使用两个变量分离视觉反馈和数据传递:

// 视觉反馈变量:高频更新,只影响 ResizeBox
const sidebarWidthImmediate = ref(200)

// 数据传递变量:低频更新,用于子组件和计算属性
const sidebarWidth = ref(200)

function handleSidebarResize(size: { width: number; height: number }) {
  // 拖拽过程中:只更新视觉反馈变量
  sidebarWidthImmediate.value = Math.max(minSidebarWidth, size.width)
}

function handleSidebarResizeEnd() {
  // 拖拽结束时:同步数据传递变量,触发一次子组件更新
  sidebarWidth.value = sidebarWidthImmediate.value
}

性能对比

方案 拖拽时子组件渲染 性能表现
单变量 高频(每秒数十次) ❌ 卡顿
双变量 低频(结束时1次) ✅ 流畅

完整示例

<template>
  <a-layout>
    <a-layout-header>Header</a-layout-header>
    
    <a-layout :has-sider="true">
      <a-resize-box
        :directions="['right']"
        :width="sidebarWidthImmediate"
        @moving="handleSidebarResize"
        @moving-end="handleSidebarResizeEnd"
      >
        <div class="sidebar">侧边栏内容</div>
      </a-resize-box>
      
      <a-layout-content>
        <MainContent
          :sidebar-width="sidebarWidth"
          :width="contentWidth"
        />
      </a-layout-content>
    </a-layout>
  </a-layout>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'

const minSidebarWidth = 200
const sidebarWidthImmediate = ref(minSidebarWidth) // 视觉反馈变量
const sidebarWidth = ref(minSidebarWidth) // 数据传递变量
const resizerWidth = 6
const containerWidth = ref(1200)

const contentWidth = computed(() => {
  return containerWidth.value - sidebarWidth.value - resizerWidth
})

function handleSidebarResize(size: { width: number; height: number }) {
  sidebarWidthImmediate.value = Math.max(minSidebarWidth, size.width)
}

function handleSidebarResizeEnd() {
  sidebarWidth.value = sidebarWidthImmediate.value
}
</script>

注意事项

  1. 必须设置 has-sider="true":否则布局会混乱
  2. 宽度计算需减去拖拽条宽度:通常为 6px
  3. 设置最小宽度限制:防止侧边栏过小
  4. 使用双变量模式:避免拖拽时频繁触发子组件更新

UniApp Vue3 词云组件开发实战:从原理到应用

作者 Amy_yang
2025年12月2日 09:13

UniApp Vue3 词云组件开发实战:从原理到应用

在数据可视化领域,词云是一种直观展示文本数据分布的方式。本文将详细介绍如何在 UniApp Vue3 环境下开发一个功能完善的词云组件,包括核心算法实现、组件封装及性能优化技巧。通过本文,你将掌握碰撞检测、螺旋布局等关键技术,最终实现一个可自定义、高性能的词云组件。

引言:词云组件的应用价值

词云作为一种将文本数据可视化的手段,通过词汇大小和颜色变化直观反映关键词的重要程度和出现频率,在数据分析、用户画像、舆情监控等场景中有着广泛应用。在移动应用开发中,一个高性能、可定制的词云组件能为用户提供更丰富的数据洞察方式。

UniApp 作为跨平台开发框架,其 Vue3 版本带来了更好的性能和更简洁的语法。然而,在移动端实现词云面临诸多挑战:如何在有限的屏幕空间内合理布局词汇?如何避免词汇重叠?如何保证在不同设备上的显示效果一致?本文将逐一解决这些问题,带你从零构建一个适配安卓环境的 UniApp Vue3 词云组件。

核心功能实现

词汇尺寸计算

词云的核心在于通过词汇大小反映其权重。我们首先需要根据词汇的权重值计算其显示尺寸。在提供的代码中,通过 calculateWordDimensions 函数实现了这一功能:

const calculateWordDimensions = (word) => {
  // Approximate text width (characters * font size * constant)
  const charWidth = word.size * 0.6;
  const charHeight = word.size * 1.2;
  return {
    width: word.text.length * charWidth,
    height: charHeight,
  };
};

该函数根据词汇的基础大小(word.size)和文本长度计算出词汇的宽高。这里使用了经验系数 0.6 和 1.2 来近似字符的宽高比,你可以根据实际字体进行调整。

碰撞检测机制

为了避免词汇重叠,我们需要实现碰撞检测。isOverlapping 函数通过比较两个词汇的边界框来判断它们是否重叠:

const isOverlapping = (word1, word2, padding = 5) => {
  return !(
    word1.x + word1.width + padding < word2.x - padding ||
    word2.x + word2.width + padding < word1.x - padding ||
    word1.y + word1.height + padding < word2.y - padding ||
    word2.y + word2.height + padding < word1.y - padding
  );
};

这个函数通过判断两个矩形是否完全分离来确定是否发生碰撞。如果四个方向上任意一个方向满足"一个矩形完全在另一个矩形之外"的条件,则认为没有碰撞,返回 false;否则返回 true,表示发生了碰撞。

image.png

边界检查

除了避免词汇间重叠,还需要确保所有词汇都在词云容器内显示。isWithinBounds 函数负责这一检查:

const isWithinBounds = (word, width, height, padding = 10) => {
  return (
    word.x >= padding &&
    word.y >= padding &&
    word.x + word.width <= width - padding &&
    word.y + word.height <= height - padding
  );
};

该函数确保词汇在容器内留有一定边距(padding),避免词汇紧贴容器边缘,提升视觉效果。

螺旋布局算法

词云布局是整个组件的核心。本组件采用了螺旋布局算法,从中心向外逐步放置词汇,当遇到碰撞时调整位置继续尝试。核心代码如下:

const angle = index + attempts * 0.2;
const radius = Math.min(effectiveWidth, effectiveHeight) * 0.3 * (attempts / maxAttempts);
const x = centerX + radius * Math.cos(angle) - dimensions.width / 2;
const y = centerY + radius * Math.sin(angle) - dimensions.height / 2;

这段代码通过极坐标计算词汇位置:随着尝试次数(attempts)增加,半径(radius)逐渐增大,同时角度(angle)也在变化,形成螺旋轨迹。这种布局方式能让词汇从中心向外均匀分布,形成美观的圆形词云。

image.png

网格布局 fallback

当螺旋布局尝试多次仍无法放置词汇时,代码会 fallback 到网格布局:

const gridCellWidth = dimensions.width + padding * 2;
const gridCellHeight = dimensions.height + padding * 2;
for (let x = padding; x <= props.width - dimensions.width - padding; x += gridCellWidth) {
  for (let y = padding; y <= props.height - dimensions.height - padding; y += gridCellHeight) {
    // 尝试放置词汇
  }
}

网格布局将容器划分为等大小的单元格,在每个单元格中尝试放置词汇,确保即使在极端情况下所有词汇都能被放置。

样式设计与交互效果

为了提升用户体验,组件还实现了丰富的样式和交互效果:

  • 随机颜色生成:通过 getRandomColor 函数为每个词汇分配随机颜色
  • 悬停效果:通过 CSS 过渡实现词汇缩放效果
  • 点击事件:通过 onWordClick 函数触发点击事件回调

关键代码解析

词汇布局主流程

calculatePositionsWithCollision 函数是词汇布局的核心,其流程如下:

  1. 对词汇按大小排序,确保大词汇优先布局

  2. 初始化中心位置和有效宽高

  3. 对每个词汇执行螺旋布局算法:

    • 计算螺旋轨迹上的候选位置
    • 检查位置是否在边界内
    • 检查是否与已放置词汇碰撞
    • 如果找到合适位置则放置词汇
  4. 如果螺旋布局失败,尝试网格布局

  5. 将最终位置信息保存到 positionedWords

// 按大小排序词汇(大词汇优先)
const sortedWords = [...props.words].sort((a, b) => b.size - a.size);

// 放置每个词汇
sortedWords.forEach((word, index) => {
  const dimensions = calculateWordDimensions(word);
  let placed = false;
  let attempts = 0;
  const maxAttempts = 200;

  while (!placed && attempts < maxAttempts) {
    // 螺旋算法计算位置
    const angle = index + attempts * 0.2;
    const radius = Math.min(effectiveWidth, effectiveHeight) * 0.3 * (attempts / maxAttempts);
    const x = centerX + radius * Math.cos(angle) - dimensions.width / 2;
    const y = centerY + radius * Math.sin(angle) - dimensions.height / 2;

    const candidateWord = { ...word, x: Math.round(x), y: Math.round(y), ...dimensions };

    // 检查边界和碰撞
    if (!isWithinBounds(candidateWord, props.width, props.height, padding)) {
      attempts++;
      continue;
    }

    let hasCollision = false;
    for (const placedWord of positions) {
      if (isOverlapping(candidateWord, placedWord)) {
        hasCollision = true;
        break;
      }
    }

    if (!hasCollision) {
      positions.push(candidateWord);
      placed = true;
    } else {
      attempts++;
    }
  }

  // 如果螺旋布局失败,尝试网格布局
  if (!placed) {
    placeInGrid(positions, word, dimensions, padding);
  }
});

这段代码体现了算法的核心思想:通过螺旋轨迹探索可能的位置,结合碰撞检测确保词汇不重叠,大词汇优先放置以保证视觉效果。

响应式更新

为了在词汇数据变化时自动更新布局,组件使用了 Vue3 的 computed:

computed(() => {
  if (props.words && props.words.length > 0) {
    calculatePositionsWithCollision();
  }
});

当 props.words 变化时,会自动触发重新布局,确保视图与数据同步。

使用示例

基本用法

在页面中引入词云组件并传入词汇数据:

<template>
  <view class="content">
    <word-cloud :words="wordData" :width="300" :height="300" @word-click="handleWordClick"></word-cloud>
  </view>
</template>

<script setup>
import WordCloud from '@/components/WordCloud.vue';
import { ref } from 'vue';

const wordData = ref([
  { text: 'JavaScript', size: 24, weight: 10 },
  { text: 'Vue3', size: 20, weight: 8 },
  { text: 'UniApp', size: 18, weight: 7 },
  { text: '词云', size: 16, weight: 6 },
  // 更多词汇...
]);

const handleWordClick = (word) => {
  uni.showToast({ title: `点击了:${word.text}` });
};
</script>

自定义样式

通过 CSS 变量自定义词云样式:

.word-cloud-container {
  --word-cloud-bg: #f5f5f5;
  --word-cloud-border-radius: 16px;
}

动态更新数据

通过修改 wordData 实现词云动态更新:

// 添加新词汇
const addWord = () => {
  wordData.value.push({
    text: '新词汇',
    size: 14 + Math.random() * 10,
    weight: 5
  });
};

// 清空词云
const clearWords = () => {
  wordData.value = [];
};

优化建议

性能优化

  1. 减少重绘:词汇位置计算是 CPU 密集型操作,建议使用 requestAnimationFrame 分批处理词汇布局。
  2. 缓存计算结果:对于相同的词汇数据,缓存布局结果,避免重复计算。
  3. 虚拟滚动:对于大量词汇,考虑实现虚拟滚动,只渲染可见区域的词汇。
  4. 使用 Web Workers:将布局计算放入 Web Worker 中执行,避免阻塞主线程。

用户体验提升

  1. 响应式设计:根据容器大小自动调整词汇布局,适应不同屏幕尺寸。
  2. 动画过渡:添加词汇出现和消失的过渡动画,提升视觉体验。
  3. 交互反馈:为词汇添加点击、长按等交互效果,支持跳转或显示详情。
  4. 可访问性:确保颜色对比度符合标准,支持屏幕阅读器。

功能扩展

  1. 自定义形状:支持自定义词云形状,如圆形、矩形、图片轮廓等。
  2. 颜色主题:提供多种预设颜色主题,支持自定义颜色映射。
  3. 词汇分组:支持按类别对词汇进行分组,使用不同颜色区分。
  4. 动态权重:支持动态更新词汇权重并实时更新词云。

总结

本文详细介绍了 UniApp Vue3 词云组件的实现原理和使用方法。通过碰撞检测、螺旋布局等核心算法,我们解决了移动端词云布局的关键问题。组件支持自定义尺寸、颜色和交互,可灵活应用于各种数据可视化场景。

UniApp 提供的跨平台能力结合 Vue3 的响应式系统,使得开发高性能移动词云组件成为可能。通过本文介绍的优化建议,你可以进一步提升组件性能和用户体验,满足更复杂的业务需求。

词云作为数据可视化的重要手段,其应用场景正在不断扩展。希望本文能为你的移动应用开发提供新的思路和工具,让数据展示更加生动直观。

image.png

昨天以前首页

Vue3 + Element Plus 动态菜单实现:一套代码完美适配多角色权限系统

作者 刘大华
2025年12月1日 19:50

今天分享一个基于Vue3Element Plus的动态菜单实现。这个方案很适用于需要权限管理的后台系统,能够根据用户角色权限显示不同的菜单项。

一、什么是动态菜单?为什么需要它?

在管理后台系统中,不同角色的用户通常需要不同的功能权限。比如:

  • 管理员可以访问所有功能
  • 编辑者只能管理内容
  • 查看者只能浏览数据

如果为每个角色单独开发一套界面,显然效率低下。动态菜单就是解决这个问题的方案——一套代码,根据不同用户角色显示不同的菜单结构

二、实现效果预览

我们先来看看最终实现的效果:

1动态菜单3.png

  1. 角色切换:右上角可以切换用户角色(管理员/编辑者/查看者)
  2. 菜单过滤:根据角色自动过滤无权限的菜单项
  3. 侧边栏折叠:支持展开/收起侧边栏
  4. 面包屑导航:显示当前页面位置

老样子,完整源码在文末获取哦~

三、核心实现原理

1. 菜单数据结构设计

合理的菜单数据结构是动态菜单的基础。我们的设计如下:

const menuData = ref([
  {
    id: 'dashboard',        // 唯一标识
    name: '仪表板',         // 显示名称
    icon: 'DataBoard',      // 图标
    route: '/dashboard',    // 路由路径
    roles: ['admin', 'editor', 'viewer']  // 可访问的角色
  },
  {
    id: 'content',
    name: '内容管理',
    icon: 'Document',
    roles: ['admin', 'editor'],
    children: [             // 子菜单
      {
        id: 'articles',
        name: '文章管理',
        route: '/articles',
        roles: ['admin', 'editor']
      }
      // ... 更多子菜单
    ]
  }
  // ... 更多菜单项
]);

这种结构的特点:

  • 支持多级嵌套菜单
  • 每个菜单项明确指定可访问的角色
  • 图标使用 Element Plus 的图标组件

2. 菜单过滤逻辑

核心功能是根据当前用户角色过滤菜单:

const filteredMenu = computed(() => {
  return menuData.value
    .map(item => {
      // 1. 检查主菜单权限
      if (!item.roles.includes(currentUser.value.role)) {
        return null;  // 无权限,过滤掉
      }
      
      // 2. 深拷贝菜单项(避免修改原始数据)
      const menuItem = { ...item };
      
      // 3. 如果有子菜单,过滤子菜单
      if (menuItem.children) {
        menuItem.children = menuItem.children.filter(
          child => child.roles.includes(currentUser.value.role)
        );
        
        // 如果子菜单全被过滤掉,主菜单也不显示
        if (menuItem.children.length === 0) {
          return null;
        }
      }
      
      return menuItem;
    })
    .filter(Boolean);  // 过滤掉null值
});

过滤过程详解

  1. 映射(map):遍历每个菜单项,返回处理后的菜单项或null
  2. 权限检查:检查当前用户角色是否在菜单项的角色列表中
  3. 子菜单过滤:对有子菜单的项,递归过滤无权限的子项
  4. 空子菜单处理:如果所有子项都被过滤,父项也不显示
  5. 最终过滤:用filter(Boolean)移除所有null值

计算属性(computed)的优势

  • 自动响应依赖变化(当用户角色变化时自动重新计算)
  • 缓存结果,避免重复计算

3. 用户角色管理

用户信息和角色切换的实现:

// 当前用户信息
const currentUser = ref({
  name: '管理员',
  role: 'admin',
  avatar: 'https://example.com/avatar.png'
});

// 处理角色切换
const handleRoleChange = (role) => {
  currentUser.value.role = role;
  
  // 角色切换后更新当前激活的菜单
  if (role === 'viewer') {
    // 查看者只能访问仪表板
    activeMenu.value = '/dashboard';
    currentPageTitle.value = '仪表板';
  } else {
    // 其他角色显示第一个可访问的菜单
    const firstMenu = findFirstAccessibleMenu();
    if (firstMenu) {
      activeMenu.value = firstMenu.route;
      currentPageTitle.value = firstMenu.name;
    }
  }
};

四、界面布局与组件使用

1. 整体布局结构

<div class="app-container">
  <!-- 侧边栏 -->
  <div class="sidebar" :class="{ collapsed: isCollapse }">
    <!-- Logo区域 -->
    <div class="logo-area">...</div>
    <!-- 菜单区域 -->
    <el-menu>...</el-menu>
  </div>
  
  <!-- 主内容区 -->
  <div class="main-content">
    <!-- 顶部导航 -->
    <div class="header">...</div>
    <!-- 页面内容 -->
    <div class="content">...</div>
    <!-- 页脚 -->
    <div class="footer">...</div>
  </div>
</div>

这种布局是管理后台的经典设计,具有清晰的视觉层次。

2. Element Plus 菜单组件使用

<el-menu
  :default-active="activeMenu"           <!-- 当前激活的菜单 -->
  class="el-menu-vertical"
  background-color="#001529"            <!-- 背景色 -->
  text-color="#bfcbd9"                  <!-- 文字颜色 -->
  active-text-color="#409EFF"           <!-- 激活项文字颜色 -->
  :collapse="isCollapse"                <!-- 是否折叠 -->
  :collapse-transition="false"          <!-- 关闭折叠动画 -->
  :unique-opened="true"                 <!-- 只保持一个子菜单展开 -->
>
  <!-- 菜单项渲染 -->
  <template v-for="item in filteredMenu" :key="item.id">
    <!-- 有子菜单的情况 -->
    <el-sub-menu v-if="item.children" :index="item.id">
      <!-- 标题区域 -->
      <template #title>
        <el-icon><component :is="item.icon" /></el-icon>
        <span>{{ item.name }}</span>
      </template>
      
      <!-- 子菜单项 -->
      <el-menu-item v-for="child in item.children" 
                   :key="child.id" 
                   :index="child.route"
                   @click="selectMenu(child)">
        {{ child.name }}
      </el-menu-item>
    </el-sub-menu>
    
    <!-- 没有子菜单的情况 -->
    <el-menu-item v-else :index="item.route" @click="selectMenu(item)">
      ...
    </el-menu-item>
  </template>
</el-menu>

关键点说明

  1. 动态组件<component :is="item.icon"> 实现动态图标渲染
  2. 条件渲染:使用 v-ifv-else 区分子菜单和单菜单项
  3. 循环渲染v-for 遍历过滤后的菜单数据
  4. 唯一key:为每个菜单项设置唯一的 :key="item.id" 提高性能

五、样式设计技巧

1. 侧边栏折叠动画

.sidebar {
  width: 240px;
  background-color: #001529;
  transition: width 0.3s;  /* 宽度变化动画 */
  overflow: hidden;
}

.sidebar.collapsed {
  width: 64px;
}

.logo-area .logo-text {
  margin-left: 10px;
  transition: opacity 0.3s;  /* 文字淡入淡出 */
}

.sidebar.collapsed .logo-text {
  opacity: 0;  /* 折叠时隐藏文字 */
}

2. 布局技巧

.app-container {
  display: flex;
  min-height: 100vh;  /* 全屏高度 */
}

.main-content {
  flex: 1;            /* 占据剩余空间 */
  display: flex;
  flex-direction: column;
  overflow: hidden;   /* 防止内容溢出 */
}

.content {
  flex: 1;            /* 内容区占据主要空间 */
  padding: 20px;
  overflow-y: auto;   /* 内容过多时滚动 */
}

使用 Flex 布局可以轻松实现经典的侧边栏+主内容区布局。

六、实际应用扩展建议

在实际项目中,你还可以进一步扩展这个基础实现:

1. 与路由集成

import { useRouter, useRoute } from 'vue-router';

const router = useRouter();
const route = useRoute();

// 菜单点击处理
const selectMenu = (item) => {
  // 路由跳转
  router.push(item.route);
};

// 根据当前路由设置激活菜单
watch(route, (newRoute) => {
  activeMenu.value = newRoute.path;
  // 根据路由查找对应的页面标题
  currentPageTitle.value = findTitleByRoute(newRoute.path);
});

2. 后端动态菜单

在实际项目中,菜单数据通常来自后端:

// 从API获取菜单数据
const fetchMenuData = async () => {
  try {
    const response = await axios.get('/api/menus', {
      params: { role: currentUser.value.role }
    });
    menuData.value = response.data;
  } catch (error) {
    console.error('获取菜单数据失败:', error);
  }
};

3. 权限控制增强

除了菜单过滤,还可以添加更细粒度的权限控制:

// 权限指令
app.directive('permission', {
  mounted(el, binding) {
    const { value: requiredRoles } = binding;
    const userRole = currentUser.value.role;
    
    if (!requiredRoles.includes(userRole)) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
});

// 在模板中使用
<button v-permission="['admin', 'editor']">编辑内容</button>

总结

通过这个 Vue 3 + Element Plus 的动态菜单实现,我们学到了:

  1. 设计合理的菜单数据结构是动态菜单的基础
  2. 使用计算属性实现菜单过滤,自动响应角色变化
  3. 利用 Element Plus 组件快速构建美观的界面
  4. Flex 布局技巧实现响应式侧边栏
  5. 扩展思路,如路由集成、后端动态菜单等

这个实现方案具有很好的可扩展性,你可以根据实际需求进行调整和增强。

完整源码GitHub地址github.com/1344160559-…

你可以直接复制到HTML文件中运行体验。尝试切换不同的用户角色,观察菜单的变化,加深对动态菜单工作原理的理解。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+MySQL+Vue实现文件共享系统》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

同事:架构太复杂了,源码文件找半天。 我:源码溯源了解一下?

2025年12月1日 17:52

背景


相信刚入行,或是刚入行的小伙伴们,对于企业级代码与架构,以及扑面而来业务需求。想要在短时间内从对应的页面定位到组件时,是很难办到的事情,尤其是突然交给一个陌生的项目的需求,问题也会比较突出。

尤其是对于鼠鼠我本人来说,也是深有体会:司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 🤯🤯🤯

在这样的情况下,一款能够快速定位源码的插件呼之欲出🎉🎉🎉


通过本篇文章,大家能学习到:
  1. 如何编写一个简易的vite插件
  2. vite插件的生命周期是怎么样的
  3. 源码溯源,快速定位:实现思路,原理

首先准备好实验环境:vue+vite+pnpm 让cursor快速生成一个项目即可

image.png

在正式将源码定位之前,我想讲讲一个简易的vite插件该如何实现,这对我们后面的学习会有比较有效的帮助

如何写Vite插件

再讲如何编写vite插件之前,需要先了解一下如何将自己编写的vite插件在Vite的构建流程中生效:

Vite插件本质是一个对象,通过到处一个对象函数,放入Vite配置项数组中即可实现效果:

在配置文件中:

那么作为Vite的自定义插件,和webpack一样,需要使用各种生命周期钩子,才能实现对应的效果:

这里介绍一下主流的生命周期钩子:

主流钩子

配置阶段:

config(config, env ):

  • 触发时机:当vite读取配置时触发

  • 常用场景:修改或扩展配置对象

configResolved(resolvedConfig):

  • 触发时机:当配置解析完成时触发

  • 常用场景:获取最终配置,初始化插件状态

该阶段主要用于插件初始化或读取用户配置,不是必须

构建阶段

buildStart:

  • 触发时机: 构建开始

  • 常用场景: 初始化状态,打印日志,准备数据

buildEnd:

  • 触发时机: 构建结束

  • 常用场景:收尾,打印统计

closeBundle:

  • 触发时机:构建完成并生成文件后

  • 常用场景:做最终清理或发布的操作

主要用于插件需要做全局初始化或构建后操作的场景

模块解析和加载阶段

resolveId(id,importer)

  • 触发时机:解析模块路径时

  • 常用场景:重写模块路径,生成虚拟模块

load(id)

  • 触发时机:模块加载内容

  • 常用场景:返回模块代码,生成虚拟模块

moduleParsed

  • 触发时机:模块 AST 解析完成

  • 常用场景:分析模块 AST ,做统计或收集信息

核心点:虚拟模块一般用 resolveId + load,处理源码前可以分析 AST。

模块transform阶段(最常用)

thransform(code,id)

  • 触发时机:模块加载后,打包前

  • 常用场景:核心 hook,用于修改 源码 、注入代码、操作 Vue/ JSX ****AST

transformIndexHtml

  • 触发时机: HTML 文件处理阶段

  • 常用场景:修改 HTML 模版,例如注入script,link

transform 是最主流的钩子,几乎所有插件都至少用它做一次源码修改。

整个构建生命周期流程图来看是这样的:

image.png

针对LLM返回给我们的主流钩子使用频率来看,我们优先掌握的肯定就是:模块 transform 阶段,因为这个阶段是能够直接接触的源代码,更容易在源代码上动手脚的阶段。

模块 transform 阶段

好记性不如烂笔头,让我们实战来看看,这个阶段能够做什么呢?

什么是transform阶段

在Vite的构建过程中,一个文件会从源码 -> 浏览器可执行文件,会经历很多处理环节。比如:

  • TS-> js
  • JSX -> JS
  • VUE单文件组件拆成JS,CSS
  • 去掉console.log
  • 注入HMR代码
  • 压缩

而 transform 就是 Vite 插件体系里专门负责“把代码转成新代码”的阶段

transform的函数签名

transform(code, id) {
  return {
    code: '新的代码',
    map: null, // 或 sourcemap
  }
}
  1. Code: 当前拿到的文件 源码
  2. id:当前文件的绝对路径

返回值:

  1. 返回一个字符串:
return transformedCode

说明只修改了代码,不管 source map,由 Vite 自动处理部分情况。

⚠️ 但 source map 会丢失或错误。

  1. 返回一个包含code+map的对象
return {
  code: transformedCode,
  map: null  // 或 SourceMap 对象
}
  • Vite 会继续把 map 传给下一环
  • 最终映射会合并到 browser source map
  • 对 HMR Debug 友好

若map为null时,让vite自己处理

  1. 返回为null或undefined
  • 表示我不处理这个模块,让下个插件处理。即:跳过这个阶段的

何时会触发transform

  1. 开发( dev server) :Vite 在浏览器请求模块时,先 resolveIdload(读文件)→ transform → 返回给浏览器(并缓存结果)。
  2. 构建(build) :Rollup 打包流程,Vite 基于 Rollup 插件接口执行,顺序类似:resolveIdloadtransform → 打包。
  3. 对于 SFC(例如 Vue 单文件组件),一个 .vue 会被拆成多个请求(script/template/style),每个子模块都会走 transform,因此你会看到同一个文件被多次 transform(通过 id 的 query 区分)。

image.png

源码溯源

为什么需要源码溯源插件

谈到为什么需要源码 溯源。就得提到司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 行, 所以我们拟设计一款Vite插件配合油猴脚本,能够识别一个页面的所有组件,通过click,能够快速定位到对应的component。

设计思路是什么?

目标:

我们想要实现一个所见即所得模式,即能够清楚的看到一个页面由哪些组件组成,并且可以看到对应的组件渲染了页面的哪些地方,并且点击对应模块后,能够立马弹出组件对应的绝对路径,方便直接去寻找到对应的组件。

具体体现成什么样呢?这里起一个简单的小项目给大家看看

image.png

是一个很简单的小架构,当我们想要知道头部组件在对应源代码的哪个位置时,我们点击他:

image.png

第一个就是头部组件对应的组件路径,下面的就是其父组件,方便我们了解嵌套关系。

具体思路:

首先我们需要知道一件事情,浏览器最后渲染的内容,拿到的源文件是经过构建工具的转译,压缩,打包后的源代码,与自己实际开发是天壤之别,所以针对打包后的源代码溯源是不切实际的。所以我们的思路是:

  1. 需要在构建阶段,针对对应文件进行处理
  2. 具体处理就是将对应文件的绝对路径,通过某些方式,在构建后,保存到 源代码
  3. 再通过油猴插件,在浏览器中执行脚本,该脚本核心代码就是提取到点击模块对应的保存的绝对路径进行转译渲染出来,成为图片中的样式。
具体实现:
  1. 编写自定义vite插件,插件用处:在每个组件的根元素中添加自定义属性,内容为该文件绝对路径的编码形式存储在此。

  2. 将根元素的自定义属性值广播到子组件的类型中,任何你想点击/调试的元素都带有足够的信息

  3. 编写js脚本,核心在于提取到点击对应元素,能够快速识别转译出路径,并渲染到弹窗。

vite插件如何编写?

在编写插件前,我们需要明确我们插件需要做什么:

  • 每个Vue文件中的根元素,添加对应的自定义属性,属性值填的是对应路径的编码。

那么针对这个需求,我们首先需要分析,要使用哪个生命周期钩子才能实现对应的效果?

搜索过后,发现thransform(code,id) 这个钩子能够帮助我们实现我们想要的效果。

transform 是 Vite 插件体系里的编译钩子。每当 Vite 正在加载某个模块(无论是 .ts、.vue 还是别的可处理资源),都会把“源代码字符串 + 模块 id(含绝对路径/查询参数)”传进每个插件的 transform(code, id) ,让插件有机会在官方编译器运行前对源码 做一次改写、替换或分析

最后效果如下:

image.png

具体源代码实现:

export function cscMark(): Plugin {
  return {
    name: 'csc-mark',
    enforce: 'pre',
    transform(code, id) {
      if (!id.endsWith('.vue')) {
        return null;
      }

      const { template } = parse(code, { filename: id }).descriptor;

      if(template) {
        const elm = template.ast.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
        if(elm) {
          const tagString = `<${elm.tag}`;

          const insertIndex = elm.loc.source.indexOf(tagString) + tagString.length;
          const newSource
              = `${elm.loc.source.slice(0, insertIndex)} csc-mark="${LZString.compressToBase64(id)}"${elm.loc.source.slice(insertIndex)}`;
  
          code = code.replace(elm.loc.source, newSource);
        }
      }

      return code;
    }
  };
}
  1. 遍历每个vue组件

  2. 获得code里面template的内容

  3. 通过ast拿到根元素:elm

  4. 通过LZString.compressToBase64( id )绝对路径赋值进去。注:该钩子参数id就是遍历该文件的绝对路径

  5. 返回新代码给后续编译构建使用

如何将路径广播到子组件?

我们需要有个钩子,能够在上述标签打完之后,再逐一遍历该文件内的其他组件。将编码后的id注入class中。那么哪个钩子能够实习这种功能呢?

经过调研后发现:

Vue插件中,有个钩子能够帮助我们

export default defineConfig(({ mode }) => ({
  plugins: [vue({
    template: {
      compilerOptions: {
          nodeTransforms: [
              自己编写的函数
          ],
      },
  },
  }),cscMark() ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  server: {
    host: '0.0.0.0',
    port: 4173,
    open: true
  },
  define: {
    __APP_ENV__: JSON.stringify(mode)
  }
}));

用法:

在编译模板时,对每个 AST 节点执行自己编写的特定函数

🌰

<template>
  <div csc-mark="路径1">
    <h1>标题</h1>
    <ks-dialog>弹窗</ks-dialog>
  </div>
</template>

Vue插件编译器会解析:

  1. 读取.vue文件
  2. 解析 template 部分
  3. 生成 AST(抽象语法树)

最后生成:

ROOT (type: 0)
  └── <div> (ELEMENT, type: 1)
       ├── csc-mark="路径1" (ATTRIBUTE)
       ├── <h1> (ELEMENT, type: 1)
       │    └── "标题" (TEXT)
       └── <ks-dialog> (ELEMENT, type: 1)
            └── "弹窗" (TEXT)

Vue 编译器会深度优先遍历 AST,对每个节点调用自定义的函数。

那这个自定义函数该如何去进行编写呢?

export const cscMarkNodeTransform = (node, context) => {
  if (node.type === NodeTypes.ELEMENT && context.parent) {
      if ([NodeTypes.ROOT, NodeTypes.IF_BRANCH].includes(context.parent.type)) {
          const firstElm = context.parent.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
          const addText = firstElm && firstElm.props.find(item => item.name === 'csc-mark')?.value?.content || '';

          if (addText) {
                  addClass(node, addText, 'class');
          }
      } else if (context.parent.props?.find(item => item.name === 'csc-mark')?.value?.content) {
          const addText = context.parent.props.find(item => item.name === 'csc-mark')?.value?.content || '';
          if (addText) {
                  addClass(node, addText, 'class');
          }
      }

  }
};
  1. cscMarkNodeTransform 中,只有当当前 node 是 NodeTypes.ELEMENT 且存在 context.parent 时才会继续处理,避免对非元素节点或无父节点的情况做多余操作
  2. 当父节点是 ROOT 或 IF_BRANCH 时,会查找父节点的首个子元素,读取其 csc-mark 属性的内容,并将该内容通过 addClass 加在当前节点的 class 上,从而把顶层 csc-mark 标记扩散到具体元素。
  3. 如果父节点本身带有 csc-mark 属性,就直接读取父节点的该属性内容并同样调用 addClass,以确保嵌套元素 继承 父级 csc-mark 定义的类名

页面效果呈现:

image.png

油猴脚本编写:

脚本作用:

  1. 添加检查button,只有点击button时,才会开启溯源功能
  2. 点击后高亮所有带有css-vite-mark-类名的元素
  3. 点击元素时,收集并显示嵌套组件及组件绝对路径

核心代码解释

1.组件层次结构的收集:

  • 这个函数从点击的元素开始向上遍历DOM树,收集所有带有标记的父元素,构建组件层次结构。
 // 函数:收集从顶层到当前元素的 csc-mark 属性列表
    function collectCscMarkHierarchy(element) {
        let cscMarkList = [];
        while (element) {
            if (element.hasAttribute('csc-mark')) {
                cscMarkList.push({ element, mark: element.getAttribute('csc-mark') });
            }
            element = element.parentElement;
        }
        return cscMarkList;
    }

2.路径解码:

这部分代码从类名中提取压缩的路径部分,然后使用LZString.decompressFromBase64解码还原为实际绝对路径。

// 处理源码路径部分代码
cssMarkList.forEach(item => {
    const tag = item.element.tagName.toLowerCase();
    try {
        const encodedPath = item.originMark.substring(prefix.length);
        const filePath = LZString.decompressFromBase64(encodedPath);
        decodedPaths.push({ tag, filePath });
    } catch (e) {
        console.error('解码路径失败:', e);
    }
});

3.交互机制

用户点击该元素时,收集组件嵌套,并渲染对话框

 // 函数:处理点击事件并显示 csc-mark 层级
    function handleClick(event) {
        let element = event.target;
  
        // 遍历 DOM 树查找最近的具有 csc-mark 属性的祖先元素
        while (element && !element.hasAttribute('csc-mark')) {
            element = element.parentElement;
        }
  
        if (element && element.hasAttribute('csc-mark')) {
            event.stopPropagation();
            event.preventDefault();
            const cscMarkList = collectCscMarkHierarchy(element);
            showCustomDialog(cscMarkList);
        }
    }
  

具体使用流程:

  1. 启动开发服务器
  2. 通过油猴插件添加脚本

image.png 3. 点击inspect按钮

image.png

  1. 之后想要修改哪个模块就可以进行点击

image.png

⚠️使用该油猴脚本时需要注意匹配到你对应的项目路径

image.png

总结:

通过上述方法可以实现一个简易的源码定位系统了,能够帮助我们在很多复杂项目中快速定位到自己需要修改的模块所对应的,通过这么一个比较小的需求,能够快速帮助大家对vite的生命周期,以及自定义插件油猴插件的基本使用,有个较为清晰的了解。综合性比较强,需求完成后对大家的开发效率也会有很大的提升,大家感兴趣的可以进我的github上看对应的插件源码和脚本代码:溯源代码

扩展点:

  1. 如何在webpack上,通过编写对应插件,实现相应的功能
  2. 目前只能够在页面上知道对应模块使用的组件,不知道这个组件能够对应哪个页面
  3. 可以修改一些样式,让整体更加美观
  4. 一步到位,点击对应模块能够自动跳转的编辑器中

使劲折腾Element Plus的Table组件

作者 至简简
2025年12月1日 16:24

背景

笔者公司的一个项目大量使用el-table组件,并做出一些魔改的效果

多列显示

废话不多讲,直接上效果

image.png

使用el-table组件的多级表头,不存在滴

核心代码如下

<script setup lang="ts">
import { ref, computed } from 'vue'
import { Search, Refresh, Edit, Delete, View } from '@element-plus/icons-vue'

interface User {
  id: number
  avatar: string
  username: string
  realName: string
  email: string
  phone: string
  gender: 'male' | 'female' | 'unknown'
  age: number
  department: string
  position: string
  status: 'active' | 'inactive' | 'banned'
  registerTime: string
  lastLoginTime: string
  province: string
  city: string
  address: string
  salary: number
  education: string
  workYears: number
}

const loading = ref(false)
const searchText = ref('')
const statusFilter = ref('')
const departmentFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)

const departments = ['技术部', '产品部', '设计部', '市场部', '运营部', '人事部', '财务部']
const positions = ['工程师', '高级工程师', '技术经理', '产品经理', '设计师', '运营专员', 'HR专员', '财务专员']
const educations = ['高中', '大专', '本科', '硕士', '博士']
const provinces = ['北京', '上海', '广东', '浙江', '江苏', '四川', '湖北']

const generateMockData = (): User[] => {
  const data: User[] = []
  for (let i = 1; i <= 100; i++) {
    data.push({
      id: i,
      avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i}`,
      username: `user${i}`,
      realName: `用户${i}`,
      email: `user${i}@example.com`,
      phone: `138${String(i).padStart(8, '0')}`,
      gender: ['male', 'female', 'unknown'][i % 3] as User['gender'],
      age: 20 + (i % 30),
      department: departments[i % departments.length],
      position: positions[i % positions.length],
      status: ['active', 'inactive', 'banned'][i % 3] as User['status'],
      registerTime: `2023-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 10:30:00`,
      lastLoginTime: `2024-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')} 14:20:00`,
      province: provinces[i % provinces.length],
      city: '市区',
      address: `街道${i}号`,
      salary: 8000 + (i % 20) * 1000,
      education: educations[i % educations.length],
      workYears: i % 15,
    })
  }
  return data
}

const allUsers = ref<User[]>(generateMockData())

const filteredUsers = computed(() => {
  let result = allUsers.value

  if (searchText.value) {
    const search = searchText.value.toLowerCase()
    result = result.filter(
      (user) =>
        user.username.toLowerCase().includes(search) ||
        user.realName.toLowerCase().includes(search) ||
        user.email.toLowerCase().includes(search) ||
        user.phone.includes(search)
    )
  }

  if (statusFilter.value) {
    result = result.filter((user) => user.status === statusFilter.value)
  }

  if (departmentFilter.value) {
    result = result.filter((user) => user.department === departmentFilter.value)
  }

  return result
})

const paginatedUsers = computed(() => {
  const start = (currentPage.value - 1) * pageSize.value
  const end = start + pageSize.value
  return filteredUsers.value.slice(start, end)
})

const total = computed(() => filteredUsers.value.length)

const getGenderText = (gender: string) => {
  const map: Record<string, string> = {
    male: '男',
    female: '女',
    unknown: '未知',
  }
  return map[gender] || '未知'
}

const getStatusType = (status: string) => {
  const map: Record<string, string> = {
    active: 'success',
    inactive: 'warning',
    banned: 'danger',
  }
  return map[status] || 'info'
}

const getStatusText = (status: string) => {
  const map: Record<string, string> = {
    active: '正常',
    inactive: '未激活',
    banned: '已禁用',
  }
  return map[status] || '未知'
}

const handleSearch = () => {
  currentPage.value = 1
}

const handleReset = () => {
  searchText.value = ''
  statusFilter.value = ''
  departmentFilter.value = ''
  currentPage.value = 1
}

const handleView = (row: User) => {
  console.log('查看用户:', row)
}

const handleEdit = (row: User) => {
  console.log('编辑用户:', row)
}

const handleDelete = (row: User) => {
  console.log('删除用户:', row)
}

const handleSizeChange = (val: number) => {
  pageSize.value = val
  currentPage.value = 1
}

const handleCurrentChange = (val: number) => {
  currentPage.value = val
}

const formatSalary = (salary: number) => {
  return `¥${salary.toLocaleString()}`
}
</script>

<template>
  <div class="user-list-container">
    <el-card class="search-card">
      <el-form :inline="true" class="search-form">
        <el-form-item label="关键词">
          <el-input
            v-model="searchText"
            placeholder="用户名/姓名/邮箱/手机"
            clearable
            :prefix-icon="Search"
            @keyup.enter="handleSearch"
          />
        </el-form-item>
        <el-form-item label="状态">
          <el-select v-model="statusFilter" placeholder="全部" clearable style="width: 120px">
            <el-option label="正常" value="active" />
            <el-option label="未激活" value="inactive" />
            <el-option label="已禁用" value="banned" />
          </el-select>
        </el-form-item>
        <el-form-item label="部门">
          <el-select v-model="departmentFilter" placeholder="全部" clearable style="width: 120px">
            <el-option v-for="dept in departments" :key="dept" :label="dept" :value="dept" />
          </el-select>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
          <el-button :icon="Refresh" @click="handleReset">重置</el-button>
        </el-form-item>
      </el-form>
    </el-card>

    <el-card class="table-card">
      <el-table
        :data="paginatedUsers"
        v-loading="loading"
        border
        stripe
        highlight-current-row
        style="width: 100%"
        :header-cell-style="{ background: '#f5f7fa', color: '#606266' }"
      >
        <el-table-column type="selection" width="50" fixed="left" />
        <el-table-column prop="id" label="ID" width="70" fixed="left" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.id }}
          </template>
        </el-table-column>
        <el-table-column label="头像" width="80">
          <template #default="{ row, $index }">
            <el-avatar v-if="$index !== 0" :size="40" :src="row.avatar" />
          </template>
        </el-table-column>
        <el-table-column prop="username" label="用户名" width="120" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.username }}
          </template>
        </el-table-column>
        <el-table-column prop="realName" label="姓名" width="100" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.realName }}
          </template>
        </el-table-column>
        <el-table-column prop="gender" label="性别" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : getGenderText(row.gender) }}
          </template>
        </el-table-column>
        <el-table-column prop="age" label="年龄" width="70" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.age }}
          </template>
        </el-table-column>
        <el-table-column prop="phone" label="手机号" width="130">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.phone }}
          </template>
        </el-table-column>
        <el-table-column prop="email" label="邮箱" width="180" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.email }}
          </template>
        </el-table-column>
        <el-table-column prop="department" label="部门" width="100">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.department }}
          </template>
        </el-table-column>
        <el-table-column prop="position" label="职位" width="120">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.position }}
          </template>
        </el-table-column>
        <el-table-column prop="education" label="学历" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.education }}
          </template>
        </el-table-column>
        <el-table-column prop="workYears" label="工龄" width="70" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : `${row.workYears}年` }}
          </template>
        </el-table-column>
        <el-table-column prop="salary" label="薪资" width="100" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : formatSalary(row.salary) }}
          </template>
        </el-table-column>
        <el-table-column prop="status" label="状态" width="100">
          <template #default="{ row, $index }">
            <span v-if="$index === 0">
              {{ '' }}
            </span>
            <el-tag v-else :type="getStatusType(row.status) as any">
              {{ getStatusText(row.status) }}
            </el-tag>
          </template>
        </el-table-column>
        <el-table-column prop="province" label="" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '省份' : row.province }}
          </template>
        </el-table-column>
        <el-table-column prop="city" label="地址" width="80">
          <template #default="{ row, $index }">
            {{ $index === 0 ? '市' : row.city }}
          </template>
        </el-table-column>
        <el-table-column prop="address" label="" width="120" show-overflow-tooltip>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '街道' : row.address }}
          </template>
        </el-table-column>
        <el-table-column prop="registerTime" label="注册时间" width="170" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.registerTime }}
          </template>
        </el-table-column>
        <el-table-column prop="lastLoginTime" label="最后登录" width="170" sortable>
          <template #default="{ row, $index }">
            {{ $index === 0 ? '' : row.lastLoginTime }}
          </template>
        </el-table-column>
        <el-table-column label="操作" width="180" fixed="right">
          <template #default="{ row, $index }">
            <template v-if="$index !== 0">
              <el-button type="primary" link :icon="View" @click="handleView(row)">查看</el-button>
              <el-button type="warning" link :icon="Edit" @click="handleEdit(row)">编辑</el-button>
              <el-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(row)">
                <template #reference>
                  <el-button type="danger" link :icon="Delete">删除</el-button>
                </template>
              </el-popconfirm>
            </template>
          </template>
        </el-table-column>
      </el-table>

      <div class="pagination-container">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="[10, 20, 50, 100]"
          :total="total"
          layout="total, sizes, prev, pager, next, jumper"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </el-card>
  </div>
</template>

<style scoped>
.user-list-container {
  padding: 20px;
}

.search-card {
  margin-bottom: 20px;
}

.search-form {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.table-card {
  width: 100%;
}

.pagination-container {
  display: flex;
  justify-content: flex-end;
  margin-top: 20px;
}

:deep(.el-table__header-wrapper thead tr th:nth-of-type(16)) {
  border-right: 0;
}

:deep(.el-table__header-wrapper thead tr th:nth-of-type(17)) {
  border-right: 0;
}
</style>

陆续更新

如何用 vxe-table 实现粘贴数据自动进入新增行与新增列

2025年12月1日 16:00

如何用 vxe-table 实现粘贴数据自动进入新增行与新增列,数据无限扩充,对于大部分业务操作场景,有时需要从 excel 复制数据并粘贴到表格中,由于粘贴的数据会列多于表格定义的行与列,多出的数据需要能支持自动新增与自行新增列,vxe-table提供非常简单的配置方式可以直接支持。

自动新增行

当粘贴数据时,如果粘贴的行数超过表格的行数,可以通过 clip-config.isRowIncrement 自动新增临时行

table_clip_increment_rows

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const gridOptions = reactive({
  border: true,
  height: 400,
  showOverflow: true,
  keepSource: true,
  columnConfig: {
    resizable: true
  },
  mouseConfig: {
    area: true // 是否开启区域选取
  },
  areaConfig: {
    multiple: true // 是否启用多区域选取功能
  },
  editConfig: {
    mode: 'cell', // 单元格编辑模式
    trigger: 'dblclick', // 双击单元格激活编辑状态
    showStatus: true // 显示数据编辑状态
  },
  keyboardConfig: {
    isClip: true, // 是否开启复制粘贴
    isEdit: true, // 是否开启任意键进入编辑(功能键除外)
    isDel: true, // 是否开启删除键功能
    isEsc: true // 是否开启Esc键关闭编辑功能
  },
  clipConfig: {
    isRowIncrement: true // 如果粘贴的行数超过表格的行数,自动新增临时行
    // createRowsMethod ({ insertRows, pasteCells }) {
    //   console.log(pasteCells)
    //   // 自定义返回新的行数据
    //   return insertRows
    // }
  },
  columns: [
    { type: 'seq', fixed: 'left', width: 60 },
    { field: 'name', fixed: 'left', title: 'name', editRender: { name: 'input' } },
    { field: 'role', title: 'Role', editRender: { name: 'input' } },
    { field: 'sex', title: 'sex', editRender: { name: 'input' } },
    { field: 'num', title: 'Num', editRender: { name: 'input' } },
    { field: 'age', title: 'age', editRender: { name: 'input' } },
    { field: 'address', title: 'Address', width: 200, editRender: { name: 'input' } }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', num: 23, age: 28, address: 'Shengzhen' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', num: 23, age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', num: 23, age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', num: 456, age: 24, address: 'Shanghai' },
    { id: 10005, name: 'Test5', role: 'Designer', sex: 'Women', num: 23, age: 42, address: 'Guangzhou' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: 'Man', num: 23, age: 38, address: 'Shengzhen' },
    { id: 10007, name: 'Test7', role: 'Test', sex: 'Women', num: 100, age: 24, address: 'Shengzhen' },
    { id: 10008, name: 'Test8', role: 'PM', sex: 'Man', num: 345, age: 34, address: 'Shanghai' }
  ]
})
</script>

自动新增列

当粘贴数据时,如果粘贴的列数超过表格的列数时,可以通过 clip-config.isColumnIncrement 自动新增临时列

table_clip_increment_cols

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const gridOptions = reactive({
  border: true,
  height: 400,
  showOverflow: true,
  keepSource: true,
  columnConfig: {
    resizable: true
  },
  mouseConfig: {
    area: true // 是否开启区域选取
  },
  areaConfig: {
    multiple: true // 是否启用多区域选取功能
  },
  editConfig: {
    mode: 'cell', // 单元格编辑模式
    trigger: 'dblclick', // 双击单元格激活编辑状态
    showStatus: true // 显示数据编辑状态
  },
  keyboardConfig: {
    isClip: true, // 是否开启复制粘贴
    isEdit: true, // 是否开启任意键进入编辑(功能键除外)
    isDel: true, // 是否开启删除键功能
    isEsc: true // 是否开启Esc键关闭编辑功能
  },
  clipConfig: {
    isColumnIncrement: true // 如果粘贴的列数超过表格的列数时,自动新增临时列
    // createColumnsMethod ({ insertColumns, pasteCells }) {
    //   console.log(pasteCells)
    //   // 自定义返回新的列配置
    //   return insertColumns
    // }
  },
  columns: [
    { type: 'seq', fixed: 'left', width: 60 },
    { field: 'name', fixed: 'left', title: 'name', editRender: { name: 'input' } },
    { field: 'role', title: 'Role', editRender: { name: 'input' } },
    { field: 'sex', title: 'sex', editRender: { name: 'input' } },
    { field: 'num', title: 'Num', editRender: { name: 'input' } },
    { field: 'age', title: 'age', editRender: { name: 'input' } },
    { field: 'address', title: 'Address', width: 200, editRender: { name: 'input' } }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', num: 23, age: 28, address: 'Shengzhen' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', num: 23, age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', num: 23, age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', num: 456, age: 24, address: 'Shanghai' },
    { id: 10005, name: 'Test5', role: 'Designer', sex: 'Women', num: 23, age: 42, address: 'Guangzhou' },
    { id: 10006, name: 'Test6', role: 'Designer', sex: 'Man', num: 23, age: 38, address: 'Shengzhen' },
    { id: 10007, name: 'Test7', role: 'Test', sex: 'Women', num: 100, age: 24, address: 'Shengzhen' },
    { id: 10008, name: 'Test8', role: 'PM', sex: 'Man', num: 345, age: 34, address: 'Shanghai' }
  ]
})
</script>

vxetable.cn

vue2中transition使用方法解析,包含底部弹窗示例、样式未生效踩坑记录

2025年12月1日 15:26

Vue2中Transition组件的使用方法与实战解析

在Vue2的前端开发中,过渡动画是提升用户体验的核心手段之一。Vue内置的transition组件为元素的插入、更新、移除等DOM操作提供了简洁且可扩展的过渡封装能力,无需手动操作CSS类名或监听DOM事件,即可快速实现流畅的动画效果。本文将从核心原理、使用规则、实战案例三个维度系统讲解transition组件,并结合实际开发中遇到的样式覆盖问题,给出完整的解决方案。

一、Transition组件核心原理与使用规则

1.1 核心工作机制

Vue的transition组件本质是一个“动画控制器”,其核心逻辑是:在包裹的元素触发显隐(或状态变化)时,自动在不同生命周期阶段为元素添加/移除预设的CSS类名,开发者只需通过这些类名定义不同阶段的样式,即可实现过渡动画。

当元素被transition包裹且触发显隐(如v-if/v-show、组件切换)时,Vue会按以下时序执行动画流程:

  1. 进入阶段(Enter):元素插入DOM → 触发进入动画 → 动画完成后移除进入相关类名;
  2. 离开阶段(Leave):元素触发隐藏 → 触发离开动画 → 动画完成后移除DOM(若为v-if)并移除离开相关类名。

1.2 核心CSS类名体系

transition组件的动画类名分为“默认前缀”和“自定义前缀”两类,核心类名及作用如下:

类名类型 进入阶段 离开阶段 核心作用
初始状态 v-enter(Vue2.1.8+为v-enter-from v-leave(Vue2.1.8+为v-leave-from 动画开始前的初始样式,元素插入/移除前瞬间添加,下一帧移除
动画过程 v-enter-active v-leave-active 动画执行过程中的样式,覆盖整个进入/离开阶段,可定义transition/animation属性
结束状态 v-enter-to(Vue2.1.8+新增) v-leave-to(Vue2.1.8+新增) 动画结束时的目标样式,动画开始后立即添加,动画完成后移除

关键说明:

  1. Vue2.1.8版本对类名做了优化,新增-from后缀替代原v-enter/v-leave(原类名仍兼容),使语义更清晰;
  2. 若为transition设置name属性(如name="slide-popup"),类名前缀会从默认的v-替换为自定义前缀(如slide-popup-),可有效避免全局样式冲突;
  3. 所有动画类名仅在动画周期内生效,动画结束后会被自动移除,不会污染元素默认样式。

1.3 基础使用条件

要让transition组件生效,需满足以下基础条件:

  1. 组件仅包裹单个元素/组件(若需包裹多个元素,需使用<transition-group>);
  2. 触发动画的方式需为Vue可检测的DOM变化:
    • 条件渲染:v-if/v-show
    • 组件动态切换:component :is="xxx"
    • 根元素的显隐切换(如路由组件);
  3. 必须通过CSS类名定义动画样式(或结合JavaScript钩子实现JS动画);
  4. 若使用v-show,需确保元素初始display属性不影响动画(如避免display: none直接覆盖过渡效果)。

1.4 过渡类型与配置

transition组件支持两种动画实现方式:

  • CSS过渡(Transition):通过transition CSS属性实现(如transition: all 0.3s ease),也是最常用的方式;
  • CSS动画(Animation):通过animation CSS属性实现(如animation: fade 0.5s linear);

可通过transition组件的属性对动画进行精细化配置:

属性名 作用
name 自定义动画类名前缀,避免样式冲突
duration 统一设置进入/离开动画时长(如:duration="300"),也可分开展开:duration="{ enter: 300, leave: 500 }"
type 指定动画类型(transition/animation),Vue会自动检测动画结束时机
appear 开启初始渲染动画(页面加载时即触发进入动画)
mode 控制进入/离开动画的执行顺序(in-out:先入后出;out-in:先出后入)

二、实战示例:底部弹出弹窗动画

以下实现一个从页面底部平滑弹出/消失的弹窗,完整覆盖transition组件的核心使用场景,并标注关键注意事项。

2.1 完整代码实现

<template>
  <div class="demo-container">
    <!-- 触发按钮 -->
    <button @click="showPopup = !showPopup" class="open-btn">
      打开底部弹窗
    </button>

    <!-- 遮罩层 -->
    <div v-if="showPopup" class="popup-mask" @click="showPopup = false"></div>

    <!-- 过渡包裹弹窗:仅保留自定义name,移除appear属性 -->
    <transition name="slide-popup">
      <div v-if="showPopup" class="popup-container">
        <div class="popup-content">
          <h3>底部弹窗示例</h3>
          <p>基于Vue2 Transition实现的底部弹出动画</p>
          <button @click="showPopup = false" class="close-btn">关闭</button>
        </div>
      </div>
    </transition>
  </div>
</template>

<script>
export default {
  name: 'SlidePopupDemo',
  data() {
    return {
      showPopup: false // 控制弹窗显示/隐藏
    };
  }
};
</script>

<style scoped>
/* 页面容器 */
.demo-container {
  position: relative;
  min-height: 100vh;
}

/* 触发按钮样式 */
.open-btn {
  padding: 8px 16px;
  font-size: 14px;
  cursor: pointer;
  margin: 20px;
  border: 1px solid #409eff;
  border-radius: 4px;
  background: #409eff;
  color: #fff;
  transition: background 0.2s ease;
}

.open-btn:hover {
  background: #66b1ff;
}

/* 遮罩层:半透明背景,点击关闭弹窗 */
.popup-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(0, 0, 0, 0.5);
  z-index: 999;
  transition: opacity 0.3s ease;
}

/* 弹窗容器 - 关键:避免与动画类冲突的样式书写顺序 */
.popup-container {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  background: #fff;
  border-radius: 12px 12px 0 0;
  box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
  z-index: 1000;
  /* 注意:此处若设置transform,需确保动画类在其后定义 */
  /* 错误示例:transform: translateY(0); 会覆盖动画类的transform */
}

.popup-content {
  padding: 30px 20px;
  text-align: center;
}

.popup-content h3 {
  margin: 0 0 10px 0;
  color: #333;
  font-size: 18px;
}

.popup-content p {
  margin: 0 0 20px 0;
  color: #666;
  font-size: 14px;
}

.close-btn {
  padding: 8px 20px;
  font-size: 14px;
  cursor: pointer;
  background: #f5f7fa;
  border: 1px solid #e4e7ed;
  border-radius: 4px;
  color: #666;
  transition: all 0.2s ease;
}

.close-btn:hover {
  background: #e4e7ed;
  color: #333;
}

/* 过渡动画类 - 需写在容器样式之后(核心!) */
/* 进入初始状态:弹窗完全在视口外(底部),透明度0 */
.slide-popup-enter {
  transform: translateY(100%);
  opacity: 0;
}

/* 进入动画过程:定义过渡属性和时长 */
.slide-popup-enter-active {
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}

/* 进入结束状态:弹窗归位,透明度1 */
.slide-popup-enter-to {
  transform: translateY(0);
  opacity: 1;
}

/* 离开初始状态:弹窗在正常位置,透明度1 */
.slide-popup-leave {
  transform: translateY(0);
  opacity: 1;
}

/* 离开动画过程:与进入动画保持一致的过渡曲线 */
.slide-popup-leave-active {
  transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease;
}

/* 离开结束状态:弹窗回到视口外,透明度0 */
.slide-popup-leave-to {
  transform: translateY(100%);
  opacity: 0;
}
</style>

2.2 代码解析

(1)结构层设计
  • transition组件通过name="slide-popup"自定义动画类名前缀,替代默认的v-前缀,避免全局样式冲突(核心实践);
  • 弹窗容器通过v-if="showPopup"控制显隐,触发transition的进入/离开动画(v-if会触发DOM的插入/移除,是transition生效的核心条件);
  • 遮罩层与弹窗联动显隐,点击遮罩层可关闭弹窗,补充交互完整性;
  • 未额外配置appear(贴合实际开发习惯,仅聚焦核心的显隐动画场景)。
(2)样式层设计
  • 弹窗容器popup-container采用fixed定位固定在页面底部,作为动画载体,通过border-radiusbox-shadow优化视觉表现;
  • 动画核心基于slide-popup-enter/slide-popup-leave-to等类名实现:
    • 进入阶段:从transform: translateY(100%)(底部完全出视口)过渡到transform: translateY(0)(归位),配合opacity实现淡入;
    • 离开阶段:从transform: translateY(0)过渡到transform: translateY(100%),配合opacity实现淡出;
  • 过渡曲线使用cubic-bezier自定义缓动函数,相比默认ease更贴合移动端弹窗的弹性交互体验;
  • 所有动画类名必须写在容器样式之后,利用CSS“后定义优先”原则保证动画样式优先级。
(3)逻辑层设计
  • 仅通过showPopup一个布尔值控制弹窗和遮罩层的显隐,逻辑极简且易维护;
  • 触发按钮、关闭按钮、遮罩层绑定同一状态切换逻辑,保证交互行为一致性。

三、踩坑记录:动画类样式不生效问题

3.1 问题现象

按常规思路定义slide-popup-enter/slide-popup-leave-to等动画类后,弹窗显隐无位移动画:

  • 弹窗直接显示/隐藏,无平滑过渡效果;
  • 浏览器开发者工具中,动画类的transform属性被划掉(样式被覆盖);
  • opacity属性生效(无样式冲突),位移动画完全失效。

3.2 根因定位

(1)CSS 优先级核心规则

类选择器权重均为0,1,0时,后定义的样式会覆盖先定义的样式,这是CSS的基础优先级规则。

(2)具体冲突场景

实际开发中错误的样式书写顺序:

/* 错误:先写动画类,后写容器类 */
.slide-popup-enter {
  transform: translateY(100%); /* 先定义,权重相同会被覆盖 */
  opacity: 0;
}
.slide-popup-leave-to {
  transform: translateY(100%);
  opacity: 0;
}

.popup-container {
  position: fixed;
  bottom: 0;
  left: 0;
  width: 100%;
  background: #fff;
  z-index: 1000;
  transform: translateY(0); /* 后定义,直接覆盖动画类的transform */
}

容器类popup-container中transform: translateY(0)后定义,完全覆盖了动画类的transform属性,导致位移动画失效;而opacity无冲突,因此仍能生效。

3.3 解决方案

方案 1:调整样式书写顺序(推荐,符合开发习惯)

将动画类样式书写在容器基础样式之后,利用CSS“后定义优先”的优先级规则,让动画类的样式覆盖容器类中冲突的属性,确保动画相关的样式能够生效,这也是实际开发中最常用、最符合编码习惯的解决方案。

方案 2:移除容器类中的冲突属性(极简方案)

直接删除容器类里和动画类重复定义的属性(如transform),不再让容器样式中存在与动画效果相关的同类型属性,由动画类完全掌控元素的动画属性,从根源上避免样式覆盖的问题,这种方式也能让样式结构更简洁。

方案 3:提高动画类权重(应急方案,不推荐)

通过组合选择器的方式提升动画类的样式权重,以此强制覆盖容器类的冲突属性。但该方式会增加样式的复杂度,不利于后续的维护和调试,仅建议在紧急场景下临时使用,不推荐作为常规解决方案。

3.4 避坑核心总结

  1. 实际开发中使用transition组件时,核心类名就是name-enter/name-enter-active/name-enter-to/name-leave/name-leave-active/name-leave-to,这是最通用、最贴合实际开发的写法;
  2. 动画类样式必须写在元素基础样式之后,这是解决样式覆盖问题的核心原则,也是保证动画生效的关键;
  3. 尽量避免在元素基础样式中定义与动画类重复的属性(如transform、opacity等),从根源上减少样式冲突的可能性;
  4. 调试动画不生效问题时,优先通过浏览器“元素→样式”面板检查动画属性是否被划掉,以此快速定位样式优先级冲突问题。

四、总结

Vue2 transition组件的核心价值是通过name自定义前缀 + 固定的enter/leave类名体系,实现低成本的过渡动画效果,实际开发中需重点关注以下几点:

  1. 掌握核心类名体系:name-enter(进入初始状态)→ name-enter-active(进入动画过程)→ name-enter-to(进入结束状态);name-leave(离开初始状态)→ name-leave-active(离开动画过程)→ name-leave-to(离开结束状态),这是最贴合实际开发的写法;
  2. 重视样式优先级:动画类务必书写在元素基础样式之后,利用CSS“后定义优先”的原则保证动画样式生效;
  3. 规避样式冲突:不重复定义动画相关属性,从根源上减少样式覆盖的风险;
  4. 优化交互体验:结合cubic-bezier自定义缓动函数,让动画效果更符合实际产品的交互质感。

transition是Vue2中实现单元素过渡动画的最优方案,掌握上述规则可解决绝大多数动画不生效的问题,同时能保证代码的可维护性和交互体验。

【Vite】离线打包@iconify/vue的图标

作者 七月十二
2025年12月1日 11:58

【Vite】离线打包@iconify/vue的图标

所需库@iconify/vue@iconify/types@iconify/json

需要创建或修改3个文件,

  1. 图标检索脚本
  2. @iconify/vue引入
  3. package.json文件

图标检索脚本

scripts\extract-icons.cjs

// scripts/extract-icons.cjs
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs')
const path = require('path')

// ============ 配置区域 ============
const CONFIG = {
  srcDir: 'src',
  outputPath: 'src/assets/icons-slim/icons.json',
  iconifyJsonDir: 'node_modules/@iconify/json/json',
  extensions: ['.vue', '.ts', '.tsx', '.js', '.jsx']
}

// Tailwind CSS 响应式/状态前缀黑名单
/* prettier-ignore */
const TAILWIND_PREFIXES = new Set([
  'sm', 'md', 'lg', 'xl', '2xl', // 响应式断点
  'hover', 'focus', 'active', 'disabled', 'visited', 'checked', 'indeterminate',  // 状态变体
  'focus-within', 'focus-visible', 'group-hover', 'group-focus', 'peer-hover', 'peer-focus',
  'before', 'after', 'first-letter', 'first-line', 'marker', 'selection', 'file', 'placeholder', // 伪元素
  'first', 'last', 'only', 'odd', 'even', 'first-of-type', 'last-of-type', 'only-of-type',  // 伪类
  'empty', 'enabled', 'default', 'required', 'valid', 'invalid', 'in-range', 'out-of-range',
  'read-only', 'read-write', 'open',
  'dark', 'light',  // 深色模式
  'ltr', 'rtl',  // 方向
  'print',  // 打印
  'motion-safe', 'motion-reduce',  // 运动
  'contrast-more', 'contrast-less',  // 对比度
  'not', 'has', 'is', 'where', 'supports', 'aria', 'data'  // 其他常见
])

// 其他需要排除的前缀
/* prettier-ignore */
const EXCLUDED_PREFIXES = new Set([
  'http', 'https', 'ftp', 'mailto', 'tel', 'data', 'file', 'ws', 'wss', // 协议
  'v-bind', 'v-on', 'v-if', 'v-else', 'v-for', 'v-show', 'v-model', 'v-slot', // Vue 指令
  'nth-child', 'nth-of-type', 'nth-last-child', 'nth-last-of-type', // CSS 相关
  'type', 'lang', 'scoped', 'module', 'setup', 'name', 'key', 'ref', 'class', 'style' // 常见误匹配
])

// ============ 工具函数 ============
function scanFiles(dir, fileList = []) {
  for (const file of fs.readdirSync(dir)) {
    const filePath = path.join(dir, file)
    const stat = fs.statSync(filePath)

    if (stat.isDirectory()) {
      if (!['node_modules', '.git', 'dist', '.nuxt', '.output'].includes(file)) {
        scanFiles(filePath, fileList)
      }
    } else if (CONFIG.extensions.includes(path.extname(file))) {
      fileList.push(filePath)
    }
  }
  return fileList
}

function preprocessContent(content, filePath) {
  if (filePath.endsWith('.vue')) {
    content = content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
  }
  return content
    .replace(/\/\/.*$/gm, '')
    .replace(/\/\*[\s\S]*?\*\//g, '')
    .replace(/<!--[\s\S]*?-->/g, '')
}

function isValidIcon(prefix, name) {
  const lowerPrefix = prefix.toLowerCase()
  if (TAILWIND_PREFIXES.has(lowerPrefix) || EXCLUDED_PREFIXES.has(lowerPrefix)) return false
  if (/^\d/.test(prefix) || prefix.length < 2) return false
  if (name.includes('$') || name.includes('{') || name.length < 2) return false
  if (/^\d+$/.test(name)) return false

  const cssValues = ['none', 'auto', 'inherit', 'initial', 'unset', 'block', 'inline', 'flex', 'grid', 'hidden', 'visible']
  return !cssValues.includes(name.toLowerCase())
}

// ============ 主逻辑 ============
function extractIcons() {
  console.log('🔍 开始扫描项目中使用的图标...\n')

  const files = scanFiles(path.resolve(process.cwd(), CONFIG.srcDir))
  console.log(`📁 找到 ${files.length} 个文件需要扫描`)

  const iconPatterns = [
    /\bicon\s*=\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi,
    /:icon\s*=\s*["'`]['"`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)['"`]["'`]/gi,
    /\bicon\s*:\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi,
    /(?:addIcon|getIcon|loadIcon)\s*\(\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi,
    /\bname\s*=\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi
  ]

  // 扫描收集图标
  const iconsByPrefix = new Map()

  for (const file of files) {
    const content = preprocessContent(fs.readFileSync(file, 'utf-8'), file)

    for (const pattern of iconPatterns) {
      pattern.lastIndex = 0
      let match
      while ((match = pattern.exec(content)) !== null) {
        const [, prefix, iconName] = match
        if (isValidIcon(prefix, iconName)) {
          if (!iconsByPrefix.has(prefix.toLowerCase())) {
            iconsByPrefix.set(prefix.toLowerCase(), new Set())
          }
          iconsByPrefix.get(prefix.toLowerCase()).add(iconName.toLowerCase())
        }
      }
    }
  }

  // 验证并提取图标
  const iconifyJsonDir = path.resolve(process.cwd(), CONFIG.iconifyJsonDir)
  const outputCollections = []
  const collectionStats = [] // 记录每个图标集的统计信息
  const missingIcons = []
  let totalFound = 0

  for (const [prefix, iconNames] of iconsByPrefix) {
    const jsonPath = path.join(iconifyJsonDir, `${prefix}.json`)
    if (!fs.existsSync(jsonPath)) continue

    const fullCollection = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
    const slimCollection = {
      prefix: fullCollection.prefix,
      icons: {},
      ...(fullCollection.width && { width: fullCollection.width }),
      ...(fullCollection.height && { height: fullCollection.height })
    }

    let foundInCollection = 0

    for (const iconName of iconNames) {
      if (fullCollection.icons[iconName]) {
        slimCollection.icons[iconName] = fullCollection.icons[iconName]
        totalFound++
        foundInCollection++
      } else if (fullCollection.aliases?.[iconName]) {
        slimCollection.aliases = slimCollection.aliases || {}
        slimCollection.aliases[iconName] = fullCollection.aliases[iconName]
        const parentName = fullCollection.aliases[iconName].parent
        if (fullCollection.icons[parentName]) {
          slimCollection.icons[parentName] = fullCollection.icons[parentName]
        }
        totalFound++
        foundInCollection++
      } else {
        missingIcons.push(`${prefix}:${iconName}`)
      }
    }

    if (Object.keys(slimCollection.icons).length > 0) {
      outputCollections.push(slimCollection)
      collectionStats.push({ prefix, count: foundInCollection })
    }
  }

  // 写入文件
  const outputPath = path.resolve(process.cwd(), CONFIG.outputPath)
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  fs.writeFileSync(outputPath, JSON.stringify(outputCollections, null, 2))

  // 输出报告
  const slimSize = fs.statSync(outputPath).size
  console.log('\n' + '='.repeat(50))
  console.log('📊 提取报告')
  console.log('='.repeat(50))
  console.log(`✅ 成功提取: ${totalFound} 个图标,来自 ${outputCollections.length} 个图标集\n`)

  // 打印图标集详情
  console.log('📦 图标集详情:')
  collectionStats
    .sort((a, b) => b.count - a.count)
    .forEach(({ prefix, count }) => {
      console.log(`   - ${prefix}: ${count} 个图标`)
    })

  if (missingIcons.length > 0) {
    console.log(`\n⚠️  未找到的图标: ${missingIcons.length} 个`)
    missingIcons.slice(0, 10).forEach(name => console.log(`   - ${name}`))
    if (missingIcons.length > 10) console.log(`   ... 还有 ${missingIcons.length - 10} 个`)
  }

  console.log(`\n📦 精简图标集大小: ${(slimSize / 1024).toFixed(2)} KB`)
  console.log(`✅ 已生成: ${CONFIG.outputPath}`)
}

extractIcons()

@iconify/vue引入离线的icon

src\plugins\iconify.ts

// plugins/iconify.ts
import type { App } from 'vue'
import { Icon, addCollection } from '@iconify/vue'
import type { IconifyJSON } from '@iconify/types'
import iconCollections from '@/assets/icons-slim/icons.json'

export default {
  install(app: App) {
    const icons = iconCollections as any as IconifyJSON[]
    for (const collection of icons) {
      addCollection(collection)
    }
    app.component('Icon', Icon)
  }
}

注:在main.ts中这样用

import { createApp } from 'vue'
import App from './App.vue'
import installIconify from '@/plugins/iconify'

const app = createApp(App)

// 注册iconify图标组件
app.use(installIconify)

app.mount('#app')

执行脚本

package.json

{
  "name": "smart-community-h5",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "extract-icons": "node scripts/extract-icons.cjs",
    "dev": "vite",
    "serve": "vite",
    "build": "vite build",
    "build:shegong": "vite build --mode shegong",
    "preview": "vite preview",
    "lint": "eslint --ext .ts,.vue src",
    "lint:fix": "eslint --ext .ts,.vue src --fix"
  },
  "dependencies": {
    "@antv/g6": "5.0.49",
    "@iconify/vue": "5.0.0",
    "@microsoft/fetch-event-source": "2.0.1",
    "axios": "1.11.0",
    "dayjs": "1.11.18",
    "echarts": "6.0.0",
    "js-cookie": "3.0.5",
    "mitt": "^3.0.1",
    "naive-ui": "2.42.0",
    "nanoid": "5.1.6",
    "nprogress": "0.2.0",
    "pdfjs-dist": "5.4.296",
    "pinia": "^3.0.3",
    "tailwindcss-palette-generator": "1.6.1",
    "vue": "^3.5.18",
    "vue-i18n": "11.1.11",
    "vue-router": "^4.5.1",
    "vue3-autocounter": "1.0.8",
    "ys-md-rendering": "0.2.4"
  },
  "devDependencies": {
    "@eslint/eslintrc": "3.3.1",
    "@eslint/js": "9.34.0",
    "@iconify/json": "2.2.412",
    "@iconify/types": "2.0.0",
    "@tailwindcss/vite": "^4.1.12",
    "@types/js-cookie": "3.0.6",
    "@types/node": "24.3.0",
    "@types/nprogress": "0.2.3",
    "@typescript-eslint/eslint-plugin": "8.40.0",
    "@typescript-eslint/parser": "8.40.0",
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/tsconfig": "^0.7.0",
    "eslint": "9.33.0",
    "eslint-config-prettier": "10.1.8",
    "eslint-plugin-import": "2.32.0",
    "eslint-plugin-prettier": "5.5.4",
    "eslint-plugin-vue": "10.4.0",
    "prettier": "^3.6.2",
    "prettier-plugin-tailwindcss": "^0.6.14",
    "sass": "1.90.0",
    "tailwindcss": "^4.1.12",
    "typescript": "~5.8.3",
    "typescript-eslint": "8.41.0",
    "unplugin-auto-import": "20.0.0",
    "unplugin-icons": "22.3.0",
    "unplugin-vue-components": "29.0.0",
    "vite": "^7.1.2",
    "vite-svg-loader": "5.1.0",
    "vue-eslint-parser": "10.2.0",
    "vue-tsc": "^3.0.5"
  }
}

通过pnpm run extract-icons检索项目中的所有icon

当然也可以通过"build": "npm run extract-icons && vite build"的方式,在每次build前都打包

生成的json示例效果如下

src\assets\icons-slim\icons.json

[
  {
    "prefix": "material-symbols",
    "icons": {
      "check-rounded": {
        "body": "<path fill=\"currentColor\" d=\"m9.55 15.15l8.475-8.475q.3-.3.7-.3t.7.3t.3.713t-.3.712l-9.175 9.2q-.3.3-.7.3t-.7-.3L4.55 13q-.3-.3-.288-.712t.313-.713t.713-.3t.712.3z\"/>"
      },
      "search": {
        "body": "<path fill=\"currentColor\" d=\"m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14\"/>"
      },
      "check": {
        "body": "<path fill=\"currentColor\" d=\"m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z\"/>"
      },
      "description": {
        "body": "<path fill=\"currentColor\" d=\"M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z\"/>"
      },
      "open-in-new": {
        "body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h14v-7h2v7q0 .825-.587 1.413T19 21zm4.7-5.3l-1.4-1.4L17.6 5H14V3h7v7h-2V6.4z\"/>"
      },
      "add-comment-outline": {
        "body": "<path fill=\"currentColor\" d=\"M11 14h2v-3h3V9h-3V6h-2v3H8v2h3zm-9 8V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18H6zm3.15-6H20V4H4v13.125zM4 16V4z\"/>"
      },
      "chevron-left": {
        "body": "<path fill=\"currentColor\" d=\"m14 18l-6-6l6-6l1.4 1.4l-4.6 4.6l4.6 4.6z\"/>"
      }
    },
    "width": 24,
    "height": 24
  },
  {
    "prefix": "ep",
    "icons": {
      "right": {
        "body": "<path fill=\"currentColor\" d=\"M754.752 480H160a32 32 0 1 0 0 64h594.752L521.344 777.344a32 32 0 0 0 45.312 45.312l288-288a32 32 0 0 0 0-45.312l-288-288a32 32 0 1 0-45.312 45.312z\"/>"
      }
    },
    "width": 1024,
    "height": 1024
  },
  {
    "prefix": "bxs",
    "icons": {
      "right-arrow": {
        "body": "<path fill=\"currentColor\" d=\"M5.536 21.886a1 1 0 0 0 1.033-.064l13-9a1 1 0 0 0 0-1.644l-13-9A1 1 0 0 0 5 3v18a1 1 0 0 0 .536.886\"/>"
      }
    },
    "width": 24,
    "height": 24
  },
  {
    "prefix": "icon-park-solid",
    "icons": {
      "down-one": {
        "body": "<path fill=\"currentColor\" stroke=\"currentColor\" stroke-linejoin=\"round\" stroke-width=\"4\" d=\"M36 19L24 31L12 19z\"/>"
      }
    },
    "width": 48,
    "height": 48
  },
  {
    "prefix": "teenyicons",
    "icons": {
      "right-outline": {
        "body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"square\" d=\"m5 14l7-6.5L5 1\"/>"
      }
    },
    "width": 15,
    "height": 15
  }
]

全栈项目:校友论坛系统

作者 温暖全栈
2025年12月1日 11:29

基于 Vue 3 + Node.js + MongoDB的全栈校园论坛系统

1. 项目概述

随着互联网技术的快速发展和社交网络的普及,校友之间的交流与联系变得越来越重要。传统的校友联系方式存在信息分散、交流不便等问题,难以满足现代校友群体的需求。为了解决这些问题,本文设计并实现了一个基于 Web 技术的校友论坛系统。

本系统采用前后端分离的架构模式,前端使用 Vue 3 框架结合 TypeScript 和 Element Plus 组件库构建用户界面,后端使用 Node.js 的 Koa 框架提供 RESTful API 服务,数据存储采用 MongoDB 非关系型数据库。系统实现了用户注册登录、帖子发布与浏览、评论互动、私信交流、公告管理等核心功能,并提供了完善的管理后台用于内容管理和数据统计。

2. 项目截图

用户端:

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png

image.png 后台管理:

image.pngimage.png

image.png

image.png

image.png

3.功能需求

3.1 用户角色分析

系统设计了两种用户角色:

1. 普通用户

普通用户是系统的主要使用者,包括在校学生和已毕业校友。其主要需求包括:

  • 注册账号并完善个人信息
  • 浏览和搜索帖子内容
  • 发布帖子分享经验和求助
  • 评论和点赞帖子
  • 与其他用户私信交流
  • 查看系统公告

2. 管理员

管理员负责系统的日常管理和维护,其主要需求包括:

  • 管理用户账号(查看、禁用、启用)
  • 管理帖子内容(删除违规内容、置顶重要帖子)
  • 管理评论(删除不当评论)
  • 发布和管理系统公告
  • 查看系统数据统计

3.2 功能模块划分

根据用户需求,系统划分为以下功能模块:

1. 用户管理模块

该模块负责用户相关的所有功能:

  • 用户注册:新用户通过邮箱注册账号,填写基本信息
  • 用户登录:已注册用户通过邮箱和密码登录系统
  • 个人资料管理:用户可以查看和编辑个人信息,包括用户名、学校、专业、毕业年份、职业、个人简介等
  • 密码管理:用户可以修改登录密码
  • 用户主页:展示用户的基本信息和发帖历史

2. 帖子管理模块

该模块是系统的核心功能:

  • 帖子发布:用户可以发布新帖子,包括标题、内容、分类和标签
  • 帖子浏览:用户可以浏览帖子列表,支持按分类筛选和排序
  • 帖子详情:查看帖子的完整内容、作者信息、评论列表
  • 帖子搜索:通过关键词搜索相关帖子
  • 帖子编辑:作者可以编辑自己发布的帖子
  • 帖子删除:作者和管理员可以删除帖子
  • 帖子点赞:用户可以对帖子进行点赞或取消点赞

帖子分类包括:

  • 求助:寻求帮助和建议
  • 分享:分享经验和知识
  • 求职:求职信息和职业发展
  • 学习:学习资源和心得
  • 创业:创业经验和项目
  • 生活:生活话题和闲聊

3. 评论管理模块

该模块支持用户对帖子进行评论互动:

  • 发表评论:用户可以对帖子发表评论
  • 评论点赞:用户可以对评论进行点赞
  • 删除评论:评论作者、帖子作者和管理员可以删除评论
  • 评论列表:按时间顺序展示帖子的所有评论

4. 私信模块

该模块支持用户之间的私密交流:

  • 发送私信:用户可以向其他用户发送私信
  • 会话列表:展示所有私信会话,显示未读消息数量
  • 消息详情:查看与某个用户的完整对话历史
  • 消息已读:自动标记已读消息

5. 公告模块

该模块用于发布系统公告和重要通知:

  • 公告展示:在首页顶部展示活跃的公告
  • 公告管理:管理员可以创建、编辑、删除公告
  • 公告分类:系统、活动、维护、重要等类型
  • 公告优先级:支持设置公告的优先级
  • 公告过期:支持设置公告的过期时间

6. 管理后台模块

该模块为管理员提供系统管理功能:

  • 用户管理:查看用户列表,搜索用户,禁用/启用用户账号
  • 内容管理:查看所有帖子,删除违规内容,置顶重要帖子
  • 评论管理:查看所有评论,删除不当评论
  • 公告管理:创建、编辑、删除公告
  • 数据统计:展示用户数、帖子数、评论数等统计数据

4. 系统架构设计

4.1 总体架构

本系统采用前后端分离的架构模式,将系统分为三层:表现层、业务逻辑层和数据访问层。系统总体架构如图 3-1 所示。

┌─────────────────────────────────────────┐
│           表现层(前端)                  │
│  ┌─────────────────────────────────┐   │
│  │  Vue 3 + TypeScript + Vite      │   │
│  │  ├─ 路由管理(Vue Router)       │   │
│  │  ├─ 状态管理(Pinia)            │   │
│  │  ├─ UI 组件(Element Plus)      │   │
│  │  └─ HTTP 客户端(Axios)         │   │
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘
                    ↕ HTTP/HTTPS
┌─────────────────────────────────────────┐
│        业务逻辑层(后端)                 │
│  ┌─────────────────────────────────┐   │
│  │  Node.js + Koa                  │   │
│  │  ├─ 路由层(Koa Router)         │   │
│  │  ├─ 中间件层(认证、日志等)      │   │
│  │  ├─ 业务逻辑层(Controllers)    │   │
│  │  └─ 数据访问层(Mongoose)       │   │
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘
                    ↕
┌─────────────────────────────────────────┐
│         数据访问层(数据库)              │
│  ┌─────────────────────────────────┐   │
│  │  MongoDB                        │   │
│  │  ├─ users(用户集合)            │   │
│  │  ├─ posts(帖子集合)            │   │
│  │  ├─ comments(评论集合)         │   │
│  │  ├─ messages(消息集合)         │   │
│  │  └─ announcements(公告集合)    │   │
│  └─────────────────────────────────┘   │
└─────────────────────────────────────────┘

架构特点

  1. 前后端分离:前端和后端独立开发、部署和维护
  2. RESTful API:前后端通过标准的 RESTful API 进行通信
  3. 模块化设计:各层职责明确,模块之间低耦合高内聚
  4. 可扩展性强:便于功能扩展和技术升级

4.2 前端架构

前端采用 Vue 3 框架,结合 TypeScript 和 Element Plus 构建单页应用(SPA)。前端架构如图 3-2 所示。

src/
├── api/              # API 接口封装
│   ├── index.ts      # Axios 实例配置
│   ├── auth.ts       # 认证相关接口
│   ├── posts.ts      # 帖子相关接口
│   └── ...
├── assets/           # 静态资源
├── components/       # 通用组件
│   ├── Header.vue    # 头部组件
│   ├── Footer.vue    # 底部组件
│   └── ...
├── router/           # 路由配置
│   └── index.ts      # 路由定义和守卫
├── stores/           # 状态管理
│   ├── auth.ts       # 用户认证状态
│   └── ...
├── types/            # TypeScript 类型定义
│   └── index.ts      # 全局类型
├── utils/            # 工具函数
│   └── index.ts      # 通用工具
├── views/            # 页面组件
│   ├── Home.vue      # 首页
│   ├── Login.vue     # 登录页
│   ├── Posts.vue     # 帖子列表
│   └── ...
├── App.vue           # 根组件
└── main.ts           # 应用入口

关键技术

  1. Vue Router:实现单页应用的路由管理
  2. Pinia:轻量级的状态管理库
  3. Axios:HTTP 客户端,封装 API 请求
  4. TypeScript:提供类型安全和更好的开发体验

4.3 后端架构

后端采用 Node.js 的 Koa 框架,使用 Mongoose 进行数据库操作。后端架构如图 3-3 所示。

server/
├── src/
│   ├── config/           # 配置文件
│   │   └── database.js   # 数据库配置
│   ├── middleware/       # 中间件
│   │   └── auth.js       # 认证中间件
│   ├── models/           # 数据模型
│   │   ├── User.js       # 用户模型
│   │   ├── Post.js       # 帖子模型
│   │   └── ...
│   ├── routes/           # 路由处理
│   │   ├── auth.js       # 认证路由
│   │   ├── posts.js      # 帖子路由
│   │   └── ...
│   └── app.js            # 应用入口
├── scripts/              # 脚本文件
│   └── import-data.js    # 数据导入脚本
└── .env                  # 环境变量

关键技术

  1. Koa:轻量级的 Web 框架
  2. Koa Router:路由管理
  3. Mongoose:MongoDB ODM
  4. JWT:身份认证
  5. bcryptjs:密码加密

4.4 数据库设计

数据库选型

本系统选择 MongoDB 作为数据库,主要基于以下考虑:

  1. 灵活的数据模型:MongoDB 使用文档模型,适合存储非结构化数据
  2. 易于扩展:支持水平扩展,便于应对数据增长
  3. 开发效率高:与 JavaScript 对象模型匹配,开发便捷
  4. 性能优秀:读写性能好,适合高并发场景

数据模型设计

1. 用户(User)集合

{
  _id: ObjectId,              // 用户ID
  username: String,           // 用户名(唯一)
  email: String,              // 邮箱(唯一)
  password: String,           // 密码(加密)
  avatar: String,             // 头像URL
  role: String,               // 角色(user/admin)
  school: String,             // 学校
  major: String,              // 专业
  graduationYear: Number,     // 毕业年份
  profession: String,         // 职业
  bio: String,                // 个人简介
  points: Number,             // 积分
  isVerified: Boolean,        // 是否认证
  lastLoginAt: Date,          // 最后登录时间
  isActive: Boolean,          // 是否激活
  createdAt: Date,            // 创建时间
  updatedAt: Date             // 更新时间
}

2. 帖子(Post)集合

{
  _id: ObjectId,              // 帖子ID
  title: String,              // 标题
  content: String,            // 内容
  category: String,           // 分类
  tags: [String],             // 标签数组
  author: ObjectId,           // 作者ID(引用User)
  likes: [{                   // 点赞列表
    user: ObjectId,           // 用户ID
    createdAt: Date           // 点赞时间
  }],
  views: Number,              // 浏览量
  comments: [ObjectId],       // 评论ID数组
  isPinned: Boolean,          // 是否置顶
  isActive: Boolean,          // 是否激活
  isDeleted: Boolean,         // 是否删除
  deletedAt: Date,            // 删除时间
  deletedBy: ObjectId,        // 删除者ID
  createdAt: Date,            // 创建时间
  updatedAt: Date             // 更新时间
}

3. 评论(Comment)集合

{
  _id: ObjectId,              // 评论ID
  content: String,            // 内容
  author: ObjectId,           // 作者ID(引用User)
  post: ObjectId,             // 帖子ID(引用Post)
  likes: [{                   // 点赞列表
    user: ObjectId,           // 用户ID
    createdAt: Date           // 点赞时间
  }],
  parentComment: ObjectId,    // 父评论ID(用于回复)
  replies: [ObjectId],        // 回复ID数组
  isActive: Boolean,          // 是否激活
  isDeleted: Boolean,         // 是否删除
  deletedAt: Date,            // 删除时间
  deletedBy: ObjectId,        // 删除者ID
  createdAt: Date,            // 创建时间
  updatedAt: Date             // 更新时间
}

4. 消息(Message)集合

{
  _id: ObjectId,              // 消息ID
  sender: ObjectId,           // 发送者ID(引用User)
  receiver: ObjectId,         // 接收者ID(引用User)
  content: String,            // 内容
  isRead: Boolean,            // 是否已读
  createdAt: Date,            // 创建时间
  updatedAt: Date             // 更新时间
}

5. 公告(Announcement)集合

{
  _id: ObjectId,              // 公告ID
  title: String,              // 标题
  content: String,            // 内容
  type: String,               // 类型(system/activity/maintenance/important)
  author: ObjectId,           // 作者ID(引用User)
  priority: Number,           // 优先级
  expiresAt: Date,            // 过期时间
  viewCount: Number,          // 浏览量
  isActive: Boolean,          // 是否激活
  createdAt: Date,            // 创建时间
  updatedAt: Date             // 更新时间
}

5. 技术知识点文档

5.1 前端技术栈

5.1.1 Vue 3

Composition API

  • setup 函数: 组件的入口点,替代 Options API
  • 响应式 API:
    • ref(): 创建响应式引用,适用于基本类型
    • reactive(): 创建响应式对象,适用于对象类型
    • computed(): 创建计算属性
    • watch(): 监听响应式数据变化
  • 生命周期钩子:
    • onMounted(): 组件挂载后执行
    • onUnmounted(): 组件卸载前执行
    • onBeforeMount(): 组件挂载前执行

Vue Router 4

  • 路由配置: 定义应用的路由规则
  • 路由守卫:
    • 全局前置守卫 beforeEach: 用于权限验证
    • 路由独享守卫: 特定路由的守卫
  • 编程式导航:
    • router.push(): 跳转到新路由
    • router.replace(): 替换当前路由
    • router.back(): 返回上一页
  • 动态路由: 使用参数匹配路由,如 /posts/:id

Pinia 状态管理

  • Store 定义: 使用 defineStore 创建状态仓库
  • State: 存储应用状态
  • Getters: 计算派生状态
  • Actions: 定义修改状态的方法
  • 持久化: 使用 localStorage 持久化用户信息和 Token
5.1.2 TypeScript

类型系统

  • 基础类型: string, number, boolean, array, object
  • 接口定义: 定义对象的结构
  • 类型别名: 使用 type 定义复杂类型
  • 泛型: 创建可重用的组件
  • 类型断言: 手动指定值的类型

在 Vue 中使用 TypeScript

  • Props 类型定义: 使用 defineProps<T>()
  • Emit 类型定义: 使用 defineEmits<T>()
  • Ref 类型: Ref<T>, ComputedRef<T>
  • 组件实例类型: ComponentPublicInstance
5.1.3 Element Plus

组件库

  • 表单组件: el-form, el-input, el-select, el-button
  • 数据展示: el-table, el-card, el-tag, el-avatar
  • 反馈组件: el-message, el-dialog, el-loading
  • 导航组件: el-menu, el-tabs, el-pagination
  • 布局组件: el-container, el-row, el-col
5.1.4 Axios

HTTP 客户端

  • 实例创建: 创建自定义 axios 实例
  • 请求拦截器: 在请求发送前添加 Token
  • 响应拦截器: 统一处理响应和错误
  • 错误处理: 根据状态码显示不同错误信息

API 封装

  • 模块化: 按功能模块划分 API
  • 类型安全: 使用 TypeScript 定义请求和响应类型
  • 统一格式: 保持 API 调用的一致性

5.2 后端技术栈

5.2.1 Koa 框架
  • 中间件: 洋葱模型的中间件机制
  • Context 对象: 封装 request 和 response
  • 错误处理: 统一的错误处理机制
  • 异步流程: 基于 async/await
5.2.2 常用中间件
  • koa-router: 路由管理
  • koa-bodyparser: 解析请求体
  • koa-cors: 处理跨域请求
  • 自定义中间件: 认证、日志等

5.3 数据库:MongoDB

5.3.1 NoSQL 数据库
  • 文档存储: 使用 JSON 格式存储数据
  • 集合: 类似关系型数据库的表
  • 灵活的模式: 无需预定义结构
  • 索引: 提高查询性能
5.3.2 Mongoose ODM
  • Schema 定义: 定义数据模型结构
  • 模型方法: 实例方法和静态方法
  • 中间件: pre/post 钩子
  • 验证: 数据验证规则
  • 关联: 使用 ref 建立文档关联
  • 查询构建器: 链式查询 API
5.3.3 常用操作
  • CRUD: Create, Read, Update, Delete
  • 查询: find, findOne, findById
  • 更新: updateOne, updateMany, findByIdAndUpdate
  • 删除: deleteOne, deleteMany, findByIdAndDelete
  • 聚合: aggregate 管道操作

6. 系统详细设计与实现

6.1 前端实现

前端项目结构

本系统前端采用 Vue3 + TypeScript + Vite 技术栈,项目结构如下:

src/
  ├── api/           // 封装所有后端接口请求
  ├── assets/        // 静态资源
  ├── components/    // 公共组件
  ├── layouts/       // 页面布局
  ├── router/        // 路由配置
  ├── stores/        // 状态管理
  ├── styles/        // 全局样式
  ├── types/         // 类型定义
  ├── views/         // 业务页面
  └── main.ts        // 入口文件

路由与页面设计

前端采用 Vue Router 实现页面路由管理,主要路由包括:首页、登录、注册、帖子列表、帖子详情、个人中心、消息中心、后台管理等。部分路由配置示例:

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
// ... 其他页面

const routes = [
  { path: '/', component: Home },
  { path: '/login', component: Login },
  { path: '/register', component: Register },
  // ... 其他路由
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

export default router

状态管理 本系统采用 Pinia 进行全局状态管理,主要管理用户信息、帖子列表、消息通知等。以用户认证为例:

// src/stores/auth.ts
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    token: '',
    user: null,
  }),
  actions: {
    setToken(token: string) {
      this.token = token
    },
    setUser(user: any) {
      this.user = user
    },
    logout() {
      this.token = ''
      this.user = null
    }
  }
})

API 封装

所有后端接口请求统一封装在 src/api/ 目录下,便于维护和复用。例如用户登录接口:

// src/api/auth.ts
import axios from 'axios'

export function login(data: { username: string, password: string }) {
  return axios.post('/api/auth/login', data)
}

主要页面与组件实现

  • 首页(Home.vue):展示最新帖子、热门标签、公告等
  • 帖子列表(Posts.vue):分页展示所有帖子,支持分类、标签筛选
  • 帖子详情(PostDetail.vue):展示帖子内容、评论列表、评论输入框
  • 登录/注册(Login.vue/Register.vue):表单校验、接口对接
  • 个人中心(Profile.vue):展示和编辑个人信息
  • 消息中心(Messages.vue):展示系统消息、私信
  • 后台管理(admin/):管理员专属页面,管理用户、帖子、举报等

6.2 后端实现

后端项目结构

后端采用 Node.js + Express + MongoDB 技术栈,项目结构如下:

server/
  ├── src/
      ├── app.js            // 应用入口
      ├── config/           // 配置文件
      ├── middleware/       // 中间件
      ├── models/           // 数据模型
      ├── routes/           // 路由
      └── ...               // 其他

路由与中间件

后端采用 Express 路由机制,将不同功能模块的接口分离管理。例如用户认证路由:

// server/src/routes/auth.js
const express = require('express')
const router = express.Router()
const { login, register } = require('../controllers/authController')

router.post('/login', login)
router.post('/register', register)

module.exports = router

中间件用于处理请求认证、错误处理、日志记录等。例如 JWT 认证中间件:

// server/src/middleware/auth.js
const jwt = require('jsonwebtoken')

module.exports = function (req, res, next) {
  const token = req.headers['authorization']
  if (!token) return res.status(401).json({ message: '未登录' })
  try {
    const decoded = jwt.verify(token, 'your_jwt_secret')
    req.user = decoded
    next()
  } catch (err) {
    res.status(401).json({ message: 'Token无效' })
  }
}

数据模型设计

后端采用 Mongoose 定义数据模型。例如用户模型:

评论与互动

  1. 用户可对帖子进行评论,评论可被回复
  2. 评论数据与帖子关联,支持评论点赞、删除
  3. 评论成功后,系统自动生成通知消息

消息通知

  1. 用户收到评论、回复、私信等事件时,后端生成消息记录
  2. 前端定时拉取或通过 WebSocket 获取新消息
  3. 用户可在消息中心查看、标记已读

举报与后台管理

  1. 用户可对违规内容进行举报,填写举报原因
  2. 后端存储举报信息,管理员后台可查看、处理举报
  3. 管理员可对用户、帖子、评论等进行管理操作

6.3 代码实现示例

6.1 用户注册接口(后端)
// server/src/controllers/authController.js
const User = require('../models/User')
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')

exports.register = async (req, res) => {
  const { username, password, email } = req.body
  const user = await User.findOne({ username })
  if (user) return res.status(400).json({ message: '用户名已存在' })
  const hash = await bcrypt.hash(password, 10)
  const newUser = new User({ username, password: hash, email })
  await newUser.save()
  res.json({ message: '注册成功' })
}
6.2 用户登录接口(后端)
exports.login = async (req, res) => {
  const { username, password } = req.body
  const user = await User.findOne({ username })
  if (!user) return res.status(400).json({ message: '用户不存在' })
  const isMatch = await bcrypt.compare(password, user.password)
  if (!isMatch) return res.status(400).json({ message: '密码错误' })
  const token = jwt.sign({ id: user._id, username: user.username }, 'your_jwt_secret', { expiresIn: '7d' })
  res.json({ token, user })
}
6.3 帖子发布接口(后端)
// server/src/controllers/postController.js
const Post = require('../models/Post')

exports.createPost = async (req, res) => {
  const { title, content, category, tags } = req.body
  const post = new Post({
    author: req.user.id,
    title,
    content,
    category,
    tags,
    createdAt: new Date()
  })
  await post.save()
  res.json({ message: '帖子发布成功', post })
}
❌
❌