Преглед на файлове

feat(runtime-vapor): component emits (#103)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
ubugeeei преди 2 години
родител
ревизия
cde91e4fb5

+ 1 - 0
README.md

@@ -72,6 +72,7 @@ The code provided here is a duplicate from `runtime-core` as Vapor cannot import
 
 - packages/runtime-vapor/src/apiWatch.ts
 - packages/runtime-vapor/src/component.ts
+- packages/runtime-vapor/src/componentEmits.ts
 - packages/runtime-vapor/src/componentProps.ts
 - packages/runtime-vapor/src/enums.ts
 - packages/runtime-vapor/src/errorHandling.ts

+ 488 - 0
packages/runtime-vapor/__tests__/componentEmits.spec.ts

@@ -0,0 +1,488 @@
+// NOTE: this test cases are based on paclages/runtime-core/__tests__/componentEmits.spec.ts
+
+// Note: emits and listener fallthrough is tested in
+// ./rendererAttrsFallthrough.spec.ts.
+
+import {
+  defineComponent,
+  nextTick,
+  onBeforeUnmount,
+  render,
+  unmountComponent,
+} from '../src'
+import { isEmitListener } from '../src/componentEmits'
+
+let host: HTMLElement
+
+const initHost = () => {
+  host = document.createElement('div')
+  host.setAttribute('id', 'host')
+  document.body.appendChild(host)
+}
+beforeEach(() => initHost())
+afterEach(() => host.remove())
+
+describe('component: emit', () => {
+  test('trigger handlers', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('foo')
+        emit('bar')
+        emit('!baz')
+      },
+    })
+    const onfoo = vi.fn()
+    const onBar = vi.fn()
+    const onBaz = vi.fn()
+    render(
+      Foo,
+      {
+        get onfoo() {
+          return onfoo
+        },
+        get onBar() {
+          return onBar
+        },
+        get ['on!baz']() {
+          return onBaz
+        },
+      },
+      '#host',
+    )
+
+    expect(onfoo).not.toHaveBeenCalled()
+    expect(onBar).toHaveBeenCalled()
+    expect(onBaz).toHaveBeenCalled()
+  })
+
+  test('trigger camelCase handler', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('test-event')
+      },
+    })
+
+    const fooSpy = vi.fn()
+    render(
+      Foo,
+      {
+        get onTestEvent() {
+          return fooSpy
+        },
+      },
+      '#host',
+    )
+    expect(fooSpy).toHaveBeenCalled()
+  })
+
+  test('trigger kebab-case handler', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('test-event')
+      },
+    })
+
+    const fooSpy = vi.fn()
+    render(
+      Foo,
+      {
+        get ['onTest-event']() {
+          return fooSpy
+        },
+      },
+      '#host',
+    )
+    expect(fooSpy).toHaveBeenCalledTimes(1)
+  })
+
+  // #3527
+  test.todo('trigger mixed case handlers', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('test-event')
+        emit('testEvent')
+      },
+    })
+
+    const fooSpy = vi.fn()
+    const barSpy = vi.fn()
+    render(
+      Foo,
+      // TODO: impl `toHandlers`
+      {
+        get ['onTest-Event']() {
+          return fooSpy
+        },
+        get onTestEvent() {
+          return barSpy
+        },
+      },
+      '#host',
+    )
+    expect(fooSpy).toHaveBeenCalledTimes(1)
+    expect(barSpy).toHaveBeenCalledTimes(1)
+  })
+
+  // for v-model:foo-bar usage in DOM templates
+  test('trigger hyphenated events for update:xxx events', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('update:fooProp')
+        emit('update:barProp')
+      },
+    })
+
+    const fooSpy = vi.fn()
+    const barSpy = vi.fn()
+    render(
+      Foo,
+      {
+        get ['onUpdate:fooProp']() {
+          return fooSpy
+        },
+        get ['onUpdate:bar-prop']() {
+          return barSpy
+        },
+      },
+      '#host',
+    )
+
+    expect(fooSpy).toHaveBeenCalled()
+    expect(barSpy).toHaveBeenCalled()
+  })
+
+  test('should trigger array of listeners', async () => {
+    const App = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('foo', 1)
+      },
+    })
+
+    const fn1 = vi.fn()
+    const fn2 = vi.fn()
+
+    render(
+      App,
+      {
+        get onFoo() {
+          return [fn1, fn2]
+        },
+      },
+      '#host',
+    )
+    expect(fn1).toHaveBeenCalledTimes(1)
+    expect(fn1).toHaveBeenCalledWith(1)
+    expect(fn2).toHaveBeenCalledTimes(1)
+    expect(fn2).toHaveBeenCalledWith(1)
+  })
+
+  test.todo('warning for undeclared event (array)', () => {
+    // TODO: warning
+  })
+
+  test.todo('warning for undeclared event (object)', () => {
+    // TODO: warning
+  })
+
+  test('should not warn if has equivalent onXXX prop', () => {
+    const Foo = defineComponent({
+      props: ['onFoo'],
+      emits: [],
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('foo')
+      },
+    })
+    render(Foo, {}, '#host')
+    expect(
+      `Component emitted event "foo" but it is neither declared`,
+    ).not.toHaveBeenWarned()
+  })
+
+  test.todo('validator warning', () => {
+    // TODO: warning validator
+  })
+
+  // NOTE: not supported mixins
+  // test.todo('merging from mixins', () => {})
+
+  // #2651
+  // test.todo(
+  //   'should not attach normalized object when mixins do not contain emits',
+  //   () => {},
+  // )
+
+  test('.once', () => {
+    const Foo = defineComponent({
+      render() {},
+      emits: {
+        foo: null,
+        bar: null,
+      },
+      setup(_: any, { emit }: any) {
+        emit('foo')
+        emit('foo')
+        emit('bar')
+        emit('bar')
+      },
+    })
+    const fn = vi.fn()
+    const barFn = vi.fn()
+    render(
+      Foo,
+      {
+        get onFooOnce() {
+          return fn
+        },
+        get onBarOnce() {
+          return barFn
+        },
+      },
+      '#host',
+    )
+    expect(fn).toHaveBeenCalledTimes(1)
+    expect(barFn).toHaveBeenCalledTimes(1)
+  })
+
+  test('.once with normal listener of the same name', () => {
+    const Foo = defineComponent({
+      render() {},
+      emits: {
+        foo: null,
+      },
+      setup(_: any, { emit }: any) {
+        emit('foo')
+        emit('foo')
+      },
+    })
+    const onFoo = vi.fn()
+    const onFooOnce = vi.fn()
+    render(
+      Foo,
+      {
+        get onFoo() {
+          return onFoo
+        },
+        get onFooOnce() {
+          return onFooOnce
+        },
+      },
+      '#host',
+    )
+    expect(onFoo).toHaveBeenCalledTimes(2)
+    expect(onFooOnce).toHaveBeenCalledTimes(1)
+  })
+
+  test('.number modifier should work with v-model on component', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('update:modelValue', '1')
+        emit('update:foo', '2')
+      },
+    })
+    const fn1 = vi.fn()
+    const fn2 = vi.fn()
+    render(
+      Foo,
+      {
+        get modelValue() {
+          return null
+        },
+        get modelModifiers() {
+          return { number: true }
+        },
+        get ['onUpdate:modelValue']() {
+          return fn1
+        },
+        get foo() {
+          return null
+        },
+        get fooModifiers() {
+          return { number: true }
+        },
+        get ['onUpdate:foo']() {
+          return fn2
+        },
+      },
+      '#host',
+    )
+    expect(fn1).toHaveBeenCalledTimes(1)
+    expect(fn1).toHaveBeenCalledWith(1)
+    expect(fn2).toHaveBeenCalledTimes(1)
+    expect(fn2).toHaveBeenCalledWith(2)
+  })
+
+  test('.trim modifier should work with v-model on component', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('update:modelValue', ' one ')
+        emit('update:foo', '  two  ')
+      },
+    })
+    const fn1 = vi.fn()
+    const fn2 = vi.fn()
+    render(
+      Foo,
+      {
+        get modelValue() {
+          return null
+        },
+        get modelModifiers() {
+          return { trim: true }
+        },
+        get ['onUpdate:modelValue']() {
+          return fn1
+        },
+        get foo() {
+          return null
+        },
+        get fooModifiers() {
+          return { trim: true }
+        },
+        get 'onUpdate:foo'() {
+          return fn2
+        },
+      },
+      '#host',
+    )
+    expect(fn1).toHaveBeenCalledTimes(1)
+    expect(fn1).toHaveBeenCalledWith('one')
+    expect(fn2).toHaveBeenCalledTimes(1)
+    expect(fn2).toHaveBeenCalledWith('two')
+  })
+
+  test('.trim and .number modifiers should work with v-model on component', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('update:modelValue', '    +01.2    ')
+        emit('update:foo', '    1    ')
+      },
+    })
+    const fn1 = vi.fn()
+    const fn2 = vi.fn()
+    render(
+      Foo,
+      {
+        get modelValue() {
+          return null
+        },
+        get modelModifiers() {
+          return { trim: true, number: true }
+        },
+        get ['onUpdate:modelValue']() {
+          return fn1
+        },
+        get foo() {
+          return null
+        },
+        get fooModifiers() {
+          return { trim: true, number: true }
+        },
+        get ['onUpdate:foo']() {
+          return fn2
+        },
+      },
+      '#host',
+    )
+    expect(fn1).toHaveBeenCalledTimes(1)
+    expect(fn1).toHaveBeenCalledWith(1.2)
+    expect(fn2).toHaveBeenCalledTimes(1)
+    expect(fn2).toHaveBeenCalledWith(1)
+  })
+
+  test('only trim string parameter when work with v-model on component', () => {
+    const Foo = defineComponent({
+      render() {},
+      setup(_: any, { emit }: any) {
+        emit('update:modelValue', ' foo ', { bar: ' bar ' })
+      },
+    })
+    const fn = vi.fn()
+    render(
+      Foo,
+      {
+        get modelValue() {
+          return null
+        },
+        get modelModifiers() {
+          return { trim: true }
+        },
+        get ['onUpdate:modelValue']() {
+          return fn
+        },
+      },
+      '#host',
+    )
+    expect(fn).toHaveBeenCalledTimes(1)
+    expect(fn).toHaveBeenCalledWith('foo', { bar: ' bar ' })
+  })
+
+  test('isEmitListener', () => {
+    const options = {
+      get click() {
+        return null
+      },
+      get 'test-event'() {
+        return null
+      },
+      get fooBar() {
+        return null
+      },
+      get FooBaz() {
+        return null
+      },
+    }
+    expect(isEmitListener(options, 'onClick')).toBe(true)
+    expect(isEmitListener(options, 'onclick')).toBe(false)
+    expect(isEmitListener(options, 'onBlick')).toBe(false)
+    // .once listeners
+    expect(isEmitListener(options, 'onClickOnce')).toBe(true)
+    expect(isEmitListener(options, 'onclickOnce')).toBe(false)
+    // kebab-case option
+    expect(isEmitListener(options, 'onTestEvent')).toBe(true)
+    // camelCase option
+    expect(isEmitListener(options, 'onFooBar')).toBe(true)
+    // PascalCase option
+    expect(isEmitListener(options, 'onFooBaz')).toBe(true)
+  })
+
+  test('does not emit after unmount', async () => {
+    const fn = vi.fn()
+    const Foo = defineComponent({
+      emits: ['closing'],
+      setup(_: any, { emit }: any) {
+        onBeforeUnmount(async () => {
+          await nextTick()
+          emit('closing', true)
+        })
+      },
+      render() {},
+    })
+    const i = render(
+      Foo,
+      {
+        get onClosing() {
+          return fn
+        },
+      },
+      '#host',
+    )
+    await nextTick()
+    unmountComponent(i)
+    await nextTick()
+    expect(fn).not.toHaveBeenCalled()
+  })
+
+  // NOTE: not supported mixins
+  // test.todo('merge string array emits', async () => {})
+  // test.todo('merge object emits', async () => {})
+})

