一、自定义Hook基础概念

1.1 什么是自定义Hook?

自定义Hook是Vue3组合式API中一种封装和复用组件逻辑的方式,本质上是一个以use为前缀命名的函数,它可以调用其他的Hook,并将组件逻辑提取到可重用的函数中。

核心特点

  • 命名必须以use开头(如useCounteruseFetch
  • 可以调用其他Hook(如refreactiveonMounted等)
  • 可以返回任意类型的数据(基本类型、对象、函数等)
  • 每次调用都会创建独立的状态,互不干扰

为什么需要自定义Hook

  • 逻辑复用:将组件中可复用的逻辑提取为Hook
  • 代码组织:使组件代码更加清晰,按功能划分
  • 关注点分离:将数据获取、状态管理等不同关注点分离
  • 测试友好:独立的Hook更容易进行单元测试

1.2 第一个自定义Hook:useCounter

// hooks/useCounter.js
import { ref, computed } from 'vue';

// 定义自定义Hook,接收初始值和步长作为参数
export function useCounter(initialValue = 0, step = 1) {
  // 定义响应式状态
  const count = ref(initialValue);
  
  // 定义修改状态的方法
  const increment = () => {
    count.value += step;
  };
  
  const decrement = () => {
    count.value -= step;
  };
  
  const reset = () => {
    count.value = initialValue;
  };
  
  // 计算属性
  const doubleCount = computed(() => count.value * 2);
  
  // 返回需要暴露的状态和方法
  return {
    count,
    doubleCount,
    increment,
    decrement,
    reset
  };
}

在组件中使用

<template>
  <div class="counter-demo">
    <h2>计数器示例</h2>
    <p>当前计数: {{ count }}</p>
    <p>双倍计数: {{ doubleCount }}</p>
    <div class="button-group">
      <button @click="decrement">-</button>
      <button @click="reset">重置</button>
      <button @click="increment">+</button>
    </div>
  </div>
</template>

<script setup>
// 导入自定义Hook
import { useCounter } from './hooks/useCounter';

// 调用Hook,获取状态和方法
const { count, doubleCount, increment, decrement, reset } = useCounter(0, 1);
</script>

<style>
.button-group {
  display: flex;
  gap: 10px;
  margin-top: 10px;
}

button {
  padding: 5px 15px;
  cursor: pointer;
}
</style>

运行结果

  • 页面显示计数器初始值为0,双倍计数为0
  • 点击"+"按钮,计数增加1,双倍计数同步增加2
  • 点击"-"按钮,计数减少1,双倍计数同步减少2
  • 点击"重置"按钮,计数恢复为初始值0
  • 所有状态变化都是响应式的,UI会实时更新

代码解析

  • useCounter函数接收initialValuestep两个参数,分别设置初始值和步长
  • 内部使用ref创建响应式状态count
  • 定义了incrementdecrementreset三个方法来修改状态
  • 使用computed创建计算属性doubleCount
  • 返回一个对象,包含需要暴露给组件的状态和方法
  • 组件中通过解构赋值获取Hook提供的状态和方法,并在模板中使用

二、自定义Hook进阶应用

2.1 带选项的自定义Hook:useLocalStorage

// hooks/useLocalStorage.js
import { ref, watch, onMounted } from 'vue';

// 自定义Hook:用于在localStorage中存储和读取数据
export function useLocalStorage(key, options = {}) {
  // 从选项中获取默认值和是否自动保存
  const { defaultValue = null, autoSave = true } = options;
  
  // 创建响应式状态
  const data = ref(defaultValue);
  
  // 从localStorage加载数据
  const load = () => {
    try {
      const storedValue = localStorage.getItem(key);
      if (storedValue !== null) {
        // 尝试解析JSON,如果失败则直接使用原始值
        data.value = JSON.parse(storedValue);
      } else {
        data.value = defaultValue;
        // 如果没有存储值且提供了默认值,则保存默认值
        if (defaultValue !== null) {
          save();
        }
      }
    } catch (error) {
      console.error('Failed to load data from localStorage:', error);
      data.value = defaultValue;
    }
  };
  
  // 保存数据到localStorage
  const save = () => {
    try {
      localStorage.setItem(key, JSON.stringify(data.value));
    } catch (error) {
      console.error('Failed to save data to localStorage:', error);
    }
  };
  
  // 清除localStorage中的数据
  const remove = () => {
    try {
      localStorage.removeItem(key);
      data.value = defaultValue;
    } catch (error) {
      console.error('Failed to remove data from localStorage:', error);
    }
  };
  
  // 组件挂载时加载数据
  onMounted(() => {
    load();
  });
  
  // 如果启用了自动保存,则监听数据变化并自动保存
  if (autoSave) {
    watch(data, () => {
      save();
    }, { deep: true }); // deep: true 用于监听对象内部变化
  }
  
  // 返回状态和方法
  return {
    data,
    load,
    save,
    remove
  };
}

在组件中使用

<template>
  <div class="local-storage-demo">
    <h2>LocalStorage示例</h2>
    <div class="input-group">
      <label>用户名:</label>
      <input v-model="username.data" placeholder="请输入用户名">
    </div>
    
    <div class="input-group">
      <label>年龄:</label>
      <input v-model.number="userInfo.data.age" type="number" placeholder="请输入年龄">
    </div>
    
    <div class="button-group">
      <button @click="userInfo.save">手动保存</button>
      <button @click="userInfo.remove">清除数据</button>
      <button @click="userInfo.load">重新加载</button>
    </div>
    
    <div class="status">
      <p>当前存储的数据: {{ userInfo.data }}</p>
    </div>
  </div>
</template>

<script setup>
import { useLocalStorage } from './hooks/useLocalStorage';

// 使用自定义Hook存储用户名(自动保存)
const username = useLocalStorage('username', { 
  defaultValue: 'Guest',
  autoSave: true 
});

// 使用自定义Hook存储用户信息(手动保存)
const userInfo = useLocalStorage('userInfo', {
  defaultValue: { name: '', age: 0 },
  autoSave: false  // 禁用自动保存,需要手动调用save方法
});
</script>

<style>
.input-group {
  margin: 10px 0;
}

input {
  margin-left: 10px;
  padding: 5px;
}

.button-group {
  margin: 15px 0;
  display: flex;
  gap: 10px;
}

button {
  padding: 5px 10px;
  cursor: pointer;
}

.status {
  margin-top: 15px;
  padding: 10px;
  background-color: #f5f5f5;
  border-radius: 4px;
}
</style>

运行结果

  • 页面加载时,会从localStorage加载数据,如果没有数据则使用默认值
  • 用户名输入框:输入内容时会自动保存到localStorage(因为autoSave: true)
  • 用户信息区域:
    • 输入年龄后不会立即保存(因为autoSave: false)
    • 点击"手动保存"按钮后才会保存到localStorage
    • 点击"清除数据"按钮会删除localStorage中的数据并重置为默认值
    • 点击"重新加载"按钮会从localStorage重新加载数据
  • 刷新页面后,数据会从localStorage中恢复

代码解析

  • useLocalStorage接收两个参数:存储的键名和选项对象
  • 选项对象支持defaultValue(默认值)和autoSave(是否自动保存)
  • 提供了loadsaveremove三个方法手动操作数据
  • 使用onMounted在组件挂载时自动加载数据
  • 使用watch监听数据变化,当autoSave为true时自动保存
  • 通过{ deep: true }选项支持对象类型数据的深度监听

2.2 异步数据获取Hook:useFetch

// hooks/useFetch.js
import { ref, onMounted, watch, computed } from 'vue';

export function useFetch(url, options = {}) {
  // 定义响应式状态
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);
  
  // 默认请求选项
  const defaultOptions = {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json'
    },
    immediate: true, // 是否在挂载后立即请求
    ...options
  };
  
  // 发送请求的函数
  const fetchData = async (customUrl = null, customOptions = {}) => {
    // 合并URL和选项
    const requestUrl = customUrl || url;
    const requestOptions = { ...defaultOptions, ...customOptions };
    
    // 如果没有URL,抛出错误
    if (!requestUrl) {
      error.value = new Error('URL is required for fetch');
      return;
    }
    
    // 重置状态
    data.value = null;
    error.value = null;
    loading.value = true;
    
    try {
      // 发送请求
      const response = await fetch(requestUrl, requestOptions);
      
      // 检查响应状态
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      
      // 解析响应数据(支持JSON和文本)
      const contentType = response.headers.get('content-type');
      if (contentType && contentType.includes('application/json')) {
        data.value = await response.json();
      } else {
        data.value = await response.text();
      }
      
    } catch (err) {
      // 捕获错误
      error.value = err;
      console.error('Fetch error:', err);
    } finally {
      // 请求完成,更新loading状态
      loading.value = false;
    }
  };
  
  // 如果设置了immediate,在组件挂载时自动请求
  if (defaultOptions.immediate) {
    onMounted(() => {
      fetchData();
    });
  }
  
  // 监听URL变化,如果URL是ref则自动重新请求
  if (url && typeof url !== 'string') {
    watch(url, (newUrl) => {
      if (newUrl) {
        fetchData(newUrl);
      }
    });
  }
  
  // 计算属性:是否处于空闲状态(既不加载也无错误)
  const isIdle = computed(() => !loading.value && !error.value && data.value === null);
  
  // 计算属性:是否请求成功
  const isSuccess = computed(() => !loading.value && !error.value && data.value !== null);
  
  return {
    data,
    error,
    loading,
    isIdle,
    isSuccess,
    fetchData
  };
}

