mirror of https://github.com/vuejs/core.git
feat(custom-element): support shadowRoot: false in defineCustomElement()
close #4314 close #4404
This commit is contained in:
parent
267093c314
commit
37d2ce5d8e
|
@ -10,12 +10,12 @@ import {
|
||||||
type VNode,
|
type VNode,
|
||||||
type VNodeArrayChildren,
|
type VNodeArrayChildren,
|
||||||
createBlock,
|
createBlock,
|
||||||
|
createVNode,
|
||||||
isVNode,
|
isVNode,
|
||||||
openBlock,
|
openBlock,
|
||||||
} 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'
|
|
||||||
import { isAsyncWrapper } from '../apiAsyncComponent'
|
import { isAsyncWrapper } from '../apiAsyncComponent'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -37,8 +37,19 @@ export function renderSlot(
|
||||||
isAsyncWrapper(currentRenderingInstance!.parent) &&
|
isAsyncWrapper(currentRenderingInstance!.parent) &&
|
||||||
currentRenderingInstance!.parent.isCE)
|
currentRenderingInstance!.parent.isCE)
|
||||||
) {
|
) {
|
||||||
|
// in custom element mode, render <slot/> as actual slot outlets
|
||||||
|
// wrap it with a fragment because in shadowRoot: false mode the slot
|
||||||
|
// element gets replaced by injected content
|
||||||
if (name !== 'default') props.name = name
|
if (name !== 'default') props.name = name
|
||||||
return createVNode('slot', props, fallback && fallback())
|
return (
|
||||||
|
openBlock(),
|
||||||
|
createBlock(
|
||||||
|
Fragment,
|
||||||
|
null,
|
||||||
|
[createVNode('slot', props, fallback && fallback())],
|
||||||
|
PatchFlags.STABLE_FRAGMENT,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
let slot = slots[name]
|
let slot = slots[name]
|
||||||
|
|
|
@ -505,7 +505,7 @@ describe('defineCustomElement', () => {
|
||||||
})
|
})
|
||||||
customElements.define('my-el-slots', E)
|
customElements.define('my-el-slots', E)
|
||||||
|
|
||||||
test('default slot', () => {
|
test('render slots correctly', () => {
|
||||||
container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
|
container.innerHTML = `<my-el-slots><span>hi</span></my-el-slots>`
|
||||||
const e = container.childNodes[0] as VueElement
|
const e = container.childNodes[0] as VueElement
|
||||||
// native slots allocation does not affect innerHTML, so we just
|
// native slots allocation does not affect innerHTML, so we just
|
||||||
|
@ -777,4 +777,71 @@ describe('defineCustomElement', () => {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('shadowRoot: false', () => {
|
||||||
|
const E = defineCustomElement({
|
||||||
|
shadowRoot: false,
|
||||||
|
props: {
|
||||||
|
msg: {
|
||||||
|
type: String,
|
||||||
|
default: 'hello',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h('div', this.msg)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
customElements.define('my-el-shadowroot-false', E)
|
||||||
|
|
||||||
|
test('should work', async () => {
|
||||||
|
function raf() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
requestAnimationFrame(resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `<my-el-shadowroot-false></my-el-shadowroot-false>`
|
||||||
|
const e = container.childNodes[0] as VueElement
|
||||||
|
await raf()
|
||||||
|
expect(e).toBeInstanceOf(E)
|
||||||
|
expect(e._instance).toBeTruthy()
|
||||||
|
expect(e.innerHTML).toBe(`<div>hello</div>`)
|
||||||
|
expect(e.shadowRoot).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggle = ref(true)
|
||||||
|
const ES = defineCustomElement({
|
||||||
|
shadowRoot: false,
|
||||||
|
render() {
|
||||||
|
return [
|
||||||
|
renderSlot(this.$slots, 'default'),
|
||||||
|
toggle.value ? renderSlot(this.$slots, 'named') : null,
|
||||||
|
renderSlot(this.$slots, 'omitted', {}, () => [h('div', 'fallback')]),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
customElements.define('my-el-shadowroot-false-slots', ES)
|
||||||
|
|
||||||
|
test('should render slots', async () => {
|
||||||
|
container.innerHTML =
|
||||||
|
`<my-el-shadowroot-false-slots>` +
|
||||||
|
`<span>default</span>text` +
|
||||||
|
`<div slot="named">named</div>` +
|
||||||
|
`</my-el-shadowroot-false-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.innerHTML).toBe(
|
||||||
|
`<span>default</span>text` +
|
||||||
|
`<div slot="named">named</div>` +
|
||||||
|
`<div>fallback</div>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
toggle.value = false
|
||||||
|
await nextTick()
|
||||||
|
expect(e.innerHTML).toBe(
|
||||||
|
`<span>default</span>text` + `<!---->` + `<div>fallback</div>`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
type SetupContext,
|
type SetupContext,
|
||||||
type SlotsType,
|
type SlotsType,
|
||||||
type VNode,
|
type VNode,
|
||||||
|
type VNodeProps,
|
||||||
createVNode,
|
createVNode,
|
||||||
defineComponent,
|
defineComponent,
|
||||||
nextTick,
|
nextTick,
|
||||||
|
@ -33,21 +34,28 @@ export type VueElementConstructor<P = {}> = {
|
||||||
new (initialProps?: Record<string, any>): VueElement & P
|
new (initialProps?: Record<string, any>): VueElement & P
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CustomElementOptions {
|
||||||
|
styles?: string[]
|
||||||
|
shadowRoot?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// defineCustomElement provides the same type inference as defineComponent
|
// defineCustomElement provides the same type inference as defineComponent
|
||||||
// so most of the following overloads should be kept in sync w/ defineComponent.
|
// so most of the following overloads should be kept in sync w/ defineComponent.
|
||||||
|
|
||||||
// overload 1: direct setup function
|
// overload 1: direct setup function
|
||||||
export function defineCustomElement<Props, RawBindings = object>(
|
export function defineCustomElement<Props, RawBindings = object>(
|
||||||
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
|
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
|
||||||
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
|
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
|
||||||
props?: (keyof Props)[]
|
CustomElementOptions & {
|
||||||
},
|
props?: (keyof Props)[]
|
||||||
|
},
|
||||||
): VueElementConstructor<Props>
|
): VueElementConstructor<Props>
|
||||||
export function defineCustomElement<Props, RawBindings = object>(
|
export function defineCustomElement<Props, RawBindings = object>(
|
||||||
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
|
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
|
||||||
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
|
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
|
||||||
props?: ComponentObjectPropsOptions<Props>
|
CustomElementOptions & {
|
||||||
},
|
props?: ComponentObjectPropsOptions<Props>
|
||||||
|
},
|
||||||
): VueElementConstructor<Props>
|
): VueElementConstructor<Props>
|
||||||
|
|
||||||
// overload 2: defineCustomElement with options object, infer props from options
|
// overload 2: defineCustomElement with options object, infer props from options
|
||||||
|
@ -81,27 +89,27 @@ export function defineCustomElement<
|
||||||
: { [key in PropsKeys]?: any },
|
: { [key in PropsKeys]?: any },
|
||||||
ResolvedProps = InferredProps & EmitsToProps<RuntimeEmitsOptions>,
|
ResolvedProps = InferredProps & EmitsToProps<RuntimeEmitsOptions>,
|
||||||
>(
|
>(
|
||||||
options: {
|
options: CustomElementOptions & {
|
||||||
props?: (RuntimePropsOptions & ThisType<void>) | PropsKeys[]
|
props?: (RuntimePropsOptions & ThisType<void>) | PropsKeys[]
|
||||||
} & ComponentOptionsBase<
|
} & ComponentOptionsBase<
|
||||||
ResolvedProps,
|
ResolvedProps,
|
||||||
SetupBindings,
|
SetupBindings,
|
||||||
Data,
|
Data,
|
||||||
Computed,
|
Computed,
|
||||||
Methods,
|
Methods,
|
||||||
Mixin,
|
Mixin,
|
||||||
Extends,
|
Extends,
|
||||||
RuntimeEmitsOptions,
|
RuntimeEmitsOptions,
|
||||||
EmitsKeys,
|
EmitsKeys,
|
||||||
{}, // Defaults
|
{}, // Defaults
|
||||||
InjectOptions,
|
InjectOptions,
|
||||||
InjectKeys,
|
InjectKeys,
|
||||||
Slots,
|
Slots,
|
||||||
LocalComponents,
|
LocalComponents,
|
||||||
Directives,
|
Directives,
|
||||||
Exposed,
|
Exposed,
|
||||||
Provide
|
Provide
|
||||||
> &
|
> &
|
||||||
ThisType<
|
ThisType<
|
||||||
CreateComponentPublicInstanceWithMixins<
|
CreateComponentPublicInstanceWithMixins<
|
||||||
Readonly<ResolvedProps>,
|
Readonly<ResolvedProps>,
|
||||||
|
@ -163,7 +171,7 @@ const BaseClass = (
|
||||||
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
|
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
|
||||||
) as typeof HTMLElement
|
) as typeof HTMLElement
|
||||||
|
|
||||||
type InnerComponentDef = ConcreteComponent & { styles?: string[] }
|
type InnerComponentDef = ConcreteComponent & CustomElementOptions
|
||||||
|
|
||||||
export class VueElement extends BaseClass {
|
export class VueElement extends BaseClass {
|
||||||
/**
|
/**
|
||||||
|
@ -176,14 +184,19 @@ export class VueElement extends BaseClass {
|
||||||
private _numberProps: Record<string, true> | null = null
|
private _numberProps: Record<string, true> | null = null
|
||||||
private _styles?: HTMLStyleElement[]
|
private _styles?: HTMLStyleElement[]
|
||||||
private _ob?: MutationObserver | null = null
|
private _ob?: MutationObserver | null = null
|
||||||
|
private _root: Element | ShadowRoot
|
||||||
|
private _slots?: Record<string, Node[]>
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _def: InnerComponentDef,
|
private _def: InnerComponentDef,
|
||||||
private _props: Record<string, any> = {},
|
private _props: Record<string, any> = {},
|
||||||
hydrate?: RootHydrateFunction,
|
hydrate?: RootHydrateFunction,
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
|
// TODO handle non-shadowRoot hydration
|
||||||
if (this.shadowRoot && hydrate) {
|
if (this.shadowRoot && hydrate) {
|
||||||
hydrate(this._createVNode(), this.shadowRoot)
|
hydrate(this._createVNode(), this.shadowRoot)
|
||||||
|
this._root = this.shadowRoot
|
||||||
} else {
|
} else {
|
||||||
if (__DEV__ && this.shadowRoot) {
|
if (__DEV__ && this.shadowRoot) {
|
||||||
warn(
|
warn(
|
||||||
|
@ -191,7 +204,12 @@ export class VueElement extends BaseClass {
|
||||||
`defined as hydratable. Use \`defineSSRCustomElement\`.`,
|
`defined as hydratable. Use \`defineSSRCustomElement\`.`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.attachShadow({ mode: 'open' })
|
if (_def.shadowRoot !== false) {
|
||||||
|
this.attachShadow({ mode: 'open' })
|
||||||
|
this._root = this.shadowRoot!
|
||||||
|
} else {
|
||||||
|
this._root = this
|
||||||
|
}
|
||||||
if (!(this._def as ComponentOptions).__asyncLoader) {
|
if (!(this._def as ComponentOptions).__asyncLoader) {
|
||||||
// for sync component defs we can immediately resolve props
|
// for sync component defs we can immediately resolve props
|
||||||
this._resolveProps(this._def)
|
this._resolveProps(this._def)
|
||||||
|
@ -200,6 +218,9 @@ export class VueElement extends BaseClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
this._parseSlots()
|
||||||
|
}
|
||||||
this._connected = true
|
this._connected = true
|
||||||
if (!this._instance) {
|
if (!this._instance) {
|
||||||
if (this._resolved) {
|
if (this._resolved) {
|
||||||
|
@ -218,7 +239,7 @@ export class VueElement extends BaseClass {
|
||||||
this._ob.disconnect()
|
this._ob.disconnect()
|
||||||
this._ob = null
|
this._ob = null
|
||||||
}
|
}
|
||||||
render(null, this.shadowRoot!)
|
render(null, this._root)
|
||||||
this._instance = null
|
this._instance = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -353,11 +374,16 @@ export class VueElement extends BaseClass {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _update() {
|
private _update() {
|
||||||
render(this._createVNode(), this.shadowRoot!)
|
render(this._createVNode(), this._root)
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createVNode(): VNode<any, any> {
|
private _createVNode(): VNode<any, any> {
|
||||||
const vnode = createVNode(this._def, extend({}, this._props))
|
const baseProps: VNodeProps = {}
|
||||||
|
if (!this.shadowRoot) {
|
||||||
|
baseProps.onVnodeMounted = baseProps.onVnodeUpdated =
|
||||||
|
this._renderSlots.bind(this)
|
||||||
|
}
|
||||||
|
const vnode = createVNode(this._def, extend(baseProps, this._props))
|
||||||
if (!this._instance) {
|
if (!this._instance) {
|
||||||
vnode.ce = instance => {
|
vnode.ce = instance => {
|
||||||
this._instance = instance
|
this._instance = instance
|
||||||
|
@ -367,7 +393,7 @@ export class VueElement extends BaseClass {
|
||||||
instance.ceReload = newStyles => {
|
instance.ceReload = newStyles => {
|
||||||
// always reset styles
|
// always reset styles
|
||||||
if (this._styles) {
|
if (this._styles) {
|
||||||
this._styles.forEach(s => this.shadowRoot!.removeChild(s))
|
this._styles.forEach(s => this._root.removeChild(s))
|
||||||
this._styles.length = 0
|
this._styles.length = 0
|
||||||
}
|
}
|
||||||
this._applyStyles(newStyles)
|
this._applyStyles(newStyles)
|
||||||
|
@ -416,7 +442,7 @@ export class VueElement extends BaseClass {
|
||||||
styles.forEach(css => {
|
styles.forEach(css => {
|
||||||
const s = document.createElement('style')
|
const s = document.createElement('style')
|
||||||
s.textContent = css
|
s.textContent = css
|
||||||
this.shadowRoot!.appendChild(s)
|
this._root.appendChild(s)
|
||||||
// record for HMR
|
// record for HMR
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
;(this._styles || (this._styles = [])).push(s)
|
;(this._styles || (this._styles = [])).push(s)
|
||||||
|
@ -424,4 +450,50 @@ export class VueElement extends BaseClass {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only called when shaddowRoot is false
|
||||||
|
*/
|
||||||
|
private _parseSlots() {
|
||||||
|
const slots: VueElement['_slots'] = (this._slots = {})
|
||||||
|
let n
|
||||||
|
while ((n = this.firstChild)) {
|
||||||
|
const slotName =
|
||||||
|
(n.nodeType === 1 && (n as Element).getAttribute('slot')) || 'default'
|
||||||
|
;(slots[slotName] || (slots[slotName] = [])).push(n)
|
||||||
|
this.removeChild(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only called when shaddowRoot is false
|
||||||
|
*/
|
||||||
|
private _renderSlots() {
|
||||||
|
const outlets = this.querySelectorAll('slot')
|
||||||
|
const scopeId = this._instance!.type.__scopeId
|
||||||
|
for (let i = 0; i < outlets.length; i++) {
|
||||||
|
const o = outlets[i] as HTMLSlotElement
|
||||||
|
const slotName = o.getAttribute('name') || 'default'
|
||||||
|
const content = this._slots![slotName]
|
||||||
|
const parent = o.parentNode!
|
||||||
|
if (content) {
|
||||||
|
for (const n of content) {
|
||||||
|
// for :slotted css
|
||||||
|
if (scopeId && n.nodeType === 1) {
|
||||||
|
const id = scopeId + '-s'
|
||||||
|
const walker = document.createTreeWalker(n, 1)
|
||||||
|
;(n as Element).setAttribute(id, '')
|
||||||
|
let child
|
||||||
|
while ((child = walker.nextNode())) {
|
||||||
|
;(child as Element).setAttribute(id, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parent.insertBefore(n, o)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while (o.firstChild) parent.insertBefore(o.firstChild, o)
|
||||||
|
}
|
||||||
|
parent.removeChild(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue