feat(runtime-dom): defineCustomElement

This commit is contained in:
Evan You 2021-07-12 15:32:38 -04:00
parent 42ace9577d
commit 8610e1c9e2
8 changed files with 546 additions and 14 deletions

View File

@ -279,12 +279,15 @@ export interface ComponentInternalInstance {
* @internal * @internal
*/ */
emitsOptions: ObjectEmitsOptions | null emitsOptions: ObjectEmitsOptions | null
/** /**
* resolved inheritAttrs options * resolved inheritAttrs options
* @internal * @internal
*/ */
inheritAttrs?: boolean inheritAttrs?: boolean
/**
* is custom element?
*/
isCE?: boolean
// the rest are only for stateful components --------------------------------- // the rest are only for stateful components ---------------------------------
@ -519,6 +522,11 @@ export function createComponentInstance(
instance.root = parent ? parent.root : instance instance.root = parent ? parent.root : instance
instance.emit = emit.bind(null, instance) instance.emit = emit.bind(null, instance)
// apply custom element special handling
if (vnode.ce) {
vnode.ce(instance)
}
return instance return instance
} }

View File

@ -1,6 +1,9 @@
import { Data } from '../component' import { Data } from '../component'
import { Slots, RawSlots } from '../componentSlots' import { Slots, RawSlots } from '../componentSlots'
import { ContextualRenderFn } from '../componentRenderContext' import {
ContextualRenderFn,
currentRenderingInstance
} from '../componentRenderContext'
import { Comment, isVNode } from '../vnode' import { Comment, isVNode } from '../vnode'
import { import {
VNodeArrayChildren, VNodeArrayChildren,
@ -11,6 +14,7 @@ import {
} from '../vnode' } from '../vnode'
import { PatchFlags, SlotFlags } from '@vue/shared' import { PatchFlags, SlotFlags } from '@vue/shared'
import { warn } from '../warning' import { warn } from '../warning'
import { createVNode } from '@vue/runtime-core'
/** /**
* Compiler runtime helper for rendering `<slot/>` * Compiler runtime helper for rendering `<slot/>`
@ -25,6 +29,14 @@ export function renderSlot(
fallback?: () => VNodeArrayChildren, fallback?: () => VNodeArrayChildren,
noSlotted?: boolean noSlotted?: boolean
): VNode { ): VNode {
if (currentRenderingInstance!.isCE) {
return createVNode(
'slot',
name === 'default' ? null : { name },
fallback && fallback()
)
}
let slot = slots[name] let slot = slots[name]
if (__DEV__ && slot && slot.length > 1) { if (__DEV__ && slot && slot.length > 1) {

View File

@ -25,7 +25,7 @@ import { isAsyncWrapper } from './apiAsyncComponent'
export type RootHydrateFunction = ( export type RootHydrateFunction = (
vnode: VNode<Node, Element>, vnode: VNode<Node, Element>,
container: Element container: Element | ShadowRoot
) => void ) => void
const enum DOMNodeTypes { const enum DOMNodeTypes {

View File

@ -93,7 +93,7 @@ export interface Renderer<HostElement = RendererElement> {
createApp: CreateAppFunction<HostElement> createApp: CreateAppFunction<HostElement>
} }
export interface HydrationRenderer extends Renderer<Element> { export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
hydrate: RootHydrateFunction hydrate: RootHydrateFunction
} }

View File

@ -136,11 +136,6 @@ export interface VNode<
*/ */
[ReactiveFlags.SKIP]: true [ReactiveFlags.SKIP]: true
/**
* @internal __COMPAT__ only
*/
isCompatRoot?: true
type: VNodeTypes type: VNodeTypes
props: (VNodeProps & ExtraProps) | null props: (VNodeProps & ExtraProps) | null
key: string | number | null key: string | number | null
@ -155,6 +150,7 @@ export interface VNode<
* - Slot fragment vnodes with :slotted SFC styles. * - Slot fragment vnodes with :slotted SFC styles.
* - Component vnodes (during patch/hydration) so that its root node can * - Component vnodes (during patch/hydration) so that its root node can
* inherit the component's slotScopeIds * inherit the component's slotScopeIds
* @internal
*/ */
slotScopeIds: string[] | null slotScopeIds: string[] | null
children: VNodeNormalizedChildren children: VNodeNormalizedChildren
@ -167,24 +163,50 @@ export interface VNode<
anchor: HostNode | null // fragment anchor anchor: HostNode | null // fragment anchor
target: HostElement | null // teleport target target: HostElement | null // teleport target
targetAnchor: HostNode | null // teleport target anchor 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
suspense: SuspenseBoundary | null suspense: SuspenseBoundary | null
/**
* @internal
*/
ssContent: VNode | null ssContent: VNode | null
/**
* @internal
*/
ssFallback: VNode | null ssFallback: VNode | null
// optimization only // optimization only
shapeFlag: number shapeFlag: number
patchFlag: number patchFlag: number
/**
* @internal
*/
dynamicProps: string[] | null dynamicProps: string[] | null
/**
* @internal
*/
dynamicChildren: VNode[] | null dynamicChildren: VNode[] | null
// application root node only // application root node only
appContext: AppContext | null appContext: AppContext | null
// v-for memo /**
* @internal attached by v-memo
*/
memo?: any[] 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 // Since v-if and v-for are the two possible ways node structure can dynamically

View File

@ -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>`)
})
})
})

View File

@ -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
}
}

View File

@ -28,12 +28,15 @@ const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)
// lazy create the renderer - this makes core renderer logic tree-shakable // lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue. // in case the user only imports reactivity utilities from Vue.
let renderer: Renderer<Element> | HydrationRenderer let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
let enabledHydration = false let enabledHydration = false
function ensureRenderer() { function ensureRenderer() {
return renderer || (renderer = createRenderer<Node, Element>(rendererOptions)) return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
} }
function ensureHydrationRenderer() { function ensureHydrationRenderer() {
@ -47,7 +50,7 @@ function ensureHydrationRenderer() {
// use explicit type casts here to avoid import() calls in rolled-up d.ts // use explicit type casts here to avoid import() calls in rolled-up d.ts
export const render = ((...args) => { export const render = ((...args) => {
ensureRenderer().render(...args) ensureRenderer().render(...args)
}) as RootRenderFunction<Element> }) as RootRenderFunction<Element | ShadowRoot>
export const hydrate = ((...args) => { export const hydrate = ((...args) => {
ensureHydrationRenderer().hydrate(...args) ensureHydrationRenderer().hydrate(...args)
@ -191,6 +194,13 @@ function normalizeContainer(
return container as any return container as any
} }
// Custom element support
export {
defineCustomElement,
defineSSRCustomElement,
VueElement
} from './apiCustomElement'
// SFC CSS utilities // SFC CSS utilities
export { useCssModule } from './helpers/useCssModule' export { useCssModule } from './helpers/useCssModule'
export { useCssVars } from './helpers/useCssVars' export { useCssVars } from './helpers/useCssVars'