阅读视图

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

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

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


前言

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

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

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


某一天的顿悟

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

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

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

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

没有答案。只有焦虑。


一次偶然的机遇

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

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

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

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

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

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

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

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

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


AI突围战”

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

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

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

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

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

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

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

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


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

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

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

我们的优势是什么?

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

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

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

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


这个专栏要写什么

所以,我开了这个专栏。

它不是:

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

它是:

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

结语

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

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

Vue 动态表单(Dynamic Form)

Vue 动态表单(Dynamic Form)

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

什么是动态表单

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

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

为什么需要动态表单

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

基础实现

定义字段配置

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

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

渲染表单

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

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

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

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

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

说明:

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

使用动态组件优化渲染

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


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

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

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

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

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

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

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

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

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

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

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

进阶功能

表单验证

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

手动实现简单验证示例:

在字段配置中增加 rules

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

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

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

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

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

在模板中显示错误信息:

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

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

布局控制

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

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

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

以 Element Plus 为例:

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

字段联动

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

实现思路:

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

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

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

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

动态增删字段

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

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

配置:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

注意事项与最佳实践

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

Vue 动态组件(Dynamic Components)

Vue 动态组件(Dynamic Components)

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

什么是动态组件

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

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

基本用法

使用 <component> 元素

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

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

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

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

:is 的两种绑定方式

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

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

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

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

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

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

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

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

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

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

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

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

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

按条件缓存

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

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

动态组件与异步组件结合

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

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

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

const asyncComponent = ref(null)

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

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

注意事项

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

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

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

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

XSS 防范

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

v-if / v-else 的选择

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

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

一、为什么要封装组件?

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

封装表格组件的价值:

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

二、组件设计思路

2.1 需求分析

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

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

2.2 组件设计原则

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

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

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

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

三、基础版本实现

3.1 项目初始化

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

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

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

3.2 基础表格组件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4.1 高级表格组件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

五、使用示例

5.1 基础用法

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

六、单元测试

// __tests__/ProTable.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import ProTable from '@/components/ProTable.vue'

describe('ProTable.vue', () => {
  const mockColumns = [
    { prop: 'name', label: '姓名' },
    { prop: 'age', label: '年龄' }
  ]
  
  const mockData = [
    { name: '张三', age: 25 },
    { name: '李四', age: 30 }
  ]
  
  it('renders table correctly', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 2
      }
    })
    
    expect(wrapper.find('.pro-table').exists()).toBe(true)
    expect(wrapper.findAll('.el-table__row').length).toBe(2)
  })
  
  it('emits page-change event when pagination changes', async () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: mockColumns,
        total: 100,
        showPagination: true
      }
    })
    
    // 模拟分页变化
    await wrapper.find('.el-pagination .btn-next').trigger('click')
    
    expect(wrapper.emitted('page-change')).toBeTruthy()
    expect(wrapper.emitted('page-change')?.[0]).toEqual([{ page: 2, limit: 20 }])
  })
  
  it('shows loading state', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: [],
        columns: mockColumns,
        loading: true
      }
    })
    
    expect(wrapper.find('.el-loading-mask').exists()).toBe(true)
  })
  
  it('renders custom slot content', () => {
    const wrapper = mount(ProTable, {
      props: {
        data: mockData,
        columns: [
          { prop: 'name', label: '姓名', slot: 'customName' }
        ]
      },
      slots: {
        customName: '<span class="custom-name">{{ row.name }}</span>'
      }
    })
    
    expect(wrapper.find('.custom-name').exists()).toBe(true)
  })
})

七、性能优化

7.1 虚拟滚动(大数据量)

<!-- 对于大量数据,可以使用虚拟滚动 -->
<template>
  <el-table
    v-loading="loading"
    :data="visibleData"
    :height="tableHeight"
    style="width: 100%"
    @scroll="handleScroll"
  >
    <!-- 列配置 -->
  </el-table>
</template>

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

const props = defineProps({
  data: {
    type: Array,
    default: () => []
  },
  rowHeight: {
    type: Number,
    default: 48
  },
  bufferSize: {
    type: Number,
    default: 10
  }
})

const scrollTop = ref(0)
const tableHeight = ref(600)

// 计算可见范围
const visibleCount = computed(() => Math.ceil(tableHeight.value / props.rowHeight))

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.rowHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  return Math.min(
    props.data.length,
    startIndex.value + visibleCount.value + props.bufferSize * 2
  )
})

const visibleData = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

const handleScroll = (e) => {
  scrollTop.value = e.target.scrollTop
}
</script>

7.2 大数据量优化策略

// 1. 使用虚拟滚动
// 2. 按需渲染
// 3. 使用函数式组件
// 4. 避免不必要的响应式
// 5. 使用 computed 缓存计算结果
// 6. 列表项使用唯一的 key
// 7. 使用 v-once 处理静态内容

八、总结与最佳实践

8.1 组件设计要点

  1. Props 设计原则

    • 提供合理的默认值
    • 使用 TypeScript 类型定义
    • 保持 API 简洁但够用
  2. 插槽设计原则

    • 提供足够的自定义能力
    • 作用域插槽传递必要数据
    • 预留扩展位置
  3. 事件设计原则

    • 遵循 v-model 规范
    • 提供完整的事件体系
    • 事件命名清晰规范

8.2 使用建议

// 1. 合理配置列宽度
const columns = [
  { prop: 'name', label: '姓名', width: 120 }, // 固定宽度
  { prop: 'address', label: '地址', minWidth: 200 }, // 最小宽度
  { prop: 'description', label: '描述', width: 'auto' } // 自适应
]

// 2. 使用唯一 rowKey
<pro-table :data="list" row-key="id" />

// 3. 合理使用插槽
<template #status="{ row }">
  <Badge :status="row.status" />
</template>

// 4. 处理加载状态
<pro-table :loading="loading" :data="list" />

// 5. 处理空状态
<pro-table :data="[]" empty-text="暂无数据" />

8.3 扩展思考

  1. 如何支持表格导出?

    • 添加导出按钮和导出方法
    • 支持导出当前页或全部数据
    • 支持导出格式配置(CSV/Excel)
  2. 如何支持表格打印?

    • 添加打印样式
    • 隐藏操作列和按钮
    • 调整列宽适配打印
  3. 如何支持表格列拖动调整宽度?

    • 使用 resizable 属性
    • 保存用户调整后的宽度到 localStorage
  4. 如何支持表格状态持久化?

    • 保存列显示状态
    • 保存排序状态
    • 保存筛选状态

通过合理封装表格组件,可以极大提升开发效率,保证项目代码质量,这也是企业级前端开发的核心能力之一。

【vue hooks】useScreenOrientation-获取屏幕方向并支持低版本系统

为了解决vueuse的useScreenOrientation不支持低版本系统的问题,尤其是ios,索性自己写了个可兼容的版本。

代码

新建一个useScreenOrientation.js文件,代码如下

import { shallowRef } from "vue";
import { useEventListener, useSupported } from "@vueuse/core";

// TypeScript dropped the inline types for these types in 5.2
// We vendor them here to avoid the dependency

// export type OrientationType = 'portrait-primary' | 'portrait-secondary' | 'landscape-primary' | 'landscape-secondary'
// export type OrientationLockType = 'any' | 'natural' | 'landscape' | 'portrait' | 'portrait-primary' | 'portrait-secondary' | 'landscape-primary' | 'landscape-secondary'

// export interface ScreenOrientation extends EventTarget {
//   lock: (orientation: OrientationLockType) => Promise<void>
//   unlock: () => void
//   readonly type: OrientationType
//   readonly angle: number
//   addEventListener: (type: 'change', listener: (this: this, ev: Event) => any, useCapture?: boolean) => void
// }

// export interface UseScreenOrientationReturn extends Supportable {
//   orientation: ShallowRef<OrientationType | undefined>
//   angle: ShallowRef<number>
//   lockOrientation: (type: OrientationLockType) => Promise<void>
//   unlockOrientation: () => void
// }

/**
 * Reactive screen orientation
 *
 * @see https://vueuse.org/useScreenOrientation
 *
 * @__NO_SIDE_EFFECTS__
 */
export function useScreenOrientation(options = {}) {
  const isSupported = useSupported(
    () => window && "screen" in window && "orientation" in window.screen
  );

  const screenOrientation = isSupported.value ? window.screen.orientation : {};

  const orientation = shallowRef(screenOrientation.type);
  const angle = shallowRef(screenOrientation.angle || 0);

  const isIOS = /iphone|ipad|ipod/.test(
    navigator.userAgent.toLocaleLowerCase()
  );
  const getLandscape = () => {
    if (isIOS && Object.prototype.hasOwnProperty.call(window, "orientation")) {
      return Math.abs(window.orientation) === 90;
    }
    return window.innerHeight / window.innerWidth < 1;
  };

  if (isSupported.value) {
    // 这部分是原代码
    useEventListener(
      window,
      "orientationchange",
      () => {
        orientation.value = screenOrientation.type;
        angle.value = screenOrientation.angle;
      },
      { passive: true }
    );
  } else {
    // 新增兼容低版本
    const landscapeChange = () => {
      orientation.value = getLandscape()
        ? "landscape-primary"
        : "portrait-primary";
    };
    landscapeChange();
    useEventListener(
      window,
      "orientationchange",
      () => {
        landscapeChange();
      },
      { passive: true }
    );
  }

  const lockOrientation = type => {
    if (isSupported.value && typeof screenOrientation.lock === "function")
      return screenOrientation.lock(type);

    return Promise.reject(new Error("Not supported"));
  };

  const unlockOrientation = () => {
    if (isSupported.value && typeof screenOrientation.unlock === "function")
      screenOrientation.unlock();
  };

  return {
    isSupported,
    orientation,
    angle,
    lockOrientation,
    unlockOrientation
  };
}

解决 Cesium 网络卡顿!5 分钟加载天地图,内网也能流畅用,附完整代码

接上文,之前使用 Cesium.Ion 已经成功将地球效果展示出来了,飞入效果也非常不错。详细可以参考这篇文章:# 拿来就用!Vue3+Cesium 飞入效果封装,3D大屏多场景直接复用

但是仍然存在一个问题没解决, Cesium.Ion 的服务部署在外面,但是我们这边因为众所周知的原因网络受到一些限制。

image.png

虽然Cesium的服务是不被禁止访问的,但是访问速度和丢包率也是异常"喜人",所以之前还是打算在这个地方做一下优化。

解决思路

其实想要解决这个问题也非常的简单,将卫星地图(瓦片地图)换成我们自己的服务即可,访问咱们这边的服务是没啥问题的。

目前基本上是两个思路

  • 使用在线服务,主要是天地图、腾讯、高德等等几家
  • 使用离线服务,自己下载瓦片地图,自己搭建服务

这两种路线我都用了,可以说如果你有资源的话,那么我强烈建议你自己搭建离线地图服务,效果非常好。

最关键的是这套系统就能够实现离线部署了,在某些私有化场景下非常契合。

image.png

但是两个问题需要解决,资源存储空间

目前资源问题勉强能凑合解决一下,但是存储空间确实没有。毕竟地图下载下来也是真不小,另外目前没有离线部署的需求,所以考虑使用在线服务。

在线服务不推荐腾讯、高德几家,一来配置起来并不好整,我之前尝试腾讯的,鼓捣了半天仅仅弄好了个矢量图,卫星图花了一下午时间也没弄好。

切换到天地图,只用了5分钟就齐活了。

解决方案

使用天地图服务首先去天地图官网注册个账号,地址给大家放一下:www.tianditu.gov.cn/

首先进入控制台,选择开发管理下的开发者认证,认证一下个人开发者

只有这样才能够创建应用,生成tk

image.png

然后进入开发管理 > 应用管理 > 我的应用 > 创建新应用,简单填写一下必要信息,就能够创建一个新应用了。

复制一下应用密钥(tk)

实际代码

初始化加载 Cesium图层 的地方设置为 false

// 初始化 Cesium 地球
const initCesium = async () => {
    // 创建 Cesium 视图实例
    viewer.value = new Cesium.Viewer('cesiumContainer', {
        // 隐藏默认控件,简化界面
        timeline: false,
        animation: false,
        baseLayerPicker: false,
        geocoder: false,
        homeButton: false,
        infoBox: false,
        sceneModePicker: false,
        navigationHelpButton: false,
        // 开启深度检测,避免地形闪烁
        scene3DOnly: true,
        requestRenderMode: true,
        // 不加载默认的 Cesium Ion 影像图层
        baseLayer: false
    });

    // 隐藏 Cesium 版权信息(可选)
    viewer.value._cesiumWidget._creditContainer.style.display = 'none';

    // 等待 Cesium 完全加载完成
    await waitForCesiumFullyLoaded();
    
    // 添加天地图地图影像图层
    addTianDituImageryLayer();

    // 触发 cesiumReady 事件
    emit('cesiumReady', viewer.value);
}

天地图图层主要有两部分,一个是卫星影像底图,另一个是注记图层,当然如果不考虑名称,注记图层可以不添加。

/**
 * 添加天地图地图影像图层(卫星图 + 注记)
 */
const addTianDituImageryLayer = () => {
    if (!viewer.value) return;

    // 使用天地图卫星影像,tk (密钥)
    const webKey = '你的tk';

    // 天地图卫星影像底图
    const imgProvider = new Cesium.WebMapTileServiceImageryProvider({
        url: 'https://t{s}.tianditu.gov.cn/img_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&TILEMATRIX={TileMatrix}&TILEROW={TileRow}&TILECOL={TileCol}&FORMAT=tiles&tk=' + webKey,
        layer: 'tdtImgBasicLayer',
        style: 'default',
        format: 'image/jpeg',
        tileMatrixSetID: 'GoogleMapsCompatible',
        maximumLevel: 18,
        minimumLevel: 1,
        subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
        credit: new Cesium.Credit('天地图'),
        // 启用 CORS
        enablePickFeatures: false
    });

    // 添加卫星影像图层
    viewer.value.imageryLayers.addImageryProvider(imgProvider);

    // 天地图注记图层(地名标注)
    const ciaProvider = new Cesium.WebMapTileServiceImageryProvider({
        url: 'https://t{s}.tianditu.gov.cn/cia_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cia&STYLE=default&TILEMATRIXSET=w&TILEMATRIX={TileMatrix}&TILEROW={TileRow}&TILECOL={TileCol}&FORMAT=tiles&tk=' + webKey,
        layer: 'tdtAnnoLayer',
        style: 'default',
        format: 'image/jpeg',
        tileMatrixSetID: 'GoogleMapsCompatible',
        maximumLevel: 18,
        minimumLevel: 1,
        subdomains: ['0', '1', '2', '3', '4', '5', '6', '7'],
        credit: new Cesium.Credit('天地图注记'),
        // 启用 CORS
        enablePickFeatures: false
    });

    // 添加注记图层(叠加在影像之上)
    viewer.value.imageryLayers.addImageryProvider(ciaProvider);

    console.log('卫星影像加载完成!');
};

这里需要注意:记得将 enablePickFeatures 设为false,避免出现跨域问题。

总结

后续看是否有合适的项目,我会将离线地图的资源和创建方式分享给大家。

如果你的资源足够强,甚至能看到非常精细的卫星图像。

离线地图的玩法也远比在线地图要多得多,你甚至可以DIY某个地方的卫星图像,做出现实版的我的世界

另外需要注意,天地图的API调用是有限制的,详情可以参考下图。

20260309-限额.png

React vs Vue 2026年怎么选?9年前端的真实建议

标签:React、Vue、前端、技术选型

这是前端圈永远吵不完的话题——React和Vue到底选哪个。

我做了9年前端,React和Vue都在生产项目中深度使用过。今天不参与阵营对立,只说实际情况,帮你做决策。

先说结论

没有绝对的好坏,只有适不适合。 但如果你非要我选一个——

  • 找工作为主 → 看你目标城市/公司的技术栈,哪个岗位多选哪个
  • 个人项目/创业 → Vue(上手快,生态齐全,AI工具生成Vue代码质量更高)
  • 大厂/大型项目 → React(大厂用的多,生态更灵活)
  • 新手入门 → Vue 3(学习曲线更平缓)

下面是详细分析。

1. 学习曲线

Vue 3:模板语法直觉性强,Composition API + <script setup> 写起来很舒服。从零到能写业务组件大概需要1-2周。

React:JSX需要适应,Hooks的心智模型比较抽象(useEffect依赖数组、闭包陷阱)。从零到能写业务组件大概3-4周。

// Vue 3 组件
<script setup lang="ts">
import { ref } from 'vue'
const count = ref(0)
const increment = () => count.value++
</script>
<template>
  <button @click="increment">{{ count }}</button>
</template>

// React 组件
import { useState } from 'react'
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

Vue的单文件组件(SFC)把模板、逻辑、样式放在一起,结构清晰。React的JSX把HTML写在JS里,灵活但对新手不太友好。

这一轮:Vue上手更快,React上限更灵活。

2. 生态对比

维度 Vue React
UI库 Element Plus、Ant Design Vue、Naive UI Ant Design、MUI、Chakra UI、shadcn/ui
状态管理 Pinia(官方推荐,简单够用) Zustand/Jotai(轻量)/ Redux Toolkit(复杂)
路由 Vue Router(官方) React Router / TanStack Router
SSR Nuxt 3(成熟稳定) Next.js(生态最强)
移动端 Uni-app / Taro React Native
桌面端 Electron + Vue Electron + React
AI工具支持 Cursor/Claude Code均良好 Cursor/Claude Code/v0均良好,v0原生React

React的生态更大、选择更多。Vue的生态更统一、选择成本更低。

这一轮:React生态广度胜,Vue生态统一性胜。

3. 就业市场

这才是很多人真正关心的。

2026年的实际情况是:

  • 一线城市大厂(北上广深杭):React占比60%+,Vue占30%左右
  • 二三线城市/中小公司:Vue占比60%+,因为上手快、招人容易
  • 外企/海外远程:React为主
  • 自由职业/外包:Vue更多,因为国内中小企业项目Vue占主流

建议:如果你已经在职,公司用什么你学什么。如果你在选方向,先看你目标城市/公司的招聘信息,哪个岗位多就学哪个。

4. 和AI编程工具的配合

这是2026年新增的重要维度。

我在用Cursor写代码时,Vue和React的AI生成质量对比:

  • 组件生成:两者差不多,Vue的SFC结构让AI更容易理解组件边界
  • 状态管理:Pinia代码比Redux简单得多,AI生成正确率更高
  • 类型推断:TypeScript + Vue 3在Cursor中的类型支持已经和React持平
  • v0工具:只支持React + Tailwind,Vue开发者需要自己转换

