1. 为什么选择TSX?Vue3开发的新思路
第一次在Vue项目里看到TSX语法时,我和很多开发者一样充满疑惑:明明有现成的模板语法,为什么还要用这种看起来像React的写法?直到在一个大型后台管理系统项目中尝试迁移后,我才真正体会到TSX的价值。
TSX最大的优势在于类型安全和逻辑表达的自由度。在传统的单文件组件(SFC)中,模板和逻辑是分离的,这导致类型检查只能在script部分生效。而在TSX中,整个组件就是一个TypeScript函数,模板部分也能享受完整的类型提示。比如下面这个简单的用户列表组件:
interface User {
id: number
name: string
avatar: string
}
const UserList = defineComponent({
setup() {
const users = ref<User[]>([
{ id: 1, name: '张三', avatar: '/avatar1.jpg' },
{ id: 2, name: '李四', avatar: '/avatar2.jpg' }
])
return () => (
<div class="user-list">
{users.value.map(user => (
<UserItem
key={user.id}
name={user.name}
avatar={user.avatar}
/>
))}
</div>
)
}
})
在这个例子中,我们定义了一个User接口,然后在组件中使用时,无论是users数组还是UserItem组件的props都能获得完整的类型检查。如果尝试传递一个不符合User类型的对象,TypeScript会在编译时就报错,而不是等到运行时才发现问题。
另一个实际优势是逻辑与视图的更好结合。在复杂组件中,经常需要根据数据状态决定渲染内容。在SFC中,这通常需要在模板中使用大量v-if/v-else,或者在script中定义复杂的计算属性。而在TSX中,你可以直接用JavaScript表达式:
const ComplexComponent = defineComponent({
setup() {
const state = reactive({
isLoading: true,
data: null as DataType | null,
error: null as Error | null
})
fetchData().then(
data => {
state.data = data
state.isLoading = false
},
error => {
state.error = error
state.isLoading = false
}
)
return () => {
if (state.isLoading) {
return <LoadingSpinner />
}
if (state.error) {
return <ErrorMessage error={state.error} />
}
return <DataDisplay data={state.data!} />
}
}
})
这种写法让组件的逻辑流更加清晰,不需要在模板和script之间来回跳转就能理解整个组件的运行逻辑。特别是在处理异步数据时,TSX的表达能力明显优于模板语法。
2. 模板指令的TSX迁移指南
2.1 条件渲染:从v-if到JSX表达式
在模板语法中,我们习惯使用v-if/v-else来做条件渲染。但在TSX中,v-if指令是不支持的,需要改用JavaScript的条件表达式。这看起来是个限制,实际上却提供了更灵活的渲染控制方式。
最简单的替代方案是三元表达式。比如原来这样的模板:
<template>
<div>
<p v-if="isAdmin">管理员面板</p>
<p v-else>普通用户面板</p>
</div>
</template>
在TSX中可以改写为:
const UserPanel = defineComponent({
setup() {
const isAdmin = ref(false)
return () => (
<div>
{isAdmin.value
? <p>管理员面板</p>
: <p>普通用户面板</p>
}
</div>
)
}
})
对于更复杂的条件逻辑,可以使用立即执行函数(IIFE):
const ComplexCondition = defineComponent({
setup() {
const user = reactive({
role: 'editor',
status: 'active'
})
return () => (
<div>
{(() => {
if (user.role === 'admin') {
return <AdminPanel />
} else if (user.role === 'editor' && user.status === 'active') {
return <EditorPanel />
} else {
return <GuestPanel />
}
})()}
</div>
)
}
})
这种方式虽然看起来比v-if冗长,但在处理复杂条件时实际上更清晰,特别是当条件分支很多时,IIFE的结构比一连串的v-if/v-else-if更容易维护。
2.2 列表渲染:从v-for到数组map
v-for是Vue模板中最常用的指令之一,在TSX中我们需要用JavaScript的数组map方法来替代。这种转换不仅更符合JavaScript的惯用法,还能更好地利用TypeScript的类型系统。
一个常见的用户列表例子:
const UserList = defineComponent({
setup() {
const users = ref([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
return () => (
<ul>
{users.value.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
})
这里有几个需要注意的点:
- key属性:和v-for一样,列表项仍然需要唯一的key,只是现在作为普通的JSX属性传递
- 类型推断:如果users有明确的类型定义,map回调中的user参数会自动获得正确的类型提示
- 复杂结构:可以在map回调中返回任意JSX结构,不受模板语法的限制
对于需要同时访问索引的情况,map回调的第二个参数就是当前项的索引:
{items.value.map((item, index) => (
<div key={item.id}>
<span>#{index + 1}</span>
<span>{item.name}</span>
</div>
))}
2.3 显示/隐藏控制:v-show的TSX实现
v-show在TSX中的实现是最简单的,因为它本质上就是一个style.display的切换。在TSX中,我们可以直接用JavaScript的逻辑与(&&)运算符来实现类似效果:
const ToggleMessage = defineComponent({
setup() {
const isVisible = ref(false)
return () => (
<div>
<button onClick={() => isVisible.value = !isVisible.value}>
切换显示
</button>
{isVisible.value && <p>这段文字会显示/隐藏</p>}
</div>
)
}
})
注意这和v-if的区别:当条件为false时,JSX的&&运算符不会渲染元素,相当于v-if;而v-show总是会渲染元素,只是切换display样式。如果需要完全模拟v-show的行为,可以这样写:
<p style={{ display: isVisible.value ? 'block' : 'none' }}>
这段文字会通过display属性显示/隐藏
</p>
2.4 属性绑定:v-bind的替代方案
在TSX中,v-bind指令不再需要,所有属性都通过JSX的属性语法直接绑定。这实际上让代码更加简洁明了。
静态属性和动态属性的对比:
const ImageComponent = defineComponent({
setup() {
const imageUrl = ref('/default.jpg')
const altText = ref('默认图片')
return () => (
<div>
{/* 静态属性 */}
<img src="/static/logo.png" alt="Logo" />
{/* 动态属性 */}
<img
src={imageUrl.value}
alt={altText.value}
class="responsive-image"
/>
</div>
)
}
})
对于需要绑定多个属性的情况,可以使用展开运算符:
const user = reactive({
id: 1,
name: '张三',
avatar: '/avatar1.jpg',
role: 'admin'
})
return () => (
<UserProfile {...user} />
)
这相当于把user对象的每个属性都作为单独的prop传递给UserProfile组件,非常简洁高效。
2.5 事件处理:从v-on到JSX事件
事件处理是交互式组件的核心功能,在TSX中,事件监听器的写法与React类似,使用on+事件名的驼峰形式。
基本的事件绑定:
const ClickDemo = defineComponent({
setup() {
const handleClick = (event: MouseEvent) => {
console.log('点击事件', event)
}
return () => (
<button onClick={handleClick}>点击我</button>
)
}
})
如果需要传递额外参数,可以使用箭头函数或bind方法:
const ListItem = defineComponent({
setup() {
const items = ref(['苹果', '香蕉', '橙子'])
const handleItemClick = (item: string, index: number, event: MouseEvent) => {
console.log(`点击了第${index + 1}项: ${item}`, event)
}
return () => (
<ul>
{items.value.map((item, index) => (
<li
key={index}
onClick={(e) => handleItemClick(item, index, e)}
>
{item}
</li>
))}
</ul>
)
}
})
需要注意的是,TSX中不支持Vue模板的事件修饰符(如.stop、.prevent等)。这些功能需要手动实现:
const handleSubmit = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
// 提交逻辑
}
return () => (
<form onSubmit={handleSubmit}>
{/* 表单内容 */}
</form>
)
3. 组件通信的TSX实现
3.1 Props:父子组件数据传递
在TSX中定义props时,我们可以充分利用TypeScript的类型系统来明确组件接口。这是TSX相比模板语法的一大优势。
定义带props的组件:
interface ButtonProps {
type?: 'primary' | 'default' | 'danger'
size?: 'small' | 'medium' | 'large'
disabled?: boolean
onClick?: (event: MouseEvent) => void
}
const Button = defineComponent({
props: {
type: {
type: String as PropType<ButtonProps['type']>,
default: 'default'
},
size: {
type: String as PropType<ButtonProps['size']>,
default: 'medium'
},
disabled: Boolean,
onClick: Function as PropType<ButtonProps['onClick']>
},
setup(props) {
return () => (
<button
class={[
'btn',
`btn-${props.type}`,
`btn-${props.size}`
]}
disabled={props.disabled}
onClick={props.onClick}
>
<slot />
</button>
)
}
})
使用这个Button组件时,TypeScript会检查传入的props是否符合定义的类型:
const App = defineComponent({
setup() {
const handleClick = () => console.log('按钮点击')
return () => (
<div>
{/* 正确的使用方式 */}
<Button
type="primary"
onClick={handleClick}
>
提交
</Button>
{/* TypeScript会报错:type只能是特定值 */}
<Button type="warning">
警告
</Button>
</div>
)
}
})
3.2 自定义事件:子到父通信
在TSX中发射自定义事件与模板语法有些不同,我们需要使用setup函数的第二个参数context来访问emit方法。
子组件发射事件:
interface Emits {
(e: 'update:value', value: string): void
(e: 'submit', payload: { value: string; isValid: boolean }): void
}
const SearchInput = defineComponent({
emits: {
'update:value': (value: string) => typeof value === 'string',
'submit': (payload: { value: string; isValid: boolean }) =>
typeof payload.value === 'string' &&
typeof payload.isValid === 'boolean'
},
setup(props, { emit }) {
const inputValue = ref('')
const handleInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value
inputValue.value = value
emit('update:value', value)
}
const handleSubmit = () => {
emit('submit', {
value: inputValue.value,
isValid: inputValue.value.length > 0
})
}
return () => (
<div class="search-box">
<input
type="text"
value={inputValue.value}
onInput={handleInput}
/>
<button onClick={handleSubmit}>
搜索
</button>
</div>
)
}
})
父组件监听事件:
const SearchPage = defineComponent({
setup() {
const searchValue = ref('')
const handleValueUpdate = (value: string) => {
searchValue.value = value
}
const handleSearch = (payload: { value: string; isValid: boolean }) => {
if (payload.isValid) {
fetchResults(payload.value)
}
}
return () => (
<div>
<h1>搜索页面</h1>
<SearchInput
onUpdate:value={handleValueUpdate}
onSubmit={handleSearch}
/>
{/* 搜索结果列表 */}
</div>
)
}
})
3.3 插槽:灵活的内容分发
TSX中的插槽实现与模板语法有所不同,但同样强大。Vue 3在setup函数中提供了slots对象来访问插槽内容。
基本插槽示例:
const Card = defineComponent({
setup(props, { slots }) {
return () => (
<div class="card">
{slots.default?.()}
</div>
)
}
})
// 使用
const App = defineComponent({
setup() {
return () => (
<Card>
<h2>卡片标题</h2>
<p>卡片内容</p>
</Card>
)
}
})
具名插槽和作用域插槽:
const UserProfile = defineComponent({
setup(props, { slots }) {
const user = reactive({
name: '张三',
age: 28,
role: 'developer'
})
return () => (
<div class="profile">
{slots.header?.()}
<div class="profile-content">
{slots.default?.({
user,
isAdmin: user.role === 'admin'
})}
</div>
{slots.footer?.()}
</div>
)
}
})
// 使用
const App = defineComponent({
setup() {
return () => (
<UserProfile>
{{
header: () => <h2>用户信息</h2>,
default: ({ user, isAdmin }) => (
<div>
<p>姓名: {user.name}</p>
<p>年龄: {user.age}</p>
{isAdmin && <p>管理员权限</p>}
</div>
),
footer: () => <div class="actions">
<button>编辑</button>
</div>
}}
</UserProfile>
)
}
})
4. 迁移实战:一个完整组件的TSX重构
让我们通过一个实际的例子,将一个使用模板语法的Vue组件逐步重构为TSX实现。这是一个任务列表组件,包含以下功能:
- 显示任务列表
- 支持任务筛选
- 可以标记任务完成状态
- 可以删除任务
4.1 原始模板实现
<template>
<div class="task-manager">
<div class="filters">
<button
v-for="filter in filters"
:key="filter"
@click="currentFilter = filter"
:class="{ active: currentFilter === filter }"
>
{{ filter }}
</button>
</div>
<ul class="task-list">
<li
v-for="task in filteredTasks"
:key="task.id"
:class="{ completed: task.completed }"
>
<input
type="checkbox"
v-model="task.completed"
/>
<span>{{ task.text }}</span>
<button @click="removeTask(task.id)">
删除
</button>
</li>
</ul>
<div class="add-task">
<input
v-model="newTaskText"
@keyup.enter="addTask"
placeholder="添加新任务"
/>
<button @click="addTask">
添加
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Task {
id: number
text: string
completed: boolean
}
const filters = ['全部', '进行中', '已完成'] as const
type Filter = typeof filters[number]
const tasks = ref<Task[]>([
{ id: 1, text: '学习Vue 3', completed: false },
{ id: 2, text: '迁移项目到TSX', completed: true }
])
const newTaskText = ref('')
const currentFilter = ref<Filter>('全部')
const filteredTasks = computed(() => {
switch (currentFilter.value) {
case '进行中':
return tasks.value.filter(t => !t.completed)
case '已完成':
return tasks.value.filter(t => t.completed)
default:
return tasks.value
}
})
function addTask() {
if (!newTaskText.value.trim()) return
tasks.value.push({
id: Date.now(),
text: newTaskText.value.trim(),
completed: false
})
newTaskText.value = ''
}
function removeTask(id: number) {
tasks.value = tasks.value.filter(t => t.id !== id)
}
</script>
4.2 TSX重构步骤
第一步:创建组件骨架
import { defineComponent, ref, computed } from 'vue'
interface Task {
id: number
text: string
completed: boolean
}
const filters = ['全部', '进行中', '已完成'] as const
type Filter = typeof filters[number]
const TaskManager = defineComponent({
setup() {
// 状态定义
const tasks = ref<Task[]>([
{ id: 1, text: '学习Vue 3', completed: false },
{ id: 2, text: '迁移项目到TSX', completed: true }
])
const newTaskText = ref('')
const currentFilter = ref<Filter>('全部')
// 计算属性
const filteredTasks = computed(() => {
switch (currentFilter.value) {
case '进行中':
return tasks.value.filter(t => !t.completed)
case '已完成':
return tasks.value.filter(t => t.completed)
default:
return tasks.value
}
})
// 方法
const addTask = () => {
if (!newTaskText.value.trim()) return
tasks.value.push({
id: Date.now(),
text: newTaskText.value.trim(),
completed: false
})
newTaskText.value = ''
}
const removeTask = (id: number) => {
tasks.value = tasks.value.filter(t => t.id !== id)
}
// 返回渲染函数
return () => (
<div class="task-manager">
{/* 这里将填充JSX内容 */}
</div>
)
}
})
export default TaskManager
第二步:实现过滤器按钮
// 在返回的JSX中添加
<div class="filters">
{filters.map(filter => (
<button
key={filter}
class={{ active: currentFilter.value === filter }}
onClick={() => currentFilter.value = filter}
>
{filter}
</button>
))}
</div>
第三步:实现任务列表
<ul class="task-list">
{filteredTasks.value.map(task => (
<li
key={task.id}
class={{ completed: task.completed }}
>
<input
type="checkbox"
checked={task.completed}
onChange={() => task.completed = !task.completed}
/>
<span>{task.text}</span>
<button onClick={() => removeTask(task.id)}>
删除
</button>
</li>
))}
</ul>
第四步:实现添加任务功能
<div class="add-task">
<input
value={newTaskText.value}
onInput={(e) => newTaskText.value = (e.target as HTMLInputElement).value}
onKeyup={(e) => e.key === 'Enter' && addTask()}
placeholder="添加新任务"
/>
<button onClick={addTask}>
添加
</button>
</div>
4.3 完整TSX实现
import { defineComponent, ref, computed } from 'vue'
interface Task {
id: number
text: string
completed: boolean
}
const filters = ['全部', '进行中', '已完成'] as const
type Filter = typeof filters[number]
const TaskManager = defineComponent({
setup() {
// 状态定义
const tasks = ref<Task[]>([
{ id: 1, text: '学习Vue 3', completed: false },
{ id: 2, text: '迁移项目到TSX', completed: true }
])
const newTaskText = ref('')
const currentFilter = ref<Filter>('全部')
// 计算属性
const filteredTasks = computed(() => {
switch (currentFilter.value) {
case '进行中':
return tasks.value.filter(t => !t.completed)
case '已完成':
return tasks.value.filter(t => t.completed)
default:
return tasks.value
}
})
// 方法
const addTask = () => {
if (!newTaskText.value.trim()) return
tasks.value.push({
id: Date.now(),
text: newTaskText.value.trim(),
completed: false
})
newTaskText.value = ''
}
const removeTask = (id: number) => {
tasks.value = tasks.value.filter(t => t.id !== id)
}
// 返回渲染函数
return () => (
<div class="task-manager">
<div class="filters">
{filters.map(filter => (
<button
key={filter}
class={{ active: currentFilter.value === filter }}
onClick={() => currentFilter.value = filter}
>
{filter}
</button>
))}
</div>
<ul class="task-list">
{filteredTasks.value.map(task => (
<li
key={task.id}
class={{ completed: task.completed }}
>
<input
type="checkbox"
checked={task.completed}
onChange={() => task.completed = !task.completed}
/>
<span>{task.text}</span>
<button onClick={() => removeTask(task.id)}>
删除
</button>
</li>
))}
</ul>
<div class="add-task">
<input
value={newTaskText.value}
onInput={(e) => newTaskText.value = (e.target as HTMLInputElement).value}
onKeyup={(e) => e.key === 'Enter' && addTask()}
placeholder="添加新任务"
/>
<button onClick={addTask}>
添加
</button>
</div>
</div>
)
}
})
export default TaskManager
4.4 迁移后的优势分析
- 类型安全:整个组件现在都有完整的类型检查,包括props、事件、模板表达式等
- 逻辑组织更灵活:不再受限于模板和script的分离,可以把相关逻辑组织在一起
- 更好的IDE支持:JSX在TypeScript环境中有更好的自动完成和错误检查
- 复用性提升:渲染逻辑可以更容易地提取为独立的函数或自定义Hook
- 更一致的编码风格:整个项目可以统一使用TypeScript的语法和工具链
在实际项目中,从模板迁移到TSX可能会遇到一些挑战,特别是对于大型复杂组件。建议采取渐进式迁移策略,先从简单的展示组件开始,逐步过渡到复杂的交互组件。

584

被折叠的 条评论
为什么被折叠?



