Skip to content

Vue3 + TypeScript 与 Pinia(三) ​

本页对应 Vue3 + TypeScript 与 Pinia,共 6 段视频,约 1 小时 2 分。

学习目标 ​

  • 能够完成频道与文章列表联动:为文章列表组件定义 props 类型,接收频道 ID,并在 Tab 切换时传递最新 ID 触发对应列表请求。
  • 能够抽离组件与接口相关类型到独立 TS 文件,并使用泛型抽象接口响应中重复的 data、message 结构,消除重复类型定义。
  • 能够按照官方文档在 Vue3 + TS 项目中安装并注册 Pinia,使用 defineStore 定义包含 state、action 的 store,并在组件中导入和使用。
  • 能够使用 computed 实现 Pinia getters,编写异步 action 发起请求并更新 state,在组件中触发 action 并渲染返回数据。
  • 能够使用 storeToRefs 对 state/getters 解构并保持响应式,区分数据与方法的不同解构方式,并使用 Vue Devtools 查看和调试 Pinia 状态。
  • 能够运用“慢就是快”的方法拆解面试题并编写最小示例,结合简历项目准备功能亮点、技术难点及实现细节说明。

知识点 ​

本节说明与学习路径 ​

本节笔记标题是“Vue3 + TS开发环境创建”,但转写稿实际覆盖了综合案例联动、类型优化、Pinia环境搭建与基础使用、getters、异步action、storeToRefs和调试等内容。原文未附图片与完整源码,下面不臆造图片,只按转写稿明确描述整理代码与推导过程。学习路径可以理解为:先用综合案例巩固父子通信与TS类型,再进入Pinia状态管理,最后掌握调试与解构响应式。

频道与文章列表联动:父传子 ​

为什么需要:点击频道后,文章列表要按最新频道ID重新请求,否则列表不会随频道变化。
怎么想:App 中使用 list 组件,二者是父子关系;频道ID在父组件获得,要传给子组件替换原来的 0,所以是父传子。
怎么做:子组件用 defineProps 定义 channelId: number,请求时使用 props.channelId;父组件用 ref(0) 保存频道ID,在 tab 的 change 事件中拿到当前项ID并赋值,再通过属性传给 list 组件。
易错点:只改子组件不传值会报类型错误;channelId 必须是 number;change 回调参数要标注 number;请求参数要确认已使用最新ID。

ts
// List.vue:按转写稿整理
interface Props {
  channelId: number
}
const props = defineProps<Props>()
// 请求文章列表时使用 props.channelId
ts
// App.vue:按转写稿整理
const channelId = ref(0)
function tabChange(id: number) {
  channelId.value = id
}

类型抽离与泛型优化 ​

为什么需要:类型都写在组件里不利于复用;多个接口返回都有 data、message,重复定义会难维护。
怎么想:与后端数据结构相关的类型抽到 src/types/data.d.ts,组件通过 import type 导入;把固定字段保留,把可变的 data 抽象成泛型参数 T。
怎么做:定义 ApiRes<T> = { data: T; message: string },频道接口写成 ApiRes<{ channels: ChannelItem[] }>,文章接口同理。
易错点:导入类型要用 type 关键词区分;props 类型通常跟随组件更聚合,不必强行抽离;泛型要找准“可变部分”。

ts
export interface ChannelItem {
  id: number
  name: string
}

export type ApiRes<T> = {
  data: T
  message: string
}

export type ChannelRes = ApiRes<{ channels: ChannelItem[] }>

Pinia环境搭建 ​

为什么需要:Vue3 项目需要新的状态管理方案,Pinia 去掉了 mutation,action 同时支持同步和异步,组合式写法与组件一致。
怎么想:先创建 Vite + TS + Vue3 空项目,再按官方文档安装 Pinia,创建实例并通过 app.use(pinia) 注册。
怎么做:安装后检查 package.json 是否出现依赖;入口文件中 createPinia() 创建实例,app.use(pinia) 安装。
易错点:装包后不看 package.json;忘记 app.use;把 Pinia 当组件局部状态使用。

ts
import { createPinia } from 'pinia'
const pinia = createPinia()
app.use(pinia)

