feat(custom-element): support shadowRoot: false in defineCustomElement()

close #4314
close #4404
This commit is contained in:
Evan You 2024-08-03 13:14:22 +08:00
parent 267093c314
commit 37d2ce5d8e
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
3 changed files with 185 additions and 35 deletions

View File

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

View File

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

View File

@ -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,19 +34,26 @@ 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'> &
CustomElementOptions & {
props?: (keyof Props)[] 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'> &
CustomElementOptions & {
props?: ComponentObjectPropsOptions<Props> props?: ComponentObjectPropsOptions<Props>
}, },
): VueElementConstructor<Props> ): VueElementConstructor<Props>
@ -81,7 +89,7 @@ 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,
@ -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\`.`,
) )
} }
if (_def.shadowRoot !== false) {
this.attachShadow({ mode: 'open' }) 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)
}
}
} }