阅读视图

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

5年前端,我为什么要all in AI Agent?

一个普通 Vue/Electron 工程师的转型自白


前言

我是一个普通的前端工程师。5年经验,公司开发框架也就是 Vue2/3 + TS。自己倒腾过Electron,uni-app,能写一些简单的功能模块。没进过大厂,没写过框架,不是什么技术大神。

每天的生活就是:上班写代码,下班刷掘金/知乎/CSDN,周末偶尔看看新技术。工资一般般,饿不死,也富不了。原本以为自己会这样一直干到退休。

直到去年,年奖金只有以往的一半了!


某一天的顿悟

和往常一样,看看手机,刷刷帖子,直到刷到:《35岁程序员裸辞两月,找不到工作,感慨程序员是碗青春饭》。

那一刻,我突然意识到:我也30多了,离35也不远了。

那天晚上,我失眠了。翻来覆去想几个问题:

  • 我的核心竞争力是什么?
  • 如果明天被裁,我能做什么?
  • 5年后,我还在写代码吗?

没有答案。只有焦虑。


一次偶然的机遇

随着公司业务发展,各部门都在鼓励使用 AI,自然而然的,我也分配了相关的任务:处理后端 AI 大模型的流式返回数据。这本来也是很简单的需求:

  • fetchEventSource 发送请求
  • onMessage() 接收并处理数据
  • onError() 处理异常/错误情况

此时,问题来了:后端返回的并不是的 text/event-stream 格式,而是 application/json;在网上查了一圈,知道可以在 onOpen() 里重新发一条 GET 请求来解决这个问题。

然后,又出现新的问题了:后端异常没有正常返回,全部要前端处理!烦躁之下,我打开了DeepSeek(之前只用它写过文档),10 秒钟,它给出了完整的解决方案。

我当时震惊的不是它写出来了,而是它写出来的代码,完全符合我的需求,而且比我预设需求的还要完整!

那一刻,我突然意识到:这东西真方便。

后来的开发中,我开始疯狂用它:

  • 让它生成Vue3组件,它懂得用<script setup>,知道我喜欢用ref而不是reactive
  • 让它写TS类型,它知道我的命名规范(IPropsTResponse
  • 让它解释一段看不懂的配置,它讲得比文档还清楚

我开始想:如果 AI 这么懂前端,那我是不是可以用它做更多事?


AI突围战”

从那天起,我给自己定了一个目标:用4个月时间,成为一个会“玩AI”的前端。

我不学 PyTorch,不学 Transformer,不调模型参数。我的路线很简单:

阶段 目标 时间
第一阶段 学会让AI按我的要求生成代码(Prompt工程) 3周
第二阶段 打通Electron + AI API,做桌面工具 4周
第三阶段 让AI能调用我写的函数(Function Calling) 6周
第四阶段 做一个真正能“干活”的Agent 8周

这4个月,我经历了什么?

第一周:信心满满 → 被 Prompt 折磨(AI 就是不按格式输出)
第二周:第一个 Electron + AI 应用跑通 → 激动得发朋友圈
第四周:Function Calling 总是失败 → 怀疑人生
第六周:第一个能用的 Agent 诞生(帮我处理Git) → 比发工资还开心
第十周:做的一个桌面助手被吐槽“鸡肋” → 反思产品思维
第十六周:现在,我能用Cursor + AI 在30分钟内开发一个小工具

这4个月,我学会了什么?

  • 不是:模型原理、Attention机制、微调技术
  • 而是:怎么让AI听我的话、怎么把AI集成进Electron、怎么让AI调用我的函数、怎么用AI帮我写代码

最重要的是:我不焦虑了,因为我知道,AI时代,前端不仅没有被淘汰,反而有了新的机会。


为什么说前端是AI时代的“天选之子”?

这4个月让我想明白一件事:

AI 是发动机,前端是驾驶舱。发动机很重要,但用户接触的是驾驶舱。

我们的优势是什么?

优势 说明
UI/UX思维 我们知道怎么让AI的“答案”变成好用的“产品”
TypeScript 严格的类型定义,让 AI 生成的代码更可控
Electron经验 桌面端是 AI 的下一站(隐私、离线、本地资源)
工程化能力 组件化、模块化,这些思维在 AI 应用开发中同样重要

我不是在安慰自己,而是在这4个月我见过太多 AI 应用翻车的案例:

  • 技术很强,但 UI 一塌糊涂 → 没人用
  • 模型很准,但交互反人类 → 用户流失
  • 功能很多,但不会产品化 → 自嗨

这些都是我们前端擅长的地方。


这个专栏要写什么

所以,我开了这个专栏。

它不是:

  • ❌ 大模型原理讲解(我不懂)
  • ❌ Python/PyTorch教程(我不会)
  • ❌ 教你成为AI科学家(做不到)

它是:

  • 一个普通前端的真实转型记录(不装逼,只记录自己踩过的坑)
  • 前端视角的AI应用开发实战(Vue3/TS/Electron)
  • Agent和Vibe Coding的落地经验(能跑起来的代码)

结语

前端已死”,这句话从10年前就开始兴起了,“死”了这么多年还没死透,我认为它就是有价值的。现如今,在 AI 的加持,对前端的要求会越来越高,但这条路我会继续走下去,也会把每一步都记录下来。

如果你也在这条路上,欢迎同行。

echart 移动端进行双指缩放时,当放大到最大级别后,手指没有离开屏幕,图表还会自动移动问题修复

echarts.min.js 源码修复

version: 6.0.0
格式化后搜索 zoom: function

未修复源码 zoom 缩放关键部分

zoom: function(t, e, n, i) {
var r = this.range,
o = r.slice(),
a = t.axisModels[0];
if (a) return a = (0 < (e = bI[e](null, [i.originX, i.originY], a, n, t)).signal ? e
.pixelStart + e.pixelLength - e.pixel : e.pixel - e.pixelStart) / e.pixelLength * (
o[1] - o[0]) + o[0], n = Math.max(1 / i.scale, 0), o[0] = (o[0] - a) * n + a, o[1] =
(o[1] - a) * n + a, jC(0, o, [0, 100], 0, (t = this.dataZoomModel
.findRepresentativeAxisProxy().getMinMaxSpan()).minSpan, t.maxSpan), this.range = o,
r[0] !== o[0] || r[1] !== o[1] ? o : void 0
},

已修复源码

zoom: function(t, e, n, i) {
/* t: dataZoomModel (当前缩放组件的模型) */
/* e: coordSysType (坐标系类型,如 'grid') */
/* n: api (ECharts 实例 API) */
/* i: payload (包含缩放比例 scale 和 鼠标位置 originX/Y) */
var r = this.range, // 当前视图百分比范围 [start, end]
o = r.slice(), // 复制一份范围用于计算
a = t.axisModels[0]; // 获取关联的轴模型
if (a) {
/* 计算缩放中心点 a (百分比 0-100) */
/* bI[e](...) 根据像素位置 originX/Y 计算其在坐标轴上的位置比例 */
// a = (0 < (e = bI[e](null, [i.originX, i.originY], a, n, t)).signal ? e.pixelStart + e.pixelLength - e.pixel : e.pixel - e.pixelStart) / e.pixelLength * (o[1] - o[0]) + o[0];

/* 固定缩放中心点 a 为当前显示范围的中点 */
a = (o[0] + o[1]) / 2;

/* n: 缩放后的比例因子 (scale > 1 表示缩小视图/放大倍率) */
n = Math.max(1 / i.scale, 0);

/* s: 计算出的预期起始百分比 (start) */
/* l: 计算出的预期结束百分比 (end) */
var s = (o[0] - a) * n + a,
l = (o[1] - a) * n + a;

/* 防止遇到边界打断缩放 */
if (s < 0) {
l -= s;
s = 0;
}

if (l > 100) {
s -= l - 100;
l = 100;
}

/* u: 获取配置中的最小/最大缩放范围 (minSpan/maxSpan/minValueSpan...) */
var u = this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan(),
h = Math.abs(l - s); // 预期范围的跨度

/* 核心逻辑:如果缩放后的范围超出了 0-100 边界,或者违反了跨度约束,则直接放弃本次操作(返回空) */
/* 这样可以防止 ECharts 内部通过平移来修正边界(即漂移/抖动) */
if (
s < -0.1 ||
l > 100.1 ||
(null != u.minSpan && h < u.minSpan) ||
(null != u.maxSpan && h > u.maxSpan)
) return;

/* 如果符合条件,则更新当前范围并返回结果 */
return o[0] = s, o[1] = l, this.range = o, o
}
},

ps: 个人摸索,如有问题请告知 🙇

通用管理后台组件库-13-页签组件

页签组件

说明:实现头部页签相关功能,包括关闭、右键刷新/关闭右侧/关闭其他。

1.实现效果

image.png

2.页签组件HeaderTabs.vue

<template>
  <el-tabs closable type="card" class="my-tabs" v-on="forwardEvents" v-model="modelValue">
    <el-tab-pane v-for="item in data" :key="item.name" :name="item?.name as string">
      <template #label>
        <span class="custom-tab-label" @contextmenu.prevent="handleContextMenu($event, item)">
          {{ item.meta && $t(item.meta?.title as string) }}
        </span>
      </template>
    </el-tab-pane>
  </el-tabs>
  <el-dropdown
    ref="dropdownRef"
    :virtual-ref="triggerRef"
    :show-arrow="false"
    :popper-options="{
      modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
    }"
    virtual-triggering
    trigger="contextmenu"
    placement="bottom-start"
    @command="commandHandler"
  >
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item command="refresh">刷新</el-dropdown-item>
        <el-dropdown-item command="closeRight">关闭右侧</el-dropdown-item>
        <el-dropdown-item command="closeOther">关闭其他</el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<script setup lang="ts">
import type { TabsPaneContext, TabsProps, DropdownInstance } from 'element-plus'
import type { AppRouteMenuItem } from '../menu/type'
import { forwardEventsUtils } from '@/utils/format'

interface HeaderTabsProps extends Partial<TabsProps> {
  data: AppRouteMenuItem[]
}
type TabPaneName = string | number
type HeaderTabsEvents = {
  tabClick: [pane: TabsPaneContext, event: Event]
  tabChange: [name: TabPaneName]
  tabRemove: [name: TabPaneName]
  tabAdd: []
  edit: [paneName: TabPaneName, action: 'add' | 'remove']
}
type TabAction = 'refresh' | 'closeRight' | 'closeOther'
type contextMenuCommand = {
  tabActions: [paneName: AppRouteMenuItem, action: TabAction]
}
defineProps<HeaderTabsProps>()

// 定义事件名称数组,包含标签页可能触发的所有事件类型
const eventName = ['tabClick', 'tabChange', 'tabRemove', 'tabAdd', 'edit', 'tabActions']

// 使用 defineEmits 定义组件可以触发的事件,类型为 HeaderTabsEvents
const emit = defineEmits<HeaderTabsEvents&contextMenuCommand>()
// 使用 forwardEventsUtils 函数处理事件转发,将 eventName 数组中定义的事件进行转发
const forwardEvents = forwardEventsUtils(emit, eventName)

const modelValue = defineModel<string>()

// 右键页签操作
const dropdownRef = ref<DropdownInstance>()
const position = ref({
  top: 0,
  left: 0,
  bottom: 0,
  right: 0
} as DOMRect)

const triggerRef = ref({
  getBoundingClientRect: () => position.value
})

const contextMenuTab = ref<AppRouteMenuItem>()
const handleContextMenu = (event: MouseEvent, tab: AppRouteMenuItem) => {
  const { clientX, clientY } = event
  position.value = DOMRect.fromRect({
    x: clientX,
    y: clientY
  })
  event.preventDefault()
  dropdownRef.value?.handleOpen()
  contextMenuTab.value = tab
}
const commandHandler = (command: TabAction) => {
  if (contextMenuTab.value) {
    emit('tabActions', contextMenuTab.value, command)
  }
}
</script>

<style scoped lang="scss">
.my-tabs {
  :deep(.el-tabs__header) {
    @apply p-0 m-0 border-b-none pl-1;
    .el-tabs__nav {
      @apply border-none;
    }
  }
  :deep(.el-tabs__item) {
    @apply py-0 h-[34px] px-4 mt-0!;
    border-radius: 4px;
    border: 1px solid var(--el-border-color) !important;
    transition: padding 0.3s cubic-bezier(0.645, 0.045, 0.355, 1) !important;
    margin-right: 5px;
    &.is-active {
      color: var(--el-color-primary) !important;
      background: var(--el-color-primary-light-9) !important;
      border: 1px solid var(--el-color-primary) !important;
    }
  }
  :deep(.el-tabs__nav-next, .el-tabs__nav-prev) {
    line-height: 35px !important;
  }
}
.el-dropdown {
  display: none !important;
}
</style>

3.tab状态存放在store中,tabs.ts

import type { AppRouteMenuItem } from '@/components/menu/type'
import { defineStore } from 'pinia'

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    tabs: [] as AppRouteMenuItem[],
    current: ''
  }),
  actions: {
    addRoute(route: AppRouteMenuItem) {
      if (this.tabs.some((item) => item.name === route.name)) return
      this.tabs.push({ ...route })
    },
    removeRoute(path: string) {
      this.tabs = this.tabs.filter((item) => item.name !== path)
    },
    closeOther(path: string) {
      this.tabs = this.tabs.filter((item) => item.name === path)
    },
    closeRight(path: string) {
      const findIndex = this.tabs.findIndex((item) => item.name === path)
      this.tabs = this.tabs.slice(0, findIndex + 1)
    }
  },
  persist: true
})

4.在默认布局中引用,default.vue

<template>
  <div
    class="w-full h-full position-absolute left-0 top-0 overflow-hidden flex"
    :style="{ '--el-color-primary': setting?.theme }"
  >
    <!-- 左右布局 -->
    <!-- sidebar -->
    <div
      :style="{
        width: mixbarMenuWidth,
        backgroundColor: setting?.backgroundColor
      }"
      class="h-full transition-width shrink-0"
      v-if="setting?.mode !== 'top'"
    >
      <el-row class="h-full">
        <el-scrollbar
          v-if="setting?.mode !== 'mix'"
          :class="[setting?.mode !== 'mixbar' ? 'flex-1' : 'w-[64px] py-4']"
          :style="{
            backgroundColor:
              setting?.mode !== 'mixbar' ? 'auto' : darkenColor(setting?.backgroundColor, 10)
          }"
        >
          <!-- 左侧菜单和左侧菜单混合模式的布局-->
          <Menu
            :class="[{ mixbar: setting?.mode === 'mixbar' }]"
            v-if="setting?.mode === 'siderbar' || setting?.mode === 'mixbar'"
            mode="vertical"
            :data="mixbarMenus"
            :collapse="setting?.mode !== 'mixbar' && localSettings.collapse"
            text-color="#b8b8b8"
            :active-text-color="setting?.theme"
            :background-color="
              setting?.mode !== 'mixbar' ? setting?.backgroundColor : 'transparent'
            "
            @select="handleMenuSelect"
          ></Menu>
        </el-scrollbar>
        <el-scrollbar v-if="setting?.mode === 'mix' || setting?.mode === 'mixbar'" class="flex-1">
          <!-- 左侧菜单混合和顶部左侧菜单混合模式的二级menu -->
          <Menu
            mode="vertical"
            :data="getSubMenus(menus)"
            :collapse="localSettings.collapse"
            text-color="#b8b8b8"
            :active-text-color="setting?.theme"
            :background-color="setting?.backgroundColor"
            @select="handleMenuSelect"
          ></Menu
        ></el-scrollbar>
      </el-row>
    </div>
    <!-- content -->
    <div
      :class="['w-full h-full flex-1 overflow-hidden', setting?.fixedHead ? '' : 'overflow-y-auto']"
    >
      <!-- header -->
      <Header
        :locales="locales"
        :username="username"
        :src="avatar"
        :data="avatarMenu"
        :setting="setting"
        v-model:collapse="localSettings.collapse"
        @setting-change="handleSettingChange"
        @select="handleMenuSelect"
        :class="[setting?.fixedHead ? 'fixed top-0 left-0 right-0 z-10' : '']"
      >
        <template #menu>
          <!-- 顶部菜单和混合模式布局 -->
          <Menu
            v-if="setting?.mode === 'top' || setting?.mode === 'mix'"
            mode="horizontal"
            :data="setting?.mode === 'mix' ? getTopMenus(menus) : menus"
            :collapse="false"
            @select="handleMenuSelect"
            :active-text-color="setting?.theme"
          ></Menu>
        </template>
      </Header>
      <HeaderTabs
        v-model="tabsStore.current"
        :data="tabsStore.tabs"
        @tab-click="handleTabClick"
        @tab-remove="handleRemoveTab"
        @tab-actions="handleTabActions"
      ></HeaderTabs>
      <!-- main -->
      <div :class="[setting?.fixedHead ? 'overflow-y-auto h-full p-2 pb-25 bg-gray-100' : '']">
        <el-card>
          <router-view :key="routerKey"></router-view>
        </el-card>
      </div>
    </div>
    <!-- 移动端菜单抽屉 -->
    <el-drawer
      direction="ltr"
      class="w-full!"
      :style="{ backgroundColor: setting?.backgroundColor }"
      v-if="isMobile"
      :model-value="!localSettings.collapse"
      @close="localSettings.collapse = true"
    >
      <Menu
        text-color="#b8b8b8"
        :active-text-color="setting?.theme"
        :data="menus"
        :background-color="setting?.backgroundColor"
        @select="handleMenuSelect"
      ></Menu>
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import type { DropMenuItem } from '@/components/Avatar/types'
import HeaderTabs from '@/components/Layouts/HeaderTabs.vue'
import type { HeaderProps } from '@/components/Layouts/types'
import type { ThemeSettingProps } from '@/components/Themes/type'
import type { AppRouteMenuItem } from '@/components/menu/type'
import { useMenu } from '@/components/menu/useMenu'
import { useTabsStore } from '@/stores/tabs'
import { darkenColor } from '@/utils'
import type { RouteRecordRaw } from 'vue-router/auto'
import { routes } from 'vue-router/auto-routes'

interface ThemeSettingsOptions extends HeaderProps {
  username: string
  avatar: string
  avatarMenu: DropMenuItem[]
}
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()

// 设置配置默认数据
const localSettings = reactive<ThemeSettingsOptions>({
  username: 'admin',
  locales: [
    {
      name: 'zh-CN',
      text: '中文',
      icon: 'uil:letter-chinese-a'
    },
    {
      text: '英文',
      name: 'en',
      icon: 'ri:english-input'
    }
  ],
  avatarMenu: [
    {
      key: '1',
      value: '个人中心'
    },
    {
      key: '2',
      value: '修改密码'
    },
    {
      key: 'divider',
      value: ''
    },
    {
      key: '4',
      value: '退出登录'
    }
  ],
  avatar: '',
  collapse: false,
  setting: { menuWidth: 280 } as ThemeSettingProps
})
const { locales, avatarMenu, username, avatar } = toRefs(localSettings)

// 菜单和路由配置类型不相同,转换一下
const genrateMenuData = (routes: RouteRecordRaw[]): AppRouteMenuItem[] => {
  const menuData: AppRouteMenuItem[] = []
  routes.forEach((route) => {
    if (route.meta?.hideMenu) return
    let menuItem: AppRouteMenuItem = {
      name: route.name,
      path: route.path,
      meta: route.meta,
      alias: typeof route.redirect === 'string' ? route.redirect : undefined,
      component: route.component
    }
    // 判断是否有子路由,递归转换
    if (route.children && Array.isArray(route.children) && route.children.length > 0) {
      menuItem.children = genrateMenuData(route.children)
    }
    menuData.push(menuItem)
  })
  return menuData
}
// 路由类型数据转换为菜单类型数据
const menus = computed(() => genrateMenuData(routes))
const isMobile = ref(false)
// 设置主题
const handleSettingChange = (themeSettings: ThemeSettingProps) => {
  localSettings.setting = themeSettings
}
// 获取菜单宽度
const menuWidth = computed(() => (localSettings.setting ? localSettings.setting.menuWidth : 240))
// 获取设置菜单
const setting = computed(() => localSettings.setting)

// 获取mixbar和mix模式下的一二级菜单
const { getTopMenus, getSubMenus } = useMenu()

onMounted(() => {
  console.log(getTopMenus(menus.value))
  console.log(getSubMenus(menus.value))
})

// 混合mixbar模式下的菜单
const mixbarMenus = computed(() =>
  setting.value?.mode === 'mixbar' ? getTopMenus(menus.value) : menus.value
)
// 混合mixbar模式下的二级菜单是否都设置了icon,判断收起的显示情况
const isFullIcons = computed(() => {
  return getSubMenus(menus.value).every(
    (item) => typeof item.meta?.icon !== 'undefined' && item.meta?.icon
  )
})
// 混合mixbar模式下的菜单宽度
const mixbarMenuWidth = computed(() => {
  if (isMobile.value) return 0
  if (setting.value?.mode === 'mixbar' && isFullIcons.value) {
    return localSettings.collapse ? 'auto' : menuWidth.value + 'px'
  } else {
    return localSettings.collapse ? '64px' : menuWidth.value + 'px'
  }
})
// 选择menu事件
const handleMenuSelect = (menuItem: AppRouteMenuItem) => {
  if (menuItem && menuItem.name) {
    router.push(menuItem.name as string)
    if (isMobile.value) {
      localSettings.collapse = true
    }
  }
}