总体来说,AI工具对两者的支持都很好。Vue因为约定更统一,AI生成的代码一致性更好。

5. 我的真实使用感受

作为两个框架都深度使用过的人,说说我的主观感受:

Vue让我感觉"舒服"——官方提供的方案够用,不需要纠结选什么状态管理、选什么路由。Pinia + Vue Router + Vite,闭眼选就行。写业务代码效率极高。

React让我感觉"自由"——想怎么组织代码就怎么组织,但选择太多有时候也是负担。一个状态管理就有Redux、MobX、Zustand、Jotai、Recoil、Valtio六七个选择,每个都有人推荐。

如果你是"我不想纠结,给我最优方案就行"的人——选Vue。

如果你是"我喜欢自己搭配,享受灵活性"的人——选React。

最终建议

不要两个都学(至少不要同时学)。先精通一个,用它接项目、找工作、做产品。等你在一个框架上有了深度理解之后,切换到另一个只需要1-2周。

框架只是工具,真正重要的是你理解组件化思维、状态管理、性能优化、工程化——这些在任何框架中都通用。

评论区说说你目前用React还是Vue?为什么选它?


我是前端老兵AI,9年+前端工程师,React和Vue都在生产项目中使用过

📦 加微信lxxs1203,备注"掘金",领取《前端+AI编程实战干货包》

🎬 B站搜索:前端老兵AI

📱 公众号搜索「前端老兵之AI」,持续更新深度技术文章

深入理解Vue中的插槽:概念、原理与应用

在Vue.js的开发中,我们经常会遇到这样一个场景:需要创建一个可复用的组件,但组件的某些部分需要根据具体使用场景展示不同的内容。这时候,插槽(Slot)就成为了我们最得力的工具。插槽是Vue实现内容分发的一种机制,它允许我们在调用组件时向组件内部传递自定义内容,从而让组件变得更加灵活和可复用。

什么是插槽

想象一下,我们生活中常见的卡片组件。一张卡片通常有固定的结构——边框、背景色、圆角,但卡片内部的内容却千变万化,可能是文字、图片,也可能是按钮或者更复杂的组合。

如果我们要用Vue实现这样的卡片组件,传统的props传递方式会显得力不从心。props适合传递数据,但不适合传递复杂的HTML结构。让我们来看一个对比:

// 使用props传递HTML内容(不推荐)
<card content="<h2>标题</h2><p>内容</p>" />

// 使用插槽传递内容(推荐)
<card>
  <h2>标题</h2>
  <p>内容</p>
</card>

插槽本质上是一个占位符,它在子组件中预留了一个位置,当父组件使用这个子组件时,可以向这个位置填充自定义的模板内容。这种设计模式被称为"内容分发",它遵循了开放封闭原则——组件对扩展开放,对修改封闭。

插槽的核心作用

1. 实现内容自定义

通过插槽,我们可以创建出具有固定框架但内部内容可变的组件,大大提升组件的复用性。一个写好插槽的卡片组件,可以在项目中的任何地方使用,而每次使用时都可以填充完全不同的内容。

2. 促进职责分离

父组件负责业务逻辑和内容组织,子组件负责结构和样式表现,两者通过插槽进行优雅的协作。这种分离让代码更容易理解和维护。

3. 提供布局灵活性

特别是具名插槽的出现,让组件可以定义多个内容区域,使用者可以精确控制内容填充的位置,实现复杂的布局需求。

4. 实现反向数据流

作用域插槽更进一步,允许子组件向父组件传递数据,让父组件可以根据子组件的数据来渲染内容,实现了双向的交互。

插槽的三种类型及使用方式

1. 默认插槽

默认插槽是最基本的形式,当组件中只使用一个<slot>标签时,所有传递给组件的内容都会显示在这个位置。

我们先创建一个基础的卡片组件:

<!-- Card.vue -->
<template>
  <div class="card">
    <div class="card-header">
      卡片标题
    </div>
    <div class="card-body">
      <!-- 插槽占位符,父组件传递的内容将显示在这里 -->
      <slot></slot>
    </div>
    <div class="card-footer">
      底部信息
    </div>
  </div>
</template>

<style scoped>
.card {
  border: 1px solid #ddd;
  border-radius: 4px;
  padding: 10px;
  margin: 10px 0;
}
.card-header {
  font-weight: bold;
  border-bottom: 1px solid #ddd;
  padding-bottom: 5px;
}
.card-body {
  padding: 10px 0;
}
.card-footer {
  border-top: 1px solid #ddd;
  padding-top: 5px;
  color: #666;
}
</style>

在父组件中使用这个卡片:

<!-- Parent.vue -->
<template>
  <div>
    <card>
      <!-- 这里的内容会填充到子组件的slot位置 -->
      <p>这是卡片的主体内容</p>
      <button>点击查看详情</button>
    </card>
    
    <card>
      <ul>
        <li>列表项1</li>
        <li>列表项2</li>
        <li>列表项3</li>
      </ul>
    </card>
  </div>
</template>

<script>
import Card from './Card.vue'

export default {
  components: {
    Card
  }
}
</script>

如果希望插槽有默认内容,可以在<slot>标签内设置:

<template>
  <div class="card">
    <slot>
      <!-- 这是默认内容,当父组件没有传递内容时显示 -->
      <p>暂无内容,请稍后查看</p>
    </slot>
  </div>
</template>

2. 具名插槽

当一个组件需要多个内容区域时,就需要使用具名插槽。通过name属性可以区分不同的插槽。

创建一个带有多个区域的布局组件:

<!-- Layout.vue -->
<template>
  <div class="layout">
    <header class="header">
      <!-- 头部插槽 -->
      <slot name="header"></slot>
    </header>
    
    <main class="main">
      <!-- 默认插槽,不设置name的插槽默认name为"default" -->
      <slot></slot>
    </main>
    
    <aside class="sidebar">
      <!-- 侧边栏插槽 -->
      <slot name="sidebar"></slot>
    </aside>
    
    <footer class="footer">
      <!-- 底部插槽,带默认内容 -->
      <slot name="footer">
        <p>版权所有 © 2024</p>
      </slot>
    </footer>
  </div>
</template>

<style scoped>
.layout {
  display: grid;
  grid-template-areas: 
    "header header"
    "sidebar main"
    "footer footer";
  grid-template-columns: 200px 1fr;
  gap: 20px;
}
.header { grid-area: header; }
.main { grid-area: main; }
.sidebar { grid-area: sidebar; }
.footer { grid-area: footer; }
</style>

使用具名插槽:

<!-- App.vue -->
<template>
  <layout>
    <!-- v-slot指令指定内容放入哪个插槽,可以简写为# -->
    <template v-slot:header>
      <h1>网站标题</h1>
      <nav>
        <a href="#">首页</a>
        <a href="#">关于</a>
        <a href="#">联系</a>
      </nav>
    </template>

    <!-- 默认插槽的内容 -->
    <article>
      <h2>文章标题</h2>
      <p>这是文章的主要内容...</p>
    </article>

    <!-- 使用简写形式 -->
    <template #sidebar>
      <ul>
        <li>分类1</li>
        <li>分类2</li>
        <li>分类3</li>
      </ul>
    </template>

    <!-- 覆盖默认的底部内容 -->
    <template #footer>
      <p>自定义底部信息 | 备案号XXX</p>
    </template>
  </layout>
</template>

3. 作用域插槽

作用域插槽允许子组件将数据传递给父组件的插槽内容。这在需要根据子组件内部状态来定制渲染内容时特别有用。

创建一个待办事项列表组件:

<!-- TodoList.vue -->
<template>
  <div class="todo-list">
    <h3>待办事项列表</h3>
    <ul>
      <li v-for="item in items" :key="item.id" class="todo-item">
        <!-- 
          通过v-bind将item数据绑定到插槽上
          这样父组件就可以访问到item对象
        -->
        <slot :todo="item" :index="index">
          <!-- 默认的渲染方式 -->
          <span>{{ item.text }}</span>
          <span :class="{ completed: item.done }">
            {{ item.done ? '已完成' : '进行中' }}
          </span>
        </slot>
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  props: {
    items: {
      type: Array,
      required: true
    }
  }
}
</script>

在父组件中使用作用域插槽自定义渲染:

<!-- Parent.vue -->
<template>
  <div>
    <h2>默认渲染方式</h2>
    <todo-list :items="todos" />

    <h2>自定义渲染方式</h2>
    <todo-list :items="todos">
      <!-- 使用v-slot接收子组件传递的数据,可以解构 -->
      <template v-slot:default="{ todo, index }">
        <div class="custom-todo">
          <input type="checkbox" v-model="todo.done">
          <span :style="{ textDecoration: todo.done ? 'line-through' : 'none' }">
            {{ index + 1 }}. {{ todo.text }}
          </span>
          <button @click="deleteTodo(todo.id)">删除</button>
        </div>
      </template>
    </todo-list>
  </div>
</template>

<script>
import TodoList from './TodoList.vue'

export default {
  components: {
    TodoList
  },
  data() {
    return {
      todos: [
        { id: 1, text: '学习Vue插槽', done: false },
        { id: 2, text: '写博客文章', done: true },
        { id: 3, text: '复习JavaScript', done: false }
      ]
    }
  },
  methods: {
    deleteTodo(id) {
      this.todos = this.todos.filter(todo => todo.id !== id)
    }
  }
}
</script>

作用域插槽的另一种常见用法是用于列表组件的列自定义。以表格组件为例:

<!-- DataTable.vue -->
<template>
  <table>
    <thead>
      <tr>
        <th v-for="column in columns" :key="column.key">
          {{ column.title }}
        </th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="(row, rowIndex) in data" :key="rowIndex">
        <td v-for="column in columns" :key="column.key">
          <!-- 如果该列定义了自定义渲染插槽,则使用插槽 -->
          <template v-if="column.slotName">
            <slot :name="column.slotName" :row="row" :column="column">
              {{ row[column.key] }}
            </slot>
          </template>
          <template v-else>
            {{ row[column.key] }}
          </template>
        </td>
      </tr>
    </tbody>
  </table>
</template>

<script>
export default {
  props: {
    columns: Array,
    data: Array
  }
}
</script>

使用表格组件:

<template>
  <data-table :columns="columns" :data="users">
    <!-- 自定义状态列的渲染 -->
    <template #status="{ row }">
      <span :class="['status-badge', row.status]">
        {{ row.status === 'active' ? '启用' : '禁用' }}
      </span>
    </template>
    
    <!-- 自定义操作列的渲染 -->
    <template #actions="{ row }">
      <button @click="editUser(row)">编辑</button>
      <button @click="deleteUser(row)">删除</button>
    </template>
  </data-table>
</template>

插槽的工作原理

理解插槽的工作原理,需要从Vue的编译和渲染过程说起。当Vue编译模板时,它会构建一个虚拟DOM树。在这个过程中,遇到组件标签时,Vue会将该组件实例化,同时处理组件标签内的子节点。

对于普通的HTML元素,子节点会直接作为父节点的children。但对于组件,情况有所不同。组件标签内的内容会被编译为插槽的内容,而组件模板中的<slot>标签则会被编译为插槽的出口。

在渲染阶段,Vue会创建一个渲染函数,这个函数会返回虚拟DOM。当渲染函数执行时,它会解析组件模板中的<slot>标签,并将其替换为父组件传递进来的对应内容。如果父组件没有传递内容,则会渲染插槽中定义的后备内容。

对于作用域插槽,Vue会建立一个从子组件到父组件的数据通道。子组件在渲染插槽时,会将绑定的数据作为参数传递给插槽函数,父组件的插槽内容就可以访问到这些数据。

实际应用场景

场景一:弹窗组件

<!-- Modal.vue -->
<template>
  <div v-if="visible" class="modal-overlay">
    <div class="modal-container">
      <div class="modal-header">
        <slot name="header">
          <h3>{{ title }}</h3>
        </slot>
        <button @click="$emit('close')">×</button>
      </div>
      <div class="modal-body">
        <slot></slot>
      </div>
      <div class="modal-footer">
        <slot name="footer">
          <button @click="$emit('close')">关闭</button>
          <button class="primary" @click="$emit('confirm')">确认</button>
        </slot>
      </div>
    </div>
  </div>
</template>

场景二:列表项的多种展示模式

<template>
  <div>
    <!-- 卡片模式 -->
    <item-list :items="products" mode="card">
      <template #item="{ item }">
        <div class="product-card">
          <img :src="item.image" :alt="item.name">
          <h4>{{ item.name }}</h4>
          <p>¥{{ item.price }}</p>
          <button @click="addToCart(item)">加入购物车</button>
        </div>
      </template>
    </item-list>
    
    <!-- 列表模式 -->
    <item-list :items="products" mode="list">
      <template #item="{ item }">
        <div class="product-row">
          <span>{{ item.name }}</span>
          <span>¥{{ item.price }}</span>
          <input type="number" v-model.number="item.quantity">
        </div>
      </template>
    </item-list>
  </div>
</template>

使用技巧与注意事项

1. 合理设置后备内容

<slot name="loading">
  <div class="loading-spinner">加载中...</div>
</slot>

2. 解构作用域插槽的props

<template #item="{ id, name, price, index }">
  <div>{{ index }}. {{ name }} - {{ price }}</div>
</template>

3. 动态插槽名

<template #[dynamicSlotName]>
  动态插槽内容
</template>

4. 多个插槽的复用

如果多个插槽需要相同的内容,考虑提取为组件:

<template>
  <complex-component>
    <template #header>
      <common-content />
    </template>
    <template #sidebar>
      <common-content />
    </template>
  </complex-component>
</template>

5. 注意事项

  • 插槽内容的作用域:插槽内容无法访问子组件的数据,除非使用作用域插槽
  • 具名插槽的简写:v-slot:header 可以简写为 #header
  • 默认插槽的显式使用:当同时使用默认插槽和具名插槽时,建议显式包裹默认插槽

结语

插槽是Vue组件化设计中不可或缺的一部分,它体现了Vue灵活、渐进的设计理念。通过合理使用插槽,我们可以构建出既强大又灵活的组件库,提高开发效率和代码质量。

从简单的默认插槽,到处理复杂布局的具名插槽,再到实现数据反向流动的作用域插槽,每一种插槽类型都有其特定的应用场景。深入理解这些概念,能让我们在组件设计时做出更合理的决策,编写出更优雅的Vue应用。

在实际项目中,插槽的使用往往能反映出开发者对组件化思想的理解深度。掌握好这个工具,相信你的Vue开发之路会更加顺畅。

最新版vue3+TypeScript开发入门到实战教程之ref与reactive的实战区别用法

概述

上节详细 说明ref、reactive如何定义使用响应式数据。总结如下:

  • ref定义基础类型数据
  • reactive定义对象数据
  • 但ref也可以定义对象类型的数据。但底层基于reactive实现 ref定义对象类型数据,底层用reactive实现, 但两者区别在何处,本文将详细说明

ref使用对象类型的数据定义响应式

  • 新建组件Fish
  • 创建响应式对象fish,数组fishs
  • 显示、修改响应式对象fish数据、fishs第一条数据
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeName">改变鱼</button>
  <button @click="addPrice">涨价</button>
  <h3>鱼的列表</h3>
  <ul>
    <li v-for="item in fishs" :key="item.id">
      {{ item.name }}:{{ item.price }}
    </li>
  </ul>
  <button @click="changeFirstPrice">改变第三条鱼的价格</button>

</template>
<script setup>
import { ref } from 'vue'
let fish = ref({ name: '鲫鱼', price: 10 });
let fishs = ref([
  {id:'txdi01',name:'鲫鱼',price:10},
  {id:'txdi02',name:'鲤鱼',price:20},
  {id:'txdi03',name:'草鱼',price:30},
])
function changeName() {
  fish.value.name = '草鱼';
  console.log(fish);
  console.log(fish.value);

}
function addPrice() {
  fish.value.price += 10;

}
function changeFirstPrice() {
  fishs.value[0].price += 10;
}
</script>

用ref定义的响应式对象,需要用.value去访问赋值。访问页面,发现他与reactive定义的响应式对象是一样的,除了reactive不需要用.value访问。如图 在这里插入图片描述 在点击按钮改变鱼,打印输入fish和fish.value,发现fish使用RefImpl定义,但 fish.value使用Proxy定义的对象,这与使用reactive定义是一样的。使用ref定义的响应式对象,底层使用reactive实现的。

ref对比reactive区别

从使用整体看

  • ref定义可以是基础数据、对象类型数据
  • reactive定义只能是对象类型数据

从细微之处看两者区别

  • ref创建的响应式对象,必须使用.value
  • reactive是深层次响应对象
  • reactive重新分配一个新对象,会丢失响应式

ref与reactive实际项目使用原则

  • 基本类型数据,必须使用ref
  • 简单的对象,不需要层级太深,ref、reactive都可以
  • 层级较深的对象,推荐使用reactive 以上使用原则,简单归纳,基本数据使用ref、对象使用reactive。但实际要清楚两者在深层次对象使用区别。

如何正确理解reactive重新分配一个新对象会流失响应式

reactive重新分配一个新对象会流失响应式是官方的说明。猛一看有些懵。我们从事例代码中去理解这句话的含义。事例中,定义let fish = reactive({ name: '鲫鱼', price: 10 }),创建5个按钮

  • 改变鱼的种类,fish.name = '草鱼';
  • 改变鱼的价格,fish.price += 10;
  • 方式一改变整个鱼,fish={ name: '鲤鱼', price: 30 }
  • 方式二改变整个鱼,fish = reactive({ name: '鲤鱼鱼', price: 40 });
  • 方式三改变整个鱼,Object.assign(fish,{ name: '带鱼', price: 50 }) 运行查看效果,发现,点击按钮方式一改变整个鱼、方式二改变整个鱼,页面无任何变。方式一、方式二都是重新给fis分配一个新的对象,导致fish丢失响应式。方式三是将新对象数据重新分配给fish,所以可以。 当fish丢失响应式数据后,再给fish重新赋值,页面也无法改变,也不在具有响应式。 细看图中的操作: 在这里插入图片描述

详细代码

<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeName">改变鱼的种类</button>
  <button @click="addPrice">改变鱼的价格</button>
  <div>
    <button @click="changeallfish1">方式一改变整个鱼</button>
    <button @click="changeallfish2">方式二改变整个鱼</button>
    <button @click="changeallfish3">方式三改变整个鱼</button>
  </div>

