feat(runtime-dom): support async component in defineCustomElement
close #4261
This commit is contained in:
parent
1994f1200d
commit
c421fb91b2
@ -293,7 +293,7 @@ export interface ComponentInternalInstance {
|
|||||||
/**
|
/**
|
||||||
* custom element specific HMR method
|
* custom element specific HMR method
|
||||||
*/
|
*/
|
||||||
ceReload?: () => void
|
ceReload?: (newStyles?: string[]) => void
|
||||||
|
|
||||||
// the rest are only for stateful components ---------------------------------
|
// the rest are only for stateful components ---------------------------------
|
||||||
|
|
||||||
|
@ -136,13 +136,21 @@ function reload(id: string, newComp: ComponentOptions | ClassComponent) {
|
|||||||
if (instance.ceReload) {
|
if (instance.ceReload) {
|
||||||
// custom element
|
// custom element
|
||||||
hmrDirtyComponents.add(component)
|
hmrDirtyComponents.add(component)
|
||||||
instance.ceReload()
|
instance.ceReload((newComp as any).styles)
|
||||||
hmrDirtyComponents.delete(component)
|
hmrDirtyComponents.delete(component)
|
||||||
} else if (instance.parent) {
|
} else if (instance.parent) {
|
||||||
// 4. Force the parent instance to re-render. This will cause all updated
|
// 4. Force the parent instance to re-render. This will cause all updated
|
||||||
// components to be unmounted and re-mounted. Queue the update so that we
|
// components to be unmounted and re-mounted. Queue the update so that we
|
||||||
// don't end up forcing the same parent to re-render multiple times.
|
// don't end up forcing the same parent to re-render multiple times.
|
||||||
queueJob(instance.parent.update)
|
queueJob(instance.parent.update)
|
||||||
|
// instance is the inner component of an async custom element
|
||||||
|
// invoke to reset styles
|
||||||
|
if (
|
||||||
|
(instance.parent.type as ComponentOptions).__asyncLoader &&
|
||||||
|
instance.parent.ceReload
|
||||||
|
) {
|
||||||
|
instance.parent.ceReload((newComp as any).styles)
|
||||||
|
}
|
||||||
} else if (instance.appContext.reload) {
|
} else if (instance.appContext.reload) {
|
||||||
// root instance mounted via createApp() has a reload method
|
// root instance mounted via createApp() has a reload method
|
||||||
instance.appContext.reload()
|
instance.appContext.reload()
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
defineAsyncComponent,
|
||||||
defineCustomElement,
|
defineCustomElement,
|
||||||
h,
|
h,
|
||||||
inject,
|
inject,
|
||||||
@ -300,4 +301,96 @@ describe('defineCustomElement', () => {
|
|||||||
expect(style.textContent).toBe(`div { color: red; }`)
|
expect(style.textContent).toBe(`div { color: red; }`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('async', () => {
|
||||||
|
test('should work', async () => {
|
||||||
|
const loaderSpy = jest.fn()
|
||||||
|
const E = defineCustomElement(
|
||||||
|
defineAsyncComponent(() => {
|
||||||
|
loaderSpy()
|
||||||
|
return Promise.resolve({
|
||||||
|
props: ['msg'],
|
||||||
|
styles: [`div { color: red }`],
|
||||||
|
render(this: any) {
|
||||||
|
return h('div', null, this.msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
customElements.define('my-el-async', E)
|
||||||
|
container.innerHTML =
|
||||||
|
`<my-el-async msg="hello"></my-el-async>` +
|
||||||
|
`<my-el-async msg="world"></my-el-async>`
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r))
|
||||||
|
|
||||||
|
// loader should be called only once
|
||||||
|
expect(loaderSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const e1 = container.childNodes[0] as VueElement
|
||||||
|
const e2 = container.childNodes[1] as VueElement
|
||||||
|
|
||||||
|
// should inject styles
|
||||||
|
expect(e1.shadowRoot!.innerHTML).toBe(
|
||||||
|
`<div>hello</div><style>div { color: red }</style>`
|
||||||
|
)
|
||||||
|
expect(e2.shadowRoot!.innerHTML).toBe(
|
||||||
|
`<div>world</div><style>div { color: red }</style>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// attr
|
||||||
|
e1.setAttribute('msg', 'attr')
|
||||||
|
await nextTick()
|
||||||
|
expect((e1 as any).msg).toBe('attr')
|
||||||
|
expect(e1.shadowRoot!.innerHTML).toBe(
|
||||||
|
`<div>attr</div><style>div { color: red }</style>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// props
|
||||||
|
expect(`msg` in e1).toBe(true)
|
||||||
|
;(e1 as any).msg = 'prop'
|
||||||
|
expect(e1.getAttribute('msg')).toBe('prop')
|
||||||
|
expect(e1.shadowRoot!.innerHTML).toBe(
|
||||||
|
`<div>prop</div><style>div { color: red }</style>`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('set DOM property before resolve', async () => {
|
||||||
|
const E = defineCustomElement(
|
||||||
|
defineAsyncComponent(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
props: ['msg'],
|
||||||
|
render(this: any) {
|
||||||
|
return h('div', this.msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
customElements.define('my-el-async-2', E)
|
||||||
|
|
||||||
|
const e1 = new E()
|
||||||
|
|
||||||
|
// set property before connect
|
||||||
|
e1.msg = 'hello'
|
||||||
|
|
||||||
|
const e2 = new E()
|
||||||
|
|
||||||
|
container.appendChild(e1)
|
||||||
|
container.appendChild(e2)
|
||||||
|
|
||||||
|
// set property after connect but before resolve
|
||||||
|
e2.msg = 'world'
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r))
|
||||||
|
|
||||||
|
expect(e1.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
|
||||||
|
expect(e2.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
|
||||||
|
|
||||||
|
e1.msg = 'world'
|
||||||
|
expect(e1.shadowRoot!.innerHTML).toBe(`<div>world</div>`)
|
||||||
|
|
||||||
|
e2.msg = 'hello'
|
||||||
|
expect(e2.shadowRoot!.innerHTML).toBe(`<div>hello</div>`)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -18,6 +18,7 @@ import {
|
|||||||
defineComponent,
|
defineComponent,
|
||||||
nextTick,
|
nextTick,
|
||||||
warn,
|
warn,
|
||||||
|
ConcreteComponent,
|
||||||
ComponentOptions
|
ComponentOptions
|
||||||
} from '@vue/runtime-core'
|
} from '@vue/runtime-core'
|
||||||
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
|
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
|
||||||
@ -124,32 +125,13 @@ export function defineCustomElement(
|
|||||||
hydate?: RootHydrateFunction
|
hydate?: RootHydrateFunction
|
||||||
): VueElementConstructor {
|
): VueElementConstructor {
|
||||||
const Comp = defineComponent(options as any)
|
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 {
|
class VueCustomElement extends VueElement {
|
||||||
static def = Comp
|
static def = Comp
|
||||||
static get observedAttributes() {
|
|
||||||
return attrKeys
|
|
||||||
}
|
|
||||||
constructor(initialProps?: Record<string, any>) {
|
constructor(initialProps?: Record<string, any>) {
|
||||||
super(Comp, initialProps, attrKeys, propKeys, hydate)
|
super(Comp, initialProps, hydate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of propKeys) {
|
|
||||||
Object.defineProperty(VueCustomElement.prototype, key, {
|
|
||||||
get() {
|
|
||||||
return this._getProp(key)
|
|
||||||
},
|
|
||||||
set(val) {
|
|
||||||
this._setProp(key, val)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return VueCustomElement
|
return VueCustomElement
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,6 +144,8 @@ const BaseClass = (
|
|||||||
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
|
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
|
||||||
) as typeof HTMLElement
|
) as typeof HTMLElement
|
||||||
|
|
||||||
|
type InnerComponentDef = ConcreteComponent & { styles?: string[] }
|
||||||
|
|
||||||
export class VueElement extends BaseClass {
|
export class VueElement extends BaseClass {
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
@ -169,13 +153,12 @@ export class VueElement extends BaseClass {
|
|||||||
_instance: ComponentInternalInstance | null = null
|
_instance: ComponentInternalInstance | null = null
|
||||||
|
|
||||||
private _connected = false
|
private _connected = false
|
||||||
|
private _resolved = false
|
||||||
private _styles?: HTMLStyleElement[]
|
private _styles?: HTMLStyleElement[]
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private _def: ComponentOptions & { styles?: string[] },
|
private _def: InnerComponentDef,
|
||||||
private _props: Record<string, any> = {},
|
private _props: Record<string, any> = {},
|
||||||
private _attrKeys: string[],
|
|
||||||
private _propKeys: string[],
|
|
||||||
hydrate?: RootHydrateFunction
|
hydrate?: RootHydrateFunction
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
@ -189,27 +172,25 @@ export class VueElement extends BaseClass {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
this.attachShadow({ mode: 'open' })
|
this.attachShadow({ mode: 'open' })
|
||||||
this._applyStyles()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
|
// set initial attrs
|
||||||
if (this._attrKeys.includes(name)) {
|
for (let i = 0; i < this.attributes.length; i++) {
|
||||||
this._setProp(camelize(name), toNumber(newValue), false)
|
this._setAttr(this.attributes[i].name)
|
||||||
}
|
}
|
||||||
|
// watch future attr changes
|
||||||
|
const observer = new MutationObserver(mutations => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
this._setAttr(m.attributeName!)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
observer.observe(this, { attributes: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this._connected = true
|
this._connected = true
|
||||||
if (!this._instance) {
|
if (!this._instance) {
|
||||||
// check if there are props set pre-upgrade
|
this._resolveDef()
|
||||||
for (const key of this._propKeys) {
|
|
||||||
if (this.hasOwnProperty(key)) {
|
|
||||||
const value = (this as any)[key]
|
|
||||||
delete (this as any)[key]
|
|
||||||
this._setProp(key, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
render(this._createVNode(), this.shadowRoot!)
|
render(this._createVNode(), this.shadowRoot!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,6 +205,50 @@ export class VueElement extends BaseClass {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* resolve inner component definition (handle possible async component)
|
||||||
|
*/
|
||||||
|
private _resolveDef() {
|
||||||
|
if (this._resolved) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolve = (def: InnerComponentDef) => {
|
||||||
|
this._resolved = true
|
||||||
|
// check if there are props set pre-upgrade or connect
|
||||||
|
for (const key of Object.keys(this)) {
|
||||||
|
if (key[0] !== '_') {
|
||||||
|
this._setProp(key, this[key as keyof this])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { props, styles } = def
|
||||||
|
// defining getter/setters on prototype
|
||||||
|
const rawKeys = props ? (isArray(props) ? props : Object.keys(props)) : []
|
||||||
|
for (const key of rawKeys.map(camelize)) {
|
||||||
|
Object.defineProperty(this, key, {
|
||||||
|
get() {
|
||||||
|
return this._getProp(key)
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
this._setProp(key, val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this._applyStyles(styles)
|
||||||
|
}
|
||||||
|
|
||||||
|
const asyncDef = (this._def as ComponentOptions).__asyncLoader
|
||||||
|
if (asyncDef) {
|
||||||
|
asyncDef().then(resolve)
|
||||||
|
} else {
|
||||||
|
resolve(this._def)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected _setAttr(key: string) {
|
||||||
|
this._setProp(camelize(key), toNumber(this.getAttribute(key)), false)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @internal
|
* @internal
|
||||||
*/
|
*/
|
||||||
@ -261,18 +286,22 @@ export class VueElement extends BaseClass {
|
|||||||
instance.isCE = true
|
instance.isCE = true
|
||||||
// HMR
|
// HMR
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
instance.ceReload = () => {
|
instance.ceReload = newStyles => {
|
||||||
this._instance = null
|
// alawys reset styles
|
||||||
// reset styles
|
|
||||||
if (this._styles) {
|
if (this._styles) {
|
||||||
this._styles.forEach(s => this.shadowRoot!.removeChild(s))
|
this._styles.forEach(s => this.shadowRoot!.removeChild(s))
|
||||||
this._styles.length = 0
|
this._styles.length = 0
|
||||||
}
|
}
|
||||||
this._applyStyles()
|
this._applyStyles(newStyles)
|
||||||
|
// if this is an async component, ceReload is called from the inner
|
||||||
|
// component so no need to reload the async wrapper
|
||||||
|
if (!(this._def as ComponentOptions).__asyncLoader) {
|
||||||
// reload
|
// reload
|
||||||
|
this._instance = null
|
||||||
render(this._createVNode(), this.shadowRoot!)
|
render(this._createVNode(), this.shadowRoot!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// intercept emit
|
// intercept emit
|
||||||
instance.emit = (event: string, ...args: any[]) => {
|
instance.emit = (event: string, ...args: any[]) => {
|
||||||
@ -299,9 +328,9 @@ export class VueElement extends BaseClass {
|
|||||||
return vnode
|
return vnode
|
||||||
}
|
}
|
||||||
|
|
||||||
private _applyStyles() {
|
private _applyStyles(styles: string[] | undefined) {
|
||||||
if (this._def.styles) {
|
if (styles) {
|
||||||
this._def.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.shadowRoot!.appendChild(s)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user