feat(runtime-vapor): component props (#40)

This commit is contained in:
ubugeeei 2023-12-10 02:33:18 +09:00 committed by GitHub
parent ecf7da98d7
commit 12250a85b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 458 additions and 22 deletions

View File

@ -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<boolean>
propsOptions: NormalizedPropsOptions
// TODO: type
proxy: Data | null
// state
props: Data
setupState: Data
/** directives */
dirs: Map<Node, DirectiveBinding[]>
// lifecycle
get isMounted(): boolean
isMountedRef: Ref<boolean>
// 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

View File

@ -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<P = Data> =
| ComponentObjectPropsOptions<P>
| string[]
export type ComponentObjectPropsOptions<P = Data> = {
[K in keyof P]: Prop<P[K]> | null
}
export type Prop<T, D = T> = PropOptions<T, D> | PropType<T>
type DefaultFactory<T> = (props: Data) => T | null | undefined
export interface PropOptions<T = any, D = T> {
type?: PropType<T> | true | null
required?: boolean
default?: D | DefaultFactory<D> | null | undefined | object
validator?(value: unknown): boolean
/**
* @internal
*/
skipFactory?: boolean
}
export type PropType<T> = PropConstructor<T> | PropConstructor<T>[]
type PropConstructor<T = any> =
| { new (...args: any[]): T & {} }
| { (): T }
| PropMethod<T>
type PropMethod<T, TConstructor = any> = [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<string, NormalizedProp>
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<any>): string {
const match = ctor && ctor.toString().match(/^\s*(function|class) (\w+)/)
return match ? match[2] : ctor === null ? 'null' : ''
}
function isSameType(a: Prop<any>, b: Prop<any>): boolean {
return getType(a) === getType(b)
}
function getTypeIndex(
type: Prop<any>,
expectedTypes: PropType<any> | 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
}

View File

@ -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<any> = {
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]
}
},
}

View File

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

View File

@ -1,6 +1,6 @@
import { render } from 'vue/vapor'
const modules = import.meta.glob<any>('./*.vue')
const modules = import.meta.glob<any>('./*.(vue|js)')
const mod = (modules['.' + location.pathname] || modules['./App.vue'])()
mod.then(({ default: mod }) => render(mod, '#app'))
mod.then(({ default: mod }) => render(mod, {}, '#app'))

104
playground/src/props.js Normal file
View File

@ -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('<button></button>')
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??
{
/* <Comp :count="count" /> */
get count() {
return _ctx.count
},
/* <Comp :inline-double="count * 2" /> */
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('<p></p>')
const n0 = t0()
const {
0: [n1]
} = children(n0)
effect(() => {
setText(n1, void 0, _ctx.count + ' * 2 = ' + _ctx.inlineDouble)
})
return n0
}
}