</template>
<script setup>
import { reactive } from 'vue'
let fish = reactive({ name: '鲫鱼', price: 10 });
function changeName() {
  fish.name = '草鱼';

}
function addPrice() {
  fish.price += 10;

}
function changeallfish1() {
  fish={ name: '鲤鱼', price: 30 }

}
function changeallfish2() {
 fish = reactive({ name: '鲤鱼鱼', price: 40 });


}
function changeallfish3() {
  console.log('sadsd')
  Object.assign(fish, { name: '带鱼', price: 50 })
  console.log(fish);

}
</script>

ref定义的响应式对象重新分配一个新对象会怎样

  • 使用.value赋值,是响应式
  • 直接赋值,流失响应式 看事例
<template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeallfish1">方式一改变整个鱼</button>
  <button @click="changeallfish2">方式二改变整个鱼</button>
</template>
<script setup>
import { ref } from 'vue'
let fish = ref({ name: '鲫鱼', price: 10 });
function changeallfish1() {
  fish.value={ name: '鲤鱼', price: 30 }

}
function changeallfish2() {
 fish = ref({ name: '鲤鱼鱼', price: 40 });
}
</script>

在这里插入图片描述

最新版vue3+TypeScript开发入门到实战教程之学会vue3真正的响应式数据

响应式数据概述

在vue2那个年代,响应式数据是在data里面定义,只要把数据放在data里,然后在模版内引用,数据一变,模版就跟着显示,如下图代码:

<template>
  <h2>我是一条{{ fish }}</h2>
  <button @click="changeFish">改变鱼</button>
</template>
<script lang="ts">
export default {
  name:'Fish',
  data() {
    return {
      fish:'鲫鱼'
    }
  },
  methods: {
    changeFish() {
      this.fish+='!'
    }
  }
}
</script>

在这里插入图片描述

点击按钮改变鱼。fish一变化,模版就跟着变化,这就是响应式数据。

vue3是如何定义响应式数据的

在vue3中,使用的是组合式 API (Composition API)语法,它没有data,数据和方式都定义在script标签里。在定义数据时,有两种方式给数据标记成响应式数据,分别是ref、reactive。

  • ref给基本数据标记成响应式数据,如整数、浮点数、字符串
  • reactive给对象标记成响应式数据,如对象、数组
  • ref也可给对象标记响应式数据,但底层用reactive实现

用ref给基本数据标记成响应式对象

  • 从vue引入ref,才可以使用ref函数
  • 定义响应式变量,用ref赋值
  • 获取、改变响应式数据变量的值,不能直接访问,需要加.value
  • 模版中可直接引用变量
<template>
  <h1>鱼类:{{ fish }}</h1>
  <h2>价格:{{price  }}</h2>
  <button @click="changeName">改变鱼</button>
  <button @click="addPrice">涨价</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
let fish = ref('鲫鱼');
let price =ref(10);
function changeName() {
  fish.value = '草鱼';
  console.log(fish);
  console.log(fish.value);

}
function addPrice() {
  price.value += 10;
  console.log(price)
  console.log(price.value)

}
</script>

在浏览中访问http://localhost:5173/,查看效果 在这里插入图片描述

当点击按钮,鱼与价格都跟着改变。fish与price变成响应式数据,控制台打印 console.log(price)、 console.log(price.value),发现定义fish与price,并不是字符串与数字,而是用RefImpl 类型的定义的数据,fish基本结构如下: 在这里插入图片描述 能够访问的属性,只有vulue。在changeFIsh与addPrice函数中,通过fish.value和price.value赋值

借助vue DevTools 工具查看Fish组件的数据与方法

在这里插入图片描述 想要谁变成响应式数据,在数据外面包一层ref。不需要的,直接定义变量。

reactive定义对象类型的响应式数据

  • 从vue引入reactive
  • 定义响应式对象,用reactive包裹对象
  • 访问、改变响应式对象,直接操作成员变量
  • 模版直接使用响应式对象 分别定义两个响应式数据,一是用对象定义的响应式,一是用数组定义的响应式,来说明响应式对象如何使用。
let fish = reactive({ name: '鲫鱼', price: 10 });
let fishs = reactive([
  {id:'txdi01',name:'鲫鱼',price:10},
  {id:'txdi02',name:'鲤鱼',price:20},
  {id:'txdi03',name:'草鱼',price:30},
])

改变鱼的名称与价格

function changeName() {
  fish.name = '草鱼';
}
function addPrice() {
  fish.price += 10;

}

改变fishs第二个鱼的价格

function changeThreePrice() {
  fishs[1].price += 10;
}

具体代码如下:

template>
  <h2>鱼类:{{ fish.name }}</h2>
  <h2>价格:{{fish.price  }}</h2>
  <button @click="changeName">改变鱼</button>
  <button @click="addPrice">涨价</button>
  <h2>鱼的列表</h2>
  <ul>
    <li v-for="item in fishs" :key="item.id">
      {{ item.name }}:{{ item.price }}
    </li>
  </ul>
  <button @click="changeThreePrice">改变第三条鱼的价格</button>

</template>
<script setup>
import { reactive } from 'vue'
let fish = reactive({ name: '鲫鱼', price: 10 });
let fishs = reactive([
  {id:'txdi01',name:'鲫鱼',price:10},
  {id:'txdi02',name:'鲤鱼',price:20},
  {id:'txdi03',name:'草鱼',price:30},
])
function changeName() {
  fish.name = '草鱼';
  console.log(fish);
  console.log(fish.name);

}
function addPrice() {
  fish.price += 10;

}
function changeThreePrice() {
  fishs[1].price += 10;
}
</script>

在浏览器访问http://localhost:5173/,数据渲染和修改都正确,如下图 在这里插入图片描述 通过reactive定义的数据,都是Proxy,这是JavaScript内置对象,它的数据存放在Target中。其结构如下图: 在这里插入图片描述

reactive定义的响应式对象是深层次

对象的深层次,是其成员变量中含有成员变量,其成员变量又含有成员变量,当修改最底层成员变量值时,数据也是响应式的。如下:

<template>
  <h2>鱼类:{{ a.b.c.color }}</h2>
  <button @click="changeC">深度改变</button>
</template>
<script setup>
import { reactive } from 'vue'
let a = reactive({
  b: {
    c: {
      color: 'red'
    }
  }
})
a.b.c.color='black'
function changeC() {
  a.b.c.color = 'black';
}
</script>

访问浏览器,点击深度改变按钮,发现color值是响应式。用reactive定义的响应式对象都是深层次的。

最新版vue3+TypeScript开发入门到实战教程之学会vue3第一步必是setup语法糖

setup 概述

在vue3中,若没有学好setup函数,后面学习vue3将会越学越乱。 setup是一个函数,它在vue3是一个新的配置项,是组合式语法 (Composition API)表演的舞台,组件中所用的属性、计算属性、方法、监视等等,均需要在setup中配置。 setup特性:

  • setup函数中没有this,它是undefined
  • setup函数返回的对象内容,可直接在模版中调用
  • setup函数在beforeCreate之前调用,领先所有生命周期钩子。

最简单setup事例语法

上节 vue3与vue2语法优劣对比中,vue2语法是选项式(OptionsAPI),如下图: 在这里插入图片描述 既然vue3是组合式语法 (Composition API),它就不应该有data,methods,而是所有内容都合并在一起,删除data与methos,建立setup函数,定义数据与方法,流程如下

  • 删除data内容
  • 删除methos内容
  • 创建setup函数
  • 在setup函数中定义变量与方法
  • setup函数要返回数据与方法供模版使用
<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <h2>价格:{{ price }}万</h2>
    <button @click="changeName">修改品牌</button>
    <button @click="changePrice">修改价格</button>
    <button @click="showLowPrice">查看低价</button>

  </div>
</template>
<script lang="ts">
export default{
  name: 'Car',
  setup() {
    console.log(this)
    let name = '奔驰';
    let price = 100;
    let lowPrice = 80;
    function changeName() {
      name = '宝马'
      console.log(name)

    }
    function changePrice() {
      price += 10;
      console.log(price)

    }
    function showLowPrice() {
      alert(lowPrice);
    }
    return { name, price, changeName, changePrice, showLowPrice }
  }
}
</script>

浏览器输入http://localhost:5173,效果如下: 在这里插入图片描述

  • 首次打开页面,控制台首先输出this为undefined,setup没有this
  • 页面能够渲染品牌与价格
  • 点击修改品牌,name值能够修改,但页面没有变化
  • 点击修改价格,price值能够修改,但页面没有变化
  • 点击查看低价,控制台正确输出 在vue2中data定义的数据是响应式数据,但在vue3这种方式定义的数据不是响应式。在vue3中有五中类型的响应式数据。在下期细讲明,避免与setup知识点理解不清,暂不提及。name、price、lowPrice都不是响应式数据。

setup与data、methods常常被问到面试题

在vue组件中,常常有人将vue2语法与vue3语法混着写,既在data定义数据,又在setup定义数据。当使用函数访问数据中,问题出现。setup数据能否访问data数据,反之亦能否?页面属性与方法非常混乱,所以在vue3中,不要去写vue2语法,实在搞不定再去写。

  • setup与data、methods可以共存
  • data、methods能访问setup数据与方法
  • setup不能访问data中的数据与方法

setup与data、methods可以共存

继上面的事例,给car新增一个color颜色属性,用vue2语法编写

<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <h2>价格:{{ price }}万</h2>
    <h2>颜色:{{ color }}</h2>
    <button @click="changeName">修改品牌</button>
    <button @click="changePrice">修改价格</button>
    <button @click="showLowPrice">查看低价</button>
    <button @click="changeColor">修改颜色</button>

  </div>
</template>
<script lang="ts">
export default{
  name: 'Car',
  data() {
    return {
      color:'红色'
    }
  },
  methods: {
    changeColor() {
      this.color='蓝色'
    }
  },
  setup() {
    let name = '奔驰';
    let price = 100;
    let lowPrice = 80;
    function changeName() {
      name = '宝马'
      console.log(name)

    }
    function changePrice() {
      price += 10;
      console.log(price)

    }
    function showLowPrice() {
      console.log(lowPrice)

    }
    return { name, price, changeName, changePrice, showLowPrice }
  }
}
</script>

在浏览器访问,发现可以共存,且color是响应式数据。如图:在这里插入图片描述

data、methods能访问setup数据与方法

在data中修改color默认赋值为name+'color',修改methods函数changeColor,让它访问name属性,调用修改价格函数

 data() {
    return {
      color:name+'color'
    }
  },
  methods: {
    changeColor() {
      this.color = '蓝色'
      console.log(this.name);
      this.changePrice();
    }
  },

在浏览器访问,效果如下图 在这里插入图片描述

  • 属性颜色,显示奔驰红色
  • 修改颜色函数,控制台输出name,并调用修改价格函数 以下是具体代码
<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <h2>价格:{{ price }}万</h2>
    <h2>颜色:{{ color }}</h2>
    <button @click="changeName">修改品牌</button>
    <button @click="changePrice">修改价格</button>
    <button @click="showLowPrice">查看低价</button>
    <button @click="changeColor">修改颜色</button>

  </div>
</template>
<script lang="ts">
export default{
  name: 'Car',
  data() {
    return {
      color: this.name + '红色'
    }
  },
  methods: {
    changeColor() {
      this.color = '蓝色'
      console.log(this.name);
      this.changePrice();
    }
  },
  setup() {
    let name = '奔驰';
    let price = 100;
    let lowPrice = 80;
    function changeName() {
      name = '宝马'
      console.log(name)

    }
    function changePrice() {
      price += 10;
      console.log(price)

    }
    function showLowPrice() {
      console.log(lowPrice)

    }
    return { name, price, changeName, changePrice, showLowPrice }
  }
}
</script>

setup数据与方法不能访问data数据与methods方法

  • 在setup函数中打印color属性,提示异常:Uncaught ReferenceError: color is not defined,页面无法渲染
  • 在setup方法中访问color属性,提示异常:Uncaught ReferenceError: color is not defined 在这里插入图片描述

让setup函数更优雅

set函数需要返回值,否则模版中无法访问setup定义的属性

setup() {
    let name = '奔驰';
    return { name }
  }

如若新增一个属性weight重量,,就需要返回weight属性

setup() {
    let name = '奔驰';
    return { name,weight }
  }

当有许多个属性时,代码很繁琐,且return { name,weight }是无必要的。只需要在script标签中添加setup可优雅解决

创建一个最简setup函数

<template>
  <div class="car">
    <h2>品牌:{{ name }}</h2>
    <button @click="changeName">修改品牌</button>
  </div>
</template>
<script setup lang="ts">
let name = '奔驰';
function changeName() {
  name = '宝马'
}
</script>

注意script有setup标识,在setup里声明的属性和方法,模版都可以访问。

被CRUD拖垮的第5年,我用Cursor 一周"复仇":pxcharts-vue开源,一个全栈老兵的AI编程账本

今天继续和大家聊聊,我们开源的 pxcharts-vue 多维表格的诞生故事。

图片

开源地址:github.com/MrXujiang/p…

演示地址:test.admin.mvtable.com/mvtable

一、开篇:那个写不动代码的凌晨

记得是5年前的一个夜晚,凌晨两点左右,我对着第46个几乎相同的表单页面发呆。

需求文档上写着:"再做一个支持关联查询的动态表格,下周上线。"

我机械地复制着上一版的CRUD代码,改字段名、调接口、写校验。Vue文件超过1000行,methods里塞着十几个功能相似但不敢重构的方法——怕牵一发而动全身。

这是我做全栈的第5年。从Vue2到Vue3,从jQuery到React,技术栈在升级,但日常还是改不完的表单、写不完的列表、调不完的接口

我自嘲是"高级CRUD工程师",但那个凌晨,我真的写不动了。

不是身体累,是认知上的绝望:我知道接下来的5年,如果继续这样"人肉搬砖",只会从"写不动"变成"不敢写"——新技术层出不穷,而我被困在业务逻辑的重复劳动里。

转机出现在三年后。

Cursor 的Agent模式刚更新,我抱着"试试又不会死"的心态,把曾经折磨我两周的多维表格需求丢给了AI。

图片

经过3天多和AI辩证推演之后,pxcharts-vue 的核心架构跑通了。我盯着屏幕上自动生成的Composition API代码,第一反应不是兴奋,是后怕——如果AI早来两年,我这5年到底在忙什么?

这篇文章,是我作为"全栈老兵"的AI编程账本。不吹不黑,只记录真实的效率数字、踩过的坑、以及那个凌晨之后,我对职业价值的重新思考。


二、产品画像:pxcharts-vue是什么?(技术人的"复仇工具")

图片

先介绍这次"复仇"的成果。pxcharts-vue 不是又一个Element Plus的封装,而是面向复杂业务场景的"关系型多维表格引擎"

它解决的是我5年来反复遇到的三个痛点:

1. 平面表格 vs 立体数据

传统表格是Excel思维:行是记录,列是属性。但真实业务是关系型的——订单关联客户、任务关联项目、SKU关联SPU。我们用"关联列"把这种关系可视化:选客户时自动带出合同,选商品时自动填充价格,底层是外键约束,表层是下拉选择。

图片

这 revenge 了我过去写过的无数遍onChange联动逻辑。

在多维表格设计中,我们完全对标了钉钉AI表格和飞书多维表格的字段设计,实现了多种表格业务字段,并支持随时编辑修改:

图片

当然有些字段比较复杂,AI无法完全理解和实现,其中40%的工作量是我们手敲代码实现的。

2. 一份数据,多种视角

图片

同一份项目数据,产品经理要看甘特图,运营要看看板,财务要看表格汇总。pxcharts-vue 实现了视图层与数据层解耦:底层是统一的数据模型,上层是表格、看板、表单等渲染适配器。

这 revenge 了我过去为"换个展示方式"而写的冗余接口。

3. 公式字段:把Excel能力Web化(React版本中实现了)

支持跨表引用、聚合计算、条件判断,非技术用户能配出"自动计算提成"的复杂逻辑。对于开发者,这意味着业务规则从后端Java代码前移到了前端配置层,需求变更不用重新部署。

这 revenge 了我过去凌晨两点还在改的"紧急加字段"需求。

技术栈:Vue3 + TypeScript + Vite,纯前端实现,零后端依赖,开箱即用。


三、复仇实录:一周重构的流水线与真实账本

这次开发全程在Cursor Composer的Agent模式下完成,我们自己研发的工作量占比40%。

我记录了一套"老兵式AI协作流"——不是盲目信任,是有策略地外包

plan 1:架构设计(从"人肉画图"到"对话式架构")

过去的我:  打开Draw.io画组件关系图,纠结半小时目录结构,再花2小时搭Vite脚手架。

AI模式:

我:基于Vue3实现一个多维表格内核,需要支持列定义、数据编辑、视图切换,采用模块化架构,优先使用Composition API和<script setup>语法。Cursor:生成项目结构 + 核心类型定义 + 基础组件框架

耗时:30分钟 vs 过去的4小时。

关键干预:  强制要求AI先生成ARCHITECTURE.md设计文档,确认模块边界后再生成代码。这是从"边想边写"的混乱中保留下来的人类架构师尊严

plan 2:核心功能(关联列与视图系统)

关联列功能:

我:需要实现表与表之间的关联,类似数据库外键约束,支持多选、级联筛选、自动回填。Cursor:生成基于Proxy的响应式关联逻辑 + 选择器组件 + 数据联动机制

过去需要2天,现在4小时。  但AI生成的第一版用了递归遍历,大数据量时卡顿明显。我要求它改用虚拟滚动+懒加载,它给出了基于vue-virtual-scroller的优化方案。

视图系统: AI建议使用策略模式管理不同视图,我确认方案后,它生成了TableStrategy、KanbanStrategy、GanttStrategy三个类,统一实现render()接口。

耗时:6小时 vs 过去的3天。

plan 3:公式引擎与边界加固

这是最复杂的模块。我采用Plan Mode

  1. 先让AI出《公式引擎设计文档》:语法解析(PEG.js)、沙箱执行(Web Worker)、错误处理机制
  2. 人工Review确认安全方案(禁用eval,使用白名单函数)
  3. 再让AI生成代码

发现的问题:  AI生成的初始版本用了new Function()执行公式,我立即叫停——这是XSS漏洞温床。CodeRabbit 的研究证实,AI代码引入安全漏洞的概率是人类代码的2.74倍。最终改用受限沙箱+语法树解析

