Vue3,那个从选项式到组合式的蜕变

3 阅读7分钟

Vue3,那个从选项式到组合式的蜕变

我们组有个前端老哥,用了五年 Vue2。有天他升级到 Vue3,盯着代码看了半小时,然后说了句:"这不是 Vue 了,这是 JavaScript。"

他说得没错。Vue3 就像一个人从被管家安排得井井有条的生活,突然有了自己选择的自由。

那个管家叫"选项式"

Vue2 的时候,咱们写组件就像填表格:

export default {
  data() {
    return { count: 0, user: null }
  },
  methods: {
    increment() { this.count++ },
    fetchUser() { /* 请求逻辑 */ }
  },
  computed: {
    doubled() { return this.count * 2 }
  },
  watch: {
    count(newVal) { console.log(newVal) }
  }
}

data 放数据,methods 放方法,computed 放计算属性,watch 放监听。每个东西都有自己的格子,管家(Vue)帮你整理得妥妥帖帖。

问题是,当逻辑复杂了,这些格子就散开了。比如一个"用户登录"的功能,验证逻辑在 methods 里,状态在 data 里,错误提示在 computed 里,监听在 watch 里。你得在五个地方跳来跳去,就像在一个迷宫里找东西。

更糟的是,如果两个组件都需要"计数器"逻辑,你得复制粘贴整个 datamethodscomputed。代码重复,维护困难。

后来来了个叫"组合式"的东西

Vue3 引入了 Composition API,让你可以把相关逻辑写在一起。比如提取一个 useCounter 的函数:

import { ref, computed, watch } from 'vue'

function useCounter(initialValue = 0) {
  const count = ref(initialValue)
  const doubled = computed(() => count.value * 2)
  const increment = () => count.value++
  const decrement = () => count.value--

  watch(count, (newVal) => {
    console.log(`计数变化: ${newVal}`)
  })

  return { count, doubled, increment, decrement }
}

然后在任何组件里复用它:

import { useCounter } from './useCounter'

export default {
  setup() {
    const { count, doubled, increment, decrement } = useCounter(10)
    return { count, doubled, increment, decrement }
  }
}

这就像把"计数逻辑"打包成一个独立的工具,需要的时候拿出来用。两个组件都需要计数?各自调用一次 useCounter,互不影响。

响应式系统,从"魔法"变成了"透明"

Vue2 用 Object.defineProperty 做响应式,有些坑:

// Vue2 的坑
data() {
  return { user: { name: 'Tom' } }
},
methods: {
  addField() {
    this.user.age = 30  // 不会触发更新!
    this.$set(this.user, 'age', 30)  // 得这样才行
  }
}

Vue3 用 Proxy,直接了当。有两种方式:

reactive 处理对象

import { reactive } from 'vue'

const user = reactive({ name: 'Tom' })
user.age = 30  // 直接就能用,没坑
user.email = 'tom@example.com'  // 新增字段也行

ref 包装任何类型

import { ref } from 'vue'

const count = ref(0)
const user = ref({ name: 'Tom' })
const list = ref([1, 2, 3])

count.value++  // 改 value 属性
user.value.age = 30  // 对象也得改 value
list.value.push(4)  // 数组也一样

窗外下着雨,我问那个老哥为什么要改成 value。他说:"这样你一眼就知道这是响应式的,不用猜。而且 ref 可以包装任何东西,基础类型、对象、数组都行。"

监听响应式数据,从"watch"到"watchEffect"

Vue2 的 watch 得手动指定监听的属性:

watch: {
  user: {
    handler(newVal) {
      console.log('用户变化:', newVal)
    },
    deep: true  // 深度监听,性能差
  }
}

Vue3 的 watch 更灵活:

import { watch, ref } from 'vue'

const user = ref({ name: 'Tom', age: 30 })

// 监听整个对象
watch(user, (newVal) => {
  console.log('用户变化:', newVal)
}, { deep: true })

// 监听对象的某个属性
watch(() => user.value.name, (newVal) => {
  console.log('名字变化:', newVal)
})

// 监听多个数据源
const count = ref(0)
watch([user, count], ([newUser, newCount]) => {
  console.log('用户或计数变化')
})

