setup 函数执行上下文

0 阅读7分钟

在之前的组件渲染文章中,我们简单介绍了 setup 函数的基本使用。今天,我们将深入探讨 setup 函数的执行上下文——它是什么时候被调用的?它接收什么参数?返回值如何处理?以及它在 Vue3 内部是如何实现的。理解这些,将帮助我们更好地掌握组合式 API 的精髓。

前言:setup 的设计初衷

在 Vue2 中,我们使用选项式 API 组织代码:

export default {
  data() { return { count: 0 } },
  computed: { double() { return this.count * 2 } },
  methods: { increment() { this.count++ } },
  mounted() { console.log('mounted') }
}

这种方式的痛点在于:相关逻辑被强制分散在不同的选项中。比如一个计数器的逻辑可能分散在 data、computed、methods、mounted 中。

Vue3 的组合式 API 通过 setup 函数解决了这个问题:

export default {
  setup() {
    // 计数器逻辑集中在一起
    const count = ref(0);
    const double = computed(() => count.value * 2);
    const increment = () => count.value++;
    return { count, double, increment };
  }
}

setup 函数是组合式 API 的入口,它在组件创建之前执行,并返回要暴露给模板的内容。

setup 的调用时机

在组件生命周期中的位置

setup 函数在组件实例创建之前执行,具体来说是在 beforeCreate 钩子之前:

export default {
  beforeCreate() {
    console.log('beforeCreate');
  },
  
  setup() {
    console.log('setup');
    
    const count = ref(0);
    
    return { count };
  },
  
  created() {
    console.log('created');
  }
}

上述代码的打印结果是:

setup 
beforeCreate
created

为什么要在beforeCreate之前执行?

setup 为什么要在 beforeCreate 之前执行,这背后有几个重要原因:

  1. 为了能够在 setup 中使用响应式 API,这些 API 需要在组件实例上注册
  2. 为了能够访问 props 参数(参数可以被访问,但此时还没有被初始化)
  3. 为了能够提前注册生命周期钩子,如 onMounted 等

setup 参数解析

setup 函数可以接收两个参数:propscontext

props 参数

setup 可以接收的第一个参数,是响应式的 props 对象:

export default {
  props: {
    title: String,
    count: Number
  },
  
  setup(props) {
    // ✅ 可以访问props
    console.log(props.title);
    
    // ❌ 不能修改props
    props.title = 'new title'; // 会触发警告
    
    // ✅ 通过toRefs转换为响应式引用
    const { title, count } = toRefs(props);
    console.log(title.value); // 需要.value访问
    
    // 或者单独转换某个属性
    const titleRef = toRef(props, 'title');
    
    return { title, count };
  }
}

props的特殊性

  1. props是响应式的,但解构会失去响应性,可以使用toRefs保持响应性:const { title, count } = toRefs(props);
  2. props 被处理为 shallowReactive

context 参数

setup 可以接收的第二个参数是上下文对象 context,包含四个属性:

  • attrs: 非props的属性
  • slots: 插槽
  • emit: 触发事件
  • expose: 暴露公共方法

attrs 和 slots 的特殊性

attrsslots 是有状态的对象,它们会随着组件更新而更新:

setup(props, { attrs, slots }) {
  // ❌ 解构会失去响应性
  const { class } = attrs; // 不会响应更新
  
  // ✅ 直接使用attrs
  console.log(attrs.class); // 总是最新的
  
  // ✅ 使用计算属性包装
  const className = computed(() => attrs.class);
  
  // slots同理
  useEffect(() => {
    console.log('slots变化了', slots);
  });
}

// attrs的内部实现
function setupContext(instance) {
  return {
    // attrs是响应式的
    get attrs() {
      return instance.attrs;
    },
    // slots也是响应式的
    get slots() {
      return instance.slots;
    },
    emit: instance.emit,
    expose: (exposed) => {
      instance.exposed = exposed;
    }
  };
}

expose 的使用场景

expose 用于控制组件暴露的公共方法:

// 子组件
export default {
  setup(props, { expose }) {
    const count = ref(0);
    
    const increment = () => count.value++;
    const reset = () => count.value = 0;
    const getCount = () => count.value;
    
    // 只暴露reset方法给父组件
    expose({
      reset,
      getCount
      // increment没有被暴露
    });
    
    return { count, increment }; // 这些只用于模板
  }
}

// 父组件
<template>
  <Child ref="childRef" />
</template>

<script setup>
const childRef = ref();

