深入理解 vue3 组件的 setup 与 emit

Vue3 组件的 setup

setup 函数的作用

setup 函数(钩子)是 Vue 组件组合式 API 的入口,用户可以在 setup 函数内建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等。在组件的整个生命周期中,setup 函数只会在被挂载时执行一次。setup 函数的返回值有两种情况。

(1) 结合 Vue 的 h 函数,返回一个函数,该函数将作为组件的 render 函数

js 复制代码
import { h, ref } from 'vue'

export default {
  setup() {
    const count = ref(0)
    return () => h('div', count.value)
  }
}

这种方式用于组件不是以模板来表达其渲染内容的情况。如果 setup 函数返回一个渲染函数,仍然采用模板表达其渲染内容的话,模板内容会被忽略。

(2) 返回一个对象,该对象中包含的数据将暴露给模板使用。

html 复制代码
<script src="../../dist/vue.global.js"></script>

<script>
const Demo = {  
  setup() {
    const title = Vue.ref('诗经')
    return {
      title
    }
  },
  render() {
    return Vue.h('div', `title is: ${this.title}`)
  }  
}
</script>

<div id="demo">
  <demo />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  },   
}).mount('#demo')
</script>

👆 setup 函数暴露的数据可以在渲染函数中通过 this 来访问。因为在执行组件的 render 函数时会指定 render 函数的 this 为公共实例代理(public instance proxy ),访问该代理对象时,会去 setup 函数的返回值(setupState)中查找是否有该属性,如果有,则返回 setup 函数的返回值(setupState)中取得的值。

ts 复制代码
// packages/runtime-core/src/componentRenderUtils.ts

export function renderComponentRoot(
  instance: ComponentInternalInstance
): VNode {
  const {
    proxy, // 公共实例代理
    render
  } = instance

  
  const proxyToUse = proxy
  let result
  result = normalizeVNode(
    // 执行组件的 render 函数
    render!.call(
      proxyToUse,
      proxyToUse!
    )
  )
}
ts 复制代码
// packages/runtime-core/src/component.ts

function setupStatefulComponent(
  instance: ComponentInternalInstance,
  isSSR: boolean
) {
  // 公共实例代理
  instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
}
ts 复制代码
// packages/runtime-core/src/componentPublicInstance.ts

export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
  get({ _: instance }: ComponentRenderContext, key: string) {
    const { setupState } = instance    
    if (hasSetupBinding(setupState, key)) {
      return setupState[key]
    }
  }
}

hasSetupBinding 函数用于判断对应的 key 是否存在相关的对象中,例如上面的代码,就是判断 key 是否存在 setupState 对象中

setupState 是组件实例(instance)的一个属性,存储了 setup 函数的返回值。

ts 复制代码
// packages/runtime-core/src/component.ts

export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean
) {
  if (isFunction(setupResult)) {
    // ...
  } else if (isObject(setupResult)) {
    instance.setupState = proxyRefs(setupResult)
  }  
}

如果 setup 函数返回一个渲染函数,仍然采用模板表达其渲染内容的话,模板内容会被忽略。另外一种类似的情况,如果组件中 setup 函数返回一个渲染函数,但是同时注册了 render 选项,则组件也会优先使用 setup 函数返回的渲染函数,而忽略 render 选项。

html 复制代码
<script src="../../dist/vue.global.js"></script>

<script>
const Demo = {  
  setup() {
    const title = Vue.ref('诗经')
    return () => {
      return Vue.h('div', `title: ${title.value}`)
    }
  },
  render() {
    return Vue.h('div', `title is: ${this.title}`)
  }  
}
</script>

<div id="demo">
  <demo />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  },   
}).mount('#demo')
</script>

另外,setup 函数接收两个参数。setup 函数的第一个参数是组件的 props ,第二个参数是 Setup 上下文 (setupContext)。因此在 setup 函数中很容易访问组件的 props 数据。setup 函数中的 props 是响应式的,并且会在传入新的 props 时同步更新。

