阅读视图

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

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

❌