阅读视图

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

浅谈 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展示组件推荐?欢迎评论区留言。

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

🔥Vue3 动态组件‘component’全解析

在 Vue3 开发中,我们经常会遇到需要根据不同状态切换不同组件的场景 —— 比如表单的步骤切换、Tab 标签页、权限控制下的组件渲染等。如果用 v-if/v-else 逐个判断,代码会变得冗余且难以维护。而 Vue 提供的动态组件特性,能让我们以更优雅的方式实现组件的动态切换,大幅提升代码的灵活性和可维护性。

本文将从基础到进阶,全面讲解 Vue3 中动态组件的使用方法、核心特性、避坑指南和实战场景,帮助你彻底掌握这一高频使用技巧。

📚 什么是动态组件?

动态组件是 Vue 内置的一个核心功能,通过 <component> 内置组件和 is 属性,我们可以动态绑定并渲染不同的组件,无需手动编写大量的条件判断。

简单来说:你只需要告诉 Vue 要渲染哪个组件,它就会自动帮你完成组件的切换

🚀 基础用法:快速实现组件切换

1. 基本语法

动态组件的核心是 <component> 标签和 is 属性:

<template>
  <!-- 动态组件:is 属性绑定要渲染的组件 -->
  <component :is="currentComponent"></component>
</template>

<script setup>
import { ref } from 'vue'
// 导入需要切换的组件
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
import ComponentC from './ComponentC.vue'

// 定义当前要渲染的组件
const currentComponent = ref('ComponentA')
</script>

2. 完整示例:Tab 标签页

下面实现一个最常见的 Tab 切换场景,直观感受动态组件的用法:

<template>
  <div class="tab-container">
    <!-- Tab 切换按钮 -->
    <div class="tab-buttons">
      <button 
        v-for="tab in tabs" 
        :key="tab.name"
        :class="{ active: currentTab === tab.name }"
        @click="currentTab = tab.name"
      >
        {{ tab.label }}
      </button>
    </div>

    <!-- 动态组件核心 -->
    <div class="tab-content">
      <component :is="currentTabComponent"></component>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
// 导入子组件
import Home from './Home.vue'
import Profile from './Profile.vue'
import Settings from './Settings.vue'

// 定义 Tab 配置
const tabs = [
  { name: 'Home', label: '首页' },
  { name: 'Profile', label: '个人中心' },
  { name: 'Settings', label: '设置' }
]

// 当前激活的 Tab
const currentTab = ref('Home')

// 计算属性:根据当前 Tab 匹配对应组件
const currentTabComponent = computed(() => {
  switch (currentTab.value) {
    case 'Home': return Home
    case 'Profile': return Profile
    case 'Settings': return Settings
    default: return Home
  }
})
</script>

<style scoped>
.tab-container {
  width: 400px;
  margin: 20px auto;
}
.tab-buttons {
  display: flex;
  gap: 4px;
}
.tab-buttons button {
  padding: 8px 16px;
  border: none;
  border-radius: 4px 4px 0 0;
  cursor: pointer;
  background: #f5f5f5;
}
.tab-buttons button.active {
  background: #409eff;
  color: white;
}
.tab-content {
  padding: 20px;
  border: 1px solid #e6e6e6;
  border-radius: 0 4px 4px 4px;
}
</style>

关键点说明

  • is 属性可以绑定:组件的导入对象、组件的注册名称(字符串)、异步组件;
  • 切换 currentTab 时,<component> 会自动渲染对应的组件,无需手动控制。

⚡ 进阶特性:缓存、传参、异步加载

1. 组件缓存:keep-alive 避免重复渲染

默认情况下,动态组件切换时,旧组件会被销毁,新组件会重新创建。如果组件包含表单输入、请求数据等逻辑,切换时会丢失状态,且重复渲染影响性能。

使用 <keep-alive> 包裹动态组件,可以缓存未激活的组件,保留其状态:

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

    <!-- 使用 keep-alive 缓存组件 -->
    <div class="tab-content">
      <keep-alive>
        <component :is="currentTabComponent"></component>
      </keep-alive>
    </div>
  </div>
</template>

keep-alive 高级用法

  • include:仅缓存指定名称的组件(需组件定义 name 属性);
  • exclude:排除不需要缓存的组件;
  • max:最大缓存数量,超出则销毁最久未使用的组件。
<!-- 仅缓存 HomeProfile 组件 -->
<keep-alive include="Home,Profile">
  <component :is="currentTabComponent"></component>
