阅读视图

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

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…

vue3+vite+ts创建项目-企业级

Vue3+Vite+TS 企业级项目搭建完整指南(含多环境配置)

本文基于 Vue3、Vite4+、TypeScript 构建企业级项目,整合多环境配置、代码规范、自动导入、样式处理等核心功能,配置结构清晰可扩展,适配生产、开发、测试多场景需求。

一、初始化 Vue3 项目

采用 Vite 构建工具(比 Webpack 启动更快、热更新更高效,是 Vue3 官方推荐方案),快速初始化项目并集成 TypeScript。

步骤 1:执行初始化命令

# 使用 npm/cnpm/pnpm 均可,这里以 cnpm 为例
cnpm create vite@latest

# 若需指定版本,可执行:cnpm create vite

步骤 2:交互式配置项目

  1. 输入项目名:如 vite-vue3-ts-enterprise(建议英文,避免特殊字符)
  2. 选择框架:上下键切换至 Vue(默认适配 Vue3)
  3. 选择变体:切换至 TypeScript(集成 TS 类型校验)

步骤 3:安装依赖并启动项目

# 进入项目目录
cd vite-vue3-ts-enterprise

# 安装依赖(优先用项目包管理器,避免版本冲突)
cnpm install

# 启动开发环境
cnpm run dev

启动成功后,访问 http://localhost:5173 即可看到 Vue3 初始页面。Vite 默认端口为 5173,后续可在配置中修改。

二、基础配置(环境、别名、类型)

完善项目基础配置,解决路径别名、Node 类型、环境变量加载等核心问题,适配企业级开发习惯。

步骤 1:安装 Node 类型依赖

为 TS 提供 Node 环境类型定义,避免路径处理等操作时 TS 报错。

cnpm i @types/node --save-dev

步骤 2:配置 tsconfig.json

优化 TS 编译规则,添加路径别名、类型目录等配置,确保 TS 语法兼容 Vue3 单文件组件(SFC)。

{
  "compilerOptions": {
    "typeRoots": [
      "node_modules/@types", // 默认类型目录
      "src/types" // 自定义类型目录(后续可存放全局类型)
    ],
    "target": "ESNext", // 目标 ES 版本
    "useDefineForClassFields": true, // 适配 Vue3 类组件
    "module": "ESNext", // 模块规范
    "moduleResolution": "Node", // 模块解析方式
    "strict": true, // 开启严格模式(强制类型校验)
    "jsx": "preserve", // 保留 JSX 语法(适配 Vue3 JSX/TSX)
    "resolveJsonModule": true, // 允许导入 JSON 文件
    "isolatedModules": true, // 确保每个文件都是独立模块(Vite 要求)
    "esModuleInterop": true, // 兼容 CommonJS 模块
    "lib": ["ESNext", "DOM"], // 引入 ES 特性和 DOM 类型
    "skipLibCheck": true, // 跳过第三方库类型校验(提升编译速度)
    "noEmit": true, // 不生成编译产物(Vite 负责构建)
    "baseUrl": "./", // 基础路径
    "paths": { // 路径别名(简化导入,避免相对路径嵌套)
      "@": ["src"],
      "@/*": ["src/*"]
    }
  },
  "include": [ // 需要 TS 校验的文件
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "build/**/*" // 新增:让 TS 识别 build 文件夹下的配置文件
  ],
  "references": [
    { "path": "./tsconfig.node.json" } // 关联 Node 环境配置
  ]
}

步骤 3:配置多环境变量(env 文件夹)

创建独立 env 文件夹管理不同环境变量,实现开发、测试、生产环境隔离,避免硬编码。

1. 创建 env 文件夹及配置文件

# 根目录创建 env 文件夹
mkdir env

# 新建 3 个环境配置文件(对应开发、测试、生产)
touch env/.env.development
touch env/.env.test
touch env/.env.production

2. 编写各环境变量

Vite 环境变量需以 VITE_ 为前缀,否则无法在业务代码中访问。

  • env/.env.development(开发环境) # 开发环境 API 基础地址 `` VITE_API_BASE_URL=http://localhost:3000/api `` # 环境标识 `` VITE_ENV=development `` # 调试模式(开发环境开启) ``VITE_DEBUG=true
  • env/.env.test(测试环境) VITE_API_BASE_URL=https://test.api.example.com `` VITE_ENV=test ``VITE_DEBUG=false
  • env/.env.production(生产环境) VITE_API_BASE_URL=https://api.example.com `` VITE_ENV=production ``VITE_DEBUG=false

步骤 4:拆分多环境 Vite 配置(build 文件夹)

将 Vite 配置拆分为「基础配置+环境专属配置」,统一放入 build 文件夹,提升可维护性,后续新增环境可快速扩展。

1. 创建 build 文件夹及配置文件

# 根目录创建 build 文件夹(集中管理所有配置)
mkdir build

# 新建 3 个配置文件
touch build/vite.base.ts # 基础通用配置
touch build/vite.dev.ts  # 开发环境配置
touch build/vite.prod.ts # 生产环境配置

调整后根目录核心结构:

vite-vue3-ts-enterprise/
├── build/               # 配置文件目录
│   ├── vite.base.ts     # 基础配置(通用逻辑)
│   ├── vite.dev.ts      # 开发环境专属配置
│   └── vite.prod.ts     # 生产环境专属配置
├── env/                 # 环境变量目录
├── src/                 # 业务代码目录
├── public/              # 静态资源目录
├── package.json
├── tsconfig.json
└── tsconfig.node.json

2. 编写 build 文件夹下的配置文件

(1)build/vite.base.ts(基础通用配置)

抽取所有环境共用逻辑,如插件、别名、环境变量目录、静态资源处理等。

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; // Vue 单文件组件插件
import vueJsx from '@vitejs/plugin-vue-jsx'; // 支持 Vue3 JSX/TSX
import path from 'path';

export default defineConfig({
  // 环境变量目录(指定 env 文件夹,而非默认根目录)
  envDir: path.resolve(__dirname, '../env'),
  // 插件配置(所有环境共用插件)
  plugins: [
    vue(), // 解析 .vue 文件
    vueJsx() // 解析 .jsx/.tsx 文件
  ],
  // 路径解析配置
  resolve: {
    alias: {
      // 别名 @ 指向 src 目录(与 tsconfig.json 保持一致)
      '@': path.resolve(__dirname, '../src')
    }
  },
  // 基础构建配置
  build: {
    outDir: path.resolve(__dirname, '../dist'), // 打包输出目录
    assetsDir: 'assets', // 静态资源存放目录
    rollupOptions: {
      // 静态资源分类打包(按后缀名分组)
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name?.endsWith('.css')) {
            return 'css/[name].[hash:8].[ext]';
          }
          if (assetInfo.name?.match(/.(png|jpg|jpeg|gif|svg)$/i)) {
            return 'images/[name].[hash:8].[ext]';
          }
          return 'assets/[name].[hash:8].[ext]';
        },
        // JS 文件分类打包
        chunkFileNames: 'js/chunks/[name].[hash:8].js',
        entryFileNames: 'js/[name].[hash:8].js'
      }
    }
  }
});
(2)build/vite.dev.ts(开发环境配置)

补充开发环境独有逻辑,如热更新、代理、端口配置等,优化开发体验。

import { defineConfig ,mergeConfig} from 'vite';
import path from 'path';
import baseConfig from './vite.base';

export default mergeConfig(
  baseConfig,
  defineConfig({
    mode: 'development', // 开发模式
    server: {
      port: 8080, // 自定义开发端口(替换默认 5173)
      open: true, // 启动后自动打开浏览器
      hmr: { // 热模块替换(提升热更新速度)
        host: 'localhost',
        port: 8080
      },
      proxy: { // 接口代理(解决跨域问题)
        '/api': {
          target: process.env.VITE_API_BASE_URL, // 读取环境变量中的 API 地址
          changeOrigin: true, // 开启跨域代理
          rewrite: (path) => path.replace(/^/api/, '') // 重写路径(移除前缀 /api)
        }
      }
    },
    css: {
      devSourcemap: true // 开发环境生成 CSS SourceMap(便于调试样式)
    }
  })
);
(3)build/vite.prod.ts(生产环境配置)

补充生产环境优化逻辑,如压缩、清除控制台、SourceMap 控制等,提升打包产物性能。

import { defineConfig ,mergeConfig} from 'vite';
import { visualizer } from 'rollup-plugin-visualizer'; // 打包分析插件
import baseConfig from './vite.base';

export default mergeConfig(
  baseConfig,
  defineConfig({
    mode: 'production', // 生产模式
    build: {
      minify: 'terser', // 使用 terser 压缩代码(比默认 esbuild 压缩更彻底)
      sourcemap: false, // 生产环境关闭 SourceMap(保护源码,减小包体积)
      terserOptions: {
        compress: {
          drop_console: true, // 清除控制台打印(生产环境可选)
          drop_debugger: true // 清除 debugger 语句
        }
      }
    },
    plugins: [
      // 打包分析插件(可选,生成可视化报告,优化包体积)
      visualizer({
        open: false, // 不自动打开报告
        filename: path.resolve(__dirname, '../dist/analysis.html')
      })
    ]
  })
);

4. 更新 package.json 脚本

修改启动、打包脚本,指向 build 文件夹下的对应配置文件,实现按环境加载配置。

"scripts": {
  "dev": "vite --config build/vite.dev.ts", // 启动开发环境
  "build:dev": "vue-tsc -b && vite build --config build/vite.dev.ts", // 开发环境打包(测试用)
  "build:test": "vue-tsc -b && vite build --config build/vite.prod.ts --mode test", // 测试环境打包
  "build:prod": "vue-tsc -b && vite build --config build/vite.prod.ts", // 生产环境打包
  "type-check": "vue-tsc --noEmit", // TS 类型校验(不生成产物)
  "preview": "vite preview" // 预览打包产物
}

vue-tsc -b 用于在打包前执行 TS 类型校验,若存在类型错误则终止打包,避免带错上线;--mode 参数用于指定环境,匹配 env 文件夹下的配置文件。

三、集成代码规范工具(ESLint + Prettier)

统一代码风格,减少团队协作冲突,自动修复格式问题,确保代码质量。需先在 VS Code 安装 ESLintPrettier 插件。

步骤 1:安装依赖

# ESLint 核心及 Vue/TS 适配依赖
cnpm i eslint @eslint/js typescript-eslint @typescript-eslint/parser @typescript-eslint/plugin eslint-plugin-vue vue-eslint-parser -D

