【AI 编程实战】第 9 篇:Hooks 封装的艺术 - 让逻辑复用更优雅

首页 编程分享 JQUERY丨JS丨VUE 正文

HashTang 转载 编程分享 2026-01-27 20:13:56

简介 一、开篇:为什么需要 Hooks 1.1 组件复用 vs 逻辑复用 上一篇讲了组件封装,解决的是 UI 复用: 但还有一类复用问题:逻辑复用。 问题: 每个页面都要定义 loading、error、d


组件封装解决了 UI 复用的问题,但还有一类复用更隐蔽——逻辑复用。同样的请求状态管理、同样的登录检查、同样的上传流程,在多个页面重复出现。这篇文章以心动恋聊小程序为例,展示如何和 AI 对话,将重复逻辑封装成优雅、可复用、易测试的 Hooks。

系列专栏【AI 编程实战】专栏目录

本篇主题:Hooks 封装的艺术 - 让逻辑复用更优雅

实战项目:心动恋聊 - AI 恋爱聊天助手

一、开篇:为什么需要 Hooks

1.1 组件复用 vs 逻辑复用

上一篇讲了组件封装,解决的是 UI 复用

<!-- 同样的按钮样式,封装成 XButton -->
<XButton text="提交" @click="handleSubmit" />

但还有一类复用问题:逻辑复用

// 页面 A:请求用户信息
const loading = ref(false);
const error = ref(null);
const userInfo = ref(null);

const fetchUserInfo = async () => {
  loading.value = true;
  try {
    const res = await getUserInfo();
    userInfo.value = res.data;
  } catch (e) {
    error.value = e;
  } finally {
    loading.value = false;
  }
};

// 页面 B:请求商品列表(几乎一样的逻辑)
const loading = ref(false);
const error = ref(null);
const productList = ref([]);

const fetchProducts = async () => {
  loading.value = true;
  try {
    const res = await getProducts();
    productList.value = res.data;
  } catch (e) {
    error.value = e;
  } finally {
    loading.value = false;
  }
};

问题

  • 每个页面都要定义 loading、error、data
  • 每个页面都要写 try-catch-finally
  • 修改请求逻辑(如加超时)要改多处

1.2 Hooks 是什么

Hooks(也叫 Composables)是 Vue 3 组合式 API 的核心概念:

// 把重复的逻辑封装成函数
function useRequest<T>(fetcher: () => Promise<T>) {
  const loading = ref(false);
  const error = ref(null);
  const data = ref<T | null>(null);

  const run = async () => {
    loading.value = true;
    try {
      data.value = await fetcher();
    } catch (e) {
      error.value = e;
    } finally {
      loading.value = false;
    }
  };

  return { loading, error, data, run };
}

// 使用时
const { loading, data, run } = useRequest(() => getUserInfo());

1.3 本文要封装的 Hooks

Hook 功能 解决的问题
useRequest 请求状态管理 loading/error/data 重复定义
useUpload 文件上传 选择、校验、上传流程复杂
useLoginFlow 登录流程 多平台登录逻辑不一致
useSystemInfo 系统信息 状态栏高度、页面高度计算
usePrivacyPolicy 隐私政策 App 端隐私弹窗管理

二、请求状态管理:useRequest

2.1 我的需求

我:每个页面都要写 loading、error、try-catch,太繁琐了。
    能不能封装一个通用的请求 Hook?

2.2 AI 询问细节

AI:好的,我来帮你设计 useRequest。先确认几个问题:

🤔 问题:
1. 是否需要"立即执行"选项?(页面加载时自动请求)
2. 是否需要初始数据?(用于列表的空数组等)
3. 返回值格式是什么?(直接返回 data 还是包装对象)

这些会影响接口设计。

2.3 我补充信息

我:需要"立即执行"选项,默认 false。
    需要初始数据,比如列表默认是空数组。
    返回值用包装对象 { loading, error, data, run }。

2.4 AI 设计接口

AI:明白了,我来设计接口:

📐 接口设计:

// 选项
interface UseRequestOptions<T> {
  immediate?: boolean;   // 是否立即执行,默认 false
  initialData?: T;       // 初始数据
}