</keep-alive>

<!-- 排除 Settings 组件 -->
<keep-alive exclude="Settings">
  <component :is="currentTabComponent"></component>
</keep-alive>

<!-- 最多缓存 2 个组件 -->
<keep-alive :max="2">
  <component :is="currentTabComponent"></component>
</keep-alive>

2. 组件传参:向动态组件传递 props / 事件

动态组件和普通组件一样,可以传递 props、绑定事件:

<template>
  <component 
    :is="currentComponent"
    <!-- 传递 props -->
    :user-id="userId"
    :title="pageTitle"
    <!-- 绑定事件 -->
    @submit="handleSubmit"
    @cancel="handleCancel"
  ></component>
</template>

<script setup>
import { ref } from 'vue'
import FormA from './FormA.vue'
import FormB from './FormB.vue'

const currentComponent = ref(FormA)
const userId = ref(1001)
const pageTitle = ref('用户表单')

const handleSubmit = (data) => {
  console.log('提交数据:', data)
}
const handleCancel = () => {
  console.log('取消操作')
}
</script>

子组件接收 props / 事件:

<!-- FormA.vue -->
<template>
  <div>
    <h3>{{ title }}</h3>
    <p>用户ID:{{ userId }}</p>
    <button @click="$emit('submit', { id: userId })">提交</button>
    <button @click="$emit('cancel')">取消</button>
  </div>
</template>

<script setup>
defineProps({
  userId: Number,
  title: String
})
defineEmits(['submit', 'cancel'])
</script>

3. 异步加载:动态导入组件(按需加载)

对于大型应用,为了减小首屏体积,我们可以结合 Vue 的异步组件和动态组件,实现组件的按需加载:

<template>
  <component :is="asyncComponent"></component>
  <button @click="loadComponent">加载异步组件</button>
</template>

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

// 初始为空
const asyncComponent = ref(null)

// 动态导入组件
const loadComponent = async () => {
  // 异步导入 + 按需加载
  const AsyncComponent = await import('./AsyncComponent.vue')
  asyncComponent.value = AsyncComponent.default
}
</script>

更优雅的写法

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

// 定义异步组件
const AsyncComponentA = defineAsyncComponent(() => import('./AsyncComponentA.vue'))
const AsyncComponentB = defineAsyncComponent(() => import('./AsyncComponentB.vue'))

const currentAsyncComponent = ref(null)

// 切换异步组件
const switchComponent = (type) => {
  currentAsyncComponent.value = type === 'A' ? AsyncComponentA : AsyncComponentB
}
</script>

4. 生命周期:缓存组件的激活 / 失活钩子

<keep-alive> 缓存的组件,不会触发 mounted/unmounted,而是触发 activated(激活)和 deactivated(失活)钩子:

<!-- Home.vue -->
<script setup>
import { onMounted, onActivated, onDeactivated } from 'vue'

onMounted(() => {
  console.log('Home 组件首次挂载')
})

onActivated(() => {
  console.log('Home 组件被激活(切换回来)')
})

onDeactivated(() => {
  console.log('Home 组件被失活(切换出去)')
})
</script>

🚨 常见坑点与解决方案

1. 组件切换后状态丢失

问题:切换动态组件时,表单输入、滚动位置等状态丢失。解决方案:使用 <keep-alive> 缓存组件,或手动保存 / 恢复状态。

2. keep-alive 不生效

问题:使用 keep-alive 后组件仍重新渲染。排查方向

  • 组件是否定义了 name 属性(include/exclude 依赖 name);
  • is 属性绑定的是否是组件对象(而非字符串);
  • 是否在 keep-alive 内部使用了 v-if(可能导致组件卸载)。

3. 异步组件加载失败

问题:动态导入组件时提示找不到模块。解决方案

  • 检查导入路径是否正确;
  • 确保异步组件返回的是默认导出(default);
  • 结合 Suspense 处理加载状态:
<template>
  <Suspense>
    <template #default>
      <component :is="currentAsyncComponent"></component>
    </template>
    <template #fallback>
      <div>加载中...</div>
    </template>
  </Suspense>
</template>

4. 动态组件传参不生效

问题:向动态组件传递的 props 未生效。解决方案

  • 确保子组件通过 defineProps 声明了对应的 props;
  • 检查 props 名称是否大小写一致(Vue 支持 kebab-case 和 camelCase 转换);
  • 避免传递非响应式数据(需用 ref/reactive 包裹)。

🎯 实战场景:动态组件的典型应用

