Vue3 进阶指南:TSX组件封装与冷门API实战
Vue3发布已经好几年了。大多数人停留在ref、reactive、watch三板斧。这没有错,但只能做到"能用",距离"用好"还差得远。
这篇文章,我想和你聊聊那些真正让代码变得优雅、健壮、可维护的Vue3技法,包括TSX组件封装的实战套路,以及一些很偏但极其好用的API。
一、TSX组件封装:摆脱模板思维的束缚
很多人对Vue3的TSX有误解,以为只是"把template换了种写法"。
这个理解太浅了。
TSX真正的价值,在于把JavaScript的动态能力还给了模板。它让组件的渲染逻辑变成一等公民,可以被函数、数组、条件随意组合。
1.1 用defineComponent + TSX写出有完整类型的组件
很多人用TSX时,随手写一个箭头函数就完了,这会丢失类型。
正确的姿势:
import { defineComponent, ref, PropType } from 'vue'
interface Option {
label: string
value: string | number
}
const SelectGroup = defineComponent({
name: 'SelectGroup',
props: {
options: {
type: Array as PropType<Option[]>,
default: () => []
},
modelValue: {
type: [String, Number],
default: ''
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const handleSelect = (val: string | number) => {
emit('update:modelValue', val)
}
return () => (
<div class="select-group">
{props.options.map(opt => (
<div
key={opt.value}
class={['option', props.modelValue === opt.value && 'active']}
onClick={() => handleSelect(opt.value)}
>
{opt.label}
</div>
))}
</div>
)
}
})
export default SelectGroup注意setup直接返回一个渲染函数,而不是{}数据对象。这是TSX最重要的写法区别。
1.2 动态列渲染:TSX的杀手级场景
表格列配置、Form表单项、动态菜单——凡是"根据配置渲染UI"的场景,TSX碾压template。
interface ColumnConfig {
key: string
title: string
render?: (val: any, row: any) => VNode
}
const DynamicTable = defineComponent({
props: {
columns: Array as PropType<ColumnConfig[]>,
data: Array as PropType<Record<string, any>[]>
},
setup(props) {
return () => (
<table>
<thead>
<tr>
{props.columns?.map(col => (
<th key={col.key}>{col.title}</th>
))}
</tr>
</thead>
<tbody>
{props.data?.map((row, i) => (
<tr key={i}>
{props.columns?.map(col => (
<td key={col.key}>
{col.render ? col.render(row[col.key], row) : row[col.key]}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
})调用方可以把任意VNode注入到列的渲染逻辑里,完全不依赖slot。代码清晰、灵活、可测试。
1.3 高阶组件(HOC)封装:逻辑复用的进阶手法
Composables是推荐的逻辑复用方式,但有一种场景Composables做不到:当你需要增强某个组件的渲染行为,同时对外透明时,高阶组件是最优解。
import { defineComponent, h, ComponentOptions } from 'vue'
function withLoading<T extends ComponentOptions>(WrappedComponent: T) {
return defineComponent({
name: `WithLoading_${WrappedComponent.name}`,
props: {
...WrappedComponent.props,
loading: {
type: Boolean,
default: false
}
},
setup(props, ctx) {
const { loading, ...rest } = props
return () => (
<div class="hoc-wrapper">
{loading && <div class="loading-mask">加载中...</div>}
{h(WrappedComponent, { ...rest, ...ctx.attrs }, ctx.slots)}
</div>
)
}
})
}
// 使用
const LoadingTable = withLoading(MyTable)这个模式在封装业务组件库时非常实用。权限控制、埋点注入、错误边界都可以用这个模式统一处理。
1.4 Renderless组件:把UI和逻辑彻底分离
这是一个Vue社区里用得不多、但极其优雅的模式。组件只负责逻辑,完全不输出DOM,UI全部由外部slot决定。
const UseCounter = defineComponent({
props: {
initial: { type: Number, default: 0 },
min: { type: Number, default: -Infinity },
max: { type: Number, default: Infinity }
},
setup(props, { slots }) {
const count = ref(props.initial)
const inc = () => count.value = Math.min(count.value + 1, props.max)
const dec = () => count.value = Math.max(count.value - 1, props.min)
const reset = () => count.value = props.initial
return () => slots.default?.({ count: count.value, inc, dec, reset })
}
})调用方:
<UseCounter :initial="0" :max="10">
<template #default="{ count, inc, dec }">
<button @click="dec">-</button>
<span>{{ count }}</span>
<button @click="inc">+</button>
</template>
</UseCounter>逻辑层和UI层彻底解耦。写测试、换UI、共享逻辑,全部变得轻而易举。
二、Composables进阶:不止是"抽函数"
很多人对Composables的理解,停留在"把setup里的逻辑抽出来"。这是对的,但不完整。
Composables真正的力量,在于有状态逻辑的封装与组合。它不只是函数,它是有生命周期感知能力的响应式闭包。
2.1 让Composable自动清理副作用
一个常见的错误:在Composable里注册了事件监听,忘了在onUnmounted里清理。
// ❌ 有内存泄漏风险
export function useWindowResize() {
const width = ref(window.innerWidth)
window.addEventListener('resize', () => {
width.value = window.innerWidth
})
return { width }
}
// ✅ 正确写法:在Composable内部管理生命周期
export function useWindowResize() {
const width = ref(window.innerWidth)
const handler = () => { width.value = window.innerWidth }
window.addEventListener('resize', handler)
onUnmounted(() => {
window.removeEventListener('resize', handler)
})
return { width }
}这是Composable和普通函数最核心的区别:它知道自己什么时候应该消亡。
2.2 Composable的参数支持响应式
这一点经常被忽视。如果你的Composable接受参数,要同时支持静态值和响应式引用。
import { MaybeRef, toRef, watchEffect } from 'vue'
export function useFetch<T>(url: MaybeRef<string>) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const urlRef = toRef(url) // 无论是ref还是普通值,统一转为ref
watchEffect(async () => {
if (!urlRef.value) return
loading.value = true
error.value = null
try {
const res = await fetch(urlRef.value)
data.value = await res.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
})
return { data, loading, error }
}这样调用方既可以传字符串,也可以传ref:
const url = ref('/api/user/1')
const { data } = useFetch(url) // url变化,自动重新请求三、那些被低估的Vue3 API
这部分是本文的重点。这些API官方文档里有,但很少被人认真对待。
3.1 effectScope:管理响应式副作用的"总闸"
这个API是为组件库和复杂状态管理场景设计的。
问题场景:你有一堆watch、computed、watchEffect,希望在某个时刻一键全部停止,而不是一个个手动stop。
import { effectScope, ref, watch } from 'vue'
const scope = effectScope()
scope.run(() => {
const count = ref(0)
watch(count, (val) => {
console.log('count changed:', val)
})
// 在这里创建的所有响应式副作用,都属于这个scope
})
// 停止这个scope下的所有副作用
scope.stop()这在封装全局状态、插件或者需要动态销毁的响应式逻辑时极其有用。
Pinia内部就是用effectScope来管理Store的响应式生命周期的。
3.2 customRef:完全掌控响应式的时机
ref的更新是即时的。但有时候你想延迟触发更新、节流更新,或者加入拦截逻辑。这就是customRef的用武之地。
import { customRef } from 'vue'
function useDebouncedRef<T>(value: T, delay = 300) {
let timeout: ReturnType<typeof setTimeout>
return customRef<T>((track, trigger) => {
return {
get() {
track() // 告知Vue:我要追踪依赖
return value
},
set(newValue) {
clearTimeout(timeout)
timeout = setTimeout(() => {
value = newValue
trigger() // 告知Vue:值变了,触发更新
}, delay)
}
}
})
}
// 使用
const searchText = useDebouncedRef('', 500)
// searchText的watcher会在输入停止500ms后才触发用customRef实现防抖ref,比在watch里加setTimeout优雅太多。
3.3 defineModel:告别冗长的v-model样板代码
Vue3.4引入的defineModel,是对props + emit v-model模式的终极简化。
以前你要这样写:
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>现在只需要:
<script setup lang="ts">
const model = defineModel<string>()
</script>
<template>
<input v-model="model" />
</template>defineModel返回一个可直接读写的ref,内部自动处理props和emit的绑定。
多个v-model:
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>代码量减少了70%,意图清晰了200%。
3.4 useTemplateRef:精确获取模板引用
Vue3.5引入的新API,替代原来字符串形式的ref模板引用。
以前:
<script setup>
const inputRef = ref(null)
</script>
<template>
<input ref="inputRef" />
</template>现在:
<script setup>
import { useTemplateRef } from 'vue'
const inputRef = useTemplateRef<HTMLInputElement>('my-input')
</script>
<template>
<input ref="my-input" />
</template>看起来差不多?关键优势在于:useTemplateRef的名字和模板里的字符串是解耦的。你可以在多个Composable里分别获取同一个元素的引用,而不会互相干扰。在大型组件或组件库开发中,这个区别很重要。
3.5 watchEffect的flush选项:控制副作用的执行时机
这个选项知道的人很少,但能解决一类棘手的bug。
watchEffect(() => {
// 你的副作用逻辑
}, {
flush: 'post' // ← 关键
})flush有三个值:
pre(默认):在组件更新前执行
post:在组件更新后执行(可以访问到最新的DOM)
sync:同步执行(几乎不用,性能差)
当你在watchEffect里需要操作DOM时,必须用flush: 'post',否则拿到的是旧DOM。
等价的便捷函数是watchPostEffect:
import { watchPostEffect } from 'vue'
watchPostEffect(() => {
// DOM已更新,可以安全操作
console.log(document.querySelector('.my-el')?.clientHeight)
})3.6 shallowRef与triggerRef:大对象的性能优化
当你的ref包含一个大型对象或数组,Vue的深度响应式追踪会带来不必要的性能开销。
用shallowRef:只追踪.value本身的变化,不深度追踪内部属性。
const bigList = shallowRef<Item[]>([])
// 直接替换整个数组 → 触发更新 ✅
bigList.value = newList
// 修改内部元素 → 不触发更新 ❌(这是预期行为)
bigList.value.push(newItem)如果你必须修改内部状态又想触发更新,用triggerRef手动通知:
bigList.value.push(newItem)
triggerRef(bigList) // 强制触发更新在渲染万级数据的场景下,从ref换成shallowRef,性能提升肉眼可见。
四、一个完整的高级实践:封装一个生产级Modal组件
理论讲了很多,来看一个综合运用的例子。
用TSX + Composable + defineModel封装一个可复用的Modal组件。
// useModal.ts
export function useModal() {
const visible = ref(false)
const open = () => (visible.value = true)
const close = () => (visible.value = false)
const toggle = () => (visible.value = !visible.value)
return { visible, open, close, toggle }
}// Modal.tsx
import { defineComponent, Teleport, Transition } from 'vue'
const Modal = defineComponent({
name: 'Modal',
props: {
title: String,
width: { type: Number, default: 520 }
},
emits: ['update:modelValue'],
setup(props, { slots, emit }) {
const model = defineModel<boolean>()
const close = () => { model.value = false }
return () => (
<Teleport to="body">
<Transition name="modal-fade">
{model.value && (
<div class="modal-overlay" onClick={close}>
<div
class="modal-container"
style={{ width: `${props.width}px` }}
onClick={(e) => e.stopPropagation()}
>
<div class="modal-header">
<span>{props.title}</span>
<button onClick={close}>✕</button>
</div>
<div class="modal-body">
{slots.default?.()}
</div>
{slots.footer && (
<div class="modal-footer">
{slots.footer()}
</div>
)}
</div>
</div>
)}
</Transition>
</Teleport>
)
}
})
export default Modal这个Modal用了:
defineModel管理visible状态
Teleport把Modal渲染到body
Transition添加过渡动画
TSX灵活处理slots存在性判断
完整的TypeScript类型推断
使用起来:
<script setup>
const { visible, open } = useModal()
</script>
<template>
<button @click="open">打开弹窗</button>
<Modal v-model="visible" title="确认操作">
<p>这是弹窗内容</p>
<template #footer>
<button>取消</button>
<button>确认</button>
</template>
</Modal>
</template>总结
回到开头的问题:为什么项目代码越写越乱?不是Vue3不够好,是没有用对工具。
几个结论:
TSX不是template的替代品,是补充。动态渲染、高阶组件、Renderless组件——这三类场景用TSX,其他场景template照样好用。
Composable的本质是"有生命周期感知的函数"。理解这一点,才能写出真正内聚、无副作用泄漏的逻辑层。
Vue3的冷门API是为复杂场景设计的。effectScope、customRef、shallowRef——这些不是花哨的玩具,是工程级问题的标准解法。
defineModel和useTemplateRef是Vue3近两年最值得学的新API。代码量更少,意图更清晰,类型更安全。
技术的进化方向,从来不是"更多功能",而是"更少代码,更清晰的意图"。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!