诗号:六道同坠,魔劫万千,引渡如来。

/img/bdx/yiyeshu-001.jpg

vue3 中 expose 的原理及使用。

本文涉及的源码包: runtime-core

expose in options

组件中的所有选项处理(methods, data, …)都在这个函数中,其中就包括 expose:

componentOptions.ts:applyOptions(instance: ComponentInternalInstance)

options 初始化顺序:

  1. props

  2. inject

  3. methods

  4. data, 延迟处理,因为它依赖 this

  5. computed

  6. watch, 延迟处理,因为它依赖 this

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
export function applyOptions(instance: ComponentInternalInstance) {
  const options = resolveMergedOptions(instance)
  const publicThis = instance.proxy! as any
  const ctx = instance.ctx
  shouldCacheAccess = false

  const {
    // ...
    // public API
    expose,
    // ...
  } = options


  // ...

  if (isArray(expose)) {
    if (expose.length) {
      const exposed = instance.exposed || (instance.exposed = {})
      expose.forEach(key => {
        Object.defineProperty(exposed, key, {
          get: () => publicThis[key],
          set: val => (publicThis[key] = val)
        })
      })
    } else if (!instance.exposed) {
      instance.exposed = {}
    }
  }

  // ...
}

expose 属性的访问路径:

expose[] -> instance.exposed -> publicThis -> instance.proxy

instance.proxy

对组件上下文对象的代理对象。

instance.proxy 创建自:

runtime-core/src/component.ts:setupStatefulComponent

1
2
3
 // 1. create public instance / render proxy
  // also mark it raw so it's never observed
  instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))

是对 instance.ctx 的代理,当你在实例中通过 this.xxxctx.xxx 去访问属性的时候实际走的是下 面这个代理。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
 export const PublicInstanceProxyHandlers: ProxyHandler<any> = {
   // get proxy
  get({ _: instance }: ComponentRenderContext, key: string) {
    const { ctx, setupState, data, props, accessCache, type, appContext } = instance

    let normalizedProps
    if (key[0] !== '$') {
      // 1. 非 $xx 的属性访问顺序: setupState -> data -> ctx -> props
      // setupState 是 setup() 返回对象时的值,或 <script setup> 标签中的状态
      // 并且这个会记录每次访问时的 key 对应在哪个对象上,这样下次就不用再过一篇这
      // 里繁琐的逻辑了
      // ...
    }

    // 2. 下面是针对 this.$xxx 的访问,顺序是:
    // publicPropertiesMap -> cssModule -> ctx -> globalProperties
    // 找到对应 key 的 get 方法, 注意点
    //   1) this.$attrs 的访问会触发 track
    //   2) globalProperties 参过 app.config.globalProperties 注册的全局属性
    const publicGetter = publicPropertiesMap[key]
    let cssModule, globalProperties
    // public $xxx properties
    // ...
  },

   // set proxy
  set(
    { _: instance }: ComponentRenderContext,
    key: string,
    value: any
  ): boolean {
    const { data, setupState, ctx } = instance
    // 设置优先级: setupState -> options data
    if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
      setupState[key] = value
    } else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
      data[key] = value
    } else if (hasOwn(instance.props, key)) {
      // ...不允许直接修改 props
      return false
    }

    if (key[0] === '$' && key.slice(1) in instance) {
      // ...不允许直接修改 $xxx 上的属性
      return false
    } else {
      // 开发时,可以修改 app.config.globalProperties 上属性的值
      if (__DEV__ && key in instance.appContext.config.globalProperties) {
        Object.defineProperty(ctx, key, {
          enumerable: true,
          configurable: true,
          value
        })
      } else {
        ctx[key] = value
      }
    }
    return true
  },

  has(
    {
      _: { data, setupState, accessCache, ctx, appContext, propsOptions }
    }: ComponentRenderContext,
    key: string
  ) {
    let normalizedProps
    // 检测属性有无时的代理:
    // 缓存(取值时缓存的) -> data -> setup state -> props -> ctx -> $xxx -> globalProperties
    return (
      accessCache![key] !== undefined ||
      (data !== EMPTY_OBJ && hasOwn(data, key)) ||
      (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) ||
      ((normalizedProps = propsOptions[0]) && hasOwn(normalizedProps, key)) ||
      hasOwn(ctx, key) ||
      hasOwn(publicPropertiesMap, key) ||
      hasOwn(appContext.config.globalProperties, key)
    )
  }
}