1. 权限控制组件

根据用户角色动态渲染不同组件:

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

<script setup>
import { ref, computed } from 'vue'
import AdminPanel from './AdminPanel.vue'
import UserPanel from './UserPanel.vue'
import GuestPanel from './GuestPanel.vue'

// 模拟用户角色
const userRole = ref('admin') // admin / user / guest

// 根据角色匹配组件
const authComponent = computed(() => {
  switch (userRole.value) {
    case 'admin': return AdminPanel
    case 'user': return UserPanel
    case 'guest': return GuestPanel
    default: return GuestPanel
  }
})
</script>

2. 表单步骤切换

多步骤表单,根据当前步骤渲染不同表单组件:

<template>
  <div class="form-steps">
    <div class="steps">
      <span :class="{ active: step === 1 }">基本信息</span>
      <span :class="{ active: step === 2 }">联系方式</span>
      <span :class="{ active: step === 3 }">提交确认</span>
    </div>

    <keep-alive>
      <component 
        :is="currentFormComponent"
        :form-data="formData"
        @next="step++"
        @prev="step--"
        @submit="handleSubmit"
      ></component>
    </keep-alive>
  </div>
</template>

<script setup>
import { ref, computed, reactive } from 'vue'
import Step1 from './Step1.vue'
import Step2 from './Step2.vue'
import Step3 from './Step3.vue'

const step = ref(1)
const formData = reactive({
  name: '',
  age: '',
  phone: '',
  email: ''
})

const currentFormComponent = computed(() => {
  return {
    1: Step1,
    2: Step2,
    3: Step3
  }[step.value]
})

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

📝 总结

Vue3 的动态组件是提升组件复用性和灵活性的核心工具,核心要点:

  1. 基础用法:通过 <component :is="组件"> 实现动态渲染;
  2. 性能优化:使用 <keep-alive> 缓存组件,避免重复渲染和状态丢失;
  3. 高级用法:结合异步组件实现按需加载,结合 computed 实现复杂逻辑的组件切换;
  4. 避坑指南:注意 keep-alive 的生效条件、组件状态的保留、异步组件的加载处理。

掌握动态组件后,你可以告别繁琐的 v-if/v-else 嵌套,写出更简洁、更易维护的 Vue 代码。无论是 Tab 切换、权限控制还是多步骤表单,动态组件都能让你的实现方式更优雅!

Vue3 defineModel 完全指南:从基础使用到进阶技巧

在 Vue3 组合式 API 中,组件间数据传递是核心需求之一。对于父子组件的双向绑定,Vue2 时代我们习惯用v-model 配合 value 属性和 input 事件,而 Vue3 最初引入了 setup 函数后,需要通过 props 接收值并手动触发事件来实现双向绑定。直到 Vue3.4 版本,官方正式推出了 defineModel 宏,彻底简化了父子组件双向绑定的实现逻辑。

本文将从 defineModel 的核心作用出发,逐步讲解其基础使用、进阶配置、常见场景及注意事项,帮助你快速掌握这一高效的 API。

一、为什么需要 defineModel?

defineModel 出现之前,实现父子组件双向绑定需要两步操作:

  1. 子组件通过 props 接收父组件传递的值;
  2. 子组件通过 emit 触发事件,将修改后的值传递回父组件。

示例代码如下:

<!-- 子组件 Child.vue -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])

const handleChange = (e) => {
  emit('update:modelValue', e.target.value)
}
</script>

<template>
  <input :value="props.modelValue" @input="handleChange" />
</template>
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const inputValue = ref('')
</script>

<template>
  <Child v-model="inputValue" />
</template>

这种方式虽然可行,但存在明显弊端:代码冗余,每次实现双向绑定都需要重复定义 propsemit。而 defineModel 正是为了解决这个问题,它将 propsemit 的逻辑封装在一起,让双向绑定的实现更简洁、更直观。

二、defineModel 基础使用

2.1 基本语法

defineModel 是 Vue3.4+ 提供的内置宏,无需导入即可直接使用。其基本语法如下:

const model = defineModel();

通过上述代码,子组件即可直接获取到父组件通过 v-model 传递的值,且 model 是一个响应式对象,修改它会自动同步到父组件。

2.2 简化双向绑定示例

defineModel 重写上面的父子组件双向绑定示例:

<!-- 子组件 Child.vue -->
<script setup>
// 直接使用 defineModel 获取响应式模型
const modelValue = defineModel()
</script>