定义与使用Store ​

为什么需要:把共享状态和修改方法集中管理。
怎么想:用 defineStore,第一个参数是模块名,第二个参数是组合式回调;ref 定义 state,函数定义 action,最后必须 return 数据和方。
怎么做:组件中导入 useXxxStore,执行得到 store 对象,模板通过点语法使用。
易错点:忘记 return;方法名与数据名混用;以为 store 里不能写组合式 API。

ts
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }
  return { count, doubleCount, increment }
})

getters与异步action ​

为什么需要:getters 用于派生状态;异步 action 用于发请求并把结果存入 state。
怎么想:getters 用 computed 模拟;异步 action 就是普通 async 函数,写法与组件内请求一致。
怎么做:定义 list = ref<ChannelItem[]>([]),在 getList 中按接口文档拼接 URL,request.get<ChannelRes> 后把 res.data.data.channels 赋给 list.value,并 return { list, getList }。组件在 onMounted 中调用 action。
易错点:请求返回层级写错;忘记 .value;忘记 return;URL 未按接口文档拼接。

ts
const list = ref<ChannelItem[]>([])
async function getList() {
  const res = await request.get<ChannelRes>('/按接口文档拼接')
  list.value = res.data.data.channels
}
return { list, getList }

storeToRefs与调试 ​

为什么需要:直接解构 store 会丢失响应式;调试时需要观察 state/getters 变化。
怎么想:数据用 storeToRefs 包裹后再解构,方法直接从原 store 解构。
怎么做:const { count, doubleCount } = storeToRefs(counterStore),const { increment } = counterStore。Vue Devtools 的 Pinia 面板可查看模块、state、getters,并支持修改调试。
易错点:方法也用 storeToRefs;解构后忘记保留原 store;调试面板选错模块。

ts
const { count, doubleCount } = storeToRefs(counterStore)
const { increment } = counterStore

学习建议与易错点收束 ​

转写稿最后还强调:看面试题不要只背答案,建议围绕一个知识点写最小示例,例如父子组件传值、双向绑定,把“曾经写过”转化为可举出的项目经验;简历项目要熟悉功能、亮点和难点。技术上要牢记:凡是存储数据的地方都可能需要类型标注;接口文档是类型定义的依据;Pinia 中数据和方法都要 return;解构数据用 storeToRefs,解构方法直接用原 store。

为什么需要 Vue + TS 模板 ​

为什么需要:基础阶段的纯 TS 环境只能练习 TypeScript 语法,不能直接写 Vue 单文件组件。进入 Vue + TS 阶段后,需要 .vue 文件、Vue 类型推导、Vite 构建等能力,所以要用 Vite 的 Vue + TS 模板快速起项目。

怎么想:不要手写一堆配置文件。Vite 官方脚手架 create-vite 已经内置模板,创建时选 vue-ts 即可。转写稿里也反复体现一个思路:项目创建已经做过很多次,重点是按命令创建、进入目录、装依赖、跑起来验证,而不是重复造配置。

怎么做:在准备放项目的父目录执行:

bash
$ npm create vite@latest  vue-ts-pro -- --template vue-ts

说明:

  1. npm create vite@latest:基于最新版本的 Vite 创建项目。
  2. vue-ts-pro:项目名称,可换成自己的项目名。
  3. -- --template vue-ts:选择 Vue + TS 开发模板。

易错点:不要漏掉中间的 --;模板名是 vue-ts,不是 vue 或 ts;项目名不要随意使用大写和特殊字符;命令要在父目录执行,不要已经在同名目录里再创建同名项目。

命令拆解与创建思路 ​

为什么需要:看懂命令才能知道每一步在做什么,出错时能定位是包管理器、项目名还是模板参数的问题。

怎么想:npm create vite@latest 是“用 npm 执行 create-vite 脚手架”,后面的 vue-ts-pro 是传给脚手架的项目名,-- --template vue-ts 是把 --template vue-ts 继续传给 create-vite。转写稿里讲师也强调按官方文档操作,包管理器不限制,npm、yarn 等都可以,但命令写法要对应。