// 菜单抽屉展开折叠,屏幕宽度适配
const tmpWidth = ref(0)
const changeWidthFlag = ref(false)
useResizeObserver(document.body, (entries) => {
  // 获取浏览器宽度
  const { width } = entries[0].contentRect
  if (tmpWidth.value === 0) {
    // 记录初始宽度
    tmpWidth.value = width
  }
  if (width > tmpWidth.value) {
    // 扩大屏幕
    changeWidthFlag.value = width < 640
  } else {
    // 缩小屏幕
    changeWidthFlag.value = width > 1200
  }
  if (width < 640 && !changeWidthFlag.value) {
    localSettings.collapse = true
  }
  if (width > 1200 && !changeWidthFlag.value) {
    localSettings.collapse = false
  }
  // 是否是移动端屏幕宽度
  isMobile.value = width < 440
  tmpWidth.value = width
})
onBeforeMount(() => {
  // 是否是移动端屏幕
  if (
    navigator.userAgent.match(
      /(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
    )
  ) {
    isMobile.value = true
    localSettings.collapse = true
  }
})
// 路由监听,tabsStore添加路由
watch(
  route,
  () => {
    tabsStore.addRoute(route)
    tabsStore.current = route.name
  },
  {
    immediate: true
  }
)
// 点击tab切换路由页面
const handleTabClick = (tab) => {
  const { index } = tab
  const route = tabsStore.tabs[index]
  router.push(route.name as string)
}
// 关闭tab,激活上一个tab
const handleRemoveTab = (tab) => {
  tabsStore.removeRoute(tab)
  if (tabsStore.current === tab) {
    if (tabsStore.tabs.length !== 0) {
      tabsStore.current = tabsStore.tabs[tabsStore.tabs.length - 1].name as string
    } else {
      // 删除最后一个tab,跳转到首页
      const tmpRoute = menus.value.filter((item) => item.path === '/')[0]
      tabsStore.addRoute(tmpRoute)
      tabsStore.current = tmpRoute.name as string
    }
    router.push(tabsStore.current as string)
  }
}

// 如果需要手动刷新,可以修改 key 的依赖,例如增加一个刷新计数器
const refreshKey = ref(0)
const routerKey = computed(() => route.fullPath + refreshKey.value)
// 页签操作
const handleTabActions = (tab, action) => {
  if (action === 'refresh') {
    router.push(tab.name as string)
    refreshKey.value++
  } else if(action === 'closeRight') {
    tabsStore.closeRight(tab.name as string)
  } else if(action === 'closeOther') {
    tabsStore.closeOther(tab.name as string)
  }
}
</script>

<style lang="scss" scoped>
.mixbar {
  :deep(.el-menu-item) {
    height: auto;
    line-height: unset !important;
    flex-direction: column;
    margin-bottom: 15px;
    padding: 4px 0 !important;
    svg {
      margin-right: 0;
      margin-bottom: 10px;
    }
  }
}
</style>

别再靠 Code Review 纠格式了!一套自动化前端工程化方案,让 Vue 项目提交即合规

上周五下午 5 点,同事提了个 PR,被 CI 卡了 7 次:

  • 缩进不对
  • 多了个 console.log
  • 提交信息写的是 “fix bug”
  • ESLint 报了 3 个 warning

他崩溃地问:“就不能在我本地就告诉我错了吗?”

我说:“能——但你们没配。”

今天,我就手把手教你搭建一套 Vue 3 项目开箱即用的自动化工程化流水线,包含:

  • 代码格式自动修复
  • 提交信息规范校验
  • Git Hooks 拦截脏提交
  • CI 零配置集成

全程只需 15 分钟,从此告别“格式战争”。


核心工具链(2026 年推荐组合)

功能 工具 优势
代码格式化 Prettier 统一风格,无配置争议
代码检查 ESLint + TypeScript 逻辑错误 + 类型安全
提交规范 Commitlint + Husky 强制 Angular 风格 commit
本地拦截 lint-staged 只检查 staged 文件,快!
构建集成 Vite + GitHub Actions CI 自动跑检查

关键理念:本地自动修,提交前拦截,CI 只做最终守门员


第一步:统一代码风格 —— Prettier + ESLint 联合治理

1. 安装依赖

npm install -D prettier eslint @typescript-eslint/eslint-plugin eslint-config-prettier

2. 配置 .prettierrc

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100
}

3. 配置 .eslintrc.js(关键:让 ESLint 不管格式)

module.exports = {
  extends: [
    'eslint:recommended',
    '@typescript-eslint/recommended',
    'prettier' // ← 关闭 ESLint 与 Prettier 冲突的规则
  ],
  plugins: ['@typescript-eslint'],
  parserOptions: { ecmaVersion: 2020, sourceType: 'module' }
};

效果:

  • ESLint 只管逻辑错误(如未使用变量)
  • Prettier 只管格式(如引号、缩进)
  • 两者不再打架!

第二步:提交前自动修复 —— lint-staged + Husky

1. 初始化 Git Hooks

npx husky-init && npm install

2. 配置 package.json 中的 lint-staged

{
  "lint-staged": {
    "*.{js,ts,vue}": [
      "eslint --fix",
      "prettier --write"
    ]
  }
}

3. 修改 .husky/pre-commit

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

效果:
当你运行 git commit只有你改动的文件会被自动格式化 + 修复
如果有无法修复的错误(如类型错误),提交直接失败


第三步:规范提交信息 —— Commitlint

1. 安装

npm install -D @commitlint/cli @commitlint/config-conventional

2. 创建 commitlint.config.js

module.exports = {
  extends: ['@commitlint/config-conventional']
};

3. 添加 commit-msg Hook

npx husky add .husky/commit-msg 'npx --no-install commitlint --edit $1'

现在,提交信息必须符合格式:

feat(auth): add login button
fix(api): handle timeout error
docs(readme): update installation guide

否则:git commit -m "update"直接拒绝!


第四步:CI 自动守门(GitHub Actions 示例)

.github/workflows/ci.yml 中添加:

name: CI
on: [push]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 18 }
      - run: npm ci
      - run: npm run lint   # 检查 ESLint
      - run: npm run format:check  # 检查 Prettier

并在 package.json 中定义脚本:

{
  "scripts": {
    "lint": "eslint . --ext .js,.ts,.vue",
    "format:check": "prettier --check ."
  }
}

效果:即使有人绕过本地 Hooks(比如 --no-verify),CI 也会拦住他!


最终效果:开发者体验流程图

你写代码
  ↓
保存时 → VS Code 自动格式化(通过 EditorConfig + Prettier 插件)
  ↓
git add .
  ↓
git commit → Husky 触发
    ├─ lint-staged: 自动修复 staged 文件
    └─ commitlint: 校验提交信息格式
  ↓
推送 → GitHub Actions 运行完整检查
  ↓
合并!零格式争议,零低级错误

最后说两句

工程化不是“加流程”,而是减少人为摩擦

一套好的工具链,应该像空气——
你感觉不到它存在,但一旦没了,立刻窒息。

花 15 分钟配好它,
未来省下的是几百小时的 Code Review 和 debug 时间

有没有因为格式问题吵过架?欢迎留言区分享


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

用 uni-app x 重构我们的 App:一套代码跑通 iOS、Android、鸿蒙!人力成本直降 60%

曾经,我们维护三套代码:

  • iOS 用 Swift
  • Android 用 Kotlin
  • 鸿蒙 NEXT 用 ArkTS

现在?一套 Vue 3 + TypeScript 代码,同时上线三大平台
构建一次,全端分发——连华为应用市场都主动推荐我们。

如果你还在为“多端适配”焦头烂额,为鸿蒙生态焦虑,为人力成本飙升失眠——uni-app x 的正式成熟,可能是你今年最值得押注的技术决策


一、多端开发的“三座大山”

过去几年,移动开发团队面临前所未有的分裂:

  1. iOS + Android 双端维护:至少 2 个原生团队,沟通成本高;
  2. 鸿蒙 NEXT 强制独立生态:不再兼容 AOSP,旧 APK 无法上架;
  3. Web/小程序还要兼顾:产品需求要求“五端一体”。

结果?

  • 开发周期拉长 2–3 倍
  • Bug 修复需三端同步验证
  • 新人入职要学三种语言

我们曾试过 React Native、Flutter,但:

  • RN 在鸿蒙上支持弱,性能一般;
  • Flutter 虽跨端,但包体大(50MB+),且与原生交互复杂。

直到 uni-app x 出现——它用一个大胆的方案破局:编译时生成各平台原生代码


二、uni-app x 是什么?为什么它能“真·一套代码”?

不同于传统跨端框架(如 RN 的 JS Bridge、Flutter 的 Skia 渲染),uni-app x 采用“源码编译到原生”的架构

平台 输出产物 运行方式
iOS Swift + UIKit/SwiftUI 真·原生 App
Android Kotlin + Jetpack Compose 真·原生 App
鸿蒙 NEXT ArkTS + ArkUI 华为官方认证原生应用
Web / 小程序 保留原有 H5/小程序输出能力

关键优势:不依赖 WebView,不嵌入 JS 引擎,性能 ≈ 手写原生

这意味着:

  • 启动速度与原生一致
  • 内存占用低(实测比 Flutter 少 40%)
  • 完全调用平台最新 API(如鸿蒙的分布式能力)

而你写的,依然是熟悉的 Vue 3 语法 + TypeScript + Composition API


三、真实项目重构:从 3 人月 → 1 人月

公司一款电商导购 App(含商品列表、购物车、支付、消息推送)做迁移实验:

指标 原三端方案 uni-app x 重构后
开发人力 3 人(iOS/Android/鸿蒙) 1 人(前端)
首版交付周期 6 周 2 周
包体积(安装包) iOS: 48MB / Android: 52MB / 鸿蒙: 45MB 统一 ≈ 28MB
启动时间(冷启动) 1.8s / 2.1s / 1.9s 1.7s / 1.8s / 1.6s
华为应用市场上架 (旧 APK 被拒) 通过审核,获“鸿蒙原生”标签

四、uni-app x 的三大杀手锏

1. 鸿蒙 NEXT 原生支持,抢占生态红利

华为已明确:2026 年起,新上架应用必须为鸿蒙原生(.hap 格式)
uni-app x 可直接输出符合规范的 ArkTS 工程,无需重写。

<!-- 你的 Vue 组件 -->
<template>
  <view class="product-card">
    <image :src="item.image" />
    <text>{{ item.name }}</text>
    <!-- 自动映射为 ArkUI 的 Image + Text -->
  </view>
</template>

编译后,鸿蒙端得到的是标准 @Component 装饰的 ArkTS 文件——华为工具链完全识别


2. 性能接近手写原生,告别“跨端卡顿”标签

得益于 编译时优化 + 原生渲染,uni-app x 在关键指标上表现优异:

  • 列表滚动 FPS:58–60(Flutter:52–56,RN:45–50)
  • 内存峰值:120MB(同场景下 Flutter 为 210MB)
  • 启动耗时:低于 2 秒(满足华为“快应用”标准)

3. 生态无缝衔接,已有 uni-app 项目可平滑升级

如果你已有 uni-app 项目(H5/小程序),只需:

  1. 升级 DCloud HBuilderX 到最新版
  2. 修改 manifest.json 启用 uni-app x 模式
  3. 微调少量平台特有 API(如蓝牙、NFC)

90% 的业务代码无需改动


五、但它适合所有人吗?

uni-app x 当前最适合:

  • 中小型团队,希望降低多端维护成本
  • 需快速覆盖鸿蒙生态的 App
  • 以内容展示、表单交互为主的业务型应用(电商、工具、资讯)

不太适合:

  • 超重度图形应用(如 3D 游戏)
  • 需深度定制原生 UI 动画的场景(但可通过原生插件扩展)

但对 80% 的商业 App,它已是“性价比之王”。


