]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
refactor(compiler-sfc): extract more defineProps logic
authorEvan You <yyx990803@gmail.com>
Tue, 11 Apr 2023 05:45:45 +0000 (13:45 +0800)
committerEvan You <yyx990803@gmail.com>
Tue, 11 Apr 2023 08:05:00 +0000 (16:05 +0800)
packages/compiler-core/src/babelUtils.ts
packages/compiler-sfc/src/compileScript.ts
packages/compiler-sfc/src/script/context.ts
packages/compiler-sfc/src/script/defineModel.ts [new file with mode: 0644]
packages/compiler-sfc/src/script/defineProps.ts
packages/compiler-sfc/src/script/definePropsDestructure.ts
packages/compiler-sfc/src/script/resolveType.ts
packages/compiler-sfc/src/script/utils.ts

index b58d9a0648faf9c25283a18fa83da9450aad8e13..7d96ec519285a2ea2962258fab47496948c94940 100644 (file)
@@ -9,8 +9,7 @@ import type {
   Program,
   ImportDefaultSpecifier,
   ImportNamespaceSpecifier,
-  ImportSpecifier,
-  CallExpression
+  ImportSpecifier
 } from '@babel/types'
 import { walk } from 'estree-walker'
 
@@ -443,25 +442,3 @@ export const TS_NODE_TYPES = [
   'TSInstantiationExpression', // foo<string>
   'TSSatisfiesExpression' // foo satisfies T
 ]
-export function unwrapTSNode(node: Node): Node {
-  if (TS_NODE_TYPES.includes(node.type)) {
-    return unwrapTSNode((node as any).expression)
-  } else {
-    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))
-  )
-}
index ae2843613db9aa0b7a0cc48ca71ff5fdb67e89c1..05bece0ed07c8ec5e7fdb505388533c889880b18 100644 (file)
@@ -10,9 +10,7 @@ import {
   SimpleExpressionNode,
   isFunctionType,
   walkIdentifiers,
-  getImportedName,
-  unwrapTSNode,
-  isCallOf
+  getImportedName
 } from '@vue/compiler-dom'
 import { DEFAULT_FILENAME, SFCDescriptor, SFCScriptBlock } from './parse'
 import { parse as _parse, parseExpression, ParserPlugin } from '@babel/parser'