怎么做:执行后如果交互式询问项目名或模板,按提示选择;如果命令参数完整,脚手架会直接生成目录。生成后进入项目:

bash
cd vue-ts-pro
npm install
npm run dev

易错点:npm create vite@latest 中的 @latest 不要写成笔记里带链接的格式;如果网络慢导致创建失败,先检查 npm 源和网络;安装依赖前确认已经进入项目目录;不要在没有 package.json 的父目录直接 npm run dev。

创建后的验证流程 ​

为什么需要:项目创建成功不等于能运行。必须安装依赖并启动开发服务器,看到页面和本地地址,才算环境可用。转写稿里创建项目后也是先进入项目、安装依赖、运行 dev,打开本地地址确认页面正常;并强调装包后要看 package.json,确认依赖确实写进去了。

怎么想:验证分三层:目录结构是否存在、依赖是否安装、开发服务器是否启动。先看项目根目录有没有 package.json、src、vite.config.ts 等;再执行安装;最后启动并打开终端给出的本地地址。

怎么做:

bash
cd vue-ts-pro
npm install
npm run dev

启动后终端会给出本地访问地址,浏览器打开能看到 Vite + Vue 页面即可。随后可打开 package.json 检查 vue、typescript、vite 等依赖是否在依赖列表中。转写稿中“每装一个新包就看一下 package.json 是否正儿八经装过来”的习惯,在这里同样适用。

易错点:只创建不安装依赖就启动,会报找不到模块;端口被占用时按终端提示换端口;不要把终端里的本地地址当成固定不变;如果页面空白,先看终端和浏览器控制台报错,不要盲目改配置。

易错点与调试建议 ​

为什么需要:环境创建阶段的问题大多来自命令、目录、依赖和 Node 版本,先按顺序排查比乱改配置高效。

怎么想:按“命令是否完整 → 当前目录是否正确 → 依赖是否安装 → Node/npm 版本是否满足 → 网络是否正常”的顺序排查。转写稿里讲师处理项目时也是先创建、再安装、再运行、再看结果,强调按步骤落地和验证。

怎么做:命令报错时先复制完整报错;确认 node -v、npm -v 可用;确认在项目根目录执行 npm install;确认 package.json 中存在依赖;必要时删除 node_modules 后重新安装。创建成功后不要急着写业务代码,先保证默认页面能跑起来。

易错点:把 npm create 和 npm install 混为一谈;在错误目录执行安装;看到报错只改一半;忽略终端提示的本地地址;项目名与已有目录冲突。完成本节后,应得到一个可启动的 vue-ts-pro 项目,为后续 Vue + TS 开发打好环境基础。

为什么要把类型检查拆成两段 ​

.vue 文件不是普通 .ts 文件:它同时包含模板、脚本和样式,类型检查既涉及 script setup 里的变量、props、接口,也涉及模板表达式。若所有类型检查都压到 Vite 启动/打包链路里,开发服务器会变慢;若完全不做,又容易把类型错误带到线上。因此本节的核心思路是:开发阶段把实时类型提示交给 IDE,打包阶段再用 vue-tsc 做兜底校验。这样既保证开发体验,又保证最终质量。

开发阶段与打包阶段的类型检查分工

开发阶段:Volar 负责实时类型错误反馈 ​

为什么需要:写 .vue 时,模板里绑定的变量、props 类型、接口字段一旦写错,最好在编辑器里立刻看到,而不是等打包才暴露。
怎么想:把 Volar 看成 Vue 单文件组件的“语言服务”,它负责让 IDE 理解 .vue 的结构并给出类型提示和错误反馈。
怎么做:在编辑器中安装并启用 Volar,确保当前工作区使用项目对应的 TypeScript 版本;打开 .vue 文件后观察模板和脚本中的类型提示是否正常。
易错点:不要把 Volar 当成打包工具;不要同时启用冲突的旧 Vue 插件;编辑器缓存或 TS 版本不一致时,可能出现“明明改了还报错”,要重启语言服务或检查工作区 TS 版本。

开发阶段:TypeScript Vue Plugin 支持在 TS 中导入 .vue ​

