Vue 面试硬核篇:Vue Router、Pinia、响应式源码原理……7 个章节深度解析

2 阅读16分钟

🔥Vue 面试硬核篇:Vue Router、Pinia、响应式源码原理……7 个章节深度解析

基础篇过关、进阶篇加分,这一篇决定你能不能拿 SP。


写在前面

这是 Vue 面试系列的第 3 篇第 1 篇:核心基础第 2 篇:Composition API 进阶)。

到这一篇,我们进入"硬核区":路由守卫的执行顺序你能一口气说完吗?Pinia 凭什么取代 Vuex?响应式源码(track/trigger/effect)能不能手写一遍?性能优化除了说"懒加载"还有没有别的货?

这一篇的特点:每节都附手写实现,源码题答得上手写代码,面试直接上一个档次。

适合谁看?

  • ✅ 冲刺中大厂前端的同学(源码题重灾区)
  • ✅ 用过 Router/Pinia 但被原理题问住的人
  • ✅ 想在性能优化话题上说出 10 个点以上的开发者

阅读建议:全文约 1.3 万字。源码章节建议边读边在纸上画流程图,文末附速查表。


📑 目录

  1. Vue Router
  2. Vuex 状态管理
  3. Pinia 状态管理
  4. Vue 3 新特性
  5. 响应式原理深入(手写实现)
  6. 性能优化
  7. TypeScript 与 Vue

📌 附:面试知识点速查表


19. Vue Router

19.1 基本配置

// router/index.js
import { createRouter, createWebHistory } from 'vue-router';

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),   // 路由懒加载
    meta: { title: '首页', requiresAuth: false }
  },
  {
    path: '/user/:id',
    name: 'User',
    component: () => import('@/views/User.vue'),
    props: true  // 将 params 作为 props 传入组件(解耦,推荐)
  },
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    children: [   // 嵌套路由
      { path: 'profile', component: () => import('@/views/Profile.vue') },
      { path: 'settings', component: () => import('@/views/Settings.vue') }
    ]
  },
  {
    path: '/:pathMatch(.*)*',   // 404 通配(Vue 4.x 语法)
    name: 'NotFound',
    component: () => import('@/views/404.vue')
  }
];

const router = createRouter({
  history: createWebHistory(),  // history 模式;hash 模式用 createWebHashHistory()
  routes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) return savedPosition;  // 浏览器前进/后退恢复位置
    return { top: 0 };                        // 新页面回到顶部
  }
});

19.2 路由使用

<script setup>
import { useRouter, useRoute } from 'vue-router';

const router = useRouter();  // 路由器实例:做导航
const route = useRoute();    // 当前路由:拿参数

console.log(route.params.id);   // 动态参数
console.log(route.query.page);  // 查询参数

// 编程式导航
router.push('/home');
router.push({ name: 'User', params: { id: 1 } });   // params 配 name 用
router.push({ path: '/user/1', query: { tab: 'a' } });
router.replace('/login');  // 不留历史记录
router.back();             // 后退(等效 go(-1))

// ⚠️ 易错点:params 传参要用 name,用 path 会被忽略!
// router.push({ path: '/user', params: { id: 1 } })  // ❌ id 丢失
</script>

<template>
  <router-link :to="{ name: 'User', params: { id: 1 } }">用户</router-link>

  <!-- 路由切换动画 + keep-alive 的标准组合 -->
  <router-view v-slot="{ Component }">
    <Transition name="fade" mode="out-in">
      <KeepAlive :max="10">
        <component :is="Component" />
      </KeepAlive>
    </Transition>
  </router-view>
</template>

19.3 路由守卫

// 全局前置守卫:权限控制的主战场
router.beforeEach((to, from) => {
  // 返回 false        → 取消导航
  // 返回路由地址       → 重定向
  // 返回 undefined/true → 放行
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return { name: 'Login', query: { redirect: to.fullPath } };
  }
});

router.afterEach((to) => {
  document.title = to.meta.title || 'App';  // 改标题
});
<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router';

