Vueファイルの詳細の型を出力する方法


始めに

Vueファイルに書いたコンポーネントのメソッドを呼び出したりdataを見る際にVueファイルからだと見れるけどテストコードでは見れないことがあります。

page.vue
<template>
  <MyComponent ref="refMyComponent" />
</template>

<script lang="ts">
import { defineComponent, ref, onMounted } from 'vue';
import MyComponent from './components/MyComponent.vue';

export default defineComponent({
  setup() {
    const refMyComponent = ref<InstanceType<typeof MyComponent>();
    
    onMounted(() => {
      if (refMyComponent.value) {
        // MyComponentのdataやmethodにアクセスできる
        refMyComponent.value.hoge;
      }
    });
    
    return {
      refMyComponent,
    };
  },
});
</script>
error.test.ts
import { mount } from '@vue/test-utils';
import MyComponent from '~/components/MyComponent.vue';

describe('テスト', () => {
  it('テスト', () => {
    const wrapper = mount(MyComponent);
    // MyComponentのdataやmethodにアクセスしようとするとanyまたはエラーになる
    wrapper.vm.hoge;
  });
});

型が見れないのは明白で、*.vueをimportするときはこういう型にするという設定をしているため、exportされた型ではなくこちらの型で認識されるためです(なんでvueファイルからvueファイルをimportするときは問題ないのかは不明ですが。。)

shims.d.ts
// https://github.com/vitejs/vite/blob/main/packages/create-vite/template-vue-ts/src/env.d.ts
declare module '*.vue' {
  import { DefineComponent } from '@vue/runtime-core'
  const component: DefineComponent<{}, {}, any>
  export default component
}

型が上記の内容で丸められるのが問題であるなら、CSSモジュールの型ファイルを出力するのと同じようにVueファイルの型を自動で出力したら良いのではと思い、gulpで型ファイルを出力するやり方をまとめましたので記事にしました。

Vueファイルの型出力する方法

全体の流れ

ざっくり方針としてはtsファイルであれば型を出力できるため、Vueファイルからscriptブロックの内容を抜き出して、そのファイルをtscして型出力させます。結構複雑なことをしているので全体の流れを図にしました。この一連の処理をgulpで実装しました。

scriptブロックを抽出し、tsファイルに変換する

まずは該当となるVueファイルを取得し、各Vueファイルからscriptブロックを正規表現で切り出します。そしてimport MyComponent from '~/components/MyComponent.vue'とかのままだとTypeScriptコンパイル時に型の詳細が見れなくなってエラーになる可能性があるのでimport MyComponent from '~/components/MyComponent'のようにtsファイルとしてimportするようにします。
抽出されたコードはvueファイルからtsファイルにリネームします。importするコードを最後MyComponent.vueのように戻す必要があるのでパス情報を変数で持っておきます。

const ROOT_DIR = process.cwd() + '/src';

