Vue 3 组合式 API 实战指南

Created on

前言

Vue 3 带来了革命性的 Composition API,相比 Options API,它提供了更好的逻辑复用和代码组织方式。本文将系统讲解 Composition API 的使用方法和最佳实践。

为什么需要 Composition API?

Options API 的局限性

<!-- Options API -->
<script>
export default {
  data() {
    return {
      count: 0,
      user: null,
      loading: false,
    };
  },
  methods: {
    increment() {
      this.count++;
    },
    async fetchUser() {
      this.loading = true;
      this.user = await fetch("/api/user").then((r) => r.json());
      this.loading = false;
    },
  },
  mounted() {
    this.fetchUser();
  },
};
</script>

问题:

Composition API 的优势

<!-- Composition API -->
<script setup>
import { ref, onMounted } from "vue";

// 计数器逻辑
const count = ref(0);
const increment = () => count.value++;

// 用户数据逻辑
const user = ref(null);
const loading = ref(false);

async function fetchUser() {
  loading.value = true;
  user.value = await fetch("/api/user").then((r) => r.json());
  loading.value = false;
}

onMounted(() => {
  fetchUser();
});
</script>

优势:

setup 函数

基础用法

<script>
import { ref } from "vue";

export default {
  setup() {
    const count = ref(0);

    function increment() {
      count.value++;
    }

    // 返回暴露给模板的内容
    return {
      count,
      increment,
    };
  },
};
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

script setup 语法糖 (推荐)

<script setup>
import { ref } from "vue";

// 顶层变量自动暴露给模板
const count = ref(0);

function increment() {
  count.value++;
}
</script>

<template>
  <button @click="increment">{{ count }}</button>
</template>

响应式核心 API

ref - 基本类型响应式

<script setup>
import { ref } from "vue";

const count = ref(0);
const message = ref("Hello");

// 访问值需要 .value
console.log(count.value); // 0

count.value++;
console.log(count.value); // 1

// 模板中自动解包,不需要 .value
</script>

<template>
  <div>{{ count }}</div>
  <div>{{ message }}</div>
</template>

reactive - 对象响应式

<script setup>
import { reactive } from "vue";

const state = reactive({
  count: 0,
  user: {
    name: "John",
    age: 30,
  },
});

// 直接访问,不需要 .value
console.log(state.count); // 0

state.count++;
state.user.name = "Jane";
</script>

<template>
  <div>{{ state.count }}</div>
  <div>{{ state.user.name }}</div>
</template>

ref vs reactive

// ✅ 使用 ref
const count = ref(0);
const message = ref("Hello");
const user = ref({ name: "John" });

// ✅ 使用 reactive
const state = reactive({
  count: 0,
  message: "Hello",
  user: { name: "John" },
});

// ❌ reactive 的局限性
let state = reactive({ count: 0 });
state = reactive({ count: 1 }); // 响应性丢失!

// ✅ ref 可以重新赋值
let count = ref(0);
count.value = 100; // OK

推荐做法:

computed - 计算属性

<script setup>
import { ref, computed } from "vue";

const firstName = ref("John");
const lastName = ref("Doe");

// 只读计算属性
const fullName = computed(() => {
  return `${firstName.value} ${lastName.value}`;
});

// 可写计算属性
const fullNameWritable = computed({
  get() {
    return `${firstName.value} ${lastName.value}`;
  },
  set(newValue) {
    [firstName.value, lastName.value] = newValue.split(" ");
  },
});

fullNameWritable.value = "Jane Smith";
console.log(firstName.value); // 'Jane'
</script>

<template>
  <div>{{ fullName }}</div>
</template>

watch - 侦听器

侦听 single source

<script setup>
import { ref, watch } from "vue";

const count = ref(0);

// 侦听 ref
watch(count, (newValue, oldValue) => {
  console.log(`count changed from ${oldValue} to ${newValue}`);
});

// 侦听 reactive 对象的属性
const state = reactive({ count: 0 });

watch(
  () => state.count,
  (newValue, oldValue) => {
    console.log(`state.count changed from ${oldValue} to ${newValue}`);
  }
);
</script>

侦听 multiple sources

<script setup>
import { ref, watch } from "vue";

const x = ref(0);
const y = ref(0);

watch([x, y], ([newX, newY], [oldX, oldY]) => {
  console.log(`x: ${oldX} -> ${newX}`);
  console.log(`y: ${oldY} -> ${newY}`);
});
</script>

深度侦听

<script setup>
import { reactive, watch } from "vue";

const state = reactive({
  user: {
    name: "John",
    age: 30,
  },
});

