阅读视图

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

vue2 甘特图 vxe-gantt 一行渲染多个子任务的配置

vue2 甘特图 vxe-gantt 一行渲染多个子任务的配置,但需要在父级任务直接显示所有子任务时,可以通过 task-bar-subview-config 和父级任务设置type= VxeGanttTaskType.Subview 来设置,会自动将所有子任务渲染到父级任务中

gantt.vxeui.com

在这里插入图片描述 设置 task-bar-subview-config.showOverview 设置是否任务总览,当子任务被展开后自动显示任务总览

<template>
  <div>
    <vxe-gantt v-bind="ganttOptions"></vxe-gantt>
  </div>
</template>

<script>
import { VxeGanttTaskType } from 'vxe-gantt'

export default {
    data() {
        const ganttOptions = {
              border: true,
  treeConfig: {
    transform: true,
    rowField: 'id',
    parentField: 'parentId'
  },
  taskConfig: {
    startField: 'start',
    endField: 'end',
    typeField: 'type',
    progressField: 'progress'
  },
  taskBarSubviewConfig: {
    showOverview: true 
  },
  taskBarConfig: {
    showContent: true,
    barStyle: {
      round: true
    }
  },
  taskViewConfig: {
    tableStyle: {
      width: 280
    }
  },
  columns: [
    { field: 'title', title: '任务名称', minWidth: 140, treeNode: true },
    { field: 'start', title: '开始时间', width: 100 },
    { field: 'end', title: '结束时间', width: 100 },
    { field: 'progress', title: '进度(%)', width: 80 }
  ],
  data: [
    { id: 10001, parentId: null, title: '我的项目1', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10002, parentId: 10001, title: '我的项目2', start: '2024-03-02', end: '2024-03-05', progress: 70 },
    { id: 10003, parentId: null, title: '我的项目3', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10004, parentId: 10003, title: '我的项目4', start: '2024-03-03', end: '2024-03-04', progress: 50 },
    { id: 10005, parentId: 10003, title: '我的项目5', start: '2024-03-05', end: '2024-03-06', progress: 50 },
    { id: 10006, parentId: 10003, title: '我的项目6', start: '2024-03-08', end: '2024-03-11', progress: 60 },
    { id: 10008, parentId: null, title: '我的项目7', start: '', end: '', progress: 0, type: VxeGanttTaskType.Subview },
    { id: 10009, parentId: 10008, title: '我的项目8', start: '2024-03-07', end: '2024-03-09', progress: 50 },
    { id: 10010, parentId: 10008, title: '我的项目9', start: '2024-03-10', end: '2024-03-12', progress: 50 },
    { id: 10011, parentId: 10008, title: '我的项目10', start: '2024-03-13', end: '2024-03-15', progress: 50 }
  ]
        };
        return {
            ganttOptions
        };
    }
};
</script>

gitee.com/x-extends/v…

【配置化 CRUD 02】 useTable Hooks:列表查询、分页封装

一:前言

在业务的CRUD开发体系中 ,几乎所有业务模块都需要实现列表查询与分页功能,你是不是经常会看到类似代码:

// 1.每个页面都要单独引入接口 
import { getListApi } from '@/api/user' 

// 2. 定义分页列表参数(每个列表页都要写一遍)
const current = ref(1) 
const pageSize = ref(10) 
const total = ref(0) 
const tableData = ref([]) 
const loading = ref(false)
const searchParams = ref({})

// 3. 重复拼接请求参数(分页+搜索)
const getListData = async () => { 
  loading.value = true 
  try {
  const params = { 
      current: current.value, 
      pageSize: pageSize.value, 
      ...searchParams.value 
     } 
  const res = await getListApi(params) 
  // 重复解析响应数据(每个页面可能还要适配不同后端格式)
  tableData.value = res.data.list 
  total.value = res.data.total 
  }catch (error) { 
     console.error('列表查询失败:', error) } 
  finally { 
    loading.value = false 
  } 
}

// 4. 重复编写分页联动监听
watch([current, pageSize], async () => { await getListData() }, 
{ immediate: true })
...

类似这样的代码,每新增一个列表页就需要几乎完整复制一遍,基于此,我们针对性封装 useTable Hooks,延续配置化 CRUD体系「通用逻辑内聚、业务逻辑外溢」的设计原则,将列表查询请求、分页参数管理、加载状态控制、查询与分页联动等通用逻辑全部抽离,支持自定义请求接口、灵活配置分页参数...,可联动专栏前一期的搜索重置组件,这样在实现列表页时,无需关注底层的查询与分页逻辑,只需传入简单配置,即可快速实现列表查询与分页功能,专注于业务本身的表单配置与数据展示。

文章指路(一起食用,味道更加):

1.动态表单深度实践: juejin.cn/post/757946…

2.搜索重置组件:juejin.cn/post/760056…

二:useTable hooks实现

首先明确useTable Hooks的实现目标,所有代码实现均围绕目标展开:

1.冗余逻辑抽离:将分页参数定义、列表查询请求、分页联动、搜索联动、表格操作等通用逻辑全部内聚;

2.配置化适配:支持通过入参灵活配置请求接口、响应数据格式、分页参数、默认参数等,适配不同业务模块、不同后端接口规范;

3.联动兼容:支持与前一期封装的搜索重置组件联动,同时适配表格组件,提供实例注册、选中行获取、表格列设置等方法,实现全流程联动;

...

接下来我们开始实现:

2.1.useTable Hooks Props类型约束

interface UseTableConfig<T = any> {
    // 请求Url 或函数
    getListApi: string | Function
    // 返回数据格式配置 -- 可指定对应列表和总数的对应字段
    response?: {
        list: string
        total?: string
    }
    // 接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    initParams?: Object 
    // 搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    searchInitParams?: Object 
    // 针对table的相关配置
    tableObject?: Object
}

2.2 数据状态:统一维护表格、搜索组件、初始参数等相关状态

// 表格组件实例引用,用于调用表格内部方法
const tableRef = ref(null) 
// 接收搜索组件传递的实时参数,参与响应式更新!
const searchParamObj = ref({}) 
// 表格相关
const tableObjects = ref({
    current: 1,    // 默认当前页为1
    pageSize: 10, // 默认页大小为10
    pageSizeOptions: ['5', '10', '15', '20'], // 默认分页大小可选值
    total: 0, // 数据总数,初始为0
    tableData: [], // 表格渲染数据,初始为空数组
    // 允许外部传入覆盖默认配置
    ...tableObject, 
    loading: false, // 表格加载状态,控制加载动画
})

// 利用`computed`计算属性自动组装请求参数
const parmsObj = computed(() => {
    return {
        current: tableObjects.value.current, // 分页-当前页
        pageSize: tableObjects.value.pageSize, // 分页-页大小
        ...initParams, // 固定参数(不响应式)
        ...searchInitParams, // 搜索默认参数(不响应式)
        ...searchParamObj.value, // 搜索实时参数(响应式)
    }
})

2.3 核心方法:封装查询、表格操作与搜索联动逻辑

2.3.1 register:注册表格ref

// 注册表格ref
const register = (ref: any) => {
   tableRef.value = ref
}
// 获取表格实例
const getTable = () => {
   return tableRef.value
}

2.3.2 getListData : 获取列表数据

const getListData = async () => {
     tableObjects.value.loading = true
     console.log('getListData::列表调用参数:', parmsObj.value)
     try {
           const res = await Http({
                 method,
                 url:getListApi,
                 params:parmsObj.value
            })
           tableObjects.value.total = res[response?.total || 'total']
           tableObjects.value.tableData = res[response?.list || 'list']
           tableObjects.value.loading = false
        } catch (error) {
           message.error(error)
        }
    }

2.3.3 setSearchParams : 搜索联动

const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            // 重置
            searchParamObj.value = {}
        }
        // 是否获取数据
        getList && getListData()
    }

2.3.4 setRowChecked : 设置列选择

const setRowChecked = (row: any) => {
     const table = getTable() as any
     table && table.toggleRowSelection(row, true)
}

...

2.4 联动监听:分页参数变化自动触发查询

// 监听当前页变化,自动刷新列表
watch(() => tableObjects.value.current, async () => {
    getListData()
}, { immediate: true, deep: true })

// 监听页大小变化,自动刷新列表
watch(() => tableObjects.value.pageSize, async () => {
    getListData()
}, { immediate: true, deep: true })

2.5 API统一暴露:对外提供简洁易用的调用接口

return {
    // 核心方法集合,供外部调用
    methods: { 
        getListData, // 列表查询
        getTable, // 获取表格实例
        setRowChecked, // 列选择
        setSearchParams, // 设置查询参数
    },
    tableRef, // 表格实例引用(可选暴露,供外部手动操作)
    register, // 表格实例注册方法(表格组件必须调用)
    tableObject: tableObjects.value, // 分页与表格核心状态(响应式,供表格组件绑定)
}

2.6 完整代码

import { ref, watch, computed } from 'vue'
interface UseTableConfig<T = any> {
    getListApi: string | Function
    // 请求方式
    method?: 'get' | 'post'
    //返回数据格式配置 -- 可指定对应列表和总数的取值
    response?: {
        list: string
        total?: string
    }
    initParams?: Object //接收默认参数, 如 pageType:"testPage",不参与响应式更新!
    searchInitParams?: Object //搜索组件默认参数,注意跟搜索组件初始值保持一致,不参与响应式更新!
    tableObject?: Object // 针对table的相关配置
}

export const useTable = (
    config: UseTableConfig
) => {
    const { method = 'get',getListApi, response = {}, initParams = {}, tableObject = {}, searchInitParams = {} } = config
    const tableRef = ref(null)
    // 接收搜索组件传递的实时参数,参与响应式更新!
    const searchParamObj = ref({})
    //表格配置
    const tableObjects = ref({
        current: 1,    // 当前页
        pageSize: 10, // 页限制
        pageSizeOptions: ['5', '10', '15', '20'], // 分页配置
        total: 0, // 总数
        tableData: [], // 表格数据
        ...tableObject,
        loading: false, //表格加载状态
    })
    const parmsObj = computed(() => {
        return {
            current: tableObjects.value.current,
            pageSize: tableObjects.value.pageSize,
            ...initParams, // 固定限制参数
            ...searchInitParams, // 搜索默认参数(不响应式)
            ...searchParamObj.value, // 搜索实时参数(响应式)
        }
    })
    const getListData = async () => {
        tableObjects.value.loading = true
        console.log('getListData::列表调用参数:', parmsObj.value)
        try {
            const res = await Http({
              method,
              url:getListApi,
              params:parmsObj.value
            })
            tableObjects.value.total = res[response?.total || 'total']
            tableObjects.value.tableData = res[response?.list || 'list']
            tableObjects.value.loading = false
        } catch (error) {
            message.error(error)
        }
    }
    //统一拿到table的Ref
    const getTable = () => {
        return tableRef.value
    }
    //获取表格选中的列
    const getSelections = async () => {
        const table = getTable() as any
        return table.getSelectionRows()
    }
    //从外界直接传入的表格列参数
    const setcolumns = (columns: any) => {
        const table = getTable() as any
        table && table?.setcolumns(columns)
    }
    //设置列选择
    const setRowChecked = (row: any) => {
        const table = getTable() as any
        table && table.toggleRowSelection(row, true)
    }
    //注册表格ref
    const register = (ref: any) => {
        tableRef.value = ref
    }
    // 支持从外面设置搜索参数,并查询
    const setSearchParams = (data: any, getList = true, isReset = false) => {
        Object.assign(searchParamObj.value, data)
        if (isReset) {
            searchParamObj.value = {}
        }
        getList && getListData()
    }
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.current, async () => {
        getListData()
    },
        { immediate: true, deep: true })
    //检测到分页变化 重新获取数据
    watch(() => tableObjects.value.pageSize, async () => {
        getListData()
    }, { immediate: true, deep: true })

    return {
        methods: {
            getListData,
            getTable,
            getSelections,
            setcolumns,
            setRowChecked,
            setSearchParams,
        },
        tableRef,
        register,
        tableObject: tableObjects.value,
    }
}

三: 实战使用(useTable结合搜索、表格组件)

页面引入Hooks使用:

const { methods, register, tableObject } = useTable({
  getListApi: 'test/list',
  initParams: {
    pageType:"testPage"
  },
  searchInitParams:{
    age:10,
    name:"初始化名字"
  }
})

const { getListData, setSearchParams } = methods

getListData()

搜索组件联动:search和reset方法改造

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                  prop: 'age',
                  path: 'componentProps.placeholder',
                  value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
    initValue: 10,
    formItemProps: {
      rules:[
      {
        required: true,
        message: '请输入年龄',
        trigger: 'blur'
      }
    ]
    }
  },
  {
    label: '上学阶段',
    prop: 'jieduan',
    component: 'Select',
    componentProps: {
        options: [
          {
            label: '幼儿园',
            value: 1
          },
          {
            label: '其他阶段',
            value: 2
          }
        ]
      }
  },
]

// 改动前:
<Search 
   ref="searchRef" 
   :schema="searchColumn" 
   @search="(params) => console.log('点击查询:',{params})" 
   :showReset="false" 
   :isVaildSearch="true"
   > 
</Search>

// 改动后:
<Search
   ref="searchRef"
   :schema="searchColumn"
   @search="setSearchParams"
   :showReset="false" 
   :isVaildSearch="true"
