Kaynağa Gözat

feat(runtime-vapor): component slot (#143)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
ubugeeei 2 yıl önce
ebeveyn
işleme
78f74ce241

+ 1 - 0
packages/compiler-vapor/src/generators/component.ts

@@ -12,6 +12,7 @@ import {
 import { genExpression } from './expression'
 import { genPropKey } from './prop'
 
+// TODO: generate component slots
 export function genCreateComponent(
   oper: CreateComponentIRNode,
   context: CodegenContext,

+ 0 - 1
packages/runtime-vapor/__tests__/apiInject.spec.ts

@@ -6,7 +6,6 @@ import {
   createComponent,
   createTextNode,
   createVaporApp,
-  getCurrentInstance,
   hasInjectionContext,
   inject,
   nextTick,

+ 8 - 0
packages/runtime-vapor/__tests__/componentAttrs.spec.ts

@@ -40,6 +40,8 @@ describe('attribute fallthrough', () => {
               id: () => _ctx.id,
             },
           ],
+          null,
+          null,
           true,
         )
       },
@@ -85,6 +87,8 @@ describe('attribute fallthrough', () => {
               id: () => _ctx.id,
             },
           ],
+          null,
+          null,
           true,
         )
       },
@@ -123,6 +127,8 @@ describe('attribute fallthrough', () => {
               'custom-attr': () => 'custom-attr',
             },
           ],
+          null,
+          null,
           true,
         )
         return n0
@@ -144,6 +150,8 @@ describe('attribute fallthrough', () => {
               id: () => _ctx.id,
             },
           ],
+          null,
+          null,
           true,
         )
       },

+ 2 - 0
packages/runtime-vapor/__tests__/componentProps.spec.ts

@@ -244,6 +244,8 @@ describe('component: props', () => {
             foo: () => _ctx.foo,
             id: () => _ctx.id,
           },
+          null,
+          null,
           true,
         )
       },

+ 191 - 0
packages/runtime-vapor/__tests__/componentSlots.spec.ts

