feat(runtime-dom): support async component in defineCustomElement

close #4261
This commit is contained in:
Evan You 2021-08-06 19:15:55 -04:00
parent 1994f1200d
commit c421fb91b2
4 changed files with 177 additions and 47 deletions

View File

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

View File

@ -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()

View File

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

View File

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