Vue3 从defineProps使用到原理分析

引言

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

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

使用方法

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

父传子Props

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

    html 复制代码
    <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种写法:(和第一种写法一样)

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

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

    html 复制代码
    <script setup>
    defineProps(['msg', 'count'])
    </script>

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

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

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

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

    html 复制代码
    <template>
      <Child msg="123" :count="123" />
    </template>

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

子传父emit

  1. 子组件中定义要向父组件传递的函数名,以及参数。(除了第一个参数是事件名,后面的全是会传递给父组件的参数)此处需要使用defineEmits这个宏定义api。

    js 复制代码
    const emit = defineEmits<{ (e: 'change', id: number): void }>()
    
    // or 不用ts写法
    const emit = defineEmits(['change'])
  2. 父组件定义接受来自子组件发送数据的函数。

    ts 复制代码
    function handleChange(id: number) {
      // 自己的逻辑
    }

    此处的参数要和defineEmits中对应上。

  3. 父组件绑定自定义事件到子组件上。

    html 复制代码
    <template>
       <Child msg="123" :count="123" @change="handleChange" />
     </template>
  4. 子组件中触发emit,父组件触发回调函数。
    Child子组件:

    html 复制代码
    <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>

    或者下面这种写法:

    html 复制代码
    <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有这么两行声明:

typescript 复制代码
export const DEFINE_PROPS = 'defineProps'
export const WITH_DEFAULTS = 'withDefaults'

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

typescript 复制代码
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 函数。

typescript 复制代码
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>
typescript 复制代码
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))
}
typescript 复制代码
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 方法对组件进行初始化。

typescript 复制代码
//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的初始化。

相关推荐
山河木马8 分钟前
前端学C++可太简单了:双冒号 :: 操作符
前端·javascript·c++
3Katrina9 分钟前
前端面试之防抖节流(二)
前端·javascript·面试
前端进阶者15 分钟前
天地图编辑支持删除编辑点
前端·javascript
江号软件分享24 分钟前
无接触服务的关键:二维码生成识别技术详解
前端
江号软件分享25 分钟前
如何利用取色器实现跨平台色彩一致性
前端
灰海29 分钟前
封装WebSocket
前端·网络·websocket·网络协议·vue
前端小巷子39 分钟前
深入理解TCP协议
前端·javascript·面试
万少40 分钟前
鸿蒙外包的十大生存法则
前端·后端·面试
顽疲1 小时前
从零用java实现 小红书 springboot vue uniapp(13)模仿抖音视频切换
java·vue.js·spring boot
江号软件分享1 小时前
有效保障隐私,如何安全地擦除电脑上的敏感数据
前端