那个复制粘贴了二十次 loading 的下午
我们组有个同事,人称"小李"。他写页面有个习惯------每次新建一个组件,第一件事是打开上一个组件,把头部那段复制过来。
loading、data、error,三件套,雷打不动。
我问他为什么不封装一下,他头也没抬:"复制快,不用想。"
那天下午,他在复制第二十一遍。
逻辑住在四个地方
Vue2 的 Options API,有一种特别的折磨方式。
你写一个"获取用户列表"的功能,相关的代码会被强制拆散到四个地方:
js
export default {
data() {
return {
userList: [],
loading: false,
error: null,
currentPage: 1,
total: 0,
}
},
computed: {
hasMore() {
return this.userList.length < this.total
}
},
methods: {
async fetchUsers() {
this.loading = true
this.error = null
try {
const res = await getUserList({ page: this.currentPage })
this.userList = res.data
this.total = res.total
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
loadMore() {
this.currentPage++
this.fetchUsers()
}
},
mounted() {
this.fetchUsers()
}
}
data、computed、methods、mounted------四个地方,一个功能。
改一个字段,要在文件里跳四次。组件一旦复杂起来,一个 500 行的文件里,你根本不知道哪段逻辑跟哪段是一伙的。
就像一份菜谱,食材写在第一页,步骤写在第五页,火候写在最后一页。你做的是同一道菜,但它住在三个地方。
把它装进一个函数
Vue3 的 Composition API 给了另一种思路:按功能组织,不按类型。
相关的逻辑住在一起,然后打包成一个函数带走。
js
// composables/useUserList.js
export function useUserList() {
const userList = ref([])
const loading = ref(false)
const error = ref(null)
const currentPage = ref(1)
const total = ref(0)
const hasMore = computed(() => userList.value.length < total.value)
async function fetchUsers() {
loading.value = true
error.value = null
try {
const res = await getUserList({ page: currentPage.value })
userList.value = res.data
total.value = res.total
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function loadMore() {
currentPage.value++
fetchUsers()
}
onMounted(fetchUsers)
return { userList, loading, error, hasMore, loadMore }
}
组件里:
vue
<script setup>
const { userList, loading, error, hasMore, loadMore } = useUserList()
</script>
原来散在四处的逻辑,现在住在一个文件里。组件只管渲染,不管数据怎么来。
小李看了一眼,说:"这不就是个函数吗。"
我说:"对,就是个函数。"
他想了想,又说:"那 loading 怎么是响应式的?解构不是会丢失响应式吗?"
这是个好问题。reactive() 解构会丢失响应式,但 ref() 不会------因为 ref 返回的是一个对象,解构拿到的是这个对象的引用,不是里面的值。模板访问 loading 时,实际上访问的是 loading.value,响应式追踪还在。
所以 composable 的惯例是:返回 ref,不返回 reactive。
让 composable 接受动态参数
上面的 useUserList 是写死的,实际项目里,接口参数往往是动态的。
比如根据搜索关键词过滤用户:
js
export function useUserList(params) {
const userList = ref([])
const loading = ref(false)
async function fetchUsers() {
loading.value = true
// toValue() 兼容传入的是普通值还是 ref
const res = await getUserList(toValue(params))
userList.value = res.data
loading.value = false
}
// 参数变化时自动重新请求
watch(() => toValue(params), fetchUsers, { immediate: true })
return { userList, loading }
}
组件里可以传静态值,也可以传响应式的 ref:
js
// 传静态值
const { userList } = useUserList({ role: 'admin' })
// 传响应式 ref,keyword 变化时自动重新请求
const keyword = ref('')
const { userList, loading } = useUserList(computed(() => ({ keyword: keyword.value })))
toValue() 是 Vue3.3 加的工具函数,作用是:如果传进来的是 ref,就取 .value;如果是普通值,就直接返回。这样 composable 对调用方更友好,不强制要求传 ref。
它自己管自己的生命周期
有一类 composable 更有意思------它不只是封装数据,还封装了副作用的清理。
比如监听窗口大小:
js
export function useWindowSize() {
const width = ref(window.innerWidth)
const height = ref(window.innerHeight)
const handler = () => {
width.value = window.innerWidth
height.value = window.innerHeight
}
onMounted(() => window.addEventListener('resize', handler))
onUnmounted(() => window.removeEventListener('resize', handler))
return { width, height }
}
组件挂载,监听开始。组件卸载,监听自动清理。
组件本身不需要知道这些细节。你调用 useWindowSize(),拿到宽高,用完走人。
以前用 Options API,你得在 mounted 里注册,在 beforeDestroy 里清理,两段代码分开写,还得记得配对。忘了清理,内存泄漏就悄悄埋下了。现在这对逻辑住在同一个函数里,忘不了。
再比如定时器:
js
export function useInterval(fn, delay) {
let timer = null
onMounted(() => { timer = setInterval(fn, delay) })
onUnmounted(() => clearInterval(timer))
}
用的时候:
js
// 每 5 秒刷新一次数据,组件销毁时自动停止
useInterval(fetchLatestData, 5000)
不用在组件里操心 clearInterval,composable 自己收尾。
Vue 的 composable 没有规则
用过 React hooks 的人可能知道,它有一条铁律:不能在条件语句或循环里调用 hook。
js
// React 里这样写,eslint 会报错,运行时也会出问题
if (isAdmin) {
const [permissions, setPermissions] = useState([]) // ❌
}
原因是 React 靠调用顺序来追踪状态------第一次渲染调了几个 hook,下次渲染必须按同样的顺序调同样多的 hook,顺序乱了,状态就对不上。
Vue 不一样。Vue 用 Proxy 做响应式,每个 ref 和 reactive 都是独立的对象,有自己的标识,不依赖调用顺序。所以 Vue 的 composable 可以随便调:
js
// Vue 里完全合法
if (isAdmin) {
const { permissions } = usePermissions() // ✅
}
// 根据路由参数决定要不要开启埋点
if (route.meta.trackable) {
usePageView(route.name)
}
// 动态订阅多个频道
for (const channelId of subscribedChannels) {
useChannelMessage(channelId)
}
这不是小事。它意味着你可以根据业务逻辑决定要不要启用某段功能,而不是被框架的规则反过来约束你的写法。
有人已经写好了
小李后来发现了 VueUse。
VueUse 是一个 composable 工具库,200 多个函数,覆盖了大部分你能想到的浏览器 API 和常见场景。
js
import {
useMouse,
useNetwork,
useLocalStorage,
useClipboard,
useIntersectionObserver,
useDebounce,
} from '@vueuse/core'
几个特别实用的:
useLocalStorage --- 响应式的本地存储。改了自动同步,刷新页面自动恢复。
js
const theme = useLocalStorage('theme', 'light')
// 直接赋值,自动写入 localStorage
theme.value = 'dark'
useIntersectionObserver --- 元素进入视口时触发,做懒加载、无限滚动都靠它。
js
const target = ref(null)
const { stop } = useIntersectionObserver(target, ([{ isIntersecting }]) => {
if (isIntersecting) {
loadMoreData()
stop() // 只触发一次就停
}
})
useClipboard --- 一行搞定复制功能,还带复制成功状态。
js
const { copy, copied } = useClipboard()
// copied 是 ref,复制后自动变 true,2 秒后恢复 false
useDebounce --- 防抖值,搜索框的标配。
js
const keyword = ref('')
const debouncedKeyword = useDebounce(keyword, 300)
// debouncedKeyword 跟着 keyword 变,但延迟 300ms
watch(debouncedKeyword, searchUsers)
他翻了翻文档,沉默了一会儿,说:"我以为我在造轮子,原来人家早造好了。"
窗外天色开始暗下来。
组合起来用
composable 最有意思的地方,是可以嵌套。
每一层只关心自己那一块,组合起来就是完整的功能。
比如一个完整的搜索列表页,通常需要:搜索词防抖、发请求、分页、loading 状态。把这些拆成小 composable,再组合:
js
// composables/useSearchList.js
export function useSearchList(fetchFn) {
const keyword = ref('')
const page = ref(1)
const list = ref([])
const total = ref(0)
const loading = ref(false)
const debouncedKeyword = useDebounce(keyword, 300)
async function search() {
loading.value = true
const res = await fetchFn({ keyword: debouncedKeyword.value, page: page.value })
list.value = res.data
total.value = res.total
loading.value = false
}
// 关键词变化时重置页码并重新搜索
watch(debouncedKeyword, () => { page.value = 1; search() })
watch(page, search)
onMounted(search)
return { keyword, page, list, total, loading }
}
组件里:
vue
<script setup>
const { keyword, page, list, total, loading } = useSearchList(fetchUserList)
</script>
<template>
<input v-model="keyword" placeholder="搜索用户" />
<div v-if="loading">加载中...</div>
<ul v-else>
<li v-for="user in list" :key="user.id">{{ user.name }}</li>
</ul>
<Pagination v-model="page" :total="total" />
</template>
搜索、防抖、分页、请求------全在 useSearchList 里。组件只管绑定和渲染,不操心任何逻辑细节。
下次换个接口,换个组件,useSearchList(fetchOrderList) 一行,全套逻辑复用。
写 composable 的几个习惯
用下来,有几个小习惯值得记一下:
1. 文件放在 composables/ 目录,函数名以 use 开头
这是约定,不是强制。但统一了之后,团队里一眼就能认出哪些是 composable。
2. 返回 ref,不返回 reactive
js
// ❌ 解构后丢失响应式
return reactive({ data, loading })
// ✅ 解构后响应式保留
return { data, loading }
3. 接受 ref 或普通值都行,内部用 toValue() 统一处理
js
export function useXxx(input) {
watchEffect(() => {
const val = toValue(input) // ref 取 .value,普通值直接用
// ...
})
}
4. 副作用在 onUnmounted 里清理
注册了事件监听、定时器、WebSocket 连接,都要配对清理。composable 自己管,不麻烦组件。
后来小李真的把那二十个页面重构了一遍。
用了一个下午。
他说,剩下的时间他去睡了个午觉。
#前端 #JavaScript #技术生活 #编程感悟 #工程师日常