普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月19日首页

从零到一打造 Vue3 响应式系统 Day 10 - 为何 Effect 会被指数级触发?

作者 我是日安
2025年9月19日 11:14

ZuB1M1H.png

DOM 交互

我们的响应式系统经过前几天的努力,已经初具雏形,感觉可以加入一些 DOM 交互,来进行简单的测试。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Title</title>
    <style>
      body {
        padding: 150px;
      }
    </style>
  </head>
  <body>
    <button id="btn">按钮</button>
    <script type="module">
      import { ref, effect } from '../dist/reactivity.esm.js'

      const flag = ref(true)

      effect(() => {
        flag.value
        console.count('effect')
      })

      btn.onclick = () => {
        flag.value = !flag.value
      }
    </script>
  </body>
</html>

day10-01.gif

我们预期每次点击按钮,effect 只会执行一次。但实际情况看起来不太妙。

console.count 的结果可以看到,effect 的执行次数随着点击呈现指数级增长。这肯定是不行的。

我们来了解一下问题的症结所在。

Link 节点创建问题的症结

执行步骤图解

初始化页面

day10-06.png 页面加载时,effect 执行一次。在执行过程中,读取了 flag.value,触发 getter 进行依赖收集。 系统会创建一个 link1 节点,将 effectflag 关联起来。到这里都符合预期。

第一次点击按钮

day10-02.png 当按钮第一次被点击,flag.valuetrue 变为 false,触发了 setter。 setter 内的 propagate 函数开始遍历 flag 的依赖链表。

propagate 执行 link1 中存储的 effect.run()

effect 函数重新执行,又读取了 flag.value,再次触发了 getter。

此时问题出现了:在 effect.run() 的过程中,又进行了一次依赖收集,系统创建了一个新的 link2 节点并添加到链表尾部。

执行结束后的链表:

day10-03.png

第二次点击按钮

day10-04.png 当按钮又被点击,flag.valuefalse 变为 true,再次触发 setter。

propagate 开始遍历依赖链表。但这一次,链表上有两个节点 (link1link2)。

  1. propagate 先执行 link1 中的 effect.run()effect 内部读取 flag.value,触发依赖收集,创建了一个新的 link3 节点并添加到链表尾部。
  2. propagate 接着执行 link2 中的 effect.run()effect 内部又一次读取 flag.value,触发依赖收集,又创建了一个新的 link4 节点并添加到链表尾部。

执行结束后的链表:

day10-05.png

执行完成后的链表结构

我们可以发现在触发更新时,链表上的每一个节点都会触发一次 effect 的重新执行,而每一次执行又会创建一个新的节点加入到链表中,因此发生了指数级触发 effect 的情况。

关键问题点

每次 effect 重新执行时:

  1. 没有检查该 effect 是否已经存在于依赖链表中。
  2. 盲目地创建新的 Link 节点并添加到链表末尾。
  3. 导致依赖链表在每次更新时都会成倍增长。

因此,每次点击按钮,链表上的每一个 Link 都会触发一次 effect 的重新执行,而在每一次执行中又会创建新的 Link,从而导致重复执行和指数级增长现象。

因为下个篇幅比较长,今天就先讲到这里。大家需要先理解问题的症结所在,这样明天在实现解决方案时,才能明白我们为什么要那样做。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

还在硬邦邦跳转页面?Vue这3招让应用丝滑如德芙!

2025年9月19日 07:33

你是不是也遇到过这种情况?页面切换生硬得像老式电视机换台,数据加载时用户一脸懵逼不知道发生了什么,列表操作毫无反馈让人怀疑到底点没点上...

别急!今天我就带你用Vue的过渡动画三招,让你的应用瞬间从"机械僵硬"变身"丝滑流畅",用户体验直接提升一个level!

第一招:基础CSS过渡,简单又高效

先来看看最基础的CSS过渡效果。Vue提供了<transition>组件,包裹一下就能让元素动起来!

<template>
  <div>
    <button @click="show = !show">切换显示</button>
    
    <transition name="fade">
      <p v-if="show">你好呀!我会淡入淡出哦~</p>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: true
    }
  }
}
</script>

<style>
/* 定义进入和离开时的动画 */
.fade-enter-active, .fade-leave-active {
  transition: opacity 0.5s ease;
}

/* 定义进入开始和离开结束时的状态 */
.fade-enter, .fade-leave-to {
  opacity: 0;
}
</style>

这里有个小秘密:Vue会自动帮我们在不同阶段添加不同的class:

  • fade-enter:进入动画开始前(第一帧)
  • fade-enter-active:进入动画过程中
  • fade-enter-to:进入动画结束后
  • fade-leave:离开动画开始前
  • fade-leave-active:离开动画过程中
  • fade-leave-to:离开动画结束后

第二招:CSS动画,让效果更丰富

如果觉得简单的过渡不够酷,试试CSS动画吧!用法和过渡差不多,但能做出更复杂的效果。

<template>
  <div>
    <button @click="show = !show">蹦出来!</button>
    
    <transition name="bounce">
      <p v-if="show" class="animated-text">看我弹跳登场!</p>
    </transition>
  </div>
</template>

<style>
/* 弹跳动画 */
.bounce-enter-active {
  animation: bounce-in 0.5s;
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
  }
  50% {
    transform: scale(1.25);
  }
  100% {
    transform: scale(1);
  }
}

.animated-text {
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  padding: 10px;
  border-radius: 5px;
  color: white;
}
</style>

这个效果特别适合重要提示或者操作反馈,让用户一眼就能注意到!

第三招:列表过渡,让数据动起来

实际项目中我们经常要处理列表数据,<transition-group>就是专门为列表设计的动画组件。

<template>
  <div>
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
    
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [
        { id: 1, text: '第一项' },
        { id: 2, text: '第二项' },
        { id: 3, text: '第三项' }
      ],
      nextId: 4
    }
  },
  methods: {
    addItem() {
      this.items.push({
        id: this.nextId++,
        text: `新项目 ${this.nextId}`
      })
    },
    removeItem() {
      this.items.pop()
    }
  }
}
</script>

<style>
.list-item {
  transition: all 0.5s;
  margin: 5px 0;
  padding: 10px;
  background: #f8f9fa;
  border-left: 4px solid #4ecdc4;
}

.list-enter-active, .list-leave-active {
  transition: all 0.5s;
}

.list-enter, .list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 让删除的项目先收缩再消失 */
.list-leave-active {
  position: absolute;
}
</style>

注意这里有两个重点:一是必须给每个列表项设置唯一的key,二是<transition-group>默认渲染为span,可以用tag属性指定为其他标签。

进阶玩法:JavaScript钩子函数

有时候CSS动画满足不了复杂需求,这时候就需要JavaScript钩子出场了!

<template>
  <div>
    <button @click="show = !show">切换</button>
    
    <transition
      @before-enter="beforeEnter"
      @enter="enter"
      @after-enter="afterEnter"
      @enter-cancelled="enterCancelled"
      @before-leave="beforeLeave"
      @leave="leave"
      @after-leave="afterLeave"
      @leave-cancelled="leaveCancelled"
    >
      <div v-if="show" class="js-box">JS控制的动画</div>
    </transition>
  </div>
</template>

<script>
export default {
  data() {
    return {
      show: false
    }
  },
  methods: {
    // 进入动画开始前
    beforeEnter(el) {
      el.style.opacity = 0
      el.style.transform = 'scale(0)'
    },
    
    // 进入动画中
    enter(el, done) {
      // 使用requestAnimationFrame保证流畅性
      let start = null
      const duration = 600
      
      function animate(timestamp) {
        if (!start) start = timestamp
        const progress = timestamp - start
        
        // 计算当前进度(0-1)
        const percentage = Math.min(progress / duration, 1)
        
        // 应用动画效果
        el.style.opacity = percentage
        el.style.transform = `scale(${percentage})`
        
        if (progress < duration) {
          requestAnimationFrame(animate)
        } else {
          done() // 动画完成,调用done回调
        }
      }
      
      requestAnimationFrame(animate)
    },
    
    // 进入动画完成后
    afterEnter(el) {
      console.log('进入动画完成啦!')
    },
    
    // 进入动画被中断
    enterCancelled(el) {
      console.log('进入动画被取消了')
    },
    
    // 离开动画相关钩子...
    beforeLeave(el) {
      el.style.opacity = 1
      el.style.transform = 'scale(1)'
    },
    
    leave(el, done) {
      // 类似的实现离开动画...
      done()
    }
  }
}
</script>

<style>
.js-box {
  width: 100px;
  height: 100px;
  background: linear-gradient(135deg, #667eea, #764ba2);
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  margin-top: 10px;
}
</style>

JavaScript钩子虽然复杂一些,但是能实现任何你能想到的动画效果!

终极武器:集成第三方动画库

如果你想快速实现酷炫效果,又不想自己写太多CSS,那么第三方动画库就是你的最佳选择!

先安装Animate.css:

npm install animate.css

然后在项目中引入:

import 'animate.css'

使用起来超级简单:

<template>
  <div>
    <button @click="show = !show">来点炫酷的!</button>
    
    <transition
      enter-active-class="animate__animated animate__bounceIn"
      leave-active-class="animate__animated animate__bounceOut"
    >
      <div v-if="show" class="demo-box">哇!好酷!</div>
    </transition>
  </div>
</template>

<style>
.demo-box {
  width: 150px;
  height: 150px;
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 10px;
  margin-top: 20px;
  font-size: 18px;
  font-weight: bold;
}
</style>

Animate.css提供了超多现成动画效果,比如:

  • animate__bounceIn(弹跳进入)
  • animate__fadeInUp(淡入上浮)
  • animate__flip(翻转效果)
  • animate__zoomIn(缩放进入)

想要什么效果,换个class名就行了,简直是懒人福音!

实战技巧:避免这些常见坑

用了这么久Vue动画,我也踩过不少坑,分享几个实用技巧:

  1. 动画闪烁问题:在初始渲染时避免使用v-if,可以用v-show或者通过CSS控制初始状态

  2. 列表动画优化:对于长列表,可以给<transition-group>设置tag="div",避免生成太多DOM节点

  3. 性能注意:尽量使用transform和opacity做动画,这两个属性不会触发重排,性能更好

  4. 移动端适配:在移动端注意动画时长,0.3s左右比较合适,不要太长

  5. 减少同时动画:同一时间不要有太多元素做动画,会影响性能

总结

好了,今天分享了Vue过渡动画的四大招式:从基础的CSS过渡,到更丰富的CSS动画,再到处理列表的<transition-group>,最后是强大的JavaScript钩子和第三方库集成。

记住,好的动画不是为了炫技,而是为了提升用户体验。适当的动画能让用户知道发生了什么,引导注意力,让操作更有反馈感。

ruoyi-vue(十五)——布局设置,导航栏,侧边栏,顶部栏

作者 Olrookie
2025年9月18日 21:51

一、布局设置

在前端页面有一个布局设置,点开可以设置主题风格,颜色,是否显示标签页等功能。

image.png

1.1 路由

页面所有请求都是通过路由,先来看路由:src→router→index.js。 路由中引用了Layout,从项目中的 src/layout 路径导入一个默认导出的组件,并将其命名为 Layout

import Layout from '@/layout'

在vite.config.js中通过resolve.alias设置了路径别名,将@映射到项目的src目录。import Layout from '@/layout' 实际上就是从 src/layout/index.vue 文件中导入默认导出的组件。

'@': path.resolve(__dirname, './src')

除了登录,注册,404等页面外基本都用了Layout

// 公共路由
export const constantRoutes = [
  {
    path: '/redirect',
    component: Layout,
    hidden: true,
    children: [
      {
        path: '/redirect/:path(.*)',
        component: () => import('@/views/redirect/index.vue')
      }
    ]
  },
  {
    path: '/login',
    component: () => import('@/views/login'),
    hidden: true
  },
  {
    path: '/register',
    component: () => import('@/views/register'),
    hidden: true
  },
  {
    path: "/:pathMatch(.*)*",
    component: () => import('@/views/error/404'),
    hidden: true
  },
  {
    path: '/401',
    component: () => import('@/views/error/401'),
    hidden: true
  },
  {
    path: '',
    component: Layout,
    redirect: '/index',
    children: [
      {
        path: '/index',
        component: () => import('@/views/index'),
        name: 'Index',
        meta: { title: '首页', icon: 'dashboard', affix: true }
      }
    ]
  },
  {
    path: '/user',
    component: Layout,
    hidden: true,
    redirect: 'noredirect',
    children: [
      {
        path: 'profile/:activeTab?',
        component: () => import('@/views/system/user/profile/index'),
        name: 'Profile',
        meta: { title: '个人中心', icon: 'user' }
      }
    ]
  }
]

// 动态路由,基于用户权限动态去加载
export const dynamicRoutes = [
  {
    path: '/system/user-auth',
    component: Layout,
    hidden: true,
    permissions: ['system:user:edit'],
    children: [
      {
        path: 'role/:userId(\\d+)',
        component: () => import('@/views/system/user/authRole'),
        name: 'AuthRole',
        meta: { title: '分配角色', activeMenu: '/system/user' }
      }
    ]
  },
  {
    path: '/system/role-auth',
    component: Layout,
    hidden: true,
    permissions: ['system:role:edit'],
    children: [
      {
        path: 'user/:roleId(\\d+)',
        component: () => import('@/views/system/role/authUser'),
        name: 'AuthUser',
        meta: { title: '分配用户', activeMenu: '/system/role' }
      }
    ]
  },
  {
    path: '/system/dict-data',
    component: Layout,
    hidden: true,
    permissions: ['system:dict:list'],
    children: [
      {
        path: 'index/:dictId(\\d+)',
        component: () => import('@/views/system/dict/data'),
        name: 'Data',
        meta: { title: '字典数据', activeMenu: '/system/dict' }
      }
    ]
  },
  {
    path: '/monitor/job-log',
    component: Layout,
    hidden: true,
    permissions: ['monitor:job:list'],
    children: [
      {
        path: 'index/:jobId(\\d+)',
        component: () => import('@/views/monitor/job/log'),
        name: 'JobLog',
        meta: { title: '调度日志', activeMenu: '/monitor/job' }
      }
    ]
  },
  {
    path: '/tool/gen-edit',
    component: Layout,
    hidden: true,
    permissions: ['tool:gen:edit'],
    children: [
      {
        path: 'index/:tableId(\\d+)',
        component: () => import('@/views/tool/gen/editTable'),
        name: 'GenEdit',
        meta: { title: '修改生成配置', activeMenu: '/tool/gen' }
      }
    ]
  }
]

1.2 组件

1.2.1 Template模板部分

<template>
  <div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
    <div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
    <sidebar v-if="!sidebar.hide" class="sidebar-container" />
    <div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
      <div :class="{ 'fixed-header': fixedHeader }">
        <navbar @setLayout="setLayout" />
        <tags-view v-if="needTagsView" />
      </div>
      <app-main />
      <settings ref="settingRef" />
    </div>
  </div>
</template>
  • 根容器:
    • 使用动态class和style,根据主题设置背景色
    • 根据设备类型和侧边栏状态添加不同CSS类
  • 移动端遮罩层
    • 当在移动端且侧边栏打开时显示半透明黑色遮罩
    • 点击遮罩会触发handleClickOutside关闭侧边栏
  • 侧边栏组件:
    • 根据sidebar.hider状态决定是否显示侧边栏
  • 主内容区域:
    • 包含固定头部(导航栏和标签视图)和主要内容区域
    • 根据设置决定是否显示标签页视图
    • 集成设置组件

1.2.2 Script脚本部分

<script setup>
// 导入模块
import { useWindowSize } from '@vueuse/core'
import Sidebar from './components/Sidebar/index.vue'
import { AppMain, Navbar, Settings, TagsView } from './components'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'

// 初始化设置状态管理实例,用于管理应用的主题、标签页显示等设置
const settingsStore = useSettingsStore()
//计算属性
const theme = computed(() => settingsStore.theme)
const sideTheme = computed(() => settingsStore.sideTheme)
const sidebar = computed(() => useAppStore().sidebar)
const device = computed(() => useAppStore().device)
const needTagsView = computed(() => settingsStore.tagsView)
const fixedHeader = computed(() => settingsStore.fixedHeader)

// 计算属性,判断是否需要固定头部
const classObj = computed(() => ({
  hideSidebar: !sidebar.value.opened,
  openSidebar: sidebar.value.opened,
  withoutAnimation: sidebar.value.withoutAnimation,
  mobile: device.value === 'mobile'
}))

// 根据侧边栏和设备状态返回相应的CSS类名对象,用于动态设置布局样式
const { width, height } = useWindowSize()
// 使用VueUse库获取窗口尺寸的响应式数据
const WIDTH = 992 // refer to Bootstrap's responsive design

// 定义断点宽度,小于该宽度视为移动设备
watch(() => device.value, () => {
  if (device.value === 'mobile' && sidebar.value.opened) {
    useAppStore().closeSideBar({ withoutAnimation: false })
  }
})

// 监听设备类型变化,当切换到移动设备且侧边栏打开时,关闭侧边栏并带动画
watchEffect(() => {
  if (width.value - 1 < WIDTH) {
    useAppStore().toggleDevice('mobile')
    useAppStore().closeSideBar({ withoutAnimation: true })
  } else {
    useAppStore().toggleDevice('desktop')
  }
})

// 响应式监听窗口宽度变化,当宽度小于断点时切换为移动设备模式并关闭侧边栏(无动画),
// 否则切换为桌面设备模式
function handleClickOutside() {
  useAppStore().closeSideBar({ withoutAnimation: false })
}

// 处理点击遮罩层事件,关闭侧边栏并带动画。
const settingRef = ref(null)
function setLayout() {
  settingRef.value.openSetting()
}
</script>
  • 导入模块
    • 使用@vueuse/core的useWindowSize监听窗口大小
    • 导入各种子组件(侧边栏、主内容、导航栏等)
  • 状态管理
    • 获取状态管理实例,用于管理应用的主题、标签页显示等设置
    • 计算属性包括主题、侧边栏状态、设备类型等
  • 自适应处理
    • 监听窗口大小变化,当宽度小于992px时切换为移动端模式
    • 移动端模式自动关闭侧边栏
  • 交互方法
    • handleClickOutside点击遮罩关闭侧边栏
    • setLayout打开设置面板

1.2.3 Style样式部分

<style lang="scss" scoped>
// 引入mixin和变量模块,分别命名为mix和vars
@use "@/assets/styles/mixin.scss" as mix;
@use "@/assets/styles/variables.module.scss" as vars;

// 应用包装器样式,使用clearfix清除浮动,设置相对定位,占满全屏
.app-wrapper {
  @include mix.clearfix;
  position: relative;
  height: 100%;
  width: 100%;
// 当在移动设备上且侧边栏打开时,使用固定定位。
  &.mobile.openSidebar {
    position: fixed;
    top: 0;
  }
}

// 移动端遮罩层样式,黑色半透明背景,覆盖全屏,层级为999。
.drawer-bg {
  background: #000;
  opacity: 0.3;
  width: 100%;
  top: 0;
  height: 100%;
  position: absolute;
  z-index: 999;
}

// 固定头部样式,使用固定定位,宽度为100%减去侧边栏宽度(200px),宽度变化有0.28秒过渡动画
.fixed-header {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 9;
  width: calc(100% - #{vars.$base-sidebar-width});
  transition: width 0.28s;
}

// 当侧边栏隐藏时,固定头部宽度为100%减去54px(折叠后的侧边栏宽度)
.hideSidebar .fixed-header {
  width: calc(100% - 54px);
}

.sidebarHide .fixed-header {
  width: 100%;
}

// 当侧边栏完全隐藏时,固定头部占满全屏宽度。
.mobile .fixed-header {
  width: 100%;
}
</style>
  • 移动端打开侧边栏时使用固定定位
  • 不同状态下(隐藏侧边栏、移动端等)的样式适配

二、导航栏

导航栏在web页面上方,左侧是一个隐藏或展开侧边栏的按钮,紧挨着的是面包屑导航组件,右边是工具栏。 在这里插入图片描述

2.1 Navbar组件

src→layout→components→Navbar.vue Navbar组件是系统的顶部导航栏,包含以下主要功能:

  • 侧边栏控制:通过汉堡菜单控制侧边栏展开/收起
  • 导航显示:根据设置显示面包屑或顶部菜单
  • 快捷功能:搜索、全屏、主题切换、布局大小调整
  • 用户信息:显示用户头像和昵称,提供个人中心和退出登录功能
  • 项目链接:提供源码和文档的快速访问
  • 响应式设计:移动端隐藏部分功能

这个组件整合了系统顶部的大部分常用功能,为用户提供了便捷的操作入口。

2.1.1 Template模板部分

<template>
  // 创建navbar容器,作为导航栏的根元素。
  <div class="navbar">
    // 汉堡菜单组件,用于控制侧边栏的展开/收起状态,点击时触发toggleSideBar方法
    <hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
    // 面包屑导航组件,当不使用顶部导航时显示,显示当前页面路径。
    <breadcrumb v-if="!settingsStore.topNav" id="breadcrumb-container" class="breadcrumb-container" />
    // 顶部导航菜单组件,当启用顶部导航时显示。
    <top-nav v-if="settingsStore.topNav" id="topmenu-container" class="topmenu-container" />

    // 右侧菜单容器,包含各种功能按钮和用户信息 
    <div class="right-menu">
      // 非移动设备上显示以下内容
      <template v-if="appStore.device !== 'mobile'">
        // 头部搜索组件,用于快速搜索菜单项
        <header-search id="header-search" class="right-menu-item" />
        // 显示项目源码链接的组件,使用Element Plus的tooltip提示"源码地址"。
        <el-tooltip content="源码地址" effect="dark" placement="bottom">
          <ruo-yi-git id="ruoyi-git" class="right-menu-item hover-effect" />
        </el-tooltip>
        // 显示项目文档链接的组件,使用Element Plus的tooltip提示"文档地址"。
        <el-tooltip content="文档地址" effect="dark" placement="bottom">
          <ruo-yi-doc id="ruoyi-doc" class="right-menu-item hover-effect" />
        </el-tooltip>
        // 全屏切换组件,用于切换页面全屏状态
        <screenfull id="screenfull" class="right-menu-item hover-effect" />
        // 主题切换组件
        <el-tooltip content="主题模式" effect="dark" placement="bottom">
          <div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
            <svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
            <svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
          </div>
        </el-tooltip>
        // 布局大小选择组件
        <el-tooltip content="布局大小" effect="dark" placement="bottom">
          <size-select id="size-select" class="right-menu-item hover-effect" />
        </el-tooltip>
      // 结束非移动端条件渲染
      </template>

      // 用户信息下拉菜单,显示用户头像和昵称,提供个人中心链接和退出登录功能
      <el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
        <div class="avatar-wrapper">
          <img :src="userStore.avatar" class="user-avatar" />
          <span class="user-nickname"> {{ userStore.nickName }} </span>
        </div>
        <template #dropdown>
          <el-dropdown-menu>
            <router-link to="/user/profile">
              <el-dropdown-item>个人中心</el-dropdown-item>
            </router-link>
            <el-dropdown-item divided command="logout">
              <span>退出登录</span>
            </el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
      // 设置按钮,当启用设置功能时显示,点击打开设置面板
      <div class="right-menu-item hover-effect setting" @click="setLayout" v-if="settingsStore.showSettings">
        <svg-icon icon-class="more-up" />
      </div>
    </div>
  </div>
// 结束navbar容器
</template>

2.1.2 Script脚本部分

<script setup>
// 导入Element Plus的消息框组件,用于确认对话框
import { ElMessageBox } from 'element-plus'
// 导入所需子组件
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect'
import HeaderSearch from '@/components/HeaderSearch'
import RuoYiGit from '@/components/RuoYi/Git'
import RuoYiDoc from '@/components/RuoYi/Doc'
// 导入状态管理模块
import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'

// 初始化各状态管理实例
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()

// 切换侧边栏展开/收起状态的方法。
function toggleSideBar() {
  appStore.toggleSideBar()
}

// 处理下拉菜单命令的通用方法,根据命令类型调用相应函数
function handleCommand(command) {
  switch (command) {
    case "setLayout":
      setLayout()
      break
    case "logout":
      logout()
      break
    default:
      break
  }
}
// 退出登录方法
function logout() {
  ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
      // 确认后调用用户登出方法并跳转到首页
    userStore.logOut().then(() => {
      location.href = '/index'
    })
  }).catch(() => { })
}

// 定义并实现setLayout事件发射方法,用于通知父组件打开设置面板
const emits = defineEmits(['setLayout'])
function setLayout() {
  emits('setLayout')
}

// 切换主题模式方法,调用设置存储中的切换主题功能
function toggleTheme() {
  settingsStore.toggleTheme()
}
</script>

2.1.3 Style样式部分

<style lang='scss' scoped>
// 导航栏基础样式
.navbar {
  height: 50px;
  overflow: hidden;
  position: relative;
  background: var(--navbar-bg);
  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);

  // 汉堡菜单容器样式
  .hamburger-container {
    line-height: 46px;
    height: 100%;
    float: left;
    cursor: pointer;
    transition: background 0.3s;
    -webkit-tap-highlight-color: transparent;

    &:hover {
      background: rgba(0, 0, 0, 0.025);
    }
  }

  // 面包屑容器左浮动 
  .breadcrumb-container {
    float: left;
  }

  // 顶部菜单容器绝对定位
  .topmenu-container {
    position: absolute;
    left: 50px;
  }

  // 错误日志容器样式
  .errLog-container {
    display: inline-block;
    vertical-align: top;
  }

  // 右侧菜单样式容器
  .right-menu {
    float: right;
    height: 100%;
    line-height: 50px;
    display: flex;

    &:focus {
      outline: none;
    }

    // 右侧菜单项通用样式
    .right-menu-item {
      display: inline-block;
      padding: 0 8px;
      height: 100%;
      font-size: 18px;
      color: #5a5e66;
      vertical-align: text-bottom;

      &.hover-effect {
        cursor: pointer;
        transition: background 0.3s;

        &:hover {
          background: rgba(0, 0, 0, 0.025);
        }
      }
      // 主题切换按钮样式
      &.theme-switch-wrapper {
        display: flex;
        align-items: center;

        svg {
          transition: transform 0.3s;
          
          &:hover {
            transform: scale(1.15);
          }
        }
      }
    }
    
    // 用户头像容器样式
    .avatar-container {
      margin-right: 0px;
      padding-right: 0px;

      .avatar-wrapper {
        margin-top: 10px;
        right: 5px;
        position: relative;

        .user-avatar {
          cursor: pointer;
          width: 30px;
          height: 30px;
          border-radius: 50%;
        }

        .user-nickname{
          position: relative;
          left: 5px;
          bottom: 10px;
          font-size: 14px;
          font-weight: bold;
        }

        i {
          cursor: pointer;
          position: absolute;
          right: -20px;
          top: 25px;
          font-size: 12px;
        }
      }
    }
  }
}
</style>

三、侧边栏

web页面左侧的侧边栏

image.png

3.1 Sidebar组件

src→layout→Sidebar→index.vue Sidebar组件实现了完整的侧边栏导航功能,具有以下特点:

  • 可配置性:支持显示/隐藏Logo、主题切换、折叠/展开等配置
  • 动态菜单:根据权限动态生成菜单项
  • 主题支持:支持多种主题和暗黑模式
  • 状态管理:与应用状态管理集成,保持状态同步
  • 自适应:根据屏幕大小和设置自动调整布局,颜色和样式根据主题动态调整 通过组合Logo、SidebarItem等子组件构建完整的侧边栏界面

3.1.1 Template模板部分

<template>
  // 创建侧边栏容器,根据showLogo的值决定是否添加'has-logo'类
  <div :class="{ 'has-logo': showLogo }" class="sidebar-container">
    // 条件渲染Logo组件,当showLogo为true时显示,传递collapse属性控制Logo的折叠状态
    <logo v-if="showLogo" :collapse="isCollapse" />
    // 使用Element Plus的滚动条组件包装菜单内容,设置包装类名为scrollbar-wrapper
    <el-scrollbar wrap-class="scrollbar-wrapper">
      // 创建Element Plus菜单组件,并设置以下属性
      <el-menu
        // 当前激活的菜单项
        :default-active="activeMenu"
        // 控制菜单是否折叠
        :collapse="isCollapse"
        // 菜单背景色
        :background-color="getMenuBackground"
        // 菜单文字颜色
        :text-color="getMenuTextColor"
        // 只保持一个子菜单展开
        :unique-opened="true"
        // 激活菜单项的文字颜色
        :active-text-color="theme"
        // 是否使用折叠动画
        :collapse-transition="false"
        // 菜单模式为垂直
        mode="vertical"
        // 菜单主题类
        :class="sideTheme"
      >
        // 遍历sidebarRouters,为每个路由创建SidebarItem组件,传递路由信息和基础路径
        <sidebar-item
          v-for="(route, index) in sidebarRouters"
          :key="route.path + index"
          :item="route"
          :base-path="route.path"
        />
      // 结束菜单和滚动条组件
      </el-menu>
    </el-scrollbar>
  </div>
</template>

3.1.2 Script脚本部分

<script setup>
// 导入所需组件和模块
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/assets/styles/variables.module.scss'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'

// 获取路由和各状态管理实例
const route = useRoute()
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()

// 计算属性,获取权限存储中的侧边栏路由
const sidebarRouters = computed(() => permissionStore.sidebarRouters)
// 根据设置决定是否显示logo
const showLogo = computed(() => settingsStore.sidebarLogo)
// 获取侧边栏主题设置
const sideTheme = computed(() => settingsStore.sideTheme)
// 获取当前主题颜色
const theme = computed(() => settingsStore.theme)
// 根据侧边栏是否打开决定菜单是否折叠
const isCollapse = computed(() => !appStore.sidebar.opened)

// 根据暗黑模式和主题设置获取菜单背景色
const getMenuBackground = computed(() => {
  if (settingsStore.isDark) {
    return 'var(--sidebar-bg)'
  }
  return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg
})

// 根据暗黑模式和主题设置获取菜单文字颜色
const getMenuTextColor = computed(() => {
  if (settingsStore.isDark) {
    return 'var(--sidebar-text)'
  }
  return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText
})

// 根据路由元信息决定当前激活的菜单项
const activeMenu = computed(() => {
  const { meta, path } = route
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})
</script>

3.1.3 Style样式部分

// 设置侧边栏容器背景色,使用v-bind绑定计算属性
<style lang="scss" scoped>
.sidebar-container {
  background-color: v-bind(getMenuBackground);
  // 设置滚动条包装器背景色
  .scrollbar-wrapper {
    background-color: v-bind(getMenuBackground);
  }

  // 设置菜单样式
  .el-menu {
    border: none;
    height: 100%;
    width: 100% !important;
    
    // 设置菜单项和子菜单标题在悬停时的背景色
    .el-menu-item, .el-sub-menu__title {
      &:hover {
        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
      }
    }

    // 设置菜单项文字颜色,并为激活状态的菜单项设置特殊颜色和背景
    .el-menu-item {
      color: v-bind(getMenuTextColor);
      
      &.is-active {
        color: var(--menu-active-text, #409eff);
        background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
      }
    }

    // 设置子菜单标题文字颜色
    .el-sub-menu__title {
      color: v-bind(getMenuTextColor);
    }
  }
}
</style>

四、顶部栏

页面上方的菜单栏,开启后会在页面上方显示一级菜单 关闭状态: 在这里插入图片描述

打开状态: 在这里插入图片描述

4.1 TopNav组件

src→components→TopNav→index.vue TopNav组件是一个基于Element Plus的顶部水平导航菜单,主要功能包括:

  • 动态菜单显示:根据路由配置动态生成顶部菜单项
  • 菜单折叠:当菜单项过多时,自动将超出部分折叠到"更多菜单"下拉中
  • 自适应:根据屏幕宽度动态调整可见菜单项数量
  • 路由联动:与侧边栏菜单联动,点击顶部菜单项可切换侧边栏内容
  • 多类型链接支持:支持内部路由跳转和外部链接新窗口打开
  • 主题定制:支持主题颜色定制

该组件充分利用了Vue 3的响应式特性和组合式API,结合Element Plus组件库实现顶部导航菜单。

4.1.1 Template模板部分

<template>
  <el-menu
    // 绑定当前激活的菜单项
    :default-active="activeMenu"
    // 设置为水平模式
    mode="horizontal"
    // 菜单选项被选择时的处理函数
    @select="handleSelect"
    // 禁用菜单项的省略显示
    :ellipsis="false"
  >
    // 遍历显示顶部菜单项
    <template v-for="(item, index) in topMenus">
      <el-menu-item :style="{'--theme': theme}" :index="item.path" :key="index" v-if="index < visibleNumber">
        // 如果菜单项有图标则显示SVG图标
        <svg-icon
        v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
        :icon-class="item.meta.icon"/>
        // 显示菜单项标题
        {{ item.meta.title }}
      </el-menu-item>
    </template>

    <!-- 顶部菜单超出数量折叠 -->
    <el-sub-menu :style="{'--theme': theme}" index="more" v-if="topMenus.length > visibleNumber">
      // 折叠菜单的标题
      <template #title>更多菜单</template>
      // 显示被折叠的菜单项
      <template v-for="(item, index) in topMenus">
        <el-menu-item
          :index="item.path"
          :key="index"
          v-if="index >= visibleNumber">
        <svg-icon
          v-if="item.meta && item.meta.icon && item.meta.icon !== '#'"
          :icon-class="item.meta.icon"/>
        {{ item.meta.title }}
        </el-menu-item>
      </template>
    </el-sub-menu>
  </el-menu>
</template>

4.1.2 Script脚本部分

<script setup>
// 导入模块
import { constantRoutes } from "@/router"
import { isHttp } from '@/utils/validate'
import useAppStore from '@/store/modules/app'
import useSettingsStore from '@/store/modules/settings'
import usePermissionStore from '@/store/modules/permission'

// 顶部栏初始化
const visibleNumber = ref(null)
// 当前激活菜单的 index
const currentIndex = ref(null)
// 隐藏侧边栏路由
const hideList = ['/index', '/user/profile']

const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const route = useRoute()
const router = useRouter()

// 主题颜色
const theme = computed(() => settingsStore.theme)
// 所有的路由信息
const routers = computed(() => permissionStore.topbarRouters)

// 顶部显示菜单
const topMenus = computed(() => {
  let topMenus = []
  routers.value.map((menu) => {
    // 过滤隐藏菜单
    if (menu.hidden !== true) {
      // 兼容顶部栏一级菜单内部跳转
      if (menu.path === '/' && menu.children) {
          // 如果是根路径且有子菜单,使用第一个子菜单
          topMenus.push(menu.children[0])
      } else {
          // 否则直接使用该菜单
          topMenus.push(menu)
      }
    }
  })
  return topMenus
})

// 设置子路由
const childrenMenus = computed(() => {
  let childrenMenus = []
  routers.value.map((router) => {
    for (let item in router.children) {
      // 处理子路由路径
      if (router.children[item].parentPath === undefined) {
        if(router.path === "/") {
          router.children[item].path = "/" + router.children[item].path
        } else {
          if(!isHttp(router.children[item].path)) {
            router.children[item].path = router.path + "/" + router.children[item].path
          }
        }
        router.children[item].parentPath = router.path
      }
      childrenMenus.push(router.children[item])
    }
  })
  // 合并常量路由和处理后的子路由
  return constantRoutes.concat(childrenMenus)
})

// 默认激活的菜单
const activeMenu = computed(() => {
  const path = route.path
  let activePath = path
  // 根据当前路由路径确定激活的菜单项
  if (path !== undefined && path.lastIndexOf("/") > 0 && hideList.indexOf(path) === -1) {
    const tmpPath = path.substring(1, path.length)
    if (!route.meta.link) {
      activePath = "/" + tmpPath.substring(0, tmpPath.indexOf("/"))
      // 显示侧边栏
      appStore.toggleSideBarHide(false)
    }
  } else if(!route.children) {
    activePath = path
    // 隐藏侧边栏
    appStore.toggleSideBarHide(true)
  }
  activeRoutes(activePath)
  return activePath
})

// 设置可见菜单项数量
function setVisibleNumber() {
  const width = document.body.getBoundingClientRect().width / 3
  // 根据屏幕宽度计算可显示菜单项数量
  visibleNumber.value = parseInt(width / 85)
}

// 处理菜单项选择
function handleSelect(key, keyPath) {
  currentIndex.value = key
  const route = routers.value.find(item => item.path === key)
  if (isHttp(key)) {
    // http(s):// 路径新窗口打开
    window.open(key, "_blank")
  } else if (!route || !route.children) {
    // 没有子路由路径内部打开
    const routeMenu = childrenMenus.value.find(item => item.path === key)
    if (routeMenu && routeMenu.query) {
      let query = JSON.parse(routeMenu.query)
      router.push({ path: key, query: query })
    } else {
      router.push({ path: key })
    }
    appStore.toggleSideBarHide(true)
  } else {
    // 显示左侧联动菜单
    activeRoutes(key)
    appStore.toggleSideBarHide(false)
  }
}

// 激活路由
function activeRoutes(key) {
  let routes = []
  if (childrenMenus.value && childrenMenus.value.length > 0) {
    childrenMenus.value.map((item) => {
      if (key == item.parentPath || (key == "index" && "" == item.path)) {
        routes.push(item)
      }
    })
  }
  if(routes.length > 0) {
    // 设置侧边栏路由
    permissionStore.setSidebarRouters(routes)
  } else {
    appStore.toggleSideBarHide(true)
  }
  return routes
}

// 声明周期钩子
onMounted(() => {
  // 监听窗口大小变化
  window.addEventListener('resize', setVisibleNumber)
})

onBeforeUnmount(() => {
  // 移除事件监听
  window.removeEventListener('resize', setVisibleNumber)
})

onMounted(() => {
  // 初始化可见菜单项数量
  setVisibleNumber()
})
</script>

4.1.3 Style样式部分

<style lang="scss">
// 菜单项基本样式
.topmenu-container.el-menu--horizontal > .el-menu-item {
  float: left;
  height: 50px !important;
  line-height: 50px !important;
  color: #999093 !important;
  padding: 0 5px !important;
  margin: 0 10px !important;
}

// 激活菜单项样式
.topmenu-container.el-menu--horizontal > .el-menu-item.is-active, .el-menu--horizontal > .el-sub-menu.is-active .el-submenu__title {
  border-bottom: 2px solid #{'var(--theme)'} !important;
  color: #303133;
}

// 子菜单项样式
/* sub-menu item */
.topmenu-container.el-menu--horizontal > .el-sub-menu .el-sub-menu__title {
  float: left;
  height: 50px !important;
  line-height: 50px !important;
  color: #999093 !important;
  padding: 0 5px !important;
  margin: 0 10px !important;
}

/* 背景色隐藏 */
.topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):focus, .topmenu-container.el-menu--horizontal>.el-menu-item:not(.is-disabled):hover, .topmenu-container.el-menu--horizontal>.el-submenu .el-submenu__title:hover {
  background-color: #ffffff;
}

/* 图标右间距 */
.topmenu-container .svg-icon {
  margin-right: 4px;
}

// 菜单箭头样式
/* topmenu more arrow */
.topmenu-container .el-sub-menu .el-sub-menu__icon-arrow {
  position: static;
  vertical-align: middle;
  margin-left: 8px;
  margin-top: 0px;
}


</style>

文件太大怎么上传?【分组分片上传大文件】-实战记录

作者 码间舞
2025年9月18日 21:41

前言

很久以前就想尝试大文件上传了,一直没有什么机会,主要是业务上没有契合的场景。前段时间偶然的一个契机尝试了一下,今天准备用一个实际案例来记录一下分组分片上传大文件

场景和背景

我用的vue3+element-plus,是的还是熟悉的配方,还是熟悉的味道,上传组件使用el-upload。需求是上传安装包,其实不算大,但是也会有几百兆。所以这里考虑使用分组分片上传。

分组上传

我这边直接使用http-request来自定义上传行为:

<el-upload
  v-model:file-list="form.fileExeList"
  accept=".exe"
  :limit="1"
  :disabled="isEdit"
  :on-preview="handlePreview"
  :on-remove="handleRemove"
  :before-remove="beforeRemove"
  :on-exceed="handleExceed"
  :http-request="httpRequest"
>
  <el-button type="primary" :disabled="isEdit">
    {{ t("versionManage.publish.dialog.uploadTips") }}
    {{ t("versionManage.publish.dialog.exe") }}
  </el-button>
</el-upload>

JS代码,我们先分析逻辑:

  1. 定义好chunk大小: chunkSize,拿到原始文件后进行分片,得到最终会有多少chunk: totalChunks。还需一个变量finishedChunks 来表示已经完成了几个chunk,用于展示进度条
  2. 由于被分割成了多个chunk,如果我们串行一个一个上传,那么分割的意义就没有了。为了节省时间,所以我们使用Promise.all来并行发出所有chunk,所以需要组装每个chunk的请求方法
  3. 等待所有chunk上传完毕后需要调用一个完成上传的方法告诉后端已经上传完毕
  4. 处理上传过程中的错误,尤其注意并行的时候发生错误弹窗不能多次弹出

好了,直接上源码:

const httpRequest = async (options) => {
  const { file, onSuccess, onError, onProgress } = options;
  const initRes = await initMultipartUpload({
    fileName: file.name,
    isPublic: true
  });
  if ((initRes.data as any)?.code) {
    ElMessage.error(t("buttons.upLoadFail"));
    onError && onError((initRes.data as any)?.msg);
    return;
  }
  const chunkSize = 5 * 1024 * 1024; // 5MB
  const rawFile = file.raw || file;
  const totalChunks = Math.ceil(rawFile.size / chunkSize);
  let finishedChunks = 0;
  let hasError = false;

  resendFormRef.value?.clearValidate();

  // 并行上传所有分片
  const uploadPromises = Array.from({ length: totalChunks }).map(
    async (_, currentChunk) => {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, rawFile.size);
      const chunk = rawFile.slice(start, end);
      const fD = new FormData();
      fD.append("partData", chunk);
      try {
        const res = await uploadMultipartPart(
          {
            uploadId: initRes.data?.uploadId,
            partNumber: currentChunk + 1
          },
          fD
        );
        // throw new Error("error");
        if ((res.data as any)?.code) {
          hasError = true;
          onError && onError((res.data as any)?.msg);
          return Promise.reject((res.data as any)?.msg);
        }
        finishedChunks++;
        if (onProgress) {
          onProgress({
            percent: Math.round((finishedChunks / totalChunks) * 100)
          });
        }
      } catch (err) {
        !hasError && ElMessage.error(t("buttons.upLoadFail")); // 避免多次弹窗
        hasError = true;
        onError && onError(err);
      }
    }
  );

  await Promise.all(uploadPromises);

  if (!hasError) {
    const res = await completeMultipartUpload({
      uploadId: initRes.data?.uploadId
    });
    if ((res.data as any)?.code) {
      ElMessage.error(t("buttons.upLoadFail"));
      onError && onError((res.data as any)?.msg);
      return;
    }
    onSuccess({ response: { fileUrl: res.data?.key }, status: "success" });
    ElMessage.success(t("buttons.upLoadSuccess"));
  }
};

代码解析:

  • 首先我们需要线条用一个初始化的接口,这个接口告诉服务器我要开始上传一个大文件了,并提交大文件的一些基本信息,服务器返回一个uploadId,后续上传使用,这个uploadId相当于一个任务id,开起了一个上传任务。
  • 将文件分好片,然后调用分段上传即可。我们使用Promise.all来做并行这个操作,为此需要封装uploadPromise这个方法,创建一个数组,然后往里面塞我们的请求方法将文件按照chunkSize一点点进行上传,并告诉服务器当前chunk的顺序partNumber(服务器需要按照顺序拼接回去)
  • 分片上传了,就更容易展示进度条了。Promise.all里面的的请求每成功一次finishedChunks就加1,以此来展示当前上传的进度,这可不是假进度条了,货真价实的进度。
  • 所有分片上传完毕后,再调用一个completeMultipartUpload的方法告诉服务器当前大文件已上传完毕,服务器合并文件后就可以提供link了

问题拓展

代码写完了,上传了一个几十MB的文件测试没有问题,满心欢喜提测。

然鹅,测试甩了一个截图,所有part分片上传失败了,即uploadMultipartPart的上传失败了。原因是测试用另一个几百MB的.exe文件上传,我们的分片有好几十个,但是服务器来不及处理完所有就超时了,http请求超时自动cancel了。

怎么办呢?

1. 延长http请求的超时时间

治标不治本,超大文件、低网速环境下依然没有用

2. 将分片大小减少

一样治不了根,原因同上

3. 分组分片上传

将分好的分片放入一个组,比如五个为一组,然后一组一组的请求,每一组请求完毕后继续下一组,直到最后一组请求完毕可以解决这个问题。

4. 请求管道

思想同第三点类似,也是将分片的请求方法放入一个组中,只是这里是一个管道。比如这个管道能装5个请求,然后发起请求,管道里每请求完毕一个就加入一个在管道里,直到请求完毕

解决方案

我综合了一下,决定还是用分组分片来实现。没用管道,主要不想太麻烦。

思路:

  • 设定每组并发上传的分片数为 5(groupSize = 5)。
  • 外层循环以每组 5 个分片为单位遍历所有分片。
  • 每组内用一个数组 groupPromises 收集 5 个分片的上传 Promise。
  • 内层循环遍历当前组的每个分片,切割文件,放入 FormData,调用 uploadMultipartPart 上传。
  • 每个分片上传成功后,finishedChunks++,并通过 onProgress 回调实时更新进度百分比。
  • 如果某个分片上传失败,设置 hasError = true,并通过 onError 回调通知外部。
  • await Promise.all(groupPromises) 等待当前组的所有分片上传完成后再进入下一组。
  • 如果有分片上传失败(hasError),中断后续上传。

根据以上思路我们重写代码为:

const httpRequest = async (options) => {
  const { file, onSuccess, onError, onProgress } = options;
  const initRes = await initMultipartUpload({
    fileName: file.name,
    isPublic: true
  });
  if ((initRes.data as any)?.code) {
    ElMessage.error(t("buttons.upLoadFail"));
    onError && onError((initRes.data as any)?.msg);
    return;
  }
  const chunkSize = 5 * 1024 * 1024; // 5MB
  const rawFile = file.raw || file;
  const totalChunks = Math.ceil(rawFile.size / chunkSize);
  let finishedChunks = 0;
  let hasError = false;

  resendFormRef.value?.clearValidate();

  // 分组并发上传,每组5个分片,组间串行
  const groupSize = 5;
  for (let groupStart = 0; groupStart < totalChunks; groupStart += groupSize) {
    const groupEnd = Math.min(groupStart + groupSize, totalChunks);
    const groupPromises = [];
    for (
      let currentChunk = groupStart;
      currentChunk < groupEnd;
      currentChunk++
    ) {
      const start = currentChunk * chunkSize;
      const end = Math.min(start + chunkSize, rawFile.size);
      const chunk = rawFile.slice(start, end);
      const fD = new FormData();
      fD.append("partData", chunk);
      const promise = (async () => {
        try {
          const res = await uploadMultipartPart(
            {
              uploadId: initRes.data?.uploadId,
              partNumber: currentChunk + 1
            },
            fD
          );
          if ((res.data as any)?.code) {
            hasError = true;
            onError && onError((res.data as any)?.msg);
            return Promise.reject((res.data as any)?.msg);
          }
          finishedChunks++;
          if (onProgress) {
            onProgress({
              percent: Math.round((finishedChunks / totalChunks) * 100)
            });
          }
        } catch (err) {
          !hasError && ElMessage.error(t("buttons.upLoadFail"));
          hasError = true;
          onError && onError(err);
        }
      })();
      groupPromises.push(promise);
    }
    await Promise.all(groupPromises);
    if (hasError) break;
  }

  if (!hasError) {
    const res = await completeMultipartUpload({
      uploadId: initRes.data?.uploadId
    });
    if ((res.data as any)?.code) {
      ElMessage.error(t("buttons.upLoadFail"));
      onError && onError((res.data as any)?.msg);
      return;
    }
    onSuccess({ response: { fileUrl: res.data?.key }, status: "success" });
    ElMessage.success(t("buttons.upLoadSuccess"));
  }
};

总结

实际上,我还是把http的超时时间扩展了,主要是测试环境速度太拉跨了,分组本来也想用10个分片一组的,也是超时报错。最后受不了了,三管齐下,把之前分析的手段都用上了。既延长请求超时时间,也降低分组数量,还可以降低分片大小,慢慢传直到上传成功。

API 设计最佳实践 Javascript 篇

作者 召摇
2025年9月18日 19:16

API 设计最佳实践 Javascript 篇

在当今的数字化时代,API(应用程序编程接口)已成为软件系统之间通信的基石。一个良好设计的 API 不仅能提高开发效率,还能增强系统的可扩展性和可维护性。本文将深入探讨 API 设计的七大最佳实践,通过理论解析、代码实现和实际案例,帮助您构建健壮、高效且易于使用的 API。

1. REST 基础原理

1.1 什么是 REST?

REST(Representational State Transfer,表述性状态转移)是一种基于 HTTP 协议的架构风格,由 Roy Fielding 在 2000 年提出。它通过统一的接口和标准化的操作,使系统之间的通信更加简单和可预测。

核心特征

  • 无状态性:每个请求包含所有必要信息,服务器不存储客户端状态
  • 可缓存性:响应必须明确表明是否可缓存
  • 分层系统:客户端无需了解是否直接连接最终服务器
  • 统一接口:简化系统架构,改善组件间交互可见性

1.2 REST 资源设计

在 REST 架构中,一切都被视为资源,每个资源都有唯一的标识符(URI)。正确的资源设计是 RESTful API 成功的关键。

资源命名最佳实践

  • 使用名词而非动词(/users 而不是 /getUsers
  • 使用复数形式表示资源集合
  • 保持一致性的大小写(推荐小写和连字符)
  • 避免文件扩展名(使用 Accept header 指定格式)
// 良好的资源设计示例
const express = require('express');
const app = express();

// 用户资源集合
app.get('/users', (req, res) => {
  // 获取用户列表
  const users = UserModel.findAll();
  res.json(users);
});

// 特定用户资源
app.get('/users/:userId', (req, res) => {
  // 根据ID获取特定用户
  const user = UserModel.findById(req.params.userId);
  res.json(user);
});

// 用户下的订单子资源
app.get('/users/:userId/orders', (req, res) => {
  // 获取用户的订单列表
  const orders = OrderModel.findByUser(req.params.userId);
  res.json(orders);
});

1.3 HTTP 方法详解

RESTful API 充分利用 HTTP 方法的语义,使 API 更加直观和自描述。

HTTP 方法 语义 幂等性 安全性
GET 检索资源
POST 创建新资源
PUT 更新或替换资源
PATCH 部分更新资源
DELETE 删除资源

实战示例:完整的 CRUD 操作

// 用户资源完整的 CRUD 实现
app.post('/users', (req, res) => {
  // 创建新用户
  const newUser = UserModel.create(req.body);
  res.status(201).json(newUser);
});

app.get('/users/:id', (req, res) => {
  // 获取用户信息
  const user = UserModel.findById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: '用户不存在' });
  }
  res.json(user);
});

app.put('/users/:id', (req, res) => {
  // 完全更新用户信息
  const updatedUser = UserModel.update(req.params.id, req.body);
  res.json(updatedUser);
});

app.patch('/users/:id', (req, res) => {
  // 部分更新用户信息
  const updatedUser = UserModel.partialUpdate(req.params.id, req.body);
  res.json(updatedUser);
});

app.delete('/users/:id', (req, res) => {
  // 删除用户
  UserModel.delete(req.params.id);
  res.status(204).send();
});

1.4 超媒体作为应用状态引擎(HATEOAS)

HATEOAS 是 REST 架构的一个关键约束,它使客户端能够通过服务器提供的超媒体动态发现可用的操作。

// HATEOAS 示例实现
app.get('/users/:id', (req, res) => {
  const user = UserModel.findById(req.params.id);
  
  const response = {
    id: user.id,
    name: user.name,
    email: user.email,
    links: [
      { rel: 'self', href: `/users/${user.id}`, method: 'GET' },
      { rel: 'update', href: `/users/${user.id}`, method: 'PUT' },
      { rel: 'delete', href: `/users/${user.id}`, method: 'DELETE' },
      { rel: 'orders', href: `/users/${user.id}/orders`, method: 'GET' }
    ]
  };
  
  res.json(response);
});

2. 错误处理机制

2.1 HTTP 状态码的正确使用

HTTP 状态码是客户端了解请求结果的首要方式。正确使用状态码对于 API 的可用性至关重要。

主要状态码类别

  • 1xx:信息性响应
  • 2xx:成功响应
  • 3xx:重定向
  • 4xx:客户端错误
  • 5xx:服务器错误

常用状态码详解

状态码 含义 使用场景
200 OK 请求成功 成功的 GET、PUT、PATCH 请求
201 Created 资源创建成功 成功的 POST 请求
204 No Content 成功但无内容返回 成功的 DELETE 请求
400 Bad Request 错误请求 请求参数错误或格式不正确
401 Unauthorized 未认证 需要身份验证但未提供
403 Forbidden 禁止访问 身份验证成功但无权限
404 Not Found 资源不存在 请求的资源不存在
429 Too Many Requests 请求过多 超出速率限制
500 Internal Server Error 服务器内部错误 服务器端未处理的异常

2.2 错误响应格式标准化

一致的错误响应格式帮助客户端统一处理各种错误情况。

// 统一的错误处理中间件
app.use((err, req, res, next) => {
  console.error('错误详情:', err);
  
  // 根据不同错误类型返回相应的状态码和消息
  let statusCode = 500;
  let errorCode = 'INTERNAL_ERROR';
  let message = '服务器内部错误';
  
  if (err.name === 'ValidationError') {
    statusCode = 400;
    errorCode = 'VALIDATION_ERROR';
    message = '输入数据验证失败';
  } else if (err.name === 'NotFoundError') {
    statusCode = 404;
    errorCode = 'NOT_FOUND';
    message = '请求的资源不存在';
  } else if (err.name === 'AuthenticationError') {
    statusCode = 401;
    errorCode = 'UNAUTHORIZED';
    message = '身份验证失败';
  } else if (err.name === 'AuthorizationError') {
    statusCode = 403;
    errorCode = 'FORBIDDEN';
    message = '没有访问权限';
  }
  
  // 结构化错误响应
  res.status(statusCode).json({
    error: {
      code: errorCode,
      message: message,
      details: process.env.NODE_ENV === 'development' ? err.message : undefined,
      timestamp: new Date().toISOString(),
      traceId: req.id // 请求追踪ID
    }
  });
});

// 自定义错误类
class AppError extends Error {
  constructor(name, message, statusCode) {
    super(message);
    this.name = name;
    this.statusCode = statusCode;
  }
}

class ValidationError extends AppError {
  constructor(message = '验证失败') {
    super('ValidationError', message, 400);
  }
}

2.3 验证和业务错误处理

输入验证是 API 安全性和稳定性的第一道防线。

// 使用 Joi 进行请求验证
const Joi = require('joi');

// 用户创建验证规则
const userCreateSchema = Joi.object({
  name: Joi.string().min(2).max(50).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(8).pattern(new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])')).required(),
  age: Joi.number().integer().min(18).max(120).optional()
});

// 验证中间件
const validate = (schema) => (req, res, next) => {
  const { error, value } = schema.validate(req.body, {
    abortEarly: false, // 返回所有验证错误
    allowUnknown: true, // 允许未知字段(将被忽略)
    stripUnknown: true // 移除未知字段
  });
  
  if (error) {
    const errorDetails = error.details.map(detail => ({
      field: detail.path.join('.'),
      message: detail.message,
      type: detail.type
    }));
    
    throw new ValidationError('输入数据验证失败', errorDetails);
  }
  
  req.body = value;
  next();
};

// 在路由中使用验证
app.post('/users', validate(userCreateSchema), (req, res) => {
  // 处理已验证的请求数据
  const user = UserModel.create(req.body);
  res.status(201).json(user);
});

3. API 版本控制

3.1 版本控制策略

API 版本控制是维护向后兼容性的关键策略,确保现有客户端在 API 演进过程中不受影响。

三种主要版本控制方法

::: tabs

@tab URL 路径版本控制

// 版本控制中间件
app.use('/api/v1', require('./routes/v1/users'));
app.use('/api/v2', require('./routes/v2/users'));

// v1 用户路由
// 路径: /api/v1/users
router.get('/', (req, res) => {
  // v1 实现
});

// v2 用户路由  
// 路径: /api/v2/users
router.get('/', (req, res) => {
  // v2 实现 - 可能包含破坏性变更
});

@tab HTTP 头版本控制

// 基于 Accept 头的版本控制中间件
const versionMiddleware = (req, res, next) => {
  const acceptHeader = req.get('Accept') || '';
  const versionMatch = acceptHeader.match(/application\/vnd\.api\.v(\d+)\+json/);
  
  if (versionMatch) {
    req.apiVersion = parseInt(versionMatch[1], 10);
  } else {
    // 默认版本
    req.apiVersion = 1;
  }
  
  next();
};

// 使用版本控制
app.use(versionMiddleware);
app.use('/api/users', (req, res) => {
  if (req.apiVersion === 1) {
    // v1 逻辑
  } else if (req.apiVersion === 2) {
    // v2 逻辑
  }
});

@tab 查询参数版本控制

// 不推荐的查询参数版本控制
app.get('/api/users', (req, res) => {
  const version = req.query.version || '1';
  
  if (version === '1') {
    // v1 实现
  } else if (version === '2') {
    // v2 实现
  }
});

:::

3.2 版本迁移策略

当引入破坏性变更时,需要制定周密的迁移计划。

graph TD
    A[现有 v1 API] --> B{评估变更影响}
    B --> C[非破坏性变更]
    B --> D[破坏性变更]
    
    C --> E[直接部署到 v1]
    D --> F[创建 v2 分支]
    
    F --> G[实现新功能]
    G --> H[并行运行 v1 和 v2]
    
    H --> I[通知客户端迁移]
    I --> J[设置弃用时间表]
    
    J --> K{所有客户端迁移完成?}
    K -->|否| J
    K -->|是| L[停用 v1 API]
    
    style E fill:#90EE90
    style L fill:#FFB6C1

3.3 版本管理最佳实践

// API 版本管理配置
const apiVersions = {
  current: 2,
  supported: [1, 2],
  deprecated: [1], // 已弃用但仍支持的版本
  sunset: {
    1: new Date('2024-12-31') // v1 的 sunset 日期
  }
};

// 版本信息中间件
app.use((req, res, next) => {
  res.set({
    'API-Version': apiVersions.current,
    'Supported-Versions': apiVersions.supported.join(', '),
    'Deprecated-Versions': apiVersions.deprecated.join(', '),
    'Sunset': apiVersions.deprecated.length > 0 ? 
      `Version 1 will be sunset on ${apiVersions.sunset[1].toISOString()}` : ''
  });
  next();
});

// 弃用警告中间件
app.use('/api/v1/*', (req, res, next) => {
  res.set({
    'Warning': `299 - "Version 1 is deprecated. Please migrate to version ${apiVersions.current} by ${apiVersions.sunset[1].toISOString()}"`
  });
  next();
});

4. 速率限制

4.1 速率限制算法

速率限制保护 API 免受滥用和过载,确保服务的可用性。

常见算法比较

::: tabs

@tab 令牌桶算法

class TokenBucket {
  constructor(capacity, refillRate) {
    this.capacity = capacity;        // 桶容量
    this.tokens = capacity;           // 当前令牌数
    this.refillRate = refillRate;     // 每秒补充速率
    this.lastRefill = Date.now();    // 上次补充时间
  }
  
  refill() {
    const now = Date.now();
    const timePassed = (now - this.lastRefill) / 1000;
    const tokensToAdd = timePassed * this.refillRate;
    
    this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
    this.lastRefill = now;
  }
  
  consume(tokens = 1) {
    this.refill();
    
    if (this.tokens >= tokens) {
      this.tokens -= tokens;
      return true; // 请求允许
    }
    
    return false; // 请求拒绝
  }
}

// 使用令牌桶的中间件
const rateLimitMiddleware = (req, res, next) => {
  const clientId = req.get('API-Key') || req.ip;
  
  if (!rateLimiters[clientId]) {
    // 每个客户端独立的桶:10个令牌,每秒补充2个
    rateLimiters[clientId] = new TokenBucket(10, 2);
  }
  
  if (rateLimiters[clientId].consume()) {
    next();
  } else {
    res.status(429).json({
      error: '请求过于频繁',
      retryAfter: calculateRetryAfter(rateLimiters[clientId])
    });
  }
};

@tab 滑动窗口算法

class SlidingWindow {
  constructor(windowSize, maxRequests) {
    this.windowSize = windowSize;    // 窗口大小(毫秒)
    this.maxRequests = maxRequests;  // 窗口内最大请求数
    this.requests = [];             // 请求时间戳数组
  }
  
  addRequest() {
    const now = Date.now();
    this.requests.push(now);
    
    // 移除超出窗口的旧请求
    const windowStart = now - this.windowSize;
    this.requests = this.requests.filter(time => time > windowStart);
    
    return this.requests.length <= this.maxRequests;
  }
}

// Redis 实现的分布式滑动窗口
const redis = require('redis');
const client = redis.createClient();

const slidingWindowRedis = async (key, windowSize, maxRequests) => {
  const now = Date.now();
  const windowStart = now - windowSize;
  
  // 使用 Redis 有序集合存储请求时间戳
  await client.zadd(key, now, now.toString());
  
  // 移除旧请求
  await client.zremrangebyscore(key, 0, windowStart);
  
  // 获取当前窗口内的请求数量
  const requestCount = await client.zcard(key);
  
  // 设置键的过期时间
  await client.expire(key, windowSize / 1000);
  
  return requestCount <= maxRequests;
};

@tab 漏桶算法

class LeakyBucket {
  constructor(capacity, leakRate) {
    this.capacity = capacity;        // 桶容量
    this.water = 0;                  // 当前水量
    this.leakRate = leakRate;        // 漏水速率(请求/秒)
    this.lastLeak = Date.now();     // 上次漏水时间
  }
  
  leak() {
    const now = Date.now();
    const timePassed = (now - this.lastLeak) / 1000;
    const waterToRemove = timePassed * this.leakRate;
    
    this.water = Math.max(0, this.water - waterToRemove);
    this.lastLeak = now;
  }
  
  addWater(amount = 1) {
    this.leak();
    
    if (this.water + amount <= this.capacity) {
      this.water += amount;
      return true; // 请求进入队列
    }
    
    return false; // 请求被拒绝
  }
}

:::

4.2 多维度速率限制

在实际应用中,通常需要基于多个维度进行速率限制。

// 多维度速率限制配置
const rateLimitConfig = {
  // IP 基础限制
  ip: {
    windowMs: 15 * 60 * 1000, // 15分钟
    max: 100 // 最大请求数
  },
  
  // API 密钥限制(更宽松)
  apiKey: {
    windowMs: 15 * 60 * 1000,
    max: 1000
  },
  
  // 特定端点限制
  endpoints: {
    '/auth/login': {
      windowMs: 60 * 60 * 1000, // 1小时
      max: 5 // 登录尝试限制
    },
    '/api/payments': {
      windowMs: 60 * 1000, // 1分钟
      max: 10 // 支付请求限制
    }
  },
  
  // 全局限制(防止DDoS)
  global: {
    windowMs: 1000, // 1秒
    max: 500 // 全局最大请求数
  }
};

// 分层速率限制中间件
const layeredRateLimit = async (req, res, next) => {
  const clientIp = req.ip;
  const apiKey = req.get('API-Key');
  const endpoint = req.path;
  
  try {
    // 检查全局限制
    const globalAllowed = await checkRateLimit('global:', rateLimitConfig.global);
    if (!globalAllowed) {
      return res.status(429).json({ error: '系统繁忙,请稍后再试' });
    }
    
    // 检查IP限制
    const ipAllowed = await checkRateLimit(`ip:${clientIp}`, rateLimitConfig.ip);
    if (!ipAllowed) {
      return res.status(429).json({ error: 'IP请求过于频繁' });
    }
    
    // 检查API密钥限制(如果存在)
    if (apiKey) {
      const keyAllowed = await checkRateLimit(`key:${apiKey}`, rateLimitConfig.apiKey);
      if (!keyAllowed) {
        return res.status(429).json({ error: 'API密钥限额已用尽' });
      }
    }
    
    // 检查特定端点限制
    if (rateLimitConfig.endpoints[endpoint]) {
      const endpointKey = apiKey ? `endpoint:${apiKey}:${endpoint}` : `endpoint:${clientIp}:${endpoint}`;
      const endpointAllowed = await checkRateLimit(endpointKey, rateLimitConfig.endpoints[endpoint]);
      
      if (!endpointAllowed) {
        return res.status(429).json({ 
          error: `端点 ${endpoint} 请求过于频繁`,
          retryAfter: rateLimitConfig.endpoints[endpoint].windowMs / 1000
        });
      }
    }
    
    next();
  } catch (error) {
    console.error('速率限制错误:', error);
    next(); // 出错时放行请求,避免因限制系统故障影响正常服务
  }
};

4.3 速率限制响应头

提供详细的速率限制信息帮助客户端合理调整请求频率。

// 速率限制响应头中间件
const rateLimitHeaders = (req, res, next) => {
  const oldSend = res.send;
  
  res.send = function(data) {
    const clientId = req.get('API-Key') || req.ip;
    const rateLimiter = rateLimiters[clientId];
    
    if (rateLimiter) {
      res.set({
        'X-RateLimit-Limit': rateLimiter.capacity,
        'X-RateLimit-Remaining': Math.floor(rateLimiter.tokens),
        'X-RateLimit-Reset': Math.ceil((rateLimiter.capacity - rateLimiter.tokens) / rateLimiter.refillRate),
        'Retry-After': calculateRetryAfter(rateLimiter)
      });
    }
    
    return oldSend.call(this, data);
  };
  
  next();
};

// 计算重试时间
const calculateRetryAfter = (rateLimiter) => {
  const tokensNeeded = 1; // 需要1个令牌才能继续
  const deficit = tokensNeeded - rateLimiter.tokens;
  
  if (deficit <= 0) return 0;
  
  return Math.ceil(deficit / rateLimiter.refillRate);
};

5. 分页技术

5.1 偏移分页 vs 游标分页

分页是处理大型数据集的关键技术,不同的分页策略适用于不同的场景。

对比分析

graph LR
    A[分页技术选择] --> B{数据集特征}
    
    B --> C[小型数据集<br/>随机访问需求<br/>简单实现]
    B --> D[大型数据集<br/>顺序访问<br/>性能要求高]
    
    C --> E[偏移分页 Offset-based]
    D --> F[游标分页 Cursor-based]
    
    E --> G[优点: 实现简单, 支持随机跳页]
    E --> H[缺点: 性能问题, 数据一致性风险]
    
    F --> I[优点: 高性能, 数据一致性]
    F --> J[缺点: 实现复杂, 不支持随机访问]
    
    style E fill:#90EE90
    style F fill:#90EE90
    style G fill:#ADD8E6
    style H fill:#FFB6C1
    style I fill:#ADD8E6  
    style J fill:#FFB6C1

5.2 偏移分页实现

偏移分页是最传统和广泛使用的分页方法,适用于大多数中小型数据集。

// 偏移分页实现
const getUsersWithOffset = async (page = 1, limit = 10, filters = {}) => {
  const offset = (page - 1) * limit;
  
  // 获取总数
  const countQuery = UserModel.query()
    .where(filters)
    .count('* as total');
  
  // 获取分页数据
  const dataQuery = UserModel.query()
    .where(filters)
    .orderBy('created_at', 'desc')
    .offset(offset)
    .limit(limit);
  
  const [totalResult, users] = await Promise.all([countQuery, dataQuery]);
  const total = parseInt(totalResult[0].total, 10);
  const totalPages = Math.ceil(total / limit);
  
  return {
    data: users,
    pagination: {
      current_page: page,
      per_page: limit,
      total: total,
      total_pages: totalPages,
      has_prev: page > 1,
      has_next: page < totalPages,
      prev_page: page > 1 ? page - 1 : null,
      next_page: page < totalPages ? page + 1 : null
    }
  };
};

// 使用示例
app.get('/users', async (req, res) => {
  try {
    const page = parseInt(req.query.page, 10) || 1;
    const limit = parseInt(req.query.limit, 10) || 10;
    const filters = parseFilters(req.query);
    
    const result = await getUsersWithOffset(page, limit, filters);
    
    res.json({
      data: result.data,
      meta: {
        pagination: result.pagination,
        filters: filters
      },
      links: generatePaginationLinks(req, result.pagination)
    });
  } catch (error) {
    res.status(500).json({ error: '获取用户列表失败' });
  }
});

// 生成分页链接
const generatePaginationLinks = (req, pagination) => {
  const baseUrl = `${req.protocol}://${req.get('host')}${req.path}`;
  const queryParams = new URLSearchParams(req.query);
  
  const links = {
    first: `${baseUrl}?${queryParams.toString()}`,
    last: null,
    prev: null,
    next: null
  };
  
  if (pagination.has_prev) {
    queryParams.set('page', pagination.prev_page);
    links.prev = `${baseUrl}?${queryParams.toString()}`;
  }
  
  if (pagination.has_next) {
    queryParams.set('page', pagination.next_page);
    links.next = `${baseUrl}?${queryParams.toString()}`;
  }
  
  queryParams.set('page', pagination.total_pages);
  links.last = `${baseUrl}?${queryParams.toString()}`;
  
  return links;
};

5.3 游标分页实现

游标分页解决了偏移分页的性能和数据一致性问题,特别适合大型数据集和无限滚动场景。

// 游标分页实现
const getUsersWithCursor = async (cursor = null, limit = 10, direction = 'after', filters = {}) => {
  let query = UserModel.query()
    .where(filters)
    .orderBy('created_at', 'desc')
    .orderBy('id', 'desc'); // 二级排序确保唯一性
  
  // 应用游标条件
  if (cursor) {
    const cursorValue = await decodeCursor(cursor);
    
    if (direction === 'after') {
      query = query.where('created_at', '<', cursorValue.created_at)
                   .orWhere(function() {
                     this.where('created_at', '=', cursorValue.created_at)
                         .where('id', '<', cursorValue.id);
                   });
    } else if (direction === 'before') {
      query = query.where('created_at', '>', cursorValue.created_at)
                   .orWhere(function() {
                     this.where('created_at', '=', cursorValue.created_at)
                         .where('id', '>', cursorValue.id);
                   });
    }
  }
  
  // 获取limit+1条记录来判断是否有更多数据
  const users = await query.limit(limit + 1);
  
  const hasMore = users.length > limit;
  const items = hasMore ? users.slice(0, limit) : users;
  
  // 生成游标
  let startCursor = null;
  let endCursor = null;
  
  if (items.length > 0) {
    const firstItem = items[0];
    const lastItem = items[items.length - 1];
    
    startCursor = await encodeCursor({
      created_at: firstItem.created_at,
      id: firstItem.id
    });
    
    endCursor = await encodeCursor({
      created_at: lastItem.created_at,
      id: lastItem.id
    });
  }
  
  return {
    items,
    pageInfo: {
      hasNextPage: hasMore,
      hasPreviousPage: cursor !== null,
      startCursor,
      endCursor
    }
  };
};

// 游标编码解码(使用Base64)
const encodeCursor = async (cursorObject) => {
  return Buffer.from(JSON.stringify(cursorObject)).toString('base64');
};

const decodeCursor = async (cursorString) => {
  return JSON.parse(Buffer.from(cursorString, 'base64').toString());
};

// 使用示例
app.get('/users/cursor', async (req, res) => {
  try {
    const { after, before, first = 10, last } = req.query;
    const filters = parseFilters(req.query);
    
    let result;
    if (after) {
      result = await getUsersWithCursor(after, parseInt(first), 'after', filters);
    } else if (before) {
      result = await getUsersWithCursor(before, parseInt(last), 'before', filters);
    } else {
      result = await getUsersWithCursor(null, parseInt(first), 'after', filters);
    }
    
    res.json({
      data: result.items,
      pageInfo: result.pageInfo,
      filters
    });
  } catch (error) {
    res.status(400).json({ error: '无效的游标参数' });
  }
});

5.4 分页策略选择指南

选择合适的分页策略需要考虑多个因素:

// 分页策略选择函数
const choosePaginationStrategy = (datasetCharacteristics) => {
  const {
    totalSize,
    updateFrequency,
    accessPattern,
    consistencyRequirements,
    performanceNeeds
  } = datasetCharacteristics;
  
  if (totalSize < 1000 && updateFrequency === 'low') {
    return {
      strategy: 'offset',
      reason: '小型数据集,偏移分页简单有效',
      recommendations: [
        '使用标准的page/limit参数',
        '提供总数和总页数信息',
        '包含导航链接'
      ]
    };
  }
  
  if (totalSize > 10000 || updateFrequency === 'high') {
    return {
      strategy: 'cursor',
      reason: '大型或频繁更新的数据集,游标分页性能更佳',
      recommendations: [
        '使用after/before游标参数',
        '基于时间戳或序列ID创建游标',
        '提供hasNextPage/hasPreviousPage信息'
      ]
    };
  }
  
  if (accessPattern === 'random') {
    return {
      strategy: 'offset',
      reason: '需要随机页面访问功能',
      recommendations: [
        '实现缓存机制提高性能',
        '考虑使用覆盖索引优化查询'
      ]
    };
  }
  
  return {
    strategy: 'cursor',
    reason: '默认推荐游标分页以获得更好性能',
    recommendations: [
      '使用稳定的排序字段',
      '限制每页最大项目数',
      '提供清晰的文档说明'
    ]
  };
};

// 使用示例
const datasetAnalysis = {
  totalSize: 50000,
  updateFrequency: 'high',
  accessPattern: 'sequential',
  consistencyRequirements: 'high',
  performanceNeeds: 'low_latency'
};

const strategy = choosePaginationStrategy(datasetAnalysis);
console.log(`推荐分页策略: ${strategy.strategy}`);

6. 幂等性设计

6.1 幂等性原理与重要性

幂等性是分布式系统中保证数据一致性的关键概念,确保同一请求多次执行与单次执行效果相同。

幂等性使用场景

  • 支付处理系统
  • 订单创建和更新
  • 资源分配操作
  • 任何可能重试的写操作

6.2 幂等性实现模式

::: tabs

@tab 基于令牌的幂等性

// 幂等性令牌服务
class IdempotencyService {
  constructor(redisClient) {
    this.redis = redisClient;
    this.defaultTtl = 24 * 60 * 60; // 24小时
  }
  
  // 生成幂等性密钥
  generateKey() {
    return require('crypto').randomUUID();
  }
  
  // 检查并记录请求
  async checkAndRecord(key, requestHash, ttl = this.defaultTtl) {
    const redisKey = `idempotency:${key}`;
    
    // 使用Redis事务确保原子性
    const multi = this.redis.multi();
    
    // 检查是否已存在
    multi.get(redisKey);
    
    // 设置新键(如果不存在)
    multi.setnx(redisKey, JSON.stringify({
      status: 'processing',
      request_hash: requestHash,
      created_at: new Date().toISOString()
    }));
    
    // 设置过期时间
    multi.expire(redisKey, ttl);
    
    const results = await multi.exec();
    const existingRecord = results[0][1];
    
    if (existingRecord) {
      const record = JSON.parse(existingRecord);
      
      // 检查请求哈希是否匹配
      if (record.request_hash !== requestHash) {
        throw new Error('幂等性密钥已用于不同请求');
      }
      
      return { exists: true, record };
    }
    
    return { exists: false };
  }
  
  // 保存成功响应
  async saveResponse(key, response, statusCode, ttl = this.defaultTtl) {
    const redisKey = `idempotency:${key}`;
    const record = {
      status: 'completed',
      response: response,
      status_code: statusCode,
      completed_at: new Date().toISOString()
    };
    
    await this.redis.setex(redisKey, ttl, JSON.stringify(record));
  }
  
  // 保存错误信息
  async saveError(key, error, ttl = this.defaultTtl) {
    const redisKey = `idempotency:${key}`;
    const record = {
      status: 'error',
      error: error.message,
      failed_at: new Date().toISOString()
    };
    
    await this.redis.setex(redisKey, ttl, JSON.stringify(record));
  }
}

// 幂等性中间件
const idempotencyMiddleware = (redisClient) => {
  const idempotencyService = new IdempotencyService(redisClient);
  
  return async (req, res, next) => {
    // 只对幂等方法应用幂等性
    const idempotentMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
    if (!idempotentMethods.includes(req.method)) {
      return next();
    }
    
    const idempotencyKey = req.get('Idempotency-Key');
    if (!idempotencyKey) {
      return next();
    }
    
    // 生成请求哈希(排除某些可能变化的头信息)
    const requestHash = createRequestHash(req);
    
    try {
      const { exists, record } = await idempotencyService.checkAndRecord(
        idempotencyKey, 
        requestHash
      );
      
      if (exists) {
        if (record.status === 'completed') {
          // 返回缓存的成功响应
          return res.status(record.status_code).json(record.response);
        } else if (record.status === 'error') {
          // 返回缓存的错误
          return res.status(500).json({ error: record.error });
        } else if (record.status === 'processing') {
          // 请求正在处理中,返回重试提示
          return res.status(409).json({ 
            error: '请求正在处理中,请稍后重试',
            retry_after: 5 // 5秒后重试
          });
        }
      }
      
      // 存储原始send方法
      const originalSend = res.send;
      const originalJson = res.json;
      
      // 拦截响应以缓存结果
      res.json = function(body) {
        idempotencyService.saveResponse(idempotencyKey, body, res.statusCode)
          .catch(console.error);
        return originalJson.call(this, body);
      };
      
      res.send = function(body) {
        idempotencyService.saveResponse(idempotencyKey, body, res.statusCode)
          .catch(console.error);
        return originalSend.call(this, body);
      };
      
      next();
    } catch (error) {
      if (error.message === '幂等性密钥已用于不同请求') {
        return res.status(422).json({ 
          error: '幂等性密钥已用于不同请求内容' 
        });
      }
      
      console.error('幂等性处理错误:', error);
      next(); // 出错时继续处理请求
    }
  };
};

// 创建请求哈希
const createRequestHash = (req) => {
  const hash = require('crypto').createHash('sha256');
  
  // 包含方法、路径和请求体
  hash.update(req.method);
  hash.update(req.path);
  hash.update(JSON.stringify(req.body || {}));
  
  return hash.digest('hex');
};

@tab 数据库约束幂等性