// 组件内守卫(<script setup> 写法)
onBeforeRouteUpdate(() => {
  // 路由变了但组件被复用时(如 /user/1 → /user/2)
});

onBeforeRouteLeave(() => {
  // 离开前:未保存表单弹确认框的经典场景
  return confirm('有未保存的修改,确定离开?');
});
</script>

19.4 动态路由与权限

// 后端权限方案:登录后根据角色动态注册路由
router.beforeEach(async (to, from) => {
  const token = getToken();
  if (to.meta.requiresAuth && !token) return { name: 'Login' };

  if (token && !router.hasRoute('Dashboard')) {      // 还没注册过
    const menus = await getUserMenus();               // 拉取该用户的菜单/路由
    menus.forEach(m => router.addRoute(m));           // 动态注册
    return to.fullPath;                               // ⚠️ 重新导航一次
  }
});

💬 面试题精选

Q1:hash 模式和 history 模式有什么区别? ⭐必考

答:

特性hash 模式history 模式
URL 样式/#/path(丑)/path(干净)
实现原理hashchange 事件(不请求服务器)HTML5 pushState / popstate
服务器配置不需要必须配置 404 fallback(刷新时把所有路径指到 index.html)
SEO
适用后台管理、不在意 URL面向用户的站点

💡 加分话术:history 模式下刷新页面浏览器会向服务器请求当前路径,Nginx 需要配 try_files $uri $uri/ /index.html,否则 404。hash 模式的 # 后面部分不会发给服务器,所以刷新永远不会 404。

Q2:导航守卫的执行顺序? ⭐必考

答(按触发顺序):

  1. 失活组件的 beforeRouteLeave
  2. 全局 beforeEach
  3. 复用组件的 beforeRouteUpdate
  4. 路由独享的 beforeEnter
  5. 解析异步路由组件
  6. 激活组件的 beforeRouteEnter(此时拿不到 this)
  7. 全局 beforeResolve
  8. 导航确认 → 全局 afterEach → 触发 DOM 更新

💡 加分话术beforeRouteEnter 是唯一拿不到组件实例的守卫(组件还没创建),要用 next(vm => {...})(Vue 4.x 已改为 Promise:beforeRouteEnter(to, from) 里返回 Promise)或直接在 setup 里用 onBeforeMount 处理。

Q3:如何实现前端路由权限控制?

答:两种主流方案——

  1. 静态路由 + meta 标记 + 全局守卫:路由全量注册,守卫里判断 to.meta.roles 是否包含当前角色,不在则拦截
  2. 动态路由:登录后根据角色拉取菜单,用 router.addRoute() 动态注册,配合 404 兜底

💡 加分话术:方案 2 更安全——没权限的路由根本不存在于前端路由表,直接访问 URL 也匹配不到;方案 1 只是"界面隐藏",路由本身还在,需要守卫兜底。实际项目常两者结合:公共路由静态注册,业务路由动态注册。


20. Vuex 状态管理

💡 Vuex 是"上一代"方案,新项目都用 Pinia 了。但面试依然会问——既问 Vuex 概念,更问"为什么被 Pinia 取代"。

20.1 核心概念

import { createStore } from 'vuex';

const store = createStore({
  // state:单一状态树
  state() {
    return { count: 0, todos: [] };
  },

  // getters:派生状态(有缓存的 computed)
  getters: {
    doubleCount: (state) => state.count * 2,
    // 返回函数,可以传参
    getTodoById: (state) => (id) => state.todos.find(t => t.id === id)
  },

  // mutations:同步修改(devtools 才能追踪)
  mutations: {
    INCREMENT(state) { state.count++; },
    SET_COUNT(state, count) { state.count = count; }
  },

  // actions:异步操作(提交 mutation)
  actions: {
    async fetchCount({ commit }) {
      const res = await fetch('/api/count');
      commit('SET_COUNT', await res.json());
    }
  },

  modules: { /* 模块化 */ }
});