<template>
  <!-- 直接绑定 modelValue,修改时自动同步到父组件 -->
  <input v-model="modelValue" />
</template>
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const inputValue = ref('')
</script>

<template>
  <Child v-model="inputValue" />
  <p>父组件值:{{ inputValue }}</p>
</template>

可以看到,子组件的代码被大幅简化,无需再手动定义 propsemit,直接通过 defineModel 即可实现双向绑定。

2.3 自定义 v-model 名称

默认情况下,defineModel 对应父组件 v-modelmodelValue 属性和 update:modelValue 事件。如果需要自定义 v-model 的名称(即多 v-model 场景),可以给 defineModel 传递一个参数作为名称:

<!-- 子组件 Child.vue -->
<script setup>
// 自定义 v-model 名称为 "username"
const username = defineModel('username')
// 再定义一个 v-model 名称为 "password"
const password = defineModel('password')
</script>

<template>
  <div>
    <input v-model="username" placeholder="请输入用户名" />
    <input v-model="password" type="password" placeholder="请输入密码" />
  </div>
</template>
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const user = ref('')
const pwd = ref('')
</script>

<template>
  <Child 
    v-model:username="user" 
    v-model:password="pwd" 
  />
  <p>用户名:{{ user }}</p>
  <p>密码:{{ pwd }}</p>
</template>

通过这种方式,我们可以轻松实现一个组件支持多个 v-model 绑定,满足复杂场景的需求。

三、defineModel 进阶配置

defineModel 还支持传入一个配置对象,用于设置默认值、类型校验、是否可写等属性,进一步增强组件的健壮性。

3.1 设置默认值

通过配置对象的 default 属性可以设置 v-model 的默认值:

<!-- 子组件 Child.vue -->
<script setup>
// 设置默认值为 "默认用户名"
const username = defineModel('username', {
  default: '默认用户名'
})
</script>

<template>
  <input v-model="username" placeholder="请输入用户名" />
</template>

此时,若父组件未给 v-model:username 传递值,子组件的 username 会默认使用 "默认用户名"。

3.2 类型校验

通过 type 属性可以对 v-model传递的值进行类型校验,支持单个类型或多个类型数组:

<!-- 子组件 Child.vue -->
<script setup>
// 限制 username 必须为字符串类型
const username = defineModel('username', {
  type: String,
  default: ''
})

// 限制 count 可以为 Number 或 String 类型
const count = defineModel('count', {
  type: [Number, String],
  default: 0
})
</script>

<template>
  <input v-model="username" placeholder="请输入用户名" />
  <button @click="count++">计数:{{ count }}</button>
</template>

若父组件传递的值类型不匹配,Vue 会在控制台给出警告,帮助我们提前发现问题。

3.3 控制是否可写

通过 settable 属性可以控制子组件是否能直接修改 defineModel 返回的响应式对象。默认情况下 settable: true,子组件可以直接修改;若设置为 false,子组件修改时会报错,只能通过父组件修改后同步过来。

<!-- 子组件 Child.vue -->
<script setup>
// 设置 settable: false,子组件不能直接修改
const username = defineModel('username', {
  type: String,
  default: '',
  settable: false
})

const handleChange = (e) => {
  // 报错:Cannot assign to 'username' because it's a read-only proxy
  username.value = e.target.value
}
</script>

<template>
  <input :value="username" @input="handleChange" />
</template>

这种配置适合需要严格控制数据流向的场景,确保数据只能由父组件修改。

3.4 转换值(getter/setter)

通过 getset 方法可以对传递的值进行转换处理,类似计算属性的逻辑。例如,我们可以实现一个自动去除空格的输入框:

<!-- 子组件 Child.vue -->
<script setup>
const username = defineModel('username', {
  get: (value) => {
    // 父组件传递的值到子组件时,自动去除前后空格
    return value?.trim() || ''
  },
  set: (value) => {
    // 子组件修改后的值传递给父组件时,再次去除空格
    return value.trim()
  },
  default: ''
})
</script>

<template>
  <input v-model="username" placeholder="请输入用户名" />
</template>

通过 getset,我们可以在数据传递的过程中对其进行加工,让组件的逻辑更灵活。

四、常见使用场景

4.1 表单组件封装

封装表单组件是 defineModel 最常用的场景之一。例如,封装一个自定义输入框组件,支持双向绑定、类型校验、默认值等功能:

<!-- 自定义输入框组件 CustomInput.vue -->
<script setup>
const props = defineProps({
  label: {
    type: String,
    required: true
  }
})