// 基于数据库唯一约束的幂等性
class DatabaseIdempotency {
  constructor(db) {
    this.db = db;
  }
  
  async ensureIdempotencyTable() {
    await this.db.schema.createTableIfNotExists('idempotency_keys', (table) => {
      table.string('key').primary();
      table.string('request_hash').notNullable();
      table.string('resource_type').notNullable();
      table.string('resource_id').nullable();
      table.string('status').notNullable();
      table.json('response').nullable();
      table.timestamp('created_at').defaultTo(this.db.fn.now());
      table.timestamp('updated_at').defaultTo(this.db.fn.now());
    });
  }
  
  async processWithIdempotency(key, requestHash, resourceType, operation) {
    return await this.db.transaction(async (trx) => {
      try {
        // 检查现有记录
        const existing = await trx('idempotency_keys')
          .where('key', key)
          .first();
        
        if (existing) {
          if (existing.request_hash !== requestHash) {
            throw new Error('IDEMPOTENCY_KEY_CONFLICT');
          }
          
          if (existing.status === 'completed') {
            return {
              idempotent: true,
              response: existing.response
            };
          }
          
          throw new Error('REQUEST_IN_PROGRESS');
        }
        
        // 插入新记录
        await trx('idempotency_keys').insert({
          key,
          request_hash: requestHash,
          resource_type: resourceType,
          status: 'processing'
        });
        
        // 执行实际操作
        const result = await operation(trx);
        
        // 更新记录状态
        await trx('idempotency_keys')
          .where('key', key)
          .update({
            status: 'completed',
            response: result,
            resource_id: result.id,
            updated_at: this.db.fn.now()
          });
        
        return {
          idempotent: false,
          response: result
        };
        
      } catch (error) {
        await trx('idempotency_keys')
          .where('key', key)
          .update({
            status: 'error',
            error: error.message,
            updated_at: this.db.fn.now()
          });
        
        throw error;
      }
    });
  }
}

// 使用示例
app.post('/payments', async (req, res) => {
  const idempotencyKey = req.get('Idempotency-Key');
  const requestHash = createRequestHash(req);
  
  try {
    const dbIdempotency = new DatabaseIdempotency(db);
    
    const result = await dbIdempotency.processWithIdempotency(
      idempotencyKey,
      requestHash,
      'payment',
      async (trx) => {
        // 实际的支付处理逻辑
        const payment = await processPayment(req.body, trx);
        return payment;
      }
    );
    
    if (result.idempotent) {
      res.status(200).json(result.response);
    } else {
      res.status(201).json(result.response);
    }
    
  } catch (error) {
    if (error.message === 'IDEMPOTENCY_KEY_CONFLICT') {
      res.status(422).json({ error: '幂等性密钥冲突' });
    } else if (error.message === 'REQUEST_IN_PROGRESS') {
      res.status(409).json({ error: '请求处理中' });
    } else {
      res.status(500).json({ error: '支付处理失败' });
    }
  }
});

:::

6.3 幂等性最佳实践

// 幂等性配置管理
class IdempotencyConfig {
  constructor() {
    this.config = {
      // 默认TTL(秒)
      defaultTtl: 86400, // 24小时
      
      // 不同资源的TTL配置
      resourceTtl: {
        payment: 172800, // 48小时
        order: 259200,   // 72小时
        user: 31536000   // 1年
      },
      
      // 需要幂等性的端点
      endpoints: {
        '/api/payments': {
          required: true,
          methods: ['POST', 'PUT'],
          ttl: 172800
        },
        '/api/orders': {
          required: true,
          methods: ['POST'],
          ttl: 259200
        },
        '/api/users': {
          required: false,
          methods: ['POST']
        }
      },
      
      // 密钥格式验证
      keyValidation: {
        pattern: /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i,
        maxLength: 36
      }
    };
  }
  
  // 验证幂等性密钥格式
  validateKey(key) {
    if (!key) return { valid: false, reason: '密钥不能为空' };
    if (key.length > this.config.keyValidation.maxLength) {
      return { valid: false, reason: '密钥长度超出限制' };
    }
    if (!this.config.keyValidation.pattern.test(key)) {
      return { valid: false, reason: '密钥格式无效' };
    }
    return { valid: true };
  }
  
  // 获取资源的TTL
  getTtlForResource(resourceType) {
    return this.config.resourceTtl[resourceType] || this.config.defaultTtl;
  }
  
  // 检查端点是否需要幂等性
  isIdempotencyRequired(path, method) {
    const endpointConfig = this.config.endpoints[path];
    return endpointConfig && 
           endpointConfig.required && 
           endpointConfig.methods.includes(method);
  }
}

// 完整的幂等性处理流程
const completeIdempotencyHandler = (req, res, next) => {
  const config = new IdempotencyConfig();
  
  // 检查是否需要幂等性处理
  if (!config.isIdempotencyRequired(req.path, req.method)) {
    return next();
  }
  
  const idempotencyKey = req.get('Idempotency-Key');
  
  // 验证密钥格式
  const validation = config.validateKey(idempotencyKey);
  if (!validation.valid) {
    return res.status(400).json({ 
      error: `无效的幂等性密钥: ${validation.reason}` 
    });
  }
  
  // 实际的幂等性处理逻辑
  // ...(使用前面展示的令牌或数据库实现)
  
  next();
};

7. 过滤与排序

7.1 高级过滤实现

过滤功能让客户端能够精确获取所需数据,减少不必要的数据传输。

// 高级过滤解析器
class AdvancedFilterParser {
  constructor(allowedFilters) {
    this.allowedFilters = allowedFilters;
    this.operators = {
      eq: '=',
      ne: '!=',
      gt: '>',
      gte: '>=',
      lt: '<',
      lte: '<=',
      like: 'LIKE',
      in: 'IN',
      between: 'BETWEEN'
    };
  }
  
  // 解析查询参数
  parse(queryParams) {
    const filters = {};
    
    for (const [key, value] of Object.entries(queryParams)) {
      if (!this.allowedFilters.includes(key)) continue;
      
      // 支持多种格式:field[operator]=value
      const match = key.match(/^(\w+)(?:\[(\w+)\])?$/);
      if (!match) continue;
      
      const field = match[1];
      const operator = match[2] || 'eq';
      
      if (!this.operators[operator]) {
        throw new Error(`不支持的运算符: ${operator}`);
      }
      
      if (!filters[field]) {
        filters[field] = [];
      }
      
      filters[field].push({
        operator: this.operators[operator],
        value: this.parseValue(value, operator)
      });
    }
    
    return filters;
  }
  
  // 解析值(根据运算符处理不同类型)
  parseValue(value, operator) {
    switch (operator) {
      case 'in':
        return Array.isArray(value) ? value : value.split(',');
      
      case 'between':
        const values = Array.isArray(value) ? value : value.split(',');
        if (values.length !== 2) {
          throw new Error('BETWEEN操作需要两个值');
        }
        return values;
      
      case 'like':
        return `%${value}%`;
      
      default:
        return value;
    }
  }
  
  // 构建数据库查询
  buildQuery(queryBuilder, filters) {
    for (const [field, conditions] of Object.entries(filters)) {
      conditions.forEach(condition => {
        const { operator, value } = condition;
        
        switch (operator) {
          case 'IN':
            queryBuilder.whereIn(field, value);
            break;
          
          case 'BETWEEN':
            queryBuilder.whereBetween(field, value);
            break;
          
          case 'LIKE':
            queryBuilder.where(field, 'LIKE', value);
            break;
          
          default:
            queryBuilder.where(field, operator, value);
        }
      });
    }
    
    return queryBuilder;
  }
}

// 使用示例
const allowedFilters = ['name', 'email', 'age', 'created_at', 'status'];
const filterParser = new AdvancedFilterParser(allowedFilters);

