فهرست منبع

feat(compiler-sfc): support resolving type imports from modules

Evan You 3 سال پیش
والد
کامیت
3982bef533

+ 58 - 10
packages/compiler-sfc/__tests__/compileScript/resolveType.spec.ts

@@ -5,9 +5,13 @@ import {
   inferRuntimeType,
   invalidateTypeCache,
   recordImports,
-  resolveTypeElements
+  resolveTypeElements,
+  registerTS
 } from '../../src/script/resolveType'
 
+import ts from 'typescript'
+registerTS(ts)
+
 describe('resolveType', () => {
   test('type literal', () => {
     const { props, calls } = resolve(`type Target = {
@@ -86,6 +90,19 @@ describe('resolveType', () => {
     })
   })
 
+  test('reference class', () => {
+    expect(
+      resolve(`
+    class Foo {}
+    type Target = {
+      foo: Foo
+    }
+    `).props
+    ).toStrictEqual({
+      foo: ['Object']
+    })
+  })
+
   test('function type', () => {
     expect(
       resolve(`
@@ -258,8 +275,8 @@ describe('resolveType', () => {
         type Target = P & PP
         `,
           {
-            'foo.ts': 'export type P = { foo: number }',
-            'bar.d.ts': 'type X = { bar: string }; export { X as Y }'
+            '/foo.ts': 'export type P = { foo: number }',
+            '/bar.d.ts': 'type X = { bar: string }; export { X as Y }'
           }
         ).props
       ).toStrictEqual({
@@ -277,9 +294,9 @@ describe('resolveType', () => {
         type Target = P & PP
         `,
           {
-            'foo.vue':
+            '/foo.vue':
               '<script lang="ts">export type P = { foo: number }</script>',
-            'bar.vue':
+            '/bar.vue':
               '<script setup lang="tsx">export type P = { bar: string }</script>'
           }
         ).props
@@ -297,9 +314,9 @@ describe('resolveType', () => {
         type Target = P
         `,
           {
-            'foo.ts': `import type { P as PP } from './nested/bar.vue'
+            '/foo.ts': `import type { P as PP } from './nested/bar.vue'
               export type P = { foo: number } & PP`,
-            'nested/bar.vue':
+            '/nested/bar.vue':
               '<script setup lang="ts">export type P = { bar: string }</script>'
           }
         ).props
@@ -317,11 +334,42 @@ describe('resolveType', () => {
         type Target = P
         `,
           {
-            'foo.ts': `export { P as PP } from './bar'`,
-            'bar.ts': 'export type P = { bar: string }'
+            '/foo.ts': `export { P as PP } from './bar'`,
+            '/bar.ts': 'export type P = { bar: string }'
+          }
+        ).props
+      ).toStrictEqual({
+        bar: ['String']
+      })
+    })
+
+    test('ts module resolve', () => {
+      expect(
+        resolve(
+          `
+        import { P } from 'foo'
+        import { PP } from 'bar'
+        type Target = P & PP
+        `,
+          {
+            '/node_modules/foo/package.json': JSON.stringify({
+              name: 'foo',
+              version: '1.0.0',
+              types: 'index.d.ts'
+            }),
+            '/node_modules/foo/index.d.ts': 'export type P = { foo: number }',
+            '/tsconfig.json': JSON.stringify({
+              compilerOptions: {
+                paths: {
+                  bar: ['./other/bar.ts']
+                }
+              }
+            }),
+            '/other/bar.ts': 'export type PP = { bar: string }'
           }
         ).props
       ).toStrictEqual({
+        foo: ['Number'],
         bar: ['String']
       })
     })
@@ -356,7 +404,7 @@ describe('resolveType', () => {
 
 function resolve(code: string, files: Record<string, string> = {}) {
   const { descriptor } = parse(`<script setup lang="ts">\n${code}\n</script>`, {
-    filename: 'Test.vue'
+    filename: '/Test.vue'
   })
   const ctx = new ScriptCompileContext(descriptor, {
     id: 'test',

+ 1 - 1
packages/compiler-sfc/src/compileScript.ts

@@ -115,7 +115,7 @@ export interface SFCScriptCompileOptions {
    */
   fs?: {
     fileExists(file: string): boolean
-    readFile(file: string): string
+    readFile(file: string): string | undefined
   }
 }
 

+ 3 - 1
packages/compiler-sfc/src/index.ts

@@ -6,7 +6,6 @@ export { compileTemplate } from './compileTemplate'
 export { compileStyle, compileStyleAsync } from './compileStyle'
 export { compileScript } from './compileScript'
 export { rewriteDefault, rewriteDefaultAST } from './rewriteDefault'
-export { invalidateTypeCache } from './script/resolveType'
 export {
   shouldTransform as shouldTransformRef,
   transform as transformRef,
@@ -29,6 +28,9 @@ export {
   isStaticProperty
 } from '@vue/compiler-core'
 
+// Internals for type resolution
+export { invalidateTypeCache, registerTS } from './script/resolveType'
+
 // Types
 export type {
   SFCParseOptions,

+ 126 - 28
packages/compiler-sfc/src/script/resolveType.ts

@@ -20,14 +20,20 @@ import {
   TSTypeReference,
   TemplateLiteral
 } from '@babel/types'
-import { UNKNOWN_TYPE, getId, getImportedName } from './utils'
+import {
+  UNKNOWN_TYPE,
+  createGetCanonicalFileName,
+  getId,
+  getImportedName
+} from './utils'
 import { ScriptCompileContext, resolveParserPlugins } from './context'
 import { ImportBinding, SFCScriptCompileOptions } from '../compileScript'
 import { capitalize, hasOwn } from '@vue/shared'
-import path from 'path'
 import { parse as babelParse } from '@babel/parser'
 import { parse } from '../parse'
 import { createCache } from '../cache'
+import type TS from 'typescript'
+import { join, extname, dirname } from 'path'
 
 type Import = Pick<ImportBinding, 'source' | 'imported'>
 
@@ -480,54 +486,82 @@ function qualifiedNameToPath(node: Identifier | TSQualifiedName): string[] {
   }
 }
 
+let ts: typeof TS
+
+export function registerTS(_ts: any) {
+  ts = _ts
+}
+
+type FS = NonNullable<SFCScriptCompileOptions['fs']>
+
 function resolveTypeFromImport(
   ctx: ScriptCompileContext,
   node: TSTypeReference | TSExpressionWithTypeArguments,
   name: string,
   scope: TypeScope
 ): Node | undefined {
-  const fs = ctx.options.fs
+  const fs: FS = ctx.options.fs || ts?.sys
   if (!fs) {
     ctx.error(
-      `fs options for compileScript are required for resolving imported types`,
-      node,
-      scope
+      `No fs option provided to \`compileScript\` in non-Node environment. ` +
+        `File system access is required for resolving imported types.`,
+      node
     )
   }
-  // TODO (hmr) register dependency file on ctx
+
   const containingFile = scope.filename
   const { source, imported } = scope.imports[name]
+
+  let resolved: string | undefined
+
   if (source.startsWith('.')) {
     // relative import - fast path
-    const filename = path.join(containingFile, '..', source)
-    const resolved = resolveExt(filename, fs)
-    if (resolved) {
-      return resolveTypeReference(
-        ctx,
+    const filename = join(containingFile, '..', source)
+    resolved = resolveExt(filename, fs)
+  } else {
+    // module or aliased import - use full TS resolution, only supported in Node
+    if (!__NODE_JS__) {
+      ctx.error(
+        `Type import from non-relative sources is not supported in the browser build.`,
         node,
-        fileToScope(ctx, resolved, fs),
-        imported,
-        true
+        scope
       )
-    } else {
+    }
+    if (!ts) {
       ctx.error(
-        `Failed to resolve import source ${JSON.stringify(
+        `Failed to resolve type ${imported} from module ${JSON.stringify(
           source
-        )} for type ${name}`,
+        )}. ` +
+          `typescript is required as a peer dep for vue in order ` +
+          `to support resolving types from module imports.`,
         node,
         scope
       )
     }
+    resolved = resolveWithTS(containingFile, source, fs)
+  }
+
+  if (resolved) {
+    // TODO (hmr) register dependency file on ctx
+    return resolveTypeReference(
+      ctx,
+      node,
+      fileToScope(ctx, resolved, fs),
+      imported,
+      true
+    )
   } else {
-    // TODO module or aliased import - use full TS resolution
-    return
+    ctx.error(
+      `Failed to resolve import source ${JSON.stringify(
+        source
+      )} for type ${name}`,
+      node,
+      scope
+    )
   }
 }
 
-function resolveExt(
-  filename: string,
-  fs: NonNullable<SFCScriptCompileOptions['fs']>
-) {
+function resolveExt(filename: string, fs: FS) {
   const tryResolve = (filename: string) => {
     if (fs.fileExists(filename)) return filename
   }
@@ -540,23 +574,83 @@ function resolveExt(
   )
 }
 
+const tsConfigCache = createCache<{
+  options: TS.CompilerOptions
+  cache: TS.ModuleResolutionCache
+}>()
+
+function resolveWithTS(
+  containingFile: string,
+  source: string,
+  fs: FS
+): string | undefined {
+  if (!__NODE_JS__) return
+
+  // 1. resolve tsconfig.json
+  const configPath = ts.findConfigFile(containingFile, fs.fileExists)
+  // 2. load tsconfig.json
+  let options: TS.CompilerOptions
+  let cache: TS.ModuleResolutionCache | undefined
+  if (configPath) {
+    const cached = tsConfigCache.get(configPath)
+    if (!cached) {
+      // The only case where `fs` is NOT `ts.sys` is during tests.
+      // parse config host requires an extra `readDirectory` method
+      // during tests, which is stubbed.
+      const parseConfigHost = __TEST__
+        ? {
+            ...fs,
+            useCaseSensitiveFileNames: true,
+            readDirectory: () => []
+          }
+        : ts.sys
+      const parsed = ts.parseJsonConfigFileContent(
+        ts.readConfigFile(configPath, fs.readFile).config,
+        parseConfigHost,
+        dirname(configPath),
+        undefined,
+        configPath
+      )
+      options = parsed.options
+      cache = ts.createModuleResolutionCache(
+        process.cwd(),
+        createGetCanonicalFileName(ts.sys.useCaseSensitiveFileNames),
+        options
+      )
+      tsConfigCache.set(configPath, { options, cache })
+    } else {
+      ;({ options, cache } = cached)
+    }
+  } else {
+    options = {}
+  }
+
+  // 3. resolve
+  const res = ts.resolveModuleName(source, containingFile, options, fs, cache)
+
+  if (res.resolvedModule) {
+    return res.resolvedModule.resolvedFileName
+  }
+}
+
 const fileToScopeCache = createCache<TypeScope>()
 
 export function invalidateTypeCache(filename: string) {
   fileToScopeCache.delete(filename)
+  tsConfigCache.delete(filename)
 }
 
 function fileToScope(
   ctx: ScriptCompileContext,
   filename: string,
-  fs: NonNullable<SFCScriptCompileOptions['fs']>
+  fs: FS
 ): TypeScope {
   const cached = fileToScopeCache.get(filename)
   if (cached) {
     return cached
   }
 
-  const source = fs.readFile(filename)
+  const source = fs.readFile(filename) || ''
   const body = parseFile(ctx, filename, source)
   const scope: TypeScope = {
     filename,
@@ -577,7 +671,7 @@ function parseFile(
   filename: string,
   content: string
 ): Statement[] {
-  const ext = path.extname(filename)
+  const ext = extname(filename)
   if (ext === '.ts' || ext === '.tsx') {
     return babelParse(content, {
       plugins: resolveParserPlugins(
@@ -705,7 +799,8 @@ function recordType(node: Node, types: Record<string, Node>) {
   switch (node.type) {
     case 'TSInterfaceDeclaration':
     case 'TSEnumDeclaration':
-    case 'TSModuleDeclaration': {
+    case 'TSModuleDeclaration':
+    case 'ClassDeclaration': {
       const id = node.id.type === 'Identifier' ? node.id.name : node.id.value
       types[id] = node
       break
@@ -899,6 +994,9 @@ export function inferRuntimeType(
       }
     }
 
+    case 'ClassDeclaration':
+      return ['Object']
+
     default:
       return [UNKNOWN_TYPE] // no runtime check
   }

+ 19 - 0
packages/compiler-sfc/src/script/utils.ts

@@ -78,3 +78,22 @@ export function getId(node: Expression) {
     ? node.value
     : null
 }
+
+const identity = (str: string) => str
+const fileNameLowerCaseRegExp = /[^\u0130\u0131\u00DFa-z0-9\\/:\-_\. ]+/g
+const toLowerCase = (str: string) => str.toLowerCase()
+
+function toFileNameLowerCase(x: string) {
+  return fileNameLowerCaseRegExp.test(x)
+    ? x.replace(fileNameLowerCaseRegExp, toLowerCase)
+    : x
+}
+
+/**
+ * We need `getCanonicalFileName` when creating ts module resolution cache,
+ * but TS does not expose it directly. This implementation is repllicated from
+ * the TS source code.
+ */
+export function createGetCanonicalFileName(useCaseSensitiveFileNames: boolean) {
+  return useCaseSensitiveFileNames ? identity : toFileNameLowerCase
+}

+ 1 - 1
packages/sfc-playground/src/Header.vue

@@ -6,7 +6,7 @@ import Moon from './icons/Moon.vue'
 import Share from './icons/Share.vue'
 import Download from './icons/Download.vue'
 import GitHub from './icons/GitHub.vue'
-import { ReplStore } from '@vue/repl'
+import type { ReplStore } from '@vue/repl'
 
 const props = defineProps<{
   store: ReplStore

+ 12 - 1
packages/sfc-playground/vite.config.ts

@@ -7,7 +7,18 @@ import execa from 'execa'
 const commit = execa.sync('git', ['rev-parse', 'HEAD']).stdout.slice(0, 7)
 
 export default defineConfig({
-  plugins: [vue(), copyVuePlugin()],
+  plugins: [
+    vue({
+      script: {
+        // @ts-ignore
+        fs: {
+          fileExists: fs.existsSync,
+          readFile: file => fs.readFileSync(file, 'utf-8')
+        }
+      }
+    }),
+    copyVuePlugin()
+  ],
   define: {
     __COMMIT__: JSON.stringify(commit),
     __VUE_PROD_DEVTOOLS__: JSON.stringify(true)

+ 2 - 0
packages/vue/compiler-sfc/index.js

@@ -1 +1,3 @@
 module.exports = require('@vue/compiler-sfc')
+
+require('./register-ts.js')

+ 3 - 1
packages/vue/compiler-sfc/index.mjs

@@ -1 +1,3 @@
-export * from '@vue/compiler-sfc'
+export * from '@vue/compiler-sfc'
+
+import './register-ts.js'

+ 1 - 1
packages/vue/compiler-sfc/package.json

@@ -2,4 +2,4 @@
   "main": "index.js",
   "module": "index.mjs",
   "types": "index.d.ts"
-}
+}

+ 5 - 0
packages/vue/compiler-sfc/register-ts.js

@@ -0,0 +1,5 @@
+if (typeof require !== 'undefined') {
+  try {
+    require('@vue/compiler-sfc').registerTS(require('typescript'))
+  } catch (e) {}
+}