likes
comments
collection
share

Vue3 从defineProps使用到原理分析

作者站长头像
站长
· 阅读数 27

引言

组件间的通信一直是我们开发过程中非常常见的场景,今天主要讲的是Vue3中的父子组件通信。

本篇统一采用vue3的setup语法糖 + ts写法进行说明,并结合Vue2的写法进行对照。

使用方法

假设我们有两个组件<Father /><Child />

父传子Props

  1. 在子组件中定义要接受的数据(及其类型)。

    <script setup lang="ts">
     defineProps<{ msg?: string, count: number }>()
     </script>
    

    在Vue3的setup中定义props时,我们需要使用defineProps这个宏定义api。可能有些刚接触Vue3的同学不太清楚什么叫宏定义api,简单来说,就类似于window.setTimeout一样,是全局注入的api,可以直接setTimeout()进行使用。

    当然实际上,对于defineProps的处理是在模板编译的时候处理的。

    在使用的时候,一般是3种写法, 第1种比较规范的写法,也就是如上所示,用ts的泛型来定义props需要传入的字段及其类型。

    第2种写法:(和第一种写法一样)

    <script setup>
    defineProps({
      msg: {
        type: String,
        required: false
      },
      count: {
        type: Number,
        required: true
      }
    })
    </script>
    

    第3种是不用ts的写法,如下所示:

    <script setup>
    defineProps(['msg', 'count'])
    </script>
    

    但是这种写法会没有类型提示,并且所有的参数都会变成可传的。

    对应的Vue2/3中Option API写法如下:

    <script>
     export default {
       props: ['msg', 'count']
     }
     </script>
    
  2. 在父组件中引入子组件。

    import Child from './Child.vue'
    
  3. 父组件给子组件传递所需数据。

    <template>
      <Child msg="123" :count="123" />
    </template>
    

注意prop前不写冒号:的时候,默认传递给子组件是字符串,添加冒号如:propA='123'的时候,就是两个引号中间的值(或者变量)对应的类型。

子传父emit

  1. 子组件中定义要向父组件传递的函数名,以及参数。(除了第一个参数是事件名,后面的全是会传递给父组件的参数)此处需要使用defineEmits这个宏定义api。
    const emit = defineEmits<{ (e: 'change', id: number): void }>()
    
    // or 不用ts写法
    const emit = defineEmits(['change'])
    
  2. 父组件定义接受来自子组件发送数据的函数。
    function handleChange(id: number) {
      // 自己的逻辑
    }
    
    此处的参数要和defineEmits中对应上。
  3. 父组件绑定自定义事件到子组件上。
    <template>
       <Child msg="123" :count="123" @change="handleChange" />
     </template>
    
  4. 子组件中触发emit,父组件触发回调函数。 Child子组件:
    <template>
      <div>
        <button @click="handleClick">Change</button>
      </div>
    </template>
    <script setup lang="ts">
    const emit = defineEmits<{ (e: 'change', id: number): void }>()
    function handleClick() {
      emit('change', 123)
    }
    </script>
    
    或者下面这种写法:
    <template>
      <div>
        <button @click="$emit('change', 123)">Change</button>
      </div>
    </template>
    
    注意:在template中直接使用emit的时候,需要加上$也就是$emit()

原理分析

那我们在使用这些宏定义的API,如definePropsdefineEmits的时候有没有想一下他们是如何实现的呢,通过源码我们来举例分析一下:

模板编译

既然是宏定义API,我们书写的时候不需要引入或者声明这个方法,那说明要么是在使用之前注入的,要么就是在模板编译的时候识别到代码进行转换的,我们带着问题去看看Vue的源码中是如何实现的。

首先我们全局搜索一下defineProps,发现在packages/compiler-sfc/src/script/defineProps.ts有这么两行声明:

export const DEFINE_PROPS = 'defineProps'
export const WITH_DEFAULTS = 'withDefaults'

同样在这个文件中,有两个函数叫做processDefinePropsprocessWithDefaults引用了这个宏变量。