@@ -36,7 +34,6 @@ import {
   TSInterfaceBody,
   TSTypeElement,
   AwaitExpression,
-  ObjectMethod,
   LVal,
   Expression,
   TSEnumDeclaration
@@ -54,13 +51,22 @@ import { rewriteDefaultAST } from './rewriteDefault'
 import { createCache } from './cache'
 import { shouldTransform, transformAST } from '@vue/reactivity-transform'
 import { transformDestructuredProps } from './script/definePropsDestructure'
-import { resolveObjectKey, FromNormalScript } from './script/utils'
 import { ScriptCompileContext } from './script/context'
 import {
   processDefineProps,
   DEFINE_PROPS,
-  WITH_DEFAULTS
+  WITH_DEFAULTS,
+  extractRuntimeProps
 } from './script/defineProps'
+import {
+  resolveObjectKey,
+  FromNormalScript,
+  UNKNOWN_TYPE,
+  isLiteralNode,
+  unwrapTSNode,
+  isCallOf
+} from './script/utils'
+import { genRuntimeProps } from './script/defineProps'
 
 // Special compiler macros
 const DEFINE_EMITS = 'defineEmits'
@@ -151,11 +157,6 @@ export type PropsDestructureBindings = Record<
 type EmitsDeclType = FromNormalScript<
   TSFunctionType | TSTypeLiteral | TSInterfaceBody
 >
-interface ModelDecl {
-  type: TSType | undefined
-  options: string | undefined
-  identifier: string | undefined
-}
 
 /**
  * Compile `<script setup>`
@@ -279,7 +280,6 @@ export function compileScript(
 
   // metadata that needs to be returned
   const bindingMetadata: BindingMetadata = {}
-  const helperImports: Set<string> = new Set()
   const userImports: Record<string, ImportBinding> = Object.create(null)
   const scriptBindings: Record<string, BindingTypes> = Object.create(null)
   const setupBindings: Record<string, BindingTypes> = Object.create(null)
@@ -290,14 +290,13 @@ export function compileScript(
   let emitsTypeDecl: EmitsDeclType | undefined
   let emitIdentifier: string | undefined
   let optionsRuntimeDecl: Node | undefined
-  let modelDecls: Record<string, ModelDecl> = {}
   let hasAwait = false
   let hasInlinedSsrRenderFn = false
   // props/emits declared via types
-  const typeDeclaredProps: Record<string, PropTypeData> = {}
+  // const typeDeclaredProps: Record<string, PropTypeData> = {}
   const typeDeclaredEmits: Set<string> = new Set()
   // record declared types for runtime props type generation
-  const declaredTypes: Record<string, string[]> = {}
+  // const declaredTypes: Record<string, string[]> = {}
 
   // magic-string state
   const s = new MagicString(source)
@@ -306,11 +305,6 @@ export function compileScript(
   const scriptStartOffset = script && script.loc.start.offset
   const scriptEndOffset = script && script.loc.end.offset
 
-  function helper(key: string): string {
-    helperImports.add(key)
-    return `_${key}`
-  }
-
   function error(
     msg: string,
     node: Node,
@@ -435,7 +429,7 @@ export function compileScript(
       s.overwrite(
         startOffset + node.start!,
         startOffset + node.end!,
-        `${helper('useSlots')}()`
+        `${ctx.helper('useSlots')}()`
       )
     }
 
@@ -461,7 +455,7 @@ export function compileScript(
       options = arg0
     }
 
-    if (modelDecls[modelName]) {
+    if (ctx.modelDecls[modelName]) {
       error(`duplicate model name ${JSON.stringify(modelName)}`, node)
     }
 
@@ -469,7 +463,7 @@ export function compileScript(
       ? s.slice(startOffset + options.start!, startOffset + options.end!)
       : undefined
 
-    modelDecls[modelName] = {
+    ctx.modelDecls[modelName] = {
       type,
       options: optionsString,
       identifier:
@@ -507,7 +501,7 @@ export function compileScript(
     s.overwrite(
       startOffset + node.start!,
       startOffset + node.end!,
-      `${helper('useModel')}(__props, ${JSON.stringify(modelName)}${
+      `${ctx.helper('useModel')}(__props, ${JSON.stringify(modelName)}${
         runtimeOptions ? `, ${runtimeOptions}` : ``
       })`
     )
@@ -753,7 +747,7 @@ export function compileScript(
     s.overwrite(
       node.start! + startOffset,
       argumentStart + startOffset,
-      `${needSemi ? `;` : ``}(\n  ([__temp,__restore] = ${helper(
+      `${needSemi ? `;` : ``}(\n  ([__temp,__restore] = ${ctx.helper(
         `withAsyncContext`
       )}(${containsNestedAwait ? `async ` : ``}() => `
     )
@@ -765,242 +759,6 @@ export function compileScript(
     )
   }
 
-  /**
-   * check defaults. If the default object is an object literal with only
-   * static properties, we can directly generate more optimized default
-   * declarations. Otherwise we will have to fallback to runtime merging.
-   */
-  function hasStaticWithDefaults() {
-    return (
-      ctx.propsRuntimeDefaults &&
-      ctx.propsRuntimeDefaults.type === 'ObjectExpression' &&
-      ctx.propsRuntimeDefaults.properties.every(
-        node =>
-          node.type !== 'SpreadElement' &&
-          (!node.computed || node.key.type.endsWith('Literal'))
-      )
-    )
-  }
-
-  function concatStrings(strs: Array<string | null | undefined | false>) {
-    return strs.filter((s): s is string => !!s).join(', ')
-  }
-
-  function genRuntimeProps() {
-    function genPropsFromTS() {
-      const keys = Object.keys(typeDeclaredProps)
-      if (!keys.length) return
-
-      const hasStaticDefaults = hasStaticWithDefaults()
-      const scriptSetupSource = scriptSetup!.content
-      let propsDecls = `{
-    ${keys
-      .map(key => {
-        let defaultString: string | undefined
-        const destructured = genDestructuredDefaultValue(
-          key,
-          typeDeclaredProps[key].type
-        )
-        if (destructured) {
-          defaultString = `default: ${destructured.valueString}${
-            destructured.needSkipFactory ? `, skipFactory: true` : ``
-          }`
-        } else if (hasStaticDefaults) {
-          const prop = (
-            ctx.propsRuntimeDefaults as ObjectExpression
-          ).properties.find(node => {
-            if (node.type === 'SpreadElement') return false
-            return resolveObjectKey(node.key, node.computed) === key
-          }) as ObjectProperty | ObjectMethod
-          if (prop) {
-            if (prop.type === 'ObjectProperty') {
-              // prop has corresponding static default value
-              defaultString = `default: ${scriptSetupSource.slice(
-                prop.value.start!,
-                prop.value.end!
-              )}`
-            } else {
-              defaultString = `${prop.async ? 'async ' : ''}${
-                prop.kind !== 'method' ? `${prop.kind} ` : ''
-              }default() ${scriptSetupSource.slice(
-                prop.body.start!,
-                prop.body.end!
-              )}`
-            }
-          }
-        }
-
-        const { type, required, skipCheck } = typeDeclaredProps[key]
-        if (!isProd) {
-          return `${key}: { ${concatStrings([
-            `type: ${toRuntimeTypeString(type)}`,
-            `required: ${required}`,
-            skipCheck && 'skipCheck: true',
-            defaultString
-          ])} }`
-        } else if (
-          type.some(
-            el =>
-              el === 'Boolean' ||
-              ((!hasStaticDefaults || defaultString) && el === 'Function')
-          )
-        ) {
-          // #4783 for boolean, should keep the type
-          // #7111 for function, if default value exists or it's not static, should keep it
-          // in production
-          return `${key}: { ${concatStrings([
-            `type: ${toRuntimeTypeString(type)}`,
-            defaultString
-          ])} }`
-        } else {
-          // production: checks are useless
-          return `${key}: ${defaultString ? `{ ${defaultString} }` : `{}`}`
-        }
-      })
-      .join(',\n    ')}\n  }`
-
-      if (ctx.propsRuntimeDefaults && !hasStaticDefaults) {
-        propsDecls = `${helper('mergeDefaults')}(${propsDecls}, ${source.slice(
-          ctx.propsRuntimeDefaults.start! + startOffset,
-          ctx.propsRuntimeDefaults.end! + startOffset
-        )})`
-      }
-
-      return propsDecls
-    }
-
-    function genModels() {
-      if (!ctx.hasDefineModelCall) return
-
-      let modelPropsDecl = ''
-      for (const [name, { type, options }] of Object.entries(modelDecls)) {
-        let skipCheck = false
-
-        let runtimeTypes = type && inferRuntimeType(type, declaredTypes)
-        if (runtimeTypes) {
-          const hasUnknownType = runtimeTypes.includes(UNKNOWN_TYPE)
-
-          runtimeTypes = runtimeTypes.filter(el => {
-            if (el === UNKNOWN_TYPE) return false
-            return isProd
-              ? el === 'Boolean' || (el === 'Function' && options)
-              : true
-          })
-          skipCheck = !isProd && hasUnknownType && runtimeTypes.length > 0
-        }
-
-        let runtimeType =
-          (runtimeTypes &&
-            runtimeTypes.length > 0 &&
-            toRuntimeTypeString(runtimeTypes)) ||
-          undefined
-
-        const codegenOptions = concatStrings([
-          runtimeType && `type: ${runtimeType}`,
-          skipCheck && 'skipCheck: true'
-        ])
-
-        let decl: string
-        if (runtimeType && options) {
-          decl = ctx.isTS
-            ? `{ ${codegenOptions}, ...${options} }`
-            : `Object.assign({ ${codegenOptions} }, ${options})`
-        } else {
-          decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
-        }
-        modelPropsDecl += `\n    ${JSON.stringify(name)}: ${decl},`
-      }
-      return `{${modelPropsDecl}\n  }`
-    }
-
-    let propsDecls: undefined | string
-    if (ctx.propsRuntimeDecl) {
-      propsDecls = scriptSetup!.content
-        .slice(ctx.propsRuntimeDecl.start!, ctx.propsRuntimeDecl.end!)
-        .trim()
-      if (ctx.propsDestructureDecl) {
-        const defaults: string[] = []
-        for (const key in ctx.propsDestructuredBindings) {
-          const d = genDestructuredDefaultValue(key)
-          if (d)
-            defaults.push(
-              `${key}: ${d.valueString}${
-                d.needSkipFactory ? `, __skip_${key}: true` : ``
-              }`
-            )
-        }
-        if (defaults.length) {
-          propsDecls = `${helper(
-            `mergeDefaults`
-          )}(${propsDecls}, {\n  ${defaults.join(',\n  ')}\n})`
-        }
-      }
-    } else if (ctx.propsTypeDecl) {
-      propsDecls = genPropsFromTS()
-    }
-
-    const modelsDecls = genModels()
-
-    if (propsDecls && modelsDecls) {
-      return `${helper('mergeModels')}(${propsDecls}, ${modelsDecls})`
-    } else {
-      return modelsDecls || propsDecls
-    }
-  }
-
-  function genDestructuredDefaultValue(
-    key: string,
-    inferredType?: string[]
-  ):
-    | {
-        valueString: string
-        needSkipFactory: boolean
-      }
-    | undefined {
-    const destructured = ctx.propsDestructuredBindings[key]
-    const defaultVal = destructured && destructured.default
-    if (defaultVal) {
-      const value = scriptSetup!.content.slice(
-        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
-      // whether tje expected runtime prop type is `Function`.
-      const needSkipFactory =
-        !inferredType &&
-        (isFunctionType(unwrapped) || unwrapped.type === 'Identifier')
-
-      const needFactoryWrap =
-        !needSkipFactory &&
-        !isLiteralNode(unwrapped) &&
-        !inferredType?.includes('Function')
-
-      return {
-        valueString: needFactoryWrap ? `() => (${value})` : value,
-        needSkipFactory
-      }
-    }
-  }
-
   function genRuntimeEmits() {
     function genEmitsFromTS() {
       return typeDeclaredEmits.size
@@ -1019,41 +777,16 @@ export function compileScript(
       emitsDecl = genEmitsFromTS()
     }
     if (ctx.hasDefineModelCall) {
-      let modelEmitsDecl = `[${Object.keys(modelDecls)
+      let modelEmitsDecl = `[${Object.keys(ctx.modelDecls)
         .map(n => JSON.stringify(`update:${n}`))
         .join(', ')}]`
       emitsDecl = emitsDecl
-        ? `${helper('mergeModels')}(${emitsDecl}, ${modelEmitsDecl})`
+        ? `${ctx.helper('mergeModels')}(${emitsDecl}, ${modelEmitsDecl})`
         : modelEmitsDecl
     }
     return emitsDecl
   }
 
-  // 0. parse both <script> and <script setup> blocks
-  // const scriptAst =
-  //   script &&
-  //   parse(
-  //     script.content,
-  //     {
-  //       plugins,
-  //       sourceType: 'module'
-  //     },
-  //     scriptStartOffset!
-  //   )
-
-  // const scriptSetupAst = parse(
-  //   scriptSetup.content,
-  //   {
-  //     plugins: [
-  //       ...plugins,
-  //       // allow top level await but only inside <script setup>
-  //       'topLevelAwait'
-  //     ],
-  //     sourceType: 'module'
-  //   },
-  //   startOffset
-  // )
-
   const scriptAst = ctx.scriptAst
   const scriptSetupAst = ctx.scriptSetupAst!
 
@@ -1267,7 +1000,7 @@ export function compileScript(
       )
       refBindings = rootRefs
       for (const h of importedHelpers) {
-        helperImports.add(h)
+        ctx.helperImports.add(h)
       }
     }
 
@@ -1458,7 +1191,7 @@ export function compileScript(
           node.exportKind === 'type') ||
         (node.type === 'VariableDeclaration' && node.declare)
       ) {
-        recordType(node, declaredTypes)
+        recordType(node, ctx.declaredTypes)
         if (node.type !== 'TSEnumDeclaration') {
           hoistNode(node)
         }
@@ -1493,14 +1226,12 @@ export function compileScript(
     )
     refBindings = refBindings ? [...refBindings, ...rootRefs] : rootRefs
     for (const h of importedHelpers) {
-      helperImports.add(h)
+      ctx.helperImports.add(h)
     }
   }
 
   // 4. extract runtime props/emits code from setup context type
-  if (ctx.propsTypeDecl) {
-    extractRuntimeProps(ctx.propsTypeDecl, typeDeclaredProps, declaredTypes)
-  }
+  extractRuntimeProps(ctx)
   if (emitsTypeDecl) {
     extractRuntimeEmits(emitsTypeDecl, typeDeclaredEmits, error)
   }
@@ -1541,10 +1272,10 @@ export function compileScript(
       bindingMetadata[key] = BindingTypes.PROPS
     }
   }
-  for (const key in typeDeclaredProps) {
+  for (const key in ctx.typeDeclaredProps) {
     bindingMetadata[key] = BindingTypes.PROPS
   }
-  for (const key in modelDecls) {
+  for (const key in ctx.modelDecls) {
     bindingMetadata[key] = BindingTypes.PROPS
   }
   // props aliases
@@ -1592,8 +1323,8 @@ export function compileScript(
     // no need to do this when targeting SSR
     !(options.inlineTemplate && options.templateOptions?.ssr)
   ) {
-    helperImports.add(CSS_VARS_HELPER)
-    helperImports.add('unref')
+    ctx.helperImports.add(CSS_VARS_HELPER)
+    ctx.helperImports.add('unref')
     s.prependLeft(
       startOffset,
       `\n${genCssVarsCode(cssVars, bindingMetadata, scopeId, isProd)}\n`
@@ -1617,7 +1348,7 @@ export function compileScript(
   if (ctx.propsDestructureRestId) {
     s.prependLeft(
       startOffset,
-      `\nconst ${ctx.propsDestructureRestId} = ${helper(
+      `\nconst ${ctx.propsDestructureRestId} = ${ctx.helper(
         `createPropsRestProxy`
       )}(__props, ${JSON.stringify(
         Object.keys(ctx.propsDestructuredBindings)
@@ -1734,7 +1465,7 @@ export function compileScript(
       // as this may get injected by the render function preamble OR the
       // css vars codegen
       if (ast && ast.helpers.has(UNREF)) {
-        helperImports.delete('unref')
+        ctx.helperImports.delete('unref')
       }
       returned = code
     } else {
@@ -1768,7 +1499,7 @@ export function compileScript(
     runtimeOptions += `\n  __ssrInlineRender: true,`
   }
 
-  const propsDecl = genRuntimeProps()
+  const propsDecl = genRuntimeProps(ctx)
   if (propsDecl) runtimeOptions += `\n  props: ${propsDecl},`
 
   const emitsDecl = genRuntimeEmits()
@@ -1797,7 +1528,7 @@ export function compileScript(
       (definedOptions ? `\n  ...${definedOptions},` : '')
     s.prependLeft(
       startOffset,
-      `\n${genDefaultAs} /*#__PURE__*/${helper(
+      `\n${genDefaultAs} /*#__PURE__*/${ctx.helper(
         `defineComponent`
       )}({${def}${runtimeOptions}\n  ${
         hasAwait ? `async ` : ``
@@ -1827,9 +1558,9 @@ export function compileScript(
   }
 
   // 12. finalize Vue helper imports
-  if (helperImports.size > 0) {
+  if (ctx.helperImports.size > 0) {
     s.prepend(
-      `import { ${[...helperImports]
+      `import { ${[...ctx.helperImports]
         .map(h => `${h} as _${h}`)
         .join(', ')} } from 'vue'\n`
     )
@@ -2028,13 +1759,6 @@ function walkPattern(
   }
 }
 
-interface PropTypeData {
-  key: string
-  type: string[]
-  required: boolean
-  skipCheck: boolean
-}
-
 function recordType(node: Node, declaredTypes: Record<string, string[]>) {
   if (node.type === 'TSInterfaceDeclaration') {
     declaredTypes[node.id.name] = [`Object`]
@@ -2050,45 +1774,6 @@ function recordType(node: Node, declaredTypes: Record<string, string[]>) {
   }
 }
 
-function extractRuntimeProps(
-  node: TSTypeLiteral | TSInterfaceBody,
-  props: Record<string, PropTypeData>,
-  declaredTypes: Record<string, string[]>
-) {
-  const members = node.type === 'TSTypeLiteral' ? node.members : node.body
-  for (const m of members) {
-    if (
-      (m.type === 'TSPropertySignature' || m.type === 'TSMethodSignature') &&
-      m.key.type === 'Identifier'
-    ) {
-      let type: string[] | undefined
-      let skipCheck = false
-      if (m.type === 'TSMethodSignature') {
-        type = ['Function']
-      } else if (m.typeAnnotation) {
-        type = inferRuntimeType(m.typeAnnotation.typeAnnotation, declaredTypes)
-        // skip check for result containing unknown types
-        if (type.includes(UNKNOWN_TYPE)) {
-          if (type.includes('Boolean') || type.includes('Function')) {
-            type = type.filter(t => t !== UNKNOWN_TYPE)
-            skipCheck = true
-          } else {
-            type = ['null']
-          }
-        }
-      }
-      props[m.key.name] = {
-        key: m.key.name,
-        required: !m.optional,
-        type: type || [`null`],
-        skipCheck
-      }
-    }
-  }
-}
-
-const UNKNOWN_TYPE = 'Unknown'
-
 function inferRuntimeType(
   node: TSType,
   declaredTypes: Record<string, string[]>
@@ -2239,10 +1924,6 @@ function flattenTypes(
   ]
 }
 
-function toRuntimeTypeString(types: string[]) {
-  return types.length > 1 ? `[${types.join(', ')}]` : types[0]
-}
-
 function inferEnumType(node: TSEnumDeclaration): string[] {
   const types = new Set<string>()
   for (const m of node.members) {
@@ -2260,27 +1941,6 @@ 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>,
@@ -2414,10 +2074,6 @@ function isStaticNode(node: Node): boolean {
   }
 }
 
-function isLiteralNode(node: Node) {
-  return node.type.endsWith('Literal')
-}
-
 /**
  * Analyze bindings in normal `<script>`
  * Note that `compileScriptSetup` already analyzes bindings as part of its
index 0afd50c2e2dcfc925e392dc28d0ce559c96dd42f..0dc94a537a30b25bace27edf67230b325cb3a726 100644 (file)
@@ -1,18 +1,14 @@
-import { Expression, Node, ObjectPattern, Program } from '@babel/types'
+import { Node, ObjectPattern, Program } from '@babel/types'
 import { SFCDescriptor } from '../parse'
 import { generateCodeFrame } from '@vue/shared'
-import { PropsDeclType } from './defineProps'
 import { parse as babelParse, ParserOptions, ParserPlugin } from '@babel/parser'
 import { SFCScriptCompileOptions } from '../compileScript'
-import MagicString from 'magic-string'
-
-export type PropsDestructureBindings = Record<
-  string, // public prop key
-  {
-    local: string // local identifier, may be different
-    default?: Expression
-  }
->
+import {
+  PropsDeclType,
+  PropTypeData,
+  PropsDestructureBindings
+} from './defineProps'
+import { ModelDecl } from './defineModel'
 
 export class ScriptCompileContext {
   isJS: boolean
@@ -21,14 +17,20 @@ export class ScriptCompileContext {
   scriptAst: Program | null
   scriptSetupAst: Program | null
 
-  s = new MagicString(this.descriptor.source)
-
+  // s = new MagicString(this.descriptor.source)
   startOffset = this.descriptor.scriptSetup?.loc.start.offset
   endOffset = this.descriptor.scriptSetup?.loc.end.offset
-
   scriptStartOffset = this.descriptor.script?.loc.start.offset
   scriptEndOffset = this.descriptor.script?.loc.end.offset
 
+  helperImports: Set<string> = new Set()
+  helper(key: string): string {
+    this.helperImports.add(key)
+    return `_${key}`
+  }
+
+  declaredTypes: Record<string, string[]> = Object.create(null)
+
   // macros presence check
   hasDefinePropsCall = false
   hasDefineEmitCall = false
@@ -47,6 +49,10 @@ export class ScriptCompileContext {
   propsDestructuredBindings: PropsDestructureBindings = Object.create(null)
   propsDestructureRestId: string | undefined
   propsRuntimeDefaults: Node | undefined
+  typeDeclaredProps: Record<string, PropTypeData> = {}
+
+  // defineModel
+  modelDecls: Record<string, ModelDecl> = {}
 
   constructor(
     public descriptor: SFCDescriptor,
diff --git a/packages/compiler-sfc/src/script/defineModel.ts b/packages/compiler-sfc/src/script/defineModel.ts
new file mode 100644 (file)
index 0000000..befe0d9
--- /dev/null
@@ -0,0 +1,55 @@
+import { TSType } from '@babel/types'
+import { ScriptCompileContext } from './context'
+import { inferRuntimeType } from './resolveType'
+import { UNKNOWN_TYPE, concatStrings, toRuntimeTypeString } from './utils'
+
+export interface ModelDecl {
+  type: TSType | undefined
+  options: string | undefined
+  identifier: string | undefined
+}
+
+export function genModels(ctx: ScriptCompileContext) {
+  if (!ctx.hasDefineModelCall) return
+
+  const isProd = !!ctx.options.isProd
+  let modelPropsDecl = ''
+  for (const [name, { type, options }] of Object.entries(ctx.modelDecls)) {
+    let skipCheck = false
+
+    let runtimeTypes = type && inferRuntimeType(type, ctx.declaredTypes)
+    if (runtimeTypes) {
+      const hasUnknownType = runtimeTypes.includes(UNKNOWN_TYPE)
+
+      runtimeTypes = runtimeTypes.filter(el => {
+        if (el === UNKNOWN_TYPE) return false
+        return isProd
+          ? el === 'Boolean' || (el === 'Function' && options)
+          : true
+      })
+      skipCheck = !isProd && hasUnknownType && runtimeTypes.length > 0
+    }
+
+    let runtimeType =
+      (runtimeTypes &&
+        runtimeTypes.length > 0 &&
+        toRuntimeTypeString(runtimeTypes)) ||
+      undefined
+
+    const codegenOptions = concatStrings([
+      runtimeType && `type: ${runtimeType}`,
+      skipCheck && 'skipCheck: true'
+    ])
+
+    let decl: string
+    if (runtimeType && options) {
+      decl = ctx.isTS
+        ? `{ ${codegenOptions}, ...${options} }`
+        : `Object.assign({ ${codegenOptions} }, ${options})`
+    } else {
+      decl = options || (runtimeType ? `{ ${codegenOptions} }` : '{}')
+    }
+    modelPropsDecl += `\n    ${JSON.stringify(name)}: ${decl},`
+  }
+  return `{${modelPropsDecl}\n  }`
+}
index 731f8201d10fbe8d7bbce35c3016845951f47bb2..9def99cf3c1df9a5f20a3275f5e8b9790a0a48c9 100644 (file)
@@ -3,20 +3,47 @@ import {
   LVal,
   Identifier,
   TSTypeLiteral,
-  TSInterfaceBody
+  TSInterfaceBody,
+  ObjectProperty,
+  ObjectMethod,
+  ObjectExpression,
+  Expression
 } from '@babel/types'
-import { isCallOf } from '@vue/compiler-dom'
+import { isFunctionType } from '@vue/compiler-dom'
 import { ScriptCompileContext } from './context'
-import { resolveObjectKey } from './utils'
-import { resolveQualifiedType } from './resolveType'
+import { inferRuntimeType, resolveQualifiedType } from './resolveType'
+import {
+  FromNormalScript,
+  resolveObjectKey,
+  UNKNOWN_TYPE,
+  concatStrings,
+  isLiteralNode,
+  isCallOf,
+  unwrapTSNode,
+  toRuntimeTypeString
+} from './utils'
+import { genModels } from './defineModel'
 
 export const DEFINE_PROPS = 'defineProps'
 export const WITH_DEFAULTS = 'withDefaults'
 
-export type PropsDeclType = (TSTypeLiteral | TSInterfaceBody) & {
-  __fromNormalScript?: boolean | null
+export type PropsDeclType = FromNormalScript<TSTypeLiteral | TSInterfaceBody>
+
+export interface PropTypeData {
+  key: string
+  type: string[]
+  required: boolean
+  skipCheck: boolean
 }
 
+export type PropsDestructureBindings = Record<
+  string, // public prop key
+  {
+    local: string // local identifier, may be different
+    default?: Expression
+  }
+>
+
 export function processDefineProps(
   ctx: ScriptCompileContext,
   node: Node,
@@ -146,3 +173,238 @@ function processWithDefaults(
   }
   return true
 }
+
+export function extractRuntimeProps(ctx: ScriptCompileContext) {
+  const node = ctx.propsTypeDecl
+  if (!node) return
+  const members = node.type === 'TSTypeLiteral' ? node.members : node.body
+  for (const m of members) {
+    if (
+      (m.type === 'TSPropertySignature' || m.type === 'TSMethodSignature') &&
+      m.key.type === 'Identifier'
+    ) {
+      let type: string[] | undefined
+      let skipCheck = false
+      if (m.type === 'TSMethodSignature') {
+        type = ['Function']
+      } else if (m.typeAnnotation) {
+        type = inferRuntimeType(
+          m.typeAnnotation.typeAnnotation,
+          ctx.declaredTypes
+        )
+        // skip check for result containing unknown types
+        if (type.includes(UNKNOWN_TYPE)) {
+          if (type.includes('Boolean') || type.includes('Function')) {
+            type = type.filter(t => t !== UNKNOWN_TYPE)
+            skipCheck = true
+          } else {
+            type = ['null']
+          }
+        }
+      }
+      ctx.typeDeclaredProps[m.key.name] = {
+        key: m.key.name,
+        required: !m.optional,
+        type: type || [`null`],
+        skipCheck
+      }
+    }
+  }
+}
+
+export function genRuntimeProps(ctx: ScriptCompileContext): string | undefined {
+  let propsDecls: undefined | string
+  if (ctx.propsRuntimeDecl) {
+    propsDecls = ctx.getString(ctx.propsRuntimeDecl).trim()
+    if (ctx.propsDestructureDecl) {
+      const defaults: string[] = []
+      for (const key in ctx.propsDestructuredBindings) {
+        const d = genDestructuredDefaultValue(ctx, key)
+        if (d)
+          defaults.push(
+            `${key}: ${d.valueString}${
+              d.needSkipFactory ? `, __skip_${key}: true` : ``
+            }`
+          )
+      }
+      if (defaults.length) {
+        propsDecls = `${ctx.helper(
+          `mergeDefaults`
+        )}(${propsDecls}, {\n  ${defaults.join(',\n  ')}\n})`
+      }
+    }
+  } else if (ctx.propsTypeDecl) {
+    propsDecls = genPropsFromTS(ctx)
+  }
+
+  const modelsDecls = genModels(ctx)
+
+  if (propsDecls && modelsDecls) {
+    return `${ctx.helper('mergeModels')}(${propsDecls}, ${modelsDecls})`
+  } else {
+    return modelsDecls || propsDecls
+  }
+}
+
+function genPropsFromTS(ctx: ScriptCompileContext) {
+  const keys = Object.keys(ctx.typeDeclaredProps)
+  if (!keys.length) return
+
+  const hasStaticDefaults = hasStaticWithDefaults(ctx)
+  let propsDecls = `{
+    ${keys
+      .map(key => {
+        let defaultString: string | undefined
+        const destructured = genDestructuredDefaultValue(
+          ctx,
+          key,
+          ctx.typeDeclaredProps[key].type
+        )
+        if (destructured) {
+          defaultString = `default: ${destructured.valueString}${
+            destructured.needSkipFactory ? `, skipFactory: true` : ``
+          }`
+        } else if (hasStaticDefaults) {
+          const prop = (
+            ctx.propsRuntimeDefaults as ObjectExpression
+          ).properties.find(node => {
+            if (node.type === 'SpreadElement') return false
+            return resolveObjectKey(node.key, node.computed) === key
+          }) as ObjectProperty | ObjectMethod
+          if (prop) {
+            if (prop.type === 'ObjectProperty') {
+              // prop has corresponding static default value
+              defaultString = `default: ${ctx.getString(prop.value)}`
+            } else {
+              defaultString = `${prop.async ? 'async ' : ''}${
+                prop.kind !== 'method' ? `${prop.kind} ` : ''
+              }default() ${ctx.getString(prop.body)}`
+            }
+          }
+        }
+
+        const { type, required, skipCheck } = ctx.typeDeclaredProps[key]
+        if (!ctx.options.isProd) {
+          return `${key}: { ${concatStrings([
+            `type: ${toRuntimeTypeString(type)}`,
+            `required: ${required}`,
+            skipCheck && 'skipCheck: true',
+            defaultString
+          ])} }`
+        } else if (
+          type.some(
+            el =>
+              el === 'Boolean' ||
+              ((!hasStaticDefaults || defaultString) && el === 'Function')
+          )
+        ) {
+          // #4783 for boolean, should keep the type
+          // #7111 for function, if default value exists or it's not static, should keep it
+          // in production
+          return `${key}: { ${concatStrings([
+            `type: ${toRuntimeTypeString(type)}`,
+            defaultString
+          ])} }`
+        } else {
+          // production: checks are useless
+          return `${key}: ${defaultString ? `{ ${defaultString} }` : `{}`}`
+        }
+      })
+      .join(',\n    ')}\n  }`
+
+  if (ctx.propsRuntimeDefaults && !hasStaticDefaults) {
+    propsDecls = `${ctx.helper('mergeDefaults')}(${propsDecls}, ${ctx.getString(
+      ctx.propsRuntimeDefaults
+    )})`
+  }
+
+  return propsDecls
+}
+
+/**
+ * check defaults. If the default object is an object literal with only
+ * static properties, we can directly generate more optimized default
+ * declarations. Otherwise we will have to fallback to runtime merging.
+ */
+function hasStaticWithDefaults(ctx: ScriptCompileContext) {
+  return (
+    ctx.propsRuntimeDefaults &&
+    ctx.propsRuntimeDefaults.type === 'ObjectExpression' &&
+    ctx.propsRuntimeDefaults.properties.every(
+      node =>
+        node.type !== 'SpreadElement' &&
+        (!node.computed || node.key.type.endsWith('Literal'))
+    )
+  )
+}
+
+function genDestructuredDefaultValue(
+  ctx: ScriptCompileContext,
+  key: string,
+  inferredType?: string[]
+):
+  | {
+      valueString: string
+      needSkipFactory: boolean
+    }
+  | undefined {
+  const destructured = ctx.propsDestructuredBindings[key]
+  const defaultVal = destructured && destructured.default
+  if (defaultVal) {
+    const value = ctx.getString(defaultVal)
+    const unwrapped = unwrapTSNode(defaultVal)
+
+    if (
+      inferredType &&
+      inferredType.length &&
+      !inferredType.includes(UNKNOWN_TYPE)
+    ) {
+      const valueType = inferValueType(unwrapped)
+      if (valueType && !inferredType.includes(valueType)) {
+        ctx.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
+    // whether tje expected runtime prop type is `Function`.
+    const needSkipFactory =
+      !inferredType &&
+      (isFunctionType(unwrapped) || unwrapped.type === 'Identifier')
+
+    const needFactoryWrap =
+      !needSkipFactory &&
+      !isLiteralNode(unwrapped) &&
+      !inferredType?.includes('Function')
+
+    return {
+      valueString: needFactoryWrap ? `() => (${value})` : value,
+      needSkipFactory
+    }
+  }
+}
+
+// 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'
+  }
+}
index cd1fe36a2f9083acda34bbdfc0bf289b5a173652..dd04e4bc555fd8acbab6a3c589192013ebcdefdb 100644 (file)
@@ -13,12 +13,11 @@ import {
   isInDestructureAssignment,
   isReferencedIdentifier,
   isStaticProperty,
-  walkFunctionParams,
-  isCallOf,
-  unwrapTSNode
+  walkFunctionParams
 } from '@vue/compiler-core'
 import { genPropsAccessExp } from '@vue/shared'
 import { PropsDestructureBindings } from '../compileScript'
+import { isCallOf, unwrapTSNode } from './utils'
 
 /**
  * true -> prop binding
index f4e0c6b2f1f091704e4c8e8a2a4a3fd52b8e6b45..cf02729976f3099dda44657433f0b918713ca0cb 100644 (file)
@@ -1,5 +1,11 @@
-import { Node, Statement, TSInterfaceBody, TSTypeElement } from '@babel/types'
-import { FromNormalScript } from './utils'
+import {
+  Node,
+  Statement,
+  TSInterfaceBody,
+  TSType,
+  TSTypeElement
+} from '@babel/types'
+import { FromNormalScript, UNKNOWN_TYPE } from './utils'
 import { ScriptCompileContext } from './context'
 
 /**
@@ -112,3 +118,153 @@ function filterExtendsType(extendsTypes: Node[], bodies: TSTypeElement[]) {
     })
   })
 }
+
+export function inferRuntimeType(
+  node: TSType,
+  declaredTypes: Record<string, string[]>
+): string[] {
+  switch (node.type) {
+    case 'TSStringKeyword':
+      return ['String']
+    case 'TSNumberKeyword':
+      return ['Number']
+    case 'TSBooleanKeyword':
+      return ['Boolean']
+    case 'TSObjectKeyword':
+      return ['Object']
+    case 'TSNullKeyword':
+      return ['null']
+    case 'TSTypeLiteral': {
+      // TODO (nice to have) generate runtime property validation
+      const types = new Set<string>()
+      for (const m of node.members) {
+        if (
+          m.type === 'TSCallSignatureDeclaration' ||
+          m.type === 'TSConstructSignatureDeclaration'
+        ) {
+          types.add('Function')
+        } else {
+          types.add('Object')
+        }
+      }
+      return types.size ? Array.from(types) : ['Object']
+    }
+    case 'TSFunctionType':
+      return ['Function']
+    case 'TSArrayType':
+    case 'TSTupleType':
+      // TODO (nice to have) generate runtime element type/length checks
+      return ['Array']
+
+    case 'TSLiteralType':
+      switch (node.literal.type) {
+        case 'StringLiteral':
+          return ['String']
+        case 'BooleanLiteral':
+          return ['Boolean']
+        case 'NumericLiteral':
+        case 'BigIntLiteral':
+          return ['Number']
+        default:
+          return [UNKNOWN_TYPE]
+      }
+
+    case 'TSTypeReference':
+      if (node.typeName.type === 'Identifier') {
+        if (declaredTypes[node.typeName.name]) {
+          return declaredTypes[node.typeName.name]
+        }
+        switch (node.typeName.name) {
+          case 'Array':
+          case 'Function':
+          case 'Object':
+          case 'Set':
+          case 'Map':
+          case 'WeakSet':
+          case 'WeakMap':
+          case 'Date':
+          case 'Promise':
+            return [node.typeName.name]
+
+          // TS built-in utility types
+          // https://www.typescriptlang.org/docs/handbook/utility-types.html
+          case 'Partial':
+          case 'Required':
+          case 'Readonly':
+          case 'Record':
+          case 'Pick':
+          case 'Omit':
+          case 'InstanceType':
+            return ['Object']
+
+          case 'Uppercase':
+          case 'Lowercase':
+          case 'Capitalize':
+          case 'Uncapitalize':
+            return ['String']
+
+          case 'Parameters':
+          case 'ConstructorParameters':
+            return ['Array']
+
+          case 'NonNullable':
+            if (node.typeParameters && node.typeParameters.params[0]) {
+              return inferRuntimeType(
+                node.typeParameters.params[0],
+                declaredTypes
+              ).filter(t => t !== 'null')
+            }
+            break
+          case 'Extract':
+            if (node.typeParameters && node.typeParameters.params[1]) {
+              return inferRuntimeType(
+                node.typeParameters.params[1],
+                declaredTypes
+              )
+            }
+            break
+          case 'Exclude':
+          case 'OmitThisParameter':
+            if (node.typeParameters && node.typeParameters.params[0]) {
+              return inferRuntimeType(
+                node.typeParameters.params[0],
+                declaredTypes
+              )
+            }
+            break
+        }
+      }
+      // cannot infer, fallback to UNKNOWN: ThisParameterType
+      return [UNKNOWN_TYPE]
+
+    case 'TSParenthesizedType':
+      return inferRuntimeType(node.typeAnnotation, declaredTypes)
+
+    case 'TSUnionType':
+      return flattenTypes(node.types, declaredTypes)
+    case 'TSIntersectionType': {
+      return flattenTypes(node.types, declaredTypes).filter(
+        t => t !== UNKNOWN_TYPE
+      )
+    }
+
+    case 'TSSymbolKeyword':
+      return ['Symbol']
+
+    default:
+      return [UNKNOWN_TYPE] // no runtime check
+  }
+}
+
+function flattenTypes(
+  types: TSType[],
+  declaredTypes: Record<string, string[]>
+): string[] {
+  return [
+    ...new Set(
+      ([] as string[]).concat(
+        ...types.map(t => inferRuntimeType(t, declaredTypes))
+      )
+    )
+  ]
+}
index 1a073434c95a2d16c27d8733b885f454eb0f1d64..c5926e4c35d0cd9b8e9712680000f168003ff7c5 100644 (file)
@@ -1,4 +1,7 @@
-import { Node } from '@babel/types'
+import { CallExpression, Node } from '@babel/types'
+import { TS_NODE_TYPES } from '@vue/compiler-dom'
+
+export const UNKNOWN_TYPE = 'Unknown'
 
 export type FromNormalScript<T> = T & { __fromNormalScript?: boolean | null }
 
@@ -12,3 +15,38 @@ export function resolveObjectKey(node: Node, computed: boolean) {
   }
   return undefined
 }
+
+export function concatStrings(strs: Array<string | null | undefined | false>) {
+  return strs.filter((s): s is string => !!s).join(', ')
+}
+
+export function isLiteralNode(node: Node) {
+  return node.type.endsWith('Literal')
+}
+
+export function unwrapTSNode(node: Node): Node {
+  if (TS_NODE_TYPES.includes(node.type)) {
+    return unwrapTSNode((node as any).expression)
+  } else {
+    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))
+  )
+}
+
+export function toRuntimeTypeString(types: string[]) {
+  return types.length > 1 ? `[${types.join(', ')}]` : types[0]
+}