# Prettier 及 ESLint 兼容依赖(解决两者规则冲突)
cnpm i prettier eslint-config-prettier eslint-plugin-prettier -D

# Vite ESLint 插件(开发时实时校验)
cnpm i vite-plugin-eslint -D

步骤 2:配置 ESLint(eslint.config.js)

ESLint 8.21.0+ 支持扁平配置文件,适配 Vue3+TS 语法,集成 Prettier 规则。

import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginVue from "eslint-plugin-vue";
import prettier from "eslint-plugin-prettier";
import prettierConfig from "eslint-config-prettier";
import eslintParser from "vue-eslint-parser";

export default [
  // 配置忽略文件(替代传统 .eslintignore)
  {
    ignores: [
      "node_modules/**",
      "dist/**",
      "public/**",
      "build/**",
      "src/assets/**",
      "*.config.js",
      "*.config.ts"
    ]
  },
  // 基础配置(适配 Vue/TS 文件)
  {
    files: ["**/*.{js,mjs,cjs,vue,ts,tsx}"],
    languageOptions: {
      parser: eslintParser, // 解析 Vue 单文件组件
      parserOptions: {
        parser: "@typescript-eslint/parser", // 解析 TS 语法
        ecmaVersion: 2020,
        sourceType: "module"
      },
      globals: { ...globals.browser, ...globals.node } // 全局变量
    },
    plugins: {
      vue: pluginVue,
      "@typescript-eslint": tseslint.plugin,
      prettier: prettier // 集成 Prettier
    },
    rules: {
      // 基础规则
      "no-var": "error", // 禁止使用 var
      "no-console": process.env.NODE_ENV === "production" ? "error" : "off", // 生产环境禁止 console
      "no-multiple-empty-lines": ["warn", { max: 1 }], // 最多允许 1 行空行
      
      // Vue 规则
      "vue/multi-word-component-names": "off", // 关闭组件名多单词校验(灵活命名)
      "vue/valid-template-root": "off", // 允许模板根节点多元素
      
      // TS 规则
      "@typescript-eslint/no-explicit-any": "off", // 允许使用 any(可选,根据团队规范调整)
      "no-unused-vars": ["error", { "varsIgnorePattern": "Vue" }], // 忽略 Vue 未使用警告
      
      // Prettier 规则(将 Prettier 错误作为 ESLint 错误提示)
      "prettier/prettier": "error"
    }
  },
  // 集成推荐规则
  pluginJs.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs["flat/essential"],
  ...pluginVue.configs["flat/recommended"],
  prettierConfig // 覆盖 ESLint 与 Prettier 冲突的规则
];

步骤 3:配置 Prettier(.prettierrc.js)

定义代码格式化规则,与 ESLint 规则兼容,统一团队代码风格。

module.exports = {
  printWidth: 80, // 一行最多 80 字符
  tabWidth: 2, // 2 个空格缩进(与 Vue 官方一致)
  useTabs: false, // 不使用 Tab 缩进
  semi: true, // 行尾添加分号
  singleQuote: true, // 使用单引号
  quoteProps: "as-needed", // 对象 key 仅必要时加引号
  jsxSingleQuote: false, // JSX 中使用双引号
  trailingComma: "all", // 对象/数组末尾添加逗号(便于 diff)
  bracketSpacing: true, // 大括号内保留空格 { foo: bar }
  jsxBracketSameLine: false, // JSX 闭合标签换行
  arrowParens: "always", // 箭头函数单参数也加括号 (x) => x
  endOfLine: "auto" // 自动适配系统换行符
};

步骤 4:配置 VS Code 自动格式化(.vscode/settings.json)

实现保存时自动修复 ESLint 错误并执行 Prettier 格式化,提升开发效率。

{
  "eslint.enable": true,
  "eslint.format.enable": true,
  "editor.quickSuggestions": true,
  "eslint.validate": ["javascript", "javascriptreact", "vue-html", "typescript", "html", "vue"],
  "eslint.options": {
    "extensions": [".js", ".jsx", ".ts", ".tsx", ".vue"]
  },
  "editor.formatOnSave": true, // 保存时格式化
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit" // 保存时自动修复 ESLint 错误
  },
  // 不同文件指定默认格式化工具
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

步骤 5:添加脚本命令

"scripts": {
  // 新增 ESLint/Prettier 命令
  "lint": "eslint "src/**/*.{js,ts,vue,tsx}" --fix", // 自动修复 ESLint 错误
  "prettier": "prettier --write "src/**/*.{js,ts,vue,tsx,json,css}"" // 自动格式化
}

四、集成 Git 提交规范(Husky + Lint-Staged + CommitLint)

约束 Git 提交信息,在提交前校验代码规范,避免不合格代码入库,保障代码仓库整洁。

步骤 1:初始化 Git 仓库(若未初始化)

git init

步骤 2:安装依赖

# Husky(Git Hook 工具)、Lint-Staged(暂存区代码校验)
cnpm i husky@9.1.2 lint-staged@^15.2.7 -D

# CommitLint(提交信息校验)
cnpm i @commitlint/cli@^19.3.0 @commitlint/config-conventional@^19.2.2 -D

步骤 3:配置 Husky

# 生成 .husky 文件夹(存储 Git Hook 脚本)
npx husky init

# 手动创建 pre-commit(提交前校验)和 commit-msg(提交信息校验)脚本
touch .husky/pre-commit
touch .husky/commit-msg

步骤 4:编写 Hook 脚本

  • .husky/pre-commit(提交前校验暂存区代码) #!/bin/sh `` . "$(dirname -- "$0")/_/husky.sh" ```` echo -e "\033[33m ------------- 正在校验暂存区代码规范 ---------------- \033[0m" ``npx --no-install lint-staged
  • .husky/commit-msg(校验提交信息格式) #!/bin/sh `` . "$(dirname -- "$0")/_/husky.sh" ```` echo -e "\033[33m ------------- 正在校验提交信息格式 ---------------- \033[0m" ``npx --no-install commitlint --edit "$1"

步骤 5:配置 Lint-Staged(package.json)

仅对暂存区代码执行校验和格式化,提升效率(避免全量校验)。

"lint-staged": {
  "src/**/*.{vue,js,jsx,ts,tsx}": [
    "eslint --fix", // 自动修复 ESLint 错误
    "prettier --write" // 格式化代码
  ],
  "src/**/*.{cjs,json,css}": [
    "prettier --write" // 格式化配置文件和样式文件
  ]
}

步骤 6:配置 CommitLint(commitlint.config.cjs)

由于 package.json 默认为 ES 模块,需用 .cjs 后缀声明 CommonJS 格式。

module.exports = {
  ignores: [commit => commit.includes('init')], // 忽略 init 初始化提交
  extends: ['@commitlint/config-conventional'], // 基础规范
  rules: {
    'body-leading-blank': [2, 'always'], // 提交描述主体前空行
    'footer-leading-blank': [1, 'always'], // 底部说明前空行
    'header-max-length': [2, 'always', 108], // 标题最大长度 108
    'subject-empty': [2, 'never'], // 标题不可为空
    'type-empty': [2, 'never'], // 类型不可为空
    'type-enum': [ // 允许的提交类型(规范提交场景)
      2,
      'always',
      [
        'wip', // 开发中
        'feat', // 新增功能
        'fix', // 修复 Bug
        'test', // 测试相关
        'refactor', // 代码重构
        'build', // 构建配置(如依赖、打包)
        'docs', // 文档更新
        'perf', // 性能优化
        'style', // 代码风格(不影响逻辑)
        'ci', // 持续集成配置
        'chore', // 琐事(如配置文件修改)
        'revert', // 回滚代码
        'types', // 类型声明更新
        'release' // 版本发布
      ]
    ]
  }
};

提交格式规范

git commit -m "<type>[optional scope]: <description>"

# 示例
git commit -m "feat[user]: 新增用户登录功能"
git commit -m "fix[api]: 修复用户列表接口跨域问题"

说明:type 为提交类型(必填),optional scope 为涉及模块(可选),description 为提交描述(必填,简洁明了)。

五、Vue3 专属插件集成(提升开发效率)

步骤 1:自动导入(API/组件)

无需手动导入 Vue 内置 API(如 ref、reactive)和全局组件,减少模板代码。

# 安装自动导入插件
cnpm i unplugin-auto-import unplugin-vue-components -D

# 若使用 UI 库(如 Ant Design Vue),需安装对应解析器
cnpm i unplugin-vue-components/resolvers -D

更新 build/vite.base.ts,添加插件配置:

import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'; // AntD Vue 解析器

export default defineConfig({
  plugins: [
    // ... 原有插件
    // 自动导入 Vue API
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'], // 自动导入的库
      dts: path.resolve(__dirname, '../src/auto-imports.d.ts'), // 生成类型声明文件
      eslintrc: {
        enabled: true // 生成 ESLint 配置,避免未导入警告
      }
    }),
    // 自动导入组件
    Components({
      dirs: [path.resolve(__dirname, '../src/components')], // 自定义组件目录
      extensions: ['vue', 'tsx'], // 组件后缀
      dts: path.resolve(__dirname, '../src/components.d.ts'), // 生成组件类型声明
      resolvers: [AntDesignVueResolver({ importStyle: false })], // UI 库组件自动导入
    })
  ]
});

步骤 2:PX 转 REM(适配多端)

实现移动端自适应,将 PX 自动转为 REM,替代手动计算。

cnpm i postcss @minko-fe/postcss-pxtorem autoprefixer -D

根目录创建 postcss.config.js

import pxtorem from '@minko-fe/postcss-pxtorem';
import autoprefixer from 'autoprefixer';

export default {
  plugins: [
    pxtorem({
      rootValue: 16, // 基准值(1rem = 16px,可根据设计稿调整)
      unitPrecision: 5, // 转换精度(保留 5 位小数)
      propList: ['*'], // 所有属性都转换
      selectorBlackList: ['no-rem'], // 类名含 no-rem 的不转换
      atRules: ['media'], // 媒体查询中的 PX 也转换
      exclude: /node_modules/ // 排除第三方库
    }),
    autoprefixer() // 自动添加 CSS 前缀(适配低版本浏览器)
  ]
};

步骤 3:SVG 组件化

将 SVG 图片转为 Vue 组件,支持按需引入和样式修改。

cnpm i vite-svg-loader -D

更新 build/vite.base.ts

import svgLoader from 'vite-svg-loader';

