Skip to content

Vue3 + TS 样例基础

概览

Vue3 的核心

  • 响应式系统升级为 Proxy 驱动
  • 组合式 API 成为主流组织方式
  • 内置能力增强例如 Teleport Suspense
  • 组件支持多根节点 Fragment 语义

以下内容以 <script setup lang="ts"> 为主。


1. 响应式系统

1.1 ref 与 reactive

ref 适合原始值与单值状态。 reactive 适合对象结构。

vue
<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 缓存特性

vue
<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 更直接。

vue
<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> 与类型推导

vue
<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 类型声明

vue
<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>

这类写法在组件层面的价值很直接。

  • 调用点有参数补全。
  • 事件名拼写错误会被拦截。
  • 对组件作者来说,PropsEmits 本身也成了可维护文档。
  • 对组件使用方来说,传参和监听事件的成本会更低。

2.3 组合函数 composable

跨组件复用逻辑时。 组合函数是首选。

ts
// 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. 生命周期与副作用清理

vue
<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 用来把模板片段渲染到组件外层。 常见于弹窗 抽屉 全局提示。

vue
<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 槽放加载态。

vue
<template>
	<Suspense>
		<AsyncPanel />

		<template #fallback>
			<div class="loading">加载中...</div>
		</template>
	</Suspense>
</template>
vue
<!-- 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 组件支持多个根节点。

vue
<template>
	<header>Header</header>
	<main>Main</main>
	<footer>Footer</footer>
</template>

6.1 与 $attrs 透传的关系

多根组件不会自动决定透传到哪个根。 需要显式绑定。

vue
<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 事件标注类型

ts
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 设定类型

ts
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

ts
import { reactive, toRefs } from 'vue'

const state = reactive({ page: 1, size: 20 })

const { page, size } = toRefs(state)

这个细节在状态拆分时非常常见。 一旦直接解构丢失响应式连接,排查起来通常会比较隐蔽。


<< 返回首页