const modelValue = defineModel({
  type: [String, Number],
  default: '',
  get: (val) => val || '',
  set: (val) => val.toString().trim()
})
</script>

<template>
  <div class="custom-input">
    <label>{{ label }}:</label>
    <input v-model="modelValue" />
  </div>
</template>
<!-- 父组件使用 -->
<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const name = ref('')
const age = ref(18)
</script>

<template>
  <CustomInput label="姓名" v-model="name" />
  <CustomInput label="年龄" v-model="age" />
  <p>姓名:{{ name }},年龄:{{ age }}</p>
</template>

4.2 开关、滑块等UI组件

对于开关(Switch)、滑块(Slider)等需要双向绑定状态的UI组件,defineModel 也能极大简化代码。以开关组件为例:

<!-- 开关组件 Switch.vue -->
<script setup>
const modelValue = defineModel({
  type: Boolean,
  default: false
})

const toggle = () => {
  modelValue.value = !modelValue.value
}
</script>

<template>
  <div 
    class="switch" 
    :class="{ active: modelValue }" 
    @click="toggle"
  >
    <div class="switch-button"></div>
  </div>
</template>

<style scoped>
.switch {
  width: 60px;
  height: 30px;
  border-radius: 15px;
  background-color: #ccc;
  position: relative;
  cursor: pointer;
}
.switch.active {
  background-color: #42b983;
}
.switch-button {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background-color: #fff;
  position: absolute;
  top: 2px;
  left: 2px;
  transition: left 0.3s;
}
.switch.active .switch-button {
  left: 32px;
}
</style>
<!-- 父组件使用 -->
<script setup>
import { ref } from 'vue'
import Switch from './Switch.vue'

const isOpen = ref(false)
</script>

<template>
  <div>
    <Switch v-model="isOpen" />
    <p>开关状态:{{ isOpen ? '开启' : '关闭' }}</p>
  </div>
</template>

五、注意事项

  1. Vue 版本要求defineModel 是 Vue3.4 及以上版本才支持的特性,若项目版本较低,需要先升级 Vue 版本(升级命令:npm update vue)。
  2. 响应式特性defineModel 返回的是一个响应式对象,修改其 value 属性会自动同步到父组件,无需手动触发 emit 事件。
  3. 与 defineProps 的关系defineModel 本质上是对 propsemit 的封装,因此不能与 defineProps 定义同名的属性,否则会出现冲突。
  4. 默认值的特殊性:当 defineModel 设置了 default 值时,若父组件传递了 undefined,子组件会使用默认值;若父组件传递了 null,则会使用 null 而不是默认值。
  5. 服务器端渲染(SSR)兼容性:在 SSR 场景下,defineModel 完全兼容,无需额外处理,因为其底层还是基于 propsemit 实现的。

六、总结

defineModel 作为 Vue3.4+ 推出的重要特性,极大地简化了父子组件双向绑定的实现逻辑,减少了重复代码,提升了开发效率。它支持自定义名称、默认值、类型校验、值转换等多种进阶功能,能够满足大部分双向绑定场景的需求。

在实际开发中,对于需要双向绑定的组件(如表单组件、UI交互组件等),推荐优先使用 defineModel 替代传统的 props + emit 方式。同时,要注意其版本要求和使用规范,避免出现兼容性问题。

Google A2UI 解读:当 AI 不再只是陪聊,而是开始画界面

1. 为什么我们不能只靠 Markdown?

目前的 Agent 交互虽然火热,但实际上体验极其割裂。

绝大多数 Chatbot(包括 ChatGPT)的交互只停留在 “文本 + Markdown” 的阶段。你要订票,它给你吐一段文字;你要看报表,它给你画个 ASCII 表格或者静态图片。虽然有了 Function Calling,但那是给后端用的,前端展示依然匮乏。

直接让 LLM 生成 HTML/JS 代码?在生产环境这是绝对禁忌。除了难以控制的幻觉,还有致命的 XSS 安全隐患。你不敢把 LLM 生成的 <script> 直接 innerHTML 到用户的浏览器里。

A2UI (Agent-Driven Interfaces) 的出现,就是为了解决这个问题。Google 并没有把它做成一个简单的 UI 库,而是一套协议 (Protocol)

它的核心逻辑是:Agent 不写代码,只传数据(JSON)。客户端也不猜意图,只管渲染。 这使得 Agent 可以在不触碰一行 JS/Swift 代码的情况下,安全地驱动原生界面。

2. A2UI 的核心:流式传输与跨平台