export default defineConfig({
  plugins: [
    // ... 原有插件
    svgLoader() // 解析 SVG 为 Vue 组件
  ]
});

使用方式:import Logo from '@/assets/logo.svg';,直接作为组件使用 <Logo />

六、项目测试与验证

  1. 开发环境启动cnpm run dev,验证热更新、接口代理、自动导入是否正常。
  2. 类型校验cnpm run type-check,确保无 TS 类型错误。
  3. 代码规范校验cnpm run lint,自动修复格式错误。
  4. 生产环境打包cnpm run build:prod,验证打包产物是否正常,体积是否合理。
  5. 提交测试:修改代码后执行 git add .git commit -m "test: 测试提交规范",验证 Husky 校验是否生效。

七、总结

本指南构建了一套企业级 Vue3+Vite+TS 项目架构,核心亮点:

  • 多环境配置拆分,环境隔离清晰,可快速扩展测试环境。
  • 完整代码规范体系,从开发到提交全流程约束,保障代码质量。
  • Vue3 专属插件集成,自动导入、自适应等功能提升开发效率。
  • 配置结构清晰,build 文件夹集中管理配置,便于后期维护。

可根据项目需求扩展 Pinia(状态管理)、Vue Router(路由)、单元测试等功能,适配更复杂的业务场景。

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

在 Vue3 编译优化体系中,Block Tree(块树)与 Fragment 根节点优化,是继静态提升(hoistStatic)、PatchFlag 之后的又一核心手段。如果说静态提升解决了静态内容的重复创建问题,PatchFlag 实现了动态内容的精准比对,那么 Block Tree 则通过对模板节点的分层归类,进一步缩小虚拟 DOM 比对范围,而 Fragment 根节点优化则解决了模板必须有唯一根节点的历史痛点,同时降低渲染冗余。本文将深度拆解二者的实现逻辑、协同关系及实战注意事项,完善 Vue3 编译优化知识体系。

一、先破局:Fragment 根节点优化(告别唯一根节点限制)

Vue2 中存在一个经典限制:模板必须有且仅有一个唯一根节点,否则编译器会报错。为了解决这一问题,开发者通常会用一个无意义的 <div></div> 标签包裹所有内容,这不仅增加了 DOM 层级冗余,还可能影响样式布局与渲染性能。Vue3 引入 Fragment 作为模板根节点解决方案,从根源上解决了这一痛点。1. Fragment 核心作用:虚拟根节点,无 DOM 冗余Fragment 本质是一个“虚拟根节点”,它仅作为模板的逻辑容器,编译后不会生成真实的 DOM 节点,既能满足模板多根节点的需求,又不会增加 DOM 层级。

<!-- Vue2 写法:需额外包裹无意义 div -->
<template>
  <div><!-- 冗余 DOM 节点 -->
    <p>文本内容 1</p>
    <p>文本内容 2</p>
  </div>
</template>

<!-- Vue3 写法:Fragment 作为根节点(无需显式声明) -->
<template>
  <p>文本内容 1</p>
  <p>文本内容 2&lt;/p&gt;
&lt;/template&gt;

<!-- 编译后逻辑(简化版) -->
// Vue3 自动用 Fragment 包裹多根节点
createVNode(Fragment, null, [
  createVNode('p', null, '文本内容 1'),
  createVNode('p', null, '文本内容 2')
])