耗时:1.5天 vs 过去的5天。

效率账本(真实数字)

环节 传统开发(第5年的我) AI辅助开发(复仇模式) 效率倍数
脚手架与架构 3天 2小时 8x
关联列逻辑 3天 1天 3x
视图切换系统 5天 1天 5x
公式引擎 5天 1天 5x
安全加固与优化 2-3天 1天 2x
总计 18-20天 4.2天 4x

整体效率提升约230% ,与GitClear对高AI使用率开发者的调研数据(4-10倍产出提升)基本吻合。

当然客观的说,我们工程师也花了大概30%-40%的经历攻克AI无法解决的问题,但是AI Coding的整体提效还是很显著的。


四、账本B面:AI编程的隐性成本与"复仇"的代价

但这不是爽文。

图片

一周交付的背后,我们付出了传统开发不会有的代价。这是账本必须记录的B面

1. 安全债务:AI的"自信"是危险的

pxcharts-vue 初期版本中,AI生成的表格解析渲染器存在原型链污染漏洞——它从某个Stack Overflow回答中学到了"巧妙"的对象合并技巧,但那是有安全缺陷的过时方案。

CodeRabbit 分析了数百万行AI生成代码,发现:

  • 引入XSS漏洞的概率:人类代码的2.74倍
  • 硬编码机密信息的概率:人类代码的2.1倍

我的对策:  核心安全模块(公式沙箱、数据校验)必须人工Review,AI仅辅助生成单元测试用例。

2. 可维护性陷阱:你成了"代码陌生人"

Day 2下午,AI生成了50行复杂的视图切换逻辑。当时我看懂了大意,觉得"没问题"。一周后回看,我盯着那团递归+闭包的组合,完全想不起来为什么这样写、边界条件是什么

GitClear的研究警告:AI辅助代码的撤销率(Churn rate)比人类代码高40% ,意味着更多返工。

我的对策:  强制要求AI生成 "逻辑注释" ——不是解释语法,而是解释设计决策("为什么用递归而非迭代""此处假设数据量小于1万条")。关键算法必须人工复述原理,确保"我懂我的代码"。

3. 架构一致性危机:AI的"创意"是混乱的

不同会话的AI会给出风格迥异的方案。早期关联列用Options API,后期视图系统被建议改成Composition API,导致代码风格混杂——就像一个项目里有5个不同架构师的手笔

我的对策:  建立《AI编程规范文档》(.cursorrules),固化:

  • 技术栈:Vue3 + <script setup> + TypeScript严格模式
  • 设计模式:优先组合式函数,类仅用于策略模式
  • 命名规范:组件PascalCase,组合式函数useXxx,工具函数纯函数优先

这让AI在约束内发挥,而非"自由创作"。

4. 幻觉税:为AI的"自信"买单

图片

视图切换的虚拟滚动功能,AI生成的代码在1000条数据时完美运行,10000条时白屏。它没有考虑内存溢出边界,也没有提示"此处需要性能测试"。

这类问题只能靠人工测试发现。AI编程省下的时间,部分要返还到更严格的测试环节


五、老兵的新战场:AI时代,全栈工程师该专注什么?

图片

pxcharts-vue 开源后,我一直在想:如果AI能写代码,我这5年积累的经验还有什么价值?答案在开发过程中逐渐清晰——

1. 从"实现者"到"架构守门员"

AI擅长生成"能跑的代码",但不懂业务领域的架构权衡

pxcharts-vue 的数据模型设计(平面表 vs 树形结构)、状态管理方案(Pinia vs 纯响应式)、视图渲染策略(Canvas vs DOM),这些决策需要人类对业务场景的深度理解。

新角色:  不是写代码,是设计代码的生成规则

凭借我之前在大厂做技术架构的经验,我能很快给出AI高效的架构和解决思路,所以这也要求我们有一定的技术背景,才能更好的让AI为我们服务。

2. 从"调试bug"到"设计防错机制"

AI代码的bug更隐蔽——它很少犯语法错误,但常犯逻辑假设错误("假设用户不会同时编辑两个单元格")。我的新工作是预判这些假设,在设计阶段就加入防御性机制。

新角色:  不是修bug,是设计让bug无法发生的系统

3. 从"技术执行"到"AI流程设计"

这次3天重构,真正的生产力提升不是来自Cursor本身,而是我设计的分层协作流程

  • 生成层(工具函数):100%信任AI
  • 业务层(组件逻辑):AI生成+人工Review,70%信任
  • 核心层(公式引擎):AI辅助设计,人工实现,30%信任

新角色:  不是写代码,是设计人机协作的流水线


六、开源的思考:不止于代码,是"复仇经验"的共享

选择开源 pxcharts-vue,除了技术分享,我还想验证一个假设:AI编程时代,开源的价值会从"代码"转向"流程"

传统开源是"拿我的代码用",未来可能是"拿我的Prompt用"——如何让AI生成高质量的Vue3组件?如何设计安全的公式引擎?如何避免我踩过的坑?

我后续会分享《pxcharts-vue AI开发手册》,包含:

  • 架构设计、高性能表格技术实践
  • 安全审计清单(AI代码常见漏洞模式)
  • 性能优化策略(虚拟滚动、大数据渲染、内存管理)

如果你也在用AI编程工具,欢迎来 留言区 交流。

我们可以一起探索:当AI成为标配,人类开发者的"复仇"该指向什么?


结语:账本结算,复仇之后

5年前那个凌晨两点写不动代码的我,不会想到三年后会写下这篇文章。

pxcharts-vue 的一周重构,是效率的胜利,也是一次职业价值的重新校准。AI编程确实"复仇"了CRUD的重复劳动,但它也暴露了人类开发者的软肋——我们过去引以为傲的"编码速度",在AI面前不值一提。

新的竞争力在于:架构设计的品味、安全风险的嗅觉、人机协作的智慧,以及对自己代码的深刻理解

vue2 和 vue3自定义指令有什么区别,都是怎么实现和使用一个指令

vue2 和 vue3自定义指令有什么区别,都是怎么实现和使用一个指令

Vue2 和 Vue3 自定义指令(Custom Directive) 整体思想一样:

直接操作 DOM 的一种扩展机制,通常用于权限控制、焦点、拖拽、懒加载等。

但 API 设计、生命周期、实现方式有明显变化。

4个层面:

1️⃣ Vue2 vs Vue3 指令生命周期区别 2️⃣ Vue2 按钮权限指令实现 3️⃣ Vue3 按钮权限指令实现 4️⃣ Vue3 指令底层设计变化

一、Vue2 vs Vue3 指令生命周期区别

Vue2 指令钩子

Vue2 指令有 5个生命周期

钩子 说明
bind 指令第一次绑定到元素
inserted 元素插入 DOM
update VNode 更新
componentUpdated 组件更新完成
unbind 解绑

示例

Vue.directive('focus', {
  bind(el) {},
  inserted(el) {},
  update(el) {},
  componentUpdated(el) {},
  unbind(el) {}
})

Vue3 指令生命周期

Vue3 完全重写了指令生命周期,名字和组件生命周期保持一致。

Vue2 Vue3
bind beforeMount
inserted mounted
update updated
componentUpdated updated
unbind unmounted

示例

app.directive('focus', {
  beforeMount(el) {},
  mounted(el) {},
  updated(el) {},
  unmounted(el) {}
})

二、Vue2 按钮权限指令实现

企业中最常见的自定义指令就是:

按钮权限控制

v-permission

例如

<button v-permission="'user:add'">新增</button>

如果没有权限:按钮直接删除

Vue2 指令实现

  1. 定义指令
import store from '@/store'

Vue.directive('permission', {
  inserted(el, binding) {

    const { value } = binding
    const permissions = store.state.user.permissions

    if (value && value instanceof Array) {

      const hasPermission = permissions.some(
        p => value.includes(p)
      )

      if (!hasPermission) {
        el.parentNode.removeChild(el)
      }

    } else {
      throw new Error('权限指令需要数组')
    }
  }
})
  1. 使用指令
<button v-permission="['user:add']">
新增用户
</button>

binding 参数结构

Vue2:

binding = {
  name: 'permission',
  value: ['user:add'],
  oldValue: undefined,
  expression: "['user:add']",
  arg: undefined,
  modifiers: {}
}

三、Vue3 按钮权限指令实现

Vue3 写法更简洁。

  1. 创建指令

src/directives/permission.ts

import type { Directive } from 'vue'
import { useUserStore } from '@/store/user'

export const permission: Directive = {
  mounted(el, binding) {

    const { value } = binding
    const userStore = useUserStore()

    const permissions = userStore.permissions

    const hasPermission = permissions.some(
      p => value.includes(p)
    )

    if (!hasPermission) {
      el.parentNode?.removeChild(el)
    }
  }
}
  1. 注册指令

main.ts

import { createApp } from 'vue'
import { permission } from '@/directives/permission'

const app = createApp(App)

app.directive('permission', permission)

app.mount('#app')
  1. 使用指令
<button v-permission="['user:add']">
新增用户
</button>

四、Vue3 指令底层设计变化

Vue3 指令其实是 VNode patch 阶段执行。

关键源码在:

runtime-core/directives.ts

核心函数:

invokeDirectiveHook

简化源码:

export function invokeDirectiveHook(
  vnode,
  prevVNode,
  instance,
  name
) {
  const bindings = vnode.dirs

  for (let i = 0; i < bindings.length; i++) {
    const binding = bindings[i]

    const hook = binding.dir[name]

    if (hook) {
      hook(vnode.el, binding, vnode, prevVNode)
    }
  }
}

执行流程:

template
   ↓
编译成 render
   ↓
VNode 上挂 dirs
   ↓
patch 阶段
   ↓
invokeDirectiveHook
   ↓
执行 mounted / updated

VNode结构:

{
  type: 'button',
  props: {},
  dirs: [
    {
      dir: permission,
      value: ['user:add']
    }
  ]
}

五、Vue2 vs Vue3 指令实现差异

区别 Vue2 Vue3
注册 Vue.directive app.directive
生命周期 bind inserted update beforeMount mounted updated
调用时机 patch patch
binding 参数 复杂 更简单
类型支持 TS Directive 类型
底层实现 directive.js runtime-core/directives.ts

六、 Vue3 指令系统真正的运行路径

源码执行链路

template
 ↓
编译 render
 ↓
withDirectives()
 ↓
VNode.dirspatch()
 ↓
invokeDirectiveHook()
 ↓
执行 mounted / updated / unmounted

七、模板里的指令是怎么变成 VNode 的

模板

<button v-permission="['user:add']">
新增
</button>

编译后的 render 函数(简化版)

import { withDirectives, createVNode } from "vue"

return withDirectives(
  createVNode("button", null, "新增"),
  [
    [permission, ['user:add']]
  ]
)

关键函数:

withDirectives()

函数的作用是:把指令挂到 VNode 上

八、withDirectives 源码

源码位置:

packages/runtime-core/src/directives.ts

核心代码(简化)

export function withDirectives(vnode, directives) {

  const bindings = vnode.dirs || (vnode.dirs = [])

  for (let i = 0; i < directives.length; i++) {

    let [dir, value, arg, modifiers] = directives[i]

    bindings.push({
      dir,
      value,
      arg,
      modifiers
    })

  }

  return vnode
}

执行完之后:

VNode 结构会变成:

{
  type: "button",
  props: null,
  children: "新增",
  dirs: [
    {
      dir: permission,
      value: ['user:add'],
      arg: undefined,
      modifiers: {}
    }
  ]
}

重点:VNode.dirs 这里存放所有指令

九、patch 阶段如何执行指令

Vue3 DOM 渲染核心:renderer.ts

当元素创建时:mountElement()

核心流程:mountElement(vnode, container)

简化代码:

const mountElement = (vnode, container) => {

  const el = vnode.el = document.createElement(vnode.type)

  // props
  patchProps(el)

  // children
  mountChildren()

  // 指令 mounted
  if (vnode.dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
  }

}

所以:mounted 是在 DOM 创建完成之后执行

十、invokeDirectiveHook(核心函数)

源码:

runtime-core/directives.ts

核心代码:

export function invokeDirectiveHook(
  vnode,
  prevVNode,
  instance,
  name
) {

  const bindings = vnode.dirs

  for (let i = 0; i < bindings.length; i++) {

    const binding = bindings[i]

    const hook = binding.dir[name]

    if (hook) {
      hook(
        vnode.el,
        binding,
        vnode,
        prevVNode
      )
    }
  }
}

执行逻辑:

遍历 vnode.dirs
   ↓
找到对应生命周期
   ↓
执行 mounted / updated

调用指令:

permission.mounted(el, binding)

十一、binding 参数真正结构

当 VNode 更新:patchElement()

源码:

if (dirs) {
  invokeDirectiveHook(
    n2,
    n1,
    parentComponent,
    'updated'
  )
}

执行顺序:

patch props
patch children
↓
directive updated

所以:updated 一定在 DOM 更新后执行

十二、指令 unmounted 执行时机

组件卸载:unmount()

源码:

if (vnode.dirs) {
  invokeDirectiveHook(vnode, null, instance, 'unmounted')
}

十三、Vue2 指令底层 vs Vue3 指令底层

Vue2 指令是在:patch.js 执行 updateDirectives()

源码:

function updateDirectives(oldVnode, vnode) {

  const dirs = normalizeDirectives()

  for (key in dirs) {

    callHook(dir, 'bind')

  }

}

逻辑非常复杂

Vue3重写原因:

1️⃣ 生命周期混乱 2️⃣ diff逻辑复杂 3️⃣ 指令和组件生命周期不一致

Vue3改进:

统一生命周期
统一调用入口
VNode直接挂 dirs

后话:

为什么 Vue3 要有 withDirectives

Vue3 是 函数式 VNode 创建:

h('button')

没有 template 的情况下:

h('button', {}, '新增')

只能:

withDirectives()

withDirectives(
  h('button'),
  [[permission, ['user:add']]]
)

所以:withDirectives 是 render 层 API

网易云桌面端--精选歌单布局思路记录

最近在学习electron想做一个自己喜欢的桌面端的软件,这边选择了网易云音乐,这边记录一下自己实现布局和功能的思路

image.png

查看图片可以发现这个页面内容包含了三个部分,左边箭头,右边箭头,中间的内容区域,这边开始将基本的布局框架搭建出来

 <div class="scroll-warp group">
 <!-- 左箭头-->
    <div
      class="arrow left-arrow transition-opacity duration-300"
      :class="{ disabled: isAtStart }"
      @click="scroll('left')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-left"></Icon>
    </div>
    <!--内容-->
    <div class="content" ref="contentRef" @scroll="handleScroll">
      <MusicItemCard v-for="(item, index) in 8" :key="index"></MusicItemCard>
    </div>
    <!-- 右箭头 -->
    <div
      class="arrow right-arrow transition-opacity duration-300"
      :class="{ disabled: isAtEnd }"
      @click="scroll('right')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-right"></Icon>
    </div>
  </div>

有了基本的容器,我们就需要将样式完善出来

.scroll-warp {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background-color: pink;
  padding: 10px;

  // 箭头的通用样式
  .arrow {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 100%;
    min-height: 40px; // 防止高度为0
    cursor: pointer;
    z-index: 10;

    // 默认隐藏,父容器 hover 时显示
    opacity: 0;
    transition:
      opacity 0.3s ease,
      background-color 0.3s;

    // 禁用状态样式
    &.disabled {
      opacity: 0.5 !important; // 即使 hover 也保持半透明
      cursor: not-allowed;
      // background-color: #ccc; // 变灰
      pointer-events: none; // 禁止点击
    }
  }

  // 当鼠标移入 scroll-warp 时,显示箭头
  &:hover .arrow {
    opacity: 1;
  }

  .content {
    flex: 1;
    flex-shrink: 0;
    background-color: rgb(0, 255, 183);
    overflow-x: scroll;
    overflow-y: hidden;
    white-space: nowrap;
    display: flex;
    align-items: center;
    padding: 10px;
    gap: 10px;
    flex-wrap: nowrap;

    // 隐藏滚动条
    &::-webkit-scrollbar {
      display: none;
    }

    // 兼容其他浏览器隐藏滚动条
    -ms-overflow-style: none; /* IE and Edge */
    scrollbar-width: none; /* Firefox */

    margin: 0 10px;
  }
}

然后我们就可以得到一个这样的布局界面

image.png

接下来我们来实现一下js逻辑

import { ref, onMounted, onUnmounted } from 'vue'
import MusicItemCard from './MusicItemCard.vue'

// 获取内容区域的 DOM 引用
const contentRef = ref(null)

// 定义状态变量
const isAtStart = ref(true) // 是否在最左侧
const isAtEnd = ref(false) // 是否在最右侧

// 滚动处理函数
const scroll = (direction) => {
  if (!contentRef.value) return

  // 每次滚动的距离,这里设置为容器宽度的 80%,也可以设置为固定像素如 300
  const scrollAmount = contentRef.value.clientWidth * 0.8

  if (direction === 'left') {
    contentRef.value.scrollBy({ left: -scrollAmount, behavior: 'smooth' })
  } else {
    contentRef.value.scrollBy({ left: scrollAmount, behavior: 'smooth' })
  }
}

// 监听滚动事件,更新按钮状态
const handleScroll = () => {
  if (!contentRef.value) return

  const { scrollLeft, scrollWidth, clientWidth } = contentRef.value

  // 判断是否在起点(允许 1px 的误差)
  isAtStart.value = scrollLeft <= 1

  // 判断是否在终点(scrollLeft + clientWidth >= scrollWidth)
  // 这里减去 1 是为了处理浮点数计算可能存在的微小误差,或者为了留一点边距
  isAtEnd.value = Math.ceil(scrollLeft + clientWidth) >= scrollWidth - 1
}

// 组件挂载和卸载时处理窗口大小变化(可选,为了更严谨)
const updateScrollState = () => handleScroll()

onMounted(() => {
  // 初始化时检查一次状态
  updateScrollState()
  // 监听窗口大小变化,因为窗口变化可能导致可滚动宽度变化
  window.addEventListener('resize', updateScrollState)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateScrollState)
})

这样我们就可以实现这种的布局切换容器和界面了

如何需要MusicItemCard代码

<template>
  <div class="music-card">
    123
  </div>
</template>

<script setup>
import { ref,reactive,getCurrentInstance} from 'vue'
const { proxy } = getCurrentInstance()
</script>

<style scoped lang="scss">
.music-card {
  width: 140px;
  height: 190px;
  border-radius: 6px;
  background-color: #fff;
  flex-shrink: 0;
  margin: 0 5px;
}
</style>

完整代码

<template>
  <div class="scroll-warp group">
    <!-- 左箭头 -->
    <!-- 
      1. 添加 @click 事件
      2. 动态绑定 class,当 isAtStart 为 true 时添加 disabled 样式
      3. 添加 opacity-0 和 group-hover:opacity-100 类实现鼠标移入显示
    -->
    <div
      class="arrow left-arrow transition-opacity duration-300"
      :class="{ disabled: isAtStart }"
      @click="scroll('left')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-left"></Icon>
    </div>

    <!-- 内容区域 -->
    <!-- 
      1. 绑定 ref 以便在 JS 中获取 DOM 元素
      2. 监听 scroll 事件以更新状态
    -->
    <div class="content" ref="contentRef" @scroll="handleScroll">
      <!-- 这里的 item 只是演示,实际使用请传入你的数据 -->
      <MusicItemCard v-for="(item, index) in 8" :key="index"></MusicItemCard>
    </div>

    <!-- 右箭头 -->
    <div
      class="arrow right-arrow transition-opacity duration-300"
      :class="{ disabled: isAtEnd }"
      @click="scroll('right')"
    >
      <Icon style="width: 100%; height: 100%" icon="tabler:chevron-right"></Icon>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import MusicItemCard from './MusicItemCard.vue'

// 获取内容区域的 DOM 引用
const contentRef = ref(null)

// 定义状态变量
const isAtStart = ref(true) // 是否在最左侧
const isAtEnd = ref(false) // 是否在最右侧

// 滚动处理函数
const scroll = (direction) => {
  if (!contentRef.value) return

  // 每次滚动的距离,这里设置为容器宽度的 80%,也可以设置为固定像素如 300
  const scrollAmount = contentRef.value.clientWidth * 0.8

  if (direction === 'left') {
    contentRef.value.scrollBy({ left: -scrollAmount, behavior: 'smooth' })
  } else {
    contentRef.value.scrollBy({ left: scrollAmount, behavior: 'smooth' })
  }
}

// 监听滚动事件,更新按钮状态
const handleScroll = () => {
  if (!contentRef.value) return

  const { scrollLeft, scrollWidth, clientWidth } = contentRef.value

  // 判断是否在起点(允许 1px 的误差)
  isAtStart.value = scrollLeft <= 1

  // 判断是否在终点(scrollLeft + clientWidth >= scrollWidth)
  // 这里减去 1 是为了处理浮点数计算可能存在的微小误差,或者为了留一点边距
  isAtEnd.value = Math.ceil(scrollLeft + clientWidth) >= scrollWidth - 1
}

// 组件挂载和卸载时处理窗口大小变化(可选,为了更严谨)
const updateScrollState = () => handleScroll()

onMounted(() => {
  // 初始化时检查一次状态
  updateScrollState()
  // 监听窗口大小变化,因为窗口变化可能导致可滚动宽度变化
  window.addEventListener('resize', updateScrollState)
})

onUnmounted(() => {
  window.removeEventListener('resize', updateScrollState)
})
</script>

<style scoped lang="scss">
.scroll-warp {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background-color: pink;
  padding: 10px;
  position: relative; // 如果箭头需要绝对定位可以开启

  // 箭头的通用样式
  .arrow {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 100%;
    min-height: 40px; // 防止高度为0
    cursor: pointer;
    z-index: 10;

    // 默认隐藏,父容器 hover 时显示 (Tailwind CSS 写法: opacity-0 group-hover:opacity-100)
    opacity: 0;
    transition:
      opacity 0.3s ease,
      background-color 0.3s;

    &:hover {
      // background-color: darkorange;
    }

    // 禁用状态样式
    &.disabled {
      opacity: 0.5 !important; // 即使 hover 也保持半透明
      cursor: not-allowed;
      // background-color: #ccc; // 变灰
      pointer-events: none; // 禁止点击
    }
  }

  // 当鼠标移入 scroll-warp 时,显示箭头
  &:hover .arrow {
    opacity: 1;
  }

  .content {
    flex: 1;
    flex-shrink: 0;
    background-color: rgb(0, 255, 183);
    overflow-x: scroll;
    overflow-y: hidden;
    white-space: nowrap;
    display: flex;
    align-items: center;
    padding: 10px;
    gap: 10px; // 使用 gap 代替 margin 控制间距
    flex-wrap: nowrap;

    // 隐藏滚动条
    &::-webkit-scrollbar {
      display: none;
    }

    // 兼容其他浏览器隐藏滚动条
    -ms-overflow-style: none; /* IE and Edge */
    scrollbar-width: none; /* Firefox */

    margin: 0 10px;
  }
}
</style>

Vite 实战教程:alias/env/proxy 配置 + 打包优化避坑|Vue 工程化必备

【Vite】前端工程化实操:从路径别名到打包优化,彻底搞懂Vite核心配置,避开高频踩坑!

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱「面向搜索引擎写代码」的尴尬。

📑 文章目录

一、alias:让 import 更清晰

1.1 为什么需要 alias?

没有 alias 时,你会经常看到这样的写法:


import Button from '../../../components/Button.vue'
import { getUserInfo } from '../../../../api/user'

问题主要有两点:

  1. ../ 太多,路径难维护,容易写错

  2. 重构时移动文件,相对路径要全改一遍

用 alias 把常用目录映射成简短路径后,可以改成:


import Button from '@/components/Button.vue'
import { getUserInfo } from '@/api/user'

⬆ 返回目录

1.2 怎么配置?

vite.config.js(或 vite.config.ts)里配置:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      // 方式一:映射到 src 目录
      '@': fileURLToPath(new URL('./src', import.meta.url)),
      // 方式二:可以配多个
      '@components': fileURLToPath(new URL('./src/components', import.meta.url)),
      '@api': fileURLToPath(new URL('./src/api', import.meta.url)),
    },
  },
})

要点:

  • fileURLToPath + new URL():在 Node 的 ESM 环境下拿到正确的绝对路径

  • import.meta.url:当前配置文件所在目录

  • ./src:相对于配置文件所在目录的路径

⬆ 返回目录

1.3 常见踩坑

坑 1:忘记在 IDE 里配置路径提示

Vite 能正确解析,但 IDE 可能不认识 @,需要加 jsconfig.jsontsconfig.json


// jsconfig.json(用 JS 的项目)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"],
      "@api/*": ["src/api/*"]
    }
  },
  "include": ["src/**/*"]
}

