feat(sfc): withDefaults helper

This commit is contained in:
Evan You 2021-06-26 21:11:57 -04:00
parent 3ffc7be864
commit 4c5844a9ca
9 changed files with 492 additions and 165 deletions

View File

@ -881,3 +881,50 @@ return { }
})"
`;
exports[`SFC compile <script setup> with TypeScript withDefaults (dynamic) 1`] = `
"import { mergeDefaults as _mergeDefaults, defineComponent as _defineComponent } from 'vue'
import { defaults } from './foo'
export default _defineComponent({
props: _mergeDefaults({
foo: { type: String, required: false },
bar: { type: Number, required: false }
}, { ...defaults }) as unknown as undefined,
setup(__props: {
foo?: string
bar?: number
}, { expose }) {
expose()
const props = __props
return { props, defaults }
}
})"
`;
exports[`SFC compile <script setup> with TypeScript withDefaults (static) 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
export default _defineComponent({
props: {
foo: { type: String, required: false, default: 'hi' },
bar: { type: Number, required: false }
} as unknown as undefined,
setup(__props: {
foo?: string
bar?: number
}, { expose }) {
expose()
const props = __props
return { props }
}
})"
`;

View File

@ -592,6 +592,51 @@ const emit = defineEmits(['a', 'b'])
})
})
test('withDefaults (static)', () => {
const { content, bindings } = compile(`
<script setup lang="ts">
const props = withDefaults(defineProps<{
foo?: string
bar?: number
}>(), {
foo: 'hi'
})
</script>
`)
assertCode(content)
expect(content).toMatch(
`foo: { type: String, required: false, default: 'hi' }`
)
expect(content).toMatch(`bar: { type: Number, required: false }`)
expect(content).toMatch(`const props = __props`)
expect(bindings).toStrictEqual({
foo: BindingTypes.PROPS,
bar: BindingTypes.PROPS,
props: BindingTypes.SETUP_CONST
})
})
test('withDefaults (dynamic)', () => {
const { content } = compile(`
<script setup lang="ts">
import { defaults } from './foo'
const props = withDefaults(defineProps<{
foo?: string
bar?: number
}>(), { ...defaults })
</script>
`)
assertCode(content)
expect(content).toMatch(`import { mergeDefaults as _mergeDefaults`)
expect(content).toMatch(
`
_mergeDefaults({
foo: { type: String, required: false },
bar: { type: Number, required: false }
}, { ...defaults })`.trim()
)
})
test('defineEmits w/ type', () => {
const { content } = compile(`
<script setup lang="ts">
@ -942,7 +987,6 @@ const emit = defineEmits(['a', 'b'])
test('defineProps/Emit() w/ both type and non-type args', () => {
expect(() => {
compile(`<script setup lang="ts">
import { defineProps } from 'vue'
defineProps<{}>({})
</script>`)
}).toThrow(`cannot accept both type and non-type arguments`)

View File

@ -36,8 +36,11 @@ import { rewriteDefault } from './rewriteDefault'
const DEFINE_PROPS = 'defineProps'
const DEFINE_EMIT = 'defineEmit'
const DEFINE_EMITS = 'defineEmits'
const DEFINE_EXPOSE = 'defineExpose'
const WITH_DEFAULTS = 'withDefaults'
// deprecated
const DEFINE_EMITS = 'defineEmits'
export interface SFCScriptCompileOptions {
/**
@ -191,6 +194,7 @@ export function compileScript(
let hasDefineEmitCall = false
let hasDefineExposeCall = false
let propsRuntimeDecl: Node | undefined
let propsRuntimeDefaults: Node | undefined
let propsTypeDecl: TSTypeLiteral | undefined
let propsIdentifier: string | undefined
let emitRuntimeDecl: Node | undefined
@ -262,13 +266,18 @@ export function compileScript(
}
function processDefineProps(node: Node): boolean {
if (isCallOf(node, DEFINE_PROPS)) {
if (!isCallOf(node, DEFINE_PROPS)) {
return false
}
if (hasDefinePropsCall) {
error(`duplicate ${DEFINE_PROPS}() call`, node)
}
hasDefinePropsCall = true
propsRuntimeDecl = node.arguments[0]
// context call has type parameters - infer runtime types from it
// call has type parameters - infer runtime types from it
if (node.typeParameters) {
if (propsRuntimeDecl) {
error(
@ -277,6 +286,7 @@ export function compileScript(
node
)
}
const typeArg = node.typeParameters.params[0]
if (typeArg.type === 'TSTypeLiteral') {
propsTypeDecl = typeArg
@ -287,13 +297,36 @@ export function compileScript(
)
}
}
return true
}
function processWithDefaults(node: Node): boolean {
if (!isCallOf(node, WITH_DEFAULTS)) {
return false
}
if (processDefineProps(node.arguments[0])) {
if (propsRuntimeDecl) {
error(
`${WITH_DEFAULTS} can only be used with type-based ` +
`${DEFINE_PROPS} declaration.`,
node
)
}
propsRuntimeDefaults = node.arguments[1]
} else {
error(
`${WITH_DEFAULTS}' first argument must be a ${DEFINE_PROPS} call.`,
node.arguments[0] || node
)
}
return true
}
function processDefineEmits(node: Node): boolean {
if (isCallOf(node, DEFINE_EMIT) || isCallOf(node, DEFINE_EMITS)) {
if (!isCallOf(node, c => c === DEFINE_EMIT || c === DEFINE_EMITS)) {
return false
}
if (hasDefineEmitCall) {
error(`duplicate ${DEFINE_EMITS}() call`, node)
}
@ -323,8 +356,6 @@ export function compileScript(
}
return true
}
return false
}
function processDefineExpose(node: Node): boolean {
if (isCallOf(node, DEFINE_EXPOSE)) {
@ -480,6 +511,63 @@ export function compileScript(
}
}
function genRuntimeProps(props: Record<string, PropTypeData>) {
const keys = Object.keys(props)
if (!keys.length) {
return ``
}
// check defaults. If the default object is an object literal with only
// static properties, we can directly generate more optimzied default
// decalrations. Otherwise we will have to fallback to runtime merging.
const hasStaticDefaults =
propsRuntimeDefaults &&
propsRuntimeDefaults.type === 'ObjectExpression' &&
propsRuntimeDefaults.properties.every(
node => node.type === 'ObjectProperty' && !node.computed
)
let propsDecls = `{
${keys
.map(key => {
let defaultString: string | undefined
if (hasStaticDefaults) {
const prop = (propsRuntimeDefaults as ObjectExpression).properties.find(
(node: any) => node.key.name === key
) as ObjectProperty
if (prop) {
// prop has corresponding static default value
defaultString = `default: ${source.slice(
prop.value.start! + startOffset,
prop.value.end! + startOffset
)}`
}
}
if (__DEV__) {
const { type, required } = props[key]
return `${key}: { type: ${toRuntimeTypeString(
type
)}, required: ${required}${
defaultString ? `, ${defaultString}` : ``
} }`
} else {
// production: checks are useless
return `${key}: ${defaultString ? `{ ${defaultString} }` : 'null'}`
}
})
.join(',\n ')}\n }`
if (propsRuntimeDefaults && !hasStaticDefaults) {
propsDecls = `${helper('mergeDefaults')}(${propsDecls}, ${source.slice(
propsRuntimeDefaults.start! + startOffset,
propsRuntimeDefaults.end! + startOffset
)})`
}
return `\n props: ${propsDecls} as unknown as undefined,`
}
// 1. process normal <script> first if it exists
let scriptAst
if (script) {
@ -675,7 +763,8 @@ export function compileScript(
// process `defineProps` and `defineEmit(s)` calls
if (
processDefineProps(node.expression) ||
processDefineEmits(node.expression)
processDefineEmits(node.expression) ||
processWithDefaults(node.expression)
) {
s.remove(node.start! + startOffset, node.end! + startOffset)
} else if (processDefineExpose(node.expression)) {
@ -692,7 +781,8 @@ export function compileScript(
if (node.type === 'VariableDeclaration' && !node.declare) {
for (const decl of node.declarations) {
if (decl.init) {
const isDefineProps = processDefineProps(decl.init)
const isDefineProps =
processDefineProps(decl.init) || processWithDefaults(decl.init)
if (isDefineProps) {
propsIdentifier = scriptSetup.content.slice(
decl.id.start!,
@ -812,6 +902,7 @@ export function compileScript(
// 5. check useOptions args to make sure it doesn't reference setup scope
// variables
checkInvalidScopeReference(propsRuntimeDecl, DEFINE_PROPS)
checkInvalidScopeReference(propsRuntimeDefaults, DEFINE_PROPS)
checkInvalidScopeReference(emitRuntimeDecl, DEFINE_PROPS)
// 6. remove non-script content
@ -1080,9 +1171,14 @@ function walkDeclaration(
for (const { id, init } of node.declarations) {
const isDefineCall = !!(
isConst &&
(isCallOf(init, DEFINE_PROPS) ||
isCallOf(init, DEFINE_EMIT) ||
isCallOf(init, DEFINE_EMITS))
isCallOf(
init,
c =>
c === DEFINE_PROPS ||
c === DEFINE_EMIT ||
c === DEFINE_EMITS ||
c === WITH_DEFAULTS
)
)
if (id.type === 'Identifier') {
let bindingType
@ -1318,29 +1414,6 @@ function inferRuntimeType(
}
}
function genRuntimeProps(props: Record<string, PropTypeData>) {
const keys = Object.keys(props)
if (!keys.length) {
return ``
}
if (!__DEV__) {
// production: generate array version only
return `\n props: [\n ${keys
.map(k => JSON.stringify(k))
.join(',\n ')}\n ] as unknown as undefined,`
}
return `\n props: {\n ${keys
.map(key => {
const { type, required } = props[key]
return `${key}: { type: ${toRuntimeTypeString(
type
)}, required: ${required} }`
})
.join(',\n ')}\n } as unknown as undefined,`
}
function toRuntimeTypeString(types: string[]) {
return types.some(t => t === 'null')
? `null`
@ -1567,13 +1640,15 @@ function isFunction(node: Node): node is FunctionNode {
function isCallOf(
node: Node | null | undefined,
name: string
test: string | ((id: string) => boolean)
): node is CallExpression {
return !!(
node &&
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.name === name
(typeof test === 'string'
? node.callee.name === test
: test(node.callee.name))
)
}

View File

@ -8,8 +8,11 @@ import {
import {
defineEmits,
defineProps,
defineExpose,
withDefaults,
useAttrs,
useSlots
useSlots,
mergeDefaults
} from '../src/apiSetupHelpers'
describe('SFC <script setup> helpers', () => {
@ -19,6 +22,12 @@ describe('SFC <script setup> helpers', () => {
defineEmits()
expect(`defineEmits() is a compiler-hint`).toHaveBeenWarned()
defineExpose()
expect(`defineExpose() is a compiler-hint`).toHaveBeenWarned()
withDefaults({}, {})
expect(`withDefaults() is a compiler-hint`).toHaveBeenWarned()
})
test('useSlots / useAttrs (no args)', () => {
@ -58,4 +67,26 @@ describe('SFC <script setup> helpers', () => {
expect(slots).toBe(ctx!.slots)
expect(attrs).toBe(ctx!.attrs)
})
test('mergeDefaults', () => {
const merged = mergeDefaults(
{
foo: null,
bar: { type: String, required: false }
},
{
foo: 1,
bar: 'baz'
}
)
expect(merged).toMatchObject({
foo: { default: 1 },
bar: { type: String, required: false, default: 'baz' }
})
mergeDefaults({}, { foo: 1 })
expect(
`props default key "foo" has no corresponding declaration`
).toHaveBeenWarned()
})
})

View File

@ -4,63 +4,104 @@ import {
createSetupContext
} from './component'
import { EmitFn, EmitsOptions } from './componentEmits'
import { ComponentObjectPropsOptions, ExtractPropTypes } from './componentProps'
import {
ComponentObjectPropsOptions,
PropOptions,
ExtractPropTypes
} from './componentProps'
import { warn } from './warning'
type InferDefaults<T> = {
[K in keyof T]?: NonNullable<T[K]> extends object
? () => NonNullable<T[K]>
: NonNullable<T[K]>
}
/**
* Compile-time-only helper used for declaring props inside `<script setup>`.
* This is stripped away in the compiled code and should never be actually
* called at runtime.
*/
// overload 1: string props
export function defineProps<
TypeProps = undefined,
PropNames extends string = string,
InferredProps = { [key in PropNames]?: any }
>(
props?: PropNames[]
): Readonly<TypeProps extends undefined ? InferredProps : TypeProps>
// overload 2: object props
export function defineProps<
TypeProps = undefined,
PP extends ComponentObjectPropsOptions = ComponentObjectPropsOptions,
InferredProps = ExtractPropTypes<PP>
>(
props?: PP,
defaults?: InferDefaults<TypeProps>
): Readonly<TypeProps extends undefined ? InferredProps : TypeProps>
// implementation
export function defineProps() {
if (__DEV__) {
// dev only
const warnRuntimeUsage = (method: string) =>
warn(
`defineProps() is a compiler-hint helper that is only usable inside ` +
`${method}() is a compiler-hint helper that is only usable inside ` +
`<script setup> of a single file component. Its arguments should be ` +
`compiled away and passing it at runtime has no effect.`
)
/**
* Vue `<script setup>` compiler macro for declaring component props. The
* expected argument is the same as the component `props` option.
*
* Example runtime declaration:
* ```js
* // using Array syntax
* const props = defineProps(['foo', 'bar'])
* // using Object syntax
* const props = defineProps({
* foo: String,
* bar: {
* type: Number,
* required: true
* }
* })
* ```
*
* Equivalent type-based decalration:
* ```ts
* // will be compiled into equivalent runtime declarations
* const props = defineProps<{
* foo?: string
* bar: number
* }>()
* ```
*
* This is only usable inside `<script setup>`, is compiled away in the
* output and should **not** be actually called at runtime.
*/
// overload 1: runtime props w/ array
export function defineProps<PropNames extends string = string>(
props: PropNames[]
): Readonly<{ [key in PropNames]?: any }>
// overload 2: runtime props w/ object
export function defineProps<
PP extends ComponentObjectPropsOptions = ComponentObjectPropsOptions
>(props: PP): Readonly<ExtractPropTypes<PP>>
// overload 3: typed-based declaration
export function defineProps<TypeProps>(): Readonly<TypeProps>
// implementation
export function defineProps() {
if (__DEV__) {
warnRuntimeUsage(`defineProps`)
}
return null as any
}
export function defineEmits<
TypeEmit = undefined,
E extends EmitsOptions = EmitsOptions,
EE extends string = string,
InferredEmit = EmitFn<E>
>(emitOptions?: E | EE[]): TypeEmit extends undefined ? InferredEmit : TypeEmit
/**
* Vue `<script setup>` compiler macro for declaring a component's emitted
* events. The expected argument is the same as the component `emits` option.
*
* Example runtime declaration:
* ```js
* const emit = defineEmits(['change', 'update'])
* ```
*
* Example type-based decalration:
* ```ts
* const emit = defineEmits<{
* (event: 'change'): void
* (event: 'update', id: number): void
* }>()
*
* emit('change')
* emit('update', 1)
* ```
*
* This is only usable inside `<script setup>`, is compiled away in the
* output and should **not** be actually called at runtime.
*/
// overload 1: runtime emits w/ array
export function defineEmits<EE extends string = string>(
emitOptions: EE[]
): EmitFn<EE[]>
export function defineEmits<E extends EmitsOptions = EmitsOptions>(
emitOptions: E
): EmitFn<E>
export function defineEmits<TypeEmit>(): TypeEmit
// implementation
export function defineEmits() {
if (__DEV__) {
warn(
`defineEmits() is a compiler-hint helper that is only usable inside ` +
`<script setup> of a single file component. Its arguments should be ` +
`compiled away and passing it at runtime has no effect.`
)
warnRuntimeUsage(`defineEmits`)
}
return null as any
}
@ -70,16 +111,70 @@ export function defineEmits() {
*/
export const defineEmit = defineEmits
/**
* Vue `<script setup>` compiler macro for declaring a component's exposed
* instance properties when it is accessed by a parent component via template
* refs.
*
* `<script setup>` components are closed by default - i.e. varaibles inside
* the `<script setup>` scope is not exposed to parent unless explicitly exposed
* via `defineExpose`.
*
* This is only usable inside `<script setup>`, is compiled away in the
* output and should **not** be actually called at runtime.
*/
export function defineExpose(exposed?: Record<string, any>) {
if (__DEV__) {
warn(
`defineExpose() is a compiler-hint helper that is only usable inside ` +
`<script setup> of a single file component. Its usage should be ` +
`compiled away and calling it at runtime has no effect.`
)
warnRuntimeUsage(`defineExpose`)
}
}
type NotUndefined<T> = T extends undefined ? never : T
type InferDefaults<T> = {
[K in keyof T]?: NotUndefined<T[K]> extends (
| number
| string
| boolean
| symbol
| Function)
? NotUndefined<T[K]>
: (props: T) => NotUndefined<T[K]>
}
type PropsWithDefaults<Base, Defaults> = Base &
{
[K in keyof Defaults]: K extends keyof Base ? NotUndefined<Base[K]> : never
}
/**
* Vue `<script setup>` compiler macro for providing props default values when
* using type-based `defineProps` decalration.
*
* Example usage:
* ```ts
* withDefaults(defineProps<{
* size?: number
* labels?: string[]
* }>(), {
* size: 3,
* labels: () => ['default label']
* })
* ```
*
* This is only usable inside `<script setup>`, is compiled away in the output
* and should **not** be actually called at runtime.
*/
export function withDefaults<Props, Defaults extends InferDefaults<Props>>(
props: Props,
defaults: Defaults
): PropsWithDefaults<Props, Defaults> {
if (__DEV__) {
warnRuntimeUsage(`withDefaults`)
}
return null as any
}
/**
* @deprecated use `useSlots` and `useAttrs` instead.
*/
@ -93,6 +188,14 @@ export function useContext(): SetupContext {
return getContext()
}
export function useSlots(): SetupContext['slots'] {
return getContext().slots
}
export function useAttrs(): SetupContext['attrs'] {
return getContext().attrs
}
function getContext(): SetupContext {
const i = getCurrentInstance()!
if (__DEV__ && !i) {
@ -101,10 +204,25 @@ function getContext(): SetupContext {
return i.setupContext || (i.setupContext = createSetupContext(i))
}
export function useSlots(): SetupContext['slots'] {
return getContext().slots
/**
* Runtime helper for merging default declarations. Imported by compiled code
* only.
* @internal
*/
export function mergeDefaults(
// the base props is compiler-generated and guaranteed to be in this shape.
props: Record<string, PropOptions | null>,
defaults: Record<string, any>
) {
for (const key in defaults) {
const val = props[key]
if (val) {
val.default = defaults[key]
} else if (val === null) {
props[key] = { default: defaults[key] }
} else if (__DEV__) {
warn(`props default key "${key}" has no corresponding declaration.`)
}
export function useAttrs(): SetupContext['attrs'] {
return getContext().attrs
}
return props
}

View File

@ -51,7 +51,7 @@ export type Prop<T, D = T> = PropOptions<T, D> | PropType<T>
type DefaultFactory<T> = (props: Data) => T | null | undefined
interface PropOptions<T = any, D = T> {
export interface PropOptions<T = any, D = T> {
type?: PropType<T> | true | null
required?: boolean
default?: D | DefaultFactory<D> | null | undefined | object

View File

@ -44,9 +44,17 @@ export { provide, inject } from './apiInject'
export { nextTick } from './scheduler'
export { defineComponent } from './apiDefineComponent'
export { defineAsyncComponent } from './apiAsyncComponent'
// <script setup> API ----------------------------------------------------------
export {
defineProps,
defineEmits,
defineExpose,
withDefaults,
// internal
mergeDefaults,
// deprecated
defineEmit,
useContext
} from './apiSetupHelpers'
@ -140,7 +148,6 @@ export {
DeepReadonly
} from '@vue/reactivity'
export {
// types
WatchEffect,
WatchOptions,
WatchOptionsBase,

View File

@ -214,7 +214,12 @@ async function doCompileScript(
return [code, compiledScript.bindings]
} catch (e) {
store.errors = [e]
store.errors = [
e.stack
.split('\n')
.slice(0, 12)
.join('\n')
]
return
}
} else {

View File

@ -1,3 +1,4 @@
import { withDefaults } from '../packages/runtime-core/src/apiSetupHelpers'
import {
expectType,
defineProps,
@ -19,30 +20,29 @@ describe('defineProps w/ type declaration', () => {
props.bar
})
describe('defineProps w/ type declaration + defaults', () => {
describe('defineProps w/ type declaration + withDefaults', () => {
const res = withDefaults(
defineProps<{
number?: number
arr?: string[]
arr2?: string[]
obj?: { x: number }
obj2?: { x: number }
obj3?: { x: number }
}>(
{},
fn?: (e: string) => void
x?: string
}>(),
{
number: 1,
arr: () => [''],
// @ts-expect-error not using factory
arr2: [''],
number: 123,
arr: () => [],
obj: () => ({ x: 123 }),
// @ts-expect-error not using factory
obj2: { x: 123 },
// @ts-expect-error factory return type does not match
obj3: () => ({ x: 'foo' })
fn: () => {}
}
)
res.number + 1
res.arr.push('hi')
res.obj.x
res.fn('hi')
// @ts-expect-error
res.x.slice()
})
describe('defineProps w/ runtime declaration', () => {