Vue 3 实战:拆解 RealWorld 项目

摘要:以 mutoe/vue3-realworld-example-app 真实项目为载体,逐文件拆解 Vue 3 Composition API、Pinia Setup Store、Vue Router 类型安全路由、Composables 路由驱动数据流与 swagger-typescript-api 自动生成客户端,带你从"会语法"跨到"会做项目"。

一、项目全景:RealWorld 是什么,怎么跑起来

如果你学过 Vue 3 的基础语法,大概会有这种感觉:文档看懂了,但一上手写真实项目就懵------不知道怎么组织代码、怎么做状态管理、怎么设计路由。别担心,这很正常------从"会语法"到"会做项目"中间有一个台阶。我们今天要做的,就是跨过这个台阶。

我们的方式不是"从零写玩具 demo",而是拆解一个真实的开源项目------边读边学,看看真实项目的代码是怎么组织的。

1.1 RealWorld 是什么

RealWorld 是一个著名的开源项目规范------"一个 Medium.com 克隆,用不同前端框架实现"。它不是简单的 Todo List,而是一个完整的博客平台:

  • 用户注册 / 登录 / 认证

  • 文章 CRUD(创建 / 编辑 / 删除)

  • 文章列表 + 分页 + 标签筛选

  • 评论系统

  • 用户关注 / 取消关注

  • 个人主页 + 收藏列表

RealWorld 规范包括前端 UI 规范、后端 API spec(OpenAPI 格式)、以及几十种框架的实现版本(React / Vue / Angular / Svelte / Nuxt......)。它被称为"the mother of all demo apps"------比 Todo List 复杂一个量级,但比企业级项目简洁。

1.2 为什么选 mutoe/vue3-realworld-example-app

本期拆解的是 mutoe/vue3-realworld-example-app,理由:

  1. 技术栈现代 :Vue 3 Composition API + <script setup> + Pinia(不是 Vuex)+ Vue Router + TypeScript------2026 年 Vue 生态的主流配置

  2. 活跃维护:468 commits,2026-05 仍在更新

  3. 有架构文档 :项目自带 CLAUDE.md,说明每个目录的职责

  4. 有完整测试:Vitest 单元测试 + Playwright E2E 测试------真实项目的质量保证

  5. API 自动生成 :用 swagger-typescript-api 从 OpenAPI spec 自动生成 API 客户端------这是"契约驱动开发"的实践

1.3 技术栈一览

| 技术 | 版本 | 作用 | | --- | --- | --- | | Vue | 3.5.32 | 视图层(Composition API + <script setup>) | | Pinia | 3.0.4 | 状态管理(替代 Vuex) | | Vue Router | 5.0.4 | 路由 | | Vite | 8.0.8 | 构建工具 + 开发服务器 | | TypeScript | 6.0 | 类型安全 | | pnpm | 10.33 | 包管理器 | | Vitest | 4.1.4 | 单元测试 | | Playwright | 1.55.1 | E2E 测试 |

1.4 跑起来

bash 复制代码
# 1. clone 项目
git clone https://github.com/mutoe/vue3-realworld-example-app.git
cd vue3-realworld-example-app

# 2. 安装依赖(需要 Node >= 20 和 pnpm)
pnpm install

# 3. 启动开发服务器
pnpm dev

打开浏览器访问 http://localhost:4173,你会看到一个完整的 Medium.com 克隆------首页有文章列表、可以注册登录、可以写文章、可以评论。

关键点 :项目需要一个 RealWorld API 服务器。默认连接官方 demo API(在 .env 文件的 VITE_API_HOST 配置)。如果官方 demo API 不可用,可以参考 RealWorld 后端列表 自己跑一个。

1.5 项目目录结构

bash 复制代码
src/
├── main.ts                 # 应用入口
├── App.vue                 # 根组件
├── router.ts               # 路由配置
├── config.ts               # 环境变量读取
├── pages/                  # 页面级组件
│   ├── Home.vue            # 首页(global-feed / my-feed / tag 共享)
│   ├── Article.vue         # 文章详情
│   ├── EditArticle.vue     # 编辑/创建文章
│   ├── Login.vue           # 登录
│   ├── Register.vue        # 注册
│   ├── Profile.vue         # 个人主页
│   ├── Settings.vue        # 设置
│   └── NotFound.vue        # 404
├── components/             # 可复用 UI 组件
├── composable/             # Composables(Vue 版"自定义 Hook")
│   ├── use-articles.ts     # 文章列表逻辑
│   └── ...
├── store/                  # Pinia 状态管理
│   └── user.ts             # 用户认证状态
├── services/               # API 客户端
│   ├── api.ts              # 自动生成的 API 客户端(不手动编辑)
│   └── index.ts            # API 实例配置
├── plugins/                # 应用初始化插件
│   ├── global-components.ts # 全局组件注册
│   ├── set-authorization-token.ts # 恢复 token
│   └── marked.ts           # Markdown 渲染
└── utils/                  # 工具函数

每个目录有明确的职责------这就是"分层设计"。后面会逐层拆解。

思考 1:RealWorld 的价值在于"真实"------它有真实的 API、真实的认证、真实的分页、真实的错误处理。学真实项目,学到的是"工程怎么做",不只是"语法怎么写"。玩具 demo 学不到"token 怎么恢复""API 错误怎么分类处理"这些工程细节。

思考 2:项目自带 CLAUDE.md 架构文档------好的开源项目会自我说明架构。读代码前先读架构文档,是高效的学习方法。这不是巧合,而是真实项目的好习惯。


二、入口拆解:main.ts + App.vue------应用是怎么启动的

2.1 main.ts 逐行拆解

我们一起来看应用入口:

bash 复制代码
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import registerGlobalComponents from './plugins/global-components'
import setAuthorizationToken from './plugins/set-authorization-token'
import { router } from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
setAuthorizationToken()
registerGlobalComponents(app)
app.mount('#app')

整个应用的启动流程就 5 步:

  1. createApp(App) :创建应用实例,传入根组件 App。Vue 3 用 createApp 替代了 Vue 2 的 new Vue()------更函数式,一个应用可以创建多个实例(如 SSR 场景)。

  2. app.use(createPinia()) :注册 Pinia 状态管理。createPinia() 返回一个 Pinia 实例,app.use() 注册为 Vue 插件。

  3. app.use(router):注册 Vue Router。router 也是一个 Vue 插件。

  4. setAuthorizationToken():恢复用户登录态------从 localStorage 读取 token,注入到 API 客户端的 header 中。这一步在 mount 前执行,避免页面渲染后再恢复 token 导致的闪烁。

  5. registerGlobalComponents(app) :注册全局组件(如 AppLink)。

  6. app.mount('#app') :挂载到 DOM 的 #app 元素。

关键点app.use() 必须在 app.mount() 之前调用------mount 后注册的插件不生效。这是 Vue 的约束,也是常识:先安装依赖再启动。

2.2 插件函数 vs Vue 插件对象

注意 setAuthorizationToken()registerGlobalComponents(app) 的区别------它们不是用 app.use() 注册的:

