阅读视图

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

深入浅出 Vue3 defineModel:极简实现组件双向绑定

深入浅出 Vue3 defineModel:极简实现组件双向绑定

在 Vue3 从 Options API 向 Composition API 演进的过程中,组件双向绑定的实现方式也经历了迭代优化。defineModel 作为 Vue3.4+ 版本推出的新语法糖,彻底简化了传统 v-model 双向绑定的实现逻辑,让开发者无需手动声明 props 和 emits 就能快速实现组件内外的数据同步。本文将从核心原理、使用场景、进阶技巧等维度,全面解析 defineModel 的使用方式。

一、为什么需要 defineModel?

在 Vue3.4 之前,实现组件双向绑定需要手动声明 props + 触发 emits,步骤繁琐且代码冗余:

<!-- 传统 v-model 实现(Vue3.4 前) -->
<template>
  <input :value="modelValue" @input="handleInput" />
</template>

<script setup>
// 1. 声明接收的 props
const props = defineProps(['modelValue'])
// 2. 声明触发的事件
const emit = defineEmits(['update:modelValue'])

// 3. 手动触发事件更新值
const handleInput = (e) => {
  emit('update:modelValue', e.target.value)
}
</script>

这种写法需要维护 props 和 emits 的一致性,且多字段双向绑定时代码量会成倍增加。而 defineModel 正是为解决这一痛点而生 —— 它将 props 声明、事件触发的逻辑封装为一个极简的 API,大幅降低双向绑定的开发成本。

二、defineModel 核心用法

1. 基础使用(单字段绑定)

defineModel 是一个内置的组合式 API,调用后会返回一个可响应的 ref 对象,既可以读取值,也可以直接修改(修改时会自动触发 update:modelValue 事件)。

<!-- 简化后的双向绑定 -->
<template>
  <!-- 直接绑定 ref 对象,无需手动处理事件 -->
  <input v-model="modelValue" />
</template>

<script setup>
// 一行代码实现双向绑定核心逻辑
const modelValue = defineModel()
</script>

父组件使用方式不变,依然是标准的 v-model

<template>
  <MyInput v-model="username" />
</template>

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

2. 自定义绑定名称(多字段绑定)

当组件需要多个双向绑定字段时,可给 defineModel 传入参数指定绑定名称,配合父组件的 v-model:xxx 语法实现多字段同步:

<!-- 子组件:多字段双向绑定 -->
<template>
  <input v-model="name" placeholder="姓名" />
  <input v-model="age" type="number" placeholder="年龄" />
</template>

<script setup>
// 自定义绑定名称:name 和 age
const name = defineModel('name')
const age = defineModel('age')
</script>

父组件使用带参数的 v-model

<template>
  <UserForm v-model:name="userName" v-model:age="userAge" />
</template>

<script setup>
import { ref } from 'vue'
const userName = ref('张三')
const userAge = ref(20)
</script>

3. 配置默认值与类型校验

defineModel 支持传入配置对象,设置 props 的默认值、类型校验等,等价于传统 defineProps 的配置:

<template>
  <input v-model="count" type="number" />
</template>

<script setup>
// 配置默认值、类型、必填项
const count = defineModel({
  type: Number,
  default: 0,
  required: false
})
</script>

三、defineModel 核心原理

defineModel 本质是 Vue 提供的语法糖,其底层依然是基于 props + emits 实现的,Vue 会自动完成以下操作:

  1. 声明一个名为 modelValue(或自定义名称)的 prop;

  2. 声明一个名为 update:modelValue(或 update:自定义名称)的 emit 事件;

  3. 返回一个 ref 对象:

    • 读取值时,取的是 props 中的值;
    • 修改值时,自动触发对应的 update 事件更新父组件数据。

四、注意事项与使用场景

1. 版本要求

defineModel 是 Vue3.4+ 新增的 API,若项目版本低于 3.4,需先升级 Vue 核心包:

运行

# npm
npm install vue@latest

# yarn
yarn add vue@latest

2. 与 v-model 修饰符结合

defineModel 支持获取父组件传入的 v-model 修饰符(如 .trim.number),通过 modelModifiers 属性访问:

<template>
  <input 
    :value="modelValue" 
    @input="handleInput"
  />
</template>

<script setup>
const modelValue = defineModel()
// 获取修饰符
const { modelModifiers } = defineProps({
  modelModifiers: { default: () => ({}) }
})

const handleInput = (e) => {
  let value = e.target.value
  // 处理 trim 修饰符
  if (modelModifiers.trim) {
    value = value.trim()
  }
  // 直接修改 ref,自动触发更新
  modelValue.value = value
}
</script>

3. 适用场景

  • 表单组件(输入框、选择器、开关等)的双向绑定;
  • 需同步父子组件状态的通用组件(如弹窗的显隐、滑块的数值等);
  • 多字段联动的复杂组件(如表单卡片、筛选面板)。

五、总结

  1. defineModel 是 Vue3.4+ 为简化组件双向绑定推出的语法糖,替代了传统 props + emits 的冗余写法;
  2. 核心用法:调用 defineModel() 返回 ref 对象,直接绑定到模板,修改 ref 自动同步父组件数据;
  3. 支持自定义绑定名称、配置 props 校验规则,兼容 v-model 修饰符,满足复杂场景需求;
  4. 底层仍基于 Vue 原生的 props 和 emits 实现,无额外性能开销,是 Vue3 组件双向绑定的首选方案。

相比于传统写法,defineModel 大幅减少了模板代码量,让开发者更聚焦于业务逻辑,是 Vue3 组件开发中提升效率的重要特性。

vue2和vue3响应式原理的区别