Vue3 模板中,多根节点会被编译器自动包裹为 Fragment,无需开发者显式声明(也可通过 2. Fragment 与普通根节点的性能差异冗余的根节点不仅会增加 DOM 树的深度,还会在虚拟 DOM 比对、DOM 更新时产生额外开销:Vue2 中,冗余根节点会参与虚拟 DOM 比对,即使内部内容无变化,也需遍历该节点及属性。Vue3 Fragment 作为虚拟节点,不会生成真实 DOM,运行时会直接跳过 Fragment 节点的比对,仅处理其子节点,减少无意义的遍历开销。 3. Fragment 适用场景与注意事项适用场景:多根节点模板场景(如表单组件、列表项组件,需返回多个同级节点)。避免 DOM 层级冗余的场景(如嵌套较深的组件树,减少不必要的父容器)。注意事项:Fragment 仅支持 key 属性(用于列表渲染时的节点复用),不支持 classstyle 等属性(因无真实 DOM 节点承载)。 二、核心优化:Block Tree 块树机制(缩小比对范围)Block Tree 是 Vue3 为进一步优化虚拟 DOM 比对效率设计的分层结构。它基于“动态节点聚集”原则,将模板中的节点划分为不同的 Block(块),仅包含动态节点或动态节点父容器的 Block 会参与比对,静态 Block 则直接复用,大幅缩小比对范围。

1. Block 与普通 VNode 的区别

在 Vue3 编译后,节点会被分为两类:

  • 普通 VNode:静态节点(经静态提升处理),仅在初始化时创建一次,后续渲染直接复用,不参与比对。
  • Block VNode:标记为 Block 的节点,内部包含动态节点或动态子节点,会参与虚拟 DOM 比对,但仅比对内部的动态内容(结合 PatchFlag)。

Block 的核心特征是“包含动态内容”,编译器会自动将模板中含动态节点的最小父容器标记为 Block,形成以 Block 为核心的树状结构(Block Tree)。

2. Block Tree 构建逻辑(编译阶段)

编译器构建 Block Tree 的核心步骤的:

  1. 识别动态节点:扫描模板,标记所有含动态内容的节点(如插值、动态绑定、v-if/v-for 等),并为其打上 PatchFlag。
  2. 确定 Block 边界:以包含动态节点的最小父容器作为 Block 边界,将该父容器标记为 Block VNode,其内部的静态节点仍会被静态提升,动态节点则保留在 Block 内部。
  3. 构建层级结构:若 Block 内部还包含其他动态节点的父容器,会递归创建子 Block,最终形成多层级的 Block Tree。
<!-- 模板示例 -->
<template>
  <div class="container"><!-- 静态父容器,非 Block -->
    <h1>静态标题</h1> <!-- 静态节点,被提升 -->
    <div class="dynamic-wrap"> <!-- 包含动态节点,标记为 Block -->
      <p>Hello {{ name }}</p><!-- 动态节点(TEXT 标记) -->
      <button :class="activeClass">点击</button> <!-- 动态节点(CLASS 标记) -->
    </div>
  </div>
</template>

<!-- 编译后 Block Tree 逻辑(简化版) -->
const _hoisted_1 = createVNode('h1', null, '静态标题') // 静态提升
function render() {
  return createVNode('div', { class: 'container' }, [
    _hoisted_1,
    // 标记为 Block,内部包含动态节点
    createBlock('div', { class: 'dynamic-wrap' }, [
      createVNode('p', null, `Hello ${name}`, 1 /* TEXT */),
      createVNode('button', { class: activeClass }, '点击', 2 /* CLASS */)
    ])
  ])
}

3. Block Tree 运行时优化:精准比对,跳过静态层级

Vue3 虚拟 DOM 比对时,会直接遍历 Block Tree,跳过所有普通静态 VNode,仅在 Block 内部结合 PatchFlag 比对动态内容,实现“双重精准优化”:

  1. 层级精准:仅遍历 Block 节点组成的树,静态节点所在的层级直接跳过,无需逐层遍历。
  2. 内容精准:在 Block 内部,通过 PatchFlag 仅比对动态内容,静态内容复用已提升的 VNode。

对比 Vue2 全量遍历虚拟 DOM 树,Block Tree 使比对范围缩小至“仅动态内容所在的 Block 层级”,尤其在复杂组件树中,性能提升效果显著。

三、协同优化:Fragment、Block Tree 与 PatchFlag 的联动

Vue3 的编译优化并非单一特性,而是 Fragment、Block Tree、PatchFlag、静态提升四大技术协同作用,形成完整的优化闭环:

  1. 静态提升:提取静态节点,避免重复创建,为 Block Tree 分层奠定基础。
  2. Fragment:作为虚拟根节点,消除冗余 DOM,使 Block Tree 层级更贴合实际内容结构。
  3. Block Tree:划分动态内容层级,缩小虚拟 DOM 比对的范围,聚焦动态节点所在容器。
  4. PatchFlag:在 Block 内部精准标记动态内容类型,实现 Block 内的靶向更新。

// 协同优化后的完整渲染逻辑(简化版)
// 1. 静态提升:提取静态节点
const _hoisted_1 = createVNode('p', null, '静态文本')

// 2. Fragment 作为根节点,包裹多节点
// 3. Block Tree:dynamic-wrap 为 Block,包含动态节点
function render() {
  return createVNode(Fragment, null, [
    _hoisted_1,
    createBlock('div', { class: 'dynamic-wrap' }, [
      // 4. PatchFlag:标记文本动态变化
      createVNode('p', null, `Hello ${name}`, 1 /* TEXT */),
      // 4. PatchFlag:标记 class 动态变化
      createVNode('div', { :class="styleClass" }, '内容', 2 /* CLASS */)
    ])
  ])
}

// 运行时比对逻辑
function patchBlock(block) {
  // 仅遍历 Block 内部节点
  block.children.forEach(child => {
    if (child.patchFlag) {
      // 结合 PatchFlag 精准更新动态内容
      updateByPatchFlag(child)
    }
    // 静态节点跳过比对
  })
}

四、实战避坑:Block Tree 与 Fragment 优化要点

1. 避免过度拆分 Block

编译器会自动优化 Block 边界,但开发者需避免在模板中过度嵌套动态节点容器,否则会导致 Block 层级过多,反而增加遍历开销。建议尽量将相关动态内容集中在同一父容器下,减少 Block 数量。

2. Fragment 不可滥用 key

仅当 Fragment 用于 v-for 列表渲染时,才需要添加 key 属性;非列表场景的 Fragment 无需设置 key,否则会增加不必要的性能开销(key 需参与节点复用比对)。

3. 动态指令对 Block 的影响

v-if、v-for 等动态指令会强制其父容器成为 Block,因这些指令会导致节点数量、顺序变化。开发中需合理规划动态指令的位置,避免将其放在顶层容器,导致整个组件树成为单一 Block,丧失分层优化优势。

4. 静态内容与动态内容分离

尽量将静态内容与动态内容拆分到不同容器中,使静态内容能被充分提升,动态内容集中在少数 Block 中,最大化发挥 Block Tree 的优化效果。

五、性能收益:实测数据与场景价值

Vue3 官方基准测试及实际项目验证显示,Block Tree 与 Fragment 优化带来的性能提升显著:

  • 复杂组件树:虚拟 DOM 比对耗时降低 40%~60%,尤其嵌套层级超过 5 层时,优化效果更明显。
  • 多根节点组件:消除冗余 DOM 后,初始渲染速度提升 20%~30%,DOM 更新时的层级遍历开销减少。
  • 长列表场景:结合 v-for 与 Block Tree,仅更新变化项所在的 Block,避免全量列表比对,渲染耗时降低 50% 以上。

总结

Fragment 根节点优化解决了模板多根节点的历史痛点,消除了冗余 DOM 层级,为渲染优化扫清了结构障碍;Block Tree 则通过编译阶段的分层归类,将虚拟 DOM 比对从“全量遍历”升级为“Block 级精准遍历”,再结合 PatchFlag 与静态提升,构建起 Vue3 高效渲染的核心体系。

理解二者的底层逻辑与协同关系,不仅能帮助我们写出更符合 Vue3 优化逻辑的模板代码,还能在性能优化场景中精准定位问题——例如,当组件渲染卡顿的,可检查是否存在过度嵌套的 Block、未分离的静态/动态内容,或滥用冗余根节点等问题,通过调整模板结构最大化发挥 Vue3 的优化能力。

相关文章

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

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

🔥 Vue3 + TS 实现一键复制指令 v-copy:优雅解决文本复制需求

🔥 Vue3 + TS 实现一键复制指令 v-copy:优雅解决文本复制需求

在前端开发中,“一键复制”是高频使用的交互功能(如复制链接、订单号、邀请码等)。本文将教你基于 Vue3 + TypeScript 实现一个功能完善、体验友好、类型安全v-copy 自定义指令,支持自定义复制内容、复制成功/失败回调、复制提示等特性,开箱即用。 在这里插入图片描述

🎯 指令核心特性

  • ✅ 支持复制元素文本/自定义内容,灵活适配不同场景
  • ✅ 复制成功/失败回调,便于业务层处理交互反馈
  • ✅ 内置复制成功提示(可自定义),提升用户体验
  • ✅ 完整 TypeScript 类型定义,开发提示友好
  • ✅ 自动兼容剪贴板 API,低版本浏览器友好提示
  • ✅ 支持指令参数动态更新,适配动态内容
  • ✅ 无第三方依赖,轻量高效

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

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

/**
 * 复制指令配置接口
 */
export interface CopyOptions {
  /** 要复制的内容,优先级高于元素文本 */
  content?: string
  /** 复制成功回调 */
  onSuccess?: (text: string) => void
  /** 复制失败回调 */
  onError?: (error: Error) => void
  /** 复制成功提示文本,默认"复制成功" */
  successTip?: string
  /** 提示显示时长(ms),默认2000 */
  tipDuration?: number
  /** 是否显示复制提示,默认true */
  showTip?: boolean
}

/**
 * 扩展元素属性,存储复制相关状态
 */
interface CopyElement extends HTMLElement {
  _copy?: {
    options: CopyOptions
    tipElement?: HTMLDivElement // 提示元素
    tipTimer?: number | null    // 提示定时器
    clickHandler: (e: MouseEvent) => void // 点击事件处理函数
  }
}

/**
 * 默认配置
 */
const DEFAULT_OPTIONS: CopyOptions = {
  successTip: '复制成功',
  tipDuration: 2000,
  showTip: true
}

/**
 * 创建复制成功提示元素
 * @param el 目标元素
 * @param text 提示文本
 * @returns 提示元素
 */
const createTipElement = (el: CopyElement, text: string): HTMLDivElement => {
  // 若已有提示元素,先移除
  if (el._copy?.tipElement) {
    document.body.removeChild(el._copy.tipElement)
    if (el._copy.tipTimer) {
      clearTimeout(el._copy.tipTimer)
      el._copy.tipTimer = null
    }
  }

  // 创建提示元素
  const tip = document.createElement('div')
  tip.style.position = 'absolute'
  tip.style.padding = '4px 12px'
  tip.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'
  tip.style.color = '#fff'
  tip.style.borderRadius = '4px'
  tip.style.fontSize = '14px'
  tip.style.zIndex = '9999'
  tip.style.transition = 'opacity 0.3s ease'
  tip.textContent = text

  // 计算提示位置(目标元素中心)
  const rect = el.getBoundingClientRect()
  const top = rect.top + window.scrollY - 40
  const left = rect.left + window.scrollX + (rect.width - tip.offsetWidth) / 2

  tip.style.top = `${top}px`
  tip.style.left = `${left}px`

  return tip
}

/**
 * 显示复制提示
 * @param el 目标元素
 * @param text 提示文本
 * @param duration 显示时长
 */
const showCopyTip = (el: CopyElement, text: string, duration: number) => {
  if (!el._copy) return

  // 创建并挂载提示元素
  const tip = createTipElement(el, text)
  document.body.appendChild(tip)
  el._copy.tipElement = tip

  // 定时隐藏提示
  el._copy.tipTimer = window.setTimeout(() => {
    tip.style.opacity = '0'
    setTimeout(() => {
      document.body.removeChild(tip)
      el._copy!.tipElement = undefined
      el._copy!.tipTimer = null
    }, 300)
  }, duration) as unknown as number
}

/**
 * 核心复制逻辑
 * @param text 要复制的文本
 * @returns Promise<string> 复制成功的文本
 */
const copyToClipboard = async (text: string): Promise<string> => {
  // 优先使用 Clipboard API(现代浏览器)
  if (navigator.clipboard && window.isSecureContext) {
    try {
      await navigator.clipboard.writeText(text)
      return text
    } catch (err) {
      // 降级处理
      throw new Error(`剪贴板API复制失败: ${(err as Error).message}`)
    }
  }

  // 降级方案:创建临时textarea元素
  const textarea = document.createElement('textarea')
  textarea.value = text
  // 隐藏textarea
  textarea.style.position = 'absolute'
  textarea.style.opacity = '0'
  textarea.style.pointerEvents = 'none'
  document.body.appendChild(textarea)

  try {
    // 选中并复制
    textarea.select()
    textarea.setSelectionRange(0, textarea.value.length) // 兼容移动设备
    const success = document.execCommand('copy')
    if (!success) {
      throw new Error('execCommand复制失败')
    }
    return text
  } finally {
    // 清理临时元素
    document.body.removeChild(textarea)
  }
}

/**
 * 清理复制相关资源
 * @param el 目标元素
 */
const cleanup = (el: CopyElement) => {
  const copyData = el._copy
  if (!copyData) return

  // 移除点击事件
  el.removeEventListener('click', copyData.clickHandler)
  
  // 清理提示定时器和元素
  if (copyData.tipTimer) {
    clearTimeout(copyData.tipTimer)
    copyData.tipTimer = null
  }
  if (copyData.tipElement) {
    document.body.removeChild(copyData.tipElement)
    copyData.tipElement = undefined
  }
  
  // 删除扩展属性
  delete el._copy
}

// 创建独立的初始化函数
const initializeCopy = (el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) => {
  // 将 mounted 中的所有逻辑移动到这里
  // 1. 解析指令参数
  let options: CopyOptions = { ...DEFAULT_OPTIONS }
  
  if (typeof binding.value === 'string') {
    options.content = binding.value
  } else if (typeof binding.value === 'object' && binding.value !== null) {
    options = { ...DEFAULT_OPTIONS, ...binding.value }
  }
  // 2. 定义点击处理函数
  const clickHandler = async (e: MouseEvent) => {
    e.preventDefault()
    
    const copyText = options.content || el.textContent?.trim() || ''
    if (!copyText) {
      const error = new Error('无可用的复制内容')
      options.onError?.(error)
      console.warn('[v-copy] 无可用的复制内容')
      return
    }
    try {
      await copyToClipboard(copyText)
      options.onSuccess?.(copyText)
      
      if (options.showTip) {
        showCopyTip(el, options.successTip!, options.tipDuration!)
      }
    } catch (error) {
      const err = error as Error
      options.onError?.(err)
      console.error('[v-copy] 复制失败:', err.message)
      
      if (options.showTip) {
        showCopyTip(el, '复制失败', options.tipDuration!)
      }
    }
  }
  // 3. 绑定点击事件
  el.addEventListener('click', clickHandler)
  // 4. 存储状态到元素
  el._copy = {
    options,
    clickHandler,
    tipTimer: null
  }
  // 5. 给元素添加可点击样式提示
  el.style.cursor = 'pointer'
}

/**
 * v-copy 自定义指令实现
 */
export const copyDirective: ObjectDirective<CopyElement, CopyOptions | string> = {
  /**
   * 指令挂载时初始化
   */
  mounted(el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) {
   initializeCopy(el, binding)
  },

  /**
   * 指令更新时处理参数变化
   */
  updated(el: CopyElement, binding: DirectiveBinding<CopyOptions | string>) {
    // 先清理旧配置
    cleanup(el)
    // 重新初始化
    initializeCopy(el, binding)
  },

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

/**
 * 全局注册复制指令
 * @param app Vue应用实例
 * @param directiveName 指令名称,默认copy
 */
export const setupCopyDirective = (app: App, directiveName: string = 'copy') => {
  app.directive(directiveName, copyDirective)
}

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

🚀 快速上手

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

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

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { setupCopyDirective } from './directives/v-copy'

const app = createApp(App)

// 注册复制指令(默认名称v-copy)
setupCopyDirective(app)

app.mount('#app')

2. 基础使用(直接传复制内容)

最简单的用法:直接传递要复制的字符串,使用默认配置(显示“复制成功”提示):

<template>
  <!-- 复制固定文本 -->
  <button v-copy="'https://github.com/your-repo'">
    复制我的GitHub地址
  </button>

  <!-- 复制元素文本内容 -->
  <div v-copy style="cursor: pointer;">
    点击复制这段文本:1234567890
  </div>
</template>

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

通过对象参数配置完整的复制规则,支持自定义提示、回调函数:

<template>
  <div>
    <input v-model="copyText" placeholder="输入要复制的内容" />
    
    <button 
      v-copy="{
        content: copyText,
        successTip: '链接复制成功啦~',
        tipDuration: 1500,
        onSuccess: handleCopySuccess,
        onError: handleCopyError
      }"
    >
      自定义复制配置
    </button>
  </div>
</template>

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

const copyText = ref('https://example.com/custom-link')

// 复制成功回调
const handleCopySuccess = (text: string) => {
  console.log('复制成功,内容:', text)
  // 可在这里添加自定义提示,如使用Element Plus的Message
  // ElMessage.success(`复制成功:${text}`)
}

// 复制失败回调
const handleCopyError = (error: Error) => {
  console.error('复制失败:', error)
  // ElMessage.error('复制失败,请手动复制')
}
</script>

4. 动态内容复制

适配动态变化的复制内容(如接口返回的订单号、邀请码):

<template>
  <div>
    <div>您的邀请码:<span v-copy="inviteCode">{{ inviteCode }}</span></div>
    <button @click="refreshInviteCode">刷新邀请码</button>
  </div>
</template>

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

// 模拟动态邀请码
const inviteCode = ref('ABC123456')

// 刷新邀请码
const refreshInviteCode = () => {
  // 生成随机邀请码
  const randomCode = Math.random().toString(36).substring(2, 8).toUpperCase()
  inviteCode.value = randomCode
}
</script>

<style>
/* 给可复制元素添加样式提示 */
span[V-copy] {
  color: #409eff;
  text-decoration: underline;
  cursor: pointer;
}
</style>

5. 关闭默认提示(自定义反馈)

若需自定义复制反馈(如使用UI库的提示组件),可关闭默认提示:

<template>
  <button 
    v-copy="{
      content: '自定义反馈示例',
      showTip: false,
      onSuccess: () => ElMessage.success('复制成功✅'),
      onError: () => ElMessage.error('复制失败❌')
    }"
  >
    自定义反馈提示
  </button>