六、5 分钟创建你的第一个 uni-app x 应用

  1. 下载最新 HBuilderX 4.20+(DCloud 官网)
  2. 新建项目 → 选择 “uni-app x” 模板
  3. 编写 Vue 3 组件(支持 <script setup>
  4. 点击“运行” → 可同时预览 iOS / Android / 鸿蒙模拟器
# 或使用 CLI(需 Node.js)
npm install -g @dcloudio/uni-cli-shared
uni create my-uniappx-app --template vue-ts
cd my-uniappx-app
uni dev

一次编码,三端真机调试——这才是多端开发该有的样子。


七、行业正在转向

  • 携程:部分工具类模块迁移到 uni-app x,鸿蒙版上线提速 3 倍
  • 美图秀秀:用 uni-app x 快速推出鸿蒙专属滤镜插件
  • 大量政务/银行 App:因合规要求,优先采用 uni-app x 构建鸿蒙原生版本

DCloud 官方数据显示:2026 年 Q1,uni-app x 项目数量环比增长 320%


结语:不是所有跨端,都叫“原生”

React Native 是“桥接”,Flutter 是“自绘”,
uni-app x,是“翻译”——把你的 Vue 代码,翻译成各平台的母语

在这个鸿蒙强制原生、人力成本飙升的时代,
用一套代码拿下 iOS、Android、鸿蒙三大阵地,不再是梦想,而是现实

官网:hx.dcloud.net.cn
鸿蒙迁移指南:ask.dcloud.net.cn/article/458…

今天,就用 uni-app x 重构你的 App——
也许下一个“鸿蒙原生标杆应用”,就是你的作品。

已尝试 uni-app x 的朋友,欢迎分享鸿蒙上架经验!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Flutter vs React Native vs HarmonyOS:谁更适合下一代跨端?2026 年技术选型终极指南

你的团队还在用 React Native 写新 App?
华为应用市场已明确:2026 年起,非鸿蒙原生应用将限流甚至拒审

而你的 Flutter 包,体积 60MB,在低端鸿蒙设备上启动要 3 秒——用户早已划走。

如果你正为“下一个三年该押注哪个跨端框架”而失眠,
这篇文章,可能决定你团队未来的技术命运


一、跨端开发的“黄金时代”正在终结

过去五年,Flutter 和 React Native 凭借“一套代码多端运行”的承诺,成为无数团队的首选。
但 2026 年,风向变了:

  • 华为鸿蒙 NEXT 彻底抛弃 AOSP,仅支持 .hap 原生包;
  • 苹果收紧 JIT 限制,JS 引擎动态能力受限;
  • 用户对性能敏感度飙升,60fps 成为底线而非上限;
  • 企业要求“一次开发,覆盖手机/平板/车机/手表”

旧有跨端方案,正在遭遇生态割裂、性能瓶颈、审核风险三重夹击。

是时候重新评估:谁才是真正的“下一代跨端王者”?


二、三大方案全景对比:不只是技术,更是战略

我们从 性能、生态、鸿蒙适配、长期 ROI 四个维度,实测对比:

维度 Flutter React Native HarmonyOS (ArkTS)
渲染方式 自绘引擎(Impeller) 原生控件桥接(JSI) 原生声明式 UI(ArkUI)
鸿蒙 NEXT 支持 社区移植 / 混合嵌入 桥接适配,性能损耗大 官方原生,深度集成
启动速度(鸿蒙手机) 2.3s 2.8s 1.4s
包体积(基础 App) 55–70MB 45–60MB 18–25MB
UI 一致性 ⭐⭐⭐⭐⭐(像素级一致) ⭐⭐(平台差异明显) ⭐⭐⭐⭐(全场景统一设计语言)
热更新能力 官方不支持(有审核风险) CodePush 成熟 华为 AppGallery Connect 支持
学习成本 需学 Dart + Widget 思维 前端友好(JS/TS) 需学 ArkTS(类 TS)
分布式能力 需自研插件 几乎无法实现 超级终端、设备协同开箱即用

关键结论:没有“最好”,只有“最适合你的业务阶段”


三、真实场景选型指南:别再凭感觉决策

场景一:ToC 电商 / 社交 App,强依赖热更新 + 已有 RN 技术栈

短期继续用 React Native,但必须规划鸿蒙迁移

  • 利用现有 JS 生态快速迭代
  • 通过 RN + 鸿蒙原生模块桥接 过渡
  • 风险:长期看,鸿蒙原子化服务、卡片等新能力难以接入

场景二:金融 / 工具类 App,UI 一致性高 + 动画复杂

优先选 Flutter

  • Impeller 引擎解决卡顿问题,帧率稳定 60fps
  • 双端视觉 100% 一致,设计师省心
  • 但需注意:在鸿蒙 NEXT 上仍为“混合应用”,无法调用分布式软总线等核心能力

场景三:新项目 / 企业级应用,目标覆盖鸿蒙全场景(手机+平板+车机)

果断拥抱 HarmonyOS + ArkTS

  • 华为提供 流量扶持 + 审核绿色通道
  • 原生支持 原子化服务、服务卡片、跨设备流转
  • 长期 ROI 最高:一次投入,享受鸿蒙生态红利 3–5 年

真实案例:某银行理财 App 用 uni-app x(编译到 ArkTS)重构后,

  • 鸿蒙版上线时间缩短 70%
  • 用户次日留存提升 12%(因启动更快、体验更原生)

四、鸿蒙 NEXT:不是“又一个 Android”,而是新操作系统

很多人误以为“鸿蒙 = 换皮 Android”,这是致命误区。

HarmonyOS NEXT 是独立内核、独立生态、独立分发体系的操作系统

  • 不兼容 APK
  • 应用必须使用 ArkTS + ArkUI 开发
  • 核心能力围绕 “超级终端” 构建(手机 → 平板 → 车机无缝协同)

这意味着:

  • React Native 和 Flutter 无法直接上架纯血鸿蒙
  • 即使通过 WebView 或混合模式嵌入,也会被标记为“非原生”,失去推荐位和用户信任

华为官方表态:“原生应用 = 更高转化率 + 更低卸载率


五、未来三年技术投资建议

团队类型 推荐策略
已有 RN/Flutter 大型项目 维持双端,新增鸿蒙模块用 ArkTS 重写,逐步解耦
中小创业团队 新项目直接用 uni-app x 或 ArkTS,抢占鸿蒙早期红利
前端主导型团队 选择 uni-app x(Vue 技术栈),平滑过渡到鸿蒙原生
追求极致性能/图形 Flutter + 鸿蒙插件 仍是选项,但接受生态局限

记住:技术选型的本质,是对未来生态的押注


六、结语:跨端的终点,是“融入平台”

React Native 试图用 JS 统一世界,却困于 Bridge;
Flutter 用 Skia 掌控一切,却难融鸿蒙生态;
HarmonyOS 说:别跨了,来我的世界,我们一起定义下一代交互

2026 年,跨端开发的胜负手不再是“代码复用率”,
而是 “能否深度融入平台生态,释放设备潜能”

鸿蒙开发者官网:developer.harmonyos.com
uni-app x 鸿蒙迁移工具:hx.dcloud.net.cn

今天的选择,决定你明年能否站在鸿蒙的浪尖——
而不是被浪潮拍在沙滩上。

如果是你,会 All in 鸿蒙还是坚守 Flutter/RN?投票!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Vite 8 来了:彻底抛弃 Rollup 和 esbuild!Rust 重写后,快到 Webpack 连尾灯都看不见

你的项目启动还在等 3 秒?
而 Vite 8,0.08 秒进入开发界面——改一行代码,10 毫秒热更新,快到浏览器都来不及渲染加载动画。

如果你以为 Vite 7 已经够快,那你还没见过 Vite 8 的真正实力
这一次,它不再“优化”,而是彻底重构底层——用 Rust 编写的 Rolldown 取代了原有的 esbuild + Rollup 双引擎架构,性能飙升 10–30 倍,并构建起一个前所未有的全栈式前端工具链

Webpack?它可能连“笨重”都不配了——它已经过时


一、从“快”到“瞬时”:Vite 8 的架构革命

过去,Vite 的“快”依赖两个引擎:

  • esbuild:用于依赖预构建(快但功能有限)
  • Rollup:用于生产打包(稳定但慢)

这种混合架构虽有效,却存在上下文切换开销、缓存不一致、调试复杂等问题。

而 Vite 8 宣布:全部交给 Rolldown

什么是 Rolldown?

  • 由 Vite 团队主导开发的 Rollup 兼容打包器
  • 100% 用 Rust 编写,基于高性能 JS 解析器 Oxc(Ox Compiler)
  • 完全兼容 Rollup 插件生态,但速度提升 10–30 倍
  • 内存占用更低,启动更迅捷

简单说:Rolldown = Rollup 的 Rust 超级加强版

这意味着:开发与生产使用同一套核心引擎,彻底消除“dev vs build”行为差异。


二、Vite 8 三大杀手级更新

1. 统一工具链:Vite + Rolldown + Oxc = 前端“全家桶”

Vite 8 不再只是一个 dev server,而是一个端到端的现代前端基础设施

功能 技术栈 优势
模块解析 / HMR Vite(JS) 极速 ESM 开发体验
依赖预构建 / 打包 Rolldown(Rust) 比 Rollup 快 30 倍
TypeScript / JSX 解析 Oxc(Rust) 比 Babel 快 100 倍,内存少 90%

从此,你不再需要:

  • Babel(Oxc 原生支持 TS/JSX)
  • TSC(类型检查仍可用,但转译不再依赖)
  • 多个打包器配置

一套工具,贯穿开发、构建、部署


2. 内置 tsconfig paths 支持,告别别名配置烦恼

曾经,要在 Vite 中使用 @/components 这类路径别名,你必须手动配置 resolve.alias

// vite.config.js(旧)
resolve: {
  alias: {
    '@': path.resolve(__dirname, 'src')
  }
}

现在?只需一行

// vite.config.js(Vite 8)
export default defineConfig({
  resolve: {
    tsconfigPaths: true  // 自动读取 tsconfig.json 中的 paths
  }
})

Vite 8 会自动同步你的 tsconfig.json零配置实现路径映射,TypeScript 开发者狂喜。


3. 装饰器元数据开箱即用:NestJS、Angular 用户终于自由了!

TypeScript 的 emitDecoratorMetadata 选项常用于依赖注入(如 NestJS、TypeORM)。
过去在 Vite 中需额外插件或 Babel 配置才能支持。

Vite 8 + Oxc 原生支持该特性,无需任何配置:

@Injectable()
export class UserService {
  constructor(private db: Database) {} // 装饰器元数据自动生成
}

这对全栈 TypeScript 项目(尤其是 Node.js + NestJS + Vue/React 前端)是巨大利好。


三、实测:Vite 8 vs Vite 3 vs Webpack 5

我们在一台 M2 MacBook Pro 上,用含 300+ 组件的大型 React + TS 项目测试:

指标 Webpack 5 Vite 8
冷启动时间 18.2 秒 0.08 秒
生产构建时间 32 秒 3.1 秒
HMR 更新延迟 1.5 秒 10 毫秒
内存峰值 1.4 GB 220 MB

构建速度提升最惊人:32 秒 → 3 秒,意味着 CI/CD 流水线效率翻倍。


四、但它适合所有人吗?

Vite 8 虽强,但迁移需注意:

  • Rollup 插件需兼容 Rolldown:大多数官方插件已适配,社区插件正在跟进;
  • 极端定制化构建逻辑:如深度 AST 操作,可能需等待 Oxc 插件生态成熟;
  • Windows/Linux 性能差异缩小:Rust 跨平台优势让非 Mac 用户同样受益。

但对于 95% 的现代前端项目(Vue、React、Svelte、Solid、Qwik),Vite 8 已是当前最优解


五、5 分钟体验 Vite 8

# 创建新项目(自动使用 Vite 8)
npm create vite@latest my-vite8-app -- --template react-ts

# 进入目录
cd my-vite8-app

# 安装(Rolldown 作为默认打包器)
npm install

# 启动
npm run dev

图片显示

你会看到终端几乎瞬间输出本地地址。打开浏览器——页面已就绪。

修改代码,保存——界面无闪烁、状态不丢失、快到你怀疑没生效

这才是 2026 年前端开发该有的体验。


六、未来已来:前端工具链的“Rust 化”浪潮

Vite 8 不是孤例:

  • Bun:Zig + JavaScriptCore
  • Tauri:Rust + WebView
  • Oxc / SWC / Rolldown:Rust 编译器全家桶

JavaScript 工具链正在全面向系统级语言迁移,只为一个目标:极致性能

而 Vite 8,正是这场变革的集大成者。


结语:快,已经不够了;我们要“瞬时”

Webpack 教会我们如何模块化;
Vite 8,正在重新定义“前端工具”的极限

在这个连 AI 都要本地运行的时代,每一毫秒的等待,都是对开发者创造力的浪费

官网:vitejs.dev
Rolldown 仓库:github.com/rolldown/ro…

今天,就用 Vite 8 创建你的下一个项目——
你可能会忘记,原来“等待”这个词,曾经存在于前端开发中。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Vue实战|折腾两天,终于找到业务打印救星:vue-print-designer网页套打插件接入

在后台管理系统、电商ERP、工单管理这类企业级业务系统里,打印功能从来都不是简单调用浏览器原生print就能落地的刚需场景。前段时间做内部业务系统改造,我卡在订单、物流面单、财务单据的web打印、网页套打环节,前前后后折腾整整两天,试过原生window.print+媒体查询、printJS、多款小众网页打印控件,要么套打精度差、换设备就错位,要么模板定制繁琐、兼容性拉胯,直到用上vue-print-designer,才彻底解决这块业务痛点,稳稳落地各类打印需求。

这篇文章不走浮夸宣传路线,只分享真实踩坑经历、零冗余实战步骤和落地心得,把这款适配Vue项目的web打印插件、网页打印控件推荐给同样被打印需求困扰的前端同行,全程只讲实操、不夸大效果,还原真实接入体验。


60b6ff7d-eee3-4f2a-b7cd-a81925c09c47.png

一、先说说踩坑两天的痛点:为什么常规打印方案行不通

最开始做业务打印,我走的是常规思路:用原生window.print配合媒体查询样式,做简单的单据打印,结果上线后问题一堆,完全扛不住真实业务场景:

  • 套打精度极差:针对固定格式的单据、快递面单、收货清单,手写CSS很难做到边距、元素位置精准对齐,换台打印机、换个浏览器就错位,业务端完全没法用;
  • 模板定制成本高:每新增一种打印模板,就要重新写一套样式,动态数据绑定繁琐,后期维护成本直线上升,前端精力全耗在调整打印样式上;
  • 功能太单一:只能触发浏览器打印,没法导出PDF、生成图片,不支持条形码、二维码自动生成,也没法做批量打印、静默打印,满足不了仓库、财务的实际业务需求;
  • 兼容性问题频发:不同浏览器的打印内核差异大,部分样式不生效,分页逻辑混乱,甚至出现内容截断的情况。

中间也换过两款小众网页打印控件,要么依赖过重,要么文档残缺,调试半天跑不起来,白白浪费了两天时间。直到在开源平台找到vue-print-designer,一款轻量、易接入、专注web套打的打印插件,上手调试后,直接解决了所有核心痛点,适配现有项目毫无压力。

二、vue-print-designer 核心优势:为什么能成为业务打印救星

这款插件没有花里胡哨的冗余功能,全部围绕web打印、网页套打、可视化模板设计核心需求设计,风格极简实用,刚好戳中业务打印的痛点,适合各类后台系统集成:

  1. 可视化拖拽设计,告别手写CSS:自带打印模板设计器,支持拖拽添加文本、图片、条形码、二维码、表格等元素,上传底图就能做精准套打,所见即所得,不用再反复调试样式;
  2. 轻量无侵入,接入成本极低:不强制依赖特定UI框架,基于标准前端规范开发,Vue2/Vue3项目都能兼容,甚至可嵌入非Vue的后台系统(比如FastAdmin这类混合架构),不用改造原有项目架构;
  3. 功能贴合业务,实用性拉满:支持浏览器直接打印、PDF导出、图片导出,支持动态数据绑定、批量打印,适配订单、工单、面单、标签等各类业务打印场景;
  4. 开源免费,文档清晰:开源可商用,代码可二次定制,官方文档步骤详细,新手也能快速上手,没有付费门槛和功能限制。

插件官方Gitee地址:gitee.com/theGreatOld… ,需要的开发者可以直接跳转查看源码和完整文档。


三、实战接入:Vue项目完整集成步骤(零踩坑版)

这里以Vue3项目为例,分享最简洁的接入流程,全程不搞复杂配置,按照步骤操作即可快速落地,Vue2项目仅需微调引入方式,官方文档有对应说明。

1. 插件引入:CDN/本地安装二选一

推荐新手直接用CDN引入,无需安装依赖,快速验证效果;正式项目可本地安装,便于管理。

方式一:CDN在线引入(推荐快速测试)

<!-- 引入Vue3核心 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<!-- 引入vue-print-designer 样式与组件 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/vue-print-designer/style.css">
<script src="https://cdn.jsdelivr.net/npm/vue-print-designer"></script>

方式二:npm本地安装

npm install vue-print-designer --save
# 或者yarn
yarn add vue-print-designer

2. 全局注册/局部引入组件

在main.js中全局注册,方便全项目各个页面调用,也可在需要打印的页面单独引入。

import { createApp } from 'vue'
import App from './App.vue'
import PrintDesigner from 'vue-print-designer'
import 'vue-print-designer/style.css'

const app = createApp(App)
// 全局注册打印组件
app.use(PrintDesigner)
app.mount('#app')

3. 页面使用:模板设计+数据绑定实战

直接在业务页面使用组件,绑定后台返回的业务数据,支持加载预设模板、动态赋值,实现单据、订单的快速套打。

<template>
  <div class="print-page">
    <!-- 打印设计器容器 -->
    <print-designer
      ref="printRef"
      :template="printTemplate"
      :variables="orderData"
    />
    <!-- 打印操作按钮组 -->
    <div class="print-btn-group">
      <button @click="handlePrint">直接打印</button>
      <button @click="handleExportPdf">导出PDF</button>
    </div>
  </div>
</template>

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

// 打印组件实例引用
const printRef = ref(null)
// 预设打印模板(设计器导出JSON后直接赋值使用)
const printTemplate = ref({})
// 业务单据数据:订单/工单动态绑定数据
const orderData = ref({
  orderNo: 'YD202603130089',
  createTime: '2026-03-13 14:30:22',
  receiver: '张先生',
  phone: '138****1234',
  address: '北京市朝阳区某某大厦15层',
  goodsList: [
    { name: '办公笔记本', num: 2, price: 39.9 },
    { name: '中性笔套装', num: 1, price: 19.9 }
  ],
  totalPrice: 99.7
})

// 初始化打印模板,可替换为接口获取后台存储的模板JSON
const initTemplate = () => {
  // 实际项目中从接口/本地文件加载已设计好的模板
  printTemplate.value = {}
}

// 浏览器直接打印调用
const handlePrint = () => {
  // 可选静默打印模式,配合客户端实现无弹窗打印
  printRef.value?.print({ mode: 'browser' })
}

// 导出PDF文件,自定义文件名
const handleExportPdf = () => {
  printRef.value?.export({ type: 'pdf', filename: `订单_${orderData.value.orderNo}.pdf` })
}

// 页面挂载后初始化模板
initTemplate()
</script>

<style scoped>
.print-page {
  width: 100%;
  padding: 20px;
  box-sizing: border-box;
}
.print-btn-group {
  margin-top: 20px;
  display: flex;
  gap: 12px;
}
.print-btn-group button {
  padding: 8px 16px;
  cursor: pointer;
  border: none;
  border-radius: 4px;
  background: #409eff;
  color: #fff;
}
</style>

4. 实战避坑技巧

  • 模板复用:先在可视化设计器里完成模板拖拽制作,导出JSON格式,存入数据库或本地文件,后续直接接口调用加载,无需前端重复配置;
  • 数据绑定:模板变量名与业务数据字段严格对应,数组类型数据直接绑定,插件自动渲染列表内容,无需手动循环;
  • 打印效果统一:正式环境建议固定纸张规格,关闭浏览器默认页眉页脚,避免不同设备打印效果不一致;
  • 批量打印优化:批量打印时采用分页加载、逐个渲染的方式,避免一次性加载大量数据导致页面卡顿。

用 分段渲染 解决小程序长列表卡顿问题

在 H5 开发中,我们习惯通过监听 onScroll 事件,根据滚动位移实时计算并更新 DOM 节点。但在微信小程序中,由于逻辑层JS与视图层Webview的双线程架构,频繁滚动通信会导致 setData 积压,造成白屏和卡顿。

一、实现思路

利用原生提供的 IntersectionObserver 实现 分段渲染。首先将成百上千条的扁平数据按固定数量(如10条一组)切分为多个块,在页面上对应渲染出一系列块容器锚点。不同于实时计算,我们仅监听这些块容器与视口的交叉状态,设置一个约 600px 的缓冲区,当块容器进入缓冲区时,利用 v-if 触发 DOM 的挂载。当其滑出缓冲区时,立即销毁内部节点以释放内存。为了防止滚动条在内容销毁后发生抖动或塌陷,动态记录每一个块在渲染时的真实高度并缓存到映射表中,内容卸载后通过 CSS 的 min-height 进行物理占位。


二、核心代码

1. 数据分块

小程序中 setData 的数据量直接影响渲染性能。通过 computed 进行分块,本质是将大数组渲染转化为分片渲染。

// 计算拿到分块数组
const chunkedList = computed(() => {
  const chunks = []
  for (let i = 0; i < list.length; i += chunkSize) {
    chunks.push({ id: i / chunkSize, items: list.slice(i, i + chunkSize) })
  }
  return chunks
})

2. 状态监听

传统的 onScroll 方案需要从视图层向逻辑层高频同步 scrollTop,造成双线程通信拥塞。而 IntersectionObserver 运行在原生渲染层,仅在达到触发条件时回调一次,极大地节省了 CPU 开销。

const visibleMap = ref({}) // 记录块的可视性
const heightMap = ref({})  // 记录块的高度
const instance = getCurrentInstance()
let observer = null

// 启动监测
const startObserver = () => {
  if (observer) observer.disconnect()
  // observeAll: true 允许同时监听所有符合条件的 .chunk-anchor
  observer = uni.createIntersectionObserver(instance.proxy, { observeAll: true })
  
  // 设置合适的缓冲区大小,让节点在进入视口前提前开始渲染
  observer.relativeToViewport({ top: 600, bottom: 600 })
    .observe('.chunk-anchor', (res) => {
      const { id } = res.dataset
      const isIntersecting = res.intersectionRatio > 0
      
      // 更新可见性
      visibleMap.value[id] = isIntersecting
      
      // 当块进入视口渲染完成后,立即捕捉真实高度并缓存
      if (isIntersecting && res.boundingClientRect.height > 0) {
        heightMap.value[id] = res.boundingClientRect.height
      }
    })
}

3. 模板占位

使用 min-height 解决长列表优化中的非固定高度导致的页面塌陷问题。

<template>
  <scroll-view class="scroll-container" scroll-y @scroll="$emit('scroll', $event)">
    <view 
      v-for="chunk in chunkedList" 
      :key="chunk.id"
      :data-id="chunk.id"
      class="chunk-anchor"
      :style="{ 
        /** 
         * 当块可见时,设为 auto 让内部元素撑开
         * 当块销毁时,使用 heightMap 记录的真实高度或 estimatedSize 初始预估高度进行物理占位
         */
        minHeight: visibleMap[chunk.id] ? 'auto' : (heightMap[chunk.id] || estimatedSize) + 'px' 
      }"
    >
      <template v-if="visibleMap[chunk.id]">
        <view v-for="item in chunk.items" :key="item[itemKey]">
          <slot name="list-item" :item="item" />
        </view>
      </template>
    </view>
  </scroll-view>
</template>

三、总结

显隐判断由小程序原生层处理,不依赖逻辑层的 onScroll 计算。滑出视口的块会被销毁,即使有数千个节点,页面始终只保持几十个真实 DOM 节点,极大降低内存占用。由于有缓冲区预渲染,用户几乎感知不到 DOM 的动态加载。通过 heightMap 自动记录块渲染后的真实高度,解决由于项高度不固定导致的滚动条跳动问题。

异步加载的组件

异步加载的组件

代码

import { ref, defineAsyncComponent, watch, type Component } from 'vue'

interface TabsConfigItem {
    key: ENUM_DETAIL_TAB
    tab: string
    component: Component
}

// Tab 栏
export const enum ENUM_DETAIL_TAB {
    accountDetail = 'accountDetail',
    relatedTransaction = 'relatedTransaction',
    historicalCase = 'historicalCase',
    processLog = 'processLog'
}

/**
 * @constant tabsConfig
 * @description 动态组件的核心配置
 * 定义了每个 Tab 的唯一 key(引用枚举值)、显示的标题以及对应的异步加载组件。
 */
const tabsConfig: TabsConfigItem[] = [
    // 账户详情
    {
        key: ENUM_DETAIL_TAB.accountDetail,
        tab: riskManagementT('accountDetail'),
        component: defineAsyncComponent(() => import('./accountDetail.vue'))
    },
    // 关联交易
    {
        key: ENUM_DETAIL_TAB.relatedTransaction,
        tab: riskManagementT('relatedTransaction'),
        component: defineAsyncComponent(() => import('./relatedTransaction.vue'))
    },
    // 历史案件
    {
        key: ENUM_DETAIL_TAB.historicalCase,
        tab: riskManagementT('historicalCase'),
        component: defineAsyncComponent(() => import('./historicalCase.vue'))
    },
    // 处理日志
    {
        key: ENUM_DETAIL_TAB.processLog,
        tab: riskManagementT('processLog'),
        component: defineAsyncComponent(() => import('./processLog.vue'))
    }
]


// 从核心配置中派生出 Tab 列表和组件映射
const { tabList, tabComponentsMap } = tabsConfig.reduce(
    (acc, tab) => {
        const { key, tab: tabTitle, component } = tab
        acc.tabList.push({ key, tab: tabTitle })
        acc.tabComponentsMap[key] = component
        return acc
    },
    {
        tabList: [] as { key: ENUM_DETAIL_TAB; tab: string }[],
        tabComponentsMap: {} as Record<ENUM_DETAIL_TAB, Component>
    }
)

// 当前激活的 Tab,默认为账户详情
const activeKey = ref<ENUM_DETAIL_TAB>(ENUM_DETAIL_TAB.accountDetail)

defineAsyncComponentVue 3 的 Composition API 中,用来定义异步组件的函数。

它的作用可以概括为:让组件在需要时才加载,而不是一开始就打包进主包,从而减小首屏体积、提升加载速度


1. 基本作用

  • 把组件变成一个异步加载的组件

    在路由或父组件渲染时,才会去请求并加载这个组件的代码。

  • 内部基于 动态 import() 实现,配合构建工具(Vite/Webpack)自动做代码分割(code splitting)


2. 基本用法

import { defineAsyncComponent } from 'vue'

// 方式1:简单动态 import
const AsyncComp = defineAsyncComponent(() => import('./MyComponent.vue'))

// 方式2:带加载/错误状态的配置
const AsyncComp = defineAsyncComponent({
  loader: () => import('./MyComponent.vue'),
  loadingComponent: LoadingSpinner,   // 加载中显示的组件
  errorComponent: ErrorDisplay,       // 加载失败时显示的组件
  delay: 200,                         // 延迟多少 ms 才显示 loading
  timeout: 3000                       // 超时时间
})

3. 使用场景

  • 路由懒加载

    const routes = [
      {
        path: '/dashboard',
        component: () => import('./views/Dashboard.vue')
      }
    ]
    

    在路由配置里,其实也是用了类似的异步组件思想。

  • 大型页面/低频功能

    比如“报表弹窗”“富文本编辑器”等,只有用户点开时才加载,减少首屏 JS 体积。

  • 需要加载状态/错误处理时

    loadingComponenterrorComponent给用户更好的体验。


4. 和 import()的区别

  • import()返回的是 Promise<Component>,需要你自己处理加载/错误状态。
  • defineAsyncComponent是对 import()封装,帮你更方便地配置加载中、错误、超时等行为,并且在 Vue 组件树里使用时更符合 Vue 的生命周期管理。

一句话总结

defineAsyncComponent的作用是 把组件变成按需异步加载的组件,既能减小首屏体积,又能优雅处理加载中和加载失败的情况,是 Vue 3 性能优化的重要工具。


动态插槽

通过 v-for 循环动态生成所有需要批量输入的具名插槽。#[item.field] 是 Vue 的动态插槽名语法,它会根据 item.field 的值(如 'icAccountList')来决定要渲染哪个插槽,从而避免了在模板中重复编写每个插槽的 template。

原代码

 <!-- 发卡账户 -->
            <template #icAccount="{ model, field }">
                <a-input-group compact>
                    <a-input
                        v-model:value.trim="model[field]"
                        :placeholder="dataT('icAccount')"
                        allow-clear
                    />
                    <a-button type="primary" class="border-5" @click="openBatchInputModal(field)">
                        <template #icon>
                            <PlusCircleOutlined />
                        </template>
                    </a-button>
                </a-input-group>
            </template>
            <!-- 企业账号 -->
            <template #opIdList="{ model, field }">
                <a-input-group compact>
                    <a-input
                        v-model:value.trim="model[field]"
                        :placeholder="dataT('opIds')"
                        allow-clear
                    />
                    <a-button type="primary" class="border-5" @click="openBatchInputModal(field)">
                        <template #icon>
                            <PlusCircleOutlined />
                        </template>
                    </a-button>
                </a-input-group>
            </template>

优化后

 <template
                v-for="item in editingList"
                :key="item.field"
                #[item.field]="{ model, field }"
            >
                <a-input-group compact>
                    <a-input
                        v-model:value.trim="model[field]"
                        :placeholder="item?.placeholder"
                        allow-clear
                        @input="handleInput($event, field)"
                    />
                    <a-button type="primary" class="border-5" @click="openBatchInputModal(field)">
                        <template #icon>
                            <PlusCircleOutlined />
                        </template>
                    </a-button>
                </a-input-group>
            </template>

Vite 项目性能优化实践:从打包体积到首屏加载

以下内容 仅供参考酌情使用,正好最近优化项目 顺便记录下 后续好翻阅

说起 Vite 和性能优化,网上大多都是讲概念,比如:

  • 什么是分包
  • 什么是懒加载
  • 什么是 Tree Shaking

但很多文章 只讲理论,不讲工程实践

最近正好优化了一个项目的打包体积和首屏加载速度,顺手把 Vite 实战中最常见、最有效的优化方式总结了一下。

本文不讲复杂原理,只讲 真实项目里最有用的优化方法

如果你的项目存在这些问题:

  • 打包后 dist 特别大
  • 首屏加载慢
  • 每次发布用户都要重新下载一堆 JS
  • 图片体积巨大

那么这篇文章基本可以解决 80% 的性能问题


一、默认情况下,Vite 的打包会发生什么?

很多人误以为:

Vite 会把所有代码打成一个 JS。

其实并不是。

Vite 的生产构建基于 Rollup,默认会做基础的 chunk splitting(代码拆分)

例如:

main.js
vendor.js

但默认拆分 不会按照业务语义优化,可能会出现:

首页代码 + lodash
报表代码 + lodash
编辑器代码 + lodash

也就是说:

同一个依赖可能会被多个 chunk 引用,或者混入业务代码。

这会导致几个问题:

1️⃣ 首屏加载体积过大
2️⃣ 浏览器缓存利用率低
3️⃣ 更新业务代码时 vendor 也失效

所以我们需要做 更精细的分包控制


二、第一层优化:路由懒加载(Code Splitting)

最基础也是最重要的一步:

按页面加载代码。

如果不做懒加载:

用户打开首页
↓
下载整个项目所有页面代码

如果做了懒加载:

打开首页
↓
只加载首页代码

进入报表页
↓
再加载报表代码

这样 首屏体积会显著下降


React 示例

import { lazy, Suspense } from 'react'

const Home = lazy(() => import('./pages/Home'))
const Report = lazy(() => import('./pages/Report'))
const Editor = lazy(() => import('./pages/Editor'))

function App() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/report" element={<Report />} />
        <Route path="/editor" element={<Editor />} />
      </Routes>
    </Suspense>
  )
}

核心思想只有一句话:

用到哪个页面,再加载哪个页面的代码。


三、第二层优化:manualChunks 控制第三方分包

懒加载解决的是 业务代码拆分

但第三方依赖仍然可能混在业务 chunk 里。

例如:

page-home.js
  └ react
  └ lodash

page-report.js
  └ echarts
  └ lodash

如果业务代码更新:

page-home hash 改变
↓
lodash 也被重新下载

浏览器缓存就失效了。


manualChunks 的作用

manualChunks 可以 手动控制依赖如何分包

把长期不变的依赖拆出来:

react-vendor.js
utils.js
echarts.js
editor.js

这样:

业务更新
↓
只更新业务 chunk
↓
vendor 继续使用缓存

示例配置

rollupOptions: {
  output: {
    manualChunks: {
      'react-vendor': [
        'react',
        'react-dom',
        'react-router-dom',
        'zustand'
      ],

      'arco-design': ['@arco-design/web-react'],

      echarts: ['@ceai-front/echarts'],

      wangeditor: [
        '@wangeditor-next/editor',
        '@wangeditor-next/editor-for-react'
      ],

      utils: [
        'lodash',
        'lodash-es',
        'dayjs',
        'axios',
        'classnames'
      ]
    }
  }
}

更稳定的写法(推荐)

在大型项目中,很多团队更推荐使用函数写法:

manualChunks(id) {
  if (id.includes('node_modules')) {

    if (id.includes('react')) {
      return 'react-vendor'
    }

    if (id.includes('arco')) {
      return 'arco'
    }

    if (id.includes('echarts')) {
      return 'echarts'
    }
  }
}

原因是:

Rollup 解析模块时使用的是 文件路径

node_modules/react/index.js

函数写法在复杂依赖场景中 更稳定


四、第三层优化:图片体积优化

在很多项目里:

图片往往才是体积最大的资源。

常见情况:

banner.jpg   2MB
icon.png     800KB
background   3MB

一旦页面加载多张图片,性能会明显下降。


使用 vite-plugin-imagemin

安装:

pnpm add vite-plugin-imagemin -D

配置:

import viteImagemin from 'vite-plugin-imagemin'

plugins: [
  viteImagemin({
    gifsicle: { optimizationLevel: 3 },
    optipng: { optimizationLevel: 7 },
    mozjpeg: { quality: 80 },
    pngquant: { quality: [0.7, 0.8] },
    svgo: {
      plugins: [{ name: 'removeViewBox' }]
    }
  })
]

通常可以减少:

PNG  20% ~ 40%
JPG  10% ~ 30%
SVG  50%+

而肉眼几乎看不出差异。


五、一个容易忽略的小优化:assetsInlineLimit

assetsInlineLimit: 4096

意思是:

小于 4KB 的资源会被转换为 Base64 并直接内联到 JS 中。

这样可以减少 HTTP 请求数量。

但不宜设置过大,否则 JS 体积会膨胀。


六、完整的 Vite 优化配置示例

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import viteImagemin from 'vite-plugin-imagemin'

export default defineConfig({

  plugins: [
    react(),

    viteImagemin({
      gifsicle: { optimizationLevel: 3 },
      optipng: { optimizationLevel: 7 },
      mozjpeg: { quality: 80 },
      pngquant: { quality: [0.7, 0.8] },
      svgo: { plugins: [{ name: 'removeViewBox' }] }
    })
  ],

  build: {

    target: 'esnext',

    minify: 'esbuild',

    cssCodeSplit: true,

    sourcemap: false,

    chunkSizeWarningLimit: 2000,

    assetsInlineLimit: 4096,

    rollupOptions: {
      output: {

        manualChunks: {
          'react-vendor': [
            'react',
            'react-dom',
            'react-router-dom',
            'zustand'
          ],

          'arco-design': ['@arco-design/web-react'],

          echarts: ['@ceai-front/echarts'],

          pdfjs: ['pdfjs-dist'],

          utils: [
            'lodash',
            'lodash-es',
            'dayjs',
            'axios',
            'classnames'
          ]
        },

        assetFileNames: 'assets/[name].[hash][extname]',
        chunkFileNames: 'js/[name].[hash].js',
        entryFileNames: 'js/[name].[hash].js'
      }
    }
  }
})

七、Vite 项目最实用的性能优化三件套

如果只记住三件事就够了:

1️⃣ 路由懒加载

控制 什么时候加载代码

首屏体积大幅下降

2️⃣ manualChunks 分包

长期不变的依赖单独打包

浏览器缓存最大化

3️⃣ 图片压缩

减少 最大体积资源

页面加载速度明显提升

三者关系(很好记)

懒加载 = 控制加载时机
分包 = 控制缓存策略
图片压缩 = 减少资源体积

一套下来,项目通常会有明显变化:

  • 首屏体积下降
  • JS chunk 更清晰
  • 浏览器缓存利用率更高
  • 页面加载更快

lodash 到 lodash-es 多的不仅仅是后缀!深入源码看 ES Module 带来的性能与体积优化

一、这东西是什么

lodashlodash-es 都是 JavaScript 实用工具库,提供数组、对象、字符串等数据类型的操作函数。它们的关系是:

  • lodash:基于 CommonJS 模块系统,主要使用 require() 导入
  • lodash-es:基于 ES Module 模块系统,使用 import 导入

核心差异:lodash-es 不是简单的格式转换,而是从源码层面重构了模块化结构,让现代打包工具(Webpack、Rollup、Vite)能够进行 Tree Shaking(摇树优化),只打包用到的函数。

二、这东西有什么用

适用场景

  • 现代前端项目(Vue、React、Angular)
  • 需要按需引入工具函数的场景
  • 对打包体积敏感的项目(移动端、性能要求高的应用)

能带来什么收益

  1. 体积优化:从几百 KB 降到几 KB
  2. Tree Shaking:自动移除未使用的代码
  3. 更好的静态分析:IDE 和打包工具能更准确分析依赖
  4. 未来兼容性:ES Module 是 JavaScript 标准模块系统

三、官方链接

四、从源码看差异

lodash 源码结构(CommonJS)

// lodash 的 _.debounce 函数
module.exports = function debounce(func, wait, options) {
  // ... 实现代码
  return debounced;
};

// 整个 lodash 导出
module.exports = {
  debounce: require('./debounce'),
  throttle: require('./throttle'),
  // ... 几百个函数
};

lodash-es 源码结构(ES Module)

// lodash-es 的 debounce.js
export default function debounce(func, wait, options) {
  // ... 实现代码
  return debounced;
}

// 每个函数独立文件,支持按需导入
// debounce.js, throttle.js, cloneDeep.js 等

关键区别:lodash 将所有函数打包在一个大对象里,lodash-es 将每个函数放在独立文件中。

五、如何做一个 demo 出来

1. 环境要求

  • Node.js 14+
  • 现代打包工具(Webpack 4+、Rollup、Vite)

2. 安装命令

# 安装 lodash-es
npm install lodash-es

# 或者安装特定函数
npm install lodash.debounce lodash.throttle

3. 目录结构说明

project/
├── src/
│   ├── main.js      # 主入口文件
│   └── utils.js     # 工具函数
├── package.json
└── webpack.config.js

4. 最小可运行示例

使用 lodash(传统方式)

// 导入整个 lodash(几百 KB)
const _ = require('lodash');

// 只使用 debounce 函数,但打包了整个 lodash
const debouncedFunc = _.debounce(() => {
  console.log('防抖函数');
}, 300);

使用 lodash-es(现代方式)

// 按需导入特定函数(Webpack 会自动 Tree Shaking)
import { debounce, throttle } from 'lodash-es';

// 或者只导入需要的函数
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';

const debouncedFunc = debounce(() => {
  console.log('防抖函数');
}, 300);

const throttledFunc = throttle(() => {
  console.log('节流函数');
}, 300);

5. Webpack 配置示例

// webpack.config.js
module.exports = {
  mode: 'production',
  entry: './src/main.js',
  output: {
    filename: 'bundle.js',
    path: path.resolve(__dirname, 'dist')
  },
  optimization: {
    usedExports: true,  // 启用 Tree Shaking
    minimize: true      // 代码压缩
  }
};

6. 打包体积对比

创建测试文件:

// test-lodash.js
const _ = require('lodash');
console.log(_.debounce);

// test-lodash-es.js  
import { debounce } from 'lodash-es';
console.log(debounce);

运行打包命令:

# 打包 lodash 版本
npx webpack --entry ./test-lodash.js --output-filename bundle-lodash.js

# 打包 lodash-es 版本  
npx webpack --entry ./test-lodash-es.js --output-filename bundle-lodash-es.js

# 查看文件大小
ls -lh dist/*.js

预期结果

  • bundle-lodash.js:~70KB(整个 lodash)
  • bundle-lodash-es.js:~2KB(仅 debounce 函数)

六、Tree Shaking 原理深入

1. 静态分析

ES Module 的 importexport静态的,打包工具可以在编译时分析:

  • 哪些函数被导入了
  • 哪些函数被使用了
  • 哪些函数可以安全移除

2. 源码对比分析

查看 lodash-es 的 cloneDeep 函数源码:

// lodash-es/cloneDeep.js
import baseClone from './.internal/baseClone.js';

/** 用于标识深拷贝 */
const CLONE_DEEP_FLAG = 1;
const CLONE_SYMBOLS_FLAG = 4;

function cloneDeep(value) {
  return baseClone(value, CLONE_DEEP_FLAG | CLONE_SYMBOLS_FLAG);
}

export default cloneDeep;

关键点:每个函数都是独立的 ES Module,有自己的依赖关系图。

3. 打包工具如何工作

// Webpack 的 Tree Shaking 过程
1. 解析 import 语句 → 找到 lodash-es/debounce
2. 分析 debounce.js 的依赖 → 找到内部依赖
3. 标记使用到的函数 → debounce 被标记为 used
4. 移除未标记的函数 → 其他函数被移除
5. 生成最终 bundle → 只包含 debounce 及其依赖

七、性能实测对比

测试代码

// performance-test.js
import { debounce, throttle, cloneDeep } from 'lodash-es';
// 对比
const _ = require('lodash');

// 测试函数
function testDebounce() {
  const start = performance.now();
  for (let i = 0; i < 10000; i++) {
    const fn = debounce(() => {}, 100);
    fn();
  }
  return performance.now() - start;
}

// 运行测试
console.log('lodash-es debounce:', testDebounce(), 'ms');

体积对比表

使用场景 lodash 体积 lodash-es 体积 优化比例
只使用 debounce 72KB 1.8KB 97.5%↓
使用 5 个常用函数 72KB 8.2KB 88.6%↓
使用 10 个函数 72KB 15.4KB 78.6%↓
使用全部函数 72KB 72KB 0%

八、周边生态推荐

1. 相关工具库

  • lodash-webpack-plugin:Webpack 插件,进一步优化 lodash
  • babel-plugin-lodash:Babel 插件,自动转换 lodash 导入
  • eslint-plugin-lodash:ESLint 插件,检查 lodash 使用

2. 最佳实践

// 推荐:按需导入特定函数
import debounce from 'lodash-es/debounce';
import throttle from 'lodash-es/throttle';

// 不推荐:导入整个库
import _ from 'lodash-es';

// 特殊情况:需要很多函数时
import { debounce, throttle, cloneDeep, isEqual, memoize } from 'lodash-es';

3. 迁移指南

从 lodash 迁移到 lodash-es

// 之前
const _ = require('lodash');
_.debounce(func, 300);

// 之后
import debounce from 'lodash-es/debounce';
debounce(func, 300);

// 或者批量替换
import { debounce, throttle, cloneDeep } from 'lodash-es';

九、常见坑与注意事项

1. Node.js 环境

// Node.js 需要启用 ES Module
// package.json
{
  "type": "module"  // 添加这一行
}

// 或���使用 .mjs 扩展名
import debounce from 'lodash-es/debounce.mjs';

2. TypeScript 配置

// tsconfig.json
{
  "compilerOptions": {
    "module": "esnext",      // 使用 ES Module
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true
  }
}

3. 浏览器直接使用

<!-- 需要支持 type="module" 的浏览器 -->
<script type="module">
  import debounce from 'https://unpkg.com/lodash-es/debounce.js';
  
  const debounced = debounce(() => {
    console.log('Hello from lodash-es!');
  }, 300);
</script>

4. 构建工具兼容性

  • Webpack 4+:原生支持
  • Rollup:原生支持
  • Vite:原生支持
  • Parcel:需要配置

十、总结

lodash 到 lodash-es 的升级,远不止是后缀变化:

  1. 模块化革命:从 CommonJS 大对象到 ES Module 独立文件
  2. 体积优化:Tree Shaking 让打包体积减少 90%+
  3. 性能提升:更快的导入速度,更好的缓存策略
  4. 未来兼容:ES Module 是 JavaScript 标准

迁移建议

  • 新项目直接使用 lodash-es
  • 老项目逐步迁移,从高频函数开始
  • 配合构建工具,最大化 Tree Shaking 效果

最后提醒:lodash-es 不是银弹,如果项目需要大量 lodash 函数,直接导入整个库可能更合适。但对于大多数现代前端项目,lodash-es + Tree Shaking 是最佳选择。

如果对你有用,欢迎点赞、收藏、关注! 下一篇我们将深入分析 antd 组件的源码实现。

参考资料

antd 组件也做了同款效果!深入源码看设计模式在前端组件库的应用

一、这东西是什么

antd(Ant Design)是阿里巴巴开源的 React UI 组件库,提供丰富的企业级 UI 组件。但 antd 的价值不止于组件本身,更在于其优秀的设计模式应用

核心观点:antd 和 lodash-es 虽然领域不同,但都应用了相似的设计模式:

  • 模块化设计:组件独立,支持按需引入
  • 组合模式:通过 props 组合实现复杂功能
  • 装饰器模式:高阶组件增强功能
  • 工厂模式:统一创建相似组件

二、这东西有什么用

适用场景

  • React 项目开发
  • 需要高质量 UI 组件的企业应用
  • 学习前端设计模式的开发者
  • 需要自定义组件库的团队

能带来什么收益

  1. 代码复用:减少重复代码,提高开发效率
  2. 可维护性:清晰的架构让代码更易维护
  3. 一致性:统一的设计模式保证代码风格一致
  4. 扩展性:易于添加新功能或修改现有功能

三、官方链接

四、从源码看设计模式

1. 模块化设计(与 lodash-es 同款)

// antd 的模块化结构
// 每个组件独立目录,支持按需引入
import { Button, Modal, Form } from 'antd';

// 或者按需引入特定组件
import Button from 'antd/es/button';
import Modal from 'antd/es/modal';

源码结构

antd/
├── es/                    # ES Module 版本
│   ├── button/
│   │   ├── index.js      # 入口文件
│   │   ├── button.js     # 主组件
│   │   └── style/        # 样式文件
│   ├── modal/
│   └── ...
├── lib/                  # CommonJS 版本
└── dist/                 # UMD 版本

2. 组合模式(Composition Pattern)

antd 的 Form 组件是组合模式的典型应用:

// antd Form 组件使用组合模式
import { Form, Input, Button } from 'antd';

const MyForm = () => (
  <Form>
    <Form.Item name="username" rules={[{ required: true }]}>
      <Input placeholder="用户名" />
    </Form.Item>
    <Form.Item name="password" rules={[{ required: true }]}>
      <Input.Password placeholder="密码" />
    </Form.Item>
    <Form.Item>
      <Button type="primary" htmlType="submit">
        提交
      </Button>
    </Form.Item>
  </Form>
);

源码分析:Form.Item 作为容器,组合了表单控件和验证逻辑。

3. 装饰器模式(Decorator Pattern)

antd 使用高阶组件(HOC)实现装饰器模式:

// antd 的 withConfigConsumer 高阶组件
import { ConfigConsumer } from '../config-provider/context';

function withConfigConsumer(config) {
  return function withConfigConsumerFunc(Component) {
    return function WrappedComponent(props) {
      return (
        <ConfigConsumer>
          {context => <Component {...config} {...props} {...context} />}
        </ConfigConsumer>
      );
    };
  };
}

// 使用装饰器增强组件
const EnhancedButton = withConfigConsumer({
  prefixCls: 'ant-btn'
})(Button);

4. 工厂模式(Factory Pattern)

antd 的 notification 组件使用工厂模式:

// notification 工厂函数
import Notification from './notification';

// 创建不同类型的通知
const notification = {
  success: (config) => Notification.success(config),
  error: (config) => Notification.error(config),
  info: (config) => Notification.info(config),
  warning: (config) => Notification.warning(config),
  open: (config) => Notification.open(config),
};

// 使用
notification.success({
  message: '操作成功',
  description: '数据已保存',
});

五、如何做一个 demo 出来

1. 环境要求

  • Node.js 14+
  • React 16.8+
  • TypeScript(可选)

2. 安装命令

# 创建 React 项目
npx create-react-app antd-pattern-demo --template typescript

# 安装 antd
cd antd-pattern-demo
npm install antd

# 安装分析工具
npm install --save-dev @types/react @types/react-dom

3. 目录结构说明

antd-pattern-demo/
├── src/
│   ├── components/
│   │   ├── MyButton/      # 自定义按钮组件
│   │   ├── MyForm/        # 自定义表单组件
│   │   └── MyModal/       # 自定义弹窗组件
│   ├── patterns/          # 设计模式示例
│   │   ├── composition/   # 组合模式
│   │   ├── decorator/     # 装饰器模式
│   │   └── factory/       # 工厂模式
│   ├── App.tsx
│   └── index.tsx
├── package.json
└── tsconfig.json

4. 最小可运行示例

组合模式示例

// src/patterns/composition/FormDemo.tsx
import React from 'react';
import { Form, Input, Button, Select } from 'antd';

const { Option } = Select;

const FormDemo: React.FC = () => {
  const onFinish = (values: any) => {
    console.log('表单值:', values);
  };

  return (
    <Form
      name="basic"
      initialValues={{ remember: true }}
      onFinish={onFinish}
      layout="vertical"
      {/* 组合 Input 和验证规则 */}
      <Form.Item
        label="用户名"
        name="username"
        rules={[
          { required: true, message: '请输入用户名' },
          { min: 3, message: '至少3个字符' }
        ]}
        <Input placeholder="请输入用户名" />
      </Form.Item>

      {/* 组合 Select 和选项 */}
      <Form.Item
        label="角色"
        name="role"
        rules={[{ required: true, message: '请选择角色' }]}
        <Select placeholder="请选择角色">
          <Option value="admin">管理员</Option>
          <Option value="user">普通用户</Option>
          <Option value="guest">访客</Option>
        </Select>
      </Form.Item>

      {/* 组合 Button 和提交逻辑 */}
      <Form.Item>
        <Button type="primary" htmlType="submit">
          提交
        </Button>
      </Form.Item>
    </Form>
  );
};

export default FormDemo;

装饰器模式示例

// src/patterns/decorator/withLoading.tsx
import React, { ComponentType, useState, useEffect } from 'react';
import { Spin } from 'antd';

// 高阶组件:为组件添加加载状态
function withLoading<P extends object>(
  WrappedComponent: ComponentType<P>
): React.FC<P & { isLoading?: boolean }> {
  return function WithLoadingComponent(props) {
    const [loading, setLoading] = useState(props.isLoading || false);

    // 模拟异步加载
    useEffect(() => {
      if (props.isLoading) {
        setLoading(true);
        const timer = setTimeout(() => {
          setLoading(false);
        }, 2000);
        return () => clearTimeout(timer);
      }
    }, [props.isLoading]);

    if (loading) {
      return (
        <div style={{ padding: '50px', textAlign: 'center' }}>
          <Spin size="large" />
          <div style={{ marginTop: '16px' }}>加载中...</div>
        </div>
      );
    }

    return <WrappedComponent {...props as P} />;
  };
}

// 使用装饰器
const UserList: React.FC<{ users: string[] }> = ({ users }) => (
  <ul>
    {users.map((user, index) => (
      <li key={index}>{user}</li>
    ))}
  </ul>
);

const UserListWithLoading = withLoading(UserList);

// 在组件中使用
const App: React.FC = () => {
  const users = ['张三', '李四', '王五'];
  
  return (
    <div>
      <h2>用户列表(带加载效果)</h2>
      <UserListWithLoading users={users} isLoading={true} />
    </div>
  );
};

工厂模式示例

// src/patterns/factory/NotificationFactory.tsx
import React from 'react';
import { Button, Space } from 'antd';
import { notification } from 'antd';

// 通知工厂
class NotificationFactory {
  static create(type: 'success' | 'error' | 'info' | 'warning', config: any) {
    const methods = {
      success: notification.success,
      error: notification.error,
      info: notification.info,
      warning: notification.warning,
    };

    return methods[type]({
      duration: 3,
      placement: 'topRight',
      ...config,
    });
  }

  // 预定义的通知类型
  static success(message: string, description?: string) {
    return this.create('success', { message, description });
  }

  static error(message: string, description?: string) {
    return this.create('error', { message, description });
  }

  static info(message: string, description?: string) {
    return this.create('info', { message, description });
  }

