Skip to content

说说应用发布与互动链路重构

这次整理的对象来自 05Nodejs/shuoshuo-demo/client。和前两轮一样,我们不直接修改这个独立子项目,而是把其中最值得复用的交互逻辑整理成一份文档化重构方案。

这个 demo 里最值得抽象的不是单个弹窗,而是整条前台互动链路:登录 / 注册 -> 发帖 -> 列表拉取 -> 点赞点踩 -> 打开回复框。它本质上是一个“小型内容社区前台”的最小闭环,非常适合练习组件边界、状态归属和请求层收敛。

这次主要参考了下面这些文件:

  • 05Nodejs/shuoshuo-demo/client/src/App.vue
  • 05Nodejs/shuoshuo-demo/client/src/components/VHeader.vue
  • 05Nodejs/shuoshuo-demo/client/src/components/VLogin.vue
  • 05Nodejs/shuoshuo-demo/client/src/components/VRegister.vue
  • 05Nodejs/shuoshuo-demo/client/src/components/VNewContent.vue
  • 05Nodejs/shuoshuo-demo/client/src/components/VList.vue
  • 05Nodejs/shuoshuo-demo/client/src/components/VReply.vue

原实现里的几个核心耦合点

VHeader.vue 目前同时承担了用户状态读取、登录弹窗、注册弹窗、发帖弹窗和注销请求。它既像导航栏,又像认证中心,还像内容创建入口。

VList.vue 也承担了太多职责。它负责列表请求、分页、点赞、点踩、回复弹窗开关,但没有把“当前回复的是哪条内容”收拢成一个明确状态。

VLogin.vueVRegister.vueVNewContent.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 大量依赖接口返回的对象直接在组件里读写,但一旦组件变多,这种写法很容易把“帖子数据”“用户数据”“弹窗上下文”混在一起。重构时应该先收敛数据结构。

ts
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 中。

ts
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.vueVRegister.vueVNewContent.vue 都存在相同模式:

  • 维护输入状态
  • 做同步校验
  • 发起异步提交
  • 成功后清空并关闭
  • 失败后提示错误

这个模式应该提炼成一个通用 composable,而不是在每个弹窗里重复写。

ts
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 中。

ts
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 布尔值,这会带来一个典型问题:弹窗打开了,但它不知道现在要回复的是哪一条内容。

更好的方案是把“弹窗是否打开”与“当前回复上下文”放在一起管理。

ts
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 最大的问题不在于写法老,而在于它同时操控了三个模态框和用户状态。更适合的方式是把它收回成一个纯展示入口组件。

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 和事件。

vue
<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 和展示组件拼起来。

vue
<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 布尔值升级成“显式上下文 + 显式开关”模型

以后碰到类似“列表 + 登录 + 发布 + 互动”的页面时,可以先检查这几个问题:

  • 当前用户状态是不是还散落在多个组件里
  • 发布成功后是否有统一的内容流状态中心
  • 点赞和点踩是否直接写死在列表组件里
  • 回复弹窗是否知道自己正在回复哪一条内容

只要这些问题里还有两三个答不上来,这个页面通常就还值得继续重构。

共 20 个模块,1301 篇 Markdown 文档。