还有个更方便的 watchEffect,自动追踪依赖:

import { watchEffect, ref } from 'vue'

const user = ref({ name: 'Tom' })
const count = ref(0)

watchEffect(() => {
  // 这里用到了 user 和 count,watchEffect 会自动监听它们
  console.log(`${user.value.name} 的计数: ${count.value}`)
})

// 改任何一个,都会触发
user.value.name = 'Jerry'  // 触发
count.value++  // 也触发

组件通信,从"props/emit"到"provide/inject"

Vue2 的组件通信,层级深了就得一层层传:

// 爷爷组件
<GrandParent :user="user" @update="handleUpdate" />

// 父组件
<Parent :user="user" @update="handleUpdate" />

// 孙子组件
<Child :user="user" @update="handleUpdate" />

Vue3 用 provide/inject 可以跨越多层:

// 爷爷组件
import { provide, ref } from 'vue'

export default {
  setup() {
    const user = ref({ name: 'Tom' })
    const updateUser = (newUser) => {
      user.value = newUser
    }

    // 提供给所有后代组件
    provide('user', user)
    provide('updateUser', updateUser)
  }
}

// 孙子组件,直接注入,不用经过父组件
import { inject } from 'vue'

export default {
  setup() {
    const user = inject('user')
    const updateUser = inject('updateUser')

    return { user, updateUser }
  }
}

就像在公司里,爷爷直接给孙子发通知,不用经过父亲转达。

性能优化,从"全量更新"到"精准打击"

Vue2 的虚拟 DOM 更新,有时候会更新一些不必要的节点。Vue3 做了几个优化:

静态提升:不变的东西,编译时就提出来,不用每次都重新创建。

<template>
  <div>
    <p>{{ msg }}</p>
    <p>不变的文本</p>
    <button @click="increment">+1</button>
  </div>
</template>

编译后,"不变的文本"这个节点只创建一次,不会在每次更新时重新创建。

事件缓存:事件处理函数也缓存起来。

<template>
  <!-- 这个 @click 处理函数会被缓存 -->
  <button @click="increment">+1</button>
</template>

Diff 算法优化:标记了哪些节点是动态的,哪些是静态的,更新时直接跳过静态部分。

结果就是,Vue3 的性能比 Vue2 快了 1.3 到 2 倍。

那个 <script setup> 语法糖

后来 Vue 又出了个更简洁的写法,叫 <script setup>

<script setup>
import { ref, computed, watch } from 'vue'

const count = ref(0)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++

watch(count, (newVal) => {
  console.log('计数变化:', newVal)
})
</script>

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubled }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

不用写 setup() 函数,不用 return,直接定义的变量和函数就能在模板里用。就像你在自己家里,东西放哪儿就用哪儿,不用特意收拾。

组件插槽和动态组件

Vue3 的插槽更灵活。比如一个弹窗组件:

<!-- Modal.vue -->
<script setup>
import { ref } from 'vue'

const isOpen = ref(false)
</script>

<template>
  <div v-if="isOpen" class="modal">
    <div class="modal-header">
      <slot name="header">默认标题</slot>
    </div>
    <div class="modal-body">
      <slot>默认内容</slot>
    </div>
    <div class="modal-footer">
      <slot name="footer">
        <button @click="isOpen = false">关闭</button>
      </slot>
    </div>
  </div>
</template>

使用时:

<Modal>
  <template #header>
    <h2>用户信息</h2>
  </template>

  <p>这是弹窗内容</p>

  <template #footer>
    <button @click="save">保存</button>
    <button @click="cancel">取消</button>
  </template>
</Modal>

Teleport 和 Fragment

有时候你需要把组件渲染到 DOM 的其他地方。比如弹窗应该渲染到 body 下,而不是嵌套在父组件里:

<script setup>
import { ref } from 'vue'

const showModal = ref(false)
</script>

<template>
  <div>
    <button @click="showModal = true">打开弹窗</button>

    <!-- 这个弹窗会被渲染到 body 下 -->
    <Teleport to="body">
      <div v-if="showModal" class="modal-overlay">
        <div class="modal">
          <p>这是弹窗</p>
          <button @click="showModal = false">关闭</button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