</template>

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

🔧 核心知识点解析

1. 复制实现原理

指令兼容两种复制方案,保证最大兼容性:

  • 现代浏览器:使用 navigator.clipboard.writeText()(异步、安全,推荐)
  • 降级方案:创建临时 textarea 元素,通过 document.execCommand('copy') 复制(兼容低版本浏览器)

2. 提示组件实现

  • 动态创建提示元素,基于目标元素位置计算居中显示
  • 使用定时器自动隐藏提示,添加过渡动画提升体验
  • 重复点击时自动清理旧提示,避免多个提示叠加

3. 内存泄漏防护

  • unmounted 钩子中移除点击事件、清理定时器、删除临时提示元素
  • updated 钩子中先清理旧配置,再初始化新配置
  • 扩展元素属性存储状态,卸载时删除属性释放内存

4. TypeScript 类型优化

  • 定义 CopyOptions 接口,明确配置项类型
  • 扩展 HTMLElement 类型,添加复制状态属性
  • 支持两种参数类型(字符串/对象),类型推导自动适配

📋 配置项说明

配置项 类型 默认值 说明
content string - 要复制的内容,优先级高于元素文本
onSuccess (text: string) => void - 复制成功回调,参数为复制的文本
onError (error: Error) => void - 复制失败回调,参数为错误对象
successTip string '复制成功' 复制成功提示文本
tipDuration number 2000 提示显示时长,单位ms
showTip boolean true 是否显示默认的复制提示

🎯 常见使用场景

场景1:复制订单号/优惠券码

<template>
  <div class="order-card">
    <div class="label">订单号:</div>
    <div class="value" v-copy="{ successTip: '订单号复制成功' }">
      {{ orderNo }}
    </div>
  </div>
</template>

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

// 模拟接口返回的订单号
const orderNo = ref('ORD20240124123456789')
</script>

<style>
.order-card {
  display: flex;
  align-items: center;
  padding: 10px;
}
.label {
  margin-right: 8px;
  color: #666;
}
.value {
  color: #409eff;
  cursor: pointer;
}
</style>

场景2:复制分享链接

<template>
  <div class="share-card">
    <input 
      type="text" 
      readonly 
      v-model="shareLink"
      class="link-input"
    />
    <button 
      v-copy="{
        content: shareLink,
        onSuccess: () => ElMessage.success('分享链接已复制'),
        onError: () => ElMessage.error('复制失败,请手动复制')
      }"
      class="copy-btn"
    >
      复制链接
    </button>
  </div>
</template>

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

// 模拟生成分享链接
const shareLink = ref(`https://example.com/share?uid=${Math.random().toString(36).substring(2, 10)}`)
</script>

<style>
.share-card {
  display: flex;
  gap: 8px;
  padding: 10px;
}
.link-input {
  flex: 1;
  padding: 6px;
  border: 1px solid #e5e7eb;
  border-radius: 4px;
}
.copy-btn {
  padding: 6px 12px;
  background: #409eff;
  color: #fff;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

🚨 注意事项

  1. Clipboard API 安全限制navigator.clipboard 仅在安全上下文(HTTPS)或本地开发环境(localhost)中可用,HTTP 环境会自动降级到 execCommand
  2. 移动端兼容性:部分移动端浏览器对 execCommand('copy') 支持有限,建议在实际项目中测试。
  3. 复制内容为空:指令会校验复制内容,为空时触发 onError 并打印警告,需确保复制内容有效。
  4. 样式提示:指令会自动给元素添加 cursor: pointer,可根据需要覆盖样式。

📌 总结

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

  1. 兼容性强:兼容现代浏览器和低版本浏览器,自动降级处理。
  2. 体验友好:内置居中提示,支持自定义提示文本和时长,交互体验佳。
  3. 配置灵活:支持自定义复制内容、成功/失败回调,适配各种业务场景。
  4. 类型安全:基于 TypeScript 开发,类型提示完善,减少开发错误。
  5. 轻量无依赖:无需引入第三方库,体积小,性能优。

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

  • 支持复制富文本(HTML内容)
  • 支持自定义提示样式(颜色、位置、动画)
  • 支持双击复制/长按复制
  • 支持复制后自动清空输入框

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

vue3.5+antdv3.2封装table自定义表头-自定义排序-自定义伸缩列

业务需求:

1.表头自定义

2.表格伸缩列自定义

3.字段长度自适应伸缩列,超过宽度设置自动省略,popover气泡卡牌显示全部字段

4.表格排序自定义

5.可展开数据通过操作栏展开

自定义表头:

<template>
  <a-modal
      :visible="modelValue"
      title="自定义表头"
      :destroyOnClose="true"
      :keyboard="false"
      :maskClosable="false"
      width="800px"
      @ok="handleOk"
      @cancel="handleCancel"
  >
    <div class="column-selector-grid">
      <div class="selector-actions">
        <a-button @click="resetToDefault">恢复默认</a-button>
        <a-button @click="selectAll">全选</a-button>
        <a-button @click="clearAll">清空</a-button>
      </div>
      <div class="grid-container">
        <draggable
            v-model="internalColumns"
            group="columns"
            item-key="dataIndex"
            class="grid-layout"
            :disabled="props.disabled"
            @start="onDragStart"
            @end="onDragEnd"
            :move="onMove">
          <template #item="{ element }">
            <div
                class="grid-item"
                :class="{
                'required-item': element.required,
                'selected-item': element.visible,}">
              <div class="drag-handle" >
                <drag-outlined />
              </div>
              <div class="item-content">
                <a-checkbox
                    v-model:checked="element.visible"
                    :disabled="element.required "
                    class="content-checkbox"
                >
                  <span class="content-title">
                    {{ element.title }}
                    <a-tooltip v-if="element.tip" :title="element.tip">
                      <question-circle-outlined class="tip-icon" />
                    </a-tooltip>
                  </span>
                </a-checkbox>
              </div>
            </div>
          </template>
        </draggable>
      </div>
      <div class="fixed-columns-info">
        <p class="info-text">
          <info-circle-outlined class="info-icon" />
          <strong>固定表头申明:</strong>
          <span class="fixed-first-text">序号</span> 固定显示在第一位,
          <span class="fixed-last-text">操作</span> 固定显示在最后一位,以及其他所有被固定的表头都不能取消选择或调整位置。
        </p>
      </div>
    </div>
  </a-modal>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue';
import { QuestionCircleOutlined, DragOutlined, InfoCircleOutlined } from '@ant-design/icons-vue';
import draggable from 'vuedraggable';

// 固定列配置
const FIXED_COLUMNS = {
  FIRST: 'no',      // 序号列固定在第一位
  LAST: 'action'    // 操作列固定在最后一位
};

const props = defineProps({
  allColumns: Array,        // 所有可用列,按照 tableColumns 顺序
  defaultColumns: Array,    // 当前选中的列
  modelValue: Boolean,       // 控制弹窗显示
  disabled:{
    type:Boolean,
    default:false
  }
});

const emit = defineEmits(['update:modelValue', 'confirm','closeSelector']);

// 内部维护的列数据
const internalColumns = ref([]);

// 拖拽状态
const isDragging = ref(false);
const draggedElement = ref(null);

// 拖拽开始事件
const onDragStart = (evt) => {
  const draggedItem = evt.item;
  const draggedIndex = evt.oldIndex;
  const draggedData = internalColumns.value[draggedIndex];
  // 检查是否是固定列
  if (draggedData.required ) {
    // 阻止拖拽固定列
    evt.preventDefault();
    return;
  }
  isDragging.value = true;
  draggedElement.value = draggedData;
};

// 拖拽结束事件
const onDragEnd = (evt) => {
  isDragging.value = false;
  draggedElement.value = null;
  // 延迟执行位置恢复,确保拖拽完全结束
  nextTick(() => {
    ensureFixedColumnsPosition();
  });
};

// 拖拽移动事件
const onMove = (evt) => {
  const draggedItem = evt?.dragged.__draggable_context?.element;
  const targetIndex = evt.draggedContext.futureIndex;
  // 检查被拖动列是否是固定列
  if (draggedItem?.required ) {
    // 阻止拖拽固定列
    return false;
  }
  // 检查目标是否是固定列
  const targetItem = internalColumns.value[targetIndex];
  if (targetItem && targetItem.required) {
    // 阻止拖拽到固定列
    return false;
  }
  // 智能位置调整:确保拖拽后固定列位置仍然正确
  const adjustedTargetIndex = adjustTargetIndex(targetIndex);
  if (adjustedTargetIndex !== targetIndex) {
    // 如果目标位置需要调整,更新拖拽目标
    evt.draggedContext.futureIndex = adjustedTargetIndex;
  }
  // 允许正常列在固定列之间自由拖拽
  return true;
};

