Composable — 封装可复用逻辑
本章你将学会 Vue3 的最佳实践——用组合式函数(Composable)封装可复用的逻辑,让代码更干净、更易维护。
什么是 Composable?
随着功能增多,组件里的 JS 逻辑会越来越长。首页和详情页都需要加载文章数据,代码被复制了两份。
Composable(组合式函数) 就是把可复用的逻辑提取到独立的 JS 函数中,哪个组件需要就在哪里调用。
它的本质就是 一个返回响应式数据和方法的普通函数。
为什么需要 Composable?
对比一下改前和改后的代码结构:
| 没有 Composable(之前) | 有 Composable(之后) |
|---|---|
| 每个组件自己写 fetch 逻辑 | 一个 usePosts() 到处复用 |
| 数据加载和 UI 逻辑混在一起 | 数据逻辑独立,组件只关心 UI |
| 改一个逻辑要改多个文件 | 改一处,所有组件同步更新 |
| 组件代码动辄 300 行 | 组件代码通常 < 100 行 |
如果用过 Vue2 的 Mixins,Composable 可以理解为更清晰、更安全的替代品——它没有命名冲突、数据来源透明。
命名规范与约定
| 规范 | 说明 | 示例 |
|---|---|---|
以 use 开头 | 一眼就知道这是 Composable | usePosts、useDarkMode |
| 放在 composables/ 目录 | 方便查找和维护 | src/composables/usePosts.js |
| 返回响应式数据 | 返回 ref 或 reactive,调用方保持响应 | return { articles, isLoading } |
| 一个函数只做一件事 | 单一职责,方便组合 | usePosts 只管数据,useDarkMode 只管主题 |
usePosts — 封装文章数据逻辑
这个 Composable 负责:加载数据 + 分类筛选 + 搜索过滤。
实例
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 即可:
实例
<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:
实例
<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 中,下次访问时自动恢复。
实例
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 中使用,只需要两行:
实例
<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 ? '☀ 亮色' : '☾ 暗黑' }}
</button>
</nav>
</header>
</template>
配合全局 CSS 变量实现主题切换
实例
此时点击切换按钮,整个站点的颜色会平滑切换,且刷新后你的选择会被记住。
与 Vue2 Mixin 的对比
| 特性 | Mixin(Vue2) | Composable(Vue3) |
|---|---|---|
| 数据来源 | 不透明,不知道属性从哪里来 | 明确从 return 中解构获取 |
| 命名冲突 | 可能多个 Mixin 的同名属性互相覆盖 | 由你决定变量名,不存在冲突 |
| 类型推导 | TypeScript 支持差 | 原生支持 TypeScript |
| 逻辑组织 | 按选项分类(data/methods/computed) | 按功能分类,一个 composable 一个文件 |
本章小结
本章你学会了 Vue3 最重要的设计模式——Composable:把可复用逻辑封装为以 use 开头的函数,放在 composables/ 目录,需要时一键调用。
usePosts 统一管理了数据加载 + 筛选 + 搜索,useDarkMode 实现了一行代码接入暗黑模式。组件从此只关心 UI,逻辑全部下沉。
