vue-web-component-wrapper
Version:
A Vue 3 plugin that provides a web component wrapper with styles, seamlessly integrating with Vuex, Vue Router, Vue I18n, and supporting Tailwind CSS and Sass styles.
554 lines (497 loc) • 15.1 kB
text/typescript
import type {
ComponentOptionsMixin,
ComponentOptionsWithArrayProps,
ComponentOptionsWithObjectProps,
ComponentOptionsWithoutProps,
ComponentPropsOptions,
ComputedOptions,
EmitsOptions,
MethodOptions,
RenderFunction,
SetupContext,
ComponentInternalInstance,
VNode,
RootHydrateFunction,
ExtractPropTypes,
ConcreteComponent,
ComponentOptions,
ComponentInjectOptions,
SlotsType,
DefineComponent
} from '@vue/runtime-core';
import {
createVNode,
defineComponent,
nextTick,
warn,
h,
FunctionalComponent
} from 'vue';
import { camelize, extend, hyphenate, isArray, toNumber } from '@vue/shared'
import { hydrate, render } from 'vue'
// @ts-ignore
const __DEV__ = import.meta.env.DEV
export type VueElementConstructor<P = {}> = {
new (initialProps?: Record<string, any>): VueElement & P
}
export interface DefineCustomElementConfig {
/**
* Render inside a shadow root DOM element
* @default true
*/
shadowRoot?: boolean
/**
* Nonce to use for CSP
*/
nonce?: string
}
// 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: Readonly<Props>,
ctx: SetupContext,
) => RawBindings | RenderFunction,
config?: DefineCustomElementConfig,
): VueElementConstructor<Props>
// overload 2: object format with no props
export function defineCustomElement<
Props = {},
RawBindings = {},
D = {},
C extends ComputedOptions = {},
M extends MethodOptions = {},
Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
E extends EmitsOptions = EmitsOptions,
EE extends string = string,
I extends ComponentInjectOptions = {},
II extends string = string,
S extends SlotsType = {},
>(
options: ComponentOptionsWithoutProps<
Props,
RawBindings,
D,
C,
M,
Mixin,
Extends,
E,
EE,
I,
II,
S
> & { styles?: string[] },
config?: DefineCustomElementConfig
): VueElementConstructor<Props>
// overload 3: object format with array props declaration
export function defineCustomElement<
PropNames extends string,
RawBindings,
D,
C extends ComputedOptions = {},
M extends MethodOptions = {},
Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
E extends EmitsOptions = Record<string, any>,
EE extends string = string,
I extends ComponentInjectOptions = {},
II extends string = string,
S extends SlotsType = {},
>(
options: ComponentOptionsWithArrayProps<
PropNames,
RawBindings,
D,
C,
M,
Mixin,
Extends,
E,
EE,
I,
II,
S
> & { styles?: string[] },
config?: DefineCustomElementConfig
): VueElementConstructor<{ [K in PropNames]: any }>
// overload 4: object format with object props declaration
export function defineCustomElement<
PropsOptions extends Readonly<ComponentPropsOptions>,
RawBindings,
D,
C extends ComputedOptions = {},
M extends MethodOptions = {},
Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
E extends EmitsOptions = Record<string, any>,
EE extends string = string,
I extends ComponentInjectOptions = {},
II extends string = string,
S extends SlotsType = {},
>(
options: ComponentOptionsWithObjectProps<
PropsOptions,
RawBindings,
D,
C,
M,
Mixin,
Extends,
E,
EE,
I,
II,
S
> & { styles?: string[] },
config?: DefineCustomElementConfig
): VueElementConstructor<ExtractPropTypes<PropsOptions>>
// overload 5: defining a custom element from the returned value of
// `defineComponent`
export function defineCustomElement<P>(
options: DefineComponent<P, any, any, any>,
config?: DefineCustomElementConfig
): VueElementConstructor<ExtractPropTypes<P>>
/*!#__NO_SIDE_EFFECTS__*/
export function defineCustomElement(
options: any,
config?: DefineCustomElementConfig,
hydrate?: RootHydrateFunction,
): VueElementConstructor {
const Comp = defineComponent(options) as any
class VueCustomElement extends VueElement {
static def = Comp
constructor(initialProps?: Record<string, any>) {
super(Comp, initialProps, config, hydrate)
}
}
return VueCustomElement
}
/*!#__NO_SIDE_EFFECTS__*/
export const defineSSRCustomElement = ((
options: any,
config?: DefineCustomElementConfig
) => {
// @ts-expect-error
return defineCustomElement(options, hydrate)
}) as typeof defineCustomElement
const BaseClass = (
typeof HTMLElement !== 'undefined' ? HTMLElement : class {}
) as typeof HTMLElement
type InnerComponentDef = ConcreteComponent & { styles?: string[], components?: { [key: string]: InnerComponentDef } }
// extend the interface ComponentInternalInstance
// to add the `styles` property
interface ComponentInternalInstanceCe extends ComponentInternalInstance {
ceReload?: (newStyles: string[] | undefined) => void
isCE?: boolean
provides?: Record<string, any>
}
export class VueElement extends BaseClass {
/**
* @internal
*/
_instance: ComponentInternalInstanceCe | null = null
private _connected = false
private _resolved = false
private _numberProps: Record<string, true> | null = null
private _styles?: HTMLStyleElement[]
private _slots: { [key: string]: VNode[] } = {};
private _ob?: MutationObserver | null = null
constructor(
private _def: InnerComponentDef,
private _props: Record<string, any> = {},
private _config: DefineCustomElementConfig = { shadowRoot: true },
hydrate?: RootHydrateFunction,
) {
super()
if (this._root && hydrate) {
hydrate(this._createVNode(), this._root)
} else {
if (__DEV__ && this._root) {
warn(
`Custom element has pre-rendered declarative shadow root but is not ` +
`defined as hydratable. Use \`defineSSRCustomElement\`.`,
)
}
if (this._config.shadowRoot !== false) {
this.attachShadow({ mode: 'open' })
}
if (!(this._def as ComponentOptions).__asyncLoader) {
// for sync component defs we can immediately resolve props
this._resolveProps(this._def)
}
}
}
get _root(): Element | ShadowRoot | null {
return this._config.shadowRoot ? this.shadowRoot : this
}
connectedCallback() {
this._connected = true
if (!this._instance) {
if (this._resolved) {
this._update()
} else {
this._resolveDef()
}
}
}
disconnectedCallback() {
this._connected = false
nextTick(() => {
if (!this._connected) {
if (this._ob) {
this._ob.disconnect()
this._ob = null
}
render(null, this._root!)
this._instance = null
}
})
}
/**
* resolve inner component definition (handle possible async component)
*/
private _resolveDef() {
this._resolved = true
// set initial attrs
for (let i = 0; i < this.attributes.length; i++) {
this._setAttr(this.attributes[i].name)
}
// watch future attr changes
this._ob = new MutationObserver(mutations => {
for (const m of mutations) {
this._setAttr(m.attributeName!)
}
})
this._ob.observe(this, { attributes: true })
const resolve = (def: InnerComponentDef, isAsync = false) => {
const { props } = def
const styles = this._collectNestedStyles(def);
// cast Number-type props set before resolve
let numberProps
if (props && !isArray(props)) {
for (const key in props) {
const opt = props[key]
if (opt === Number || (opt && opt.type === Number)) {
if (key in this._props) {
this._props[key] = toNumber(this._props[key])
}
;(numberProps || (numberProps = Object.create(null)))[
camelize(key)
] = true
}
}
}
this._numberProps = numberProps
if (isAsync) {
// defining getter/setters on prototype
// for sync defs, this already happened in the constructor
this._resolveProps(def)
}
// replace slot
if (!this._config.shadowRoot) {
this._slots = {};
const processChildNodes = (nodes: NodeList): (VNode | string)[] => {
return Array.from(nodes)
.map((node) : VNode | string | null => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
const attributes = Object.fromEntries(
Array.from(element.attributes).map((attr) => [attr.name, attr.value])
);
return h(
element.tagName.toLowerCase(),
attributes,
processChildNodes(element.childNodes)
);
} else if (node.nodeType === Node.TEXT_NODE) {
return node.textContent?.trim() || null;
}
return null;
})
.filter((node): node is VNode | string => node != null);
};
for (const child of Array.from(this.childNodes)) {
const slotName = child.nodeType === Node.ELEMENT_NODE
? (child as HTMLElement).getAttribute('slot') || 'default'
: 'default';
if (!this._slots[slotName]) {
this._slots[slotName] = [];
}
if (child.nodeType === Node.ELEMENT_NODE) {
const element = child as HTMLElement;
const attributes = Object.fromEntries(
Array.from(element.attributes).map((attr) => [attr.name, attr.value])
);
this._slots[slotName].push(
h(
element.tagName.toLowerCase(),
attributes,
processChildNodes(element.childNodes)
)
);
} else if (child.nodeType === Node.TEXT_NODE) {
const textContent = child.textContent?.trim();
if (textContent) {
// @ts-ignore
this._slots[slotName].push(textContent);
}
}
}
this.replaceChildren();
}
// apply CSS
this._applyStyles(styles)
// initial render
this._update()
}
const asyncDef = (this._def as ComponentOptions).__asyncLoader
if (asyncDef) {
asyncDef().then((def: InnerComponentDef) => resolve(def, true))
} else {
resolve(this._def)
}
}
private _resolveProps(def: InnerComponentDef) {
const { props } = def
const declaredPropKeys = isArray(props) ? props : Object.keys(props || {})
// check if there are props set pre-upgrade or connect
for (const key of Object.keys(this)) {
if (key[0] !== '_' && declaredPropKeys.includes(key)) {
this._setProp(key, this[key as keyof this], true, false)
}
}
// defining getter/setters on prototype
for (const key of declaredPropKeys.map(camelize)) {
Object.defineProperty(this, key, {
get() {
return this._getProp(key)
},
set(val) {
this._setProp(key, val)
},
})
}
}
protected _setAttr(key: string) {
let value = this.hasAttribute(key) ? this.getAttribute(key) : undefined
const camelKey = camelize(key)
if (this._numberProps && this._numberProps[camelKey]) {
value = toNumber(value)
}
this._setProp(camelKey, value, false)
}
/**
* @internal
*/
protected _getProp(key: string) {
return this._props[key]
}
/**
* @internal
*/
protected _setProp(
key: string,
val: any,
shouldReflect = true,
shouldUpdate = true,
) {
if (val !== this._props[key]) {
this._props[key] = val
if (shouldUpdate && this._instance) {
this._update()
}
// reflect
if (shouldReflect) {
if (val === true) {
this.setAttribute(hyphenate(key), '')
} else if (typeof val === 'string' || typeof val === 'number') {
this.setAttribute(hyphenate(key), val + '')
} else if (!val) {
this.removeAttribute(hyphenate(key))
}
}
}
}
private _update() {
render(this._createVNode(), this._root!)
}
private _createVNode(): VNode<any, any> {
const vnode = createVNode(this._def as FunctionalComponent, extend({}, this._props), this._slots) as VNode & {
ce?: (instance: ComponentInternalInstance) => void
}
if (!this._instance) {
vnode.ce = (instance: ComponentInternalInstanceCe) => {
this._instance = instance
instance.isCE = true
// HMR
if (__DEV__) {
instance.ceReload = (newStyles: string[] | undefined) => {
// always reset styles
if (this._styles) {
this._styles.forEach(s => this._root!.removeChild(s))
this._styles.length = 0
}
this._applyStyles(newStyles)
this._instance = null
this._update()
}
}
const dispatch = (event: string, args: any[]) => {
this.dispatchEvent(
new CustomEvent(event, {
detail: args,
}),
)
}
instance.emit = (event: string, ...args: any[]) => {
// dispatch both the raw and hyphenated versions of an event
// to match Vue behavior
dispatch(event, args)
if (hyphenate(event) !== event) {
dispatch(hyphenate(event), args)
}
}
// locate nearest Vue custom element parent for provide/inject
let parent: Node | null = this
while (
(parent =
parent && (parent.parentNode || (parent as ShadowRoot).host))
) {
if (parent instanceof VueElement) {
instance.parent = parent._instance
instance.provides = parent._instance!.provides
break
}
}
}
}
return vnode
}
private _applyStyles(styles: string[] | undefined) {
if (styles) {
styles.forEach(css => {
const s = document.createElement('style')
s.textContent = css
if (this._config.nonce) s.setAttribute('nonce', this._config.nonce);
this._root!.prepend(s)
if (__DEV__) {
;(this._styles || (this._styles = [])).push(s)
}
})
}
}
private _collectNestedStyles(componentDef:
InnerComponentDef
): string[] {
let styles = componentDef.styles ?? [];
if (componentDef.components) {
Object.values(componentDef.components).forEach(subComponent => {
styles = styles.concat(this._collectNestedStyles(subComponent as InnerComponentDef));
});
}
return styles;
}
}