// 返回值
interface UseRequestReturn<T> {
  loading: Ref<boolean>;
  error: Ref<Error | null>;
  data: Ref<T | undefined>;
  run: () => Promise<T | undefined>;
}

// 函数签名
function useRequest<T>(
  fetcher: () => Promise<IResData<T>>,
  options?: UseRequestOptions<T>
): UseRequestReturn<T>

🎯 设计要点:
1. 泛型 T 保证类型安全
2. run 返回 Promise,支持链式调用
3. fetcher 返回 IResData<T>,自动提取 .data

2.5 AI 生成代码

// src/hooks/useRequest.ts
import type { Ref } from 'vue';

interface IUseRequestOptions<T> {
  /** 是否立即执行 */
  immediate?: boolean;
  /** 初始化数据 */
  initialData?: T;
}

interface IUseRequestReturn<T> {
  loading: Ref<boolean>;
  error: Ref<boolean | Error>;
  data: Ref<T | undefined>;
  run: () => Promise<T | undefined>;
}

/**
 * 请求状态管理 Hook
 * @param func 请求函数,返回 Promise<IResData<T>>
 * @param options 配置选项
 */
export default function useRequest<T>(
  func: () => Promise<IResData<T>>,
  options: IUseRequestOptions<T> = { immediate: false },
): IUseRequestReturn<T> {
  const loading = ref(false);
  const error = ref<boolean | Error>(false);
  const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>;

  const run = async () => {
    loading.value = true;
    return func()
      .then((res) => {
        data.value = res.data;
        error.value = false;
        return data.value;
      })
      .catch((err) => {
        error.value = err;
        throw err;
      })
      .finally(() => {
        loading.value = false;
      });
  };

  // 立即执行
  if (options.immediate) {
    run();
  }

  return { loading, error, data, run };
}

2.6 使用示例

// 基础用法:手动触发
const { loading, data, run } = useRequest(() => getUserInfo());

onMounted(() => {
  run();
});

// 立即执行
const { loading, data } = useRequest(
  () => getProductList(),
  { immediate: true, initialData: [] }
);

// 带参数的请求
const productId = ref(1);
const { data, run: fetchProduct } = useRequest(
  () => getProductDetail(productId.value)
);

// 参数变化时重新请求
watch(productId, () => {
  fetchProduct();
});

2.7 我追问:为什么用 Promise 链式写法

我:为什么用 .then().catch().finally(),而不是 try-catch?
AI:两种写法都可以,但 Promise 链式有个优势:

📊 对比:

【try-catch 写法】
const run = async () => {
  loading.value = true;
  try {
    const res = await func();
    data.value = res.data;
    error.value = false;
    return data.value;
  } catch (err) {
    error.value = err;
    throw err;
  } finally {
    loading.value = false;
  }
};

【Promise 链式写法】
const run = async () => {
  loading.value = true;
  return func()
    .then((res) => { ... })
    .catch((err) => { ... })
    .finally(() => { ... });
};

📌 链式写法的优势:
1. 更函数式,流程更清晰
2. 返回 Promise,调用方可以继续 .then()
3. finally 语义更明确

两种写法效果一样,看团队习惯选择。

三、文件上传:useUpload

3.1 我的需求

我:项目里有几个地方要上传图片:头像、聊天图片、反馈截图。
    每次都要写选择文件、校验大小、上传、处理结果,太繁琐了。

3.2 AI 分析复杂度

AI:文件上传确实复杂,需要处理几个问题:

🔍 复杂度分析:

1. 多平台差异
   - 小程序:uni.chooseMedia(新 API)
   - H5:uni.chooseImage
   - App:uni.chooseImage

2. 文件校验
   - 大小限制
   - 类型限制

3. 上传流程
   - 选择 → 校验 → 上传 → 回调

4. 状态管理
   - loading、error、data

📐 接口设计:

interface UseUploadOptions {
  maxSize?: number;        // 最大文件大小,默认 5MB
  accept?: string[];       // 允许的文件类型
  fileType?: 'image' | 'file';  // 文件类型
  formData?: object;       // 额外的表单数据
  success?: (data) => void;  // 成功回调
  error?: (err) => void;     // 失败回调
}

3.3 AI 生成关键代码