>
</Search>

表格组件支持:提供register方法、及相关props接收

const tablecolumns = [
  {
    label: '姓名',
    prop: 'name',
    minWidth: '140px',
  },
  {
    label: '年龄',
    prop: 'age',
      minWidth: '140px',
  },
  {
     label: '上学阶段',
     prop: 'jieduan',
    minWidth: '140px',
  }
]

 <Comtable
     v-model:current="tableObject.current"
     v-model:pageSize="tableObject.pageSize"
     :rowKey="'id'"
     :columns="tablecolumns"
     :dataList="tableObject.tableData"
     :pagination="tableObject"
     :loading="tableObject.loading"
     @register="register"
   >
      <template #operation="{ row }">
         <el-button
            type="primary"
            size="small"
            link
            @click="editClick(row)"
            >{{$t('Edit')}}</el-button
         >
      </template>
</Comtable>

运行截图:

初始状态:

image.png

输入搜索:

image.png

分页变化:

image.png

以上就是useTable Hooks 的封装过程以及实战案例~

从打包到优化|Vue3 可 Tree-shaking API 分类与避坑要点

在 Vue3 的性能优化体系中,除了静态提升、PatchFlag、Block Tree 等渲染层面的优化,Tree-shaking(摇树优化)是构建层面的核心手段——它能自动移除项目中未被使用的代码,大幅缩减打包体积,尤其对中小型项目而言,体积优化效果可达 30% 以上。不同于 Vue2 全局引入导致大量冗余代码的问题,Vue3 从架构设计上原生支持 Tree-shaking,核心 API 均采用“按需引入”模式。本文将聚焦 Tree-shaking 在 Vue3 中的具体体现,重点梳理哪些 API 可被摇树、分类说明适用场景,同时拆解其底层实现逻辑与生效注意事项,完善 Vue3 优化知识体系。

一、先理清:Tree-shaking 是什么?Vue3 为何能原生支持?

Tree-shaking 本质是“消除死代码”的打包优化技术,依赖 ES6 模块的 import/export 语法(静态模块解析)——打包工具(Webpack、Vite、Rollup)能在编译阶段分析模块依赖,识别出未被引用的代码,将其从最终打包产物中移除。

Vue2 无法很好地支持 Tree-shaking,核心原因是其 API 多为全局挂载(如 Vue.componentVue.directive),即使未使用,也会被打包进产物;而 Vue3 彻底重构了 API 架构,采用“模块化导出”模式,所有 API 均通过 ES6 模块单独导出,未被引用的 API 可被打包工具精准识别并摇掉,实现“用多少、打包多少”。


// Vue2 全局挂载(无法 Tree-shaking)
import Vue from 'vue'
Vue.component('HelloWorld', HelloWorld) // 即使未使用该组件,也会打包

// Vue3 模块化导出(支持 Tree-shaking)
import { defineComponent, ref } from 'vue'
// 仅引用 defineComponent,ref 未被使用,打包时会被摇掉
export default defineComponent({
  setup() {
    // 未使用 ref
    return {}
  }
})

关键前提:Vue3 的 Tree-shaking 仅在 生产环境 生效,且打包工具需支持 ES6 模块解析(Webpack 4+、Vite、Rollup 均支持);开发环境为便于调试,不会移除未使用代码。

二、Vue3 中可被 Tree-shaking 的 API 全分类(附场景)

Vue3 的 API 按“功能模块”可分为核心 API、组件 API、指令 API、工具类 API 四大类,其中绝大多数 API 均可被 Tree-shaking,仅少数全局 API(需挂载到 App 实例)无法被摇掉。以下按分类梳理,明确每类可摇树 API 的用途与示例。

1. 核心响应式 API(最常用,均可摇树)

这类 API 是 Vue3 响应式系统的核心,均支持按需引入,未被使用时会被 Tree-shaking 移除,也是日常开发中体积优化的重点。

API 名称 功能说明 可摇树性 使用示例
ref 创建基本类型响应式数据 import { ref } from 'vue';
reactive 创建引用类型响应式数据 import { reactive } from 'vue';
computed 创建计算属性 import { computed } from 'vue';
watch 监听响应式数据变化 import { watch } from 'vue';
watchEffect 自动追踪响应式依赖的监听 import { watchEffect } from 'vue';
toRefs 将 reactive 对象转为 ref 集合 import { toRefs } from 'vue';

注意:这类 API 若仅引入未使用(如 import { ref } from 'vue' 但未创建 ref 数据),打包时会被完全摇掉,不会产生任何冗余代码。

2. 组件核心 API(组件开发必备,均可摇树)

这类 API 用于组件定义、生命周期管理,仅在组件开发中使用,未被引用时可被摇掉,核心包括组件定义、生命周期钩子、组件通信相关 API。

  • 组件定义 API:defineComponent(定义组件,支持 TypeScript 类型推导)、defineProps(定义组件 props)、defineEmits(定义组件事件)、defineExpose(暴露组件内部属性/方法)——均支持 Tree-shaking,仅在组件中使用时才会被打包。
  • 生命周期钩子:onMounted、onUpdated、onUnmounted、onBeforeMount 等所有组合式 API 生命周期钩子——按需引入,未使用的钩子会被摇掉(区别于 Vue2 全局生命周期)。
  • 组件通信 API:useAttrs、useSlots、provide、inject——仅在需要组件通信时引入,未使用时会被移除。

<script setup>
// 仅引入需要的组件 API,未使用的会被 Tree-shaking
import { defineProps, onMounted, provide } from 'vue'

const props = defineProps(['title'])

onMounted(() => {
  console.log('组件挂载完成')
})

// provide 仅引入未使用,打包时会被摇掉
</script>

3. 内置指令 API(按需使用,均可摇树)

Vue3 的内置指令中,除了 v-textv-html 等基础指令(默认打包,体积极小),其他指令均支持 Tree-shaking,仅在模板中使用时才会被打包。

核心可摇树指令 API 及场景:

  • v-model 相关:vModelText、vModelNumber、vModelCheckbox 等——仅在使用对应类型 v-model 时引入(如 v-model.number 需引入 vModelNumber)。
  • v-show:单独打包,未使用时会被摇掉(区别于 v-if,v-if 是模板语法,无需额外打包)。
  • 自定义指令相关:withDirectives(用于封装自定义指令)——仅在开发自定义指令时使用,未使用时会被移除。

4. 工具类 API(按需引入,均可摇树)

Vue3 提供了一系列工具类 API,用于辅助开发(如响应式数据转换、模板渲染等),这类 API 均为模块化导出,未被使用时会被 Tree-shaking 移除,核心包括:

  • 响应式工具:isRef、isReactive、isComputed、unref(判断/转换响应式数据);
  • 模板工具:h(创建 VNode,用于渲染函数)、render(渲染 VNode 到 DOM);
  • 其他工具:nextTick(等待 DOM 更新)、mergeProps(合并组件 props)。

// 按需引入工具类 API,未使用的会被摇掉
import { isRef, nextTick, h } from 'vue'

const count = ref(0)
if (isRef(count)) {
  console.log('count 是 ref 类型')
}

// nextTick 仅引入未使用,打包时被摇掉
// h 仅引入未使用,打包时被摇掉

5. 不可被 Tree-shaking 的 API(少数全局 API)

并非 Vue3 所有 API 都能被摇掉,以下全局 API 需挂载到 App 实例(如 app.use()app.component()),或为全局运行时依赖,即使未使用,也会被打包进产物(体积极小,无需担心):

  • 创建 App 相关:createApp(必用,全局依赖);
  • 全局挂载相关:app.use()、app.component()、app.directive()(挂载全局插件、组件、指令);
  • 基础全局 API:Vue.version(获取 Vue 版本,极少使用)。

三、Tree-shaking 在 Vue3 中的底层实现逻辑

Vue3 能实现高效 Tree-shaking,核心依赖“模块化架构设计”与“编译时标记”,具体分为两个层面:

1. 架构层面:ES6 模块化导出,避免全局挂载

Vue3 源码采用 ES6 模块编写,所有 API 均通过 export 单独导出(如 export const ref = ...),而非像 Vue2 那样挂载到全局 Vue 对象上。这种设计让打包工具能精准识别“哪些 API 被 import 且被使用”,未被引用的 API 会被判定为死代码,在打包时移除。

2. 编译层面:标记未使用代码,辅助打包工具摇树

Vue3 编译器在编译阶段(如 SFC 编译),会对引入但未使用的 API 进行标记,同时移除模板中未使用的指令、组件相关代码,为打包工具的 Tree-shaking 提供辅助,确保冗余代码被彻底移除。

例如,在 <script setup> 中引入但未使用的 API,编译器会在生成的代码中移除该 import 语句,进一步确保打包工具无需额外处理:


// 开发时代码(引入未使用)
import { ref, computed } from 'vue'
const count = 1

// 编译器处理后代码(移除未使用的 import)
import { } from 'vue'
const count = 1

// 打包工具最终处理(无任何冗余)
const count = 1

四、Tree-shaking 生效条件与避坑要点

Vue3 虽然原生支持 Tree-shaking,但在实际开发中,若配置不当或使用方式有误,会导致 Tree-shaking 失效,冗余代码无法被移除。以下是关键生效条件与常见坑点。

1. 生效条件(必满足)

  • 打包工具支持 ES6 模块解析:Webpack 4+、Vite、Rollup 均支持,需确保配置中未禁用 ES6 模块(如 Webpack 中 module.exports = { mode: 'production' } 即可)。
  • 使用 ES6 模块语法:必须使用 import/export 引入/导出 API,不可使用 CommonJS 语法(require/module.exports),否则 Tree-shaking 失效。
  • 生产环境打包:Vue3 仅在生产环境(process.env.NODE_ENV === 'production')下开启 Tree-shaking 相关优化,开发环境不会移除未使用代码。

2. 常见避坑点

  • 误区1:全局引入 Vue3 会触发 Tree-shaking——若使用 import Vue from 'vue' 全局引入,所有 API 都会被打包,无法摇掉;必须使用按需引入(import { ref } from 'vue')。
  • 误区2:引入但未使用的 API 一定会被摇掉——若 API 被间接引用(如通过第三方插件引用),或打包工具无法识别死代码(如配置错误),可能导致 Tree-shaking 失效,需检查打包配置。
  • 误区3:自定义指令/插件会影响 Tree-shaking——自定义指令/插件若采用按需引入模式(如 import { MyDirective } from './directive'),未使用时会被摇掉;若全局挂载(app.directive('my-dir', MyDirective)),则无法摇掉。
  • 误区4:Tree-shaking 会摇掉必要的运行时代码——Vue3 源码中已做好兼容,运行时核心依赖(如响应式系统基础逻辑)会被自动保留,不会被误摇掉。

五、实战验证:Tree-shaking 的体积优化效果

以一个简单的 Vue3 项目为例,对比“全局引入”与“按需引入”的打包体积差异(基于 Vite 打包,生产环境):

  1. 全局引入(未使用 Tree-shaking):import Vue from 'vue',打包后 Vue 相关体积约 50KB(gzip 后约 18KB);
  2. 按需引入(使用 Tree-shaking):仅引入 refdefineComponent,打包后 Vue 相关体积约 15KB(gzip 后约 6KB),体积缩减 70%。

实际项目中,随着 API 使用数量的增加,体积优化效果会略有下降,但总体仍能比 Vue2 减少 30%~50% 的冗余体积,尤其对轻量级应用(如移动端 H5、小程序),体积优化带来的加载速度提升更为明显。

六、总结:Tree-shaking 与 Vue3 优化体系的协同价值

Tree-shaking 在 Vue3 中的体现,本质是“模块化架构”与“按需引入”理念的落地,它与前文提到的静态提升、PatchFlag、Block Tree 等优化特性,形成了“构建层面+渲染层面”的全链路优化:

  • Tree-shaking:构建阶段移除未使用代码,缩减打包体积,提升加载速度;
  • 静态提升/PatchFlag/Block Tree:渲染阶段优化 VNode 创建与 Diff 效率,提升运行时性能。

梳理可被 Tree-shaking 的 API 后,我们能在实际开发中更有针对性地优化:优先采用按需引入模式,避免全局引入 API;减少“引入未使用”的冗余代码;合理使用第三方插件(选择支持 Tree-shaking 的插件)。

Vue3 对 Tree-shaking 的原生支持,彻底解决了 Vue2 体积冗余的痛点,让开发者无需额外配置,就能轻松实现项目体积优化。理解其底层原理与可摇树 API 清单,不仅能帮助我们写出更轻量的代码,更能深入掌握 Vue3 的架构设计思路,在性能优化场景中精准发力。

相关文章

避坑+实战|Vue3 hoistStatic静态提升,让渲染速度翻倍的秘密

吃透 Vue3 PatchFlag!8 大类标识含义+精准比对逻辑

Vue3 渲染优化双核心:Block Tree 原理与 Fragment 根节点妙用

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

🔥 Vue3 + TS 实现长按指令 v-longPress:优雅解决移动端/PC端长按交互需求

在前端开发中,长按(Long Press)是高频交互场景(如移动端删除操作、PC端右键菜单、批量操作触发等)。原生HTML没有直接的长按事件,通常需要通过 mousedown/touchstart 结合定时器实现。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、跨端兼容v-longPress 自定义指令,支持自定义长按时长、触发回调、取消触发等特性,开箱即用。