@@ -0,0 +1,191 @@
+// NOTE: This test is implemented based on the case of `runtime-core/__test__/componentSlots.spec.ts`.
+
+import {
+  createComponent,
+  createVaporApp,
+  defineComponent,
+  getCurrentInstance,
+  nextTick,
+  ref,
+  template,
+} from '../src'
+import { makeRender } from './_utils'
+
+const define = makeRender<any>()
+function renderWithSlots(slots: any): any {
+  let instance: any
+  const Comp = defineComponent({
+    render() {
+      const t0 = template('<div></div>')
+      const n0 = t0()
+      instance = getCurrentInstance()
+      return n0
+    },
+  })
+
+  const { render } = define({
+    render() {
+      return createComponent(Comp, {}, slots)
+    },
+  })
+
+  render()
+  return instance
+}
+
+describe('component: slots', () => {
+  test('initSlots: instance.slots should be set correctly', () => {
+    const { slots } = renderWithSlots({ _: 1 })
+    expect(slots).toMatchObject({ _: 1 })
+  })
+
+  // NOTE: slot normalization is not supported
+  test.todo(
+    'initSlots: should normalize object slots (when value is null, string, array)',
+    () => {},
+  )
+  test.todo(
+    'initSlots: should normalize object slots (when value is function)',
+    () => {},
+  )
+
+  test('initSlots: instance.slots should be set correctly', () => {
+    let instance: any
+    const Comp = defineComponent({
+      render() {
+        const t0 = template('<div></div>')
+        const n0 = t0()
+        instance = getCurrentInstance()
+        return n0
+      },
+    })
+
+    const { render } = define({
+      render() {
+        return createComponent(Comp, {}, { header: () => template('header')() })
+      },
+    })
+
+    render()
+
+    expect(instance.slots.header()).toMatchObject(
+      document.createTextNode('header'),
+    )
+  })
+
+  // runtime-core's "initSlots: instance.slots should be set correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
+  test('initSlots: instance.slots should be set correctly', () => {
+    const { slots } = renderWithSlots({
+      default: () => template('<span></span>')(),
+    })
+
+    // expect(
+    //   '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
+    // ).toHaveBeenWarned()
+
+    expect(slots.default()).toMatchObject(document.createElement('span'))
+  })
+
+  test('updateSlots: instance.slots should be updated correctly', async () => {
+    const flag1 = ref(true)
+
+    let instance: any
+    const Child = () => {
+      instance = getCurrentInstance()
+      return template('child')()
+    }
+
+    const { render } = define({
+      render() {
+        return createComponent(Child, {}, { _: 2 as any }, () => [
+          flag1.value
+            ? { name: 'one', fn: () => template('<span></span>')() }
+            : { name: 'two', fn: () => template('<div></div>')() },
+        ])
+      },
+    })
+
+    render()
+
+    expect(instance.slots).toHaveProperty('one')
+    expect(instance.slots).not.toHaveProperty('two')
+
+    flag1.value = false
+    await nextTick()
+
+    expect(instance.slots).not.toHaveProperty('one')
+    expect(instance.slots).toHaveProperty('two')
+  })
+
+  // NOTE: it is not supported
+  // test('updateSlots: instance.slots should be updated correctly (when slotType is null)', () => {})
+
+  // runtime-core's "updateSlots: instance.slots should be update correctly (when vnode.shapeFlag is not SLOTS_CHILDREN)"
+  test('updateSlots: instance.slots should be update correctly', async () => {
+    const flag1 = ref(true)
+
+    let instance: any
+    const Child = () => {
+      instance = getCurrentInstance()
+      return template('child')()
+    }
+
+    const { render } = define({
+      setup() {
+        return createComponent(Child, {}, {}, () => [
+          flag1.value
+            ? [{ name: 'header', fn: () => template('header')() }]
+            : [{ name: 'footer', fn: () => template('footer')() }],
+        ])
+      },
+    })
+    render()
+
+    expect(instance.slots).toHaveProperty('header')
+    flag1.value = false
+    await nextTick()
+
+    // expect(
+    //   '[Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance.',
+    // ).toHaveBeenWarned()
+
+    expect(instance.slots).toHaveProperty('footer')
+  })
+
+  test.todo('should respect $stable flag', async () => {
+    // TODO: $stable flag?
+  })
+
+  test.todo('should not warn when mounting another app in setup', () => {
+    // TODO: warning
+    const Comp = defineComponent({
+      render() {
+        const i = getCurrentInstance()
+        return i!.slots.default!()
+      },
+    })
+    const mountComp = () => {
+      createVaporApp({
+        render() {
+          return createComponent(
+            Comp,
+            {},
+            { default: () => template('msg')() },
+          )!
+        },
+      })
+    }
+    const App = {
+      setup() {
+        mountComp()
+      },
+      render() {
+        return null!
+      },
+    }
+    createVaporApp(App).mount(document.createElement('div'))
+    expect(
+      'Slot "default" invoked outside of the render function',
+    ).not.toHaveBeenWarned()
+  })
+})

+ 5 - 0
packages/runtime-vapor/src/apiCreateComponent.ts

@@ -5,17 +5,22 @@ import {
 } from './component'
 import { setupComponent } from './apiRender'
 import type { RawProps } from './componentProps'