bash 复制代码
// src/plugins/set-authorization-token.ts
import { api } from 'src/services'
import { userStorage } from 'src/store/user'

export default function setAuthorizationToken(): void {
  const token = userStorage.get()?.token
  if (token !== undefined)
    api.setSecurityData(token)
}
bash 复制代码
// src/plugins/global-components.ts
import type { App } from 'vue'
import AppLink from 'src/components/AppLink.vue'

export default function registerGlobalComponents(app: App): void {
  app.component('AppLink', AppLink)
}

setAuthorizationToken() 是一个普通函数,不需要 app 参数------它直接操作 apiuserStorage(模块级导入)。registerGlobalComponents(app) 接收 app 参数,调用 app.component() 注册全局组件。

关键点 :不是所有初始化逻辑都需要用 app.use() 注册 Vue 插件------普通函数更简单直接。app.use() 适合需要 install 方法的第三方库(Pinia、Router),自定义初始化逻辑用普通函数即可。

2.3 App.vue:<script setup> 极简写法

bash 复制代码
<!-- src/App.vue -->
<template>
  <AppNavigation />
  <RouterView />
  <AppFooter />
</template>

<script setup lang="ts">
import AppFooter from './components/AppFooter.vue'
import AppNavigation from './components/AppNavigation.vue'
</script>

根组件只有 4 行 script------导入两个导航组件,模板里三段式布局:<AppNavigation />(导航栏)+ <RouterView />(路由出口)+ <AppFooter />(页脚)。

<RouterView /> 是 Vue Router 的内置组件------当前路由匹配的页面组件会在这里渲染。比如访问 / 渲染 Home.vue,访问 /login 渲染 Login.vue

<script setup> 是 Vue 3 的编译时语法糖------顶层 import 的组件自动在模板中可用,不需要 components: { AppFooter, AppNavigation } 的 Options API 注册。这就是为什么 script 只有 4 行。

思考 1 :Vue 3 的 createApp 比 Vue 2 的 new Vue() 更函数式------一个应用实例可以创建多个(如 SSR),互不干扰。这是 Vue 3 架构层面的改进,虽然日常开发感知不强,但在 SSR 和测试场景下很重要。

思考 2setAuthorizationToken() 在 mount 前执行------为什么?因为要在应用渲染前恢复用户登录态,避免页面先显示"未登录"再跳变为"已登录"的闪烁。这种"初始化顺序"在真实项目里很重要,玩具 demo 不会遇到这个问题。


三、路由设计:Vue Router 的类型安全与懒加载

3.1 路由表结构

bash 复制代码
// src/router.ts(节选)
import { createRouter, createWebHashHistory } from 'vue-router'
import { isAuthorized } from './store/user'

export type AppRouteNames
  = | 'global-feed'
    | 'my-feed'
    | 'tag'
    | 'article'
    | 'create-article'
    | 'edit-article'
    | 'login'
    | 'register'
    | 'profile'
    | 'profile-favorites'
    | 'settings'
    | 'not-found'

export const routes: RouteRecordRaw[] = [
  {
    name: 'global-feed',
    path: '/',
    component: () => import('./pages/Home.vue'),
  },
  {
    name: 'login',
    path: '/login',
    component: () => import('./pages/Login.vue'),
    beforeEnter: () => isAuthorized() ? { name: 'global-feed' as const } : true,
  },
  // ... 其他路由
]

export const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

路由表是一个 RouteRecordRaw[] 数组,每个路由有 name(唯一标识)、path(URL)、component(组件)。这个项目展示了三个真实项目级实践。

3.2 懒加载:代码分割

bash 复制代码
component: () => import('./pages/Home.vue')

注意 component 的值不是直接导入的组件,而是一个返回 import() 的函数------这就是懒加载。Webpack / Vite 会把每个懒加载的组件打包成单独的 chunk,只在用户访问对应路由时才加载。

关键点:懒加载 = 按需加载 = 减小首屏 bundle 体积。如果所有页面组件都打包在一起,首屏要下载所有页面的代码------包括用户可能永远不访问的页面。懒加载让首屏只加载首页代码,其他页面按需加载。

3.3 类型安全路由名

bash 复制代码
export type AppRouteNames
  = | 'global-feed'
    | 'my-feed'
    | 'tag'
    // ...

export async function routerPush(
  name: AppRouteNames,
  params?: RouteParams
): ReturnType<typeof router.push> {
  return params === undefined
    ? await router.push({ name })
    : await router.push({ name, params })
}

AppRouteNames 是一个联合类型,枚举了所有路由名。routerPush() 函数接受 AppRouteNames 类型的参数------如果你拼错了路由名(如 routerPush('global-feedd')),TypeScript 编译时就报错,不用等运行时。

对比很多教程只教 router.push('/path') 字符串导航------字符串没有类型检查,拼错了只有运行时才发现。真实项目用类型安全导航,编译时就排除错误。

关键点 :这是 Vue Router 的进阶用法------用 TypeScript 联合类型约束路由名,配合 routerPush() 封装函数,实现编译时类型安全。IDE 也能自动补全路由名。

3.4 路由复用

bash 复制代码
// 三个路由共享 Home.vue
{ name: 'global-feed', path: '/', component: () => import('./pages/Home.vue') },
{ name: 'my-feed', path: '/my-feeds', component: () => import('./pages/Home.vue') },
{ name: 'tag', path: '/tag/:tag', component: () => import('./pages/Home.vue') },

// 两个路由共享 EditArticle.vue
{ name: 'create-article', path: '/article/create', component: () => import('./pages/EditArticle.vue') },
{ name: 'edit-article', path: '/article/:slug/edit', component: () => import('./pages/EditArticle.vue') },

global-feedmy-feedtag 三个路由共享 Home.vue------一个组件根据 useRoute() 返回的路由名切换 feed 类型(全球 / 关注 / 标签),减少重复代码。

3.5 路由守卫

bash 复制代码
{
  name: 'login',
  path: '/login',
  component: () => import('./pages/Login.vue'),
  beforeEnter: () => isAuthorized() ? { name: 'global-feed' as const } : true,
}

beforeEnter 是路由级守卫------只在进入这个路由时执行。这里检查 isAuthorized()(用户是否已登录),如果已登录就重定向到首页(避免已登录用户看到登录页)。

对比 router.beforeEach(全局守卫,每个路由都执行),beforeEnter 粒度更细------只守需要守的路由。真实项目按需选择。

关键点isAuthorized() 是从 store/user.ts 导出的模块级函数------不需要 useUserStore() 创建 store 实例就能调用。这个设计在下一节状态管理中展开。

3.6 Hash 模式

bash 复制代码
export const router = createRouter({
  history: createWebHashHistory(),
  routes,
})