在这里插入图片描述

🎯 指令核心特性

  • ✅ 跨端兼容:同时支持PC端(鼠标长按)和移动端(触摸长按)
  • ✅ 自定义配置:支持自定义长按时长、触发回调、取消回调
  • ✅ 防误触优化:按下后移动超过阈值自动取消长按
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 支持指令参数动态更新,适配动态业务场景
  • ✅ 自动清理定时器/事件监听,无内存泄漏
  • ✅ 支持阻止默认行为/冒泡,适配复杂交互场景

📁 完整代码实现(v-longPress.ts)

// directives/v-longPress.ts
import type { ObjectDirective, DirectiveBinding, App } from 'vue'

/**
 * 长按指令配置接口
 */
export interface LongPressOptions {
  /** 长按触发时长(ms),默认500ms */
  duration?: number
  /** 长按触发的回调函数 */
  handler: (e: MouseEvent | TouchEvent) => void
  /** 长按取消的回调函数(可选) */
  cancelHandler?: (e: MouseEvent | TouchEvent) => void
  /** 移动阈值(px),超过则取消长按,默认5px */
  moveThreshold?: number
  /** 是否阻止默认行为,默认true */
  preventDefault?: boolean
  /** 是否阻止事件冒泡,默认false */
  stopPropagation?: boolean
}

/**
 * 扩展元素属性,存储长按相关状态
 */
interface LongPressElement extends HTMLElement {
  _longPress?: {
    options: LongPressOptions
    timer: number | null          // 长按定时器
    startX: number                // 按下时X坐标
    startY: number                // 按下时Y坐标
    isPressing: boolean           // 是否正在长按中
    // 事件处理函数(绑定this,便于移除监听)
    mouseDownHandler: (e: MouseEvent) => void
    touchStartHandler: (e: TouchEvent) => void
    mouseUpHandler: (e: MouseEvent) => void
    touchEndHandler: (e: TouchEvent) => void
    mouseMoveHandler: (e: MouseEvent) => void
    touchMoveHandler: (e: TouchEvent) => void
    mouseLeaveHandler: (e: MouseEvent) => void
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: Omit<LongPressOptions, 'handler'> = {
  duration: 500,
  moveThreshold: 5,
  preventDefault: true,
  stopPropagation: false
}

/**
 * 计算两点之间的距离
 * @param x1 起始X坐标
 * @param y1 起始Y坐标
 * @param x2 结束X坐标
 * @param y2 结束Y坐标
 * @returns 距离(px)
 */
const calculateDistance = (x1: number, y1: number, x2: number, y2: number): number => {
  return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2))
}

/**
 * 清除长按相关状态和定时器
 * @param el 目标元素
 * @param e 触发事件(可选,用于取消回调)
 */
const clearLongPressState = (el: LongPressElement, e?: MouseEvent | TouchEvent) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // 清除定时器
  if (longPressData.timer) {
    clearTimeout(longPressData.timer)
    longPressData.timer = null
  }

  // 触发取消回调
  if (longPressData.isPressing && longPressData.options.cancelHandler && e) {
    longPressData.options.cancelHandler(e)
  }

  // 重置状态
  longPressData.isPressing = false
}

/**
 * 绑定事件监听
 * @param el 目标元素
 */
const bindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.addEventListener('mousedown', longPressData.mouseDownHandler)
  el.addEventListener('mouseup', longPressData.mouseUpHandler)
  el.addEventListener('mousemove', longPressData.mouseMoveHandler)
  el.addEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.addEventListener('touchstart', longPressData.touchStartHandler)
  el.addEventListener('touchend', longPressData.touchEndHandler)
  el.addEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 解绑事件监听
 * @param el 目标元素
 */
const unbindEvents = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return

  // PC端事件
  el.removeEventListener('mousedown', longPressData.mouseDownHandler)
  el.removeEventListener('mouseup', longPressData.mouseUpHandler)
  el.removeEventListener('mousemove', longPressData.mouseMoveHandler)
  el.removeEventListener('mouseleave', longPressData.mouseLeaveHandler)

  // 移动端事件
  el.removeEventListener('touchstart', longPressData.touchStartHandler)
  el.removeEventListener('touchend', longPressData.touchEndHandler)
  el.removeEventListener('touchmove', longPressData.touchMoveHandler)
}

/**
 * 初始化长按事件处理函数
 * @param el 目标元素
 */
const initHandlers = (el: LongPressElement) => {
  const longPressData = el._longPress
  if (!longPressData) return
  const { options } = longPressData

  // PC端鼠标按下
  longPressData.mouseDownHandler = (e: MouseEvent) => {
    // 阻止右键菜单(仅左键触发)
    if (e.button !== 0) return

    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标
    longPressData.startX = e.clientX
    longPressData.startY = e.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // 移动端触摸开始
  longPressData.touchStartHandler = (e: TouchEvent) => {
    // 阻止默认行为/冒泡
    if (options.preventDefault) e.preventDefault()
    if (options.stopPropagation) e.stopPropagation()

    // 记录起始坐标(取第一个触摸点)
    const touch = e.touches[0]
    longPressData.startX = touch.clientX
    longPressData.startY = touch.clientY
    longPressData.isPressing = true

    // 设置长按定时器
    longPressData.timer = window.setTimeout(() => {
      if (longPressData.isPressing) {
        options.handler(e)
        longPressData.isPressing = false
      }
    }, options.duration!) as unknown as number
  }

  // PC端鼠标抬起
  longPressData.mouseUpHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }

  // 移动端触摸结束
  longPressData.touchEndHandler = (e: TouchEvent) => {
    clearLongPressState(el, e)
  }

  // PC端鼠标移动
  longPressData.mouseMoveHandler = (e: MouseEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      e.clientX,
      e.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // 移动端触摸移动
  longPressData.touchMoveHandler = (e: TouchEvent) => {
    if (!longPressData.isPressing) return

    // 计算移动距离,超过阈值取消长按
    const touch = e.touches[0]
    const distance = calculateDistance(
      longPressData.startX,
      longPressData.startY,
      touch.clientX,
      touch.clientY
    )

    if (distance > options.moveThreshold!) {
      clearLongPressState(el, e)
    }
  }

  // PC端鼠标离开元素
  longPressData.mouseLeaveHandler = (e: MouseEvent) => {
    clearLongPressState(el, e)
  }
}

/**
 * 清理所有长按相关资源
 * @param el 目标元素
 */
const cleanup = (el: LongPressElement) => {
  // 清除状态和定时器
  clearLongPressState(el)
  
  // 解绑事件
  unbindEvents(el)
  
  // 删除扩展属性
  delete el._longPress
}

/**
 * v-longPress 自定义指令实现
 */
export const longPressDirective: ObjectDirective<LongPressElement, LongPressOptions | (() => void)> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 1. 解析指令参数
    let options: LongPressOptions = {
      ...DEFAULT_OPTIONS,
      handler: () => {}
    }

    if (typeof binding.value === 'function') {
      // 直接传函数:作为长按触发回调,使用默认配置
      options.handler = binding.value
    } else if (typeof binding.value === 'object' && binding.value !== null) {
      // 传对象:合并配置
      options = {
        ...DEFAULT_OPTIONS,
        ...binding.value
      }
    }

    // 校验必填项
    if (typeof options.handler !== 'function') {
      console.warn('[v-longPress] 必须指定有效的长按回调函数')
      return
    }

    // 2. 初始化长按状态
    el._longPress = {
      options,
      timer: null,
      startX: 0,
      startY: 0,
      isPressing: false,
      mouseDownHandler: () => {},
      touchStartHandler: () => {},
      mouseUpHandler: () => {},
      touchEndHandler: () => {},
      mouseMoveHandler: () => {},
      touchMoveHandler: () => {},
      mouseLeaveHandler: () => {}
    }

    // 3. 初始化事件处理函数
    initHandlers(el)

    // 4. 绑定事件监听
    bindEvents(el)

    // 5. 添加长按样式提示
    el.style.cursor = 'pointer'
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: LongPressElement, binding: DirectiveBinding<LongPressOptions | (() => void)>) {
    // 先清理旧配置
    cleanup(el)
    
    // 重新初始化
    this.mounted(el, binding)
  },

  /**
   * 指令卸载时清理资源
   */
  unmounted(el: LongPressElement) {
    cleanup(el)
  }
}

/**
 * 全局注册长按指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认longPress
 */
export const setupLongPressDirective = (app: App, directiveName: string = 'longPress') => {
  app.directive(directiveName, longPressDirective)
}

// TypeScript 类型扩展
declare module 'vue' {
  export interface ComponentCustomDirectives {
    longPress: typeof longPressDirective
  }
}

🚀 快速上手

1. 全局注册指令(main.ts)

在 Vue3 入口文件中注册指令,全局可用:

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupLongPressDirective } from './directives/v-longPress'

const app = createApp(App)

// 注册长按指令(默认名称v-longPress)
setupLongPressDirective(app)

app.mount('#app')

2. 基础使用(直接传回调)

最简单的用法:直接传递长按触发的回调函数,使用默认配置(500ms长按时长):

<template>
  <!-- PC端鼠标长按/移动端触摸长按触发 -->
  <button v-longPress="handleLongPress">
    长按500ms触发
  </button>
</template>

<script setup lang="ts">
// 长按触发回调
const handleLongPress = (e: MouseEvent | TouchEvent) => {
  console.log('长按触发', e)
  alert('长按成功!')
}
</script>

3. 高级使用(自定义配置)

通过对象参数配置完整的长按规则,支持自定义时长、移动阈值、取消回调等:

<template>
  <div class="card">
    <div 
      class="delete-btn"
      v-longPress="{
        duration: 800,          // 长按800ms触发
        moveThreshold: 10,      // 移动超过10px取消
        handler: handleDelete,  // 长按触发回调
        cancelHandler: handleCancel, // 长按取消回调
        preventDefault: true,   // 阻止默认行为
        stopPropagation: true   // 阻止事件冒泡
      }"
    >
      长按删除(800ms)
    </div>
  </div>
</template>

<script setup lang="ts">
// 长按删除回调
const handleDelete = () => {
  console.log('执行删除操作')
  if (confirm('确定要删除这条数据吗?')) {
    // 实际业务逻辑:调用删除接口
    alert('删除成功!')
  }
}

// 长按取消回调
const handleCancel = () => {
  console.log('长按取消')
  // 可添加取消提示,如:
  // ElMessage.info('已取消删除')
}
</script>

<style scoped>
.card {
  padding: 20px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  width: 300px;
  margin: 20px;
}
.delete-btn {
  color: #ff4d4f;
  cursor: pointer;
  padding: 8px 16px;
  border: 1px solid #ff4d4f;
  border-radius: 4px;
  display: inline-block;
}
.delete-btn:hover {
  background: #fff2f2;
}
</style>

4. 动态配置长按指令

适配动态变化的长按配置(如根据不同状态调整长按时长):

<template>
  <div>
    <div>
      <label>长按时长(ms):</label>
      <input 
        type="number" 
        v-model.number="pressDuration"
        min="100"
        max="2000"
        step="100"
      />
    </div>
    
    <button 
      v-longPress="{
        duration: pressDuration,
        handler: handleDynamicLongPress,
        cancelHandler: handleDynamicCancel
      }"
    >
      动态配置长按按钮
    </button>
  </div>
</template>

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

// 动态长按时长
const pressDuration = ref(500)

// 动态长按回调
const handleDynamicLongPress = () => {
  alert(`长按${pressDuration.value}ms触发成功!`)
}

// 动态取消回调
const handleDynamicCancel = () => {
  console.log(`长按${pressDuration.value}ms取消`)
}
</script>

5. 结合UI库实现长按菜单

实际业务中常结合下拉菜单实现长按操作(以Element Plus为例):

<template>
  <div>
    <el-button 
      ref="btnRef"
      v-longPress="{
        handler: openContextMenu,
        duration: 600
      }"
    >
      长按打开操作菜单
    </el-button>
    
    <!-- 自定义上下文菜单 -->
    <el-dropdown 
      ref="dropdownRef"
      :visible="menuVisible"
      @visible-change="menuVisible = false"
    >
      <el-dropdown-menu>
        <el-dropdown-item @click="handleEdit">编辑</el-dropdown-item>
        <el-dropdown-item @click="handleCopy">复制</el-dropdown-item>
        <el-dropdown-item @click="handleDelete" divided>删除</el-dropdown-item>
      </el-dropdown-menu>
    </el-dropdown>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const btnRef = ref<HTMLButtonElement>(null)
const dropdownRef = ref()
const menuVisible = ref(false)

// 打开上下文菜单
const openContextMenu = () => {
  if (btnRef.value && dropdownRef.value) {
    const rect = btnRef.value.getBoundingClientRect()
    // 设置菜单位置
    dropdownRef.value.$el.style.position = 'absolute'
    dropdownRef.value.$el.style.top = `${rect.bottom + 10}px`
    dropdownRef.value.$el.style.left = `${rect.left}px`
    menuVisible.value = true
  }
}

// 编辑操作
const handleEdit = () => {
  ElMessage.info('执行编辑操作')
  menuVisible.value = false
}

