现在位置: 首页 > Vue3 教程 > 正文

Composable — 封装可复用逻辑

本章你将学会 Vue3 的最佳实践——用组合式函数(Composable)封装可复用的逻辑,让代码更干净、更易维护。


什么是 Composable?

随着功能增多,组件里的 JS 逻辑会越来越长。首页和详情页都需要加载文章数据,代码被复制了两份。

Composable(组合式函数) 就是把可复用的逻辑提取到独立的 JS 函数中,哪个组件需要就在哪里调用。

它的本质就是 一个返回响应式数据和方法的普通函数


为什么需要 Composable?

对比一下改前和改后的代码结构:

没有 Composable(之前)有 Composable(之后)
每个组件自己写 fetch 逻辑一个 usePosts() 到处复用
数据加载和 UI 逻辑混在一起数据逻辑独立,组件只关心 UI
改一个逻辑要改多个文件改一处,所有组件同步更新
组件代码动辄 300 行组件代码通常 < 100 行

如果用过 Vue2 的 Mixins,Composable 可以理解为更清晰、更安全的替代品——它没有命名冲突、数据来源透明。


命名规范与约定

规范说明示例
use 开头一眼就知道这是 ComposableusePosts、useDarkMode
放在 composables/ 目录方便查找和维护src/composables/usePosts.js
返回响应式数据返回 ref 或 reactive,调用方保持响应return { articles, isLoading }
一个函数只做一件事单一职责,方便组合usePosts 只管数据,useDarkMode 只管主题

usePosts — 封装文章数据逻辑

这个 Composable 负责:加载数据 + 分类筛选 + 搜索过滤

实例

// 文件路径:src/composables/usePosts.js
import { ref, computed, onMounted } from 'vue'

export function usePosts() {
  const articles = ref([])          // 所有文章数据
  const isLoading = ref(true)       // 加载状态
  const error = ref(null)           // 错误信息
  const activeCategory = ref('全部') // 当前选中的分类
  const keyword = ref('')           // 搜索关键词

  // 提取所有分类(去重)
  const categories = computed(() => {
    const cats = articles.value.map(a => a.category)
    return ['全部', ...new Set(cats)]
  })

  // 过滤后的文章:先按分类,再按关键词
  const filteredArticles = computed(() => {
    let result = articles.value

    // 按分类过滤
    if (activeCategory.value !== '全部') {
      result = result.filter(a => a.category === activeCategory.value)
    }

    // 按关键词过滤(匹配标题或摘要)
    if (keyword.value.trim()) {
      const kw = keyword.value.trim().toLowerCase()
      result = result.filter(a =>
        a.title.toLowerCase().includes(kw) ||
        a.summary.toLowerCase().includes(kw)
      )
    }

    return result
  })

  // 根据 ID 查找单篇文章
  function getArticleById(id) {
    return articles.value.find(a => a.id === id)
  }

  // 切换分类
  function setCategory(cat) {
    activeCategory.value = cat
  }

  // 加载数据
  async function fetchPosts() {
    isLoading.value = true
    error.value = null
    try {
      const res = await fetch('/posts.json')
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      articles.value = await res.json()
    } catch (err) {
      error.value = err.message
    } finally {
      isLoading.value = false
    }
  }

  // 组件挂载时自动加载
  onMounted(() => {
    fetchPosts()
  })

  // 返回给组件使用的数据和方法
  return {
    articles,
    isLoading,
    error,
    activeCategory,
    keyword,
    categories,
    filteredArticles,
    getArticleById,
    setCategory,
    fetchPosts
  }
}

现在首页组件变得非常简洁,只需要调用 usePosts 即可:

实例

<!-- 文件路径:src/views/HomeView.vue -->
<script setup>
import { usePosts } from '../composables/usePosts.js'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'

// 一行代码获取所有文章相关的数据和方法
const {
  isLoading,
  error,
  activeCategory,
  keyword,
  categories,
  filteredArticles,
  setCategory,
  fetchPosts
} = usePosts()
</script>

<template>
  <div class="home">
    <div class="search-bar">
      <input
        v-model="keyword"
        type="text"
        placeholder="搜索文章标题或摘要..."
      />
    </div>

    <CategoryFilter
      :categories="categories"
      :active-category="activeCategory"
      @update-category="setCategory"
    />

    <p v-if="isLoading">加载中...</p>
    <p v-else-if="error">加载失败:{{ error }}
      <button @click="fetchPosts">重试</button>
    </p>
    <p v-else-if="filteredArticles.length === 0">没有匹配的文章</p>
    <div v-else class="article-grid">
      <BlogCard
        v-for="article in filteredArticles"
        :key="article.id"
        :id="article.id"
        :title="article.title"
        :summary="article.summary"
        :date="article.date"
        :category="article.category"
      />
    </div>
  </div>