// 深度侦听
watch(
  state,
  (newValue) => {
    console.log("State changed:", newValue);
  },
  { deep: true }
);

// 侦听嵌套属性
watch(
  () => state.user.name,
  (newName) => {
    console.log("Name changed:", newName);
  }
);
</script>

watchEffect - 自动追踪依赖

<script setup>
import { ref, watchEffect } from "vue";

const count = ref(0);
const multiplier = ref(2);

// 自动追踪 count 和 multiplier
watchEffect(() => {
  console.log(`Result: ${count.value * multiplier.value}`);
});

count.value++; // 日志: Result: 2
multiplier.value = 3; // 日志: Result: 3
</script>

生命周期钩子

<script setup>
import {
  onBeforeMount,
  onMounted,
  onBeforeUpdate,
  onUpdated,
  onBeforeUnmount,
  onUnmounted,
} from "vue";

// 创建后立即执行
console.log("setup executed");

onBeforeMount(() => {
  console.log("Before mount");
});

onMounted(() => {
  console.log("Mounted");
  // DOM 已挂载,可以访问 $refs
});

onBeforeUpdate(() => {
  console.log("Before update");
});

onUpdated(() => {
  console.log("Updated");
});

onBeforeUnmount(() => {
  console.log("Before unmount");
});

onUnmounted(() => {
  console.log("Unmounted");
  // 清理副作用
});
</script>

Composables - 可复用逻辑

useCounter

// composables/useCounter.js
import { ref } from "vue";

export function useCounter(initialValue = 0) {
  const count = ref(initialValue);

  function increment() {
    count.value++;
  }

  function decrement() {
    count.value--;
  }

  function reset() {
    count.value = initialValue;
  }

  return {
    count,
    increment,
    decrement,
    reset,
  };
}

使用:

<script setup>
import { useCounter } from "./composables/useCounter";

const { count, increment, decrement, reset } = useCounter(0);
</script>

<template>
  <div>Count: {{ count }}</div>
  <button @click="increment">+</button>
  <button @click="decrement">-</button>
  <button @click="reset">Reset</button>
</template>

useFetch

// composables/useFetch.js
import { ref, watchEffect, toValue } from "vue";

export function useFetch(url) {
  const data = ref(null);
  const error = ref(null);
  const loading = ref(false);

  async function fetchData() {
    loading.value = true;
    error.value = null;

    try {
      const response = await fetch(toValue(url));
      if (!response.ok) throw new Error("Failed to fetch");
      data.value = await response.json();
    } catch (e) {
      error.value = e.message;
    } finally {
      loading.value = false;
    }
  }

  // 响应 URL 变化
  watchEffect(() => {
    fetchData();
  });

  return {
    data,
    error,
    loading,
    refetch: fetchData,
  };
}

使用:

<script setup>
import { ref } from "vue";
import { useFetch } from "./composables/useFetch";

const userId = ref(1);
const {
  data: user,
  loading,
  error,
  refetch,
} = useFetch(computed(() => `/api/users/${userId.value}`));
</script>

<template>
  <div v-if="loading">Loading...</div>
  <div v-else-if="error">Error: {{ error }}</div>
  <div v-else-if="user">
    <h2>{{ user.name }}</h2>
    <button @click="refetch">Refresh</button>
  </div>
</template>

useLocalStorage

// composables/useLocalStorage.js
import { ref, watch } from "vue";

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key);
  const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue);

  // 监听变化并同步到 localStorage
  watch(
    data,
    (newValue) => {
      localStorage.setItem(key, JSON.stringify(newValue));
    },
    { deep: true }
  );

  return data;
}

使用:

<script setup>
import { useLocalStorage } from "./composables/useLocalStorage";

const theme = useLocalStorage("theme", "light");

function toggleTheme() {
  theme.value = theme.value === "light" ? "dark" : "light";
}
</script>

<template>
  <button @click="toggleTheme">Current: {{ theme }}</button>
</template>

组件通信

Props

<!-- Parent.vue -->
<script setup>
import { ref } from "vue";
import Child from "./Child.vue";

const message = ref("Hello");
</script>

<template>
  <Child :message="message" :count="100" />
</template>
<!-- Child.vue -->
<script setup>
// 使用 TypeScript 定义 props
interface Props {
  message: string
  count?: number
}

const props = withDefaults(defineProps<Props>(), {
  count: 0
})

console.log(props.message)
</script>

<template>
  <div>{{ message }} - {{ count }}</div>
</template>

Emits

<!-- Child.vue -->
<script setup>
const emit = defineEmits<{
  update: [value: string]
  delete: [id: number]
}>()