image.png

A2UI 不是 React,也不是 Flutter,它是**“Headless 的 UI 描述语言”**。

  • 声明式 (Declarative): Agent 发送的是 JSON 描述(比如“这里有个列表,列表项绑定了变量 X”),而不是命令式代码。
  • 流式优先 (Streaming First): 专为 LLM 的 Token 输出特性设计。很多传统 JSON 协议需要等 JSON 闭合才能解析,A2UI 允许边生成、边解析、边渲染。首屏延迟(TTFB)被压到最低。
  • 平台无关 (Framework-Agnostic): 同一套 JSON 流,在 Web 端可以是 React 组件,在 iOS 端可以是 SwiftUI,在 Android 端可以是 Jetpack Compose。这是它区别于 Vercel AI SDK (RSC) 的最大优势——它不绑死在 React 生态上。

3. 技术深挖:四种消息类型(Vue/React 视角)

A2UI 的文档里充斥着“Adjacency List(邻接表)”、“Flat Hierarchy(扁平层级)”等学术词汇。但对于前端工程师来说,如果你懂 Vue.js 或 React 的底层原理,A2UI 的机制其实就是“通过网络传输的响应式系统” (Reactivity over the wire)。

我们可以将 A2UI 的四种核心消息类型(Message Types),与 Vue 的渲染机制做一个类比:

image.png

(1) surfaceUpdate ≈ Virtual DOM / Template

这是 UI 的骨架

  • Vue 原理: 就像你写的 <template> 或者编译后的 render 函数。它定义了组件树的结构(Layout),以及组件属性(Props)。
  • A2UI 机制: Agent 发送 surfaceUpdate,告诉客户端:“这里放一个卡片,卡片里有个 Text,Text 的内容绑定到 model.restaurantName”。
  • 关键点: 它只定义结构和绑定关系,不一定包含具体的值。

(2) dataModelUpdate ≈ Reactivity System (ref / reactive)

这是 UI 的血液

  • Vue 原理: 就像你在 Vue 里执行了 this.count++。Vue 的响应式系统会通过 Setter 劫持,通知 Watcher 去更新对应的 DOM 节点。
  • A2UI 机制: Agent 后续只需要发送 dataModelUpdate 消息,包含 { "restaurantName": "海底捞" } 的 JSON Patch。
  • 性能杀手锏: 这意味着 Agent 不需要每次都重发整个 UI 结构。当数据变化时,客户端的 SDK 会像 Vue 一样,实现细粒度的更新 (Fine-grained updates)。这极大地节省了 Token 和带宽。

(3) beginRenderingmounted Hook

  • Vue 原理: 组件挂载完成,开始展示。
  • A2UI 机制: 控制渲染时机。Agent 可能想先在后台默默把数据和结构都发完,避免用户看到界面“跳变”,最后发一个 beginRendering 信号,界面瞬间呈现。

(4) deleteSurfacev-if="false" / unmounted

  • Vue 原理: 组件销毁,DOM 移除。
  • A2UI 机制: 会话结束或上下文切换时,清理不再需要的 UI 片段,释放客户端内存。

4. 生态位分析:A2UI 到底处于什么位置?

在 AI 工程化(AI Engineering)的版图中,A2UI 并不是孤立的。我们需要看看它和现在的热门工具有什么区别:

vs. Vercel AI SDK (RSC)

  • Vercel: 强绑定 Next.js 和 React Server Components。如果你的全栈都是 Next.js,Vercel 的体验是无敌的。
  • A2UI: 更加底层和通用。如果你的产品是一个 Flutter App 或者 Native Android 应用,你没法跑 React 组件。这时候,A2UI 这种纯 JSON 协议就是唯一解。

vs. MCP (Model Context Protocol)

Anthropic 推出的 MCP 最近很火,很多人容易混淆。

  • MCP (Model Context Protocol): 解决的是 Agent 如何连接后端数据(Server-side)。比如 Agent 怎么读取你的 Git 仓库、怎么连数据库。
  • A2UI: 解决的是 Agent 如何展示前端界面(Client-side)。
  • 结论: 它们是互补的。理想的架构是:Agent 通过 MCP 获取数据,处理后通过 A2UI 协议画出界面展示给用户。

vs. OpenAI Canvas / ChatKit

  • Canvas: OpenAI 的闭源产品功能。
  • ChatKit / CopilotKit: 这些是开源领域的应用框架。目前像 CopilotKit 这样的库,正在积极实现类似 Generative UI 的功能(通过 useCopilotAction 渲染自定义组件)。

