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 个属性 attrs
、emit
、expose
、slots
。
-
attrs
,当为组件传递 props 时,那些没有显示地声明为 props 的属性会存储到attrs
对象中。 -
emit
,一个函数,用来发射(触发)自定义事件。 -
expose
,一个函数,用来显式地对外暴露组件数据。 -
slots
,组件接收到的插槽。
attrs
和 slots
都是有状态的对象,它们总是会随着组件自身的更新而更新。这意味着我们应当避免解构它们,并始终通过 attrs.x
或 slots.x
的形式使用其中的属性。同时,和 props
不同,attrs
和 slots
的属性都不是 响应式的。如果我们想要基于 attrs
或 slots
的改变来执行副作用,那么应该在 onBeforeUpdate
生命周期钩子中编写相关逻辑。
expose
函数用于显式地限制该组件暴露出的属性,当父组件通过模板引用访问该组件的实例时,将仅能访问 expose
函数暴露出的内容:
js
export default {
setup(props, { expose }) {
// 让组件实例处于 "关闭状态"
// 即不向父组件暴露任何东西
expose()
const publicCount = ref(0)
const privateCount = ref(0)
// 有选择地暴露局部状态
expose({ count: publicCount })
}
}
通常情况下,不建议将 setup
与 Vue2 中其他组件选项混合使用。例如 data
、watch
、methods
等选项,我们称之为"传统"组件选项。这是因为在 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
函数会创建一个包含了 attrs
、slots
、emit
和 expose
属性的对象
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
的形式,比如 event
是 change
,经过 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 上下文 对象中可以访问到 attrs
、emit
、expose
、slots
。
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)和组件实例中。
参考
-
《Vue.js 设计与实现》霍春阳·著