说说应用发布与互动链路重构
这次整理的对象来自 05Nodejs/shuoshuo-demo/client。和前两轮一样,我们不直接修改这个独立子项目,而是把其中最值得复用的交互逻辑整理成一份文档化重构方案。
这个 demo 里最值得抽象的不是单个弹窗,而是整条前台互动链路:登录 / 注册 -> 发帖 -> 列表拉取 -> 点赞点踩 -> 打开回复框。它本质上是一个“小型内容社区前台”的最小闭环,非常适合练习组件边界、状态归属和请求层收敛。
这次主要参考了下面这些文件:
05Nodejs/shuoshuo-demo/client/src/App.vue05Nodejs/shuoshuo-demo/client/src/components/VHeader.vue05Nodejs/shuoshuo-demo/client/src/components/VLogin.vue05Nodejs/shuoshuo-demo/client/src/components/VRegister.vue05Nodejs/shuoshuo-demo/client/src/components/VNewContent.vue05Nodejs/shuoshuo-demo/client/src/components/VList.vue05Nodejs/shuoshuo-demo/client/src/components/VReply.vue
原实现里的几个核心耦合点
VHeader.vue 目前同时承担了用户状态读取、登录弹窗、注册弹窗、发帖弹窗和注销请求。它既像导航栏,又像认证中心,还像内容创建入口。
VList.vue 也承担了太多职责。它负责列表请求、分页、点赞、点踩、回复弹窗开关,但没有把“当前回复的是哪条内容”收拢成一个明确状态。
VLogin.vue、VRegister.vue 和 VNewContent.vue 的结构高度相似,都是“表单状态 + 校验 + 提交 + 清空 + 关闭”的模式,但现在每个组件都在各自重复这套流程。
从重构角度看,这个 demo 最值得统一的其实是三类能力:
- 会话能力:当前用户、登录、注册、注销
- 内容能力:列表查询、分页、发布、点赞、点踩
- 弹窗能力:当前打开的弹窗、当前回复目标、表单校验与提交流程
推荐的重构边界
如果按 Vue 3 + Composition API 的思路重构,更合理的拆分结构可以是这样:
pages/feed/index.vue:页面入口,负责首屏数据和页面装配components/feed/FeedHeader.vue:只负责顶部展示与入口按钮components/feed/AuthDialog.vue:认证弹窗壳组件components/feed/LoginForm.vue:登录表单components/feed/RegisterForm.vue:注册表单components/feed/CreatePostDialog.vue:发帖弹窗components/feed/PostList.vue:帖子列表components/feed/PostCard.vue:单条帖子卡片components/feed/ReplyDialog.vue:回复弹窗components/feed/PaginationBar.vue:分页条composables/useSession.ts:用户会话管理composables/usePostFeed.ts:帖子列表、分页和互动动作composables/useDialogController.ts:弹窗开关和当前上下文composables/useSubmitState.ts:复用表单的提交状态与错误管理
拆开以后,头部组件不再直接碰请求细节,列表组件不再自己保存回复弹窗语义,表单组件也可以共享同一套提交状态模型。
先把前台领域模型稳定下来
原始 demo 大量依赖接口返回的对象直接在组件里读写,但一旦组件变多,这种写法很容易把“帖子数据”“用户数据”“弹窗上下文”混在一起。重构时应该先收敛数据结构。
export interface SessionUser {
id: string
username: string
}
export interface PostItem {
id: string
userId: string
username: string
title: string
content: string
createdAt: string
likeCount: number
unlikeCount: number
commentCount: number
}
export interface PostQuery {
page: number
limit: number
}
export interface CreatePostPayload {
title: string
content: string
}
export interface ReplyContext {
postId: string
title: string
}当这些类型稳定以后,登录、发帖、回复和列表分页都会围绕同一套领域语言工作,组件边界也更容易拉清楚。
会话状态应该抽成 useSession
VHeader.vue 现在直接从 localStorage 读取用户名,又在退出时直接清空本地存储。这类逻辑不应该长期留在展示组件里,更适合集中到一个会话 composable 中。
import { shallowRef } from 'vue'
import type { SessionUser } from '../types/feed'
export interface SessionService {
login: (payload: { username: string; password: string }) => Promise<SessionUser>
register: (payload: { username: string; password: string }) => Promise<SessionUser>
logout: () => Promise<void>
restore: () => SessionUser | null
}
export function useSession(service: SessionService) {
const currentUser = shallowRef<SessionUser | null>(service.restore())
const pending = shallowRef(false)
async function login(payload: { username: string; password: string }) {
pending.value = true
try {
currentUser.value = await service.login(payload)
} finally {
pending.value = false
}
}
async function register(payload: { username: string; password: string }) {
pending.value = true
try {
currentUser.value = await service.register(payload)
} finally {
pending.value = false
}
}
async function logout() {
pending.value = true
try {
await service.logout()
currentUser.value = null
} finally {
pending.value = false
}
}
return {
currentUser,
pending,
login,
register,
logout
}
}把会话逻辑抽离以后,FeedHeader 只需要关心“当前是否登录”和“点击哪个入口”,不再直接管理本地缓存与请求细节。
登录、注册、发帖表单可以共用一套提交模型
VLogin.vue、VRegister.vue 和 VNewContent.vue 都存在相同模式:
- 维护输入状态
- 做同步校验
- 发起异步提交
- 成功后清空并关闭
- 失败后提示错误
这个模式应该提炼成一个通用 composable,而不是在每个弹窗里重复写。
import { shallowRef } from 'vue'
export function useSubmitState() {
const pending = shallowRef(false)
const errorMessage = shallowRef('')
async function run(task: () => Promise<void>) {
pending.value = true
errorMessage.value = ''
try {
await task()
return true
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '提交失败'
return false
} finally {
pending.value = false
}
}
function reset() {
pending.value = false
errorMessage.value = ''
}
return {
pending,
errorMessage,
run,
reset
}
}这样登录表单、注册表单和发帖表单都能复用同一套“提交状态 + 错误提示”模型,只需要保留各自的字段和校验规则。
发帖与列表状态应该收拢到 usePostFeed
VList.vue 现在负责分页、列表拉取、点赞和点踩,VNewContent.vue 负责发帖,但两边没有统一的内容数据源。这意味着发帖成功后,列表如何刷新、如何插入新内容,都没有一个稳定的状态中心。
更合理的方式是把“内容流”收拢到一个 feed composable 中。
import { computed, reactive, shallowRef } from 'vue'
import type { CreatePostPayload, PostItem } from '../types/feed'
export interface FeedService {
fetchPosts: (page: number, limit: number) => Promise<{ total: number; items: PostItem[] }>
createPost: (payload: CreatePostPayload) => Promise<PostItem>
likePost: (postId: string, userId: string) => Promise<PostItem>
unlikePost: (postId: string, userId: string) => Promise<PostItem>
}
export function usePostFeed(service: FeedService) {
const posts = shallowRef<PostItem[]>([])
const total = shallowRef(0)
const query = reactive({
page: 1,
limit: 10
})
const pages = computed(() => {
return total.value > 0 ? Math.ceil(total.value / query.limit) : 0
})
async function hydrate() {
const result = await service.fetchPosts(query.page, query.limit)
total.value = result.total
posts.value = result.items
}
async function changePage(page: number) {
query.page = Math.max(1, Math.min(page, pages.value || 1))
await hydrate()
}
async function createPost(payload: CreatePostPayload) {
const created = await service.createPost(payload)
posts.value = [created, ...posts.value]
total.value += 1
}
async function like(postId: string, userId: string) {
const updated = await service.likePost(postId, userId)
posts.value = posts.value.map(post => (post.id === updated.id ? updated : post))
}
async function unlike(postId: string, userId: string) {
const updated = await service.unlikePost(postId, userId)
posts.value = posts.value.map(post => (post.id === updated.id ? updated : post))
}
return {
posts,
total,
query,
pages,
hydrate,
changePage,
createPost,
like,
unlike
}
}这样发帖成功以后就不需要“关闭弹窗就算完成”,而是可以立刻把新帖推进列表,或者根据接口策略触发一次重新拉取。
回复弹窗应该显式保存上下文
原始 VReply.vue 只有一个 show 布尔值,这会带来一个典型问题:弹窗打开了,但它不知道现在要回复的是哪一条内容。
更好的方案是把“弹窗是否打开”与“当前回复上下文”放在一起管理。
import { shallowRef } from 'vue'
import type { ReplyContext } from '../types/feed'
export type DialogName = 'login' | 'register' | 'create-post' | 'reply' | null
export function useDialogController() {
const activeDialog = shallowRef<DialogName>(null)
const replyContext = shallowRef<ReplyContext | null>(null)
function open(name: Exclude<DialogName, null>) {
activeDialog.value = name
}
function openReply(context: ReplyContext) {
replyContext.value = context
activeDialog.value = 'reply'
}
function close() {
activeDialog.value = null
}
return {
activeDialog,
replyContext,
open,
openReply,
close
}
}这样 ReplyDialog 就能明确拿到 postId 和标题,而不是只被动知道自己“该显示了”。
头部组件应该缩回展示职责
原始 VHeader.vue 最大的问题不在于写法老,而在于它同时操控了三个模态框和用户状态。更适合的方式是把它收回成一个纯展示入口组件。
<script setup lang="ts">
import type { SessionUser } from '../../types/feed'
defineProps<{
currentUser: SessionUser | null
}>()
const emit = defineEmits<{
openLogin: []
openRegister: []
openCreatePost: []
logout: []
}>()
</script>
<template>
<header class="feed-header">
<div v-if="!currentUser" class="actions">
<button type="button" @click="emit('openRegister')">注册</button>
<button type="button" @click="emit('openLogin')">登录</button>
</div>
<div v-else class="actions">
<button type="button" @click="emit('openCreatePost')">我要发表</button>
<span>{{ currentUser.username }}</span>
<button type="button" @click="emit('logout')">注销</button>
</div>
</header>
</template>这个版本的头部组件不再直接读写本地缓存,也不负责显示三个不同的弹窗,只暴露明确入口事件。
列表组件应该只接收数据和动作
VList.vue 现在既请求数据,又渲染卡片,还管理回复框开关。更适合的方式是把它拆成 PostList + PaginationBar + ReplyDialog 组合,而列表本身只接收 props 和事件。
<script setup lang="ts">
import type { PostItem } from '../../types/feed'
defineProps<{
items: PostItem[]
}>()
const emit = defineEmits<{
like: [post: PostItem]
unlike: [post: PostItem]
reply: [post: PostItem]
}>()
</script>
<template>
<div class="post-list">
<article v-for="post in items" :key="post.id" class="post-card">
<header>
<h3>{{ post.title }}</h3>
<small>{{ post.createdAt }}</small>
</header>
<p>{{ post.content }}</p>
<footer>
<button type="button" @click="emit('like', post)">赞({{ post.likeCount }})</button>
<button type="button" @click="emit('unlike', post)">踩({{ post.unlikeCount }})</button>
<button type="button" @click="emit('reply', post)">回复({{ post.commentCount }})</button>
</footer>
</article>
</div>
</template>这样点赞、点踩和回复都变成显式业务事件,列表组件本身不需要知道请求实现。
页面容器负责装配整条互动链路
当会话、内容流和弹窗控制都分离以后,真正的页面根组件会清楚很多。它只负责把 composable 和展示组件拼起来。
<script setup lang="ts">
import { onMounted } from 'vue'
import FeedHeader from '../../components/feed/FeedHeader.vue'
import PostList from '../../components/feed/PostList.vue'
import ReplyDialog from '../../components/feed/ReplyDialog.vue'
import { useDialogController } from '../../composables/useDialogController'
import { usePostFeed } from '../../composables/usePostFeed'
import { useSession } from '../../composables/useSession'
import { createFeedService, createSessionService } from '../../services/feed-service'
const session = useSession(createSessionService())
const feed = usePostFeed(createFeedService())
const dialog = useDialogController()
onMounted(() => {
feed.hydrate()
})
</script>
<template>
<section class="feed-page">
<FeedHeader
:current-user="session.currentUser"
@open-login="dialog.open('login')"
@open-register="dialog.open('register')"
@open-create-post="dialog.open('create-post')"
@logout="session.logout()"
/>
<PostList
:items="feed.posts"
@like="session.currentUser && feed.like($event.id, session.currentUser.id)"
@unlike="session.currentUser && feed.unlike($event.id, session.currentUser.id)"
@reply="dialog.openReply({ postId: $event.id, title: $event.title })"
/>
<ReplyDialog
:visible="dialog.activeDialog === 'reply'"
:context="dialog.replyContext"
@close="dialog.close()"
/>
</section>
</template>这个容器最大的价值在于,它把“谁负责什么”说清楚了:
useSession管用户usePostFeed管内容流useDialogController管弹窗与上下文- 组件层只做输入输出
这次重构真正沉淀下来的模式
这个 demo 很适合拿来练一种非常常见的前台页面模型:一个页面里同时存在用户会话、内容流和多个弹窗入口。只要不先做状态分层,组件很快就会变成“能跑但不好改”的状态。
这次最值得复用的经验有四个:
- 把本地登录态和接口认证统一收口到
useSession - 把发帖、分页、点赞、点踩统一收口到
usePostFeed - 把登录、注册、发帖这类重复表单统一成相同的提交状态模型
- 把回复弹窗从单纯的
show布尔值升级成“显式上下文 + 显式开关”模型
以后碰到类似“列表 + 登录 + 发布 + 互动”的页面时,可以先检查这几个问题:
- 当前用户状态是不是还散落在多个组件里
- 发布成功后是否有统一的内容流状态中心
- 点赞和点踩是否直接写死在列表组件里
- 回复弹窗是否知道自己正在回复哪一条内容
只要这些问题里还有两三个答不上来,这个页面通常就还值得继续重构。
