组件封装解决了 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 应该:职责单一、接口清晰、状态隔离、平台无关。
如果这篇文章对你有帮助,请点赞、收藏、转发!