普通视图
权限封装不是写个指令那么简单:一次真实项目的反思
如何在Vue中传递函数作为Prop
解析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)
}
打包类型文件
![]()
![]()
对于npx vue-tsc -p tsconfig.web.json --declaration --emitDeclarationOnly --declarationDir dist/types,-p tsconfig.web.json指定要使用的编译配置文件,--declaration 指定生成声明文件,--emitDeclarationOnly表示只生成声明文件不会进行代码编译,--declarationDir dist/types指定了输出目录
然后运行pnpm buid,可以看到生成了对应的类型文件
![]()
重写类型文件
![]()
![]()
主要就是针对路径进行处理,把开发环境的路径处理成了打包之后的路径
重写类型文件之前
重写类型文件之后 ![]()
可以看到这里import的是es文件目录下的类型文件,之前打包的es下面并没有对应的类型啊???其实后面还有处理,会把types的相关类型移动到对应目录下,需要往后面看 copyTypesDefinitions
提升package/element-plus的类型文件到上层
![]()
我们之前打包的配置 preserveModules 值为 true 时,不会有element-plus的目录结构了,下面的文件会提升到上层。
这里就是为了把类型提到上层,使得其和之前的组件打包的结构一致
提升前:![]()
提升后:![]()
copyTypesDefinitions
![]()
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的相关文件下
![]()
JavaScript 数组中删除偶数下标值的多种方法
- 使用 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); // 原数组也被修改
- 使用 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]
- 使用 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]
- 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]
- 性能优化版本(大数据量)
// 方法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
- 原地修改的高效方法
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); // 原数组被修改
- 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>
- 通用工具函数
// 工具函数集合
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的倍数的元素
- 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)
- 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']
- 性能对比测试
// 性能测试
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 最慢
总结
推荐方法:
-
最简洁:
filter方法arr.filter((_, i) => i % 2 !== 0) -
最高性能:
for循环const result = [] for (let i = 1; i < arr.length; i += 2) { result.push(arr[i]) } -
原地修改:使用写指针
let writeIndex = 0 for (let i = 0; i < arr.length; i++) { if (i % 2 !== 0) { arr[writeIndex] = arr[i] writeIndex++ } } arr.length = writeIndex
注意事项:
- 索引从0开始:JavaScript 数组下标从0开始
-
避免splice:在循环中使用
splice会改变数组长度,容易出错 -
性能考虑:大数据量时避免使用
splice和filter链式调用 - 不变性:根据需求选择是否修改原数组
扩展应用:
- 删除奇数下标:
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),这样可以:
- 让当前渲染的数据显示在正确的位置
- 保持滚动条的位置准确
这就像是一个"移动的窗口",窗口内的内容在不断变化,但窗口的位置始终准确。通过动态调整 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:容器高度,决定可视区域大小
总结
这个项目的核心思路很简单:
- 借助 VueUse 的
useVirtualList实现虚拟列表逻辑 - 将虚拟列表的数据转换为 Ant Design Vue Table 需要的格式
- 通过 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:最大缓存数量,超出则销毁最久未使用的组件。
<!-- 仅缓存 Home 和 Profile 组件 -->
<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 的动态组件是提升组件复用性和灵活性的核心工具,核心要点:
- 基础用法:通过
<component :is="组件">实现动态渲染; - 性能优化:使用
<keep-alive>缓存组件,避免重复渲染和状态丢失; - 高级用法:结合异步组件实现按需加载,结合
computed实现复杂逻辑的组件切换; - 避坑指南:注意
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 出现之前,实现父子组件双向绑定需要两步操作:
- 子组件通过
props接收父组件传递的值; - 子组件通过
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>
这种方式虽然可行,但存在明显弊端:代码冗余,每次实现双向绑定都需要重复定义 props 和 emit。而 defineModel 正是为了解决这个问题,它将 props 和 emit 的逻辑封装在一起,让双向绑定的实现更简洁、更直观。
二、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>
可以看到,子组件的代码被大幅简化,无需再手动定义 props 和 emit,直接通过 defineModel 即可实现双向绑定。
2.3 自定义 v-model 名称
默认情况下,defineModel 对应父组件 v-model 的 modelValue 属性和 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)
通过 get 和 set 方法可以对传递的值进行转换处理,类似计算属性的逻辑。例如,我们可以实现一个自动去除空格的输入框:
<!-- 子组件 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>
通过 get 和 set,我们可以在数据传递的过程中对其进行加工,让组件的逻辑更灵活。
四、常见使用场景
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>
五、注意事项
-
Vue 版本要求:
defineModel是 Vue3.4 及以上版本才支持的特性,若项目版本较低,需要先升级 Vue 版本(升级命令:npm update vue)。 -
响应式特性:
defineModel返回的是一个响应式对象,修改其value属性会自动同步到父组件,无需手动触发emit事件。 -
与 defineProps 的关系:
defineModel本质上是对props和emit的封装,因此不能与defineProps定义同名的属性,否则会出现冲突。 -
默认值的特殊性:当
defineModel设置了default值时,若父组件传递了undefined,子组件会使用默认值;若父组件传递了null,则会使用null而不是默认值。 -
服务器端渲染(SSR)兼容性:在 SSR 场景下,
defineModel完全兼容,无需额外处理,因为其底层还是基于props和emit实现的。
六、总结
defineModel 作为 Vue3.4+ 推出的重要特性,极大地简化了父子组件双向绑定的实现逻辑,减少了重复代码,提升了开发效率。它支持自定义名称、默认值、类型校验、值转换等多种进阶功能,能够满足大部分双向绑定场景的需求。
在实际开发中,对于需要双向绑定的组件(如表单组件、UI交互组件等),推荐优先使用 defineModel 替代传统的 props + emit 方式。同时,要注意其版本要求和使用规范,避免出现兼容性问题。
jitword协同AI文档SDK已开源!轻松接入任何后端!
vue3静态文件打包404解决方案
这是OpenTiny与开发者一起写下的2025答卷!
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 的核心:流式传输与跨平台
![]()
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 的渲染机制做一个类比:
![]()
(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) beginRendering ≈ mounted Hook
- Vue 原理: 组件挂载完成,开始展示。
-
A2UI 机制: 控制渲染时机。Agent 可能想先在后台默默把数据和结构都发完,避免用户看到界面“跳变”,最后发一个
beginRendering信号,界面瞬间呈现。
(4) deleteSurface ≈ v-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渲染自定义组件)。
![]()
- 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 setup,Suspense 仍然会捕获这个异步状态,并在 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…
Vue 基础:状态管理入门
Vue3中,我的Watch为什么总监听不到数据?
前言
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) // 任何嵌套属性变化都会触发
})
总结
选择哪种方式主要取决于:
- 监听粒度:需要监听整个对象还是特定属性
- 性能考虑:避免不必要的深度监听
- 数据类型:基本类型用 getter,复杂对象可直接监听
- 变化类型:需要监听引用变化还是值变化
当发现监听不到变化时,检查是否是:
- 嵌套对象属性变化(需要
deep: true) - 数组方法修改(直接监听数组)
- 解构导致响应性丢失(使用函数)
vue3中使用defineModel
是的!从 Vue 3.4 开始,Vue 官方引入了 defineModel() 编译宏(macro),极大简化了组件中实现 v-model 双向绑定的写法,无需手动声明 props 和 emit,也无需处理 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() ****让组件的双向绑定回归“直觉”——就像操作本地状态一样简单,却能自动同步到父组件。