// src/hooks/useUpload.ts
import { ref } from 'vue';
import { getEnvBaseUploadUrl } from '@/utils';

const UPLOAD_URL = getEnvBaseUploadUrl();

type TFileType = 'image' | 'file';

interface UseUploadOptions<T extends TFileType> {
  formData?: Record<string, any>;
  maxSize?: number;  // 默认 5MB
  accept?: string[];
  fileType?: T;
  success?: (data: any) => void;
  error?: (err: any) => void;
}

export default function useUpload<T extends TFileType>(
  options: UseUploadOptions<T> = {}
) {
  const {
    formData = {},
    maxSize = 5 * 1024 * 1024,
    fileType = 'image',
    success,
    error: onError,
  } = options;

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

  // 处理文件选择后的逻辑
  const handleFileChoose = ({ tempFilePath, size }: { tempFilePath: string; size: number }) => {
    // 校验文件大小
    if (size > maxSize) {
      uni.showToast({
        title: `文件大小不能超过 ${maxSize / 1024 / 1024}MB`,
        icon: 'none',
      });
      return;
    }

    loading.value = true;

    // 上传文件
    uni.uploadFile({
      url: UPLOAD_URL,
      filePath: tempFilePath,
      name: 'file',
      formData,
      success: (res) => {
        try {
          const result = JSON.parse(res.data);
          data.value = result.data;
          success?.(result.data);
        } catch (err) {
          error.value = err as Error;
          onError?.(err);
        }
      },
      fail: (err) => {
        error.value = err as Error;
        onError?.(err);
      },
      complete: () => {
        loading.value = false;
      },
    });
  };

  // 触发文件选择
  const run = () => {
    const chooseOptions = {
      count: 1,
      success: (res: any) => {
        // 多平台适配
        let tempFilePath = '';
        let size = 0;

        // #ifdef MP-WEIXIN
        tempFilePath = res.tempFiles[0].tempFilePath;
        size = res.tempFiles[0].size;
        // #endif

        // #ifndef MP-WEIXIN
        tempFilePath = res.tempFilePaths[0];
        size = res.tempFiles[0].size;
        // #endif

        handleFileChoose({ tempFilePath, size });
      },
      fail: (err: any) => {
        error.value = err;
        onError?.(err);
      },
    };

    if (fileType === 'image') {
      // #ifdef MP-WEIXIN
      uni.chooseMedia({
        ...chooseOptions,
        mediaType: ['image'],
      });
      // #endif

      // #ifndef MP-WEIXIN
      uni.chooseImage(chooseOptions);
      // #endif
    } else {
      uni.chooseFile({
        ...chooseOptions,
        type: 'all',
      });
    }
  };

  return { loading, error, data, run };
}

3.4 使用示例

<template>
  <view class="avatar-upload" @click="handleUpload">
    <image v-if="avatarUrl" :src="avatarUrl" class="avatar" />
    <view v-else class="placeholder">
      <text v-if="loading">上传中...</text>
      <text v-else>点击上传</text>
    </view>
  </view>
</template>

<script setup>
import useUpload from '@/hooks/useUpload';

const avatarUrl = ref('');

const { loading, run: handleUpload } = useUpload({
  maxSize: 2 * 1024 * 1024,  // 2MB
  fileType: 'image',
  success: (data) => {
    avatarUrl.value = data.url;
    toast.success('上传成功');
  },
  error: (err) => {
    toast.error('上传失败');
  },
});
</script>

3.5 我追问多平台适配

我:为什么小程序和 H5 的代码不一样?
AI:微信小程序和 H5/App 的 API 返回值格式不同:

📊 返回值对比:

【小程序 chooseMedia】
res = {
  tempFiles: [{
    tempFilePath: 'wxfile://xxx',
    size: 12345,
    fileType: 'image'
  }]
}

【H5/App chooseImage】
res = {
  tempFilePaths: ['blob:http://xxx'],
  tempFiles: [File]  // File 对象
}

📌 差异点:
1. 小程序返回 tempFilePath 在对象内
2. H5/App 返回 tempFilePaths 数组

所以需要用条件编译区分:
// #ifdef MP-WEIXIN
tempFilePath = res.tempFiles[0].tempFilePath;
// #endif