一、核心原理对比(先看本质) Vue 响应式的核心目标是:监听数据的读取和修改,自动更新视图。两者的实现思路一致,但底层 API 的能力差异导致了最终效果的不同: 特性 Vue2(Object.def

前端实现进度条

后端处理数据处理逻辑特别多的时候,并不会很及时返回数据,一般情况后端给前端返回进度,这个目前是前端自己返回进度到90,等到接口返回完成再到100% 1、设置全局样式 2、当触发的时候就调用进度条展示方

浅谈 import.meta.env 和 process.env 的区别

这是一个前端构建环境里非常核心、也非常容易混淆的问题。下面我们从来源、使用场景、编译时机、安全性四个维度来谈谈 import.meta.envprocess.env 的区别。


一句话结论

process.env 是 Node.js 的环境变量接口 import.meta.env 是 Vite(ESM)在构建期注入的前端环境变量


一、process.env 是什么?

1️⃣ 本质

  • 来自 Node.js
  • 运行时读取 服务器 / 构建机的系统环境变量
  • 本身 浏览器里不存在
console.log(process.env.NODE_ENV);

2️⃣ 使用场景

  • Node 服务
  • 构建工具(Webpack / Vite / Rollup)
  • SSR(Node 端)

3️⃣ 前端能不能用?

👉 不能直接用

浏览器里没有 process

// 浏览器原生环境 ❌
Uncaught ReferenceError: process is not defined

4️⃣ 为什么 Webpack 项目里能用?

因为 Webpack 帮你“编译期替换”了

process.env.NODE_ENV
// ⬇️ 构建时被替换成
"production"

本质是 字符串替换,不是运行时读取。


二、import.meta.env 是什么?

1️⃣ 本质

  • Vite 提供
  • 基于 ES Module 的 import.meta
  • 构建期 + 运行期可用(但值是构建期确定的)
console.log(import.meta.env.MODE);

2️⃣ 特点

  • 浏览器里 原生支持
  • 不依赖 Node 的 process
  • 更符合现代 ESM 规范

三、两者核心区别对比(重点)

维度 process.env import.meta.env
来源 Node.js Vite
标准 Node API ESM 标准扩展
浏览器可用 ❌(需编译替换)
注入时机 构建期 构建期
是否运行时读取
推荐前端使用

⚠️ 两者都不是“前端运行时读取服务器环境变量”


四、Vite 中为什么不用 process.env

1️⃣ 因为 Vite 不再默认注入 process

// Vite 项目中 ❌
process.env.API_URL

会直接报错。

2️⃣ 官方设计选择

  • 避免 Node 全局污染
  • 更贴近浏览器真实环境
  • 更利于 Tree Shaking

五、Vite 环境变量的正确用法(非常重要)

1️⃣ 必须以 VITE_ 开头

# .env
VITE_API_URL=https://api.example.com
console.log(import.meta.env.VITE_API_URL);

❌ 否则 不会注入到前端


2️⃣ 内置变量

import.meta.env.MODE        // development / production
import.meta.env.DEV         // true / false
import.meta.env.PROD        // true / false
import.meta.env.BASE_URL

六、安全性

⚠️ 重要警告

import.meta.env 里的变量 ≠ 私密

它们会:

  • 打进 JS Bundle
  • 可在 DevTools 直接看到

❌ 不要这样做

VITE_SECRET_KEY=xxxx

✅ 正确做法

  • 前端:只放“公开配置”(API 域名、开关)
  • 私密变量:只放在 Node / 服务端

七、SSR / 全栈项目里怎么区分?

在 Vite + SSR(如 Nuxt / 自建 SSR):

Node 端

process.env.DB_PASSWORD

浏览器端

import.meta.env.VITE_API_URL

两套环境变量是刻意分开的

  1. 为什么必须分成两套?(设计原因)

1️⃣ 执行环境不同(这是根因)

位置 运行在哪 能访问什么
SSR Server Node.js process.env
Client Bundle 浏览器 import.meta.env

浏览器里 永远不可能安全地访问服务器环境变量


2️⃣ SSR ≠ 浏览器

很多人误解:

“SSR 是不是浏览器代码先在 Node 跑一遍?”

不完全对

SSR 实际是:

Node.js 先跑一份 → 生成 HTML
浏览器再跑一份 → hydrate

这两次执行:

  • 环境不同
  • 变量来源不同
  • 安全级别不同

  1. 在 Vite + SSR 中,变量的“真实流向”

1️⃣ Node 端(SSR Server)

// server.ts / entry-server.ts
const dbPassword = process.env.DB_PASSWORD;

✔️ 真实运行时读取

✔️ 不会进 bundle

✔️ 只存在于服务器内存


2️⃣ Client 端(浏览器)

// entry-client.ts / React/Vue 组件
const apiUrl = import.meta.env.VITE_API_URL;

✔️ 构建期注入

✔️ 会打进 JS

✔️ 用户可见


3️⃣ 中间那条“禁止通道”

// ❌ 绝对禁止
process.env.DB_PASSWORD → 浏览器

SSR 不会、也不允许,自动帮你“透传”环境变量


  1. SSR 中最容易踩的 3 个坑(重点)


❌ 坑 1:在“共享代码”里直接用 process.env

// utils/config.ts(被 server + client 共用)
export const API = process.env.API_URL; // ❌

问题:

  • Server OK
  • Client 直接炸(或被错误替换)

✅ 正确方式:

export const API = import.meta.env.VITE_API_URL;

或者:

export const API =typeof window === 'undefined'
    ? process.env.INTERNAL_API
    : import.meta.env.VITE_API_URL;

❌ 坑 2:误以为 SSR 可以“顺手用数据库变量”

// Vue/React 组件里
console.log(process.env.DB_PASSWORD); // ❌

哪怕你在 SSR 模式下,这段代码:

  • 最终仍会跑在浏览器
  • 会被打包
  • 是严重安全漏洞

❌ 坑 3:把“环境变量”当成“运行时配置”

// ❌ 想通过部署切换 API
import.meta.env.VITE_API_URL

🚨 这是 构建期值

build 时确定
→ CDN 缓存
→ 所有用户共享

想运行期切换?只能:

  • 接口返回配置
  • HTML 注入 window.CONFIG
  • 拉 JSON 配置文件

  1. SSR 项目里“正确的分层模型”(工程视角)

┌──────────────────────────┐
│        浏览器 Client       │
│  import.meta.env.VITE_*   │ ← 公开配置
└───────────▲──────────────┘
            │
        HTTP / HTML
            │
┌───────────┴──────────────┐
│        Node SSR Server     │
│      process.env.*        │ ← 私密配置
└───────────▲──────────────┘
            │
        内部访问
            │
┌───────────┴──────────────┐
│        DB / Redis / OSS    │
└──────────────────────────┘

这是一条 单向、安全的数据流


  1. Nuxt / 自建 SSR 的对应关系

类型 用途
runtimeConfig Server-only
runtimeConfig.public Client 可见
process.env 仅 server

👉 Nuxt 本质也是在帮你维护这条边界


八、常见误区总结

❌ 误区 1

import.meta.env 是运行时读取

,仍是构建期注入


❌ 误区 2

可以用它动态切换环境

不行,想动态只能:

  • 接口返回配置
  • 或运行时请求 JSON

❌ 误区 3

Vite 里还能继续用 process.env

❌ 除非你手动 polyfill(不推荐)


九、总结

  • 前端(Vite)只认 import.meta.env.VITE_*
  • 服务端(Node)只认 process.env
  • 永远不要把秘密放进前端 env

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

Vue 3 中 v-for 动态组件 ref 收集失败问题排查与解决

问题描述

在开发部门管理页面的搜索栏功能时,遇到了一个奇怪的问题:在 v-for 循环中渲染的动态组件,无法正确收集到 ref 数组中。

image.png

问题现象

// schema-search-bar.vue
const searchComList = ref([]);

const getValue = () => {
  let dtoObj = {};
  console.log("searchComList", searchComList.value); // 输出: Proxy(Array) {}
  searchComList.value.forEach((component) => {
    dtoObj = { ...dtoObj, ...component.getValue() };
  });
  return dtoObj; // 返回: {}
};

现象:

  • searchComList.value 始终是空数组 []
  • 无法获取到任何子组件的实例
  • 导致搜索功能无法正常工作

代码结构

<template>
  <el-form v-if="schema && schema.properties" :inline="true">
    <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
      <!-- 动态组件 -->
      <component 
        :ref="searchComList"  <!-- ❌ 问题所在 -->
        :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
        :schemaKey="key"
        :schema="schemaItem">
      </component>
    </el-form-item>
  </el-form>
</template>

<script setup>
const searchComList = ref([]);
</script>

排查过程

1. 初步怀疑:打印时机问题

最初怀疑是打印时机不对,组件还没有挂载完成。但即使使用 nextTick 或在 onMounted 中打印,searchComList.value 仍然是空数组。

2. 对比其他正常工作的代码

在同一个项目中,发现 schema-view.vue 中类似的代码却能正常工作:

<!-- schema-view.vue - ✅ 正常工作 -->
<component 
  v-for="(item, key) in components" 
  :key="key" 
  :is="ComponentConfig[key]?.component" 
  ref="comListRef"  <!-- ✅ 使用字符串形式 -->
  @command="onComponentCommand">
</component>

<script setup>
const comListRef = ref([]);
// comListRef.value 能正确收集到所有组件实例
</script>

3. 发现关键差异

对比两个文件的代码,发现了关键差异:

文件 ref 写法 结果
schema-view.vue ref="comListRef" (字符串) ✅ 正常工作
schema-search-bar.vue :ref="searchComList" (绑定对象) ❌ 无法收集

根本原因

Vue 3 中 v-for 使用 ref 的机制

在 Vue 3 中,v-for 中使用 ref 时,两种写法的行为完全不同

1. 字符串形式的 ref(自动收集到数组)
<component v-for="item in list" ref="comListRef" />

行为:

  • Vue 会自动将 ref 的值设置为一个数组
  • 数组中的元素按顺序对应 v-for 中的每一项
  • 这是 Vue 3 的特殊处理机制
2. 绑定 ref 对象(不会自动收集)
<component v-for="item in list" :ref="comListRef" />

行为:

  • :ref 绑定的是一个 ref 对象,Vue 会直接赋值
  • v-for 中,不会自动收集到数组
  • 每次循环都会覆盖上一次的值
  • 最终只会保留最后一个组件的引用

官方文档说明

根据 Vue 3 官方文档:

当在 v-for 中使用 ref 时,ref 的值将是一个数组,包含所有循环项对应的组件实例。

关键点: 这个特性只适用于字符串形式的 ref,不适用于 :ref 绑定。

解决方案

方案一:使用字符串形式的 ref(推荐)

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      ref="searchComList"  <!-- ✅ 去掉冒号,使用字符串形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
// 现在 searchComList.value 会自动收集到所有组件实例
</script>

方案二:使用函数形式的 ref(更灵活)

如果需要更精细的控制(比如去重、按 key 索引等),可以使用函数形式:

<template>
  <el-form-item v-for="(schemaItem, key) in schema.properties" :key="key">
    <component 
      :ref="(el) => handleRef(el, key)"  <!-- ✅ 函数形式 -->
      :is="SearchItemConfig[schemaItem.option?.comType]?.component" 
      :schemaKey="key"
      :schema="schemaItem">
    </component>
  </el-form-item>
</template>

<script setup>
const searchComList = ref([]);
const componentMap = new Map();

const handleRef = (el, key) => {
  if (el) {
    // 如果已经存在,先移除旧的(避免重复)
    if (componentMap.has(key)) {
      const oldIndex = searchComList.value.indexOf(componentMap.get(key));
      if (oldIndex > -1) {
        searchComList.value.splice(oldIndex, 1);
      }
    }
    // 添加新的组件实例
    componentMap.set(key, el);
    searchComList.value.push(el);
  } else {
    // 组件卸载时,从 Map 和数组中移除
    if (componentMap.has(key)) {
      const oldEl = componentMap.get(key);
      const index = searchComList.value.indexOf(oldEl);
      if (index > -1) {
        searchComList.value.splice(index, 1);
      }
      componentMap.delete(key);
    }
  }
};
</script>

技术要点总结

1. Vue 3 ref 在 v-for 中的行为

写法 在 v-for 中的行为 适用场景
ref="xxx" 自动收集到数组 ✅ 推荐,简单场景
:ref="xxx" 不会自动收集,会覆盖 ❌ 不适用于 v-for
:ref="(el) => fn(el)" 手动控制收集逻辑 ✅ 需要精细控制时

2. 最佳实践

  1. 在 v-for 中使用 ref 时,优先使用字符串形式

    <component v-for="item in list" ref="comListRef" />
    
  2. 如果需要按 key 索引或去重,使用函数形式

    <component v-for="(item, key) in list" :ref="(el) => handleRef(el, key)" />
    
  3. 避免在 v-for 中使用 :ref="refObject"

    <!-- ❌ 不推荐 -->
    <component v-for="item in list" :ref="comListRef" />
    

3. 调试技巧

当遇到 ref 收集问题时,可以:

  1. 检查 ref 的写法:确认是字符串还是绑定对象
  2. 使用 nextTick 延迟检查:确保组件已挂载
  3. 对比正常工作的代码:找出差异点
  4. 查看 Vue DevTools:检查组件实例是否正确创建

相关资源

总结

这个问题看似简单,但实际上涉及到 Vue 3 中 refv-for 中的特殊处理机制。关键点在于:

  1. 字符串形式的 ref 在 v-for 中会自动收集到数组
  2. 绑定形式的 :ref 在 v-for 中不会自动收集
  3. 函数形式的 :ref 可以手动控制收集逻辑

记住这个规则,可以避免很多类似的坑。在开发过程中,如果遇到 ref 收集问题,首先检查是否在 v-for 中使用了错误的 ref 写法。

2026最新款Vue3+DeepSeek-V3.2+Arco+Markdown网页端流式生成AI Chat

一周左右爆肝迭代研发,最新款vite7.2+vue3接入deepseek api网页版ai系统,完结了!

未标题-ee.png

p4-1.gif

技术栈

  • 开发工具:vscode
  • 技术框架:vite^7.2.4+vue^3.5.24+vue-router^4.6.4
  • 大模型ai框架:DeepSeek-R1 + OpenAI
  • UI组件库:arco-design^2.57.0 (字节桌面端组件库)
  • 状态插件:pinia^3.0.4
  • 本地存储:pinia-plugin-persistedstate^4.7.1
  • 高亮插件:highlight.js^11.11.1
  • markdown插件:markdown-it
  • katex公式:@mdit/plugin-katex^0.24.1

未标题-aa.png

p0.gif

项目功能性

  1. 最新框架vite7.x集成deepseek流式生成,效果更丝滑流畅
  2. 提供暗黑+浅色主题、侧边栏展开/收缩
  3. 支持丰富Markdown样式,代码高亮/复制/收缩功能
  4. 支持思考模式DeepSeek-R1
  5. 支持Katex数学公式
  6. 支持Mermaid各种甘特图/流程图/类图等图表

p4-4.gif

项目结构目录

360截图20260104094519850.png

项目环境变量.env

根据自己申请的deepseek apikey替换项目根目录下.env文件里的key即可体验ai流式打字效果。

eea27d7de7408d26b80a8e50eb3a9103_1289798-20260104231837231-58531704.png

# title
VITE_APP_TITLE = 'Vue3-Web-DeepSeek'

# port
VITE_PORT = 3001

# 运行时自动打开浏览器
VITE_OPEN = true

# 开启https
VITE_HTTPS = false

# 是否删除生产环境 console
VITE_DROP_CONSOLE = true

# DeepSeek API配置
VITE_DEEPSEEK_API_KEY = 替换为你的 API Key
VITE_DEEPSEEK_BASE_URL = https://api.deepseek.com

未标题-kk.png

未标题-bb.png

公共布局模板

2e8247992f19e747d1a9eaa2a6f909cc_1289798-20260104232329143-383578235.png

项目整体结构如下图所示:

90b5497d6fdf0a31688d4a4e0c2cb09a_1289798-20260104232839646-1862838173.png

<script setup>
  import Sidebar from '@/layouts/components/sidebar/index.vue'
</script>

<template>
  <div class="vu__container">
    <div class="vu__layout flexbox flex-col">
      <div class="vu__layout-body flex1 flexbox">
        <!-- 侧边区域 -->
        <Sidebar />

        <!-- 主面板区域 -->
        <div class="vu__layout-main flex1">
          <router-view v-slot="{ Component, route }">
            <keep-alive>
              <component :is="Component" :key="route.path" />
            </keep-alive>
          </router-view>
        </div>
      </div>
    </div>
  </div>
</template>

001360截图20260103080509704.png

002360截图20260103081002002.png

002360截图20260103084412883.png

002360截图20260103085708292.png

003360截图20260103091843214.png

004360截图20260103115744725.png

005360截图20260103120220903.png

007360截图20260103120754686.png

008360截图20260103124947743.png

008360截图20260103125240325.png

vue3集成deepseek深度思考模式

8719e16c9af153eafdefa8121102ba4a_1289798-20260104233124703-1428202818.png

6a20e5f59713e1bc0d6d9b639dd4be1c_1289798-20260104233216967-269432762.png

// 调用deepseek接口
const completion = await openai.chat.completions.create({
  // 单一会话
  /* messages: [
    {role: 'user', content: editorValue}
  ], */
  // 多轮会话
  messages: props.multiConversation ? historySession.value : [{role: 'user', content: editorValue}],
  // deepseek-chat对话模型 deepseek-reasoner推理模型
  model: sessionstate.thinkingEnabled ? 'deepseek-reasoner' : 'deepseek-chat',
  stream: true, // 流式输出
  max_tokens: 8192, // 一次请求中模型生成 completion 的最大 token 数(默认使用 4096)
  temperature: 0.4, // 严谨采样
})

006360截图20260103120533402.png

008360截图20260103124947743.png

010360截图20260103125925726.png

012360截图20260103130203476.png

vue3-deepseek集成katex和mermaid插件

import { katex } from "@mdit/plugin-katex"; // 支持数学公式
import 'katex/dist/katex.min.css'
// 渲染mermaid图表
import { markdownItMermaidPlugin } from '@/components/markdown/plugins/mermaidPlugin'

解析markdown结构

<Markdown
  :source="item.content"
  :html="true"
  :linkify="true"
  :typographer="true"
  :plugins="[
    [katex, {delimiters: 'all'}],
    [markdownItMermaidPlugin, { ... }]
  ]"
  @copy="onCopy"