// 只能访问到暴露的方法
childRef.value?.reset(); // ✅ 可以
childRef.value?.increment(); // ❌ undefined
</script>

setup 返回值的处理

setup 函数可以返回两种类型的值:对象或函数。

返回对象

setup 返回值最常见的情况就是返回一个对象,对象的属性会被暴露给模板:

<script>
  export default {
    setup() {
      const count = ref(0);
      const double = computed(() => count.value * 2);

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

      // 返回对象,模板中可以访问这些属性
      return {
        count,
        double,
        increment
      };
    }
  }
</script>
<template>
  <!-- 模板中可以访问返回的属性 -->
  <div>
    <p>count: {{ count }}</p>
    <p>double: {{ double }}</p>
    <button @click="increment">+1</button>
  </div>
</template>

返回对象的处理过程

function handleSetupResult(instance, setupResult) {
  if (typeof setupResult === 'object' && setupResult !== null) {
    // 1. 标记为refs(用于模板解包)
    instance.setupState = proxyRefs(setupResult);
    
    // 2. 将属性合并到实例代理上
    // 这样在模板中可以直接使用count而不是setupState.count
  }
}

// proxyRefs的实现
function proxyRefs(target) {
  return new Proxy(target, {
    get(target, key) {
      const value = Reflect.get(target, key);
      // 自动解包ref
      return isRef(value) ? value.value : value;
    },
    set(target, key, value) {
      const oldValue = target[key];
      if (isRef(oldValue) && !isRef(value)) {
        // 如果是ref,设置其value属性
        oldValue.value = value;
        return true;
      } else {
        return Reflect.set(target, key, value);
      }
    }
  });
}

返回函数

如果 setup 返回一个函数,这个函数会被作为渲染函数使用:

export default {
  props: ['title'],
  
  setup(props) {
    const count = ref(0);
    
    // 返回渲染函数
    return () => {
      return h('div', [
        h('h1', props.title),
        h('p', `count: ${count.value}`),
        h('button', {
          onClick: () => count.value++
        }, '+1')
      ]);
    };
  }
}

注:不能在 setup 中同时使用 template 和返回渲染函数。

返回函数的场景:

  • 需要完全控制渲染逻辑
  • 渲染逻辑依赖响应式数据
  • 在渲染函数中使用JSX
    // JSX示例
    setup() {
      const items = ref([]);
      
      return () => (
        <div>
          {items.value.map(item => (
            <div key={item.id}>{item.text}</div>
          ))}
        </div>
      );
    }
    

返回值的合并优先级

setup 返回值、data、methods 等的合并优先级:

export default {
  data() {
    return {
      message: 'from data',
      count: 0
    };
  },
  
  setup() {
    const count = ref(100); // 同名属性
    const message = 'from setup';
    
    return {
      count,
      message
      // 这里的message会覆盖data中的message
    };
  },
  
  computed: {
    double() {
      return this.count * 2; // 这里的count来自setup
    }
  }
}

最终实例上的属性合并顺序:

  1. props (最高优先级,不能被覆盖)
  2. setup返回的对象
  3. data
  4. computed
  5. methods

setup 中的 this

为什么 setup 中没有 this?

在 Vue2 中,我们习惯使用 this 访问组件实例:

export default {
  data() { return { count: 0 } },
  methods: {
    increment() {
      this.count++; // 使用this
    }
  }
}

但在setup中,是没有 this 的:

export default {
  setup() {
    console.log(this); // undefined
    
    const count = ref(0);
    
    function increment() {
      // 不能使用this.count
      count.value++; // 直接使用变量
    }
    
    return { count, increment };
  }
}

为什么呢?其原因有以下几点:

  • 执行时机:setup 执行在所有钩子函数之前,组件实例尚未完全创建
  • 类型推导:避免 this 带来的类型推导困难
  • 解耦:让逻辑更独立,不依赖组件实例

如何获取组件实例?

虽然 setup 中没有 this,但可以通过 getCurrentInstance 获取当前组件实例:

import { getCurrentInstance } from 'vue';

export default {
  setup() {
    // 获取当前组件实例
    const instance = getCurrentInstance();
    
    console.log(instance); // 组件实例对象
    console.log(instance.proxy); // 代理对象(模板中使用的this)
    
    // 访问实例上的属性
    console.log(instance.props);
    console.log(instance.attrs);
    
    // 注意:这个函数只能在setup或生命周期钩子中调用
    onMounted(() => {
      const instance = getCurrentInstance();
      console.log('在钩子中也可以获取', instance);
    });
    
    return {};
  }
}

