Composition API 的诞生:RFC 0013、Mixin 灾难与社区博弈
第2章:Composition API 的诞生——RFC 0013、Mixin 灾难与社区博弈
2019 年 6 月,RFC 0013 在 GitHub 上线的 72 小时内收到了超过 300 条评论,其中大多数是反对的。这是 Vue 历史上争议最大的一次 API 设计决策,也是 Composition API 最终成为它现在模样的原因。
本章核心问题:Mixin 究竟有多坏?Composition API 解决了什么 Mixin 解决不了的问题?
读完本章你将理解:
- Mixin 的三个根本性缺陷,以及为什么在大型代码库中这些缺陷会变成灾难
- RFC 0013 的原始提案与最终版本之间的关键差异,以及社区反馈如何改变了设计
- Composition API 与 React Hooks 的本质差异:不是语法层面的,是执行模型层面的
Level 1 · 你需要知道的(1-3年经验)
2.1 在 Mixin 出现之前,逻辑复用靠什么?
Vue 2 有四种逻辑复用机制,按流行程度排序:
- Mixin:把组件选项合并进来,最流行但问题最多
- 高阶组件(HOC):包装一个组件,返回增强后的组件,适合 render function
- Renderless Component:通过 slot 暴露逻辑,不渲染 UI
- Plugin:挂载全局方法
Mixin 最流行是因为它最直观:把可复用的逻辑写在一个对象里,然后用 mixins: [myMixin] 注入。看起来很简单。
// 一个"看起来合理"的 Mixin
const mouseMixin = {
data() {
return {
x: 0,
y: 0
}
},
mounted() {
window.addEventListener('mousemove', this.onMouseMove);
},
beforeDestroy() {
window.removeEventListener('mousemove', this.onMouseMove);
},
methods: {
onMouseMove(event) {
this.x = event.clientX;
this.y = event.clientY;
}
}
};
// 使用
export default {
mixins: [mouseMixin],
// 现在 this.x 和 this.y 可以用了
template: `<div>Mouse: {{ x }}, {{ y }}</div>`
}
这段代码单独看没有问题。问题从你开始使用多个 mixin 时开始出现。
2.2 Mixin 的三大灾难
灾难一:命名冲突
const userMixin = {
data() {
return {
loading: false, // "用户数据加载中"
userData: null
}
},
methods: {
async fetchUser() {
this.loading = true; // 设置 userMixin 的 loading
// ...
}
}
};
const postMixin = {
data() {
return {
loading: false, // "文章数据加载中"
postData: null
}
},
methods: {
async fetchPost() {
this.loading = true; // 哪个 loading?!
// ...
}
}
};
export default {
mixins: [userMixin, postMixin],
// 这里只有一个 this.loading
// 当 fetchUser 设置 loading=true 时,fetchPost 的进度条也会消失
}
Vue 2 的 mixin 合并策略:data 中的属性以组件自身优先,但两个 mixin 之间的同名属性会合并成一个,后者覆盖前者(依赖声明顺序)。这种行为难以预测,在大型项目中极难调试。
灾难二:来源不明
export default {
mixins: [authMixin, permissionMixin, pageMixin, formMixin, notificationMixin],
methods: {
handleSubmit() {
if (this.isAuthenticated) { // 这是哪个 mixin 的?
if (this.hasPermission('write')) { // 这个呢?
this.saveForm(); // 是本组件的还是 mixin 的?
this.showNotification('Saved!'); // 这又是哪个?
this.trackPageEvent('form_submit'); // 和这个?
}
}
}
}
}
当你读这段代码时,this.isAuthenticated、this.hasPermission、this.showNotification 可能来自任意一个 mixin,也可能是本组件定义的。你必须逐个打开每个 mixin 文件才能确认。在有 10+ mixin 的组件里,这种"神秘属性追踪游戏"会消耗大量时间。
灾难三:隐式依赖
const paginationMixin = {
data() {
return {
currentPage: 1,
pageSize: 20
}
},
computed: {
paginatedData() {
// 注意这里:paginationMixin 依赖了 this.allData
// 但 allData 是在 dataMixin 里定义的!
return this.allData.slice(
(this.currentPage - 1) * this.pageSize,
this.currentPage * this.pageSize
);
}
}
};
const dataMixin = {
data() {
return {
allData: [] // paginationMixin 隐式依赖这个
}
}
};
// 只有同时使用两个 mixin 时才能正常工作
// 单独使用 paginationMixin 会导致 this.allData 未定义
export default {
mixins: [dataMixin, paginationMixin] // 顺序也很关键!
}
这种隐式依赖是 mixin 最难维护的问题:你无法从 paginationMixin 的代码中看出它依赖 allData,除非你知道它最终会和 dataMixin 一起使用。文档不足时,这种关系完全是隐藏的。
2.3 RFC 0013 引发的风暴
2019 年 6 月,Evan You 在 GitHub vuejs/rfcs 仓库提交了 RFC-0013,标题是 "Function-based Component API"。这是 Composition API 的最初形式。
RFC 的核心是 setup() 函数,在组件实例创建之前执行,返回组件可以使用的响应式状态和函数:
// RFC 0013 的原始提案(简化版)
export default {
setup() {
const count = value(0); // 当时叫 value(),后来改名为 ref()
function increment() {
count.value++;
}
return { count, increment };
}
}
社区的主要反对声音集中在三点:
反对一:"这和 React Hooks 有什么区别?" "Vue 要变成 React 了。如果我想用函数式编程,我会用 React。"
这个反对声音反映了真实的担忧:Vue 的核心价值之一是对初学者的友好性,Options API 的结构清晰,每个选项(data/methods/computed/watch)有明确的职责。Composition API 把这种结构打散了。
反对二:"破坏性变更" "我有大量 Vue 2 项目,这个改变意味着我必须重写所有东西。"
这个担忧是误解,但非常普遍。RFC 并不是要废弃 Options API,但这一点在最初的 RFC 文本中表述不够清晰。
反对三:"学习曲线陡增"
"Vue 的优势就是容易上手。现在要理解 setup()、ref()、reactive()、computed()、生命周期函数的新用法... 这对新手太不友好了。"
Evan You 在社区讨论中做了大量解释工作,最终形成了几个关键的设计取舍:
- Options API 不会被废弃,两者永久并存
setup()是可选的,不使用 Composition API 的代码完全不受影响- 在同一个组件中可以混用两种风格(虽然不推荐)
2.4 <script setup> 的诞生
Composition API 正式发布后,Evan You 发现 setup() 还是有点啰嗦:你需要写 setup() 函数,在里面定义变量,然后 return 一个对象。
2021 年,RFC-0227 提出了 <script setup> 语法——Composition API 的"语法糖":
<!-- 普通 Composition API -->
<script>
import { ref, computed } from 'vue';
export default {
setup() {
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
return { count, doubled, increment };
}
}
</script>
<!-- <script setup> 等价写法 -->
<script setup>
import { ref, computed } from 'vue';
const count = ref(0);
const doubled = computed(() => count.value * 2);
function increment() {
count.value++;
}
// 不需要 return,顶层变量/函数自动暴露给模板
</script>
<script setup> 的优势不只是少写几行代码:
- 编译器可以对其进行更激进的优化(因为知道所有变量的来源)
defineProps/defineEmits有完整的 TypeScript 类型推断- 没有额外的闭包开销(
setup()返回的对象是额外的闭包层)
Level 2 · 它是怎么运行的(3-5年经验)
2.5 Mixin 合并策略的完整规则
理解 mixin 的合并策略,是理解为什么它难以维护的关键:
Mixin 合并优先级规则:
组件自身 > 后声明的 mixin > 先声明的 mixin
mixins: [A, B, C]
↑最低优先级 ↑最高优先级(低于组件)
合并规则按选项类型不同:
data: 深度合并,组件优先,同名组件覆盖 mixin
methods: 同名组件覆盖 mixin,mixin 间后者覆盖前者
computed: 同名组件覆盖 mixin
watch: 合并为数组,所有 watcher 都执行(!不覆盖)
生命周期: 合并为数组,所有钩子都执行,mixin 先于组件
components: 合并,组件优先
directives: 合并,组件优先
最容易引起 bug 的是 watch 的合并规则:两个 mixin 都 watch 同一个属性时,两个 handler 都会执行,执行顺序取决于 mixin 声明顺序。这在命名冲突上与 methods 的行为完全不同,很容易造成混淆。
2.6 Composition API 的执行时序
Composition API 中的 setup() 函数在组件实例创建过程中的精确位置:
组件实例化流程(简化):
1. createApp().mount()
│
2. 解析组件选项
│
3. beforeCreate 钩子(Options API)
│
4. 初始化响应式 data(Options API)
│
5. 执行 setup() ◄── Composition API 在这里运行
│ • 此时 props 已经是响应式的
│ • 此时 emit 可用
│ • 此时 this 不可用(setup 里没有 this!)
│
6. created 钩子(Options API)
│
7. 模板编译 → render function
│
8. beforeMount → mounted
setup() 里没有 this 是最重要的设计决策之一。this 在 Options API 中是所有东西的容器,也是 TypeScript 类型推断的噩梦来源。去掉 this,所有变量来源都明确——来自 setup() 的参数、来自 import、或者是 setup() 内部定义的。
export default {
props: {
userId: String
},
setup(props, context) {
// props:响应式的,不要解构(丢失响应性)
// context:{ attrs, slots, emit, expose },非响应式,可以解构
const { emit } = context; // 可以解构
// const { userId } = props; // 不要这样!丢失响应性
// 正确使用 props
const user = computed(() => fetchUser(props.userId)); // 响应式
// 没有 this!
// this.emit → emit(来自 context)
// this.userId → props.userId
return { user };
}
}
2.7 Composition API vs React Hooks:执行模型的本质差异
这是最容易被误解的对比:
React Hooks 执行模型:
render 1: [useState=0] [useEffect=fn1] [useMemo=42]
↓
state 变化,重新渲染
↓
render 2: [useState=1] [useEffect=fn2] [useMemo=42]
每次渲染:hooks 按顺序重新执行一次
hooks 必须在顶层调用(不能在 if/for 里)
stale closure 问题:fn1 捕获的是 render 1 时的值
Vue setup() 执行模型:
setup() 执行一次:
│
├── const count = ref(0) → 创建响应式引用(永久存在)
├── const doubled = computed(...) → 创建 computed(永久存在)
├── watchEffect(...) → 注册 effect(永久存在)
└── return { count, doubled } → 暴露给模板
后续更新:
- 模板的 render function 重新执行
- setup() 本身不再执行
- ref/computed/effect 的内部仍然响应变化
这个差异导致了截然不同的编程模式:
React 中需要依赖数组的场景:
// React
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // 必须声明依赖,否则 userId 变化时不会重新请求
const greeting = useMemo(() => {
return `Hello, ${user?.name}`;
}, [user]); // 必须声明依赖,否则每次渲染都重新计算
return <div>{greeting}</div>;
}
Vue Composition API 的等价写法:
// Vue
export default {
props: ['userId'],
setup(props) {
const user = ref(null);
// watch 自动追踪 props.userId,不需要依赖数组
watch(() => props.userId, async (userId) => {
user.value = await fetchUser(userId);
}, { immediate: true });
// computed 自动追踪 user.value,不需要依赖数组
const greeting = computed(() => `Hello, ${user.value?.name}`);
return { user, greeting };
}
}
Vue 不需要依赖数组的原因:响应式系统在 effect 执行时自动收集依赖(读取哪些响应式值,就依赖哪些),不需要你手动声明。这是 Vue 的细粒度追踪模型与 React 的不可变数据模型之间最本质的差异。
2.8 Composable 的设计模式
Composable(可组合函数)是 Composition API 下的逻辑复用单元,类似 React 的自定义 Hook。但因为 Vue 的响应式系统不需要在每次渲染时重新执行,composable 的设计比 Hook 更自由:
// useMouse.js —— 标准 composable 写法
import { ref, onMounted, onUnmounted } from 'vue';
export function useMouse() {
const x = ref(0);
const y = ref(0);
function onMouseMove(event) {
x.value = event.clientX;
y.value = event.clientY;
}
// 生命周期钩子在 composable 内部可以直接使用
// 它们会被注册到当前运行的组件实例上
onMounted(() => window.addEventListener('mousemove', onMouseMove));
onUnmounted(() => window.removeEventListener('mousemove', onMouseMove));
return { x, y };
}
// 使用
// <script setup>
import { useMouse } from './useMouse';
const { x, y } = useMouse(); // 解构是安全的,因为 x 和 y 是 ref
// 对比 reactive:const { x } = reactive({x: 0}) 会丢失响应性
与 Mixin 对比的关键优势:
对比维度 Mixin Composable
─────────────────────────────────────────────────
属性来源 不明确(神秘 this) 明确(解构赋值时可见)
命名冲突 自动合并(静默覆盖) 解构时手动命名(显式控制)
隐式依赖 存在(mixin 间耦合) 不存在(显式参数传递)
TypeScript 类型推断差 完整类型推断
嵌套 不支持 支持(composable 调用 composable)
测试 困难(需要完整组件) 简单(纯函数测试)
// Mixin:命名冲突无法避免
export default {
mixins: [useMouseMixin, useScrollMixin],
// 如果两个 mixin 都有 x/y,静默冲突
}
// Composable:可以重命名解决冲突
// <script setup>
const { x: mouseX, y: mouseY } = useMouse();
const { x: scrollX, y: scrollY } = useScroll();
// 完全清晰,没有冲突
Level 3 · 设计文档与源码(资深开发者)
2.9 RFC 0013 到最终实现的演变
RFC 0013 的原始版本(2019.06)与 Vue 3 正式发布(2020.09)之间有 14 个月的时间,API 发生了多处重要变化:
变化一:value() → ref()
原始 RFC 使用 value() 函数创建响应式引用,后来改为 ref()。原因是 value 作为属性名已经有既定含义(访问 ref 的值就是 .value),用作函数名容易混淆。
变化二:模板自动解包范围的精确界定
最初的设计中,在模板里使用 ref 时不需要 .value,但在 reactive 对象里嵌套 ref 时是否自动解包存在争议。最终规则:
- 模板顶层的 ref:自动解包
- reactive 对象内的 ref:自动解包
- 数组内的 ref:不自动解包(为了性能和一致性)
变化三:生命周期钩子命名
原始提案中生命周期钩子叫 onCreated,后来统一加了 on 前缀并对 beforeDestroy → onBeforeUnmount、destroyed → onUnmounted 进行了重命名,与组件选项保持语义一致但名称不同。
2.10 <script setup> 的编译原理
<script setup> 不是运行时特性,而是编译时特性。Vue 编译器(@vue/compiler-sfc)在编译 .vue 文件时,把 <script setup> 块转换成普通的 setup() 函数:
<!-- 源代码 -->
<script setup>
import { ref } from 'vue';
import MyComponent from './MyComponent.vue';
const props = defineProps({
title: String
});
const count = ref(0);
function increment() {
count.value++;
}
</script>
编译后大致等价于:
// 编译产物(简化)
import { ref, defineComponent } from 'vue';
import MyComponent from './MyComponent.vue';
export default defineComponent({
props: {
title: String // defineProps 被编译到这里
},
components: {
MyComponent // import 的组件自动注册
},
setup(props) {
const count = ref(0);
function increment() {
count.value++;
}
// 所有顶层绑定自动 expose
return { count, increment };
// 注意:props 不需要 return,因为它已经在外层了
}
});
编译器做了几件运行时无法做到的事:
- 自动注册组件:
<script setup>中 import 的组件不需要手动注册,编译器自动把它加入components选项 - 自动暴露绑定:顶层的变量/函数自动成为模板可访问的,不需要
return - 类型宏展开:
defineProps<{title: string}>()、defineEmits<{change: [string]}>()、withDefaults()这些只在<script setup>中存在的"宏"会被展开成运行时代码 - 性能优化:编译器知道所有顶层绑定,可以生成更精确的 PatchFlag
2.11 defineProps 的类型系统实现
defineProps 有两种调用方式,类型系统的实现完全不同:
// 方式一:运行时声明(Vue 2 风格,有运行时类型检查)
const props = defineProps({
title: { type: String, required: true },
count: { type: Number, default: 0 }
});
// 方式二:类型声明(TypeScript 风格,编译时类型检查,无运行时检查)
interface Props {
title: string;
count?: number;
}
const props = defineProps<Props>();
// 或者用 withDefaults 添加默认值:
const props = withDefaults(defineProps<Props>(), {
count: 0
});
方式二的实现是纯编译时的:@vue/compiler-sfc 分析 defineProps<Props>() 中的类型参数,把 TypeScript 类型转换成运行时的 prop 定义:
// 编译器提取的类型信息(packages/compiler-sfc/src/script/defineProps.ts)
// interface Props { title: string; count?: number; }
// 转换为:
// props: { title: { type: String, required: true }, count: { type: Number } }
这个转换过程在 packages/compiler-sfc/src/script/resolveType.ts 中实现,支持递归解析 TypeScript 类型,包括联合类型、交叉类型、泛型等。
2.12 Composable 的 SSR 安全边界
在 SSR(服务器端渲染)场景中,composable 有一个需要特别注意的边界:onMounted 和 onUnmounted 在服务器端不会执行。
// 在服务器端,这段代码的行为:
export function useMouse() {
const x = ref(0);
onMounted(() => {
// 服务器端:这里永远不执行
window.addEventListener('mousemove', handler); // window 在 Node.js 中不存在!
});
return { x };
}
这实际上是正确行为——在 SSR 中,你不需要鼠标追踪。但如果你在 composable 的顶层直接访问浏览器 API:
export function useMouse() {
const x = ref(0);
// 危险:服务器端会报错 ReferenceError: window is not defined
window.addEventListener('mousemove', handler);
return { x };
}
正确的 SSR 安全写法:
export function useMouse() {
const x = ref(0);
// 方案1:放在 onMounted 内(服务器端不执行)
onMounted(() => {
window.addEventListener('mousemove', handler);
});
// 方案2:运行时检测
if (typeof window !== 'undefined') {
window.addEventListener('mousemove', handler);
}
return { x };
}
Level 4 · 边界与陷阱(全体适用)
陷阱1:在 setup 外调用 composable 的生命周期钩子
// 错误!
const { x, y } = useMouse(); // 在 setup 外调用
export default {
setup() {
// 此时 useMouse 内部的 onMounted/onUnmounted 已经注册到错误的上下文
}
}
// 正确写法:始终在 setup() 内部调用
export default {
setup() {
const { x, y } = useMouse(); // 生命周期钩子注册到当前组件实例
return { x, y };
}
}
根本原因:Vue 维护一个全局变量 currentInstance,指向当前正在执行 setup() 的组件实例。onMounted 等生命周期钩子调用时,会读取这个全局变量并把 handler 注册到对应实例上。如果在 setup() 执行完成后调用 composable,currentInstance 已经是 null,生命周期钩子的注册会被静默忽略(或者在开发模式下打印警告)。
陷阱2:async setup 与 Suspense
// 这样写是危险的!
export default {
async setup() {
const data = await fetch('/api/data').then(r => r.json());
// ⚠️ 问题:await 之后,currentInstance 不再指向当前组件
// 以下生命周期钩子注册会失败:
onMounted(() => {
console.log('mounted'); // 这可能不会执行!
});
return { data };
}
}
// 正确写法:将 await 之后的生命周期钩子移到 await 之前
export default {
async setup() {
// 先注册生命周期钩子(在 await 之前)
onMounted(() => {
console.log('mounted'); // 正常执行
});
// 再 await
const data = await fetch('/api/data').then(r => r.json());
return { data };
}
}
根本原因:JavaScript 的 async/await 是基于 Promise 的,await 之后的代码在微任务队列中执行,此时当前组件的 setup() 已经"返回"了(返回一个 Promise),currentInstance 已被重置为 null。Vue 3 配合 <Suspense> 组件可以处理 async setup,但生命周期钩子的时序问题依然存在。
陷阱3:watch 与 watchEffect 的执行时机差异
import { ref, watch, watchEffect, nextTick } from 'vue';
const count = ref(0);
// watchEffect:立即执行,然后响应变化
watchEffect(() => {
console.log('watchEffect:', count.value);
});
// 立即打印:watchEffect: 0
// watch:默认不立即执行,只响应变化
watch(count, (newVal) => {
console.log('watch:', newVal);
});
// 不会立即打印
count.value = 1;
// watchEffect 打印:watchEffect: 1(下一个 tick)
// watch 打印:watch: 1(下一个 tick)
// 重要:两者都不是同步执行的!
count.value = 2;
// 此时两个回调还没执行
console.log('sync'); // 先打印这个
// 然后批量执行:watchEffect: 2, watch: 2
// count.value 从 0 → 1 → 2 的中间值 1 被跳过了!
陷阱:在 watch 回调中修改被监听的值
const count = ref(0);
// 无限循环!
watch(count, (newVal) => {
count.value = newVal + 1; // 修改 count → 触发 watch → 再次修改 count → ...
});
Vue 有最大递归深度检测(默认 100 次),超过后会打印警告并停止,但问题依然存在于设计层面。
陷阱4:<script setup> 中 defineProps 的响应性
// <script setup>
const props = defineProps(['title', 'count']);
// 错误:解构 props 丢失响应性!
const { title, count } = props;
// title 和 count 现在是普通字符串/数字,不跟随 props 更新
// 正确:直接使用 props.xxx
console.log(props.title); // 响应式,props 更新时这里会重新执行
// 或者用 toRefs
import { toRefs } from 'vue';
const { title, count } = toRefs(props);
// title.value 和 count.value 是 ref,跟随 props 更新
根本原因:props 对象本身是响应式的(Proxy 代理),但解构取出的值是基础类型,失去了与 Proxy 的联系。toRefs 为每个属性创建一个 ref,ref 的 getter 内部访问 props.xxx,保持了与 Proxy 的连接。
本章小结
-
Mixin 的三大问题是结构性的,无法通过约定解决:命名冲突(合并策略不透明)、来源不明(神秘
this属性)、隐式依赖(跨 mixin 的数据依赖无法从代码中看出),这些问题在项目规模增大时呈指数级恶化。 -
RFC 0013 的社区争论改变了最终 API 设计:社区的反对促使 Vue 团队明确保留 Options API,使 Composition API 成为可选项而非替代品,并在接下来 14 个月的迭代中持续打磨边界细节。
-
Composition API 与 React Hooks 的本质差异是执行模型:
setup()只执行一次,响应式系统自动追踪依赖,不需要依赖数组;React Hooks 每次渲染重新执行,需要手动声明依赖。这不是语法偏好,而是两套完全不同的心智模型。 -
<script setup>是编译时特性,不是运行时特性:它的类型宏(defineProps<T>())、自动组件注册、自动暴露绑定都是由@vue/compiler-sfc在编译阶段完成的,运行时看到的是普通的setup()函数。 -
Composable 彻底解决了 Mixin 的三大问题:变量来源明确(解构赋值可见)、命名冲突可手动重命名(显式控制)、依赖关系通过参数传递(显式声明),这三点优势在大型项目中的价值远超语法层面的改进。