setup 语法糖的前世今生

Q: Vue3 最推荐的写法是什么?它是怎么一步一步演进而来的?

A: 目前 Vue3 最推荐的写法是 setup 语法糖<script setup>),但它并非一开始就是这样,而是经历了从 Options API 到 Composition API,再到 defineComponent,最后到 setup 标签写法的演进过程。

阶段一:Vue2 Options API 经典写法

在 Vue2 时期,组件采用 Options API,通过 datamethodsprops 等选项来组织代码:

export default {
  name: 'TaskManager',
  props: {
    initialTasks: { type: Array, required: true, default: () => [] },
  },
  data() {
    return {
      tasks: [...this.initialTasks],
      newTaskTitle: '',
    };
  },
  methods: {
    addTask() {
      if (this.newTaskTitle.trim() === '') return;
      this.tasks.push({ id: Date.now(), title: this.newTaskTitle, completed: false });
      this.newTaskTitle = '';
    },
    completeTask(id) {
      const task = this.tasks.find((task) => task.id === id);
      if (task) { task.completed = true; this.$emit('task-completed', task); }
    },
  },
};

这种写法的缺点是:逻辑分散在不同选项中,当组件逻辑复杂时,代码难以维护和复用。

阶段二:Vue3 初期 Composition API

Vue3 推出 Composition API,在 setup 函数中统一组织数据和方法,最后返回给模板使用:

import { ref, toRefs } from 'vue';
export default {
  name: 'TaskManager',
  props: { initialTasks: { type: Array, required: true } },
  emits: ['task-completed', 'task-uncompleted'],
  setup(props, { emit }) {
    const { initialTasks } = toRefs(props);
    const tasks = ref([...initialTasks.value]);
    const newTaskTitle = ref('');
    const addTask = () => { /* ... */ };
    const completeTask = (taskId) => { /* ... */ emit('task-completed', task); };
    return { tasks, newTaskTitle, addTask, completeTask };
  },
};

这种写法依然保留了 Options API 的影子,需要手动 return 数据和方法。

阶段三:defineComponent 辅助函数

defineComponent 是 Vue3 引入的一个辅助函数,主要用于 TypeScript 提供更好的类型推断。但它没有从根本上改变 Composition API 的写法,本质依然相同。

阶段四:setup 语法糖(最终形态)

从 Vue 3.2 版本开始正式引入 <script setup> 语法糖:

import { ref, toRefs } from 'vue';

const props = defineProps({
  initialTasks: { type: Array, required: true },
});
const emit = defineEmits(['task-completed', 'task-uncompleted']);

const { initialTasks } = toRefs(props);
const tasks = ref([...initialTasks.value]);
const newTaskTitle = ref('');

const addTask = () => { /* ... */ };
const completeTask = (taskId) => { /* 直接使用 emit('task-completed', task) */ };

其优化点主要有两方面:

  1. 简化书写:所有顶层定义的变量和方法会自动暴露给模板,无需手动 return
  2. 更好的类型推断:TypeScript 类型推断更加直观和简单。

关于「宏」的理解

definePropsdefineEmits 实际上是一种编译宏。宏的概念源自 C 语言,在编译前会对宏代码进行文本替换(预处理)。同样地,definePropsdefineEmits 在编译阶段会被替换为 Vue3 早期的 propsemits 配置。这一点可以从 vite-plugin-inspect 插件的编译分析中得到验证——setup 语法糖最终会被编译为 Composition API 早期的写法。

expose 行为的区别

setup 语法糖虽然只是语法糖,但在 expose 行为上与传统写法有所不同。在传统写法中,通过 ref 获取组件实例时,默认能访问组件内部所有数据和方法。Vue 提供了 expose 方法让组件自行决定暴露哪些成员。而在 setup 语法糖中,默认行为就是完全不对外暴露任何成员,需要通过 defineExpose 宏来显式声明。

defineExpose({
  // 要暴露的成员
});

组件生命周期全景

Q: Vue3 的生命周期钩子有哪些?它们的执行顺序是怎样的?

A: Vue3 的组件生命周期分为五个大阶段,每个阶段对应一组钩子函数。

Vue生命周期图

五大生命周期阶段

阶段 关键钩子 说明
初始化选项式 API setup / beforeCreate / created 组件实例创建前后
模板编译 beforeMount 模板编译完成后
初始化渲染 mounted 真实 DOM 生成后
更新组件 beforeUpdate / updated 响应式数据变化时
销毁组件 beforeUnmount / unmounted 组件卸载前后

Vue2 与 Vue3 钩子对照

生命周期名称 Vue2 Vue3
beforeCreate 阶段 beforeCreate setup
created 阶段 created setup
beforeMount 阶段 beforeMount onBeforeMount
mounted 阶段 mounted onMounted
beforeUpdate 阶段 beforeUpdate onBeforeUpdate
updated 阶段 updated onUpdated
beforeUnmount 阶段 beforeDestroy onBeforeUnmount
unmounted 阶段 destoryed onUnmounted

