vue.js
転職
転職活動
Firebase
ポートフォリオ

Vue.js + Firebaseでポートフォリオを作ろう!

作っていきましょう

突然ながらエンジニアのみなさま、ご自身の実力を市場にアピールできるような成果物をお持ちでしょうか?
会社が信用できなくなった時の備えとして、良いチャンスを掴むためのきっかけとして、外から見える成果というのは用意しておいて損はありません!

本記事は、私がサーバーサイド処理を組み込んだポートフォリオを作成した際の知見をまとめたものになります。

主に普段サーバーサイドエンジニアをやっていて、アピールするものが無いからなんか作らなと思ってはいるものの、Webサービスとポートフォリオ別々に作らなきゃいけないの?めんどくない?なんて思っちゃったりしている方向けの記事になります。

既にWebサービスをお持ちの方はお帰り下さい。
そんなすごい人は適当なpdfファイルでも上げておけば良いんです。

作ったもの

ブツ: https://hello-sosmii.firebaseapp.com
コード: https://github.com/sosmii/hello-sosmii
(スマホ / レスポンシブ対応はまだです。デバイス毎のアクセス比率等、重要性は知っているのですが、優先度の関係でどうしても後回しに...)

持たせた機能

  • 許可制による情報の出し分け機能
    インターネッツの大海に個人情報や写真など、ヤバげな情報を垂れ流さないために実装します。
    会いたい企業様にのみ、特別なJsonをCloud Storageから取り出して送りつけ、クライアントで描画します。

  • 面談の予約機能
    いいじゃんと思ったら、次は会う日を決めますよね?
    googleカレンダーの共有機能、Slack、twitterのDMなどなど、正直便利なツールは他に山程あるわけですが、それらの真似くらいは出来ますということを示すために作ります。

使った技術

  • Vue.js
    前職でAngularを使用していてSPAに馴染みがあったこと、今回ターゲットとする小規模企業に刺さりそうな技術であること、そして何より、@becolomochi様の『Vue.jsでポートフォリオサイト制作記』に刺激を受けたため使います。

  • Firebase
    慣れ親しんだJavaでも良いのですが、Web系企業には刺さりそうにないですし、流石にクラウドに一度も触ったことがないというのはやばかろうと思い選択。
    AWSでも良いものの、セキュリティ周りの取り扱いをガバってしまい、パスワードとメールアドレスを流出する、なんてことは絶対に避けたかったため、そのあたりをうまいことやってくれるFirebaseを使うことにしました。
    自前で認証機能を持たなくて良い安心感は圧倒的です。
    Rubyを覚えている余裕はなかったため、サーバーサイドのロジックをJavaScript/TypeScriptで書けるというのも大きかったですね。

レイアウト紹介

完成形はこのようになります。

  • トップページ
    screencapture-localhost-8080-2018-08-23-14_38_53.png
    各ボタンをクリックすると、トップページの下にコンテンツが差し込まれ、スクロールします。
    screencapture-localhost-8080-aboutMe-2018-08-23-14_39_13.png

  • ログインモーダル
    スクリーンショット 2018-08-23 14.42.40.png
    スクリーンショット 2018-08-23 14.45.13.png
    スクリーンショット 2018-08-23 14.45.50.png
    スクリーンショット 2018-08-23 14.46.35.png
    ここまで来るとColud FunctionsがNodemailerを使ってメールを送ってきます。

  • コンタクトモーダル
    面談の日程を合わせるためのコンポーネントです。
    下の画像のように、私の側でええやんと思った企業様にisAuthorizedフラグを立てると、アクセスできるようになっています。
    スクリーンショット 2018-08-23 15.01.19.png
    コンタクトボタンがちょっと変わります。
    screencapture-localhost-8080-2018-08-23-14_48_10.png
    スクリーンショット 2018-08-23 14.49.15.png
    2日連続でアポを入れると疲れてしまうので、外出しなければならないアポは1日おきにするのと、準備の時間を整えるためにSkypeも1日に2件までにしてあります。
    もちろんCloud Functionsで制御します。
    スクリーンショット 2018-08-23 14.49.32.png
    大体こんな感じでアポが取れるようになっています。