createWebHashHistory() 使用 URL hash(/#/path)做路由------适合静态托管(如 GitHub Pages),不需要服务器配置 URL 重写。对比 createWebHistory()(HTML5 history 模式,URL 更干净如 /path),但需要服务器配置 fallback 到 index.html

思考 1 :类型安全路由名是 Vue Router 的进阶用法------很多教程只教 router.push('/path') 字符串导航,但真实项目用 routerPush('global-feed') 类型安全导航,编译时就排除拼写错误。这种"编译时 vs 运行时"的差异,在项目变大后价值越来越大。

思考 2 :路由复用不是"偷懒",是"设计"------Home.vue 根据 useRoute() 返回的路由名切换 feed 类型,一个组件服务多个路由。这比写三个几乎一样的组件更好维护。

思考 3beforeEnter 守卫只守这一个路由,不影响其他路由------与 router.beforeEach 全局守卫对比,粒度更细。真实项目按需选择:全局守卫适合"所有路由都要做的事"(如鉴权),路由级守卫适合"只有这个路由要做的事"(如已登录用户不能看登录页)。


四、状态管理:Pinia Setup Store------Vue 3 的状态管理

4.1 Pinia 是什么

Pinia 是 Vue 3 官方推荐的状态管理库(State Management),替代 Vuex。它和 Vuex 解决同样的问题------跨组件共享状态------但 API 更简洁、TypeScript 支持更好、没有 mutations(直接改 state)。

Pinia 的核心 API 是 defineStore()

bash 复制代码
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  // ...
})

defineStore 接受两个参数:store 的唯一 id(字符串)和定义函数。返回一个 useUserStore() 函数------在组件中调用来获取 store 实例。

4.2 Setup Store vs Option Store

Pinia 有两种定义 store 的语法:

Option Store(对象语法,类似 Options API):

bash 复制代码
export const useUserStore = defineStore('user', {
  state: () => ({ user: null }),
  getters: { isAuthorized: (state) => !!state.user },
  actions: { updateUser(userData) { this.user = userData } },
})

Setup Store(函数语法,类似 Composition API):

bash 复制代码
export const useUserStore = defineStore('user', () => {
  const user = ref(null)                    // → state
  const isAuthorized = computed(() => !!user.value)  // → getters
  function updateUser(userData) { user.value = userData }  // → actions
  return { user, isAuthorized, updateUser }
})

映射规则:ref() → state,computed() → getters,function() → actions。

这个项目用 Setup Store ------和 <script setup> 风格一致,学会了 Composition API 就会了 Pinia Setup Store。这是 Vue 3 设计的一致性之美。

4.3 store/user.ts 逐行拆解

bash 复制代码
// src/store/user.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'src/services'
import type { User } from 'src/services/api'
import Storage from 'src/utils/storage'

// 模块级:不需要 useUserStore() 就能调用
export const userStorage = new Storage<User>('user')
export const isAuthorized = (): boolean => !!userStorage.get()

export const useUserStore = defineStore('user', () => {
  // state:从 localStorage 初始化
  const user = ref(userStorage.get())

  // getters:用户是否已登录
  const isAuthorized = computed(() => !!user.value)

  // actions:更新用户状态(同步三个系统)
  function updateUser(userData?: User | null) {
    if (userData) {
      userStorage.set(userData)              // ① 持久化到 localStorage
      api.setSecurityData(userData.token)    // ② 注入 API token
      user.value = userData                  // ③ 更新响应式状态
    }
    else {
      userStorage.remove()                   // 清除 localStorage
      api.setSecurityData(null)              // 清除 API token
      user.value = null                      // 清除响应式状态
    }
  }

  return { user, isAuthorized, updateUser }
})

4.4 三个关键设计

设计一:localStorage 持久化

user = ref(userStorage.get()) 从 localStorage 初始化用户状态------页面刷新后状态不丢失。userStorage 是一个封装了 localStorage 的工具类,用泛型保证类型安全。

设计二:updateUser 的"原子更新"

updateUser() 同时更新三个地方:

  1. userStorage.set(userData)------持久化到 localStorage(刷新不丢)

  2. api.setSecurityData(userData.token)------注入 API 客户端的 token(后续请求自动带 Authorization header)

  3. user.value = userData------更新响应式状态(UI 自动更新)

这三个操作必须一起做------少做任何一个都会导致状态不一致。这种"原子更新"思维在面试中也是加分项。

设计三:模块级函数

bash 复制代码
// 模块级导出,不需要 useUserStore() 就能调用
export const userStorage = new Storage<User>('user')
export const isAuthorized = (): boolean => !!userStorage.get()

isAuthorized()userStorage 在 store 文件的模块级导出------不需要 useUserStore() 创建 store 实例就能调用。为什么需要这个?因为路由守卫在组件外执行:

bash 复制代码
// src/router.ts
beforeEnter: () => isAuthorized() ? { name: 'global-feed' } : true

路由守卫 beforeEnter 不在组件内,不能调用 useUserStore()(store 实例需要组件上下文)。模块级函数绕过了这个限制------直接读 localStorage 判断登录态。

4.5 storeToRefs 解构

很多同学会卡在这里------Pinia store 不能直接解构,会丢失响应性:

bash 复制代码
const store = useUserStore()
const { user } = store  // ❌ user 不再是响应式的

需要用 storeToRefs() 解构 state 和 getters:

bash 复制代码
import { storeToRefs } from 'pinia'

const store = useUserStore()
const { user, isAuthorized } = storeToRefs(store)  // ✅ 响应式解构
const { updateUser } = store                        // ✅ actions 可以直接解构

关键点storeToRefs() 只对 state 和 getters 创建响应式 ref,actions 是普通函数可以直接解构。这是 Pinia 的响应式机制决定的------store 本质是 reactive() 包裹的对象,直接解构 reactive 对象的属性会丢失响应性。

4.6 在组件中使用 store

bash 复制代码
// src/pages/Login.vue(节选)
import { useUserStore } from 'src/store/user'

const { updateUser } = useUserStore()  // 直接解构 action

async function login() {
  // ...
  const result = await api.users.login({ user: form })
  updateUser(result.data.user)         // 调用 action 更新全局状态
  await routerPush('global-feed')
}

Login.vue 调用 useUserStore() 获取 store,直接解构 updateUser action(不需要 storeToRefs,因为 actions 可以直接解构)。登录成功后调用 updateUser() 更新全局用户状态------store 自动同步 localStorage 和 API token。

思考 1 :Setup Store 和 Composition API 是同一套思维模型------ref 管 state,computed 管派生,function 管操作。学会了 <script setup>,Pinia Setup Store 就是"把组件逻辑搬到 store 里"。不需要学两套 API,这是 Vue 3 设计的一致性之美。

思考 2isAuthorized() 模块级函数是一个设计巧思------store 实例需要 useUserStore() 在组件内调用(依赖组件上下文),但路由守卫在组件外执行。模块级函数绕过了这个限制,让状态逻辑在任意位置可用。这种"跨上下文状态访问"是真实项目才会遇到的问题。

思考 3updateUser() 同时更新三个地方(localStorage + API token + 响应式状态)------真实项目的状态管理不是"改一个变量",是"同步多个系统"。这种"原子更新"思维在面试中也是加分项。面试官追问"你怎么管理登录态",你能答出"三层同步"而不是"改一个变量",立刻脱颖而出。