// 复制操作
const handleCopy = () => {
  ElMessage.success('复制成功')
  menuVisible.value = false
}

// 删除操作
const handleDelete = () => {
  ElMessage.warning('执行删除操作')
  menuVisible.value = false
}
</script>

🔧 核心知识点解析

1. 长按实现原理

长按的核心逻辑是**“按下计时 + 抬起/移动取消”**:

  1. 按下(mousedown/touchstart):记录起始坐标,启动定时器,达到指定时长触发回调。
  2. 抬起/离开(mouseup/touchend/mouseleave):清除定时器,取消长按。
  3. 移动(mousemove/touchmove):计算移动距离,超过阈值自动取消长按,防止误触。

2. 跨端兼容处理

  • PC端:监听 mousedown/mouseup/mousemove/mouseleave 事件。
  • 移动端:监听 touchstart/touchend/touchmove 事件。
  • 统一的坐标计算逻辑,兼容鼠标和触摸事件的坐标获取方式。

3. 防误触优化

通过 moveThreshold 移动阈值实现防误触:

  • 默认阈值为5px,按下后移动超过该距离自动取消长按。
  • 计算两点间距离使用勾股定理:distance=(x2x1)2+(y2y1)2distance = \sqrt{(x2-x1)^2 + (y2-y1)^2}

4. 内存泄漏防护

  • unmounted 钩子中清理定时器、解绑所有事件监听。
  • updated 钩子中先清理旧配置,再初始化新配置。
  • 使用元素扩展属性存储状态,卸载时删除属性释放内存。

5. TypeScript 类型优化

  • 定义 LongPressOptions 接口,明确配置项类型。
  • 扩展 HTMLElement 类型,添加长按状态属性。
  • 支持两种参数类型(函数/对象),类型推导自动适配。

📋 配置项说明

配置项 类型 默认值 说明
duration number 500 长按触发时长,单位ms
handler (e: MouseEvent | TouchEvent) => void - 长按触发的回调函数(必填)
cancelHandler (e: MouseEvent | TouchEvent) => void - 长按取消的回调函数(可选)
moveThreshold number 5 移动阈值,超过则取消长按,单位px
preventDefault boolean true 是否阻止默认行为(如移动端长按弹出菜单)
stopPropagation boolean false 是否阻止事件冒泡

🎯 常见使用场景

场景1:移动端列表项长按操作

<template>
  <div class="list">
    <div 
      class="list-item"
      v-for="item in list"
      :key="item.id"
      v-longPress="{
        duration: 600,
        handler: () => showActionSheet(item),
        cancelHandler: () => hideActionSheet()
      }"
    >
      {{ item.name }}
    </div>
  </div>
</template>

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

const list = ref([
  { id: 1, name: '微信' },
  { id: 2, name: '支付宝' },
  { id: 3, name: '抖音' }
])

// 显示操作面板
const showActionSheet = (item: any) => {
  console.log('长按触发', item)
  // 实际开发中可调用移动端ActionSheet组件
  alert(`长按${item.name},显示操作菜单`)
}

// 隐藏操作面板
const hideActionSheet = () => {
  console.log('长按取消')
}
</script>

<style scoped>
.list {
  width: 300px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
}
.list-item {
  padding: 12px 16px;
  border-bottom: 1px solid #f0f0f0;
  cursor: pointer;
}
.list-item:last-child {
  border-bottom: none;
}
.list-item:hover {
  background: #f5f5f5;
}
</style>

场景2:PC端长按批量选择

<template>
  <div class="table-container">
    <el-table 
      :data="tableData"
      @row-click="handleRowClick"
    >
      <el-table-column 
        label="名称"
        prop="name"
      >
        <template #default="scope">
          <div 
            v-longPress="{
              handler: () => toggleSelect(scope.row),
              duration: 400
            }"
          >
            {{ scope.row.name }}
          </div>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="100">
        <template #default="scope">
          <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
        </template>
      </el-table-column>
    </el-table>
    
    <div class="selected-count" v-if="selectedRows.length">
      已选择 {{ selectedRows.length }} 条数据
      <el-button size="small" @click="selectedRows = []">清空选择</el-button>
    </div>
  </div>
</template>

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

// 模拟表格数据
const tableData = ref([
  { id: 1, name: '数据1' },
  { id: 2, name: '数据2' },
  { id: 3, name: '数据3' }
])

// 选中的行
const selectedRows = ref<any[]>([])

// 长按切换选择状态
const toggleSelect = (row: any) => {
  const index = selectedRows.value.findIndex(item => item.id === row.id)
  if (index > -1) {
    selectedRows.value.splice(index, 1)
  } else {
    selectedRows.value.push(row)
  }
}

// 点击行(取消批量选择)
const handleRowClick = (row: any) => {
  selectedRows.value = []
}

// 编辑操作
const handleEdit = (row: any) => {
  console.log('编辑', row)
}
</script>

<style scoped>
.table-container {
  width: 500px;
  margin: 20px;
}
.selected-count {
  margin-top: 10px;
  padding: 8px;
  background: #f5f5f5;
  border-radius: 4px;
}
</style>

🚨 注意事项

  1. 移动端默认行为:长按会触发浏览器默认菜单(如复制、保存图片),需设置 preventDefault: true 阻止。
  2. 触摸事件穿透:移动端需注意事件穿透问题,可结合 pointer-events: none 处理。
  3. 性能优化:避免在长按回调中执行复杂计算,建议使用防抖/节流。
  4. 兼容性
    • PC端:所有现代浏览器均支持。
    • 移动端:iOS Safari/Android Chrome 均支持,低版本安卓需测试。
  5. 右键菜单冲突:PC端长按左键触发,避免与右键菜单冲突,可通过 e.button !== 0 过滤右键。

📌 总结

本文实现的 v-longPress 指令具备以下核心优势:

  1. 跨端兼容:同时支持PC端和移动端,一套代码适配多端。
  2. 体验优秀:内置防误触机制,支持自定义移动阈值,避免误触发。
  3. 配置灵活:支持自定义长按时长、回调函数、事件行为,适配各种业务场景。
  4. 类型安全:基于TypeScript开发,类型提示完善,减少开发错误。
  5. 性能优异:自动清理定时器和事件监听,无内存泄漏问题。

这个指令可以直接集成到你的Vue3项目中,解决各种长按交互需求。如果需要进一步扩展,可以在此基础上增加:

  • 支持长按进度条显示
  • 支持多段长按(短按/中按/长按触发不同操作)
  • 支持长按拖拽
  • 支持自定义长按样式(如按压反馈)

希望这篇文章对你有帮助,欢迎点赞、收藏、评论交流!

Vue 3中实现打字机效果的技术指南

一、效果简介

打字机效果是一种常见的页面动画效果,它模拟了打字机逐字输入的过程,为静态页面增添了动态感和趣味性。在Vue 3中,我们可以通过组合式API、响应式数据和定时器来实现这一效果。

二、实现原理

打字机效果的核心原理是:

  1. 逐字显示:通过定时器逐个获取文本字符并更新显示
  2. 响应式数据:利用Vue 3的响应式系统实时更新界面
  3. 状态控制:通过变量控制动画的开始、进行和结束

三、基础实现(单次显示)

1. Vue组件模板

<template>
  <!-- 标题/标语区 -->
  <div class="header">
    <h1 class="main-title" style="min-height: 60px;">{{ typedTitle }}</h1>
    <p class="sub-title">AI智能优化,让简历闪闪发光</p>
  </div>
</template>

2. Vue组件逻辑

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

const typedTitle = ref('');
const fullTitle = '为你的工作经历画龙点睛';

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 打字机效果函数
function typeWriter() {
  let typedText = '';
  let index = 0;
  
  const typeInterval = setInterval(() => {
    if (index < fullTitle.length) {
      typedText += fullTitle.charAt(index);
      typedTitle.value = typedText;
      index++;
    } else {
      clearInterval(typeInterval);
    }
  }, 150); // 每个字符的间隔时间,单位毫秒
}
</script>

3. CSS样式

<style scoped>
.main-title {
  font-size: 32px;
  font-weight: bold;
  color: #333;
  margin-bottom: 20px;
  line-height: 1.3;
}

.sub-title {
  font-size: 18px;
  color: #666;
  margin: 0;
}
</style>

四、高级实现(循环展示)

1. 效果说明

循环展示的打字机效果包括以下阶段:

  • 打字阶段:逐字显示文本
  • 停留阶段:完整显示后短暂停留
  • 消失阶段:逐字消失文本
  • 等待阶段:完全消失后短暂等待,然后重新开始

2. Vue组件实现

<template>
  <!-- 标题/标语区 -->
  <div class="header">
    <h1 class="main-title" style="min-height: 60px;">{{ typedTitle }}</h1>
    <p class="sub-title">AI智能优化,让简历闪闪发光</p>
  </div>
</template>

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

const typedTitle = ref('');
const fullTitle = '为你的工作经历画龙点睛';

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 打字机效果函数 - 循环版
function typeWriter() {
  let typedText = '';
  let index = 0;
  
  // 打字阶段
  const typeInterval = setInterval(() => {
    if (index < fullTitle.length) {
      typedText += fullTitle.charAt(index);
      typedTitle.value = typedText;
      index++;
    } else {
      clearInterval(typeInterval);
      // 打字完成后,等待2秒开始消失
      setTimeout(() => {
        fadeOutTitle();
      }, 2000);
    }
  }, 150); // 每个字符的间隔时间,单位毫秒
}

// 标题淡出效果
function fadeOutTitle() {
  let typedText = fullTitle;
  let index = fullTitle.length - 1;
  
  const fadeInterval = setInterval(() => {
    if (index >= 0) {
      typedText = typedText.substring(0, index);
      typedTitle.value = typedText;
      index--;
    } else {
      clearInterval(fadeInterval);
      // 消失完成后,等待1秒重新开始
      setTimeout(() => {
        typeWriter();
      }, 1000);
    }
  }, 100); // 每个字符的消失间隔时间,单位毫秒
}
</script>

<style scoped>
.main-title {
  font-size: 32px;
  font-weight: bold;
  color: #333;
  margin-bottom: 20px;
  line-height: 1.3;
}

.sub-title {
  font-size: 18px;
  color: #666;
  margin: 0;
}
</style>

五、优化建议

1. 性能优化

  • 合理设置定时器间隔:建议在100-200毫秒之间,过快会导致动画不流畅,过慢会影响用户体验
  • 及时清除定时器:使用clearInterval确保定时器在完成后被清除,避免内存泄漏
  • 避免频繁响应式更新:虽然逐字更新需要多次更新响应式数据,但Vue 3的响应式系统已经做了优化

2. 用户体验优化

  • 添加最小高度:为标题容器添加min-height,避免打字过程中页面布局跳动
  • 调整动画速度:根据文本长度和场景调整打字速度,确保用户有足够时间阅读
  • 考虑不同场景:单次显示适合重要标题,循环显示适合需要持续吸引注意力的场景

3. 扩展功能

  • 添加光标效果:可以在打字过程中添加光标闪烁效果,增强真实感
  • 多文本切换:可以实现多个文本的循环切换显示,丰富页面内容
  • 响应式调整:根据设备尺寸和屏幕方向调整字体大小和动画速度
  • 封装为可复用组件:将打字机效果封装为独立组件,方便在多个地方使用

六、应用场景

打字机效果适用于以下场景:

  1. 页面标题:为页面主标题添加动态效果,提升页面吸引力
  2. 品牌标语:突出展示品牌核心价值,增强记忆点
  3. 引导文本:引导用户进行下一步操作,提高转化率
  4. 加载提示:在数据加载过程中显示动态提示,缓解用户等待焦虑

七、代码示例

以下是一个完整的Vue 3组件示例,包含了循环打字机效果的实现:

完整Vue组件

<template>
  <div class="container">
    <!-- 标题/标语区 -->
    <div class="header">
      <h1 class="main-title" style="min-height: 60px;">{{ typedTitle }}</h1>
      <p class="sub-title">AI智能优化,让简历闪闪发光</p>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const typedTitle = ref('');
const fullTitle = '为你的工作经历画龙点睛';
let typeInterval = null;
let fadeInterval = null;

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 组件卸载时清除定时器
onUnmounted(() => {
  if (typeInterval) clearInterval(typeInterval);
  if (fadeInterval) clearInterval(fadeInterval);
});

// 打字机效果函数 - 循环版
function typeWriter() {
  let typedText = '';
  let index = 0;
  
  // 打字阶段
  typeInterval = setInterval(() => {
    if (index < fullTitle.length) {
      typedText += fullTitle.charAt(index);
      typedTitle.value = typedText;
      index++;
    } else {
      clearInterval(typeInterval);
      // 打字完成后,等待2秒开始消失
      setTimeout(() => {
        fadeOutTitle();
      }, 2000);
    }
  }, 150); // 每个字符的间隔时间,单位毫秒
}

// 标题淡出效果
function fadeOutTitle() {
  let typedText = fullTitle;
  let index = fullTitle.length - 1;
  
  fadeInterval = setInterval(() => {
    if (index >= 0) {
      typedText = typedText.substring(0, index);
      typedTitle.value = typedText;
      index--;
    } else {
      clearInterval(fadeInterval);
      // 消失完成后,等待1秒重新开始
      setTimeout(() => {
        typeWriter();
      }, 1000);
    }
  }, 100); // 每个字符的消失间隔时间,单位毫秒
}
</script>