事前準備

お手本の収集

TODOアプリ以上のコードをかき集めます。
Vueが普段どのように使われているか目にする機会は少なくなりがちですので、実際に動いているプロダクトのコードを頑張って探しましょう。

(私はnote時代に購入しました。多分中身は同じはずです。)

前者はVueの基本的な部分について非常に分かりやすく、後者はVueとFirebaseの連携についてよく解説されています。
ただし、Cloud Functionsについては触れられていませんので、そこは自力でAPI Referenceを読んで乗り切りましょう。
どちらも内容からは考えられぬ格安価格でした。ありがたや。1

当時はありませんでしたが、今なら「基礎から学ぶ Vue.js」という本が発売されているようなので、そちらを参考にしてみても良いかと思います。
何でも良いのですが、「動いているプロダクトのコードが見られる」と「その解説」がセットになっているものを探すことをオススメします。

環境構築・マークアップ

きほんは省略

先程挙げた参考文献を元に構築していますので、そちらを参考にして下さい。
入り口の情報はそこら中に溢れていますので、環境構築部分の詳細な解説はいたしません。

このセクションに該当するコミットは741231952df6b82334f5eです。
git logで確認し、該当のコミットをチェックアウトしたりdiffで見てみて下さい。

今回はinitで大量のファイルが生まれてしまっているのでアレですが、このようにcompareを使用して比較しても良いかもしれません。

今回流したコマンドは:

npm i -g vue-cli
npm i -g firebase-tools
firebase login

vue init webpack
firebase init

Cloud Firestoreのセキュリティ設定

解説しないとは言いつつも、ここだけは非常に重要なので簡単に。

Firebaseでは、セキュリティルールというものを設定することで、サーバーサイドの認証処理を書かなくても、「自分のユーザーに紐づくデータのみ取得出来る」というような機能を簡単に実装することが出来ます。

開発時には利便性のため、全ユーザーが全データを取得可能、というような設定にすると思いますが、ここを変えるのを忘れると大惨事になるため気をつけましょう。

今回はFirestoreのデータは、全てCloud Functionsを介して読み込むため、全部非許可にして構いません。
screencapture-console-firebase-google-u-0-project-hello-sosmii-database-firestore-rules-2018-08-21-09_03_45.png

カードフォルダ実装

screencapture-localhost-8080-aboutMe-2018-08-23-14_39_13.png
該当コミットは1d8b643cec39a3a14a5e7166cc5fb76f2a545408a8です。

カードフォルダはメインコンテンツとなるコンポーネントです。
ルートから渡されるJsonの中身を、カード↓
スクリーンショット 2018-08-25 22.01.23.png
に描画していく役割を持っています。

任意にインデントやモザイクをかけたり出来る必要があるため、カードの中身はv-htmlで描画される必要がありますね。

基本形はこんな感じです。

CardsFolder.vue
<template>
  <div class="cardsFolder">
    <h1 id="page-title">{{ pageTitle }}</h1>
    <div
      class="card"
      v-for="card in cards"
      :key="card.displayOrder"
    >
      <div class="card__title">{{ card.title }}</div>
      <div class="card__body" v-html="card.body"></div>
      <div class="card__expand-fake-button"></div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CardsFolder',
  data () {
    return {
      pageTitle: 'aaa',
      cards: [{
        title: 'bbb',
        body: '<span style="color:red">ccc</span>',
        displayOrder: 1
      }]
    }
  }
}
</script>

これ↑を、Jsonをパースしてその中身を表示するように変更します