五、组件实战:<script setup> + 表单 + Suspense

5.1 <script setup> 基本写法

bash 复制代码
<!-- 最简写法 -->
<script setup lang="ts">
import { ref, reactive } from 'vue'
import SomeComponent from './SomeComponent.vue'

const count = ref(0)                    // 响应式状态
const form = reactive({ name: '' })     // 响应式对象
function increment() { count.value++ }  // 方法
</script>

<template>
  <SomeComponent />
  <button @click="increment">{{ count }}</button>
  <input v-model="form.name" />
</template>

<script setup> 的核心规则:

  • 顶层 import 的组件自动在模板中可用

  • 顶层 ref() / reactive() / function 自动暴露给模板

  • 无需 return 语句(普通 setup() 函数需要 return)

  • ref 在模板中自动解包(不需要 .value),在 script 中需要 .value

  • <script setup lang="ts"> 支持 TypeScript

5.2 reactive vs ref

很多同学第一次用 Vue 3 会困惑:什么时候用 reactive,什么时候用 ref

  • reactive() :用于对象 。直接访问属性,不需要 .value。如 form.email

  • ref() :用于基本类型 (或需要"整体替换"的场景)。在 script 中用 .value 访问,模板中自动解包。

规则很简单:对象用 reactive,基本类型用 ref。但有一个例外------如果需要"整体替换"对象(如 form = newForm),用 refreactive 不能整体替换,会丢失响应性)。

Login.vue 的选择很有代表性:

bash 复制代码
const form: LoginUser = reactive({   // 对象 → reactive
  email: '',
  password: '',
})
const errors = ref()                 // 可能是 undefined/对象,需要整体替换 → ref

5.3 Login.vue 表单全流程

bash 复制代码
<!-- src/pages/Login.vue(完整 script) -->
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { routerPush } from 'src/router'
import { api, isFetchError } from 'src/services'
import type { LoginUser } from 'src/services/api'
import { useUserStore } from 'src/store/user'

const formRef = ref<HTMLFormElement | null>(null)
const form: LoginUser = reactive({
  email: '',
  password: '',
})
const { updateUser } = useUserStore()
const errors = ref()

async function login() {
  errors.value = {}
  if (!formRef.value?.checkValidity())
    return
  try {
    const result = await api.users.login({ user: form })
    updateUser(result.data.user)
    await routerPush('global-feed')
  }
  catch (error) {
    if (isFetchError(error)) {
      errors.value = error.error?.errors
      return
    }
    console.error(error)
  }
}
</script>
bash 复制代码
<!-- src/pages/Login.vue(模板节选) -->
<template>
  <!-- 错误信息渲染 -->
  <ul class="error-messages">
    <li v-for="(error, field) in errors" :key="field">
      {{ field }} {{ error ? error[0] : '' }}
    </li>
  </ul>

  <!-- 表单 -->
  <form ref="formRef" @submit.prevent="login">
    <input type="email" v-model="form.email" required placeholder="Email">
    <input type="password" v-model="form.password" required placeholder="Password">
    <button type="submit" :disabled="!form.email || !form.password">
      Sign in
    </button>
  </form>
</template>

表单全流程:

  1. reactive() 创建表单对象form = reactive({ email, password })

  2. v-model 双向绑定<input v-model="form.email">------输入框值和 form.email 双向同步

  3. @submit.prevent 阻止默认提交 :表单提交时执行 login() 而不是页面跳转

  4. formRef.value?.checkValidity() 原生校验 :HTML5 表单校验 API,检查 required / type="email"

  5. api.users.login() 异步请求:调用 API 客户端发送登录请求

  6. updateUser() 更新状态:登录成功后更新全局用户状态

  7. routerPush('global-feed') 跳转:类型安全的路由跳转

5.4 Login.vue 错误处理

bash 复制代码
catch (error) {
  if (isFetchError(error)) {
    errors.value = error.error?.errors
    return
  }
  console.error(error)
}

错误处理展示了"字段级错误"模式:

  • try/catch 捕获异常

  • isFetchError(error) 类型守卫------判断是否为 API 返回的业务错误(有 error 字段)

  • errors.value = error.error?.errors------存储字段级错误,如 { email: ['is invalid'], password: ['is too short'] }

  • 模板用 v-for="(error, field) in errors" 渲染------按字段显示错误信息

关键点:这比"显示一个通用错误信息"体验好得多。API 返回的错误是按字段分的,前端也按字段渲染------用户知道具体是哪个字段错了。这是真实项目的标准做法。

5.5 ArticlesList.vue:composable + 顶层 await

bash 复制代码
<!-- src/components/ArticlesList.vue -->
<template>
  <ArticlesListNavigation v-bind="$attrs" :tag="tag" :username="username" />

  <div v-if="articlesDownloading" class="article-preview">
    Articles are downloading...
  </div>
  <div v-else-if="articles.length === 0" class="article-preview">
    No articles are here... yet.
  </div>
  <template v-else>
    <ArticlesListArticlePreview
      v-for="(article, index) in articles"
      :key="article.slug"
      :article="article"
      @update="newArticle => updateArticle(index, newArticle)"
    />
    <AppPagination :count="articlesCount" :page="page" @page-change="changePage" />
  </template>
</template>

<script setup lang="ts">
import { useArticles } from 'src/composable/use-articles'
import AppPagination from './AppPagination.vue'
import ArticlesListArticlePreview from './ArticlesListArticlePreview.vue'
import ArticlesListNavigation from './ArticlesListNavigation.vue'

const {
  fetchArticles, articlesDownloading, articlesCount, articles,
  updateArticle, page, changePage, tag, username,
} = useArticles()

await fetchArticles()
</script>

这个组件展示了两个重要模式:

模式一:composable 调用

bash 复制代码
const { articles, fetchArticles, articlesDownloading, ... } = useArticles()

一行代码拿到文章列表的全部状态和操作------useArticles() 封装了数据获取、分页、更新等逻辑。组件只管"用",不管"怎么拿数据"。这是 Composables 模式的威力(下一节展开)。

模式二:顶层 await

bash 复制代码
await fetchArticles()

注意这行代码在 <script setup> 顶层,不在任何异步函数内------这就是顶层 await 。它让异步代码写得像同步代码一样:await 等待数据获取完成后,组件才渲染。

但顶层 await 有一个前提:组件必须被 <Suspense> 包裹(否则 Vue 不知道这是一个异步组件)。看 Home.vue:

5.6 Suspense 异步组件

bash 复制代码
<!-- src/pages/Home.vue -->
<template>
  <div class="home-page">
    <div class="container page">
      <div class="row">
        <div class="col-md-9">
          <Suspense>
            <ArticlesList use-global-feed use-my-feed use-tag-feed />
            <template #fallback>
              Articles are downloading...
            </template>
          </Suspense>
        </div>
        <div class="col-md-3">
          <Suspense>
            <PopularTags />
            <template #fallback>
              Popular tags are downloading...
            </template>
          </Suspense>
        </div>
      </div>
    </div>
  </div>