在组件中使用

<template>
  <div class="fetch-demo">
    <h2>数据获取示例</h2>
    
    <div class="controls">
      <input v-model="postId" type="number" min="1" max="100" placeholder="输入帖子ID">
      <button @click="fetchPost">获取帖子</button>
    </div>
    
    <!-- 加载状态 -->
    <div v-if="loading" class="status loading">
      加载中...
    </div>
    
    <!-- 错误状态 -->
    <div v-else-if="error" class="status error">
      错误: {{ error.message }}
    </div>
    
    <!-- 空闲状态 -->
    <div v-else-if="isIdle" class="status idle">
      请输入帖子ID并点击"获取帖子"按钮
    </div>
    
    <!-- 成功状态 -->
    <div v-else-if="isSuccess" class="post">
      <h3>{{ data.title }}</h3>
      <div class="post-meta">
        <span>作者ID: {{ data.userId }}</span>
        <span>帖子ID: {{ data.id }}</span>
      </div>
      <div class="post-body">
        {{ data.body }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useFetch } from './hooks/useFetch';

// 帖子ID
const postId = ref(1);

// 创建自定义Hook实例,不立即请求(immediate: false)
const { 
  data, 
  error, 
  loading, 
  isIdle, 
  isSuccess, 
  fetchData: fetchPost 
} = useFetch(null, { 
  immediate: false 
});