+import type { DynamicSlots, Slots } from './componentSlots'
 import { withAttrs } from './componentAttrs'
 
 export function createComponent(
   comp: Component,
   rawProps: RawProps | null = null,
+  slots: Slots | null = null,
+  dynamicSlots: DynamicSlots | null = null,
   singleRoot: boolean = false,
 ) {
   const current = currentInstance!
   const instance = createComponentInstance(
     comp,
     singleRoot ? withAttrs(rawProps) : rawProps,
+    slots,
+    dynamicSlots,
   )
   setupComponent(instance, singleRoot)
 

+ 7 - 1
packages/runtime-vapor/src/apiCreateVaporApp.ts

@@ -41,7 +41,13 @@ export function createVaporApp(
 
     mount(rootContainer): any {
       if (!instance) {
-        instance = createComponentInstance(rootComponent, rootProps, context)
+        instance = createComponentInstance(
+          rootComponent,
+          rootProps,
+          null,
+          null,
+          context,
+        )
         setupComponent(instance)
         render(instance, rootContainer)
         return instance

+ 32 - 4
packages/runtime-vapor/src/component.ts

@@ -17,6 +17,12 @@ import {
   emit,
   normalizeEmitsOptions,
 } from './componentEmits'
+import {
+  type DynamicSlots,
+  type InternalSlots,
+  type Slots,
+  initSlots,
+} from './componentSlots'
 import { VaporLifecycleHooks } from './apiLifecycle'
 import { warn } from './warning'
 import { type AppContext, createAppContext } from './apiCreateVaporApp'
@@ -32,7 +38,7 @@ export type SetupContext<E = EmitsOptions> = E extends any
       attrs: Data
       emit: EmitFn<E>
       expose: (exposed?: Record<string, any>) => void
-      // TODO slots
+      slots: Readonly<InternalSlots>
     }
   : never
 
@@ -46,6 +52,9 @@ export function createSetupContext(
       get attrs() {
         return getAttrsProxy(instance)
       },
+      get slots() {
+        return getSlotsProxy(instance)
+      },
       get emit() {
         return (event: string, ...args: any[]) => instance.emit(event, ...args)
       },
@@ -57,6 +66,7 @@ export function createSetupContext(
         return getAttrsProxy(instance)
       },
       emit: instance.emit,
+      slots: instance.slots,
       expose: NOOP,
     }
   }
@@ -102,9 +112,11 @@ export interface ComponentInternalInstance {
   emit: EmitFn
   emitted: Record<string, boolean> | null
   attrs: Data
+  slots: InternalSlots
   refs: Data
 
-  attrsProxy: Data | null
+  attrsProxy?: Data
+  slotsProxy?: Slots
 
   // lifecycle
   isMounted: boolean
@@ -188,6 +200,8 @@ let uid = 0
 export function createComponentInstance(
   component: ObjectComponent | FunctionalComponent,
   rawProps: RawProps | null,
+  slots: Slots | null = null,
+  dynamicSlots: DynamicSlots | null = null,
   // application root node only
   appContext: AppContext | null = null,
 ): ComponentInternalInstance {
@@ -224,10 +238,9 @@ export function createComponentInstance(
     emit: null!,
     emitted: null,
     attrs: EMPTY_OBJ,
+    slots: EMPTY_OBJ,
     refs: EMPTY_OBJ,
 
-    attrsProxy: null,
-
     // lifecycle
     isMounted: false,
     isUnmounted: false,
@@ -283,6 +296,7 @@ export function createComponentInstance(
     // [VaporLifecycleHooks.SERVER_PREFETCH]: null,
   }
   initProps(instance, rawProps, !isFunction(component))
+  initSlots(instance, slots, dynamicSlots)
   instance.emit = emit.bind(null, instance)
 
   return instance
@@ -315,3 +329,17 @@ function getAttrsProxy(instance: ComponentInternalInstance): Data {
     ))
   )
 }
+
+/**
+ * Dev-only
+ */
+function getSlotsProxy(instance: ComponentInternalInstance): Slots {
+  return (
+    instance.slotsProxy ||
+    (instance.slotsProxy = new Proxy(instance.slots, {
+      get(target, key: string) {
+        return target[key]
+      },
+    }))
+  )
+}

+ 80 - 0
packages/runtime-vapor/src/componentSlots.ts

@@ -0,0 +1,80 @@
+import { type IfAny, extend, isArray } from '@vue/shared'
+import { baseWatch } from '@vue/reactivity'
+import type { ComponentInternalInstance } from './component'
+import type { Block } from './apiRender'
+import { createVaporPreScheduler } from './scheduler'
+
+// TODO: SSR
+
+export type Slot<T extends any = any> = (
+  ...args: IfAny<T, any[], [T] | (T extends undefined ? [] : never)>
+) => Block
+
+export type InternalSlots = {
+  [name: string]: Slot | undefined
+}
+
+export type Slots = Readonly<InternalSlots>
+
+export interface DynamicSlot {
+  name: string
+  fn: Slot
+  key?: string
+}
+
+export type DynamicSlots = () => (DynamicSlot | DynamicSlot[])[]
+
+export const initSlots = (
+  instance: ComponentInternalInstance,
+  rawSlots: InternalSlots | null = null,
+  dynamicSlots: DynamicSlots | null = null,
+) => {
+  const slots: InternalSlots = extend({}, rawSlots)
+
+  if (dynamicSlots) {
+    const dynamicSlotKeys: Record<string, true> = {}
+    baseWatch(
+      () => {
+        const _dynamicSlots = dynamicSlots()
+        for (let i = 0; i < _dynamicSlots.length; i++) {
+          const slot = _dynamicSlots[i]
+          // array of dynamic slot generated by <template v-for="..." #[...]>
+          if (isArray(slot)) {
+            for (let j = 0; j < slot.length; j++) {
+              slots[slot[j].name] = slot[j].fn
+              dynamicSlotKeys[slot[j].name] = true
+            }
+          } else if (slot) {
+            // conditional single slot generated by <template v-if="..." #foo>
+            slots[slot.name] = slot.key
+              ? (...args: any[]) => {
+                  const res = slot.fn(...args)
+                  // attach branch key so each conditional branch is considered a
+                  // different fragment
+                  if (res) (res as any).key = slot.key
+                  return res
+                }
+              : slot.fn
+            dynamicSlotKeys[slot.name] = true
+          }
+        }
+        // delete stale slots
+        for (const key in dynamicSlotKeys) {
+          if (
+            !_dynamicSlots.some(slot =>
+              isArray(slot)
+                ? slot.some(s => s.name === key)
+                : slot?.name === key,
+            )
+          ) {
+            delete slots[key]
+          }
+        }
+      },
+      undefined,
+      { scheduler: createVaporPreScheduler(instance) },
+    )
+  }
+
+  instance.slots = slots
+}

+ 85 - 0
playground/src/slots.js

@@ -0,0 +1,85 @@
+// @ts-check
+import {
+  children,
+  createComponent,
+  defineComponent,
+  insert,
+  on,
+  ref,
+  renderEffect,
+  setText,
+  template,
+} from '@vue/vapor'
+
+// <template #mySlot="{ message, changeMessage }">
+//   <div clas="slotted">
+//     <h1>{{ message }}</h1>
+//     <button @click="changeMessage">btn parent</button>
+//   </div>
+// </template>
+const t1 = template(
+  '<div class="slotted"><h1><!></h1><button>parent btn</button></div>',
+)
+
+const Parent = defineComponent({
+  vapor: true,
+  setup() {
+    return (() => {
+      /** @type {any} */
+      const n0 = createComponent(
+        Child,
+        {},
+        {
+          mySlot: ({ message, changeMessage }) => {
+            const n1 = t1()
+            const n2 = /** @type {any} */ (children(n1, 0))
+            const n3 = /** @type {any} */ (children(n1, 1))
+            renderEffect(() => setText(n2, message()))
+            on(n3, 'click', changeMessage)
+            return n1
+          },
+          // e.g. default slot
+          // default: () => {
+          //   const n1 = t1()
+          //   return n1
+          // }
+        },
+      )
+      return n0
+    })()
+  },
+})
+
+const t2 = template(
+  '<div class="child-container"><button>child btn</button></div>',
+)
+
+const Child = defineComponent({
+  vapor: true,
+  setup(_, { slots }) {
+    const message = ref('Hello World!')
+    function changeMessage() {
+      message.value += '!'
+    }
+
+    return (() => {
+      // <div>
+      //   <slot name="mySlot" :message="msg" :changeMessage="changeMessage" />
+      //   <button @click="changeMessage">button in child</button>
+      // </div>
+      const n0 = /** @type {any} */ (t2())
+      const n1 = /** @type {any} */ (children(n0, 0))
+      on(n1, 'click', () => changeMessage)
+      const s0 = /** @type {any} */ (
+        slots.mySlot?.({
+          message: () => message.value,
+          changeMessage: () => changeMessage,
+        })
+      )
+      insert(s0, n0, n1)
+      return n0
+    })()
+  },
+})
+
+export default Parent