</template>

<Suspense> 是 Vue 3 内置组件,协调异步组件的加载态:

  • 默认 slot:异步组件(<ArticlesList />

  • #fallback slot:加载中显示的内容(Articles are downloading...

ArticlesList 的顶层 await fetchArticles() 在执行时,<Suspense> 显示 fallback;执行完成后,显示 <ArticlesList /> 的内容。

关键点 :对比 React 的异步组件处理------React 需要 useEffect + loading state 手动管理加载态,Vue 用 Suspense + 顶层 await 让异步逻辑写得像同步一样。两种范式都能解决问题,但 Vue 的写法更简洁。

思考 1reactive vs ref 的选择不是"哪种都行"------对象用 reactive(直接访问属性,不需要 .value),基本类型用 ref。Login.vue 的 form 是对象用 reactiveerrors 是可能为 null 的动态值用 ref。选择有语义意义,不是随便选的。

思考 2 :Login.vue 的错误处理展示了"字段级错误"------API 返回 { errors: { email: ['is invalid'] } },前端按字段渲染。这比"显示一个通用错误信息"体验好得多,是真实项目的标准做法。很多教程只教 alert('登录失败'),真实项目要精细得多。

思考 3 :顶层 await fetchArticles() 看起来像"同步代码",但实际是异步的------Suspense 在等待时显示 fallback。这是 Vue 3 的"异步组件"语法糖,让异步逻辑写得像同步一样。对比 React 需要 useEffect + loading state 手动管理,Vue 的写法更声明式。


六、Composables 模式:Vue 版"自定义 Hook"

6.1 什么是 Composable

Composable 是 Vue 3 的核心逻辑复用机制------以 use 开头的函数,封装可复用的有状态逻辑,返回响应式数据和方法。如果你有 React 经验,它就是 Vue 版的"自定义 Hook"。

一个 composable 长这样:

bash 复制代码
export function useXxx() {
  const data = ref(null)       // 响应式状态
  const loading = ref(false)   // 加载状态

  async function fetchData() { /* ... */ }  // 操作方法

  return { data, loading, fetchData }  // 暴露给外部
}

在组件中使用:

bash 复制代码
const { data, loading, fetchData } = useXxx()

一行代码拿到全部状态和操作------这就是逻辑复用的威力。

6.2 useArticles() 返回值

这个项目中最复杂的 composable 是 useArticles(),封装了文章列表的全部逻辑:

bash 复制代码
// src/composable/use-articles.ts(结构概览)
export function useArticles() {
  const { articlesType, tag, username, metaChanged } = useArticlesMeta()
  const articles = ref<Article[]>([])       // 文章列表
  const articlesCount = ref(0)              // 文章总数(分页用)
  const page = ref(1)                       // 当前页码

  async function fetchArticles(): Promise<void> { /* ... */ }
  const changePage = (value: number): void => { page.value = value }
  const updateArticle = (index: number, article: Article): void => { articles.value[index] = article }

  const { active: articlesDownloading, run: runWrappedFetchArticles } = useAsync(fetchArticles)

  watch(metaChanged, async () => { /* ... */ })
  watch(page, runWrappedFetchArticles)

  return {
    fetchArticles: runWrappedFetchArticles,
    articlesDownloading,
    articles, articlesCount,
    page, changePage, updateArticle,
    tag, username,
  }
}

返回值包含 9 个东西------一个函数封装了文章列表的"获取、分页、更新、加载状态、标签过滤"全部逻辑。组件只管"用",不管"怎么拿数据"。

6.3 fetchArticles() 多分支数据获取

bash 复制代码
async function fetchArticles(): Promise<void> {
  articles.value = []
  let responsePromise: null | Promise<{ articles: Article[], articlesCount: number }> = null

  if (articlesType.value === 'my-feed') {
    responsePromise = api.articles.getArticlesFeed(pageToOffset(page.value))
      .then(res => res.data)
  }
  else if (articlesType.value === 'tag-feed' && tag.value) {
    responsePromise = api.articles.getArticles({ tag: tag.value, ...pageToOffset(page.value) })
      .then(res => res.data)
  }
  else if (articlesType.value === 'user-feed' && username.value) {
    responsePromise = api.articles.getArticles({ author: username.value, ...pageToOffset(page.value) })
      .then(res => res.data)
  }
  else if (articlesType.value === 'user-favorites-feed' && username.value) {
    responsePromise = api.articles.getArticles({ favorited: username.value, ...pageToOffset(page.value) })
      .then(res => res.data)
  }
  else if (articlesType.value === 'global-feed') {
    responsePromise = api.articles.getArticles(pageToOffset(page.value))
      .then(res => res.data)
  }

  if (responsePromise === null) {
    console.error(`Articles type "${articlesType.value}" not supported`)
    return
  }

  const response = await responsePromise
  articles.value = response.articles
  articlesCount.value = response.articlesCount
}

一个函数服务五种 feed 类型(global / my-feed / tag / user-feed / user-favorites)------根据 articlesType 切换不同的 API 调用。这是"路由驱动数据获取"的核心:路由变化 → articlesType 变化 → fetchArticles() 自动切换 API。

6.4 useArticlesMeta():路由驱动

bash 复制代码
function useArticlesMeta(): UseArticlesMetaReturn {
  const route = useRoute()
  const tag = ref('')
  const username = ref('')
  const articlesType = ref<ArticlesType>('global-feed')

  // 路由名 → feed 类型映射
  watch(
    () => route.name,
    routeName => {
      const possibleArticlesType = routeNameToArticlesType[routeName as AppRouteNames]
      if (!isArticlesType(possibleArticlesType))
        return
      articlesType.value = possibleArticlesType
    },
    { immediate: true },
  )

  // 路由参数 → tag
  watch(
    () => route.params.tag,
    tagParam => { tag.value = typeof tagParam === 'string' ? tagParam : '' },
    { immediate: true },
  )

  // 路由参数 → username
  watch(
    () => route.params.username,
    usernameParam => { username.value = typeof usernameParam === 'string' ? usernameParam : '' },
    { immediate: true },
  )

  return {
    tag: computed(() => tag.value),
    username: computed(() => username.value),
    articlesType: computed(() => articlesType.value),
    metaChanged: computed(() => `${articlesType.value}-${username.value}-${tag.value}`),
  }
}

useArticlesMeta() 用三个 watch 监听路由变化,自动映射到 feed 类型、tag、username。metaChanged 是一个 computed,把三个值拼成字符串------任何一项变化,metaChanged 就变。

6.5 watch 联动:自动化引擎

bash 复制代码
watch(metaChanged, async () => {
  if (page.value === 1)
    await runWrappedFetchArticles()
  else
    changePage(1)
})

watch(page, runWrappedFetchArticles)

两个 watch 构成了"自动化引擎":

  1. watch(metaChanged, ...):feed 类型 / tag / username 变化时,如果在第 1 页直接重新获取,否则重置到第 1 页(重置后会触发第二个 watch)

  2. watch(page, ...):页码变化时,重新获取文章

整个流程:用户切换标签 → metaChanged 变化 → 重置到第 1 页 → page 变化 → fetchArticles() 执行 → articles 更新 → 模板自动渲染。

关键点 :这是"声明式数据流"------数据获取不是"手动触发",而是"状态变化自动触发"。对比命令式(每次切换标签手动调 fetchData()),声明式更可靠,不容易忘记刷新。

6.6 useAsync 工具

bash 复制代码
const { active: articlesDownloading, run: runWrappedFetchArticles } = useAsync(fetchArticles)

useAsync 是一个工具 composable,包装异步函数,返回 { active, run }------active 是加载状态 ref(true/false),run 是包装后的函数(执行时 active 自动变 true,完成后变 false)。

这个工具让"加载状态管理"变成一行代码------不需要手写 loading.value = true; try { ... } finally { loading.value = false }

6.7 与 React Hooks 的关键区别

Vue 官方文档明确指出 Composition API 与 React Hooks 的关键区别:

| 维度 | Vue Composables | React Hooks | | --- | --- | --- | | 执行时机 | setup()只执行一次 | 每次渲染 都执行 | | 闭包陷阱 | 无(状态是响应式的,不需要捕获) | 有(stale closure 问题) | | 依赖数组 | 不需要 | 需要 useCallback / useMemo 依赖数组 | | 状态范式 | 可变(ref.value = xxx) | 不可变(setState(newValue)) | | 更新机制 | 细粒度(只更新依赖该数据的 DOM) | 整组件重渲染 + 虚拟 DOM diff |

关键点 :Vue Composable 只执行一次------这意味着 watchcomputed 只注册一次,不会因为重复执行产生性能问题。React Hooks 每次渲染都执行,所以需要 useCallback / useMemo 避免重复创建函数和计算。

Vue 官方文档还强调:"Composition API is NOT functional programming"------虽然用函数写,但状态是可变的(ref.value = xxx),不是不可变的。Vue 的响应式系统追踪依赖关系,只更新需要更新的 DOM 节点。

思考 1:useArticles 的"路由驱动数据获取"是一个真实项目级模式------数据获取不是"手动触发",而是"路由变化自动触发"。这种声明式数据流比命令式更可靠,不容易忘记刷新。真实项目里,"忘记刷新数据"是最常见的 bug 之一------声明式从根本上杜绝了这个问题。

思考 2 :watch 联动是 Vue 响应式系统的威力------metaChanged 变了 → 重置页码 → page 变了 → 重新获取数据,一个 watch 链条自动完成。对比 React 需要 useEffect + 依赖数组手动管理,Vue 的 watch 更直觉,也不容易写错依赖。

思考 3 :Vue 官方文档明确说"Composition API is NOT functional programming"------虽然用函数写,但状态是可变的(ref.value = xxx),不是不可变的。这与 React 的 setState 不可变更新形成对比。两种范式都能解决问题,理解差异比争论优劣更有价值。


七、API 层设计:自动生成的类型安全客户端

7.1 为什么自动生成

这个项目的 API 客户端不是手写的------是 swagger-typescript-api 从 RealWorld OpenAPI spec 自动生成的。为什么这样做?

手写 API 客户端有一个根本问题:前后端类型容易不同步。后端改了 API(如加了字段、改了参数名),前端如果手写类型,可能不知道后端改了,直到运行时才发现错误。

自动生成解决这个问题------后端的 OpenAPI spec 是"契约",前端从契约自动生成 TypeScript 类型 + 请求方法。后端改了 API,前端重新生成,类型检查立刻报错。

7.2 swagger-typescript-api 工作流

bash 复制代码
// package.json 中的脚本
"generate:api": "curl -sL https://raw.githubusercontent.com/gothinkster/realworld/refs/heads/main/api/openapi.yml -o ./src/services/openapi.yml && sta generate -p ./src/services/openapi.yml -o ./src/services -n api.ts"

工作流:

  1. 下载 RealWorld OpenAPI spec(openapi.yml

  2. swagger-typescript-apista)生成 TypeScript 客户端到 src/services/api.ts

  3. api.ts 包含所有 API 请求方法 + TypeScript 类型定义

关键点src/services/api.ts 是自动生成的,CLAUDE.md 明确标注"不手动编辑",ESLint 也排除了这个文件的检查。如果 API 有变化,重新 pnpm generate:api 即可。

7.3 services/index.ts 拆解

bash 复制代码
// src/services/index.ts
import { CONFIG } from 'src/config'
import type { GenericErrorModel, HttpResponse } from 'src/services/api'
import { Api, ContentType } from 'src/services/api'

export const limit = 10

export const api = new Api({
  baseUrl: `${CONFIG.API_HOST}/api`,
  securityWorker: token => token
    ? { headers: { Authorization: `Token ${String(token)}` } }
    : {},
  baseApiParams: {
    headers: {
      'content-type': ContentType.Json,
    },
    format: 'json',
  },
})

new Api(...) 创建 API 实例,三个关键配置:

  • baseUrl :从环境变量 VITE_API_HOST 读取(CONFIG.API_HOST)。不同环境(开发 / 生产)连不同的 API 服务器。

  • securityWorker :token 注入函数------有 token 时自动加 Authorization: Token xxx header,无 token 时返回空对象。登录后所有请求自动带 token。

  • baseApiParams:默认请求参数------content-type 设为 JSON,格式设为 JSON。

7.4 securityWorker:token 自动注入

bash 复制代码
securityWorker: token => token
  ? { headers: { Authorization: `Token ${String(token)}` } }
  : {}

securityWorker 是一个函数,接收 token,返回需要附加到请求的配置。当用户登录后:

  1. updateUser(userData) 调用 api.setSecurityData(userData.token) 存储 token

  2. 后续所有 API 请求自动调用 securityWorker(token),注入 Authorization header

  3. 退出登录时 api.setSecurityData(null) 清除 token,后续请求不再带 header

关键点 :token 管理逻辑集中在 store(updateUser),但 token 注入逻辑集中在 API 层(securityWorker)。职责分离------store 只管"谁登录了",API 层只管"怎么带 token"。互不干扰。

7.5 pageToOffset:分页工具

bash 复制代码
export function pageToOffset(
  page: number = 1,
  localLimit = limit
): { limit: number, offset: number } {
  const offset = (page - 1) * localLimit
  return { limit: localLimit, offset }
}

RealWorld API 用 offset 分页(?limit=10&offset=0),不是 page 分页(?page=1)。这个工具函数做转换------页码 1 → offset 0,页码 2 → offset 10,页码 3 → offset 20......

useArticles 中的用法:

bash 复制代码
api.articles.getArticles(pageToOffset(page.value))
// 等价于 api.articles.getArticles({ limit: 10, offset: (page-1)*10 })

7.6 isFetchError:类型守卫

bash 复制代码
export function isFetchError<E = GenericErrorModel>(
  e: unknown
): e is HttpResponse<unknown, E> {
  return e instanceof Object && 'error' in e
}

isFetchError 是一个 TypeScript 类型守卫 (type guard)------判断异常是否为 API 返回的业务错误(有 error 字段)。

在 Login.vue 中的使用:

bash 复制代码
catch (error) {
  if (isFetchError(error)) {
    errors.value = error.error?.errors  // ✅ 类型安全访问 error.error.errors
    return
  }
  console.error(error)  // 其他错误(网络错误、代码错误)
}

关键点catch (error)error 的类型是 unknown------不能直接访问属性。用 isFetchError(error) 类型守卫把 unknown 收窄到 HttpResponse<unknown, E>,才能安全访问 error.error.errors。这是 TypeScript 错误处理的标准模式。

思考 1:自动生成 API 客户端是"契约驱动开发"------后端 spec 是"契约",前端从契约生成代码。后端改了 API,前端重新生成,类型检查立刻报错。这比"手动维护 API 类型"可靠得多。如果你的项目前后端都是 TypeScript,但 API 类型是手写的,可以考虑引入 swagger-typescript-api 或 openapi-typescript-codegen。

思考 2 :securityWorker 的设计很巧妙------token 管理逻辑集中在 store,但 token 注入逻辑集中在 API 层。store 调 api.setSecurityData(token),API 层自动给每个请求加 header。职责分离,互不干扰。这种"谁管什么"的边界设计在真实项目里非常重要。

思考 3isFetchError 类型守卫是 TypeScript 的类型 narrowing------catch (error) 中 error 是 unknown 类型,用类型守卫收窄到 HttpResponse<unknown, E>,才能安全访问 error.error.errors。这是 TS 错误处理的标准模式,但很多教程不教------真实项目的 catch 块不是 console.log(err) 就完事的。


八、架构收束:分层设计与数据流

8.1 分层架构图

我们一起来看整体架构:

每层职责明确,依赖方向单一------上层依赖下层,不能反向依赖。

8.2 每层职责

| 层 | 目录 | 职责 | 可依赖 | | --- | --- | --- | --- | | pages | src/pages/ | 页面级组件,对应路由 | components / composable / store / services | | components | src/components/ | 可复用 UI 组件 | composable / store | | composable | src/composable/ | 业务逻辑封装 | store / services | | store | src/store/ | 全局状态管理 | services | | services | src/services/ | API 客户端 | 无(最底层) | | plugins | src/plugins/ | 应用初始化 | services / store |

关键点:services 是最底层,不依赖任何上层------它只负责"发请求"。store 依赖 services(需要调 API),但不依赖 components(不知道 UI 的存在)。这种单向依赖保证了"改 UI 不影响 API"、"换 API 不影响状态管理"。

8.3 数据流全链路:打开首页

用户访问 http://localhost:4173/ 后发生的事:

8.4 数据流全链路:登录

bash 复制代码
1. 用户填写表单,点击 Sign in
2. Login.vue 的 login() 执行
3. api.users.login({ user: form }) 发送请求
4. API 返回 { user: { token, ... } }
5. updateUser(result.data.user) 更新全局状态
   → ① userStorage.set() 持久化 localStorage
   → ② api.setSecurityData(token) 注入 API token
   → ③ user.value = userData 更新响应式状态
6. routerPush('global-feed') 跳转首页
7. 首页 ArticlesList 带着已认证的 token 获取数据

关键点:两条数据流都是"单向"的------从路由触发,经过 composable / API / store,最终到达渲染。没有反向流(如组件直接调 API、store 依赖组件)。这种单向数据流比"谁都能调谁"的意大利面条代码可靠得多。

思考 1:这个项目的分层设计是"关注点分离"的经典实践------UI(components)、业务逻辑(composable)、状态(store)、数据(services)各司其职。改 UI 不影响业务逻辑,换 API 不影响状态管理。这种架构让代码可维护性大幅提升。项目变大时,分层清晰和分层混乱的维护成本差距巨大。

思考 2:数据流是"单向"的------路由触发 composable,composable 调 API,API 返回更新 store,store 驱动渲染。没有反向流。这种单向数据流比"谁都能调谁"的意大利面条代码可靠得多。调试时也容易------沿着数据流方向追,不会绕圈子。


九、对比与升华:Vue 3 vs React(上期对照)

上期我们拆解了 React + mdn/todo-react(一个 Todo List),这期拆了 Vue 3 + RealWorld(一个 Medium 克隆)。两个框架解决同样的问题------构建 UI------但理念不同。理解差异比争论优劣更有价值。

9.1 响应式范式:可变 vs 不可变

这是两个框架最根本的差异。

Vue 3(可变响应式)

bash 复制代码
const count = ref(0)
count.value++  // 直接修改,Vue 自动追踪并更新 DOM

React(不可变更新)

bash 复制代码
const [count, setCount] = useState(0)
setCount(count + 1)  // 创建新值,React 重新渲染组件

Vue 通过 Proxy 追踪依赖关系,当数据变化时只更新依赖该数据的 DOM 节点------细粒度更新。React 用虚拟 DOM diff------数据变化后整个组件重新渲染,通过 diff 算出最小 DOM 操作。

9.2 逻辑复用:Composables vs Hooks

| 维度 | Vue Composables | React Hooks | | --- | --- | --- | | 执行时机 | setup()只执行一次 | 每次渲染 都执行 | | 闭包陷阱 | 无 | 有(stale closure) | | 依赖数组 | 不需要 | 需要 useCallback / useMemo | | 状态范式 | 可变(ref.value = xxx) | 不可变(setState(newValue)) |

Vue Composable 只执行一次------watchcomputed 只注册一次。React Hooks 每次渲染都执行,所以需要 useCallback / useMemo 避免重复创建函数和计算。

关键点:这不是"谁更好"的问题------Vue 的"只执行一次"更直觉,React 的"每次执行"更函数式。两种范式都能解决问题,理解差异有助于你在两个框架间迁移。

9.3 状态管理

| 维度 | Vue 3 | React | | --- | --- | --- | | 官方推荐 | Pinia(唯一选择) | useState/useReducer(内置) | | 大型项目 | Pinia 一个库搞定 | Redux / Zustand / Jotai 等百花齐放 | | 语法风格 | Setup Store(和 Composition API 一致) | 各库不同 |

Pinia 是 Vue 3 的"唯一选择"------官方推荐,与 Composition API 一致,学习成本低。React 状态管理百花齐放------Redux(重)、Zustand(轻)、Jotai(原子化)......选择多但也意味着决策成本高。

9.4 路由

| 维度 | Vue Router | React Router | | --- | --- | --- | | 维护方 | Vue 团队官方 | 社区(事实标准) | | 类型安全 | AppRouteNames 联合类型 | hooks 式 API | | API 风格 | 配置式 + routerPush() | 组件式 + useNavigate() |

Vue Router 是 Vue 团队维护的官方库------和 Vue 核心同步更新,兼容性有保证。React Router 是社区维护的------虽然是事实标准,但不是 React 团队官方。

9.5 语法:SFC vs JSX

| 维度 | Vue SFC | React JSX | | --- | --- | --- | | 结构 | template + script + style 三段式 | JS 里写 HTML | | 编译优化 | 模板有编译时优化 | JSX 无编译优化(运行时 diff) | | 灵活性 | 模板有限制(但够用) | JS 全部能力 |

Vue SFC 的模板有编译时优化------Vue 编译器知道哪些节点是静态的,哪些是动态的,生成优化的渲染函数。JSX 更灵活(能用 JS 全部能力),但无编译优化(全靠运行时 diff)。

9.6 什么时候选哪个

  • 选 Vue 3:快速上手 + 官方全家桶(Pinia + Router)+ 模板偏好 + 团队统一技术栈

  • 选 React:生态丰富 + 灵活组合 + JSX 偏好 + 大型团队需要更多选择

没有绝对优劣,看团队和技术栈。两个框架都在进化,差异在缩小------Vue 3 的 Composition API 借鉴了 React Hooks 的理念,React 也在简化状态管理(如 use hook)。

思考 1:Vue 官方文档明确说"Composition API is NOT functional programming"------虽然用函数写,但状态是可变的。这是 Vue 和 React 的根本差异:Vue 拥抱可变性(响应式追踪),React 拥抱不可变性(setState + 虚拟 DOM diff)。两种范式都能解决问题,理解差异比争论优劣更有价值。

思考 2:Pinia 是 Vue 3 的"唯一选择"(官方推荐),React 状态管理百花齐放(Redux/Zustand/Jotai)。Vue 的"统一"降低选择成本,React 的"多样"提供更多可能性。这是两种生态哲学------没有对错,只有取舍。

思考 3:两个框架的核心差异其实就一句话------Vue 是"响应式系统驱动 UI",React 是"UI 是状态的函数"。理解了这句话,框架选择就不纠结了。两者都在进化,差异在缩小,选哪个都不亏。


十、练习与下期预告

10.1 三个练习

读懂真实项目只是第一步------动手改才是学会。三个练习从易到难:

练习一(易):空表单校验

当前 Login.vue 用 HTML5 原生校验(required + type="email")。尝试加一个自定义校验------密码至少 8 位,否则显示错误信息。

提示:在 login() 函数中加 if (form.password.length < 8) 检查,在 errors 中设置自定义错误。

练习二(中):localStorage 草稿持久化

用户在 EditArticle.vue 写文章时,如果意外关闭页面,草稿会丢失。尝试把草稿保存到 localStorage------参考 store/user.tsStorage 工具类,创建一个 draftStorage,在输入框变化时自动保存,页面加载时恢复。

提示:用 watch 监听表单变化,debounce 后保存到 localStorage;在 onMounted 中从 localStorage 恢复。

练习三(难):文章搜索功能

新增 /search?q=xxx 路由 + SearchBar 组件 + useSearch composable,调用 RealWorld API 的文章列表接口,按 title 过滤。

提示:RealWorld API 没有搜索接口,但可以用 getArticles() 获取文章后前端过滤。创建 useSearch() composable,包含 query ref、results ref、search() 方法。

10.2 进阶学习路径

  • VueUse --- Vue 3 Composables 工具库(Composition API 生态标杆),提供 useDebounce / useLocalStorage / useIntersectionObserver 等数百个工具 composable

  • Vitest 组件测试 --- 这个项目的 src/**/*.spec.ts 是很好的学习材料,用 MSW mock API、Testing Library 测组件

  • Nuxt.js --- Vue 全栈框架(SSR + 文件路由 + 自动导入),从纯前端 SPA 到全栈

  • VitePress --- Vue 驱动的文档站点生成器

10.3 开源仓库推荐

10.4 下期预告

本期拆解了一个 Vue 3 前端项目。下期可能切入:

  • Nuxt.js --- Vue 全栈 SSR 框架,从纯前端到全栈

  • Express.js --- Node.js 后端框架,从前端到后端

  • Spring Boot --- Java 后端框架,用户有 Java 基础

从"拆前端项目"到"拆全栈项目",逐步深入。

思考 1:三个练习的共同模式------"加一个 composable + 改一个组件"。这就是 Vue 3 的开发节奏:逻辑放 composable,UI 放组件,状态放 store。掌握这个节奏,新增功能就是"填空"------不用纠结"代码该放哪"。

思考 2:RealWorld 项目有多个框架实现(React / Vue / Angular / Svelte / Nuxt)------同一个 spec,不同框架。对比阅读这些实现,是理解框架差异的最佳方式。不用争论"哪个好",看代码怎么写就行。


结语

本期以 mutoe/vue3-realworld-example-app 为载体,拆解了 Vue 3 的核心概念------Composition API(<script setup> / ref / reactive / computed / watch)、Pinia(Setup Store + localStorage 持久化)、Vue Router(类型安全 + 懒加载 + 守卫)、Composables(路由驱动数据获取 + watch 联动)、API 自动生成(swagger-typescript-api + securityWorker)。

这些不是孤立的语法点,而是一套完整的工程实践。读懂真实项目,学到的不仅是"语法怎么写",更是"工程怎么做"------分层设计、单向数据流、类型安全、职责分离。这些工程思维,在任何一个框架里都适用。


参考资料

  1. mutoe/vue3-realworld-example-app --- 本期拆解的开源项目

  2. Vue 3 官方文档 --- Composition API、<script setup>、响应式系统

  3. Vue 3 Composition API FAQ --- Composition API 与 React Hooks 对比

  4. Pinia 官方文档 --- Setup Store、storeToRefs、插件系统

  5. Vue Router 官方文档 --- 路由守卫、懒加载、类型安全

  6. RealWorld 项目规范 --- Medium 克隆 spec、API 定义

  7. VueUse --- Vue 3 Composables 工具库

  8. swagger-typescript-api --- OpenAPI spec 自动生成 TypeScript 客户端

  9. 上期:React + mdn/todo-react --- 全栈技术讲解第 1 期对照

相关推荐
铁皮饭盒1 小时前
网页端, 6.5MB人脸识别模型, 谷歌框架, 又快又准
前端·javascript·后端
xiaohaiAIgeo1 小时前
【2026年】ASHRAE 110与EN 14175通风柜测试标准对比:进口与国产品牌性能差距
java·前端·数据库·科普知识
用户2930750976692 小时前
TypeScript 必考题:Type 与 Interface 全面对比
前端
hunterandroid2 小时前
[鸿蒙从零到一] HarmonyOS 媒体能力实战:图片、音频与视频处理
前端
打呵欠的猫2 小时前
我让 AI 封装了一个 ImageUpload 组件,它设计的 5 层校验链路比我想的周全
前端·ai编程
AlexMaybeBot2 小时前
躺在沙发上开发 Openclaw 的移动端APP
前端·flutter
用户2181697049302 小时前
Flutter(十三)Text Image TextField
前端
程序员黑豆2 小时前
深入解析Java数据类型:基本类型与引用类型的本质区别与实战选择
前端·ai编程·全栈
东方小月3 小时前
从零开发一个 Coding Agent(七):实现纯文本 Agent Loop
前端·人工智能