wip(vapor): vdom in vapor interop

This commit is contained in:
Evan You 2025-02-04 21:38:09 +08:00
parent f09e343962
commit c3e4f6621c
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
13 changed files with 226 additions and 84 deletions

View File

@ -1,6 +1,5 @@
import {
type Component,
type ComponentInternalInstance,
type ConcreteComponent,
type Data,
type GenericComponent,
@ -17,7 +16,11 @@ import type {
ComponentPublicInstance,
} from './componentPublicInstance'
import { type Directive, validateDirectiveName } from './directives'
import type { ElementNamespace, RootRenderFunction } from './renderer'
import type {
ElementNamespace,
RootRenderFunction,
UnmountComponentFn,
} from './renderer'
import type { InjectionKey } from './apiInject'
import { warn } from './warning'
import type { VNode } from './vnode'
@ -29,6 +32,7 @@ import type { NormalizedPropsOptions } from './componentProps'
import type { ObjectEmitsOptions } from './componentEmits'
import { ErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
import type { DefineComponent } from './apiDefineComponent'
import type { VaporInteropInterface } from './vaporInterop'
export interface App<HostElement = any> {
version: string
@ -172,26 +176,6 @@ export interface AppConfig extends GenericAppConfig {
* @deprecated use config.compilerOptions.isCustomElement
*/
isCustomElement?: (tag: string) => boolean
/**
* @internal
*/
vapor?: VaporInVDOMInterface
}
/**
* @internal
*/
export interface VaporInVDOMInterface {
mount(
vnode: VNode,
container: any,
anchor: any,
parentComponent: ComponentInternalInstance | null,
): GenericComponentInstance // VaporComponentInstance
update(n1: VNode, n2: VNode, shouldUpdate: boolean): void
unmount(vnode: VNode, doRemove?: boolean): void
move(vnode: VNode, container: any, anchor: any): void
}
/**
@ -208,6 +192,19 @@ export interface GenericAppContext {
* @internal
*/
reload?: () => void
/**
* @internal vapor interop only, for creating vapor components in vdom
*/
vapor?: VaporInteropInterface
/**
* @internal vapor interop only, for creating vdom components in vapor
*/
vdomMount?: (component: ConcreteComponent, props?: any, slots?: any) => any
/**
* @internal
*/
vdomUnmount?: UnmountComponentFn
}
export interface AppContext extends GenericAppContext {

View File

@ -339,6 +339,7 @@ export interface GenericComponentInstance {
vapor?: boolean
uid: number
type: GenericComponent
root: GenericComponentInstance | null
parent: GenericComponentInstance | null
appContext: GenericAppContext
/**
@ -823,9 +824,15 @@ export function setupComponent(
): Promise<void> | undefined {
isSSR && setInSSRSetupState(isSSR)
const { props, children } = instance.vnode
const { props, children, vi } = instance.vnode
const isStateful = isStatefulComponent(instance)
initProps(instance, props, isStateful, isSSR)
if (vi) {
// Vapor interop override - use Vapor props/attrs proxy
vi(instance)
} else {
initProps(instance, props, isStateful, isSSR)
}
initSlots(instance, children, optimized)
const setupResult = isStateful

View File

@ -454,7 +454,7 @@ export function updateHOCHostEl(
{ vnode, parent }: ComponentInternalInstance,
el: typeof vnode.el, // HostNode
): void {
while (parent) {
while (parent && !parent.vapor) {
const root = parent.subTree
if (root.suspense && root.suspense.activeBranch === vnode) {
root.el = vnode.el

View File

@ -498,7 +498,14 @@ export {
type LifecycleHook,
} from './component'
export { type NormalizedPropsOptions } from './componentProps'
/**
* @internal
*/
export { type VaporInteropInterface } from './vaporInterop'
/**
* @internal
*/
export { type RendererInternals } from './renderer'
/**
* @internal
*/
@ -530,7 +537,6 @@ export {
createAppAPI,
type AppMountFn,
type AppUnmountFn,
type VaporInVDOMInterface,
} from './apiCreateApp'
/**
* @internal

View File

@ -65,7 +65,6 @@ import {
type AppMountFn,
type AppUnmountFn,
type CreateAppFunction,
type VaporInVDOMInterface,
createAppAPI,
} from './apiCreateApp'
import { setRef } from './rendererTemplateRef'
@ -96,10 +95,12 @@ import { isAsyncWrapper } from './apiAsyncComponent'
import { isCompatEnabled } from './compat/compatConfig'
import { DeprecationTypes } from './compat/compatConfig'
import type { TransitionHooks } from './components/BaseTransition'
import type { VaporInteropInterface } from './vaporInterop'
export interface Renderer<HostElement = RendererElement> {
render: RootRenderFunction<HostElement>
createApp: CreateAppFunction<HostElement>
internals: RendererInternals
}
export interface HydrationRenderer extends Renderer<Element | ShadowRoot> {
@ -175,6 +176,7 @@ export interface RendererInternals<
r: RemoveFn
m: MoveFn
mt: MountComponentFn
umt: UnmountComponentFn
mc: MountChildrenFn
pc: PatchChildrenFn
pbc: PatchBlockChildrenFn
@ -271,6 +273,12 @@ export type MountComponentFn = (
optimized: boolean,
) => void
export type UnmountComponentFn = (
instance: ComponentInternalInstance,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean,
) => void
type ProcessTextOrCommentFn = (
n1: VNode | null,
n2: VNode,
@ -1433,6 +1441,7 @@ function baseCreateRenderer(
if (
initialVNode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE ||
(parent &&
parent.vnode &&
isAsyncWrapper(parent.vnode) &&
parent.vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE)
) {
@ -2308,10 +2317,10 @@ function baseCreateRenderer(
hostRemove(end)
}
const unmountComponent = (
instance: ComponentInternalInstance,
parentSuspense: SuspenseBoundary | null,
doRemove?: boolean,
const unmountComponent: UnmountComponentFn = (
instance,
parentSuspense,
doRemove,
) => {
if (__DEV__ && instance.type.__hmrId) {
unregisterHMR(instance)
@ -2437,6 +2446,7 @@ function baseCreateRenderer(
m: move,
r: remove,
mt: mountComponent,
umt: unmountComponent,
mc: mountChildren,
pc: patchChildren,
pbc: patchBlockChildren,
@ -2494,6 +2504,7 @@ function baseCreateRenderer(
return {
render,
hydrate,
internals,
createApp: createAppAPI(
mountApp,
unmountApp,
@ -2608,8 +2619,8 @@ export function invalidateMount(hooks: LifecycleHook | undefined): void {
function getVaporInterface(
instance: ComponentInternalInstance | null,
): VaporInVDOMInterface {
const res = instance!.appContext.config.vapor
): VaporInteropInterface {
const res = instance!.appContext.vapor
if (__DEV__ && !res) {
warn(
`Vapor component found in vdom tree but vapor-in-vdom interop was not installed. ` +

View File

@ -0,0 +1,21 @@
import type {
ComponentInternalInstance,
GenericComponentInstance,
} from './component'
import type { VNode } from './vnode'
/**
* The vapor in vdom implementation is in runtime-vapor/src/vdomInterop.ts
* @internal
*/
export interface VaporInteropInterface {
mount(
vnode: VNode,
container: any,
anchor: any,
parentComponent: ComponentInternalInstance | null,
): GenericComponentInstance // VaporComponentInstance
update(n1: VNode, n2: VNode, shouldUpdate: boolean): void
unmount(vnode: VNode, doRemove?: boolean): void
move(vnode: VNode, container: any, anchor: any): void
}

View File

@ -253,6 +253,10 @@ export interface VNode<
* @internal custom element interception hook
*/
ce?: (instance: ComponentInternalInstance) => void
/**
* @internal VDOM in Vapor interop hook
*/
vi?: (instance: ComponentInternalInstance) => void
}
// Since v-if and v-for are the two possible ways node structure can dynamically

View File

@ -73,7 +73,7 @@ let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
let enabledHydration = false
function ensureRenderer() {
function ensureRenderer(): Renderer<Element | ShadowRoot> {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
@ -230,7 +230,7 @@ function injectCompilerOptionsCheck(app: App) {
/**
* @internal
*/
export function normalizeContainer<T extends ParentNode>(
function normalizeContainer<T extends ParentNode>(
container: T | string,
): T | null {
if (isString(container)) {
@ -313,7 +313,13 @@ export * from '@vue/runtime-core'
export * from './jsx'
// VAPOR -----------------------------------------------------------------------
// Everything below are exposed for vapor only and can change any time.
// They are also trimmed from non-bundler builds.
/**
* @internal
*/
export { ensureRenderer, normalizeContainer }
/**
* @internal
*/

View File

@ -20,6 +20,8 @@ export type BlockFn = (...args: any[]) => Block
export class VaporFragment {
nodes: Block
anchor?: Node
insert?: (parent: ParentNode, anchor: Node | null) => void
remove?: () => void
constructor(nodes: Block) {
this.nodes = nodes
@ -118,7 +120,11 @@ export function insert(
}
} else {
// fragment
insert(block.nodes, parent, anchor)
if (block.insert) {
block.insert(parent, anchor)
} else {
insert(block.nodes, parent, anchor)
}
if (block.anchor) insert(block.anchor, parent, anchor)
}
}
@ -151,7 +157,11 @@ export function remove(block: Block, parent: ParentNode): void {
}
} else {
// fragment
remove(block.nodes, parent)
if (block.remove) {
block.remove()
} else {
remove(block.nodes, parent)
}
if (block.anchor) remove(block.anchor, parent)
if ((block as DynamicFragment).scope) {
;(block as DynamicFragment).scope!.stop()

View File

@ -1,4 +1,5 @@
import {
type ComponentInternalInstance,
type ComponentInternalOptions,
type ComponentPropsOptions,
EffectScope,
@ -135,7 +136,7 @@ export type LooseRawProps = Record<
$?: DynamicPropsSource[]
}
type LooseRawSlots = Record<string, VaporSlot | DynamicSlotSource[]> & {
export type LooseRawSlots = Record<string, VaporSlot | DynamicSlotSource[]> & {
$?: DynamicSlotSource[]
}
@ -144,17 +145,23 @@ export function createComponent(
rawProps?: LooseRawProps | null,
rawSlots?: LooseRawSlots | null,
isSingleRoot?: boolean,
appContext?: GenericAppContext,
appContext: GenericAppContext = (currentInstance &&
currentInstance.appContext) ||
emptyContext,
): VaporComponentInstance {
// check if we are the single root of the parent
// if yes, inject parent attrs as dynamic props source
// TODO avoid child overwriting parent
// vdom interop enabled and component is not an explicit vapor component
if (appContext.vdomMount && !component.__vapor) {
return appContext.vdomMount(component as any, rawProps, rawSlots)
}
if (
isSingleRoot &&
component.inheritAttrs !== false &&
isVaporComponent(currentInstance) &&
currentInstance.hasFallthrough
) {
// check if we are the single root of the parent
// if yes, inject parent attrs as dynamic props source
const attrs = currentInstance.attrs
if (rawProps) {
;((rawProps as RawProps).$ || ((rawProps as RawProps).$ = [])).push(
@ -175,6 +182,10 @@ export function createComponent(
if (__DEV__) {
pushWarningContext(instance)
startMeasure(instance, `init`)
// cache normalized options for dev only emit check
instance.propsOptions = normalizePropsOptions(component)
instance.emitsOptions = normalizeEmitsOptions(component)
}
const prev = currentInstance
@ -287,8 +298,10 @@ export class VaporComponentInstance implements GenericComponentInstance {
vapor: true
uid: number
type: VaporComponent
root: GenericComponentInstance | null
parent: GenericComponentInstance | null
children: VaporComponentInstance[] // TODO handle vdom children
children: VaporComponentInstance[]
vdomChildren?: ComponentInternalInstance[]
appContext: GenericAppContext
block: Block
@ -361,7 +374,8 @@ export class VaporComponentInstance implements GenericComponentInstance {
this.vapor = true
this.uid = nextUid()
this.type = comp
this.parent = currentInstance // TODO proper parent source when inside vdom instance
this.parent = currentInstance
this.root = currentInstance ? currentInstance.root : this
this.children = []
if (currentInstance) {
@ -418,12 +432,6 @@ export class VaporComponentInstance implements GenericComponentInstance {
? new Proxy(rawSlots, dynamicSlotsProxyHandlers)
: rawSlots
: EMPTY_OBJ
if (__DEV__) {
// cache normalized options for dev only emit check
this.propsOptions = normalizePropsOptions(comp)
this.emitsOptions = normalizeEmitsOptions(comp)
}
}
/**
@ -448,8 +456,8 @@ export function isVaporComponent(
*/
export function createComponentWithFallback(
comp: VaporComponent | string,
rawProps?: RawProps | null,
rawSlots?: RawSlots | null,
rawProps?: LooseRawProps | null,
rawSlots?: LooseRawSlots | null,
isSingleRoot?: boolean,
): HTMLElement | VaporComponentInstance {
if (!isString(comp)) {
@ -462,7 +470,7 @@ export function createComponentWithFallback(
if (rawProps) {
renderEffect(() => {
setDynamicProps(el, [resolveDynamicProps(rawProps)])
setDynamicProps(el, [resolveDynamicProps(rawProps as RawProps)])
})
}
@ -470,7 +478,7 @@ export function createComponentWithFallback(
if (rawSlots.$) {
// TODO dynamic slot fragment
} else {
insert(getSlot(rawSlots, 'default')!(), el)
insert(getSlot(rawSlots as RawSlots, 'default')!(), el)
}
}
@ -517,6 +525,14 @@ export function unmountComponent(
}
instance.children = EMPTY_ARR as any
if (instance.vdomChildren) {
const unmount = instance.appContext.vdomUnmount!
for (const c of instance.vdomChildren) {
unmount(c, null)
}
instance.vdomChildren = EMPTY_ARR as any
}
if (parentNode) {
// root remove: need to both remove this instance's DOM nodes
// and also remove it from the parent's children list.

View File

@ -199,7 +199,9 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown {
return rawProps[key]()
}
}
return merged
if (merged && merged.length) {
return merged
}
}
export function hasAttrFromRawProps(rawProps: RawProps, key: string): boolean {
@ -342,3 +344,18 @@ function propsDeleteDevTrap(_: any, key: string | symbol) {
)
return true
}
export const rawPropsProxyHandlers: ProxyHandler<RawProps> = {
get: getAttrFromRawProps,
has: hasAttrFromRawProps,
ownKeys: getKeysFromRawProps,
getOwnPropertyDescriptor(target, key: string) {
if (hasAttrFromRawProps(target, key)) {
return {
configurable: true,
enumerable: true,
get: () => getAttrFromRawProps(target, key),
}
}
},
}

View File

@ -1,11 +1,6 @@
import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared'
import { type Block, type BlockFn, DynamicFragment } from './block'
import {
type RawProps,
getAttrFromRawProps,
getKeysFromRawProps,
hasAttrFromRawProps,
} from './componentProps'
import { rawPropsProxyHandlers } from './componentProps'
import { currentInstance } from '@vue/runtime-core'
import type { LooseRawProps, VaporComponentInstance } from './component'
import { renderEffect } from './renderEffect'
@ -90,21 +85,6 @@ export function getSlot(
}
}
const dynamicSlotsPropsProxyHandlers: ProxyHandler<RawProps> = {
get: getAttrFromRawProps,
has: hasAttrFromRawProps,
ownKeys: getKeysFromRawProps,
getOwnPropertyDescriptor(target, key: string) {
if (hasAttrFromRawProps(target, key)) {
return {
configurable: true,
enumerable: true,
get: () => getAttrFromRawProps(target, key),
}
}
},
}
// TODO how to handle empty slot return blocks?
// e.g. a slot renders a v-if node that may toggle inside.
// we may need special handling by passing the fallback into the slot
@ -119,7 +99,7 @@ export function createSlot(
const isDynamicName = isFunction(name)
const fragment = __DEV__ ? new DynamicFragment('slot') : new DynamicFragment()
const slotProps = rawProps
? new Proxy(rawProps, dynamicSlotsPropsProxyHandlers)
? new Proxy(rawProps, rawPropsProxyHandlers)
: EMPTY_OBJ
const renderSlot = () => {

View File

@ -1,19 +1,29 @@
import {
type ComponentInternalInstance,
type ConcreteComponent,
type Plugin,
type VaporInVDOMInterface,
type RendererInternals,
type VaporInteropInterface,
createVNode,
currentInstance,
ensureRenderer,
shallowRef,
simpleSetCurrentInstance,
} from '@vue/runtime-dom'
import {
type VaporComponentInstance,
type LooseRawProps,
type LooseRawSlots,
VaporComponentInstance,
createComponent,
mountComponent,
unmountComponent,
} from './component'
import { insert } from './block'
import { VaporFragment, insert } from './block'
import { extend, remove } from '@vue/shared'
import { type RawProps, rawPropsProxyHandlers } from './componentProps'
import type { RawSlots } from './componentSlots'
const vaporInVDOMInterface: VaporInVDOMInterface = {
const vaporInteropImpl: VaporInteropInterface = {
mount(vnode, container, anchor, parentComponent) {
const selfAnchor = (vnode.anchor = document.createComment('vapor'))
container.insertBefore(selfAnchor, anchor)
@ -49,6 +59,63 @@ const vaporInVDOMInterface: VaporInVDOMInterface = {
},
}
export const vaporInteropPlugin: Plugin = app => {
app.config.vapor = vaporInVDOMInterface
function createVDOMComponent(
internals: RendererInternals,
component: ConcreteComponent,
rawProps?: LooseRawProps | null,
rawSlots?: LooseRawSlots | null,
): VaporFragment {
const frag = new VaporFragment([])
const vnode = createVNode(
component,
rawProps && new Proxy(rawProps, rawPropsProxyHandlers),
)
const wrapper = new VaporComponentInstance(
{ props: component.props },
rawProps as RawProps,
rawSlots as RawSlots,
)
// overwrite how the vdom instance handles props
vnode.vi = (instance: ComponentInternalInstance) => {
instance.props = wrapper.props
instance.attrs = wrapper.attrs
// TODO slots
}
let isMounted = false
const parentInstance = currentInstance as VaporComponentInstance
frag.insert = (parent, anchor) => {
if (!isMounted) {
internals.mt(
vnode,
parent,
anchor,
parentInstance as any,
null,
undefined,
false,
)
;(parentInstance.vdomChildren || (parentInstance.vdomChildren = [])).push(
vnode.component!,
)
isMounted = true
} else {
// TODO move
}
}
frag.remove = () => {
internals.umt(vnode.component!, null, true)
remove(parentInstance.vdomChildren!, vnode.component)
isMounted = false
}
return frag
}
export const vaporInteropPlugin: Plugin = app => {
app._context.vapor = extend(vaporInteropImpl)
const internals = ensureRenderer().internals
app._context.vdomMount = createVDOMComponent.bind(null, internals)
app._context.vdomUnmount = internals.umt
}