值得注意的是,Vue2 和 Vue3 的生命周期钩子在同一个组件中可以共存(例如同时写 mountedonMounted),Vue3 钩子的执行时机会比 Vue2 对应的钩子稍早一些。不过实际开发中不会同时使用两种写法。

生命周期的本质

所谓生命周期,本质上就是在合适的时机调用用户所设置的回调函数

从源码角度看,渲染器在渲染组件时,先从选项对象中提取注册的生命周期钩子函数,然后在内部流程的对应位置调用它们:

function mountComponent(vnode, container, anchor) {
  const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions;

  beforeCreate && beforeCreate();           // 组件实例创建前
  const state = reactive(data());
  const instance = { state, isMounted: false, subTree: null };
  vnode.component = instance;
  created && created.call(state);           // 组件实例创建后

  effect(() => {
    const subTree = render.call(state, state);
    if (!instance.isMounted) {
      beforeMount && beforeMount.call(state); // 挂载前
      patch(null, subTree, container, anchor);
      instance.isMounted = true;
      mounted && mounted.call(state);         // 挂载后
    } else {
      beforeUpdate && beforeUpdate.call(state); // 更新前
      patch(instance.subTree, subTree, container, anchor);
      updated && updated.call(state);           // 更新后
    }
    instance.subTree = subTree;
  }, { scheduler: queueJob });
}

嵌套组件的生命周期顺序

当组件嵌套时,生命周期遵循递归模式。假设 A 组件下嵌套了 B 组件:

  1. 组件 A:onBeforeMount
  2. 组件 B:onBeforeMount
  3. 组件 B:mounted
  4. 组件 A:mounted

销毁时同样是递归逻辑——先销毁子组件,再销毁父组件。


KeepAlive 生命周期与原理

Q: KeepAlive 是如何工作的?它的核心原理是什么?

A: KeepAlive 的概念借鉴于 HTTP 协议中的 HTTP 持久连接。在 Vue 中,keep-alive 组件用于缓存组件,避免组件被频繁地销毁和重建,从而优化性能。

基本使用回顾

在 Tab 切换场景中,不使用 keep-alive 会导致每次切换都销毁和重建组件:

<template>
  <Tab v-if="currentTab === 1">...</Tab>
  <Tab v-if="currentTab === 2">...</Tab>
  <Tab v-if="currentTab === 3">...</Tab>
</template>

使用 keep-alive 包裹后,组件会被缓存:

<keep-alive>
  <Tab v-if="currentTab === 1">...</Tab>
  <Tab v-if="currentTab === 2">...</Tab>
  <Tab v-if="currentTab === 3">...</Tab>
</keep-alive>

同时支持 includeexcludemax 等属性进行精细化控制。

KeepAlive 专属生命周期

组件被缓存后,常规的生命周期钩子(如 onMountedonUnmounted)不会再触发,这带来了新问题:不知道组件是否处于激活状态。为此,Vue3 提供了两个专属钩子:

  • onActivated:首次挂载及组件激活时触发
  • onDeactivated:组件卸载及组件失活时触发

KeepAlive 的核心实现

KeepAlive 的实现需要渲染器层面的支持。当组件需要「卸载」时,不是真的卸载,而是搬运到隐藏容器中(即「假卸载」)。当需要重新挂载时,再从隐藏容器搬回来。

KeepAlive卸载 KeepAlive重新挂载

基本实现原理:

const KeepAlive = {
  __isKeepAlive: true,
  setup(props, { slots }) {
    const cache = new Map();                    // 缓存对象:key → vnode
    const instance = currentInstance;
    const { move, createElement } = instance.keepAliveCtx;
    const storageContainer = createElement('div'); // 隐藏容器

    // 失活:移动到隐藏容器
    instance._deActivate = (vnode) => { move(vnode, storageContainer); };
    // 激活:移动回页面容器
    instance._activate = (vnode, container, anchor) => { move(vnode, container, anchor); };

    return () => {
      let rawVNode = slots.default();
      if (typeof rawVNode.type !== 'object') return rawVNode;

      const cachedVNode = cache.get(rawVNode.type);
      if (cachedVNode) {
        rawVNode.component = cachedVNode.component;
        rawVNode.keptAlive = true;      // 标记:已缓存
      } else {
        cache.set(rawVNode.type, rawVNode);
      }
      rawVNode.shouldKeepAlive = true;  // 标记:需要缓存
      rawVNode.keepAliveInstance = instance;
      return rawVNode;
    };
  },
};

KeepAlive 组件对「内部组件」添加了三个关键标记属性,与渲染器配合工作:

标记属性 作用
keptAlive 标识组件已被缓存,渲染器不会重新挂载,而是调用 _activate 激活
shouldKeepAlive 标识需要缓存,卸载时不会真正卸载,而是调用 _deActivate 移入隐藏容器
keepAliveInstance 让内部组件持有 KeepAlive 实例,以便访问激活/失活方法

缓存管理策略

KeepAlive 通过 max 属性控制缓存上限,采用类似 LRU 的淘汰策略:当缓存数量超过阈值时,最久未被访问的缓存实例会被销毁。

keys.add(key); // 新缓存加入队列末尾
if (max && keys.size > parseInt(max, 10)) {
  pruneCacheEntry(keys.values().next().value); // 删除最旧的
}

includeexclude 则通过对组件名称进行正则匹配来控制缓存范围。


key 的本质与 diff 算法

Q: Vue 中的 key 到底起什么作用?为什么不能使用数组下标作为 key?

A: 在关系型数据库中,primary key 用于标记数据的唯一性,方便精准查找。Vue 中的 key 道理相同——它是虚拟节点(VNode)的唯一标识

不用 key 时的复用问题

假设新旧 VNode 列表如下:

// 旧
const oldVNode = { type: 'div', children: [
  { type: 'p', children: '1' },
  { type: 'p', children: '2' },
  { type: 'p', children: '3' },
]};
// 新
const newVNode = { type: 'div', children: [
  { type: 'p', children: '4' },
  { type: 'p', children: '5' },
  { type: 'p', children: '6' },
]};

如果不采用任何复用策略,需要 6 次 DOM 操作(3 次删除 + 3 次新增)。

不复用策略

通过简单的遍历复用(按索引一一对应更新文本),只需要 3 次 DOM 操作,性能提升一倍。

但当子节点类型和顺序都发生变化时,仅靠 type 无法判断是否可以复用:

类型变化无法复用

引入 key 后的精准复用

key 相当于给每个 VNode 一个「身份证号」,通过 key 可以精准找到对应的 VNode:

// 旧
const oldVNode = { type: 'div', children: [
  { type: 'p', children: '3', key: 1 },
  { type: 'div', children: '2', key: 2 },
  { type: 'p', children: '1', key: 3 },
]};
// 新
const newVNode = { type: 'div', children: [
  { type: 'p', children: '1', key: 3 },
  { type: 'p', children: '3', key: 1 },
  { type: 'div', children: '2', key: 2 },
]};

当 VNode 的 typekey 都相同时,说明是同一组映射,可以进行 DOM 节点复用,只需移动 DOM 即可。

key精准映射

就地更新策略 vs 有 key 的精准复用

没有 key 时,Vue 内部采用「就地更新」策略——在旧节点中找一个类型相同的就直接复用。这种策略默认是高效的,但仅保证 DOM 节点类型对得上。如果节点依赖子组件状态或临时 DOM 状态,就地复用反而会造成状态错乱。

就地复用示意图1 就地复用示意图2

加上 key 后,新旧节点能够精准对应,避免状态的错误还原:

key精准对应

为什么不能使用数组下标作为 key?

使用下标作为 key 时,如果列表中发生插入或删除操作,每个元素的 key 都会变化,Vue 会复用错误的元素,导致状态和数据不一致:

// 初始状态
[{ id: 1, text: 'Item 1' }, { id: 2, text: 'Item 2' }, { id: 3, text: 'Item 3' }]

// 删除第二个元素后,第三个元素的下标从 2 变为 1
// Vue 会误以为原本的第三个元素和第二个元素是同一个,导致错误更新
[{ id: 1, text: 'Item 1' }, { id: 3, text: 'Item 3' }]

key 的核心价值总结

  1. 高效更新:帮助 Vue 识别哪些元素是变化的、哪些是新的、哪些需要被移除,避免不必要的 DOM 操作。
  2. 确保元素唯一性:每个元素在列表中可被唯一标识,避免在移动、插入或删除时出现混淆。
  3. 提升渲染性能:通过 key 快速定位到需要更新的元素,避免重新渲染整个列表,在大列表场景下优势尤为明显。

总结

本文从 setup 语法糖的演进历程入手,介绍了 Vue3 组件写法的四种形态,重点理解了 definePropsdefineEmits 作为编译宏的本质。在生命周期部分,深入分析了钩子函数的执行时机、嵌套组件的递归挂载顺序,以及 onActivated/onDeactivated 这两个 KeepAlive 专属钩子的使用场景。KeepAlive 的核心原理是通过隐藏容器实现「假卸载」,配合 keptAliveshouldKeepAlive 等标记与渲染器紧密协作。最后讲解了 key 在 diff 算法中的关键作用——它是 VNode 的唯一标识,精准复用比就地更新更可靠,始终应避免使用数组下标作为 key。