Fragment 让你可以返回多个根元素,不用包裹在一个 div 里:

<template>
  <header>头部</header>
  <main>主体</main>
  <footer>底部</footer>
</template>

Vue2 会报错,Vue3 直接支持。

生命周期钩子的变化

Vue2 的生命周期钩子:

export default {
  created() { console.log('创建') },
  mounted() { console.log('挂载') },
  updated() { console.log('更新') },
  unmounted() { console.log('卸载') }
}

Vue3 的 Composition API 里,用函数形式:

import { onMounted, onUpdated, onUnmounted } from 'vue'

export default {
  setup() {
    onMounted(() => console.log('挂载'))
    onUpdated(() => console.log('更新'))
    onUnmounted(() => console.log('卸载'))
  }
}

或者在 <script setup> 里直接用:

<script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'

onMounted(() => console.log('挂载'))
onUpdated(() => console.log('更新'))
onUnmounted(() => console.log('卸载'))
</script>

自定义 Hook,把复杂逻辑打包成工具

真正的威力在于自定义 Hook。比如一个"获取用户列表"的逻辑,涉及加载状态、错误处理、分页等:

import { ref, computed } from 'vue'

function useUserList(pageSize = 10) {
  const users = ref([])
  const loading = ref(false)
  const error = ref(null)
  const currentPage = ref(1)

  const totalPages = computed(() =>
    Math.ceil(users.value.length / pageSize)
  )

  const fetchUsers = async (page = 1) => {
    loading.value = true
    error.value = null
    try {
      const res = await fetch(`/api/users?page=${page}`)
      users.value = await res.json()
      currentPage.value = page
    } catch (e) {
      error.value = e.message
    } finally {
      loading.value = false
    }
  }

  const nextPage = () => {
    if (currentPage.value < totalPages.value) {
      fetchUsers(currentPage.value + 1)
    }
  }

  return { users, loading, error, currentPage, totalPages, fetchUsers, nextPage }
}

然后在组件里用:

<script setup>
import { useUserList } from './useUserList'

const { users, loading, error, nextPage } = useUserList()

// 组件挂载时自动获取
onMounted(() => fetchUsers())
</script>

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-if="error" class="error">{{ error }}</div>
    <ul v-else>
      <li v-for="user in users" :key="user.id">{{ user.name }}</li>
    </ul>
    <button @click="nextPage">下一页</button>
  </div>
</template>

这就像把"用户列表"的整个逻辑打包成一个工具,需要的时候拿出来用。

自定义指令,给 DOM 加魔法

Vue3 的自定义指令更简洁。比如一个"自动聚焦"的指令:

// 全局注册
app.directive('focus', {
  mounted(el) {
    el.focus()
  }
})

// 或者局部注册
const vFocus = {
  mounted(el) {
    el.focus()
  }
}

使用时:

<template>
  <input v-focus />
</template>

再比如一个"点击外部关闭"的指令:

const vClickOutside = {
  mounted(el, binding) {
    el.clickOutsideEvent = (event) => {
      if (!(el === event.target || el.contains(event.target))) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el.clickOutsideEvent)
  },
  unmounted(el) {
    document.removeEventListener('click', el.clickOutsideEvent)
  }
}

使用时:

<script setup>
import { ref } from 'vue'

const showMenu = ref(false)
const closeMenu = () => { showMenu.value = false }
</script>

<template>
  <div v-click-outside="closeMenu">
    <button @click="showMenu = !showMenu">菜单</button>
    <ul v-if="showMenu">
      <li>选项1</li>
      <li>选项2</li>
    </ul>
  </div>
</template>

TypeScript 支持,从"猜测"到"确定"

Vue3 对 TypeScript 的支持比 Vue2 好得多。首先,定义类型:

interface User {
  id: number
  name: string
  email: string
}

interface ApiResponse<T> {
  code: number
  data: T
  message: string
}

然后在 Hook 里用:

import { ref, computed, Ref } from 'vue'

