diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 26518598d..5ff3b86b8 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1,13 +1,25 @@ -import { type Ref, EffectScope, ref } from '@vue/reactivity' -import type { Block } from './render' -import type { DirectiveBinding } from './directive' +import { EffectScope, Ref, ref } from '@vue/reactivity' + +import { EMPTY_OBJ } from '@vue/shared' +import { Block } from './render' +import { type DirectiveBinding } from './directive' +import { + type ComponentPropsOptions, + type NormalizedPropsOptions, + normalizePropsOptions, +} from './componentProps' + import type { Data } from '@vue/shared' +export type Component = FunctionalComponent | ObjectComponent + export type SetupFn = (props: any, ctx: any) => Block | Data export type FunctionalComponent = SetupFn & { + props: ComponentPropsOptions render(ctx: any): Block } export interface ObjectComponent { + props: ComponentPropsOptions setup: SetupFn render(ctx: any): Block } @@ -17,13 +29,22 @@ export interface ComponentInternalInstance { container: ParentNode block: Block | null scope: EffectScope - component: FunctionalComponent | ObjectComponent - get isMounted(): boolean - isMountedRef: Ref + propsOptions: NormalizedPropsOptions + + // TODO: type + proxy: Data | null + + // state + props: Data + setupState: Data /** directives */ dirs: Map + + // lifecycle + get isMounted(): boolean + isMountedRef: Ref // TODO: registory of provides, appContext, lifecycles, ... } @@ -51,14 +72,25 @@ export const createComponentInstance = ( block: null, container: null!, // set on mount scope: new EffectScope(true /* detached */)!, - component, + + // resolved props and emits options + propsOptions: normalizePropsOptions(component), + // emitsOptions: normalizeEmitsOptions(type, appContext), // TODO: + + proxy: null, + + // state + props: EMPTY_OBJ, + setupState: EMPTY_OBJ, + + dirs: new Map(), + + // lifecycle get isMounted() { return isMountedRef.value }, isMountedRef, - - dirs: new Map(), // TODO: registory of provides, appContext, lifecycles, ... } return instance diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts new file mode 100644 index 000000000..5cd0f1d21 --- /dev/null +++ b/packages/runtime-vapor/src/componentProps.ts @@ -0,0 +1,267 @@ +// NOTE: runtime-core/src/componentProps.ts + +import { + Data, + EMPTY_ARR, + EMPTY_OBJ, + camelize, + extend, + hasOwn, + hyphenate, + isArray, + isFunction, + isReservedProp, +} from '@vue/shared' +import { shallowReactive, toRaw } from '@vue/reactivity' +import { type ComponentInternalInstance, type Component } from './component' + +export type ComponentPropsOptions

= + | ComponentObjectPropsOptions

+ | string[] + +export type ComponentObjectPropsOptions

= { + [K in keyof P]: Prop | null +} + +export type Prop = PropOptions | PropType + +type DefaultFactory = (props: Data) => T | null | undefined + +export interface PropOptions { + type?: PropType | true | null + required?: boolean + default?: D | DefaultFactory | null | undefined | object + validator?(value: unknown): boolean + /** + * @internal + */ + skipFactory?: boolean +} + +export type PropType = PropConstructor | PropConstructor[] + +type PropConstructor = + | { new (...args: any[]): T & {} } + | { (): T } + | PropMethod + +type PropMethod = [T] extends [ + ((...args: any) => any) | undefined, +] // if is function with args, allowing non-required functions + ? { new (): TConstructor; (): T; readonly prototype: TConstructor } // Create Function like constructor + : never + +enum BooleanFlags { + shouldCast, + shouldCastTrue, +} + +type NormalizedProp = + | null + | (PropOptions & { + [BooleanFlags.shouldCast]?: boolean + [BooleanFlags.shouldCastTrue]?: boolean + }) + +export type NormalizedProps = Record +export type NormalizedPropsOptions = [NormalizedProps, string[]] | [] + +export function initProps( + instance: ComponentInternalInstance, + rawProps: Data | null, +) { + const props: Data = {} + + const [options, needCastKeys] = instance.propsOptions + let rawCastValues: Data | undefined + if (rawProps) { + for (let key in rawProps) { + // key, ref are reserved and never passed down + if (isReservedProp(key)) { + continue + } + + const valueGetter = () => rawProps[key] + let camelKey + if (options && hasOwn(options, (camelKey = camelize(key)))) { + if (!needCastKeys || !needCastKeys.includes(camelKey)) { + // NOTE: must getter + // props[camelKey] = value + Object.defineProperty(props, camelKey, { + get() { + return valueGetter() + }, + }) + } else { + // NOTE: must getter + // ;(rawCastValues || (rawCastValues = {}))[camelKey] = value + rawCastValues || (rawCastValues = {}) + Object.defineProperty(rawCastValues, camelKey, { + get() { + return valueGetter() + }, + }) + } + } else { + // TODO: + } + } + } + + if (needCastKeys) { + const rawCurrentProps = toRaw(props) + const castValues = rawCastValues || EMPTY_OBJ + for (let i = 0; i < needCastKeys.length; i++) { + const key = needCastKeys[i] + + // NOTE: must getter + // props[key] = resolvePropValue( + // options!, + // rawCurrentProps, + // key, + // castValues[key], + // instance, + // !hasOwn(castValues, key), + // ) + Object.defineProperty(props, key, { + get() { + return resolvePropValue( + options!, + rawCurrentProps, + key, + castValues[key], + instance, + !hasOwn(castValues, key), + ) + }, + }) + } + } + + instance.props = shallowReactive(props) +} + +function resolvePropValue( + options: NormalizedProps, + props: Data, + key: string, + value: unknown, + instance: ComponentInternalInstance, + isAbsent: boolean, +) { + const opt = options[key] + if (opt != null) { + const hasDefault = hasOwn(opt, 'default') + // default values + if (hasDefault && value === undefined) { + const defaultValue = opt.default + if ( + opt.type !== Function && + !opt.skipFactory && + isFunction(defaultValue) + ) { + // TODO: caching? + // const { propsDefaults } = instance + // if (key in propsDefaults) { + // value = propsDefaults[key] + // } else { + // setCurrentInstance(instance) + // value = propsDefaults[key] = defaultValue.call( + // __COMPAT__ && + // isCompatEnabled(DeprecationTypes.PROPS_DEFAULT_THIS, instance) + // ? createPropsDefaultThis(instance, props, key) + // : null, + // props, + // ) + // unsetCurrentInstance() + // } + } else { + value = defaultValue + } + } + // boolean casting + if (opt[BooleanFlags.shouldCast]) { + if (isAbsent && !hasDefault) { + value = false + } else if ( + opt[BooleanFlags.shouldCastTrue] && + (value === '' || value === hyphenate(key)) + ) { + value = true + } + } + } + return value +} + +export function normalizePropsOptions(comp: Component): NormalizedPropsOptions { + // TODO: cahching? + + const raw = comp.props as any + const normalized: NormalizedPropsOptions[0] = {} + const needCastKeys: NormalizedPropsOptions[1] = [] + + if (!raw) { + return EMPTY_ARR as any + } + + if (isArray(raw)) { + for (let i = 0; i < raw.length; i++) { + const normalizedKey = camelize(raw[i]) + if (validatePropName(normalizedKey)) { + normalized[normalizedKey] = EMPTY_OBJ + } + } + } else if (raw) { + for (const key in raw) { + const normalizedKey = camelize(key) + if (validatePropName(normalizedKey)) { + const opt = raw[key] + const prop: NormalizedProp = (normalized[normalizedKey] = + isArray(opt) || isFunction(opt) ? { type: opt } : extend({}, opt)) + if (prop) { + const booleanIndex = getTypeIndex(Boolean, prop.type) + const stringIndex = getTypeIndex(String, prop.type) + prop[BooleanFlags.shouldCast] = booleanIndex > -1 + prop[BooleanFlags.shouldCastTrue] = + stringIndex < 0 || booleanIndex < stringIndex + // if the prop needs boolean casting or default value + if (booleanIndex > -1 || hasOwn(prop, 'default')) { + needCastKeys.push(normalizedKey) + } + } + } + } + } + + const res: NormalizedPropsOptions = [normalized, needCastKeys] + return res +} + +function validatePropName(key: string) { + if (key[0] !== '$') { + return true + } + return false +} + +function getType(ctor: Prop): string { + const match = ctor && ctor.toString().match(/^\s*(function|class) (\w+)/) + return match ? match[2] : ctor === null ? 'null' : '' +} + +function isSameType(a: Prop, b: Prop): boolean { + return getType(a) === getType(b) +} + +function getTypeIndex( + type: Prop, + expectedTypes: PropType | void | null | true, +): number { + if (isArray(expectedTypes)) { + return expectedTypes.findIndex((t) => isSameType(t, type)) + } else if (isFunction(expectedTypes)) { + return isSameType(expectedTypes, type) ? 0 : -1 + } + return -1 +} diff --git a/packages/runtime-vapor/src/componentPublicInstance.ts b/packages/runtime-vapor/src/componentPublicInstance.ts new file mode 100644 index 000000000..8bfacf981 --- /dev/null +++ b/packages/runtime-vapor/src/componentPublicInstance.ts @@ -0,0 +1,22 @@ +import { hasOwn } from '@vue/shared' +import { type ComponentInternalInstance } from './component' + +export interface ComponentRenderContext { + [key: string]: any + _: ComponentInternalInstance +} + +export const PublicInstanceProxyHandlers: ProxyHandler = { + get({ _: instance }: ComponentRenderContext, key: string) { + let normalizedProps + const { setupState, props } = instance + if (hasOwn(setupState, key)) { + return setupState[key] + } else if ( + (normalizedProps = instance.propsOptions[0]) && + hasOwn(normalizedProps, key) + ) { + return props![key] + } + }, +} diff --git a/packages/runtime-vapor/src/render.ts b/packages/runtime-vapor/src/render.ts index b03e56ae8..422d5e689 100644 --- a/packages/runtime-vapor/src/render.ts +++ b/packages/runtime-vapor/src/render.ts @@ -1,14 +1,20 @@ -import { reactive } from '@vue/reactivity' +import { markRaw, proxyRefs } from '@vue/reactivity' +import { type Data } from '@vue/shared' + import { + type Component, type ComponentInternalInstance, - type FunctionalComponent, - type ObjectComponent, createComponentInstance, setCurrentInstance, unsetCurrentInstance, } from './component' + +import { initProps } from './componentProps' + import { invokeDirectiveHook } from './directive' + import { insert, remove } from './dom' +import { PublicInstanceProxyHandlers } from './componentPublicInstance' export type Block = Node | Fragment | Block[] export type ParentBlock = ParentNode | Node[] @@ -16,13 +22,13 @@ export type Fragment = { nodes: Block; anchor: Node } export type BlockFn = (props: any, ctx: any) => Block export function render( - comp: ObjectComponent | FunctionalComponent, + comp: Component, + props: Data, container: string | ParentNode, ): ComponentInternalInstance { const instance = createComponentInstance(comp) - setCurrentInstance(instance) - mountComponent(instance, (container = normalizeContainer(container))) - return instance + initProps(instance, props) + return mountComponent(instance, (container = normalizeContainer(container))) } export function normalizeContainer(container: string | ParentNode): ParentNode { @@ -39,29 +45,34 @@ export function mountComponent( setCurrentInstance(instance) const block = instance.scope.run(() => { - const { component } = instance - const props = {} + const { component, props } = instance const ctx = { expose: () => {} } const setupFn = typeof component === 'function' ? component : component.setup const state = setupFn(props, ctx) + instance.proxy = markRaw( + new Proxy({ _: instance }, PublicInstanceProxyHandlers), + ) if (state && '__isScriptSetup' in state) { - return (instance.block = component.render(reactive(state))) + instance.setupState = proxyRefs(state) + return (instance.block = component.render(instance.proxy)) } else { return (instance.block = state as Block) } })! - invokeDirectiveHook(instance, 'beforeMount') insert(block, instance.container) instance.isMountedRef.value = true invokeDirectiveHook(instance, 'mounted') + unsetCurrentInstance() // TODO: lifecycle hooks (mounted, ...) // const { m } = instance // m && invoke(m) + + return instance } export function unmountComponent(instance: ComponentInternalInstance) { diff --git a/playground/src/main.ts b/playground/src/main.ts index 43565bc94..717629057 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -1,6 +1,6 @@ import { render } from 'vue/vapor' -const modules = import.meta.glob('./*.vue') +const modules = import.meta.glob('./*.(vue|js)') const mod = (modules['.' + location.pathname] || modules['./App.vue'])() -mod.then(({ default: mod }) => render(mod, '#app')) +mod.then(({ default: mod }) => render(mod, {}, '#app')) diff --git a/playground/src/props.js b/playground/src/props.js new file mode 100644 index 000000000..b80768dcc --- /dev/null +++ b/playground/src/props.js @@ -0,0 +1,104 @@ +import { watch } from 'vue' +import { + children, + on, + ref, + template, + effect, + setText, + render as renderComponent // TODO: +} from '@vue/vapor' + +export default { + props: undefined, + + setup(_, {}) { + const count = ref(1) + const handleClick = () => { + count.value++ + } + + const __returned__ = { count, handleClick } + + Object.defineProperty(__returned__, '__isScriptSetup', { + enumerable: false, + value: true + }) + + return __returned__ + }, + + render(_ctx) { + const t0 = template('') + const n0 = t0() + const { + 0: [n1] + } = children(n0) + on(n1, 'click', _ctx.handleClick) + effect(() => { + setText(n1, void 0, _ctx.count) + }) + + // TODO: create component fn? + // const c0 = createComponent(...) + // insert(n0, c0) + renderComponent( + child, + + // TODO: proxy?? + { + /* */ + get count() { + return _ctx.count + }, + + /* */ + get inlineDouble() { + return _ctx.count * 2 + } + }, + n0 + ) + + return n0 + } +} + +const child = { + props: { + count: { type: Number, default: 1 }, + inlineDouble: { type: Number, default: 2 } + }, + + setup(props) { + watch( + () => props.count, + v => console.log('count changed', v) + ) + watch( + () => props.inlineDouble, + v => console.log('inlineDouble changed', v) + ) + + const __returned__ = {} + + Object.defineProperty(__returned__, '__isScriptSetup', { + enumerable: false, + value: true + }) + + return __returned__ + }, + + render(_ctx) { + const t0 = template('

') + const n0 = t0() + const { + 0: [n1] + } = children(n0) + effect(() => { + setText(n1, void 0, _ctx.count + ' * 2 = ' + _ctx.inlineDouble) + }) + return n0 + } +}