  static warning(message: string, description?: string) {
    return this.create('warning', { message, description });
  }
}

// 使用工厂
const NotificationDemo: React.FC = () => {
  const showNotification = (type: 'success' | 'error' | 'info' | 'warning') => {
    const messages = {
      success: '操作成功!',
      error: '操作失败!',
      info: '这是提示信息',
      warning: '请注意警告',
    };

    NotificationFactory[type](messages[type], '详细描述信息');
  };

  return (
    <Space>
      <Button type="primary" onClick={() => showNotification('success')}>
        成功通知
      </Button>
      <Button danger onClick={() => showNotification('error')}>
        错误通知
      </Button>
      <Button onClick={() => showNotification('info')}>
        信息通知
      </Button>
      <Button type="dashed" onClick={() => showNotification('warning')}>
        警告通知
      </Button>
    </Space>
  );
};

5. 运行项目

# 启动开发服务器
npm start

# 访问 http://localhost:3000

六、设计模式在前端开发中的应用场景

1. 组合模式(Composition)

适用场景

  • 表单组件(Form + Form.Item + Input)
  • 布局组件(Layout + Header + Content + Footer)
  • 导航菜单(Menu + Menu.Item + SubMenu)

antd 源码示例

// antd/es/form/Form.tsx
const Form: React.FC<FormProps> = (props) => {
  return (
    <FormProvider>
      <FormContext.Provider value={formContextValue}>
        <FormComponent {...props} />
      </FormContext.Provider>
    </FormProvider>
  );
};

// Form.Item 作为子组件
Form.Item = FormItem;

2. 装饰器模式(Decorator)

适用场景

  • 权限控制(withAuth)
  • 数据加载(withLoading)
  • 错误处理(withErrorBoundary)
  • 样式增强(withStyles)

antd 源码示例

// antd/es/config-provider/context.tsx
export const ConfigConsumer = ConfigContext.Consumer;

// 使用 ConfigConsumer 装饰组件
export function withConfigConsumer<C extends React.ComponentType<any>>(
  config: ConsumerConfig
) {
  return function withConfigConsumerFunc(
    Component: C
  ): React.ComponentType<any> {
    // 返回装饰后的组件
    return (props: any) => (
      <ConfigConsumer>
        {(context) => (
          <Component {...config} {...props} {...context} />
        )}
      </ConfigConsumer>
    );
  };
}

3. 工厂模式(Factory)

适用场景

  • 创建不同类型的弹窗(Modal.success/error/info)
  • 创建不同类型的消息(message.success/error)
  • 创建不同类型的通知(notification.success/error)

antd 源码示例

// antd/es/modal/confirm.tsx
export default function confirm(config: ModalFuncProps) {
  // 创建确认对话框的工厂函数
  const div = document.createElement('div');
  document.body.appendChild(div);
  
  let currentConfig = { ...config, close, visible: true };
  
  function destroy() {
    // 销毁逻辑
  }
  
  function render(props: any) {
    // 渲染逻辑
  }
  
  function update(newConfig: ModalFuncProps) {
    // 更新逻辑
  }
  
  function close() {
    // 关闭逻辑
  }
  
  render(currentConfig);
  
  return {
    destroy: close,
    update,
  };
}

// 工厂方法
Modal.confirm = (props: ModalFuncProps) => confirm(props);
Modal.success = (props: ModalFuncProps) => confirm({ ...props, icon: <CheckCircleOutlined /> });
Modal.error = (props: ModalFuncProps) => confirm({ ...props, icon: <CloseCircleOutlined /> });

七、性能优化与最佳实践

1. 按需引入(与 lodash-es 同款)

// 推荐:按需引入
import Button from 'antd/es/button';
import Form from 'antd/es/form';
import 'antd/es/button/style';
import 'antd/es/form/style';

// 不推荐:全量引入
import { Button, Form } from 'antd';
import 'antd/dist/antd.css';

2. 使用 babel-plugin-import

// .babelrc 或 babel.config.js
{
  "plugins": [
    ["import", {
      "libraryName": "antd",
      "libraryDirectory": "es",
      "style": true
    }]
  ]
}

// 现在可以这样写,插件会自动转换
import { Button } from 'antd';
// 转换为 ↓
import Button from 'antd/es/button';
import 'antd/es/button/style';

3. 组件性能优化

// 使用 React.memo 避免不必要的重渲染
import React, { memo } from 'react';
import { Button } from 'antd';

const MyButton = memo(({ onClick, children }) => {
  console.log('MyButton 渲染');
  return <Button onClick={onClick}>{children}</Button>;
});

// 使用 useCallback 缓存函数
const App = () => {
  const handleClick = useCallback(() => {
    console.log('按钮点击');
  }, []);
  
  return <MyButton onClick={handleClick}>点击我</MyButton>;
};

八、与 lodash-es 的对比分析

特性 lodash-es antd 共同点
模块化 ES Module 独立文件 ES Module 独立组件 都支持按需引入
Tree Shaking 支持 支持 都依赖静态分析
设计模式 函数式编程 面向对象设计模式 都注重代码组织
使用场景 工具函数 UI 组件 都提供高质量代码

核心相似点:都通过优秀的架构设计,解决了代码复用性能优化的问题。

九、常见坑与注意事项

1. 样式问题

// 错误:忘记引入样式
import { Button } from 'antd';
// 缺少:import 'antd/es/button/style';

// 正确:使用 babel-plugin-import 或手动引入
import Button from 'antd/es/button';
import 'antd/es/button/style';

2. 版本兼容性

// package.json
{
  "dependencies": {
    "antd": "^4.0.0",  // 注意主版本号
    "react": "^16.8.0", // 需要 React 16.8+
    "react-dom": "^16.8.0"
  }
}

3. TypeScript 配置

// tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  }
}

4. 自定义主题

// craco.config.js(Create React App)
const CracoLessPlugin = require('craco-less');

module.exports = {
  plugins: [
    {
      plugin: CracoLessPlugin,
      options: {
        lessLoaderOptions: {
          lessOptions: {
            modifyVars: {
              '@primary-color': '#1DA57A', // ��改主题色
            },
            javascriptEnabled: true,
          },
        },
      },
    },
  ],
};

十、总结

antd 和 lodash-es 虽然解决不同问题,但都体现了优秀前端库的共同特点:

  1. 模块化设计:支持按需引入,减少打包体积
  2. 设计模式应用:组合、装饰器、工厂等模式提升代码质量
  3. 性能优化:Tree Shaking、Memoization 等技术
  4. 开发者体验:清晰的 API、完整的文档、TypeScript 支持

学习建议

  1. 阅读优秀开源库的源码,理解设计思想
  2. 在实际项目中应用设计模式
  3. 关注性能优化,特别是打包体积
  4. 保持代码的可维护性和可扩展性

最后:优秀的前端工程师不仅要会使用工具,更要理解工具背后的设计思想。antd 和 lodash-es 都是学习前端架构的绝佳教材。

如果对你有用,欢迎点赞、收藏、关注! 下一篇我们将深入分析 Vue KeepAlive 的源码实现。

参考资料

前端巨型列表渲染

1️⃣虚拟化渲染

只渲染用户当前能看到的那几行,其他的用空白占位。这样DOM节点数大大减少,性能飙升。

// 下载
npm install react-virtuoso
// 引入
import { Virtuoso } from 'react-virtuoso';
// 模拟数据
const generateDynamicItems = (count: number) => {
    return Array.from({ length: count }, (_, index) => ({
        id: index,
        title: `Post ${index}`,
        // 随机生成不同长度的内容
        content: 'Lorem ipsum '.repeat(Math.floor(Math.random() * 20) + 1),
    }));
};
const VirtuosoDynamicExample = () => {
    const items = generateDynamicItems(5000);

    return (
        <div style={{ border: '1px solid #ccc', padding: '10px' }}>
            <h3>Virtuoso (动态高度,自动测量)</h3>
            <Virtuoso
                style={{ height: '400px', width: '100%' }}
                totalCount={items.length}
                itemContent={(index) => {
                    const item = items[index];
                    return (
                        <div
                            style={{
                                padding: '12px',
                                borderBottom: '1px solid #eee',
                                backgroundColor: index % 2 ? '#f9f9f9' : '#fff',
                            }}
                        >
                            <h4 style={{ margin: '0 0 6px 0' }}>{item.title}</h4>
                            <p style={{ margin: 0 }}>{item.content}</p>
                        </div>
                    );
                }}
                // 增加上下预加载缓冲区,防止快速滚动出现空白
                overscan={200}
            />
        </div>
     );
};
export default VirtuosoDynamicExample;
image.png
  • 优点:简单、成熟、支持交互。
  • 缺点:每个项还是DOM,如果列表项内部太复杂(比如嵌套图表、大量图片),DOM节点依然可能拖垮性能。

2️⃣Canvas 手绘列表

既然DOM是瓶颈,那我们干脆不用DOM!用 <canvas> 直接画!把列表项当成画布上的线条和文字,滚动时重绘视口内容。没有DOM节点,只有像素,性能直接起飞。

  • 用一个 <canvas> 覆盖整个列表容器。
  • 监听滚动事件,计算当前应该显示哪些数据。
  • 调用 Canvas API 绘制这些数据。
  • 用占位 div 撑开滚动条。
// 数据准备
const generateItems = (count: number) => Array.from({ length: count }, (_, i) => `Item ${i} - ${Math.random().toFixed(2)}`);

// 固定配置
const ITEM_HEIGHT = 30; // 每行高度
const LIST_HEIGHT = 200; // 容器高度
const TOTAL_COUNT = 10000; // 总数据量
// 滚动容器
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 渲染容器
const canvasWrapperRef = useRef<HTMLDivElement>(null);
// canvas实例
const canvasRef = useRef<HTMLCanvasElement>(null);

// 同步存储:数据 + 滚动偏移
const dataRef = useRef(generateItems(TOTAL_COUNT));
const scrollTopRef = useRef(0);

初始化 Canvas 画布

// 初始化 Canvas 尺寸(只执行一次)
const initCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    const wrapper = canvasWrapperRef.current;
    if (!canvas || !wrapper) return;
    const dpr = window.devicePixelRatio || 1;
    const width = wrapper.clientWidth;
    const height = LIST_HEIGHT;
    
    // 强制设置 Canvas 尺寸(物理 + 显示)
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;

    // 初始化上下文
    const ctx = canvas.getContext("2d");
    if (ctx) {
        ctx.scale(dpr, dpr);
        ctx.clearRect(0, 0, width, height);
    }
}, []);

初始化+窗口改变再次初始化

// 初始化 + 窗口resize
useEffect(() => {
    initCanvas();
    renderFrame();
    
    // 监听resize
    const handleResize = () => {
        initCanvas();
        renderFrame();
    };
    
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
}, [initCanvas, renderFrame]);

监听滚动事件

// 滚动监听:同步更新 + 立即绘制
const handleScroll = useCallback(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    // 同步更新滚动偏移
    scrollTopRef.current = container.scrollTop;
    // 强制逐帧绘制
    requestAnimationFrame(renderFrame);
}, [renderFrame]);

// 监听滚动事件(直接绑DOM,跳过React合成事件)
useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    
    container.addEventListener("scroll", handleScroll);
    
    return () => container.removeEventListener("scroll", handleScroll);
}, [handleScroll]);

核心的绘制方法

// 核心绘制:纯同步,无任何异步依赖
const renderFrame = useCallback(() => {
    const canvas = canvasRef.current;
    const wrapper = canvasWrapperRef.current;
    const container = scrollContainerRef.current;
    if (!canvas || !wrapper || !container) return;
    
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const width = wrapper.clientWidth;
    const height = LIST_HEIGHT;
    const scrollTop = scrollTopRef.current;

    // 1. 清空画布(强制重绘,无残留)
    ctx.clearRect(0, 0, width, height);
    // 2. 计算可视范围(极简,无边界错误)
    const firstIdx = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT));
    const lastIdx = Math.min(firstIdx + Math.ceil(height / ITEM_HEIGHT) + 2, TOTAL_COUNT - 1);
    // 3. 逐行绘制(坐标绝对正确)
    for (let i = firstIdx; i <= lastIdx; i++) {
        // y 坐标
        const y = i * ITEM_HEIGHT - scrollTop;
        // 背景
        ctx.fillStyle = i % 2 ? "#f5f5f5" : "#ffffff";
        ctx.fillRect(0, y, width, ITEM_HEIGHT);
        // 文字
        ctx.fillStyle = "#333";
        ctx.font = "14px Arial";
        ctx.textBaseline = "middle";
        ctx.fillText(dataRef.current[i], 10, y + ITEM_HEIGHT / 2);
    }
}, []);

UI 展示组件

const CanvasVirtualList = () => {
    return (
        {/* 1. 整体容器(超出隐藏,作为顶层定位元素) */}
        <div
            style={{
                width: "100%",
                height: LIST_HEIGHT,
                position: "relative",
                border: "1px solid #ccc",
                overflow: "hidden",
            }}
        >
            {/* 2. 滚动容器(只负责滚动,无视觉渲染) */}
            <div
                ref={scrollContainerRef}
                style={{
                    width: "100%",
                    height: "100%",
                    overflowY: "auto",
                    opacity: 0,
                    position: "relative",
                    zIndex: 1,
                }}
            >
                <div style={{ height: `${TOTAL_COUNT * ITEM_HEIGHT}px` }} />
            </div>
            {/* 3. Canvas容器(绝对定位覆盖,负责渲染) */}
            <div
                ref={canvasWrapperRef}
                style={{
                    position: "absolute",
                    top: 0,
                    left: 0,
                    width: "100%",
                    height: "100%",
                    zIndex: 0,
                }}
            >
                <canvas ref={canvasRef} style={{ width: "100%", height: "100%" }} />
            </div>
        </div>
    );
};
image.png
  • 效果:丝般顺滑!10万条数据随便滚,CPU占用极低。
  • 缺点:文本无法选中,无法直接绑定点击事件(需要自己用坐标判断),样式全靠Canvas API。

3️⃣离屏 Canvas + 双缓冲

上面的方案每次滚动都重绘整个可见区,如果列表项很复杂(比如带渐变、阴影),每次绘制可能还是会有点卡。怎么办?预绘制 + 双缓冲

  • 准备一个离屏Canvas(内存里的画布),尺寸比可视区大几倍(比如3倍高)。
  • 在离屏Canvas上预先绘制好当前视口附近的数据。
  • 当用户滚动时,直接从离屏Canvas裁剪相应区域绘制到主Canvas,省去了绘制逻辑
  • 当滚动超出预绘制范围时,重新生成离屏Canvas。
// 数据准备
const generateItems = (count: number) => Array.from({ length: count }, (_, i) => `Item ${i} - ${Math.random().toFixed(2)}`);
// 固定配置
const ITEM_HEIGHT = 30;
const LIST_HEIGHT = 200;
const TOTAL_COUNT = 10000;
// DOM 引用
const scrollContainerRef = useRef<HTMLDivElement>(null);
const canvasWrapperRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
// 离屏渲染(每个项独立缓存,避免尺寸关联)
const itemCacheRef = useRef<Map<number, HTMLCanvasElement>>(new Map()); // 缓存每个项的Canvas
const dataRef = useRef(generateItems(TOTAL_COUNT));
const scrollTopRef = useRef(0);
// 缓存容器宽度,避免频繁获取DOM
const containerWidthRef = useRef(0);
// 滚动监听(防抖+立即绘制)
const handleScroll = useCallback(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    scrollTopRef.current = container.scrollTop;
    // 取消旧的绘制请求,避免重复绘制导致闪烁
    cancelAnimationFrame(Number(container.dataset.rafId));
    const rafId = requestAnimationFrame(renderFrame);
    container.dataset.rafId = rafId.toString();
}, [renderFrame]);

// 滚动事件绑定

useEffect(() => {
    const container = scrollContainerRef.current;
    if (!container) return;
    container.addEventListener("scroll", handleScroll);
    return () => {
        container.removeEventListener("scroll", handleScroll);
        // 取消未执行的绘制请求
        cancelAnimationFrame(Number(container.dataset.rafId));
    };
}, [handleScroll]);
// 初始化主Canvas(解决布局+尺寸问题)
const initCanvas = useCallback(() => {
    const canvas = canvasRef.current;
    const wrapper = canvasWrapperRef.current;
    if (!canvas || !wrapper) return;
    // 缓存容器宽度(后续绘制用)
    containerWidthRef.current = wrapper.clientWidth;
    const width = containerWidthRef.current;
    const height = LIST_HEIGHT;
    const dpr = window.devicePixelRatio || 1;
    // 主Canvas核心配置(解决闪烁+尺寸)
    canvas.width = width * dpr;
    canvas.height = height * dpr;
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
    canvas.style.display = "block"; // 消除inline布局的像素偏差
    canvas.style.imageRendering = "pixelated"; // 避免模糊
    // 初始化主上下文(只缩放一次)
    const ctx = canvas.getContext("2d");
    if (ctx) {
        ctx.scale(dpr, dpr);
        ctx.clearRect(0, 0, width, height);
    }
    // 清空旧缓存(容器尺寸变了,重新生成项Canvas)
    itemCacheRef.current.clear();
}, []);
// 核心渲染(无闪烁+精准绘制)
const renderFrame = useCallback(() => {
    const canvas = canvasRef.current;
    const wrapper = canvasWrapperRef.current;
    const container = scrollContainerRef.current;
    if (!canvas || !wrapper || !container) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const width = containerWidthRef.current;
    const height = LIST_HEIGHT;
    const scrollTop = scrollTopRef.current;
    // 1. 单次清空(彻底解决闪烁):只清一次可视区域
    ctx.clearRect(0, 0, width, height);
    // 2. 精准计算可视范围(无边界错误)
    const firstIdx = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT));
    const lastIdx = Math.min(
        firstIdx + Math.ceil(height / ITEM_HEIGHT) + 1, // 只多渲染1项,减少绘制量
        TOTAL_COUNT - 1
    );
    // 3. 逐项绘制(从独立离屏Canvas复制,无拉伸)
    for (let i = firstIdx; i <= lastIdx; i++) {
        const itemCanvas = createItemCanvas(i);
        const y = i * ITEM_HEIGHT - scrollTop; // 精准Y坐标
        // 无缩放复制:源尺寸=目标尺寸,彻底解决拉伸
        ctx.drawImage(
            itemCanvas,
            0,
            0,
            width,
            ITEM_HEIGHT,
            0,
            y,
            width,
            ITEM_HEIGHT
        );
    }
}, [createItemCanvas]);
// 初始化单个项的离屏Canvas(独立尺寸,避免拉伸)
const createItemCanvas = useCallback((index: number) => {
    if (itemCacheRef.current.has(index)) {
        return itemCacheRef.current.get(index)!;
    }
    const canvas = document.createElement("canvas");
    const dpr = window.devicePixelRatio || 1;
    const width = containerWidthRef.current || 400; // 用缓存的容器宽度
    // 关键:项Canvas尺寸 = 容器宽度 * DPR + 项高度 * DPR(不缩放)
    canvas.width = width * dpr;
    canvas.height = ITEM_HEIGHT * dpr;
    const ctx = canvas.getContext("2d");
    if (!ctx) return canvas;
    // 绘制项内容(背景+文字,尺寸完全固定)
    ctx.fillStyle = index % 2 ? "#f5f5f5" : "#ffffff";
    ctx.fillRect(0, 0, width, ITEM_HEIGHT);
    ctx.fillStyle = "#333";
    ctx.font = "14px Arial"; // 固定14px,不会放大
    ctx.textBaseline = "middle";
    ctx.fillText(dataRef.current[index], 10, ITEM_HEIGHT / 2);
    itemCacheRef.current.set(index, canvas);
    return canvas;
}, []);

这种技术常用于地图、长列表的平滑滚动,尤其适合列表项非常复杂的场景。

4️⃣瓦片化渲染(Tile)

还记得地图是怎么做到无限缩放的吗?瓦片(Tile)!我们可以把列表也切成固定大小的“瓦片”,每个瓦片包含多行数据,预先渲染并缓存起来。滚动时只加载和绘制视口内的瓦片。

实现思路

  1. 定义瓦片高度(如512px),每片包含若干行。
  2. 维护一个瓦片缓存:Map<tileIndex, canvas>
  3. 滚动时计算当前视口需要哪些瓦片索引。
  4. 如果缓存中没有,就创建离屏Canvas绘制该瓦片,存入缓存。
  5. 将所有需要的瓦片绘制到主Canvas的对应位置。
  • 优点:缓存复用,重复区域不必重绘;适合无限滚动 + 复杂项。
  • 缺点:需要管理缓存淘汰,实现稍复杂。

5️⃣WebGL/GPU 加速

当数据量达到百万级,即使Canvas的绘制也可能成为瓶颈(因为绘制指令还是CPU发出的)。这时候就要请出GPU了!用WebGL直接操作GPU,每个列表项作为一个“图元”或“纹理”,利用并行计算疯狂输出。

常用库

  • PixiJS:基于WebGL的2D渲染引擎,API友好,可以轻松创建数千个Sprite并流畅交互。
  • Three.js:主要用于3D,但也可以做2D。
  • regl:函数式WebGL,极致性能,但学习曲线陡。

用 PixiJS 渲染大列表(示例)

import { useEffect, useRef } from 'react';
import * as PIXI from 'pixi.js';