Jsonの取得自体はCardsFolderインスタンスの生成を待つ必要がありませんので、Appがロードされ次第読み込み始め、それをCardsFolderコンポーネントに渡します。

App.vue
<template>
  <div id="app">
    <Header/>
    <FixedTop/>
    <router-view :cardsFolder="cardsFolder"/> <!-- ←ここで渡してます -->
  </div>
</template>

<script>
import 'normalize.css'
import Header from '@/components/Header'
import FixedTop from '@/components/FixedTop'
import * as personalJson from '@/assets/personal-public.json'
import * as quittingJson from '@/assets/quitting-public.json'
import * as desireJson from '@/assets/desire-public.json'

export default {
  name: 'App',
  components: {
    Header,
    FixedTop
  },
  data () {
    return {
      cardsFolder: null
    }
  },
  mounted () {
    this.cardsFolder = {
      personal: personalJson,
      quitting: quittingJson,
      desire: desireJson
    }
  }
}
</script>
CardsFolder.vue
<template>
  <div class="cardsFolder">
    <h1 id="page-title">{{ pageTitle }}</h1>
    <div
      class="card"
      v-for="card in cards"
      :key="card.displayOrder"
    >
      <div class="card__title">{{ card.title }}</div>
      <div class="card__body" v-html="card.body"></div>
      <div class="card__expand-fake-button"></div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CardsFolder',
  props: [ 'cardsFolder' ], // ←ここで受け取ってます
  computed: {
    routeName () {
      return this.$route.name
    },
    pageTitle () {
      if (!this.cardsFolder) {
        return null
      }

      switch (this.routeName) { // ←ここと
        case 'AboutMe':
          return this.cardsFolder.personal.pageTitle
        case 'Quitting':
          return this.cardsFolder.quitting.pageTitle
        case 'Desire':
          return this.cardsFolder.desire.pageTitle
      }
    },
    cards () {
      if (!this.cardsFolder) {
        return null
      }

      switch (this.routeName) { // ←ここで取り出してます
        case 'AboutMe':
          return this.cardsFolder.personal.cardsData
        case 'Quitting':
          return this.cardsFolder.quitting.cardsData
        case 'Desire':
          return this.cardsFolder.desire.cardsData
      }
    }
  }
}
</script>

「私について」、「転職の理由」、「こんな風に働きたい」はページの構造が同じですので、CardsFolderのインスタンスは破棄せずに使いまわしています。2

URLが変わったら取り出し先を変えて中身を入れ替えているだけですね。

URLを見て背景色などのスタイルを動的に変えたりカードをクリックで展開可能にしたり読み込み/切り替えでタイトルまでスクロールしたりするようにすれば、権限機能やモーダルの実装を待つことなく、ほぼ完成に近づきました。

ユーザーオーソリゼーション実装

Cloud Functionsを使うお話です。
権限に応じてJsonをCloud Storageから取り出したり、アポの取れる/取れないを切り替えたりします。

  • GithubAuthを使用して、Github ID・メールアドレスを取得し、登録時に保存する。
  • 登録されたユーザー一人ひとりに対して、許可/拒否を設定する。
  • 許可されたユーザーには個人情報など、モザイクをかけた情報へのアクセスを許可する。
  • 許可されたユーザーには面談機能へのアクセスを許可する。

やりたいのはこんな感じのことです。

まずはモーダルを作る

該当するコミットは3add2c61071b2bです。

ログインはモーダルを使用するとかっこ良さそうです。
ログイン用のモーダルはヘッダーから起動するので、まずはヘッダーを綺麗にします

[ここスクショ]
きれいになりました。

次はモーダルを作ります

色々試してみた結果、モーダルは3段構えにすることにしました。3

modal-kaisetsu.png

こんな感じにヘッダーに埋め込んで使います。

Header.vue
<template>
  <header class="header">
    <!-- (略...) -->
    <BaseModal v-if="loginModalState" @close="closeContactModal()">
      <BaseContact/>
    </BaseModal>
  </header>