// #ifndef MP-WEIXIN
tempFilePath = res.tempFilePaths[0];
// #endif

四、登录流程:useLoginFlow

4.1 我的需求

我:项目要同时支持小程序和 App。
    小程序用弹窗登录,App 用页面登录。
    能不能统一封装?

4.2 AI 设计多平台适配

AI:可以用条件编译实现多平台统一:

📐 设计思路:

useLoginFlow
├── checkAuth()       // 检查登录态,未登录则触发登录
├── openLoginModal()  // 打开登录(自动判断平台)
│   ├── 小程序 → 打开 LoginModal 弹窗
│   └── App → 跳转登录页面
└── handleLoginSuccess()  // 登录成功回调

📐 接口设计:

function useLoginFlow() {
  return {
    checkAuth: (options?) => Promise<boolean>,
    openLoginModal: (options?) => void,
    handleLoginSuccess: (userInfo?) => void,
  };
}

4.3 AI 生成代码

// src/composables/useLoginFlow.ts
import type { UserInfo } from 'shared-types';
import { useUserStore } from '@/store/user';
import { useLoginModalStore } from '@/store/loginModal';

type LoginSuccessHandler = (userInfo?: UserInfo) => void | Promise<void>;

/**
 * 统一登录流程 Hook
 * - App 端:跳转到登录页
 * - 小程序端:打开 LoginModal 弹窗
 */
export function useLoginFlow() {
  const loginModalStore = useLoginModalStore();
  const userStore = useUserStore();

  // 基础登录成功处理
  const baseHandleLoginSuccess = async (loginUserInfo?: UserInfo) => {
    if (loginUserInfo) {
      userStore.setUserInfo(loginUserInfo);
    }
    await userStore.loadUserInfo();
  };

  // 合并自定义回调和基础回调
  const combineHandlers = (handler?: LoginSuccessHandler): LoginSuccessHandler => {
    if (!handler) {
      return baseHandleLoginSuccess;
    }
    return async (userInfo) => {
      await baseHandleLoginSuccess(userInfo);
      await handler(userInfo);
    };
  };

  /**
   * 打开登录(自动判断平台)
   */
  const openLoginModal = async (options?: { onSuccess?: LoginSuccessHandler }) => {
    // #ifdef APP || APP-HARMONY
    // App 端:跳转到登录页
    uni.navigateTo({
      url: '/pages/login/index',
    });
    return;
    // #endif

    // 小程序端:打开 LoginModal 弹窗
    loginModalStore.open({
      onSuccess: combineHandlers(options?.onSuccess),
    });
  };

  /**
   * 检查登录态,未登录则触发登录
   * @returns 是否已登录
   */
  const checkAuth = async (options?: { onSuccess?: LoginSuccessHandler }) => {
    if (!userStore.isLoggedIn) {
      await openLoginModal(options);
      return false;
    }
    return true;
  };

  return {
    checkAuth,
    openLoginModal,
    handleLoginSuccess: baseHandleLoginSuccess,
  };
}

4.4 使用示例

<script setup>
import { useLoginFlow } from '@/composables/useLoginFlow';

const { checkAuth } = useLoginFlow();

// 需要登录才能执行的操作
const handleSendMessage = async () => {
  const isLoggedIn = await checkAuth({
    onSuccess: () => {
      console.log('登录成功,继续发送');
    },
  });

  if (!isLoggedIn) {
    // 用户正在登录流程中,等待回调
    return;
  }

  // 已登录,直接发送
  doSendMessage();
};

// 直接触发登录
const handleLoginClick = () => {
  openLoginModal({
    onSuccess: (userInfo) => {
      toast.success(`欢迎回来,${userInfo?.username}`);
    },
  });
};
</script>

4.5 我追问:回调合并的设计

我:combineHandlers 是做什么的?为什么要合并回调?
AI:combineHandlers 确保"基础处理"和"自定义处理"都执行:

📊 场景分析:

【只有基础处理】
用户登录成功 → setUserInfo → loadUserInfo

【有自定义处理】
用户登录成功 → setUserInfo → loadUserInfo → 自定义逻辑

📐 合并逻辑:

const combineHandlers = (handler?) => {
  if (!handler) {
    return baseHandleLoginSuccess;  // 只有基础
  }
  return async (userInfo) => {
    await baseHandleLoginSuccess(userInfo);  // 先执行基础
    await handler(userInfo);  // 再执行自定义
  };
};

📌 好处:
1. 基础处理(设置用户信息)不会被遗漏
2. 自定义处理可以使用最新的用户信息
3. 调用方不用关心基础处理的细节

五、系统信息:useSystemInfo

5.1 我的需求

我:很多页面需要获取状态栏高度、计算页面可用高度。
    每次都 uni.getSystemInfoSync() 太繁琐,而且可能有性能问题。

5.2 AI 设计单例模式

AI:系统信息只需要获取一次,可以用单例模式:

📐 设计思路:

1. 模块级变量存储结果(只初始化一次)
2. 多个 Hook 共享同一份数据
3. 提供计算属性(如页面高度)

📐 Hook 拆分:

useSystemInfo()     // 获取完整系统信息
useStatusBarHeight()  // 只获取状态栏高度
usePageHeightStyle()  // 计算页面高度样式

5.3 AI 生成代码

// src/composables/useSystemInfo.ts
import { computed, ref, type Ref } from 'vue';
import { tabbarConfig } from '@/tabbar/config';

// 模块级变量(单例)
let initialized = false;
const statusBarHeightRef = ref(0);
const systemInfoRef = ref<UniApp.GetSystemInfoResult | null>(null);

// 确保只初始化一次
function ensureSystemInfo() {
  if (!initialized) {
    initialized = true;
    try {
      systemInfoRef.value = uni.getSystemInfoSync();
    } catch {
      systemInfoRef.value = null;
    }
    statusBarHeightRef.value = systemInfoRef.value?.statusBarHeight ?? 0;
  }
  return systemInfoRef.value;
}

/**
 * 获取系统信息
 */
export function useSystemInfo() {
  const info = ensureSystemInfo();
  return {
    systemInfo: info,
    statusBarHeight: statusBarHeightRef,
  };
}

/**
 * 只获取状态栏高度
 */
export function useStatusBarHeight() {
  ensureSystemInfo();
  return statusBarHeightRef;
}

/**
 * 计算页面可用高度样式
 */
interface PageHeightOptions {
  includeStatusBar?: boolean;  // 是否减去状态栏,默认 true
  headerHeight?: number;       // 顶部固定头部高度
  includeTabbar?: boolean;     // 是否减去 TabBar,默认 true
  includeBottomSafeArea?: boolean;  // 是否减去底部安全区
  extraOffset?: string | number;    // 额外偏移
}

export function usePageHeightStyle(options: PageHeightOptions = {}) {
  ensureSystemInfo();

  const {
    includeStatusBar = true,
    headerHeight = 0,
    includeTabbar = true,
    includeBottomSafeArea = true,
    extraOffset,
  } = options;

  // 获取 TabBar 高度
  const tabbarHeight = (() => {
    const h = tabbarConfig?.height;
    if (!h) return 0;
    if (typeof h === 'number') return h;
    if (typeof h === 'string' && h.endsWith('px')) {
      return parseFloat(h) || 0;
    }
    return 0;
  })();

  return computed(() => {
    const segments: string[] = ['100vh'];

    // 减去状态栏
    if (includeStatusBar) {
      segments.push(`- ${statusBarHeightRef.value}px`);
    }

    // 减去头部
    if (headerHeight > 0) {
      segments.push(`- ${headerHeight}px`);
    }

    // 减去 TabBar
    if (includeTabbar && tabbarHeight > 0) {
      segments.push(`- ${tabbarHeight}px`);
    }

    // 减去额外偏移
    if (extraOffset !== undefined) {
      if (typeof extraOffset === 'string') {
        segments.push(`- ${extraOffset}`);
      } else {
        segments.push(`- ${extraOffset}px`);
      }
    }

    // 减去底部安全区
    if (includeBottomSafeArea) {
      segments.push('- env(safe-area-inset-bottom)');
    }

    return {
      height: `calc(${segments.join(' ')})`,
    };
  });
}

5.4 使用示例

