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,68 +266,95 @@ export function compileScript(
}
function processDefineProps(node: Node): boolean {
if (isCallOf(node, DEFINE_PROPS)) {
if (hasDefinePropsCall) {
error(`duplicate ${DEFINE_PROPS}() call`, node)
}
hasDefinePropsCall = true
propsRuntimeDecl = node.arguments[0]
// context call has type parameters - infer runtime types from it
if (node.typeParameters) {
if (propsRuntimeDecl) {
error(
`${DEFINE_PROPS}() cannot accept both type and non-type arguments ` +
`at the same time. Use one or the other.`,
node
)
}
const typeArg = node.typeParameters.params[0]
if (typeArg.type === 'TSTypeLiteral') {
propsTypeDecl = typeArg
} else {
error(
`type argument passed to ${DEFINE_PROPS}() must be a literal type.`,
typeArg
)
}
}
return true
if (!isCallOf(node, DEFINE_PROPS)) {
return false
}
return false
if (hasDefinePropsCall) {
error(`duplicate ${DEFINE_PROPS}() call`, node)
}
hasDefinePropsCall = true
propsRuntimeDecl = node.arguments[0]
// call has type parameters - infer runtime types from it
if (node.typeParameters) {
if (propsRuntimeDecl) {
error(
`${DEFINE_PROPS}() cannot accept both type and non-type arguments ` +
`at the same time. Use one or the other.`,
node
)
}
const typeArg = node.typeParameters.params[0]
if (typeArg.type === 'TSTypeLiteral') {
propsTypeDecl = typeArg
} else {
error(
`type argument passed to ${DEFINE_PROPS}() must be a literal type.`,
typeArg
)
}
}
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 (hasDefineEmitCall) {
error(`duplicate ${DEFINE_EMITS}() call`, node)
}
hasDefineEmitCall = true
emitRuntimeDecl = node.arguments[0]
if (node.typeParameters) {
if (emitRuntimeDecl) {
error(
`${DEFINE_EMIT}() cannot accept both type and non-type arguments ` +
`at the same time. Use one or the other.`,
node
)
}
const typeArg = node.typeParameters.params[0]
if (
typeArg.type === 'TSFunctionType' ||
typeArg.type === 'TSTypeLiteral'
) {
emitTypeDecl = typeArg
} else {
error(
`type argument passed to ${DEFINE_EMITS}() must be a function type ` +
`or a literal type with call signatures.`,
typeArg
)
}
}
return true
if (!isCallOf(node, c => c === DEFINE_EMIT || c === DEFINE_EMITS)) {
return false
}
return false
if (hasDefineEmitCall) {
error(`duplicate ${DEFINE_EMITS}() call`, node)
}
hasDefineEmitCall = true
emitRuntimeDecl = node.arguments[0]
if (node.typeParameters) {
if (emitRuntimeDecl) {
error(
`${DEFINE_EMIT}() cannot accept both type and non-type arguments ` +
`at the same time. Use one or the other.`,
node
)
}
const typeArg = node.typeParameters.params[0]
if (
typeArg.type === 'TSFunctionType' ||
typeArg.type === 'TSTypeLiteral'
) {
emitTypeDecl = typeArg
} else {
error(
`type argument passed to ${DEFINE_EMITS}() must be a function type ` +
`or a literal type with call signatures.`,
typeArg
)
}
}
return true
}
function processDefineExpose(node: Node): boolean {
@@ -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))
)
}