image.png

  • Flutter GenUI SDK: 这是 A2UI 理念的最佳实践者。利用 Flutter 强大的渲染引擎,解析标准化的 JSON 协议,实现“一次生成,多端原生渲染”。

5. 总结:UI 开发范式的转移

A2UI 给我们最大的启示并非协议本身,而是开发模式的变革

以前我们写 UI,是写死的页面(Page-based)。 未来我们写 UI,是提供一堆高质量的**“组件积木” (Component Registry)**。

前端工程师的工作将从“画页面”转变为“维护组件库”和“配置 Schema”。剩下的组装工作,将由 Agent 根据用户的意图,通过类似 A2UI 的协议,在运行时动态完成。这就是 "Component-First, AI-Assembled" 的未来。

Vue3-异步组件 && suspense

异步组件(Async Components)也称为懒加载组件(Lazy-loaded Components)。

默认情况下,Webpack 或 Vite 等构建工具会把你所有的组件打包到一个(或几个)JavaScript 文件中。当用户访问你的网站时,他们必须一次性下载所有代码,这可能导致初始加载时间很长。

异步组件允许你将某些组件(通常是“重量级”或不总是需要立即显示的组件)分离成单独的 JavaScript 文件(chunk) 。这些文件只会在实际需要渲染该组件时才从服务器下载。

主要好处:

  • 代码分割 (Code-splitting): 减小初始包的体积。
  • 提升性能: 加快应用的初始加载和渲染速度。

1.在 Vue 3 中, 使用 defineAsyncComponent 函数来创建异步组件

举个栗子,骨架屏,准备两个组件,card和skeleton

card.vue