// tsconfig.json(用 TS 的项目)
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@components/*": ["src/components/*"]
    }
  }
}

坑 2:alias 和 Vite 配置不一致

jsconfig/tsconfigpaths 要跟 vite.configalias 保持一致,否则可能出现:开发时没问题,打包后路径错误或 IDE 报错。

⬆ 返回目录

二、env:环境变量怎么用

2.1 为什么需要 env?

不同环境需要不同配置,例如:

  • 开发环境:本地 API 地址、调试开关

  • 生产环境:线上 API 地址、关闭调试

如果写死在代码里,每次发版都要手动改,容易出错。用 env 可以按环境自动切换。

⬆ 返回目录

2.2 基本规则

Vite 的环境变量规则:

  • 文件名必须是 .env.env.local.env.[mode].env.[mode].local 这类

  • 只有以 VITE_ 开头的变量会暴露给客户端

  • mode 默认是 development(dev)和 production(build)

⬆ 返回目录

2.3 典型文件结构


项目根目录/
├── .env                 # 所有环境都加载
├── .env.local           # 本地覆盖,一般加在 .gitignore
├── .env.development     # 开发环境
├── .env.production      # 生产环境
└── .env.staging         # 可选:预发环境

⬆ 返回目录

2.4 示例配置

.env (公共变量)


# API 基础路径(会被 .env.development / .env.production 覆盖)
VITE_APP_TITLE=我的项目

.env.development (开发)


VITE_API_BASE_URL=http://localhost:3000/api
VITE_USE_MOCK=true

.env.production (生产)


VITE_API_BASE_URL=https://api.yoursite.com
VITE_USE_MOCK=false

.env.local (本地覆盖,不提交)


# 比如你本机端口不同
VITE_API_BASE_URL=http://localhost:8080/api

⬆ 返回目录

2.5 在代码里怎么用


// 直接通过 import.meta.env 访问
console.log(import.meta.env.VITE_API_BASE_URL)
console.log(import.meta.env.VITE_USE_MOCK)
console.log(import.meta.env.MODE)  // 'development' | 'production'

如果要集中管理,可以再包一层:


// src/config/env.js
export const config = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  useMock: import.meta.env.VITE_USE_MOCK === 'true',
  isDev: import.meta.env.DEV,
  isProd: import.meta.env.PROD,
}

⬆ 返回目录

2.6 常见踩坑

坑 1:没用 VITE_ 前缀


API_URL=xxx   # ❌ 客户端拿不到
VITE_API_URL=xxx  # ✅ 正确

坑 2:把 env 当布尔用


// env 读出来都是字符串
if (import.meta.env.VITE_USE_MOCK) { }  // 'true' 和 'false' 都是 truthy!
// 正确写法
if (import.meta.env.VITE_USE_MOCK === 'true') { }

坑 3:.env.local 被提交

.env.local 里常放本地密钥、端口等,要加到 .gitignore,不要提交。

⬆ 返回目录

三、proxy:解决开发环境跨域

3.1 为什么需要 proxy?

前端开发时往往是 localhost:5173,接口在 api.yoursite.com,浏览器会因同源策略限制产生跨域。

后端配 CORS 是一种方式,但有时后端不方便改,或者你想在本地连不同环境的接口,这时用 Vite 的 proxy 最方便:浏览器只请求同源的 dev 服务器,由 dev 服务器转发到真实接口。

⬆ 返回目录

3.2 基本配置


// vite.config.js
export default defineConfig({
  server: {
    port: 5173,
    proxy: {
      // 简单写法:/api 开头的请求转发到目标服务器
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
      },
    },
  },
})

这样访问 http://localhost:5173/api/user/info 时,会被转发到 https://api.yoursite.com/api/user/info

⬆ 返回目录

3.3 更完整的配置示例


// vite.config.js
export default defineConfig({
  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''), // 转发时去掉 /api 前缀
        secure: false,
        configure: (proxy, options) => {
          proxy.on('proxyReq', (proxyReq, req, res) => {
            // 可选:加 token 等请求头
            // proxyReq.setHeader('Authorization', 'Bearer xxx')
          })
          proxy.on('proxyRes', (proxyRes, req, res) => {
            // 可选:处理响应
          })
        },
      },
      // 多个接口可以配多个代理
      '/upload': {
        target: 'https://upload.yoursite.com',
        changeOrigin: true,
      },
    },
  },
})

常用选项说明:

选项 作用
target 真实后端地址
changeOrigin 改请求头 Host,避免目标服务器校验失败
rewrite 重写请求路径,例如去掉 /api 前缀
secure 目标为 https 且证书有问题时,可设 false
⬆ 返回目录

3.4 和 env 配合

开发环境用 proxy,生产用完整 URL,可以这样配合 env:

.env.development


VITE_API_BASE_URL=/api

.env.production


VITE_API_BASE_URL=https://api.yoursite.com

src/api/request.js


const baseURL = import.meta.env.VITE_API_BASE_URL

export function request(url, options = {}) {
  return fetch(`${baseURL}${url}`, options)
}

开发时请求 /api/xxx,会被 proxy 转发;生产时直接请求完整域名。

⬆ 返回目录

3.5 常见踩坑

坑 1:忘记 changeOrigin

目标为域名时,建议设 changeOrigin: true,否则可能被后端拒绝。

坑 2:rewrite 把路径改错了

要清楚 rewrite 前后路径的对应关系,比如:


// 前端请求:/api/user/info
// 未 rewrite:https://api.xxx.com/api/user/info
// rewrite 去掉 /api:https://api.xxx.com/user/info
rewrite: (path) => path.replace(/^\/api/, ''),

要看后端实际路径再决定是否 rewrite。

坑 3:proxy 只在开发环境生效

server.proxy 只在 vite 开发服务器下生效,生产构建不会用到,生产环境依赖你配置的 VITE_API_BASE_URL 等。

⬆ 返回目录

四、打包优化

4.1 为什么需要打包优化?

不做优化时常见问题:

  • 单个 JS 过大,首屏加载慢

  • 第三方库和业务代码混在一起,缓存利用差

  • 未压缩的包体积大

Vite 默认已经做了不少优化,我们再针对常见场景补充一些配置。

⬆ 返回目录

4.2 代码分割(手动分包)


// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Vue 全家桶单独打包
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          // 体积较大的 UI 库单独打包
          'element-plus': ['element-plus'],
        },
      },
    },
  },
})

这样可以把 Vue、路由、状态管理和 UI 库拆成独立 chunk,利于缓存。

⬆ 返回目录

4.3 分包策略示例(按路由/模块)


// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes('node_modules')) {
            // node_modules 里的包
            if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {
              return 'vue-vendor'
            }
            if (id.includes('element-plus')) {
              return 'element-plus'
            }
            return 'vendor'
          }
        },
        chunkFileNames: 'js/[name]-[hash].js',
        entryFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
      },
    },
    chunkSizeWarningLimit: 1000, // 单 chunk 超过 1000kb 时警告
  },
})

⬆ 返回目录

4.4 CDN 外链(可选)

把 Vue、Element Plus 等用 CDN 引入,减小打包体积:


// vite.config.js
import { defineConfig } from 'vite'

export default defineConfig({
  build: {
    rollupOptions: {
      external: ['vue', 'vue-router', 'pinia', 'element-plus'],
      output: {
        globals: {
          vue: 'Vue',
          'vue-router': 'VueRouter',
          pinia: 'Pinia',
          'element-plus': 'ElementPlus',
        },
      },
    },
  },
})

index.html 中用 <script> 引入对应 CDN,并确保全局变量名和 globals 一致。

注意:一般 SPA 不推荐全部 external,可以只 external 少数大库,其余照常打包。

⬆ 返回目录

4.5 压缩与产物清理


// vite.config.js
export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,  // 生产环境去掉 console
        drop_debugger: true,
      },
    },
    cssCodeSplit: true,
    sourcemap: false,
  },
})

⬆ 返回目录

4.6 常见踩坑

坑 1:manualChunks 拆得太碎

拆出太多小 chunk 会多很多请求,反而影响性能,一般把体积大的依赖拆几块即可。

坑 2:忘记配 chunkSizeWarningLimit

默认 500kb 会报警,可按项目实际情况调大,例如 1000 或 1500。

坑 3:生产 sourcemap

生产环境建议关掉 sourcemap,否则包体积会明显增大。

⬆ 返回目录

五、完整配置示例

下面是一份整合了 alias、env、proxy 和打包优化的 vite.config.js 示例:


// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],

  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },

  server: {
    port: 5173,
    open: true,
    proxy: {
      '/api': {
        target: 'https://api.yoursite.com',
        changeOrigin: true,
      },
    },
  },

  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
        },
        chunkFileNames: 'js/[name]-[hash].js',
        assetFileNames: '[ext]/[name]-[hash].[ext]',
      },
    },
    chunkSizeWarningLimit: 1000,
    sourcemap: false,
  },
})

⬆ 返回目录

六、小结

配置项 作用 重点
alias 简化 import 路径 和 jsconfig/tsconfig 保持一致
env 按环境切换配置 必须 VITE_ 前缀,注意值是字符串
proxy 开发环境解决跨域 changeOrigin,和 env 配合使用
打包优化 减小体积、提升加载 合理分包,控制 chunk 数量和大小

建议在实际项目里按需启用和调整这些配置,有问题可以在评论区补充你的项目结构和错误信息,便于一起排查。

⬆ 返回目录


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战的方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

早点下班(Vue2.7版):旧项目也能少写 40%+ 异步代码

前段我在文章 早点下班:在 Vue3 中少写 40%+ 的异步代码 中分享了自己开发的 vue-asyncx,当时就有同学问:那 Vue 2 的老项目呢?就在今天,它来了!

vue-asyncx 发布 v1.11.0 版本,该版本向前兼容 Vue 2.7,同样帮你秒减 40%+ 异步代码。写法用法与在 Vue 3 中一模一样,毫不妥协

如果你还在维护 Vue 2.7 的老项目,每次写异步请求都要手动管理 loadingerrordata,还要处理竞态问题。那这篇文章,可能就是你的「下班加速器」🚀

少说废话,先看代码!

🎯 先来看个最常见的详情页查询

<!-- 老项目里的经典异步三板斧 + watch 联动 -->
<template>
  <div>
    <div v-if="queryDetailLoading">加载中...</div>
    <div v-else-if="queryDetailError">加载失败:{{ queryDetailError.message }}</div>
    <div v-else>
      <h2>{{ detail.name }}</h2>
      <p>{{ detail.desc }}</p>
    </div>
  </div>
</template>

<script>
export default {
  props: { id: String },
  data() {
    return {
      detail: null,
      queryDetailLoading: false,
      queryDetailError: null
    }
  },
  created() {
    this.queryDetail()
  },
  watch: {
    id: function () {  
      this.queryDetail()
    },
  },
  methods: {
    async queryDetail() {
      this.queryDetailLoading = true
      this.queryDetailError = null
      try {
        // 💥 有坑:快速切换 id,旧请求返回覆盖新数据(竞态)
        this.detail = await queryDetailApi(this.id)
      } catch (e) {
        this.queryDetailError = e
      } finally {
        this.queryDetailLoading = false
      }
    }
  }
}
</script>

👆 这段代码,你是不是写过 10 遍、20 遍、100 遍?

别急,现在,这些样板代码可以一键消失了

🔥 代码对比:少写 40%+ 是什么体验?

传统写法(~20 行)

export default {
  props: { id: String },
  data() {
    return {
      detail: null,
      queryDetailLoading: false,
      queryDetailError: null
    }
  },
  created() {
    this.queryDetail()
  },
  watch: {
    id: function () {  
      this.queryDetail()
    },
  },
  methods: {
    async queryDetail() {
      this.queryDetailLoading = true
      this.queryDetailError = null
      try {
        // 💥 有坑:快速切换 id,旧请求返回覆盖新数据(竞态)
        this.detail = await queryDetailApi(this.id)
      } catch (e) {
        this.queryDetailError = e
      } finally {
        this.queryDetailLoading = false
      }
    }
  }
}

vue-asyncx 写法(~8 行)✨

import { useAsyncData } from 'vue-asyncx'

export default {
  props: { id: String },
  setup(props) {
    const { 
      detail, 
      queryDetail, 
      queryDetailLoading, 
      queryDetailError 
    } = useAsyncData('detail', () => queryDetailApi(props.id), { 
      immediate: true,  // setup 执行时自动请求
      watch: () => props.id  // id 变化自动重新请求 + 竞态防护
    })
    return { detail, queryDetail, queryDetailLoading, queryDetailError }
  }
}

🎯 核心能力一览

  • ✅ 代码一屏变半屏,逻辑聚合在一起
  • loading / error / data 自动绑定,响应式更新
  • watch 依赖自动追踪props.id 变化自动重新请求
  • 竞态请求自动处理:快速切换 id 时,旧数据自动废弃,不串数据
  • ✅ 支持手动触发 queryDetail()、防抖节流
  • ✅ TypeScript 友好,智能类型推导

📦 两个核心 API,覆盖 90% 异步场景

1️⃣ useAsyncData:自动执行 + 数据绑定,查询类场景神器

import { useAsyncData } from 'vue-asyncx'

export default {
  props: { id: String },
  setup(props) {
    const { 
      detail,              // 异步数据(响应式)
      queryDetail,         // 手动查询函数(可选)
      queryDetailLoading,  // 加载状态
      queryDetailError,    // 错误状态
    } = useAsyncData('detail', () => queryDetailApi(props.id), {
      immediate: true,     // setup 执行时自动调用
      watch: () => props.id  // 监听 id 变化,自动重新请求
    })

    return { detail, queryDetail, queryDetailLoading, queryDetailError }
  }
}

✅ 适用场景:详情页加载、列表查询、参数变化自动刷新的数据获取

使用异步数据,就是 useAsyncData

2️⃣ useAsync:包装异步函数,自动管理状态

import { useAsync } from 'vue-asyncx'

export default {
  setup() {
    const { 
      submit,           // 包装后的异步函数(可直接绑定 @click)
      submitLoading,   // 加载状态(响应式)
      submitError,     // 错误状态(响应式)
    } = useAsync('submit', submitApi)

    return { submit, submitLoading, submitError }
  }
}

✅ 适用场景:表单提交、按钮操作、手动触发的异步任务

使用异步(函数),就是 useAsync


useAsyncDatauseAsync 的首个参数实际上是变量名。

  • 对于 useAsyncData,传入 'detail' 返回 detail、queryDetail、queryDetailLoading 等
  • 对于 useAsync 传入 'submit' 返回 submit、submitLoading 等

这种命名约定方式在代码可读性、团队协作性上有巨大优势,且在 TS 加持下不损失开发效率

进一步阅读:如何通过工程手段统一团队变量命名

🛠️ Vue 2.7 接入示例:详情页完整实战

常规详情信息获取 + 审批确认详情页

<!-- DetailPage.vue -->
<template>
  <div>
    <!-- 加载状态 -->
    <div v-if="queryDetailLoading" class="loading">
      <Spinner /> 加载中...
    </div>
    
    <!-- 错误状态 -->
    <div v-else-if="queryDetailError" class="error">
      <ErrorMsg :error="queryDetailError" />
      <button @click="queryDetail()">重试</button>
    </div>
    
    <!-- 数据展示 -->
    <div v-else class="detail">
      <h2>{{ detail.name }}</h2>
      <p>{{ detail.desc }}</p>
    </div>
    
    <!-- 确认操作 -->
    <button @click="confirm" v-loading="confirmLoading">确认</button>
  </div>
</template>

<script>
import { useAsyncData, useAsync } from 'vue-asyncx'
import { queryDetailApi, confirmApi } from '@/api/detail'

export default {
  props: { id: String },
  setup(props) {
    // 🎯 详情获取:一个调用搞定详情页查询 + 自动 watch + 竞态防护
    const { 
      detail, 
      queryDetail, 
      queryDetailLoading, 
      queryDetailError 
    } = useAsyncData('detail', () => queryDetailApi(props.id), {
      immediate: true,
      watch: () => props.id  // id 变化自动重新请求,旧请求自动取消
    })

    // 🎯 确认操作:手动触发的异步任务
    const { confirm, confirmLoading } = useAsync('confirm', 
      () => confirmApi(props.id).then(() => queryDetail())
    )

    return {
      detail,
      queryDetail,
      queryDetailLoading,
      queryDetailError,
      confirm,
      confirmLoading
    }
  }
}
</script>

用 Vue Options 实现相同功能:

  • 代码量轻松翻一倍
  • 代码交织、阅读需要上下跳转,无法做到:详情获取逻辑在一起,确认操作逻辑在一起

🚀 为什么支持 Vue 2.7?

很多团队不是不想升级 Vue 3,而是:

  • 老项目重构成本高,业务迭代紧
  • 老项目只是维护,偶尔有新需求,没有重构资源
  • Vue 2.7 也能用 Composition API,够用了

vue-asyncx 想你所想,让旧项目也能享受异步管理的优雅。

  • 用 options 也没有关系,加个 setup 属性,马上就能用,渐进式改造。
  • 后期老项目升级 Vue 版本,API 一模一样,一行代码都不用改

为什么要迁移 setup,怎么迁移 setup,安利下我的文章:

🧪 质量保障:敢在生产环境用的底气

很多人问:「新库稳不稳定?老项目敢不敢接?」

直接上数据👇

指标 数值 说明
📦 单元测试 300+ 覆盖所有 API 边界场景
🌐 E2E 测试 10+ 模拟真实用户使用流程
🔄 双版本测试 Vue 2.7 / 3 相同测试用例、流程,确保行为一致
🤖 CI/CD GitHub Actions 每次提交自动跑全量测试 Test Status codecov
📊 代码覆盖率 100% 分支/语句/函数全覆盖

🎯 基于这套测试体系,vue-asyncx

  • 在多个产线项目大规模使用,日调用 2w+,稳如老狗 🐶
  • 在单个大型项目中,总共 900+ .vue 文件,有 300+ .vue 文件使用超 500+ 次
  • 发布 2 年,20+ 版本更新,接口零破坏性变更(做兼容,我是认真的)

🔜 后续预告:兼容背后的「技术狠活」

支持双版本不是 if (vue2) {...} else {...} 那么简单。
后续我会深度拆解 双版本兼容的测试架构与实现方案——包括兼容策略选型、同一套用例双环境跑通、无头浏览器 E2E 验证、低版本 TS 适配等。

💡 如果你对这类“技术狠活”感兴趣,记得点个关注,更新不迷路~

🙋 现在就能试试!

npm install vue-asyncx

📚 文档 & 示例:
👉 GitHub: vue-asyncx
👉 NPM: vue-asyncx

版本要求:vue-asyncx 目前最低兼容到 Vue 2.7

💬 互动时间

1️⃣ 你的项目还在用 Vue 2.7 吗?详情页查询是不是也写过类似的 watch + 竞态防护?
2️⃣ 对双版本兼容方案有什么好奇的?评论区聊聊👇

✅ 觉得有用,别忘了:
⭐ 点个 Star 支持开源
👍 点赞 + 收藏,下次找得到
🔄 转发给还在手写 loading + watch 的同事,一起早点下班!

拍照记单词项目学习笔记

本次学习围绕“拍照记单词”项目展开,该项目是一款结合AI多模态技术、前端开发、后端部署的轻量化英语学习工具,核心功能是通过拍照或上传图片,调用大模型接口解析图片内容,提取核心英文单词、生成例句及场景化解释,同时支持音频播放,帮助用户在真实场景中高效记忆单词。通过对该项目的全面学习,我系统掌握了Vue3+TS前端开发、NestJS后端技术、多模态大模型接口调用、产品原型设计等相关知识,也深刻理解了AI时代下产品开发的思路与逻辑。本笔记将从项目背景、产品分析、技术架构、代码解析、问题总结与拓展思考六个维度,详细记录学习过程中的知识点、难点及收获,确保内容全面、逻辑清晰,为后续同类项目开发提供参考。

一、项目背景与AI时代发展趋势

1.1 AI时代的技术变革与产品机遇

随着人工智能技术的快速迭代,尤其是大模型、多模态技术的突破,互联网行业迎来了“所有产品值得用AI重新做一遍”的全新机遇。在AI时代,技术不再是单纯的工具,而是深度融入产品设计、用户体验的核心要素,能够极大地提升产品的效率、智能化水平和用户粘性。

项目中提到的“vibe coding”理念,正是AI时代开发模式的体现——代码和项目开发变得快速且靠谱,AI能够辅助开发者完成需求分析、代码编写、bug调试等一系列工作,降低开发门槛,提升开发效率。同时,“one person company(一人公司)”的概念也逐渐成为可能,借助AI工具,个人能够完成创意构思、产品规划、商业分析、用户共情等多个环节的工作,尤其是AI产品经理的角色,需要具备将AI技术与用户需求结合的能力,打造出贴合用户痛点的智能化产品。

1.2 单词学习类产品的市场现状与痛点

在英语学习领域,单词记忆是核心痛点之一,传统的单词记忆方式枯燥、低效,难以结合真实场景,导致用户记忆不牢固、遗忘速度快。当前市场上已有多款单词类APP,通过不同的模式解决用户的单词记忆需求,其中最具代表性的是百词斩和扇贝,通过对这两款产品的分析,能够为“拍照记单词”项目提供借鉴。

百词斩的核心优势的是“细分领域背单词”,将单词与形象的图片结合,通过视觉联想帮助用户记忆,例如“awkward(尴尬的)”“giraffe(长颈鹿)”等单词,搭配对应的场景图片,能够让用户快速建立单词与含义的关联,降低记忆难度。这种“图文结合”的模式,也为“拍照记单词”项目提供了核心灵感——借助图片解析,让单词记忆更贴近真实场景。

扇贝的核心优势是“智能间隔重复算法”,该算法能够精准规划用户的复习时间,确保单词在即将遗忘时被强化记忆,从而实现长期记忆。这种“科学复习”的理念,也是“拍照记单词”项目需要借鉴的点,后续可以通过优化产品功能,加入复习规划模块,提升用户的单词记忆效果。

尽管现有单词类APP各有优势,但仍存在明显的痛点:一是场景化不足,多数APP的单词记忆脱离真实生活场景,用户在实际使用中难以灵活运用;二是交互不够便捷,需要用户手动输入单词或选择单词本,操作繁琐;三是个性化不足,无法根据用户的英语水平(如A1~A2级别)定制单词难度和例句。而“拍照记单词”项目,正是针对这些痛点,结合AI多模态技术,打造出“拍照即记词、场景化学习、个性化适配”的轻量化工具,填补市场空白。

1.3 项目核心定位与价值

“拍照记单词”项目的核心定位是:一款面向有基础英语学习需求(A1~A2级别)、注重场景化记忆的轻量化单词学习工具,适用于跨国生活、旅游、点餐等真实场景,帮助用户快速识别图片中的核心单词,掌握单词的用法和场景应用。

项目的核心价值主要体现在三个方面:一是便捷性,用户无需手动输入单词,只需拍照或上传图片,即可快速获取单词、例句及解释,降低操作门槛;二是场景化,结合图片场景记忆单词,让用户在真实场景中理解单词的用法,提升单词运用能力;三是智能化,借助多模态大模型和TTS技术,实现单词解析、音频播放等功能,提升用户学习体验。同时,项目还注重无障碍访问,通过label for + input#id等技术,帮助使用读屏器的盲人用户使用,体现产品的包容性。

二、产品分析与原型设计

2.1 产品核心需求与场景分析

产品的核心需求来源于用户在真实场景中对单词识别和记忆的需求,具体可分为以下几类:

  1. 识别需求:用户在跨国旅游、点餐、购物时,遇到不认识的英文单词(如菜单上的菜品名称、商品标签、路标等),需要快速识别单词含义;

  2. 记忆需求:识别单词后,需要掌握单词的发音、例句及用法,实现快速记忆,便于后续运用;

  3. 便捷需求:操作流程简单,无需复杂步骤,拍照即可完成单词识别和学习,节省时间;

  4. 个性化需求:根据用户的英语水平(A1~A2级别),提供简单易懂的单词和例句,避免过于复杂的词汇和句式。

结合核心需求,产品的核心应用场景主要包括:

  1. 跨国生活场景:用户在国外生活时,遇到家具、日用品、食品等物品的英文标签,拍照即可识别单词,了解物品名称和用法;

  2. 旅游场景:在国外旅游时,识别路标、景点介绍、菜单等内容中的英文单词,解决语言沟通障碍;

  3. 日常学习场景:用户看到身边的物品,拍照识别对应的英文单词,结合场景记忆,提升单词积累效率。

产品的核心痛点是“足够痛、强需求”——用户在真实场景中遇到不认识的英文单词时,往往没有时间手动查询词典,需要快速、便捷的识别和学习方式,而“拍照记单词”项目正好解决了这一痛点,能够在用户需要时,快速提供单词解析和学习内容。

2.2 竞品分析与产品差异化

除了前文提到的百词斩、扇贝,“拍照记单词”项目的竞品还包括多邻国等APP。多邻国的核心优势是“游戏化学习”,通过趣味关卡、打卡等方式,提升用户的学习积极性,但其拍照记词功能较为简单,主要以单词识别为主,缺乏场景化解释和个性化适配。

与现有竞品相比,“拍照记单词”项目的差异化优势主要体现在以下几点:

  1. 场景化深度结合:以图片为核心,不仅识别单词,还会结合图片场景生成例句和解释,让用户在理解场景的基础上记忆单词,提升记忆效果;

  2. 个性化适配:针对A1~A2级别用户,提供简单易懂的单词和例句,避免过于复杂的内容,贴合用户的英语水平;

  3. 轻量化设计:核心功能聚焦于拍照记词,操作流程简单,无需注册登录(可后续优化),打开即可使用,适合碎片化学习;

  4. 无障碍访问:考虑到特殊用户群体的需求,加入无障碍设计,提升产品的包容性;

  5. 多模态技术应用:结合多模态大模型(kimi-shot、moonshot-v1-8k-vision-preview)和TTS技术,实现图片解析、音频播放等功能,提升产品的智能化水平。

2.3 产品原型设计思路

产品原型的核心是围绕“拍照/上传图片→解析图片→获取单词学习内容→音频播放”的核心流程,设计简洁、便捷的交互界面,确保用户能够快速完成操作。根据项目文档,产品原型的核心功能模块及交互流程如下:

  1. 核心功能模块:

(1)拍照/上传图片模块:用户可通过点击摄像头图标,调用手机摄像头拍照,或上传本地图片,实现图片输入;

(2)图片解析模块:调用kimi大模型接口,解析图片内容,提取最能描述图片的英文单词(A1~A2级别),生成图片描述、例句、场景化解释及回复;

(3)音频播放模块:点击播放按钮,调用TTS技术,播放单词或例句的发音,帮助用户掌握正确的发音;

(4)详情展开模块:用户可点击“Talk bout it”按钮,展开详情页面,查看图片预览、单词解释及回复内容,收起则隐藏详情,保持界面简洁。

  1. 交互流程设计:

用户打开产品→点击摄像头图标→拍照/上传图片→系统自动解析图片→显示单词、例句→点击播放按钮听发音→点击详情按钮查看完整解释→完成单词学习。

  1. 页面设计要点:

(1)主页面:简洁明了,核心突出拍照功能,避免多余的元素干扰用户操作;

(2)图片上传/拍照页面:适配手机屏幕,提供清晰的摄像头调用入口和图片上传入口;

(3)结果展示页面:单词居中显示,字体清晰,例句和发音按钮便于用户查看和操作,详情模块隐藏,避免界面杂乱;

(4)详情页面:图片预览清晰,解释内容分行显示,回复内容带有边框,便于区分,整体布局简洁、易读。

2.4 设计稿相关思考

项目文档中未提供具体的设计稿,但结合代码中的样式部分,能够看出设计稿的核心思路是“简洁、美观、贴合场景”。设计风格以暖色调为主,背景采用线性渐变(从rgb(235,189,166)到rgb(71,49,32)),营造出温馨、舒适的学习氛围;卡片式设计,搭配阴影效果,提升界面的层次感;按钮、图片等元素采用圆角设计,增强界面的柔和度;文字颜色对比清晰,确保用户能够轻松阅读。

设计过程中需要注意的细节:一是适配不同屏幕尺寸,确保在手机、平板等设备上都能正常显示;二是优化图片上传和预览的体验,确保图片显示清晰、加载快速;三是无障碍设计,确保读屏器能够正常识别界面元素,帮助特殊用户使用;四是交互反馈,如图片上传中显示加载状态、解析完成后显示提示等,提升用户体验。

三、技术架构与技术调研

3.1 技术架构整体设计

“拍照记单词”项目采用前后端分离的技术架构,前端负责用户交互、界面展示、图片上传、音频播放等功能,后端负责接口开发、大模型调用、数据处理等功能,整体架构清晰、易于维护和扩展。具体架构如下:

  1. 前端层:采用Vue3+TS+Composition API开发,结合组件化思想,将页面拆分为PictureCard组件和主页面,实现代码复用和维护;使用FileReader实现图片本地读取和base64编码,用于多模态接口调用;使用TTS技术实现文本转语音,提供音频播放功能;

  2. 接口层:前端通过调用kimi大模型接口(moonshot-v1-8k-vision-preview),实现图片解析和单词生成;后端(NestJS)负责接口转发、鉴权、数据处理等,确保接口调用的安全性和稳定性;

  3. 数据层:暂时未涉及数据库存储(可后续优化,加入用户单词本、复习记录等功能),当前主要通过前端响应式数据存储用户的图片数据、单词信息、音频地址等;

  4. 技术支撑层:包括多模态大模型(kimi-shot、moonshot-v1-8k-vision-preview)、TTS文本转语音技术、前端构建工具(Vite)等,为项目提供核心技术支持。

3.2 核心技术调研与选型

3.2.1 大模型选型与调研

项目的核心技术之一是多模态大模型的选型,多模态模型能够同时处理图片和文本信息,实现图片内容解析和单词生成。经过调研,项目选择了kimi的多模态模型,具体包括kimi-shot和moonshot-v1-8k-vision-preview,选型理由如下:

  1. 多模态能力强:能够精准解析图片内容,提取核心元素,生成贴合场景的单词和例句,符合项目的核心需求;

  2. 接口友好:提供清晰的API接口文档,支持图片base64编码传入,便于前端调用;

  3. 响应速度快:对于简单图片的解析,响应时间较短,能够提升用户体验;

  4. 支持自定义Prompt:能够通过Prompt设计,控制单词难度(A1~A2级别)、输出格式(JSON),满足项目的个性化需求。

多模态模型的调用流程:前端将图片转换为base64编码,结合自定义Prompt,通过POST请求调用kimi接口,接口返回JSON格式的响应数据,包含图片描述、代表单词、例句、解释等内容,前端解析数据后展示给用户。

3.2.2 TTS技术调研与选型

TTS(Text to Speech,文本转语音)技术用于实现单词和例句的音频播放,提升用户的学习体验。项目中采用的TTS技术,核心是将文本(单词、例句)转换为音频文件(如MP3),并通过前端播放。选型时主要考虑以下几点:

  1. 发音标准:确保英语发音准确、清晰,符合 native speaker 的发音习惯;

  2. 响应速度快:能够快速将文本转换为音频,避免用户等待;

  3. 接口便捷:支持前端直接调用,或通过后端接口转发,集成难度低;

  4. 兼容性好:支持不同浏览器、不同设备,确保音频能够正常播放。

项目中通过generateAudio函数调用TTS接口,将例句转换为音频URL,然后通过Audio对象实现播放,流程简洁、高效。

3.2.3 前端技术栈选型

前端采用Vue3+TS+Composition API的技术栈,选型理由如下:

  1. Vue3的优势:相比Vue2,Vue3具有更好的性能、更小的体积,支持Composition API,能够更灵活地组织代码,提升代码的复用性和维护性;

  2. TypeScript的优势:提供静态类型检查,能够在开发阶段发现代码中的错误,提升代码的健壮性和可维护性,尤其适合团队开发;

  3. Composition API的优势:相比Options API,Composition API能够将相关的逻辑代码聚合在一起,避免代码分散,便于逻辑的复用和维护,适合复杂组件的开发;

  4. 组件化思想:将页面拆分为PictureCard组件和主页面,实现代码复用,降低开发难度,便于后续功能扩展和维护。

此外,前端还使用了Vite作为构建工具,Vite具有快速的冷启动、热更新等优势,能够提升开发效率;使用CSS Scoped实现样式隔离,避免样式冲突,提升代码的可维护性。

3.2.4 后端技术栈选型

后端采用NestJS作为开发框架,NestJS是一款基于Node.js的后端框架,具有以下优势:

  1. 模块化设计:支持模块化开发,能够将不同的功能模块拆分,提升代码的复用性和维护性;

  2. 依赖注入:支持依赖注入,便于代码的测试和解耦;

  3. 类型安全:与TypeScript完美结合,提供静态类型检查,提升代码的健壮性;

  4. 丰富的生态:提供丰富的中间件、插件,支持多种数据库、缓存、认证等功能,便于项目的扩展。

后端的核心功能是接口转发、鉴权、数据处理等,例如将前端的图片解析请求转发给kimi大模型接口,对接口返回的数据进行处理后,返回给前端;同时,后端还可以实现用户认证、单词本存储等功能(后续优化)。

3.3 技术难点与解决方案

3.3.1 图片处理与base64编码

难点:多模态大模型接口需要传入图片的base64编码,而前端需要将用户上传的图片或拍照的图片转换为base64编码,同时要确保图片加载快速、编码正确,避免出现接口调用失败的情况。

解决方案:使用HTML5的FileReader API,实现图片的本地读取和base64编码。具体流程如下:用户上传图片或拍照后,获取文件对象,创建FileReader实例,调用readAsDataURL方法读取文件,在onload事件中获取base64编码的图片数据,然后将其传入大模型接口。同时,优化图片加载体验,在图片读取过程中显示加载状态,避免用户误以为操作失败。

3.3.2 大模型接口调用与数据解析

难点:大模型接口的调用需要注意请求格式、鉴权方式,同时接口返回的数据格式需要严格按照Prompt中定义的JSON格式,否则会导致前端解析失败;此外,接口响应时间可能受到网络影响,需要处理加载状态和异常情况。

解决方案:

  1. 严格按照接口文档要求,设置请求头(Content-Type、Authorization),请求体中传入模型名称、messages(包含图片base64和Prompt)、stream(关闭流式输出)等参数;

  2. 自定义Prompt,明确要求大模型返回JSON格式的数据,指定JSON中的字段(image_description、representative_word、example_sentence等),确保数据格式正确;

  3. 处理接口调用的异常情况,例如网络错误、接口返回错误信息等,通过try-catch捕获异常,给用户显示错误提示;

  4. 在接口调用过程中,显示“分析中...”的提示,让用户了解当前状态,提升用户体验。

3.3.3 音频播放与兼容性

难点:不同浏览器、不同设备对音频播放的支持存在差异,可能出现音频无法播放、播放卡顿等问题;同时,音频文件的加载速度也会影响用户体验。

解决方案:

  1. 使用HTML5的Audio对象实现音频播放,确保兼容性;

  2. 优化音频加载速度,通过TTS接口生成的音频URL尽量轻量化,减少加载时间;

  3. 处理音频播放的异常情况,例如音频加载失败、播放失败等,给用户显示提示信息;

  4. 提供清晰的播放按钮,用户点击后立即播放,提升交互体验。

3.3.4 无障碍访问实现

难点:实现无障碍访问,需要确保读屏器能够正常识别界面元素,尤其是图片、按钮、输入框等,同时要处理好样式控制与无障碍的兼容性(如input[type="file"]难以控制样式)。

解决方案:

  1. 使用label for + input#id的方式,将标签与输入框关联,读屏器能够通过标签识别输入框的功能;

  2. 对于难以控制样式的input[type="file"],使用display: none;隐藏,通过label标签触发输入框的点击事件,既保证样式美观,又不影响无障碍访问;

  3. 为图片、按钮等元素添加alt属性和清晰的文本提示,确保读屏器能够正确识别元素的功能和内容。

四、代码详细解析

4.1 前端代码整体结构

前端代码分为两个主要组件:主页面(App.vue)和PictureCard组件(PictureCard.vue),采用Vue3+TS+Composition API开发,代码结构清晰,逻辑连贯。具体结构如下:

  1. 主页面(App.vue):负责整体界面布局、图片解析逻辑、音频播放、详情展开/收起等功能,引入PictureCard组件,接收组件传递的图片数据,调用大模型接口,处理响应数据并展示;

  2. PictureCard组件:负责图片上传、拍照、图片预览等功能,通过props接收主页面传递的单词、音频信息,通过emit向主页面传递图片数据,实现组件间的通信。

4.2 主页面(App.vue)代码解析

4.2.1 脚本部分(script setup lang="ts")

脚本部分主要实现响应式数据定义、大模型接口调用、音频生成、数据处理等功能,具体解析如下:

  1. 引入依赖:
import PictureCard from './components/PictureCard.vue';
import { ref } from 'vue';
import { generateAudio } from './lib/audio.ts';
  • 引入PictureCard组件,用于图片上传和预览;

  • 引入ref函数,用于创建响应式数据(Vue3中,ref用于创建基本类型的响应式数据,包裹成带.value属性的响应式对象);

  • 引入generateAudio函数,用于调用TTS接口,生成音频URL。

  1. 定义响应式数据:
const imagePreview = ref(''); // 图片预览地址(base64)
const userPrompt = `...`; // 自定义Prompt,用于大模型接口调用
const word = ref('请上传文件'); // 识别出的单词
const audio = ref(''); // 音频URL
const sentence = ref(''); // 例句
const detailExpand = ref(false); // 详情展开/收起状态
const explanations = ref([]); // 单词解释(分行存储)
const expReplys = ref([]); // 解释对应的回复
  • imagePreview:存储图片的base64编码,用于图片预览和大模型接口调用;

  • userPrompt:自定义的Prompt,明确要求大模型解析图片,返回A1~A2级别单词、JSON格式数据,包含图片描述、单词、例句、解释等字段;

  • word、audio、sentence:分别存储识别出的单词、音频URL、例句,用于界面展示;

  • detailExpand:控制详情模块的展开和收起,默认收起;

  • explanations、expReplys:分别存储单词解释(分行处理后)和对应的回复,用于详情模块展示。

  1. 核心函数:update函数(图片解析核心函数)
const update = async(imageDate: string) => {
  imagePreview.value = imageDate; // 设置图片预览地址
  const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions'; // 大模型接口地址
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${import.meta.env.VITE_KIMI_API_KEY}`, // 鉴权信息
  }
  word.value = '分析中...'; // 显示加载提示
  try {
    const response = await fetch(endpoint,{
      method: 'POST',
      headers,
      body: JSON.stringify({
        model: 'moonshot-v1-8k-vision-preview', // 模型名称
        messages: [
          {
            role: 'user',
            content: [{
              type: 'image_url',
              image_url: {
                url: imageDate, // 图片base64编码
              }
            },{
              type: 'text',
              text: userPrompt, // 自定义Prompt
            }]
          }
        ],
        stream: false, // 关闭流式输出
      })
    });
    const data = await response.json(); // 解析接口响应数据
    const replyData = JSON.parse(data.choices[0].message.content); // 解析JSON格式的回复内容
    // 更新响应式数据
    word.value = replyData.representative_word;
    sentence.value = replyData.example_sentence;
    explanations.value = replyData.explaination.split('\n').filter((item:string) => item !== ''); // 分行处理解释内容,过滤空行
    expReplys.value = replyData.explanation_replys;
    // 生成音频URL
    const audioUrl = await generateAudio(replyData.example_sentence);
    audio.value = audioUrl;
  } catch (error) {
    word.value = '解析失败,请重新上传'; // 错误提示
    console.error('解析失败:', error);
  }
}

函数解析:

  • 接收参数imageDate(图片base64编码),设置图片预览地址;

  • 拼接大模型接口地址,设置请求头(Content-Type为application/json,Authorization为鉴权信息,通过环境变量获取,避免硬编码);

  • 设置word为“分析中...”,提示用户当前正在解析;

  • 使用fetch发起POST请求,请求体中传入模型名称、messages(包含图片和Prompt)、stream(关闭流式输出);

  • 解析接口响应数据,将大模型返回的JSON字符串解析为对象,提取单词、例句、解释等内容,更新响应式数据;

  • 调用generateAudio函数,生成例句的音频URL,更新audio响应式数据;

  • 使用try-catch捕获异常,出现错误时显示“解析失败,请重新上传”的提示,并打印错误信息。

  1. 提交函数:submit函数
const submit = (imageData: string) => {
  update(imageData);
}

函数解析:接收PictureCard组件传递的图片base64编码,调用update函数,触发图片解析流程。

4.2.2 模板部分(template)

模板部分负责界面布局和交互,结合响应式数据,实现动态展示,具体解析如下:

<!-- 引入PictureCard组件,传递单词、音频信息,监听update-image事件 -->
    <PictureCard 
    :word="word" 
    :audio="audio"
    @update-image="submit" 
    />
    <!-- 结果展示区域 -->
   {{ sentence }}<!-- 显示例句 -->
      <!-- 详情展开/收起按钮 -->
        <button @Talk bout it<!-- 收起状态 -->
        <!-- 展开状态:显示图片预览、单词解释、回复 -->
        {{ item }}{{ item }}

模板解析:

  • container:主容器,采用flex布局,垂直排列,居中对齐,设置背景渐变;

  • PictureCard组件:传递word(单词)、audio(音频URL) props,监听update-image事件,当组件传递图片数据时,调用submit函数;

  • output:结果展示区域,显示例句,设置居中对齐、加粗字体;

  • details:详情模块,固定在页面底部,居中显示;

  • 按钮:点击后切换detailExpand的状态,实现详情的展开和收起;

  • fold:详情收起状态,显示白色圆角矩形,保持界面简洁;

  • expand:详情展开状态,显示图片预览、单词解释(循环渲染explanations数组)、回复(循环渲染expReplys数组),设置白色背景、圆角,提升可读性。

4.2.3 样式部分(style scoped)

样式部分采用CSS Scoped,实现样式隔离,避免与其他组件冲突,具体解析如下:

.container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: start;
  margin: 0;
  padding: 0;
  width: 100vw;
  height: 100vh;
  font-size: .85rem;
  background: linear-gradient(180deg,rgb(235,189,166) 0%,rgb(71,49,32) 100%);
}
#selecteImage {
  display: none;
}
.input {
  width: 200px;
}
.output {
  margin-top: 20px;
  width: 80%;
  text-align: center;
  font-weight: bold;
}
.preview img {
  max-width: 100%;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.details {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
}
.details button {
  background-color: black;
  color: white;
  width: 160px;
  height: 32px;
  border-radius: 8px 8px 0 0;
  border: none;
  font-size: 12px;
  font-weight: bold;
  cursor: pointer;
}
.details .fold {
  width: 200px;
  height: 30px;
  background-color: white;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.details .expand {
  width: 200px;
  height: 88vh;
  background-color: white;
  border-top-left-radius: 8px;
  border-top-right-radius: 8px;
}
.expand img {
  width: 60%;
  margin-top: 20px;
  border-radius: 6px;
}
.expand .explaination {
  color: black;
  font-weight: normal;
}
.expand .explaination p {
  margin: 0 10px 10px 10px;
}
.expand .reply {
  color: black;
  font-weight: normal;
  margin-top: 20px;
}
.expand .reply p {
  padding: 4px 10px;
  margin: 0 10px 10px 10px;
  border-radius: 6px;
  border: solid 1px grey;
}

样式解析:

  • container:设置全屏显示,背景为线性渐变(暖色调),字体大小0.85rem,flex布局垂直排列,居中对齐;

  • #selecteImage:隐藏文件输入框,通过label标签触发;

  • output:设置margin-top为20px,宽度80%,居中对齐,字体加粗,突出例句显示;

  • details:固定在页面底部,居中显示(left:50% + transform: translateX(-50%));

  • 按钮样式:黑色背景、白色字体,圆角设计,鼠标悬停显示指针,提升交互体验;

  • fold和expand:均为白色背景、圆角设计,fold高度30px(收起状态),expand高度88vh(展开状态),显示图片、解释和回复;

  • 解释和回复样式:解释内容为黑色、常规字体,回复内容带有灰色边框和圆角,便于区分。

4.3 PictureCard组件代码解析

4.3.1 脚本部分(script setup lang="ts")

脚本部分主要实现图片上传、拍照、图片预览、组件通信等功能,具体解析如下:

import { ref } from 'vue';
import defaultImg from '../assets/camera.png';
import voiceIcon from '../assets/voice.png';
const imgPreview = ref(defaultImg); // 图片预览地址,默认显示相机图标
// 定义组件props,接收主页面传递的单词和音频信息
const props = defineProps({
    word: {
        type: String,
        default: ''
    },
    audio: {
        type: String,
        default: ''
    },
})
// 定义组件事件,向主页面传递图片数据
const emit = defineEmits(['updateImage'])
// 图片上传/拍照处理函数
const updateImageData = async (e:Event): Promise<any> => {
    const file = (e.target as HTMLInputElement).files?.[0]; // 获取上传的文件对象
    if (!file) return; // 若没有文件,直接返回
    return new Promise((resolve, reject) => {
        const reader = new FileReader(); // 创建FileReader实例
        reader.readAsDataURL(file); // 读取文件,转换为base64编码
        reader.onload = () => {
            const data = reader.result as string; // 获取base64编码数据
            imgPreview.value = data; // 更新图片预览地址
            emit('updateImage', data); // 向主页面传递图片数据
            resolve(data);
        }
        reader.onerror = (error) => {
            reject(error); // 处理读取错误
        }
    })
}
// 音频播放函数
const playAudio = () => {
  const audio = new Audio(props.audio); // 创建Audio对象,传入音频URL
  audio.play(); // 播放音频
}

脚本解析:

  1. 引入依赖:引入ref函数、默认相机图标(defaultImg)、音频图标(voiceIcon);

  2. 响应式数据:imgPreview用于存储图片预览地址,默认显示相机图标;

  3. defineProps:定义组件的props,接收主页面传递的word(单词)和audio(音频URL),设置默认值为空字符串;

  4. defineEmits:定义组件的事件updateImage,用于向主页面传递图片的base64编码;

  5. updateImageData函数:处理图片上传/拍照事件,获取文件对象,使用FileReader将文件转换为base64编码,更新图片预览地址,通过emit向主页面传递数据,返回Promise对象,便于处理异步操作;

  6. playAudio函数:创建Audio对象,传入props.audio(音频URL),调用play()方法播放音频。

4.3.2 模板部分(template)

<!-- 文件输入框,隐藏显示,用于接收图片上传/拍照数据 -->
    <input type="file" id="selecteImage" class="input"
    accept="image*" @ />
    <!-- 标签,触发文件输入框,显示图片预览 -->
   <!-- 显示单词 -->
        {{ props.word }}
    <!-- 音频播放按钮,只有音频存在时显示 -->
    <div class="playAudio" v-if="audio" @

模板解析:

  • card:组件容器,采用卡片式设计,设置圆角、阴影、背景色,提升界面层次感;

  • input[type="file"]:文件输入框,隐藏显示(#selecteImage设置display: none),accept="image/*"表示只接收图片文件,@change事件绑定updateImageData函数,当用户上传图片或拍照后,触发函数处理;

  • label:与文件输入框关联(for="selecteImage"),点击标签即可触发文件输入框的点击事件,显示图片预览(img标签绑定imgPreview);

  • word:显示主页面传递的单词,设置白色字体,突出显示;

  • playAudio:音频播放按钮,只有audio存在时显示(v-if="audio"),点击触发playAudio函数,显示音频图标。

4.3.3 样式部分(style scoped)

#selecteImage {
    display: none;
}
.card {
  border-radius: 8px;
  padding: 20px;
  margin-top: 40px;
  height: 280px;
  box-shadow: rgb(63,38,21) 0 3px 0px 0;
  background-color: rgb(105,78,62);
  box-sizing: border-box;
}
.upload {
  width: 160px;
  height: 160px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.upload img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}
.word {
  margin-top: 20px;
  font-size: 16px;
  color: rgb(255,255,255);
}
.playAudio {
  margin-top: 16px;
}
.playAudio img {
  cursor: pointer;
}

样式解析:

  • #selecteImage:隐藏文件输入框;

  • card:设置圆角8px,内边距20px,margin-top40px,高度280px,阴影效果(rgb(63,38,21)),背景色rgb(105,78,62)(深棕色),box-sizing: border-box确保内边距不影响整体尺寸;

  • upload:设置宽度和高度160px,flex布局居中对齐,确保图片预览居中显示;

  • upload img:宽度和高度100%,object-fit: contain确保图片按比例显示,不拉伸;

  • word:margin-top20px,字体大小16px,白色字体,突出显示单词;

  • playAudio:margin-top16px,音频图标鼠标悬停显示指针,提升交互体验。

4.4 核心技术点补充解析

4.4.1 Vue3 Composition API 核心用法

项目中大量使用了Vue3的Composition API,核心用法包括ref、defineProps、defineEmits等,补充解析如下:

  1. ref:用于创建基本类型的响应式数据,如string、number、boolean等,返回一个带.value属性的响应式对象。例如,const word = ref('请上传文件'),修改时需要使用word.value = '分析中...',界面会自动更新;

  2. defineProps:用于定义组件的属性,接收一个对象,指定属性的类型、默认值等,组件外部可以通过props向组件传递数据。例如,PictureCard组件通过defineProps接收主页面传递的word和audio;

  3. defineEmits:用于定义组件的事件,组件内部可以通过emit向外部传递数据。例如,PictureCard组件通过emit('updateImage', data)向主页面传递图片的base64编码;

  4. 脚本设置语法(script setup):Vue3的语法糖,无需导出组件,直接编写脚本代码,简化了组件的编写流程,提升开发效率。

4.4.2 Prompt设计技巧

Prompt设计是AIGC产品的核心,项目中的Prompt设计具有很强的参考价值,具体技巧如下:

  1. 明确指令:清晰告知大模型需要完成的任务,例如“分析图片内容,找出最能描述图片的一个英文单词,尽量选择更简单的A1~A2的词汇”;

  2. 指定输出格式:明确要求大模型返回JSON格式的数据,并指定JSON中的字段,例如image_description、representative_word、example_sentence等,确保数据格式统一,便于前端解析;

  3. 控制输出内容:对输出的内容进行约束,例如“解释的最后给一个日常生活有关的问句”“回复根据explaination给出”,确保输出内容贴合项目需求;

  4. 适配用户水平:明确要求单词级别为A1~A2,确保输出的单词和例句简单易懂,贴合目标用户的英语水平。

4.4.3 环境变量的使用

项目中使用了环境变量(import.meta.env)存储大模型接口地址和API Key,避免将敏感信息硬编码到代码中,提升代码的安全性和可维护性。具体用法如下:

  1. 在项目根目录创建.env文件,定义环境变量:VITE_KIMI_API_ENDPOINT=接口地址,VITE_KIMI_API_KEY=API Key;

  2. 在代码中通过import.meta.env.VITE_XXX获取环境变量,例如const endpoint = import.meta.env.VITE_KIMI_API_ENDPOINT + '/chat/completions';

  3. 注意:环境变量名必须以VITE_开头,否则无法在前端代码中访问。

五、总结

本次“拍照记单词”项目学习,围绕AI多模态技术与前后端开发的结合展开,全面覆盖了产品设计、技术实现、问题解决等多个核心环节,不仅掌握了具体的技术栈用法,更理解了AI时代轻量化产品的开发逻辑与核心思路,收获颇丰。

项目层面,“拍照记单词”作为一款贴合真实场景的英语学习工具,精准抓住了传统单词学习产品场景化不足、操作繁琐的痛点,以“拍照即记词”为核心,结合多模态大模型与TTS技术,实现了场景化、便捷化、个性化的学习体验,同时兼顾无障碍设计,体现了产品的包容性与人文关怀。通过对百词斩、扇贝、多邻国等竞品的分析,明确了项目的差异化优势,也为后续产品优化提供了清晰方向。

技术层面,本次学习系统掌握了Vue3+TS+Composition API的前端开发方法,理解了组件化思想在项目中的实际应用,能够熟练运用FileReader实现图片base64编码、调用多模态大模型接口、通过TTS技术实现音频播放等核心功能;同时了解了NestJS后端框架的选型逻辑与核心作用,掌握了前后端分离架构的基本设计思路。在技术实践中,针对图片处理、接口调用、音频播放、无障碍适配等难点,通过合理的技术方案解决了实际问题,提升了问题排查与解决能力,也深刻认识到细节处理(如环境变量使用、Prompt设计)在项目开发中的重要性。

整体而言,通过本次项目学习,不仅夯实了前端开发、AI接口调用等技术基础,更建立了“产品需求→技术实现→问题优化”的完整思维模式,理解了AI技术与用户需求结合的核心逻辑。后续将把本次学习收获运用到同类项目开发中,重点关注产品细节优化与用户体验提升,同时持续深耕多模态技术、前端框架等相关知识,不断提升自身的开发能力与产品思维。

LogicFlow 小地图性能优化:从「实时克隆」到「占位缩略块」!🚀

写在开头

Hi,各位朋友们好呀!😋

今是2026年03月10日,虽迟但到,时间飞快,又过去一个月了。

灵魂一问:你养虾了吗?🦞

最近 OpenClaw 很火呢,但...它真能给你带来实际作用吗?🤔
小编也养了一只,但目前好像除了提供点"情绪价值",其他场景的还没派上用场,生产力也还在观察中。

二月过了个年,这个年小编过得非常开心的,从各个方面。🥳 然后,也给辛苦一年的自己买了个小礼物:换了台电脑——MacBook Air M4 24+512。

猜猜小编花了多少钱拿下的?评论区有答案。

言归正传,今天要分享的内容依旧是关于 LogicFlow 库的,给其小地图插件增加缩略块模式,效果如下,请诸君按需食用哈。

image.png

需求背景 💡

在最近的项目里,小编基于 LogicFlow 做了流程图页面,节点类型不少,而且很多是自定义 HTML 节点,内容里有文本、图片、视频、音频等富媒体。

画布上用了官方推荐的小地图插件,功能没问题,但小地图是实时同步主画布的:主画布渲染一份节点,小地图再渲染一份,内容一样。节点一多,加上拖拽、缩放、批量操作,两边都要更新,有点一个页面干两份活的意思,几十上百个节点时性能压力就很明显。

主画布可以靠局部渲染缓解,小地图那块就没辙了,如果节点再显示图片、视频这类资源,一进页面就要全量加载,非常容易卡顿。

这次目标很明确:给小地图开发一种缩略块模式——用轻量占位块代替真实节点渲染,缓解性能问题。具体来说🤔:

  1. 保留定位导航能力
  2. 不再同步创建真实节点内容
  3. 用轻量占位块表达节点位置和大小
  4. 与现有 miniMap 配置兼容

实现过程 ⚡

以下改造思路和实现均基于 LogicFlow 官方 MiniMap 源码,插件整体代码并不算多,可以仔细瞧瞧:传送门

第1️⃣步:明确改造策略——继承官方 MiniMap

小编没有另起炉灶,从零开始,而是直接继承官方 MiniMap,只改关键实现点。

这样做的好处是:

  • 官方行为仍可复用(例如视口更新、定位等)
  • 后续升级 LogicFlow 时,迁移成本更低

🍊 为什么选择「继承 + 局部重写」❓

因为咱们真正的痛点不是功能不够,只是渲染太重。只要把渲染部分调整一下,就能快速拿到收益,不必把整个插件推倒从零开始。

import { MiniMap } from "@logicflow/extension";

/**
 * 自定义小地图:继承官方 MiniMap,通过 placeholderMode 支持「占位块」与「实时克隆」双模式
 */
class CustomMiniMap extends MiniMap {
  constructor({ lf, LogicFlow, options }) {
    const { placeholderMode = true, ...restOptions } = options || {};
    const hasRestOptions = Object.keys(restOptions).length > 0;
    // 将 placeholderMode 以外的配置透传给官方 MiniMap
    super({ lf, LogicFlow, options: hasRestOptions ? restOptions : undefined });
    this.placeholderMode = placeholderMode;  // 默认开启占位块模式
  }
}

第2️⃣步:增加 placeholderMode,支持双模式切换

这一步是整个方案的开关:

  • placeholderMode: true:占位块模式(默认)
  • placeholderMode: false:实时模式(回退到官方行为)

也就是说,咱们不是把官方逻辑「干掉」,而是给它加了个性能开关。

/**
 * 重写 setView:根据 placeholderMode 决定走官方渲染还是轻量占位块渲染
 */
setView(reRender = true) {
  if (!this.placeholderMode) {
    return MiniMap.prototype.setView.call(this, reRender);  // 回退到官方实时克隆
  }
  // placeholderMode === true 时,走轻量占位块渲染逻辑(此处省略具体实现)
}

第3️⃣步:把真实节点数据转换为占位块数据

⏰ 关键点❗❗❗

小地图不再吃原始节点类型,而是统一转换成一个占位节点类型: minimap:placeholder

