Nuxt.jsでTypeScriptを使うために色々試して諦めた

最近色々あってあんまりコードが書けてなかったけど、これじゃあかんと思って再開した。

とりあえず、アツいと思ってるものをやろうかな〜ということで、VueFesで話を聞いてからNuxt熱が再燃してきたので、ibuosをNuxtで書き換えてみよう、というのを試していた。

React&MobX&TypeScriptからの置き換え

もともとがReact&MobX&TypeScriptで動いている。これを書き換えたい。

Nuxtを使う以上、

  • React -> Vue
  • MobX -> Vuex

は確定として、TypeScriptをどうするか?という問題が残った。

正直、TypeScript&VSCodeでコードを書く体験を一度味わうと、もうただのPureJSには戻れない感がある。

ということで、NuxtでTypeScriptを使うために色々頑張ってみた。

最終的にはタイトルにある通り諦めたのだが、これを再度構築するの結構ツラいし時間がかかると予想されたので、いつか役に立つかもしれないのでここに残しておく。

型定義を書く

あたりは自分で型定義ファイルを用意した。 @fukuiretuさんの記事やGistをだいぶ参考にさせてもらった。感謝。

最終的にはこんなのが仕上がった。

import Vue from 'vue';
import { Route } from 'vue-router';
import { Store } from 'vuex';
import { AxiosInstance, AxiosRequestConfig, AxiosPromise } from 'axios';

interface NuxtAxiosInstance extends AxiosInstance {
  $request<T = any>(config: AxiosRequestConfig): AxiosPromise<T>;
  $get<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>;
  $delete<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>;
  $head<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>;
  $options<T = any>(url: string, config?: AxiosRequestConfig): AxiosPromise<T>;
  $post<T = any>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): AxiosPromise<T>;
  $put<T = any>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): AxiosPromise<T>;
  $patch<T = any>(
    url: string,
    data?: any,
    config?: AxiosRequestConfig
  ): AxiosPromise<T>;
}

interface NuxtContext {
  isClient: boolean;
  isServer: boolean;
  isStatic: boolean;
  isDev: boolean;
  isHMR: boolean;
  route: Route;
  store: Store<any>;
  env: object;
  query: object;
  nuxtState: object;
  req: Request;
  res: Response;
  params: { [key: string]: any };
  redirect: (path: string) => void;
  error: (params: { statusCode?: String; message?: String }) => void;
  beforeNuxtRender: (param: { Conmponents: any; nuxtState: any }) => void;
  $axios: NuxtAxiosInstance;
}

declare module 'vue/types/options' {
  interface ComponentOptions<V extends Vue> {
    layout?: string;
    middleware?: string | String[];
    fetch?: (context: NuxtContext) => void;
    asyncData?: (context: NuxtContext) => void;
    scrollToTop?: boolean;
    transition?: string | object | Function;
    validate?: (context: NuxtContext) => boolean;
    head?: () => { [key: string]: any };
    watchQuery?: string[];
  }
}
declare module '*.vue' {
  import Vue, { ComponentOptions } from 'vue';
  const value: ComponentOptions<Vue>;
  export default value;
}

Vuexで型定義

TypeScript力が足りてないので、正直これであってるのかわからないが、こんな感じになった。 (コードは雰囲気で抜粋してる)

State

export interface State {
  user: User;
}

export const state = (): State => ({
  user: {
    id: null,
    displayName: '',
  }
});

Getter

import { GetterTree, ActionTree, MutationTree } from 'vuex';

export const getters: GetterTree<State, any> = {
  isSignin: (state: State) => state.user.id
};

Action

import { GetterTree, ActionTree, MutationTree } from 'vuex';

export const types = {
  SET_DISPLAY_NAME: 'SET_DISPLAY_NAME',
};

export const actions: ActionTree<State, any> = {
  async updateDisplayName(context, name: string): Promise<void> {
    await (this.$axios as NuxtAxiosInstance).$patch('/myself/name', { name });
    context.commit(types.SET_DISPLAY_NAME, name);
  },
}

Mutation

import { GetterTree, ActionTree, MutationTree } from 'vuex';

export const types = {
  SET_DISPLAY_NAME: 'SET_DISPLAY_NAME',
};

export const mutations: MutationTree<State> = {
  [types.SET_DISPLAY_NAME](state, displayName: string) {
    state.user.displayName = displayName;
  }
}

だいぶ意味不明な感じにはなってる。

  • vuexの XXXTree って使って大丈夫なのか?
  • this.$axios とかを型付けする方法がさっぱりわからない

といった悩みを抱えていたが、とりあえずある程度それっぽくなったのでこれで妥協した。

Vueファイル

たとえば、とある pages/ 配下のファイルからscript部を一部抜粋すると

import Vue from 'vue';
import Component from 'vue-class-component';
import { namespace } from 'vuex-class';
import { State as Auth } from '../store/auth';
import * as auth from '~/store/auth';
import { NuxtContext } from 'types';

const authModule = namespace(auth.name);

@Component({
  head: () => ({ title: 'マイページ' }),
})
export default class MyPage extends Vue {
  @authModule.State user!: auth.User;
  @authModule.Action updateDisplayName: any;

  name = '';