// 自定义获取帖子的方法
const fetchData = () => {
  // 调用Hook提供的fetchData方法,传入动态URL
  fetchPost(`https://jsonplaceholder.typicode.com/posts/${postId.value}`);
};
</script>

<style>
.controls {
  margin: 15px 0;
  display: flex;
  gap: 10px;
  align-items: center;
}

input {
  padding: 5px;
  width: 100px;
}

button {
  padding: 5px 15px;
  cursor: pointer;
}

.status {
  padding: 15px;
  border-radius: 4px;
  margin: 10px 0;
}

.loading {
  background-color: #e3f2fd;
  color: #1976d2;
}

.error {
  background-color: #ffebee;
  color: #d32f2f;
}

.idle {
  background-color: #fff8e1;
  color: #ff8f00;
}

.post {
  border: 1px solid #e0e0e0;
  border-radius: 4px;
  padding: 15px;
  margin-top: 10px;
}

.post-meta {
  color: #666;
  font-size: 0.9em;
  margin: 5px 0 15px;
  display: flex;
  gap: 15px;
}

.post-body {
  line-height: 1.6;
}
</style>

运行结果

  • 初始状态显示"请输入帖子ID并点击’获取帖子’按钮"
  • 输入1-100之间的数字并点击"获取帖子"按钮:
    • 显示"加载中…"状态
    • 加载完成后显示帖子标题、作者ID、帖子ID和内容
  • 如果输入无效ID或网络错误:
    • 显示错误信息,如"HTTP error! status: 404"
  • 可以多次输入不同ID获取不同帖子内容

