Vue 3 高级特性与 JSX/TSX 开发实战指南

约 11 分钟阅读

引言:解锁 Vue 3 的无限潜能

Vue 3 不仅带来了 Composition API 的革命性变化,还引入了许多高级特性和开发技巧。从 JSX/TSX 的灵活渲染到 defineModeldefineSlots 等新特性,这些工具让我们能够构建更加强大、类型安全且易于维护的应用。

本文将通过实际案例,带你深入了解这些高级特性的使用方法和最佳实践。

1. JSX/TSX 在 Vue 3 中的应用

1.1 配置 JSX/TSX 支持

首先,我们需要配置项目以支持 JSX/TSX:

bash
# 安装必要的依赖
pnpm add @vitejs/plugin-vue-jsx

更新 vite.config.ts

ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'

export default defineConfig({
  plugins: [
    vue(),
    vueJsx(), // 启用 JSX 支持
  ],
})

1.2 基础 JSX 语法

在 Vue 3 中,JSX 提供了更灵活的渲染方式:

tsx
// components/Button.tsx
import { defineComponent, type PropType } from 'vue'

interface ButtonProps {
  type?: 'primary' | 'secondary' | 'danger'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  onClick?: () => void
}

export default defineComponent({
  name: 'Button',
  props: {
    type: {
      type: String as PropType<ButtonProps['type']>,
      default: 'primary'
    },
    size: {
      type: String as PropType<ButtonProps['size']>,
      default: 'md'
    },
    disabled: {
      type: Boolean,
      default: false
    }
  },
  emits: ['click'],
  setup(props, { emit, slots }) {
    const handleClick = () => {
      if (!props.disabled) {
        emit('click')
      }
    }

    return () => (
      <button
        class={[
          'btn',
          `btn-${props.type}`,
          `btn-${props.size}`,
          { 'btn-disabled': props.disabled }
        ]}
        disabled={props.disabled}
        onClick={handleClick}
      >
        {slots.default?.()}
      </button>
    )
  }
})

1.3 条件渲染与列表渲染

JSX 中的条件渲染和列表渲染更接近原生 JavaScript:

tsx
// components/TodoList.tsx
import { defineComponent, ref, computed } from 'vue'

interface Todo {
  id: number
  text: string
  completed: boolean
}

export default defineComponent({
  name: 'TodoList',
  setup() {
    const todos = ref<Todo[]>([
      { id: 1, text: '学习 Vue 3', completed: false },
      { id: 2, text: '掌握 JSX', completed: true },
      { id: 3, text: '构建项目', completed: false }
    ])

    const newTodo = ref('')

    const completedCount = computed(() => 
      todos.value.filter(todo => todo.completed).length
    )

    const addTodo = () => {
      if (newTodo.value.trim()) {
        todos.value.push({
          id: Date.now(),
          text: newTodo.value,
          completed: false
        })
        newTodo.value = ''
      }
    }

    const toggleTodo = (id: number) => {
      const todo = todos.value.find(t => t.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    }

    return () => (
      <div class="todo-container">
        <h2>Todo List ({completedCount.value}/{todos.value.length} 完成)</h2>
        
        <div class="add-todo">
          <input
            v-model={newTodo.value}
            placeholder="添加新任务..."
            onKeypress={(e: KeyboardEvent) => {
              if (e.key === 'Enter') addTodo()
            }}
          />
          <button onClick={addTodo}>添加</button>
        </div>

        <ul class="todo-list">
          {todos.value.map(todo => (
            <li 
              key={todo.id}
              class={['todo-item', { completed: todo.completed }]}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => toggleTodo(todo.id)}
              />
              <span class="todo-text">{todo.text}</span>
              {todo.completed && <span class="badge">✓</span>}
            </li>
          ))}
        </ul>

        {todos.value.length === 0 && (
          <div class="empty-state">
            <p>暂无任务</p>
          </div>
        )}
      </div>
    )
  }
})

2. defineModel - 双向绑定的新方式

defineModel 是 Vue 3.4+ 引入的新特性,简化了组件的双向绑定:

vue
<!-- components/CustomInput.vue -->
<script setup lang="ts">
// 传统方式需要 props + emit
// const props = defineProps<{ modelValue: string }>()
// const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()

// 使用 defineModel 一行搞定
const model = defineModel<string>()

// 支持多个 model
const title = defineModel('title', { default: '' })
const content = defineModel('content', { default: '' })

// 支持修饰符
const lazy = defineModel('lazy', { 
  get(value: string) {
    return value?.trim()
  },
  set(value: string) {
    return value?.toLowerCase()
  }
})
</script>

<template>
  <div class="custom-input">
    <label>基础输入:</label>
    <input v-model="model" placeholder="输入内容..." />
    
    <label>标题:</label>
    <input v-model="title" placeholder="输入标题..." />
    
    <label>内容:</label>
    <textarea v-model="content" placeholder="输入内容..."></textarea>
    
    <label>懒加载 (小写转换):</label>
    <input v-model="lazy" placeholder="输入后失焦查看效果..." />
  </div>