function handleClick() {
  emit('update', 'new value')
  emit('delete', 123)
}
</script>

<template>
  <button @click="handleClick">Click</button>
</template>
<!-- Parent.vue -->
<script setup>
import Child from "./Child.vue";

function handleUpdate(value) {
  console.log("Updated:", value);
}

function handleDelete(id) {
  console.log("Deleted:", id);
}
</script>

<template>
  <Child @update="handleUpdate" @delete="handleDelete" />
</template>

Provide / Inject

<!-- Parent.vue -->
<script setup>
import { provide, ref } from "vue";

const theme = ref("light");

provide("theme", theme);
</script>
<!-- Child.vue (任意深度) -->
<script setup>
import { inject } from "vue";

const theme = inject("theme");

console.log(theme.value); // 'light'
</script>

defineExpose - 暴露组件实例

<!-- Child.vue -->
<script setup>
import { ref } from "vue";

const count = ref(0);

function increment() {
  count.value++;
}

// 暴露给父组件
defineExpose({
  count,
  increment,
});
</script>
<!-- Parent.vue -->
<script setup>
import { ref } from "vue";
import Child from "./Child.vue";

const childRef = ref(null);

function callChildMethod() {
  childRef.value.increment();
  console.log(childRef.value.count);
}
</script>

<template>
  <Child ref="childRef" />
  <button @click="callChildMethod">Call Child</button>
</template>

Pinia 状态管理

// stores/user.js
import { defineStore } from "pinia";
import { ref, computed } from "vue";

export const useUserStore = defineStore("user", () => {
  // State
  const user = ref(null);
  const token = ref(null);

  // Getters
  const isLoggedIn = computed(() => !!token.value);
  const userName = computed(() => user.value?.name || "Guest");

  // Actions
  async function login(credentials) {
    const response = await fetch("/api/login", {
      method: "POST",
      body: JSON.stringify(credentials),
    });
    const data = await response.json();

    user.value = data.user;
    token.value = data.token;
  }

  function logout() {
    user.value = null;
    token.value = null;
  }

  return {
    user,
    token,
    isLoggedIn,
    userName,
    login,
    logout,
  };
});

使用:

<script setup>
import { useUserStore } from "./stores/user";

const userStore = useUserStore();

async function handleLogin() {
  await userStore.login({
    email: "[email protected]",
    password: "password",
  });
}
</script>

<template>
  <div v-if="userStore.isLoggedIn">
    Welcome, {{ userStore.userName }}
    <button @click="userStore.logout">Logout</button>
  </div>
  <div v-else>
    <button @click="handleLogin">Login</button>
  </div>
</template>

最佳实践

1. Composables 命名规范

// ✅ 推荐:使用 use 前缀
export function useMouse() {}
export function useCounter() {}
export function useFetch() {}

// ❌ 不推荐
export function mouse() {}
export function counter() {}

2. 响应式解构

import { reactive, toRefs } from "vue";

function useUser() {
  const state = reactive({
    name: "John",
    age: 30,
  });

  // ✅ 使用 toRefs 保持响应性
  return {
    ...toRefs(state),
  };
}

// 使用时保持响应性
const { name, age } = useUser();

3. 条件性副作用

import { watchEffect } from "vue";

const enabled = ref(false);

// ❌ 不好:总是执行
watchEffect(() => {
  if (enabled.value) {
    console.log("Enabled");
  }
});

// ✅ 好:只在需要时执行
let stop = null;

watch(enabled, (value) => {
  if (value && !stop) {
    stop = watchEffect(() => {
      console.log("Enabled");
    });
  } else if (!value && stop) {
    stop();
    stop = null;
  }
});

Options API vs Composition API 迁移

<!-- Before: Options API -->
<script>
export default {
  data() {
    return { count: 0 };
  },
  computed: {
    double() {
      return this.count * 2;
    },
  },
  methods: {
    increment() {
      this.count++;
    },
  },
  mounted() {
    console.log("mounted");
  },
};
</script>
<!-- After: Composition API -->
<script setup>
import { ref, computed, onMounted } from "vue";

const count = ref(0);
const double = computed(() => count.value * 2);

function increment() {
  count.value++;
}

onMounted(() => {
  console.log("mounted");
});
</script>

总结

Vue 3 Composition API 的核心优势:

  1. 更好的逻辑组织: 相关代码聚合在一起
  2. 更强的复用能力: Composables 替代 mixins
  3. 更好的类型推导: 完美支持 TypeScript
  4. 更灵活: 不受选项约束

记住:Composition API 不是为了替代 Options API,而是提供更灵活的选择。

参考资源