<style scoped>
.container {
  display: flex;
  flex-direction: column;
  padding: 40px 30px 60px;
  min-height: 100vh;
  background: linear-gradient(135deg, #f0f9ff 0%, #e6f7ff 100%);
}

.header {
  text-align: center;
  margin-bottom: 60px;
}

.main-title {
  font-size: 32px;
  font-weight: bold;
  color: #333;
  margin-bottom: 20px;
  line-height: 1.3;
}

.sub-title {
  font-size: 18px;
  color: #666;
  margin: 0;
}
</style>

封装为可复用组件

如果需要在多个地方使用打字机效果,可以将其封装为一个可复用的组件:

<template>
  <div class="typewriter-container">
    <slot :text="typedText">{{ typedText }}</slot>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';

const props = defineProps({
  text: {
    type: String,
    default: '为你的工作经历画龙点睛'
  },
  speed: {
    type: Number,
    default: 150
  },
  loop: {
    type: Boolean,
    default: false
  },
  delay: {
    type: Number,
    default: 2000
  }
});

const typedText = ref('');
let typeInterval = null;
let fadeInterval = null;

// 监听文本变化
watch(() => props.text, () => {
  if (typeInterval) clearInterval(typeInterval);
  if (fadeInterval) clearInterval(fadeInterval);
  typedText.value = '';
  typeWriter();
});

// 页面加载完成后执行
onMounted(() => {
  typeWriter();
});

// 组件卸载时清除定时器
onUnmounted(() => {
  if (typeInterval) clearInterval(typeInterval);
  if (fadeInterval) clearInterval(fadeInterval);
});

// 打字机效果函数
function typeWriter() {
  let text = '';
  let index = 0;
  
  // 打字阶段
  typeInterval = setInterval(() => {
    if (index < props.text.length) {
      text += props.text.charAt(index);
      typedText.value = text;
      index++;
    } else {
      clearInterval(typeInterval);
      
      if (props.loop) {
        // 打字完成后,等待指定时间开始消失
        setTimeout(() => {
          fadeOutTitle();
        }, props.delay);
      }
    }
  }, props.speed);
}

// 标题淡出效果
function fadeOutTitle() {
  let text = props.text;
  let index = props.text.length - 1;
  
  fadeInterval = setInterval(() => {
    if (index >= 0) {
      text = text.substring(0, index);
      typedText.value = text;
      index--;
    } else {
      clearInterval(fadeInterval);
      // 消失完成后,等待1秒重新开始
      setTimeout(() => {
        typeWriter();
      }, 1000);
    }
  }, props.speed * 0.6); // 消失速度稍快
}
</script>

<style scoped>
.typewriter-container {
  /* 可根据需要添加样式 */
}
</style>

使用示例:

<template>
  <div class="container">
    <h1 class="main-title" style="min-height: 60px;">
      <Typewriter 
        text="为你的工作经历画龙点睛" 
        :loop="true" 
        :speed="150"
      />
    </h1>
    <p class="sub-title">AI智能优化,让简历闪闪发光</p>
  </div>
</template>

<script setup>
import Typewriter from './components/Typewriter.vue';
</script>

八、总结

打字机效果是一种简单但有效的页面动画技术,通过JavaScript定时器和小程序的数据绑定机制,我们可以轻松实现这一效果。无论是单次显示还是循环展示,都可以为页面增添动态感和趣味性,提升用户体验。

在实现过程中,我们需要注意性能优化和用户体验,合理设置动画参数,确保效果流畅自然。同时,我们也可以根据具体场景进行扩展和定制,创造出更加丰富多样的动态效果。

希望本指南对你有所帮助,祝你在微信小程序开发中创造出更多精彩的页面效果**!**

RBAC 权限系统实战(一):页面级访问控制全解析

前言

本篇文章主要讲解 RBAC 权限方案在中后台管理系统的实现

在公司内部写过好几个后台系统,都需要实现权限控制,在职时工作繁多,没有系统性的来总结一下相关经验,现在人已离职,就把自己的经验总结一下,希望能帮助到你

本文是《通俗易懂的中后台系统建设指南》系列的第九篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

权限模型有哪些?

主流的权限模型主要分为以下五种:

  • ACL模型:访问控制列表
  • DAC模型:自主访问控制
  • MAC模型:强制访问控制
  • ABAC模型:基于属性的访问控制
  • RBAC模型:基于角色的权限访问控制

这里不介绍全部的权限模型,有兴趣你可以看看这篇文章:权限系统就该这么设计,yyds

如果你看过、用过市面上一些开源后台系统及权限设计,你会发现它们主要都是基于 RBAC 模型来实现的

为什么是 RBAC 权限模型?

好问题!我帮你问了下 AI

对比维度 ACL (访问控制列表) RBAC (基于角色) ABAC (基于属性)
核心逻辑 用户 ↔ 权限
直接点对点绑定,无中间层
用户 ↔ 角色 ↔ 权限
引入“角色”解耦,权限归于角色
属性 + 规则 = 权限
动态计算 (Who, When, Where)
优点 模型极简,开发速度快,适合初期 MVP 结构清晰,复用性高,符合企业组织架构,维护成本低 极度灵活,支持细粒度控制
(如:只能在工作日访问)
缺点 用户量大时维护工作呈指数级增长,极易出错 角色爆炸:若特例过多,可能导致定义成百上千个角色 开发复杂度极高,规则引擎难设计,有一定的性能消耗
适用场景 个人博客、小型内部工具 中大型后台系统、SaaS 平台 (行业标准) 银行风控、AWS IAM、国家安全级系统

总结来说,在后台系统的场景下,RBAC 模型在灵活性(对比ACL)和复杂性(对比ABAC)上取得了一个很好的平衡

RBAC 概念理解

RBAC 权限模型,全称 Role-Based Access Control,基于角色的权限访问控制

模型有三要素:

  • 用户(User):系统主体,即操作系统的具体人员或账号
  • 角色(Role):角色是一组权限的集合,代表了用户在组织中的职能或身份
  • 权限(Permission):用户可以对系统资源进行的访问或操作能力

RBAC 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制

image.png

并且,它们之间的逻辑关系通常是多对多的:

用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”)

角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限)

主导权限控制的前端、后端方案

市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案

前端主导的权限方案

前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中

后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮

这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限”

适合一些小型、简单系统

后端主导的权限方案

后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等

菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改

倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(404、403...)

在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案

权限方案整体流程

在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示:

image.png

后台系统中的 RBAC 权限实战

权限菜单类型定义

首先,在前后端人员配合中,我们最好约定一套菜单数据的结构,比如:

import type { RouteMeta, RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';
import type { Component } from 'vue';
import type { DefineComponent } from 'vue';
import type { RouteType } from '#/type';

declare global {
  export interface CustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
    /**
     * 路由地址
     */
    path?: string;
    /**
     * 路由名称
     */
    name?: string;
    /**
     * 重定向路径
     */
    redirect?: RouteRecordRedirectOption;
    /**
     * 组件
     */
    component?: Component | DefineComponent | (() => Promise<unknown>);
    /**
     * 子路由信息
     */
    children?: CustomRouteRecordRaw[];
    /**
     * 路由类型
     */
    type?: RouteType;
    /**
     * 元信息
     */
    meta: {
      /**
       * 菜单标题
       */
      title: string;
      /**
       * 菜单图标
       */
      menuIcon?: string;
      /**
       * 排序
       */
      sort?: number;
      /**
       * 是否在侧边栏菜单中隐藏
       * @default false
       */
      hideMenu?: boolean;
      /**
       * 是否在面包屑中隐藏
       * @default false
       */
      hideBreadcrumb?: boolean;
      /**
       * 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
       * @default false
       */
      hideParentIfSingleChild?: boolean;
    };
  }

  /**
   * 后端返回的权限路由类型定义
   */
  export type PermissionRoute = Omit<CustomRouteRecordRaw, 'component' | 'children' | 'type'> & {
    /**
     * 路由ID
     */
    id?: number;
    /**
     * 路由父ID
     */
    parentId?: number;
    /**
     * 组件路径(后端返回时为字符串,前端处理后为组件)
     */
    component: string;
    /**
     * 子路由信息
     */
    children?: PermissionRoute[];
    /**
     * 路由类型
     */
    type: RouteType;
  };
}

router.d.ts 找到类型文件

以上面的类型定义为例,我们约定 PermissionRoute 类型是后端返回的权限路由类型:

我这里使用 ApiFox 来 Mock 权限路由数据,数据是这样的:

clean-admin ApiFox 文档在线地址

image.png

从登录页到路由守卫

权限方案的第一步,是登录并拿到用户信息

假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事:

  1. 调用登录接口,将账号、密码发送给后端进行验证,验证通过则返回 JWT 信息
  2. 将返回的 JWT 信息保存到本地,后续每次请求都携带 Token 来识别用户身份并决定你能拿到的权限路由数据
  3. 触发路由守卫拦截

image.png

account-login.vue 找到全部代码

基本 Vue Router 配置

登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router

在整个权限系统中,我们将路由数据分为两种:

  1. 静态路由:系统固定的路由,比如登录页、异常页(404、403...)
  2. 动态路由:由后端接口返回的用户角色对应的菜单路由数据

静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 Vue Router 中即可

Vue Router 配置:

import { createRouter, createWebHashHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import type { App } from 'vue';
import type { ImportGlobRoutes } from './typing';
import { extractRoutes } from './helpers';
import { afterEachGuard, beforeEachGuard } from './guards';

/** 静态路由 */
const staticRoutes = extractRoutes(
  import.meta.glob<ImportGlobRoutes>(['./modules/constant-routes/**/*.ts'], {
    eager: true,
  }),
);

/** 系统路由 */
const systemRoutes = extractRoutes(
  import.meta.glob<ImportGlobRoutes>(['./modules/system-routes/**/*.ts'], {
    eager: true,
  }),
);

const router = createRouter({
  history: createWebHashHistory(),
  routes: [...staticRoutes, ...systemRoutes] as RouteRecordRaw[],
  strict: true,
  scrollBehavior: () => ({ left: 0, top: 0 }),
});

beforeEachGuard(router);
afterEachGuard(router);

/** 初始化路由 */
function initRouter(app: App<Element>) {
  app.use(router);
}

export { router, initRouter, staticRoutes };

图中的静态路由和系统路由是同一类路由数据,即静态路由

这个配置文件可以在 router/index.ts 找到

这个基本的 Vue Router 配置,做了这么几件事:

  1. 导入 modules 文件夹下的静态路由进行注册
  2. 路由初始化配置 initRouter ,在 main.ts 中调用
  3. 注册全局前置守卫 beforeEach、全局后置守卫 afterEach

我们实现动态路由注册的逻辑就写在 beforeEach

值得一提的是,使用了 import.meta.glob 来动态导入指定路径下的文件模块,这是 Vite 提供的一种导入方式,参考:Vite Glob 导入

路由守卫与动态注册

路由守卫是 Vue Router 提供的一种机制,主要用来通过跳转或取消的方式守卫导航:Vue Router 路由守卫

重头戏在全局前置守卫 router.beforeEach 中实现,来看看我们做哪些事:

import { ROUTE_NAMES } from '../config';
import type { RouteRecordNameGeneric, RouteRecordRaw, Router } from 'vue-router';
import { getLocalAccessToken } from '@/utils/permission';
import { userService } from '@/services/api';
import { nprogress } from './helpers';
import { storeToRefs } from 'pinia';

/** 登录认证页面:账号登录页、短信登录页、二维码登录页、忘记密码页、注册页... */
const authPages: RouteRecordNameGeneric[] = [
  ROUTE_NAMES.AUTH,
  ROUTE_NAMES.ACCOUNT_LOGIN,
  ROUTE_NAMES.SMS_LOGIN,
  ROUTE_NAMES.QR_LOGIN,
  ROUTE_NAMES.FORGOT_PASSWORD,
  ROUTE_NAMES.REGISTER,
];

/** 页面白名单:不需要登录也能访问的页面 */
const pageWhiteList: RouteRecordNameGeneric[] = [...authPages];

export function beforeEachGuard(router: Router) {
  router.beforeEach(async (to) => {
    /** 进度条:开始 */
    nprogress.start();

    const { name: RouteName } = to;

    const userStore = useUserStore();
    const { getAccessToken, getRoutesAddStatus, registerRoutes } = storeToRefs(userStore);
    const { setRoutesAddStatus, setUserInfo, logout } = userStore;

    /** 访问令牌 */
    const accessToken = getAccessToken.value || getLocalAccessToken();

    // 1.用户未登录(无 Token)
    if (!accessToken) {
      const isWhitePage = pageWhiteList.includes(RouteName);
      // 1.1 未登录,如果访问的是白名单中的页面,直接放行
      if (isWhitePage) return true;

      nprogress.done();

      // 1.2 未登录又不在白名单,则拦截并重定向到登录页
      return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
    }

    // 如果已登录用户试图访问登录页,避免重复登录,要强制重定向到首页
    if (authPages.includes(RouteName)) {
      nprogress.done();
      return { name: ROUTE_NAMES.ROOT };
    }

    // 判断是否需要动态加载路由的操作
    if (!getRoutesAddStatus.value) {
      // isRoutesAdded 默认为 false(未持久化),在已经动态注册过时会设置为true,在页面刷新时会重置为 false
      try {
        // 1.拉取用户信息
        const userInfo = await userService.getUserInfo();

        // 2.将用户信息存入 Store
        setUserInfo(userInfo);

        // 3.动态注册路由,registerRoutes 是处理后的路由表
        registerRoutes.value.forEach((route) => {
          router.addRoute(route as unknown as RouteRecordRaw);
        });

        // 4.标记路由已添加
        setRoutesAddStatus(true);

        // 5.中断当前导航,重新进入守卫
        return { ...to, replace: true };
      } catch (error) {
        // 获取用户信息失败(如 Token 过期失效、网络异常)
        logout();
        nprogress.done();
        // 重定向回登录页,让用户重新登录
        return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
      }
    }

    return true;
  });
}

before-each-guard.ts 找到全部代码

上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事:

  1. 处理一些情况,比如用户未登录、登录后访问登录页、白名单等情况
  2. 拉取用户信息,动态注册路由

image.png

在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定

image.png

比如在 vue-clean-admin 中,返回的数据结构是这样的:

在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息

image.png

后端路由结构的转化

在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构,registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRaw

处理什么内容呢?

比如,接口拿到的路由数据字段 component 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径

image.png

实现路由结构转换的代码,我写在了 router/helpers.ts,最主要逻辑是 generateRoutes 函数:

/**
 * 生成符合 Vue Router 定义的路由表
 * @param routes 未转化的路由数据
 * @returns 符合结构的路由表
 */
export function generateRoutes(routes: PermissionRoute[]): CustomRouteRecordRaw[] {
  if (!routes.length) return [];
  return routes.map((route) => {
    const { path, name, redirect, type, meta } = route;
    const baseRoute: Omit<CustomRouteRecordRaw, 'children'> = {
      path,
      name,
      redirect,
      type,
      component: loadComponent(route),
      meta: {
        ...meta,
        // 是否在侧边栏菜单中隐藏
        hideMenu: route.meta?.hideMenu || false,
        // 是否在面包屑中隐藏
        hideBreadcrumb: route.meta?.hideBreadcrumb || false,
        // 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
        hideParentIfSingleChild: route.meta?.hideParentIfSingleChild || false,
      },
    };

    // 是目录数据,设置重定向路径
    if (type === PermissionRouteTypeEnum.DIR) {
      baseRoute.redirect = redirect || getRedirectPath(route);
    }
    // 递归处理子路由
    const processedChildren =
      route.children && route.children.length ? generateRoutes(route.children) : undefined;

    return {
      ...baseRoute,
      ...(processedChildren ? { children: processedChildren } : {}),
    };
  });
}

经过 generateRoutes 处理的路由表,再 addRoute 到 Vue Router 中

侧边栏菜单的渲染

当路由守卫的逻辑走完后,就进入到首页,在首页中,我们会根据路由表(转换过的)来渲染侧边栏菜单

侧边栏菜单是拿 Element Plus 的 el-menu 组件来做的,我们封装了一个菜单组件,除了渲染路由数据外,也更方便自定义配置菜单属性(meta)来实现一些功能

封装不难,就是拿处理后的路由表循环渲染 menu-item,根据 meta 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等

image.png

菜单组件的封装代码在 basic-menu 文件夹中

到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完

因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

交流讨论

文章如有错误或需要改进之处,欢迎指正

【配置化 CRUD 01】搜索重置组件:封装与复用

一:前言

在后台管理系统的配置化 CRUD 开发中,搜索+重置是高频组合场景,几乎所有列表页都需要通过搜索筛选数据、通过重置恢复初始查询状态等等...。基于此,本文将详细讲解「搜索重置组件」的封装思路及使用方法,该组件基于Vue3 + Element Plus 开发,支持配置化扩展、响应式联动,可直接集成到配置化 CRUD 体系中,提升开发效率与代码一致性。

二:解决问题

封装搜索重置组件主要为了解决以下几个问题:

1.代码冗余 : 每个列表页都要重复编写表单结构、搜索按钮、重置按钮,以及对应的点击事件、数据校验逻辑;

2.风格不统一:不同开发人员编写的搜索表单,在布局、按钮尺寸、标签宽度、间距等细节上可能存在差异;

3.维护成本高:当需要修改搜索表单的布局、按钮样式,需要逐个页面排查修改;

...

三:具体实现

在开始之前,请先阅读一下本专栏的第一篇文章,动态表单的实现是搜索重置组件的基础:

juejin.cn/post/757946…

image.png

接下来我们可以思考一下一个通用的搜索重置组件具备的基本功能

1.搜索项的展示

2.搜索项默认展示搜索初始值

3.搜素与重置按钮功能

...

扩展功能:

1.搜索表单项的联动

2.搜索表单项的校验

...

接下来我们一步一步来实现:

3.1 基础功能实现

先完成最简单的部分 : 展示搜索项和搜索重置按钮、以及基本样式统一处理

组件基础实现:


type SearchPrams = {
  schema?: FormOptions[] // 配置表
  search?: () => void  // 搜索回调
  reset?: () => void   // 重置回调
  labelWidth?: string,
  flex?: number
}
const props = withDefaults(defineProps<SearchPrams>(), {
  schema: {},
  labelWidth:'140px',
  flex:5
})

const codeFormRef = ref(null)

//搜索
const search = async () => {
  const data = await codeFormRef?.value?.getData()
  emits('search', data)
}

//重置
const reset = () => {
  codeFormRef?.value?.resetFields('')
  emits('reset', {})
}

 <div class="sea-box">
    <CodeForm
      class="form-box"
      :style="{ flex: props?.flex || 5 }"
      layoutType="cell"
      ref="codeFormRef"
      :schema="schema"
      :labelWidth="props.labelWidth"
    >
    </CodeForm>
    <div class="sea-btn-box">
      <div>
        <ElButton
          type="primary"
          :style="{ width: '80px' }"
          @click="search"
          >{{ $t('Search') }}</ElButton
        >
        <ElButton
          :style="{ width: '80px', marginLeft: '15px' }"
          @click="reset"
          >{{ $t('Reset') }}</ElButton
        >
      </div>
    </div>
  </div>
  
 <style scoped>
    .sea-btn-box {
      flex: 1;
      display: flex;
      justify-content: flex-end;
    }
    .form-box {
      flex: 5;
    }
    .sea-box {
      display: flex;
      padding: 20px;
      padding-bottom: 0;
      padding-top: 0;
    }
</style>

外部定义配置表:

  const searchColumn = [
      {
        label: '姓名',
        prop: 'name',
        component: 'Input',
      },
      {
        label: '年龄',
        prop: 'age',
        component: 'Input',
      },
      {
        label: '上学阶段',
        prop: 'jieduan',
        component: 'Select',
        componentProps: {
            options: [
              {
                label: '幼儿园',
                value: 1
              },
              {
                label: '其他阶段',
                value: 2
              }
            ]
          }
      },
]

引入组件使用:

 <Search
    :schema="allshema.searchcolumns"
    @search="(params) => console.log('点击查询:',{params})"
    @reset="() => setSearchParams({}, true, true)"
   >
</Search>

运行截图:

image.png

到这一步我们就已经实现了基本功能:展示表单、统一风格、查询重置

当然我们可能会想要某些表单项具有初始值,或者不展示重置按钮,只要组件内部稍加改造一下就行:


type SearchPrams = {
  showSearch?: boolean // 展示搜索按钮
  showReset?: boolean // 展示重置按钮
  schema?: any // 配置表
  search?: () => any
  reset?: () => any
  labelWidth?: string,
  flex?: number
}

 <div class="sea-btn-box">
      <div>
        <ElButton
          v-if="showSearch"
          type="primary"
          :style="{ width: '80px' }"
          @click="search"
          >{{ $t('Search') }}</ElButton
        >
        <ElButton
          v-if="showReset"
          :style="{ width: '80px', marginLeft: '15px' }"
          @click="reset"
          >{{ $t('Reset') }}</ElButton
        >
      </div>
 </div>

外部引入:

const searchColumn = [
      {
        label: '姓名',
        prop: 'name',
        initValue: '初始化名字',
        component: 'Input',
      },
      ...
  ]
  <Search
     :schema="searchColumn"
     @search="(params) => console.log('点击查询:',{params})"
     :showReset="false"
    >
 </Search>
 

运行截图:

image.png

这样就实现了按钮的展示与隐藏以及初始化默认值。

3.2 扩展功能实现

接下来我们继续实现一下扩展功能:

1.表单项的联动

利用动态表单组件内置的 setValues、setSchemas方法,

组件内部增加方法定义及暴露:


const setValues = (data: any) => {
  codeFormRef?.value?.setValues(data)
}

const setSchemas = (data: any) => {
  codeFormRef?.value?.setSchemas(data)
}

defineExpose({
  getData,
  setValues,
  setSchemas
})

外部增加搜索组件的ref引用:

const searchRef: any = ref(null)

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                prop: 'age',
                path: 'componentProps.placeholder',
                value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
  },
  ...
]

<Search
   ref="searchRef"
   :schema="allshema.searchcolumns"
   @search="setSearchParams"
   @reset="() => setSearchParams({}, true, true)"
  >
</Search>

运行截图:

image.png

这样就实现了搜索表单项之间的联动。

2.表单项的校验

组件内部改动:

type SearchPrams = {
  showSearch?: boolean // 展示搜索
  showReset?: boolean // 展示重置按钮
  isVaildSearch?: boolean // 是否校验搜索
  schema?: any // 配置表
  search?: () => any
  reset?: () => any
  labelWidth?: string,
  flex?: number
}

const props = withDefaults(defineProps<SearchPrams>(), {
  showSearch: true,
  showReset: true,
  isVaildSearch: false,
  schema: {}, // 表单配置
  labelWidth:'140px',
  flex:5
})

const search = async () => {
  if(props.isVaildSearch) {
    const valid = await codeFormRef?.value?.validate();
    if(!valid) return;
  }
  const data = await codeFormRef?.value?.getData()
  emits('search', data)
}

外部引入使用:

const searchColumn = [
    ...,
    {
       label: '年龄',
       prop: 'age',
       component: 'Input',
       formItemProps: {
         rules:[
             {
               required: true,
               message: '请输入年龄',
               trigger: 'blur'
             }
          ]
       }
    },
    ...
]

<Search
    ref="searchRef"
    :schema="searchColumn"
    @search="(params) => console.log('点击查询:',{params})"
    :showReset="false"
    :isVaildSearch="true"
   >
</Search>

运行截图:

image.png

这样就实现了搜索表单项的表单校验。

以上就是搜索重置组件的核心实现步骤~

基于Web Component的React与Vue跨栈系统融合实践

基于Web Component的React与Vue跨栈系统融合实践

一、背景与需求

最近一直会有一些这样的需求, 两套完全独立的前端系统,分别基于React和Vue框架开发,用户体系及鉴权体系独立,本次测试将尝试把Vue系统嵌入React中,实现核心交互逻辑:点击切换至React系统时,侧边栏(Aside)渲染React菜单,内容区(Content)加载React组件;切换至Vue系统时,侧边栏与内容区同步渲染Vue对应的菜单及组件,形成视觉与功能统一的集成体验,基础UI如下图:

ScreenShot_2026-01-28_155041_722.png

二、技术环境

  • Vue技术栈:Vue3 + Vite.js + UnoCss + TypeScript (Vue项目用的是开源的)

  • React技术栈:React17 + Webpack + Sass + TypeScript (React项目是自有的)

  • 后端及部署:Spring Boot + JAVA17 + Docker + MySQL + Redis (Vue项目后台)

三、方案选型

目前微前端领域已有qiankun.js、MicroApp等成熟方案,但也又一定的局限性,本次实践旨在探索更轻量化的浏览器原生方案——Web Component。作为W3C制定的浏览器原生组件化标准,Web Component具备跨框架UI复用与封装能力,无需依赖第三方框架,可天然实现不同技术栈的融合。

四、工程改造实现

4.1 Vue工程改造(Web Component打包)

核心目标是将Vue项目打包为可被React调用的Web Component自定义元素,需新增专属入口文件并配置打包规则。

4.1.1 新增Web Component入口文件

创建src/web-component-entry.ts作为打包入口,封装Vue应用为自定义元素,实现组件的挂载、卸载与属性监听,以下是伪代码:

// src/web-component-entry.ts
import App from "./App.vue";
import { createApp, h } from "vue";

class VueWebComponentElement extends HTMLElement {
  private _app: any = null;
  private _reactToken: string = "";

  // 定义需要监听的属性
  static get observedAttributes() {
    return ["mode"];
  }

  constructor() {
    super();
    // 监听来自React的事件
    this.addEventListener("app-changed", (e: CustomEvent) => {
      const { token } = e.detail;
      this._reactToken = token;
    });
  }

  async connectedCallback() {
    if (this._app) return;
    // 创建挂载容器并设置样式
    const rootNode = document.createElement("div");
    rootNode.setAttribute("id", "app-vue");
    rootNode.style.height = "100%";
    this.appendChild(rootNode);

    // 获取属性并初始化Vue应用
    const mode = this.getAttribute("mode") || "full";
    const app = createApp({
      render() {
        return h(App, { mode });
      },
    });

    // 比如挂载Vue生态依赖(权限、指令、全局组件、Store、Router等)
    app.mount(rootNode);
    this._app = app;
  }