转换时只保留导航必需信息:

  • id
  • x / y
  • width / height
  • 少量 properties(用于占位模型读取)
const MINIMAP_PLACEHOLDER_TYPE = "minimap:placeholder";

/**
 * 将原始节点数据转换为占位块数据,仅保留定位、尺寸等导航必需信息
 * @param {Object} data - { nodes, edges }
 * @returns {Object} 转换后的 { nodes, edges },节点类型统一为 minimap:placeholder
 */
_resetDataWithPlaceholder(data) {
  const nodes = data.nodes.map((node) => {
    // 优先从 properties 取尺寸,再 fallback 到节点顶层,默认 200
    const width = Number(node.properties?.width) || Number(node.width) || 200;
    const height = Number(node.properties?.height) || Number(node.height) || 200;
    return {
      id: node.id,
      type: MINIMAP_PLACEHOLDER_TYPE,
      x: node.x,
      y: node.y,
      width,
      height,
      properties: { width, height, _originalType: node.type },
    };
  });

  return {
    nodes,
    edges: this.showEdge ? data.edges.map((e) => ({ ...e, text: undefined })) : [],
  };
}

💡 小贴士:这里优先从 properties.width/height 取尺寸,再 fallback 到节点顶层尺寸,这个细节非常重要,能保证小地图占位块尺寸更贴近主画布真实节点。

第4️⃣步:注册轻量占位节点视图与模型

占位节点本身非常轻,只渲染一个 rect,不挂任何复杂内容。

import { h, RectNode, RectNodeModel } from "@logicflow/core";

/**
 * 轻量占位节点视图:只渲染一个圆角矩形,不挂载任何子节点或富媒体内容
 */
class MinimapPlaceholderView extends RectNode {
  getShape() {
    const { x, y, width, height } = this.props.model;
    return h("g", {}, [
      h("rect", {
        x: x - width / 2,   // LogicFlow 节点以中心点为坐标,rect 需偏移
        y: y - height / 2,
        rx: 10,
        ry: 10,
        width,
        height,
      }),
    ]);
  }
}

这样小地图的渲染成本就从创建一堆真实节点内容,降到了画几个轻量矩形块。

第5️⃣步:接入现有使用的地方

在业务页面里,小编是直接替换 MiniMap 的来源,不改既有交互入口:

// 仅改 import 来源,其余用法与官方 MiniMap 一致
import { CustomMiniMap as MiniMap } from "./plugins/CustomMiniMap";

LogicFlow.use(MiniMap);

再加上 CustomMiniMap.pluginName = "miniMap",可以继续复用原有 pluginsOptions.miniMap 配置,不需要大动干戈改业务代码,这点非常香。😁

完整源码

传送门

总结

这次改造的核心就一句话:小地图有时可能并不需要真实还原,只需要正确导航就行。

通过二次改造增加小地图新模式后,咱们拿到了几个关键收益:

  • 小地图渲染负担显著下降
  • 节点规模上来后,交互更稳
  • 依然保留官方 MiniMap 的主要能力




至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

在 Vue 项目中玩转 FullCalendar:从零搭建可交互的事件日历

在很多业务场景中,我们都需要把「时间维度上的事件」清晰地呈现在一个可交互的日历里,并和其它数据视图(图表、报表、分析面板等)联动。本文基于一个典型的「事件日历 + 外部报表联动」场景,总结一套在 Vue 项目中落地 FullCalendar 的通用实践。

概览

FullCalendar 是最受欢迎的js calendar组件

官网:fullcalendar.io/

demos: fullcalendar.io/demos

vue demo: github.com/fullcalenda…

安装

使用 NPM 或 Yarn 安装软件包core以及您计划使用的任何插件(按需安装) 插件列表:fullcalendar.io/docs/plugin…

标题 描述 示意
@fullcalendar/core 必须
@fullcalendar/vue vue2项目
@fullcalendar/vue3 vue3项目
@fullcalendar/interaction 支持点击、拖拽等事件
@fullcalendar/daygrid DayGrid 视图 image.png
@fullcalendar/timegrid timegrid视图 image.png
@fullcalendar/resource-timeline 时间轴视图 image.png
npm install \
  @fullcalendar/core \ 
  @fullcalendar/vue  \  
  @fullcalendar/daygrid \   
  @fullcalendar/timegrid \   
  @fullcalendar/interaction \  

如果是vue3项目用@fullcalendar/vue3

使用

引入组件

<template>
    <FullCalendar ref="myCalendar" :options="calendarOptions" />
</template>

<script>
  // 引入已经安装好的,页面所需要的 FullCalendar 插件
  import FullCalendar from '@fullcalendar/vue'
  import dayGridPlugin from '@fullcalendar/daygrid'
  import timeGridPlugin from '@fullcalendar/timegrid'
  import interactionPlugin from '@fullcalendar/interaction'
  // 日历参数配置
  const calendarOptions = {}

  export default {
    name: "my-calendar",
    components: {
      FullCalendar
    },
    data () {
      return {
        calendarOptions
      }
    }
  }
</script>

参数配置

dayGridMonth视图的简单配置参考:

calendarOptions = {
        locale: 'zh-cn',
        plugins: [
          dayGridPlugin,
          // interactionPlugin, // needed for dateClick
        ],
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth',
        },
        buttonText: { today: '今天', prev: '上个月', next: '下个月', dayGridMonth: '月' }, // 设置按钮文本内容
        initialView: 'dayGridMonth',
        initialEvents: [], // alternatively, use the `events` setting to fetch from a feed

        firstDay: 1,
        aspectRatio: 1.35, // 日历单元格宽高比 默认值:1.35
        eventColor: '#3a79eb', // 日历中事件的默认背景色颜色,优先级低于添加事件时设置的背景色
        dayMaxEvents: true,
        editable: false,
        selectable: false,
        selectMirror: true,
        dayMaxEvents: true,
        weekends: true,
        events: this.fetchEvents, // 获取事件
        eventClick: this.handleEventClick, // 点击事件
        eventsSet: this.handleEvents,
        // you can update a remote database when these fire:
        // eventChange: this.handleEventChange        // eventAdd:
        // eventRemove:

详细的配置可参考文章:blog.csdn.net/FlowGuanEr/…

slot模板

<template>
  <FullCalendar :options="calendarOptions">
    <template v-slot:eventContent='arg'>
      <b>{{ arg.event.title }}</b>
    </template>
  </FullCalendar>
</template>

Calendar API

let calendarApi = this.$refs.myCalendar.getApi() 
calendarApi.next()

事件

1. 事件对象

var calendar = new Calendar(calendarEl, {
  timeZone: 'UTC',
  events: [
    {
      id: 'a',
      title: 'my event',
      start: '2018-09-01',
      end: '2018-09-01'
    }
  ]
})

更多字段详解:fullcalendar.io/docs/event-…

2. 初始化事件

可以在initialEvents配置初始化事件列表

也可以用events中配置方法,调用接口去获取数据,将数据格式化成事件对象规范的格式,显示事件列表。

事件排序:接口返回的顺序可能杂乱,建议在传给 successCallback 前对列表按 start(及可选的 endtitle)排序,这样同一天内多事件在月视图中的展示顺序一致、可预期(FullCalendar 会按你传入的顺序在同一格内排列)。

fetchEvents (info, successCallback, failureCallback) {
  // info.start / info.end 是当前视图的起止时间
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map((item) => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      // 按开始时间排序,同一天内按结束时间、再按标题排序,保证展示顺序稳定
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}

3. 事件回调

实战:事件日历与外部报表的联动方案

下面是一个抽象化的实战场景:在某个运营看板页面,我们用 FullCalendar 搭建了一个「事件日历」,并和右侧的数据分析报表(通过 iframe 嵌入)联动,整体思路可以概括为三步:

  1. Calendar 只负责展示事件排期
    • 通过 events: this.fetchEvents 懒加载当前视图范围内的事件,避免一次性加载整年数据。
    • 接口返回后在前端做一次格式化,转成 FullCalendar 认可的事件对象:
      • id: 使用 eventId 标识事件;
      • title: 使用 eventTitle 作为日历上展示的文案;
      • start / end: 用 moment 格式化为 YYYY-MM-DD,结束时间额外 +1 天,避免跨天活动少算一天。
    • 在调用 successCallback(list) 前对 liststartendtitle 排序,保证同一天内多事件的展示顺序稳定(见上文「事件排序」)。
fetchEvents (info, successCallback, failureCallback) {
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map(item => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}
  1. 点击日历事件,高亮并联动外部报表
    • eventClick 中拿到被点击的事件,做两件事:
      • 把上一次选中的事件颜色还原为默认蓝色;
      • 把当前事件改成高亮色,并记录 currentEventId
    • 同时,基于事件编号 eventId 拼接外部报表地址,赋值给 pageUrl,iframe 会自动切到该事件对应的数据分析页面:
handleEventClick (clickInfo) {
  if (clickInfo?.event?.id) {
    this.currentEvents.forEach(event => {
      if (event.id === this.currentEventId) {
        event.setProp('color', '#3a79eb')
      }
    })
    clickInfo.event.setProp('color', '#db3491')
    this.currentEventId = clickInfo.event.id
    this.pageUrl = `${REPORT_BASE_URL}?eventId=${clickInfo.event.id}`
  }
}
  1. 通过插槽自定义事件渲染
    • 使用 eventContent 插槽可以灵活控制日历单元里的展示结构,比如在运营看板里我们希望同时显示「活动时间 + 活动名称」:
<FullCalendar class="calendar-app-calendar" :options="calendarOptions">
  <template v-slot:eventContent="arg">
    <b>{{ arg.timeText }}</b>
    <i>{{ arg.event.title }}</i>
  </template>
</FullCalendar>

综合以上三点,一个完整的「事件日历 + 数据分析联动」就搭建好了:
使用者只需要在日历上点选某个事件,对应的外部报表就会自动切换到该事件的分析视图,从「时间排期」自然跳转到「结果分析」,大大提升日常分析效率。

❌