Frontend · #frontend#vue3#composition-api

Vue3 Composition API设计与实战

2025.05.07 6 min 2.5k
// 目录 · contents

引言

Vue3 的 Composition API 是 Vue 框架自诞生以来最重大的范式变革。它借鉴了 React Hooks 的组合思想,同时保留了 Vue 特有的响应式系统优势。本文将从响应式系统的底层原理讲起,系统梳理 refreactivecomputed 等核心 API,深入探讨 composables 设计模式,并与 Options API 进行全方位对比。

响应式系统核心原理

Proxy-Based Reactivity

Vue3 的响应式系统建立在 ES6 Proxy 之上,替代了 Vue2 中的 Object.defineProperty。这带来了对属性的新增/删除、数组索引修改等场景的完整支持。

graph TB
    A[reactive/ref 创建响应式数据] --> B[Proxy Handler]
    B --> C{操作类型}
    C -->|get| D[track: 收集依赖]
    C -->|set| E[trigger: 触发更新]
    D --> F[activeEffect 当前正在执行的副作用]
    F --> G[targetMap: WeakMap 存储依赖关系]
    E --> G
    G --> H[执行关联的 effect / computed / watch]

    style A fill:#42b883,color:#fff
    style D fill:#3498db,color:#fff
    style E fill:#e74c3c,color:#fff
    style G fill:#f39c12,color:#fff

依赖收集与触发的简化模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Simplified reactive system implementation
const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(activeEffect);
}

function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}

function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key);
const result = Reflect.get(target, key, receiver);
// Deep reactivity: recursively wrap nested objects
return typeof result === 'object' && result !== null
? reactive(result)
: result;
},
set(target, key, value, receiver) {
const oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
trigger(target, key);
}
return result;
},
});
}

ref 与 reactive 的选择

ref:包装原始值

ref 通过 .value 属性包装原始类型值,使其具备响应式能力:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { ref, watch } from 'vue';

const count = ref(0);
console.log(count.value); // 0

// In template, auto-unwrapped (no need for .value)
// <template>{{ count }}</template>

count.value++; // Triggers reactivity

// ref can also wrap objects (internally uses reactive)
const user = ref({ name: 'Alice', age: 25 });
user.value.name = 'Bob'; // Reactive!

reactive:代理对象

reactive 直接返回对象的 Proxy,无需 .value,但有限制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { reactive } from 'vue';

const state = reactive({
user: { name: 'Alice', age: 25 },
todos: [],
settings: { theme: 'dark' },
});

state.user.name = 'Bob'; // Reactive
state.todos.push({ text: 'Learn Vue3' }); // Reactive

// PITFALL: destructuring loses reactivity!
const { user } = state; // `user` is now a plain object reference
// Reassigning state properties also breaks reactivity
// state = { ...state, newProp: true }; // Don't do this!

选择指南

flowchart TD
    A[需要响应式数据] --> B{数据类型?}
    B -->|原始类型 string/number/boolean| C[使用 ref]
    B -->|对象/数组| D{是否需要整体替换?}
    D -->|是| E[使用 ref]
    D -->|否| F{是否需要解构?}
    F -->|是| G[使用 ref 或 toRefs]
    F -->|否| H[使用 reactive]

    style C fill:#42b883,color:#fff
    style E fill:#42b883,color:#fff
    style G fill:#f39c12,color:#000
    style H fill:#3498db,color:#fff

setup 函数与 <script setup>

传统 setup 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { ref, computed, onMounted } from 'vue';

export default {
props: {
initialCount: { type: Number, default: 0 },
},
setup(props, { emit, slots, attrs }) {
const count = ref(props.initialCount);
const doubled = computed(() => count.value * 2);

function increment() {
count.value++;
emit('update', count.value);
}

onMounted(() => {
console.log('Component mounted');
});

// Must explicitly return what template needs
return { count, doubled, increment };
},
};