/>

49d19d2a7fb1c55ccd846a129970d002_1289798-20260104234115865-193884745.png

21bcd22f4247c768a9f61f878444dd1c_1289798-20260104234151062-1791535844.png

vue3调用deepseek api流式对话

// 调用deepseek接口
const completion = await openai.chat.completions.create({
  // 单一会话
  // messages: [{role: 'user', content: editorValue}],
  // 多轮会话
  messages: props.multiConversation ? historySession.value : [{role: 'user', content: editorValue}],
  // deepseek-chat对话模型 deepseek-reasoner推理模型
  model: sessionstate.thinkingEnabled ? 'deepseek-reasoner' : 'deepseek-chat',
  stream: true, // 流式输出
  max_tokens: 8192,
  temperature: 0.4
})

处理流式生成内容。

for await (const chunk of completion) {
  // 检查是否已终止
  if(sessionstate.aborted) break

  const content = chunk.choices[0]?.delta?.content || ''
  // 获取推理内容
  const reasoningContent = chunk.choices[0]?.delta?.reasoning_content || ''
  
  if(content || reasoningContent) {
    answerText += content
    reasoningText += reasoningContent

    // 限制更新频率:每100ms最多更新一次
    const now = Date.now()
    if(now - lastUpdate > 100) {
      lastUpdate = now
      requestAnimationFrame(() => {
        // ...
      })
    }
  }
  if(chunk.choices[0]?.finish_reason === 'stop') {
    // ...
  }
}