</template>

BaseModalの役割はスタイル(見た目)の提供と、モーダル外(外側の灰色の部分)をクリックした時にcloseイベントをエミットするくらいです。
モーダルひとつひとつに書いていたら面倒な部分の共通化ですね。

BaseModal.vue
<template>
  <transition name="modal">
    <div class="modal-overlay" @click.self="$emit('close')">
      <div class="modal-window">
        <div class="modal-content">
          <slot/> <!-- ←スロットに子コンポーネントが入ります -->
        </div>
      </div>
    </div>
  </transition>
</template>

<script>
export default {
  name: 'BaseModal'
}
</script>

BaseContactの役割はユーザーの権限状況を見て、子コンポーネントの表示を切り替えることです。
今はとりあえず表示だけしたいので、その部分のロジックは後回しにしてガワだけ作ります。

BaseContact.vue
<template>
  <div class="base-contact-modal">
    <LoginContact v-if="step === 'login'"/>
  </div>
</template>

<script>
import LoginContact from '@/components/modals/contact/LoginContact'

export default {
  name: 'BaseContact',
  components: {
    LoginContact
  },
  data () {
    return {
      step: 'login'
    }
  }
}
</script>

LoginContactの役割は、GithubAuthProviderを使用して認証情報を取ってくることです。
今はとりあえず表示だけしたいので、その部分の解説は後ほどします。
何も考えずに表示したい内容をマークアップしていけばOKです。

LoginContact.vue
<template>
  <div class="modal-content-wrapper">
    <header class="login-modal__header">GitHub認証による登録</header>
    <div class="login-modal__body">
      <div class="login-icon">
        <img class="github-icon" @click="githubSignIn(); showLoadingIcon()" src="@/assets/GitHub-Mark-64px.png" v-if="!whileLoading">
        <div class="loading-icon-wrapper" v-if="whileLoading">
          <LineSpinFadeLoader/>
        </div>
      </div>
      <div class="login-annotation">
        当プロジェクトでは以下の情報を取得します。
        <ul>
          <li style="margin-bottom: 0.5em;">GitHub UUID</li>
          <li style="margin-bottom: 1em;">GitHubに紐づくメールアドレス</li>
            Cloud Functionsを通じて確認・日程調整用メールを送信するために使用します。<br>
            取得したアドレスは、上記の目的以外には使用されませんが、<b>データベースに保存されます</b>ことを、お含みおき下さい。
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
/* global firebase:true */

export default {
  name: 'LoginContact',
  data () {
    return {
      whileLoading: false
    }
  },
  methods: {
    async githubSignIn () {
      await firebase.auth().signInWithPopup(new firebase.auth.GithubAuthProvider())
    },
    showLoadingIcon () {
      this.whileLoading = true
    }
  }
}
</script>

以上でGithubアイコンがあるあのモーダルが表示されます。
ただ、このままではログインしても何も切り替わりませんので、Authの状態を見てモーダルを切り替える必要があります。

Authの状態の取得

Authの状態やユーザー権限は、子コンポーネントの側から更新をかけることもあるため、parent地獄やemit地獄を避けるためにVuexを導入します

正直、この段階だとVuexが要るかどうかなんて分からないので、まずは愚直に書いても良いと思います。

AppにAuthのウォッチャーを立て、状態をVuexのStoreに保存させます。

App.vue
<script>
export default {
  // 略...
  mounted () {
    // 略...

    firebase.auth().onAuthStateChanged(async user => {
      if (!user) {
        this.$store.commit('updateLoginState', false)
        this.$store.commit('updateRegisterState', false)

        return
      }

      this.$store.commit('updateLoginState', true)
      this.$store.commit('updateRegisterState', false)
    })
  }
}
</script>

Authの状態監視は、onAuthStateChanged()を使うほかありません。

