🕷️

Nuxt.js + TypeScriptでとりあえず何か作るためのチュートリアル

2021/01/19に公開

はじめに

Nuxt.js (SSG) + TypeScriptを試してるのでよく使いそうな機能をいれた最小構成のベースラインを作成した。

基本的な構成は以下の通り。

  • TypeScript対応
  • 個別ページのOGP対応
  • Firebase認証
  • Typed Vuex

必要に応じて随時記事はメンテ。たぶん。
コードは以下。修正ファイル一覧はこちら
https://github.com/koduki/example-nuxt

プロジェクトの作成

プロジェクトの作成。

$ yarn create nuxt-app example-nuxt
? Project name: example-nuxt
? Programming language: TypeScript
? Package manager: Yarn
? UI framework: Bootstrap Vue
? Nuxt.js modules: Axios - Promise based HTTP client
? Linting tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Testing framework: None
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Static (Static/JAMStack hosting)
? Development tools: (Press <space> to select, <a> to toggle all, <i> to invert selection)
$ cd example-nuxt

tsconfig.jsonにAxiosの設定を追加します。

    "types": [
      "@nuxt/types",
      "@nuxtjs/axios",
      "@types/node"
    ]

アプリケーションの起動 & 静的ファイルの生成

$ yarn dev
$ yarn build && yarn generate
$ yarn start

認証機能の作成

基本構成

GCPのIDプラットフォームを利用して認証機能を実装する。
ライブラリはFireabase認証と同様なのでこちらを利用。nuxt/fireabaseもあったけど、ちょっと大仰な気がしたのでそのまま自分で実装。

Nuxt.jsでは認証のチェックにはMiddlewareを使うのが良さそう。これは他の言語のFWだとフィルタとかインターセプターと呼ばれるようなレンダリングの前処理を行うレイヤー。最終的にはVue Routerに変換されているっぽい。基本的にはNuxt.jsではVue Routerは直接触らずこのように関節的に触った結果として最終的なコードが生成される模様。

認証の永続化レイヤーにはTyped Vuexを使用。Nuxt.jsでVuexをTypeScriptで使う記載はこのあたりにあるけどvuex-module-decoratorsなどは既存と書き方がかなり変わって使いずらい。
その点、Typed VuexはほぼJSのVuexと同じ書き方で型チェックや補完が利くし、$accesoerを使うことでactionmutationも通常の関数のように呼べるようになるので、とても取り扱いやすいため、こちらを採用。

認証モジュールの作成

まずはFirebase Authを呼ぶコード。色々な場所で利用する可能性があるのでビジネスロジックとしてservicesというディレクトリに作成。Nuxt.jsでもUIの無いビジネスロジックをどこに書くべきかはやはり良く分からない。なんとなくTypeScriptなのでクラスで作成。

まずは、fireabaseのインストール。あわせてservicesも作成。

$ yarn add firebase
$ mkdir -p services

services/auth.tsとして下記のように作成。後述する$accessorを通してvuexにトークンやユーザ情報を書き込んでいる。

import firebase from "firebase";
import { accessorType } from '@/store'

// enum altanative
export const Provider = {
    Google: 'GOOGLE',
    Twitter: 'TWITTER'
} as const;
type Provider = typeof Provider[keyof typeof Provider];

type Config = {
    apiKey: string;
    authDomain: string;
}

export class Auth {
    private $accessor: typeof accessorType;

    constructor($accessor: typeof accessorType, $config: Config) {
        this.$accessor = $accessor;
        if (firebase.apps.length == 0) {
            const config = {
                apiKey: $config.apiKey,
                authDomain: $config.authDomain,
            }

            firebase.initializeApp(config);
        }
    }

    async login(provider: Provider) {
        const response = await ((provider: Provider) => {
            switch (provider) {
                case "GOOGLE": return firebase.auth().signInWithPopup(new firebase.auth.GoogleAuthProvider());
                case "TWITTER": return firebase.auth().signInWithPopup(new firebase.auth.TwitterAuthProvider());
            }
        })(provider);
        const user = await response.user;
        const token = await user?.getIdToken();

        this.$accessor.user.store({
            id: user!.uid,
            name: (user?.displayName) ? user?.displayName : "",
            pic: (user?.photoURL) ? user?.photoURL : "",
            token: token!
        })
    }

    async logout() {
        firebase
            .auth()
            .signOut()
            .then(() => {
                this.$accessor.user.drop();
            })
    }
}