<script setup> 语法糖(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<script setup>
import { ref, computed, onMounted } from 'vue';

const props = defineProps({
initialCount: { type: Number, default: 0 },
});
const emit = defineEmits(['update']);

const count = ref(props.initialCount);
const doubled = computed(() => count.value * 2);

function increment() {
count.value++;
emit('update', count.value);
}

onMounted(() => {
console.log('Component mounted');
});

// Everything declared is automatically available in template
</script>

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

<script setup> 的优势:更少的样板代码、更好的 TypeScript 类型推断、更高的编译优化空间。

生命周期 Hooks

graph TD
    A[setup / 创建阶段] --> B[onBeforeMount]
    B --> C[onMounted]
    C --> D{数据变化?}
    D -->|是| E[onBeforeUpdate]
    E --> F[onUpdated]
    F --> D
    D -->|卸载| G[onBeforeUnmount]
    G --> H[onUnmounted]

    style A fill:#42b883,color:#fff
    style C fill:#3498db,color:#fff
    style H fill:#e74c3c,color:#fff
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import {
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted,
onActivated,
onDeactivated,
onErrorCaptured,
} from 'vue';

// All lifecycle hooks can be called multiple times
// Useful in composables
onMounted(() => {
console.log('First mounted callback');
});

onMounted(() => {
console.log('Second mounted callback — both will execute');
});

provide / inject 依赖注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Parent component
import { provide, ref } from 'vue';

const theme = ref('dark');
const toggleTheme = () => {
theme.value = theme.value === 'dark' ? 'light' : 'dark';
};

// Use Symbol keys to avoid naming conflicts
const THEME_KEY = Symbol('theme');
provide(THEME_KEY, { theme, toggleTheme });

// Deep child component
import { inject } from 'vue';

const { theme, toggleTheme } = inject(THEME_KEY, {
theme: ref('light'), // Default value
toggleTheme: () => {},
});

TypeScript 类型安全的 provide/inject:

1
2
3
4
5
6
7
8
9
10
11
12
import type { InjectionKey, Ref } from 'vue';

interface ThemeContext {
theme: Ref<'light' | 'dark'>;
toggleTheme: () => void;
}

export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('theme');

// provide and inject now have full type inference
provide(THEME_KEY, { theme, toggleTheme });
const themeCtx = inject(THEME_KEY)!; // ThemeContext

Composables 设计模式

Composables 是 Composition API 最核心的复用机制——以 use 开头的函数,封装并复用有状态逻辑。

模式一:useMouse — 全局事件监听

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// composables/useMouse.js
import { ref, onMounted, onUnmounted } from 'vue';

export function useMouse() {
const x = ref(0);
const y = ref(0);

function update(event) {
x.value = event.pageX;
y.value = event.pageY;
}

onMounted(() => window.addEventListener('mousemove', update));
onUnmounted(() => window.removeEventListener('mousemove', update));

return { x, y };
}

模式二:useAsync — 异步操作封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// composables/useAsync.js
import { ref, shallowRef } from 'vue';

export function useAsync(asyncFn) {
const data = shallowRef(null);
const error = ref(null);
const loading = ref(false);

async function execute(...args) {
loading.value = true;
error.value = null;
try {
data.value = await asyncFn(...args);
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}

return { data, error, loading, execute };
}

// Usage
const { data: users, loading, execute: fetchUsers } = useAsync(
() => fetch('/api/users').then(r => r.json())
);

onMounted(() => fetchUsers());

模式三:useFormValidation — 表单验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// composables/useFormValidation.js
import { reactive, computed } from 'vue';

export function useFormValidation(rules) {
const errors = reactive({});
const fields = reactive({});

function setField(name, value) {
fields[name] = value;
validateField(name);
}

function validateField(name) {
const fieldRules = rules[name];
if (!fieldRules) return;

const fieldErrors = [];
for (const rule of fieldRules) {
const result = rule.validator(fields[name], fields);
if (!result) {
fieldErrors.push(rule.message);
}
}
errors[name] = fieldErrors;
}

function validateAll() {
Object.keys(rules).forEach(validateField);
return isValid.value;
}

const isValid = computed(() =>
Object.values(errors).every(errs => errs.length === 0)
);

return { fields, errors, setField, validateAll, isValid };
}

// Usage
const { fields, errors, setField, validateAll, isValid } = useFormValidation({
email: [
{ validator: v => !!v, message: '邮箱不能为空' },
{ validator: v => /.+@.+\..+/.test(v), message: '邮箱格式不正确' },
],
password: [
{ validator: v => !!v, message: '密码不能为空' },
{ validator: v => v?.length >= 8, message: '密码至少8位' },
],
});

Composition API vs Options API

graph LR
    subgraph "Options API: 按选项分组"
        A1[data] --> A2[methods]
        A2 --> A3[computed]
        A3 --> A4[watch]
        A4 --> A5[lifecycle hooks]
    end

    subgraph "Composition API: 按功能分组"
        B1[Feature A: state + logic + lifecycle]
        B2[Feature B: state + logic + lifecycle]
        B3[Feature C: state + logic + lifecycle]
    end

    style A1 fill:#e74c3c,color:#fff
    style A2 fill:#3498db,color:#fff
    style A3 fill:#2ecc71,color:#fff
    style B1 fill:#9b59b6,color:#fff
    style B2 fill:#f39c12,color:#000
    style B3 fill:#1abc9c,color:#fff
维度 Options API Composition API
代码组织 按选项类型分组 按功能逻辑分组
逻辑复用 Mixins(命名冲突风险) Composables(清晰的输入输出)
TypeScript 需要额外类型体操 原生友好,类型自动推断
学习曲线 低(对象结构直观) 中(需理解响应式原语)
适用场景 简单组件 复杂组件、逻辑复用

实战:组合多个 Composables

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<script setup>
import { useFetch } from '@/composables/useFetch';
import { useDebounce } from '@/composables/useDebounce';
import { usePagination } from '@/composables/usePagination';
import { ref, computed, watch } from 'vue';

const search = ref('');
const debouncedSearch = useDebounce(search, 300);

const { page, pageSize, offset, nextPage, prevPage } = usePagination();

const url = computed(
() => `/api/products?q=${debouncedSearch.value}&offset=${offset.value}&limit=${pageSize.value}`
);

const { data, loading, error } = useFetch(url);

// Reset pagination when search changes
watch(debouncedSearch, () => {
page.value = 1;
});
</script>

<template>
<div>
<input v-model="search" placeholder="搜索产品..." />
<div v-if="loading">加载中...</div>
<div v-else-if="error">错误: {{ error.message }}</div>
<div v-else>
<ProductList :products="data?.items" />
<Pagination
:page="page"
:total="data?.total"
@next="nextPage"
@prev="prevPage"
/>
</div>
</div>
</template>

总结

Vue3 Composition API 通过响应式原语(ref / reactive / computed)和生命周期 Hooks,提供了一种灵活的代码组织方式。Composables 模式解决了 Mixins 的命名冲突和来源不明问题,使逻辑复用变得清晰可控。推荐在新项目中优先使用 <script setup> 语法配合 Composition API,而简单组件仍可使用 Options API。两种 API 在 Vue3 中完全兼容,可以按需选择。

作者 · authorzt
发布 · date2025-05-07
篇幅 · length2.5k 字 · 6 min
许可 · licenseCC BY-SA 4.0
$ echo "comments" · 评论