+ 27 - 1
packages/runtime-vapor/src/component.ts

@@ -8,6 +8,13 @@ import {
   type NormalizedPropsOptions,
   normalizePropsOptions,
 } from './componentProps'
+import {
+  type EmitFn,
+  type EmitsOptions,
+  type ObjectEmitsOptions,
+  emit,
+  normalizeEmitsOptions,
+} from './componentEmits'
 
 import type { Data } from '@vue/shared'
 import { VaporLifecycleHooks } from './enums'
@@ -17,10 +24,12 @@ export type Component = FunctionalComponent | ObjectComponent
 export type SetupFn = (props: any, ctx: any) => Block | Data
 export type FunctionalComponent = SetupFn & {
   props: ComponentPropsOptions
+  emits: EmitsOptions
   render(ctx: any): Block
 }
 export interface ObjectComponent {
   props: ComponentPropsOptions
+  emits: EmitsOptions
   setup?: SetupFn
   render(ctx: any): Block
 }
@@ -37,13 +46,21 @@ export interface ComponentInternalInstance {
   block: Block | null
   scope: EffectScope
   component: FunctionalComponent | ObjectComponent
+
+  // TODO: ExtraProps: key, ref, ...
+  rawProps: { [key: string]: any }
+
+  // normalized options
   propsOptions: NormalizedPropsOptions
+  emitsOptions: ObjectEmitsOptions | null
 
   parent: ComponentInternalInstance | null
 
   // state
   props: Data
   setupState: Data
+  emit: EmitFn
+  emitted: Record<string, boolean> | null
   refs: Data
   metadata: WeakMap<Node, ElementMetadata>
 
@@ -139,6 +156,7 @@ export const unsetCurrentInstance = () => {
 let uid = 0
 export const createComponentInstance = (
   component: ObjectComponent | FunctionalComponent,
+  rawProps: Data,
 ): ComponentInternalInstance => {
   const instance: ComponentInternalInstance = {
     uid: uid++,
@@ -146,13 +164,18 @@ export const createComponentInstance = (
     container: null!, // set on mountComponent
     scope: new EffectScope(true /* detached */)!,
     component,
+    rawProps,
 
     // TODO: registory of parent
     parent: null,
 
     // resolved props and emits options
     propsOptions: normalizePropsOptions(component),
-    // emitsOptions: normalizeEmitsOptions(type, appContext), // TODO:
+    emitsOptions: normalizeEmitsOptions(component),
+
+    // emit
+    emit: null!, // to be set immediately
+    emitted: null,
 
     // state
     props: EMPTY_OBJ,
@@ -225,5 +248,8 @@ export const createComponentInstance = (
      */
     // [VaporLifecycleHooks.SERVER_PREFETCH]: null,
   }
+
+  instance.emit = emit.bind(null, instance)
+
   return instance
 }

+ 142 - 0
packages/runtime-vapor/src/componentEmits.ts

@@ -0,0 +1,142 @@
+// NOTE: runtime-core/src/componentEmits.ts
+
+import {
+  EMPTY_OBJ,
+  type UnionToIntersection,
+  camelize,
+  extend,
+  hasOwn,
+  hyphenate,
+  isArray,
+  isOn,
+  isString,
+  looseToNumber,
+  toHandlerKey,
+} from '@vue/shared'
+import type { Component, ComponentInternalInstance } from './component'
+import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
+
+export type ObjectEmitsOptions = Record<
+  string,
+  ((...args: any[]) => any) | null // TODO: call validation?
+>
+
+export type EmitsOptions = ObjectEmitsOptions | string[]
+
+export type EmitFn<
+  Options = ObjectEmitsOptions,
+  Event extends keyof Options = keyof Options,
+> =
+  Options extends Array<infer V>
+    ? (event: V, ...args: any[]) => void
+    : {} extends Options // if the emit is empty object (usually the default value for emit) should be converted to function
+      ? (event: string, ...args: any[]) => void
+      : UnionToIntersection<
+          {
+            [key in Event]: Options[key] extends (...args: infer Args) => any
+              ? (event: key, ...args: Args) => void
+              : (event: key, ...args: any[]) => void
+          }[Event]
+        >
+
+export function emit(
+  instance: ComponentInternalInstance,
+  event: string,
+  ...rawArgs: any[]
+) {
+  if (instance.isUnmounted) return
+  const { rawProps } = instance
+
+  let args = rawArgs
+  const isModelListener = event.startsWith('update:')
+
+  // for v-model update:xxx events, apply modifiers on args
+  const modelArg = isModelListener && event.slice(7)
+
+  if (modelArg && modelArg in rawProps) {
+    const modifiersKey = `${
+      modelArg === 'modelValue' ? 'model' : modelArg
+    }Modifiers`
+    const { number, trim } = rawProps[modifiersKey] || EMPTY_OBJ
+    if (trim) {
+      args = rawArgs.map(a => (isString(a) ? a.trim() : a))
+    }
+    if (number) {
+      args = rawArgs.map(looseToNumber)
+    }
+  }
+
+  // TODO: warn
+
+  let handlerName
+  let handler =
+    rawProps[(handlerName = toHandlerKey(event))] ||
+    // also try camelCase event handler (#2249)
+    rawProps[(handlerName = toHandlerKey(camelize(event)))]
+  // for v-model update:xxx events, also trigger kebab-case equivalent
+  // for props passed via kebab-case
+  if (!handler && isModelListener) {
+    handler = rawProps[(handlerName = toHandlerKey(hyphenate(event)))]
+  }
+
+  if (handler) {
+    callWithAsyncErrorHandling(
+      handler,
+      instance,
+      VaporErrorCodes.COMPONENT_EVENT_HANDLER,
+      args,
+    )
+  }
+
+  const onceHandler = rawProps[`${handlerName}Once`]
+  if (onceHandler) {
+    if (!instance.emitted) {
+      instance.emitted = {}
+    } else if (instance.emitted[handlerName]) {
+      return
+    }
+    instance.emitted[handlerName] = true
+    callWithAsyncErrorHandling(
+      onceHandler,
+      instance,
+      VaporErrorCodes.COMPONENT_EVENT_HANDLER,
+      args,
+    )
+  }
+}
+
+export function normalizeEmitsOptions(
+  comp: Component,
+): ObjectEmitsOptions | null {
+  // TODO: caching?
+
+  const raw = comp.emits
+  let normalized: ObjectEmitsOptions = {}
+
+  if (isArray(raw)) {
+    raw.forEach(key => (normalized[key] = null))
+  } else {
+    extend(normalized, raw)
+  }
+
+  return normalized
+}
+
+// Check if an incoming prop key is a declared emit event listener.
+// e.g. With `emits: { click: null }`, props named `onClick` and `onclick` are
+// both considered matched listeners.
+export function isEmitListener(
+  options: ObjectEmitsOptions | null,
+  key: string,
+): boolean {
+  if (!options || !isOn(key)) {
+    return false
+  }
+
+  key = key.slice(2).replace(/Once$/, '')
+  return (
+    hasOwn(options, key[0].toLowerCase() + key.slice(1)) ||
+    hasOwn(options, hyphenate(key)) ||
+    hasOwn(options, key)
+  )
+}

+ 3 - 3
packages/runtime-vapor/src/render.ts

@@ -27,7 +27,7 @@ export function render(
   props: Data,
   container: string | ParentNode,
 ): ComponentInternalInstance {
-  const instance = createComponentInstance(comp)
+  const instance = createComponentInstance(comp, props)
   initProps(instance, props)
   return mountComponent(instance, (container = normalizeContainer(container)))
 }
@@ -46,8 +46,8 @@ export function mountComponent(
 
   const reset = setCurrentInstance(instance)
   const block = instance.scope.run(() => {
-    const { component, props } = instance
-    const ctx = { expose: () => {} }
+    const { component, props, emit } = instance
+    const ctx = { expose: () => {}, emit }
 
     const setupFn =
       typeof component === 'function' ? component : component.setup