わからん

最近Nuxt.jsと戯れるようにしてますが、Nuxt.jsとVue.jsの新しいAPIであるCompositionAPIの相性があまりよくないのか色々苦戦してます。

いよいよツラミもわかってきた頃合いなので一つずつまとめていこうかと思います。

今回はNuxt.jsmodulesCompositionAPIでどう使っていくかを書きます。

先に結論

実装方法だけ見たい人は下記に進んでください。

CompositionAPIだとVueインスタンスにアクセスできるのはsetup内のみ

また、本実装を施したWebアプリを作ってみました。

ebook-homebrew-nuxt-with-typescript-client

該当のComponent

img

そもそもCompositionAPIとは?

CompositionAPIとは、Vue3.x系から正式採用される新しいVue.jsの使い方です。

公式的には

a set of additive, function-based APIs that allow flexible composition of component logic. (コンポーネントロジックの構成を柔軟にできる関数ベースな追加API)

とのこと。

ここら辺はだんだん使っていけば何となく良いところが見えてきますが、そちらのまとめはまた今度。

CompositionAPIを使おうと思ったのは、Vue3.xで採用されるというのと、もはやTypeScriptで書かないと現場でいじめられてしまうこの世の中で、VueもTypeScriptで書くことが急務になりつつある状況の中、Vue + TypeScriptで一定のデファクトスタンダードを勝ち得たClassAPIという使い方が、色々問題になっているようだったのでそのツラミを取り除いたらしいCompositionAPIを採用しました。

上記のツラミ・スゴミについて詳しくは下記のプレゼンがすごくわかりやすかったです。

Composition API TypeScriptはVue.jsの夢を見るか?

ざっくりと書き方の違いとしては

ClassAPI(decoratorを使ったパターン)

<script lang="ts">
import axios from 'axios';
import { Component, Prop, Vue } from "vue-property-decorator";

@Component
export default class HelloWorld extends Vue {

  // props
  @Prop() private propHoge!: string;

  // data
  message: string = "Hoge";
  hogeCount: number = 0;

  // computed
  get double() {
    return this.hogeCount* 2;
  }

  // mounted
  mounted() {
    this.gethoge();
  }

  // methods
  getData() {
    axios
      .get("https://hogehoge.com")
      .then(response => (this.message = response));
  }
}
</script>

CompositionAPIで書くパターン

<script lang="ts">
  import {
    createComponent,
    reactive,
    onBeforeMount,
    onMounted,
    computed,
    ref
  } from '@vue/composition-api';
  import axios from 'axios';
  import toast from '@nuxtjs/toast';
  import {PdfFileNotFoundError} from "~/types/error";
  const backendURL = 'https://ebook-homebrew.herokuapp.com/';

  // data
  // ref,またはreactiveとして設定するとTemplateでreactiveに変更が反映される
  // setup()の外でも中でもOK
  const state = reactive<{
    uploadList: Array<Map<string, string>>
  }>({
    uploadList: []
  });

  // methods
  const updateFileList = async (): Promise<void> => {
     (ロジック)
  };

  const downloadPDF = async (filePath: string): Promise<Blob> => {
   (ロジック)
  };
  
  // typeまたはinterfaceでpropsの型指定
  type Props = {
    propHello: string;
  };
  
  //createComponet内でprops, components, layoutなどを設定
  export default createComponent({
    //props
    props: {
      propHello: {
        type: String
      }
    },
    //setup()で初めてVueインスタンス化されるのでinjectされたものはsetup内でしかとれない。
    setup (props: Props, ctx) {

      // propsをsetup内ローカル変数で再設定
      const propsHello = props.propHello;

      //Contextをsetupで受け取ることができ、module化されたものはroot要素からとれる
      const toast = ctx.root.$root.$toast;

      //setup内でもmethods作成可能。Context rootから取得するものを使わないといけない場合、setup内で実装するしか道はなさそう
      const doDownload = async (filePath: string): Promise<void> => {
         (ロジック)
      };
      //ライフサイクルはsetup内で記載、またライフサイクル自体も従来と異なる
      onBeforeMount( async () => {
        await updateFileList()
        }
      );
      //setupのreturnで返したものがtemplateで使える変数
      return {
        state,
        propsHello,
        doDownload
      };
    }
  });
</script>

となります。

ぜんぜんかきっぷり違ってびっくり!

ぱっと見ClassAPIのデコレーターの方がコード量少なくて見通しはいい気がしますが、ロジック、ステート、レンダリングを好きなように(究極別ファイルに切り出しも可)宣言して、setupでまとめ上げるのは確かに見通しよいかもしれませんね。

まだ、ここらへんは自分の中でのベストプラクティスができあがってないので今後考察します。

あと、テストコードはまだ書いてないのですが、毎回VueインスタンスをshallowMountして頑張って書く感じから解放されそうでテストコード的なメリットはありそうです。