js 复制代码
export default {
  props: {
    title: String
  },
  setup(props) {
    console.log(props.title)
  }
}

如果结构了 props 对象,解构出的变量将会失去响应性。因此我们推荐通过 props.xxx 的形式来使用其中的 props。

如果确实需要解构 props 对象,或者需要将某个 prop 传到一个外部函数中并保持响应性,那么可以使用 toRefs()toRef() 这两个工具函数:

js 复制代码
import { toRefs, toRef } from 'vue'

export default {
  setup(props) {
    // 将 `props` 转为一个其中全是 ref 的对象,然后解构
    const { title } = toRefs(props)
    // `title` 是一个追踪着 `props.title` 的 ref
    console.log(title.value)

    // 或者,将 `props` 的单个属性转为一个 ref
    const title = toRef(props, 'title')
  }
}

传入 setup 函数的第二个参数是一个Setup 上下文对象。

html 复制代码
<script src="../../dist/vue.global.js"></script>

<script type="text/x-template" id="grid-demo">
  <div>
    hello, {{ title }}<br>
    age, {{ age }}
  </div>
</script>
<script>
const Demo = {
  template: '#grid-demo',
  props: {
    age: {
      type: Number,
      default: 18
    }
  },
  setup(props, context) {
    const title = Vue.ref('world')
    return {
      title
    }
  }
}
</script>

<div id="demo">
  <demo />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  },   
}).mount('#demo')
</script>

Setup 上下文 对象有 4 个属性 attrsemitexposeslots

  • attrs,当为组件传递 props 时,那些没有显示地声明为 props 的属性会存储到 attrs 对象中。

  • emit,一个函数,用来发射(触发)自定义事件。

  • expose,一个函数,用来显式地对外暴露组件数据。

  • slots,组件接收到的插槽。

attrsslots 都是有状态的对象,它们总是会随着组件自身的更新而更新。这意味着我们应当避免解构它们,并始终通过 attrs.xslots.x 的形式使用其中的属性。同时,和 props 不同,attrsslots 的属性都不是 响应式的。如果我们想要基于 attrsslots 的改变来执行副作用,那么应该在 onBeforeUpdate 生命周期钩子中编写相关逻辑。

expose 函数用于显式地限制该组件暴露出的属性,当父组件通过模板引用访问该组件的实例时,将仅能访问 expose 函数暴露出的内容:

js 复制代码
export default {
  setup(props, { expose }) {
    // 让组件实例处于 "关闭状态"
    // 即不向父组件暴露任何东西
    expose()

    const publicCount = ref(0)
    const privateCount = ref(0)
    // 有选择地暴露局部状态
    expose({ count: publicCount })
  }
}

通常情况下,不建议将 setup 与 Vue2 中其他组件选项混合使用。例如 datawatchmethods 等选项,我们称之为"传统"组件选项。这是因为在 Vue3 的场景下,更加提倡组合式 API,setup 函数就是为组合式 API 而生的。混用组合式 API 的 setup 选项与"传统"组件选项会带来语义和理解上的负担。

setup 函数的源码分析

html 复制代码
<script src="../../dist/vue.global.js"></script>

<script type="text/x-template" id="grid-demo">
  <div>
    hello, {{ title }}<br>
    age, {{ age }}
  </div>
</script>
<script>
const Demo = {
  template: '#grid-demo',
  props: {
    age: {
      type: Number,
      default: 18
    }
  },
  setup(props, context) {
    const title = Vue.ref('world')
    return {
      title
    }
  }
}
</script>

<div id="demo">
  <demo />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  },   
}).mount('#demo')
</script>

通过断点调试,可以知道 setup 函数相关的函数调用栈,我们可以在函数调用栈中找到 Vue3 内部实现 setup 函数相关的源码。

ts 复制代码
// packages/runtime-core/src/component.ts

