摘要:以 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,理由:
-
技术栈现代 :Vue 3 Composition API +
<script setup>+ Pinia(不是 Vuex)+ Vue Router + TypeScript------2026 年 Vue 生态的主流配置 -
活跃维护:468 commits,2026-05 仍在更新
-
有架构文档 :项目自带 CLAUDE.md,说明每个目录的职责
-
有完整测试:Vitest 单元测试 + Playwright E2E 测试------真实项目的质量保证
-
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 步:
-
createApp(App):创建应用实例,传入根组件App。Vue 3 用createApp替代了 Vue 2 的new Vue()------更函数式,一个应用可以创建多个实例(如 SSR 场景)。 -
app.use(createPinia()):注册 Pinia 状态管理。createPinia()返回一个 Pinia 实例,app.use()注册为 Vue 插件。 -
app.use(router):注册 Vue Router。router 也是一个 Vue 插件。 -
setAuthorizationToken():恢复用户登录态------从 localStorage 读取 token,注入到 API 客户端的 header 中。这一步在 mount 前执行,避免页面渲染后再恢复 token 导致的闪烁。 -
registerGlobalComponents(app):注册全局组件(如AppLink)。 -
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 参数------它直接操作 api 和 userStorage(模块级导入)。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 和测试场景下很重要。
思考 2 :setAuthorizationToken() 在 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-feed、my-feed、tag 三个路由共享 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 类型,一个组件服务多个路由。这比写三个几乎一样的组件更好维护。
思考 3 :beforeEnter 守卫只守这一个路由,不影响其他路由------与 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() 同时更新三个地方:
-
userStorage.set(userData)------持久化到 localStorage(刷新不丢) -
api.setSecurityData(userData.token)------注入 API 客户端的 token(后续请求自动带 Authorization header) -
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 设计的一致性之美。
思考 2 :isAuthorized() 模块级函数是一个设计巧思------store 实例需要 useUserStore() 在组件内调用(依赖组件上下文),但路由守卫在组件外执行。模块级函数绕过了这个限制,让状态逻辑在任意位置可用。这种"跨上下文状态访问"是真实项目才会遇到的问题。
思考 3 :updateUser() 同时更新三个地方(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),用 ref(reactive 不能整体替换,会丢失响应性)。
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>
表单全流程:
-
reactive()创建表单对象 :form = reactive({ email, password }) -
v-model双向绑定 :<input v-model="form.email">------输入框值和form.email双向同步 -
@submit.prevent阻止默认提交 :表单提交时执行login()而不是页面跳转 -
formRef.value?.checkValidity()原生校验 :HTML5 表单校验 API,检查required/type="email"等 -
api.users.login()异步请求:调用 API 客户端发送登录请求 -
updateUser()更新状态:登录成功后更新全局用户状态 -
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 />) -
#fallbackslot:加载中显示的内容(Articles are downloading...)
当 ArticlesList 的顶层 await fetchArticles() 在执行时,<Suspense> 显示 fallback;执行完成后,显示 <ArticlesList /> 的内容。
关键点 :对比 React 的异步组件处理------React 需要 useEffect + loading state 手动管理加载态,Vue 用 Suspense + 顶层 await 让异步逻辑写得像同步一样。两种范式都能解决问题,但 Vue 的写法更简洁。
思考 1 :reactive vs ref 的选择不是"哪种都行"------对象用 reactive(直接访问属性,不需要 .value),基本类型用 ref。Login.vue 的 form 是对象用 reactive,errors 是可能为 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 构成了"自动化引擎":
-
watch(metaChanged, ...):feed 类型 / tag / username 变化时,如果在第 1 页直接重新获取,否则重置到第 1 页(重置后会触发第二个 watch) -
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 只执行一次------这意味着 watch、computed 只注册一次,不会因为重复执行产生性能问题。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"
工作流:
-
下载 RealWorld OpenAPI spec(
openapi.yml) -
用
swagger-typescript-api(sta)生成 TypeScript 客户端到src/services/api.ts -
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 xxxheader,无 token 时返回空对象。登录后所有请求自动带 token。 -
baseApiParams:默认请求参数------content-type 设为 JSON,格式设为 JSON。
7.4 securityWorker:token 自动注入
bash
securityWorker: token => token
? { headers: { Authorization: `Token ${String(token)}` } }
: {}
securityWorker 是一个函数,接收 token,返回需要附加到请求的配置。当用户登录后:
-
updateUser(userData)调用api.setSecurityData(userData.token)存储 token -
后续所有 API 请求自动调用
securityWorker(token),注入Authorizationheader -
退出登录时
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。职责分离,互不干扰。这种"谁管什么"的边界设计在真实项目里非常重要。
思考 3 :isFetchError 类型守卫是 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 只执行一次------watch、computed 只注册一次。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.ts 的 Storage 工具类,创建一个 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 开源仓库推荐
-
mutoe/vue3-realworld-example-app --- 本期拆解的项目
-
VueUse --- Composables 生态标杆,学习 composable 写法的最佳参考
-
gothinkster/realworld --- RealWorld 项目规范,有几十种框架实现可对比阅读
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)。
这些不是孤立的语法点,而是一套完整的工程实践。读懂真实项目,学到的不仅是"语法怎么写",更是"工程怎么做"------分层设计、单向数据流、类型安全、职责分离。这些工程思维,在任何一个框架里都适用。
参考资料
-
mutoe/vue3-realworld-example-app --- 本期拆解的开源项目
-
Vue 3 官方文档 --- Composition API、
<script setup>、响应式系统 -
Vue 3 Composition API FAQ --- Composition API 与 React Hooks 对比
-
Pinia 官方文档 --- Setup Store、storeToRefs、插件系统
-
Vue Router 官方文档 --- 路由守卫、懒加载、类型安全
-
RealWorld 项目规范 --- Medium 克隆 spec、API 定义
-
VueUse --- Vue 3 Composables 工具库
-
swagger-typescript-api --- OpenAPI spec 自动生成 TypeScript 客户端
-
上期:React + mdn/todo-react --- 全栈技术讲解第 1 期对照