属性代理

  1. 取值操作可能经过的路径(依优先级从左到右):

    $xxx 的属性: setup state > data > ctx > props

    $xxx 的属性: publicPropertiesMap -> cssModule -> ctx -> globalProperties

    实际上 $xxx 的属性只是 ctx.xxx 的别名。

  2. 设值,只能设置 setup state > options data

    有一种情况比较特殊:当要设置的 key 在 setup state, options data, props, $xxx, globalProperties 上都没有的时候,最后会直接被添加的 ctx 上去:

    ctx[key] = value

  3. has 属性检查的顺序: cache 中 > data > setup state > props > ctx > publicPropertiesMap > globalProperties

publicPropertiesMap:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
export const publicPropertiesMap: PublicPropertiesMap = extend(
  Object.create(null),
  {
    $: i => i,
    $el: i => i.vnode.el,
    $data: i => i.data,
    $props: i => (__DEV__ ? shallowReadonly(i.props) : i.props),
    $attrs: i => (__DEV__ ? shallowReadonly(i.attrs) : i.attrs),
    $slots: i => (__DEV__ ? shallowReadonly(i.slots) : i.slots),
    $refs: i => (__DEV__ ? shallowReadonly(i.refs) : i.refs),
    $parent: i => getPublicInstance(i.parent),
    $root: i => getPublicInstance(i.root),
    $emit: i => i.emit,
    $options: i => (__FEATURE_OPTIONS_API__ ? resolveMergedOptions(i) : i.type),
    $forceUpdate: i => () => queueJob(i.update),
    $nextTick: i => nextTick.bind(i.proxy!),
    $watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP)
  } as PublicPropertiesMap
)

从上面的 代码流程 来看,好像和 props, data 没什么区别吧❓

最终不都是走了 proxy get 那一套❓

从这里好像看不出什么…

ref & setRef

来看下面官方(runtime-core/__tests__/apiExpose.spec.ts)的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const url = process.env.VNEXT_PKG_RC +'/../runtime-test/dist/runtime-test.cjs.js'
const value = require(url.replace('stb-', ''))
const { nodeOps, render, h, serializeInner: s, ref, defineComponent, toRaw } = value

const Child = defineComponent({
  render() {},
  expose: ['fox', 'foo'],
  setup(_, { expose }) {
    expose({
      foo: 1,
      bar: ref(2)
    })

    return {
      bar: ref(3),
      baz: ref(4)
    }
  }
})

const childRef = ref()
const Parent = {
  setup() {
    return () => h(Child, { ref: childRef })
  }
}

const root = nodeOps.createElement('div')
render(h(Parent), root)
console.log('childRef.value = ', childRef.value);
console.log('childRef.value.foo = ', childRef.value.foo);
console.log('childRef.value.bar = ', childRef.value.bar);
console.log('childRef.value.baz = ', childRef.value.baz);
console.log('childRef.value.fox = ', childRef.value.fox);
key = __v_raw
key = __v_isReadonly
key = __v_raw
key = __v_skip
childRef.value =  {
  foo: [Getter/Setter],
  bar: RefImpl { _rawValue: 2, _shallow: false, __v_isRef: true, _value: 2 }
}
key = foo
childRef.value.foo =  undefined
key = bar
childRef.value.bar =  2
key = baz
childRef.value.baz =  undefined
key = fox
childRef.value.fox =  undefined
undefined

从测试用例来看,这个 expose 用途是将组件本身的属性暴露出去,可以在父组件中通过 ref 取到该组件元素的引用 childRef (严格来说当有 expose 时就不再是指向 vnode.el了), 然后就可以通过这个引用来直接访问 expose 出来的属性。

那这个是怎么做到的?

既然和 ref 元素本身有关系,那就得从这个 ref 去下手看看了。

ref 值设置的地方(setRef(...)):在组件渲染完成之后才会有实际的DOM元素,所以这个肯定是发生在 mounted 之后。

调用 setRef() 的地方有:

  1. patch(n1, n2, …) 函数的最后

    1
    2
    3
    4
    
     // set ref
     if (ref != null && parentComponent) {
       setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
     }
    
  2. unmount(vnode, …) 的时候取消引用,防止内存泄漏

    1
    2
    3
    
     if (ref != null) {
       setRef(ref, null, parentSuspense, vnode, true)
     }
    

TIP