2026最新款Vite7+Vue3+DeepSeek-V3.2+Markdown流式输出AI会话

基于uniapp+vue3+deepseek+markdown搭建app版流式输出AI模板

electron38.2-vue3os系统|Vite7+Electron38+Pinia3+ArcoDesign桌面版OS后台管理

基于electron38+vite7+vue3 setup+elementPlus电脑端仿微信/QQ聊天软件

2025最新款Electron38+Vite7+Vue3+ElementPlus电脑端后台系统Exe

自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统

基于uni-app+vue3+uvui跨三端仿微信app聊天模板【h5+小程序+app】

基于uniapp+vue3+uvue短视频+聊天+直播app系统

基于flutter3.32+window_manager仿macOS/Wins风格桌面os系统

flutter3.27+bitsdojo_window电脑端仿微信Exe应用

自研tauri2.0+vite6.x+vue3+rust+arco-design桌面版os管理系统Tauri2-ViteOS

如何取消Vue Watch监听

1. 为什么要取消 Watch 监听? 在实际项目中,watch 本质上是一种长期订阅关系。 如果不加控制,它会在数据变化的整个生命周期内持续触发,这在很多场景下并不是我们想要的。 合理地取消监听,主

如何在Vue中传递函数作为Prop

在Vue中,你可以传递字符串、数组、数字和对象作为props。 但是你能传递一个函数作为道具吗? 虽然你可以传递一个函数作为道具,但这几乎总是一个坏主意。相反,Vue可能有一个特性是专门为解决你的问题

解析ElementPlus打包源码(三、打包类型)

runTask('generateTypesDefinitions')

我们定位到相关的代码

import path from 'path'
import { readFile, writeFile } from 'fs/promises'
import glob from 'fast-glob'
import { copy, remove } from 'fs-extra'
import { buildOutput } from '@element-plus/build-utils'
import { pathRewriter, run } from '../utils'