为什么需要:TypeScript 默认不认识 import X from './X.vue',会提示找不到模块。只有让 TS 语言服务知道 .vue 也是一个有类型的模块,导入才能获得类型。
怎么想:Volar 解决 .vue 内部理解问题,TypeScript Vue Plugin 解决“从 .ts 里导入 .vue”的模块识别问题,两者配合。
怎么做:在 IDE/TS 插件环境启用 TypeScript Vue Plugin,与 Volar 配套使用;随后在 .ts 中导入 .vue 组件时,应能获得组件类型而不是模块报错。
易错点:只装 Volar 不启用该插件,可能出现 *.vue 模块找不到;用 declare module '*.vue' { const c: any } 这类 shim 虽然能消错,但会把组件类型变成 any,丢失检查价值;插件版本与 Volar 不匹配也会异常。

打包阶段:vue-tsc 做最终类型检查 ​

为什么需要:开发时 IDE 可能没打开、类型错误可能被漏看;而 Vite 的构建转译并不等同于完整类型检查。发布前必须有统一兜底。
怎么想:开发阶段追求快,打包/CI 阶段追求稳。vue-tsc 负责对 .vue 和 TS 代码做最终类型校验,失败就阻断构建。
怎么做:在项目打包脚本中配置并执行 vue-tsc 做类型检查(具体参数以项目脚本为准),把它作为构建前或构建中的质量门禁。
易错点:误以为 vite build 一定会做完整类型检查;把 vue-tsc 和普通 tsc 混用;只在本地偶尔跑、CI 不跑;类型报错被忽略或跳过。

从转写稿看类型检查的调试顺序 ​

转写稿没有直接讲 Volar 和 vue-tsc,但综合案例展示了类型落地后的调试顺序:先把频道、文章列表的接口类型抽到独立的 .d.ts 文件,组件里用 import type 引入;再把重复的 data、message 结构抽成泛型,把可变的数据类型作为 T 传入。改完后先检查 App.vue 和列表组件是否还有类型错误,再刷新浏览器确认运行无报错。这里要纠正几个同音字:props、defineProps、泛型、.d.ts、import type。这套顺序正好说明:编辑器先暴露类型问题,改完再由项目运行和打包检查兜底。

课件图示 ​

课件第53页:文章列表渲染实现 基础文章列表实现(固定频道id) 1. 根据接口格式定义数据类

课件第37页:如何为组件的emits标注类型 语法:通过 defineEmits 宏函数进行类

课件第39页:思考 Son组件再触发一个事件’get-list’, 传递参数类型为下图 所示的

课件第51页:频道列表渲染 - 类型思想转变 1. 使用ref创建响应式列表 2. 使用axi

课件第52页:频道列表渲染 - 定义axios的返回数据类型 定义axios的返回数据类型需要

课件第3页:和.vue文件TS环境相关的工具职责说明 开发阶段 1. Volar工具对.vu

课件第6页:如何标注类型 ref 函数和 TS 的配合通常分为俩种情况,类型推导和泛型指定类

课件第10页:如何标注类型 reactive函数和TS的配合通常分为俩种情况,类型推导和显式标

代码示例 ​

示例 1:频道与文章列表联动(父传子 + defineProps) ​

vue
<!-- src/App.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import ArticleList from './components/ArticleList.vue'
import type { ChannelItem } from './types/data'

// 频道列表(实际项目由接口返回,这里先写死方便运行)
const channelList = ref<ChannelItem[]>([
  { id: 0, name: '推荐' },
  { id: 1, name: 'HTML' },
  { id: 2, name: 'JS' }
])

// 父组件维护"当前选中频道ID",默认 0
const channelId = ref(0)

// 对应 van-tabs 的 @change 回调:点击哪一项就把哪一项的 id 拿到
const onTabChange = (id: number) => {
  channelId.value = id
}
</script>

<template>
  <!-- 原案例用 van-tabs 的 @change,这里用原生按钮还原同样的逻辑 -->
  <div class="tabs">
    <button
      v-for="item in channelList"
      :key="item.id"
      :class="{ active: item.id === channelId }"
      @click="onTabChange(item.id)"
    >
      {{ item.name }}
    </button>
  </div>

  <!-- 父传子:把当前频道ID传给子组件 -->
  <ArticleList :channel-id="channelId" />