const PixiList = ({ items, itemHeight = 30 }) => {
  const containerRef = useRef();

  useEffect(() => {
    // 创建Pixi应用
    const app = new PIXI.Application({
      width: 800,
      height: 600,
      backgroundColor: 0xf5f5f5,
      resolution: window.devicePixelRatio || 1,
    });
    containerRef.current.appendChild(app.view);

    // 创建文本样式
    const style = new PIXI.TextStyle({ fontSize: 16, fill: '#333' });

    // 批量创建文本对象(Pixi内部会优化渲染)
    for (let i = 0; i < items.length; i++) {
      const text = new PIXI.Text(items[i], style);
      text.x = 10;
      text.y = i * itemHeight;
      app.stage.addChild(text);
    }

    // 添加一个遮罩,实现视口裁剪
    const mask = new PIXI.Graphics();
    mask.beginFill(0xffffff);
    mask.drawRect(0, 0, 800, 600);
    mask.endFill();
    app.stage.mask = mask;

    // 添加滚动事件(需要自己实现)
    // ...

    return () => app.destroy(true);
  }, [items]);

  return <div ref={containerRef} />;
};

体验:百万条数据滚动依然60帧!而且可以轻松绑定交互事件(Pixi支持点击检测)。但要注意,如果创建太多Pixi对象,内存也可能爆掉,所以还是要结合虚拟化(只创建可见区域的对象)。

方案对比与选型指南 📊

方案 数据量级 优点 缺点 适用场景
虚拟滚动 1k - 10w 简单、支持交互、CSS样式 DOM仍有开销,复杂项可能卡 大部分业务列表
Canvas 即时绘制 10w - 百万 无DOM限制,绘制快 交互难,文本不可选 纯展示型数据,如日志
离屏Canvas 10w - 百万 滚动更平滑,适合复杂项 内存占用稍大 需要流畅滚动的复杂列表
瓦片化渲染 百万+ 缓存复用,性能极佳 实现复杂,需管理缓存 地图式列表、无限滚动
WebGL (Pixi) 百万+ GPU加速,可交互 学习成本高,内存管理重要 超大规模可视化、游戏

Taskpool简单使用2

taskpool 传递参数;

在调用方法时传递多个值;代码如下:

@Concurrent
function culSum(param1:number,param2:number){
  let sum = param1+param2
  console.log(`--------- > taskpool ${sum} .. param1 ${param1} .. params2 ${param2}` )
  return sum;
}


taskpool.execute(culSum, 10, 10).catch(() => {
})
  .then((result) => {
    console.log('--------- > taskpool result ' + result)
  })

执行结果代码:

 --------- > taskpool 20 .. param1 10 .. params2 10
 --------- > taskpool result 20

其实在taskpool.execute()方法针对参数的传递是不限制个数的;相应的参数代码如下:

function execute(func: Function, ...args: Object[]): Promise<Object>;

args:参数有点类似于kotlin 方法中的 vararg,java 中的 String... ;

如何在@Concurrent 方法中调用其他方法:

在@Concurrent 方法中调用其他方法;在平时的开发中,如果在子线程执行任务,不可能就写一个方法直接将全部的业务都堆积到内部。因此我们可能根据设计模式的单一原则,将功能进行拆分成多个方法执行;但是在鸿蒙中@Concurrent 方法 是禁止使用闭包变量。

例如错误代码

function bar() {
}

@Concurrent
function foo() {
  bar(); // 违反闭包原则,报错
}

那么在开发中,可以通过在调用方法时传递进入一个共享对象,在该对象类中存在执行的逻辑;或者直接使用工具类调用其静态方法;代码如下:

model:
@Sendable
export class StudentModel{
  public name:string = '小明'
  constructor( name: string) {
    this.name = name
  }
//执行的方法,在子线程中
  culSum(param:number,param2:number){
    return param +param2
  }
}

触发代码:

 let student = new StudentModel( '小明')
  taskpool.execute(verifyEnum, student).catch(() => {
  }).then((result) => {
    console.error('--------- > taskpool '+JSON.stringify(result))
  })

 方法:
 @Concurrent
function verifyEnum(student: StudentModel) {
  console.error('--------- > taskpool ' + JSON.stringify(student))
  student.name = '小花'
  student.culSum(10,10)
  return student
}

同样也可以调用静态方法执行相关逻辑 如下:

export class StudentUtil {
  private constructor() {
  }

  static culSum(param: number, param2: number) {
    return param + param2
  }
}

方法:
@Concurrent
function verifyEnum(student: StudentModel) {
 let sum =  student.culSum(10,10)
  let sum1 = StudentUtil.culSum(11,11)

  console.error(`--------- > taskpool sum ${sum}  sum1 ${sum1}`)

  return student
}

结果:

--------- > taskpool sum 20  sum1 22

遇见问题:

1.在使用taskpool 过程中,如果在接收方法上添加了 async 后,需要在taskpool.execute 方法添加await 否则也无法正常的拿到值;

2.在低版本中在自定义 bean类上添加了@Sendable 后,无法使用枚举类型;这个升级版本就可以了;

3.线程间数据传递,如果自定义 Bean 不添加@Sendable 是采用的序列化方式;因此在子线程中的对象调用对象类中的方法会失效;

如果描述中出现问题,请各位大佬不吝赐教;

Vue 动态表单(Dynamic Form)

Vue 动态表单(Dynamic Form)

动态表单是指根据数据配置(如 JSON 或 JavaScript 对象)来动态生成表单字段的组件。它能够极大地提高开发效率,减少重复代码,尤其适用于字段频繁变化、需要配置化的场景,如后台管理系统、问卷生成器、自定义表单等。

什么是动态表单

传统的表单开发中,每个字段都需要在模板中手动编写 <input><select> 等标签,并绑定对应的 v-model 和验证规则。而动态表单通过配置驱动的方式,将字段的元数据(类型、标签、验证规则、布局等)抽象为一个数组或对象,然后使用 Vue 的渲染能力(如 v-for)循环生成表单元素。

核心思想: 将表单的结构与实现分离,通过修改配置即可调整表单,无需修改模板代码。

为什么需要动态表单

  • 提高开发效率:减少模板代码的编写,尤其是表单字段数量大、变化频繁的场景。
  • 增强可维护性:表单结构集中在配置中,修改字段只需调整配置项。
  • 支持配置化/可视化:可与后台接口配合,实现由后端返回表单配置的动态表单;也可用于拖拽式表单设计器。
  • 易于扩展:增加新的字段类型只需在渲染函数中添加对应组件,不影响现有逻辑。

基础实现

定义字段配置

首先,我们需要定义一组字段配置,每个字段包含类型、标签、字段名、默认值等信息。

// formConfig.js
export const fields = [  {    type: 'input',    label: '用户名',    field: 'username',    placeholder: '请输入用户名',    defaultValue: ''  },  {    type: 'select',    label: '性别',    field: 'gender',    options: [      { label: '男', value: 1 },      { label: '女', value: 2 }    ],
    defaultValue: 1
  },
  {
    type: 'radio',
    label: '爱好',
    field: 'hobby',
    options: [
      { label: '读书', value: 'book' },
      { label: '运动', value: 'sport' }
    ],
    defaultValue: 'book'
  },
  {
    type: 'checkbox',
    label: '技能',
    field: 'skills',
    options: [
      { label: 'Vue', value: 'vue' },
      { label: 'React', value: 'react' }
    ],
    defaultValue: ['vue']
  }
]

渲染表单

在 Vue 组件中,使用 v-for 遍历配置,根据 type 动态渲染不同的表单项。为了简化,我们可以用 v-if / v-else-if 判断,或者使用动态组件 <component :is="...">

<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in fields" :key="field.field" class="form-item">
      <label :for="field.field">{{ field.label }}</label>
      
      <!-- 根据字段类型渲染不同控件 -->
      <input
        v-if="field.type === 'input'"
        :id="field.field"
        v-model="formData[field.field]"
        :placeholder="field.placeholder"
      />
      
      <select
        v-else-if="field.type === 'select'"
        :id="field.field"
        v-model="formData[field.field]"
      >
        <option v-for="opt in field.options" :key="opt.value" :value="opt.value">
          {{ opt.label }}
        </option>
      </select>
      
      <div v-else-if="field.type === 'radio'">
        <label v-for="opt in field.options" :key="opt.value">
          <input
            type="radio"
            :name="field.field"
            :value="opt.value"
            v-model="formData[field.field]"
          />
          {{ opt.label }}
        </label>
      </div>
      
      <div v-else-if="field.type === 'checkbox'">
        <label v-for="opt in field.options" :key="opt.value">
          <input
            type="checkbox"
            :value="opt.value"
            v-model="formData[field.field]"
          />
          {{ opt.label }}
        </label>
      </div>
    </div>
    
    <button type="submit">提交</button>
  </form>
</template>

<script setup>
import { ref } from 'vue'
import { fields } from './formConfig'

// 初始化表单数据
const formData = ref({})
fields.forEach(field => {
  formData.value[field.field] = field.defaultValue
})

const handleSubmit = () => {
  console.log('表单数据:', formData.value)
}
</script>

说明:

  • 使用 v-model 绑定到 formData 对象的对应字段。
  • 注意 checkboxv-model 绑定到数组,允许多选。
  • 这种方式简单直观,但当字段类型增多时,模板中的 v-if 会显得臃肿。我们可以进一步优化,使用动态组件。

使用动态组件优化渲染

我们可以为每种字段类型创建一个独立的组件(如 InputField.vueSelectField.vue),然后在模板中用 <component :is="getComponent(field.type)" /> 动态渲染。


<template>
  <form @submit.prevent="handleSubmit">
    <div v-for="field in fields" :key="field.field" class="form-item">
      <label>{{ field.label }}</label>
      <component
        :is="getComponent(field.type)"
        :field="field"
        v-model="formData[field.field]"
      />
    </div>
    <button type="submit">提交</button>
  </form>
</template>

<script setup>
import { ref, markRaw } from 'vue'
import InputField from './components/InputField.vue'
import SelectField from './components/SelectField.vue'
import RadioField from './components/RadioField.vue'
import CheckboxField from './components/CheckboxField.vue'

const fields = [...] // 配置数组

const componentMap = markRaw({
  input: InputField,
  select: SelectField,
  radio: RadioField,
  checkbox: CheckboxField
                
})

const getComponent = (type) => componentMap[type] || null

const formData = ref({})
fields.forEach(field => {
  formData.value[field.field] = field.defaultValue
})

const handleSubmit = () => {
  console.log('表单数据:', formData.value)
}
</script>

每个字段组件接收 field 配置和 modelValue(用于 v-model),内部实现对应的控件。例如 InputField.vue

<template>
  <input
    :value="modelValue"
    @input="$emit('update:modelValue', $event.target.value)"
    v-bind="$attrs"
  />
</template>

<script setup>
defineProps(['modelValue', 'field'])
defineEmits(['update:modelValue'])
</script>

使用动态组件让代码更清晰,扩展新类型只需增加对应的组件,无需修改模板。

进阶功能

表单验证

动态表单的验证可以设计为配置式,例如在字段配置中添加 rules 属性。验证可以在提交时统一执行,也可以实时触发。我们可以使用第三方库如 VeeValidateVuelidate,也可以手动实现。

手动实现简单验证示例:

在字段配置中增加 rules

{
  type: 'input',
  label: '邮箱',
  field: 'email',
  rules: [
    { required: true, message: '邮箱不能为空' },
    { pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: '邮箱格式不正确' }
  ]
}

在组件中,添加验证逻辑:

<script setup>
import { ref } from 'vue'
const errors = ref({})

const validate = () => {
  const newErrors = {}
  fields.forEach(field => {
    if (field.rules) {
      for (const rule of field.rules) {
        if (rule.required && !formData.value[field.field]) {
          newErrors[field.field] = rule.message
          break
        }
        if (rule.pattern && !rule.pattern.test(formData.value[field.field])) {
          newErrors[field.field] = rule.message
          break
        }
      }
    }
  })
  errors.value = newErrors
  return Object.keys(newErrors).length === 0
}

const handleSubmit = () => {
  if (validate()) {
    // 提交
  }
}
</script>

在模板中显示错误信息:

<div v-if="errors[field.field]" class="error">{{ errors[field.field] }}</div>

如果使用 UI 库(如 Element Plus),其表单组件通常自带验证机制,只需将配置传递给相应组件即可。

布局控制

动态表单常常需要灵活的布局,例如栅格系统。可以在字段配置中添加布局属性,如 span(占列数)、offset 等。

{
  type: 'input',
  label: '姓名',
  field: 'name',
  span: 12, // 占12列(假设24栅格)
  // ...
}

在模板中,可以结合 CSS 框架(如 Tailwind、Bootstrap 或 Element Plus 的布局组件)实现动态布局。

以 Element Plus 为例:

<el-form>
  <el-row :gutter="20">
    <el-col v-for="field in fields" :key="field.field" :span="field.span || 24">
      <el-form-item :label="field.label">
        <component
          :is="getComponent(field.type)"
          :field="field"
          v-model="formData[field.field]"
        />
      </el-form-item>
    </el-col>
  </el-row>
</el-form>

字段联动

联动是指一个字段的值变化影响另一个字段的显示、禁用、选项等。可以在配置中定义 dependencies,并在渲染时根据依赖动态计算属性。

实现思路:

  • 在字段配置中添加 visible 函数(或 if 条件),返回布尔值控制显示。
  • 使用 watch 监听依赖字段的变化,动态更新目标字段的配置(如选项列表)。

简单示例:根据选择的“国家”改变“城市”的选项。

{
  type: 'select',
  label: '国家',
  field: 'country',
  options: [...]
},
{
  type: 'select',
  label: '城市',
  field: 'city',
  options: [], // 初始为空
  dependsOn: 'country',
  updateOptions: (country) => {
    // 根据 country 返回新的选项数组
    if (country === 'china') return [{ label: '北京', value: 'beijing' }]
    // ...
  }
}

在组件中,可以定义一个方法监听依赖变化并更新选项。

动态增删字段

某些场景需要允许用户动态添加表单项,例如一组可重复的输入框(如教育经历)。可以在配置中支持 array 类型,使用 v-for 渲染多个相同结构的组。

示例: 动态添加技能列表。

配置:

{
  type: 'dynamic',
  label: '技能列表',
  field: 'skills',
  itemConfig: {
    type: 'input',
    placeholder: '请输入技能'
  },
  defaultValue: ['']
}

渲染时,维护一个数组,并提供添加/删除按钮。

<template>
  <div v-for="(item, index) in formData.skills" :key="index">
    <input v-model="formData.skills[index]" />
    <button @click="removeSkill(index)">删除</button>
  </div>
  <button @click="addSkill">添加技能</button>
</template>

<script setup>
const formData = ref({ skills: [''] })
const addSkill = () => formData.value.skills.push('')
const removeSkill = (index) => formData.value.skills.splice(index, 1)
</script>

结合 UI 库(Element Plus)的完整示例

下面是一个使用 Element Plus 实现的动态表单示例,包含验证和布局。

<template>
  <el-form :model="formData" :rules="rules" ref="formRef" label-width="100px">
    <el-row :gutter="20">
      <el-col
        v-for="field in fields"
        :key="field.field"
        :span="field.span || 24"
        v-if="field.visible ? field.visible(formData) : true"
      >
        <el-form-item
          :label="field.label"
          :prop="field.field"
          :rules="field.rules"
        >
          <!-- 动态组件渲染字段 -->
          <component
            :is="getComponent(field.type)"
            :field="field"
            v-model="formData[field.field]"
            v-bind="field.props"
          />
        </el-form-item>
      </el-col>
    </el-row>
    <el-form-item>
      <el-button type="primary" @click="submitForm">提交</el-button>
    </el-form-item>
  </el-form>
</template>

<script setup>
import { ref, reactive, markRaw } from 'vue'
import { ElMessage } from 'element-plus'

// 字段类型映射组件
import ElInput from './components/ElInput.vue'   // 封装 Element Plus 输入框
import ElSelect from './components/ElSelect.vue' // 封装 Element Plus 选择器
// ... 其他组件

const componentMap = markRaw({
  input: ElInput,
  select: ElSelect,
  // ...
})

const getComponent = (type) => componentMap[type]

// 字段配置
const fields = ref([
  {
    type: 'input',
    label: '用户名',
    field: 'username',
    span: 12,
    rules: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    props: { placeholder: '请输入用户名' }
  },
  {
    type: 'select',
    label: '性别',
    field: 'gender',
    span: 12,
    rules: [{ required: true, message: '请选择性别', trigger: 'change' }],
    options: [
      { label: '男', value: 1 },
      { label: '女', value: 2 }
    ],
    props: { placeholder: '请选择' }
  },
  {
    type: 'input',
    label: '邮箱',
    field: 'email',
    span: 24,
    rules: [
      { required: true, message: '请输入邮箱', trigger: 'blur' },
      { type: 'email', message: '请输入正确的邮箱', trigger: 'blur' }
    ],
    props: { placeholder: '请输入邮箱' }
  }
])

// 表单数据
const formData = ref({})
fields.value.forEach(field => {
  formData.value[field.field] = field.defaultValue ?? ''
})

// 表单引用
const formRef = ref()

const submitForm = async () => {
  if (!formRef.value) return
  await formRef.value.validate((valid, fields) => {
    if (valid) {
      ElMessage.success('提交成功')
      console.log('表单数据:', formData.value)
    } else {
      console.log('验证失败', fields)
    }
  })
}
</script>

其中,封装的组件(如 ElInput.vue)需要适配 Element Plus 的 v-model 用法,并将 field.props 传递给原生组件:

<template>
  <el-input
    :model-value="modelValue"
    @update:model-value="$emit('update:modelValue', $event)"
    v-bind="field.props"
  />
</template>

<script setup>
defineProps(['modelValue', 'field'])
defineEmits(['update:modelValue'])
</script>

注意事项与最佳实践

  • 响应式数据:确保 formData 是响应式的,并在字段变化时能够触发视图更新。
  • 性能优化:如果字段数量很大,考虑使用虚拟滚动或懒加载;避免在模板中放置复杂的计算逻辑。
  • 类型扩展:将字段类型组件设计为可插拔,便于新增类型。
  • 配置标准化:定义统一的字段配置格式,便于维护和文档化。
  • 与后端配合:动态表单常与后端 API 结合,由后端返回表单配置(包括字段、选项、验证规则),前端只需渲染。
  • 可访问性:确保动态生成的表单元素具有正确的 idname 和标签关联,提升无障碍体验。

Vue 动态组件(Dynamic Components)

Vue 动态组件(Dynamic Components)

动态组件是 Vue 中一个非常实用的特性,它允许我们在同一个挂载点(一个 <component> 元素)上动态地切换不同的组件。这种机制使得组件的渲染逻辑更加灵活,尤其在需要根据用户交互或应用状态改变视图时非常有用。

什么是动态组件

简单来说,动态组件就是通过一个特殊的 <component> 元素,并绑定其 is 属性来决定当前要渲染的组件is 属性的值可以是一个已注册的组件名,也可以是一个导入的组件对象。

is 的值发生变化时,Vue 就会销毁旧的组件实例并用新的组件替换。

基本用法

使用 <component> 元素

在模板中,使用 <component> 标签,并通过 :is 绑定要渲染的组件:

<template>
  <component :is="currentComponent"></component>
</template>

<script setup>
import { ref } from 'vue'
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'

const currentComponent = ref(ComponentA) // 也可以使用组件名 'ComponentA'
</script>

:is 的两种绑定方式

  • 绑定组件对象(推荐):直接导入组件并绑定。

    <script setup>
    import { ref } from 'vue'
    import ComponentA from './ComponentA.vue'
    import ComponentB from './ComponentB.vue'
    
    const currentComponent = ref(ComponentA) // 也可以使用组件名 'ComponentA'
    </script>
    
  • 绑定组件名称字符串:组件必须在 components 选项中注册(选项式API)或全局注册。

    <script>
    // 选项式 API 示例
    export default {
      data() {
        return {
          current: 'MyComponent'
        }
      },
      components: {
        MyComponent
      }
    }
    </script>
    

实际示例:通过按钮切换组件

<template>
  <div>
    <button
      v-for="tab in tabs"
      :key="tab"
      @click="currentTab = tab"
      :class="{ active: currentTab === tab }"
    >
      {{ tab }}
    </button>

    <component :is="currentTabComponent" class="tab-content"></component>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import HomeTab from './HomeTab.vue'
import PostsTab from './PostsTab.vue'
import ArchiveTab from './ArchiveTab.vue'

const tabs = ['Home', 'Posts', 'Archive']
const currentTab = ref('Home')

const currentTabComponent = computed(() => {
  switch (currentTab.value) {
    case 'Home': return HomeTab
    case 'Posts': return PostsTab
    case 'Archive': return ArchiveTab
    default: return HomeTab
  }
})
</script>

使用 <keep-alive> 缓存组件状态

默认情况下,每次切换动态组件,Vue 都会销毁旧组件并创建新组件,这意味着组件内部的状态会丢失。如果我们希望保留组件的状态(例如表单输入内容、滚动位置等),可以将 <component> 包裹在 <keep-alive> 标签内。

<template>
  <keep-alive>
    <component :is="currentTabComponent"></component>
  </keep-alive>
</template>

这样,被切换掉的组件会被缓存,而不是销毁。当再次切换回来时,组件会从缓存中恢复,保留之前的状态。

按条件缓存

<keep-alive> 还支持 includeexclude 属性,用于指定哪些组件需要被缓存(通过组件名称匹配)。

<keep-alive include="HomeTab,PostsTab">
  <component :is="currentTabComponent"></component>
</keep-alive>

动态组件与异步组件结合

当应用较大时,我们可以结合 Vue 的异步组件来按需加载,提高首屏加载速度。

<template>
  <component :is="asyncComponent"></component>
</template>

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

const asyncComponent = ref(null)

// 假设在某个时机加载组件
function loadComponent() {
  asyncComponent.value = defineAsyncComponent(() =>
    import('./HeavyComponent.vue')
  )
}
</script>