</template>

详情页也可以复用 usePosts,不用再写一遍 fetch:

实例

<!-- 文件路径:src/views/PostView.vue -->
<script setup>
import { useRoute } from 'vue-router'
import { usePosts } from '../composables/usePosts.js'

const route = useRoute()
const id = Number(route.params.id)

const { isLoading, error, getArticleById } = usePosts()

// getArticleById 从已加载的数据中查找,不需要再次 fetch
const article = computed(() => getArticleById(id))
</script>

<template>
  <div>
    <p v-if="isLoading">加载中...</p>
    <div v-else-if="!article">文章不存在</div>
    <article v-else>
      <h1>{{ article.title }}</h1>
      <div v-html="article.content"></div>
    </article>
  </div>
</template>

注意:如果用户直接访问详情页 URL(如刷新页面),usePosts 会重新 fetch 数据。因为首页和详情页是不同的组件实例,各自调用 usePosts 时都会执行 onMounted 中的 fetch。


useDarkMode — 一行代码切换暗黑模式

暗黑模式的核心思路:在 html 标签上加一个 class,然后用 CSS 变量控制颜色。

用户的选择存到 localStorage 中,下次访问时自动恢复。

实例

// 文件路径:src/composables/useDarkMode.js
import { ref, watchEffect } from 'vue'

export function useDarkMode() {
  // 从 localStorage 读取用户之前的设置(没有则默认 light)
  const saved = localStorage.getItem('blog-theme')
  const isDark = ref(saved === 'dark')

  // 应用主题到 DOM
  function applyTheme(dark) {
    if (dark) {
      document.documentElement.classList.add('dark')
      localStorage.setItem('blog-theme', 'dark')
    } else {
      document.documentElement.classList.remove('dark')
      localStorage.setItem('blog-theme', 'light')
    }
  }

  // watchEffect 自动追踪 isDark,变化时同步到 DOM
  watchEffect(() => {
    applyTheme(isDark.value)
  })

  // 切换暗黑模式
  function toggleDark() {
    isDark.value = !isDark.value
  }

  return { isDark, toggleDark }
}

在 NavBar 中使用,只需要两行:

实例

<!-- 文件路径:src/components/NavBar.vue -->
<script setup>
import { useDarkMode } from '../composables/useDarkMode.js'

const { isDark, toggleDark } = useDarkMode()
</script>

<template>
  <header class="navbar">
    <a href="/" class="logo">RUNOOB Blog</a>
    <nav>
      <a href="/">首页</a>
      <button class="theme-btn" @click="toggleDark">
        {{ isDark ? '&#x2600; 亮色' : '☾ 暗黑' }}
      </button>
    </nav>
  </header>
</template>

配合全局 CSS 变量实现主题切换

实例

[mycode4 type="css"> /* 文件路径:src/assets/main.css */ /* 浅色模式(默认) */ :root { --bg-primary: #f5f5f5; --bg-card: #ffffff; --text-primary: #333333; --text-secondary: #666666; --border-color: #eeeeee; } /* 暗黑模式(html 标签加上 .dark 类时生效) */ html.dark { --bg-primary: #1a1a2e; --bg-card: #16213e; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --border-color: #2a2a4a; } /* 组件中引用这些变量 */ body { background: var(--bg-primary); color: var(--text-primary); } .card { background: var(--bg-card); border: 1px solid var(--border-color); } [/mycode4]

此时点击切换按钮,整个站点的颜色会平滑切换,且刷新后你的选择会被记住。


与 Vue2 Mixin 的对比

特性Mixin(Vue2)Composable(Vue3)
数据来源不透明,不知道属性从哪里来明确从 return 中解构获取
命名冲突可能多个 Mixin 的同名属性互相覆盖由你决定变量名,不存在冲突
类型推导TypeScript 支持差原生支持 TypeScript
逻辑组织按选项分类(data/methods/computed)按功能分类,一个 composable 一个文件

本章小结

本章你学会了 Vue3 最重要的设计模式——Composable:把可复用逻辑封装为以 use 开头的函数,放在 composables/ 目录,需要时一键调用。

usePosts 统一管理了数据加载 + 筛选 + 搜索,useDarkMode 实现了一行代码接入暗黑模式。组件从此只关心 UI,逻辑全部下沉。