feat(compiler-sfc): enable reactive props destructure by default and deprecate withDefaults() (#7986)

This commit is contained in:
Evan You 2023-03-30 11:58:16 +08:00 committed by GitHub
parent e10a89e608
commit ba9c2ae247
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 593 additions and 68 deletions

View File

@ -9,7 +9,8 @@ import type {
Program,
ImportDefaultSpecifier,
ImportNamespaceSpecifier,
ImportSpecifier
ImportSpecifier,
CallExpression
} from '@babel/types'
import { walk } from 'estree-walker'
@ -449,3 +450,18 @@ export function unwrapTSNode(node: Node): Node {
return node
}
}
export function isCallOf(
node: Node | null | undefined,
test: string | ((id: string) => boolean) | null | undefined
): node is CallExpression {
return !!(
node &&
test &&
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(typeof test === 'string'
? node.callee.name === test
: test(node.callee.name))
)
}

View File

@ -0,0 +1,210 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`sfc props transform > aliasing 1`] = `
"import { toDisplayString as _toDisplayString } from \\"vue\\"
export default {
props: ['foo'],
setup(__props) {
let x = foo
let y = __props.foo
return (_ctx, _cache) => {
return _toDisplayString(__props.foo + __props.foo)
}
}
}"
`;
exports[`sfc props transform > basic usage 1`] = `
"import { toDisplayString as _toDisplayString } from \\"vue\\"
export default {
props: ['foo'],
setup(__props) {
console.log(__props.foo)
return (_ctx, _cache) => {
return _toDisplayString(__props.foo)
}
}
}"
`;
exports[`sfc props transform > computed static key 1`] = `
"import { toDisplayString as _toDisplayString } from \\"vue\\"
export default {
props: ['foo'],
setup(__props) {
console.log(__props.foo)
return (_ctx, _cache) => {
return _toDisplayString(__props.foo)
}
}
}"
`;
exports[`sfc props transform > default values w/ array runtime declaration 1`] = `
"import { mergeDefaults as _mergeDefaults } from 'vue'
export default {
props: _mergeDefaults(['foo', 'bar', 'baz'], {
foo: 1,
bar: () => ({}),
func: () => {}, __skip_func: true
}),
setup(__props) {
return () => {}
}
}"
`;
exports[`sfc props transform > default values w/ object runtime declaration 1`] = `
"import { mergeDefaults as _mergeDefaults } from 'vue'
export default {
props: _mergeDefaults({ foo: Number, bar: Object, func: Function, ext: null }, {
foo: 1,
bar: () => ({}),
func: () => {}, __skip_func: true,
ext: x, __skip_ext: true
}),
setup(__props) {
return () => {}
}
}"
`;
exports[`sfc props transform > default values w/ type declaration 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
foo: { type: Number, required: false, default: 1 },
bar: { type: Object, required: false, default: () => ({}) },
func: { type: Function, required: false, default: () => {} }
},
setup(__props: any) {
return () => {}
}
})"
`;
exports[`sfc props transform > default values w/ type declaration, prod mode 1`] = `
"import { defineComponent as _defineComponent } from 'vue'
export default /*#__PURE__*/_defineComponent({
props: {
foo: { default: 1 },
bar: { default: () => ({}) },
baz: null,
boola: { type: Boolean },
boolb: { type: [Boolean, Number] },
func: { type: Function, default: () => {} }
},
setup(__props: any) {
return () => {}
}
})"
`;
exports[`sfc props transform > multiple variable declarations 1`] = `
"import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from \\"vue\\"
export default {
props: ['foo'],
setup(__props) {
const bar = 'fish', hello = 'world'
return (_ctx, _cache) => {
return (_openBlock(), _createElementBlock(\\"div\\", null, _toDisplayString(__props.foo) + \\" \\" + _toDisplayString(hello) + \\" \\" + _toDisplayString(bar), 1 /* TEXT */))
}
}
}"
`;
exports[`sfc props transform > nested scope 1`] = `
"export default {
props: ['foo', 'bar'],
setup(__props) {
function test(foo) {
console.log(foo)
console.log(__props.bar)
}
return () => {}
}
}"
`;
exports[`sfc props transform > non-identifier prop names 1`] = `
"import { toDisplayString as _toDisplayString } from \\"vue\\"
export default {
props: { 'foo.bar': Function },
setup(__props) {
let x = __props[\\"foo.bar\\"]
return (_ctx, _cache) => {
return _toDisplayString(__props[\\"foo.bar\\"])
}
}
}"
`;
exports[`sfc props transform > rest spread 1`] = `
"import { createPropsRestProxy as _createPropsRestProxy } from 'vue'
export default {
props: ['foo', 'bar', 'baz'],
setup(__props) {
const rest = _createPropsRestProxy(__props, [\\"foo\\",\\"bar\\"]);
return () => {}
}
}"
`;

View File

@ -6,7 +6,6 @@ describe('sfc props transform', () => {
function compile(src: string, options?: Partial<SFCScriptCompileOptions>) {
return compileSFCScript(src, {
inlineTemplate: true,
reactivityTransform: true,
...options
})
}
@ -211,23 +210,6 @@ describe('sfc props transform', () => {
})
})
test('$$() escape', () => {
const { content } = compile(`
<script setup>
const { foo, bar: baz } = defineProps(['foo'])
console.log($$(foo))
console.log($$(baz))
$$({ foo, baz })
</script>
`)
expect(content).toMatch(`const __props_foo = _toRef(__props, 'foo')`)
expect(content).toMatch(`const __props_bar = _toRef(__props, 'bar')`)
expect(content).toMatch(`console.log((__props_foo))`)
expect(content).toMatch(`console.log((__props_bar))`)
expect(content).toMatch(`({ foo: __props_foo, baz: __props_bar })`)
assertCode(content)
})
// #6960
test('computed static key', () => {
const { content, bindings } = compile(`
@ -292,7 +274,7 @@ describe('sfc props transform', () => {
).toThrow(`cannot reference locally declared variables`)
})
test('should error if assignment to constant variable', () => {
test('should error if assignment to destructured prop binding', () => {
expect(() =>
compile(
`<script setup>
@ -300,7 +282,49 @@ describe('sfc props transform', () => {
foo = 'bar'
</script>`
)
).toThrow(`Assignment to constant variable.`)
).toThrow(`Cannot assign to destructured props`)
expect(() =>
compile(
`<script setup>
let { foo } = defineProps(['foo'])
foo = 'bar'
</script>`
)
).toThrow(`Cannot assign to destructured props`)
})
test('should error when watching destructured prop', () => {
expect(() =>
compile(
`<script setup>
import { watch } from 'vue'
const { foo } = defineProps(['foo'])
watch(foo, () => {})
</script>`
)
).toThrow(`"foo" is a destructured prop and cannot be directly watched.`)
expect(() =>
compile(
`<script setup>
import { watch as w } from 'vue'
const { foo } = defineProps(['foo'])
w(foo, () => {})
</script>`
)
).toThrow(`"foo" is a destructured prop and cannot be directly watched.`)
})
// not comprehensive, but should help for most common cases
test('should error if default value type does not match declared type', () => {
expect(() =>
compile(
`<script setup lang="ts">
const { foo = 'hello' } = defineProps<{ foo?: number }>()
</script>`
)
).toThrow(`Default value of prop "foo" does not match declared type.`)
})
})
})

View File

@ -11,7 +11,8 @@ import {
isFunctionType,
walkIdentifiers,
getImportedName,
unwrapTSNode
unwrapTSNode,
isCallOf
} from '@vue/compiler-dom'
import { DEFAULT_FILENAME, SFCDescriptor, SFCScriptBlock } from './parse'
import {
@ -59,6 +60,7 @@ import { warnOnce } from './warn'
import { rewriteDefaultAST } from './rewriteDefault'
import { createCache } from './cache'
import { shouldTransform, transformAST } from '@vue/reactivity-transform'
import { transformDestructuredProps } from './compileScriptPropsDestructure'
// Special compiler macros
const DEFINE_PROPS = 'defineProps'
@ -132,6 +134,14 @@ export interface ImportBinding {
isUsedInTemplate: boolean
}
export type PropsDestructureBindings = Record<
string, // public prop key
{
local: string // local identifier, may be different
default?: Expression
}
>
type FromNormalScript<T> = T & { __fromNormalScript?: boolean | null }
type PropsDeclType = FromNormalScript<TSTypeLiteral | TSInterfaceBody>
type EmitsDeclType = FromNormalScript<
@ -151,7 +161,6 @@ export function compileScript(
// feature flags
// TODO remove support for deprecated options when out of experimental
const enableReactivityTransform = !!options.reactivityTransform
const enablePropsTransform = !!options.reactivityTransform
const isProd = !!options.isProd
const genSourceMap = options.sourceMap !== false
const hoistStatic = options.hoistStatic !== false && !script
@ -310,14 +319,8 @@ export function compileScript(
// record declared types for runtime props type generation
const declaredTypes: Record<string, string[]> = {}
// props destructure data
const propsDestructuredBindings: Record<
string, // public prop key
{
local: string // local identifier, may be different
default?: Expression
isConst: boolean
}
> = Object.create(null)
const propsDestructuredBindings: PropsDestructureBindings =
Object.create(null)
// magic-string state
const s = new MagicString(source)
@ -410,11 +413,7 @@ export function compileScript(
}
}
function processDefineProps(
node: Node,
declId?: LVal,
declKind?: VariableDeclaration['kind']
): boolean {
function processDefineProps(node: Node, declId?: LVal): boolean {
if (!isCallOf(node, DEFINE_PROPS)) {
return false
}
@ -452,10 +451,9 @@ export function compileScript(
}
if (declId) {
const isConst = declKind === 'const'
if (enablePropsTransform && declId.type === 'ObjectPattern') {
// handle props destructure
if (declId.type === 'ObjectPattern') {
propsDestructureDecl = declId
// props destructure - handle compilation sugar
for (const prop of declId.properties) {
if (prop.type === 'ObjectProperty') {
const propKey = resolveObjectKey(prop.key, prop.computed)
@ -479,14 +477,12 @@ export function compileScript(
// store default value
propsDestructuredBindings[propKey] = {
local: left.name,
default: right,
isConst
default: right
}
} else if (prop.value.type === 'Identifier') {
// simple destructure
propsDestructuredBindings[propKey] = {
local: prop.value.name,
isConst
local: prop.value.name
}
} else {
error(
@ -515,7 +511,12 @@ export function compileScript(
if (!isCallOf(node, WITH_DEFAULTS)) {
return false
}
if (processDefineProps(node.arguments[0], declId, declKind)) {
warnOnce(
`withDefaults() has been deprecated. ` +
`Props destructure is now reactive by default - ` +
`use destructure with default values instead.`
)
if (processDefineProps(node.arguments[0], declId)) {
if (propsRuntimeDecl) {
error(
`${WITH_DEFAULTS} can only be used with type-based ` +
@ -943,7 +944,23 @@ export function compileScript(
defaultVal.start!,
defaultVal.end!
)
const unwrapped = unwrapTSNode(defaultVal)
if (
inferredType &&
inferredType.length &&
!inferredType.includes(UNKNOWN_TYPE)
) {
const valueType = inferValueType(unwrapped)
if (valueType && !inferredType.includes(valueType)) {
error(
`Default value of prop "${key}" does not match declared type.`,
unwrapped
)
}
}
// If the default value is a function or is an identifier referencing
// external value, skip factory wrap. This is needed when using
// destructure w/ runtime declaration since we cannot safely infer
@ -951,10 +968,12 @@ export function compileScript(
const needSkipFactory =
!inferredType &&
(isFunctionType(unwrapped) || unwrapped.type === 'Identifier')
const needFactoryWrap =
!needSkipFactory &&
!isLiteralNode(unwrapped) &&
!inferredType?.includes('Function')
return {
valueString: needFactoryWrap ? `() => (${value})` : value,
needSkipFactory
@ -1220,6 +1239,7 @@ export function compileScript(
}
// apply reactivity transform
// TODO remove in 3.4
if (enableReactivityTransform && shouldTransform(script.content)) {
const { rootRefs, importedHelpers } = transformAST(
scriptAst,
@ -1300,7 +1320,7 @@ export function compileScript(
// defineProps / defineEmits
const isDefineProps =
processDefineProps(init, decl.id, node.kind) ||
processDefineProps(init, decl.id) ||
processWithDefaults(init, decl.id, node.kind)
const isDefineEmits = processDefineEmits(init, decl.id)
if (isDefineProps || isDefineEmits) {
@ -1416,19 +1436,30 @@ export function compileScript(
}
}
// 3. Apply reactivity transform
// 3.1 props destructure transform
if (propsDestructureDecl) {
transformDestructuredProps(
scriptSetupAst,
s,
startOffset,
propsDestructuredBindings,
error,
vueImportAliases.watch
)
}
// 3.2 Apply reactivity transform
// TODO remove in 3.4
if (
(enableReactivityTransform &&
// normal <script> had ref bindings that maybe used in <script setup>
(refBindings || shouldTransform(scriptSetup.content))) ||
propsDestructureDecl
enableReactivityTransform &&
// normal <script> had ref bindings that maybe used in <script setup>
(refBindings || shouldTransform(scriptSetup.content))
) {
const { rootRefs, importedHelpers } = transformAST(
scriptSetupAst,
s,
startOffset,
refBindings,
propsDestructuredBindings
refBindings
)
refBindings = refBindings ? [...refBindings, ...rootRefs] : rootRefs
for (const h of importedHelpers) {
@ -1444,7 +1475,7 @@ export function compileScript(
extractRuntimeEmits(emitsTypeDecl, typeDeclaredEmits)
}
// 5. check useOptions args to make sure it doesn't reference setup scope
// 5. check macro args to make sure it doesn't reference setup scope
// variables
checkInvalidScopeReference(propsRuntimeDecl, DEFINE_PROPS)
checkInvalidScopeReference(propsRuntimeDefaults, DEFINE_PROPS)
@ -2219,6 +2250,27 @@ function inferEnumType(node: TSEnumDeclaration): string[] {
return types.size ? [...types] : ['Number']
}
// non-comprehensive, best-effort type infernece for a runtime value
// this is used to catch default value / type declaration mismatches
// when using props destructure.
function inferValueType(node: Node): string | undefined {
switch (node.type) {
case 'StringLiteral':
return 'String'
case 'NumericLiteral':
return 'Number'
case 'BooleanLiteral':
return 'Boolean'
case 'ObjectExpression':
return 'Object'
case 'ArrayExpression':
return 'Array'
case 'FunctionExpression':
case 'ArrowFunctionExpression':
return 'Function'
}
}
function extractRuntimeEmits(
node: TSFunctionType | TSTypeLiteral | TSInterfaceBody,
emits: Set<string>
@ -2275,21 +2327,6 @@ function genRuntimeEmits(emits: Set<string>) {
: ``
}
function isCallOf(
node: Node | null | undefined,
test: string | ((id: string) => boolean) | null | undefined
): node is CallExpression {
return !!(
node &&
test &&
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
(typeof test === 'string'
? node.callee.name === test
: test(node.callee.name))
)
}
function canNeverBeRef(node: Node, userReactiveImport?: string): boolean {
if (isCallOf(node, userReactiveImport)) {
return true

View File

@ -0,0 +1,236 @@
import {
Node,
Identifier,
BlockStatement,
Program,
VariableDeclaration
} from '@babel/types'
import MagicString from 'magic-string'
import { walk } from 'estree-walker'
import {
extractIdentifiers,
isFunctionType,
isInDestructureAssignment,
isReferencedIdentifier,
isStaticProperty,
walkFunctionParams,
isCallOf,
unwrapTSNode
} from '@vue/compiler-core'
import { hasOwn, genPropsAccessExp } from '@vue/shared'
import { PropsDestructureBindings } from './compileScript'
/**
* true -> prop binding
* false -> local binding
*/
type Scope = Record<string, boolean>
export function transformDestructuredProps(
ast: Program,
s: MagicString,
offset = 0,
knownProps: PropsDestructureBindings,
error: (msg: string, node: Node, end?: number) => never,
watchMethodName = 'watch'
) {
const rootScope: Scope = {}
const scopeStack: Scope[] = [rootScope]
let currentScope: Scope = rootScope
const excludedIds = new WeakSet<Identifier>()
const parentStack: Node[] = []
const propsLocalToPublicMap: Record<string, string> = Object.create(null)
for (const key in knownProps) {
const { local } = knownProps[key]
rootScope[local] = true
propsLocalToPublicMap[local] = key
}
function registerLocalBinding(id: Identifier) {
excludedIds.add(id)
if (currentScope) {
currentScope[id.name] = false
} else {
error(
'registerBinding called without active scope, something is wrong.',
id
)
}
}
function walkScope(node: Program | BlockStatement, isRoot = false) {
for (const stmt of node.body) {
if (stmt.type === 'VariableDeclaration') {
walkVariableDeclaration(stmt, isRoot)
} else if (
stmt.type === 'FunctionDeclaration' ||
stmt.type === 'ClassDeclaration'
) {
if (stmt.declare || !stmt.id) continue
registerLocalBinding(stmt.id)
} else if (
(stmt.type === 'ForOfStatement' || stmt.type === 'ForInStatement') &&
stmt.left.type === 'VariableDeclaration'
) {
walkVariableDeclaration(stmt.left)
} else if (
stmt.type === 'ExportNamedDeclaration' &&
stmt.declaration &&
stmt.declaration.type === 'VariableDeclaration'
) {
walkVariableDeclaration(stmt.declaration, isRoot)
} else if (
stmt.type === 'LabeledStatement' &&
stmt.body.type === 'VariableDeclaration'
) {
walkVariableDeclaration(stmt.body, isRoot)
}
}
}
function walkVariableDeclaration(stmt: VariableDeclaration, isRoot = false) {
if (stmt.declare) {
return
}
for (const decl of stmt.declarations) {
const isDefineProps =
isRoot && decl.init && isCallOf(unwrapTSNode(decl.init), 'defineProps')
for (const id of extractIdentifiers(decl.id)) {
if (isDefineProps) {
// for defineProps destructure, only exclude them since they
// are already passed in as knownProps
excludedIds.add(id)
} else {
registerLocalBinding(id)
}
}
}
}
function rewriteId(
scope: Scope,
id: Identifier,
parent: Node,
parentStack: Node[]
): boolean {
if (hasOwn(scope, id.name)) {
const binding = scope[id.name]
if (binding) {
if (
(parent.type === 'AssignmentExpression' && id === parent.left) ||
parent.type === 'UpdateExpression'
) {
error(`Cannot assign to destructured props as they are readonly.`, id)
}
if (isStaticProperty(parent) && parent.shorthand) {
// let binding used in a property shorthand
// skip for destructure patterns
if (
!(parent as any).inPattern ||
isInDestructureAssignment(parent, parentStack)
) {
// { prop } -> { prop: __props.prop }
s.appendLeft(
id.end! + offset,
`: ${genPropsAccessExp(propsLocalToPublicMap[id.name])}`
)
}
} else {
// x --> __props.x
s.overwrite(
id.start! + offset,
id.end! + offset,
genPropsAccessExp(propsLocalToPublicMap[id.name])
)
}
}
return true
}
return false
}
// check root scope first
walkScope(ast, true)
;(walk as any)(ast, {
enter(node: Node, parent?: Node) {
parent && parentStack.push(parent)
// skip type nodes
if (
parent &&
parent.type.startsWith('TS') &&
parent.type !== 'TSAsExpression' &&
parent.type !== 'TSNonNullExpression' &&
parent.type !== 'TSTypeAssertion'
) {
return this.skip()
}
if (isCallOf(node, watchMethodName)) {
const arg = unwrapTSNode(node.arguments[0])
if (arg.type === 'Identifier') {
error(
`"${arg.name}" is a destructured prop and cannot be directly watched. ` +
`Use a getter () => ${arg.name} instead.`,
arg
)
}
}
// function scopes
if (isFunctionType(node)) {
scopeStack.push((currentScope = {}))
walkFunctionParams(node, registerLocalBinding)
if (node.body.type === 'BlockStatement') {
walkScope(node.body)
}
return
}
// catch param
if (node.type === 'CatchClause') {
scopeStack.push((currentScope = {}))
if (node.param && node.param.type === 'Identifier') {
registerLocalBinding(node.param)
}
walkScope(node.body)
return
}
// non-function block scopes
if (node.type === 'BlockStatement' && !isFunctionType(parent!)) {
scopeStack.push((currentScope = {}))
walkScope(node)
return
}
if (node.type === 'Identifier') {
if (
isReferencedIdentifier(node, parent!, parentStack) &&
!excludedIds.has(node)
) {
// walk up the scope chain to check if id should be appended .value
let i = scopeStack.length
while (i--) {
if (rewriteId(scopeStack[i], node, parent!, parentStack)) {
return
}
}
}
}
},
leave(node: Node, parent?: Node) {
parent && parentStack.pop()
if (
(node.type === 'BlockStatement' && !isFunctionType(parent!)) ||
isFunctionType(node)
) {
scopeStack.pop()
currentScope = scopeStack[scopeStack.length - 1] || null
}
}
})
}

View File

@ -216,6 +216,8 @@ type PropsWithDefaults<Base, Defaults> = Base & {
*
* This is only usable inside `<script setup>`, is compiled away in the output
* and should **not** be actually called at runtime.
*
* @deprecated use reactive props destructure instead.
*/
export function withDefaults<Props, Defaults extends InferDefaults<Props>>(
props: Props,