</template>
vue
<!-- src/components/ArticleList.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import request from '@/utils/request'          // axios 封装
import type { ArticleItem, ArticleRes } from '@/types/data'

// 第一步:定义 props 类型
interface Props {
  channelId: number
}
// 第二步:通过 defineProps 的泛型参数把类型注解过来
const props = defineProps<Props>()

const list = ref<ArticleItem[]>([])

const getList = async () => {
  const res = await request.get<ArticleRes>('/v1_0/articles', {
    // 用 props 里的 ID 替换掉之前写死的 0
    params: { channel_id: props.channelId }
  })
  list.value = res.data.data.results
}

// 频道ID变化就重新请求
watch(() => props.channelId, getList, { immediate: true })
</script>

<template>
  <ul>
    <li v-for="item in list" :key="item.art_id">{{ item.title }}</li>
  </ul>
</template>

要点与易错点

  • defineProps<Props>() 是"宏函数 + 泛型参数"两步走:先写 interface Props,再把 Props 作为泛型传进去,不需要再写 props: { channelId: Number }。
  • 类型必须跟后端接口一致:channelId 后端给的是 number,就写 number;写成 string 后父组件传值会直接飘红。
  • 父组件必须真的把值传下来(:channel-id="channelId"),只改子组件不传,props.channelId 就是 undefined,请求参数会丢。
  • 千万不要 const { channelId } = defineProps<Props>() 解构,解构后 props 失去响应式,频道切换了列表不会跟着变;用 props.channelId 或 toRefs。
  • 子组件里只负责"拿 ID 发请求",切 ID 的动作由父组件的 @change 完成,这就是最典型的父传子通信。

示例 2:类型抽离到 .d.ts + 用泛型消除重复 ​

ts
// src/types/data.d.ts
// 频道相关
export interface ChannelItem {
  id: number
  name: string
}

// 文章相关
export interface ArticleItem {
  art_id: string
  title: string
}

// 把两个接口重复的 { message, data } 抽出来,data 用泛型 T 表示可变部分
export interface ApiRes<T> {
  message: string
  data: T
}

// 频道列表接口:可变部分是 { channels: ChannelItem[] }
export type ChannelRes = ApiRes<{ channels: ChannelItem[] }>

// 文章列表接口:可变部分是 { results: ArticleItem[]; total_count: number }
export type ArticleRes = ApiRes<{
  results: ArticleItem[]
  total_count: number
}>
ts
// 组件中导入类型使用(注意 type 关键字,区分普通模块导入)
import type { ChannelItem, ChannelRes } from '@/types/data'
import type { ArticleItem, ArticleRes } from '@/types/data'
ts
// 组件内部:props 类型跟着组件走,更聚合,不抽到 data.d.ts
interface Props {
  channelId: number
}
const props = defineProps<Props>()

要点与易错点

  • message 是固定的,data 是每个接口各不相同的,所以只把 data 抽成泛型参数 T;泛型优化的思路就是"找到可变的那部分,其余固定"。
  • 抽出去的类型要 export,组件里用 import type { ... } 导入,type 关键字用于区分类型导入和值导入。
  • 命名建议 xxx.d.ts 放在 src/types 下,纯类型文件不会被打包进产物。
  • 易错点:把 interface Props 也一起搬到 data.d.ts 里——props 类型属于当前组件,留在组件内部更聚合;只有跟后端接口相关的类型才抽离。
  • 易错点:泛型写成 ApiRes<T> 却在使用的接口里把 message 也塞进 T 里,那就白抽了。

示例 3:Pinia 安装、getters、异步 action 与 storeToRefs ​

ts
// src/main.ts —— 创建 pinia 实例并作为插件注册到应用
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()
const app = createApp(App)
app.use(pinia)          // 本质是插件安装
app.mount('#app')
ts
// src/store/counter.ts —— 组合式风格定义 store
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)                                  // state
  const doubleCount = computed(() => count.value * 2)   // getters:用 computed 模拟
  const increment = () => { count.value++ }             // action:同步

  // 必须 return,组件里才能拿到
  return { count, doubleCount, increment }
})
ts
// src/store/channel.ts —— 异步 action 获取频道列表
import { defineStore } from 'pinia'
import { ref } from 'vue'
import request from '@/utils/request'
import type { ChannelItem, ChannelRes } from '@/types/data'

