在 Vue 3 Composition API 中,如何设计一个可复用、可测试的自定义 Hook,用于封装复杂的数据获取和状态管理逻辑?

各位老铁,大家好! 今天咱们来聊聊 Vue 3 Composition API 的自定义 Hook,教大家如何像搭积木一样,把复杂的数据获取和状态管理逻辑封装起来,做到可复用、可测试,让你的代码既优雅又健壮!

一、 啥是自定义 Hook?为啥要用它?

想象一下,你在做一个电商网站,首页要展示各种商品列表,商品详情页要展示商品信息,搜索页也要展示商品列表。这些页面都涉及到数据获取和状态管理,如果每个页面都写一遍相同的代码,那简直就是程序员的噩梦!代码冗余不说,改起来也费劲。

这时候,自定义 Hook 就派上用场了!它可以把这些通用的逻辑提取出来,封装成一个独立的函数,然后在不同的组件中复用。就像一个工具箱,里面装着各种工具,你需要哪个就拿哪个,方便快捷。

简单来说,自定义 Hook 就是一个函数,它利用 Vue 3 Composition API 的各种函数(比如 refreactivecomputedwatch 等)来封装一些可复用的逻辑。

二、 自定义 Hook 的设计原则

设计一个好的自定义 Hook,要遵循以下几个原则:

  1. 单一职责原则: 一个 Hook 只做一件事情,不要把太多的逻辑塞到一个 Hook 里。
  2. 可复用性: Hook 设计的要通用,可以在多个组件中使用。
  3. 可测试性: Hook 的逻辑要清晰,方便进行单元测试。
  4. 易于理解: 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 创建了 usersloadingerrorpagepageSizetotal 这些响应式变量,用于存储用户列表、加载状态、错误信息、当前页码、每页条数和总条数。
  • options 参数: Hook 接受一个 options 参数,用于配置 API 地址、初始页码和初始每页条数。这样可以增加 Hook 的灵活性。
  • fetchUsers 函数: 这是一个异步函数,用于从 API 获取用户列表。它会先设置 loadingtrue,然后发起请求,如果请求成功,就把数据赋值给 users,如果请求失败,就把错误信息赋值给 error。最后,无论请求成功还是失败,都会把 loading 设置为 false
  • onMounted 生命周期钩子: 在组件挂载后,会自动调用 fetchUsers 函数,获取用户列表。
  • watch 监听器: 监听 pagepageSize 的变化,当这两个变量发生变化时,会自动调用 fetchUsers 函数,重新获取用户列表。
  • return 语句: 返回 usersloadingerrorpagepageSizetotalfetchUsers 这些变量和函数,供组件使用。

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 } 把变量返回给模板使用。
  • 模板中的使用: 在模板中,可以使用 usersloadingerrorpagepageSize 这些变量来展示用户列表、加载状态、错误信息和分页控件。

四、 让 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 开头,比如 useUsersuseCounter
  • 参数传递: 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 的各种函数(如 refreactivecomputedwatch 等)来创建响应式状态,并将其暴露给组件使用。
生命周期管理 Hook 可以使用 onMountedonUnmounted 等生命周期钩子来管理组件的生命周期。
灵活性 Hook 可以接受参数,并根据参数的不同来执行不同的逻辑,从而增加 Hook 的灵活性。

希望这些示例能帮助你更好地理解自定义 Hook 的应用场景和设计方法。 记住,多实践,多思考,才能真正掌握这项技术!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注