</template>

使用组件:

vue
<!-- ParentComponent.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import CustomInput from './components/CustomInput.vue'

const inputValue = ref('')
const articleTitle = ref('')
const articleContent = ref('')
const lazyValue = ref('')
</script>

<template>
  <div>
    <CustomInput 
      v-model="inputValue"
      v-model:title="articleTitle"
      v-model:content="articleContent"
      v-model:lazy="lazyValue"
    />
    
    <div class="preview">
      <h3>实时预览:</h3>
      <p>基础输入: {{ inputValue }}</p>
      <p>标题: {{ articleTitle }}</p>
      <p>内容: {{ articleContent }}</p>
      <p>懒加载值: {{ lazyValue }}</p>
    </div>
  </div>
</template>

3. defineSlots - 类型安全的插槽

defineSlots 为插槽提供了完整的 TypeScript 支持:

vue
<!-- components/Card.vue -->
<script setup lang="ts">
interface CardSlots {
  default?: (props: { data: any }) => any
  header?: (props: { title: string }) => any
  footer?: (props: { actions: string[] }) => any
  empty?: () => any
}

const slots = defineSlots<CardSlots>()

defineProps<{
  title?: string
  data?: any[]
  loading?: boolean
}>()
</script>

<template>
  <div class="card">
    <!-- 头部插槽 -->
    <header v-if="slots.header" class="card-header">
      <slot name="header" :title="title || '默认标题'" />
    </header>

    <!-- 主要内容 -->
    <main class="card-content">
      <div v-if="loading" class="loading">
        加载中...
      </div>
      <div v-else-if="data && data.length > 0">
        <slot :data="data" />
      </div>
      <div v-else class="empty">
        <slot name="empty">
          <p>暂无数据</p>
        </slot>
      </div>
    </main>

    <!-- 底部插槽 -->
    <footer v-if="slots.footer" class="card-footer">
      <slot name="footer" :actions="['编辑', '删除', '分享']" />
    </footer>
  </div>
</template>

使用带类型检查的插槽:

vue
<!-- UserList.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import Card from './components/Card.vue'

interface User {
  id: number
  name: string
  email: string
  avatar: string
}

const users = ref<User[]>([
  { id: 1, name: 'Alice', email: 'alice@example.com', avatar: '/avatar1.jpg' },
  { id: 2, name: 'Bob', email: 'bob@example.com', avatar: '/avatar2.jpg' }
])

const loading = ref(false)
</script>

<template>
  <Card 
    title="用户列表" 
    :data="users" 
    :loading="loading"
  >
    <!-- 头部插槽 - 自动获得 title 参数的类型检查 -->
    <template #header="{ title }">
      <h2>{{ title }} ({{ users.length }} 位用户)</h2>
      <button @click="loading = !loading">
        {{ loading ? '停止' : '开始' }}加载
      </button>
    </template>

    <!-- 默认插槽 - 自动获得 data 参数的类型检查 -->
    <template #default="{ data }">
      <div class="user-grid">
        <div 
          v-for="user in data" 
          :key="user.id" 
          class="user-card"
        >
          <img :src="user.avatar" :alt="user.name" />
          <h3>{{ user.name }}</h3>
          <p>{{ user.email }}</p>
        </div>
      </div>
    </template>

    <!-- 空状态插槽 -->
    <template #empty>
      <div class="custom-empty">
        <img src="/empty-users.svg" alt="无用户" />
        <p>还没有用户数据</p>
        <button>添加第一个用户</button>
      </div>
    </template>

    <!-- 底部插槽 - 自动获得 actions 参数的类型检查 -->
    <template #footer="{ actions }">
      <div class="actions">
        <button 
          v-for="action in actions" 
          :key="action"
          class="action-btn"
        >
          {{ action }}
        </button>
      </div>
    </template>
  </Card>
</template>

4. 高级 Composition API 技巧

4.1 defineExpose - 组件方法暴露

vue
<!-- components/Modal.vue -->
<script setup lang="ts">
import { ref, nextTick } from 'vue'

const isVisible = ref(false)
const modalRef = ref<HTMLElement>()

const open = async () => {
  isVisible.value = true
  await nextTick()
  modalRef.value?.focus()
}

const close = () => {
  isVisible.value = false
}

const toggle = () => {
  isVisible.value ? close() : open()
}

// 暴露方法给父组件
defineExpose({
  open,
  close,
  toggle,
  isVisible: readonly(isVisible)
})
</script>

<template>
  <Teleport to="body">
    <div 
      v-if="isVisible"
      ref="modalRef"
      class="modal-overlay"
      tabindex="-1"
      @click.self="close"
      @keydown.esc="close"
    >
      <div class="modal-content">
        <slot />
        <button @click="close">关闭</button>
      </div>
    </div>
  </Teleport>
</template>

4.2 自定义 Composable 函数

ts
// composables/useLocalStorage.ts
import { ref, watch, type Ref } from 'vue'