注:getCurrentInstance 只能在同步代码中使用,不能在异步代码中使用: setTimeout(() => { const instance = getCurrentInstance(); }, 100); 此时 instance 结果为 null

什么时候使用 getCurrentInstance

在 Vue 官方文档中,并不推荐频繁使用 getCurrentInstance,但在某些场景下我们确实需要使用这个方法,比如:

  • 开发工具/调试
  • 访问全局属性
  • 插件开发

源码对标:setupComponent 函数

Vue3 源码中的 setupComponent

让我们深入 Vue3 源码,看看 setup 是如何被调用的,源码位置:packages/runtime-core/src/component.ts

export function setupComponent(instance) {
  // 1. 初始化props和slots
  initProps(instance, instance.vnode.props);
  initSlots(instance, instance.vnode.children);
  
  // 2. 设置有状态组件(执行setup)
  setupStatefulComponent(instance);
}

function setupStatefulComponent(instance) {
  const Component = instance.type;
  const { setup } = Component;
  
  if (setup) {
    // 创建setup上下文
    const setupContext = createSetupContext(instance);
    
    // 设置当前实例(用于getCurrentInstance)
    setCurrentInstance(instance);
    
    // 执行setup
    const setupResult = setup(
      instance.props,          // 第一个参数:props
      setupContext             // 第二个参数:context
    );
    
    // 清理当前实例
    setCurrentInstance(null);
    
    // 处理返回值
    handleSetupResult(instance, setupResult);
  } else {
    // 没有setup,直接完成组件初始化
    finishComponentSetup(instance);
  }
}

function createSetupContext(instance) {
  return {
    // 使用getter保持响应性
    get attrs() {
      return instance.attrs;
    },
    get slots() {
      return instance.slots;
    },
    emit: instance.emit,
    expose: (exposed) => {
      instance.exposed = exposed;
    }
  };
}

function handleSetupResult(instance, setupResult) {
  if (typeof setupResult === 'function') {
    // 返回函数:作为渲染函数
    instance.render = setupResult;
  } else if (typeof setupResult === 'object' && setupResult !== null) {
    // 返回对象:作为模板上下文
    instance.setupState = proxyRefs(setupResult);
  }
  
  // 完成组件初始化
  finishComponentSetup(instance);
}

function finishComponentSetup(instance) {
  const Component = instance.type;
  
  // 如果还没有render函数,尝试从模板编译
  if (!instance.render) {
    if (Component.template) {
      instance.render = compile(Component.template);
    }
  }
}

完整的执行流程

完整的执行流程

常见陷阱与最佳实践

props解构陷阱

// ❌ 错误:解构后失去响应性
export default {
  props: ['user'],
  setup({ user }) {
    // user不是响应式的
    watch(user, () => {}); // 不会触发
    
    return { user };
  }
}

// ✅ 正确:使用toRefs
import { toRefs } from 'vue';

export default {
  props: ['user'],
  setup(props) {
    const { user } = toRefs(props);
    
    watch(user, () => {
      console.log('user变化了', user.value);
    });
    
    return { user };
  }
}

// ✅ 或者直接使用props
setup(props) {
  watch(() => props.user, () => {
    console.log('user变化了');
  });
}

异步 setup 的处理

// ❌ 错误:setup不能是async
export default {
  async setup() {
    const data = await fetchData(); // 这会导致问题
    return { data };
  }
}

// ✅ 正确:在<script setup>中使用await
<script setup>
const data = await fetchData(); // 自动支持async
</script>

// ✅ 或者使用组合式函数
function useData() {
  const data = ref(null);
  
  onMounted(async () => {
    data.value = await fetchData();
  });
  
  return data;
}

export default {
  setup() {
    const data = useData();
    return { data };
  }
}

生命周期钩子的注册

export default {
  setup() {
    // ✅ 直接在setup中注册
    onMounted(() => {});
    onUpdated(() => {});
    onUnmounted(() => {});
    
    // ❌ 不要在条件语句中注册
    if (someCondition) {
      onMounted(() => {}); // 顺序可能混乱
    }
    
    // ❌ 不要在异步中注册
    setTimeout(() => {
      onMounted(() => {}); // 不会生效
    }, 100);
  }
}

结语

理解 setup 的执行上下文,是掌握 Vue3 组合式 API 的关键。它不仅帮助我们写出更清晰的代码,也为深入理解 Vue3 的响应式系统打下基础。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!