export function processDefineProps(
  ctx: ScriptCompileContext,
  node: Node,
  declId?: LVal
) {
  if (!isCallOf(node, DEFINE_PROPS)) {
    return processWithDefaults(ctx, node, declId)
  }

  if (ctx.hasDefinePropsCall) {
    ctx.error(`duplicate ${DEFINE_PROPS}() call`, node)
  }
  ctx.hasDefinePropsCall = true
  ctx.propsRuntimeDecl = node.arguments[0]

  // register bindings
  if (ctx.propsRuntimeDecl) {
    for (const key of getObjectOrArrayExpressionKeys(ctx.propsRuntimeDecl)) {
      if (!(key in ctx.bindingMetadata)) {
        ctx.bindingMetadata[key] = BindingTypes.PROPS
      }
    }
  }

  // call has type parameters - infer runtime types from it
  if (node.typeParameters) {
    if (ctx.propsRuntimeDecl) {
      ctx.error(
        `${DEFINE_PROPS}() cannot accept both type and non-type arguments ` +
          `at the same time. Use one or the other.`,
        node
      )
    }
    ctx.propsTypeDecl = node.typeParameters.params[0]
  }

  if (declId) {
    // handle props destructure
    if (declId.type === 'ObjectPattern') {
      processPropsDestructure(ctx, declId)
    } else {
      ctx.propsIdentifier = ctx.getString(declId)
    }
  }

  return true
}

大家可以看到这段代码其实就是在解析我们在代码中声明的defineProps,另外个函数就是处理withDefault。这个函数又是在packages/compiler-sfc/src/compileScript.ts 中的 compileScript函数中调用的。

这个函数上面有一段注释: * Compile <script setup> * It requires the whole SFC descriptor because we need to handle and merge * normal <script> + <script setup> if both are present.

其实大家不用看代码也能通过注释和函数名知道,这个函数就是在使用setup语法糖的时候解析我们的代码。从而去判断我们代码中使用的宏定义,并生成相关的数据。(这里有诸多模板编译的一些知识,就不再继续深入讨论)。

转化成虚拟DOM

在模板编译的过程中,会经历一系列的变化,最终转换成虚拟DOM也就是VNode模板 -> 模板AST -> Javascript AST -> 代码生成 -> 渲染函数 -> VNode

组件实例化和渲染

在上一步被编译成VNode之后,我们就会执行组件的实例化。在这个过程中,Vue会将父组件传递的属性值赋值给子组件的props

我们都知道在Vue3中会使用统一的创建函数createApp对我们的App进行创建和挂载。其实createApp中主要是调用ensureRenderer函数,而ensureRenderer实际上真正调用的是createRenderer最终执行的是baseCreateRenderer 函数。

export const createApp = ((...args) => {
  const app = ensureRenderer().createApp(...args)

  const { mount } = app
  app.mount = (containerOrSelector: Element | string): any => {
    const container = normalizeContainer(containerOrSelector)
    if (!container) return
    const component = app._component
    if (!isFunction(component) && !component.render && !component.template) {
      component.template = container.innerHTML
    }
    // clear content before mounting
    container.innerHTML = ''
    const proxy = mount(container)
    container.removeAttribute('v-cloak')
    return proxy
  }

  return app
}) as CreateAppFunction<Element>
const rendererOptions = {
  patchProp,  // 处理 props 属性 
  ...nodeOps // 处理 DOM 节点操作
}

// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer: Renderer | HydrationRenderer

let enabledHydration = false

function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions))
}
export function createRenderer<
  HostNode = RendererNode,
  HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
  return baseCreateRenderer<HostNode, HostElement>(options)
}

这里可能有很多函数,但是不重要,大家只需要记住createApp -> baseCreateRenderer这个结果

packages/runtime-core/src/renderer.ts中的baseCreateRenderer函数中,如果识别到执行的是一个组件,那么就会调用名为mountComponent的内部函数,最终执行 setupComponent 方法对组件进行初始化。

//packages/runtime-core/src/component.ts
export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  isInSSRComponentSetup = isSSR

  const { props, children } = instance.vnode
  const isStateful = isStatefulComponent(instance)
  initProps(instance, props, isStateful, isSSR)
  initSlots(instance, children)

  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined
  isInSSRComponentSetup = false
  return setupResult
}

我们可以看到,最终在setupComponent 中完成了对Props的初始化。