那个复制粘贴了二十次 loading 的下午

那个复制粘贴了二十次 loading 的下午

我们组有个同事,人称"小李"。他写页面有个习惯------每次新建一个组件,第一件事是打开上一个组件,把头部那段复制过来。

loadingdataerror,三件套,雷打不动。

我问他为什么不封装一下,他头也没抬:"复制快,不用想。"

那天下午,他在复制第二十一遍。


逻辑住在四个地方

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()
  }
}

datacomputedmethodsmounted------四个地方,一个功能。

改一个字段,要在文件里跳四次。组件一旦复杂起来,一个 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 做响应式,每个 refreactive 都是独立的对象,有自己的标识,不依赖调用顺序。所以 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 #技术生活 #编程感悟 #工程师日常

相关推荐
还是大剑师兰特2 小时前
vue3中slot,template #名称 的详细说明和具体示例
javascript·vue.js·ecmascript
HelloReader2 小时前
Flutter 底层原理揭秘框架如何工作(十五)
前端
南篱2 小时前
前端必看:一口气搞懂跨域是什么、为什么、怎么解决
前端·javascript·面试
qq_406176142 小时前
Vue 插槽与组件传参:从入门到精通
前端·javascript·vue.js
三年三月2 小时前
Redux 技术栈使用总结
前端·react.js
Tody Guo2 小时前
OpenClaw与企业微信的定时任务设定
前端·github·企业微信
张雨zy2 小时前
Vue 的 v-if 与 v-show,Android 的 GONE 与 INVISIBLE
android·前端·vue.js
sudo_明天上线2 小时前
React Compiler 技术原理解析
前端
xjf77112 小时前
Vue转TypeDOM的AI训练方案
前端·vue.js·人工智能·typedom