动态组件的 is 属性可以直接接收一个异步组件工厂函数,Vue 会在需要渲染时自动解析它。

注意事项

is 属性的绑定方式(Vue 2 vs Vue 3)

  • Vue 3:直接使用 :is="组件对象/名称",无需额外指令。
  • Vue 2:动态组件的 is 属性通常写作 :is="componentName",但如果想直接传入组件对象,需要使用 is 属性并配合 v-bind

避免使用 HTML 元素名作为组件名

在 Vue 3 中,如果将 is 绑定到一个 HTML 标签名(如 'div'),Vue 会将其渲染为普通 HTML 元素,而不是 Vue 组件。这通常用于在原生元素上动态切换标签,但如果你想要的是 Vue 组件,请确保绑定的值是组件对象或已注册的组件名称。

XSS 防范

永远不要将用户可编辑的内容直接作为 is 的值(例如通过 v-html 或拼接字符串),否则可能导致 XSS 攻击。应当始终使用受控的组件名或组件对象。

v-if / v-else 的选择

  • 如果只有少数几个固定组件的切换,使用 v-if / v-else-if / v-else 也可以。
  • 当组件的数量不确定或需要动态变化时,动态组件更加简洁。

多端统一你真的会了吗?

多端统一适配指南:告别 if else

引言:多端 H5 的「分裂」之痛

随着移动互联网的发展,H5 页面早已不只在手机浏览器中运行。它可能被嵌入多个不同的 App(如公司主 App、合作方 App)、运行在微信/支付宝/字节等小程序容器中,甚至还要兼容 PC 浏览器。每个端都有一套自己的 JS-SDK,用来调用原生能力(支付、分享、登录、地理位置等)。这些 SDK 的方法名、参数格式、返回值规范往往各不相同,甚至有些端根本不存在某些能力。

当业务方要求「一套 H5 代码,多端运行」时,开发者面临的第一道坎就是:如何优雅地处理这些差异?

直接 if else 判断环境可能是最先想到的方案,但这是最优解吗?本文将带你从原始 if else 出发,逐步演进到适配器模式,并最终封装成开箱即用的 SDK,彻底解决多端 API 调用的混乱局面。


一、问题复现:不同端的 API 差异有多大?

假设我们需要实现一个支付功能,在三端调用方式如下:

  • App1:通过 window.App1JSBridge.invoke('pay', params, callback) 调用,参数是对象,回调返回结果。
  • App2:通过 window.Native.call('PayOrder', JSON.stringify(params)) 调用,参数是 JSON 字符串,返回值是 Promise。
  • 小程序:使用 wx.requestPayment(params),参数格式完全不同,且返回格式也与 App 不同。

如果业务代码直接写死某端的调用,那在其他端就会报错。于是,第一个朴素的想法诞生了:

二、方案一:业务代码中的「万能 if else」

实现方式

在每个需要调用 SDK 的地方,判断当前环境,然后执行对应的代码:

function pay(orderInfo) {
  const env = getEnv(); // 返回 'app1' | 'app2' | 'mini' | 'web'
  
  if (env === 'app1') {
    window.App1JSBridge.invoke('pay', orderInfo, (res) => {
      if (res.code === 0) console.log('支付成功');
    });
  } else if (env === 'app2') {
    window.Native.call('PayOrder', JSON.stringify(orderInfo))
      .then(res => console.log('支付成功'));
  } else if (env === 'mini') {
    wx.requestPayment({
      timeStamp: orderInfo.timeStamp,
      nonceStr: orderInfo.nonceStr,
      package: orderInfo.package,
      signType: 'MD5',
      paySign: orderInfo.paySign,
      success: () => console.log('支付成功')
    });
  } else {
    // Web 端没有原生支付,可能跳转 H5 支付页面
    window.location.href = `https://pay.example.com?order=${orderInfo.id}`;
  }
}

优点

  • 简单直观:新手也能立即上手,无需设计额外抽象。
  • 快速实现:对于少量调用点,能迅速完成适配。

缺点

  • 代码膨胀:每个需要适配的地方都要写一堆 if else,随着调用点增多,代码行数爆炸。
  • 维护噩梦:当新增一个端(比如 App3),你需要搜索整个项目,找到所有用到相关 API 的地方,逐个添加 else if。极易遗漏。
  • 违反开闭原则:对修改是开放的,对扩展却是封闭的——每增加新端,必须修改已有业务代码。
  • 可读性差:业务逻辑与适配逻辑高度耦合,阅读者需要同时理解业务和所有端的 API 细节。
  • 测试困难:无法轻松模拟某个端的返回值,单元测试需要 mock 多个环境。

显然,if else 只适合极少数、极简单的场景。当项目发展到一定规模,必须寻找更优雅的方案。


三、方案二:适配器模式——将变化封装起来

适配器模式(Adapter Pattern)的核心思想是:定义一个统一接口,内部封装不同端的实现细节,对外提供一致的方法调用。  业务代码只需依赖这个接口,无需关心具体是哪个端。

3.1 定义统一接口

首先,根据业务需求定义一套“理想”的 API,例如支付功能统一为 pay(orderInfo) 方法,返回 Promise。

3.2 创建各端适配器

分别为 App1、App2、小程序等编写适配器,实现上述统一接口,内部调用各自的 SDK。

3.3 根据环境选择适配器

在应用启动时,通过环境识别函数,决定使用哪个适配器,并导出统一 API。

代码示例(简化版)
// adapters/index.js
import app1Adapter from './app1Adapter';
import app2Adapter from './app2Adapter';
import miniAdapter from './miniAdapter';
import webAdapter from './webAdapter';

const env = getEnv(); // 返回 'app1' | 'app2' | 'mini' | 'web'

let adapter;
switch (env) {
  case 'app1':
    adapter = app1Adapter;
    break;
  case 'app2':
    adapter = app2Adapter;
    break;
  case 'mini':
    adapter = miniAdapter;
    break;
  default:
    adapter = webAdapter;
}

export const pay = adapter.pay;
export const share = adapter.share;
// ... 其他统一方法

各适配器实现:

// adapters/app1Adapter.js
export default {
  pay(orderInfo) {
    return new Promise((resolve, reject) => {
      window.App1JSBridge.invoke('pay', orderInfo, (res) => {
        res.code === 0 ? resolve(res) : reject(res);
      });
    });
  },
  share(shareData) {
    // ... App1 分享实现
  }
};
// adapters/miniAdapter.js
export default {
  pay(orderInfo) {
    return new Promise((resolve, reject) => {
      wx.requestPayment({
        ...orderInfo,
        success: resolve,
        fail: reject
      });
    });
  }
  // ...
};

业务代码调用:

import { pay } from '@/adapters';

async function checkout() {
  try {
    await pay({ orderId: '123', amount: 100 });
    showSuccess('支付成功');
  } catch (err) {
    showError('支付失败');
  }
}

优点

  • 业务代码统一:所有调用处只有一行 pay(),无需任何 if else。
  • 易于维护:新增端只需新建适配器,修改原有端的实现也只影响适配器文件,业务代码无感知。
  • 可测试性:可以轻松 mock 适配器,进行单元测试。
  • 符合开闭原则:对扩展开放(加新端只需加适配器),对修改封闭(业务代码不动)。

缺点

  • 初期设计成本:需要抽象出统一接口,并考虑各端差异(比如参数格式转换、错误码归一化)。
  • 可能引入间接层:如果适配器实现过于复杂,可能带来性能损耗(通常可忽略)。

3.4 适配器架构图

deepseek_mermaid_20260313_ac30c6.png


四、适配器模式就够了吗?还得考虑这些!

适配器模式解决了 API 调用的统一,但在实际项目中,我们还需要关注以下问题:

4.1 环境识别

getEnv() 如何实现?通常需要结合 UserAgent、全局变量、容器特性等综合判断。例如:

  • 判断是否在微信小程序:typeof wx !== 'undefined' && wx.requestPayment
  • 判断是否在 App1:window.App1JSBridge 是否存在
  • 判断是否在 App2:window.Native 是否存在

注意识别顺序(有些容器可能同时满足多个条件),一般优先级高的先判断。

4.2 参数格式转换

不同端的参数格式差异可能很大,适配器内部需要做转换。例如 App2 需要 JSON 字符串,而统一接口接收的是对象,适配器里要 JSON.stringify

4.3 返回值统一

各端成功/失败的回调形式不同,有的用回调,有的用 Promise,有的错误码不同。适配器应统一返回 Promise,并将错误标准化(如统一抛出特定错误码)。

4.4 能力降级

某些端可能不支持某个功能(比如 Web 端没有原生支付),适配器可以优雅降级:例如跳转 H5 支付页,或者抛出一个特殊错误,让业务方决定如何处理。

4.5 初始化与生命周期

有些 SDK 需要先初始化(如监听 ready 事件),适配器可能需要提供 init() 方法,并在内部管理状态。

4.6 按需加载

如果适配器体积较大,可以考虑使用动态 import(),只在特定环境加载对应适配器代码,减少主包体积。


五、更进一步:将适配器封装为 SDK

既然适配器层已经将多端差异隔离,为什么不把它打包成一个独立的 npm 包,让所有业务线直接安装使用呢?这样不仅避免了重复造轮子,还能统一维护和升级。

5.1 SDK 设计目标

  • 零配置或极简配置:业务方安装后,直接引入方法即可使用。
  • 自动环境识别:内部自动判断当前端,加载对应适配器。
  • 统一 API:所有功能通过命名空间导出,如 sdk.paysdk.share
  • 类型支持:提供 TypeScript 定义,增强开发体验。
  • 轻量:按功能拆分,支持 tree-shaking。

5.2 SDK 结构示例

text

multi-end-sdk/
├── src/
│   ├── adapters/          # 各端适配器实现
│   │   ├── app1.js
│   │   ├── app2.js
│   │   ├── mini.js
│   │   └── web.js
│   ├── env.js             # 环境识别
│   ├── index.js           # 统一导出
│   └── types/             # TypeScript 类型定义
├── package.json
└── README.md

index.js 核心逻辑:

import { detectEnv } from './env';

let adapter = null;

async function loadAdapter() {
  if (adapter) return adapter;
  
  const env = detectEnv();
  // 动态加载对应适配器
  switch (env) {
    case 'app1':
      adapter = await import('./adapters/app1.js');
      break;
    case 'app2':
      adapter = await import('./adapters/app2.js');
      break;
    // ...
    default:
      adapter = await import('./adapters/web.js');
  }
  return adapter;
}

// 创建代理方法,确保每次调用前适配器已加载
export async function pay(params) {
  const mod = await loadAdapter();
  return mod.pay(params);
}

export async function share(params) {
  const mod = await loadAdapter();
  return mod.share(params);
}

5.3 业务方使用

npm install @company/multi-end-sdk
import { pay } from '@company/multi-end-sdk';

pay({ orderId: '123' }).then(...);

5.4 优点

  • 复用性:一次编写,多项目使用,统一升级。
  • 解耦更彻底:业务代码完全不关心适配逻辑,只需依赖 SDK 的 API。
  • 便于团队协作:由基础设施团队维护 SDK,业务团队专注业务。
  • 版本管理:通过 npm 版本控制,可以平滑升级,降级回退。

六、最佳实践建议

  1. 环境识别要健壮:不仅靠 UA,还要探测特有对象,并处理边缘情况(如 iOS 与 Android 的差异)。
  2. 统一错误处理:定义一套错误码,例如 E_PAY_FAILEDE_NOT_SUPPORTED,便于业务方统一处理。
  3. 提供同步与异步:有些方法可能同步返回,但建议统一使用 Promise 或 async/await,保持一致性。
  4. 编写单元测试:使用 Jest 等工具 mock 不同端的全局对象,测试适配器逻辑。
  5. 文档完善:详细说明每个 API 的参数、返回值、各端支持情况,以及降级策略。
  6. 考虑扩展性:未来可能出现新端,设计时要预留扩展点,比如通过插件机制注册新适配器。

七、总结

多端统一适配的核心思想是分离变化。从业务代码中抽离出环境差异,通过适配器模式封装变化,再进一步封装成 SDK,实现了从混乱到清晰、从耦合到解耦的演进。

方案 优点 缺点 适用场景
if else 简单直接 维护成本高、代码冗余 仅 1-2 个端,极少调用点
适配器模式 业务统一、易扩展 初期设计成本 多端并存,调用点多
SDK 封装 复用、解耦、易维护 需要额外打包发布 中大型项目,多个业务复用

最终,我们得到的不仅是一套技术方案,更是一种工程思维:面向接口编程,而非面向实现编程。当你的 H5 需要跑在越来越多的端上时,这套方案将帮助你保持代码的优雅与可维护性。

希望本文能为你提供切实可行的多端适配思路。如果你有更好的实践,欢迎在评论区交流讨论!

Vue3 组件封装实战 | 从 0 封装一个可复用的表格组件(附插槽 / Props 设计)

一、为什么要封装组件?

在企业级项目中,表格是最常见的 UI 形态之一。几乎每个后台管理系统都有大量的表格页面:用户列表、订单管理、商品管理...如果每个页面都重复写表格逻辑,不仅代码冗余,维护成本也极高。

封装表格组件的价值:

  • 提升开发效率:一次封装,多处使用
  • 统一交互体验:分页、排序、筛选行为一致
  • 降低维护成本:修改逻辑只需改一处
  • 代码复用:避免重复造轮子

二、组件设计思路

2.1 需求分析

一个成熟的表格组件应该具备哪些能力?

// 核心功能需求
1. 数据展示:支持列表数据渲染
2. 列配置:自定义列标题、字段、宽度、对齐方式
3. 分页:支持分页器,可配置每页条数
4. 排序:支持单列排序、多列排序
5. 筛选:支持表头筛选
6. 操作列:编辑、删除等操作按钮
7. 自定义内容:插槽支持个性化渲染
8. 加载状态:显示加载中效果
9. 空状态:无数据时显示占位
10. 选择功能:支持行选择(单选/多选)
11. 展开行:支持展开查看更多信息
12. 固定列:左侧/右侧固定列

2.2 组件设计原则

// 1. 单一职责原则
// 表格组件只负责表格渲染,不关心数据获取

// 2. 可配置原则
// 通过 props 提供灵活的配置选项

// 3. 可扩展原则
// 通过插槽支持自定义内容

// 4. 类型安全
// 使用 TypeScript 定义 Props 和事件

三、基础版本实现

3.1 项目初始化

# 创建项目
npm create vite@latest vue3-table-demo -- --template vue-ts

# 安装依赖
npm install element-plus @element-plus/icons-vue

# 启动项目
cd vue3-table-demo
npm run dev

3.2 基础表格组件

<!-- components/BaseTable.vue -->
<template>
  <div class="base-table">
    <!-- 表格主体 -->
    <el-table
      v-loading="loading"
      :data="data"
      :border="border"
      :stripe="stripe"
      :size="size"
      :empty-text="emptyText"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
    >
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        width="55"
        fixed="left"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        width="55"
        label="序号"
        fixed="left"
      />
      
      <!-- 动态渲染列 -->
      <template v-for="column in columns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
            >
              {{ row[column.prop] }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        />
      </template>
      
      <!-- 操作列(预留插槽) -->
      <el-table-column
        v-if="$slots.action"
        label="操作"
        :width="actionWidth"
        :fixed="actionFixed"
        align="center"
      >
        <template #default="{ row, $index }">
          <slot name="action" :row="row" :index="$index" />
        </template>
      </el-table-column>
    </el-table>
    
    <!-- 分页器 -->
    <div v-if="showPagination" class="table-pagination">
      <el-pagination
        v-model:current-page="currentPage"
        v-model:page-size="pageSize"
        :page-sizes="pageSizes"
        :total="total"
        :layout="paginationLayout"
        background
        @size-change="handleSizeChange"
        @current-change="handleCurrentChange"
      />
    </div>
  </div>
</template>

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

// TypeScript 接口定义
export interface TableColumn {
  prop: string                // 字段名
  label: string               // 列标题
  width?: number | string     // 宽度
  align?: 'left' | 'center' | 'right'  // 对齐方式
  fixed?: boolean | 'left' | 'right'   // 固定列
  sortable?: boolean          // 是否可排序
  slot?: string               // 插槽名称
  formatter?: (row: any, column: any, cellValue: any, index: number) => any  // 格式化函数
  showTooltip?: boolean       // 超出是否显示tooltip
}

// Props 定义
const props = defineProps({
  // 表格数据
  data: {
    type: Array as PropType<any[]>,
    required: true,
    default: () => []
  },
  
  // 列配置
  columns: {
    type: Array as PropType<TableColumn[]>,
    required: true,
    default: () => []
  },
  
  // 总条数(用于分页)
  total: {
    type: Number,
    default: 0
  },
  
  // 是否显示分页
  showPagination: {
    type: Boolean,
    default: true
  },
  
  // 当前页码
  page: {
    type: Number,
    default: 1
  },
  
  // 每页条数
  limit: {
    type: Number,
    default: 20
  },
  
  // 每页条数选项
  pageSizes: {
    type: Array as PropType<number[]>,
    default: () => [10, 20, 50, 100]
  },
  
  // 分页布局
  paginationLayout: {
    type: String,
    default: 'total, sizes, prev, pager, next, jumper'
  },
  
  // 是否显示选择列
  showSelection: {
    type: Boolean,
    default: false
  },
  
  // 是否显示序号列
  showIndex: {
    type: Boolean,
    default: false
  },
  
  // 是否显示边框
  border: {
    type: Boolean,
    default: true
  },
  
  // 是否显示斑马纹
  stripe: {
    type: Boolean,
    default: true
  },
  
  // 表格尺寸
  size: {
    type: String as PropType<'large' | 'default' | 'small'>,
    default: 'default'
  },
  
  // 加载状态
  loading: {
    type: Boolean,
    default: false
  },
  
  // 空数据提示
  emptyText: {
    type: String,
    default: '暂无数据'
  },
  
  // 操作列宽度
  actionWidth: {
    type: [Number, String],
    default: 150
  },
  
  // 操作列是否固定
  actionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'right'
  }
})

// 事件定义
const emit = defineEmits([
  'update:page',
  'update:limit',
  'selection-change',
  'sort-change',
  'row-click',
  'page-change'
])

// 内部状态
const currentPage = ref(props.page)
const pageSize = ref(props.limit)

// 监听外部变化
watch(() => props.page, (val) => {
  currentPage.value = val
})

watch(() => props.limit, (val) => {
  pageSize.value = val
})

// 分页变化处理
const handleSizeChange = (size: number) => {
  pageSize.value = size
  emit('update:limit', size)
  emit('page-change', { page: currentPage.value, limit: size })
}

const handleCurrentChange = (page: number) => {
  currentPage.value = page
  emit('update:page', page)
  emit('page-change', { page, limit: pageSize.value })
}

// 选择变化处理
const handleSelectionChange = (selection: any[]) => {
  emit('selection-change', selection)
}

// 排序变化处理
const handleSortChange = ({ prop, order, column }: any) => {
  emit('sort-change', { prop, order, column })
}

// 行点击处理
const handleRowClick = (row: any, column: any, event: Event) => {
  emit('row-click', { row, column, event })
}

// 暴露方法给父组件
defineExpose({
  // 清除选择
  clearSelection: () => {
    // 通过 ref 调用 el-table 的方法
  },
  
  // 切换某行的选择状态
  toggleRowSelection: (row: any, selected?: boolean) => {
    // 实现...
  }
})
</script>

<style scoped lang="scss">
.base-table {
  width: 100%;
  
  .table-pagination {
    margin-top: 20px;
    display: flex;
    justify-content: flex-end;
  }
}
</style>

四、增强版封装(企业级)

4.1 高级表格组件

