Vue3 进阶指南:TSX组件封装与冷门API实战

更新日期: 2026-04-02 阅读: 5 标签: 组件

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 }
}
tsx
// 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。代码量更少,意图更清晰,类型更安全。

技术的进化方向,从来不是"更多功能",而是"更少代码,更清晰的意图"。

本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!

链接: https://fly63.com/article/detial/13550

相关推荐

vue重新渲染组件(重置或者更新)

当数据通过异步操作后,对之前加载的数据进行变更后,发现数据不生效。A组件或者B组件触发数据更新,C组件数据更新了,但是C组件仍显示上一次数据。

Vuetify基于vue2.0,为移动而生的组件框架

Vuetify 支持SSR(服务端渲染),SPA(单页应用程序),PWA(渐进式Web应用程序)和标准HTML页面。 Vuetify是一个渐进式的框架,试图推动前端开发发展到一个新的水平。

React高阶组件中使用React.forwardRef的技巧

之前使用React.forwardRef始终无法应用于React高阶组件中,关键点就是React.forwardRef的API中ref必须指向dom元素而不是React组件。codepen实例请划到底部。

Vue使用Props绑定Object并且传参

通过Props 给子组件传变量,变量是对象时,子组件无法在首次打开时获取到传入对象数据,并且在父组件中改变对象的属性,子组件也是无法监听

Vue中插槽的作用_Vue组件插槽的使用以及调用组件内的方法

通过给组件传递参数, 可以让组件变得更加可扩展, 组件内使用props接收参数,slot的使用就像它的名字一样, 在组件内定义一块空间。在组件外, 我们可以往插槽里填入任何元素。slot-scope的作用就是把组件内的数据带出来

React Hook父组件获取子组件的数据/函数

我们知道在react中,常用props实现子组件数据到父组件的传递,但是父组件调用子组件的功能却不常用。文档上说ref其实不是最佳的选择,但是想着偷懒不学redux,在网上找了很多教程,要不就是hook的讲的太少

使用Vue 自定义文件选择器组件

文件选择元素是web上最难看的 input 类型之一。它们在每个浏览器中实现的方式不同,而且通常非常难看。这里有一个解决办法,就是把它封装成一个组件。

element-ui 的隐藏滚动组件el-scrollbar

为什么要用el-scrollbar,大家都知道,模拟一个滚动不难,而且市面上有很多这样的库。我考虑的,首先项目用的框架是Vue,然后用的组件库是Element,Element官网也有很多滚动

vue中prop属性传值解析

prop的定义:在没有状态管理机制的时候,prop属性是组件之间主要的通信方式,prop属性其实是一个对象,在这个对象里可以定义一些数据,而这些数据可以通过父组件传递给子组件。 prop属性中可以定义属性的类型,也可以定义属性的初始值。

写一个vue组件库_跟着element学习写组件

组件以插件的形式引入使用,当然,也可以直接在页面引入组件文件,两者按需使用。通过源码可知,vue不会重复安装同一个插件。以第一次安装为准,现在,可以在代码中使用组件啦~

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!