From 2241ad776545cfc83abd52df85cb46941a466694 Mon Sep 17 00:00:00 2001 From: Evan You Date: Tue, 2 Oct 2018 18:29:14 -0400 Subject: [PATCH] feat: runtime prop validation --- packages/core/src/componentOptions.ts | 2 +- packages/core/src/componentProps.ts | 136 +++++++++++++++++++++++--- packages/core/src/utils.ts | 4 + 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/packages/core/src/componentOptions.ts b/packages/core/src/componentOptions.ts index fbe25083..4cdfcb3e 100644 --- a/packages/core/src/componentOptions.ts +++ b/packages/core/src/componentOptions.ts @@ -29,7 +29,7 @@ export type PropType = Prop | Prop[] export type PropValidator = PropOptions | PropType export interface PropOptions { - type?: PropType + type?: PropType | true | null required?: boolean default?: T | null | undefined | (() => T | null | undefined) validator?(value: T): boolean diff --git a/packages/core/src/componentProps.ts b/packages/core/src/componentProps.ts index 0b11c11a..f48ae1a9 100644 --- a/packages/core/src/componentProps.ts +++ b/packages/core/src/componentProps.ts @@ -8,12 +8,11 @@ import { immutable, unwrap, lock, unlock } from '@vue/observer' import { Data, ComponentPropsOptions, - PropValidator, PropOptions, Prop, PropType } from './componentOptions' -import { camelize, hyphenate } from './utils' +import { camelize, hyphenate, capitalize } from './utils' export function initializeProps(instance: MountedComponent, data: Data | null) { const { props, attrs } = resolveProps( @@ -136,7 +135,7 @@ export function resolveProps( } // runtime validation if (__DEV__) { - validateProp(key, rawData[key], opt, Component) + validateProp(key, rawData[key], opt, Component, isAbsent) } } } @@ -211,7 +210,7 @@ function isSameType(a: Prop, b: Prop): boolean { function getTypeIndex( type: Prop, - expectedTypes: PropType | void + expectedTypes: PropType | void | null | true ): number { if (Array.isArray(expectedTypes)) { for (let i = 0, len = expectedTypes.length; i < len; i++) { @@ -219,17 +218,130 @@ function getTypeIndex( return i } } - } else if (expectedTypes != null) { + } else if (expectedTypes != null && typeof expectedTypes === 'object') { return isSameType(expectedTypes, type) ? 0 : -1 } return -1 } -function validateProp( - key: string, - value: any, - validator: PropValidator, - Component: ComponentClass | FunctionalComponent -) { - // TODO +type AssertionResult = { + valid: boolean + expectedType: string +} + +function validateProp( + name: string, + value: any, + prop: PropOptions, + Component: ComponentClass | FunctionalComponent, + isAbsent: boolean +) { + const { type, required, validator } = prop + // required! + if (required && isAbsent) { + console.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 = Array.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) { + console.warn(getInvalidTypeMessage(name, value, expectedTypes)) + return + } + } + // custom validator + if (validator && !validator(value)) { + console.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 = Array.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/core/src/utils.ts b/packages/core/src/utils.ts index 13d81d2d..834a7cce 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -55,6 +55,10 @@ export const hyphenate = (str: string): string => { return str.replace(hyphenateRE, '-$1').toLowerCase() } +export const capitalize = (str: string): string => { + return str.charAt(0).toUpperCase() + str.slice(1) +} + // https://en.wikipedia.org/wiki/Longest_increasing_subsequence export function lis(arr: number[]): number[] { const p = arr.slice()