<template>
  <!-- 滚动区域,自动计算高度 -->
  <scroll-view :style="pageHeightStyle" scroll-y>
    <view v-for="item in list" :key="item.id">
      {{ item.name }}
    </view>
  </scroll-view>
</template>

<script setup>
import { usePageHeightStyle } from '@/composables/useSystemInfo';

// 自动计算:100vh - 状态栏 - TabBar - 安全区
const pageHeightStyle = usePageHeightStyle();

// 有自定义导航栏的页面
const pageHeightStyle = usePageHeightStyle({
  headerHeight: 44,  // 自定义导航栏高度
});

// 不需要减去 TabBar 的页面
const pageHeightStyle = usePageHeightStyle({
  includeTabbar: false,
});
</script>

5.5 我追问单例模式

我:为什么用模块级变量而不是 ref?
AI:为了实现"跨组件共享"和"只初始化一次":

📊 对比:

【方式一:Hook 内部 ref】
export function useSystemInfo() {
  const systemInfo = ref(null);  // 每次调用都创建新的
  systemInfo.value = uni.getSystemInfoSync();  // 每次都调用
  return { systemInfo };
}
// 问题:调用 100 次就初始化 100 次

【方式二:模块级变量(单例)】
let initialized = false;
const systemInfoRef = ref(null);  // 模块级,只有一份

export function useSystemInfo() {
  if (!initialized) {
    initialized = true;
    systemInfoRef.value = uni.getSystemInfoSync();  // 只调用一次
  }
  return { systemInfo: systemInfoRef };
}
// 优点:无论调用多少次,只初始化一次

📌 适用场景:
- 系统信息(不会变)
- 全局配置(加载一次)
- 共享状态(多组件访问)

六、隐私政策:usePrivacyPolicy

6.1 我的需求

我:App 端要在启动时显示隐私政策弹窗,用户同意后才能继续使用。
    小程序不需要这个弹窗。

6.2 AI 设计条件编译方案

AI:用条件编译隔离平台差异:

📐 设计思路:

1. 使用条件编译,小程序端直接返回 false
2. App 端检查本地存储,决定是否显示
3. 提供同意/拒绝方法

📐 接口设计:

function usePrivacyPolicy() {
  return {
    showPrivacyModal: Ref<boolean>,  // 是否显示弹窗
    checkPrivacyAgreement: () => void,  // 检查并触发弹窗
    handlePrivacyAgree: () => void,   // 用户同意
    handlePrivacyDisagree: () => void, // 用户拒绝
  };
}

6.3 AI 生成代码

// src/composables/usePrivacyPolicy.ts
import { ref } from 'vue';

const PRIVACY_AGREED_KEY = 'privacy_policy_agreed';

// 检查是否需要显示弹窗
const shouldShowPrivacyModal = () => {
  // #ifdef APP || APP-HARMONY
  const agreed = uni.getStorageSync(PRIVACY_AGREED_KEY);
  return !agreed;
  // #endif

  // #ifndef APP || APP-HARMONY
  return false;  // 非 App 平台不显示
  // #endif
};

// 全局状态
const showPrivacyModal = ref(false);
const privacyChecked = ref(false);

export const usePrivacyPolicy = () => {
  /**
   * 检查是否需要展示隐私政策弹窗
   */
  const checkPrivacyAgreement = () => {
    if (privacyChecked.value) return;

    // #ifdef APP || APP-HARMONY
    const needShow = shouldShowPrivacyModal();
    if (needShow) {
      // 延迟显示,让页面先完成加载
      setTimeout(() => {
        showPrivacyModal.value = true;
      }, 800);
    }
    privacyChecked.value = true;
    // #endif
  };

  /**
   * 用户同意隐私政策
   */
  const handlePrivacyAgree = () => {
    // #ifdef APP || APP-HARMONY
    uni.setStorageSync(PRIVACY_AGREED_KEY, 'true');
    showPrivacyModal.value = false;
    // #endif
  };

  /**
   * 用户拒绝隐私政策
   */
  const handlePrivacyDisagree = () => {
    // #ifdef APP-PLUS || APP-HARMONY
    if (typeof plus !== 'undefined' && plus.runtime) {
      plus.runtime.quit();  // 退出应用
    } else {
      uni.navigateBack({ delta: 999 });
    }
    // #endif
  };

  return {
    showPrivacyModal,
    checkPrivacyAgreement,
    handlePrivacyAgree,
    handlePrivacyDisagree,
  };
};