export const generateTypesDefinitions = async () => {
  await run(
    'npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types'
  )
  const typesDir = path.join(buildOutput, 'types', 'packages')
  const filePaths = await glob(`**/*.d.ts`, {
    cwd: typesDir,
    absolute: true,
  })
  const rewriteTasks = filePaths.map(async (filePath) => {
    const content = await readFile(filePath, 'utf8')
    await writeFile(filePath, pathRewriter('esm')(content), 'utf8')
  })
  await Promise.all(rewriteTasks)
  const sourceDir = path.join(typesDir, 'element-plus')
  await copy(sourceDir, typesDir)
  await remove(sourceDir)
}

打包类型文件

image.png

image.png

对于npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types-p tsconfig.web.json指定要使用的编译配置文件,--declaration 指定生成声明文件,--emitDeclarationOnly表示只生成声明文件不会进行代码编译,--declarationDir dist/types指定了输出目录

然后运行pnpm buid,可以看到生成了对应的类型文件

image.png

重写类型文件

image.png

image.png

主要就是针对路径进行处理,把开发环境的路径处理成了打包之后的路径

重写类型文件之前 image.png 重写类型文件之后 image.png

可以看到这里import的是es文件目录下的类型文件,之前打包的es下面并没有对应的类型啊???其实后面还有处理,会把types的相关类型移动到对应目录下,需要往后面看 copyTypesDefinitions

提升package/element-plus的类型文件到上层

image.png

我们之前打包的配置 preserveModules 值为 true 时,不会有element-plus的目录结构了,下面的文件会提升到上层。

这里就是为了把类型提到上层,使得其和之前的组件打包的结构一致

提升前:image.png

提升后:image.png

copyTypesDefinitions

image.png

export const copyTypesDefinitions: TaskFunction = (done) => {
  const src = path.resolve(buildOutput, 'types', 'packages')
  const copyTypes = (module: Module) =>
    withTaskName(`copyTypes:${module}`, () =>
      copy(src, buildConfig[module].output.path, { recursive: true })
    )

  return parallel(copyTypes('esm'), copyTypes('cjs'))(done)
}

这是要把types下面的各种文件,copy到es和lib的相关文件下

image.png

JavaScript 数组中删除偶数下标值的多种方法

  1. 使用 filter 方法(推荐)
// 方法1: 使用filter保留奇数下标元素
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

// 删除偶数下标(0, 2, 4, 6, 8...),保留奇数下标
const result1 = arr.filter((_, index) => index % 2 !== 0);
console.log(result1); // [1, 3, 5, 7, 9]

// 或删除奇数下标,保留偶数下标
const result2 = arr.filter((_, index) => index % 2 === 0);
console.log(result2); // [0, 2, 4, 6, 8]

2. 使用 for 循环(原地修改)

// 方法2: 从后向前遍历,原地删除
function removeEvenIndexes(arr) {
  // 从后向前遍历,避免索引错乱
  for (let i = arr.length - 1; i >= 0; i--) {
    if (i % 2 === 0) {  // 删除偶数下标
      arr.splice(i, 1);
    }
  }
  return arr;
}

const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(removeEvenIndexes(arr)); // [1, 3, 5, 7, 9]
console.log(arr); // 原数组也被修改
  1. 使用 reduce 方法
// 方法3: 使用reduce
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

const result = arr.reduce((acc, cur, index) => {
  if (index % 2 !== 0) {  // 只保留奇数下标
    acc.push(cur);
  }
  return acc;
}, []);

console.log(result); // [1, 3, 5, 7, 9]

  1. 使用 for 循环创建新数组
// 方法4: 遍历奇数下标创建新数组
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

function removeEvenIndexes(arr) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    if (i % 2 !== 0) {  // 只取奇数下标
      result.push(arr[i]);
    }
  }
  return result;
}

console.log(removeEvenIndexes(arr)); // [1, 3, 5, 7, 9]

  1. while 循环 + splice
// 方法5: 使用while循环
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

function removeEvenIndexes(arr) {
  let i = 0;
  while (i < arr.length) {
    if (i % 2 === 0) {  // 删除偶数下标
      arr.splice(i, 1);
    } else {
      i++;  // 只有不删除时才递增
    }
  }
  return arr;
}

console.log(removeEvenIndexes(arr)); // [1, 3, 5, 7, 9]
  1. 性能优化版本(大数据量)
// 方法6: 高性能版本,避免splice
function removeEvenIndexesOptimized(arr) {
  const result = [];
  const length = arr.length;
  
  // 从第一个奇数下标开始,步长为2
  for (let i = 1; i < length; i += 2) {
    result.push(arr[i]);
  }
  
  return result;
}

// 测试大数据量
const largeArr = Array.from({ length: 1000000 }, (_, i) => i);
console.time('优化版');
const optimizedResult = removeEvenIndexesOptimized(largeArr);
console.timeEnd('优化版'); // 大约 5-10ms
console.log('结果长度:', optimizedResult.length); // 500000
  1. 原地修改的高效方法
function removeEvenIndexesInPlace(arr) {
  let writeIndex = 0;
  
  for (let readIndex = 0; readIndex < arr.length; readIndex++) {
    if (readIndex % 2 !== 0) {  // 只保留奇数下标
      arr[writeIndex] = arr[readIndex];
      writeIndex++;
    }
  }
  
  // 截断数组
  arr.length = writeIndex;
  return arr;
}

const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
console.log(removeEvenIndexesInPlace(arr)); // [1, 3, 5, 7, 9]
console.log(arr); // 原数组被修改

  1. Vue 3 响应式数组处理
<template>
  <div>
    <h3>原始数组: {{ originalArray }}</h3>
    <h3>处理后: {{ processedArray }}</h3>
    <button @click="processArray">删除偶数下标</button>
    <button @click="resetArray">重置数组</button>
  </div>
</template>

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