Nuxt.jsとの相性

CompositionAPIとNuxt.jsの相性は今のところよくないと思います。

その一例がmodulesだと思うので検証がてら考察していきます。

Nuxt.jsのmodulesがCompositionAPIで使いたいんだが

ここからが本題なのですが、よくあるNuxt.jsのmodulesを使う実装例の中で全くといっていいほどCompositionAPIでやってるものがないので、Nuxt.jsの動き方を逐次確認しながらmodulesを使ってみます。

例えば、ClassAPI(またはOptionsAPI)の場合よくあるNuxt.jsモジュールの例はaxiosです。

次のようなお困りごとを解決する使い方が例によく出ます。

  • HeadlessCMSなど他コンテンツURIをProxyしている場合などで、APIコール時にHTTP Statusチェックし、404だった場合は別ページを表示させる

こういったケースだとOptionsAPIでは下記のような実装例があります。

//あらかじめnuxt.config.jsにmodules: ['@nuxtjs/axios']を宣言し、同configにplugins: ['~/plugins/axios'] も宣言しておく
//@/plugins/axios.js

// modulesのaxiosを呼び出す際の共通のエラー処理を記載
export default function ({ $axios, redirect }) {

    $axios.onError(err => {
        const statusCode = parseInt(err.response && err.response.status)
        if (statusCode === 404) {
            redirect('/not-found-page')
 
        }
    })

}
//利用側components: hoge.vue

export default {
  methods: {
    async sendRequest() {
 //methods内では this.$axios
      const response = await this.$axios.$get('https://hoge.com');
      res = response.headers.Accept;
    }
  },
  async asyncData({ $axios }) {
 //asyncData, fetchなどでは $axiosで取得
    const hoges= await $axios.$get("https://hoge/hoge",{
        params: {
          userId: "hoon"
        }
      }
    )
    return { hoges };
  }
};

共通のエラーハンドリングをpluginsに記載するだけで冗長なハンドリングを回避できるのはすごいですね。

ポイントはmodulesで宣言した@nuxtjs/axiosは書くpage, componentで利用可能でVueインスタンス内ではthis.$axiosで取得できるということです。

CompositionAPIだとVueインスタンスにアクセスできるのはsetup内のみ

ということは先ほど話した通りなのですがそうするとmodulesの利用側はsetup内でのみ使えることになります。

ということを頭に入れながら@nuxtjs/toastを実装していきます。

まずnuxt.config.ts にmodulesを設定していきます。

//nuxt.config.ts

(中略)
modules: [
    '@nuxtjs/toast',
  ],

toastの利用側のコンポーネントではsetup内で使います。

ただ、できるだけロジックをsetup内にごちゃごちゃ書きたくないのでエラーハンドリングを使いながら頑張ります。

<script lang="ts">
  import {
    createComponent,
    reactive,
    onBeforeMount,
    onMounted,
    computed,
    ref
  } from '@vue/composition-api';
  import axios from 'axios';
  import toast from '@nuxtjs/toast';

  // 独自エラー(404 NotFound)を作ってtoastを出しわける
  class PdfFileNotFoundError extends Error {
    constructor(e?: string) {
      super(e);
      this.name = new.target.name;
      Object.setPrototypeOf(this, new.target.prototype);
    }  
  }

  const backendURL = 'https://ebook-homebrew.herokuapp.com/';

  // ロジック
  const downloadPDF = async (filePath: string): Promise<Blob> => {
    const res = await axios.post(backendURL + 'convert/pdf/download', { uploadId: filePath, },
      {responseType: 'blob'}).catch((err) => {
      if (err.response.status === 404) {
        throw new PdfFileNotFoundError('PdfFileNotFound');
 //404 NotFoundだったら独自エラーをthrow
      } else {
        throw err;
      }
    },
    );
    return new Blob([res.data], {type: 'application/pdf'});
  };

  export default createComponent({

    setup (ctx) {
 //setupでContextを受け取れるので受け取る
      
      //modulesはContextのrootから取れる
      const toast = ctx.root.$root.$toast;
      const doDownload = async (filePath: string): Promise<void> => {
        const options = {
          position: 'top-center',
          duration: 2000,
          fullWidth: true,
          type: 'error',
        } as any;
        try{
          const blob = await downloadPDF(filePath);
          const link = document.createElement('a');
          link.href = window.URL.createObjectURL(blob);
          link.download = 'result.pdf';
          link.click();
        } catch (e) {
          //errorをキャッチ
          if (e instanceof PdfFileNotFoundError) {
            toast.show('No File!!', options)
 //エラーハンドリングでtoast呼び出し
          } else {
            toast.show('UnknownError!!', options)
          }
        }
      };
      return {
        state,
        doDownload
      };
    }
  });
</script>

とまぁ、結局のところmodulesはsetupで使うのですが、stateの処理やAPIコール部分はなるべく外だししました。

結論

むずかしい。