四个概念的职责一句话总结:state 存数据、getters 算数据、mutations 改数据(同步)、actions 做异步然后提交 mutations。

20.2 模块化与使用

// store/modules/user.js
export default {
  namespaced: true,   // ⚠️ 命名空间,防止命名冲突
  state: () => ({ token: null, profile: null }),
  getters: { isLoggedIn: (state) => !!state.token },
  mutations: { SET_TOKEN(state, t) { state.token = t; } },
  actions: {
    async login({ commit }, credentials) {
      const { token } = await api.login(credentials);
      commit('SET_TOKEN', token);
    }
  }
};

// 使用(带命名空间要加前缀)
store.dispatch('user/login', credentials);
store.getters['user/isLoggedIn'];
<script setup>
import { useStore } from 'vuex';
import { computed } from 'vue';

const store = useStore();
const count = computed(() => store.state.count);
const doubleCount = computed(() => store.getters.doubleCount);
store.commit('INCREMENT');       // 提交 mutation
store.dispatch('fetchCount');    // 分发 action
</script>

💡 Vuex 与响应式原理的联系:state 是 reactive 的,getters 是 computed 的——所以 state 一变,组件自动更新。


21. Pinia 状态管理

21.1 两种定义风格

// stores/counter.js
import { defineStore } from 'pinia';

// ① Option Store(像 Vuex)
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubleCount: (state) => state.count * 2
  },
  actions: {
    increment() { this.count++; },       // 不再需要 commit!
    async fetchCount() {
      const res = await fetch('/api/count');
      this.count = await res.json();    // 直接改 state
    }
  }
});

// ② Setup Store(推荐,与 Composition API 完全一致)
import { ref, computed } from 'vue';

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0);
  const doubleCount = computed(() => count.value * 2);

  function increment() { count.value++; }
  async function fetchCount() {
    const res = await fetch('/api/count');
    count.value = await res.json();
  }

  return { count, doubleCount, increment, fetchCount };
});

21.2 使用与进阶 API

<script setup>
import { useCounterStore } from '@/stores/counter';
import { storeToRefs } from 'pinia';

const store = useCounterStore();

// ⚠️ 解构 state/getters 必须用 storeToRefs 保持响应性
const { count, doubleCount } = storeToRefs(store);
// 方法可以直接解构(方法不依赖响应性)
const { increment } = store;

// 修改的四种方式
store.count++;                        // 直接改
store.$patch({ count: 5 });           // 批量改
store.$patch((s) => { s.count++; });  // 函数式批量改
store.$reset();                       // 重置(仅 Option Store)

// 订阅:跨组件/跨模块联动、持久化
store.$subscribe((mutation, state) => {
  localStorage.setItem('count', JSON.stringify(state.count));
});
</script>

💬 面试题精选

Q1:Pinia 和 Vuex 的区别?为什么新项目都选 Pinia? ⭐必考

答:

特性VuexPinia
修改数据mutations(同步)+ actions(异步),强制分离只有 actions,直接改 state
TypeScript类型推断弱(this、模块嵌套难推断)原生支持,Setup Store 类型自动推断
模块化modules 嵌套 + namespaced,繁琐每个 Store 独立扁平,天然隔离
体积较大~1KB
API有 mutation/action/getter/state 四概念state/getters/actions 三概念
使用体验mapState 等辅助函数storeToRefs 一行搞定

为什么 Pinia 更好

  1. 去掉了 mutations——Vuex 强制"异步必须走 action → commit mutation",实际开发中 90% 的 mutation 只是赋值,纯样板代码;Pinia 允许 action 直接改 state,devTools 照样能追踪
  2. TS 体验质变:Setup Store 就是普通 Composition API,类型全自动推断
  3. 模块化零成本:不需要 namespaced 手动开关

💡 加分话术:Pinia 由 Vue 核心团队成员 Eduardo 开发,Vuex 5 的规划被 Pinia 吸收,官方已宣布 Pinia 是 Vuex 的继任者——新项目直接上 Pinia 即可,不用再纠结。