// 智能调整目标位置,确保不破坏序号和操作的位置
const adjustTargetIndex = (targetIndex) => {
  const columns = internalColumns.value;
  // 找到序号和操作的位置
  const noIndex = columns.findIndex(col => col.dataIndex === FIXED_COLUMNS.FIRST);
  const actionIndex = columns.findIndex(col => col.dataIndex === FIXED_COLUMNS.LAST);
  // 如果目标位置是序号列位置,调整到序号列后面
  if (targetIndex === 0) {
    return 1;
  }
  // 如果目标位置是操作列位置,调整到操作列前面
  if (targetIndex === columns.length - 1) {
    return columns.length - 2;
  }
  // 如果目标位置在序号列之前,调整到序号列后面
  if (targetIndex < noIndex) {
    return noIndex + 1;
  }
  // 如果目标位置在操作列之后,调整到操作列前面
  if (targetIndex > actionIndex) {
    return actionIndex - 1;
  }
  return targetIndex;
};

// 确保固定列在正确位置
const ensureFixedColumnsPosition = () => {
  const columns = [...internalColumns.value];
  let hasChanges = false;

  // 确保序号列在第一位
  const noColumn = columns.find(col => col.dataIndex === FIXED_COLUMNS.FIRST);
  if (noColumn) {
    const noIndex = columns.indexOf(noColumn);
    if (noIndex !== 0) {
      columns.splice(noIndex, 1);
      columns.unshift(noColumn);
      hasChanges = true;
    }
  }

  // 确保操作列在最后一位
  const actionColumn = columns.find(col => col.dataIndex === FIXED_COLUMNS.LAST);
  if (actionColumn) {
    const actionIndex = columns.indexOf(actionColumn);
    if (actionIndex !== columns.length - 1) {
      columns.splice(actionIndex, 1);
      columns.push(actionColumn);
      hasChanges = true;
    }
  }

  // 如果有变化,更新列顺序
  if (hasChanges) {
    internalColumns.value = [...columns];
  }
};

// 初始化列数据,保持 tableColumns 的顺序
const initColumns = () => {
  // 按照 allColumns 的顺序初始化
  internalColumns.value = props.allColumns.map(col => ({
    ...col,
    visible: props.defaultColumns.includes(col.dataIndex) || col.required
  }));
  // 确保固定列在正确位置
  ensureFixedColumnsPosition();
};

// 恢复默认
const resetToDefault = () => {
  initColumns();
};

// 全选
const selectAll = () => {
  internalColumns.value.forEach(col => {
    col.visible = true;
  });
};

// 清空(除了必需列和固定列)
const clearAll = () => {
  internalColumns.value.forEach(col => {
    if (!col.required ) {
      col.visible = false;
    }
  });
};

// 确认保存
const handleOk = () => {
  // 按照当前顺序返回可见列的 dataIndex 数组
  const visibleColumns = internalColumns.value
      .filter(col => col.visible)
      .map(col => col.dataIndex);

  emit('confirm', visibleColumns);
  emit('update:modelValue', false);
};

const handleCancel = () => {
  if(props.disabled){
    internalColumns.value.forEach(col => {
      col.visible = props.defaultColumns.includes(col.dataIndex) || col.required ;
    });
  }

  emit('update:modelValue', false);
  emit('closeSelector');
};

// 监听 props 变化重新初始化
watch(() => props.allColumns, initColumns, { immediate: true });
watch(() => props.defaultColumns, () => {
  internalColumns.value.forEach(col => {
    col.visible = props.defaultColumns.includes(col.dataIndex) || col.required ;
  });
});

// 监听内部列顺序变化,确保固定列位置正确
watch(() => internalColumns.value, (newColumns) => {
  if (newColumns && newColumns.length > 0) {
    // 检查固定列位置
    const noIndex = newColumns.findIndex(col => col.dataIndex === FIXED_COLUMNS.FIRST);
    const actionIndex = newColumns.findIndex(col => col.dataIndex === FIXED_COLUMNS.LAST);

    // 如果固定列位置不正确,立即恢复
    if (noIndex !== 0 || actionIndex !== newColumns.length - 1) {
      nextTick(() => {
        ensureFixedColumnsPosition();
      });
    }
  }
}, { deep: true });
</script>

<style scoped>
.column-selector-grid {
  padding: 12px;
}

.selector-actions {
  margin-bottom: 16px;
  display: flex;
  gap: 8px;
}

.grid-container {
  border: 1px solid #f0f0f0;
  border-radius: 4px;
  padding: 12px;
  background: #fafafa;
  margin-bottom: 16px;
}

.grid-layout {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}

.grid-item {
  position: relative;
  height: 80px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  background: #fff;
  cursor: move;
  transition: all 0.2s;
  padding: 8px;
  overflow: hidden;
}

.grid-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.grid-item.required-item {
  background-color: #f6ffed;
}

.grid-item.selected-item {
  background-color: #e6f7ff;
}

.drag-handle {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f0f0f0;
  border-radius: 2px;
  color: #666;
  cursor: move;
  z-index: 1;
}

.grid-item.required-item .drag-handle {
  background: #d9f7be;
}

.item-content {
  height: 100%;
  display: flex;
  align-items: center;
}

.content-checkbox {
  width: 100%;
  display: flex;
  align-items: center;
  margin: 0;
  padding-right: 20px;
}

.content-checkbox >>> .ant-checkbox {
  flex-shrink: 0;
}

.content-checkbox >>> .ant-checkbox + span {
  display: flex;
  align-items: center;
  flex: 1;
  padding-right: 4px;
  white-space: normal;
}

.content-title {
  display: inline-flex;
  align-items: center;
  font-size: 12px;
  line-height: 1.4;
  word-break: break-word;
  text-align: left;
}

.tip-icon {
  margin-left: 4px;
  color: #999;
  font-size: 12px;
}

.fixed-columns-info {
  background-color: #f6ffed;
  border: 1px solid #b7eb8f;
  border-radius: 4px;
  padding: 12px;
}

.info-text {
  margin: 0;
  font-size: 12px;
  color: #666;
  line-height: 1.5;
}

.info-icon {
  margin-right: 6px;
  color: #52c41a;
}

.fixed-first-text {
  color: #52c41a;
  font-weight: bold;
}

.fixed-last-text {
  color: #1890ff;
  font-weight: bold;
}
</style>

表格封装:

<template>
  <div class="list-page">
    <div>
      <slot name="header"/>
    </div>
    <div>
      <a-table
          bordered
          row-key="id"
          size="small"
          ref="tableRef"
          :loading="loading"
          :columns="currentTableColumns"
          :data-source="dataSource"
          :expand-row-by-click="false"
          :row-selection="rowSelection"
          :expandIconColumnIndex="-1"
          @expand="handleExpand"
          :expanded-row-keys="expandedRowKeys"
          @resizeColumn="handleResizeColumn"
          :scroll="{ x: '120%', y: 'calc(100vh - 375px)' }"
          :pagination="false">

        <!-- 动态插槽处理 -->
        <template v-for="slotName in Object.keys($slots)" #[slotName]="scope" :key="slotName">
          <slot :name="slotName" v-bind="scope" />
        </template>

        <!--通用bodyCell插槽封装-->
        <template #bodyCell="{text,record,column,index}">
          <!--  先检查是否有自定义的bodyCell插槽-->
          <template v-if="$slots.bodyCell">
            <slot name="bodyCell" :text="text" :record="record" :column="column" :index="index"></slot>
          </template>
          <!-- 无自定义的bodyCell插槽时的默认处理逻辑-->
          <template v-else>
            {{ formatCellText(text,record,column) }}
          </template>
        </template>

        <!-- 通用操作列 -->
        <template #action="{ record, index }">
          <slot name="action" :record="record" :index="index">
            <!-- 默认操作列 -->
          </slot>
        </template>

        <!-- 扩展行插槽 -->
        <template #expandedRowRender="{ record, index }">
          <slot name="expandedRowRender" :record="record" :index="index" />
        </template>

      </a-table>
      <div class="pagination-wrapper">
        <a-pagination
            show-size-changer
            size="small"
            v-model:current="pagination.page"
            v-model:page-size="pagination.pageSize"
            :show-total="(total) => `共 ${total} 条`"
            :total="pagination.total"
            :pageSizeOptions="['100', '200', '300', '500']"
            @change="onChangePage"
        >
        </a-pagination>
      </div>
    </div>

    <!--   自定义表头模态框-->
    <ColumnSelector
        :visible="showColumnSelector"
        :all-columns="enhancedColumns"
        :default-columns="selectedColumns"
        @confirm="handleColumnConfirm"
        @closeSelector="closeSelector"
    />
  </div>
</template>

<script setup>
import {computed, h, nextTick, onMounted, onUnmounted, onUpdated, reactive, ref} from "vue";
import {settingStore} from "../../store/index.js";
import {storeToRefs} from "pinia";
import ColumnSelector from "../../components/ColumnsSelector/index.vue";
import {debounce, deepClone} from "../../utils/util.js";
const settingStoreRef = settingStore();
const { tableOrderByName, tableAscOrDescOrNomal } = storeToRefs(settingStoreRef)
import customizeHeaderApi from '../../api/CustomizeHeader/index.js'
import {message} from "ant-design-vue";
import {useRoute} from "vue-router";

let tableRef = ref(null)
const props = defineProps({
  //表格加载状态
  loading:Boolean,
  showColumnSelector:Boolean,
  rowSelection: {
    type: Object
  },
  pagination:{
    type: Object,
    default: { },
  },
  //需要排序的表格标题
  sortTableList:{
    type: Array,
    default: [],
  },
  //补充必需的列
  requiredColumns:{
    type: Array,
    default: [],
  },
  expandedRowKeys:{
    type: Array,
    default: [],
  },
  columns:Array,
  dataSource:{
    type: Array,
    default: [],
  },
})
const route = useRoute();

const emit = defineEmits(['onChangePage','closeSelector','resetPage','columnResized'])

//自定义表头拉伸
const handleResizeColumn=(w, col)=>{
  col.width = w;
  // 强制表格重新渲染
  nextTick(() => {
    tableRef.value?.$forceUpdate?.();
  })
  debounceResizeColumn({
    dataIndex: col.dataIndex,
    width: w,
    routePath: route.path
  })
}
const debounceResizeColumn = debounce((payload)=>{
  emit('columnResized',payload)
  checkAllCellsOverflow();
},500)

let DEFAULT_VISIBLE_COLUMNS = props.columns.map((item)=>{
  return item.dataIndex
})
const handleExpand = (expanded, record) => {
  emit('handleExpand',expanded, record)
};