githubログインされていないようならisUserLoggedIn = falseになり、
githubログインされ次第isUserLoggedIn = trueになります。

Vuexのステート(変数)はミューテーションを介してしか変更できないので、上記のような書き方になりますね。

モーダルの切り替え

先ほど示したこの図↓の、振り分けを担当するBaseContactを作り込みます。

modal-kaisetsu.png

先程Authのウォッチャーを立てましたので、github認証済みでないならLoginContactを、認証済みならRegisterContactを表示するようにしましょう

BaseContact.vue
<template>
  <div class="base-contact-modal">
    <LoginContact v-if="step === 'login'"/>
    <RegisterContact v-if="step === 'register'"/>
    <CompleteContact v-if="step === 'complete'"/>
  </div>
</template>

<script>
import LoginContact from '@/components/modals/contact/LoginContact'
import RegisterContact from '@/components/modals/contact/RegisterContact'
import CompleteContact from '@/components/modals/contact/CompleteContact'
import { mapState } from 'vuex'

export default {
  name: 'BaseContact',
  components: {
    LoginContact,
    RegisterContact,
    CompleteContact
  },
  computed: {
    ...mapState([ // ←ここでStoreから変数を抜いてきてます
      'isUserLoggedIn',
      'isUserRegistered'
    ]),
    step () {
      if (!this.isUserLoggedIn) {
        return 'login'
      }

      if (this.isUserLoggedIn && !this.isUserRegistered) {
        return 'register'
      }

      return 'complete'
    }
  }
}
</script>

mapStateは算出プロパティにいちいち登録するのを軽減してくれる便利なやつです。
this.$store.state.isUserLoggedInでも抜いてこれますが、流石に長いのと、算出プロパティじゃないとステートに変更があった際に更新されなかったりします。

ここまでで、ログインモーダルは問題なく切り替わるようになりました。

ただ、現時点ではRegisterContactの封筒アイコン(登録ボタン)を押してもただ完了画面に切り替わるだけですので、次のセクションで、サーバー通信してFirestore(DB)にユーザー情報を登録できるようにしましょう。

ユーザー登録実装

CFを使う前に初期設定をします

CF内ではAdmin SDKを使用してFirebaseの各機能にアクセスしていきます。
セキュリティルールで制限されていても、全ての機能に問題なくアクセスできるため、クライアント側では決して使わないようにしましょう。

index.ts
import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';

admin.initializeApp();
const firestore = admin.firestore();
firestore.settings({ timestampsInSnapshots: true });

ユーザー情報は権限を読んだり登録したりしますので、index.tsに処理をべた書きにせず、ユーザー情報を取り扱ってくれるクラスをこしらえると都合が良さそうです。
作ります

変更点が多いので、順に解説していきます。

まず、ユーザークラスのインスタンスを生成する時に、インスタンス変数として内部に権限情報などを読み込んでおいて欲しいため、インスタンス生成前にDBにアクセスする必要があります。
よって、今回はファクトリーメソッドみたいな感じにします。4

こんな感じに使えます。

index.ts
const user = await User.create(firestore, uid);
User.ts
/**
 * a class which handles user states by accessing firestore.
 */
export default class User {

  private _firestore: any;
  private _uid: string;
  private _isUserAlreadySignedUp: boolean;
  private _isUserAuthorized: boolean;
  private _hasUserMadeAppointment: boolean;
  private _companyName: string;

  private constructor(firestore: any, uid: string) {
    this._firestore = firestore;
    this._uid = uid;
  }

  /**
   * factory method.
   * 
   * @param firestore an "authorized" instance of Firestore
   * @param uid UUID of the user
   * @return an instance of the class User
   */
  public static async create(firestore: any, uid: string): Promise<User> {
    const instance = new User(firestore, uid);
    await instance.initializeWithDbValue();

    return instance;
  }

