]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-dom): defineCustomElement
authorEvan You <yyx990803@gmail.com>
Mon, 12 Jul 2021 19:32:38 +0000 (15:32 -0400)
committerEvan You <yyx990803@gmail.com>
Fri, 16 Jul 2021 18:30:49 +0000 (14:30 -0400)
packages/runtime-core/src/component.ts
packages/runtime-core/src/helpers/renderSlot.ts
packages/runtime-core/src/hydration.ts
packages/runtime-core/src/renderer.ts
packages/runtime-core/src/vnode.ts
packages/runtime-dom/__tests__/customElement.spec.ts [new file with mode: 0644]
packages/runtime-dom/src/apiCustomElement.ts [new file with mode: 0644]
packages/runtime-dom/src/index.ts

index 3caf0df5864825d348d6d384ec7bb77b0861063e..528c3becdb1548bc243aa9aeae94b789165397fe 100644 (file)
@@ -279,12 +279,15 @@ export interface ComponentInternalInstance {
    * @internal
    */
   emitsOptions: ObjectEmitsOptions | null
-
   /**
    * resolved inheritAttrs options
    * @internal
    */
   inheritAttrs?: boolean
+  /**
+   * is custom element?
+   */
+  isCE?: boolean
 
   // the rest are only for stateful components ---------------------------------
 
@@ -519,6 +522,11 @@ export function createComponentInstance(
   instance.root = parent ? parent.root : instance
   instance.emit = emit.bind(null, instance)
 
+  // apply custom element special handling
+  if (vnode.ce) {
+    vnode.ce(instance)
+  }
+
   return instance
 }
 
index 181d49a547cfe840e592caa6dcf914d72d6282d0..9389cc28abe73bd5849491bc84fd05ef37ec5d01 100644 (file)
@@ -1,6 +1,9 @@
 import { Data } from '../component'
 import { Slots, RawSlots } from '../componentSlots'
-import { ContextualRenderFn } from '../componentRenderContext'
+import {
+  ContextualRenderFn,
+  currentRenderingInstance
+} from '../componentRenderContext'
 import { Comment, isVNode } from '../vnode'
 import {
   VNodeArrayChildren,
@@ -11,6 +14,7 @@ import {
 } from '../vnode'
 import { PatchFlags, SlotFlags } from '@vue/shared'
 import { warn } from '../warning'
+import { createVNode } from '@vue/runtime-core'
 
 /**
  * Compiler runtime helper for rendering `<slot/>`
@@ -25,6 +29,14 @@ export function renderSlot(
   fallback?: () => VNodeArrayChildren,
   noSlotted?: boolean
 ): VNode {
+  if (currentRenderingInstance!.isCE) {
+    return createVNode(
+      'slot',
+      name === 'default' ? null : { name },
+      fallback && fallback()
+    )
+  }
+
   let slot = slots[name]
 
   if (__DEV__ && slot && slot.length > 1) {
index 50274fcb489b5ffd80aec037b4596a9174134522..4e5fc8b5d22d30c5af1793130b35f8055ae3e0a1 100644 (file)
@@ -25,7 +25,7 @@ import { isAsyncWrapper } from './apiAsyncComponent'
 
 export type RootHydrateFunction = (
   vnode: VNode<Node, Element>,
-  container: Element
+  container: Element | ShadowRoot
 ) => void
 
 const enum DOMNodeTypes {
index cfa175301e35f816f2bd85ca8c7c2093acfe789c..7cf9abc9cc3d0f5dac332be2f7d2dea09c66bba3 100644 (file)
@@ -93,7 +93,7 @@ export interface Renderer<HostElement = RendererElement> {
   createApp: CreateAppFunction<HostElement>
 }
 
-export interface HydrationRenderer extends Renderer<Element> {
+export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
   hydrate: RootHydrateFunction
 }
 
index aceaf3a90128408d962214018a15ac90711147b9..703c20fe971ef11885603dccff3797f3403f628a 100644 (file)
@@ -136,11 +136,6 @@ export interface VNode<
    */
   [ReactiveFlags.SKIP]: true
 
-  /**
-   * @internal __COMPAT__ only
-   */
-  isCompatRoot?: true
-
   type: VNodeTypes
   props: (VNodeProps & ExtraProps) | null
   key: string | number | null
@@ -155,6 +150,7 @@ export interface VNode<
    * - Slot fragment vnodes with :slotted SFC styles.
    * - Component vnodes (during patch/hydration) so that its root node can
    *   inherit the component's slotScopeIds
+   * @internal
    */
   slotScopeIds: string[] | null
   children: VNodeNormalizedChildren
@@ -167,24 +163,50 @@ export interface VNode<
   anchor: HostNode | null // fragment anchor
   target: HostElement | null // teleport target
   targetAnchor: HostNode | null // teleport target anchor
-  staticCount?: number // number of elements contained in a static vnode
+  /**
+   * number of elements contained in a static vnode
+   * @internal
+   */
+  staticCount: number
 
   // suspense
   suspense: SuspenseBoundary | null
+  /**
+   * @internal
+   */
   ssContent: VNode | null
+  /**
+   * @internal
+   */
   ssFallback: VNode | null
 
   // optimization only
   shapeFlag: number
   patchFlag: number
+  /**
+   * @internal
+   */
   dynamicProps: string[] | null
+  /**
+   * @internal
+   */
   dynamicChildren: VNode[] | null
 
   // application root node only
   appContext: AppContext | null
 
-  // v-for memo
+  /**
+   * @internal attached by v-memo
+   */
   memo?: any[]
+  /**
+   * @internal __COMPAT__ only
+   */
+  isCompatRoot?: true
+  /**
+   * @internal custom element interception hook
+   */
+  ce?: (instance: ComponentInternalInstance) => void
 }
 
 // Since v-if and v-for are the two possible ways node structure can dynamically
diff --git a/packages/runtime-dom/__tests__/customElement.spec.ts b/packages/runtime-dom/__tests__/customElement.spec.ts
new file mode 100644 (file)
index 0000000..d4f86f5
--- /dev/null
@@ -0,0 +1,224 @@
+import {
+  defineCustomElement,
+  h,
+  nextTick,
+  ref,
+  renderSlot,
+  VueElement
+} from '../src'
+
+describe('defineCustomElement', () => {
+  const container = document.createElement('div')
+  document.body.appendChild(container)
+
+  beforeEach(() => {
+    container.innerHTML = ''
+  })
+
+  describe('mounting/unmount', () => {
+    const E = defineCustomElement({
+      render: () => h('div', 'hello')
+    })
+    customElements.define('my-element', E)
+
+    test('should work', () => {
+      container.innerHTML = `<my-element></my-element>`
+      const e = container.childNodes[0] as VueElement
+      expect(e).toBeInstanceOf(E)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+    })
+
+    test('should work w/ manual instantiation', () => {
+      const e = new E()
+      // should lazy init
+      expect(e._instance).toBe(null)
+      // should initialize on connect
+      container.appendChild(e)
+      expect(e._instance).toBeTruthy()
+      expect(e.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
+    })
+
+    test('should unmount on remove', async () => {
+      container.innerHTML = `<my-element></my-element>`
+      const e = container.childNodes[0] as VueElement
+      container.removeChild(e)
+      await nextTick()
+      expect(e._instance).toBe(null)
+      expect(e.shadowRoot!.innerHTML).toBe('')
+    })
+
+    test('should not unmount on move', async () => {
+      container.innerHTML = `<div><my-element></my-element></div>`
+      const e = container.childNodes[0].childNodes[0] as VueElement
+      const i = e._instance
+      // moving from one parent to another - this will trigger both disconnect
+      // and connected callbacks synchronously
+      container.appendChild(e)
+      await nextTick()
+      // should be the same instance
+      expect(e._instance).toBe(i)
+      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
+    })
+  })
+
+  describe('props', () => {
+    const E = defineCustomElement({
+      props: ['foo', 'bar', 'bazQux'],
+      render() {
+        return [
+          h('div', null, this.foo),
+          h('div', null, this.bazQux || (this.bar && this.bar.x))
+        ]
+      }
+    })
+    customElements.define('my-el-props', E)
+
+    test('props via attribute', async () => {
+      // bazQux should map to `baz-qux` attribute
+      container.innerHTML = `<my-el-props foo="hello" baz-qux="bye"></my-el-props>`
+      const e = container.childNodes[0] as VueElement
+      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div><div>bye</div>')
+
+      // change attr
+      e.setAttribute('foo', 'changed')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div><div>bye</div>')
+
+      e.setAttribute('baz-qux', 'changed')
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe(
+        '<div>changed</div><div>changed</div>'
+      )
+    })
+
+    test('props via properties', async () => {
+      const e = new E()
+      e.foo = 'one'
+      e.bar = { x: 'two' }
+      container.appendChild(e)
+      expect(e.shadowRoot!.innerHTML).toBe('<div>one</div><div>two</div>')
+
+      e.foo = 'three'
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>two</div>')
+
+      e.bazQux = 'four'
+      await nextTick()
+      expect(e.shadowRoot!.innerHTML).toBe('<div>three</div><div>four</div>')
+    })
+  })
+
+  describe('emits', () => {
+    const E = defineCustomElement({
+      setup(_, { emit }) {
+        emit('created')
+        return () =>
+          h('div', {
+            onClick: () => emit('my-click', 1)
+          })
+      }
+    })
+    customElements.define('my-el-emits', E)
+
+    test('emit on connect', () => {
+      const e = new E()
+      const spy = jest.fn()
+      e.addEventListener('created', spy)
+      container.appendChild(e)
+      expect(spy).toHaveBeenCalled()
+    })
+
+    test('emit on interaction', () => {
+      container.innerHTML = `<my-el-emits></my-el-emits>`
+      const e = container.childNodes[0] as VueElement
+      const spy = jest.fn()
+      e.addEventListener('my-click', spy)
+      e.shadowRoot!.childNodes[0].dispatchEvent(new CustomEvent('click'))
+      expect(spy).toHaveBeenCalled()
+      expect(spy.mock.calls[0][0]).toMatchObject({
+        detail: [1]
+      })
+    })
+  })
+
+  describe('slots', () => {
+    const E = defineCustomElement({
+      render() {
+        return [
+          h('div', null, [
+            renderSlot(this.$slots, 'default', undefined, () => [
+              h('div', 'fallback')
+            ])
+          ]),
+          h('div', null, renderSlot(this.$slots, 'named'))
+        ]
+      }
+    })
+    customElements.define('my-el-slots', E)
+
+    test('default slot', () => {
+      container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
+      const e = container.childNodes[0] as VueElement
+      // native slots allocation does not affect innerHTML, so we just
+      // verify that we've rendered the correct native slots here...
+      expect(e.shadowRoot!.innerHTML).toBe(
+        `<div><slot><div>fallback</div></slot></div><div><slot name="named"></slot></div>`
+      )
+    })
+  })
+
+  describe('provide/inject', () => {
+    const Consumer = defineCustomElement({
+      inject: ['foo'],
+      render(this: any) {
+        return h('div', this.foo.value)
+      }
+    })
+    customElements.define('my-consumer', Consumer)
+
+    test('over nested usage', async () => {
+      const foo = ref('injected!')
+      const Provider = defineCustomElement({
+        provide: {
+          foo
+        },
+        render() {
+          return h('my-consumer')
+        }
+      })
+      customElements.define('my-provider', Provider)
+      container.innerHTML = `<my-provider><my-provider>`
+      const provider = container.childNodes[0] as VueElement
+      const consumer = provider.shadowRoot!.childNodes[0] as VueElement
+
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
+
+      foo.value = 'changed!'
+      await nextTick()
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
+    })
+
+    test('over slot composition', async () => {
+      const foo = ref('injected!')
+      const Provider = defineCustomElement({
+        provide: {
+          foo
+        },
+        render() {
+          return renderSlot(this.$slots, 'default')
+        }
+      })
+      customElements.define('my-provider-2', Provider)
+
+      container.innerHTML = `<my-provider-2><my-consumer></my-consumer><my-provider-2>`
+      const provider = container.childNodes[0]
+      const consumer = provider.childNodes[0] as VueElement
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>injected!</div>`)
+
+      foo.value = 'changed!'
+      await nextTick()
+      expect(consumer.shadowRoot!.innerHTML).toBe(`<div>changed!</div>`)
+    })
+  })
+})
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
new file mode 100644 (file)
index 0000000..82992ae
--- /dev/null
@@ -0,0 +1,256 @@
+import {
+  Component,
+  ComponentOptionsMixin,
+  ComponentOptionsWithArrayProps,
+  ComponentOptionsWithObjectProps,
+  ComponentOptionsWithoutProps,
+  ComponentPropsOptions,
+  ComponentPublicInstance,
+  ComputedOptions,
+  EmitsOptions,
+  MethodOptions,
+  RenderFunction,
+  SetupContext,
+  ComponentInternalInstance,
+  VNode,
+  RootHydrateFunction,
+  ExtractPropTypes,
+  createVNode,
+  defineComponent,
+  nextTick,
+  warn
+} from '@vue/runtime-core'
+import { camelize, hyphenate, isArray } from '@vue/shared'
+import { hydrate, render } from '.'
+
+type VueElementConstructor<P = {}> = {
+  new (): VueElement & P
+}
+
+// defineCustomElement provides the same type inference as defineComponent
+// so most of the following overloads should be kept in sync w/ defineComponent.
+
+// overload 1: direct setup function
+export function defineCustomElement<Props, RawBindings = object>(
+  setup: (
+    props: Readonly<Props>,
+    ctx: SetupContext
+  ) => RawBindings | RenderFunction
+): VueElementConstructor<Props>
+
+// overload 2: object format with no props
+export function defineCustomElement<
+  Props = {},
+  RawBindings = {},
+  D = {},
+  C extends ComputedOptions = {},
+  M extends MethodOptions = {},
+  Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
+  Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
+  E extends EmitsOptions = EmitsOptions,
+  EE extends string = string
+>(
+  options: ComponentOptionsWithoutProps<
+    Props,
+    RawBindings,
+    D,
+    C,
+    M,
+    Mixin,
+    Extends,
+    E,
+    EE
+  >
+): VueElementConstructor<Props>
+
+// overload 3: object format with array props declaration
+export function defineCustomElement<
+  PropNames extends string,
+  RawBindings,
+  D,
+  C extends ComputedOptions = {},
+  M extends MethodOptions = {},
+  Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
+  Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
+  E extends EmitsOptions = Record<string, any>,
+  EE extends string = string
+>(
+  options: ComponentOptionsWithArrayProps<
+    PropNames,
+    RawBindings,
+    D,
+    C,
+    M,
+    Mixin,
+    Extends,
+    E,
+    EE
+  >
+): VueElementConstructor<{ [K in PropNames]: any }>
+
+// overload 4: object format with object props declaration
+export function defineCustomElement<
+  PropsOptions extends Readonly<ComponentPropsOptions>,
+  RawBindings,
+  D,
+  C extends ComputedOptions = {},
+  M extends MethodOptions = {},
+  Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
+  Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
+  E extends EmitsOptions = Record<string, any>,
+  EE extends string = string
+>(
+  options: ComponentOptionsWithObjectProps<
+    PropsOptions,
+    RawBindings,
+    D,
+    C,
+    M,
+    Mixin,
+    Extends,
+    E,
+    EE
+  >
+): VueElementConstructor<ExtractPropTypes<PropsOptions>>
+
+// overload 5: defining a custom element from the returned value of
+// `defineComponent`
+export function defineCustomElement(options: {
+  new (...args: any[]): ComponentPublicInstance
+}): VueElementConstructor
+
+export function defineCustomElement(
+  options: any,
+  hydate?: RootHydrateFunction
+): VueElementConstructor {
+  const Comp = defineComponent(options as any)
+  const { props } = options
+  const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : []
+  const attrKeys = rawKeys.map(hyphenate)
+  const propKeys = rawKeys.map(camelize)
+
+  class VueCustomElement extends VueElement {
+    static get observedAttributes() {
+      return attrKeys
+    }
+    constructor() {
+      super(Comp, attrKeys, hydate)
+    }
+  }
+
+  for (const key of propKeys) {
+    Object.defineProperty(VueCustomElement.prototype, key, {
+      get() {
+        return this._getProp(key)
+      },
+      set(val) {
+        this._setProp(key, val)
+      }
+    })
+  }
+
+  return VueCustomElement
+}
+
+export const defineSSRCustomElement = ((options: any) => {
+  // @ts-ignore
+  return defineCustomElement(options, hydrate)
+}) as typeof defineCustomElement
+
+export class VueElement extends HTMLElement {
+  /**
+   * @internal
+   */
+  _props: Record<string, any> = {}
+  /**
+   * @internal
+   */
+  _instance: ComponentInternalInstance | null = null
+  /**
+   * @internal
+   */
+  _connected = false
+
+  constructor(
+    private _def: Component,
+    private _attrs: string[],
+    hydrate?: RootHydrateFunction
+  ) {
+    super()
+    if (this.shadowRoot && hydrate) {
+      hydrate(this._initVNode(), this.shadowRoot)
+    } else {
+      if (__DEV__ && this.shadowRoot) {
+        warn(
+          `Custom element has pre-rendered declarative shadow root but is not ` +
+            `defined as hydratable. Use \`defineSSRCustomElement\`.`
+        )
+      }
+      this.attachShadow({ mode: 'open' })
+    }
+  }
+
+  attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
+    if (this._attrs.includes(name)) {
+      this._setProp(camelize(name), newValue)
+    }
+  }
+
+  connectedCallback() {
+    this._connected = true
+    if (!this._instance) {
+      render(this._initVNode(), this.shadowRoot!)
+    }
+  }
+
+  disconnectedCallback() {
+    this._connected = false
+    nextTick(() => {
+      if (!this._connected) {
+        render(null, this.shadowRoot!)
+        this._instance = null
+      }
+    })
+  }
+
+  protected _getProp(key: string) {
+    return this._props[key]
+  }
+
+  protected _setProp(key: string, val: any) {
+    const oldValue = this._props[key]
+    this._props[key] = val
+    if (this._instance && val !== oldValue) {
+      this._instance.props[key] = val
+    }
+  }
+
+  protected _initVNode(): VNode<any, any> {
+    const vnode = createVNode(this._def, this._props)
+    vnode.ce = instance => {
+      this._instance = instance
+      instance.isCE = true
+
+      // intercept emit
+      instance.emit = (event: string, ...args: any[]) => {
+        this.dispatchEvent(
+          new CustomEvent(event, {
+            detail: args
+          })
+        )
+      }
+
+      // locate nearest Vue custom element parent for provide/inject
+      let parent: Node | null = this
+      while (
+        (parent = parent && (parent.parentNode || (parent as ShadowRoot).host))
+      ) {
+        if (parent instanceof VueElement) {
+          instance.parent = parent._instance
+          break
+        }
+      }
+    }
+    return vnode
+  }
+}
index 2ad8f2b2170a244d8cc4285122d1b3d86e866343..257c554a683c6b4e1aa7575b00e88fbfb2156c72 100644 (file)
@@ -28,12 +28,15 @@ const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)
 
 // lazy create the renderer - this makes core renderer logic tree-shakable
 // in case the user only imports reactivity utilities from Vue.
-let renderer: Renderer<Element> | HydrationRenderer
+let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
 
 let enabledHydration = false
 
 function ensureRenderer() {
-  return renderer || (renderer = createRenderer<Node, Element>(rendererOptions))
+  return (
+    renderer ||
+    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
+  )
 }
 
 function ensureHydrationRenderer() {
@@ -47,7 +50,7 @@ function ensureHydrationRenderer() {
 // use explicit type casts here to avoid import() calls in rolled-up d.ts
 export const render = ((...args) => {
   ensureRenderer().render(...args)
-}) as RootRenderFunction<Element>
+}) as RootRenderFunction<Element | ShadowRoot>
 
 export const hydrate = ((...args) => {
   ensureHydrationRenderer().hydrate(...args)
@@ -191,6 +194,13 @@ function normalizeContainer(
   return container as any
 }
 
+// Custom element support
+export {
+  defineCustomElement,
+  defineSSRCustomElement,
+  VueElement
+} from './apiCustomElement'
+
 // SFC CSS utilities
 export { useCssModule } from './helpers/useCssModule'
 export { useCssVars } from './helpers/useCssVars'