app.get('/users/advanced', async (req, res) => {
  try {
    const filters = filterParser.parse(req.query);
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    
    let query = UserModel.query();
    
    // 应用过滤
    filterParser.buildQuery(query, filters);
    
    // 应用分页
    const result = await query
      .page(page - 1, limit)
      .orderBy('created_at', 'desc');
    
    res.json({
      data: result.results,
      pagination: {
        page,
        limit,
        total: result.total,
        pages: Math.ceil(result.total / limit)
      },
      filters: filters
    });
    
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

7.2 智能排序实现

排序功能让客户端能够以有意义的方式组织数据。

// 高级排序解析器
class AdvancedSortParser {
  constructor(allowedSortFields, defaultSort) {
    this.allowedSortFields = allowedSortFields;
    this.defaultSort = defaultSort;
  }
  
  // 解析排序参数
  parse(sortParam) {
    if (!sortParam) return [this.defaultSort];
    
    const sortRules = [];
    const sortItems = Array.isArray(sortParam) ? sortParam : sortParam.split(',');
    
    for (const item of sortItems) {
      let field = item;
      let direction = 'asc';
      
      // 支持格式:field:desc, field:asc
      if (item.includes(':')) {
        [field, direction] = item.split(':');
      }
      
      // 验证字段是否允许排序
      if (!this.allowedSortFields.includes(field)) {
        throw new Error(`不允许排序的字段: ${field}`);
      }
      
      // 验证排序方向
      if (direction !== 'asc' && direction !== 'desc') {
        throw new Error(`无效的排序方向: ${direction}`);
      }
      
      sortRules.push({ field, direction });
    }
    
    return sortRules.length > 0 ? sortRules : [this.defaultSort];
  }
  
  // 构建排序查询
  buildQuery(queryBuilder, sortRules) {
    sortRules.forEach(({ field, direction }) => {
      queryBuilder.orderBy(field, direction);
    });
    
    return queryBuilder;
  }
}

// 使用示例
const allowedSortFields = ['name', 'email', 'age', 'created_at', 'updated_at'];
const defaultSort = { field: 'created_at', direction: 'desc' };
const sortParser = new AdvancedSortParser(allowedSortFields, defaultSort);

// 在路由中使用
app.get('/users/sorted', async (req, res) => {
  try {
    const sortRules = sortParser.parse(req.query.sort);
    const filters = filterParser.parse(req.query);
    
    let query = UserModel.query();
    
    // 应用过滤和排序
    filterParser.buildQuery(query, filters);
    sortParser.buildQuery(query, sortRules);
    
    const result = await query;
    
    res.json({
      data: result,
      sorting: sortRules,
      filters: filters
    });
    
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

7.3 字段选择与性能优化

字段选择允许客户端指定需要返回的字段,减少不必要的数据传输。

// 字段选择器
class FieldSelector {
  constructor(allowedFields, defaultFields) {
    this.allowedFields = allowedFields;
    this.defaultFields = defaultFields;
  }
  
  // 解析字段选择参数
  parse(fieldsParam) {
    if (!fieldsParam) return this.defaultFields;
    
    const requestedFields = Array.isArray(fieldsParam) ? 
      fieldsParam : fieldsParam.split(',');
    
    // 验证字段是否允许
    const invalidFields = requestedFields.filter(
      field => !this.allowedFields.includes(field)
    );
    
    if (invalidFields.length > 0) {
      throw new Error(`不允许的字段: ${invalidFields.join(', ')}`);
    }
    
    return requestedFields;
  }
  
  // 构建选择查询
  buildQuery(queryBuilder, fields) {
    return queryBuilder.select(fields);
  }
}

// 完整的查询构建器
class QueryBuilder {
  constructor() {
    this.filterParser = new AdvancedFilterParser(['name', 'email', 'age', 'status']);
    this.sortParser = new AdvancedSortParser(['name', 'created_at'], 
      { field: 'created_at', direction: 'desc' });
    this.fieldSelector = new FieldSelector(
      ['id', 'name', 'email', 'age', 'created_at'],
      ['id', 'name', 'email']
    );
  }
  
  // 构建完整查询
  build(req) {
    return async (model) => {
      let query = model.query();
      
      try {
        // 解析查询参数
        const filters = this.filterParser.parse(req.query);
        const sortRules = this.sortParser.parse(req.query.sort);
        const fields = this.fieldSelector.parse(req.query.fields);
        
        // 应用过滤、排序和字段选择
        this.filterParser.buildQuery(query, filters);
        this.sortParser.buildQuery(query, sortRules);
        this.fieldSelector.buildQuery(query, fields);
        
        return query;
        
      } catch (error) {
        throw error;
      }
    };
  }
}

// 使用完整查询构建器
app.get('/users/optimized', async (req, res) => {
  try {
    const queryBuilder = new QueryBuilder();
    const query = await queryBuilder.build(req)(UserModel);
    
    const result = await query;
    
    res.json({
      data: result,
      meta: {
        count: result.length,
        // 包含使用的过滤、排序和字段信息
        query: {
          filters: queryBuilder.filterParser.parse(req.query),
          sorting: queryBuilder.sortParser.parse(req.query.sort),
          fields: queryBuilder.fieldSelector.parse(req.query.fields)
        }
      }
    });
    
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

总结

  1. REST基础原理:采用资源导向的设计理念,充分利用HTTP协议的语义化特性,构建可预测、易理解的API接口
  2. 错误处理机制:标准化错误响应格式,合理使用HTTP状态码,提供清晰的问题诊断信息
  3. API版本控制:通过多种版本控制策略确保向后兼容性,平滑处理API演进和破坏性变更
  4. 速率限制:采用分层限制策略保护系统资源,防止滥用同时保证合法用户的访问体验
  5. 分页技术:根据数据集特性选择合适的

原文:xuanhu.info/projects/it…

昨天 — 2025年9月18日首页

基于 Vue3@3.5+跟Ant Design of Vue 的二次封装的 Form跟搜索Table

2025年9月18日 17:02

二次封装的Form跟搜索Table

为啥想二次封装组件

作为一名刚入职新公司的前端开发者,我开局就面临 “双重挑战”:一方面,公司此前所有子系统基于 Vue2 开发,而团队希望我用 Vue3 搭建新业务模块,但给到的只有一个 “裸奔” 的种子工程 —— 没有任何封装好的通用组件,所有功能都得从零手撸;另一方面,我此前都是 React 技术栈,面试也是面的React的,说来也是干React,结果一拉gitlab仓库干的是vue的活,哈哈,对 Vue 生态只能算 “入门级玩家”,不是太深入的了解跟使用。

不过,受原公司主管理念的影响,原来一直吃的都是细糠,现在轮到自己起灶了,做好不好吃那也得吃。我还是决定先从高频场景入手:封装基于 ant-design-vue 的 JsonForm(JSON 配置化表单) 和 可搜索 Table。这么做有三个核心原因:

  1. 提效减重复:分页、表单校验、状态收集这些逻辑,不用每次开发都写一遍,后续维护也不用逐个模块改;

  2. 练手 Vue3:借封装过程熟悉 Vue3 的响应式、组合式 API、组件通信等核心能力,快速补齐技术短板;

  3. 公司水太深,适合摸鱼!?

JsonForm

网上其实有很多 JSON 配置化表单的二次封装方案,核心思路都大同小异 ——通过 “组件类型枚举匹配” 实现动态渲染。我这套方案也基于这个思路,但针对 Vue3 特性和实际业务场景做了适配优化。

核心实现:组件映射与增强(componentsMap)

JsonForm 的核心是 componentsMap 对象:它定义了 “配置类型” 与 “ant-design-vue 组件” 的映射关系,同时对部分组件做了默认属性注入和功能增强(比如支持异步加载选项、适配 Vue3 v-model 绑定)。

  1. 部分 componentsMap 代码
export const componentsMap = {
  Text,
  Time,
  Textarea,
  InputNumber,
  DatePicker,
  Input,
  RangePicker,
  // 扩展options可以支持异步获取,返回一个promise进行处理options数据结构
  Cascader: extendComponentsOptions(Cascader, {
    allowClear: true,
    showSearch: true,
    getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode,
  }),
  // 扩展options可以支持异步获取,返回一个promise进行处理options数据结构
  TreeSelect: extendComponentsOptions(TreeSelect, {
    allowClear: true,
    showSearch: true,
    getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode,
    filterTreeNode: (inputValue: string, { label }: any) =>
      label.indexOf(inputValue) !== -1,
  }),
  // 扩展options可以支持异步获取,返回一个promise进行处理options数据结构
  Select: extendComponentsOptions(Select, {
    allowClear: true,
    showSearch: true,
    getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode,
  }),
  CheckboxGroup: extendComponentsOptions(CheckboxGroup),
  RadioGroup: extendComponentsOptions(RadioGroup),
  Checkbox: transformBinding(Checkbox), // 处理 v-model:checked 绑定
  Switch: transformBinding(Switch), // 处理 v-model:checked 绑定
  Radio: transformBinding(Radio), // 处理 v-model:checked 绑定
}

  1. 关键设计亮点
  • 默认属性注入:比如所有选择类组件(Select/TreeSelect/Cascader)默认开启 showSearch(搜索)和 allowClear(清空),不用每次配置都写这两个属性;
  • 异步选项支持:getOptions: () => Promise(如从接口拉取部门列表),组件内部自动处理加载状态;
  • 依赖项显隐:isShow可以支持自定义场景下显示隐藏,也可以支持高级配置根据表单项内依赖值进行显示隐藏

配套资源:在线文档与示例

为了方便团队使用,用vitepress框架搭建了配套的在线文档,包含所有配置项说明、组件用法示例和常见问题解答:

image.png

在线文档地址:wangxuelina.github.io/general_ant…

四、后续规划

  1. 新增组件:支持在表单上传按钮组件、富文本编辑器等

  2. 上线npm包进行引用

Element UI 2.X 主题定制完整指南:解决官方工具失效的实战方案

作者 前端大鱼
2025年9月18日 16:44

基于Vue2+Element UI 2.X项目的主题换肤实战经验分享

背景与需求

最近在开发Vue2项目的换肤功能时,遇到了一个典型需求:除了变更自定义vue组件的主题相关CSS变量外,还需要同步更改Element UI 2.X组件的主题色。然而在实际操作过程中,发现官方提供的主题定制工具存在各种问题,给开发工作带来了不小困扰。

本文将分享两种经过验证的有效方法,帮助你顺利生成完整的Element UI 2.X CSS主题文件,实现完美的主题换肤效果。

问题分析

1. 官方在线主题编辑器服务不可用

Element UI官方主题编辑器(element.eleme.io/#/zh-CN/the…

2. 命令行工具问题频出

使用element-theme命令行工具时,各种安装报错和依赖问题屡见不鲜,即使更换Node版本也难以解决。

解决方案一:使用在线编辑器替代方案

这种方法适合需要快速生成主题的开发者,无需搭建本地环境。

操作步骤:

  1. 访问在线主题生成工具

    打开浏览器,访问 elementui.github.io/theme-chalk…

  1. 定制主题颜色

    • 点击右上角的"切换主题色"按钮
    • 选择或输入你需要的主题主色调
    • 实时预览效果,确保满足需求
  2. 获取主题文件

    • 点击"下载主题"按钮,获取ZIP压缩包
    • 解压后发现只有字体文件,没有CSS文件
  3. 提取CSS代码

    • 按F12打开开发者工具
    • 切换到"元素"标签页
    • <head>标签下找到<style>元素
    • 复制其中的压缩CSS代码

  1. 创建CSS文件
    • 将复制的代码保存为CSS文件(如element-ui.theme.css
    • 注意调整字体文件的引用路径,确保与实际文件位置匹配

解决方案二:通过源码编译定制主题(推荐)

这种方法适合需要深度定制的项目,可以修改所有样式变量,也是更稳定的方案。

环境准备与操作步骤:

  1. 获取Element UI源码

    # 克隆Element UI官方仓库
    git clone https://github.com/ElemeFE/element.git  
    # 进入项目目录
    cd element
    
  2. 安装项目依赖

    # 安装所有必要依赖
    npm install
    
    # 如果遇到node-sass问题,可以尝试使用镜像源
    npm install --sass-binary-site=https://npm.taobao.org/mirrors/node-sass
    
  3. 自定义主题变量

    • 打开文件:packages/theme-chalk/src/common/var.scss
    • 修改主要颜色变量:
    // 修改为主题色
    $--color-primary: #0080fe !default;
    
    // 可以继续修改其他变量
    $--color-success: #67c23a !default;
    $--color-warning: #e6a23c !default;
    $--color-danger: #f56c6c !default;
    $--color-text-primary: #303133 !default;
    $--color-text-regular: #606266 !default;
    
  4. 编译主题

    # 执行编译命令
    npm run build:theme
    
  5. 获取生成的文件

    • 编译完成后,在packages/theme-chalk/lib/目录下:
      • index.css - 完整的CSS样式文件
      • fonts/ - 相关字体文件
    • 将CSS文件和字体文件一并复制到你的项目中

高级定制技巧

除了修改主色调,你还可以定制其他样式变量,实现更精细的主题控制:

// 修改字体路径
$--font-path: 'element-ui/fonts' !default;

// 修改边框颜色和圆角
$--border-color-base: #dcdfe6 !default;
$--border-color-light: #e4e7ed !default;
$--border-color-lighter: #ebeef5 !default;
$--border-radius-base: 4px !default;
$--border-radius-small: 2px !default;

// 修改背景色
$--background-color-base: #f5f7fa !default;

// 修改尺寸变量
$--size-base: 14px !default;
$--size-large: 16px !default;
$--size-small: 13px !default;

// 修改按钮样式
$--button-font-size: $--size-base !default;
$--button-border-radius: $--border-radius-base !default;
$--button-padding-vertical: 12px !default;
$--button-padding-horizontal: 20px !default;

// 修改输入框样式
$--input-font-size: $--size-base !default;
$--input-border-radius: $--border-radius-base !default;
$--input-border-color: $--border-color-base !default;
$--input-background-color: #FFFFFF !default;

注意事项与最佳实践

  1. 字体路径问题

    • 确保CSS中的字体路径与实际存放路径一致
    • 如果字体加载失败,图标将无法正常显示
    • 建议使用相对路径或CDN地址
  2. 版本兼容性

    • 确保使用的Element UI版本与主题版本匹配
    • 不同版本的变量名称可能有所差异
  3. 生产环境部署

    • 对CSS文件进行压缩,减少体积
    • 使用CDN加速字体文件的加载
    • 考虑将主题文件与主应用代码分离部署
  4. 性能优化

    • 实现主题懒加载,避免初始加载时间过长
    • 考虑使用CSS变量实现部分动态样式,减少CSS文件大小

总结

本文介绍了两种解决Element UI 2.X主题定制问题的方法:

  1. 在线编辑器替代方案 - 适合快速生成基本主题
  2. 源码编译方式 - 适合需要深度定制的项目(推荐)

希望本文能帮助你顺利完成Element UI的主题定制工作。如果你有任何问题或更好的解决方案,欢迎在评论区分享交流!


欢迎关注我的微信公众号【大前端历险记】,获取更多开发实用技巧和解决方案!

SSE 实现 AI 对话中的流式输出

作者 东坡白菜
2025年9月18日 15:14

SSE 实现 AI 对话中的流式输出

日常使用 deepseek,经常看到聊天机器人的流式输出,感觉很赞,想尝试下自己实现一个类似效果

各个大模型平台,api 调用都支持流式输出,如果用 node.js 如何实现一个流式输出效果呢

整体内容包含服务端实现客户端实现

整体效果

steamOut.gif

SSE 技术原理

Server-Sent Events (SSE) 是一种基于 HTTP 的服务器向客户端推送数据的技术。与 WebSocket 不同,SSE 是单向的,仅支持服务器向客户端推送数据,但实现简单,且天然支持断线重连。

  • 单向通信,由服务器发送数据,客户端接收数据
  • 自动重连
  • 轻量级,相比 websocket,开销更小
  • 主要传输文本数据,适合 JSON 等结构化数据
  • sse 的消息结构
    • id: 事件 id
    • event: 事件名称 如果不传默认为 message,如果设置其他名称,前端也需要修改成对应的名称,进行消息接受
    • data: 事件数据
    • retry: 重连时间

服务端实现

  • node.js 的 koa2 框架

实现步骤

  1. 设置 SSE 响应头 响应内容类型,客户端不缓存,保持长连接
  2. 防止 koa 自动处理响应,手动设置响应状态码为 200
  3. 定时发送消息模拟推流,通过ctx.res.write(data: msg),也可以传入id, event补充消息内容。
  4. 通过ctx.req.on(eventName, callback)进行连接监听,处理连接关闭,连接失败
aiRouer.get("/stream", (ctx) => {
  console.log("进入stream");

  // 设置SSE响应头
  ctx.set({
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
    "Access-Control-Allow-Origin": "*",
  });

  // 关键修复:防止Koa自动处理响应
  ctx.respond = false;
  ctx.status = 200;

  // 发送初始连接成功消息
  ctx.res.write(`data: ${JSON.stringify({ message: "Connected to Koa2 SSE server" })}\n\n`);

  // 设置定时器,定期发送消息
  let counter = 0;
  const timer = setInterval(() => {
    counter++;
    const data = {
      message: `Server time: ${new Date().toLocaleTimeString()}`,
      counter: counter,
    };

    // 检查连接是否仍然有效
    if (ctx.res.writable) {
      // 按照SSE格式发送数据
      ctx.res.write(`id: ${counter}\n`);
      ctx.res.write(`event: message\n`);
      ctx.res.write(`data: ${JSON.stringify(data)}\n\n`);

      if (counter > 10) {
        ctx.res.end();
      }
    } else {
      // 如果连接已关闭,清理资源
      clearInterval(timer);
      console.log("SSE connection closed due to client disconnect");
    }
  }, 1000);

  // 处理连接关闭
  ctx.req.on("close", () => {
    clearInterval(timer);
    console.log("SSE connection closed");
  });

  // 处理错误
  ctx.req.on("error", (err) => {
    clearInterval(timer);
    console.error("SSE connection error:", err);
  });
});

客户端实现

  • vue.js

实现步骤

  1. 触发 SSE 推送,创建一个 EventSource 对象,并监听服务端的推送消息。
  2. 通过 inputStr 接受消息,设置连接关闭条件,并结束定时器。
  3. 定时取数据,输出到outStr
<template>
  <div class="steam">
    <h3>服务端回答的消息</h3>
    <div ref="outRef" class="out-textarea" v-if="outStr">{{ outStr }}</div>
    <div>
      <n-button type="info" @click="onSend">发送</n-button>
    </div>
  </div>
</template>
import { NInput, NButton } from "naive-ui";
import { reactive, toRefs } from "vue";

const state = reactive({
  outRef: null,
  outStr: "",
  inputStr: "",
  inputFinish: false,
});
const { outRef, outStr, inputStr, inputFinish } = toRefs(state);

/**
 * 流式输出
 */
function outStream() {
  let i = 0;
  let timer = setInterval(() => {
    // 一直等待输出,直到服务端停止输出
    if (inputFinish.value && i >= inputStr.value.length) {
      clearInterval(timer);
      timer = null;
      return;
    }
    if (i < inputStr.value.length) {
      state.outStr += inputStr.value[i];
      i++;
    }
  }, 100);
}
function onSend() {
  let eventSource = new EventSource("/api/ai/stream");
  inputStr.value = "";
  outStr.value = "";
  inputFinish.value = false;
  eventSource.onmessage = function (e) {
    const data = JSON.parse(e.data);
    inputStr.value += data.message;
    if (data.counter > 10) {
      eventSource.close();
      eventSource = null;
      inputFinish.value = true;
    }
  };
  outStream();
}
.out-textarea {
  display: inline-block;
  background-color: rgba(0, 0, 0, 0.06);
  padding: 12px 16px;
  border-radius: 8px;
  position: relative;
  box-sizing: border-box;
  min-width: 0;
  max-width: 100%;
  color: rgba(0, 0, 0, 0.88);
  font-size: 14px;
  line-height: 1.5714285714285714;
  min-height: 46px;
  word-break: break-word;
  margin-top: 24px;
  margin-bottom: 24px;
  scrollbar-color: rgba(0, 0, 0, 0.45) transparent;
}

ai 组件库

  • 目前大厂都有成熟的 ai 组件库,其中就包括对话流式输出组件
  • antd-design-x-vue 对话气泡框
  • RICH 设计范式思考
    • Role 【角色】以后产品和人交互,更像是一个人。可以通过角色外观,声音,情绪,专业领域知识
    • Intention 【意图】 以前收集用户需求,通过输入框,按钮,鼠标,触摸动作 以后 ai 会做的更多,比如通过对话、语音、结合少量原先图形化交互、更加准确和简单
    • Conversation 【对话】人与 ai 的会话规则 开始/追问/提示/确认/错误/结束
    • Hybrid UI 【混合界面】 Do 为主/Do + Chat 均衡/Chat 为主

前端面向对象设计原则运用 - 策略模式

作者 猩兵哥哥
2025年9月18日 11:19

一、业务需求

    公司因业务发展的需要,需自主研发云管控台,在实现以往云管控台的功能的基础上对架构和业务逻辑进行优化,使其更加符合公司的实际业务、运维可控、更新迭代更及时。

    作为本项目的前端开发,我主要负责云资源从申请到开通的全部功能。其中资源申请部分最为复杂,需要实现ECS、EVS、EIP、NAT、各种数据库等十几种云产品的申请,若各个页面单独完成,由使用的地方自己引入会显得比较杂乱,通常会用到长串的if-else。另外,各种云产品的申请页面均不相同,但又有部分类似,类似的部分每个页面都写一份,对于后续的维护将是灾难。

二、需求分析

    通过业务需求,可以看出这部分业务主要存在两个痛点,一是有十几种产品,需要对外提供统一的接入方式;二是不同的产品之间差异比较大,但是又有部分类似之处。

    对于痛点一,直接使用策略模式即可,将所有产品封装在一个Content类中,由这个类统一向外提供服务即可。

    痛点二则相对复杂,需要仔细分析不同产品之间的相同和不同之处,将一个产品需要用到的所有功能拆分为尽可能小的可复用的功能模块,这些功能模块要尽可能满足单一职责原则。经过仔细分析和拆解,将资源申请需要使用到的功能模块差分为

  • Region:产品所属区域以及可用区组件
  • Spec: 产品规格组件
  • SystemDisk:系统盘组件
  • DataDisk:数据盘组件
  • Image:服务器镜像组件
  • Network:网络模块组件,包含了VPC、子网、安全组
  • Name:资源名称组件
  • Size:大小组件,例如EIP的带宽,EVS的存储大小等
  • password:密码组件

    部分产品需聚合的组件如下:

产品 组件组合
ECS Region、Spec、SystemDisk、DataDisk、Image、Network、Name、password
EVS Region、Spec、Size
RDS Region、Spec、Name、Size、Network
Redis Region、Spec、Name、Network、password

    不同的组件各自实现自己独有的功能,例如 Region 组件内部实现区域和可用区的查询、展示、和区域可用区选择后的双向绑定;Spec 组件实现查询当前产品的所有规格,并处理成可在页面显示选择的样式;Network 组件实现可用VPC、子网、安全组的查询、显示、选择后的数据绑定等。

三、结构设计与详细设计

    按(二)中的分析结果,可以得到此部分的设计类图。如下: image.png

  • Content: 聚合了 ResourceStrategy , 通过接收 category (产品类别)来调用不同的产品申请页面,实现根据不同产品选择不同页面,并向外部提供了getOrderData() 方法,供外部获取当前产品选择的配置订单信息;
  • ResourceStrategy: 实际并不存在,因为vue中没有组件实现接口这种操作,这算是一种约定,所有的产品都必须实现 getOrderData() 方法,这是方便 Content 调用;
  • FormComponent: 实际也并不存在,就是规定了 Region、Spec、SystemDisk 等各个小模块必须要实现validate() 方法,这个方法是用于验证当前模块数据是否可用
  • Region、Spec等:就是具体模块实现Region 组件内部实现区域和可用区的查询、展示、和区域可用区选择后的双向绑定;Spec 组件实现查询当前产品的所有规格,并处理成可在页面显示选择的样式;Network 组件实现可用VPC、子网、安全组的查询、显示、选择后的数据绑定等。
  • ECS/EVS等各类云产品:通过聚合Region、Spec等组件实现各个产品的完整功能

四、代码实现

    Content: 利用 Vue 的 component 组件实现策略选择,代码如下:

<template>
  <component
    ref="resourceInfoRef"
    :is="curComponent"
    :category="props.category"
    @spec-change="emitSpecChange"
  />
</template>
<script lang="ts" setup>
import ECS from './ECS.vue'
import EIP from './EIP.vue'
import EVS from './EVS.vue'
import REDIS from './REDIS.vue'
...  // 产品很多,这里省略

const components = {
  // 服务器
  [ResourceMapEnum.ECS]: ECS,

  // 存储
  [ResourceMapEnum.EVS]: EVS,
  [ResourceMapEnum.EIP]: EIP,

  // 数据库
  [ResourceMapEnum.REDIS]: REDIS,
   ...
}

const props = defineProps({
  category: {
    type: Object as PropType<CategoryVO>,
    default: () => {},
    required: true
  }
})
const curComponent = shallowRef()

onMounted(() => {
  initConponent()
})

watch(
  () => props.category,
  () => {
    initConponent()
    emitSpecChange(null)
  },
  { deep: true }
)

const initConponent = () => {
  const { productType } = props.category
  // productType 查看字典 product_type 对应的值
  if (productType && curComponent.value !== components[productType]) {
    curComponent.value = components[productType]
  }
}

const resourceInfoRef = ref()
const getOrderData = () => {
  return resourceInfoRef.value?.getOrderData?.()
}
defineExpose({ getOrderData })
</script>

    其中一个产品的部分代码

<template>
  <div class="rescoure-main-page" v-loading="!productLoaded">
    <div class="module-title">规格配置</div>
    <div class="module-content">
      <!-- 区域和可用区,分别绑定regionCode 和 zoneCode -->
      <ResourceArea
        ref="areaRef"
        v-model:region-code="formData.regionCode"
        v-model:region-name="formData.regionName"
        v-model:zone-code="formData.zoneCode"
        v-model:zone-name="formData.zoneName"
      />
      <!-- 规格 v-if="areaLoaded" -->
      <ResourceSpec
        ref="specRef"
        :category-id="props.category.id"
        :region-code="formData.regionCode"
        :zone-code="formData.zoneCode"
        :product-type="props.category.productType"
        v-model:spec-code="formData.specCode"
        v-model:spec-name="formData.specName"
        v-model:spec-price="formData.specPrice"
      />

      <!-- 系统盘 -->
      <Disk
        v-if="areaLoaded"
        :region-code="formData.regionCode"
        :zone-code="formData.zoneCode"
        v-model:system-disk="formData.orderAttr.systemDisk"
      />
      <!-- 数据盘 -->
      <DataDisk
        ref="dataDiskRef"
        :region-code="formData.regionCode"
        :zone-code="formData.zoneCode"
        v-model:disks="formData.orderAttr.dataDisk"
      />
      <!-- 镜像 -->
      <Image
        ref="imageRef"
        :region-code="formData.regionCode"
        :zone-code="formData.zoneCode"
        v-model:image-id="formData.orderAttr.imageId"
        v-model:image-name="formData.orderAttr.imageName"
      />

      <!-- 云服务器名称 -->
      <ResourceName
        ref="resourceNameRef"
        :label="'云服务器名称'"
        :zone-code="formData.zoneCode"
        :product-type="props.category.productType"
        v-model:resource-name="formData.resourceName"
      />
      <!-- 密码 -->
      <password
        ref="passwordRef"
        v-model:password="formData.orderAttr.password"
      />
    </div>
    <div class="module-title">网络配置</div>
    <div class="module-content">
      <!-- 网络 -->
      <NetWork
        ref="networkRef"
        v-model:vpc-id="formData.orderAttr.vpcId"
        v-model:vpc-name="formData.orderAttr.vpcName"
        v-model:subnet-id="formData.orderAttr.subnetId"
        v-model:subnet-name="formData.orderAttr.subnetName"
        v-model:security-group-id="formData.orderAttr.securityGroupId"
        v-model:security-group-name="formData.orderAttr.securityGroupName"
        v-model:custom-i-p="formData.orderAttr.customIP"
        :region-code="formData.regionCode"
        :ip-type="formData.orderAttr.version"
        :regionCloudResource="formData.regionCloudResource"
      />
    </div>
  </div>
</template>
<script lang="ts" setup>
const formData = ref({
  regionCloudResource: '', 
  productType: '',
  regionCode: '',
  zoneCode: '',
  regionName: '', // 区域名称
  zoneName: '', // 可用区名称
  architecture: '', // 架构类型,
  resourceType: props.category.productType,
  resourceName: '', // 云服务器名称
  specCode: '', // 产品规格ID
  specPrice: 0,
  specName: '',
  orderAttr: {
    imageId: '', // 镜像ID
    imageName: '',
    system: '', // 系统
    vpcId: '', // 虚拟私有云ID
    vpcName: '',
    version: '',
    versionName: '',
    securityGroupId: '', // 安全组ID
    securityGroupName: '',
    subnetId: '', // 子网ID
    subnetName: '',
    customIP: '', // 自定义IP
    systemDisk: {
      type: '', // 系统盘SKU ID
      price: 0,
      name: '',
      size: 0 // 系统盘大小
    },
    dataDisk: [],
    password: '', // 密码
    secureImageId: '' // 安全镜像ID
  }
})

const areaRef = ref()
const specRef = ref()
const addressVersionRef = ref()
const systemDiskRef = ref()
const dataDiskRef = ref()
const imageRef = ref()
const resourceNameRef = ref()
const passwordRef = ref()
const networkRef = ref()
const verifyFormData = () => {
  let result = true

  try {
    areaRef.value.validate()
    specRef.value.validate()
    addressVersionRef.value?.validate()
    systemDiskRef.value.validate()
    dataDiskRef.value.validate()
    imageRef.value.validate()
    resourceNameRef.value.validate()
    passwordRef.value.validate()
    networkRef.value.validate()
  } catch (error) {
    console.log(error)
    result = false
  }

  return result
}
const getOrderData = () => {
    return formData.value
}

defineExpose({ getOrderData })
</script>

    各个小模块中使用了 Vue3 的 defineModel 来实现多个值的绑定,具体小模块的代码就属于核心代码啦!这里就不展示了。

五、个人的一点点感想

    其实,在实际开发中并不是按上面所写的步骤一步一步往下做的,类图都是代码都写完了快上线之前才抽时间画出来的。但是在开发这个模块之前确实有花了几天时间反复看老系统,边看边琢磨边问懂业务的同事才慢慢理解业务,从而将复杂的页面按职责拆分为不同的小模块单独开发。可能本身是学过 java 以及面向对象设计原则的原因,脑子里有接口的概念,所以设计时特意让每个小模块实现了同样方法,并且每种资源类型也实现了同样的方法。

    另外,申请这个业务一开始技术经理的策略是按产品分开做,例如A程序员负责ECS/EIP...,B程序员负责RDS,Redis...这种策略,我加入这个模块开发时,已经有同事开发完成ECS的页面,但是看了她的代码人直接麻了。贴下面给大家看看,光是定义的变量都看麻了(其实这位同事的样式写得挺好,我拆分的小模块就是以她的代码为基础改造的)。我分析完后直接给技术经理商量改变了策略,我一个人完成所有资源的申请部分,其他同事分别完成资源管理的部分,若是分开写,后续直接无法维护。

<template>
  <div class="rescoure-main-page">
    <div class="module-title">规格配置</div>
    <div>
      <!-- 节点 -->
      <div class="info-item">
        <span class="info-label">节点</span>
        <div class="info-main">
          <div>
            <el-radio-group v-model="nodeResult" @change="handleAvailableArea">
              <el-radio-button
                class="radio-option-btn"
                v-for="item in nodeListOption"
                :key="item.nodeCode"
                :label="item.nodeCode"
                >{{ item.nodeName }}</el-radio-button
              >
            </el-radio-group>
          </div>
        </div>
      </div>
      <!-- 可用区 -->
      <div class="info-item">
        <span class="info-label">可用区</span>
        <div class="info-main">
          <el-radio-group v-model="availableArea" @change="handleAvailableArea">
            <el-radio-button
              class="radio-option-btn"
              v-for="item in availableAreaList"
              :key="item.zoneCode"
              :label="item.zoneCode"
              >{{ item.zoneName }}</el-radio-button
            >
          </el-radio-group>
          <el-tooltip
            popper-class="el-popover-self "
            placement="bottom-start"
            effect="light"
            trigger="hover"
          >
            <template #content>
              <div class="popover-tips">xxx
              </div>
            </template>
            <el-icon class="icon-quest bottom-icon-style"><QuestionFilled /></el-icon>
          </el-tooltip>
        </div>
      </div>
      <!-- 规格版本 -->
      <div class="info-item">
        <div class="info-label">规格版本</div>
        <div class="info-main">
          <el-radio-group v-model="specType" @change="changSpecType">
            <el-radio-button
              class="radio-option-btn"
              v-for="(item, index) in specTypeList"
              :key="index"
              :label="item.categoryCode"
              >{{ item.category }}</el-radio-button
            >
          </el-radio-group>
        </div>
      </div>
      <!-- 规格 -->
      <div class="info-item">
        <span class="info-label">规格</span>
        <div class="info-main">
          <div class="specification-main">
            <div class="spec-filter">
              <el-input
                class="info-input-180"
                v-model="standardName"
                style="width: 250px"
                placeholder="请输入规格名称"
                clearable
                @clear="standardclick"
              >
                <template #suffix>
                  <el-icon class="el-icon-search" @click="handleQueryStandard"><Search /></el-icon>
                </template>
              </el-input>
            </div>
            <div class="specification-table">
              <el-table
                border
                v-if="specType"
                :data="performDataList"
                style="width: 100%"
                max-height="280"
                ref="singleTable"
                highlight-current-row
                :header-cell-style="headClass"
                @current-change="handleCurrentChange"
              >
                <el-table-column prop="specName" label="规格名称" width="440" />
                <el-table-column prop="monthPrice" label="规格参考价(¥)" width="140">
                  <template #default="scope">
                    <div>{{ scope.row.monthPrice }}元/月</div>
                  </template>
                </el-table-column>
              </el-table>
            </div>
          </div>
        </div>
      </div>
      <!-- 系统盘 -->
      <div class="info-item">
        <span class="info-label">系统盘</span>
        <div class="info-main">
          <el-select
            class="info-select-140"
            v-model="systemDisk"
            placeholder="请选择"
            @change="handleSystemDisk"
            value-key="value"
            :popper-append-to-body="false"
          >
            <el-option
              v-for="(item, index) in systemDiskList"
              :key="index"
              :label="item.name"
              :value="item.value"
            />
          </el-select>
          <el-input-number
            class="info-input-number system-disk-num"
            :max="ECSmax"
            :min="ECSmin"
            @keydown="channelInputLimit"
            v-model="currentSystemValueEcs"
          />
          <span class="input-number-unit">GB</span>
          <div class="create-btn-list">
            <el-checkbox v-model="customizeVal" @click="handlerOpenCustomize"
              >开启自定义备份策略</el-checkbox
            >
            <el-select
              v-model="snapTactics"
              placeholder="请选择自动快照策略"
              size="small"
              class="selece-snap"
              style="width: 150px"
            >
              <el-option
                v-for="item in optionsSnap"
                :key="item.value"
                :label="item.label"
                :value="item.value"
              />
            </el-select>
            <span class="create-btn">创建自定义快照策略>>></span>
          </div>
        </div>
      </div>
      <div class="chang">可选硬盘容量:{{ ECSmin }}--{{ ECSmax }}</div>
      <!-- 数据盘 -->
      <div class="datadisk-info-main" v-for="(item, index) in datadiskData" :key="item.id">
        <div class="info-item">
          <span class="info-label">
            <template v-if="index == 0">数据盘</template>
          </span>
          <div class="info-main">
            <el-select
              class="info-select-140"
              v-model="item.systemNumDisk"
              placeholder="请选择"
              @change="handleDataDisk(item)"
              :popper-append-to-body="false"
            >
              <el-option
                v-for="i in DiskList"
                :key="i.specCode"
                :label="i.specName"
                :value="i.specCode"
              />
            </el-select>
            <!-- :step="item.dataStepValue" -->
            <el-input-number
              class="info-input-number system-disk-num"
              v-model="item.systemDiskSizeValue"
              @change="handleDataDiskSize(item)"
              @keyup.enter="handleDataDiskSizeBlur(item)"
              @keydown="channelInputNum(item)"
              :min="item.min"
              :max="item.max"
            />
            <span class="input-number-unit">GB</span>
            <div>
              <el-icon class="delete-btn-icon" @click="handleDelDatadisk(index)"
                ><Delete
              /></el-icon>
            </div>
          </div>
        </div>
        <div class="chang">可选硬盘容量:{{ item.min }}--{{ item.max }}</div>
      </div>
      <div class="info-item">
        <span class="info-label"></span>
        <div class="info-main">
          <div>
            <div class="create-btn-list">
              <span class="heard-icon">-</span>
              <span class="create-btn" @click="openSnapDataDisks">快照创建数据盘</span>
            </div>
            <div class="create-btn-list">
              <span class="heard-icon">-</span>
              <el-checkbox v-model="customizeVal" @click="handlerOpenCustomize"
                >开启自定义备份策略</el-checkbox
              >
              <el-select
                v-model="snapTactics"
                placeholder="请选择自动快照策略"
                size="small"
                class="selece-snap"
                style="width: 150px"
              >
                <el-option
                  v-for="item in optionsSnap"
                  :key="item.value"
                  :label="item.label"
                  :value="item.value"
                />
              </el-select>
              <span class="create-btn">创建自定义快照策略>>></span>
            </div>
          </div>
        </div>
      </div>
      <div class="info-item">
        <span class="info-label"></span>
        <div class="info-main">
          <div
            class="add-datadisk"
            :class="DATADISTNUM - maxNumDisk === 0 ? 'add-datadisk-disabled' : ''"
            @click="handleAdddDtadisk"
          >
            <span style="color: #3369ff">+添加数据盘</span> 您还可以挂载
            {{ DATADISTNUM - maxNumDisk }} 块磁盘
          </div>
        </div>
      </div>
      <!-- 镜像 -->
      <div class="info-item">
        <span class="info-label">镜像</span>
        <div class="info-main">
          <div class="specification-main">
            <el-radio-group v-model="mirror" @change="handleMirror">
              <el-tooltip
                v-for="item in mirrorImList"
                :key="item.value"
                effect="light"
                popper-class="question-mark-tips"
                placement="top-start"
              >
                <template #content>
                  <div class="popover-tips">
                    <span>
                      {{ item.label }}:
                      <br />
                      {{ item.info }}
                    </span>
                  </div>
                </template>
                <el-radio-button class="radio-option-btn" :key="item.value" :label="item.value">
                  {{ item.label }}
                </el-radio-button>
              </el-tooltip>
            </el-radio-group>
            <div class="system-version-select">
              <el-select
                v-model="operatingSystemVal"
                placeholder="--请选择操作系统--"
                @change="handleoPeratingSystem"
                :popper-append-to-body="false"
                clearable
                :disabled="!!sendRequestCount"
                style="margin-right: 12px"
              >
                <el-option
                  v-for="item in operatingSystemListVal"
                  :key="item"
                  :label="item"
                  :value="item"
                />
              </el-select>
              <el-select
                v-model="operateSysVersionVal"
                @change="handleOperateSysVersionEcs"
                value-key="id"
                placeholder="--请选择操作系统版本--"
                class="info-select-320"
                :disabled="!!sendRequestCount"
                clearable
              >
                <el-option
                  v-for="item in operateSysVersionsDataListVal"
                  :key="item.id"
                  :label="item.name"
                  :value="item.id"
                />
              </el-select>
              <el-tooltip
                popper-class="el-popover-self"
                placement="bottom-start"
                effect="light"
                trigger="hover"
              >
                <template #content>
                  <div class="popover-tips">
                    镜像:
                    <br />xxx
                    <br />
                    <br />公共镜像:
                    <br />xxx
                    <br />
                  </div>
                </template>
                <el-icon class="icon-quest"><QuestionFilled /></el-icon>
              </el-tooltip>
            </div>
          </div>
        </div>
      </div>
      <!-- 云服务器名称 -->
      <div class="info-item">
        <span class="info-label">云服务器名称</span>
        <div class="info-main">
          <div>
            <el-input v-model="cloudServerName" placeholder="请输入云服务器名称" clearable />
            <div class="popup-tip" v-if="cloudServerName == ''">请输入云服务器名称</div>
          </div>
        </div>
      </div>
      <!-- 登录凭证 -->
      <div class="info-item">
        <span class="info-label">登录凭证</span>
        <div class="info-main">
          <div>
            <el-radio-group v-model="loginVoucher" @change="handleAvailableArea">
              <el-radio-button label="1">密码</el-radio-button>
              <el-radio-button label="2">创建后设置</el-radio-button>
            </el-radio-group>
            <div class="popup-tip" v-if="loginVoucher == '2'"
              >xxx</div
            >
          </div>
        </div>
      </div>
      <!-- 用户名 -->
      <div class="info-item" v-if="loginVoucher == '1'">
        <span class="info-label">用户名</span>
        <div class="info-main">
          <div>
            <div v-if="operatingSystem == 'Windows'">Administrator</div>
            <div v-else class="show-info-box">root</div>
            <div class="popup-tip">xxx</div>
          </div>
        </div>
      </div>
      <!-- 密码 -->
      <div class="info-item" v-if="loginVoucher == '1'">
        <span class="info-label">密码</span>
        <div class="info-main">
          <el-input
            v-model="password"
            ref="passwordInput"
            maxlength="26"
            minlength="8"
            placeholder="请输入密码"
            type="password"
            @focus="handleFocusPassword"
            clearable
          />
          <el-tooltip
            v-if="!password"
            ref="popoverRef"
            :virtual-ref="passwordInput"
            placement="right"
            effect="light"
          >
            <template #content>
              <div class="popover-tips">
                <h4 class="h4">密码规则</h4>
                <div class="dl">
                  <el-icon class="icon-style-red"><WarningFilled /></el-icon>
                  <span>xxx</span>
                  <br />
                  <el-icon class="icon-style-red"><WarningFilled /></el-icon>
                  <span
                    >xxx</span
                  >
                  <br />
                  <el-icon class="icon-style-success"><CircleCheckFilled /></el-icon>
                  <span>xxx</span>
                </div>
              </div>
            </template>
          </el-tooltip>
        </div>
      </div>
      <!-- 再次确认密码 -->
      <div class="info-item">
        <span class="info-label">再次确认密码</span>
        <div class="info-main">
          <div>
            <el-input
              class="info-input-240"
              v-model="confirmPassword"
              placeholder="请再次输入密码"
              maxlength="26"
              minlength="8"
              type="password"
              @input="handlePasswordVerifyComfirm"
              clearable
            />

            <div class="popup-tip" v-if="passwordVerify2">两次输入密码不一样</div>
            <div class="popup-tip">xxx</div>
          </div>
        </div>
      </div>
    </div>
    <div class="module-title">网络配置</div>
    <div>
      <!-- 网络 -->
      <div class="info-item">
        <span class="info-label">网络</span>
        <div class="info-main">
          <div>
            <div class="spec-filter">
              <el-select
                class="info-select-240"
                v-model="sleVpcId"
                @change="handlePrivateCloud"
                placeholder="请选择虚拟私有云"
                value-key="id"
                :popper-append-to-body="false"
                clearable
              >
                <el-option
                  v-for="item in vpclistVuex"
                  :key="item.id"
                  :label="item.name"
                  :value="item.id"
                />
              </el-select>
              <img
                @click="hnadleVpcListRefresh"
                src="@/assets/imgs/refresh.png"
                width="16"
                height="16"
                class="refresh"
                alt=""
                :class="refreshVpcLoading ? 'refresh-rotate' : ''"
              />
              <el-select
                class="info-select-240"
                v-model="subnet"
                @change="handleSubnet"
                placeholder="请选择子网"
                value-key="id"
                :disabled="netchild"
                :popper-append-to-body="false"
                clearable
              >
                <el-option
                  v-for="item in subnetList"
                  :key="item.id"
                  :label="item.name"
                  :value="item.id"
                />
              </el-select>
              <img
                @click="hnadleSubnetListRefresh"
                src="@/assets/imgs/refresh.png"
                width="16"
                height="16"
                class="refresh"
                alt=""
                :class="refreshSubnetLoading ? 'refresh-rotate' : ''"
              />
              <!-- <el-tooltip
                popper-class="el-popover-self"
                placement="bottom-start"
                effect="light"
                trigger="hover"
              >
                <template #content>
                  <div class="popover-tips">
                    虚拟私有云:
                    <br />xxx
                    <br />
                  </div>
                </template>
                <el-icon class="icon-quest"><QuestionFilled /></el-icon>
              </el-tooltip> -->
            </div>
            <div class="check-ip-type">
              <el-checkbox v-model="checkedAllocationIp">指定主网卡主私网IP地址 </el-checkbox>
              <div class="input-number-ip">
                <el-input
                  v-model="ipA"
                  @input="(val) => (ipA = val.replace(/[^0-9]/g, '').slice(0, 4))"
                />
                <span class="dian-dian">.</span>
                <el-input
                  v-model="ipB"
                  @input="(val) => (ipB = val.replace(/[^0-9]/g, '').slice(0, 4))"
                />
                <span class="dian-dian">.</span>
                <el-input
                  v-model="ipC"
                  @input="(val) => (ipC = val.replace(/[^0-9]/g, '').slice(0, 4))"
                />
                <span class="dian-dian">.</span>
                <el-input
                  v-model="ipD"
                  @input="(val) => (ipD = val.replace(/[^0-9]/g, '').slice(0, 4))"
                />
              </div>
            </div>
            <!-- <el-select
              class="info-select-160"
              v-model="allocationIp"
              @change="handleAllocationIp"
              placeholder="自动分配IP地址"
              value-key="value"
              :popper-append-to-body="false"
            >
              <el-option label="自动分配IP地址" value="00" />
            </el-select> -->
            <div class="info-main-tips-box">
              <p class="info-main-tips"
                >如需创建新的安全组,您可<span
                  style="color: #3369ff; cursor: pointer"
                  @click="creatGroup"
                >
                  前往xxx创建 </span
                >。</p
              >
            </div>
          </div>
        </div>
      </div>
      <!-- 安全组 -->
      <div class="info-item">
        <span class="info-label">安全组</span>
        <div class="info-main">
          <div>
            <div class="spec-filter">
              <el-select
                class="info-select-280"
                v-model="securityGroupId"
                placeholder="请选择安全组"
                :popper-append-to-body="false"
                @change="handleSecurityGroup"
                clearable
              >
                <el-option
                  v-for="item in securityGroupList"
                  :key="item.id"
                  :label="item.name"
                  :value="item.id"
                />
              </el-select>
              <img
                @click="hnadleSecurityGroupRefresh"
                src="@/assets/imgs/refresh.png"
                width="16"
                height="16"
                class="refresh"
                alt=""
                :class="refreshSecurityGroupLoading ? 'refresh-rotate' : ''"
              />
            </div>
            <div class="info-main-tips-box">
              <p class="info-main-tips"
                >如需创建新的安全组,您可<span
                  style="color: #3369ff; cursor: pointer"
                  @click="creatGroup"
                >
                  前往xxx创建 </span
                >。</p
              >
              <p class="info-main-tips"
                >xxx</p
              >
              <p class="info-main-tips tips-color"
                >xxx</p
              >
            </div>
          </div>
        </div>
      </div>
    </div>
    <ConfigureView />
    <el-dialog
      v-model="showSnapDataDisks"
      title="用快照创建数据盘"
      width="500"
      :before-close="handleCloseDataDisks"
    >
      <span>快照列表</span>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="showSnapDataDisks = false"> 确定 </el-button>
          <el-button @click="showSnapDataDisks = false">关闭</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>

<script lang="ts" setup>
import {
  QuestionFilled,
  Search,
  Delete,
  WarningFilled,
  CircleCheckFilled
} from '@element-plus/icons-vue'
import ConfigureView from '../ConfigureView.vue'

const nodeResult = ref('1') //节点
const nodeListOption = ref() // 节点option
const availableArea = ref(1)
const availableAreaList = ref() // 可用区数据
const specType = ref(1)
const specTypeList = ref() // 规格版本数据
const standardName = ref() // 输入的规格名称
const performDataList = ref() //规格名称表格数据
const singleTable = ref() // 规格表格ref
const systemDisk = ref() // 系统盘
const systemDiskList = ref() // 系统盘options数据
const ECSmax = ref(1024)
const ECSmin = ref(1)
const currentSystemValueEcs = ref() // 系统盘GB数
const datadiskData = ref() // 挂载的数据盘总数
const DiskList = ref() // 数据盘options数据
const DATADISTNUM = ref(5) // 最多可以挂载5个
const maxNumDisk = ref()
const customizeVal = ref(false) //是否自定义备份策略
const showSnapDataDisks = ref()
const snapTactics = ref() //快照策略
const optionsSnap = ref() //快照策略option
const mirror = ref('1') //镜像
const mirrorImList = ref()
const operatingSystemVal = ref()
const sendRequestCount = ref(false)
const operatingSystemListVal = ref()
const operateSysVersionVal = ref()
const operateSysVersionsDataListVal = ref()
const loginVoucher = ref('1') //登录凭证
const cloudServerName = ref('2344444') //云服务器名称
const operatingSystem = ref()
const password = ref(1234556) //密码
const passwordInput = ref() //密码ref
const popoverRef = ref() // 密码规则ref
const confirmPassword = ref() // 确认密码
const passwordVerify2 = ref() // 判断两次密码是否一致
const sleVpcId = ref() // 虚拟私有云
const vpclistVuex = ref() // 虚拟私有云option
const subnet = ref() // 子网
const netchild = ref(false) // 是否禁用子网
const subnetList = ref() // 子网option
const allocationIp = ref() // IP地址
const checkedAllocationIp = ref() // 是否指定主网卡ip地址
const ipA = ref()
const ipB = ref()
const ipC = ref()
const ipD = ref()
const refreshVpcLoading = ref(false) // 控制刷新样式,获取数据时为true
const refreshSubnetLoading = ref(false) // 控制刷新样式,获取数据时为true
const securityGroupId = ref() // 安全组
const securityGroupList = ref() // 安全组option
const refreshSecurityGroupLoading = ref(false)

// 可用区切换
const handleAvailableArea = () => {}

//规格版本切换
const changSpecType = () => {}

// 清空规格名称输入框
const standardclick = () => {}
// 根据规格名称查询
const handleQueryStandard = () => {}
const handleCurrentChange = () => {}
// 系统盘选择
const handleSystemDisk = () => {}
// 数据盘选择
const handleDataDisk = (item) => {}
// 规格名称表格样式
const headClass = () => {
  return { height: '42px', background: 'rgb(244, 247, 252)' }
}
// 修改系统盘GB
const channelInputLimit = () => {}
// 数据盘数据变动
const handleDataDiskSize = (item) => {}
const handleDataDiskSizeBlur = (item) => {}
const channelInputNum = (item) => {}
// 添加数据盘
const handleAdddDtadisk = () => {
  // 添加数据盘
  const num = DATADISTNUM.value - maxNumDisk.value
  if (num === 0) {
    return
  }
  if (DiskList.value.length > 0) {
    let dataitem = {
      systemDisk: DiskList.value[0].specCode,
      dataName: DiskList.value[0].specName
    }
    datadiskData.value.push(dataitem)
    maxNumDisk.value = datadiskData.value.length
    // $store.commit('ecs/setDatadiskData', datadiskData.value)
  }
}
// 删除数据盘
const handleDelDatadisk = (index) => {
  datadiskData.value.splice(index, 1)
  maxNumDisk.value = datadiskData.value.length
}
// 快照创建数据盘
const openSnapDataDisks = () => {
  showSnapDataDisks.value = true
}
// 关闭快照创建数据盘
const handleCloseDataDisks = () => {
  showSnapDataDisks.value = false
}
// 是否自定义备份策略
const handlerOpenCustomize = () => {}
// 镜像部分
const handleMirror = () => {}
const handleoPeratingSystem = () => {}
const handleOperateSysVersionEcs = () => {}
// 显示密码规则
const handleFocusPassword = () => {
  unref(popoverRef).popperRef?.delayHide?.()
}
// 判断密码是否一致
const handlePasswordVerifyComfirm = (v) => {
  if (password.value !== v) {
    passwordVerify2.value = true
  } else {
    passwordVerify2.value = false
  }
}
// 选择虚拟私有云
const handlePrivateCloud = () => {}
// 网络刷新私有云
const hnadleVpcListRefresh = () => {}

// 选择子网
const handleSubnet = () => {}
// 刷新子网
const hnadleSubnetListRefresh = () => {}
// 选择ip地址
const handleAllocationIp = () => {}
// 选择安全组
const handleSecurityGroup = () => {}
// 刷新安全组
const hnadleSecurityGroupRefresh = () => {}
// 跳转控制台
const creatGroup = () => {}
// 假数据专用
const getPseudoInfo = () => {
  const nodeListOption222 = [
    { nodeCode: '1', nodeName: '节点A' },
    { nodeCode: '2', nodeName: '节点B' },
    { nodeCode: '3', nodeName: '节点C' },
  ]
  const pseudoArea = [
    { zoneCode: 1, zoneName: '可用区1' },
    { zoneCode: 2, zoneName: '可用区2' }
  ]
  const specType222 = [
    { categoryCode: 1, category: '通用型' },
    { categoryCode: 2, category: '通用计算增强型' },
    { categoryCode: 3, category: 'AI加速型' }
  ]
  const sepcList = [
    { specName: 'spec1', monthPrice: '31.00' },
    { specName: 'spec2', monthPrice: '31.00' }
  ]
  const systemdisk111 = [
    { name: 'disk1', value: '40' },
    { name: 'disk2', value: '20' }
  ]
  const DiskList222 = [
    { specCode: '1', specName: '搞笑云盘' },
    { specCode: '2', specName: '好的云南' },
    { specCode: '3', specName: '是的师大' }
  ]
  const datadiskData222 = [
    { systemNumDisk: '1', systemDiskSizeValue: '22', min: 3, max: 88 }
  ]
  const mirrorImList222 = [
    {
      value: '1',
      label: '公共镜像',
    },
    { value: '2', label: '自定义镜像', info: '用户自己的镜像' }
  ]

  const operatingSystemVal222 = ['EulerOs', 'Other']
  const operateSysVersionsDataListVal222 = [
    { id: 1, name: 'hshsus' },
    { id: 2, name: 'xgxgxg' },
    { id: 3, name: 'lklklk' }
  ]

  nodeListOption.value = nodeListOption222
  availableAreaList.value = pseudoArea
  specTypeList.value = specType222
  performDataList.value = sepcList
  systemDiskList.value = systemdisk111
  DiskList.value = DiskList222
  datadiskData.value = datadiskData222
  maxNumDisk.value = datadiskData.value.length
  mirrorImList.value = mirrorImList222
  operatingSystemListVal.value = operatingSystemVal222
  operateSysVersionsDataListVal.value = operateSysVersionsDataListVal222
  // 高亮列表第一行
  singleTable.value.setCurrentRow(performDataList.value[0])
}

// 高亮显示规格列表第一行
watch(
  () => performDataList,
  () => {
    nextTick(() => {
      singleTable.value.setCurrentRow(performDataList.value[0])
    })
  }
)
onMounted(() => {
  getPseudoInfo()
})
</script>
<style lang="scss" src="./styleFiles/styleCss.scss" scoped></style>

    经过以往的项目,也发现很多前端的同事主要都是关注Vue语法、或者具体业务实现了,在程序设计方面有所缺失,因此有以下几点小小的感想

5.1 前端也需要懂设计原则

    单一职责原则、里氏替换原则、开闭原则、依赖倒置原则、迪米特法则,接口隔离原则 六大面向对象程序设计原则是值得每一个前端开发深入学习和理解的。初学不知其然,但是在工作中已经受到其潜移默化的影响。

    在实际开发中,是否需要封装,封装到什么程度往往是很难以衡量的,见过将一个简单页面拆得稀碎的,也见过复杂页面从不拆分组件的。不同的人有不同的思维方式,想法不一样,因此做法也不一样。设计原则就是统一思想的利器,如果大家都学过,并且深入理解了,那在实际运用中应该也会写出比较类似的代码吧。学习设计原则之前我自己也出现过复杂页面没有拆分导致后期只有自己能维护的情况。

5.2 前端也需要懂设计模式

    设计模式,简单来说就是面向对象程序设计原则的一些具体的实践经验。按java来说,设计原则就像是定义好的接口,而设计模式就是设计原则接口的具体实现类。如果设计原则太抽象,从设计模式学习也不错,即使学完没记住几个,但是也会对你有潜移默化的影响。

5.3 考个软考中级

    如果有空的话,考个软考中级,已经有开发经验的同事,通过以考促学的方式,将软件设计相关的课程系统的在学习一遍是个不错的选择。部分公司考证还有补贴、涨工资等福利哦。如何准备软考,可以参考我的另一篇文章《纯自学,软件设计师、系统架构设计师一把过》

注:文中使用的代码已获得开发者本人同意

昨天以前首页

这次来点狠的:用 Vue 3 把 AI 的“碎片 Markdown”渲染得又快又稳(Monaco 实时更新 + Mermaid 渐进绘图)

作者 Simon_He
2025年9月17日 17:06

在做 AI 应用或协同编辑时,你八成遇到过这些痛点:

  • 模型在“打字”,Markdown 半截一坨,渲染器直接崩溃
  • 代码块动不动几千行,实时更新卡得怀疑人生
  • Mermaid 图不合法就整块失效,用户看到一片空白
  • 内容一边流一边更新,DOM 抖动、滚动乱跳

我做了一个专门为“流式 Markdown”场景优化的组件库——vue-renderer-markdown,它能把这些“坏情况”处理得优雅、顺滑、还很快。

为什么它不一样

  • 为流式而生:兼容“半截 Markdown/半截代码块/半截 Mermaid”的中间态,边流边稳
  • 高性能 Monaco:大块代码的增量更新,不卡 UI,编辑/预览都丝滑
  • 渐进式 Mermaid:一旦语法“刚好可解析”,先画出来;后续流入再迭代完善,避免整块空白
  • 完整 Markdown 能力:表格、公式、emoji、复选框、代码块……该有的都有
  • 实时更新友好:尽量少的重排重绘,几乎没有 DOM 抖动
  • TypeScript First:完备类型定义 + 智能提示
  • 零配置引入:开箱即用,Vue 3 项目即插即用
  • 数学公式极速渲染(KaTeX):支持行内/块级公式,流式输入也能稳健增量更新;半截公式不“炸屏”,后续补齐自动完善显示

一分钟上手

安装

可任选你的包管理工具(以下是可选命令,仅供参考):

# 推荐
pnpm add vue-renderer-markdown

# 同时安装常见 peer 依赖(按需取舍)
pnpm add vue @iconify/vue @vueuse/core katex mermaid vue-use-monaco
# npm
npm i vue-renderer-markdown vue @iconify/vue @vueuse/core katex mermaid vue-use-monaco
# yarn
yarn add vue-renderer-markdown vue @iconify/vue @vueuse/core katex mermaid vue-use-monaco

基础用法:就是一个普通的 Vue 组件

<script setup lang="ts">
import MarkdownRender from 'vue-renderer-markdown'

const markdownContent = `
# Hello Vue Markdown

这是一段**Markdown**,支持:
- 列表
- [x] 复选框
- :tada: Emoji
`
</script>

<template>
  <MarkdownRender :content="markdownContent" />
</template>

流式渲染(AI 输出、实时更新场景)

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'vue-renderer-markdown'

const content = ref('')
const full = `# 流式内容\n\n这段文字会“逐字”出现…`

let i = 0
const timer = setInterval(() => {
  if (i < full.length) {
    content.value += full[i++]
  } else {
    clearInterval(timer)
  }
}, 40)
</script>

<template>
  <MarkdownRender :content="content" />
</template>

实战 1:Mermaid 渐进式绘图(边流边画)

Mermaid 的常见问题是“语法半截 -> 图形全挂”。这里我们做了渐进式解析:一旦内容达到“可画”的下限,就先画出来;后续流入再增量更新。

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'vue-renderer-markdown'

const content = ref('')
const steps = [
  '```mermaid\n',
  'graph TD\n',
  'A[Start]-->B{Valid?}\n',
  'B -- Yes --> C[Render]\n',
  'B -- No  --> D[Wait]\n',
  '```\n',
]

let i = 0
const id = setInterval(() => {
  content.value += steps[i] || ''
  i++
  if (i >= steps.length) clearInterval(id)
}, 120)
</script>

<template>
  <MarkdownRender :content="content" />
</template>

实战 2:Monaco 大代码块的“丝滑流更”

大代码块每次整体重渲染是灾难。vue-renderer-markdown 针对这类场景做了“增量更新 + 合理切分”,在内容不断流入时保持 UI 流畅。

Vite 项目推荐配合 vite-plugin-monaco-editor-esm,打包 Worker 更稳(Windows 尤其):

// vite.config.ts
import path from 'node:path'
import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'

export default {
  plugins: [
    monacoEditorPlugin({
      languageWorkers: [
        'editorWorkerService',
        'typescript',
        'css',
        'html',
        'json',
      ],
      customDistPath(root, outDir, base) {
        return path.resolve(outDir, 'monacoeditorwork')
      },
    }),
  ],
}

然后像上面一样用 <MarkdownRender :content="..."> 渲染带代码块的 Markdown;当内容流式追加时,你会发现 Monaco 的高亮和结构更新不会阻塞主线程。

实战 3:数学公式极速渲染(KaTeX)

KaTeX 渲染快、稳定,非常适合 AI 场景的“边流边出”。本库内置对数学节点的处理,配好 peer 依赖即可用。

  • 依赖提示:请在你的项目中安装 katex(本库会处理 KaTeX CSS 的加载;如需自定义主题样式,可自行覆盖)。

基础用法(行内 + 块级)

<script setup lang="ts">
import MarkdownRender from 'vue-renderer-markdown'

const markdownContent = `
这是行内公式:$E = mc^2$

这是块级公式:
$$
\\int_{-\\infty}^{\\infty} e^{-x^2} \\, dx = \\sqrt{\\pi}
$$
`
</script>

<template>
  <MarkdownRender :content="markdownContent" />
</template>

流式渲染(逐步补齐的公式)

当 AI 一段段吐词时,公式也可以渐进显示:一旦达到可解析的下限就先展示,后续补齐再增量更新,不会整块空白或抖动。

<script setup lang="ts">
import { ref } from 'vue'
import MarkdownRender from 'vue-renderer-markdown'

const content = ref('')
const steps = [
  '$$\\n',
  '\\\\sum_{i=1}^n i = ',
  '\\\\frac{n(n+1)}{2}',
  '\\n$$\\n',
]

let i = 0
const id = setInterval(() => {
  content.value += steps[i] || ''
  i++
  if (i >= steps.length) clearInterval(id)
}, 120)
</script>

<template>
  <MarkdownRender :content="content" />
</template>

它为什么快?

  • 增量解析:不是“全量重跑解析器”,而是只处理变动的片段
  • DOM 最小化更新:节点级别的精准更新,避免重排/重绘风暴
  • 动画帧调度:把昂贵任务放进 requestAnimationFrame,滚动/输入更顺滑
  • 内存优化:长时间流式渲染也不“越跑越重”,自动清理废弃状态
  • 容错/降级:半截语法、异常 token、临时不合法内容都能稳稳接住
  • 数学节点增量更新:内容未完整时先稳态呈现,补齐后迅速完善,不阻塞其他内容渲染

适合哪些场景?

  • AI Chat / Copilot 类应用:模型边说边写、Markdown 边渲染
  • 文档/博客编辑器的实时预览:对大代码块/长文档友好
  • 数据/日志可视化:Mermaid/表格/高亮混合流式输出
  • 协同编辑:多人同时修改,内容频繁变动

API 简要

  • 组件:MarkdownRender

  • 关键 Props

    • content: string(渲染 Markdown 字符串)
    • nodes: BaseNode[](也可传 AST 节点)
    • customComponents: Record<string, any>(在 Markdown 中渲染自定义组件)
  • 模板中使用时注意驼峰转短横线,例如 customComponents => custom-components

生态与依赖

  • 同时安装必要的 peer 依赖(根据你启用的功能按需选择),例如:vue、@iconify/vue、@vueuse/core、katex、mermaid、vue-use-monaco
  • Monaco 仅在需要编辑器/大代码预览时安装
  • Mermaid 仅在需要绘图时安装

在线体验 & 下一步

如果你正在做 AI 应用、云 IDE、或任何需要“边流边渲染”的工具,这个库会让你少掉很多不必要的性能坑。欢迎 Star、试用、提 Issue/PR,一起把“流式 Markdown 渲染”这件小事做到极致。

Vue Router 路由懒加载引发的生产页面白屏问题

作者 FarmerLiusun
2025年9月17日 17:05

使用Vue Router 路由懒加载引发的生产页面白屏问题

1.项目技术栈

项目使用vue3 + Vue Router + ant-design-vue,构建工具使用 vite.

  • 框架: vue3
  • 组件库: andt-design-vue
  • 路由: Vue Router
  • 构建工具: vite

2.问题现象

  • 1.本地开发阶段,一切正常没有任何问题。
  • 2.通过nginx部署到开发环境后,访问该前端,其他所有功能均正常无任何问题。
  • 3.当访问/deviceMgmt/deviceLogQuery页面时,页面白屏,控制台无任何报错。

3.问题排查

刚看到这个问题还是挺懵的,自己本地开发环境没有问题,一部署到开发环境就出问题,而且还是没有任何报错、警告、提示。根据上面的现象,初步想到以下几个可能造成该问题的原因:

3.1 路径问题

  • 前端访问的路径写错了
  • 路由配置的路径跟组件的路径写错了

但是通过仔细对比发现,路由配置路径跟组件路径都没有任何问题,故排除了路径问题

3.1 路由配置问题

查看页面元素发现,访问/deviceMgmt/deviceLogQuery路径时,app容器内的组件为空。 并且之后发现之前部署的上一个前端版本是没有问题的,但是只要换成现在重新编译的版本就有这个问题。

通过git版本对比发现,原来是路由懒加载的配置写错了造成的

   // router>index.js
    const routes = [
        {
          name: 'deviceLogQuery',
          path: '/deviceMgmt/deviceLogQuery',
          //✅ 正确写法
          // component: () => import('../views/deviceLogQuery/test.vue'),
          // ❌ 错误写法
          component: import('../views/deviceLogQuery/index.vue'),
        }
    ]

路由配置中如果使用路由懒加载,呢组件的导入配置,component 是通过() => import(./MyPage.vue) 的方式导入。 这里少了 () => ,直接导入了😥。

4.问题解决

  • 通过git版本对比发现,原来是路由懒加载的配置写错了 image.png

  • 并且仔细查看控制台发现原来Vue Router 已经报警提示了:但是只在第一次加载才会报警告😰(自己没仔细看)

    image.png

  • 修改配置,重新打包部署,问题解决。

5.总结

  • Vue Router 路由懒加载引发的生产页面白屏问题: 是由于路由懒加载的配置写错造成的
  • 写代码的过程: 需要更加细致,原本是一个很小的问题,就是不够仔细才发生
  • git的重要性: 一定要多提交代码,出现问题时才能更好的溯源
  • 给Vue Router的建议: 建议直接将警告改成报错,并且增加提示

Vue Keep-Alive 组件详解:优化性能与保留组件状态的终极指南

作者 前端缘梦
2025年9月17日 16:24

面试题:请阐述 keep-alive 组件的作用和原理

一、Keep-Alive 组件概述

组件性质

Keep-Alive 是 Vue 的内置组件,主要用于缓存内部组件实例,避免重复创建和销毁带来的性能开销。

核心机制

  • 内部维护一个 key 数组和一个缓存对象
  • 自动为未指定 key 的组件生成唯一 key 值
  • 采用 LRU(最近最少使用)算法管理缓存

二、组件切换场景分析

典型应用场景

  • 条件渲染:使用 v-if/v-else-if/v-else 切换组件
  • 路由切换:在 router-view 中切换不同页面组件

问题本质

传统组件切换会导致组件实例销毁和重建,带来性能损耗和状态丢失问题。

三、路由切换与组件缓存

实现方式

使用 keep-alive 包裹动态组件:

<keep-alive>
  <component :is="currentComponent"></component>
</keep-alive>

缓存过程

  1. 首次渲染时缓存组件实例
  2. 切换时直接复用缓存实例
  3. 缓存数量随使用情况动态增长

四、Keep-Alive 的优势

性能优化

  • 避免重复创建组件实例的开销
  • 跳过完整的生命周期流程

状态保留

  • 保持组件 data 数据状态
  • 保留 DOM 元素及其内部状态
  • 维持计算属性(computed)等响应式数据

实例复用

  • 重用组件实例对象(component instance)
  • 复用已生成的 DOM 元素(el 属性)

五、应用案例解析

案例1:基础组件切换缓存

<template>
  <div>
    <button @click="switchComponent">切换组件</button>
    <keep-alive>
      <component :is="comps[curIndex]"></component>
    </keep-alive>
  </div>
</template>

<script>
export default {
  data() {
    return {
      comps: Object.freeze([Comp1, Comp2, Comp3]),
      curIndex: 0
    }
  },
  methods: {
    switchComponent() {
      this.curIndex = (this.curIndex + 1) % this.comps.length;
    }
  }
}
</script>

效果对比:

  • 未使用 keep-alive:每次切换触发 created/mounted/destroyed 生命周期,状态丢失
  • 使用 keep-alive:首次加载触发完整生命周期,后续切换只触发 activated/deactivated,状态保留

案例2:后台管理系统页面缓存

实现方案:

// store.js
export default new Vuex.Store({
  state: {
    pageNames: [] // 需要缓存的页面名称数组
  },
  mutations: {
    addPage(state, pageName) {
      if (!state.pageNames.includes(pageName)) {
        state.pageNames.push(pageName);
      }
    },
    removePage(state, pageName) {
      const index = state.pageNames.indexOf(pageName);
      if (index > -1) {
        state.pageNames.splice(index, 1);
      }
    }
  }
});
<!-- App.vue -->
<template>
  <div>
    <!-- 菜单区域 -->
    <nav>
      <ul>
        <li v-for="route in $router.options.routes" :key="route.name">
          <router-link :to="{ name: route.name }">
            {{ route.meta.title }}
          </router-link>
          <button @click="addPage(route.name)">+</button>
        </li>
      </ul>
    </nav>
    
    <!-- 选项卡区域 -->
    <div class="tabs">
      <div v-for="page in pageNames" :key="page" class="tab">
        <router-link :to="{ name: page }">
          {{ getTitle(page) }}
        </router-link>
        <button @click="removePage(page)">×</button>
      </div>
    </div>
    
    <!-- 页面内容区域 -->
    <keep-alive :include="pageNames">
      <router-view></router-view>
    </keep-alive>
  </div>
</template>

六、Keep-Alive 的属性详解

include 和 exclude 属性

功能:  精确控制哪些组件需要缓存

使用方式:

<!-- 数组形式 -->
<keep-alive :include="['Comp1', 'Comp2']">
  <component :is="currentComponent"></component>
</keep-alive>

<!-- 字符串形式 -->
<keep-alive include="Comp1,Comp2">
  <component :is="currentComponent"></component>
</keep-alive>

<!-- 正则表达式 -->
<keep-alive :include="/^Comp[1-2]/">
  <component :is="currentComponent"></component>
</keep-alive>

注意事项:  组件必须设置 name 属性才能被正确识别

max 属性

功能:  限制最大缓存组件数量,防止内存占用过多

使用示例:

<keep-alive :max="2">
  <component :is="currentComponent"></component>
</keep-alive>

淘汰机制:  当缓存数量超过 max 值时,移除最久未使用的组件缓存

七、生命周期变化

新增的生命周期钩子

  • activated:组件被激活(显示)时触发
  • deactivated:组件失活(隐藏)时触发

生命周期执行顺序

  1. 首次加载:created → mounted → activated
  2. 切换隐藏:deactivated
  3. 再次显示:activated(不重新创建实例)

使用示例

export default {
  name: 'MyComponent',
  activated() {
    console.log('组件激活');
    // 恢复计时器、重新请求数据等操作
  },
  deactivated() {
    console.log('组件失活');
    // 清除计时器、暂停耗时操作等
  }
}

八、Keep-Alive 的实现原理

数据结构

created() {
  this.cache = Object.create(null); // 缓存对象
  this.keys = []; // 缓存键数组
}

渲染流程

render() {
  const vnode = getFirstComponentChild(this.$slots.default);
  const key = /* 获取或生成key */;
  
  if (this.cache[key]) {
    // 重用缓存实例
    vnode.componentInstance = this.cache[key].componentInstance;
    // 更新 keys 数组顺序(LRU)
    this.keys.splice(this.keys.indexOf(key), 1);
    this.keys.push(key);
  } else {
    // 新建缓存
    this.cache[key] = vnode;
    this.keys.push(key);
    // 检查缓存限制
    if (this.max && this.keys.length > this.max) {
      pruneCacheEntry(this.cache, this.keys[0], this.keys);
    }
  }
  
  return vnode;
}

缓存淘汰机制

采用 LRU(最近最少使用)算法:

  • 每次访问将 key 移到数组末尾
  • 超出 max 时移除数组第一个 key 对应的缓存

九、最佳实践与注意事项

适用场景

  1. 需要保留表单输入状态的页面
  2. 包含复杂计算或网络请求的组件
  3. 需要保持滚动条位置的列表页
  4. 频繁切换的标签页或导航内容

注意事项

  1. 缓存组件会占用内存,需合理设置 max 值
  2. 动态组件切换时注意状态清理
  3. 对于简单组件,可能不需要使用 keep-alive
  4. 确保组件有唯一的 key 或 name 属性

性能优化建议

  1. 使用 include/exclude 精确控制缓存范围
  2. 对于复杂组件树,考虑分层缓存策略
  3. 结合路由元信息动态管理缓存

十、总结

Keep-Alive 是 Vue 中强大的组件缓存工具,面试官也经常问到这个知识。

核心要点回顾:

  • 使用 keep-alive 包裹需要缓存的组件
  • 通过 include/exclude 控制缓存范围
  • 使用 max 属性限制缓存数量
  • 利用 activated/deactivated 生命周期管理组件状态
  • 在需要保留状态的频繁切换场景中使用

高德地图-地图选择经纬度问题【使用输入提示-使用Autocomplete进行联想输入】(复盘)

作者 Gracemark
2025年9月16日 18:22

一、问题一:打开第一次弹框这个可以监听,然后关闭。然后使用其他按钮打开弹框,搜索关键词,这个select监听不到

49b42bb2c5277467cbc96119b931b953.jpg

解决问题复盘:

我先打开并浏览 src/views/wlhy/baidumap/bf9-16-2.vue,梳理其与 Map.vue 的差异点和事件绑定、销毁、输入处理等实现,以便做对照复盘。

[1 tool called]

我先通读了 src/views/wlhy/baidumap/bf9-16-2.vue,对照今天在 Map.vue 的修复点,做一次聚焦性的复盘与改进建议。

换个模型--问题陈述

  • 同样使用高德 AMap 的抽屉式地图选点;带搜索框、搜索按钮、结果面板 panel、地图标记与定位等能力。
  • 该文件你之前说明“不需要同步更改”,因此它保留了与 Map.vue 不同的实现策略。

关键实现与优点

  • 使用 :id="mapContainerId":id="tipInputId" 的动态容器与输入框 ID,理论上可以避免 DOM 复用导致的实例绑定陈旧问题。
  • 抽屉打开/关闭使用 @afterVisibleChange 驱动初始化,配合 MutationObserver、反复 resize()、甚至“移除并重建地图容器”的兜底路径,增强了在复杂布局/动画场景下的渲染稳定性。
  • 显式的“搜索按钮 + 结果面板 panel”交互,用户心智简单,便于定位问题。
  • 插件封装清晰:initAMapPlugins() 内完成 AutoCompletePlaceSearchToolBar 初始化与事件绑定。

核心风险与不一致点

  • AutoComplete 未使用动态 ID,实际绑定仍然是硬编码 'tipinput'

    • 现状:
      • 模板:<input ... :id="tipInputId" ...>
      • 插件初始化:document.getElementById('tipinput')input: 'tipinput'
    • 风险:当抽屉反复开关或 tipInputId 变化时,AutoComplete 可能绑定到不存在或旧的节点,导致选择事件丢失。
    • 建议:统一改为 document.getElementById(this.tipInputId)input: this.tipInputId
  • 方法重复定义,存在覆盖与维护风险

    • getCurrentPosition 定义了两次(L193 与 L1090 附近),后者会覆盖前者。
    • handleKeywordInput 定义了两次(L242 与 L765 附近),后者会覆盖前者。
    • 影响:行为以“最后一次定义为准”,前面实现与日志都失效,增加排查难度。
    • 建议:保留一份,删除重复,整合日志。
  • 资源释放方式与兼容性

    • 多处直接调用 this.autoComplete.destroy()this.placeSearch.destroy()(L408、L409、L889、L890、L1295、L1303),在某些版本/构造器上可能没有 destroy(),导致报错(这正是 Map.vue 中出现过的警告来源之一)。
    • 建议:调用前加 typeof ...destroy === 'function' 守卫,或使用事件解绑 + 置空的方式收尾。
  • 事件模型混用

    • PlaceSearch 使用了 .on('complete')/.on('error')(L705、L706),也使用了 search(kw, callback) 的回调风格(L249)。API 版本不同事件模型不同,容易引发“双通道回调”或空回调。
    • 建议:二选一,统一事件模型,减少重复触发。
  • 过度复杂的渲染兜底路径

    • 包含 MutationObserver、多次 resize()、定时器兜底、甚至“删除并重建地图容器”(L443-L451、L589-L663、L878-L926)。虽然鲁棒,但复杂度较高,后续维护成本与副作用(例如插件状态丢失)上升。
    • 建议:仅保留必要路径;若真实环境不再复现“容器尺寸为 0/首帧白屏”,可收敛策略。
  • 细节一致性

    • visible 监听中会条件初始化地图或强制重绘(L169-L176),同时 afterDrawerVisibleChange 也负责初始化(L455-L545),实际存在两处入口的竞态可能。
    • 建议:二选一,建议继续由 afterVisibleChange 统一入口。

与 Map.vue 的对照要点

  • Map.vue 的修复重心:
    • 动态输入框 ID 全链路应用(模板、插件初始化一致);
    • 事件绑定收敛:只保留 AutoComplete.on('select', ...) + PlaceSearch Fallback;
    • 关闭时“解绑事件 + 置空实例”而非强依赖 destroy()
    • 每次进入清空 keyword(而你明确说明此文件无需同步这一点)。
  • bf9-16-2.vue 更偏“工程兜底型”:
    • 更强的容器可见性检测与重建;
    • 明确的“按钮搜索 + 结果面板”;
    • 但仍有“硬编码 ID / 重复方法 / destroy 兼容性 / 双模型事件”的技术债。

建议的最小修正(不改变你“不需要同步更改”的产品行为)

  • 统一 AutoComplete 绑定为动态 ID:
    • initAMapPlugins()
      • document.getElementById('tipinput')document.getElementById(this.tipInputId)
      • input: 'tipinput'input: this.tipInputId
  • 移除方法重复定义:
    • 删除重复的 getCurrentPositionhandleKeywordInput,只保留一份。
  • 守卫 destroy()
    • 所有 destroy() 改为先判断类型,或仅做 事件解绑 + 置空
  • 事件模型统一:
    • PlaceSearch 只保留一种回调方式(建议保留 .on('complete')/.on('error')search(kw, cb) 二选一)。

验证点(按现有实现)

  • 首次打开:能初始化地图、AutoCompletePlaceSearch,按钮搜索可见 panel
  • 再次打开:依赖 afterDrawerVisibleChange + 观察器重试能恢复地图;若不改动 ID 硬编码,select 有概率因节点不匹配而不触发。
  • 关闭:会尝试销毁地图与插件;在某版本无 destroy() 的情况下,可能产生控制台警告。

如果你愿意,我可以按“最小修正”帮你把上述四点直接在 bf9-16-2.vue 中落地,保持交互不变,仅提升稳定性与一致性。

问题陈述

  • 打开抽屉第一次可以监听到高德 AutoCompleteselect 事件;关闭后再次打开,输入关键词搜索,select 事件不再触发。
  • 新需求:每次打开组件时清空搜索框 keyword

根因分析

  • 静态 DOM ID 复用导致实例绑定到了已销毁的输入框:id="tipinput" 在抽屉反复打开/关闭后,旧 DOM 被销毁但 AutoComplete 仍引用旧节点。
  • 事件与实例清理不彻底:地图与插件实例未完全释放,重复初始化下出现“事件未触发/触发到旧实例”的不一致。
  • 事件绑定方式混乱:多次尝试 on/choose/complete/addListener 等并行方案,进一步引发重复绑定、误判、竞态。

关键改动

  • 动态 ID 防陈旧绑定
    • data: 新增 tipInputId: 'tipinput-' + Date.now()
    • template: 关键词输入框改为 :id="tipInputId"
    • show(): 每次打开生成新 ID:this.tipInputId = 'tipinput-' + Date.now();
  • 规范初始化与销毁
    • initMap():
      • 地图重复进入时先安全销毁旧 mapGD
      • AMap.plugin 内部初始化 AutoCompletePlaceSearch
      • AutoComplete 仅保留核心:this.autoComplete.on('select', this.onAutocompleteSelect)
      • 地图 complete 后标记 mapInitialized=true 并执行 checkAndLocate()
    • close():
      • 解绑 AutoCompleteselect 事件(off('select', ...)),将实例置空。
      • 清理 currentMarker,销毁 mapGD,重置状态变量。
      • 清空输入数据(keyword/location/inputLng/inputLat)。
  • 选择结果落地逻辑统一
    • onAutocompleteSelect(e): 优先直接用 e.poi.location 定位、打点、更新 addrInfo,无 location 时再走 placeSearch
    • 新增 handlePOISelection(poi): 封装“根据 POI 更新地图与地址信息”,兼容多种 location 结构。
  • 输入法/选择的兜底
    • 保留 handleManualSelection(selectedText) 以便在极端情况下根据文本触发 placeSearch
    • 将早先会造成重复和干扰的 DOM 事件绑定(keydown/click/input 全局监听)移出默认流程,避免二次进入时重复绑定。
  • 体验优化
    • show() 首行清空关键词:this.keyword = '',满足“每次进入清空”的需求。
    • visible 变化时触发 map.resize(),避免首次渲染尺寸为 0 导致白屏。

验证结果

  • 首次打开:select 正常触发。
  • 关闭后再次打开:因 tipInputId 更新,AutoComplete 绑定到全新 DOM,select 可持续触发。
  • 关键词每次进入自动清空;若通过参数传入 keyword,会在后续逻辑中覆盖为传入值(兼容业务场景)。
  • 地图加载完成后能回显已选坐标点;手动经纬度搜索、拖拽标记、逆地理地址回显均正常。

已规避的历史错误

  • 调用不存在的 destroy():所有销毁调用前均做了类型检查或改为安全置空。
  • 事件重复绑定与全局监听泄漏:通过在 close() 中集中清理,且默认不再附加额外 DOM 监听,避免重复与干扰。
  • 依赖 complete 事件做选择:统一以 select 为主,placeSearch 为 fallback。

可能的后续优化(可选)

  • 如业务需要跨城市联动:在城市输入变更时调用 autoComplete.setCity()placeSearch.setCity()
  • 若需保持键盘回车搜索体验,可单独加按钮触发 placeSearch.search(keyword),避免全局 DOM 监听。

结论

  • 根因是“静态 DOM ID 导致的陈旧引用 + 实例/事件清理不彻底”。通过“动态 ID + 严格初始化/销毁 + 事件收敛”彻底解决。
  • 新需求“每次进入清空关键词”已在 show() 中生效,不影响传参覆盖。
  • 当前 Map.vue 处于可复现、可维护、行为稳定的状态。

问题二、切换打开这个组件,控制台打印地图加载完成,但是页面地图区域空白。

f91e6e17a819164adbdfc80ca3a08223.png

79884a1550e03315d93f6a1583711c0a.jpg

我先复盘,再给出可执行的修复要点与自检清单。

复盘:为什么“加载完成”但页面空白

  • 地图实例在容器尺寸为 0(抽屉未完成展开)时初始化,AMap 内部 canvas 尺寸为 0,导致空白。
  • 二次打开时沿用上次实例或残留 DOM,或插件未重新绑定到新 map 实例,导致渲染异常。
  • 关键词输入框和搜索插件未对齐(动态 id/DOM 还没渲染就初始化),引发搜索正常但地图未刷新中心或未重绘。
  • 仅在 visible 改变时调用 resize,时机过早,抽屉动画未结束,仍为 0 尺寸。

已做的关键修复(Map.vue)

  • 延后初始化到抽屉动画完成后,并且只在容器尺寸 > 0 时创建地图:
    • 使用抽屉的 @afterVisibleChangeMutationObserver 监听容器尺寸,尺寸有效时再 initMap(),否则等待。
  • 强约束容器存在性:
    • 若地图容器被移除,重新创建 div#mapContainerId 并挂回父节点,再初始化地图。
  • 地图实例重建和强制重绘:
    • 旧实例存在则先销毁;完成后强制多次 mapGD.resize(),并在插件加载后再次 resize()
  • 插件安全重绑:
    • AutoCompletePlaceSearch 每次初始化都销毁旧实例,绑定到当前 mapGD,并保证输入框、面板 DOM 存在后再初始化。
  • 运行时防御与兜底:
    • 如果有初始经纬度,初始化后立即定位并打点;否则尝试浏览器定位。
    • 增加调试日志,便于定位是“容器未就绪”还是“插件未就绪”。

推荐落地做法(更稳)

  • 容器高度用固定 CSS 类而非运行时动态改样式,避免“动态样式”引发的新问题:
    • .map-gaode-container 配置固定高度(如 500px),抽屉内布局不要依赖内容自适应高度。
  • 初始化时序:
    • 抽屉 afterVisibleChange(true) → 容器尺寸检测通过 → new AMap.Map(...) → 插件加载 → resize() → 定位/打点。
  • 二次打开:
    • 关闭时销毁 mapGD 和插件、移除容器或清空容器;打开时按上面流程重建。

快速自检清单(排查空白的关键节点)

  • 抽屉刚打开时 #mapContainerIdoffsetWidth/offsetHeight 是否 > 0?
  • 初始化后 container.querySelector('canvas') 是否存在,canvas 宽高是否为 0?
  • mapGD.getSize() 是否非 0?mapGD.resize() 是否被调用过(并在抽屉动画后调用)?
  • 插件是否绑定到当前 mapGD,输入框/面板 DOM 是否已渲染后再初始化?
  • 关闭后是否有彻底 destroy(),二次打开是否复用旧 DOM/实例?

如果你更倾向“不用动态样式”,我可以把目前 watch 里设置容器内联样式的逻辑改成纯 CSS 类方式,并保留上述延后初始化与 resize 策略。你要我直接改成纯 CSS 版本吗?

【手写组件】 Vue3 + Uniapp 手写一个高颜值日历组件(含跨月补全+今日高亮+选中状态)

作者 天下无贼
2025年9月16日 16:59

文章目录

日历组件实现

一个基于 Vue3 + Composition API 的日历组件,功能包括:

功能概览

功能点 实现情况
月份切换 ✅ 通过 handleLastMonth / handleNestMonth 实现
今日高亮 ✅ 使用 isToday 判断并加样式
选中日期 ✅ 使用 SelectedDate 存储并高亮
跨月补全 ✅ 上月和下月的日期用 isOtherMonth 标记并灰显
响应式布局 ✅ 使用 grid + aspect-ratio 实现正方形格子
样式变量 ✅ 使用 CSS 变量(如 --primary, --gary_light

代码亮点

  1. 逻辑清晰:用 computed 生成 days 数组,结构干净。
  2. 日期处理封装得好getDateformatDate 方法复用性强。
  3. 样式语义化:类名如 .today, .selected, .other-month 非常直观。
  4. 组件化思维:使用 TagCop 作为子组件,符合 uni-app 风格。

接下来开始我们的代码之旅😄:在这里插入图片描述

基础功能搭建

首先创建模板结构:

<template>
  <div class="calendarCop">
    <!-- 日历顶部栏 -->
    <div class="calendarCop-header"></div>
    <!-- 日历星期栏 -->
    <div class="calendarCop-weekdays">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </div>
    <!-- 日历 -->
    <div class="calendarCop-days"></div>
  </div>
</template>

<script setup></script>

<style scoped lang="scss">
.calendarCop {
  background-color: var(--gary_light);
  padding: 16rpx;
  border-radius: var(--radius);
  .calendarCop-header {
  }
  .calendarCop-weekdays {
  }
  .calendarCop-days {
  }
}
</style>

搭建日历顶部栏结构:

创建出顶部栏需要展示的空间,分别有分布于左侧的切换至上个月图标按钮和右侧的切换至下个月图标按钮,以及中间年月份展示区

<!-- 日历顶部栏 -->
<div class="calendarCop-header">
    <!-- 顶部栏月份切换区 -->
    <div class="changeMouth">
        <!-- 切换至上个月图标按钮 -->
        <span class="left">
            <uni-icons type="left" size="24" color="#23ad1e"> </uni-icons>
        </span>
        <!-- 年月份展示区 -->
        <p class="data">2025 年 9 月</p>
        <!-- 切换至下个月图标按钮 -->
        <span class="right">
            <uni-icons type="right" size="24" color="#23ad1e"></uni-icons>
        </span>
    </div>
</div>

接下来编写样式:

.calendarCop-header {
    .changeMouth {
        display: inline-flex;
        align-items: center;
        gap: 16rpx;
        height: 50rpx;
        .left,
        .right {
            font-weight: 900;
        }
        .data {
            font-size: 36rpx;
            line-height: 50rpx;
        }
    }
}

效果:

image-20250916100540390

现在结构已经搭建好了,逻辑交互等日历日期渲染出来了再做。

搭建日历星期栏样式:

直接使用网格布局将七个星期都渲染出来,然后再添加一些修饰就完成啦。

  .calendarCop-weekdays {
    color: var(--primary_dark);
    font-weight: 900;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    padding-bottom: 8rpx;
    margin-bottom: 8rpx;
    border-bottom: 4rpx solid var(--gary_dark);
  }

效果:

image-20250916101310240

渲染日历日期

接下来就是重头戏了,要想渲染出时间日期,我们就要请出Date时间对象来。

先来获取到当前年|月|日数据:

  • 这里使用ref响应式创建了一个时间对象
  • 然后用计算属性分别计算出CurrentDate时间对象的年|月|日信息
/* 当前日期时间 */
// 获取当前时间对象
const CurrentDate = ref(new Date());
// 获取当前年份
const Year = computed(() => CurrentDate.value.getFullYear());
// 获取当前月份
const Month = computed(() => CurrentDate.value.getMonth());
// 获取当前日期
const Today = computed(() => CurrentDate.value.getDate());

拿到了日期时间后,就可以在日历顶部栏中替换掉之前写死的年月份

<!-- 年月份展示区 -->
<p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>

⚠注意:时间对象中拿到的月份是从0开始的,所以相比较于现实的月份会少1,所以要在渲染月份的时候加上1

写一个获取日期对象方法

const getDate = ({ year, month, day } = {}) =>
  new Date(year ?? Year.value, month ?? Month.value, day ?? Today.value);

生成日期数据:

/* 生成日期数据 */
const days = computed(() => {
  const result = [];
  // 获取每个月的第一天和最后一天
  const firstDay = getDate({ day: 0 });
  const lastDay = getDate({ month: Month.value + 1, day: 0 });
  // 通过遍历来渲染所有日期
  for (let i = 1; i <= lastDay.getDate(); i++) {
     const date = getDate({ year: Year.value, month: Month.value, day: i });
    result.push({
      date,
      text: i,
    });
  }
  return result;
});

整体逻辑就是先拿 lastDay 定出本月共有多少天,然后从 1 号循环到该天数,每天调用 getDate 生成一个 Date 对象塞进数组,最终得到“本月所有日期”列表。

我们可以打印一下days来观察数据长啥样:console.log("🚀:", days.value);

image-20250916110935784

接下来将日期数据渲染到模板上:

    <!-- 日历 -->
    <div class="calendarCop-days">
      <div class="item" v-for="day in days" :key="day.date">
        <div class="day">
          {{ day.text }}
        </div>
      </div>
    </div>

// 样式
  .calendarCop-days {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 8rpx;
    .item {
      font-size: 32rpx;
      aspect-ratio: 1; // 宽=高,正方形
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .day {
      }
    }
  }

如今,日历已经有初步形态:

image-20250916113420544

接下来完成今日日期显示:

给日期格子添加上样式类名和并且准备好样式:

<div class="calendarCop-days">
    <div class="item" v-for="day in days" :key="day.date">
        <div
             :class="{
                     day: true,
                     base: true,
                     today: isToday(day.date),
                     }"
             >
            {{ day.text }}
        </div>
    </div>
</div>

// 样式:
.base {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: var(--radius);
    font-weight: 900;
}
.today {
    color: var(--primary_dark);
    background: var(--primary_light);
}

判断是否为今天isToday方法:

// 格式化日期方法
const formatDate = (date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
    2,
    "0"
  )}-${String(date.getDate()).padStart(2, "0")}`;

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);

这里补充一下padStart方法:

padStart 只做一件事:把字符串“补长”到指定长度,不够就在前面补规定的字符。

语法:str.padStart(目标长度, 补什么)

例如:

'5'.padStart(2, '0')   // 长度=1,差1位 →补0 → '05'
'12'.padStart(2, '0')  // 长度已够 → 原样返回 '12'

效果:

image-20250916135515932

处理选中日期效果:

同样的,先添加上选中的类名和样式效果:

<div
     :class="{
             day: true,
             base: true,
             today: isToday(day.date),
             selected: isSelected(day.date),
             }"
     @click="selectDay(day.date)"
     >
    {{ day.text }}
</div>

// 样式:⚠注意selected类名要在today下方,这样选中效果才能覆盖掉today样式
.today {
    color: var(--primary_dark);
    background: var(--primary_light);
}
.selected {
color: #fff;
background: var(--primary);
}

编写逻辑:

/* 选择日期相关 */
// 选中日期
const SelectedDate = ref(null);
// 选中日期方法
const selectDay = (date) => {
  SelectedDate.value = formatDate(date);
};
const isSelected = (date) => SelectedDate.value === formatDate(date);
// 初始化选中今天
onMounted(() => {
  SelectedDate.value = formatDate(today_date);
});

现在选中效果也做好啦:

image-20250916140133877

回到今日:

<div class="calendarCop-header">
    <!-- 顶部栏月份切换区 -->
    <div class="changeMouth">
        <!-- ... -->
    </div>
    <TagCop
            class="selectToday"
            text="今日"
            backgroundColor="var(--primary_light)"
            @click="selectToday"
            />
</div>

⚠这个<TagCop>就是一个标签组件而已啦!

添加回到今日方法:

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);
const selectToday = () => {
  CurrentDate.value = today_date;
  selectDay(today_date);
};

效果:

image-20250916143412219

月份切换

现在来制作月份切换效果:

给图标绑定好切换方法:

<!-- 切换至上个月图标按钮 -->
<uni-icons
           class="left"
           type="left"
           size="24"
           color="#23ad1e"
           @click="handleLastMonth"添加
           >
</uni-icons>
<!-- 年月份展示区 -->
<p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>
<!-- 切换至下个月图标按钮 -->
<uni-icons
           class="right"
           type="right"
           size="24"
           color="#23ad1e"
           @click="handleNestMonth"添加
           >
</uni-icons>

编写方法:

/* 月份切换相关 */
const handleLastMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value - 1,
    day: 1,
  });
};
const handleNestMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value + 1,
    day: 1,
  });
};

现在月份可以切换了,但是每个日期对应的星期没有正确分布出来,接下来就需要引入上个月的日期,才能保证后面星期数是对的上的。

月份补充

为了方便理解,先记住 3 个前提:

  1. getDate({ year, month, day }) 内部就是 new Date(year, month, day) – 月份从 0 开始(0=1 月 … 11=12 月) – 如果 day=0 会得到“上个月的最后一天”,day=-n 会得到“上个月倒数第 n 天”——这是 JS Date 的天生能力。
  2. 组件要求日历从周一开头(模板里周一在第一个格子)。
  3. 一行 7 格,总共 5 行 = 35 格。 如果“上月补头 + 当月天数 + 下月补尾”不足 35,就再补 7 格,凑够 42 格(6 行)。

可视化说明:

gap = 5(周一到周五共 5 天)
头补:12728293031 日

当月:1 日 … 28 日
已用:5 + 28 = 33

remains = 35 - 33 = 2
尾补:312 日
最终数组长度:35

上月补充(补“头部”)

// 1. 当月 1 号
const firstDay = getDate({ day: 1 });

// 2. 当月 1 号是星期几? 0=周日 1=周一 ... 6=周六
const startDayOfWeek = firstDay.getDay(); // 例如 3 → 周三

// 3. 要补几个空位?
//    我们想让它从“周一”开始,所以:
//    周一 → 补 0 个
//    周二 → 补 1 个
//    ...
//    周日 → 补 6 个
const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;

举例:

  • 2025-09-01 是周一 → startDayOfWeek=1gap=0不补
  • 2025-10-01 是周三 → startDayOfWeek=3gap=2补 2 天
本月1号 周日 周一 周二 周三 周四 周五 周六
getDay() 0 1 2 3 4 5 6
需补几天 6 0 1 2 3 4 5

所以就可以通过这一特性,当作遍历次数:

/* 上月补充 */
// 获取第一天的星期数
const startDayOfWeek = firstDay.getDay(); // 0=周日
// 获取上个月最后一天(从周一开始算,所以要调整偏移)
const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
for (let i = gap; i > 0; i--) {
    // 倒序生成日期对象
    const date = getDate({ year: Year.value, month: Month.value, day: -i });
    result.push({
        date,
        text: date.getDate(),
        isOtherMonth: true,
    });
}

下月补充(补“尾部”)

实现原理:

// 1. 已经装了几天?
const already = result.length; // 头补 + 当月天数

// 2. 一共想要 35 格(5 行),不够就再补 7 格,凑够 42 格
const remains = 5 * 7 - already; // 可能为 0 甚至负数

如果 remains ≤ 0 说明 35 格已够,就不会再进循环; 如果 remains > 0 就继续往后数数:

/* 下月补充 */
  const remains = 5 * 7 - result.length;
  for (let i = 1; i <= remains; i++) {
    const date = getDate({ year: Year.value, month: Month.value + 1, day: i });
    result.push({
      date,
      text: i,
      isOtherMonth: true,
    });
  }

技巧点

  • month: Month.value + 1 如果原来是 11(12 月),+1 变成 12,JS 会自动变成下一年 0 月(1 月),无需手写跨年逻辑。
  • 只补到 35 格,保证永远是完整 5 行;如果想固定 6 行,可把 5*7 改成 6*7

完成效果:

image-20250916155023574

完整代码:

<template>
  <div class="calendarCop">
    <!-- 日历顶部栏 -->
    <div class="calendarCop-header">
      <!-- 顶部栏月份切换区 -->
      <!-- 年月展示区 -->
      <div class="changeMouth">
        <!-- 切换至上个月图标按钮 -->
        <uni-icons
          class="left"
          type="left"
          size="24"
          color="#23ad1e"
          @click="handleLastMonth"
        >
        </uni-icons>
        <!-- 年月份展示区 -->
        <p class="data">{{ Year }} 年 {{ Month + 1 }} 月</p>
        <!-- 切换至下个月图标按钮 -->
        <uni-icons
          class="right"
          type="right"
          size="24"
          color="#23ad1e"
          @click="handleNestMonth"
        ></uni-icons>
      </div>
      <!-- 回到今日 -->
      <TagCop
        class="selectToday"
        text="今日"
        backgroundColor="var(--primary_light)"
        @click="selectToday"
      />
      <!-- 更多操作 -->
      <uni-icons
        v-show="false"
        type="more-filled"
        class="more"
        size="24"
        color="#23ad1e"
      ></uni-icons>
    </div>
    <!-- 日历星期栏 -->
    <div class="calendarCop-weekdays">
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
      <div></div>
    </div>
    <!-- 日历 -->
    <div class="calendarCop-days">
      <div class="item" v-for="day in days" :key="day.date">
        <div
          :class="{
            day: true,
            base: true,
            today: isToday(day.date),
            selected: isSelected(day.date),
            'other-month': day.isOtherMonth,
          }"
          @click="selectDay(day.date)"
        >
          {{ day.text }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
import TagCop from "@/components/base/tag-cop";

/* 当前日期时间 */
// 获取当前时间对象
const CurrentDate = ref(new Date());
// 获取当前年份
const Year = computed(() => CurrentDate.value.getFullYear());
// 获取当前月份
const Month = computed(() => CurrentDate.value.getMonth());
// 获取当前日期
const Today = computed(() => CurrentDate.value.getDate());

// 获取日期对象方法
const getDate = ({ year, month, day } = {}) =>
  new Date(year ?? Year.value, month ?? Month.value, day ?? Today.value);
// 格式化日期方法
const formatDate = (date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
    2,
    "0"
  )}-${String(date.getDate()).padStart(2, "0")}`;

/* 生成日期数据 */
const days = computed(() => {
  const result = [];
  // 获取每个月的第一天和最后一天
  const firstDay = getDate({ day: 1 });
  const lastDay = getDate({ month: Month.value + 1, day: 0 });
  /* 上月补充 */
  // 获取第一天的星期数
  const startDayOfWeek = firstDay.getDay(); // 0=周日
  // 获取上个月最后一天(从周一开始算,所以要调整偏移)
  const gap = startDayOfWeek === 0 ? 6 : startDayOfWeek - 1;
  for (let i = gap; i > 0; i--) {
    // 倒序生成日期对象
    const date = getDate({ year: Year.value, month: Month.value, day: -i });
    result.push({
      date,
      text: date.getDate(),
      isOtherMonth: true,
    });
  }

  /* 本月日期 */
  // 通过遍历来渲染所有日期
  for (let i = 1; i <= lastDay.getDate(); i++) {
    const date = getDate({ year: Year.value, month: Month.value, day: i });
    result.push({
      date,
      text: i,
    });
  }

  /* 下月补充 */
  const remains = 5 * 7 - result.length;
  for (let i = 1; i <= remains; i++) {
    const date = getDate({ year: Year.value, month: Month.value + 1, day: i });
    result.push({
      date,
      text: i,
      isOtherMonth: true,
    });
  }

  return result;
});

/* 今日日期 */
const today_date = new Date();
const isToday = (date) => formatDate(today_date) === formatDate(date);
const selectToday = () => {
  CurrentDate.value = today_date;
  selectDay(today_date);
};

/* 选择日期相关 */
// 选中日期
const SelectedDate = ref(null);
// 选中日期方法
const selectDay = (date) => {
  SelectedDate.value = formatDate(date);
};
const isSelected = (date) => SelectedDate.value === formatDate(date);
// 初始化选中今天
onMounted(() => {
  SelectedDate.value = formatDate(today_date);
});

/* 月份切换相关 */
const handleLastMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value - 1,
    day: 1,
  });
};
const handleNestMonth = () => {
  CurrentDate.value = getDate({
    year: Year.value,
    month: Month.value + 1,
    day: 1,
  });
};
</script>

<style scoped lang="scss">
.calendarCop {
  background-color: var(--gary_light);
  padding: 16rpx;
  border-radius: var(--radius_big);
  .calendarCop-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    .more {
      transform: rotate(90deg);
    }
    .changeMouth {
      display: inline-flex;
      align-items: center;
      gap: 16rpx;
      height: 50rpx;
      .left,
      .right {
        font-weight: 900;
      }
      .data {
        font-size: 36rpx;
        line-height: 50rpx;
      }
    }
  }
  .calendarCop-weekdays {
    color: var(--primary_dark);
    font-weight: 900;
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    text-align: center;
    padding-bottom: 8rpx;
    margin: 8rpx 0;
    border-bottom: 4rpx solid var(--gary_dark);
  }
  .calendarCop-days {
    display: grid;
    grid-template-columns: repeat(7, 1fr);
    gap: 8rpx;
    .item {
      font-size: 32rpx;
      aspect-ratio: 1; // 宽=高,正方形
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      .day {
      }
      .other-month {
        color: var(--gary_dark);
      }
      .base {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: var(--radius);
        font-weight: 900;
      }
      .today {
        color: var(--primary_dark);
        background: var(--primary_light);
        position: relative;
        &::after {
          content: "今";
          font-size: 18rpx;
          position: absolute;
          top: 4rpx;
          right: 8rpx;
        }
      }
      .selected {
        color: #fff;
        background: var(--primary);
      }
    }
  }
}
</style>

最终效果: image-20250916164921068

🧩 未来迭代方向

  1. 把“选中”改成 v-model 双向绑定 父组件 <CalendarCop v-model="date" /> 就能直接拿到日期。
  2. 加“事件点” 父组件传 events: Record<'yyyy-mm-dd', {dot?: boolean, text?: string, color?: string}>, 日历在对应格子画小圆点/小标签。
  3. 支持“范围选择”SelectedDate: Ref<string> 升级成 SelectedRange: Ref<{start?: string; end?: string}>, 点击逻辑改为:
    • 第一次点 → start
    • 第二次点 → end(若再点则清空重设) 样式层加 .in-range 做背景条。
  4. 支持“多选” SelectedDates: Set<string>,点击 toggle,样式加 .selected 即可。
  5. 加“ swipe 手势” 因为你在 uni-app,可以直接用 @touchstart/@touchend 算滑动距离, 或者引 uni-swiper-action 做整月滑动切换。
  6. 加“农历/节假日”text: number 拓展成 text: number | {solar: number; lunar: string; festival?: string}, 下面再画一行小字。

image.png

vue3项目不支持低版本的android,如何做兼容

作者 洋葱头_
2025年9月16日 16:00

vue3 项目做 Android 低版本(尤其是 Android 5/6 及以下的 WebView 和旧浏览器)兼容处理是一个系统性的工程。你需要从语法转译、APIpolyfill、构建配置等多个方面入手。

以下是详细的步骤和解决方案:

核心问题分析

Android 低版本浏览器(如 Android 5.1 自带的 WebView 和 Chrome 40~50)的主要问题:

  1. 不支持 ES6+ 语法(如 const/let, 箭头函数, Promiseasync/awaitClass)。
  2. 缺少现代 Web API(如 fetchObject.assignArray.prototype.includes)。
  3. 不支持 Vue 3 运行时所依赖的现代 JavaScript 特性

系统性兼容方案

第一步:语法转译与 API Polyfill (最关键的一步)

这是兼容的基础,主要依靠 Babel 和 core-js

  1. 安装核心依赖

    bash

    npm install --save-dev @babel/core @babel/preset-env @vue/babel-preset-app
    npm install --save core-js regenerator-runtime # 注意 core-js 是 --save
    
    • @babel/preset-env: 智能预设,根据目标浏览器决定需要转译的语法和引入的 polyfill。
    • core-js: JavaScript 标准库的 polyfill,提供了几乎所有新 API 的实现。
    • regenerator-runtime: 用于转译 async/await 和 generator 函数。
  2. 创建或修改 Babel 配置文件 (babel.config.js) :

    javascript

    module.exports = {
      presets: [
        [
          '@babel/preset-env',
          {
            // 1. 指定需要兼容的安卓低版本
            // 这里的参数意味着“兼容所有浏览器的最新两个版本,但必须兼容安卓4.4以上”
            // targets: { android: '4.4' } 或直接指定版本
            targets: {
              android: '4.4', // 非常重要!指定最低安卓版本
              // 也可以更精确地指定浏览器版本
              // browsers: ['last 2 versions', 'android >= 4.4', 'ie >= 11'] 
            },
            // 2. 核心配置:按需加载 polyfill 和语法转译
            useBuiltIns: 'usage', // 'usage' 表示只引入代码中用到的 polyfill
            corejs: 3, // 指定 core-js 的版本,必须与安装的版本一致(推荐3)
            // 3. 确保转换所有 ES6+ 模块语法
            modules: false, // 让 webpack 处理模块化,babel 只做语法转换
          },
        ],
      ],
    };
    
  3. 在项目入口文件 (main.js 或 src/main.ts) 顶部引入 polyfill:

    javascript

    // 必须放在最前面,最先执行
    import 'core-js/stable'; // 提供 API polyfill
    import 'regenerator-runtime/runtime'; // 提供 async/await 支持
    
    import { createApp } from 'vue';
    import App from './App.vue';
    
    createApp(App).mount('#app');
    

第二步:配置 Vue CLI 或 Vite (构建工具)

如果你是使用 Vue CLI 创建的项目:
Vue CLI 内部已经集成了 Babel 和 polyfill 的配置,但你仍需检查和覆盖。

  1. 检查根目录下的 package.json 或 vue.config.js 文件中的 browserslist 字段。这是 Vue CLI 和许多工具共享目标浏览器配置的地方。

    json

    // package.json
    {
      "browserslist": [
        "> 1%",
        "last 2 versions",
        "Android >= 4.4", // 添加这一行
        "not dead"
      ]
    }
    

    这个配置会被 @babel/preset-env 自动读取。

  2. (可选) 创建 vue.config.js 进行高级配置:

    javascript

    const { defineConfig } = require('@vue/cli-service')
    
    module.exports = defineConfig({
      transpileDependencies: true, // 默认为 true,会转译 node_modules 中的依赖
      // 配置 webpack 的 loader,确保 node_modules 里的依赖也被正确转译
      chainWebpack: config => {
        config.module
          .rule('js')
          .include.add(/node_modules/(.*my-es6-module.*)/) // 如果需要转译特定node_modules包
      }
    })
    

如果你是使用 Vite 创建的项目:
Vite 默认使用 ESBuild 进行构建,速度极快,但ESBuild 不做语法降级。因此需要额外的插件。

  1. 安装 Vite 的降级插件

    bash

    npm install --save-dev @vitejs/plugin-legacy
    
  2. 配置 vite.config.js:

    javascript

    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import legacy from '@vitejs/plugin-legacy' // 引入插件
    
    // https://vitejs.dev/config/
    export default defineConfig({
      plugins: [
        vue(),
        // 配置 legacy 插件
        legacy({
          targets: ['android >= 4.4', 'iOS >= 9'], // 指定目标版本
          modernPolyfills: true // 为现代浏览器也提供必要的 polyfill
        })
      ]
    })
    

    @vitejs/plugin-legacy 插件会自动为你生成两套包:一套给现代浏览器,一套给旧浏览器,并自动在旧浏览器中注入所需的 polyfill。

第三步:处理第三方库

有些第三方库可能使用了未转译的 ES6+ 语法,即使你自己的代码转译了,它们也会在低版本浏览器中报错。

  1. 将第三方库加入 Babel 转译范围
    在 vue.config.js 中,transpileDependencies 选项默认是 true,这会转译所有 node_modules 中以 vue@vue@vuepressvuexvue-router 等开头的依赖。如果你的问题库不在此列,需要显式添加:

    javascript

    // vue.config.js
    module.exports = {
      transpileDependencies: true, // 转译所有依赖(不推荐,慢)
      // 或者更精确地指定
      transpileDependencies: ['my-es6-library', 'another-es6-package'],
    }
    

第四步:验证与调试

  1. 构建并测试

    bash

    npm run build
    

    将 dist 目录部署到服务器,然后使用真实的低版本 Android 设备浏览器开发者工具的模拟功能进行测试。

  2. 使用 Can I Use
    访问 caniuse.com 查询特定 API 或语法在目标 Android 版本中的支持情况。

  3. 查看打包结果
    运行 npm run build -- --report(Vue CLI)或使用 vite-bundle-analyzer(Vite)分析最终生成的包,检查 polyfill 是否被正确引入。

总结 checklist

  1. 安装 polyfill 依赖core-js 和 regenerator-runtime

  2. 配置 Babel:在 babel.config.js 中设置 targets 和 useBuiltIns: 'usage'

  3. 入口文件引入:在 main.js 最顶部引入 core-js 和 regenerator-runtime

  4. 配置构建工具

    • Vue CLI:检查 package.json 中的 browserslist 字段。
    • Vite:安装并配置 @vitejs/plugin-legacy
  5. 处理第三方库:必要时在 vue.config.js 中配置 transpileDependencies

  6. 真实环境测试:务必在真机或可靠的模拟环境下进行最终测试。

通过以上步骤,你的 Vue 3 应用应该可以顺利在 Android 低版本系统上运行。

Vue3 + Element Plus 输入框省略号插件:零侵入式全局解决方案

2025年9月16日 15:49

🚀 Vue3 + Element Plus 输入框省略号插件:零侵入式全局解决方案

📖 前言

在日常开发中,我们经常会遇到输入框内容过长需要显示省略号的需求。传统的做法是在每个组件中手动添加样式和逻辑,但这种方式存在以下问题:

  • 重复代码:每个输入框都要写一遍相同的逻辑
  • 维护困难:样式分散在各个组件中,难以统一管理
  • 容易遗漏:新增输入框时容易忘记添加省略号功能
  • 性能问题:每个组件都要单独处理,没有统一的优化

今天我将分享一个零侵入式的全局解决方案,通过 Vue3 插件的方式,自动为所有 `el-input` 输入框添加省略号显示和悬浮提示功能。

🎯 功能特性

  • 完全自动化:无需在任何组件中手动添加代码
  • 智能监听:自动处理动态添加的输入框
  • 性能优化:使用 WeakSet 避免重复处理
  • 类型安全:完整的 TypeScript 支持
  • 内存友好:完善的事件监听器清理机制
  • 响应式:支持窗口大小变化时重新计算

🛠️ 技术实现

核心思路

我们的解决方案基于以下几个核心技术:

  1. MutationObserver:监听 DOM 变化,自动处理动态添加的输入框
  2. WeakSet:记录已处理的元素,避免重复处理
  3. Vue3 插件系统:通过插件方式全局注册功能
  4. 事件委托:统一管理事件监听器

完整代码实现

/**
 * el-input 省略号全局插件
 * 自动为所有 el-input 输入框添加省略号显示和悬浮提示功能
 * 不包含 textarea 类型
 */

class InputEllipsisManager {
  private observer: MutationObserver | null = null
  private processedElements = new WeakSet<HTMLElement>()

  constructor() {
    this.init()
  }

  init() {
    // 等待 DOM 加载完成后开始处理
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', () => this.startObserving())
    } else {
      this.startObserving()
    }
  }

  private startObserving() {
    // 处理已存在的元素
    this.processExistingElements()

    // 创建 MutationObserver 监听 DOM 变化
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach((mutation) => {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach((node) => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.processElement(node as HTMLElement)
            }
          })
        }
      })
    })

    // 开始观察
    this.observer.observe(document.body, {
      childList: true,
      subtree: true
    })
  }

  private processExistingElements() {
    // 处理页面中已存在的所有 el-input
    const inputs = document.querySelectorAll('.el-input:not(.el-textarea)')
    inputs.forEach(input => this.processElement(input as HTMLElement))
  }

  private processElement(element: HTMLElement) {
    // 如果已经处理过,跳过
    if (this.processedElements.has(element)) {
      return
    }

    // 查找 el-input 元素
    const inputs = element.classList?.contains('el-input') && !element.classList?.contains('el-textarea')
      ? [element]
      : Array.from(element.querySelectorAll?.('.el-input:not(.el-textarea)') || [])

    inputs.forEach(inputEl => {
      if (this.processedElements.has(inputEl)) {
        return
      }

      this.processedElements.add(inputEl)
      this.addEllipsisToInput(inputEl)
    })
  }

  private addEllipsisToInput(inputEl: HTMLElement) {
    const inputInner = inputEl.querySelector('.el-input__inner') as HTMLInputElement
    
    if (!inputInner || inputInner.tagName.toLowerCase() === 'textarea') {
      return
    }

    // 添加省略号样式
    inputInner.style.textOverflow = 'ellipsis'
    inputInner.style.whiteSpace = 'nowrap'
    inputInner.style.overflow = 'hidden'

    // 创建更新提示的函数
    const updateTooltip = () => {
      const text = inputInner.value || inputInner.placeholder || ''
      if (text && inputInner.scrollWidth > inputInner.clientWidth) {
        inputInner.title = text
      } else {
        inputInner.removeAttribute('title')
      }
    }

    // 添加事件监听器
    const events = ['input', 'focus', 'blur', 'change']
    events.forEach(eventType => {
      inputInner.addEventListener(eventType, updateTooltip)
    })

    // 初始检查
    updateTooltip()

    // 监听窗口大小变化
    const resizeHandler = () => {
      setTimeout(updateTooltip, 100)
    }
    window.addEventListener('resize', resizeHandler)

    // 保存清理函数
    ;(inputEl as any)._ellipsisCleanup = () => {
      events.forEach(eventType => {
        inputInner.removeEventListener(eventType, updateTooltip)
      })
      window.removeEventListener('resize', resizeHandler)
    }
  }

  // 公共方法:手动刷新所有输入框的省略号状态
  public refreshInputEllipsis() {
    this.processExistingElements()
  }

  // 销毁方法
  public destroy() {
    if (this.observer) {
      this.observer.disconnect()
    }
    
    // 清理所有已处理元素的事件监听器
    document.querySelectorAll('.el-input').forEach(inputEl => {
      if ((inputEl as any)._ellipsisCleanup) {
        ;(inputEl as any)._ellipsisCleanup()
        delete (inputEl as any)._ellipsisCleanup
      }
    })
  }
}