function useUserList(pageSize: number = 10): {
  users: Ref<User[]>
  loading: Ref<boolean>
  error: Ref<string | null>
  fetchUsers: (page: number) => Promise<void>
} {
  const users = ref<User[]>([])
  const loading = ref(false)
  const error = ref<string | null>(null)

  const fetchUsers = async (page: number) => {
    loading.value = true
    try {
      const res = await fetch(`/api/users?page=${page}`)
      const data: ApiResponse<User[]> = await res.json()
      users.value = data.data
    } catch (e) {
      error.value = e instanceof Error ? e.message : '未知错误'
    } finally {
      loading.value = false
    }
  }

  return { users, loading, error, fetchUsers }
}

在组件里,TypeScript 会自动推断类型:

<script setup lang="ts">
import { useUserList } from './useUserList'

const { users, loading, error, fetchUsers } = useUserList()

// TypeScript 知道 users 是 User[],会自动提示属性
users.value.forEach(user => {
  console.log(user.name)  // 有代码补全
})
</script>

泛型 Hook,让代码更通用

有时候你需要一个通用的"获取数据"Hook,可以用泛型:

function useFetch<T>(url: string) {
  const data = ref<T | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)

  const fetch = async () => {
    loading.value = true
    try {
      const res = await fetch(url)
      data.value = await res.json()
    } catch (e) {
      error.value = e instanceof Error ? e.message : '未知错误'
    } finally {
      loading.value = false
    }
  }

  return { data, loading, error, fetch }
}

使用时,类型会自动推断:

// 获取用户列表
const { data: users, fetch: fetchUsers } = useFetch<User[]>('/api/users')

// 获取单个用户
const { data: user, fetch: fetchUser } = useFetch<User>('/api/user/1')

// TypeScript 知道 users 是 User[] | nulluserUser | null

响应式计算属性的高级用法

有时候你需要一个计算属性依赖多个响应式数据,并且有缓存:

import { ref, computed, watch } from 'vue'

function useSearch(query: Ref<string>, items: Ref<Item[]>) {
  const results = computed(() => {
    return items.value.filter(item =>
      item.name.toLowerCase().includes(query.value.toLowerCase())
    )
  })

  const hasResults = computed(() => results.value.length > 0)

  // 监听搜索结果变化
  watch(results, (newResults) => {
    console.log(`找到 ${newResults.length} 个结果`)
  })

  return { results, hasResults }
}

组合多个 Hook

真正的威力在于组合。比如一个"用户搜索"的功能,需要搜索和分页:

function useUserSearch() {
  const query = ref('')
  const { users, loading: listLoading, fetchUsers } = useUserList()
  const { results, hasResults } = useSearch(query, users)

  const search = async (q: string) => {
    query.value = q
    await fetchUsers(1)
  }

  return {
    query,
    results,
    hasResults,
    loading: listLoading,
    search
  }
}

一个 Hook 组合了两个 Hook,逻辑清晰,易于维护。

自定义指令的 TypeScript 类型

定义一个类型安全的指令:

import { DirectiveBinding, VNode } from 'vue'

interface ClickOutsideBinding extends DirectiveBinding {
  value: (event: MouseEvent) => void
}

const vClickOutside = {
  mounted(el: HTMLElement, binding: ClickOutsideBinding) {
    el.clickOutsideEvent = (event: MouseEvent) => {
      if (!(el === event.target || el.contains(event.target as Node))) {
        binding.value(event)
      }
    }
    document.addEventListener('click', el.clickOutsideEvent as EventListener)
  },
  unmounted(el: HTMLElement) {
    document.removeEventListener('click', el.clickOutsideEvent as EventListener)
  }
}

那个老哥现在怎么样

他现在用 Vue3 + TypeScript 写项目,说代码更安全了。有个新来的实习生问他:"Vue3 难吗?"

他想了想说:"不难,就是自由度大了。以前是管家告诉你怎么做,现在是你自己决定。"

有天他写了个 useUserSearch Hook,一个 Hook 里组合了三个其他的 Hook。我问他这样会不会太复杂。

他摇摇头:"不复杂,就是把小工具组合成大工具。就像乐高积木,一块一块拼起来。"

凌晨两点,他还在改 Bug。我问他 Vue3 有没有坑。

他笑了笑:"有啊,但都是自己挖的。现在有 TypeScript,至少能提前发现一些坑。"


#前端 #Vue3 #TypeScript #技术生活 #编程感悟 #工程师日常