Vue3 + TS 样例基础
概览
Vue3 的核心
- 响应式系统升级为 Proxy 驱动
- 组合式 API 成为主流组织方式
- 内置能力增强例如 Teleport Suspense
- 组件支持多根节点 Fragment 语义
以下内容以 <script setup lang="ts"> 为主。
1. 响应式系统
1.1 ref 与 reactive
ref 适合原始值与单值状态。 reactive 适合对象结构。
<script setup lang="ts">
import { ref, reactive } from 'vue'
const count = ref<number>(0)
interface UserState {
name: string
age: number
tags: string[]
}
const user = reactive<UserState>({
name: 'kuro',
age: 12,
tags: ['vue', 'ts']
})
function addTag(tag: string): void {
// tag 被限制为 string,传入非字符串会报错
// user.tags 被推断为 string[]
// push 的参数也会被限制为 string
user.tags.push(tag)
}
</script>- 模板里
ref自动解包。 - 脚本里访问
ref需要.value。 reactive返回代理对象,不等于原对象。
1.2 computed 缓存特性
<script setup lang="ts">
import { ref, computed } from 'vue'
const price = ref(199)
const count = ref(2)
const total = computed<number>(() => price.value * count.value)
const summary = computed({
get: () => `总价 ${total.value}`,
set: (v: string) => {
// setter 参数显式约束为 string
// 这里先做格式清洗
// 再写回响应式源 price
const n = Number(v.replace(/\D/g, ''))
if (!Number.isNaN(n)) price.value = n
}
})
</script>对比方法。 computed 会基于依赖缓存。 方法每次渲染都会执行。 当计算逻辑偏重时,computed 能明显减少重复计算。 而方法更适合一次性动作,不适合承载高频派生状态。
1.3 watch 与 watchEffect
watch 更精确。 watchEffect 更直接。
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'
const keyword = ref('')
const loading = ref(false)
const result = ref<string[]>([])
watch(
keyword,
async (v) => {
// v 是 keyword 当前值
// 类型由 ref 自动推断为 string
if (!v.trim()) {
result.value = []
return
}
loading.value = true
try {
await new Promise(r => setTimeout(r, 200))
result.value = [`${v}-1`, `${v}-2`]
} finally {
loading.value = false
}
},
{ immediate: true }
)
const a = ref(1)
const b = ref(2)
watchEffect(() => {
console.log('sum =', a.value + b.value)
})
</script>这里需要注意两件事。
- 深度侦听有性能成本。
- 侦听对象属性时应传 getter。
watch适合做“明确依赖 -> 明确副作用”的流程。watchEffect适合快速联动,但依赖过多时要防止副作用扩散。
2. Composition API 组织方式
2.1 <script setup> 与类型推导
<script setup lang="ts">
import { ref } from 'vue'
const localCount = ref(0)
function increment(step = 1): void {
localCount.value += step
}
</script>
<template>
<button @click="increment()">{{ localCount }}</button>
</template>顶层变量与函数可直接被模板使用。 在项目里这能减少 setup return 的样板代码。
2.2 Props 与 Emits 类型声明
<script setup lang="ts">
interface Props {
title: string
pageSize?: number
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 10
})
const emit = defineEmits<{
change: [page: number]
reset: []
}>()
function onNext(page: number): void {
// 事件名写错或参数类型不对,都会被即时提示
// 这里 page 只能是 number
emit('change', page)
}
</script>这类写法在组件层面的价值很直接。
- 调用点有参数补全。
- 事件名拼写错误会被拦截。
- 对组件作者来说,
Props和Emits本身也成了可维护文档。 - 对组件使用方来说,传参和监听事件的成本会更低。
2.3 组合函数 composable
跨组件复用逻辑时。 组合函数是首选。
// useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initial = 0) {
const count = ref<number>(initial)
// computed 会追踪 count.value
// count 变化时 isEven 自动更新
const isEven = computed<boolean>(() => count.value % 2 === 0)
function inc(): void {
count.value += 1
}
function dec(): void {
count.value -= 1
}
return { count, isEven, inc, dec }
}这类 composable 的关键是: 逻辑被提取后仍保留完整类型关系。 复用时不会丢失参数提示与返回提示。
3. 生命周期与副作用清理
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const online = ref<boolean>(navigator.onLine)
function updateOnline(): void {
online.value = navigator.onLine
}
onMounted(() => {
// 挂载后再注册监听
// 避免服务端环境访问 window
window.addEventListener('online', updateOnline)
window.addEventListener('offline', updateOnline)
})
onUnmounted(() => {
// 卸载前做清理
// 防止重复绑定
window.removeEventListener('online', updateOnline)
window.removeEventListener('offline', updateOnline)
})
</script>副作用与清理写在同一模块。 比选项式分散写法更集中。 尤其在事件绑定、定时器、订阅场景里。 更容易避免遗漏清理导致的内存泄漏。
4. Teleport
Teleport 用来把模板片段渲染到组件外层。 常见于弹窗 抽屉 全局提示。
<script setup lang="ts">
import { ref } from 'vue'
const open = ref(false)
</script>
<template>
<button @click="open = true">打开弹窗</button>
<Teleport to="body">
<!-- Teleport 只改挂载位置 -->
<!-- 响应式依赖关系不变 -->
<div v-if="open" class="modal-mask" @click="open = false">
<div class="modal" @click.stop>
<h3>kuro modal</h3>
<button @click="open = false">关闭</button>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: grid;
place-items: center;
}
.modal {
width: 360px;
padding: 16px;
border-radius: 10px;
background: #fff;
}
</style>- 仅改变 DOM 挂载位置。
- 组件逻辑父子关系保持不变。
- 目标节点需要先存在。
- 对弹窗、抽屉、全局浮层这类组件来说。
5. Suspense
Suspense 用来协调异步依赖。 默认槽放真实内容。 fallback 槽放加载态。
<template>
<Suspense>
<AsyncPanel />
<template #fallback>
<div class="loading">加载中...</div>
</template>
</Suspense>
</template><!-- AsyncPanel.vue -->
<script setup lang="ts">
interface PanelData {
title: string
count: number
}
const data = await new Promise<PanelData>((resolve) => {
setTimeout(() => {
// resolve 数据不符合 PanelData 时会报错
// 这里 title 必须是 string
// 这里 count 必须是 number
resolve({ title: 'kuro dashboard', count: 42 })
}, 600)
})
</script>
<template>
<section>
<h3>{{ data.title }}</h3>
<p>{{ data.count }}</p>
</section>
</template>Suspense 仍是实验性特性。 升级版本时建议关注变更。 如果页面存在多个异步子组件。 统一在上层处理 fallback,视觉上会更稳定。
6. Fragment 多根节点
Vue3 组件支持多个根节点。
<template>
<header>Header</header>
<main>Main</main>
<footer>Footer</footer>
</template>6.1 与 $attrs 透传的关系
多根组件不会自动决定透传到哪个根。 需要显式绑定。
<script setup lang="ts">
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>
<template>
<header>Header</header>
<!-- 多根节点场景下,attrs 需要显式决定绑定位置 -->
<!-- 否则 Vue 无法判断透传到哪个根 -->
<main v-bind="attrs">Main</main>
<footer>Footer</footer>
</template>这里还需要特别注意。 attrs 本身不是响应式对象。 如果场景需要响应式联动,优先通过 props 建立显式契约。 多根节点下显式绑定 v-bind="attrs" 也能避免运行时警告。
7. 常见 TS 写法片段
7.1 为 DOM 事件标注类型
function onInput(e: Event): void {
// e.target 在 DOM 标准里可能是 null
const target = e.target as HTMLInputElement | null
if (!target) return
// 走到这里 target 已经排除 null
console.log(target.value)
}这段写法主要是为了处理 DOM 事件目标可能为空的情况。 在开启严格模式后,这类防御性判断可以减少很多低级报错。
7.2 为 provide 与 inject 设定类型
import { provide, inject, type InjectionKey, ref, type Ref } from 'vue'
interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
const ThemeKey: InjectionKey<ThemeContext> = Symbol('ThemeKey')
const mode = ref<'light' | 'dark'>('light')
provide(ThemeKey, {
mode,
toggle: () => {
// 这里是字面量联合切换
// 不会出现其他字符串
mode.value = mode.value === 'light' ? 'dark' : 'light'
}
})
const theme = inject(ThemeKey)如果项目里大量使用 provide/inject。 建议始终配合 InjectionKey,避免字符串 key 带来的类型漂移。
7.3 reactive 与解构的边界
直接解构会丢失响应式连接。 需要时使用 toRefs。
import { reactive, toRefs } from 'vue'
const state = reactive({ page: 1, size: 20 })
const { page, size } = toRefs(state)这个细节在状态拆分时非常常见。 一旦直接解构丢失响应式连接,排查起来通常会比较隐蔽。