Typed Vuexによる永続化層の作成

セットアップ

つづいて永続化層の作成。まずはTyped Vuexをインストール。セットアップガイドを参考に実施。

$ yarn add nuxt-typed-vuex

nuxt.config.jsを改修しtyped-vuexのカスタムモジュールを読み込む。

  buildModules: [
    ...
    // https://typed-vuex.roe.dev
    'nuxt-typed-vuex',
  ],

最後に型定義index.d.tsをプロジェクト直下に作成する。

import { accessorType } from '~/store'

declare module 'vue/types/vue' {
    interface Vue {
        $accessor: typeof accessorType
    }
}

declare module '@nuxt/types' {
    interface NuxtAppOptions {
        $accessor: typeof accessorType
    }
}

永続化ストアの作成

続いてstore/index.tsを以下のように作成。今回は基本的にはサブモジュールを使うので中身はからのままでOK. 書き方は基本的にVuexと同等。

import { getAccessorType } from 'typed-vuex'

import * as user from '@/store/user'

export const state = () => ({})
export const getters = {}
export const mutations = {}
export const actions = {}

export const accessorType = getAccessorType({
    state,
    getters,
    mutations,
    actions,
    modules: {
        user,
    },
})

まだ@/store/userを作ってないのでコンパイルエラーが出るが問題ない。続いてサブモジュールのstore/user.tsを作成。

import { mutationTree } from 'typed-vuex'

export type RootState = ReturnType<typeof state>
export type UserState = {
    id: string,
    name: string,
    token: string,
    pic: string,
}

export const state = () => ({
    id: "",
    name: "",
    token: "",
    pic: "",
    timestamp: 0,
})

export const mutations = mutationTree(state, {
    store(state, user: UserState) {
        state.id = user.id;
        state.name = user.name;
        state.token = user.token;
        state.pic = user.pic;
    },
    drop(state) {
        state.id = state.token = state.name = state.pic = "";
    },
    reflesh(state, token: string) {
        state.token = token;
        state.timestamp = new Date().getTime();
    },
    initialiseStore() {
        console.log('initialised user')
    },
})

これで永続化層はOK。

ログイン画面を作る

つづいてログイン画面の作成。といってもUIとしてはシンプルにログインボタンがあるだけです。TwitterでのログインとGoogleでのログインのそれぞれに対応します。IDプラットフォーム/FirebaseのAPIキーと認証ドメインは以下ではハードコーディングしてるけど、後で設定から読み込めるように修正。

pages/login.vue

<template>
  <div class="container">
    <div>
      <h1 class="title">Login Page</h1>
      <div>
        <button class="button--grey" @click="authTwitter">Twitterでログイン</button>
        <button class="button--grey" @click="authGoogle">Googleでログイン</button>
      </div>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from "vue";

import { Auth, Provider } from "@/services/auth";
export default Vue.extend({
  data() {
    return {};
  },
  async asyncData() {},
  methods: {
    async authGoogle() {
      const auth = new Auth(this.$accessor, { apiKey:"API KEY", authDomain:"xxxx.firebaseapp.com" });
      await auth.login(Provider.Google);
      this.$router.push("/");
    },
    async authTwitter() {
      const auth = new Auth(this.$accessor, { apiKey:"API KEY", authDomain:"xxxx.firebaseapp.com" });
      await auth.login(Provider.Twitter);
      this.$router.push("/");
    },
  },
});
</script>

<style>
.container {
  margin: 0 auto;
  min-height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  text-align: center;
}
</style>

Middlewareで認証機能を実装する

このままだと無条件に繊維出来てしまうのでページ遷移のタイミングでトークンの有無をチェックする認証機能を実装します。

Nuxt.jsではMiddlewareを使って認証機能を実装する。Vue Routerに直接書くよりも環境がしっかりしてて良い。
ただ、なぜJS界隈ではフィルタやインターセプターではなくMiddlewareと呼ぶのか。。。非常に紛らわし。閑話休題。

middleware/auth-filter.tsを以下のように作成する。

import { Context } from '@nuxt/types'

export default ({ redirect, app: { $accessor } }: Context) => {
    console.log("check token");
    if (!$accessor.user.token) {
        console.log("redirect to login");
        return redirect('/login');
    }
}

づづいてMiddlewareの利用を各コンポーネントで宣言します。pages/index.tsにmiddlewareを追加します。ついでにログインしたユーザ名を画面に表示するようにしてみます。。