代码解析

  • useFetch是一个通用的HTTP请求Hook,支持GET、POST等请求方式
  • 提供了完整的状态管理:data(响应数据)、loading(加载状态)、error(错误信息)
  • 支持自动请求和手动请求两种模式(通过immediate选项控制)
  • 可以监听URL的变化自动重新请求(如果URL是ref响应式对象)
  • 提供了isIdleisSuccess计算属性,方便在模板中进行条件渲染
  • 支持自定义请求头、请求方法等选项
  • 自动处理JSON和文本类型的响应数据

三、组合多个Hook的高级应用

3.1 组合Hook示例:useUserData

// hooks/useValidation.js
import { ref, computed } from 'vue';

// 表单验证Hook
export function useValidation(initialValues = {}, rules = {}) {
  // 表单值
  const values = ref({ ...initialValues });
  // 错误信息
  const errors = ref({});
  
  // 验证单个字段
  const validateField = (field) => {
    const value = values.value[field];
    const fieldRules = rules[field] || [];
    let errorMessage = '';
    
    // 遍历该字段的所有规则
    for (const rule of fieldRules) {
      //  required规则
      if (rule.required && (!value || value === '')) {
        errorMessage = rule.message || `${field} is required`;
        break;
      }
      
      // minLength规则
      if (rule.minLength && value.length < rule.minLength) {
        errorMessage = rule.message || `${field} must be at least ${rule.minLength} characters`;
        break;
      }
      
      // maxLength规则
      if (rule.maxLength && value.length > rule.maxLength) {
        errorMessage = rule.message || `${field} cannot exceed ${rule.maxLength} characters`;
        break;
      }
      
      // pattern规则(正则表达式)
      if (rule.pattern && !rule.pattern.test(value)) {
        errorMessage = rule.message || `${field} is invalid`;
        break;
      }
      
      // 自定义验证函数
      if (rule.validator && typeof rule.validator === 'function') {
        const result = rule.validator(value, values.value);
        if (result !== true) {
          errorMessage = result || `${field} is invalid`;
          break;
        }
      }
    }
    
    // 更新错误信息
    errors.value[field] = errorMessage;
    return !errorMessage;
  };
  
  // 验证所有字段
  const validate = () => {
    const fields = Object.keys(rules);
    let isValid = true;
    
    // 遍历所有字段进行验证
    for (const field of fields) {
      const fieldIsValid = validateField(field);
      if (!fieldIsValid) {
        isValid = false;
      }
    }
    
    return isValid;
  };
  
  // 重置表单
  const reset = () => {
    values.value = { ...initialValues };
    errors.value = {};
  };
  
  // 计算属性:是否整体有效
  const isValid = computed(() => {
    validate();
    return Object.values(errors.value).every(error => !error);
  });
  
  return {
    values,
    errors,
    isValid,
    validate,
    validateField,
    reset
  };
}
// hooks/useUserData.js
import { ref, watch } from 'vue';
import { useFetch } from './useFetch';
import { useValidation } from './useValidation';