export function setupComponent(
  instance: ComponentInternalInstance,
  isSSR = false
) {
  isInSSRComponentSetup = isSSR

  const { props, children } = instance.vnode
  // 通过组件实例判断当前组件是否为有状态组件
  const isStateful = isStatefulComponent(instance)
  // 初始化 props
  initProps(instance, props, isStateful, isSSR)
  // 初始化 slots
  initSlots(instance, children)

  // 获取 setup 函数执行后的返回值
  const setupResult = isStateful
    ? setupStatefulComponent(instance, isSSR)
    : undefined
  isInSSRComponentSetup = false
  return setupResult
}

本文中的源码均摘自 Vue.js 3.2.45,为了方便理解,会省略与本文主题无关的代码

ts 复制代码
// packages/runtime-core/src/component.ts

function setupStatefulComponent(
  instance: ComponentInternalInstance,
  isSSR: boolean
) {
  const Component = instance.type as ComponentOptions
  // 省略其他代码

  // 获取用户注册的 setup 函数
  const { setup } = Component
  if (setup) {
    // 创建 setup 上下文对象
    const setupContext = (instance.setupContext =
      setup.length > 1 ? createSetupContext(instance) : null)
    // ...
    // 调用 setup 函数,取得 setup 函数返回的结果
    const setupResult = callWithErrorHandling(
      setup,
      instance,
      ErrorCodes.SETUP_FUNCTION,
      // 将组件的 props 、setup 上下文对象(setupContext)作为 setup 函数的参数
      [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
    )
    // ...

    handleSetupResult(instance, setupResult, isSSR)
  }
}

callWithErrorHandling 函数是 Vue3 执行用户传入代码的公共函数,它封装了 try catch 语句,传入该函数的函数会被包裹在 try catch 语句中执行。有关 callWithErrorHandling 函数更多的详细信息可见笔者写的另外一篇文章 深入源码,剖析 Vue3 是如何做错误处理的

createSetupContext 函数用于创建 Setup 上下文 对象。createSetupContext 函数会创建一个包含了 attrsslotsemitexpose 属性的对象

ts 复制代码
export function createSetupContext(
  instance: ComponentInternalInstance
): SetupContext {
  // 创建 expose 函数
  const expose: SetupContext['expose'] = exposed => {
    if (__DEV__ && instance.exposed) {
      warn(`expose() should be called only once per setup().`)
    }
    instance.exposed = exposed || {}
  }

  let attrs: Data
  if (__DEV__) {
    // We use getters in dev in case libs like test-utils overwrite instance
    // properties (overwrites should not be done in prod)
    return Object.freeze({
      get attrs() {
        return attrs || (attrs = createAttrsProxy(instance))
      },
      get slots() {
        return shallowReadonly(instance.slots)
      },
      get emit() {
        return (event: string, ...args: any[]) => instance.emit(event, ...args)
      },
      expose
    })
  } else {
    return {
      get attrs() {
        return attrs || (attrs = createAttrsProxy(instance))
      },
      slots: instance.slots,
      emit: instance.emit,
      expose
    }
  }
}

调用 setup 函数后,得到 setup 函数的返回结果 setupResult 会传入 handleSetupResult 函数处理。

ts 复制代码
// packages/runtime-core/src/component.ts

export function handleSetupResult(
  instance: ComponentInternalInstance,
  setupResult: unknown,
  isSSR: boolean
) {
  if (isFunction(setupResult)) {
    // setup 函数的返回值是函数
    if (__SSR__ && (instance.type as ComponentOptions).__ssrInlineRender) {
      
      instance.ssrRender = setupResult
    } else {
      instance.render = setupResult as InternalRenderFunction
    }
  } else if (isObject(setupResult)) {
    // setup 函数的返回值是对象
    instance.setupState = proxyRefs(setupResult)
  }
}

如果 setup 函数的返回值是函数,则将其作为渲染函数,如果 setup 函数的返回值是对象,则作为数据状态存入组件实例(instance)的 setupState 属性中。

Vue3 组件的 emit

emit 用于发射组件的自定义事件。通过 v-on 指令(简写为 @)为组件绑定的事件在经过编译后,会以 onXXX 的形式存储到 props 对象中,比如,组件 emit 了一个 change 事件,则会在组件的 props 对象中找到 onChange 属性,该属性存储了 change 事件的处理函数。组件自定义事件的原理就是当 emit 函数执行时,会在 props 对象中寻找对应的事件处理函数并执行它。

如下面代码所示:

js 复制代码
const MyComponent = {
  name: 'MyComponent',
  setup(props, { emit }) {
    // 发射 change 事件,并传递给事件处理函数两个参数
    emit('change', 1, 2)

    return () => {
      return // ...
    }
  }
}

当使用该组件时,我们可以监听由 emit 函数发射的自定义事件:

html 复制代码
<MyComponent @change="handler" />

上面这段模板对应的虚拟 DOM 为:

js 复制代码
const CompVNode = {
  type: MyComponent,
  props: {
    onChange: handler
  }
}

可以看到,自定义事件 change 被编译成名为 onChange 的属性,并存储在 props 数据对象中。这实际上是一种约定。作为框架设计者,也可以按照自己期望的方式来设计事件的编译结果。

和原生 DOM 事件不同,组件触发的事件没有冒泡机制,我们只能监听直接子组件触发的事件。平级组件或是跨越多层嵌套的组件间通信,应使用一个外部的事件总线,或是使用一个全局状态管理方案

Vue3 中 emit 函数的实现如下

ts 复制代码
// packages/runtime-core/src/componentEmits.ts

export function emit(
  instance: ComponentInternalInstance,
  event: string,
  ...rawArgs: any[]
) {
  // 如果组件未挂载,则直接返回
  if (instance.isUnmounted) return
  // 从 vnode 中获取 props 属性
  const props = instance.vnode.props || EMPTY_OBJ

  let handlerName

  // 从 props 对象中找到 emit 对应的事件处理函数
  let handler =
    props[(handlerName = toHandlerKey(event))] ||
    props[(handlerName = toHandlerKey(camelize(event)))]
    
  if (handler) {
    // 执行找到的事件处理函数
    callWithAsyncErrorHandling(
      handler,
      instance,
      ErrorCodes.COMPONENT_EVENT_HANDLER,
      args
    )
  }
}

本文所有 Vue.js 的源码均摘自 3.2.45 版本 ,为了方便理解,会省略与本文主题无关的代码

callWithAsyncErrorHandling 函数是 Vue3 用于处理异步异常的错误处理函数,会执行传入的函数,如果传入的函数发生异常,会被捕获。有关该函数更多的信息可查看笔者的另一篇文章 深入源码,剖析 Vue3 是如何做错误处理的

toHandlerKey 函数用于将事件名(event)转成 onXXX 的形式,比如 eventchange ,经过 toHandlerKey 函数处理后会转成 onChange

camelize 函数则用于将短横线转成驼峰的形式。

ts 复制代码
// packages/shared/src/index.ts

// 利用闭包来对传入的函数的执行结果缓存起来的公共函数
const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null)
  return ((str: string) => {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }) as T
}