Q2:store 解构后为什么丢失响应性?怎么解决?

答:const { count } = store 相当于把 store 对象上的属性值拷贝到普通变量,count 变成一个普通数字。解决:state 和 getters 用 storeToRefs(store) 解构(内部转成了 ref),方法直接解构即可。

💡 加分话术storeToRefs 本质上就是遍历 store 的响应式属性,用 toRefs 转成 ref——和 reactive 解构用 toRefs 是同一个问题。


22. Vue 3 新特性

💡 这一节不是罗列 API,而是回答"Vue 3 相比 Vue 2 到底新在哪",面试按"编译层 / 运行时层 / API 层"三层组织答案更显深度。

22.1 组件与模板层

<template>
  <!-- ① Fragment:多根节点 -->
  <header />
  <main />
  <footer />

  <!-- ② Teleport:DOM 传送到 body(弹窗场景) -->
  <Teleport to="body" :disabled="isInline">
    <Modal />
  </Teleport>

  <!-- ③ Suspense:异步依赖统一占位 -->
  <Suspense>
    <template #default><AsyncPage /></template>
    <template #fallback><Loading /></template>
  </Suspense>
</template>

22.2 新增响应式 API(容易忽略的冷门考点)

import { ref, customRef, effectScope, markRaw, toRaw, unref } from 'vue';

// ① customRef:自定义 ref(防抖输入框的经典实现)
function useDebouncedRef(value, delay = 300) {
  let timer;
  return customRef((track, trigger) => ({
    get() { track(); return value; },
    set(newValue) {
      clearTimeout(timer);
      timer = setTimeout(() => { value = newValue; trigger(); }, delay);
    }
  }));
}
const keyword = useDebouncedRef('');

// ② effectScope:批量管理副作用,离开作用域统一停止
const scope = effectScope();
scope.run(() => {
  watch(x, cb);
  watchEffect(cb);
});
scope.stop();  // 全部停止(大型组件卸载优化、库开发常用)

// ③ markRaw / toRaw:与响应式系统划清界限
const lib = markRaw(new HugeLibrary()); // 永不代理,省性能
const plain = toRaw(reactiveObj);       // 取原始对象

// ④ unref:写工具函数时的糖
unref(ref(0));   // 0(是 ref 就取 .value)
unref(0);        // 0(不是 ref 原样返回)

💬 面试题精选

Q1:Vue 3 有哪些新特性?(组织答案的框架)

答,按层组织:

  1. 响应式层Proxy 重写 + ref/reactive/computed/watch 等独立 API + customRef/effectScope 等高级 API
  2. 组件层:Fragment 多根节点、Teleport、Suspense、<script setup>
  3. 编译层:静态提升、Patch Flags(动态标记)、Block Tree、事件缓存——运行时性能大幅提升的来源
  4. API 层:Composition API + Composables 逻辑复用
  5. 工程层:Tree-shaking(打包体积更小)、原生 TS 支持

💡 加分话术:编译优化是 Vue 3 性能提升的大头——模板编译时给动态节点打 Patch Flag(文本、class、style 等),运行时 diff 只比对这些标记,静态节点整块跳过,所以 Vue 3 的更新性能比 Vue 2 快 1.2~2 倍(官方数据)。


23. 响应式原理深入

⭐⭐ 这是本篇的重头戏,也是中大厂源码面的必考区。建议跟着代码手写一遍

23.1 Vue 2:Object.defineProperty 的局限

// Vue 2 响应式核心逻辑简化版
function defineReactive(obj, key, val) {
  const dep = new Dep();

  Object.defineProperty(obj, key, {
    get() {
      if (Dep.target) dep.depend();   // 收集依赖
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      dep.notify();                   // 派发更新
    }
  });
}

四个致命局限(面试张口就来):

  1. ❌ 无法检测新增属性(没有提前 defineProperty)→ $set
  2. ❌ 无法检测删除属性$delete
  3. ❌ 无法检测数组索引修改length 变化 → 重写 7 个变异方法
  4. ❌ 初始化时递归遍历所有属性,大对象性能差