export const useChannelStore = defineStore('channel', () => {
  const list = ref<ChannelItem[]>([])                   // state,泛型标注依据接口文档

  const getList = async () => {                         // 异步 action,写法和组件里完全一致
    const res = await request.get<ChannelRes>('/v1_0/channels')
    list.value = res.data.data.channels
  }

  return { list, getList }
})
vue
<!-- src/App.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useCounterStore } from './store/counter'
import { useChannelStore } from './store/channel'

const counterStore = useCounterStore()
// 数据(state / getters)用 storeToRefs 包裹后再解构,保持响应式
const { count, doubleCount } = storeToRefs(counterStore)
// 方法直接从 store 上解构即可
const { increment } = counterStore

const channelStore = useChannelStore()
const { list } = storeToRefs(channelStore)
const { getList } = channelStore

// 组件挂载时触发异步 action
onMounted(() => {
  getList()
})
</script>

<template>
  <button @click="increment">{{ count }}</button>
  <span>getter:{{ doubleCount }}</span>

  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

要点与易错点

  • defineStore('模块名', 回调) 两个参数缺一不可;回调里用 ref 定义 state、用 computed 定义 getters、用普通函数定义 action,最后必须 return,不 return 组件里拿不到。
  • Pinia 没有 mutation,action 同时支持同步和异步;异步 action 就是"组件里那个发请求的普通函数"搬进了 store,写法一模一样。
  • 数据用 storeToRefs(store) 解构才能保持响应式;直接 const { count } = counterStore 会丢失响应式,点击加号视图不动。
  • storeToRefs 只对数据有效(state 和 getters),方法不要用 storeToRefs,直接从原 store 上解构。
  • request.get<ChannelRes> 的泛型依据接口文档写,写对后 res.data.data.channels 才有联想和类型校验。
  • 浏览器装好 Vue Devtools 后,面板里会多出一个小菠萝(Pinia)标签,可以看到 counter、channel 两个模块的 state / getters,并支持直接改数据调试。

常见错误 ​

1. 父组件没传 channelId,子组件类型检查报错

  • 现象:子组件模板里 channelId 出现红色波浪线,运行时拿到 undefined,请求参数变成 undefined。
  • 原因:只完成了 defineProps 的类型声明,父组件忘了绑定;或绑定时写成 channelId="1"(字符串)。
  • 修法:父组件用 :channel-id="channelId" 绑定,保持 number 类型;给 props 一个默认值兜底,先渲染再请求。

2. tab 切换后请求参数还是旧值

  • 现象:点击频道后列表没变,Network 里参数一直是 0。
  • 原因:只在 change 回调里打了 log,没把事件参数写回响应式变量,或写回的不是 ref.value。
  • 修法:change 回调显式标注 (id: number),回调里 channelId.value = id;确认请求函数依赖的是这个响应式变量而非写死的常量。

3. 直接解构 store,数据变了页面不动

  • 现象:点按钮后 devtools 里 count 在变,视图始终停在旧值。
  • 原因:const { count } = counterStore 拿到的是普通值,脱离响应式追踪。
  • 修法:用 storeToRefs(counterStore) 包裹后再解构。

4. 对方法用 storeToRefs 导致调用失效

  • 现象:解构出来的 increment 是 undefined 或调用无反应。
  • 原因:storeToRefs 只处理 state 与 getters 这类响应式数据,函数不在其列。
  • 修法:数据走 storeToRefs,方法仍从原 store 解构:const { increment } = counterStore。

5. store 里写了数据和方法,组件里却取不到

  • 现象:store.list 为 undefined,控制台打印 store 对象里没有该字段。
  • 原因:setup 风格的 store 必须显式 return,漏 return 等于没暴露。
  • 修法:把 state、getters、action 统一以对象形式 return;新增字段后回组件里打一次 log 自查。