const onChangePage=(data)=>{
  emit('onChangePage', data)
}
const closeSelector=()=>{
  emit('closeSelector')
}
//排序
// 当前排序状态
const currentSorter = ref({
  field: tableOrderByName.value,
  order: tableAscOrDescOrNomal.value ?
      (tableAscOrDescOrNomal.value === '0' ? 'ascend' : 'descend') :
      null
});
// 生成带有自定义排序图标的列配置
const currentSortTableColumns = computed(() => {
  return props.columns.map(col => {
    if (props.sortTableList.includes(col.dataIndex)) {
      const isCurrentSorted = currentSorter.value.field === col.dataIndex;
      const sortUpActive = isCurrentSorted && currentSorter.value.order === 'ascend';
      const sortDownActive = isCurrentSorted && currentSorter.value.order === 'descend';
      return {
        ...col,
        // 禁用 Ant Design 的默认排序图标
        sorter: false,
        // 使用自定义标题渲染
        title: () => h('div', {
          class: 'sortable-header',
          onClick: (event) => {
            handleHeaderClick(col.dataIndex, event);
          }
        }, [
          h('span', {
            class: 'header-title',
          }, col.title),
          h('div', {
            class: 'sort-icons',
            style: 'display: flex; flex-direction: column; margin-left: 4px; line-height: 1;',
            onClick: (event) => {
              // 阻止事件冒泡,排序图标有自己的点击事件
              event.stopPropagation();
            }
          }, [
            // 升序三角形图标(正三角)
            h('div', {
              class: `sort-icon sort-up ${sortUpActive ? 'active' : ''}`,
              onClick: () => handleSortClick(col.dataIndex, 'ascend')
            }),
            // 降序三角形图标(倒三角)
            h('div', {
              class: `sort-icon sort-down ${sortDownActive ? 'active' : ''}`,
              onClick: () => handleSortClick(col.dataIndex, 'descend')
            })
          ])
        ])
      };
    }
    return col;
  });
});
// 处理排序图标点击
const handleSortClick = (field, order) => {
  // 如果点击的是当前排序字段且当前已经是该排序方式,则取消排序
  if (currentSorter.value.field === field && currentSorter.value.order === order) {
    settingStoreRef.$patch({
      tableOrderByName: undefined,
      tableAscOrDescOrNomal: undefined
    });
    currentSorter.value = {
      field: undefined,
      order: null
    };
  } else {
    // 设置新的排序
    const sortOrder = order === 'ascend' ? '0' : '1';
    settingStoreRef.$patch({
      tableOrderByName: field,
      tableAscOrDescOrNomal: sortOrder
    });
    currentSorter.value = {
      field: field,
      order: order
    };
  }
  // 重置页码并重新加载数据
  emit('resetPage')
};
// 处理列标题点击(点击标题其他位置)
// 处理表头点击
const handleHeaderClick = (field, event) => {
  const target = event.target;
  // 判断是否点击了排序图标区域
  const isSortIcon = target.closest('.sort-icons') ||
      target.classList.contains('sort-icon') ||
      target.classList.contains('sort-up') ||
      target.classList.contains('sort-down') ||
      target.tagName === 'DIV' && (
          target.style.borderLeft.includes('transparent') ||
          target.style.borderRight.includes('transparent')
      );

  // 如果点击的是排序图标区域,不处理(由 handleSortClick 处理)
  if (isSortIcon) {
    return;
  }
  // 获取当前列的排序状态
  const isCurrentColumn = currentSorter.value.field === field;
  const isAscending = currentSorter.value.order === 'ascend';
  const isDescending = currentSorter.value.order === 'descend';
  if (isCurrentColumn) {
    // 如果当前列已经是排序状态,点击表头其他区域取消排序
    if (isAscending || isDescending) {
      settingStoreRef.$patch({
        tableOrderByName: undefined,
        tableAscOrDescOrNomal: undefined
      });
      currentSorter.value = {
        field: undefined,
        order: null
      };
      emit('resetPage')
    }
  } else {
    // 如果当前列没有排序,点击表头设置为默认升序
    settingStoreRef.$patch({
      tableOrderByName: field,
      tableAscOrDescOrNomal: '0' // 0 表示升序
    });
    currentSorter.value = {
      field: field,
      order: 'ascend'
    };
    emit('resetPage')
  }
};
function initTableOrder(){
  settingStoreRef.$patch({
    tableOrderByName:undefined,
    tableAscOrDescOrNomal:undefined
  })
  currentSorter.value = {
    field: undefined,
    order: null
  };
}

//自定义表头 start
const visibleColumns = ref([...DEFAULT_VISIBLE_COLUMNS]);
let allDefaultColumns = ref([])
// 获取实际显示的表格列
const currentTableColumns = computed(() => {
  const allColumns = currentSortTableColumns.value;
  // 过滤出需要显示的列,并保持顺序
  return visibleColumns.value
      .map(dataIndex => allColumns.find(col => col.dataIndex === dataIndex))
      .filter(Boolean);
});

// 当前选中的列
const selectedColumns = ref([...DEFAULT_VISIBLE_COLUMNS]);
const selectorColumns = computed(() => {
  // 过滤出需要显示的列,并保持顺序
  return allDefaultColumns.value
      .map(dataIndex => props.columns.find(col => col.dataIndex === dataIndex))
      .filter(Boolean);
});
function getColumnTip(dataIndex) {
  let tips = {};
  props.columns.forEach((item)=>{
    tips[item.dataIndex] =  item.title;
  })
  return tips[dataIndex];
}
// 增强列信息
const enhancedColumns = computed(() => {
  let columns = selectorColumns.value;
  // 确保顺序与 tableColumns 一致
  return columns.map(col => ({
    ...col,
    tip: getColumnTip(col.dataIndex),
    required: props.requiredColumns.includes(col.dataIndex)
  }));
});
// 保存列设置
async function handleColumnConfirm(columns) {
  try {
    // 更新visibleColumns和selectedColumns
    visibleColumns.value = columns;
    selectedColumns.value = columns;
    // 接口保存
    await customizeHeaderApi.update({
      pageRoutePath: route.path,
      tableColumsNameLst: columns
    })
    emit('closeSelector');
    message.success('表头设置已保存');
  } catch (e) {
    console.error('保存列设置失败', e);
    message.error('保存表头设置失败');
  }
}
// 加载保存的设置
async function loadColumnPreference() {
  try {
    //获取已保存的设置
    const res = await customizeHeaderApi.get({pageRoutePath:route?.path});
    const saved = res.data?.tableColumsNameLst;
    // 验证并处理保存的列设置
    if (saved && saved.length > 0) {
      // 1. 过滤掉不存在的列
      const validColumns = saved.filter(dataIndex =>
          props.columns.some(col => col.dataIndex === dataIndex)
      );
      // 2. 补充必需的列
      props.requiredColumns.forEach(col => {
        if (!validColumns.includes(col)) {
          validColumns.unshift(col); // 必需列放在前面
        }
      });
      visibleColumns.value = validColumns;
      //必须列
      let requiredColumns = deepClone(props.columns);
      requiredColumns = requiredColumns.filter((col)=>props.requiredColumns.includes(col.dataIndex));
      //已保存的必须列
      let savedNonRequiredColumns = saved.filter(dataIndex => !props.requiredColumns.includes(dataIndex)).map(dataIndex=>
          props.columns.find(col=> col.dataIndex == dataIndex)).filter(Boolean);
      //未保存的列
      let unsavedCols = props.columns.filter(col=>!props.requiredColumns.includes(col.dataIndex) && !saved.includes(col.dataIndex));
      const orderColumns = [...requiredColumns,...savedNonRequiredColumns,...unsavedCols];
      allDefaultColumns.value = orderColumns.map(col=>col.dataIndex);
    } else {
      // 使用默认值
      visibleColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
      allDefaultColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
    }
    // 更新selectedColumns(用于ColumnSelector)
    selectedColumns.value = [...visibleColumns.value];
  } catch (e) {
    console.error('加载列设置失败', e);
    visibleColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
    selectedColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
  }
}
//自定义表头 end

// 格式化单元格文本
const formatCellText = (text, record, column) => {
  if (typeof text === 'object') {
    return JSON.stringify(text)
  }
  return text || ''
}
// 检查文本是否溢出
const overflowMap = reactive(new Map());
const isOverflow = (columnKey, rowKey) => {
  return overflowMap.get(`${rowKey}-${columnKey}`) || false;
};
const checkAllCellsOverflow =()=>{
  nextTick(()=>{
    const cells = document.querySelectorAll('.table-cell');
    cells.forEach((cell)=>{
      const columnKey = cell.getAttribute('data-column-key');
      const rowKye = cell.getAttribute('data-row-key');
      const key = `${rowKye}-${columnKey}`;
      const isOverFlow = cell.scrollWidth > cell.clientWidth;
      overflowMap.set(key,isOverFlow);
    })
  })
}

onMounted(()=>{
  initTableOrder();
  loadColumnPreference();
  // 监听窗口变化
  window.addEventListener('resize', checkAllCellsOverflow);
})
onUpdated(()=>{
  checkAllCellsOverflow();
})
// 清理事件监听
onUnmounted(() => {
  window.removeEventListener('resize', checkAllCellsOverflow);
});
// 暴露方法给父组件
defineExpose({
  initTableOrder,
  isOverflow,
});
</script>

<style scoped>
.pagination-wrapper {
  text-align: right;
  margin-top: 10px;
}
</style>
/**  防抖函数,规定时间内连续出发的函数只执行最后一次 */
function debounce(func, wait, immediate) {
    let timeout; // 定义一个计时器变量,用于延迟执行函数
    return function (...args) { // 返回一个包装后的函数
        const context = this; // 保存函数执行上下文对象
        const later = function () { // 定义延迟执行的函数
            timeout = null; // 清空计时器变量
            if (!immediate) func.apply(context, args); // 若非立即执行,则调用待防抖函数
        };
        const callNow = immediate && !timeout; // 是否立即调用函数的条件
        clearTimeout(timeout); // 清空计时器
        timeout = setTimeout(later, wait); // 创建新的计时器,延迟执行函数
        if (callNow) func.apply(context, args); // 如果满足立即调用条件,则立即执行函数
    };
}
import { defineStore } from "pinia";