  // 属性变化回调
  attributeChangedCallback(name: string, oldValue: string, newValue: string) {
    // 可根据属性变化执行对应逻辑(如样式切换、数据更新)
  }

  // 组件卸载回调
  disconnectedCallback() {
    if (this._app) {
      this._app.unmount();
      delete this._app;
    }
  }
}

// 定义自定义元素(避免重复定义)
if (!customElements.get("wc-pvue")) {
  customElements.define("wc-pvue", VueWebComponentElement);
}

export default VueWebComponentElement;
4.1.2 Vite打包配置调整

vite.config.ts中新增Web Component打包模式,指定输出格式、入口文件及资源命名规则:

// vite.config.ts部分配置
import { defineConfig, loadEnv, resolve } from "vite";
import vue from "@vitejs/plugin-vue";

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd());
  const isWebComponent = env.VITE_BUILD_MODE === "webcomponent";

  return {
    plugins: [vue()],
    build: {
      minify: "terser",
      // 区分Web Component打包目录
      outDir:
        env.VITE_OUT_DIR && isWebComponent
          ? `${env.VITE_OUT_DIR}/web-component`
          : env.VITE_OUT_DIR || "dist",
      sourcemap: env.VITE_SOURCEMAP === "true" ? "inline" : false,
      terserOptions: {
        compress: {
          drop_debugger: env.VITE_DROP_DEBUGGER === "true",
          drop_console: env.VITE_DROP_CONSOLE === "true",
        },
      },
      // Web Component专属打包配置
      ...(isWebComponent
        ? {
            lib: {
              entry: resolve(__dirname, "src/web-component-entry.ts"),
              name: "PVue",
              fileName: "pvue",
              formats: ["umd"], // 输出UMD格式,兼容浏览器环境
            },
            rollupOptions: {
              output: {
                entryFileNames: "pvue.js",
                assetFileNames: "pvue.[ext]",
              },
            },
          }
        : {}),
    },
  };
});

注:为简化测试,当前配置未分离Vue运行时依赖,导致最终UMD文件体积偏大。若需优化体积,可通过external配置排除Vue核心依赖,但需在React项目中同步引入对应依赖,确保Vue应用运行环境完整。

4.2 React工程改造(集成Web Component)

React端需通过布局组件控制系统切换逻辑,同时引入Vue打包后的资源文件。

4.2.1 布局组件改造

layout.tsx中通过状态控制渲染逻辑,切换至Vue系统时加载自定义元素<wc-pvue />

import React, { useState } from "react";
import { Layout } from "antd"; // 假设使用Ant Design布局组件
import SiderMenu from "./SiderMenu";
import Header from "./Header";
import styles from "./layout.module.sass";

const AppLayout = ({ children }: { children: React.ReactNode }) => {
  const [app, setApp] = useState<"react" | "vue">("react");

  // 系统切换回调
  const onAppChanged = (targetApp: "react" | "vue") => {
    setApp(targetApp);
    // 延迟发送事件,确保Vue组件已渲染
    setTimeout(() => {
      const wcEl = document.querySelector("wc-pvue");
      wcEl?.dispatchEvent(
        new CustomEvent("app-changed", {
          detail: {
            token: (cache.getCache("accessInfo", "session") as any)
              ?.accessToken,
          },
          bubbles: true,
          composed: true, // 允许事件穿透Shadow DOM
        }),
      );
    }, 500);
  };

  return (
    <Layout className={styles["app-layout-wrapper"]}>
      <Header onAppChanged={onAppChanged} />
      {app === "react" ? (
        <Layout className={styles["app-content-wrapper"]}>
          <SiderMenu />
          <Layout>{children}</Layout>
        </Layout>
      ) : (
        // 加载Vue对应的Web Component
        <wc-pvue />
      )}
    </Layout>
  );
};

export default AppLayout;
4.2.2 引入Vue资源

在React项目的index.html中引入Vue打包后的CSS与JS文件,确保自定义元素可正常渲染:


<!-- 引入Vue Web Component样式 -->
<link rel="stylesheet" href="vue/pvue.css" /<!-- 引入Vue Web Component脚本 -->

至此,基础嵌入功能实现完成,可通过切换菜单验证两侧系统的渲染效果。

五、关键技术点突破

5.1 样式隔离与覆盖

Web Component天然支持Shadow DOM,可构建独立DOM树实现样式隔离,避免与React主系统样式冲突;Vue端也可通过Scoped CSS限定样式作用域。但实际业务中常需覆盖子系统样式,结合本次Vue项目使用UnoCSS及CSS变量的特性,采用变量覆盖方案实现样式定制:

wc-pvue {
  height: 100%;
  /* 覆盖Vue项目内部CSS变量 */
  --app-footer-height: 0px;
  --tags-view-height: 0px;
  --top-tool-height: 0px;

  /* 隐藏Vue项目中不需要的元素 */
  #v-tool-header,
  #v-tags-view {
    display: none;
  }
}

样式覆盖需结合项目实际场景调整:若无法通过CSS变量或选择器覆盖,需修改Vue项目源码;若涉及主题切换等动态需求,可通过自定义元素属性传递状态,在Vue端监听属性变化同步更新样式。

5.2 跨框架消息通讯

UI层嵌入仅完成视觉整合,跨框架逻辑协同的核心在于消息通讯。常用方案包括全局状态共享(挂载至window)、属性传递、事件驱动等,本次实践采用浏览器原生CustomEvent实现解耦式通讯。

前文实现了React向Vue发送事件传递Token,但通过setTimeout规避渲染时机问题的方案存在不稳定性。更优实践为Vue主动发起通讯:在Vue组件的connectedCallback生命周期中发送就绪事件,React监听该事件后再传递数据,确保渲染与通讯时序一致:


// Vue端:web-component-entry.ts 中修改connectedCallback
async connectedCallback() {
  // 省略原有挂载逻辑...
  // 组件挂载完成后通知React
  this.dispatchEvent(
    new CustomEvent('vue-ready', {
      bubbles: true,
      composed: true
    })
  )
}

// React端:layout.tsx 中监听事件
useEffect(() => {
  const handleVueReady = () => {
    const wcEl = document.querySelector('wc-pvue')
    wcEl?.dispatchEvent(
      new CustomEvent('app-changed', {
        detail: { token: (cache.getCache('accessInfo', 'session') as any)?.accessToken },
        bubbles: true,
        composed: true
      })
    )
  }
  document.addEventListener('vue-ready', handleVueReady)
  return () => document.removeEventListener('vue-ready', handleVueReady)
}, [])

六、实践总结与待解决问题

基于Web Component可实现React与Vue跨栈系统的基础融合,通过自定义元素封装、原生事件通讯、CSS变量覆盖等手段,满足核心交互与样式适配需求。但本次实践仍存在诸多待优化点:

  1. 路由兼容性:React采用BrowserRouter(HTML5 History模式),Vue采用HashRouter,两者路由规则冲突,且页面切换时HTML标题同步、路由守卫协同等问题未解决。可通过统一路由模式(如均采用History模式)、主应用接管路由分发实现兼容。

  2. 统一认证体系:两套系统原有独立登录权限机制,目前仅实现Token传递,未完成身份态同步、权限统一校验等功能,需设计跨系统认证中心或共享令牌机制。

  3. 第三方系统改造限制:本次实践基于可自由修改的开源Vue项目,若需嵌入第三方不可控Vue系统,无法进行源码改造,需探索无侵入式封装方案。

相较于qiankun等成熟微前端框架,Web Component也是一种更轻量化的选择方案, 具体实践依然要根据具体的项目情况来选择和评估。当然,后续抽空还会分享一种基于类似门户系统的iframe融合方案,但不会在浏览器打开新页签,大家还有哪些方案可以分享呢,欢迎留言讨论!

Vue3时间戳转换器实现方案

在线工具网址:see-tool.com/timestamp-c…

工具截图: 工具截图.png

一、核心功能设计

时间戳转换器包含三个主要模块:

  1. 实时时间戳显示: 自动刷新的当前时间戳(秒/毫秒)
  2. 时间戳转日期: 将Unix时间戳转换为可读日期格式
  3. 日期转时间戳: 将日期时间转换为Unix时间戳

二、实时时间戳显示实现

2.1 核心状态管理

// 响应式数据
const autoRefresh = ref(true)           // 自动刷新开关
const currentSeconds = ref(0)           // 当前秒级时间戳
const currentMilliseconds = ref(0)      // 当前毫秒级时间戳

let refreshInterval = null              // 定时器引用

2.2 更新时间戳逻辑

// 更新当前时间戳
const updateCurrentTimestamp = () => {
  if (!process.client) return           // SSR 保护
  const now = Date.now()                // 获取当前毫秒时间戳
  currentSeconds.value = Math.floor(now / 1000)  // 转换为秒
  currentMilliseconds.value = now
}

关键点:

  1. SSR 保护: 使用 process.client 判断,避免服务端渲染错误
  2. Date.now(): 返回毫秒级时间戳,性能优于 new Date().getTime()
  3. 秒级转换: 使用 Math.floor() 向下取整

2.3 自动刷新机制

// 监听自动刷新开关
watch(autoRefresh, (val) => {
  if (!process.client) return

  if (val) {
    updateCurrentTimestamp()            // 立即更新一次
    refreshInterval = setInterval(updateCurrentTimestamp, 1000)  // 每秒更新
  } else {
    if (refreshInterval) {
      clearInterval(refreshInterval)    // 清除定时器
      refreshInterval = null
    }
  }
})

关键点:

  1. 立即更新: 开启时先执行一次,避免1秒延迟
  2. 定时器管理: 关闭时清除定时器,防止内存泄漏
  3. 1秒间隔: setInterval(fn, 1000) 实现秒级刷新

2.4 生命周期管理

onMounted(() => {
  if (!process.client) return
  updateCurrentTimestamp()
  if (autoRefresh.value) {
    refreshInterval = setInterval(updateCurrentTimestamp, 1000)
  }
})

onUnmounted(() => {
  if (refreshInterval) {
    clearInterval(refreshInterval)      // 组件销毁时清理定时器
  }
})

说明:

  • 组件挂载时初始化时间戳和定时器
  • 组件卸载时必须清理定时器,防止内存泄漏

三、时间戳转日期实现

3.1 格式自动检测

// 检测时间戳格式(秒 or 毫秒)
const detectTimestampFormat = (ts) => {
  const str = String(ts)
  return str.length >= 13 ? 'milliseconds' : 'seconds'
}

判断依据:

  • 秒级时间戳: 10位数字 (如: 1706425716)
  • 毫秒级时间戳: 13位数字 (如: 1706425716000)
  • 临界点: 13位作为分界线

3.2 核心转换逻辑

const convertTimestampToDate = () => {
  if (!process.client) return
  if (!timestampInput.value.trim()) {
    safeMessage.warning(t('timestampConverter.notifications.enterTimestamp'))
    return
  }

  try {
    let ts = parseInt(timestampInput.value)

    // 自动检测或手动指定格式
    const format = tsInputFormat.value === 'auto'
      ? detectTimestampFormat(ts)
      : tsInputFormat.value

    // 统一转换为毫秒
    if (format === 'seconds') {
      ts = ts * 1000
    }

    const date = new Date(ts)

    // 验证日期有效性
    if (isNaN(date.getTime())) {
      safeMessage.error(t('timestampConverter.notifications.invalidTimestamp'))
      return
    }

    // ... 后续处理
  } catch (err) {
    safeMessage.error(t('timestampConverter.notifications.convertFailed'))
  }
}

关键点:

  1. 输入验证: 检查空值和有效性
  2. 格式统一: 统一转换为毫秒级时间戳
  3. 有效性检查: isNaN(date.getTime()) 判断日期是否有效
  4. 异常捕获: try-catch 保护,防止程序崩溃

3.3 时区处理

