Vueファイルの詳細の型を出力する方法
始めに
Vueファイルに書いたコンポーネントのメソッドを呼び出したりdataを見る際に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>
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するときは問題ないのかは不明ですが。。)
// 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の内容
コンパイル結果は以下のようなものになります。
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');
})
)
);
});
変換後の内容
こうして変換された内容が以下のようになります。
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ファイルの全体がありますので、興味がある方は見てください。
Discussion