探索Nuxt UI弹窗系统

本文简单介绍了对Nuxt UI弹窗系统探索的心路历程

长久以来,对「状态驱动」和「命令式」来展示弹窗始终都有争议,在知乎上,Vue作者尤雨溪有对此做过回答,他的选择是:状态驱动;而我个人更偏向于命令式,但始终没有找到一个我满意的方案。

最近在使用Nuxt UI,它提供了useOverlay,我认为它所提供的API非常合我的口味。于是好奇地想看看它是如何管理弹窗的,便以useOverlay的源码为入口开始阅读。

Usage介绍了基础的使用方法:

<script setup lang="ts">
import { LazyModalExample } from '#components'

const overlay = useOverlay()

const modal = overlay.create(LazyModalExample)

async function openModal() {
  modal.open()
}
</script>

所以我从create方法开始:

const overlays = shallowReactive<Overlay[]>([])

const create = <T extends Component>(component: T, _options?: OverlayOptions<ComponentProps<T>>): OverlayInstance<T> => {
  const { props, defaultOpen, destroyOnClose } = _options || {}

  const options = reactive<Overlay>({
    id: Symbol(import.meta.dev ? 'useOverlay' : ''),
    isOpen: !!defaultOpen,
    component: markRaw(component!),
    isMounted: !!defaultOpen,
    destroyOnClose: !!destroyOnClose,
    originalProps: props || {},
    props: { ...props }
  })

  overlays.push(options)

  return {
    ...options,
    open: <T extends Component>(props?: ComponentProps<T>) => open(options.id, props),
    close: value => close(options.id, value),
    patch: <T extends Component>(props: Partial<ComponentProps<T>>) => patch(options.id, props)
  }
}

它将组件和OverlayOptions合并后 push 到了overlays里,一个用于管理的数组,同时返回了openclosepatch方法等,仅此而已。

那么再看open方法:

const open = <T extends Component>(id: symbol, props?: ComponentProps<T>): OpenedOverlay<T> => {
  const overlay = getOverlay(id)

  // If props are provided, merge them with the original props, otherwise use the original props
  if (props) {
    overlay.props = { ...overlay.originalProps, ...props }
  } else {
    overlay.props = { ...overlay.originalProps }
  }

  overlay.isOpen = true  overlay.isMounted = true  const result = new Promise<any>(resolve => overlay.resolvePromise = resolve)

  return Object.assign(result, {
    id,
    isMounted: overlay.isMounted,
    isOpen: overlay.isOpen,
    result
  })
}

它也仅仅是通过getOverlay找到这个弹窗后将isOpenisMounted设为true

getOverlay中也仅仅是通过id找到了对应的Overlay

const getOverlay = (id: symbol): Overlay => {
  const overlay = overlays.find(overlay => overlay.id === id)

  if (!overlay) {
    throw new Error('Overlay not found')
  }

  return overlay
}

但是以上的这些操作,也仅仅是在操作弹窗组件的实例,并没有哪一步是将其实际渲染出来的,于是我又注意到刚刚的overlays

最终我找到了OverlayProvider

<script setup lang="ts">
import { computed } from 'vue'
import { useOverlay } from '../composables/useOverlay'
import type { Overlay } from '../composables/useOverlay'

const { overlays, unmount, close } = useOverlay()

const mountedOverlays = computed(() => overlays.filter((overlay: Overlay) => overlay.isMounted))

const onAfterLeave = (id: symbol) => {
  close(id)
  unmount(id)
}

const onClose = (id: symbol, value: any) => {
  close(id, value)
}
</script>

<template>
  <component
    :is="overlay.component"
    v-for="overlay in mountedOverlays"
    :key="overlay.id"
    v-bind="overlay.props"
    v-model:open="overlay.isOpen"
    @close="(value:any) => onClose(overlay.id, value)"
    @after:leave="onAfterLeave(overlay.id)"
  />
</template>

这个组件使用v-foroverlays中的所有弹窗都进行了渲染,并且由于被push进数组的先后顺序,似乎也很好地解决了弹窗的层级问题。

现在组件是被渲染了,但是如何控制显示和隐藏呢?显然v-model:open一定是关键。

于是我寻找了官方示例中可以用于useOverlayModal组件

抛开所有无关的逻辑,实际与显示相关的代码是以下这些:

const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen', 'modal'), emits)

<template>
  <DialogRoot v-slot="{ open, close }" v-bind="rootProps">
    ...
  </DialogRoot>
</template>

根结点的DialogRoot来自reka-ui,实际只是将open属性provide了出去:

<script setup lang="ts">
provideDialogRootContext({
  open,
  modal,
  openModal: () => {
    open.value = true
  },
  onOpenChange: (value) => {
    open.value = value
  },
  onOpenToggle: () => {
    open.value = !open.value
  },
  contentId: '',
  titleId: '',
  descriptionId: '',
  triggerElement,
  contentElement,
})
</script>

对应地,寻找inject方法(injectDialogRootContext),最终在DialogOverly中找到了传入open的组件:Presence,实际与显示相关的最简化代码为:

export default defineComponent({
  name: 'Presence',
  props: {
    present: {
      type: Boolean,
      required: true,
    },
  },
  slots: {} as SlotsType<{
    default: (opts: { present: boolean }) => any
  }>,
  setup(props, { slots, expose }) {
    const { present } = toRefs(props)

    return () => {
      if (present.value) {
        return h(slots.default()[0] as VNode)
      }
      else { return null }
    }
  },
})

至此,我对弹窗系统的探索就结束了,其实实现非常简单,但也收获颇丰。

另外,在探索的过程中可以发现,Nuxt UI的类型声明也非常优秀,值得学习,但在此不再过多赘述。

#Credits