// 获取本地时区偏移
const getTimezoneOffset = () => {
  const offset = -date.getTimezoneOffset()  // 注意负号
  const hours = Math.floor(Math.abs(offset) / 60)
  const minutes = Math.abs(offset) % 60
  const sign = offset >= 0 ? '+' : '-'
  return `UTC${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}

说明:

  • getTimezoneOffset() 返回的是 UTC 与本地时间的分钟差
  • 返回值为正表示本地时间落后于 UTC,需要取反
  • 格式化为 UTC+08:00 形式
// 获取指定时区的偏移
const getTimezoneOffsetForZone = (timezone) => {
  if (timezone === 'local') {
    return getTimezoneOffset()
  }

  try {
    const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }))
    const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone }))
    const offset = (tzDate - utcDate) / (1000 * 60)
    const hours = Math.floor(Math.abs(offset) / 60)
    const minutes = Math.abs(offset) % 60
    const sign = offset >= 0 ? '+' : '-'
    return `GMT${sign}${hours}`
  } catch (e) {
    return ''
  }
}

关键技巧:

  • 使用 toLocaleString()timeZone 参数转换时区
  • 通过 UTC 和目标时区的时间差计算偏移量
  • 异常捕获处理无效时区名称

3.4 日期格式化输出

// 根据选择的时区格式化本地时间
let localTime = date.toLocaleString(
  locale.value === 'en' ? 'en-US' : 'zh-CN',
  { hour12: false }
)

if (tsOutputTimezone.value !== 'local') {
  try {
    localTime = date.toLocaleString(
      locale.value === 'en' ? 'en-US' : 'zh-CN',
      {
        timeZone: tsOutputTimezone.value === 'UTC' ? 'UTC' : tsOutputTimezone.value,
        hour12: false
      }
    )
  } catch (e) {
    // 时区无效时回退到本地时间
    localTime = date.toLocaleString(
      locale.value === 'en' ? 'en-US' : 'zh-CN',
      { hour12: false }
    )
  }
}

格式化选项:

  • hour12: false: 使用24小时制
  • timeZone: 指定时区(如 'Asia/Shanghai', 'UTC')
  • 根据语言环境自动调整日期格式

3.5 年中第几天/第几周计算

// 计算年中第几天
const getDayOfYear = (d) => {
  const start = new Date(d.getFullYear(), 0, 0)  // 去年12月31日
  const diff = d - start
  const oneDay = 1000 * 60 * 60 * 24
  return Math.floor(diff / oneDay)
}

// 计算年中第几周
const getWeekOfYear = (d) => {
  const start = new Date(d.getFullYear(), 0, 1)  // 今年1月1日
  const days = Math.floor((d - start) / (24 * 60 * 60 * 1000))
  return Math.ceil((days + start.getDay() + 1) / 7)
}

算法说明:

  1. 年中第几天: 当前日期 - 去年最后一天 = 天数差
  2. 年中第几周: (天数差 + 1月1日星期几 + 1) / 7 向上取整

3.6 相对时间计算

// 相对时间(如: 3天前, 2小时后)
const getRelativeTime = (timestamp) => {
  if (!process.client) return ''

  const now = Date.now()
  const diff = now - timestamp
  const seconds = Math.abs(Math.floor(diff / 1000))
  const minutes = Math.floor(seconds / 60)
  const hours = Math.floor(minutes / 60)
  const days = Math.floor(hours / 24)

  const isAgo = diff > 0  // 是否是过去时间
  const units = tm('timestampConverter.timeUnits')

  let value, unit
  if (seconds < 60) {
    value = seconds
    unit = units.second
  } else if (minutes < 60) {
    value = minutes
    unit = units.minute
  } else if (hours < 24) {
    value = hours
    unit = units.hour
  } else {
    value = days
    unit = units.day
  }

  return isAgo
    ? t('timestampConverter.timeAgo', { value, unit })
    : t('timestampConverter.timeAfter', { value, unit })
}

逻辑分析:

  1. 时间差计算: 当前时间 - 目标时间
  2. 单位选择: 自动选择最合适的单位(秒/分/时/天)
  3. 方向判断: 正数为"前",负数为"后"
  4. 国际化: 使用 i18n 支持多语言

3.7 完整结果对象

const weekdays = tm('timestampConverter.weekdays')
const timezoneLabel = tsOutputTimezone.value === 'local'
  ? `${t('timestampConverter.localTimezone')} (${getTimezoneOffset()})`
  : `${tsOutputTimezone.value} (${getTimezoneOffsetForZone(tsOutputTimezone.value)})`

tsToDateResult.value = {
  timezone: timezoneLabel,           // 时区信息
  local: localTime,                  // 本地时间
  utc: date.toUTCString(),          // UTC 时间
  iso: date.toISOString(),          // ISO 8601 格式
  relative: getRelativeTime(ts),    // 相对时间
  dayOfWeek: weekdays[date.getDay()],  // 星期几
  dayOfYear: getDayOfYear(date),    // 年中第几天
  weekOfYear: getWeekOfYear(date)   // 年中第几周
}

四、日期转时间戳实现

4.1 设置当前时间

// 设置为当前时间
const setToNow = () => {
  if (!process.client) return
  const now = new Date()
  const year = now.getFullYear()
  const month = String(now.getMonth() + 1).padStart(2, '0')
  const day = String(now.getDate()).padStart(2, '0')
  const hours = String(now.getHours()).padStart(2, '0')
  const minutes = String(now.getMinutes()).padStart(2, '0')
  const seconds = String(now.getSeconds()).padStart(2, '0')
  dateTimeInput.value = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}

格式化技巧:

  • padStart(2, '0'): 补齐两位数(如: 9 → 09)
  • 月份需要 +1 (getMonth() 返回 0-11)
  • 格式: YYYY-MM-DD HH:mm:ss

4.2 核心转换逻辑

const convertDateToTimestamp = () => {
  if (!process.client) return

  if (!dateTimeInput.value) {
    safeMessage.warning(t('timestampConverter.notifications.selectDateTime'))
    return
  }

  try {
    const date = new Date(dateTimeInput.value)

    // 验证日期有效性
    if (isNaN(date.getTime())) {
      safeMessage.error(t('timestampConverter.notifications.invalidDateTime'))
      return
    }

    // 根据时区调整
    let finalDate = date

    if (dateInputTimezone.value === 'UTC') {
      // UTC 时区: 需要加上本地时区偏移
      finalDate = new Date(date.getTime() + date.getTimezoneOffset() * 60000)
    } else if (dateInputTimezone.value !== 'local') {
      // 其他时区: 计算时区差异
      const localDate = date
      const tzString = localDate.toLocaleString('en-US', {
        timeZone: dateInputTimezone.value
      })
      const tzDate = new Date(tzString)
      const offset = localDate.getTime() - tzDate.getTime()
      finalDate = new Date(localDate.getTime() - offset)
    }

    const ms = finalDate.getTime()
    const seconds = Math.floor(ms / 1000)

    dateToTsResult.value = {
      seconds,                    // 秒级时间戳
      milliseconds: ms,           // 毫秒级时间戳
      iso: finalDate.toISOString()  // ISO 8601 格式
    }

    safeMessage.success(t('timestampConverter.notifications.convertSuccess'))
  } catch (err) {
    safeMessage.error(t('timestampConverter.notifications.convertFailed'))
  }
}

时区处理详解:

  1. 本地时区 (local):

    • 直接使用用户输入的日期时间
    • 不做任何调整
  2. UTC 时区:

    • 用户输入的是 UTC 时间
    • 需要加上 getTimezoneOffset() 转换为本地时间戳
    • 例: 输入 "2024-01-01 00:00:00 UTC" → 北京时间 "2024-01-01 08:00:00"
  3. 其他时区 (如 Asia/Tokyo):

    • 计算目标时区与本地时区的偏移量
    • 通过 toLocaleString() 转换时区
    • 调整时间戳以反映正确的时间

4.3 时区转换原理

// 示例: 将 "2024-01-01 12:00:00" 从东京时区转换为时间戳

// 步骤1: 创建本地时间对象
const localDate = new Date('2024-01-01 12:00:00')  // 假设本地是北京时间

// 步骤2: 转换为东京时区的字符串
const tzString = localDate.toLocaleString('en-US', { timeZone: 'Asia/Tokyo' })
// 结果: "1/1/2024, 1:00:00 PM" (东京比北京快1小时)

// 步骤3: 将字符串解析为日期对象
const tzDate = new Date(tzString)

// 步骤4: 计算偏移量
const offset = localDate.getTime() - tzDate.getTime()
// offset = -3600000 (负1小时的毫秒数)

// 步骤5: 应用偏移量
const finalDate = new Date(localDate.getTime() - offset)

核心思想:

  • 通过两次转换计算时区差异
  • 利用偏移量调整时间戳
  • 确保时间戳代表的是正确的绝对时间

五、Date 对象核心 API 总结

6.1 创建日期对象

// 当前时间
new Date()                          // 当前日期时间
Date.now()                          // 当前时间戳(毫秒)

// 从时间戳创建
new Date(1706425716000)             // 毫秒时间戳
new Date(1706425716 * 1000)         // 秒时间戳需要 * 1000

// 从字符串创建
new Date('2024-01-28')              // ISO 格式
new Date('2024-01-28 12:00:00')     // 日期时间
new Date('Jan 28, 2024')            // 英文格式

// 从参数创建
new Date(2024, 0, 28)               // 年, 月(0-11), 日
new Date(2024, 0, 28, 12, 0, 0)     // 年, 月, 日, 时, 分, 秒

6.2 获取日期信息

const date = new Date()

// 获取年月日
date.getFullYear()      // 年份 (2024)
date.getMonth()         // 月份 (0-11, 0=1月)
date.getDate()          // 日期 (1-31)
date.getDay()           // 星期 (0-6, 0=周日)

// 获取时分秒
date.getHours()         // 小时 (0-23)
date.getMinutes()       // 分钟 (0-59)
date.getSeconds()       // 秒 (0-59)
date.getMilliseconds()  // 毫秒 (0-999)

// 获取时间戳
date.getTime()          // 毫秒时间戳
date.valueOf()          // 同 getTime()

// 时区相关
date.getTimezoneOffset()  // 本地时区与 UTC 的分钟差

6.3 设置日期信息

const date = new Date()

// 设置年月日
date.setFullYear(2024)
date.setMonth(0)        // 0-11
date.setDate(28)

// 设置时分秒
date.setHours(12)
date.setMinutes(30)
date.setSeconds(45)
date.setMilliseconds(500)

// 设置时间戳
date.setTime(1706425716000)

6.4 格式化输出

const date = new Date()

// 标准格式
date.toString()         // "Sun Jan 28 2024 12:00:00 GMT+0800 (中国标准时间)"
date.toDateString()     // "Sun Jan 28 2024"
date.toTimeString()     // "12:00:00 GMT+0800 (中国标准时间)"

// ISO 格式
date.toISOString()      // "2024-01-28T04:00:00.000Z"
date.toJSON()           // 同 toISOString()

// UTC 格式
date.toUTCString()      // "Sun, 28 Jan 2024 04:00:00 GMT"

// 本地化格式
date.toLocaleString()           // "2024/1/28 12:00:00"
date.toLocaleDateString()       // "2024/1/28"
date.toLocaleTimeString()       // "12:00:00"

// 自定义本地化
date.toLocaleString('zh-CN', {
  year: 'numeric',
  month: '2-digit',
  day: '2-digit',
  hour: '2-digit',
  minute: '2-digit',
  second: '2-digit',
  hour12: false,
  timeZone: 'Asia/Shanghai'
})

反应力测试应用

项目概述

这是一个基于 Nuxt 3 构建的认知能力测试应用,旨在通过两个经典的实验测试用户的反应时间和注意力控制能力。应用采用现代化的 Vue 3 技术栈开发,所有数据均本地存储,保护用户隐私。

image.png

image.png

image.png

在线体验:younglina.wang/reaction

核心特性

  • 🎨 现代化UI设计 - 提供优雅的视觉体验
  • 双重测试模式 - 包含颜色反应测试和 Simon Task 两套完整的认知测试
  • 📊 数据可视化 - 使用折线图实现反应时间趋势图表和统计分布
  • 💾 本地数据存储 - 基于 localStorage 的客户端数据持久化
  • 📱 响应式设计 - 完美适配桌面和移动设备
  • 🔒 隐私保护 - 所有数据保存在用户本地设备,不上传服务器

技术架构

技术栈

  • 前端框架: Nuxt 3.14.0
  • UI框架: Vue 3.5.0
  • 开发语言: TypeScript 5.0.0
  • 样式方案: Tailwind CSS 3.4.0
  • 图表库: Chart.js 4.4.0 + vue-chartjs 5.3.0
  • 构建工具: Vite (内置于 Nuxt 3)
  • 包管理器: npm

功能详解

1. 颜色反应测试 (Color Test)

颜色反应测试是评估简单视觉反应时间的经典实验。

测试原理

  1. 屏幕初始显示蓝色背景
  2. 随机延迟一定时间后变为红色
  3. 用户点击屏幕
  4. 系统记录反应时间(毫秒)

核心实现

const startTest = () => {

  // 设置随机延迟 (1-4秒)
  const delay = Math.floor(Math.random() * 3000) + 1000

  changeTimer = setTimeout(() => {

    changeTimestamp = performance.now()

    backgroundColor.value = 'bg-red-500'

    displayStatus.value = '点击!'

  }, delay)

}

const handleClick = () => {

  if (!isRunning) return

  // 计算反应时间
  const rt = Math.round(performance.now() - changeTimestamp)

  reactionTimes.push(rt)

  recalcStats()

  saveStats()

}

数据统计

  • 测试次数: 累计测试总数
  • 平均反应时间: 所有测试的平均值
  • 最快反应时间: 历史最佳成绩
  • 趋势图表: 折线图显示反应时间变化趋势

2. Simon Task

Simon Task 是测试认知控制和注意力分配的经典实验。

测试原理

  • 规则: 出现红色点击左边,出现绿色点击右边
  • 干扰: 颜色可能出现在屏幕左侧或右侧
  • 目标: 测试用户是否能克服空间位置干扰,正确执行颜色-动作映射

统计指标

  • 正确率: 正确反应百分比
  • 平均反应时间: 正确反应的平均时间
  • 错误次数: 测试过程中的错误反应
  • 进度跟踪: 当前测试进度(12次试验)

数据可视化

项目使用 Chart.js 的折线图与柱状图实现数据可视化。

许可证

MIT License 源码地址:github.com/Younglina/r…

❌