feat(custom-element): support configurable app instance in defineCustomElement

Support configuring via `configureApp` option:

```js
defineCustomElement({
  // ...
}, {
  configureApp(app) {
    // ...
  }
})
```

close #4356
close #4635
This commit is contained in:
Evan You 2024-08-07 16:07:47 +08:00
parent 261c8b111d
commit 6758c3cd04
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
4 changed files with 105 additions and 40 deletions

View File

@ -50,8 +50,18 @@ export interface App<HostElement = any> {
directive<T = any, V = any>(name: string, directive: Directive<T, V>): this directive<T = any, V = any>(name: string, directive: Directive<T, V>): this
mount( mount(
rootContainer: HostElement | string, rootContainer: HostElement | string,
/**
* @internal
*/
isHydrate?: boolean, isHydrate?: boolean,
/**
* @internal
*/
namespace?: boolean | ElementNamespace, namespace?: boolean | ElementNamespace,
/**
* @internal
*/
vnode?: VNode,
): ComponentPublicInstance ): ComponentPublicInstance
unmount(): void unmount(): void
onUnmount(cb: () => void): void onUnmount(cb: () => void): void
@ -76,6 +86,11 @@ export interface App<HostElement = any> {
_context: AppContext _context: AppContext
_instance: ComponentInternalInstance | null _instance: ComponentInternalInstance | null
/**
* @internal custom element vnode
*/
_ceVNode?: VNode
/** /**
* v2 compat only * v2 compat only
*/ */
@ -337,7 +352,7 @@ export function createAppAPI<HostElement>(
` you need to unmount the previous app by calling \`app.unmount()\` first.`, ` you need to unmount the previous app by calling \`app.unmount()\` first.`,
) )
} }
const vnode = createVNode(rootComponent, rootProps) const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
// store app context on the root VNode. // store app context on the root VNode.
// this will be set on the root instance on initial mount. // this will be set on the root instance on initial mount.
vnode.appContext = context vnode.appContext = context

View File

@ -1136,4 +1136,26 @@ describe('defineCustomElement', () => {
expect(fooVal).toBe('foo') expect(fooVal).toBe('foo')
expect(barVal).toBe('bar') expect(barVal).toBe('bar')
}) })
describe('configureApp', () => {
test('should work', () => {
const E = defineCustomElement(
() => {
const msg = inject('msg')
return () => h('div', msg!)
},
{
configureApp(app) {
app.provide('msg', 'app-injected')
},
},
)
customElements.define('my-element-with-app', E)
container.innerHTML = `<my-element-with-app></my-element-with-app>`
const e = container.childNodes[0] as VueElement
expect(e.shadowRoot?.innerHTML).toBe('<div>app-injected</div>')
})
})
}) })

View File