23.2 Vue 3:手写一个迷你响应式系统

// ========== 1. 依赖收集中心 ==========
const targetMap = new WeakMap();   // WeakMap: 目标对象 → (Map: key → Set<effect>)
let activeEffect = null;           // 当前正在执行的副作用

// ========== 2. track:get 时收集 ==========
function track(target, key) {
  if (!activeEffect) return;       // 不在副作用中,不收集
  let depsMap = targetMap.get(target);
  if (!depsMap) targetMap.set(target, (depsMap = new Map()));
  let dep = depsMap.get(key);
  if (!dep) depsMap.set(key, (dep = new Set()));
  dep.add(activeEffect);
}

// ========== 3. trigger:set 时派发 ==========
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  depsMap.get(key)?.forEach(effect => effect());
}

// ========== 4. reactive:Proxy 代理 ==========
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const result = Reflect.get(target, key, receiver);
      track(target, key);          // 收集
      // ⭐ 惰性代理:只有访问到嵌套对象才递归代理
      if (typeof result === 'object' && result !== null) {
        return reactive(result);
      }
      return result;
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      if (oldValue !== value) {
        trigger(target, key);      // 派发
      }
      return result;
    },
    deleteProperty(target, key) {
      const result = Reflect.deleteProperty(target, key);
      trigger(target, key);        // 删除也通知!Vue 2 做不到
      return result;
    }
  });
}

// ========== 5. effect:执行并记录自己 ==========
function effect(fn) {
  activeEffect = fn;
  fn();                 // 执行时会触发 get → 被 track 收集
  activeEffect = null;
}

// ========== 验证 ==========
const state = reactive({ count: 0 });
effect(() => console.log('count =', state.count));
// 输出: count = 0(执行时收集了依赖)

state.count++;
// 输出: count = 1(trigger 重新执行 effect)✅

state.nested = { a: 1 };   // 新增属性 ✅ 可检测
delete state.nested;        // 删除属性 ✅ 可检测

23.3 依赖收集完整流程

组件渲染(render effect)
    │ 访问响应式数据 state.count
    ▼
Proxy.get 拦截 ──track()──▶ activeEffect(当前渲染 effect)
                              存入 targetMap
    │
数据变化:state.count++
    ▼
Proxy.set 拦截 ──trigger()──▶ 从 targetMap 取出 count 的 effect 集合
                                逐个执行 → 组件重新渲染

23.4 ref 的响应式原理(补充)

ref 内部就是一个 { value: x } 对象,通过 class 的 get value() / set value() 实现拦截,本质和 reactive 一样是 Proxy/defineProperty。所以 ref 能包基本类型——因为拦截发生在 .value 这一层,而不是对象属性层。

💬 面试题精选

Q1:Vue 3 响应式原理讲一下?(要求手写) ⭐⭐必考

答:核心三要素——Proxy 拦截、track 依赖收集、trigger 派发更新。然后按上面 23.2 的手写流程讲(面试官要的就是这个)。最后补两点加分项:

  • 惰性代理:嵌套对象在 get 时才递归代理,初始化性能远好于 Vue 2
  • WeakMap 设计:targetMap 用 WeakMap 存,target 对象被垃圾回收时映射自动清除,防止内存泄漏

Q2:什么是依赖收集?为什么要依赖收集?

答:依赖收集 = 记录"谁在用这个数据"。组件渲染时访问数据,把当前的渲染 effect 存到该数据名下的依赖集合中。数据变了,就知道该通知谁更新。

为什么需要:精确更新。没有这个机制,只能全量 diff 或全量重新渲染;有了它,哪个数据变了只重新执行依赖它的 effect,做到细粒度更新。

Q3:Vue 3 用 Proxy 为什么还是不能拦截基本类型?

答:Proxy 的 target 必须是对象。基本类型(string、number 等)不是对象,无法被代理——所以 Vue 3 仍然需要 ref.value 包装一层对象来承载基本类型的响应式。这是 ref 存在的根本原因。