// 组合多个Hook,创建用户数据管理Hook
export function useUserData() {
  // 用户ID
  const userId = ref(null);
  
  // 使用useFetch获取用户数据
  const {
    data: user,
    loading,
    error,
    fetchData: fetchUser
  } = useFetch(null, { immediate: false });
  
  // 用户表单验证规则
  const validationRules = {
    name: [
      { required: true, message: '姓名不能为空' },
      { minLength: 2, message: '姓名至少2个字符' }
    ],
    email: [
      { required: true, message: '邮箱不能为空' },
      { pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: '请输入有效的邮箱地址' }
    ],
    age: [
      { required: true, message: '年龄不能为空' },
      { 
        validator: (value) => {
          const num = Number(value);
          return num >= 18 && num <= 120 || '年龄必须在18-120之间';
        }
      }
    ]
  };
  
  // 使用useValidation进行表单验证
  const {
    values: formData,
    errors,
    isValid,
    validate,
    reset: resetForm
  } = useValidation(
    { name: '', email: '', age: null },
    validationRules
  );
  
  // 当用户数据加载完成后,更新表单数据
  watch(user, (newUser) => {
    if (newUser) {
      formData.name = newUser.name || '';
      formData.email = newUser.email || '';
      formData.age = newUser.age || null;
    }
  });
  
  // 加载用户数据
  const loadUser = (id) => {
    if (id) {
      userId.value = id;
      fetchUser(`https://jsonplaceholder.typicode.com/users/${id}`);
    }
  };
  
  // 保存用户数据
  const saveUser = async () => {
    // 先验证表单
    if (!validate()) {
      return false;
    }
    
    try {
      // 模拟API请求
      const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId.value || ''}`, {
        method: userId.value ? 'PUT' : 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(formData)
      });
      
      const result = await response.json();
      console.log('保存成功:', result);
      return true;
    } catch (err) {
      console.error('保存失败:', err);
      return false;
    }
  };
  
  return {
    userId,
    user,
    formData,
    errors,
    loading,
    error,
    isValid,
    loadUser,
    saveUser,
    resetForm,
    validate
  };
}

在组件中使用

<template>
  <div class="user-data-demo">
    <h2>用户数据管理</h2>
    
    <div class="user-controls">
      <input v-model="userIdInput" type="number" min="1" max="10" placeholder="用户ID">
      <button @click="loadUser(userIdInput)">加载用户</button>
      <button @click="resetForm">重置表单</button>
    </div>
    
    <div v-if="loading" class="loading">加载中...</div>
    
    <div v-if="error" class="error">错误: {{ error.message }}</div>
    
    <form v-else class="user-form" @submit.prevent="handleSubmit">
      <div class="form-group">
        <label>姓名:</label>
        <input 
          v-model="formData.name" 
          @blur="validate('name')"
          :class="{ invalid: errors.name }"
        >
        <span v-if="errors.name" class="error-message">{{ errors.name }}</span>
      </div>
      
      <div class="form-group">
        <label>邮箱:</label>
        <input 
          v-model="formData.email" 
          type="email"
          @blur="validate('email')"
          :class="{ invalid: errors.email }"
        >
        <span v-if="errors.email" class="error-message">{{ errors.email }}</span>
      </div>
      
      <div class="form-group">
        <label>年龄:</label>
        <input 
          v-model.number="formData.age" 
          type="number"
          @blur="validate('age')"
          :class="{ invalid: errors.age }"
        >
        <span v-if="errors.age" class="error-message">{{ errors.age }}</span>
      </div>
      
      <button 
        type="submit" 
        :disabled="!isValid || loading"
        class="submit-btn"
      >
        保存用户
      </button>
    </form>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { useUserData } from './hooks/useUserData';

// 导入组合后的Hook
const {
  userId,
  user,
  formData,
  errors,
  loading,
  error,
  isValid,
  loadUser,
  saveUser,
  resetForm,
  validate
} = useUserData();

// 用户ID输入框
const userIdInput = ref('');

// 表单提交处理
const handleSubmit = async () => {
  const success = await saveUser();
  if (success) {
    alert('保存成功!');
  } else {
    alert('保存失败,请检查表单数据!');
  }
};
</script>

<style>
.user-controls {
  margin: 15px 0;
  display: flex;
  gap: 10px;
  align-items: center;
}

.loading {
  color: #666;
  padding: 10px;
}

.error {
  color: #d32f2f;
  padding: 10px;
  background-color: #ffebee;
  border-radius: 4px;
}

.user-form {
  margin-top: 15px;
  border: 1px solid #e0e0e0;
  padding: 20px;
  border-radius: 4px;
}

.form-group {
  margin-bottom: 15px;
}

label {
  display: inline-block;
  width: 80px;
}

input {
  padding: 8px;
  width: 250px;
  border: 1px solid #ddd;
  border-radius: 4px;
}

input.invalid {
  border-color: #d32f2f;
}

.error-message {
  color: #d32f2f;
  font-size: 0.9em;
  margin-left: 85px;
  display: block;
  margin-top: 5px;
}

.submit-btn {
  margin-left: 85px;
  padding: 8px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

.submit-btn:disabled {
  background-color: #a0a0a0;
  cursor: not-allowed;
}
</style>

运行结果

  • 初始状态显示空表单和用户ID输入区域
  • 输入1-10之间的数字并点击"加载用户":
    • 显示"加载中…"状态
    • 加载完成后,表单会自动填充用户数据
  • 表单验证:
    • 姓名为空或少于2个字符时,失去焦点会显示错误信息
    • 邮箱格式不正确时,显示错误信息
    • 年龄不在18-120之间时,显示错误信息
  • 表单填写不完整或验证不通过时,"保存用户"按钮禁用
  • 点击"保存用户"按钮:
    • 表单验证通过后,模拟提交数据
    • 提交成功显示"保存成功!"提示
    • 提交失败显示"保存失败"提示
  • 点击"重置表单"按钮,清空表单数据和错误信息

代码解析

  • useUserData组合了useFetchuseValidation两个基础Hook
  • useFetch用于从API获取用户数据和提交用户数据
  • useValidation用于处理表单验证逻辑
  • 通过watch监听用户数据变化,自动更新表单
  • 提供了完整的用户数据管理功能:加载、验证、保存、重置
  • 组件中只需关注UI渲染和用户交互,业务逻辑由Hook封装

四、自定义Hook最佳实践

4.1 命名规范

  • 必须以use开头:遵循Vue的命名约定,便于识别Hook
  • 使用动词+名词结构:如useCounteruseFetchuseLocalStorage
  • 避免过于宽泛的名称:如useData不如useUserData明确
  • 保持一致性:项目内Hook命名风格保持一致

4.2 代码组织

  • 单一职责原则:一个Hook只做一件事,便于复用和维护
  • 合理拆分:复杂逻辑拆分为多个小Hook,再组合使用
  • 目录结构:将Hook集中放在src/hooks目录下
  • 分类组织:可以按功能进一步分类,如use/validationuse/data-fetching

4.3 状态管理

  • 内部状态私有化:不需要暴露给组件的状态应保持私有
  • 返回必要的状态:只返回组件需要的状态和方法,避免过度暴露
  • 使用ref而非reactive:返回数据时优先使用ref,组件中使用更直观
  • 提供修改方法:状态修改应通过提供的方法,而非直接修改返回的ref

4.4 错误处理

  • 内部捕获错误:Hook内部适当捕获和处理错误
  • 提供错误状态:将错误信息作为状态暴露给组件
  • 错误边界:复杂Hook考虑使用错误边界处理异常
  • 详细错误信息:提供有意义的错误信息,便于调试

4.5 文档和测试

  • 添加JSDoc注释:为Hook及其参数、返回值添加注释
  • 示例代码:提供清晰的使用示例
  • 单元测试:为Hook编写单元测试,确保可靠性
  • 使用说明:说明Hook的用途、参数、返回值和注意事项

4.6 性能优化

  • 避免不必要的响应式:非响应式数据不需要用ref/reactive包装
  • 合理使用watch选项:使用deepimmediate等选项优化watch性能
  • 缓存计算结果:复杂计算使用computed缓存结果
  • 清理副作用:使用生命周期钩子清理定时器、事件监听等

五、常见自定义Hook示例

5.1 useWindowSize - 监听窗口大小

// hooks/useWindowSize.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useWindowSize() {
  // 初始窗口大小
  const width = ref(window.innerWidth);
  const height = ref(window.innerHeight);
  
  // 窗口大小变化处理函数
  const handleResize = () => {
    width.value = window.innerWidth;
    height.value = window.innerHeight;
  };
  
  // 组件挂载时添加事件监听
  onMounted(() => {
    window.addEventListener('resize', handleResize);
    // 初始调用一次,确保尺寸正确
    handleResize();
  });
  
  // 组件卸载时移除事件监听
  onUnmounted(() => {
    window.removeEventListener('resize', handleResize);
  });
  
  return {
    width,
    height,
    isMobile: ref(false), // 可以根据需要添加更多派生状态
    isTablet: ref(false),
    isDesktop: ref(false)
  };
}

5.2 useDarkMode - 深色模式切换

// hooks/useDarkMode.js
import { ref, watch, onMounted } from 'vue';

export function useDarkMode() {
  // 深色模式状态
  const isDarkMode = ref(false);
  
  // 应用深色模式到DOM
  const applyDarkMode = (dark) => {
    if (dark) {
      document.documentElement.classList.add('dark');
      localStorage.setItem('darkMode', 'true');
    } else {
      document.documentElement.classList.remove('dark');
      localStorage.setItem('darkMode', 'false');
    }
  };
  
  // 切换深色模式
  const toggleDarkMode = () => {
    isDarkMode.value = !isDarkMode.value;
  };
  
  // 组件挂载时初始化
  onMounted(() => {
    // 从localStorage读取保存的设置
    const savedDarkMode = localStorage.getItem('darkMode');
    // 检查系统偏好
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    
    // 确定初始模式
    if (savedDarkMode !== null) {
      isDarkMode.value = savedDarkMode === 'true';
    } else {
      isDarkMode.value = prefersDark;
    }
    
    // 应用初始模式
    applyDarkMode(isDarkMode.value);
  });
  
  // 监听模式变化并应用
  watch(isDarkMode, (newValue) => {
    applyDarkMode(newValue);
  });
  
  return {
    isDarkMode,
    toggleDarkMode
  };
}

5.3 useTimer - 定时器Hook

// hooks/useTimer.js
import { ref, onUnmounted, watch } from 'vue';

export function useTimer(initialSeconds = 0, autoStart = false) {
  // 定时器状态
  const seconds = ref(initialSeconds);
  const isRunning = ref(autoStart);
  let timerId = null;
  
  // 启动定时器
  const start = () => {
    if (!isRunning.value) {
      isRunning.value = true;
      timerId = setInterval(() => {
        seconds.value++;
      }, 1000);
    }
  };
  
  // 暂停定时器
  const pause = () => {
    if (isRunning.value) {
      isRunning.value = false;
      clearInterval(timerId);
      timerId = null;
    }
  };
  
  // 重置定时器
  const reset = (newSeconds = 0) => {
    pause();
    seconds.value = newSeconds;
  };
  
  // 组件卸载时清理定时器
  onUnmounted(() => {
    if (timerId) {
      clearInterval(timerId);
    }
  });
  
  // 当autoStart变化时自动启动/停止
  watch(
    () => autoStart,
    (newValue) => {
      if (newValue && !isRunning.value) {
        start();
      } else if (!newValue && isRunning.value) {
        pause();
      }
    }
  );
  
  // 格式化时间为 HH:MM:SS
  const formattedTime = ref('00:00:00');
  watch(seconds, (value) => {
    const hours = Math.floor(value / 3600).toString().padStart(2, '0');
    const minutes = Math.floor((value % 3600) / 60).toString().padStart(2, '0');
    const secs = (value % 60).toString().padStart(2, '0');
    formattedTime.value = `${hours}:${minutes}:${secs}`;
  }, { immediate: true });
  
  return {
    seconds,
    formattedTime,
    isRunning,
    start,
    pause,
    reset
  };
}

六、总结

自定义Hook是Vue3组合式API中强大的特性,它提供了一种优雅的方式来封装和复用组件逻辑。通过本文的学习,你应该掌握:

  1. 自定义Hook的基本概念:以use开头的函数,用于封装可复用逻辑
  2. 基础Hook的创建:如计数器、本地存储等简单Hook
  3. 高级Hook技巧:带参数和选项、异步操作、组合多个Hook
  4. 最佳实践:命名规范、代码组织、状态管理、错误处理
  5. 常见Hook示例:窗口大小、深色模式、定时器等实用Hook

合理使用自定义Hook可以显著提高代码复用性和可维护性,使组件代码更加清晰和专注于UI渲染。在实际项目中,建议根据业务需求开发适合的自定义Hook,并遵循本文介绍的最佳实践。

随着对自定义Hook的深入理解和应用,你将能够构建更加模块化、可扩展的Vue3应用程序。

Logo

火山引擎开发者社区是火山引擎打造的AI技术生态平台,聚焦Agent与大模型开发,提供豆包系列模型(图像/视频/视觉)、智能分析与会话工具,并配套评测集、动手实验室及行业案例库。社区通过技术沙龙、挑战赛等活动促进开发者成长,新用户可领50万Tokens权益,助力构建智能应用。

更多推荐