export const columWidthStore = defineStore('columnWidthStore', {
    state: () => ({
        columnWidthList: [],
    }),
    actions: {
        setColumnList(record){
            const { routePath,dataIndex,width } = record;
            let routeConfig = this.columnWidthList.find(item=>item.routePath == routePath);
            if(routeConfig){
                const columnConfig = routeConfig?.columnList.find(item=>item.dataIndex == dataIndex);
                if(columnConfig){
                    columnConfig.width = width;
                }else{
                    routeConfig.columnList.push({dataIndex,width})
                }
            }else{
                this.columnWidthList.push({
                    routePath,columnList:[{dataIndex,width}]
                })
            }

        },
        getRouteColumnWidths(routePath) {
            const routeConfig = this.columnWidthList.find(item=>item.routePath == routePath);
            if(!routeConfig)return [];
            return routeConfig.columnList;
        },
        deleteRouteConfig(routePath) {
            let index = this.columnWidthList.findIndex((item)=>{return item.routePath == routePath});
            this.columnWidthList.splice(index,1);
        },
        deleteAllRoute(){
            this.columnWidthList = []
        }

    },
    getters: {

    },
    persist: {
        enabled: true,
        // 自定义持久化参数
        strategies: [
            {
                // 自定义key,默认就是仓库的key
                key: "columWidthStore",
                // 自定义存储方式,默认sessionStorage
                storage: localStorage,
                // 指定要持久化的数据,默认所有 state 都会进行缓存,可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
                paths: [
                    "columnWidthList",
                ],
            }
        ],
    },
})


import { defineStore } from "pinia";
import { deepClone } from "../../utils/util.js";
const customTitlesStr = sessionStorage.getItem(import.meta.env.VITE_APP_TBAS_TITLES_KEY)
const customTitles = (customTitlesStr && JSON.parse(customTitlesStr)) || []
export const settingStore = defineStore('settingStore', {
    state: () => ({
        isMobile: false,
        visible: false,
        pageMinHeight: 0,
        tableOrderByName:undefined,
        tableAscOrDescOrNomal:undefined,
        menuData: [],
        menuList: {},
        activeValue: '',
        activatedFirst: undefined,
        customTitles,
    }),
    actions: {
        reset() {
            this.isMobile = false;
            this.tableOrderByName = undefined;
            this.tableAscOrDescOrNomal = undefined;
            this.visible = false;
            this.pageMinHeight = 0;
            this.menuData = [];
            this.menuList = {};
            this.activeValue = '';
            this.activatedFirst = undefined;       
            this.customTitles = customTitles;
        },
        setDevice(state, isMobile) {
            state.isMobile = isMobile
        },
        setVisible(state, visible) {
            state.visible = visible
        },
        setMenuValue(value) {
            this.menuData.push(value)
        },

    },
    getters: {
 
    },
    persist: {
        enabled: true,
        // 自定义持久化参数
        strategies: [
            {
                // 自定义key,默认就是仓库的key
                key: "settingStore",
                // 自定义存储方式,默认sessionStorage
                storage: localStorage,
                // 指定要持久化的数据,默认所有 state 都会进行缓存,可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
                paths: [
                    "menuData",
                    "activeValue",
                    "menuList",
                    "tableOrderByName",
                    "tableAscOrDescOrNomal"
                ],
            }
        ],
    },
})

组件使用:

<template>
  <div>
    <customizeTable
        ref="customizeTableRef"
        :loading="loading"
        :columns="tableColumns"
        :pagination="pagination"
        :data-source="dataSource"
        :requiredColumns="requiredColumns"
        :sortTableList="sortTableList"
        :showColumnSelector="showColumnSelector"
        :expandedRowKeys="expandedRowKeys"
        @onChangePage="onChangePage"
        @closeSelector="closeSelector"
        @expand="handleExpand"
        @resetPage="resetPage"
        @columnResized="columnResized"
    >
      <template #header>
        <div style="display: flex;margin-bottom: 10px">
          <span>测试:</span>
          <a-input size="small" style="width:120px;margin-right: 4px" v-model:value="testValue" ></a-input>
          <a-button size="small" @click="testValue = undefined">清空</a-button>
        </div>
      </template>
      <template #bodyCell="{ text, record, column }">
        <template v-if="overFlowColumns.includes(column.dataIndex)">
          <div class="table-cell"
               :data-row-key="record.key"
               :data-column-key="column.dataIndex"
               @dblclick="handleCellDoubleClick(record, column)">
            <a-popover  placement="leftBottom" v-if="getCellOverflow(column.dataIndex, record.key)" :content="record[column.dataIndex]">
              <span> {{ text }}</span>
            </a-popover>
            <div v-else>
              {{ text }}
            </div>
          </div>
        </template>
        <template v-if="column.dataIndex === 'action'">
          <a-dropdown placement="bottom" >
            <a class="ant-dropdown-link" @click.prevent>
              操作
            </a>
            <template #overlay>
              <a-menu class="menu-active-background-color">
                <a-menu-item >
                  <a-button
                      size="small"
                      @click="toggleExpand(record.id)"
                      style="padding: 0 4px;"
                  >
                    {{ expandedRowKeys.includes(record.id) ? '收起' : '详情' }}
                  </a-button>
                </a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </template>
      </template>

      <template #expandedRowRender="{ record }">
        <div class="expanded-style">
          <a-descriptions class="mt-10" title="详细信息" bordered :column="2">
            <a-descriptions-item :contentStyle="contentColor" label="1">{{record.one}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="2">{{record.two}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="3">{{record.three}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="4">{{record.four}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="5">{{record.five}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="6" >{{record.six}}</a-descriptions-item>
          </a-descriptions>
        </div>
      </template>
    </customizeTable>
  </div>
</template>
<script setup>
import { onMounted,  ref} from "vue";
import {columWidthStore} from "../src/store/index.js";
import customizeTable from './components/CustomizeTable/index.vue'
import {useRoute} from "vue-router";
import {copyToClipboard} from './utils/util.js'
let route = useRoute();
let customizeTableRef = ref(null)
let loading = ref(false)
let testValue = ref('')
let contentColor = ref({
  backgroundColor:'#fff'
});
let tableColumns = ref([
  {
    title: "序号",
    dataIndex: "no",
    width: 46,
    fixed:'left',
    customRender: ({index}) => `${index + 1}`,
  },
  {
    title: "1",
    dataIndex: "one",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "2",
    dataIndex: "two",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "3",
    dataIndex: "three",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "4",
    dataIndex: "four",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "5",
    dataIndex: "five",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "6",
    dataIndex: "six",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "操作",
    dataIndex: "action",
    fixed:'right',
    key:'action',
    width: 50,
  },
])
let overFlowColumns = tableColumns.value.filter((item)=>!['no','action'].includes(item.dataIndex)).map((item)=>item.dataIndex);
let dataSource = ref([
  {one:'111',two:'2222',three:'333',four:'444444444444444444444444444444444444444',five:'555',six:'666',id:1,key:1111}
])
const pagination = ref({
  page: 1,
  pageSize: 10,
  total: 0,
  size: "default",
});
let requiredColumns = ref(['no','one','two'])
let sortTableList = ref(['one','two','three'])
let showColumnSelector = ref(false)
function handleCellDoubleClick(record, column) {
  const text = record[column.dataIndex];
  copyToClipboard(text);
}
const onChangePage = (data) => {
  pagination.value.page = data;
  // getList();
};
const resetPage=()=>{
  pagination.value.page = 1;
  // getList();
}
const closeSelector=()=>{
  showColumnSelector.value = false;
}

/* 表格详情展开关闭 start */
// 添加展开行的状态管理
let expandedRowKeys = ref([]);
// 切换展开状态的方法
const toggleExpand = (id) => {
  const index = expandedRowKeys.value.indexOf(id);
  if (index > -1) {
    // 如果已经展开,则关闭
    expandedRowKeys.value.splice(index, 1);
  } else {
    // 如果未展开,则展开并关闭其他已展开的行
    expandedRowKeys.value = [id];
  }
};
// 处理表格展开事件
const handleExpand = (expanded, record) => {
  if (expanded) {
    // 展开时关闭其他行
    expandedRowKeys.value = [record.id];
  } else {
    // 收起时移除
    const index = expandedRowKeys.value.indexOf(record.id);
    if (index > -1) {
      expandedRowKeys.value.splice(index, 1);
    }
  }
};
/* 表格详情展开关闭 end */

const columStore = columWidthStore();
const columnResized=(record)=>{
  columStore.setColumnList({
    routePath: record.routePath,
    dataIndex: record.dataIndex,
    width: record.width,
  });

}
const initColumnWidth=()=>{
  let temp = columStore.getRouteColumnWidths(route?.path)
  if(temp?.length){
    tableColumns.value.forEach((el)=>{
      let index = temp.findIndex(item=>item.dataIndex == el.dataIndex);
      if(index>=0)el.width = temp[index].width;
    })
  }
}
const getCellOverflow = (columnKey, rowKey) => {
  if (!customizeTableRef.value?.isOverflow) {
    console.warn('无法访问子组件的 isOverflow 方法');
    return false;
  }
  try {
    return customizeTableRef.value.isOverflow(columnKey, rowKey);
  } catch (error) {
    console.error('调用 isOverflow 方法出错:', error);
    return false;
  }
};
onMounted(()=>{
  initColumnWidth();
})

</script>
<style scoped>
.table-cell {
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
}
</style>


GIF 2026-1-27 11-27-26.gif

vue 表格 vxe-table 如何设置列默认不显示,用户可以手动通过工具栏设置显示

vue 表格 vxe-table 如何设置列默认不显示,用户可以手动通过工具栏设置显示。实现该方式非常简单,可以通过列的 visible 属性设置为默认不显示,然后用户可以在工具栏的自定义列里面勾选显示或隐藏。自定义方式就很方便了。

vxetable.cn

table_custom_def_hide

通过设置 toolbar-config.custom 启用列个性化设置功能,然后将列的 visible=false 设置为默认隐藏,用户可以通过自定义勾选显示;还可以设置是否否禁用,拖拽排序,冻结列等

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

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

const gridOptions = reactive({
  border: true,
  toolbarConfig: {
    custom: true
  },
  columns: [
    { field: 'seq', type: 'seq', width: 70 },
    { field: 'role', title: 'Role', visible: false },
    { field: 'name', title: 'Name' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'address', title: 'Address', visible: false }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 24, address: 'Shanghai' }
  ]
})
</script>

多种显示模式

自定义列弹出层支持多种方式,可以设置为弹出层,窗口,抽屉等方式.

通过设置 custom-config.mode='modal' 启用窗口模式

image

设置 custom-config.mode='drawer' 启用窗口模式

image

gitee.com/x-extends/v…

❌