第 2 章

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 解决不了的问题?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

2.1 在 Mixin 出现之前,逻辑复用靠什么?

Vue 2 有四种逻辑复用机制,按流行程度排序:

  1. Mixin:把组件选项合并进来,最流行但问题最多
  2. 高阶组件(HOC):包装一个组件,返回增强后的组件,适合 render function
  3. Renderless Component:通过 slot 暴露逻辑,不渲染 UI
  4. 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.isAuthenticatedthis.hasPermissionthis.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 在社区讨论中做了大量解释工作,最终形成了几个关键的设计取舍:

  1. Options API 不会被废弃,两者永久并存
  2. setup() 是可选的,不使用 Composition API 的代码完全不受影响
  3. 在同一个组件中可以混用两种风格(虽然不推荐)

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> 的优势不只是少写几行代码:


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 时是否自动解包存在争议。最终规则:

变化三:生命周期钩子命名

原始提案中生命周期钩子叫 onCreated,后来统一加了 on 前缀并对 beforeDestroyonBeforeUnmountdestroyedonUnmounted 进行了重命名,与组件选项保持语义一致但名称不同。

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,因为它已经在外层了
  }
});

编译器做了几件运行时无法做到的事:

  1. 自动注册组件<script setup> 中 import 的组件不需要手动注册,编译器自动把它加入 components 选项
  2. 自动暴露绑定:顶层的变量/函数自动成为模板可访问的,不需要 return
  3. 类型宏展开defineProps<{title: string}>()defineEmits<{change: [string]}>()withDefaults() 这些只在 <script setup> 中存在的"宏"会被展开成运行时代码
  4. 性能优化:编译器知道所有顶层绑定,可以生成更精确的 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 有一个需要特别注意的边界:onMountedonUnmounted 在服务器端不会执行

// 在服务器端,这段代码的行为:
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 的连接。


本章小结

  1. Mixin 的三大问题是结构性的,无法通过约定解决:命名冲突(合并策略不透明)、来源不明(神秘 this 属性)、隐式依赖(跨 mixin 的数据依赖无法从代码中看出),这些问题在项目规模增大时呈指数级恶化。

  2. RFC 0013 的社区争论改变了最终 API 设计:社区的反对促使 Vue 团队明确保留 Options API,使 Composition API 成为可选项而非替代品,并在接下来 14 个月的迭代中持续打磨边界细节。

  3. Composition API 与 React Hooks 的本质差异是执行模型setup() 只执行一次,响应式系统自动追踪依赖,不需要依赖数组;React Hooks 每次渲染重新执行,需要手动声明依赖。这不是语法偏好,而是两套完全不同的心智模型。

  4. <script setup> 是编译时特性,不是运行时特性:它的类型宏(defineProps<T>())、自动组件注册、自动暴露绑定都是由 @vue/compiler-sfc 在编译阶段完成的,运行时看到的是普通的 setup() 函数。

  5. Composable 彻底解决了 Mixin 的三大问题:变量来源明确(解构赋值可见)、命名冲突可手动重命名(显式控制)、依赖关系通过参数传递(显式声明),这三点优势在大型项目中的价值远超语法层面的改进。

本章评分
4.6  / 5  (98 评分)

💬 留言讨论