<template>
  <div class="container">
    <div>
      <Logo />
      <h1 class="title">Hello, {{$accessor.user.name}}</h1>
    </div>
  </div>
</template>

<script lang="ts">
import Vue from "vue";

export default Vue.extend({
  middleware: ["auth-filter"],
});
</script>
...

この状態でhttp://localhost:3000/にアクセスするとログイン画面にリダイレクトされ、認証をするとHome画面に飛んだのが確認できるかと思います。

これで認証機能は作成完了です。

動的ルーティングとSSG

動的ルーティング

Nuxt.jsではVue.jsと違いpagesに記載した内容で自動的にルーティングが生成される。静的なルーティングは単に、URLに入れて置けばいいが、パスパラメータを使った動的ルーティングを使いたい場合はアンダースコアを付けてディレクトリやファイルを作成する。
https://ja.nuxtjs.org/docs/2.x/features/file-system-routing/#動的なルーティング

例えば/posts/{user}/{id}と言ったブログのような以下のような構造にする。

pages/
--| posts/
-----| _user/
---------| _id.vue

pages/posts/_user/_id.vueは以下の通り。

<template>
  <section>
    <h1>{{ message }}</h1>
    <p>{{ $route.params.user + ":" + $route.params.id }}</p>
  </section>
</template>
<script>
export default {
  data() {
    return {
      message: "",
    };
  },
  async asyncData({ params, $config, $axios }) {
    let data = await $axios.$get(
      `http://localhost:5000/${params.user}/${params.id}`
    );
    return { message: data.message };
  },
};
</script>

ページ生成時にAPIをコールしています。また、asyncDataで実行しているのでビルド時に実行する事が可能です。asyncData内では$route.paramsの代わりに引数でマップしたparamsを使ってルーティングパラメータにアクセスする必要があります。
検証用のダミーAPIサーバとして以下を立ち上げます。

$ httpd4test -p 5000 -m '{"message":"Welcome #{1}, document is #{2}", "title":"Welcome #{1}"}' --json --interval 50

これでhttp://localhost:3000/posts/koduki/123にアクセスすると以下のような画面になります。

SSG/静的サイトジェネレーション

元々Nuxt.jsを試している動機はJAMStackが構築したいためなので今回のPostのような機能はSSGを実施したいです。
静的ページの場合は特に何もしなくてもyarn generationをすればページが生成されますが、動的ルーティングの場合はnuxt.config.jsに以下のような改修を入れる必要があります。

  // Generate Dynamic Routing
  generate: {
    routes: ['/posts/koduki/1', '/posts/koduki/2', '/posts/misuzu/1']
  },

以下のコマンドでビルドを実行します。

$ yarn build && yarn generate
...
✔ Generated route "/"                        21:16:53
✔ Generated route "/login"                   21:16:54
✔ Generated route "/posts/koduki/1"          21:16:54
✔ Generated route "/posts/koduki/2"          21:16:54
✔ Generated route "/posts/misuzu/1"          21:16:54
✔ Client-side fallback created: 200.html     21:16:54
✔ Static manifest generated

中身を見てみるとAPIコールの結果も適切に静的ファイルとして含まれていることが確認できます。

$ tail -n4 dist/posts/koduki/1/index.html
  <body>
    <div data-server-rendered="true" id="__nuxt"><!----><div id="__layout"><div><section><h1>Welcome koduki, document is 1</h1> <p>koduki:1</p></section></div></div></div><script defer src="/_nuxt/static/1611033413/posts/koduki/1/state.js"></script><script src="/_nuxt/b233411.js" defer></script><script src="/_nuxt/2a54563.js" defer></script><script src="/_nuxt/0e3b885.js" defer></script><script src="/_nuxt/0f17050.js" defer></script><script src="/_nuxt/b65feba.js" defer></script>
  </body>
</html>

メタタグ/OGP対応

Nuxt.jsではVue Metaが取り込まれているため特に工夫しなくてもSEOのためのメタタグやTwitetrなどSNSへのOGP対応が出来ます。
https://ja.nuxtjs.org/docs/2.x/features/meta-tags-seo

グローバルなメタタグの設定

まずはサイト全体に関連するメタタグの記述をします。これはnuxt.config.jsに以下のように修正を入れます。