<template>
  <div class="card">
    <div class="user-info">
      <img :src="user.avatar" alt="avatar" />
      <div class="user-name">{{ user.name }}</div>
    </div>
    <hr />
    <div class="content">
      <p>{{ user.content }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
interface User {
  id: number;
  name: string;
  avatar: string;
  content: string;
}

const user = ref<User>({
  id: 1,
  name: "YaeZed",
  avatar: "https://avatars.githubusercontent.com/u/52018740?v=5",
  content: "Hello, Vue3!",
});
</script>

<style scoped>
.card {
  width: 300px;
  height: 300px;
  box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
}

.user-info {
  display: flex;
  flex-direction: row;
  text-align: center;
  align-items: center;
}

img {
  width: 150px;
  height: 150px;
  border-radius: 100px;
}

.user-name {
  margin-left: 30px;
  font-size: 24px;
  font-weight: bold;
}

.content {
  margin-left: 10px;
}
</style>

skeleton.vue

<template>
  <div class="card">
    <div class="user-info">
      <img src="" alt="" />
      <div class="user-name"></div>
    </div>
    <hr />
    <div class="content">
      <p></p>
    </div>
  </div>
</template>

<script setup lang="ts"></script>

<style scoped>
.card {
  width: 300px;
  height: 300px;
  box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
}

.user-info {
  display: flex;
  flex-direction: row;
  text-align: center;
  align-items: center;
}

img {
  background-color: bisque;
  width: 150px;
  height: 150px;
  border-radius: 100px;
}

.user-name {
  width: 70px;
  height: 20px;
  background-color: bisque;
  margin-left: 30px;
  font-size: 24px;
  font-weight: bold;
}

.content {
  background-color: beige;
  height: 120px;
  width: 300px;
}
</style>

结合使用异步组件和 Suspense

App.vue中 ,使用 Suspense 来加载 card.vue(作为异步组件),并在加载时显示 skeleton.vue

<template>
 <div>
   <h1>应用</h1>
   <p>下面的卡片是异步加载的...</p>

   <Suspense>

     <template #default>
       <AsyncUserCard />
     </template>

     <template #fallback>
       <CardSkeleton />
     </template>

   </Suspense>
 </div>
</template>

<script setup lang="ts">
// 导入 defineAsyncComponent 来创建异步组件
import { defineAsyncComponent } from 'vue';

// 1. 同步导入骨架屏
//
// "fallback" 内容必须是立即(同步)可用的,
// 所以我们像平常一样导入 skeleton.vue。
import CardSkeleton from './skeleton.vue';

// 2. 异步导入你的卡片组件
//
// 我们使用 `defineAsyncComponent` 来“包装”你的 card.vue。
// 这告诉 Vue 这是一个异步组件,应该懒加载。
const AsyncUserCard = defineAsyncComponent(() => 
 import('./card.vue')
);


// ----------------------------------------------------
// 💡 (可选) 如何在本地测试骨架屏:
//
// 在本地开发中,`card.vue` 加载得太快,你可能
// 看不到骨架屏。你可以像这样模拟一个 2 秒的网络延迟:
//
// const AsyncUserCard = defineAsyncComponent(() => {
//   return new Promise(resolve => {
//     setTimeout(() => {
//       // @ts-ignore
//       resolve(import('./card.vue'));
//     }, 2000); // 延迟 2 秒
//   });
// });
// ----------------------------------------------------

</script>

<style>
/* 一些全局样式 */
body {
 font-family: sans-serif;
 padding: 20px;
}
</style>

流程

  • 用户加载 App.vue

  • Suspense 开始渲染。它尝试渲染 #default 插槽。

  • 它发现 #default 里的 AsyncUserCard 是一个异步组件,并且尚未加载。

  • Suspense 立即切换到渲染 #fallback 插槽,显示 CardSkeleton

  • 同时,Vue 在后台开始下载 card.vue 对应的 JavaScript 文件。

  • 下载和解析完成后,Suspense 自动用 AsyncUserCard 的内容替换掉 CardSkeleton

2.另一种触发 Suspense 的方式:async setup

修改一下card.vue

<template>
  <div class="card">
    <div class="user-info">
      <img :src="user.avatar" alt="avatar" />
      <div class="user-name">{{ user.name }}</div>
    </div>
    <hr />
    <div class="content">
      <p>{{ user.content }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
interface User {
  id: number;
  name: string;
  avatar: string;
  content: string;
}

// 1. 模拟一个异步数据获取 (例如 API 调用)
const fetchUserData = (): Promise<User> => {
  return new Promise(resolve => {
    setTimeout(() => {
      console.log("数据获取完成!");
      resolve({
        id: 1,
        name: "YaeZed (来自 API)",
        avatar: "https://avatars.githubusercontent.com/u/52018740?v=4",
        content: "Hello, from async setup!",
      });
    }, 2000); // 模拟 2 秒的 API 调用
  });
};

// 2. 在 <script setup> 顶层使用 await
//
// Vue 会自动将 `setup` 变为 `async setup`。
// `Suspense` 将会等待这个 `await` (fetchUserData) 完成。
const user = ref<User>(await fetchUserData());

</script>

<style scoped>
/* 样式不变 */
.card {
  width: 300px;
  height: 300px;
  box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.3);
}

.user-info {
  display: flex;
  flex-direction: row;
  text-align: center;
  align-items: center;
}

img {
  width: 150px;
  height: 150px;
  border-radius: 100px;
}

.user-name {
  margin-left: 30px;
  font-size: 24px;
  font-weight: bold;
}

.content {
  margin-left: 10px;
}
</style>

现在,card.vue 自身包含了一个异步操作。App.vue 可以这样写:

<template>
  <Suspense>
    <template #default>
      <UserCard />
    </template>
    <template #fallback>
      <CardSkeleton />
    </template>
  </Suspense>
</template>

<script setup lang="ts">
// 这次可以同步导入 card
import UserCard from './card.vue'; 
import CardSkeleton from './skeleton.vue';
</script>

在这个版本中,同步导入UserCard,但因为 UserCard 内部有 async setupSuspense 仍然会捕获这个异步状态,并在 await fetchUserData() 完成之前显示 CardSkeleton

3.处理加载失败

异步组件在网络不稳定或资源不存在时可能会加载失败。为了防止整个页面崩溃,通常会配合 Vue 的 onErrorCaptured 钩子或专门的“错误边界”组件来处理异常。

可以通过 defineAsyncComponent 的高级配置项来处理超时或加载失败的情况:

const AsyncUserCard = defineAsyncComponent({
  loader: () => import('./card.vue'),
  // 加载异步组件时使用的组件(类似 skeleton)
  loadingComponent: CardSkeleton,
  // 展示加载组件前的延迟时间。默认:200ms
  delay: 200,
  // 如果提供了一个加载组件,在超时前它将被展示
  timeout: 3000,
  // 加载失败时使用的组件
  errorComponent: ErrorComponent, 
});

或者在 Suspense 的父组件中使用 onErrorCaptured 钩子来捕获异步依赖抛出的任何错误。

参考文章

小满zs 学习Vue3 第十八章(异步组件&代码分包&suspense)xiaoman.blog.csdn.net/article/det…

❌