// 原始响应式数组
const originalArray = ref([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

// 计算属性:删除偶数下标
const processedArray = computed(() => {
  return originalArray.value.filter((_, index) => index % 2 !== 0)
})

// 方法处理
const processArray = () => {
  // 方法1: 创建新数组(推荐,不修改原数组)
  originalArray.value = originalArray.value.filter((_, index) => index % 2 !== 0)
  
  // 方法2: 原地修改(会触发响应式更新)
  // let writeIndex = 0
  // for (let i = 0; i < originalArray.value.length; i++) {
  //   if (i % 2 !== 0) {
  //     originalArray.value[writeIndex] = originalArray.value[i]
  //     writeIndex++
  //   }
  // }
  // originalArray.value.length = writeIndex
}

const resetArray = () => {
  originalArray.value = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
}
</script>
  1. 通用工具函数
// 工具函数集合
const ArrayUtils = {
  /**
   * 删除偶数下标元素
   * @param {Array} arr - 输入数组
   * @param {boolean} inPlace - 是否原地修改
   * @returns {Array} 处理后的数组
   */
  removeEvenIndexes: function(arr, inPlace = false) {
    if (!Array.isArray(arr)) {
      throw new TypeError('输入必须是数组')
    }
    
    if (inPlace) {
      return this._removeEvenIndexesInPlace(arr)
    } else {
      return this._removeEvenIndexesNew(arr)
    }
  },
  
  /**
   * 创建新数组(不修改原数组)
   */
  _removeEvenIndexesNew: function(arr) {
    return arr.filter((_, index) => index % 2 !== 0)
  },
  
  /**
   * 原地修改
   */
  _removeEvenIndexesInPlace: function(arr) {
    let writeIndex = 0
    for (let i = 0; i < arr.length; i++) {
      if (i % 2 !== 0) {
        arr[writeIndex] = arr[i]
        writeIndex++
      }
    }
    arr.length = writeIndex
    return arr
  },
  
  /**
   * 删除奇数下标元素
   */
  removeOddIndexes: function(arr, inPlace = false) {
    if (inPlace) {
      let writeIndex = 0
      for (let i = 0; i < arr.length; i++) {
        if (i % 2 === 0) {
          arr[writeIndex] = arr[i]
          writeIndex++
        }
      }
      arr.length = writeIndex
      return arr
    } else {
      return arr.filter((_, index) => index % 2 === 0)
    }
  },
  
  /**
   * 删除指定下标的元素
   * @param {Array} arr - 输入数组
   * @param {Function} condition - 条件函数,返回true则删除
   */
  removeByIndexCondition: function(arr, condition, inPlace = false) {
    if (inPlace) {
      let writeIndex = 0
      for (let i = 0; i < arr.length; i++) {
        if (!condition(i)) {
          arr[writeIndex] = arr[i]
          writeIndex++
        }
      }
      arr.length = writeIndex
      return arr
    } else {
      return arr.filter((_, index) => !condition(index))
    }
  }
}

// 使用示例
const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

// 删除偶数下标
console.log(ArrayUtils.removeEvenIndexes(arr)) // [1, 3, 5, 7, 9]
console.log(arr) // 原数组不变 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

// 原地修改
console.log(ArrayUtils.removeEvenIndexes(arr, true)) // [1, 3, 5, 7, 9]
console.log(arr) // 原数组被修改 [1, 3, 5, 7, 9]

// 删除奇数下标
const arr2 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
console.log(ArrayUtils.removeOddIndexes(arr2)) // [0, 2, 4, 6, 8]

// 自定义条件
const arr3 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
console.log(ArrayUtils.removeByIndexCondition(
  arr3, 
  index => index % 3 === 0
)) // 删除下标是3的倍数的元素
  1. ES6+ 高级写法
// 使用生成器函数
function* filterByIndex(arr, condition) {
  for (let i = 0; i < arr.length; i++) {
    if (condition(i)) {
      yield arr[i]
    }
  }
}

const arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const result = [...filterByIndex(arr, i => i % 2 !== 0)]
console.log(result) // [1, 3, 5, 7, 9]

// 使用箭头函数和三元运算符
const removeEvenIndexes = arr => arr.filter((_, i) => i % 2 ? arr[i] : null).filter(Boolean)

// 使用位运算(性能更好)
const removeEvenIndexesBit = arr => {
  const result = []
  for (let i = 1; i < arr.length; i += 2) {
    result.push(arr[i])
  }
  return result
}

// 使用 Array.from
const removeEvenIndexesFrom = arr => 
  Array.from(
    { length: Math.ceil(arr.length / 2) },
    (_, i) => arr[i * 2 + 1]
  ).filter(Boolean)

  1. TypeScript 版本
// TypeScript 类型安全的版本
class ArrayProcessor {
  /**
   * 删除偶数下标元素
   * @param arr 输入数组
   * @param inPlace 是否原地修改
   * @returns 处理后的数组
   */
  static removeEvenIndexes<T>(arr: T[], inPlace: boolean = false): T[] {
    if (inPlace) {
      return this.removeEvenIndexesInPlace(arr)
    } else {
      return this.removeEvenIndexesNew(arr)
    }
  }
  
  private static removeEvenIndexesNew<T>(arr: T[]): T[] {
    return arr.filter((_, index) => index % 2 !== 0)
  }
  
  private static removeEvenIndexesInPlace<T>(arr: T[]): T[] {
    let writeIndex = 0
    for (let i = 0; i < arr.length; i++) {
      if (i % 2 !== 0) {
        arr[writeIndex] = arr[i]
        writeIndex++
      }
    }
    arr.length = writeIndex
    return arr
  }
  
  /**
   * 泛型方法:根据下标条件过滤数组
   */
  static filterByIndex<T>(
    arr: T[], 
    predicate: (index: number) => boolean
  ): T[] {
    return arr.filter((_, index) => predicate(index))
  }
}

// 使用示例
const numbers: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const strings: string[] = ['a', 'b', 'c', 'd', 'e', 'f']

console.log(ArrayProcessor.removeEvenIndexes(numbers)) // [1, 3, 5, 7, 9]
console.log(ArrayProcessor.filterByIndex(strings, i => i % 2 === 0)) // ['a', 'c', 'e']
  1. 性能对比测试
// 性能测试
function testPerformance() {
  const largeArray = Array.from({ length: 1000000 }, (_, i) => i)
  
  // 测试 filter 方法
  console.time('filter')
  const result1 = largeArray.filter((_, i) => i % 2 !== 0)
  console.timeEnd('filter')
  
  // 测试 for 循环
  console.time('for loop')
  const result2 = []
  for (let i = 1; i < largeArray.length; i += 2) {
    result2.push(largeArray[i])
  }
  console.timeEnd('for loop')
  
  // 测试 while 循环
  console.time('while loop')
  const arrCopy = [...largeArray]
  let i = 0
  while (i < arrCopy.length) {
    if (i % 2 === 0) {
      arrCopy.splice(i, 1)
    } else {
      i++
    }
  }
  console.timeEnd('while loop')
  
  // 测试 reduce
  console.time('reduce')
  const result4 = largeArray.reduce((acc, cur, i) => {
    if (i % 2 !== 0) acc.push(cur)
    return acc
  }, [])
  console.timeEnd('reduce')
}

testPerformance()
// 结果通常: for loop 最快, filter 次之, reduce 较慢, while+splice 最慢

总结

推荐方法:

  1. 最简洁filter方法

    arr.filter((_, i) => i % 2 !== 0)
    
  2. 最高性能for循环

    const result = []
    for (let i = 1; i < arr.length; i += 2) {
      result.push(arr[i])
    }
    
  3. 原地修改:使用写指针

    let writeIndex = 0
    for (let i = 0; i < arr.length; i++) {
      if (i % 2 !== 0) {
        arr[writeIndex] = arr[i]
        writeIndex++
      }
    }
    arr.length = writeIndex
    

注意事项:

  1. 索引从0开始:JavaScript 数组下标从0开始
  2. 避免splice:在循环中使用 splice会改变数组长度,容易出错
  3. 性能考虑:大数据量时避免使用 splicefilter链式调用
  4. 不变性:根据需求选择是否修改原数组

扩展应用:

  • 删除奇数下标:i % 2 === 0
  • 删除特定模式:i % 3 === 0删除3的倍数下标
  • 保留特定范围:i >= 2 && i <= 5

**

给 Ant Design Vue 装上"涡轮增压":虚拟列表封装实践

前言

如果你用过 Ant Design Vue 的 Table 组件渲染过上万条数据,你一定体验过那种"浏览器在思考人生"的感觉。页面卡顿、滚动掉帧,仿佛在对你说:"兄弟,我真的尽力了。"

问题的根源很简单:Ant Design Vue 的 Table 组件并不支持虚拟列表。当你塞给它 10 万条数据时,它会老老实实地把这 10 万个 DOM 节点全部渲染出来。浏览器:我谢谢你啊。

于是,这个项目诞生了——基于 Ant Design Vue 二次封装,让它也能享受虚拟列表的"涡轮增压"。

什么是虚拟列表?

简单来说,虚拟列表就是一种"障眼法"。用户看起来在滚动一个包含 10 万条数据的列表,但实际上浏览器只渲染了可视区域内的那几十条数据。当你滚动时,组件会动态计算应该显示哪些数据,然后快速替换 DOM。

这就像是火车车窗外的风景:你坐在车厢里,透过窗户看到的永远只是窗外那一小片景色,但随着火车移动,窗外的景色不断变化,给你一种穿越了整片大地的感觉。虚拟列表也是如此,可视区域就是那扇窗户,数据就是窗外的风景。

核心实现:站在巨人的肩膀上

这个项目的核心是 @vueuse/core 提供的 useVirtualList hook。VueUse 是一个优秀的 Vue 组合式 API 工具集,而 useVirtualList 就是其中专门用来实现虚拟列表的工具。

让我们看看关键代码:

const { list, containerProps, wrapperProps } = useVirtualList(
  computed(() => props.dataSource),
  {
    itemHeight: props.rowHeight,
    overscan: 10,
  },
)

必要参数解析

1. 数据源(第一个参数)

这里传入的是一个响应式的数据源。 computed(() => props.dataSource),当父组件传入的数据变化时,虚拟列表会自动更新。

2. itemHeight(行高)

这是虚拟列表的"灵魂参数"。它告诉 useVirtualList:"嘿,我的每一行有多高。"有了这个信息,它才能准确计算出:

  • 可视区域能显示多少行
  • 滚动到某个位置时应该显示哪些数据
  • 整个列表的总高度是多少

重要提示:这个值必须尽可能准确。如果你设置的行高和实际渲染的行高不一致,滚动时就会出现"跳跃"或"错位"的问题。在我们的实现中,默认值是 55px,这是 Ant Design Vue Table 的默认行高。

3. overscan(预渲染数量)

这是一个性能优化参数。它的意思是:"除了可视区域的数据,我还要多渲染上下各 10 条数据。"

为什么要这么做?想象一下,如果只渲染可视区域的数据,当用户快速滚动时,新的数据可能来不及渲染,就会出现短暂的空白。通过预渲染一些数据,可以让滚动更加流畅。

当然,这个值也不能设置太大,否则就失去了虚拟列表的意义。10 是一个比较平衡的值。

返回值解析:虚拟列表的"三剑客"

useVirtualList 返回了三个关键对象,它们各司其职,共同完成虚拟列表的魔法。

1. list - 数据的"精选集"

这是当前应该渲染的数据列表。注意,这不是完整的数据源,而是经过计算后,当前可视区域(加上 overscan)应该显示的数据。

数据结构如下:

[
  { data: 原始数据, index: 在完整列表中的索引 },
  { data: 原始数据, index: 在完整列表中的索引 },
  ...
]

比如你有 10 万条数据,但 list 可能只包含 20-30 条数据,这就是虚拟列表的核心优势。

2. containerProps - 滚动的"指挥官"

这是绑定到外层容器的属性对象,它的结构大概是这样的:

{
  ref: containerRef,           // 容器的引用
  onScroll: () => {            // 滚动事件监听
    calculateRange();          // 重新计算应该显示哪些数据
  },
  style: {
    overflowY: "auto"          // 允许垂直滚动
  }
}

核心作用

  • ref:VueUse 需要获取容器的 DOM 引用,以便计算可视区域的大小和滚动位置
  • onScroll:这是虚拟列表的"心跳"。每次滚动时,它会触发 calculateRange() 函数,重新计算当前应该显示哪些数据
  • style:确保容器可以滚动

在我们的实现中,这样使用:

<div v-bind="containerProps" class="virtual-scroll-container">
  <!-- 内容 -->
</div>

通过 v-bind 直接绑定,所有的属性和事件监听都会自动应用到容器上。

3. wrapperProps - 高度的"撑杆"

这是绑定到内层包裹元素的属性对象,它的结构是这样的:

{
  width: "100%",
  height: "5500000px",    // 注意这个惊人的高度!
  marginTop: "0px"        // 动态调整,实现滚动偏移
}

为什么高度这么夸张?

假设你有 10 万条数据,每条高度 55px,那么完整列表的总高度就是:

100000 × 55 = 5,500,000px = 5500000px

这个高度是计算出来的"虚拟高度"。虽然实际只渲染了几十条数据,但通过设置这个巨大的高度,可以让滚动条的长度和滚动范围与真实的 10 万条数据保持一致。

marginTop 的妙用

当你滚动到列表中间时,比如滚动到第某条数据,marginTop 会动态调整为正值(比如 2915px),这样可以:

  1. 让当前渲染的数据显示在正确的位置
  2. 保持滚动条的位置准确

这就像是一个"移动的窗口",窗口内的内容在不断变化,但窗口的位置始终准确。通过动态调整 marginTop,VueUse 将实际渲染的少量数据"推"到了应该显示的位置,从而实现了虚拟滚动的效果。

在我们的实现中:

<div v-bind="wrapperProps">
  <a-table ... />
</div>

这个包裹层撑起了整个虚拟列表的"骨架",让浏览器以为真的有 10 万条数据在那里。

封装

1. 容器高度的控制

虚拟列表需要一个固定高度的容器来触发滚动。我们通过 CSS 变量动态绑定容器高度:

<style scoped>
.virtual-scroll-container {
  height: v-bind(containerHeight + 'px');
  overflow-y: auto;
  /* 其他样式... */
}
</style>

这样,父组件可以通过 container-height prop 灵活控制可视区域的高度。

2. 插槽的完整透传

为了保持 Ant Design Vue Table 的灵活性,我们需要把所有插槽都透传给内部的 Table 组件:

<template v-for="(_, name) in $slots" #[name]="slotData">
  <slot :name="name" v-bind="slotData || {}"></slot>
</template>

这样,父组件可以像使用原生 Table 一样使用自定义列、自定义单元格等功能。

3. 必须注意的细节

在使用这个组件时,有一个容易被忽略的细节:表格列的 ellipsis 必须设置为 true

为什么?因为虚拟列表的行高是固定的,如果内容超出了单元格,就会撑高行高,导致计算错位。设置 ellipsis: true 可以确保内容超出时显示省略号,而不是换行。

const columns = [
  { title: '类型', key: 'type', width: 400, ellipsis: true },
  // ...
]

性能对比

在测试页面中,我们生成了 10 万条数据

const tableData = ref(generateMockData(100000))

如果用原生的 Ant Design Vue Table 渲染,浏览器会直接"去世"。但使用虚拟列表封装后,滚动依然丝滑流畅。

这就是虚拟列表的魅力:无论数据有多少,实际渲染的 DOM 节点永远只有那么几十个。

使用方式

使用这个组件非常简单,和原生 Table 几乎一样:

<v-table
  :data-source="tableData"
  :columns="columns"
  :row-height="55"
  :container-height="600"
  row-key="id"
/>

唯一的区别是多了两个参数:

  • row-height:行高,必须准确
  • container-height:容器高度,决定可视区域大小

总结

这个项目的核心思路很简单:

  1. 借助 VueUse 的 useVirtualList 实现虚拟列表逻辑
  2. 将虚拟列表的数据转换为 Ant Design Vue Table 需要的格式
  3. 通过 props 和插槽保持组件的灵活性

项目地址:ant-virtual-table

让 JSON 数据可视化:两款 Vue 组件实战解析

最近的项目中正好遇到JSON格式化展示的需求,需要在前端清晰美观的展示JSON数据结构。

调研了下Vue生态中有两款出色的组件:vue-json-pretty和vue-json-viewer,它们都能将JSON数据变得直观易读。

组件定位与核心差异

vue-json-pretty更像是功能全面的JSON编辑器。它采用树形结构展示数据,支持节点编辑、虚拟滚动和深度自定义。如果你需要用户交互或处理大型数据集,这是更好的选择。

vue-json-viewer则定位为简洁高效的查看器。它专注于快速展示和便捷复制,API简单直接。对于只需展示不需编辑的场景,它更加轻量实用。

实际选择时,问问自己:需要编辑功能吗?数据量有多大?需要多深的自定义?回答这些问题后,选择就清晰了。

vue-json-pretty:美观实用的JSON编辑器

基础使用

安装很简单:

# Vue 3
npm install vue-json-pretty --save

# Vue 2
npm install vue-json-pretty@v1-latest --save

基本集成:

<template>
  <vue-json-pretty :data="apiResponse" />
</template>

<script>
import VueJsonPretty from 'vue-json-pretty'
import 'vue-json-pretty/lib/styles.css'

export default {
  components: { VueJsonPretty },
  data() {
    return {
      apiResponse: {
        user: { name: '张三', id: 123 },
        status: 'active',
        permissions: ['read', 'write']
      }
    }
  }
}
</script>

核心优势

树形结构清晰:缩进和连接线让嵌套数据一目了然。数组和对象会显示长度,快速掌握数据结构。

编辑功能实用:开启编辑模式后,用户可以直接修改数值(ps:所以我最后选了它):

<vue-json-pretty
  :data="configData"
  :editable="true"
  @data-change="handleConfigUpdate"
/>

这对于配置编辑器、主题定制器等场景特别有用。

虚拟滚动处理大数据

<vue-json-pretty
  :data="largeDataset"
  :virtual="true"
  :item-height="24"
/>

即使渲染数千节点,依然保持流畅。

高度可定制:控制展示细节的选项丰富:

<vue-json-pretty
  :data="complexData"
  :show-length="true"
  :show-line="true"
  :deep="3"
  :highlight-selected="true"
  :custom-value="renderTimestamp"
/>

实战:API调试面板

在实际开发中,我常用它构建API调试工具:

<template>
  <div class="api-debugger">
    <div class="request-panel">
      <h4>请求参数</h4>
      <vue-json-pretty :data="requestParams" :deep="2" />
    </div>
    <div class="response-panel">
      <h4>响应数据</h4>
      <vue-json-pretty 
        :data="responseData"
        :highlight-selected="true"
        @node-click="copyNodeValue"
      />
    </div>
  </div>
</template>

vue-json-viewer:轻量高效的查看利器

快速集成

按Vue版本选择安装:

# Vue 2
npm install vue-json-viewer@2 --save

# Vue 3  
npm install vue-json-viewer@3 --save

基本使用:

<template>
  <json-viewer 
    :value="logData"
    :expand-depth="2"
    copyable
    boxed
  />
</template>

<script>
import JsonViewer from 'vue-json-viewer'
import 'vue-json-viewer/style.css'

export default {
  components: { JsonViewer },
  data() {
    return {
      logData: {
        timestamp: Date.now(),
        level: 'error',
        message: '数据库连接失败',
        details: { retryCount: 3 }
      }
    }
  }
}
</script>

设计特点

极简但实用:没有多余功能,但复制、折叠、主题切换都做得很好。默认样式清爽,颜色区分明显。

一键复制:添加copyable属性,每个值旁都会出现复制按钮,调试时特别方便。

性能优化好:采用延迟加载策略,大文件初始加载快。但要注意,这会影响浏览器的全局搜索功能(Ctrl+F可能找不到未渲染内容)。

主题支持:轻松切换明暗主题:

<json-viewer :value="data" theme="dark" />

实战:系统日志查看器

对于日志查看场景,vue-json-viewer非常合适:

<template>
  <div class="log-viewer">
    <div v-for="(log, index) in filteredLogs" :key="index">
      <div class="log-meta">
        <span class="level-tag">{{ log.level }}</span>
        <span class="time">{{ formatTime(log.timestamp) }}</span>
      </div>
      <json-viewer 
        :value="log.data"
        :expand-depth="log.level === 'error' ? 3 : 1"
        copyable
      />
    </div>
  </div>
</template>

决策指南:如何选择?

根据我的使用经验,选择建议如下:

选vue-json-pretty当:

  • 需要编辑JSON数据
  • 处理超大型数据集(>5MB)
  • 要求深度自定义样式和交互
  • 构建开发者工具或管理后台

选vue-json-viewer当:

  • 只需查看不可编辑
  • 需要频繁复制字段值
  • 项目对包体积敏感
  • 快速集成,最小配置

实用技巧

处理循环引用:两个组件遇到循环引用都会出错。传递数据前先处理:

function safeStringify(obj) {
  const cache = new Set()
  return JSON.stringify(obj, (key, value) => {
    if (typeof value === 'object' && value !== null) {
      if (cache.has(value)) return '[Circular]'
      cache.add(value)
    }
    return value
  })
}

自定义样式:使用深度选择器覆盖默认样式:

::v-deep .vjs-tree {
  font-family: 'Monaco', 'Menlo', monospace;
  font-size: 13px;
}

总结

vue-json-pretty和vue-json-viewer都是优秀的Vue JSON组件,选择取决于具体需求。 需要功能全面、支持编辑、处理大数据?选vue-json-pretty。只需简单展示、快速集成、便捷复制?选vue-json-viewer。


你是否有更好的JSON展示组件推荐?欢迎评论区留言。

关注微信公众号" 大前端历险记",掌握更多前端开发干货姿势!

❌