export function useLocalStorage<T>(
  key: string,
  defaultValue: T
): [Ref<T>, (value: T) => void, () => void] {
  const storedValue = localStorage.getItem(key)
  const initialValue = storedValue ? JSON.parse(storedValue) : defaultValue
  
  const state = ref<T>(initialValue)

  const setValue = (value: T) => {
    state.value = value
  }

  const removeValue = () => {
    localStorage.removeItem(key)
    state.value = defaultValue
  }

  // 监听变化并同步到 localStorage
  watch(
    state,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue))
    },
    { deep: true }
  )

  return [state, setValue, removeValue]
}
ts
// composables/useAsyncData.ts
import { ref, computed, type Ref } from 'vue'

interface UseAsyncDataOptions<T> {
  immediate?: boolean
  onSuccess?: (data: T) => void
  onError?: (error: Error) => void
}

export function useAsyncData<T>(
  asyncFn: () => Promise<T>,
  options: UseAsyncDataOptions<T> = {}
) {
  const { immediate = true, onSuccess, onError } = options

  const data = ref<T | null>(null)
  const error = ref<Error | null>(null)
  const loading = ref(false)

  const isReady = computed(() => !loading.value && data.value !== null)
  const isError = computed(() => error.value !== null)

  const execute = async () => {
    try {
      loading.value = true
      error.value = null
      
      const result = await asyncFn()
      data.value = result
      
      onSuccess?.(result)
    } catch (err) {
      error.value = err as Error
      onError?.(err as Error)
    } finally {
      loading.value = false
    }
  }

  const refresh = () => execute()
  const reset = () => {
    data.value = null
    error.value = null
    loading.value = false
  }

  if (immediate) {
    execute()
  }

  return {
    data: data as Ref<T | null>,
    error,
    loading,
    isReady,
    isError,
    execute,
    refresh,
    reset
  }
}

4.3 使用示例

vue
<!-- pages/UserProfile.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import Modal from '@/components/Modal.vue'
import { useLocalStorage } from '@/composables/useLocalStorage'
import { useAsyncData } from '@/composables/useAsyncData'

// 本地存储
const [userPreferences, setUserPreferences] = useLocalStorage('userPreferences', {
  theme: 'light',
  language: 'zh-CN'
})

// 异步数据获取
const { data: userProfile, loading, error, refresh } = useAsyncData(
  () => fetch('/api/user/profile').then(res => res.json()),
  {
    onSuccess: (data) => console.log('用户数据加载成功:', data),
    onError: (err) => console.error('加载失败:', err)
  }
)

// 模态框引用
const modalRef = ref<InstanceType<typeof Modal>>()

const openEditModal = () => {
  modalRef.value?.open()
}

const updateTheme = (theme: string) => {
  setUserPreferences({ ...userPreferences.value, theme })
}
</script>

<template>
  <div class="user-profile">
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">加载失败: {{ error.message }}</div>
    <div v-else-if="userProfile">
      <h1>{{ userProfile.name }}</h1>
      <p>{{ userProfile.email }}</p>
      
      <div class="preferences">
        <h3>偏好设置</h3>
        <label>
          主题:
          <select :value="userPreferences.theme" @change="updateTheme($event.target.value)">
            <option value="light">浅色</option>
            <option value="dark">深色</option>
          </select>
        </label>
      </div>
      
      <button @click="openEditModal">编辑资料</button>
      <button @click="refresh">刷新</button>
    </div>

    <Modal ref="modalRef">
      <h2>编辑用户资料</h2>
      <form>
        <!-- 表单内容 -->
      </form>
    </Modal>
  </div>
</template>

5. 性能优化技巧

5.1 异步组件与懒加载

ts
// router/index.ts
import { defineAsyncComponent } from 'vue'

const router = createRouter({
  routes: [
    {
      path: '/dashboard',
      component: defineAsyncComponent({
        loader: () => import('@/views/Dashboard.vue'),
        loadingComponent: () => h('div', '加载中...'),
        errorComponent: () => h('div', '加载失败'),
        delay: 200,
        timeout: 3000
      })
    }
  ]
})

5.2 KeepAlive 优化

vue
<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <KeepAlive :include="['UserList', 'ProductList']">
      <component 
        :is="Component" 
        :key="route.meta.keepAliveKey || route.fullPath" 
      />
    </KeepAlive>
  </router-view>
</template>

6. 总结

Vue 3 的高级特性为我们提供了更强大、更灵活的开发方式:

  • JSX/TSX 提供了类似 React 的渲染灵活性,同时保持 Vue 的响应式特性
  • defineModel 简化了双向绑定的实现,减少了样板代码
  • defineSlots 为插槽提供了完整的类型安全
  • defineExpose 让组件方法暴露更加明确和可控
  • 自定义 Composables 实现了逻辑复用的最佳实践

这些特性不仅提升了开发效率,还增强了代码的可维护性和类型安全性。在实际项目中,合理运用这些高级特性能够让我们构建出更加健壮和优雅的 Vue 应用。

掌握这些技巧,你将能够充分发挥 Vue 3 的强大潜能,构建出现代化的前端应用。

相关文章