另外,组件实际的 DOM 元素的引用是在 vnode.el 上,这个值是在 mountElement(vnode) 中创建真实 DOM 元素的时候被赋值的,而 setRef() 是在 patch() 最后执行,所以在这个 时候 vnode.el 上就已经有了该组件真实 DOM 元素的引用,因为所有的组件流程一开始都 是经过的 patch() 函数(组件渲染完整流程图)。

Vue3 源码头脑风暴之 7 ☞ runtime-core(3) - render component

上面只是知道了设置,但实际这里是需要知道是对 childRef.value.foo 的取值会发生些什 么,流程是什么,最终又是怎么和 expose 发生关联的?

下面来仔细看下 setRef 里面又发生了什么,设想应该是对 ref 的引用是不是做了代理❓

runtime-core/src/renderer.ts:setRef

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
 export const setRef = (
  rawRef: VNodeNormalizedRef,
  oldRawRef: VNodeNormalizedRef | null,
  parentSuspense: SuspenseBoundary | null,
  vnode: VNode,
  isUnmount = false
) => {
  // ... rawRef 是数组处理

  // 异步组件判断,如果是异步的需要等异步组件完成渲染才可以
  // isAsyncWrapper:
  // export const isAsyncWrapper = (i: ComponentInternalInstance | VNode): boolean =>
  // !!(i.type as ComponentOptions).__asyncLoader

  // 有状态的组件:对象组件, vnode.ts:createVNode 里面的检测
  // isObject(type) ? ShapeFlags.STATEFUL_COMPONENT : ...
  // 重点就在这:先取 expose proxy 然后取 component.proxy
  const refValue =
    vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
      ? getExposeProxy(vnode.component!) || vnode.component!.proxy
      : vnode.el
  const value = isUnmount ? null : refValue

  // ...

  // <div ref="formRef" /> -> this.$refs.formRef
  if (isString(ref)) {
    // refs[ref] = value ...
  } else if (isRef(ref)) {
    // 这个正是这里使用的案例所执行的分支
  }

  // ...
}

在 setRef 中检测到如果是有状态的组件,会先执行 getExposeProxy(instance)instance.exposed 中取值,如果没有则再去组件实例的代 理(也就是最开始分析的instance.proxy)中去取 expose proxy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
export function getExposeProxy(instance: ComponentInternalInstance) {
  if (instance.exposed) {
    return (
      instance.exposeProxy ||
        (instance.exposeProxy = new Proxy(proxyRefs(markRaw(instance.exposed)), {
          get(target, key: string) {
            console.log('key = ' + key);
            if (key in target) {
              return target[key]
            } else if (key in publicPropertiesMap) {
              return publicPropertiesMap[key](instance)
            }
          }
        }))
    )
  }
}

上面代码加了打印可以从 🔗上面的例子 中看到 key 的值。

以上就是 expose + ref 的使用及原理,下面还会对一些其它细节进行回顾。

$root and $parent

在 Child 中可以通过 this.$root 和 this.$parent 去取到 parent 中 expose 出 来的属性,这个又是怎么实现的呢❓❓❓

先看下测试用例(runtime-core/__tests__/apiExpose.spec.ts):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const url = process.env.VNEXT_PKG_RC +'/../runtime-test/dist/runtime-test.cjs.js'
const value = require(url.replace('stb-', ''))
const { nodeOps, render, h, serializeInner: s, defineComponent } = value

let Root
const Child = defineComponent( {
  render() {
    console.log("this.$parent.foo = " + this.$parent.foo);
    console.log("this.$parent.bar = " + this.$parent.bar);
    console.log("this.$root.foo = " + this.$root.foo);
    console.log("this.$root.bar = " + this.$root.bar);
    console.log("$root: ", this.$root);
    console.log("Root:", Root);
  }
} )

const Parent = defineComponent({
  expose: [],
  setup(_, { expose }) {
    expose({ foo: 1 })
    return { bar: 2 }
  },
  render() {
    return h(Child)
  }
})

// #1
console.log('> root = parent');
const root1 = nodeOps.createElement('div')
render(h(Parent), root1)

Root = defineComponent({
  render () {
    return h(Parent)
  }
})

// #2
console.log('> root = parent.$parent');
const root2 = nodeOps.createElement('div')
render(h(Root), root2)
return 0
> root = parent
this.$parent.foo = 1
this.$parent.bar = undefined
this.$root.foo = 1
this.$root.bar = undefined
$root:  { foo: 1 }
Root: undefined
> root = parent.$parent
this.$parent.foo = 1
this.$parent.bar = undefined
this.$root.foo = undefined
this.$root.bar = undefined
$root:  {}
Root: { render: [Function: render] }
0