@ -1,4 +1,5 @@
import { import {
type App,
type Component, type Component,
type ComponentCustomElementInterface, type ComponentCustomElementInterface,
type ComponentInjectOptions, type ComponentInjectOptions,
@ -10,6 +11,7 @@ import {
type ComponentProvideOptions, type ComponentProvideOptions,
type ComputedOptions, type ComputedOptions,
type ConcreteComponent, type ConcreteComponent,
type CreateAppFunction,
type CreateComponentPublicInstanceWithMixins, type CreateComponentPublicInstanceWithMixins,
type DefineComponent, type DefineComponent,
type Directive, type Directive,
@ -18,7 +20,6 @@ import {
type ExtractPropTypes, type ExtractPropTypes,
type MethodOptions, type MethodOptions,
type RenderFunction, type RenderFunction,
type RootHydrateFunction,
type SetupContext, type SetupContext,
type SlotsType, type SlotsType,
type VNode, type VNode,
@ -39,7 +40,7 @@ import {
isPlainObject, isPlainObject,
toNumber, toNumber,
} from '@vue/shared' } from '@vue/shared'
import { hydrate, render } from '.' import { createApp, createSSRApp, render } from '.'
export type VueElementConstructor<P = {}> = { export type VueElementConstructor<P = {}> = {
new (initialProps?: Record<string, any>): VueElement & P new (initialProps?: Record<string, any>): VueElement & P
@ -49,6 +50,7 @@ export interface CustomElementOptions {
styles?: string[] styles?: string[]
shadowRoot?: boolean shadowRoot?: boolean
nonce?: string nonce?: string
configureApp?: (app: App) => void
} }
// defineCustomElement provides the same type inference as defineComponent // defineCustomElement provides the same type inference as defineComponent
@ -165,14 +167,14 @@ export function defineCustomElement(
/** /**
* @internal * @internal
*/ */
hydrate?: RootHydrateFunction, _createApp?: CreateAppFunction<Element>,
): VueElementConstructor { ): VueElementConstructor {
const Comp = defineComponent(options, extraOptions) as any const Comp = defineComponent(options, extraOptions) as any
if (isPlainObject(Comp)) extend(Comp, extraOptions) if (isPlainObject(Comp)) extend(Comp, extraOptions)
class VueCustomElement extends VueElement { class VueCustomElement extends VueElement {
static def = Comp static def = Comp
constructor(initialProps?: Record<string, any>) { constructor(initialProps?: Record<string, any>) {
super(Comp, initialProps, hydrate) super(Comp, initialProps, _createApp)
} }
} }
@ -185,7 +187,7 @@ export const defineSSRCustomElement = ((
extraOptions?: ComponentOptions, extraOptions?: ComponentOptions,
) => { ) => {
// @ts-expect-error // @ts-expect-error
return defineCustomElement(options, extraOptions, hydrate) return defineCustomElement(options, extraOptions, createSSRApp)
}) as typeof defineCustomElement }) as typeof defineCustomElement
const BaseClass = ( const BaseClass = (
@ -202,6 +204,14 @@ export class VueElement
* @internal * @internal
*/ */
_instance: ComponentInternalInstance | null = null _instance: ComponentInternalInstance | null = null
/**
* @internal
*/
_app: App | null = null
/**
* @internal
*/
_nonce = this._def.nonce
private _connected = false private _connected = false
private _resolved = false private _resolved = false
@ -225,15 +235,19 @@ export class VueElement
private _slots?: Record<string, Node[]> private _slots?: Record<string, Node[]>
constructor( constructor(
/**
* Component def - note this may be an AsyncWrapper, and this._def will
* be overwritten by the inner component when resolved.
*/
private _def: InnerComponentDef, private _def: InnerComponentDef,
private _props: Record<string, any> = {}, private _props: Record<string, any> = {},
hydrate?: RootHydrateFunction, private _createApp: CreateAppFunction<Element> = createApp,
) { ) {
super() super()
// TODO handle non-shadowRoot hydration if (this.shadowRoot && _createApp !== createApp) {
if (this.shadowRoot && hydrate) {
hydrate(this._createVNode(), this.shadowRoot)
this._root = this.shadowRoot this._root = this.shadowRoot
// TODO hydration needs to be reworked
this._mount(_def)
} else { } else {
if (__DEV__ && this.shadowRoot) { if (__DEV__ && this.shadowRoot) {
warn( warn(
@ -303,9 +317,10 @@ export class VueElement
this._ob.disconnect() this._ob.disconnect()
this._ob = null this._ob = null
} }
render(null, this._root) // unmount
this._app && this._app.unmount()
this._instance!.ce = undefined this._instance!.ce = undefined
this._instance = null this._app = this._instance = null
} }
}) })
} }
@ -371,11 +386,8 @@ export class VueElement
) )
} }
// initial render // initial mount
this._update() this._mount(def)
// apply expose
this._applyExpose()
} }
const asyncDef = (this._def as ComponentOptions).__asyncLoader const asyncDef = (this._def as ComponentOptions).__asyncLoader
@ -388,6 +400,34 @@ export class VueElement
} }
} }
private _mount(def: InnerComponentDef) {
if ((__DEV__ || __FEATURE_PROD_DEVTOOLS__) && !def.name) {
// @ts-expect-error
def.name = 'VueElement'
}
this._app = this._createApp(def)
if (def.configureApp) {
def.configureApp(this._app)
}
this._app._ceVNode = this._createVNode()
this._app.mount(this._root)
// apply expose after mount
const exposed = this._instance && this._instance.exposed
if (!exposed) return
for (const key in exposed) {
if (!hasOwn(this, key)) {
// exposed properties are readonly
Object.defineProperty(this, key, {
// unwrap ref to be consistent with public instance behavior
get: () => unref(exposed[key]),
})
} else if (__DEV__) {
warn(`Exposed property "${key}" already exists on custom element.`)
}
}
}
private _resolveProps(def: InnerComponentDef) { private _resolveProps(def: InnerComponentDef) {
const { props } = def const { props } = def
const declaredPropKeys = isArray(props) ? props : Object.keys(props || {}) const declaredPropKeys = isArray(props) ? props : Object.keys(props || {})
@ -412,22 +452,6 @@ export class VueElement
} }
} }
private _applyExpose() {
const exposed = this._instance && this._instance.exposed
if (!exposed) return
for (const key in exposed) {
if (!hasOwn(this, key)) {
// exposed properties are readonly
Object.defineProperty(this, key, {
// unwrap ref to be consistent with public instance behavior
get: () => unref(exposed[key]),
})
} else if (__DEV__) {
warn(`Exposed property "${key}" already exists on custom element.`)
}
}
}
protected _setAttr(key: string) { protected _setAttr(key: string) {
if (key.startsWith('data-v-')) return if (key.startsWith('data-v-')) return
let value = this.hasAttribute(key) ? this.getAttribute(key) : undefined let value = this.hasAttribute(key) ? this.getAttribute(key) : undefined
@ -534,7 +558,7 @@ export class VueElement
} }
this._styleChildren.add(owner) this._styleChildren.add(owner)
} }
const nonce = this._def.nonce const nonce = this._nonce
for (let i = styles.length - 1; i >= 0; i--) { for (let i = styles.length - 1; i >= 0; i--) {
const s = document.createElement('style') const s = document.createElement('style')
if (nonce) s.setAttribute('nonce', nonce) if (nonce) s.setAttribute('nonce', nonce)

View File

@ -108,9 +108,9 @@ export const createApp = ((...args) => {
// rendered by the server, the template should not contain any user data. // rendered by the server, the template should not contain any user data.
component.template = container.innerHTML component.template = container.innerHTML
// 2.x compat check // 2.x compat check
if (__COMPAT__ && __DEV__) { if (__COMPAT__ && __DEV__ && container.nodeType === 1) {
for (let i = 0; i < container.attributes.length; i++) { for (let i = 0; i < (container as Element).attributes.length; i++) {
const attr = container.attributes[i] const attr = (container as Element).attributes[i]
if (attr.name !== 'v-cloak' && /^(v-|:|@)/.test(attr.name)) { if (attr.name !== 'v-cloak' && /^(v-|:|@)/.test(attr.name)) {
compatUtils.warnDeprecation( compatUtils.warnDeprecation(
DeprecationTypes.GLOBAL_MOUNT_CONTAINER, DeprecationTypes.GLOBAL_MOUNT_CONTAINER,
@ -123,7 +123,9 @@ export const createApp = ((...args) => {
} }
// clear content before mounting // clear content before mounting
if (container.nodeType === 1) {
container.textContent = '' container.textContent = ''
}
const proxy = mount(container, false, resolveRootNamespace(container)) const proxy = mount(container, false, resolveRootNamespace(container))
if (container instanceof Element) { if (container instanceof Element) {
container.removeAttribute('v-cloak') container.removeAttribute('v-cloak')
@ -154,7 +156,9 @@ export const createSSRApp = ((...args) => {
return app return app
}) as CreateAppFunction<Element> }) as CreateAppFunction<Element>
function resolveRootNamespace(container: Element): ElementNamespace { function resolveRootNamespace(
container: Element | ShadowRoot,
): ElementNamespace {
if (container instanceof SVGElement) { if (container instanceof SVGElement) {
return 'svg' return 'svg'
} }
@ -215,7 +219,7 @@ function injectCompilerOptionsCheck(app: App) {
function normalizeContainer( function normalizeContainer(
container: Element | ShadowRoot | string, container: Element | ShadowRoot | string,
): Element | null { ): Element | ShadowRoot | null {
if (isString(container)) { if (isString(container)) {
const res = document.querySelector(container) const res = document.querySelector(container)
if (__DEV__ && !res) { if (__DEV__ && !res) {