24. 性能优化

💡 面试问性能优化,最忌讳只说"路由懒加载、keep-alive"。按下面的"四层框架"答,体系感拉满。

24.1 加载性能(让用户更快看到页面)

// ① 路由懒加载:首屏只下载当前页面
{ path: '/about', component: () => import('./About.vue') }

// ② 组件懒加载:弹窗等低频组件
const Modal = defineAsyncComponent(() => import('./Modal.vue'));

// ③ 第三方库按需引入 + Tree-shaking
import { Button } from 'element-plus';  // 配 unplugin 自动按需

// ④ 图片优化:懒加载 + 懒加载 + 压缩 + CDN
<img src="a.jpg" />

// ⑤ 预加载/预取关键资源
<link rel="preload" href="critical.js" />

24.2 运行时性能(让页面更流畅)

<script setup>
import { shallowRef, markRaw } from 'vue';

// ① 大对象避开深层响应式
const bigList = shallowRef([/* 万级数据 */]);
const chart = markRaw(echarts.init(el));  // 第三方实例不需要响应式

// ② v-memo:列表项只有满足条件才重渲染(Vue 3.2+)
// <div v-memo="[item.id === selected]">...</div>

// ③ 大列表虚拟滚动
import { useVirtualList } from '@vueuse/core';
const { list, containerProps, wrapperProps } = useVirtualList(bigList, { itemHeight: 50 });

// ④ 防抖节流
import { useDebounceFn } from '@vueuse/core';
const onSearch = useDebounceFn(search, 300);
</script>

<template>
  <!-- ⑤ v-once 标记纯静态内容,跳过更新 diff -->
  <p v-once>版权信息 © 2026</p>

  <!-- ⑥ 用 computed 代替模板里的复杂表达式,避免每次渲染重算 -->
</template>

24.3 更新性能(让 diff 更高效)

<template>
  <!-- ① 列表 key 用唯一 id,不用 index(diff 能复用和移动节点) -->
  <li v-for="item in list" :key="item.id">{{ item.name }}</li>

  <!-- ② v-for 不与 v-if 混用:过滤逻辑放 computed -->

  <!-- ③ 细分组件:状态变化只重渲染最小子树,而不是整个大组件 -->
</template>

24.4 体验性能

  • keep-alive 缓存 Tab 页,切回不白屏不重新请求
  • SSR / SSG:首屏直出 HTML,解决白屏(见第 4 篇)
  • 骨架屏 + 路由过渡动画,掩盖加载等待

💬 面试题精选

Q1:Vue 项目性能优化手段有哪些? ⭐必考

答(四层框架):

  1. 加载层:路由懒加载、组件懒加载、按需引入、图片懒加载压缩、HTTP 缓存/CDN、Gzip
  2. 运行时层:虚拟滚动、防抖节流、shallowRef/markRaw 避开深层响应式、v-memo/v-once、Web Worker 处理重计算
  3. 更新层:合理 key、组件拆细缩小重渲染范围、computed 缓存、避免模板复杂表达式
  4. 体验层:keep-alive、骨架屏、SSR/SSG

💡 加分话术:优化前先测量——用 Lighthouse 跑分、Vue DevTools 的 Performance 面板看组件渲染耗时、Chrome Performance 看主线程长任务,找到瓶颈再针对性优化,而不是盲目上手段。

Q2:v-memov-once 的区别?

答:

  • v-once:渲染一次后永久跳过更新,适合纯静态内容
  • v-memo:接收依赖数组,依赖不变才跳过更新,适合"大部分时候不变、特定条件才变"的列表项(如选中态高亮的列表)

25. TypeScript 与 Vue

25.1 组件类型三件套

<script setup lang="ts">
// ① Props:泛型接口,类型 + 校验一次搞定
interface Props {
  title: string;
  count?: number;
  user: { name: string; age: number };
}
const props = withDefaults(defineProps<Props>(), {
  count: 0   // 有默认值的就不算必填
});