...
  head: {
    title: 'Nuxt Baseline',
    htmlAttrs: {
      lang: 'en'
    },
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: '' },
      { hid: 'og:site_name', property: 'og:site_name', content: 'サイト名' },
      { hid: 'og:type', property: 'og:type', content: 'website' },
      { hid: 'og:url', property: 'og:url', content: 'https://example.com' },
    ],
...

これで全体に対するメタタグは作成できました。SPAモードでなければHTMLにも反映されているのでバッチりですね。このあたりVueで直接やると面倒なので助かります。

ページ毎の動的なメタタグ

ブログなどの場合は動的ルーティングとAPIコールに元づいて動的なメタタグを作成してやる必要があります。特にOGPでは記事毎に書く内容も変わりますからね。

これはvueコンポーネントのheadメソッドに記載する事で対応できます。先ほどのposts/_user/_id.vueを以下のように改修します。

<template>
  <section>
    <h1>{{ message }}</h1>
    <p>{{ $route.params.user + ":" + $route.params.id }}</p>
  </section>
</template>

<script lang="ts">
import Vue from "vue";
import { MetaInfo } from "vue-meta";

export default Vue.extend({
  data() {
    return {
      message: "",
      title: "",
    };
  },
  head(): MetaInfo {
    return {
      title: this.title,
      meta: [
        {
          hid: "description",
          name: "description",
          content: "about " + this.message,
        },
      ],
    };
  },
  async asyncData({ params, $config, $axios }) {
    let data = await $axios.$get(
      `http://localhost:5000/${params.user}/${params.id}`
    );
    return { message: data.message, title: data.title };
  },
});
</script>

これで無事にAPIの結果を反映した個別ページ向けのメタタグも作成できました。これはビルド時に実行されるので、APIにアクセスできる状態でビルドする必要があることには注意をしてください。

設定ファイルと環境変数

ランタイムコンフィグレーション

先ほど簡略化のためにいくつかハードコーディングしてますが、APIキーなどはセキュリティや管理の観点からGitHubなどのソースコードには入れたくありません。また、URLなど環境によって変わる値も設定として外だししてソースコードへのハードコーディングは避けたいです。

そのためそういった値は環境変数で管理するのが一般的。Nuxt.jsでもprocess.env.XXXで環境変数を取り扱うことができます。加えてランタイムコンフィグレーションを使うことでConfigurationとしてラップする事が出来ます。nuxt.config.jsに設定値を集約する事が出来るし、複雑な処理も要れることができるので直接使うよりはRuntimeConfigを経由した方が便利そうです。

というわけでnuxt.config.jsに以下を追加。

  // Set Runtime Configuration: https://nuxtjs.org/docs/2.x/configuration-glossary/configuration-runtime-config
  publicRuntimeConfig: {
    baseURLAPI1: process.env.BASE_URL_API1,
    firebase: {
      apiKey: process.env.FIREBAE_AUTH_API_KEY,
      authDomain: process.env.FIREBAE_AUTH_DOMAIN
    }
  },

利用する場合はコードを以下のように修正。

  async asyncData({ params, $config, $axios }) {
    let data = await $axios.$get(
      `${$config.baseURLAPI1}/${params.user}/${params.id}`
    );
 const auth = new Auth(this.$accessor, this.$config.firebase);

これで環境変数BASE_URL_API1, FIREBAE_AUTH_API_KEY, FIREBAE_AUTH_DOMAINに定義した値を設定値として取り扱えます。

開発環境向けの.envファイル

ただ、これだけだと開発環境では毎回たくさんの環境変数を定義しないといけなくて不便ですね?
Nuxt.jsでは環境変数をファイルから読み込むため.envファイルを使えます。

プロジェクト直下に.envを下記のように作ります。

BASE_URL_API1="http://localhost:5000"
FIREBAE_AUTH_API_KEY=xxxx
FIREBAE_AUTH_DOMAIN=xxxx.firebaseapp.com

こちらに情報を入れる事でいちいちたくさんの環境変数を定義する必要がありません。ただし、gitに間違ってコミットしないのはもちろんだけど、クライアントサイドで使う値にパスワードなどの機密情報を入れないように注意。ビルド時に織り込まれちゃうので。。。

まとめ

Vue.jsで面倒だったところが整理されていてNuxt.jsは確かに良さそうなFWですね。TypeScript周りがちょっと整理が進んでない感じだけど、この辺はReact系のが強いらしい。

とりあえず、比較的よく使いそうな機能は触れたので今Vueで作ってるアプリケーションをNuxt.jsで書き直してようかな。

それではHappy Hacking!

Discussion