结果显示, 在 Child 中都可以正确取到 foo ,而取不到 bar。

那么为什么呢❓

可以从 publicPropertiesMap 中找到 $parent 和 $root 的引用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
export const publicPropertiesMap: PublicPropertiesMap = extend(
  Object.create(null),
  {
    // ...
    $el: i => i.vnode.el,
    // ...
    $parent: i => getPublicInstance(i.parent),
    $root: i => getPublicInstance(i.root),
    // ...
  } as PublicPropertiesMap
)

两者都是映射到了 getPublicInstance 那什么是 public instance❓

代码位于: runtime-core/src/componentPublicInstance.ts:getPublicInstance

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/**
 ,* #2437 In Vue 3, functional components do not have a public instance proxy but
 ,* they exist in the internal parent chain. For code that relies on traversing
 ,* public $parent chains, skip functional ones and go to the parent instead.
 ,*/
const getPublicInstance = (
  i: ComponentInternalInstance | null
): ComponentPublicInstance | ComponentInternalInstance['exposed'] | null => {
  if (!i) return null
  // 对象组件,跳过函数组件,如上面的注释,函数组件并没有公共的实例,但是它们
  // 依旧在 parent 链上
  if (isStatefulComponent(i)) return i.exposed ? i.exposed : i.proxy
  // 递归取父级组件实例,这个一直会找到 root
  return getPublicInstance(i.parent)
}

this.$parent.foo 倒是好理解, $parent 被映射到 getPublicInstance(i.parent) 找自 己的父级组件,如果有 exposed 级返回这个对象,所以这里也就等于是 instance.exposed.foo

然而对于 this.$root.foo 为什么也能取到 expose 里面的 1

因为在 root1 的时候 Parent 就是 root 组件,所以 instance.root == parent

每个组件都会持有一份对 root 组件的引用,instance.root,这个引用的设置发生在创建 组件实例的时候:

1
2
// createComponentInstance(vnode, parent, suspense) ->
instance.root = parent ? parent.root : instance

所以 #1#2 的结果不一样(1undefined)。

expose()

除了能使用 options api expose 之外,还可以通过在 setup() 中调用 expose({...}) 来 暴露部分属性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
const url = process.env.VNEXT_PKG_RC +'/../runtime-test/dist/runtime-test.cjs.js'
const value = require(url.replace('stb-', ''))
const { nodeOps, render, h, serializeInner: s, defineComponent, ref } = value

const Child = {
  render() {
    return h('div')
  },
  setup(_, { expose }) {
    expose()
    return {}
  }
}

const childRef = ref()

const Parent = {
  setup() {
    return () => h(Child, { ref: childRef })
  }
}

const root = nodeOps.createElement('div')
render(h(Parent), root)

console.log('childRef.value.$el.tag = ' + childRef.value.$el.tag);
return 0
key = __v_raw
key = __v_isReadonly
key = __v_raw
key = __v_skip
key = $el
childRef.value.$el.tag = div
0

expose() 函数又做了什么呢?为什么这个不传参数呢?

WARNING

expose() 只能在 setup() 中执行,且只能执行一次(非强制,多次也无意义,会覆盖)。

有关 setup ctx 参数的说明见: setup(_, ctx) 的第二个参数?

expose 函数其实很简单,赋值及初始化,不能重复调用也只是给出了警告:

1
2
3
4
5
6
const expose: SetupContext['expose'] = exposed => {
  if (__DEV__ && instance.exposed) {
    warn(`expose() should be called only once per setup().`)
  }
  instance.exposed = exposed || {}
}

getExposeProxy(instance)

设置了 instance.exposed 的代理,并且如果要访问的属性这个对象本身没有的时候会去 publicPropertiesMap 中去找,所以这个去访问 childRef.value.$el 的时候在 instance.exposed 上面是找不到的,最后找到的是 $el: i => i.vnode.el

总结

  1. 支持 options api expose: [...]

  2. 支持 setup context expose({...})

  3. 同时支持 option api 和 setup context

  4. empty expose 等于没有

  5. this.$parent 在子组件中使用可以取到父组件 expose 的属性

  6. this.$root 取到根节点上 expose 的属性

  7. setup() 中调用 expose() 不传参,最后属性访问会被代理到 publicPropertiesMap

/img/vue3/runtime-core/runtime-core-expose.svg