Vue 3源码深度解析之:`defineProps`和`defineEmits`:`script setup`中的宏指令如何编译。

咳咳,各位靓仔靓女们,晚上好!我是今晚的讲师,代号“挖源码小能手”,很高兴能和大家一起扒一扒Vue 3 script setupdefinePropsdefineEmits 这俩宏指令的底裤,啊不,是源码编译原理。

咱们今天不搞虚的,直接上干货,争取让各位听完之后,下次面试直接反问面试官:”你知道defineProps是怎么编译的吗?“,直接镇住全场!

一、script setup 的魔力

首先,我们要明白,script setup 是Vue 3中一种非常简洁的组件编写方式。它最大的特点就是:

  1. 无需显式 return: 所有顶层声明的变量、函数都会自动暴露给模板。
  2. 更简洁的 API: 使用 definePropsdefineEmits 来声明 props 和 emits,告别了 props: { ... }emits: ['event'] 的繁琐。

但是,问题来了,definePropsdefineEmits 本身并不是 JavaScript API,它们是只存在于 script setup 语法糖中的“魔法”。 那么,编译器是如何将它们转换成 Vue 组件选项的呢?这就是我们今天要探索的核心。

二、defineProps 的编译过程

defineProps 主要负责声明组件的 props,它有两种使用方式:

  1. 运行时声明 (Runtime Declaration): 传递一个对象作为参数,对象描述了每个 prop 的类型、默认值等。

    <script setup>
    const props = defineProps({
      title: {
        type: String,
        required: true
      },
      count: {
        type: Number,
        default: 0
      }
    })
    
    console.log(props.title)
    </script>
  2. 类型声明 (Type Declaration): 使用 TypeScript 类型声明来推断 props 的类型。

    <script setup lang="ts">
    interface Props {
      title: string;
      count?: number;
    }
    
    const props = defineProps<Props>();
    
    console.log(props.title)
    </script>

这两种方式在编译时的处理方式略有不同,但最终目的都是生成 props 选项。

接下来,我们深入源码,看看编译器是如何处理 defineProps 的。

2.1 源码定位

script setup 的编译逻辑主要集中在 @vue/compiler-sfc 这个包中。 我们重点关注 transformScriptSetup 这个函数,它负责处理 script setup 中的各种语法糖。 在 transformScriptSetup 内部,会调用 processDefineProps 函数来处理 defineProps

2.2 运行时声明的编译

defineProps 接收一个对象参数时,编译器会直接将这个对象转换成 props 选项。 简单来说,就是把你的对象字面量,原封不动地放到组件的 props 选项里。

// 简化后的代码片段 (来自 @vue/compiler-sfc)
function processDefineProps(node: CallExpression, context: TransformContext) {
  if (node.arguments.length > 0) {
    const arg = node.arguments[0];
    // ... 省略一些类型检查和错误处理 ...

    // 直接将参数作为 props 选项的值
    const propsExpression = generatePropsExpression(arg, context);

    // 将 props 选项添加到组件选项中
    injectOptions(context, 'props', propsExpression);
  }
}

function generatePropsExpression(arg: any, context: TransformContext) {
  // ... 省略一些处理逻辑 ...
  return arg; // 直接返回参数节点
}

function injectOptions(context: TransformContext, name: string, content: any) {
  // ...  将选项注入到组件的 setupOptions 中 ...
}

这里 generatePropsExpression 函数(简化版)直接返回了 defineProps 的参数节点。 injectOptions 函数负责将这个参数节点作为 props 选项注入到组件的 setupOptions 中。

所以,对于运行时声明,编译器几乎不做任何修改,只是简单地把你的对象放到 props 选项里。

2.3 类型声明的编译

类型声明的处理就复杂一些了。 编译器需要解析 TypeScript 类型,提取出 props 的类型信息,然后生成对应的 props 选项。

// 简化后的代码片段 (来自 @vue/compiler-sfc)
function processDefineProps(node: CallExpression, context: TransformContext) {
  if (node.typeParameters) {
    const typeParam = node.typeParameters.params[0];
    // ... 省略一些类型检查和错误处理 ...

    // 解析 TypeScript 类型
    const resolvedProps = resolvePropsType(typeParam, context);

    // 生成 props 选项
    const propsExpression = generatePropsExpressionFromType(resolvedProps);

    // 将 props 选项添加到组件选项中
    injectOptions(context, 'props', propsExpression);
  }
}

function resolvePropsType(typeParam: any, context: TransformContext) {
  // ... 解析 TypeScript 类型,提取类型信息 ...
  // ...  包括类型、是否必填、默认值等 ...
}

function generatePropsExpressionFromType(resolvedProps: PropTypeResult[]) {
  // ...  将类型信息转换成 props 选项的 JavaScript 代码 ...
}

核心步骤:

  1. resolvePropsType: 这个函数负责解析 TypeScript 类型,提取出 props 的类型信息。 它会分析类型声明中的每一个属性,获取属性的类型、是否必填、是否有默认值等信息。 这个过程涉及到 TypeScript 编译器的 API 调用,比较复杂。

  2. generatePropsExpressionFromType: 这个函数接收 resolvePropsType 解析出的类型信息,然后生成对应的 JavaScript 代码,作为 props 选项的值。 例如,如果解析出一个 title: string 的 prop,它可能会生成类似 { title: { type: String, required: true } } 的代码。

类型声明编译的例子:

假设我们有这样的代码:

<script setup lang="ts">
interface Props {
  name: string;
  age?: number;
  address?: string | null;
}
const props = defineProps<Props>();
</script>

经过编译后,最终生成的 props 选项可能如下所示 (简化):