gulp.task('types', () => {
  return (
    gulp
      .src(['./src/components/*.vue', './src/pages/*.vue'], {
        base: ROOT_DIR,
      })
      // scriptブロックだけ抽出する
      .pipe(
        modifyFile((content, path) => {    
          // tsブロックの場合
          const match = content.match(
            /<script lang="ts">\n((.|\n)+)<\/script>/
          );
          if (match) {
            const srcTs = match[1];
            // importパスの.vueを取り除いてTSとしてimportされるようにする
            const replacedSrc = srcTs.replace(/\.vue'/g, `'`);
            return replacedSrc;
          }

          return content;
        })
      )
      // 拡張子を.vue → .tsに変える
      .pipe(
        rename((path) => {
          // パス情報を保存する
          targetFilePaths.push('~/' + path.dirname + '/' + path.basename);
          path.extname = '.ts';
        })
      )
  );
});

トランスパイルして型ファイルを出力する

こうして出力されたtsファイル達をまとめてコンパイルします。既存のtsconfig.jsonをベースに、型定義ファイルが出力されるように設定し、コンパイルします。.dtsの方で型定義ファイルの操作ができるため、そこからpipeで処理を続けていきます。

import ts from 'gulp-typescript';

const tsProject = ts.createProject('tsconfig.json', {
  declaration: true,
  noEmit: false,
});

gulp.task('task', () => {
  return (
    gulp
      // 省略
      // コンパイルをする
      .pipe(tsProject())
      // 型ファイルの方を操作する
      .dts.pipe(
        rename((path) => {
          // .d.ts → .vue.d.tsにする
          path.basename = path.basename.replace(/\.d$/, '.vue.d');
        })
      )
  );
});

vueの型ファイルになるように調整する

コンパイル時はあくまでtsファイルとして扱ったので型も.d.tsになっていますが、実際はvueファイルですので.vue.d.tsになるようにリネームします。

.vue.d.tsにリネーム

gulp.task('task', () => {
  return (
    gulp
      // 省略
      // 型ファイルの方を操作する
      .dts.pipe(
        rename((path) => {
          // .d.ts → .vue.d.tsにする
          path.basename = path.basename.replace(/\.d$/, '.vue.d');
        })
      )
  );
});

.vue.d.tsの内容

コンパイル結果は以下のようなものになります。

.vue.d.ts
import { Todo } from '~/types/Todo';
declare const _default: import("vue").DefineComponent<{}, {
    state: {
        todoList: {
            title: string;
            detail: string;
        }[];
    };
    onSubmitTodo: (todo: Todo) => void;
    onRemoveTodo: (index: number) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, import("vue").EmitsOptions, string, import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, Readonly<{} & {} & {}>, {}>;
export default _default;

declare moduleにして型ファイルだけ別な場所に移動できるようにする

このまま同じ階層に.d.tsをおくと型を見てくれるようになりますが、ファイルが邪魔になるので別な場所に移動させたいです。これをするにはdeclare moduleで宣言して、特定のpathの時にここで定義した内容が参照されるようにします。これに加えてファイルパスを拡張子なしから.vueに戻しておきます。

gulp.task('task', () => {
  return (
    gulp
      // 省略
      .pipe(
        modifyFile((content, path) => {
          // .d.tsを取り除く
          const renamedPath = path.replace(/\.d\.ts$/, '');
          const relativePath = '~/' + pathUtil.relative(ROOT_DIR, renamedPath);
          // コンテンツの内容を書き換える
          const replacedContent = (() => {
            // declare moduleで括るため、content内のdeclare句は不要になる
            let replacedContent = content.replace(/declare/g, '');
            // ファイルパスを.vueに戻す
            targetFilePaths.forEach((targetFilePath) => {
              replacedContent = replacedContent.replace(
                new RegExp(targetFilePath),
                `${targetFilePath}.vue`
              );
            });
            return replacedContent;
          })();
          return [
            `declare module '${relativePath}' {`,
            replacedContent,
            '}',
          ].join('\n');
        })
      )
  );
});

変換後の内容

こうして変換された内容が以下のようになります。

.vue.d.ts
declare module '~/pages/index.vue' {
import { Todo } from '~/types/Todo';
 const _default: import("vue").DefineComponent<{}, {
    state: {
        todoList: {
            title: string;
            detail: string;
        }[];
    };
    onSubmitTodo: (todo: Todo) => void;
    onRemoveTodo: (index: number) => void;
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, import("vue").EmitsOptions, string, import("vue").VNodeProps & import("vue").AllowedComponentProps & import("vue").ComponentCustomProps, Readonly<{} & {} & {}>, {}>;
export default _default;

}

@typesディレクトリに入れる

後は指定したディレクトリに出力させます。

gulp.task('task', () => {
  return (
    gulp
      // 省略
      // @types/generate以下に出力する
      .pipe(gulp.dest('src/@types/generate'))
  );
});

補足

setup構文の場合

最近出てきたsetup構文の場合はdefineExposeされたものだけpublicでアクセスできるようにする仕様らしいので、空のdefineComposeを書いてsetupのreturnでdefineExposeされるものだけ書くと型としては十分機能します。

// scriptブロックだけ抽出する
.pipe(
  modifyFile((content, path) => {
    {
      // tsブロックの処理
    }
    
    {
      // setup tsブロックの場合
      const match = content.match(
        /<script setup lang="ts">\n((.|\n)+)<\/script>/
      );
      if (match) {
        const srcTs = match[1];
        // importパスの.vueを取り除いてTSとしてimportされるようにする
        const replacedSrc = srcTs.replace(/\.vue'/g, `'`);

        const exposeMatch = replacedSrc.match(
          /defineExpose\({([^}]+)}\)/
        );
        const exposeText = exposeMatch ? exposeMatch[1] : '';
        return [
          `import { defineComponent } from 'vue';`,
          replacedSrc,
          'export default defineComponent({',
          '  setup() {',
          `    return {${exposeText}}`,
          '  }',
          '});',
        ].join('\n');
    }
  })
)

終わりに

以上がVueファイルの詳細の型を出力する方法でした。大分複雑な処理をしているので、基本的にはやらずに済む方法を検討した方が良いと思いました(テストではロジックテストはcomposableとして切り出しておく、vueファイルではexport default以外はexportしないなど)。どうしてもテストコードなどの時にdataやmethodを型付きで参照したい場合に検討していただければと思います。
また最近はsetup構文ができてdefineExposeとか用意されているので、もしかしたら上手いことtsファイルからでも型は見れるようになっているかもしれません。もしそうであれば是非教えて欲しいです😭
最後にサンプルはちゃんと用意できていませんが、一応こちらにgulpファイルの全体がありますので、興味がある方は見てください。

https://github.com/wintyo/test-nuxt3-app/blob/main/gulpfile.ts