// 创建全局实例
let ellipsisManager: InputEllipsisManager | null = null

// Vue 插件定义
export default {
  install(app: any) {
    // 在应用挂载后启动
    app.mixin({
      mounted() {
        if (!ellipsisManager) {
          ellipsisManager = new InputEllipsisManager()
        }
      }
    })

    // 提供全局方法
    app.config.globalProperties.\$refreshInputEllipsis = () => {
      if (ellipsisManager) {
        ellipsisManager.refreshInputEllipsis()
      }
    }
  }
}

// 导出管理器类(可选,用于高级用法)
export { InputEllipsisManager }

关键代码解析

1. MutationObserver 监听 DOM 变化

this.observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.type === 'childList') {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === Node.ELEMENT_NODE) {
          this.processElement(node as HTMLElement)
        }
      })
    }
  })
})

作用:自动监听页面中新增的 DOM 元素,确保动态添加的输入框也能被处理。

2. WeakSet 避免重复处理

private processedElements = new WeakSet<HTMLElement>()

if (this.processedElements.has(element)) {
  return
}
this.processedElements.add(element)

作用:使用 WeakSet 记录已处理的元素,避免重复处理同一个输入框,提高性能。

3. 智能省略号检测

const updateTooltip = () => {
  const text = inputInner.value || inputInner.placeholder || ''
  if (text && inputInner.scrollWidth > inputInner.clientWidth) {
    inputInner.title = text
  } else {
    inputInner.removeAttribute('title')
  }
}