// 将传入的字符串首字母转成大写的
export const capitalize = cacheStringFunction(
  (str: string) => str.charAt(0).toUpperCase() + str.slice(1)
)

export const toHandlerKey = cacheStringFunction((str: string) =>
  str ? `on${capitalize(str)}` : ``
)

const camelizeRE = /-(\w)/g
export const camelize = cacheStringFunction((str: string): string => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})

如下面的例子,我们在 emit 函数中打上断点,可以看到模板上的 search-person 已被编译成 onSearchPerson ,并保存到 props 属性中。

html 复制代码
<script src="../../dist/vue.global.js"></script>

<script type="text/x-template" id="grid-demo">
  <div>
    title is {{ title }}
    <div>
      <button
        @click="handleClick">click me</button>
    </div>
  </div>
</script>
<script>
const Demo = {  
  template: '#grid-demo',
  setup(props, ctx) {
    const title = Vue.ref('诗经')
    const handleClick = () => {
      console.error('handleClick')
      ctx.emit('searchPerson')
    }
    return {
      title,
      handleClick
    }
  }
}
</script>

<div id="demo">
  <demo
    @search-person="handleSearch"
  />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  },
  setup() {
    const handleSearch = () => {
      console.error('handleSearch')
    }
    return {
      handleSearch
    }
  }
}).mount('#demo')
</script>