6. 异步 action 里赋值忘了 .value

  • 现象:接口有数据返回,模板渲染空白,list 打印出来是 RefImpl 对象。
  • 原因:ref 在 script 中必须通过 .value 赋值,写成 list = res.data.data.channels 是重新赋值局部变量。
  • 修法:list.value = res.data.data.channels;赋值前先访问 res.data.data 验证泛型是否生效。

7. 接口返回类型在两个组件里各写一份

  • 现象:后端加字段时改了一处漏了另一处,类型检查通过但运行时字段缺失。
  • 原因:没有抽离公共类型,也没用泛型抽象 { data, message } 这层重复结构。
  • 修法:类型集中到 src/types/data.ts 并 export,用 ApiRes<T> 泛型承接变化部分;组件里用 import type { ... } 引入。

8. 本地跑得好好的,build 时类型报错

  • 现象:npm run dev 一切正常,打包阶段 vue-tsc 抛出一堆类型错误。
  • 原因:Vite 开发期不做类型检查,只靠 Volar 在 IDE 里提示,关掉编辑器提示就"看不见"错误。
  • 修法:把 vue-tsc --noEmit 接进构建脚本,提交前跑一次;保证 Volar 与 TypeScript Vue Plugin 均已启用。

练习 ​

练习 1|联动补全 在频道与文章列表联动的基础上,增加"切换频道时先把列表清空并显示加载中"的效果。要求:props 用 defineProps 泛型声明;change 事件参数标注为 number;加载态由子组件内部维护,不通过 props 传递。

练习 2|类型抽离与泛型改造 把 ChannelItem、ChannelRes、ArticleItem、ArticleRes 全部迁到 src/types/data.ts,并用泛型把重复的 { data, message } 结构抽象掉。要求:组件内只保留 props 类型,接口返回类型全部外部导入;改造完成后确认编辑器无红色提示。

练习 3|新建一个 store 创建 articleStore:包含 list 响应式数据、getList(channelId: number) 异步 action、total 计算属性(返回列表长度)。在文章列表组件中 onMounted 触发请求,用 storeToRefs 解构 list 与 total 渲染,方法从原 store 解构。完成后打开 devtools 的 Pinia 面板,观察点击频道时 state 的实时变化。

练习 4|排查改错 有如下代码,点击按钮数据会变但页面不更新,且点击时报 increment is not a function:

ts
const { count, increment } = useCounterStore()

找出两处问题并给出修正写法;再说明如果 count 换成 getter doubleCount,结论是否变化。

练习 5|自测清单 不看笔记,口头回答:Pinia 里 state / getters / action 分别对应组合式 API 的哪个函数?为什么 Pinia 不需要 mutation?回答完毕后回代码里各找出一个对应实例验证。

原视频对照 ​

#视频时长
1综合案例-频道和文章列表联动实现05:43
2综合案例-综合和类型优化10:11
3Pinia-环境搭建和基础使用16:59
4Pinia-getters和异步action13:32
5Pinia-storeToRefs和调试07:19
6day03-项目-13番外_如何看面试题和面试_个人建议09:02

小结 ​

  1. 类型定义的唯一依据是接口文档,不是"看起来像什么"——文档改,类型跟着改。
  2. 父子联动的本质只有两步:父传子给 ID,子用 ID 发请求;复杂交互都是这两步的叠加。
  3. 判断类型要不要抽离,看"是否被两处以上引用",而不是看代码行数多少。
  4. 泛型消除的是结构上的重复,不是逻辑上的重复;找不到"可变的那部分"就说明还不该用泛型。
  5. Pinia 把同步与异步统一收敛到 action,概念数量减少才是它替代 Vuex 的核心收益。
  6. storeToRefs 只解决数据解构的响应式丢失,方法解构从来不需要它——这条区分错了,排查会绕很远。
  7. 开发期靠 IDE 提示、构建期靠 vue-tsc 兜底,是两条独立的防线,缺一条都会在上线前集中爆雷。
  8. 调试顺序固定为:先看 devtools 里的 state 是否符合预期,再看 Network 的请求参数与返回值——顺序反了会白改很多代码。