  asyncData(context: NuxtContext) {
    const user: auth.User = context.store.state.auth.user;
    if (!user.id) {
      return context.redirect(
        `/signin?r=${encodeURIComponent(context.route.path)}`
      );
    }
    return { name: user.displayName };
  }

  async handleUpdate() {
    await this.updateDisplayName(this.name);
  }
}

こんなことになった。

Propsなどを受け取るコンポーネント等の場合は、こんな感じ。

import Vue from 'vue';
import Component from 'vue-class-component';
import { Prop } from 'vue-property-decorator';
import { ellipsis } from '~/lib/text';

@Component
export default class EllipsisText extends Vue {
  @Prop({ default: '' })
  text!: string;
  @Prop({ default: 100 })
  size!: number;

  get ellipsisText() {
    return ellipsis(this.text, this.size);
  }
}

いずれにしても、

  • vue-class-component
  • vuex-class
  • vue-property-decorator

あたりを駆使して、うまく型情報を渡していくような書き方になる。

ずいぶん普通にVueを使う場合とは書き方が違うが、 @Props とかは解りやすくて良いな〜と思いながら書いてた。

ESLint & Prettier

正直色々いじりすぎて何のプラグインを入れたか記憶が薄いが、create-nuxt-appを叩いたときに作られる .eslintrc.js をもとに色々いじって、最終的にこうなった。

module.exports = {
  root: true,
  env: {
    browser: true,
    node: true
  },
  parserOptions: {
    parser: 'typescript-eslint-parser',
  },
  extends: [
    // https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
    // consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
    'typescript',
    'plugin:vue/essential',
    'plugin:prettier/recommended'
  ],
  // required to lint *.vue files
  plugins: [
    'vue',
    'prettier'
  ],
  // add your custom rules here
  rules: {
    'typescript/no-var-requires': false,
    'typescript/no-non-null-assertion': false,
    'typescript/explicit-function-return-type': false,
    'typescript/no-angle-bracket-type-assertion': false
  }
}

確かこのあたりを使うのがポイントだったと思う。

  • eslint-plugin-typescript
  • eslint-config-prettier
  • eslint-plugin-prettier
  • typescript-eslint-parser

ビルド時に error TypeError: Cannot set property 'ts' of undefined が出る対策

こちらのissueを参考にして解決。 Nuxt Edge - Issue when running/building the project · Issue #48 · nuxt-community/typescript-template · GitHub

webpackにts-loaderを通してあげる。

front/modules/typescript.js

module.exports = function() {
  // Add .ts & .tsx extension to Nuxt
  this.nuxt.options.extensions.push('ts', 'tsx');

  // Extend webpack build
  this.extendBuild(config => {
    // Add TypeScript
    config.module.rules.push({
      test: /\.tsx?$/,
      loader: 'ts-loader',
      options: { appendTsSuffixTo: [/\.vue$/] },
    });

    // Add .ts extension in webpack resolve
    if (!config.resolve.extensions.includes('.ts')) {
      config.resolve.extensions.push('.ts');
    }

    // Add .tsx extension in webpack resolve
    if (!config.resolve.extensions.includes('.tsx')) {
      config.resolve.extensions.push('.tsx');
    }
  });
};

front/nuxt.config.js

module.exports = {
  ...
  modules: ['~/modules/typescript.js'],
  ...
}

結果

Nuxt.jsで、それなりに型で守られつつ、ESLint/Prettierでコードもきれいに書いていくことが出来るようになった。

正直、いろんなところで躓いて、ここに辿り着くまでに5〜6時間ハマり続けていた。

その後コードを書き進めて...

ここまで頑張ったのに結局かよって感じですが、最終的には、TypeScriptを併用するのはあきらめることにした。

大きい理由は2点ある。

  1. 記述が増えすぎてキツかった

    特にこれはVuex周りで思った。Vuexはサクサク書けるな〜という印象を持ってたんだけど、型で守ろうとすると記述がすさまじく増える感じがあった。

  2. 型付けのための記述のクセが強い

    型を付けようとした際に、クラスコンポーネントやデコレータといったものを駆使して、いかにTypeScriptに型情報を教えてあげるか、といったところがポイントになってくる。これ自体は凄くて、書いてみると「おぉ... Vueに秩序が..!!!」という感動も覚えた。
    ただ、ECMAScriptを使うような、いわゆる良く書くいつものSFCとだいぶ記述の差異が大きい点は考えないといけなくて、しばらく時間をあけてからコードを見たときに、「俺の知ってるVueと違う!」ってなってしまった。(私自身の記憶力とかの問題も大きい。)
    便利ではあるんだけど、標準のレールから結構外れるような感覚を覚えて、これをずっと面倒見ていける自信が沸かなくなってしまった。

とはいえ、これはあくまでも個人の、しかもほぼ誰もアクセスしてこないような小さい趣味サイトの開発するときの一人の感想でしかない。

ある程度の規模であったり、サービス・プロダクトの性質によっては、恩恵がコストを上回ることも十分考えられそうなので、お仕事で使う場合には様々な事情を考慮した上で吟味しないといけないな、と思った。(これはNuxtやTypeScriptに限った話ではないので、常日頃から意識していきたい。)

というわけで、燃え尽きて終了です。