emit 函数会被包含在 Setup 上下文(setupContext)和组件实例中。

👆 createSetupContext 函数用于创建 Setup 上下文,位置在 packages/runtime-core/src/component.ts

👆 createComponentInstance 函数用于创建组件实例,位置在 packages/runtime-core/src/component.ts

总结

setup 函数(钩子)是 Vue 组件组合式 API 的入口,用户可以在 setup 函数内建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等。在组件的整个生命周期中,setup 函数只会在被挂载时执行一次。setup 函数的返回值有两种情况,一种为函数,另一种为对象,返回值为函数的话,会作为组件的渲染函数,返回值为对象的话,该对象会暴露给模板使用。

setup 函数接收两个参数,第一个参数为组件的 props ,第二个参数为 Setup 上下文 对象。在 setup 函数中可以很容易访问到组件的 props 数据,同时 Setup 上下文 对象中可以访问到 attrsemitexposeslots

js 复制代码
export default {
  setup(props, context) {
    // 透传 Attributes(非响应式的对象,等价于 $attrs)
    console.log(context.attrs)

    // 插槽(非响应式的对象,等价于 $slots)
    console.log(context.slots)

    // 触发事件(函数,等价于 $emit)
    console.log(context.emit)

    // 暴露公共属性(函数)
    console.log(context.expose)
  }
}

在 Vue3 内部会取得用户注册的 setup 函数,然后会将组件的 props 和创建 Setup 上下文 对象传入 setup 函数中,调用完 setup 函数后取得 setup 函数的返回值,如果返回值是函数,则会当做组件的渲染函数,如果 setup 函数的返回值是对象,则会将该对象保存到组件实例中(instance.setupState)。

emit 用于发射组件的自定义事件。通过 v-on 指令(简写为 @)为组件绑定的事件在经过编译后,会以 onXXX 的形式存储到 props 对象中,比如,组件 emit 了一个 change 事件,则会在组件的 props 对象中找到 onChange 属性,该属性存储了 change 事件的处理函数。组件自定义事件的原理就是当 emit 函数执行时,会在 props 对象中寻找对应的事件处理函数并执行它

同时,emit 函数会包含在 setup 上下文(setupContext)和组件实例中。

参考

  1. 组合式 API:setup()

  2. 组件事件

  3. 《Vue.js 设计与实现》霍春阳·著

相关推荐
熊的猫25 分钟前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
瑶琴AI前端1 小时前
uniapp组件实现省市区三级联动选择
java·前端·uni-app
会发光的猪。1 小时前
如何在vscode中安装git详细新手教程
前端·ide·git·vscode
mosen8681 小时前
Uniapp去除顶部导航栏-小程序、H5、APP适用
vue.js·微信小程序·小程序·uni-app·uniapp
别拿曾经看以后~2 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
我要洋人死2 小时前
导航栏及下拉菜单的实现
前端·css·css3
科技探秘人2 小时前
Chrome与火狐哪个浏览器的隐私追踪功能更好
前端·chrome
科技探秘人2 小时前
Chrome与傲游浏览器性能与功能的深度对比
前端·chrome
JerryXZR2 小时前
前端开发中ES6的技术细节二
前端·javascript·es6
七星静香2 小时前
laravel chunkById 分块查询 使用时的问题
java·前端·laravel