基本使用
上次说了下scriptSetup的编译过程,最后结论是会编译成有setup选项的组件对象,然后这次就来说一下重点,setup()这个选项到底做了什么,国际惯例我们还是先看一下文档:
符合我们的认知,而且用法有点类似于data选项,但是setup里可以访问props和一些其他的上下文,如果对react有了解的小伙伴会发现这玩意很像react的函数式组件,但有个很大的不同,react的函数式组件最终返回的是render函数,但setup的返回值是模版能访问到的上下文。不过setup也可以返回渲染函数,这种情况下其实我们就不需要模版了:
了解vnode:
在vue中,我们书写的模版,最终会被转换为vnode,也就是虚拟DOM,虚拟DOM是对html模版的描述,比如<p>hellowrold<p/>,对应的虚拟DOM就类似于
bash
{
type:'p',
children: [
{
type:'text',
content:'helloworld'
}
]
}
vnode类型有这么几种:
1.文本,这个很好理解,就是标签中是纯文本,类似于这种<p>hellowrold<p/>,其中的helloworld就是文本类型
2.注释类型,和文本差不多
3.fragment类型:我们知道vue3是允许在组件有多个根节点的,而vue2只有一个,vue3中把这种组件有多个根节点的模版类型叫做一个fragment,也就是"片段"。
4.普通元素类型,也就是div,p等原生浏览器标签,比如上面的例子。
5.组件类型,也就是我们要说的,如果一个vnode值组件类型,那么他的对应的类型就是组件选项对象,而一般的vnode,类型都是一个string,比如"p","div","comment","text", 渲染他们时就是根据虚拟DOM创建真实DOM。
组件的不同之处在于,组件会有"状态",也就是我们定义的响应式变量,所以组件的渲染要使用响应式数据作为上下文来执行render生成vnode,才能执行渲染,如果组件生产的Vnode又发现了组件类型,就进入了递归过程,一直渲染到没有children的节点为止,这也就解释了为什么组件的mount钩子执行是从父组件一直层层执行到最底层的子组件,mounted钩子会从子组件一直执行到最顶层父组件,因为父组件的mount过程一直要到所有子组件挂载完成才会终止。
另外fragment算是比较特殊一种vnode,fragment类型不会有针对他们自身vnode的处理,他只是类似于一个"代理vnode",实际上要渲染的是他的children。
组件渲染
前面我们大致了解了vnode,那么我们这里通过调试一个组件类型的vnode挂载来了解setup,也就是我们前几篇文章中出镜的heloworld组件,我用它来代表普通组件的挂载流程:
图中的n2是对vnode做的一层封装,这个时候还没有执行helloworld组件对应的render得到真正的vnode,所以很多属性都是null,而n2的type就是helloworld组件对应的组件选项。
mountComponent就是组件挂载的全部了,这个函数的内容比较繁琐,抽象出来就是做了三件事:
scss
// 伪代码
// 创建一个初始组件实例
const instance = createComponentInstance()
// 设置组件实例,比如我们定义了data,会把data代理到组件实例上,等等一系列设置,也包含了setup的执行和相关逻辑的处理
setupComponent(instance)
// 设置响应式的渲染函数,响应式变量更新后能通知render重新执行
setupRenderEffect()
创建组件选项
首先来看第一步,createComponentInstance,这一步其实就是初始化一个空壳的组件实例,我们知道vue的组件实例上有很多属性,函数执行时会通过对象字面量的方式创建一个组件实例,我这里直接把源码贴出来:
less
export function createComponentInstance(
vnode: VNode,
parent: ComponentInternalInstance | null,
suspense: SuspenseBoundary | null
) {
// 就是上面说的n2的type,也就是组件的组件选项
const type = vnode.type as ConcreteComponent
// inherit parent app context - or - if root, adopt from root vnode
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext
const instance: ComponentInternalInstance = {
uid: uid++,
vnode,
// type
type,
parent,
appContext,
root: null!, // to be immediately set
next: null,
subTree: null!, // will be set synchronously right after creation
effect: null!,
update: null!, // will be set synchronously right after creation
scope: new EffectScope(true /* detached */),
render: null,
proxy: null,
exposed: null,
exposeProxy: null,
withProxy: null,
provides: parent ? parent.provides : Object.create(appContext.provides),
accessCache: null!,
renderCache: [],
// local resolved assets
components: null,
directives: null,
// resolved props and emits options
propsOptions: normalizePropsOptions(type, appContext),
emitsOptions: normalizeEmitsOptions(type, appContext),
// emit
emit: null!, // to be set immediately
emitted: null,
// props default value
propsDefaults: EMPTY_OBJ,
// inheritAttrs
inheritAttrs: type.inheritAttrs,
// state
// 这些属性就是我们在组件选项里定义的状态,也就是"state"
ctx: EMPTY_OBJ,
data: EMPTY_OBJ,
props: EMPTY_OBJ,
attrs: EMPTY_OBJ,
slots: EMPTY_OBJ,
refs: EMPTY_OBJ,
setupState: EMPTY_OBJ,
setupContext: null,
attrsProxy: null,
slotsProxy: null,
// suspense related
suspense,
suspenseId: suspense ? suspense.pendingId : 0,
asyncDep: null,
asyncResolved: false,
// lifecycle hooks
// 生命周期钩子
// not using enums here because it results in computed properties
isMounted: false,
isUnmounted: false,
isDeactivated: false,
bc: null,
c: null,
bm: null,
m: null,
bu: null,
u: null,
um: null,
bum: null,
da: null,
a: null,
rtg: null,
rtc: null,
ec: null,
sp: null
}
// 只需要看else分支就可以,__DEV__分支为了访问我们在开发时期篡改组件实例上的$props等公共属性,
// 把他们做了一层只读代理,生产环境因为代码已经写好了,所以就不需要保护公共属性了
if (__DEV__) {
instance.ctx = createDevRenderContext(instance)
} else {
// 组件实例等ctx会代理到组件实例本身
instance.ctx = { _: instance }
}
// 根组件
instance.root = parent ? parent.root : instance
// 绑定emit方法,这样可以使用$emit
instance.emit = emit.bind(null, instance)
// apply custom element special handling
if (vnode.ce) {
vnode.ce(instance)
}
return instance
}
可以根据注释大概看一下都做了什么事情,就是return了一个巨大的对象,并不复杂
组件实例的代理
接下来就是setupComponet:
javascript
export function setupComponent(
// 这里就是我们之前创建的空壳instance
instance: ComponentInternalInstance,
isSSR = false
) {
isInSSRComponentSetup = isSSR
// 从组件实例上拿到props和children
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
}
首先需要判断组件是不是一个stateful的组件,也就是判断组件是不是"有状态",那么怎么判断呢,如果按照一般的想法肯定是会判断组件上的某个值是不是等于某个字符或者标记之类的,但vue为了性能使用了位运算。首先需要判断组件的vnode是什么类型的,vue使用shapeFlag这个字段来判断,如果vnode的type是string类型,也就意味着是一个普通标签,比如"div","p",如果是组件那么type就是我们的组件选项对象,肯定是个object了,当然还有特殊的函数类型的组件,suspense和teleport我们可以忽略。
go
// encode the vnode type information into a bitmap
const shapeFlag = isString(type)
? ShapeFlags.ELEMENT
: __FEATURE_SUSPENSE__ && isSuspense(type)
? ShapeFlags.SUSPENSE
: isTeleport(type)
? ShapeFlags.TELEPORT
: isObject(type)
? ShapeFlags.STATEFUL_COMPONENT
: isFunction(type)
? ShapeFlags.FUNCTIONAL_COMPONENT
: 0
这里说一下vue的位运算是怎么算的,在vue的shapeFlag中,每个状态都有二进制中的一位表示,比如1表示a状态,10表示b状态,100表示c,依次类推,如果是相同的状态,按位与后会依然返回true,比如100 & 100,但如果是不同的状态,比如100 & 010,同为true时才会返回true,第一位1和第一位0,返回了false,也就是0,每一位都进行&运算,最后就会得到0,也就是false,十分巧妙。
说回最终生成的vnode,其中的shapeFlag就会变成STATEFUL_COMPONENT,换算成二进制是100,判断是不是"stateful" 就让100 & 100,会发现依然是100,也就是true。
再往下就是initProps和initSlots,我们知道setup在执行时是可以访问props和slots的,所以在这之前我们需要把组件选项上的props和slots更新到组件实例上。
然后后面就是最重要的,如果我们是有状态组件那么就会执行setupStatefulComponent,我精简了一下源码,大概是这样:
setup执行
javascript
function setupStatefulComponent(
instance: ComponentInternalInstance,
isSSR: boolean
) {
const Component = instance.type as ComponentOptions
// 0. create render proxy property access cache
// 创建proxy缓存
instance.accessCache = Object.create(null)
// 1. create public instance / render proxy
// also mark it raw so it's never observed
// 创建组件选项代理
instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
// 2. call setup()
// 调用
const { setup } = Component
if (setup) {
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
// 处理setup返回值
handleSetupResult(instance, setupResult, isSSR)
} else {
// 如果没有setup选项
finishComponentSetup(instance, isSSR)
}
}
我们一条一条说,第一步创建一个代理缓存,我们后面会说这个代理缓存到底起什么作用
然后就是创建代理了,我们前面知道instance.ctx其实就是{ :instance },我们先看一下get的实现,get的第一个参数解构了ctx的"",然后起了一个别名instance,实际上代理的还是instance。这块还是稍微有点绕的。
但是这个代理的实现还是很重要的,他决定了我们怎么访问组件实例上的属性,我们日常开发,对组件实例的访问频率还是很高的,所以我这里贴出来了精简后的PublicInstanceProxyHandlers的源码
kotlin
const PublicInstanceProxyHandlers = {
get ({ _: instance }, key) {
const { ctx, setupState, data, props, accessCache, type, appContext } = instance
if (key[0] !== '$') {
// setupState / data / props / ctx
// 渲染代理的属性访问缓存中
const n = accessCache[key]
if (n !== undefined) {
// 从缓存中取
switch (n) {
case 0: /* SETUP */
return setupState[key]
case 1 :/* DATA */
return data[key]
case 3 :/* CONTEXT */
return ctx[key]
case 2: /* PROPS */
return props[key]
}
}
else if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
accessCache[key] = 0
// 从 setupState 中取数据
return setupState[key]
}
else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
accessCache[key] = 1
// 从 data 中取数据
return data[key]
}
else if (
type.props &&
hasOwn(normalizePropsOptions(type.props)[0], key)) {
accessCache[key] = 2
// 从 props 中取数据
return props[key]
}
else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
accessCache[key] = 3
// 从 ctx 中取数据
return ctx[key]
}
else {
// 都取不到
accessCache[key] = 4
}
}
const publicGetter = publicPropertiesMap[key]
let cssModule, globalProperties
// 公开的 $xxx 属性或方法
if (publicGetter) {
return publicGetter(instance)
}
else if (
(cssModule = type.__cssModules) &&
(cssModule = cssModule[key])) {
return cssModule
}
else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
// 用户自定义的属性,也用 `$` 开头
accessCache[key] = 3
return ctx[key]
}
else if (
// 全局定义的属性
((globalProperties = appContext.config.globalProperties),
hasOwn(globalProperties, key))) {
return globalProperties[key]
}
else if ((process.env.NODE_ENV !== 'production') &&
currentRenderingInstance && key.indexOf('__v') !== 0) {
if (data !== EMPTY_OBJ && key[0] === '$' && hasOwn(data, key)) {
// 如果在 data 中定义的数据以 $ 开头,会报警告,因为 $ 是保留字符,不会做代理
warn(`Property ${JSON.stringify(key)} must be accessed via $data because it starts with a reserved ` +
`character and is not proxied on the render context.`)
}
else {
// 在模板中使用的变量如果没有定义,报警告
warn(`Property ${JSON.stringify(key)} was accessed during render ` +
`but is not defined on instance.`)
}
}
}
}
可以看下这个对组件实例的get的代理到底做了什么,首先第一行,如果要访问的不是$开头,也就意味着是不是内部属性。这时候会优先从代理缓存中取出key对应的类型然后访问对应的对象key,如果没有这个代理的话,每次我们访问组件实例,都要去走if-else判断key到底在哪里,就造成了性能的浪费,所以上面声明的代理缓存的作用就体现在这里。
我们在代理中还可以发现,虽然我们这里还没有看到setup执行, 不过这里的setupState就是setup的返回值,可以看到访问值的时候总是会优先访问setupState,这也就解释了setup返回值优先级高的是为什么,当然如果没有的话就会相应的从data,props,最后是ctx,也就是组件选项上去找。
前面都是讨论不以 <math xmlns="http://www.w3.org/1998/Math/MathML"> 开头的情况,如果 开头的情况,如果 </math>开头的情况,如果开头,那么会去找是不是公共属性,比如 <math xmlns="http://www.w3.org/1998/Math/MathML"> e l , el, </math>el,props,$refs等等,如果还找不到那么就看是不是用户定义的属性,然后是全局属性,然后找不到就会报错,层层递进访问ctx上的数据。
执行setup
从组件选项上拿到setup之后,执行setup之前要先判断一下开发者写的setup是不是有第二个参数,如果有那么需要给把setup的第二个参数计算一下,计算出来的就是setupContext,这第二个参数我们很熟悉,内容就是{ emit, slots, attrs , expose}。
至于后面的callWithErrorHandling熟悉vue的小伙伴都知道这个函数也是常客了,其实就是调用函数时包装了一层错误处理。得到setupResult之后,会走到handleSetupResult,我们看一下他的源码:
scss
function handleSetupResult(instance, setupResult) {
if (isFunction(setupResult)) {
// setup 返回渲染函数
instance.render = setupResult
}
else if (isObject(setupResult)) {
// 把 setup 返回结果变成响应式
instance.setupState = reactive(setupResult)
}
finishComponentSetup(instance)
}
我们知道setup的返回值有两种,要么返回一个对象,要么返回一个渲染函数,如果返回渲染函数就是组件的render,如果返回了对象,我们前面已经说过了,会优先通过代理去访问。
兼容options选项式API
最后会执行finishComponentSetup,这个函数是对选项式API对一个兼容,如果我们没有setup,那么就直接走到这来,流程整体上就和vue2类似了,感兴趣的小伙伴可以去看一下源码。我在这里贴一下finishComponentSetup内部调用applyOptions函数的github地址,就如同它的名字一样,这个函数消费了选项式API的除Setup的其余选项,这个过程也涉及到了许多生命周期钩子的执行: github.com/vuejs/core/...
执行applyoptions函数的过程中,生命周期和选项处理的顺序大概是这样:
- 执行beforeCreate
- 循环methods,将每一个method定义到组件实例
- 将reactive(data)定义到组件实例的data字段
- 循环computed选项,将每一个computed定义到组件实例上
- 循环watch选项,并执行每一个watch开始监听
- 调用created
尾声
那么到这里setup以及组件选项的流程就结束了,下一步vue会以组件实例为上下文,调用render函数,render函数中访问数据是就会触发我们上文所说的ctx的代理,拿到ctx上的数据后,根据数据生成了vnode,然后根据vnode继续进行vnode的挂载。
整体流程就是这样循环往复。
如果有不明白的地方,还请大家留言告诉我,我会补充文章让大家看的更明白