From 9dd133b1e9d9a3594586268706cc935c25891522 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 28 May 2019 18:06:00 +0800 Subject: [PATCH] wip: props resolving --- packages/runtime-core/src/component.ts | 58 +++- packages/runtime-core/src/componentProps.ts | 326 ++++++++++++++++++++ packages/runtime-core/src/createRenderer.ts | 9 +- packages/runtime-core/src/index.ts | 11 +- packages/runtime-core/src/warning.ts | 3 + 5 files changed, 390 insertions(+), 17 deletions(-) create mode 100644 packages/runtime-core/src/componentProps.ts create mode 100644 packages/runtime-core/src/warning.ts diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 3ba82c31..a37355dc 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -1,6 +1,7 @@ import { VNode, normalizeVNode, VNodeChild } from './vnode' import { ReactiveEffect } from '@vue/observer' -import { isFunction, EMPTY_OBJ } from '@vue/shared' +import { isFunction } from '@vue/shared' +import { resolveProps, ComponentPropsOptions } from './componentProps' interface Value { value: T @@ -18,14 +19,28 @@ type ExtractPropTypes = { : PropOptions[key] extends null | undefined ? any : PropOptions[key] } -interface ComponentPublicProperties { - $props: P +export type Data = { [key: string]: any } + +export interface ComponentPublicProperties

{ $state: S + $props: P + $attrs: Data + + // TODO + $refs: Data + $slots: Data +} + +interface RenderFunctionArg { + state: B + props: P + attrs: Data + slots: Slots } export interface ComponentOptions< - RawProps = { [key: string]: Prop }, - RawBindings = { [key: string]: any } | void, + RawProps = ComponentPropsOptions, + RawBindings = Data | void, Props = ExtractPropTypes, Bindings = UnwrapBindings > { @@ -33,13 +48,22 @@ export interface ComponentOptions< setup?: (props: Props) => RawBindings render?: ( this: ComponentPublicProperties, - ctx: { - state: B - props: Props - } + ctx: RenderFunctionArg ) => VNodeChild } +export interface FunctionalComponent

{ + (ctx: RenderFunctionArg): any + props?: ComponentPropsOptions

+ displayName?: string +} + +export type Slot = (...args: any[]) => VNode[] + +export type Slots = Readonly<{ + [name: string]: Slot +}> + // no-op, for type inference only export function createComponent< RawProps, @@ -55,19 +79,25 @@ export function createComponent< return options as any } -export interface ComponentHandle { - type: Function | ComponentOptions +export type ComponentHandle = { + type: FunctionalComponent | ComponentOptions vnode: VNode | null next: VNode | null subTree: VNode | null update: ReactiveEffect -} +} & ComponentPublicProperties export function renderComponentRoot(handle: ComponentHandle): VNode { const { type, vnode } = handle - // TODO actually resolve props + const { 0: props, 1: attrs } = resolveProps( + (vnode as VNode).props, + type.props + ) const renderArg = { - props: (vnode as VNode).props || EMPTY_OBJ + state: handle.$state, + slots: handle.$slots, + props, + attrs } if (isFunction(type)) { return normalizeVNode(type(renderArg)) diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts new file mode 100644 index 00000000..1544c1eb --- /dev/null +++ b/packages/runtime-core/src/componentProps.ts @@ -0,0 +1,326 @@ +import { immutable, unwrap } from '@vue/observer' +import { + EMPTY_OBJ, + camelize, + hyphenate, + capitalize, + isString, + isFunction, + isArray, + isObject +} from '@vue/shared' +import { warn } from './warning' +import { Data, ComponentHandle } from './component' + +export type ComponentPropsOptions

= { + [K in keyof P]: PropValidator +} + +export type Prop = { (): T } | { new (...args: any[]): T & object } + +export type PropType = Prop | Prop[] + +export type PropValidator = PropOptions | PropType + +export interface PropOptions { + type?: PropType | true | null + required?: boolean + default?: T | null | undefined | (() => T | null | undefined) + validator?(value: T): boolean +} + +const enum BooleanFlags { + shouldCast = '1', + shouldCastTrue = '2' +} + +type NormalizedProp = PropOptions & { + [BooleanFlags.shouldCast]?: boolean + [BooleanFlags.shouldCastTrue]?: boolean +} + +type NormalizedPropsOptions = Record + +const isReservedKey = (key: string): boolean => key[0] === '_' || key[0] === '$' + +export function initializeProps( + instance: ComponentHandle, + options: NormalizedPropsOptions | undefined, + data: Data | null +) { + const { 0: props, 1: attrs } = resolveProps(data, options) + instance.$props = __DEV__ ? immutable(props) : props + instance.$attrs = options + ? __DEV__ + ? immutable(attrs) + : attrs + : instance.$props +} + +// resolve raw VNode data. +// - filter out reserved keys (key, ref, slots) +// - extract class and style into $attrs (to be merged onto child +// component root) +// - for the rest: +// - if has declared props: put declared ones in `props`, the rest in `attrs` +// - else: everything goes in `props`. + +const EMPTY_PROPS = [EMPTY_OBJ, EMPTY_OBJ] as [Data, Data] + +export function resolveProps( + rawData: any, + _options: ComponentPropsOptions | void +): [Data, Data] { + const hasDeclaredProps = _options != null + const options = normalizePropsOptions(_options) as NormalizedPropsOptions + if (!rawData && !hasDeclaredProps) { + return EMPTY_PROPS + } + const props: any = {} + let attrs: any = void 0 + if (rawData != null) { + for (const key in rawData) { + // key, ref, slots are reserved + if (key === 'key' || key === 'ref' || key === 'slots') { + continue + } + // any non-declared data are put into a separate `attrs` object + // for spreading + if (hasDeclaredProps && !options.hasOwnProperty(key)) { + ;(attrs || (attrs = {}))[key] = rawData[key] + } else { + props[key] = rawData[key] + } + } + } + // set default values, cast booleans & run validators + if (hasDeclaredProps) { + for (const key in options) { + let opt = options[key] + if (opt == null) continue + const isAbsent = !props.hasOwnProperty(key) + const hasDefault = opt.hasOwnProperty('default') + const currentValue = props[key] + // default values + if (hasDefault && currentValue === undefined) { + const defaultValue = opt.default + props[key] = isFunction(defaultValue) ? defaultValue() : defaultValue + } + // boolean casting + if (opt[BooleanFlags.shouldCast]) { + if (isAbsent && !hasDefault) { + props[key] = false + } else if ( + opt[BooleanFlags.shouldCastTrue] && + (currentValue === '' || currentValue === hyphenate(key)) + ) { + props[key] = true + } + } + // runtime validation + if (__DEV__ && rawData) { + validateProp(key, unwrap(rawData[key]), opt, isAbsent) + } + } + } else { + // if component has no declared props, $attrs === $props + attrs = props + } + return [props, attrs] +} + +const normalizationMap = new WeakMap() + +function normalizePropsOptions( + raw: ComponentPropsOptions | void +): NormalizedPropsOptions | void { + if (!raw) { + return + } + if (normalizationMap.has(raw)) { + return normalizationMap.get(raw) + } + const normalized: NormalizedPropsOptions = {} + normalizationMap.set(raw, normalized) + if (isArray(raw)) { + for (let i = 0; i < raw.length; i++) { + if (__DEV__ && !isString(raw[i])) { + warn(`props must be strings when using array syntax.`, raw[i]) + } + const normalizedKey = camelize(raw[i]) + if (!isReservedKey(normalizedKey)) { + normalized[normalizedKey] = EMPTY_OBJ + } else if (__DEV__) { + warn(`Invalid prop name: "${normalizedKey}" is a reserved property.`) + } + } + } else { + if (__DEV__ && !isObject(raw)) { + warn(`invalid props options`, raw) + } + for (const key in raw) { + const normalizedKey = camelize(key) + if (!isReservedKey(normalizedKey)) { + const opt = raw[key] + const prop = (normalized[normalizedKey] = + isArray(opt) || isFunction(opt) ? { type: opt } : opt) + if (prop) { + const booleanIndex = getTypeIndex(Boolean, prop.type) + const stringIndex = getTypeIndex(String, prop.type) + ;(prop as NormalizedProp)[BooleanFlags.shouldCast] = booleanIndex > -1 + ;(prop as NormalizedProp)[BooleanFlags.shouldCastTrue] = + booleanIndex < stringIndex + } + } else if (__DEV__) { + warn(`Invalid prop name: "${normalizedKey}" is a reserved property.`) + } + } + } + return normalized +} + +// use function string name to check type constructors +// so that it works across vms / iframes. +function getType(ctor: Prop): string { + const match = ctor && ctor.toString().match(/^\s*function (\w+)/) + return match ? match[1] : '' +} + +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)) { + for (let i = 0, len = expectedTypes.length; i < len; i++) { + if (isSameType(expectedTypes[i], type)) { + return i + } + } + } else if (isObject(expectedTypes)) { + return isSameType(expectedTypes, type) ? 0 : -1 + } + return -1 +} + +type AssertionResult = { + valid: boolean + expectedType: string +} + +function validateProp( + name: string, + value: any, + prop: PropOptions, + isAbsent: boolean +) { + const { type, required, validator } = prop + // required! + if (required && isAbsent) { + warn('Missing required prop: "' + name + '"') + return + } + // missing but optional + if (value == null && !prop.required) { + return + } + // type check + if (type != null && type !== true) { + let isValid = false + const types = isArray(type) ? type : [type] + const expectedTypes = [] + // value is valid as long as one of the specified types match + for (let i = 0; i < types.length && !isValid; i++) { + const { valid, expectedType } = assertType(value, types[i]) + expectedTypes.push(expectedType || '') + isValid = valid + } + if (!isValid) { + warn(getInvalidTypeMessage(name, value, expectedTypes)) + return + } + } + // custom validator + if (validator && !validator(value)) { + warn('Invalid prop: custom validator check failed for prop "' + name + '".') + } +} + +const simpleCheckRE = /^(String|Number|Boolean|Function|Symbol)$/ + +function assertType(value: any, type: Prop): AssertionResult { + let valid + const expectedType = getType(type) + if (simpleCheckRE.test(expectedType)) { + const t = typeof value + valid = t === expectedType.toLowerCase() + // for primitive wrapper objects + if (!valid && t === 'object') { + valid = value instanceof type + } + } else if (expectedType === 'Object') { + valid = toRawType(value) === 'Object' + } else if (expectedType === 'Array') { + valid = isArray(value) + } else { + valid = value instanceof type + } + return { + valid, + expectedType + } +} + +function getInvalidTypeMessage( + name: string, + value: any, + expectedTypes: string[] +): string { + let message = + `Invalid prop: type check failed for prop "${name}".` + + ` Expected ${expectedTypes.map(capitalize).join(', ')}` + const expectedType = expectedTypes[0] + const receivedType = toRawType(value) + const expectedValue = styleValue(value, expectedType) + const receivedValue = styleValue(value, receivedType) + // check if we need to specify expected value + if ( + expectedTypes.length === 1 && + isExplicable(expectedType) && + !isBoolean(expectedType, receivedType) + ) { + message += ` with value ${expectedValue}` + } + message += `, got ${receivedType} ` + // check if we need to specify received value + if (isExplicable(receivedType)) { + message += `with value ${receivedValue}.` + } + return message +} + +function styleValue(value: any, type: string): string { + if (type === 'String') { + return `"${value}"` + } else if (type === 'Number') { + return `${Number(value)}` + } else { + return `${value}` + } +} + +function toRawType(value: any): string { + return Object.prototype.toString.call(value).slice(8, -1) +} + +function isExplicable(type: string): boolean { + const explicitTypes = ['string', 'number', 'boolean'] + return explicitTypes.some(elem => type.toLowerCase() === elem) +} + +function isBoolean(...args: string[]): boolean { + return args.some(elem => elem.toLowerCase() === 'boolean') +} diff --git a/packages/runtime-core/src/createRenderer.ts b/packages/runtime-core/src/createRenderer.ts index 9a907855..169ad0d9 100644 --- a/packages/runtime-core/src/createRenderer.ts +++ b/packages/runtime-core/src/createRenderer.ts @@ -349,11 +349,16 @@ export function createRenderer(options: RendererOptions) { anchor?: HostNode ) { const instance: ComponentHandle = (vnode.component = { - type: vnode.type as Function, + type: vnode.type as any, vnode: null, next: null, subTree: null, - update: null as any + update: null as any, + $attrs: EMPTY_OBJ, + $props: EMPTY_OBJ, + $refs: EMPTY_OBJ, + $slots: EMPTY_OBJ, + $state: EMPTY_OBJ }) // TODO call setup, handle bindings and render context diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 09bbadef..92850f94 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -7,6 +7,15 @@ export { Text, Empty } from './vnode' + +export { + ComponentOptions, + FunctionalComponent, + Slots, + Slot, + createComponent +} from './component' + export { createRenderer, RendererOptions } from './createRenderer' -export * from '@vue/observer' export { TEXT, CLASS, STYLE, PROPS, KEYED, UNKEYED } from './patchFlags' +export * from '@vue/observer' diff --git a/packages/runtime-core/src/warning.ts b/packages/runtime-core/src/warning.ts new file mode 100644 index 00000000..78a8981b --- /dev/null +++ b/packages/runtime-core/src/warning.ts @@ -0,0 +1,3 @@ +export function warn(...args: any[]) { + // TODO +}