6.4 使用示例

<!-- App.vue -->
<template>
  <PrivacyPolicyModal
    v-model="showPrivacyModal"
    @agree="handlePrivacyAgree"
    @disagree="handlePrivacyDisagree"
  />
</template>

<script setup>
import { usePrivacyPolicy } from '@/composables/usePrivacyPolicy';

const {
  showPrivacyModal,
  checkPrivacyAgreement,
  handlePrivacyAgree,
  handlePrivacyDisagree,
} = usePrivacyPolicy();

onLaunch(() => {
  checkPrivacyAgreement();
});
</script>

七、Hooks 封装的核心经验

7.1 命名规范

类型 命名 示例
状态管理 Hook use + 名词 useRequest, useUpload
行为 Hook use + 动词 useLoginFlow, useCheckAuth
数据获取 Hook use + 数据名 useUserInfo, useSystemInfo

7.2 返回值设计

// ✅ 推荐:返回对象,解构使用
function useRequest() {
  return { loading, error, data, run };
}
const { loading, data, run } = useRequest();

// ❌ 不推荐:返回数组,位置敏感
function useRequest() {
  return [loading, error, data, run];
}
const [loading, _, data] = useRequest();  // 需要占位符

7.3 状态隔离 vs 共享

// 隔离状态:每次调用都是独立的
function useRequest() {
  const loading = ref(false);  // 组件级状态
  return { loading };
}

// 共享状态:多次调用共享同一份
const globalLoading = ref(false);  // 模块级状态
function useGlobalLoading() {
  return { loading: globalLoading };
}

7.4 平台适配模式

// ✅ 推荐:条件编译隔离
const openLoginModal = () => {
  // #ifdef APP
  uni.navigateTo({ url: '/pages/login' });
  // #endif

  // #ifdef MP-WEIXIN
  loginModalStore.open();
  // #endif
};

// ❌ 不推荐:运行时判断
const openLoginModal = () => {
  if (process.env.UNI_PLATFORM === 'app') {
    uni.navigateTo({ url: '/pages/login' });
  } else {
    loginModalStore.open();
  }
};

八、总结:Hooks 封装方法论

8.1 何时封装 Hook

需要封装 Hook 吗?
    ├── 同样的状态管理出现 3 次以上 → ✅ 封装
    ├── 复杂的异步流程 → ✅ 封装
    ├── 多平台差异逻辑 → ✅ 封装
    ├── 只用一次的简单逻辑 → ❌ 不封装
    └── 纯计算逻辑 → ⚠️ 考虑用普通函数

8.2 本文封装的 Hooks 清单

Hook 类型 核心功能 设计要点
useRequest 状态管理 请求状态封装 泛型、立即执行、初始数据
useUpload 流程封装 文件上传 多平台适配、大小校验
useLoginFlow 行为封装 登录流程 条件编译、回调合并
useSystemInfo 数据获取 系统信息 单例模式、计算属性
usePrivacyPolicy 状态管理 隐私弹窗 条件编译、全局状态

8.3 下一篇预告

《【AI 编程实战】第 10 篇:错误处理与边界情况 - 让应用更健壮》

下一篇展示如何处理各种异常情况:

  • 网络错误的统一处理
  • Token 过期的自动刷新
  • 边界情况的防御性编程

Hooks 不是"把代码挪到另一个文件",而是封装可复用的逻辑单元。 好的 Hook 应该:职责单一、接口清晰、状态隔离、平台无关。

如果这篇文章对你有帮助,请点赞、收藏、转发!

转载链接:https://juejin.cn/post/7599232628185235491


Tags:


本篇评论 —— 揽流光,涤眉霜,清露烈酒一口话苍茫。


    声明:参照站内规则,不文明言论将会删除,谢谢合作。


      最新评论




ABOUT ME

Blogger:袅袅牧童 | Arkin

Ido:PHP攻城狮

WeChat:nnmutong

Email:nnmutong@icloud.com

标签云