props: {
  name: {
    type: String,
    required: true
  },
  age: {
    type: Number,
    required: false
  },
  address: {
    type: [String, null],
    required: false
  }
}

总结一下,defineProps 的编译过程:

声明方式 编译过程
运行时声明 直接将参数对象作为 props 选项的值。
类型声明 1. 解析 TypeScript 类型,提取类型信息。 2. 根据类型信息生成 props 选项的 JavaScript 代码。

三、defineEmits 的编译过程

defineEmits 用于声明组件可以触发的事件。 与 defineProps 类似,它也有两种使用方式:

  1. 运行时声明 (Runtime Declaration): 传递一个字符串数组作为参数,数组包含了所有事件的名称。

    <script setup>
    const emit = defineEmits(['update:title', 'submit'])
    
    emit('update:title', 'New Title')
    </script>
  2. 类型声明 (Type Declaration): 使用 TypeScript 类型声明来定义 emits 的参数类型。

    <script setup lang="ts">
    interface Emits {
      (e: 'update:title', value: string): void
      (e: 'submit', data: any): void
    }
    
    const emit = defineEmits<Emits>()
    
    emit('update:title', 'New Title')
    </script>

3.1 源码定位

defineProps 类似,defineEmits 的编译逻辑也在 transformScriptSetup 函数中,由 processDefineEmits 函数处理。

3.2 运行时声明的编译

对于运行时声明,编译器会将字符串数组转换成 emits 选项。 这个过程非常简单,几乎就是把你的数组原封不动地放到 emits 选项里。

// 简化后的代码片段 (来自 @vue/compiler-sfc)
function processDefineEmits(node: CallExpression, context: TransformContext) {
  if (node.arguments.length > 0) {
    const arg = node.arguments[0];
    // ... 省略一些类型检查和错误处理 ...

    // 直接将参数作为 emits 选项的值
    const emitsExpression = generateEmitsExpression(arg, context);

    // 将 emits 选项添加到组件选项中
    injectOptions(context, 'emits', emitsExpression);
  }
}

function generateEmitsExpression(arg: any, context: TransformContext) {
  // ... 省略一些处理逻辑 ...
  return arg; // 直接返回参数节点
}

3.3 类型声明的编译

类型声明的处理就比较复杂了。 编译器需要解析 TypeScript 类型,提取出 emits 的事件名称和参数类型,然后生成对应的 emit 函数的类型定义。

// 简化后的代码片段 (来自 @vue/compiler-sfc)
function processDefineEmits(node: CallExpression, context: TransformContext) {
  if (node.typeParameters) {
    const typeParam = node.typeParameters.params[0];
    // ... 省略一些类型检查和错误处理 ...

    // 解析 TypeScript 类型
    const resolvedEmits = resolveEmitsType(typeParam, context);

     // 生成 emits 选项
    const emitsExpression = generateEmitsExpressionFromType(resolvedEmits);

    // 将 emits 选项添加到组件选项中 (如果能从类型中提取出事件名称)
    if (emitsExpression) {
        injectOptions(context, 'emits', emitsExpression);
    }
  }
}

function resolveEmitsType(typeParam: any, context: TransformContext) {
  // ... 解析 TypeScript 类型,提取事件名称和参数类型 ...
}

function generateEmitsExpressionFromType(resolvedEmits: EmitTypeResult[]) {
  // ...  将类型信息转换成 emits 选项的 JavaScript 代码 (如果能提取出事件名称) ...
}

核心步骤:

  1. resolveEmitsType: 这个函数负责解析 TypeScript 类型,提取出 emits 的事件名称和参数类型。 它会分析类型声明中的每一个函数签名,获取事件名称和参数类型。

  2. generateEmitsExpressionFromType: 这个函数接收 resolveEmitsType 解析出的类型信息,然后生成对应的 JavaScript 代码,作为 emits 选项的值(如果能提取出事件名称)。 如果无法从类型中提取出事件名称,则不生成 emits 选项,这意味着组件的 emits 选项将为空,Vue将不会对触发的事件进行校验。

类型声明编译的例子:

假设我们有这样的代码:

<script setup lang="ts">
interface Emits {
  (e: 'update:title', value: string): void
  (e: 'submit', data: any): void
}

const emit = defineEmits<Emits>()

emit('update:title', 'New Title')
</script>

经过编译后,最终生成的 emits 选项可能如下所示 (简化):

emits: ['update:title', 'submit']

总结一下,defineEmits 的编译过程:

声明方式 编译过程
运行时声明 直接将参数数组作为 emits 选项的值。
类型声明 1. 解析 TypeScript 类型,提取事件名称和参数类型。 2. 根据类型信息生成 emits 选项的 JavaScript 代码。 如果无法提取事件名称,则不生成 emits 选项。

四、一些注意事项

  1. 编译时错误: definePropsdefineEmits 只能在 script setup 中使用。 如果在其他地方使用,编译器会报错。

  2. 类型推断: 在使用类型声明时,如果编译器无法正确推断出类型信息,可能会导致编译错误或者运行时错误。

  3. 性能优化: 尽量使用类型声明,因为类型声明可以提供更详细的类型信息,帮助编译器进行更好的优化。

  4. propsemit 的关系: definePropsdefineEmits 只是语法糖,最终会被编译成组件的 propsemits 选项。

五、总结

今天我们一起深入探讨了 Vue 3 script setupdefinePropsdefineEmits 的编译过程。 希望通过今天的讲解,大家对这两个宏指令的底层原理有了更深入的了解。 记住,理解源码是成为大佬的第一步!

好了,今天的讲座就到这里,感谢大家的聆听! 下次有机会,我们再一起扒其他Vue 3的源码! 溜了溜了~

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注