Composable — 封装可复用逻辑
本章你将学会 Vue3 的最佳实践——用组合式函数(Composable)封装可复用的逻辑,让代码更干净、更易维护。
什么是 Composable?
随着功能增多,组件里的 JS 逻辑会越来越长。首页和详情页都需要加载文章数据,代码被复制了两份。
Composable(组合式函数) 就是把可复用的逻辑提取到独立的 JS 函数中,哪个组件需要就在哪里调用。
它的本质就是 一个返回响应式数据和方法的普通函数。
为什么需要 Composable?
对比一下改前和改后的代码结构:
| 没有 Composable(之前) | 有 Composable(之后) |
|---|---|
| 每个组件自己写 fetch 逻辑 | 一个 usePosts() 到处复用 |
| 数据加载和 UI 逻辑混在一起 | 数据逻辑独立,组件只关心 UI |
| 改一个逻辑要改多个文件 | 改一处,所有组件同步更新 |
| 组件代码随业务增长迅速膨胀 | 组件代码职责单一,体积可控 |
如果用过 Vue2 的 Mixins,Composable 可以理解为更清晰、更安全的替代品——它没有命名冲突、数据来源透明。
命名规范与约定
| 规范 | 说明 | 示例 |
|---|---|---|
以 use 开头 | 一眼就知道这是 Composable | usePosts、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
}
}
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>
<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。注意需要单独从 Vue 导入 computed:
实例
<!-- 文件路径:src/views/PostView.vue -->
<script setup>
import { computed } from 'vue'
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()
// articles 是响应式数据,fetch 完成后 computed 会自动重新计算
const article = computed(() => getArticleById(id))
</script>
<template>
<div>
<p v-if="isLoading">加载中...</p>
<p v-else-if="error">加载失败:{{ error }}</p>
<div v-else-if="!article">文章不存在</div>
<article v-else>
<h1>{{ article.title }}</h1>
<div v-html="article.content"></div>
</article>
</div>
</template>
<script setup>
import { computed } from 'vue'
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()
// articles 是响应式数据,fetch 完成后 computed 会自动重新计算
const article = computed(() => getArticleById(id))
</script>
<template>
<div>
<p v-if="isLoading">加载中...</p>
<p v-else-if="error">加载失败:{{ error }}</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,数据不共享。如果需要跨页面共享同一份数据,可以将状态提升到 Pinia store,或在顶层组件调用一次后通过 provide / inject 向下传递。
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 }
}
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 ? '☀ 亮色' : '☾ 暗黑' }}
</button>
</nav>
</header>
</template>
<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 变量实现主题切换
实例
/* 文件路径: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);
}
/* 浅色模式(默认) */
: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);
}
此时点击切换按钮,整个站点的颜色会即时切换,且刷新后你的选择会被记住。
与 Vue2 Mixin 的对比
| 特性 | Mixin(Vue2) | Composable(Vue3) |
|---|---|---|
| 数据来源 | 不透明,不知道属性从哪里来 | 明确从 return 中解构获取,来源一目了然 |
| 命名冲突 | 多个 Mixin 的同名属性会互相覆盖 | 由你决定变量名,不存在冲突 |
| 类型推导 | TypeScript 支持差 | 原生支持 TypeScript,类型自动推导 |
| 逻辑组织 | 按选项分类(data/methods/computed) | 按功能分类,一个 composable 一个文件 |
| 逻辑复用 | Mixin 之间难以组合,容易产生隐式依赖 | Composable 可以互相调用,组合灵活 |