<!-- components/ProTable.vue -->
<template>
  <div class="pro-table">
    <!-- 工具栏 -->
    <div v-if="showToolbar" class="table-toolbar">
      <div class="toolbar-left">
        <slot name="toolbar-left">
          <span class="table-title">{{ title }}</span>
        </slot>
      </div>
      
      <div class="toolbar-right">
        <slot name="toolbar-right">
          <!-- 刷新按钮 -->
          <el-button 
            v-if="showRefresh" 
            :icon="Refresh" 
            circle 
            @click="handleRefresh"
          />
          
          <!-- 密度切换 -->
          <el-dropdown v-if="showDensity" @command="handleDensityChange">
            <el-button :icon="Grid" circle />
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item command="large">宽松</el-dropdown-item>
                <el-dropdown-item command="default">默认</el-dropdown-item>
                <el-dropdown-item command="small">紧凑</el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          
          <!-- 列设置 -->
          <el-popover
            v-if="showColumnSetting"
            placement="bottom-end"
            :width="200"
            trigger="click"
          >
            <template #reference>
              <el-button :icon="Setting" circle />
            </template>
            
            <div class="column-setting">
              <div class="setting-header">
                <span>列展示</span>
                <el-checkbox 
                  v-model="checkAll" 
                  :indeterminate="isIndeterminate"
                  @change="handleCheckAllChange"
                >
                  全选
                </el-checkbox>
              </div>
              <el-divider />
              <el-checkbox-group v-model="checkedColumns" @change="handleCheckedChange">
                <div v-for="col in allColumns" :key="col.prop" class="setting-item">
                  <el-checkbox :label="col.prop">
                    {{ col.label }}
                  </el-checkbox>
                  <el-icon class="drag-icon"><Rank /></el-icon>
                </div>
              </el-checkbox-group>
            </div>
          </el-popover>
        </slot>
      </div>
    </div>
    
    <!-- 表格主体 -->
    <el-table
      ref="tableRef"
      v-loading="loading"
      :data="filteredData"
      :border="border"
      :stripe="stripe"
      :size="tableSize"
      :empty-text="emptyText"
      :row-key="rowKey"
      :expand-row-keys="expandRowKeys"
      :default-sort="defaultSort"
      :span-method="spanMethod"
      :row-class-name="rowClassName"
      :cell-class-name="cellClassName"
      :header-row-class-name="headerRowClassName"
      :header-cell-class-name="headerCellClassName"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @row-click="handleRowClick"
      @row-dblclick="handleRowDblClick"
      @expand-change="handleExpandChange"
    >
      <!-- 展开行 -->
      <el-table-column
        v-if="showExpand"
        type="expand"
        width="50"
      >
        <template #default="{ row }">
          <slot name="expand" :row="row" />
        </template>
      </el-table-column>
      
      <!-- 选择列 -->
      <el-table-column
        v-if="showSelection"
        type="selection"
        :width="selectionWidth"
        :fixed="selectionFixed"
        :selectable="selectable"
        :reserve-selection="reserveSelection"
      />
      
      <!-- 序号列 -->
      <el-table-column
        v-if="showIndex"
        type="index"
        :width="indexWidth"
        :label="indexLabel"
        :fixed="indexFixed"
        :index="indexMethod"
      />
      
      <!-- 动态渲染列(支持拖拽排序) -->
      <template v-for="column in visibleColumns" :key="column.prop">
        <!-- 有自定义插槽的列 -->
        <el-table-column
          v-if="column.slot"
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, $index }">
            <slot 
              :name="column.slot" 
              :row="row" 
              :index="$index"
              :prop="column.prop"
              :column="column"
            >
              {{ formatCellValue(row, column) }}
            </slot>
          </template>
          
          <template #header="{ column: col, $index }">
            <slot 
              :name="`header-${column.prop}`" 
              :column="col" 
              :index="$index"
              :prop="column.prop"
            >
              {{ column.label }}
            </slot>
          </template>
        </el-table-column>
        
        <!-- 普通列 -->
        <el-table-column
          v-else
          :prop="column.prop"
          :label="column.label"
          :width="column.width"
          :min-width="column.minWidth"
          :align="column.align || 'left'"
          :fixed="column.fixed"
          :sortable="column.sortable"
          :sort-method="column.sortMethod"
          :sort-by="column.sortBy"
          :sort-orders="column.sortOrders"
          :resizable="column.resizable !== false"
          :formatter="column.formatter"
          :show-overflow-tooltip="column.showTooltip"
        >
          <template #default="{ row, column: col, $index }">
            {{ formatCellValue(row, column) }}
          </template>
        </el-table-column>
      </template>
      
      <!-- 操作列 -->
      <el-table-column
        v-if="hasAction"
        :label="actionLabel"
        :width="actionWidth"
        :min-width="actionMinWidth"
        :fixed="actionFixed"
        :align="actionAlign"
      >
        <template #default="{ row, $index }">
          <slot 
            name="action" 
            :row="row" 
            :index="$index"
          />
        </template>
      </el-table-column>
      
      <!-- 自定义列插槽 -->
      <slot name="append" />
    </el-table>
    
    <!-- 底部区域 -->
    <div class="table-footer">
      <!-- 左侧统计信息 -->
      <div v-if="showSummary" class="footer-left">
        <slot name="summary">
          <span>共 {{ total }} 条记录</span>
          <span v-if="showSelection && selectedRows.length">
            已选择 {{ selectedRows.length }} 条
          </span>
        </slot>
      </div>
      
      <!-- 右侧分页器 -->
      <div v-if="showPagination" class="footer-right">
        <el-pagination
          v-model:current-page="currentPage"
          v-model:page-size="pageSize"
          :page-sizes="pageSizes"
          :total="total"
          :layout="paginationLayout"
          :background="paginationBackground"
          :disabled="paginationDisabled"
          :hide-on-single-page="hideOnSinglePage"
          @size-change="handleSizeChange"
          @current-change="handleCurrentChange"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { Refresh, Grid, Setting, Rank } from '@element-plus/icons-vue'
import type { PropType } from 'vue'
import type { TableColumn } from './BaseTable'
import Sortable from 'sortablejs'

// Props 定义(继承 BaseTable 的 props 并扩展)
const props = defineProps({
  // ... 继承 BaseTable 的所有 props
  
  // 表格标题
  title: {
    type: String,
    default: ''
  },
  
  // 是否显示工具栏
  showToolbar: {
    type: Boolean,
    default: true
  },
  
  // 是否显示刷新按钮
  showRefresh: {
    type: Boolean,
    default: true
  },
  
  // 是否显示密度切换
  showDensity: {
    type: Boolean,
    default: true
  },
  
  // 是否显示列设置
  showColumnSetting: {
    type: Boolean,
    default: true
  },
  
  // 行唯一标识
  rowKey: {
    type: String,
    default: 'id'
  },
  
  // 是否显示展开行
  showExpand: {
    type: Boolean,
    default: false
  },
  
  // 展开行的 keys
  expandRowKeys: {
    type: Array as PropType<string[]>,
    default: () => []
  },
  
  // 默认排序
  defaultSort: {
    type: Object as PropType<{ prop: string; order: 'ascending' | 'descending' }>,
    default: null
  },
  
  // 合并单元格的方法
  spanMethod: {
    type: Function as PropType<({
      row,
      column,
      rowIndex,
      columnIndex
    }: {
      row: any
      column: any
      rowIndex: number
      columnIndex: number
    }) => number[] | { rowspan: number; colspan: number }>,
    default: null
  },
  
  // 是否显示汇总信息
  showSummary: {
    type: Boolean,
    default: true
  },
  
  // 选择列宽度
  selectionWidth: {
    type: [Number, String],
    default: 55
  },
  
  // 选择列是否固定
  selectionFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 行是否可选
  selectable: {
    type: Function as PropType<(row: any, index: number) => boolean>,
    default: null
  },
  
  // 是否保留选择(数据更新后)
  reserveSelection: {
    type: Boolean,
    default: false
  },
  
  // 序号列宽度
  indexWidth: {
    type: [Number, String],
    default: 60
  },
  
  // 序号列标签
  indexLabel: {
    type: String,
    default: '序号'
  },
  
  // 序号列是否固定
  indexFixed: {
    type: [Boolean, String] as PropType<boolean | 'left' | 'right'>,
    default: 'left'
  },
  
  // 序号生成方法
  indexMethod: {
    type: Function as PropType<(index: number) => number>,
    default: (index: number) => index + 1
  },
  
  // 操作列标签
  actionLabel: {
    type: String,
    default: '操作'
  },
  
  // 操作列最小宽度
  actionMinWidth: {
    type: [Number, String],
    default: 120
  },
  
  // 操作列对齐方式
  actionAlign: {
    type: String as PropType<'left' | 'center' | 'right'>,
    default: 'center'
  },
  
  // 分页器背景
  paginationBackground: {
    type: Boolean,
    default: true
  },
  
  // 分页器禁用
  paginationDisabled: {
    type: Boolean,
    default: false
  },
  
  // 只有一页时是否隐藏分页器
  hideOnSinglePage: {
    type: Boolean,
    default: false
  },
  
  // 行类名
  rowClassName: {
    type: [String, Function] as PropType<string | (({ row, rowIndex }: { row: any; rowIndex: number }) => string)>,
    default: ''
  },
  
  // 单元格类名
  cellClassName: {
    type: [String, Function] as PropType<string | (({ row, column, rowIndex, columnIndex }: any) => string)>,
    default: ''
  }
})

// 事件定义
const emit = defineEmits([
  // ... 继承 BaseTable 的事件
  'refresh',
  'density-change',
  'column-change',
  'row-dblclick',
  'expand-change'
])

// 表格引用
const tableRef = ref()

// 内部状态
const tableSize = ref<'large' | 'default' | 'small'>(props.size as any)
const selectedRows = ref<any[]>([])
const checkedColumns = ref<string[]>([])
const allColumns = ref<TableColumn[]>([])

// 计算属性:是否有操作列
const hasAction = computed(() => !!props.$slots.action)

// 计算属性:可见列
const visibleColumns = computed(() => {
  if (!checkedColumns.value.length) return allColumns.value
  return allColumns.value.filter(col => checkedColumns.value.includes(col.prop))
})

// 计算属性:过滤后的数据(可用于前端搜索)
const filteredData = computed(() => {
  // 实现前端筛选逻辑
  return props.data
})

// 初始化列配置
onMounted(() => {
  allColumns.value = props.columns.filter(col => !col.hidden)
  checkedColumns.value = allColumns.value.map(col => col.prop)
  initDrag()
})

// 初始化拖拽排序
const initDrag = () => {
  nextTick(() => {
    const settingEl = document.querySelector('.column-setting .el-checkbox-group')
    if (!settingEl) return
    
    new Sortable(settingEl as HTMLElement, {
      animation: 150,
      handle: '.drag-icon',
      onEnd: (evt) => {
        const { oldIndex, newIndex } = evt
        if (oldIndex === newIndex) return
        
        // 重新排序列
        const newColumns = [...allColumns.value]
        const [movedColumn] = newColumns.splice(oldIndex!, 1)
        newColumns.splice(newIndex!, 0, movedColumn)
        allColumns.value = newColumns
        
        emit('column-change', newColumns)
      }
    })
  })
}

// 格式化单元格值
const formatCellValue = (row: any, column: TableColumn) => {
  if (column.formatter) {
    return column.formatter(row, column, row[column.prop], 0)
  }
  return row[column.prop]
}

// 列设置相关
const checkAll = computed({
  get: () => checkedColumns.value.length === allColumns.value.length,
  set: (val) => {
    checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  }
})

const isIndeterminate = computed(() => {
  return checkedColumns.value.length > 0 && 
         checkedColumns.value.length < allColumns.value.length
})

const handleCheckAllChange = (val: boolean) => {
  checkedColumns.value = val ? allColumns.value.map(col => col.prop) : []
  emit('column-change', visibleColumns.value)
}

const handleCheckedChange = (value: string[]) => {
  emit('column-change', visibleColumns.value)
}

// 密度切换
const handleDensityChange = (size: string) => {
  tableSize.value = size as any
  emit('density-change', size)
}

// 刷新
const handleRefresh = () => {
  emit('refresh')
}

// 双击行
const handleRowDblClick = (row: any, column: any) => {
  emit('row-dblclick', { row, column })
}

// 展开行变化
const handleExpandChange = (row: any, expandedRows: any[]) => {
  emit('expand-change', { row, expandedRows })
}

// 暴露方法
defineExpose({
  // 清除选择
  clearSelection: () => {
    tableRef.value?.clearSelection()
    selectedRows.value = []
  },
  
  // 切换行选择
  toggleRowSelection: (row: any, selected?: boolean) => {
    tableRef.value?.toggleRowSelection(row, selected)
  },
  
  // 切换所有行选择
  toggleAllSelection: () => {
    tableRef.value?.toggleAllSelection()
  },
  
  // 设置某行展开状态
  toggleRowExpansion: (row: any, expanded?: boolean) => {
    tableRef.value?.toggleRowExpansion(row, expanded)
  },
  
  // 设置当前行
  setCurrentRow: (row: any) => {
    tableRef.value?.setCurrentRow(row)
  },
  
  // 清除排序
  clearSort: () => {
    tableRef.value?.clearSort()
  },
  
  // 清除筛选
  clearFilter: (columnKeys?: string[]) => {
    tableRef.value?.clearFilter(columnKeys)
  },
  
  // 重新布局
  doLayout: () => {
    tableRef.value?.doLayout()
  },
  
  // 滚动到某行
  scrollToRow: (row: any, offset?: number) => {
    // 实现滚动逻辑
  }
})
</script>

<style scoped lang="scss">
.pro-table {
  background-color: #fff;
  border-radius: 4px;
  padding: 16px;
  
  .table-toolbar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 16px;
    
    .toolbar-left {
      .table-title {
        font-size: 16px;
        font-weight: 600;
        color: #303133;
      }
    }
    
    .toolbar-right {
      display: flex;
      gap: 8px;
    }
  }
  
  .table-footer {
    margin-top: 16px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    
    .footer-left {
      color: #909399;
      font-size: 14px;
      
      span {
        margin-right: 16px;
      }
    }
  }
  
  .column-setting {
    padding: 8px;
    
    .setting-header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 8px;
    }
    
    .setting-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      
      &:hover {
        background-color: #f5f7fa;
      }
      
      .drag-icon {
        cursor: move;
        color: #909399;
      }
    }
  }
}
</style>

五、使用示例

5.1 基础用法

<!-- views/UserList.vue -->
<template>
  <div class="user-list">
    <pro-table
      ref="tableRef"
      :data="userList"
      :columns="columns"
      :total="total"
      :loading="loading"
      :show-selection="true"
      :show-index="true"
      :page="page"
      :limit="limit"
      @page-change="handlePageChange"
      @selection-change="handleSelectionChange"
      @sort-change="handleSortChange"
      @refresh="handleRefresh"
    >
      <!-- 自定义状态列 -->
      <template #status="{ row }">
        <el-tag :type="row.status === 1 ? 'success' : 'info'">
          {{ row.status === 1 ? '启用' : '禁用' }}
        </el-tag>
      </template>
      
      <!-- 自定义操作列 -->
      <template #action="{ row }">
        <el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
        <el-button type="danger" link @click="handleDelete(row)">删除</el-button>
      </template>
    </pro-table>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ProTable from '@/components/ProTable.vue'
import type { TableColumn } from '@/components/BaseTable'
import { getUserList } from '@/api/user'

// 表格列配置
const columns: TableColumn[] = [
  {
    prop: 'name',
    label: '姓名',
    width: 120,
    sortable: true
  },
  {
    prop: 'age',
    label: '年龄',
    width: 80,
    align: 'center'
  },
  {
    prop: 'email',
    label: '邮箱',
    minWidth: 200,
    showTooltip: true
  },
  {
    prop: 'phone',
    label: '手机号',
    width: 150
  },
  {
    prop: 'status',
    label: '状态',
    width: 80,
    slot: 'status'  // 使用自定义插槽
  },
  {
    prop: 'createTime',
    label: '创建时间',
    width: 180,
    sortable: true,
    formatter: (row: any, column: any, value: string) => {
      return new Date(value).toLocaleString()
    }
  }
]

// 表格数据
const userList = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const limit = ref(20)

// 获取数据
const fetchData = async () => {
  loading.value = true
  try {
    const res = await getUserList({
      page: page.value,
      limit: limit.value
    })
    userList.value = res.list
    total.value = res.total
  } finally {
    loading.value = false
  }
}

// 分页变化
const handlePageChange = ({ page: newPage, limit: newLimit }: any) => {
  page.value = newPage
  limit.value = newLimit
  fetchData()
}

// 选择变化
const handleSelectionChange = (selection: any[]) => {
  console.log('选中:', selection)
}

// 排序变化
const handleSortChange = ({ prop, order }: any) => {
  console.log('排序:', prop, order)
  // 可以在这里处理排序逻辑
}

// 刷新
const handleRefresh = () => {
  fetchData()
}

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

// 删除
const handleDelete = (row: any) => {
  ElMessageBox.confirm('确认删除该用户吗?', '提示', {
    type: 'warning'
  }).then(() => {
    // 调用删除接口
    ElMessage.success('删除成功')
    fetchData()
  })
}

onMounted(() => {
  fetchData()
})
</script>

5.2 高级用法:动态列 + 展开行

<!-- views/OrderList.vue -->
<template>
  <pro-table
    :data="orderList"
    :columns="dynamicColumns"
    :total="total"
    :show-expand="true"
    :show-summary="true"
    :span-method="objectSpanMethod"
  >
    <!-- 展开行内容 -->
    <template #expand="{ row }">
      <div class="order-detail">
        <h4>订单详情</h4>
        <el-descriptions :column="3" border>
          <el-descriptions-item label="商品名称">{{ row.productName }}</el-descriptions-item>
          <el-descriptions-item label="单价">¥{{ row.price }}</el-descriptions-item>
          <el-descriptions-item label="数量">{{ row.quantity }}</el-descriptions-item>
          <el-descriptions-item label="总价">¥{{ row.totalPrice }}</el-descriptions-item>
          <el-descriptions-item label="下单时间">{{ row.orderTime }}</el-descriptions-item>
          <el-descriptions-item label="支付方式">{{ row.payMethod }}</el-descriptions-item>
        </el-descriptions>
      </div>
    </template>
    
    <!-- 自定义操作列 -->
    <template #action="{ row }">
      <el-button type="primary" link @click="viewOrder(row)">查看</el-button>
      <el-button type="success" link @click="processOrder(row)">处理</el-button>
    </template>
  </pro-table>
</template>

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

// 动态列配置(可以根据权限动态生成)
const columnsConfig = ref([
  { prop: 'orderNo', label: '订单号', width: 180, fixed: 'left' },
  { prop: 'customer', label: '客户', width: 120 },
  { prop: 'amount', label: '金额', width: 120, align: 'right' },
  { prop: 'status', label: '状态', width: 100 },
  { prop: 'payStatus', label: '支付状态', width: 100 },
  { prop: 'deliveryStatus', label: '发货状态', width: 100 },
  { prop: 'createTime', label: '创建时间', width: 180 },
  { prop: 'updateTime', label: '更新时间', width: 180 }
])

// 根据用户权限过滤列
const dynamicColumns = computed(() => {
  const userPermissions = ['orderNo', 'customer', 'amount', 'status']
  return columnsConfig.value.filter(col => userPermissions.includes(col.prop))
})

// 合并单元格
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
  if (columnIndex === 0) {
    if (rowIndex % 2 === 0) {
      return {
        rowspan: 2,
        colspan: 1
      }
    } else {
      return {
        rowspan: 0,
        colspan: 0
      }
    }
  }
}
</script>

六、单元测试

// __tests__/ProTable.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProTable from '@/components/ProTable.vue'

describe('ProTable.vue', () => {
  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]
  
  const mockData = [
    { name: '张三', age: 25 },
    { name: '李四', age: 30 }
  ]
  
  it('renders table correctly', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 2
      }
    })
    
    expect(wrapper.find('.pro-table').exists()).toBe(true)
    expect(wrapper.findAll('.el-table__row').length).toBe(2)
  })
  
  it('emits page-change event when pagination changes', async () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 100,
        showPagination: true
      }
    })
    
    // 模拟分页变化
    await wrapper.find('.el-pagination .btn-next').trigger('click')
    
    expect(wrapper.emitted('page-change')).toBeTruthy()
    expect(wrapper.emitted('page-change')?.[0]).toEqual([{ page: 2, limit: 20 }])
  })
  
  it('shows loading state', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: [],
        columns: mockColumns,
        loading: true
      }
    })
    
    expect(wrapper.find('.el-loading-mask').exists()).toBe(true)
  })
  
  it('renders custom slot content', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: [
          { prop: 'name', label: '姓名', slot: 'customName' }
        ]
      },
      slots: {
        customName: '<span class="custom-name">{{ row.name }}</span>'
      }
    })
    
    expect(wrapper.find('.custom-name').exists()).toBe(true)
  })
})

七、性能优化

7.1 虚拟滚动(大数据量)

<!-- 对于大量数据,可以使用虚拟滚动 -->
<template>
  <el-table
    v-loading="loading"
    :data="visibleData"
    :height="tableHeight"
    style="width: 100%"
    @scroll="handleScroll"
  >
    <!-- 列配置 -->
  </el-table>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  bufferSize: {
    type: Number,
    default: 10
  }
})

const scrollTop = ref(0)
const tableHeight = ref(600)

// 计算可见范围
const visibleCount = computed(() => Math.ceil(tableHeight.value / props.rowHeight))

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  return Math.min(
    props.data.length,
    startIndex.value + visibleCount.value + props.bufferSize * 2
  )
})

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

7.2 大数据量优化策略

// 1. 使用虚拟滚动
// 2. 按需渲染
// 3. 使用函数式组件
// 4. 避免不必要的响应式
// 5. 使用 computed 缓存计算结果
// 6. 列表项使用唯一的 key
// 7. 使用 v-once 处理静态内容

八、总结与最佳实践

8.1 组件设计要点

  1. Props 设计原则

    • 提供合理的默认值
    • 使用 TypeScript 类型定义
    • 保持 API 简洁但够用
  2. 插槽设计原则

    • 提供足够的自定义能力
    • 作用域插槽传递必要数据
    • 预留扩展位置
  3. 事件设计原则

    • 遵循 v-model 规范
    • 提供完整的事件体系
    • 事件命名清晰规范

8.2 使用建议

// 1. 合理配置列宽度
const columns = [
  { prop: 'name', label: '姓名', width: 120 }, // 固定宽度
  { prop: 'address', label: '地址', minWidth: 200 }, // 最小宽度
  { prop: 'description', label: '描述', width: 'auto' } // 自适应
]

// 2. 使用唯一 rowKey
<pro-table :data="list" row-key="id" />

// 3. 合理使用插槽
<template #status="{ row }">
  <Badge :status="row.status" />
</template>

// 4. 处理加载状态
<pro-table :loading="loading" :data="list" />

// 5. 处理空状态
<pro-table :data="[]" empty-text="暂无数据" />

8.3 扩展思考

  1. 如何支持表格导出?

    • 添加导出按钮和导出方法
    • 支持导出当前页或全部数据
    • 支持导出格式配置(CSV/Excel)
  2. 如何支持表格打印?

    • 添加打印样式
    • 隐藏操作列和按钮
    • 调整列宽适配打印
  3. 如何支持表格列拖动调整宽度?

    • 使用 resizable 属性
    • 保存用户调整后的宽度到 localStorage
  4. 如何支持表格状态持久化?

    • 保存列显示状态
    • 保存排序状态
    • 保存筛选状态

通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。

❌