作用:通过比较 `scrollWidth` 和 `clientWidth` 来判断内容是否超出,只有超出时才显示悬浮提示。

📦 安装使用

1. 创建插件文件

将上述代码保存为 `src/plugins/inputEllipsis.ts`

2. 在 main.js 中注册插件

import { createApp } from 'vue'
import App from '@/App.vue'
import inputEllipsisPlugin from '@/plugins/inputEllipsis'

const app = createApp(App)

app
  .use(inputEllipsisPlugin) // 注册输入框省略号插件
  .mount('#app')

3. 添加全局样式(可选)

// src/styles/element-plus.scss

// el-input 省略号全局样式
.el-input:not(.el-textarea) {
  .el-input__inner {
    // 确保省略号正确显示
    &[style*=\"text-overflow: ellipsis\"] {
      display: block;
      width: 100%;
      box-sizing: border-box;
    }
  }

  // 为只读状态的输入框也支持省略号
  &.is-disabled .el-input__inner {
    &[style*=\"text-overflow: ellipsis\"] {
      cursor: default;
    }
  }
}

// 确保输入框容器支持省略号
.el-input__wrapper {
  overflow: hidden;
}

🎨 使用效果

安装插件后,所有的 `el-input` 都会自动添加省略号功能:

<template>
  <!-- 这些输入框会自动添加省略号功能 -->
  <el-input v-model=\"value1\" placeholder=\"自动添加省略号\" />
  <el-input v-model=\"value2\" placeholder=\"这个也会自动处理\" />
  
  <!-- textarea 不会被影响 -->
  <el-input type=\"textarea\" v-model=\"value3\" placeholder=\"这是文本域,不会被处理\" />
  
  <!-- 动态添加的输入框也会被自动处理 -->
  <el-input v-if=\"showInput\" v-model=\"value4\" placeholder=\"动态输入框也会被处理\" />
