- Update yarn.lock - Add project implementation docs in docs/ - Add personal internship experience notes in 实习讲解/
769 lines
29 KiB
Markdown
769 lines
29 KiB
Markdown
# Composition API Hooks 逻辑复用 — 面试版
|
||
|
||
> 简历原话:**"封装 Composition API Hooks 实现逻辑复用"**
|
||
>
|
||
> 这篇文档帮你理解项目里到底封装了哪些 Hooks、怎么复用的,以及面试时怎么讲。
|
||
|
||
---
|
||
|
||
## 一、先搞清楚:什么是 Hooks?为什么要封装?
|
||
|
||
### 问题场景
|
||
|
||
```
|
||
没有 Hooks 的时候:
|
||
页面A:需要响应式布局 → 写一堆 width 判断逻辑
|
||
页面B:也需要响应式布局 → 再写一遍 width 判断逻辑
|
||
页面C:也需要响应式布局 → 又写一遍...
|
||
|
||
页面D:需要获取仓库权限 → 调 API、存 ref、写 computed
|
||
页面E:也需要获取仓库权限 → 同样的代码再写一遍...
|
||
```
|
||
|
||
**痛点**:相同的逻辑散落在多个组件里,改了 A 忘改 B,代码臃肿、维护困难。
|
||
|
||
### 有了 Hooks 之后
|
||
|
||
```
|
||
封装一个 usePageResize() Hook:
|
||
页面A → const { widthType } = usePageResize()
|
||
页面B → const { widthType } = usePageResize()
|
||
页面C → const { widthType } = usePageResize()
|
||
26 个文件复用同一套逻辑,改 Hook 一处,全局生效
|
||
```
|
||
|
||
**一句话概括**:把可复用的响应式逻辑封装成独立的 `useXxx()` 函数,哪个组件需要就直接调用,避免重复代码。这是 Vue3 Composition API 最核心的设计思想。
|
||
|
||
### 项目中 Hooks 的定位
|
||
|
||
```
|
||
┌────────────────────────────────────────────────────────┐
|
||
│ Vue 组件层 │
|
||
│ Security/index.vue Ecosystem/index.vue AI/index.vue │
|
||
└────────┬───────────┬───────────┬──────────────────────┘
|
||
│ │ │ 调用 useXxx()
|
||
▼ ▼ ▼
|
||
┌────────────────────────────────────────────────────────┐
|
||
│ Hooks 逻辑层(30+ 个) │
|
||
│ usePageResize useRepoId usePagination useLogin ... │
|
||
└────────┬───────────┬───────────┬──────────────────────┘
|
||
│ │ │
|
||
▼ ▼ ▼
|
||
┌────────────────────────────────────────────────────────┐
|
||
│ Pinia Store / API / Router │
|
||
│ (底层数据源和基础能力) │
|
||
└────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 二、项目里有 30+ 个 Hooks,分六大类
|
||
|
||
| 分类 | Hooks | 数量 | 核心思想 |
|
||
|------|-------|------|----------|
|
||
| **通用交互** | usePageResize、useShowMore、useModel、usePagination、useLazyImport | 5+ | 跨组件复用的 UI 交互逻辑 |
|
||
| **表单与认证** | useFormInteraction、useLogin、useAccount、useAccModal、useNotice、useLoginCheck | 6+ | 登录/注册/表单校验的完整流程 |
|
||
| **数据获取** | useReq/useAsync、useRepoInit、useWikiHome/create/detail/history、useUserDashboard、useRepoList | 9+ | 异步请求 + 状态管理的一站式封装 |
|
||
| **路由/参数** | useRepoId、useOrgId、useBranchName、useTitle、useNav、useRepoPathValidate | 6+ | URL 参数解析、导航控制 |
|
||
| **权限控制** | useUserAccessLevel、usePagePermission、useIsPrivate | 3 | 权限判断 + computed 封装 |
|
||
| **其他辅助** | useFile、usePopup、useReport、useTimeFormat、useStarFollow、useUserInfo、useDiscussGetUserInfo 等 | 7+ | 文件图标、弹窗管理、埋点上报等 |
|
||
|
||
---
|
||
|
||
## 三、每个分类怎么实现的?(面试核心)
|
||
|
||
### 3.1 通用交互 Hooks(最常用)
|
||
|
||
#### usePageResize — 响应式断点判断(26 个文件复用)
|
||
|
||
```typescript
|
||
import { computed } from 'vue';
|
||
import { useWindowSize } from '@vueuse/core';
|
||
|
||
const { width } = useWindowSize();
|
||
|
||
export const usePageResize = () => {
|
||
const widthConfig = { xxl: 1536, xl: 1280, md: 1024, lg: 768 };
|
||
|
||
const widthType = computed(() => {
|
||
if (width.value > 1536) return 'xxl';
|
||
if (width.value > 1280) return 'xl';
|
||
if (width.value > 1024) return 'md';
|
||
if (width.value > 768) return 'lg';
|
||
return 'sm';
|
||
});
|
||
|
||
const isMobile = computed(() => width.value <= 1024);
|
||
|
||
return { widthType, width, isMobile };
|
||
};
|
||
```
|
||
|
||
**怎么用**:
|
||
```vue
|
||
<script setup>
|
||
const { widthType, isMobile } = usePageResize();
|
||
// 根据不同断点渲染不同布局
|
||
</script>
|
||
```
|
||
|
||
**复用在**:15 个布局文件 + 4 个态势感知图表组件 + 导航栏、底部栏等,共 26 个文件。
|
||
|
||
**面试话术**:
|
||
> "封装了 usePageResize,基于 vueuse 的 useWindowSize 响应式监听窗口宽度,定义了 5 个断点(xxl/xl/md/lg/sm),返回 computed 类型的 widthType 和 isMobile。26 个布局和组件文件都在用,页面布局根据断点自动切换,不需要每个文件自己写一遍判断。"
|
||
|
||
---
|
||
|
||
#### useModel — v-model 双向绑定封装(18 个文件复用)
|
||
|
||
```typescript
|
||
// 把 v-model 的 prop + emit 封装成统一的写法
|
||
export function useModel(props, emits) {
|
||
const vModels = computed({
|
||
get() { return props.modelValue; },
|
||
set(val) { emits('update:modelValue', val); }
|
||
});
|
||
return { vModels };
|
||
}
|
||
```
|
||
|
||
**解决什么**:Vue3 自定义组件里使用 v-model 需要写 `props.modelValue` + `emits('update:modelValue')`,每个弹窗/输入组件都写一遍很繁琐。这个 Hook 一行代码搞定。
|
||
|
||
**怎么用**:
|
||
```vue
|
||
<script setup>
|
||
const props = defineProps({ modelValue: Boolean });
|
||
const emits = defineEmits(['update:modelValue']);
|
||
const { vModels } = useModel(props, emits);
|
||
// vModels.value 就是双向绑定的值
|
||
</script>
|
||
```
|
||
|
||
**复用在**:18 个弹窗/表单组件(登录弹窗、标签选择、分支选择等)。
|
||
|
||
---
|
||
|
||
#### usePagination — 分页器封装
|
||
|
||
```typescript
|
||
export const usePagination = ({ storageKey, clientType }) => {
|
||
const pager = reactive({
|
||
page: 1, pageSize: 10, total: 0,
|
||
loading: false, isFirstLoad: true,
|
||
showLoading: false, showEmpty: false,
|
||
});
|
||
|
||
// 自动判断显示骨架屏还是 loading
|
||
watch(() => pager.loading, (value) => {
|
||
if (value) {
|
||
if (pager.isFirstLoad || pager.total === 0) {
|
||
pager.showLoading = false; // 首次加载用骨架屏
|
||
} else {
|
||
pager.showLoading = true; // 切换页用 loading
|
||
}
|
||
} else {
|
||
pager.showEmpty = pager.total === 0;
|
||
}
|
||
});
|
||
|
||
return { pager, pageOptions };
|
||
};
|
||
```
|
||
|
||
**亮点**:自动区分"首次加载"和"翻页加载",首次加载显示骨架屏(结构预览),翻页显示 loading 动画,体验更好。
|
||
|
||
---
|
||
|
||
#### useShowMore — 内容溢出检测
|
||
|
||
```typescript
|
||
export const useShowMore = (eleRef) => {
|
||
const isOver = ref(false); // 内容是否超出容器
|
||
|
||
const checkRange = () => {
|
||
isOver.value = eleRef.value.scrollHeight > eleRef.value.clientHeight;
|
||
};
|
||
|
||
useResizeObserver(eleRef, checkRange); // 利用 ResizeObserver 自动检测
|
||
|
||
return { isOver, showMore, onShowMore };
|
||
};
|
||
```
|
||
|
||
**解决什么**:很多卡片展示简介时只能显示 3 行,超出部分要显示"查看更多"。这个 Hook 自动检测内容是否溢出。
|
||
|
||
---
|
||
|
||
#### useLazyImport — 异步组件懒加载封装
|
||
|
||
```typescript
|
||
export function useLazyImport(loader, options = {}) {
|
||
return defineAsyncComponent({
|
||
loader,
|
||
loadingComponent: showLoading ? LoadingComponents : '',
|
||
timeout: 2000, // 2s 超时
|
||
delay: 100, // 100ms 后才显示 loading(避免闪烁)
|
||
onError: (_, retry, fail) => {
|
||
Message.error('加载失败');
|
||
fail();
|
||
}
|
||
});
|
||
}
|
||
```
|
||
|
||
**解决什么**:大组件异步加载时,统一处理 loading 状态、超时、错误重试。项目中有至少 6 个组件通过它注册为异步组件。
|
||
|
||
---
|
||
|
||
### 3.2 表单与认证 Hooks
|
||
|
||
#### useFormInteraction — 表单校验完整流程(最复杂,332 行)
|
||
|
||
这是项目中最大的 Hook,封装了整个表单交互逻辑:
|
||
|
||
```typescript
|
||
export function useFormInteraction(currentForm, flag, extraStatus) {
|
||
// 状态管理
|
||
const formErrors = reactive({}); // 表单校验错误
|
||
const disabled = ref(true); // 提交按钮禁用
|
||
const loading = ref(false); // 提交 loading
|
||
const FormRef = shallowRef(null); // 表单组件引用
|
||
const status = ref(flag); // 协议勾选状态
|
||
|
||
// 表单字段 change:实时校验单个字段
|
||
const handleFormChange = ({ key, errors }) => { ... };
|
||
|
||
// 表单 input:判断是否禁用提交
|
||
const handleFormInput = (val) => { disabled.value = val; };
|
||
|
||
// 表单 submit:校验全部 → 通过则回调
|
||
const handleSubmit = async (callback) => {
|
||
const formData = await FormRef.value.ValidateForm();
|
||
if (formData.type === 'success') {
|
||
await callback(formData.forms); // 执行提交逻辑
|
||
} else {
|
||
catchFormErrors(formData); // 展示错误
|
||
}
|
||
};
|
||
|
||
// 倒计时:发送验证码后 59 秒倒计时
|
||
const handleCountDown = async (conf, callback) => { ... };
|
||
|
||
// 第三方登录:OAuth 跳转
|
||
const handleAuthLogin = (type) => { ... };
|
||
|
||
return {
|
||
FormRef, formErrors, disabled, loading,
|
||
handleFormChange, handleFormInput, handleSubmit,
|
||
handleCountDown, handleAuthLogin, ...
|
||
};
|
||
}
|
||
```
|
||
|
||
**封装了什么**:表单校验、清错、提交、协议勾选、倒计时、第三方 OAuth 登录——所有登录/注册页的通用逻辑,全部封装在一个 Hook 里。
|
||
|
||
**怎么用**:
|
||
```vue
|
||
<script setup>
|
||
const { FormRef, disabled, loading, handleSubmit, handleFormChange } =
|
||
useFormInteraction(formList);
|
||
</script>
|
||
<template>
|
||
<Form ref="FormRef" @change="handleFormChange">
|
||
<!-- 表单内容 -->
|
||
</Form>
|
||
<Button :disabled="disabled" :loading="loading" @click="handleSubmit(onLogin)">
|
||
登录
|
||
</Button>
|
||
</template>
|
||
```
|
||
|
||
---
|
||
|
||
#### useLogin / useAccModal / useNotice — 弹窗类 Hooks
|
||
|
||
这三个 Hook 都基于 `usePopup` 封装,负责不同类型的弹窗:
|
||
|
||
```typescript
|
||
// useLogin — 登录弹窗
|
||
export function useLogin() {
|
||
const { mount, unMount, isMounted } = usePopup();
|
||
const login = (options) => {
|
||
if (isMounted()) return; // 防止重复弹出
|
||
mount(LoginModal, {
|
||
onLogin: (userInfo) => { unMount(); RecordInfo(userInfo); },
|
||
onClose: () => { unMount(); }
|
||
});
|
||
};
|
||
return { login };
|
||
}
|
||
|
||
// useAccModal — 快捷登录弹窗(支持 CSDN/Gitee/GitHub)
|
||
export function useAccModal() {
|
||
const { mount, unMount } = usePopup('g-acc-modal');
|
||
const openModal = () => {
|
||
if (isMounted()) return;
|
||
mount(AccModal, { onConfirm: (type) => { /* OAuth 跳转 */ } });
|
||
};
|
||
return { openModal };
|
||
}
|
||
|
||
// useNotice — 邮箱修改提醒弹窗
|
||
export function useNotification() {
|
||
const { mount, unMount } = usePopup('global-notification', document.body);
|
||
const notice = () => {
|
||
mount(NoticeModal, { onConfirm: () => { router.push('/setting/email'); } });
|
||
};
|
||
return { notice };
|
||
}
|
||
```
|
||
|
||
**设计模式**:三个弹窗 Hook 基于同一个 `usePopup` 底层能力,各自封装不同的弹窗组件和业务逻辑。调用方只需要一行:
|
||
```typescript
|
||
const { login } = useLogin();
|
||
login({ type: 'login' }); // 弹出登录窗口
|
||
```
|
||
|
||
---
|
||
|
||
#### useAccount — 用户信息增删改查
|
||
|
||
```typescript
|
||
export function useAccount() {
|
||
const userInfo = useAccountStore();
|
||
const { accountInfo } = storeToRefs(userInfo);
|
||
|
||
// 记录用户信息(登录时)
|
||
const RecordInfo = (source) => {
|
||
localStorage.setItem('op_access_token', source.op_access_token);
|
||
localStorage.setItem('opUserInfo', JSON.stringify(userInfo));
|
||
saveStatus(true);
|
||
saveAccountInfo(userInfo);
|
||
};
|
||
|
||
// 清除用户信息(退出时)
|
||
const RemoveInfo = () => {
|
||
localStorage.removeItem('op_access_token');
|
||
// ... 清除 10+ 个 localStorage key
|
||
saveStatus(false);
|
||
saveAccountInfo();
|
||
};
|
||
|
||
return { RecordInfo, RemoveInfo, accountInfo };
|
||
}
|
||
```
|
||
|
||
**封装的逻辑**:Token 存 localStorage + Store 同步 + 登录状态更新。一次调用 RecordInfo 完成三件事,13 个文件复用。
|
||
|
||
---
|
||
|
||
### 3.3 数据获取 Hooks
|
||
|
||
#### useReq/useAsync — 异步请求封装(通用能力)
|
||
|
||
```typescript
|
||
export const useAsync = (fn, params, precondition, map) => {
|
||
const data = ref(null);
|
||
const error = ref(null);
|
||
const loading = ref(false);
|
||
|
||
watchEffect(() => {
|
||
if (!precondition()) return; // 前提条件不满足,不发请求
|
||
loading.value = true;
|
||
fn(params).then(res => data.value = map(res))
|
||
.catch(e => error.value = e)
|
||
.finally(() => loading.value = false);
|
||
});
|
||
|
||
return { data, error, loading, mutate }; // mutate 手动触发重新请求
|
||
};
|
||
```
|
||
|
||
**解决什么**:每个页面都要写 `data/loading/error` 三件套 + try/catch。这个 Hook 一行调用搞定,自动处理 loading 状态和错误捕获。
|
||
|
||
**面试话术**:
|
||
> "封装了 useAsync 和 useReq 两个通用请求 Hook。useAsync 接收请求函数、参数、前置条件和结果映射函数,自动管理 data/loading/error 三种状态。前置条件不满足时不会发请求(比如用户未登录时不发),通过 watchEffect 自动追踪依赖变化。还提供了 mutate 方法手动触发重新请求。"
|
||
|
||
---
|
||
|
||
#### useRepoInit — 仓库首页全部数据一站式初始化(243 行)
|
||
|
||
```typescript
|
||
export const useRepoInit = () => {
|
||
const { repoId } = useRepoId();
|
||
const repoInfo = reactive({...}); // 仓库信息
|
||
const loadingStatus = reactive({ // 5 个模块的加载状态
|
||
profileLoading: true,
|
||
readmeLoading: true,
|
||
eventsLoading: true,
|
||
contributorLoading: true,
|
||
releasesLoading: true,
|
||
});
|
||
|
||
const initRepoHeader = async () => { // 顶部初始化(快)
|
||
initRepoData(); await initNotice();
|
||
};
|
||
|
||
const initRepoDashboard = () => { // 首页初始化(完整)
|
||
initRepoData(); initEvents(); initReadme();
|
||
initContributors(); initRelease();
|
||
};
|
||
|
||
return {
|
||
repoInfo, loadingStatus, readmeText, timeData,
|
||
contributorList, releasesList,
|
||
initRepoHeader, initRepoDashboard, ...
|
||
};
|
||
};
|
||
```
|
||
|
||
**为什么这样设计**:仓库首页需要加载仓库信息、README、动态、贡献者、Release 等 5+ 个模块的数据。每个模块都有独立的 loading 状态。Hook 封装了它们的初始化时机(header 先加载,dashboard 完整加载),页面只管调用 `initRepoHeader()` 和 `initRepoDashboard()`。
|
||
|
||
---
|
||
|
||
#### useWikiHome / useWikiCreate / useWikiDetail / useWikiHistory — Wiki 全流程 Hooks
|
||
|
||
四个 Hook 覆盖 Wiki 的完整生命周期(首页 → 创建 → 详情 → 历史版本),每个 Hook 封装对应的数据获取 + 状态管理:
|
||
|
||
```
|
||
useWikiHome() → 首页:获取 Wiki 列表 + Home.md 内容
|
||
useWikiCreate() → 创建/编辑页:表单数据 + 提交 + 删除
|
||
useWikiDetail() → 详情页:内容 + 侧边栏 + 页脚
|
||
useWikiHistory() → 历史版本:版本列表 + 分页
|
||
```
|
||
|
||
**设计思想**:每个页面一个 Hook,页面组件只负责渲染,数据逻辑全部在 Hook 里。
|
||
|
||
---
|
||
|
||
#### useUserDashboard — 用户首页一站式数据初始化(272 行)
|
||
|
||
```typescript
|
||
export const useUserDashboard = () => {
|
||
// 6 个模块的数据
|
||
const userRepoList = ref([]); // 我的仓库
|
||
const userEventsList = ref({}); // 最近动态
|
||
const recommandRepoList = ref([]); // 推荐仓库
|
||
const recommandOrgList = ref([]); // 推荐组织
|
||
const userActivityList = ref([]); // 关注动态
|
||
const operationData = ref({}); // 运营信息
|
||
|
||
// 6 个模块的加载状态
|
||
const loadingStatus = reactive({
|
||
boardLoading: true, myRepoLoading: true,
|
||
eventLoading: true, repoRecmLoading: true,
|
||
orgRecmLoading: true, activityLoading: true,
|
||
});
|
||
|
||
const pageInit = async () => { // 一键初始化
|
||
loadingStatus.boardLoading = true;
|
||
await getUserInfo();
|
||
await getUserRepos();
|
||
loadingStatus.boardLoading = false;
|
||
getRecentEvents(); // 不阻塞首屏
|
||
getOperationData(); // 不阻塞首屏
|
||
recommandRepos();
|
||
recommandOrgs();
|
||
getUserActivity();
|
||
};
|
||
|
||
return { userInfo, userRepoList, loadingStatus, pageInit, ... };
|
||
};
|
||
```
|
||
|
||
**面试话术**:
|
||
> "比如 useUserDashboard 这个 Hook,封装了用户首页 6 个模块的数据获取——仓库列表、最近动态、推荐仓库、推荐组织、关注动态、运营信息,每个模块有独立的 loading 状态。pageInit 方法分层加载:先加载用户信息和仓库列表(阻塞首屏),然后并行加载其他 5 个模块(不阻塞首屏)。页面上只需要 `const { pageInit } = useUserDashboard(); onMounted(pageInit);` 两行代码。"
|
||
|
||
---
|
||
|
||
#### useRepoList — 用户仓库列表(含 Star 操作)
|
||
|
||
```typescript
|
||
export const useRepoList = (params) => {
|
||
const repoList = ref([]);
|
||
const allRepoList = ref([]);
|
||
const createdRepoList = ref([]);
|
||
const starredRepoList = ref([]);
|
||
const loading = ref(false);
|
||
|
||
// 四类仓库列表 + Star/Unstar 操作
|
||
const toggleRepoStar = ({ id, isStar }) => {
|
||
if (!userInfo.username) {
|
||
emitEvent('login', { triggerType: 'Star' }); // 未登录 → 弹出登录框
|
||
return;
|
||
}
|
||
isStar ? unstarRepo({ repoId: id }) : starRepo({ repoId: id });
|
||
init(); // 操作后重新拉取列表
|
||
};
|
||
|
||
return { allRepoList, createdRepoList, starredRepoList, toggleRepoStar, init };
|
||
};
|
||
```
|
||
|
||
**封装的逻辑**:四种仓库列表 + Star/Unstar 操作 + 未登录时自动弹出登录框。8 个文件复用。
|
||
|
||
---
|
||
|
||
### 3.4 路由/参数 Hooks
|
||
|
||
#### useRepoId — 从 URL 提取仓库 ID(30 个文件复用,使用最多)
|
||
|
||
```typescript
|
||
export const useRepoId = (connector = '%2F', namespace = '') => {
|
||
const route = useRoute();
|
||
const repoId = computed(() => {
|
||
const [, ns, repo, ...rest] = route.path.split('/');
|
||
if (namespace) {
|
||
return [ns, repo, ...rest].join(connector); // 拼接完整路径
|
||
}
|
||
return [ns, repo].join(connector); // namespace/repo
|
||
});
|
||
return { repoId };
|
||
};
|
||
```
|
||
|
||
**解决什么**:项目中大量页面需要从 URL 中提取仓库的 namespace/repoName(如 `gitcode/MyProject`)。30 个文件都在用这个 Hook,不需要每个文件自己解析 URL。
|
||
|
||
**类似的还有**:`useOrgId`(提取组织 ID)、`useBranchName`(提取分支名+编码)。
|
||
|
||
---
|
||
|
||
#### useTitle — 页面标题设置
|
||
|
||
```typescript
|
||
export const usePageTitle = (title, name) => {
|
||
if (title && name) {
|
||
pageTitle.value = `${title}`;
|
||
} else {
|
||
pageTitle.value = title || (name ? `${name} - GitCode` : 'Tcode');
|
||
}
|
||
};
|
||
```
|
||
|
||
路由守卫中每个页面切换时调用,自动更新浏览器标签页标题。
|
||
|
||
---
|
||
|
||
### 3.5 权限控制 Hooks
|
||
|
||
#### useUserAccessLevel — 权限查询 + 角色判断
|
||
|
||
```typescript
|
||
export function useUserAccessLevel({ type = 'org', targetId = '' } = {}) {
|
||
const access_level = ref(0);
|
||
|
||
async function getPermission(id) {
|
||
if (type === 'org') {
|
||
const res = await getOrgPermission({ group_id: id });
|
||
access_level.value = res.data.data.my_role?.access_level || 0;
|
||
} else {
|
||
const res = await getRepoPermission({ repo_id: id });
|
||
access_level.value = res.data.data.access_level || 0;
|
||
}
|
||
}
|
||
|
||
// 三种角色自动 computed
|
||
const isAdmin = computed(() => access_level.value >= admin); // 50
|
||
const isDeveloper = computed(() => access_level.value >= developer); // 30
|
||
const isVisitor = computed(() => access_level.value >= visitor); // 10
|
||
|
||
return { access_level, getPermission, isAdmin, isDeveloper, isVisitor };
|
||
}
|
||
```
|
||
|
||
**解决什么**:查询用户在当前组织/仓库的权限后,自动算出三个布尔角色值。配合 Store 里的权限 computed,页面只需 `if (isAdmin)` 判断是否显示管理按钮。
|
||
|
||
#### usePagePermission — 页面级权限控制
|
||
|
||
```typescript
|
||
export function usePagePermission(access_level, isPrivate) {
|
||
// 根据权限等级和是否私有,返回哪些菜单/操作可用
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### 3.6 其他辅助 Hooks
|
||
|
||
#### useFile — 文件类型识别(图标 + 语言高亮)
|
||
|
||
```typescript
|
||
export function useFile() {
|
||
// 根据文件名后缀返回对应图标名
|
||
const getFileIcon = (filename) => {
|
||
const format = filename?.split('.').pop();
|
||
if (/^(zip|rar|7z)$/i.test(format)) return 'gt-file-zip-c';
|
||
if (/^(png|jpg|jpeg|gif)$/i.test(format)) return 'gt-picture-c';
|
||
if (/^(js|ts|vue|py)$/i.test(format)) return 'gt-file-code-c';
|
||
return 'gt-file-c';
|
||
};
|
||
|
||
// 根据文件名返回代码高亮语言
|
||
const getFileLanguage = (filename) => {
|
||
const map = { js: 'javascript', ts: 'typescript', vue: 'html', py: 'python' };
|
||
return map[format] || format;
|
||
};
|
||
|
||
return { getIcon, getFileLanguage, getFileFormat };
|
||
}
|
||
```
|
||
|
||
#### useStarFollow — 关注/粉丝操作
|
||
|
||
```typescript
|
||
export const useStarFollow = (params, auto) => {
|
||
const toggleStar = (username, followedUsername, follow) => {
|
||
if (!username) {
|
||
emitEvent('login', { triggerType: '关注用户' }); // 未登录→弹出登录
|
||
return;
|
||
}
|
||
follow ? followUser(...) : unfollowUser(...);
|
||
microApp.setData('user-center', { type: 'user_hasFollowed_update', ... });
|
||
};
|
||
return { fanCount, followCount, hasFollowed, toggleStar };
|
||
};
|
||
```
|
||
|
||
**封装了什么**:关注/取消关注的 API 调用 + 状态更新 + 微前端数据同步 + 未登录自动弹登录框。
|
||
|
||
---
|
||
|
||
#### useReport — 埋点上报
|
||
|
||
```typescript
|
||
export const useReport = (eventID, eventParams, headers) => {
|
||
// 统一埋点上报逻辑
|
||
};
|
||
```
|
||
|
||
#### usePopup — 弹窗管理器(底层能力)
|
||
|
||
被 useLogin、useAccModal、useNotice 三个 Hook 依赖的底层弹窗管理:
|
||
|
||
```typescript
|
||
export function usePopup(className?, rootElement?) {
|
||
const mount = (component, props) => { /* 挂载弹窗到 DOM */ };
|
||
const unMount = () => { /* 卸载弹窗 */ };
|
||
const isMounted = () => { /* 是否已挂载 */ };
|
||
return { mount, unMount, isMounted };
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、Hooks 之间的层次关系
|
||
|
||
```
|
||
底层能力(被其他 Hook 依赖)
|
||
├── usePopup → 弹窗挂载/卸载
|
||
├── useReq/useAsync → 通用异步请求封装
|
||
├── useRepoId → URL 参数解析
|
||
└── useModel → v-model 封装
|
||
|
||
中间能力(组合底层能力)
|
||
├── useLogin → 基于 usePopup
|
||
├── useAccModal → 基于 usePopup
|
||
├── useNotice → 基于 usePopup
|
||
├── useFormInteraction → 基于 useAccount + useRouter
|
||
├── useUserAccessLevel → 基于 useUserInfo
|
||
|
||
业务能力(组合中间能力)
|
||
├── useRepoInit → 基于 useRepoId
|
||
├── useWikiHome → 基于 useRepoId + useRouter
|
||
├── useRepoList → 基于 API 调用
|
||
├── useUserDashboard → 基于 6 个 API 调用
|
||
├── useStarFollow → 基于 API + eventBus + microApp
|
||
└── usePageResize → 基于 useWindowSize(vueuse)
|
||
```
|
||
|
||
**面试话术**:
|
||
> "我设计的 Hooks 是有层次的。底层 Hooks 提供通用能力(如 usePopup 管理弹窗生命周期、useAsync 封装异步请求);中间层 Hooks 基于底层组合出业务场景(如 useLogin 基于 usePopup 封装登录弹窗流程);最上层是业务 Hooks,组件里一行代码调用就能获得完整的数据和操作方法。"
|
||
|
||
---
|
||
|
||
## 五、整体架构总结
|
||
|
||
```
|
||
30+ 个 Hooks 在项目中的定位:
|
||
|
||
可复用逻辑提取为 Hooks
|
||
↓
|
||
┌───────────────┐
|
||
│ 通用交互 (5+) │ usePageResize(26文件) useModel(18文件) usePagination useShowMore useLazyImport
|
||
├───────────────┤
|
||
│ 表单认证 (6+) │ useFormInteraction(8文件) useLogin useAccount(13文件) useAccModal useNotice
|
||
├───────────────┤
|
||
│ 数据获取 (9+) │ useAsync/useReq useRepoInit(7文件) useWikiHome/create/detail/history
|
||
│ │ useUserDashboard useRepoList(8文件) useDiscussGetUserInfo
|
||
├───────────────┤
|
||
│ 路由参数 (6+) │ useRepoId(30文件) useOrgId useBranchName useTitle useNav useRepoPathValidate
|
||
├───────────────┤
|
||
│ 权限控制 (3) │ useUserAccessLevel usePagePermission useIsPrivate
|
||
├───────────────┤
|
||
│ 其他辅助 (7+) │ useFile usePopup useReport useTimeFormat useStarFollow(4文件) useUserInfo
|
||
└───────────────┘
|
||
↓
|
||
Vue 组件层(40+ 组件调用)
|
||
```
|
||
|
||
---
|
||
|
||
## 六、面试问答准备
|
||
|
||
### Q1:"封装 Composition API Hooks 实现逻辑复用"具体做了什么?
|
||
|
||
> 项目里封装了 30 多个 Hooks,分为六大类。最核心的几个:usePageResize 把响应式断点判断封装成 Hook,26 个布局文件复用;useRepoId 从 URL 提取仓库 ID,30 个文件复用;useFormInteraction 封装了完整的表单校验流程(332 行),8 个登录/注册页共享同一套校验逻辑;useUserDashboard 封装了用户首页 6 个模块的数据初始化,页面只需两行代码完成全部数据加载。Hooks 之间还有层次关系——底层 Hooks(usePopup、useAsync)提供通用能力,中间层组合底层能力,业务层直接给组件用。
|
||
|
||
### Q2:Hooks 和之前写的工具函数有什么区别?
|
||
|
||
> 工具函数是无状态的(纯函数,输入→输出),Hooks 是有状态的(包含 ref、computed、watch)。比如 useFormInteraction 内部管理了 formErrors、disabled、loading 等 6 个状态,还监听了表单 change 事件自动校验。如果写成工具函数,这些状态管理就要在每个页面里重复写。Hooks 把"响应式状态 + 操作逻辑"封装在一起,是 Vue3 Composition API 的核心优势。
|
||
|
||
### Q3:Hooks 怎么保证类型安全?
|
||
|
||
> 所有 Hooks 都用 TypeScript 写,返回值和参数都有类型定义。比如 usePagination 接收 `{storageKey, clientType}` 接口类型,useRepoList 接收包含 profile/all/created/starred 四种子参数的类型。组件调用时 VSCode 有完整的智能提示。
|
||
|
||
### Q4:有没有用过 VueUse 的 Hooks?
|
||
|
||
> 用过,useWindowSize、useResizeObserver、useTitle 等都是从 @vueuse/core 引入的。但项目不只是用第三方 Hooks,更关键的是基于 VueUse 做二次封装——比如 usePageResize 基于 useWindowSize 封装了断点判断逻辑,useShowMore 基于 useResizeObserver 封装了内容溢出检测。既用了社区的最佳实践,又做了业务定制。
|
||
|
||
### Q5:30 多个 Hooks 怎么组织的?会不会管理混乱?
|
||
|
||
> 按职责分目录:通用的放在 `src/utils/hooks/`(30 个),特定业务域放在对应的 views 目录下(如 `src/views/User/hooks/` 放用户相关的 7 个 Hook),布局相关的放在 `src/layouts/hooks/`。调用时能直观看出 Hook 的作用域。
|
||
|
||
### Q6:Hooks 和 Pinia Store 怎么分工?
|
||
|
||
> Hooks 和 Store 的分工很明确。Store 存跨组件共享的持久状态(用户信息、仓库信息),Hooks 封装的是"行为逻辑"——怎么获取数据、怎么校验表单、怎么处理交互。Hooks 经常调用 Store 来读写状态,但它们不替代 Store。比如 useAccount Hook 既调用 Store 的 saveAccountInfo,又操作 localStorage,把"用户信息存储到 Store + 持久化到 localStorage + 更新登录状态"三步封装成一步 RecordInfo。
|
||
|
||
---
|
||
|
||
## 七、关键数字(面试时用)
|
||
|
||
| 数据 | 数字 |
|
||
|------|------|
|
||
| Hooks 总数 | 30+ 个 |
|
||
| 使用最多的 Hook | useRepoId(30 个文件、61 处引用) |
|
||
| 第二常用 | usePageResize(26 个文件、51 处引用) |
|
||
| 第三常用 | useModel(18 个文件、35 处引用) |
|
||
| 最大的 Hook | useFormInteraction(332 行) |
|
||
| 业务最全的 Hook | useUserDashboard(6 个模块数据初始化) |
|
||
| 弹窗类 Hooks | 3 个(useLogin/useAccModal/useNotice,共用 usePopup) |
|
||
| Wiki 全家桶 | 4 个(Home/Create/Detail/History) |
|
||
| 权限类 Hooks | 2 个(useUserAccessLevel/usePagePermission) |
|
||
| 基于 @vueuse/core 封装的 | 3 个(useWindowSize/useResizeObserver/useTitle) |
|
||
|
||
---
|
||
|
||
## 八、涉及的源码文件(需要看的时候查)
|
||
|
||
| 做什么 | 文件在哪 |
|
||
|--------|----------|
|
||
| 通用交互 Hooks(5 个) | `src/utils/hooks/usePageResize.ts`、`useShowMore.ts`、`useModel.ts`、`usePagination.ts`、`useLazy.ts` |
|
||
| 表单与认证 Hooks(6 个) | `src/utils/hooks/useForm.ts`、`useLogin.ts`、`useAccount.ts`、`useAccModal.ts`、`useNotice.ts`、`useLoginCheck.ts` |
|
||
| 数据获取 Hooks(9 个) | `src/utils/hooks/useReq.ts`、`useRepoInit.ts`、`useWikiInit.ts`、`useIssueTemplate.ts`、`src/api/discussion/hook.ts` |
|
||
| 用户域 Hooks(7 个) | `src/views/User/hooks/useUserDashboard.ts`、`useRepoList.ts`、`useStarFollow.ts`、`useContributes.ts`、`useTimelineActivities.ts`、`useIsPrivate.ts`、`useUserLang.ts` |
|
||
| 路由/参数 Hooks(6 个) | `src/utils/hooks/useRepoId.ts`、`useOrgId.ts`、`usebranchName.ts`、`useTitle.ts`、`useNav.ts`、`useRepoPathValidate.ts` |
|
||
| 权限控制 Hooks(3 个) | `src/utils/hooks/useUserAccessLevel.ts`、`src/layouts/hooks/usePagePermission.ts`、`src/views/User/hooks/useIsPrivate.ts` |
|
||
| 其他辅助 Hooks(7 个) | `src/utils/hooks/useFile.ts`、`usePopup.ts`、`useReport.ts`、`useTimeFormat.ts`、`useUserInfo.ts`、`useRepoHeaderInit.ts`、`useBranchOptions.ts` |
|
||
| 组织域 Hooks | `src/views/Org/hooks/orgInfo.ts` |
|
||
| 弹窗底层能力 | `src/utils/hooks/usePopup.ts`(被 useLogin/useAccModal/useNotice 依赖) |
|