通过 expose 暴露组件属性,并使用 ref 获取元素和组件实例

2025-11-20 17:41:31

前面已经实现了组件的 attrs、emit 和 slots。

除此之外,组件还可以通过 expose 主动暴露一部分内部状态和方法,父组件再通过 ref 获取这些公开内容。

import { createApp, h, ref } from '../dist/vue.esm.js'

const Child = {
  setup(_, { expose }) {
    const count = ref(0)

    function increment() {
      count.value++
    }

    expose({
      count,
      increment
    })

    return () => {
      return h('div', `count: ${count.value}`)
    }
  }
}

const App = {
  setup() {
    const childRef = ref(null)

    function handleClick() {
      childRef.value.increment()
    }

    return () => {
      return h('div', [
        h(Child, { ref: childRef }),
        h(
          'button',
          {
            onClick: handleClick
          },
          'increment'
        )
      ])
    }
  }
}

createApp(App).mount('#app')
  • expose:决定组件实例对外公开哪些属性和方法
  • ref:在组件挂载完成后,保存元素节点或组件公开实例

如果 ref 指向普通元素,得到的是元素节点;如果 ref 指向组件,得到的是组件公开实例。

实现 expose

expose 和 attrs、emit、slots 一样,都是通过 setup 的第二个参数传入的。

function createSetupContext(instance) {
  return {
    get attrs() {
      return instance.attrs
    },
    emit(event, ...args) {
      emit(instance, event, ...args)
    },
    slots: instance.slots,
    expose(exposed) {
      instance.exposed = exposed
    }
  }
}

调用 expose 时,只需要把传入的对象保存到组件实例上:

expose({
  count,
  increment
})

对应的组件实例会保存:

instance.exposed = {
  count,
  increment
}

expose 不会把组件内部的所有状态都暴露出去,而是由组件主动决定公开的内容。

实现 ref

1. 在 vnode 上保存 ref

父组件使用 ref 时,ref 会作为 vnode 的一部分传递下来:

h(Child, {
  ref: childRef
})

所以,在创建 vnode 时需要保存 props.ref:

export function createVNode(type, props = null, children = null) {
  const vnode = {
    type,
    props,
    children,
    ref: props?.ref
  }

  return vnode
}

但是,在设置 ref 时不仅需要知道原始的 ref,还需要知道这个 ref 属于哪个组件。

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

上面这个ref 是在父组件的 render 中创建的,应该保存到父组件的 refs 中,而不是保存到子组件自身。

所以,在执行组件 render 时,需要记录当前正在渲染的组件实例。

2. 保存当前渲染实例

在组件开始执行 render 前保存实例,执行结束后清空实例:

// packages/runtime-core/src/componentRenderContext.ts
let currentRenderingInstance = null

export function setCurrentRenderingInstance(instance) {
  currentRenderingInstance = instance
}

export function unsetCurrentRenderingInstance() {
  currentRenderingInstance = null
}

export function getCurrentRenderingInstance() {
  return currentRenderingInstance
}

在组件更新函数中使用:

function componentUpdateFn() {
  setCurrentRenderingInstance(instance)
  let subTree

  try {
    subTree = instance.render.call(instance.proxy)
  }
  finally {
    unsetCurrentRenderingInstance()
  }

  // 首次挂载或后续更新
  patch(instance.subTree, subTree, container, anchor)
  instance.subTree = subTree
}

创建 vnode 时,将原始 ref 和当前渲染实例一起保存:

import { getCurrentRenderingInstance } from './componentRenderContext'

export function createVNode(type, props = null, children = null) {
  const vnode = {
    type,
    props,
    children,
    ref: normalizeRef(props?.ref)
  }

  return vnode
}

function normalizeRef(rawRef) {
  if (rawRef == null) {
    return null
  }

  return {
    // 原始 ref,可以是字符串或 Ref 对象
    r: rawRef,
    // 创建这个 vnode 时正在渲染的组件实例
    i: getCurrentRenderingInstance()
  }
}

在挂载完成后设置 ref

ref 对应的元素或组件实例,只有在挂载完成后才真正可用。

因此,应该在 patch 完成对应节点的挂载后设置 ref:

function patch(n1, n2, container, anchor = null) {
  const { shapeFlag, ref } = n2

  // 根据节点类型完成挂载或更新
  // ...

  if (ref != null) {
    setRef(ref, n2)
  }
}

setRef 需要区分两种情况:

  1. ref 指向普通元素,保存 vnode.el
  2. ref 指向组件,保存组件的公开实例
// packages/runtime-core/src/rendererTemplateRef.ts
import { isRef, proxyRefs } from '@vue/reactivity'
import { isString, ShapeFlags } from '@vue/shared'

export function setRef(ref, vnode) {
  const { r: rawRef, i: instance } = ref
  const { shapeFlag } = vnode

  const value = shapeFlag & ShapeFlags.COMPONENT
    ? getComponentPublicInstance(vnode.component)
    : vnode.el

  if (isRef(rawRef)) {
    rawRef.value = value
  }
  else if (isString(rawRef) && instance) {
    instance.refs[rawRef] = value
  }
}

instance 就是创建 vnode 时记录的父组件实例。

获取组件公开实例

普通元素可以直接保存 vnode.el。

但组件 vnode 本身并不是一个真实的 DOM 元素,组件 ref 应该返回什么,由 getComponentPublicInstance 统一处理。

export function getComponentPublicInstance(instance) {
  if (instance.exposed) {
    if (instance.exposedProxy) {
      return instance.exposedProxy
    }

    instance.exposedProxy = new Proxy(
      proxyRefs(instance.exposed),
      {
        get(target, key) {
          if (key in target) {
            return target[key]
          }

          if (key in publicPropertiesMap) {
            return publicPropertiesMap[key](instance)
          }
        }
      }
    )

    return instance.exposedProxy
  }

  return instance.proxy
}

组件调用了 expose

如果组件调用了 expose,就通过 instance.exposed 创建一个代理对象。

代理对象有两个作用:

  1. 优先访问组件通过 expose 暴露的属性
  2. 继续支持 $el、$attrs、$slots 和 $refs 等公共属性

组件没有调用 expose

如果组件没有显式调用 expose,则返回之前创建的 instance.proxy。

这样可以保持组件 ref 的默认行为,同时让显式调用 expose 的组件拥有更明确的公开接口。

小结

  1. expose 通过 setupContext 提供给组件
  2. 调用 expose 时,将公开对象保存到 instance.exposed
  3. ref 会保存到 vnode 上,并记录原始值和所属的组件实例
  4. 执行组件 render 时,需要保存当前正在渲染的组件实例
  5. 节点挂载完成后,普通元素的 ref 指向 vnode.el
  6. 组件的 ref 指向 getComponentPublicInstance 返回的公开实例
  7. expose 创建的公开实例会优先暴露显式公开的属性
  8. proxyRefs 让公开对象中的 ref 可以直接读取