</template>

功能演示

  • 内容超出时显示省略号
  • 鼠标悬浮时显示完整内容
  • 支持输入内容变化时动态更新
  • 支持窗口大小变化时重新计算
  • 自动排除 textarea 类型

🔧 高级用法

手动刷新省略号状态

// 在任何组件中
this.\$refreshInputEllipsis()

获取管理器实例

import { InputEllipsisManager } from '@/plugins/inputEllipsis'

// 创建自定义实例
const customManager = new InputEllipsisManager()

🚀 性能优化

1. 防抖处理

const resizeHandler = () => {
  setTimeout(updateTooltip, 100)
}

窗口大小变化时使用防抖,避免频繁计算。

2. 事件监听器清理

;(inputEl as any)._ellipsisCleanup = () => {
  events.forEach(eventType => {
    inputInner.removeEventListener(eventType, updateTooltip)
  })
  window.removeEventListener('resize', resizeHandler)
}

每个输入框都保存清理函数,避免内存泄漏。

3. WeakSet 优化

使用 WeakSet 而不是 Set,让垃圾回收器自动清理不再使用的元素引用。

🎯 适用场景

  • 管理系统:大量表单输入框
  • 数据展示:表格中的输入框
  • 动态表单:根据条件动态生成的输入框
  • 组件库:需要统一处理输入框样式的项目

🔍 技术亮点

  1. 零侵入式:无需修改任何现有组件代码
  2. 自动化:完全自动处理,无需手动干预
  3. 高性能:使用现代浏览器 API 优化性能
  4. 类型安全:完整的 TypeScript 支持
  5. 内存友好:完善的内存管理机制

📝 总结

这个输入框省略号插件通过 Vue3 插件系统、MutationObserver 和 WeakSet 等技术,实现了一个完全自动化的解决方案。它不仅解决了传统方案的痛点,还提供了更好的性能和用户体验。

核心优势

  • 🚀 零侵入:安装即用,无需修改现有代码
  • 🎯 自动化:智能处理所有输入框
  • 高性能:优化的算法和内存管理
  • 🛡️ 类型安全:完整的 TypeScript 支持

如果您觉得这个方案有用,欢迎点赞收藏!也欢迎在评论区分享您的使用心得和改进建议。


作者简介:专注于前端技术分享,Vue3 + TypeScript 实践者

技术栈:Vue3, TypeScript, Element Plus, 前端工程化

❌
❌