普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月6日首页

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

作者 jqq666
2026年1月6日 15:52

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 数组中删除偶数下标值的多种方法

作者 持续前行
2026年1月6日 15:18
  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 装上"涡轮增压":虚拟列表封装实践

作者 baozj
2026年1月6日 14:22

前言

如果你用过 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 组件实战解析

作者 前端大鱼
2026年1月6日 11:23

最近的项目中正好遇到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’全解析

2026年1月6日 10:48

在 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 完全指南:从基础使用到进阶技巧

2026年1月6日 10:42

在 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 不再只是陪聊,而是开始画界面

2026年1月5日 00:20

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

作者 YaeZed
2026年1月4日 20:31

异步组件(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…

Vue3中,我的Watch为什么总监听不到数据?

2026年1月4日 17:09

前言

vue3中, watch使用 watch(()=>xxx,v=>{})或者watch(xxx,val=>{})时, 总是遇到函数写法的watch监听,数据改变后没有监听到数据改变;非函数的写法反而可以监听到。

在 Vue 3 中,这两种 watch 的使用方式确实有重要区别:

1. 语法区别

// 方式一:使用函数
watch(() => obj.property, (val) => {
  console.log('值变化了:', val)
})

// 方式二:直接监听响应式对象
watch(obj, (val) => {
  console.log('对象变化了:', val)
})

2. 关键区别

方式一: 函数 () => xxx

  • 监听特定属性:只追踪 obj.property 这个具体的值
  • 浅层监听:只有当 obj.property 的值本身发生变化时才触发
  • 不追踪对象引用:不关心整个 obj 对象的变化

方式二:直接监听响应式对象 xxx

  • 监听整个对象:追踪对象的所有属性变化
  • 深层监听:默认深度监听对象内部所有嵌套属性的变化
  • 追踪引用变化:也会监听对象本身的重新赋值

3. 为什么有时候 "函数的写法监听不到?"

情况一:嵌套对象属性变化

const obj = reactive({ 
  nested: { 
    value: 1 
  } 
})

// ❌ 监听不到 nested.value 的变化
watch(() => obj.nested, (val) => {
  console.log('不会触发')
})

// ✅ 需要深度监听
watch(() => obj.nested, (val) => {
  console.log('会触发')
}, { deep: true })

// ✅ 或监听具体属性
watch(() => obj.nested.value, (val) => {
  console.log('会触发')
})

情况二:数组操作

const arr = reactive([1, 2, 3])

// ❌ 监听不到 push/pop 等操作
watch(() => arr.length, (val) => {
  console.log('可能不会触发')
})

// ✅ 直接监听数组
watch(arr, (val) => {
  console.log('会触发')
})

情况三:解构丢失响应性

const obj = reactive({ x: 1, y: 2 })

// ❌ 解构会丢失响应性
const { x } = obj
watch(x, (val) => { }) // 无效

// ✅ 使用 getter 函数
watch(() => obj.x, (val) => {
  console.log('会触发')
})

4. 实践建议

使用函数 () => xxx 的场景:

  • 监听基本类型值(string, number, boolean)
  • 监听对象的特定属性
  • 需要计算或组合的依赖
  • 避免不必要的深度监听
// 监听特定属性
watch(() => user.name, (name) => {
  console.log('用户名变化:', name)
})

// 监听计算值
watch(() => x.value + y.value, (sum) => {
  console.log('和变化:', sum)
})

使用直接监听 xxx 的场景:

  • 需要监听对象所有变化
  • 监听数组变化
  • 需要深度监听嵌套对象
  • 监听引用变化
// 监听整个响应式对象
watch(state, (newState) => {
  console.log('状态变化:', newState)
}, { deep: true })

// 监听数组
watch(items, (newItems) => {
  console.log('列表变化:', newItems)
})

5. 注意事项

ref 对象的处理:

const count = ref(0)

// ✅ 正确:直接监听 ref
watch(count, (val) => {
  console.log('count变化:', val)
})

// ✅ 也正确:通过 .value 监听
watch(() => count.value, (val) => {
  console.log('count变化:', val)
})

reactive 对象的处理:

const state = reactive({ count: 0 })

// ✅ 推荐:使用 getter 监听特定属性
watch(() => state.count, (val) => {
  console.log('count变化:', val)
})

// ⚠️ 注意:直接监听 reactive 对象会自动开启深度监听
watch(state, (val) => {
  console.log('state变化:', val) // 任何嵌套属性变化都会触发
})

总结

选择哪种方式主要取决于:

  1. 监听粒度:需要监听整个对象还是特定属性
  2. 性能考虑:避免不必要的深度监听
  3. 数据类型:基本类型用 getter,复杂对象可直接监听
  4. 变化类型:需要监听引用变化还是值变化

当发现监听不到变化时,检查是否是:

  • 嵌套对象属性变化(需要 deep: true
  • 数组方法修改(直接监听数组)
  • 解构导致响应性丢失(使用函数)

vue3中使用defineModel

2026年1月4日 16:41

是的!从 Vue 3.4 开始,Vue 官方引入了 defineModel() 编译宏(macro),极大简化了组件中实现 v-model 双向绑定的写法,无需手动声明 propsemit,也无需处理 modelValue / update:modelValue 的样板代码。

下面系统讲解 defineModel() 的使用方式、原理、优势和注意事项。


一、基础用法:替代 modelValue + emit

传统写法(Vue 3.0 ~ 3.3)

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

  function update() {
    emit('update:modelValue', props.modelValue + 1)
  }
</script>

<template>
  <div>{{ props.modelValue }}</div>
  <button @click="update">+</button>
</template>

Vue 3.4+ 新写法: defineModel()

<!-- Child.vue -->
<script setup>
  const model = defineModel() // 返回一个 ref

  function update() {
    model.value++ // 直接修改,自动同步到父组件
  }
</script>

<template>
  <div>Parent bound v-model is: {{ model }}</div>
  <button @click="update">Increment</button>
</template>

父组件完全不变:

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

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

model 是一个 双向绑定的 ref

  • 读取 model.value → 获取父组件传入的值
  • 修改 model.value → 自动触发 update:modelValue,更新父组件数据

二、支持带参数的 v-model (多模型绑定)

Vue 支持多个 v-model,例如:

<Parent>
  <Child v-model:name="name" v-model:age="age" />
</Parent>

使用 defineModel 实现:

<!-- Child.vue -->
<script setup>
  const name = defineModel('name')
  const age = defineModel('age')

  // 或者用对象形式(可选)
  // const { name, age } = defineModels({ name: String, age: Number })
</script>

<template>
  <input v-model="name" />
  <input v-model.number="age" />
</template>

注意:defineModel('propName') 会自动对应 v-model:propName


三、类型与默认值(TypeScript / 运行时校验)

1. 指定类型(TypeScript)

const model = defineModel<string>()
// model.value 类型为 string | undefined

2. 设置默认值

const model = defineModel({ default: 'hello' })

3. 运行时校验 + 默认值

const model = defineModel({
  type: String,
  required: false,
  default: 'default text'
})

💡 这些选项会自动转换为等效的 ****props ****声明,由 Vue 编译器处理。


四、与 useAttrs() 协同工作

虽然 defineModel() 自动处理了 modelValue,但其他属性仍需透传:

<script setup>
  const model = defineModel()
  // 如果有多个根节点,或想控制透传位置,才需要 useAttrs
</script>

<template>
  <!-- 单根节点:自动透传 attrs(包括 class/style/@focus 等) -->
  <input v-model="model" />
</template>

如果组件有多个根节点,必须手动使用 v-bind="$attrs",否则 Vue 会警告。


五、总结:为什么推荐 defineModel()

对比项 传统方式 defineModel()
代码量 多(props + emits) 极简(一行)
易错性 容易拼错 update:modelValue 零错误
可读性 逻辑分散 聚焦数据流
封装效率 高(尤其包装原生元素)
TypeScript 支持 需手动标注 自动推导

一句话
defineModel() ****让组件的双向绑定回归“直觉”——就像操作本地状态一样简单,却能自动同步到父组件。


前端密码验证详解:Vue3+Element Plus 方案/纯血Vue 3 方案:从语法解析到实战实现

2026年1月4日 10:36
在现代 Web 应用开发中,用户密码的安全性是至关重要的。一个健壮的密码验证系统不仅能保护用户账户安全,还能提升用户体验。本文将结合实际代码,详细介绍如何在 Vue 3 + Element Plus
❌
❌