  /**
   * retrieve data from firestore and initialize instance varibales with it.
   */
  private async initializeWithDbValue(): Promise<void> {
    if (!this._uid) {
      this._isUserAlreadySignedUp = false;
      this._isUserAuthorized = false;
      this._hasUserMadeAppointment = false;

      return;
    }

    // ドキュメント名(RDBで言うところのレコードのPKeyみたいなやつ)は、ユーザーのUUIDにするので、
    // こんな感じで引いてきます
    const snapshot = await this._firestore.collection('user').doc(this._uid).get();

    // 該当するドキュメントがない場合、結果はnullにならず、DocumentSnapshotは必ず返ってきます。
    // DocumentSnapshot.exists()で結果の有無を確認しましょう。
    if (!snapshot.exists) {
      this._isUserAlreadySignedUp = false;
      this._isUserAuthorized = false;
      this._hasUserMadeAppointment = false;

      return;
    }

    this._isUserAlreadySignedUp = true;
    this._isUserAuthorized = snapshot.data().isAuthorized;
    this._hasUserMadeAppointment = snapshot.data().isReserved;
    this._companyName = snapshot.data().companyName;
  }
}

次に登録機能を作ります

ドキュメントのセットのサーバーサイドに関しては特に解説することはありませんが、なりすまし防止のためにGithubのUUIDを控えています。
捨て垢作って、他社の名前を騙ってコンタクトされるのは怖すぎます。

const githubApiUrl = `https://api.github.com/user/${userInput.githubId}`;

クライアント側から、登録した関数を呼び出して通信します。

RegisterContact.vue
<script>
export default {
  // 略...
  methods: {
    async sendRegister () {
      // ここ↓の名称(register)は、index.tsでexportした名前と対応しています
      const registerFunc = firebase.functions().httpsCallable('register')
      await registerFunc({
        pic: this.pic,
        companyName: this.companyName,
        companyHp: this.companyHp,
        message: this.message,
        githubId: this.githubId
      })

      this.$store.commit('updateRegisterState', true)
    }
  }
}
</script>

今回の肝はユーザークラスを作って、そちらにDBとのやり取りをやってもらったことでした。
index.tsはできる限り見やすく保ったほうが良いと思います。

ファイル分割とかも出来るのでしょうが、今回はそれが必要な規模ではありませんでした。

ユーザー権限取得の実装

CFから権限受け取って、アポが取れるように切り替えたりする話です
先走って導入していたVuexの本領発揮です

毎日更新していた頃とは事情が変わったので、9月末まで寝かせます。
まったり書きますので、焦らずにお待ち下さい(必ず仕上げます)
続きが気になる方は、ストックに入れておくと、更新がかかり次第通知が行くと思います

ユーザー権限による分岐

確認メール送信機能の実装

アポイントメント機能実装

インタラクション周りの改善

課題

Cold Start

エラーハンドリング

フォーマッター

さいごに

sosmiiは若手エンジニアを求めている、小規模企業様を募集しています!
Vue.js・Firebaseに限らずイチから独学できる、拙いなりにもこのくらいのコードが書ける、売り込みの方法を考案できる5、くらいがやれることです。6
連絡お待ちしております!


  1. Qiitaって宣伝っぽいことダメなんでしたっけ?まずいなら消します。 

  2. そのため、動きを付けるときにちょっと面倒なこともしています。カードの展開/縮小を配列で管理しているのもそれが原因ですね。 

  3. ググってもv-ifで切り替える以外の方法が出てこなかったので、ベストプラクティスみたいなやり方があったら教えて下さい🙇 

  4. 本来の使い方は、サブクラスの生成の切り替えなんかを、呼び出す時に意識しなくて済む、みたいな風に使うようです。今回みたいなケースではなんと呼ぶのが適切なんでしょうか? 

  5. Qiita転職けっこう有効だと思うんですけど、これでコケたら笑って下さい😇 

  6. 忘れがちだけどJavaも一応書けました。