Vue 3 Composition API实战 - 可复用组件开发技巧
作为一个Vue开发者,从Vue 2升级到Vue 3之后,Composition API给我带来了全新的编程体验。说实话,刚开始接触Composition API的时候,我也有点不习惯,毕竟Options API已经用了很多年。但经过几个项目的实践,我发现Composition API在代码组织和复用方面确实有着天然的优势。
今天我就来和大家分享一下我在实际项目中使用Vue 3 Composition API的一些经验和技巧,希望能对大家有所帮助。
一、初识Composition API
Composition API是Vue 3引入的一大新特性,它让我们能够使用函数的方式组织和复用组件逻辑。相比于Options API的分散式结构,Composition API允许我们将相关的逻辑组织在一起。
export default { data() { return { count: 0, loading: false, error: null, items: [] } }, computed: { doubleCount() { return this.count * 2; } }, methods: { increment() { this.count++; }, async fetchData() { this.loading = true; try { const response = await api.get('/items'); this.items = response.data; } catch (error) { this.error = error; } finally { this.loading = false; } } }, mounted() { this.fetchData(); } }
|
import { ref, computed, onMounted } from 'vue';
export function useCounter() { const count = ref(0); const doubleCount = computed(() => count.value * 2); const increment = () => { count.value++; }; return { count, doubleCount, increment }; }
export function useFetch(url) { const data = ref([]); const loading = ref(false); const error = ref(null); const fetchData = async () => { loading.value = true; try { const response = await fetch(url); data.value = await response.json(); } catch (err) { error.value = err; } finally { loading.value = false; } }; onMounted(fetchData); return { data, loading, error, fetchData }; }
|
二、组合式函数 - 代码复用的核心
组合式函数(Composables)是Composition API的精髓所在,它让我们能够将可复用的逻辑抽取成独立的函数。
1. 基础组合式函数
import { ref, watch } from 'vue';
export function useLocalStorage(key, defaultValue) { const storedValue = ref(localStorage.getItem(key)); const setValue = (value) => { localStorage.setItem(key, value); storedValue.value = value; }; if (storedValue.value === null) { setValue(defaultValue); } window.addEventListener('storage', (event) => { if (event.key === key) { storedValue.value = event.newValue; } }); return [storedValue, setValue]; }
|
使用方式:
import { useLocalStorage } from './composables/useLocalStorage';
export default { setup() { const [theme, setTheme] = useLocalStorage('theme', 'light'); return { theme, setTheme }; } }
|
2. 复杂的组合式函数
import { ref, watchEffect } from 'vue';
export function useAsyncData(apiFn, options = {}) { const data = ref(null); const loading = ref(false); const error = ref(null); const execute = async (params) => { loading.value = true; error.value = null; try { const result = await apiFn(params); data.value = result; } catch (err) { error.value = err; } finally { loading.value = false; } }; if (options.auto !== false) { watchEffect(() => { execute(options.params); }); } return { data, loading, error, execute }; }
|
使用方式:
import { useAsyncData } from './composables/useAsyncData'; import axios from 'axios';
export default { setup() { const { data, loading, error, execute } = useAsyncData( (params) => axios.get('/api/users', { params }), { auto: true, params: { page: 1 } } ); return { data, loading, error, execute }; } }
|
3. 表单处理组合式函数
import { ref, reactive } from 'vue';
export function useForm(initialValues, validationRules) { const form = reactive({ ...initialValues }); const errors = reactive({}); const touched = reactive({}); const validateField = (field) => { if (!validationRules[field]) return true; const rule = validationRules[field]; const value = form[field]; if (rule.required && !value) { errors[field] = rule.message || '此字段为必填项'; return false; } if (rule.pattern && !rule.pattern.test(value)) { errors[field] = rule.message || '格式不正确'; return false; } delete errors[field]; return true; }; const validateForm = () => { let isValid = true; Object.keys(validationRules).forEach(field => { if (!validateField(field)) { isValid = false; } touched[field] = true; }); return isValid; }; const setFieldValue = (field, value) => { form[field] = value; touched[field] = true; validateField(field); }; const resetForm = () => { Object.keys(form).forEach(field => { form[field] = initialValues[field]; errors[field] = undefined; touched[field] = false; }); }; return { form, errors, touched, validateField, validateForm, setFieldValue, resetForm }; }
|
使用方式:
import { useForm } from './composables/useForm';
export default { setup() { const { form, errors, touched, validateForm, setFieldValue, resetForm } = useForm( { username: '', email: '', password: '' }, { username: { required: true, pattern: /^[a-zA-Z0-9_]{4,20}$/, message: '用户名必须是4-20位的字母数字下划线' }, email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '请输入有效的邮箱地址' }, password: { required: true, pattern: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/, message: '密码至少8位,必须包含字母和数字' } } ); const handleSubmit = () => { if (validateForm()) { console.log('Form submitted:', form); } }; return { form, errors, touched, setFieldValue, handleSubmit, resetForm }; } }
|
4. 搜索和分页组合式函数
import { ref, watchEffect } from 'vue';
export function usePaginatedData(fetchFn, options = {}) { const data = ref([]); const loading = ref(false); const error = ref(null); const currentPage = ref(1); const totalItems = ref(0); const totalPages = ref(0); const pagination = computed(() => ({ current: currentPage.value, total: totalPages.value, totalItems: totalItems.value, hasNext: currentPage.value < totalPages.value, hasPrev: currentPage.value > 1 })); const loadData = async (page = currentPage.value, params = {}) => { loading.value = true; error.value = null; try { const response = await fetchFn(page, params); data.value = response.data; totalItems.value = response.total; totalPages.value = Math.ceil(response.total / options.pageSize || 10); } catch (err) { error.value = err; } finally { loading.value = false; } }; const nextPage = () => { if (currentPage.value < totalPages.value) { currentPage.value++; loadData(); } }; const prevPage = () => { if (currentPage.value > 1) { currentPage.value--; loadData(); } }; const goToPage = (page) => { currentPage.value = Math.max(1, Math.min(page, totalPages.value)); loadData(); }; watchEffect(() => { loadData(); }); return { data, loading, error, currentPage, totalItems, totalPages, pagination, loadData, nextPage, prevPage, goToPage }; }
|
使用方式:
import { usePaginatedData } from './composables/usePaginatedData';
export default { setup() { const { data, loading, error, pagination, nextPage, prevPage } = usePaginatedData( (page) => api.get('/api/products', { page, limit: 10 }), { pageSize: 10 } ); return { data, loading, error, pagination, nextPage, prevPage }; } }
|
三、组合式函数的最佳实践
1. 单一职责原则
每个组合式函数应该专注于单一的功能,这样更容易测试和维护。
import { useLocalStorage } from './useLocalStorage'; import { useTheme } from './useTheme';
export function useAppSettings() { const [theme, setTheme] = useLocalStorage('theme', 'light'); const darkMode = computed(() => theme.value === 'dark'); const toggleTheme = () => { const newTheme = darkMode.value ? 'light' : 'dark'; setTheme(newTheme); }; return { theme, darkMode, toggleTheme }; }
export function useAppSettingsBad() { const [theme, setTheme] = useLocalStorage('theme', 'light'); const darkMode = computed(() => theme.value === 'dark'); const [fontSize, setFontSize] = useLocalStorage('fontSize', 'medium'); const [language, setLanguage] = useLocalStorage('language', 'zh-CN'); }
|
2. 类型安全
如果使用TypeScript,可以为组合式函数添加类型定义:
export interface FormField { value: string; touched: boolean; error?: string; }
export interface FormOptions { initialValues: Record<string, any>; validationRules: Record<string, ValidationRule>; }
export interface ValidationRule { required?: boolean; pattern?: RegExp; min?: number; max?: number; message?: string; }
import { ref, reactive } from 'vue'; import type { FormField, FormOptions, ValidationRule } from './types';
export function useForm<T extends Record<string, any>>(options: FormOptions) { const form = reactive({ ...options.initialValues }) as T; const errors = reactive<Record<string, string>>({}); }
|
3. 性能优化
import { computed } from 'vue';
export function useFilteredList(data, filters) { const filteredData = computed(() => { return data.value.filter(item => { return Object.keys(filters).every(key => { const filter = filters[key]; if (!filter) return true; switch (key) { case 'search': return item.name.toLowerCase().includes(filter.toLowerCase()); case 'category': return item.category === filter; case 'price': return item.price <= filter.max && item.price >= filter.min; default: return true; } }); }); }); return { filteredData }; }
|
4. 错误处理
import { ref } from 'vue';
export function useErrorHandler() { const error = ref(null); const handleError = (err) => { console.error('Error:', err); error.value = err; if (process.env.NODE_ENV === 'production') { reportError(err); } }; const clearError = () => { error.value = null; }; return { error, handleError, clearError }; }
|
四、实战案例
1. 可复用的图表组件
import { ref, watch, onMounted, onUnmounted } from 'vue'; import * as echarts from 'echarts';
export function useChart(containerId, options) { const chartInstance = ref(null); const container = ref(null); const initChart = () => { if (container.value) { chartInstance.value = echarts.init(container.value); watch(options, (newOptions) => { if (chartInstance.value) { chartInstance.value.setOption(newOptions); } }, { deep: true }); const resizeObserver = new ResizeObserver(() => { if (chartInstance.value) { chartInstance.value.resize(); } }); resizeObserver.observe(container.value); onUnmounted(() => { if (chartInstance.value) { chartInstance.value.dispose(); } resizeObserver.disconnect(); }); } }; onMounted(initChart); return { container, chartInstance, updateChart: (newOptions) => { if (chartInstance.value) { chartInstance.value.setOption(newOptions, true); } } }; }
|
使用方式:
<template> <div ref="chartContainer" style="width: 100%; height: 400px;"></div> </template>
<script> import { useChart } from './composables/useChart';
export default { setup() { const { container, updateChart } = useChart('chartContainer', { title: { text: '销售趋势' }, tooltip: {}, xAxis: { data: ['1月', '2月', '3月', '4月', '5月', '6月'] }, yAxis: {}, series: [{ name: '销量', type: 'line', data: [150, 230, 224, 218, 135, 147] }] }); return { container }; } } </script>
|
2. 拖拽功能组合式函数
import { ref, computed, onMounted, onUnmounted } from 'vue';
export function useDraggable(elementRef, options = {}) { const isDragging = ref(false); const position = ref({ x: 0, y: 0 }); const offset = ref({ x: 0, y: 0 }); const style = computed(() => ({ position: 'fixed', left: `${position.value.x}px`, top: `${position.value.y}px`, cursor: isDragging.value ? 'grabbing' : 'grab', zIndex: isDragging.value ? 1000 : options.zIndex || 1, transform: isDragging.value ? 'scale(1.05)' : 'scale(1)', transition: isDragging.value ? 'none' : 'transform 0.2s ease' })); const startDrag = (event) => { isDragging.value = true; const rect = elementRef.value.getBoundingClientRect(); offset.value = { x: event.clientX - rect.left, y: event.clientY - rect.top }; document.addEventListener('mousemove', drag); document.addEventListener('mouseup', stopDrag); document.body.style.userSelect = 'none'; }; const drag = (event) => { if (!isDragging.value) return; position.value = { x: event.clientX - offset.value.x, y: event.clientY - offset.value.y }; }; const stopDrag = () => { isDragging.value = false; document.removeEventListener('mousemove', drag); document.removeEventListener('mouseup', stopDrag); document.body.style.userSelect = ''; }; const constrainPosition = (x, y) => { const maxX = window.innerWidth - elementRef.value.offsetWidth; const maxY = window.innerHeight - elementRef.value.offsetHeight; return { x: Math.max(0, Math.min(x, maxX)), y: Math.max(0, Math.min(y, maxY)) }; }; onMounted(() => { elementRef.value.addEventListener('mousedown', startDrag); }); onUnmounted(() => { elementRef.value.removeEventListener('mousedown', startDrag); stopDrag(); }); return { isDragging, position, style, constrainPosition }; }
|
使用方式:
<template> <div :style="style" class="draggable-element" > <h3>拖拽我</h3> <p>位置: {{ position.x }}, {{ position.y }}</p> </div> </template>
<script> import { useDraggable } from './composables/useDraggable';
export default { setup() { const elementRef = ref(null); const { isDragging, position, style } = useDraggable(elementRef); return { elementRef, isDragging, position, style }; } } </script>
<style> .draggable-element { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); user-select: none; } </style>
|
3. 权限管理组合式函数
import { ref, computed } from 'vue';
export function usePermissions() { const userRoles = ref(['user']); const hasRole = computed(() => (role) => { return userRoles.value.includes(role); }); const hasPermission = computed(() => (permission) => { if (hasRole.value('admin')) return true; const userPermissions = { 'user:read': ['user', 'editor'], 'user:write': ['editor'], 'user:delete': ['admin'] }; return userPermissions[permission]?.includes(userRoles.value[0]) || false; }); const checkAccess = (requiredPermission) => { return hasPermission.value(requiredPermission); }; return { hasRole, hasPermission, checkAccess }; }
|
使用方式:
<template> <div> <button v-if="checkAccess('user:write')" @click="editPost"> 编辑文章 </button> <button v-if="checkAccess('user:delete')" @click="deletePost"> 删除文章 </button> </div> </template>
<script> import { usePermissions } from './composables/usePermissions';
export default { setup() { const { checkAccess } = usePermissions(); const editPost = () => { console.log('编辑文章'); }; const deletePost = () => { console.log('删除文章'); }; return { checkAccess, editPost, deletePost }; } } </script>
|
五、常见问题解决
1. 响应式丢失问题
问题:在组合式函数中,返回的响应式数据可能会丢失响应性。
export function useCounter() { const count = ref(0); const increment = () => { count.value++; }; const getCount = () => { return count.value; }; return { count, increment, getCount }; }
export function useCounterCorrect() { const count = ref(0); const increment = () => { count.value++; }; return { count, increment, doubleCount: computed(() => count.value * 2) }; }
|
2. 依赖注入问题
问题:组合式函数之间的依赖关系不清晰。
export function useUserSettings() { const { theme } = useTheme(); const { language } = useLanguage(); const settings = computed(() => ({ theme: theme.value, language: language.value })); return { settings }; }
export function useUserSettingsBad() { const theme = ref('light'); const language = ref('zh-CN'); }
|
3. 内存泄漏问题
问题:组合式函数中未清理的事件监听器。
export function useWebSocket(url) { const socket = ref(null); const data = ref(null); const connect = () => { socket.value = new WebSocket(url); socket.value.onmessage = (event) => { data.value = JSON.parse(event.data); }; socket.value.onclose = () => { setTimeout(connect, 1000); }; }; onUnmounted(() => { if (socket.value) { socket.value.close(); } }); return { data, connect }; }
|
六、总结
Vue 3的Composition API为我们提供了更加灵活和强大的代码组织方式。通过组合式函数,我们能够更好地复用逻辑、提高代码质量,并且使组件更加清晰和易于维护。
在实际项目中,我发现组合式函数特别适合处理复杂的业务逻辑,比如表单处理、数据获取、状态管理等。但是,也要注意不要过度使用,保持代码的简洁和可读性。
希望这篇文章能够帮助你更好地理解和使用Vue 3的Composition API。如果你有任何问题或建议,欢迎在评论区交流!
本文由前端开发者原创,如需转载请注明出处。