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 VNodeArrayChildren,
|
||||
createBlock,
|
||||
createVNode,
|
||||
isVNode,
|
||||
openBlock,
|
||||
} from '../vnode'
|
||||
import { PatchFlags, SlotFlags } from '@vue/shared'
|
||||
import { warn } from '../warning'
|
||||
import { createVNode } from '@vue/runtime-core'
|
||||
import { isAsyncWrapper } from '../apiAsyncComponent'
|
||||
|
||||
/**
|
||||
|
@ -37,8 +37,19 @@ export function renderSlot(
|
|||
isAsyncWrapper(currentRenderingInstance!.parent) &&
|
||||
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
|
||||
return createVNode('slot', props, fallback && fallback())
|
||||
return (
|
||||
openBlock(),
|
||||
createBlock(
|
||||
Fragment,
|
||||
null,
|
||||
[createVNode('slot', props, fallback && fallback())],
|
||||
PatchFlags.STABLE_FRAGMENT,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let slot = slots[name]
|
||||
|
|
|
@ -505,7 +505,7 @@ describe('defineCustomElement', () => {
|
|||
})
|
||||
customElements.define('my-el-slots', E)
|
||||
|
||||
test('default slot', () => {
|
||||
test('render slots correctly', () => {
|
||||
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
|
||||
|
@ -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 SlotsType,
|
||||
type VNode,
|
||||
type VNodeProps,
|
||||
createVNode,
|
||||
defineComponent,
|
||||
nextTick,
|
||||
|
@ -33,21 +34,28 @@ export type VueElementConstructor<P = {}> = {
|
|||
new (initialProps?: Record<string, any>): VueElement & P
|
||||
}
|
||||
|
||||
export interface CustomElementOptions {
|
||||
styles?: string[]
|
||||
shadowRoot?: boolean
|
||||
}
|
||||
|
||||
// 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: Props, ctx: SetupContext) => RawBindings | RenderFunction,
|
||||
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
|
||||
props?: (keyof Props)[]
|
||||
},
|
||||
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
|
||||
CustomElementOptions & {
|
||||
props?: (keyof Props)[]
|
||||
},
|
||||
): VueElementConstructor<Props>
|
||||
export function defineCustomElement<Props, RawBindings = object>(
|
||||
setup: (props: Props, ctx: SetupContext) => RawBindings | RenderFunction,
|
||||
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> & {
|
||||
props?: ComponentObjectPropsOptions<Props>
|
||||
},
|
||||
options?: Pick<ComponentOptions, 'name' | 'inheritAttrs' | 'emits'> &
|
||||
CustomElementOptions & {
|
||||
props?: ComponentObjectPropsOptions<Props>
|
||||
},
|
||||
): VueElementConstructor<Props>
|
||||
|
||||
// overload 2: defineCustomElement with options object, infer props from options
|
||||
|
@ -81,27 +89,27 @@ export function defineCustomElement<
|
|||
: { [key in PropsKeys]?: any },
|
||||
ResolvedProps = InferredProps & EmitsToProps<RuntimeEmitsOptions>,
|
||||
>(
|
||||
options: {
|
||||
options: CustomElementOptions & {
|
||||
props?: (RuntimePropsOptions & ThisType<void>) | PropsKeys[]
|
||||
} & ComponentOptionsBase<
|
||||
ResolvedProps,
|
||||
SetupBindings,
|
||||
Data,
|
||||
Computed,
|
||||
Methods,
|
||||
Mixin,
|
||||
Extends,
|
||||
RuntimeEmitsOptions,
|
||||
EmitsKeys,
|
||||
{}, // Defaults
|
||||
InjectOptions,
|
||||
InjectKeys,
|
||||
Slots,
|
||||
LocalComponents,
|
||||
Directives,
|
||||
Exposed,
|
||||
Provide
|
||||
> &
|
||||
ResolvedProps,
|
||||
SetupBindings,
|
||||
Data,
|
||||
Computed,
|
||||
Methods,
|
||||
Mixin,
|
||||
Extends,
|
||||
RuntimeEmitsOptions,
|
||||
EmitsKeys,
|
||||
{}, // Defaults
|
||||
InjectOptions,
|
||||
InjectKeys,
|
||||
Slots,
|
||||
LocalComponents,
|
||||
Directives,
|
||||
Exposed,
|
||||
Provide
|
||||
> &
|
||||
ThisType<
|
||||
CreateComponentPublicInstanceWithMixins<
|
||||
Readonly<ResolvedProps>,
|
||||
|
@ -163,7 +171,7 @@ const BaseClass = (
|
|||
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
|
||||
) as typeof HTMLElement
|
||||
|
||||
type InnerComponentDef = ConcreteComponent & { styles?: string[] }
|
||||
type InnerComponentDef = ConcreteComponent & CustomElementOptions
|
||||
|
||||
export class VueElement extends BaseClass {
|
||||
/**
|
||||
|
@ -176,14 +184,19 @@ export class VueElement extends BaseClass {
|
|||
private _numberProps: Record<string, true> | null = null
|
||||
private _styles?: HTMLStyleElement[]
|
||||
private _ob?: MutationObserver | null = null
|
||||
private _root: Element | ShadowRoot
|
||||
private _slots?: Record<string, Node[]>
|
||||
|
||||
constructor(
|
||||
private _def: InnerComponentDef,
|
||||
private _props: Record<string, any> = {},
|
||||
hydrate?: RootHydrateFunction,
|
||||
) {
|
||||
super()
|
||||
// TODO handle non-shadowRoot hydration
|
||||
if (this.shadowRoot && hydrate) {
|
||||
hydrate(this._createVNode(), this.shadowRoot)
|
||||
this._root = this.shadowRoot
|
||||
} else {
|
||||
if (__DEV__ && this.shadowRoot) {
|
||||
warn(
|
||||
|
@ -191,7 +204,12 @@ export class VueElement extends BaseClass {
|
|||
`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) {
|
||||
// for sync component defs we can immediately resolve props
|
||||
this._resolveProps(this._def)
|
||||
|
@ -200,6 +218,9 @@ export class VueElement extends BaseClass {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (!this.shadowRoot) {
|
||||
this._parseSlots()
|
||||
}
|
||||
this._connected = true
|
||||
if (!this._instance) {
|
||||
if (this._resolved) {
|
||||
|
@ -218,7 +239,7 @@ export class VueElement extends BaseClass {
|
|||
this._ob.disconnect()
|
||||
this._ob = null
|
||||
}
|
||||
render(null, this.shadowRoot!)
|
||||
render(null, this._root)
|
||||
this._instance = null
|
||||
}
|
||||
})
|
||||
|
@ -353,11 +374,16 @@ export class VueElement extends BaseClass {
|
|||
}
|
||||
|
||||
private _update() {
|
||||
render(this._createVNode(), this.shadowRoot!)
|
||||
render(this._createVNode(), this._root)
|
||||
}
|
||||
|
||||
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) {
|
||||
vnode.ce = instance => {
|
||||
this._instance = instance
|
||||
|
@ -367,7 +393,7 @@ export class VueElement extends BaseClass {
|
|||
instance.ceReload = newStyles => {
|
||||
// always reset 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._applyStyles(newStyles)
|
||||
|
@ -416,7 +442,7 @@ export class VueElement extends BaseClass {
|
|||
styles.forEach(css => {
|
||||
const s = document.createElement('style')
|
||||
s.textContent = css
|
||||
this.shadowRoot!.appendChild(s)
|
||||
this._root.appendChild(s)
|
||||
// record for HMR
|
||||
if (__DEV__) {
|
||||
;(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