// ② Emits:调用签名(写错参数类型直接报错)
const emit = defineEmits<{
  (e: 'update', value: string): void;
  (e: 'delete', id: number): void;
}>();
// Vue 3.3+ 更简洁的元组写法:
const emit2 = defineEmits<{
  update: [value: string];
  delete: [id: number];
}>();

// ③ 模板 ref:标注元素类型
const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => inputRef.value?.focus());
</script>

25.2 组合式函数与 Store 的类型

// composables/useFetch.ts —— 泛型让返回值类型精确
import { ref, type Ref } from 'vue';

interface UseFetchReturn<T> {
  data: Ref<T | null>;
  error: Ref<Error | null>;
  loading: Ref<boolean>;
}

export function useFetch<T>(url: string): UseFetchReturn<T> {
  const data = ref<T | null>(null);
  const error = ref<Error | null>(null);
  const loading = ref(true);
  // ...
  return { data, error, loading };
}

// 使用:泛型自动推断
const { data } = useFetch<User[]>('/api/users');
// data 的类型是 Ref<User[] | null> ✅
// stores/user.ts —— Pinia Setup Store 类型全自动
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

interface User { id: number; name: string; }

export const useUserStore = defineStore('user', () => {
  const user = ref<User | null>(null);
  const isLoggedIn = computed(() => !!user.value);
  function setUser(u: User) { user.value = u; }
  return { user, isLoggedIn, setUser };
  // 返回什么类型就是什么类型,零额外标注 ✅
});

💬 面试题精选

Q1:如何在 Vue 3 中用好 TypeScript?

答五个要点:

  1. <script setup lang="ts"> 开启 TS
  2. defineProps<接口>() 替代运行时对象写法(类型即文档)
  3. defineEmits<{...}>() 约束事件参数
  4. Composables 用泛型 <T> 让返回值可推断
  5. Pinia 用 Setup Store,状态类型自动推导

💡 加分话术withDefaults + 泛型 Props 最大的好处是"类型即校验"——TS 编译时报错提前到写代码阶段,且不需要写 validator 函数那套运行时代码了。

Q2:ref 的类型为什么写作 Ref<number> 而不是 number

答:因为 ref(0) 返回的是一个包装对象 { value: 0 },它的类型是 Ref<number>——这个对象才是响应式载体。解构时 storeToRefs / toRefs 干的事就是把属性转成 Ref<T>,保住这层包装。


📋 面试知识点速查表

问题一句话答案
hash vs history#不请求服务器 vs 需配 fallback;hashchange vs pushState
params 传参必须用 name,用 path 会丢
导航守卫顺序离开→beforeEach→update→beforeEnter→beforeRouteEnter→beforeResolve→afterEach
权限方案meta+守卫(隐藏)或 addRoute 动态注册(真正隔离),后者更安全
Vuex 四概念state存/getters算/mutations同步改/actions异步改
Pinia vs Vuex无 mutations、TS 原生、扁平 store、~1KB
store 解构丢响应storeToRefs(state/getters),方法直接解构
Vue3 新特性分层响应式层/组件层/编译层(Patch Flags)/API层/工程层
Proxy 为什么快惰性代理 + 细粒度依赖收集 + 编译期 Patch Flags
手写响应式三要素Proxy 拦截 + track 收集 + trigger 派发(WeakMap 防泄漏)
ref 为什么能包基本类型value 层拦截,Proxy 需要对象
性能优化四层加载/运行时/更新/体验,先测量再优化
v-memo vs v-once依赖数组控制 vs 永久静态
TS 三件套defineProps<接口>/defineEmits<签名>/withDefaults

📌 最后

  • 硬核篇值得点赞 ❤️ + 收藏 ⭐ 反复看,尤其响应式手写部分,面试前自己默写一遍
  • 最终篇(SSR / 测试 / 性能优化落地 / 30 道高频面试题精讲)即将发布,关注追更, series 完结时会放出全系列目录索引
  • 有任何问题或勘误,评论区见 🙏