各位老铁,大家好! 今天咱们来聊聊 Vue 3 Composition API 的自定义 Hook,教大家如何像搭积木一样,把复杂的数据获取和状态管理逻辑封装起来,做到可复用、可测试,让你的代码既优雅又健壮!
一、 啥是自定义 Hook?为啥要用它?
想象一下,你在做一个电商网站,首页要展示各种商品列表,商品详情页要展示商品信息,搜索页也要展示商品列表。这些页面都涉及到数据获取和状态管理,如果每个页面都写一遍相同的代码,那简直就是程序员的噩梦!代码冗余不说,改起来也费劲。
这时候,自定义 Hook 就派上用场了!它可以把这些通用的逻辑提取出来,封装成一个独立的函数,然后在不同的组件中复用。就像一个工具箱,里面装着各种工具,你需要哪个就拿哪个,方便快捷。
简单来说,自定义 Hook 就是一个函数,它利用 Vue 3 Composition API 的各种函数(比如 ref
、reactive
、computed
、watch
等)来封装一些可复用的逻辑。
二、 自定义 Hook 的设计原则
设计一个好的自定义 Hook,要遵循以下几个原则:
- 单一职责原则: 一个 Hook 只做一件事情,不要把太多的逻辑塞到一个 Hook 里。
- 可复用性: Hook 设计的要通用,可以在多个组件中使用。
- 可测试性: Hook 的逻辑要清晰,方便进行单元测试。
- 易于理解: Hook 的命名要规范,让人一看就知道是干嘛的。
三、 实战演练:封装一个数据获取的 Hook
咱们以一个获取用户列表的 Hook 为例,来演示如何封装一个可复用、可测试的数据获取和状态管理逻辑。
1. 创建 Hook 文件
首先,在你的 src
目录下创建一个 hooks
文件夹,然后在里面创建一个 useUsers.js
文件。
// src/hooks/useUsers.js
import { ref, onMounted, watch } from 'vue';
export default function useUsers(options = {}) {
const users = ref([]);
const loading = ref(false);
const error = ref(null);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const { apiUrl = '/api/users', initialPage = 1, initialPageSize = 10 } = options;
page.value = initialPage;
pageSize.value = initialPageSize;
const fetchUsers = async () => {
loading.value = true;
error.value = null;
try {
const response = await fetch(`${apiUrl}?page=${page.value}&pageSize=${pageSize.value}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
users.value = data.data; // 假设接口返回的格式是 { data: [], total: number }
total.value = data.total;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
};
onMounted(fetchUsers);
watch([page, pageSize], fetchUsers);
return {
users,
loading,
error,
page,
pageSize,
total,
fetchUsers, // 暴露 fetchUsers 方法,允许外部手动刷新数据
};
}
2. Hook 的代码分析
ref
变量: 使用ref
创建了users
、loading
、error
、page
、pageSize
和total
这些响应式变量,用于存储用户列表、加载状态、错误信息、当前页码、每页条数和总条数。options
参数: Hook 接受一个options
参数,用于配置 API 地址、初始页码和初始每页条数。这样可以增加 Hook 的灵活性。fetchUsers
函数: 这是一个异步函数,用于从 API 获取用户列表。它会先设置loading
为true
,然后发起请求,如果请求成功,就把数据赋值给users
,如果请求失败,就把错误信息赋值给error
。最后,无论请求成功还是失败,都会把loading
设置为false
。onMounted
生命周期钩子: 在组件挂载后,会自动调用fetchUsers
函数,获取用户列表。watch
监听器: 监听page
和pageSize
的变化,当这两个变量发生变化时,会自动调用fetchUsers
函数,重新获取用户列表。return
语句: 返回users
、loading
、error
、page
、pageSize
、total
和fetchUsers
这些变量和函数,供组件使用。
3. 在组件中使用 Hook
<template>
<div>
<div v-if="loading">Loading...</div>
<div v-if="error">Error: {{ error.message }}</div>
<ul>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="page--" :disabled="page <= 1">上一页</button>
<span>{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<button @click="page++" :disabled="page * pageSize >= total">下一页</button>
</div>
</template>
<script>
import { ref } from 'vue';
import useUsers from '@/hooks/useUsers';
export default {
setup() {
const { users, loading, error, page, pageSize, total } = useUsers({
apiUrl: '/api/custom/users',
initialPageSize: 5,
});
return {
users,
loading,
error,
page,
pageSize,
total,
};
},
};
</script>
4. 代码分析
import useUsers from '@/hooks/useUsers'
: 引入useUsers
Hook。const { users, loading, error, page, pageSize, total } = useUsers()
: 调用useUsers
Hook,并解构返回的变量。return { users, loading, error, page, pageSize, total }
: 把变量返回给模板使用。- 模板中的使用: 在模板中,可以使用
users
、loading
、error
、page
和pageSize
这些变量来展示用户列表、加载状态、错误信息和分页控件。
四、 让 Hook 更加灵活:使用 Provide/Inject
如果你的 Hook 需要在多个组件中使用,并且这些组件之间存在父子关系,那么可以使用 provide/inject
来共享 Hook 的状态和方法。
1. 在父组件中 Provide Hook
<template>
<div>
<ChildComponent />
</div>
</template>
<script>
import { provide } from 'vue';
import ChildComponent from './ChildComponent.vue';
import useUsers from '@/hooks/useUsers';
export default {
components: {
ChildComponent,
},
setup() {
const usersHook = useUsers();
provide('usersHook', usersHook);
return {};
},
};
</script>
2. 在子组件中 Inject Hook
<template>
<div>
<ul>
<li v-for="user in usersHook.users" :key="user.id">{{ user.name }}</li>
</ul>
<button @click="usersHook.page--" :disabled="usersHook.page <= 1">上一页</button>
<span>{{ usersHook.page }} / {{ Math.ceil(usersHook.total / usersHook.pageSize) }}</span>
<button @click="usersHook.page++" :disabled="usersHook.page * usersHook.pageSize >= usersHook.total">下一页</button>
</div>
</template>
<script>
import { inject } from 'vue';
export default {
setup() {
const usersHook = inject('usersHook');
return {
usersHook,
};
},
};
</script>
五、 Hook 的测试
一个可测试的 Hook 是一个好的 Hook 的重要标志。 咱们使用 vitest
(或者你喜欢的测试框架) 来测试 useUsers
Hook。
// tests/unit/useUsers.spec.js
import { useUsers } from '@/hooks/useUsers';
import { ref, nextTick } from 'vue';
import { describe, it, expect, vi } from 'vitest';
// Mock fetch
global.fetch = vi.fn();
describe('useUsers', () => {
it('should fetch users successfully', async () => {
const mockUsers = [{ id: 1, name: 'John Doe' }, { id: 2, name: 'Jane Doe' }];
const mockTotal = 2;
fetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockUsers, total: mockTotal }),
});
const { users, loading, error, total } = useUsers();
expect(loading.value).toBe(true); // Initial loading state
await nextTick(); // Wait for the fetch to resolve
expect(loading.value).toBe(false);
expect(users.value).toEqual(mockUsers);
expect(error.value).toBe(null);
expect(total.value).toBe(mockTotal);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('should handle fetch error', async () => {
fetch.mockRejectedValue(new Error('Failed to fetch'));
const { users, loading, error } = useUsers();
expect(loading.value).toBe(true);
await nextTick();
expect(loading.value).toBe(false);
expect(users.value).toEqual([]); // or the initial value
expect(error.value).toBeInstanceOf(Error);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('should update users when page changes', async () => {
const mockUsersPage1 = [{ id: 1, name: 'John Doe' }];
const mockUsersPage2 = [{ id: 2, name: 'Jane Doe' }];
const mockTotal = 2;
fetch.mockImplementation((url) => {
if (url.includes('page=1')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: mockUsersPage1, total: mockTotal }),
});
} else if (url.includes('page=2')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: mockUsersPage2, total: mockTotal }),
});
}
return Promise.reject(new Error('Unexpected URL'));
});
const { users, page } = useUsers();
await nextTick();
expect(users.value).toEqual(mockUsersPage1);
page.value = 2;
await nextTick();
expect(users.value).toEqual(mockUsersPage2);
expect(fetch).toHaveBeenCalledTimes(2);
});
});
六、 最佳实践和注意事项
- 命名规范: Hook 的命名要以
use
开头,比如useUsers
、useCounter
。 - 参数传递: Hook 的参数要尽量少,如果参数过多,可以考虑使用
options
对象来传递。 - 返回值: Hook 的返回值要尽量简洁,只返回必要的变量和函数。
- 错误处理: Hook 要做好错误处理,避免组件因为 Hook 的错误而崩溃。
- 避免过度封装: 不要为了封装而封装,只有当逻辑足够通用时,才应该考虑封装成 Hook。
- 文档: 为你的 Hook 编写清晰的文档,方便其他开发者使用。
七、 总结
自定义 Hook 是 Vue 3 Composition API 的一个强大特性,它可以帮助我们把复杂的逻辑封装起来,提高代码的可复用性和可维护性。 只要掌握了自定义 Hook 的设计原则和使用方法,就能像搭积木一样,构建出各种各样的应用。
希望今天的讲座能帮助大家更好地理解和使用 Vue 3 Composition API 的自定义 Hook。 记住,实践是检验真理的唯一标准,多写多练,才能真正掌握这项技术! 祝大家编程愉快!
补充:常见场景下的 Hook 设计示例
为了让大家更好地理解 Hook 的应用,我再补充几个常见场景下的 Hook 设计示例:
1. useLocalStorage
Hook:封装 LocalStorage 的读写操作
// src/hooks/useLocalStorage.js
import { ref, watch, onMounted } from 'vue';
export default function useLocalStorage(key, defaultValue) {
const storedValue = ref(defaultValue);
onMounted(() => {
try {
const item = localStorage.getItem(key);
storedValue.value = item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error('Error parsing localStorage value:', error);
storedValue.value = defaultValue;
}
});
watch(
storedValue,
(newValue) => {
try {
localStorage.setItem(key, JSON.stringify(newValue));
} catch (error) {
console.error('Error setting localStorage value:', error);
}
},
{ deep: true } // 深度监听,如果 defaultValue 是对象或数组
);
return storedValue;
}
使用示例:
<template>
<div>
<input v-model="name" type="text" />
<p>Hello, {{ name }}!</p>
</div>
</template>
<script>
import { ref } from 'vue';
import useLocalStorage from '@/hooks/useLocalStorage';
export default {
setup() {
const name = useLocalStorage('userName', 'Guest');
return {
name,
};
},
};
</script>
2. useDebounce
Hook:封装防抖逻辑
// src/hooks/useDebounce.js
import { ref, watch, onUnmounted } from 'vue';
export default function useDebounce(value, delay = 300) {
const debouncedValue = ref(value);
let timeout;
watch(
value,
(newValue) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
debouncedValue.value = newValue;
}, delay);
}
);
onUnmounted(() => {
clearTimeout(timeout);
});
return debouncedValue;
}
使用示例:
<template>
<div>
<input v-model="searchTerm" type="text" />
<p>Searching for: {{ debouncedSearchTerm }}</p>
</div>
</template>
<script>
import { ref } from 'vue';
import useDebounce from '@/hooks/useDebounce';
export default {
setup() {
const searchTerm = ref('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
return {
searchTerm,
debouncedSearchTerm,
};
},
};
</script>
表格总结 Hook 的特点
特性 | 描述 |
---|---|
可复用性 | 允许在多个组件中共享相同的逻辑,避免代码冗余。 |
可测试性 | 将逻辑提取到独立的函数中,方便进行单元测试。 |
组合性 | 可以将多个 Hook 组合在一起,构建更复杂的逻辑。 |
易于维护 | 将逻辑封装在 Hook 中,使组件代码更简洁,易于理解和维护。 |
响应式 | Hook 可以使用 Vue 3 Composition API 的各种函数(如 ref 、reactive 、computed 、watch 等)来创建响应式状态,并将其暴露给组件使用。 |
生命周期管理 | Hook 可以使用 onMounted 、onUnmounted 等生命周期钩子来管理组件的生命周期。 |
灵活性 | Hook 可以接受参数,并根据参数的不同来执行不同的逻辑,从而增加 Hook 的灵活性。 |
希望这些示例能帮助你更好地理解自定义 Hook 的应用场景和设计方法。 记住,多实践,多思考,才能真正掌握这项技术!