阅读视图

发现新文章,点击刷新页面。

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

重复接口取消

思路

我们创建了一个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 实现可拖拽侧边栏

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 词云组件开发实战:从原理到应用

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 动态菜单实现:一套代码完美适配多角色权限系统

今天分享一个基于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个细节》

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

背景


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

尤其是对于鼠鼠我本人来说,也是深有体会:司内的源码架构:自研微前端+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组件

背景

笔者公司的一个项目大量使用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 实现粘贴数据自动进入新增行与新增列

如何用 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使用方法解析,包含底部弹窗示例、样式未生效踩坑记录

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

❌