Help us understand the problem. What is going on with this article?

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

追記

この記事は公開から一ヶ月後くらいに、中身をだいぶ変えました。
前の記事が読みたい方はこちらからどうぞ。
今のコードの方が結構マシなはずなので、あまり需要は無いでしょうが...。

作っていきましょう

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

本記事は、私がサーバーサイド処理を組み込んだポートフォリオを作成し、それをネタにSI業界からWeb業界に逃げ込んだ際の知見をまとめたものです。

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

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

作ったもの

モバイル対応は、めんどくさくなったので結局やめました。
次作る時は、レスポンシブを最初から考慮してデザインしたいですね。

持たせた機能

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

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

使った技術

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

  • Firebase
    慣れ親しんだJavaでも良いのですが、Web系企業には刺さりそうにないですし、流石にクラウドに一度も触ったことがないというのはやばかろうと思い選択。
    AWSでも良いものの、セキュリティ周りの取り扱いをガバってしまい、パスワードとメールアドレスを流出する、なんてことは絶対に避けたかったため、そのあたりをうまいことやってくれるFirebaseを使うことにしました。
    自前で認証機能を持たなくて良い安心感は圧倒的です。
    RubyやPHPを覚えている余裕はなかったため、サーバーサイドのロジックを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』や、『Nuxt.jsビギナーズガイド』という本が発売されているようなので、そちらを参考にしてみても良いかと思います。

何でも良いのですが、「動いているプロダクトのコードが見られる」と「その解説」がセットになっているものを探すことをオススメします。

環境構築・マークアップ

Nuxtを使うべきか使わざるべきか

私の場合、恥ずかしながら寡聞であったため最初はバニラで作り、あとから必要性を感じてNuxtに載せ替えました。
結論としては、

  • ある程度ややこしいことになりそう(Vuexとか使うことになりそう)で、
  • Nuxtに載せられる

なら載せたほうが良いと感じました。
Nuxt自体はpages / middleware / pluginsなどの便利な機能や、サーバーサイドレンダリングを手軽に実装できる大変便利なフレームワークですが、それ以上に

これに乗っかって作ると、ド初心者でもある程度きれいに作れる

という点が大変魅力的です。
この点については、こちらの記事が大変勉強になりましたので、ご参照下さい。

「結局Nuxt.jsって何がいいの?」に対する回答

メインコンテンツの実装

ビュー

screencapture-localhost-8080-aboutMe-2018-08-23-14_39_13.png

個人情報や転職理由などの、メインコンテンツとなるページを実装します。

  • 私について
  • 転職の理由
  • こんな風に働きたい

これらのページは、渡されるJsonの中身を、カード↓
スクリーンショット 2018-08-25 22.01.23.png
に描画していく役割を持っています。

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

こんな感じに使います。

pages/aboutMe.vue
components/CardsFolder.vue
components/Cards.vue

ご覧の通り、VuexのStoreからデータを持ってきています。
今回は他にも権限情報などを全部Storeに突っ込んでおり、DBとの通信のロジックもStoreに集めました。
ビューに持たせるよりは良いと思います。

json(カード情報)に関するStoreはこちら:

store/modules/json-data.js
import { fireFunc } from '~/plugins/firebase-setting';

import personalPublic from '~/assets/jsons/personal-public.json';
import quittingPublic from '~/assets/jsons/quitting-public.json';
import desirePublic from '~/assets/jsons/desire-public.json';

export default {
  namespaced: true,
  state: {
    publicJson: {
      personal: personalPublic,
      quitting: quittingPublic,
      desire: desirePublic,
    },
    privateJson: null,
  },
  getters: {
    json: state => {
      return state.privateJson ? state.privateJson : state.publicJson;
    },
  },
  mutations: {
    setPrivateJson(state, val) {
      state.privateJson = val;
    },
  },
  actions: {
    async fetchCardsDataFromRemote({ state, commit }) {
      if (state.privateJson) return;

      const cardsData = await fetchCardsDataFromRemote();
      commit('setPrivateJson', cardsData);
    },
  },
};

const fetchCardsDataFromRemote = async () => {
  const fetchFunc = fireFunc.httpsCallable('fetchCardsData');

  return (await fetchFunc()).data;
};

actionが叩かれるとCloud Functionsにリクエストが飛び、許可されたユーザーであれば、個人情報もろもろが書かれたjsonをCloud Storageから飛ばして入れ替えるようになっています。

■■■■■■■■■■■■■ここイラスト

Cloud Storage

スクリーンショット 2018-10-29.png

Cloud Function

CF側の実装はこんな感じです。
ユーザーの権限を見に行って、大丈夫そうならCloud Storageから取り出しているだけです。
権限周りの処理は、セキュリティルールでも書けますが、ややこしくなりそうな場合はCFを噛ませた方が楽です。

functions/src/index.ts
// cache the result
let jsons;
exports.fetchCardsData = functions.https.onCall(async (data, context) => {
  const uid: string = (context.auth) ? context.auth.uid : null;
  const user = await User.create(firestore, uid);

  if (!user.getIsUserAuthorized()) {
    throw new functions.https.HttpsError('failed-precondition');
  }

  try {
    if (!jsons) {
      jsons = await Promise.all([
        downloadJson('personal-private.json'),
        downloadJson('quitting-private.json'),
        downloadJson('desire-private.json')
      ]);
    }

    return {
      personal: jsons[0],
      quitting: jsons[1],
      desire: jsons[2]
    };
  } catch (err) {
    console.error(`error@fetchCardsData: uid = ${context.auth.uid}, error: ${err}`);

    throw err;
  }
});

/**
 * download a json file from cloud storage, then return the content of it.
 * a downloaded file will be automatically removed by cloud function.
 * 
 * @param target name of the file u wanna download from the default bucket
 * @return an Array, or an Object parsed from the json file
 */
async function downloadJson(target): Promise<any> {
  const randomFileName = crypto.randomBytes(20).toString('hex') + path.extname(target);
  const tempFilePath = path.join(os.tmpdir(), randomFileName);

  await storage.bucket().file(target).download({
    destination: tempFilePath
  });

  const fileContent = fs.readFileSync(tempFilePath, { encoding: 'utf-8' });
  const obj = JSON.parse(fileContent);

  return obj;
}

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

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

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

まずはモーダルを作る

ログインはモーダルを使用するとかっこ良さそうです。
色々試してみた結果、モーダルは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>

BaseContactの役割はユーザーの権限状況を見て、子コンポーネントの表示を切り替えることです。

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, mapGetters } from 'vuex';

export default {
  name: 'BaseContact',
  components: {
    LoginContact,
    RegisterContact,
    CompleteContact,
  },
  computed: {
    ...mapState('authState', ['isUserLoggedIn']),
    ...mapGetters('authState', ['isUserRegistered']),
    step() {
      if (!this.isUserLoggedIn) {
        return 'login';
      }

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

      return 'complete';
    },
  },
};
</script>

この2つの内側に、例えばLoginContact.vueなど、ログイン用のコンテンツなどを表示していきます。

ユーザー権限の状態の取得

そもそもユーザー認証機能は、コンタクトを下さった企業様のうち、こちらからもお会いしたい企業様のみに個人情報を開示するために作成したのでした。
となると、ユーザー権限の取得にはリアルタイム性が必要な(許可したらすぐに画面に反映して欲しい)ため、Vuexfireを使用して、FirestoreとVuexのStoreを直接つなげます。2

store/modules/auth-state.js
import { firestore } from '~/plugins/firebase-setting';
import { firebaseAction } from 'vuexfire';

export default {
  namespaced: true,
  state: {
    isUserLoggedIn: false,
    dbState: null,
  },
  getters: {
    isUserRegistered: state => {
      return state.dbState != null;
    },
    isUserAuthorized: state => {
      return state.dbState ? state.dbState.isAuthorized : false;
    },
    hasUserReserved: state => {
      return state.dbState ? state.dbState.isReserved : false;
    },
  },
  mutations: {
    setLoginState(state, val) {
      state.isUserLoggedIn = val;
    },
    setDbState(state, val) {
      state.dbState = val;
    },
  },
  actions: {
    BIND_USER_STATE: firebaseAction( // <- ココだよ!
      async ({ bindFirebaseRef, getters, dispatch }, payload) => {
        await bindFirebaseRef(
          'dbState',
          firestore.collection('user').doc(payload.uid)
        );

        if (getters.isUserAuthorized) {
          dispatch('jsonData/fetchCardsDataFromRemote', null, { root: true });
        }
      }
    ),
    UNBIND_USER_STATE: firebaseAction(({ unbindFirebaseRef }) => {
      unbindFirebaseRef('dbState');
    }),
    resetAllState({ commit }) {
      commit('setDbState', null);
    },
  },
};

firebase authの状態を監視しておいて、ログインしたらbindFirebaseRefを使用してバインドを試みています。

plugins/firebase-auth-wather.js
import { fireAuth } from '~/plugins/firebase-setting';

export default ({ store }) => {
  fireAuth.onAuthStateChanged(async user => {
    if (!user) {
      store.commit('authState/setLoginState', false);
      store.commit('setGithubId', null);

      store.dispatch('authState/UNBIND_USER_STATE');
      store.dispatch('authState/resetAllState');
      return;
    }

    store.commit('authState/setLoginState', true);
    store.commit('setGithubId', user.providerData[0].uid);

    const uid = user.uid;
    store.dispatch('authState/BIND_USER_STATE', { uid: uid }); // <- バインドしてます
  });
};

もちろん、Firestoreのセキュリティルールを設定することも忘れずに。

firestore.rules
service cloud.firestore {
  match /databases/{database}/documents {
    match /user/{uid} {
      allow get: if request.auth.uid == uid;
    }
  }
}

ユーザー登録実装

リアルタイム性は必要ありませんので、CFに登録用のファンクションを用意してこいつを叩きます。
特に芸があることはしていないので、ソースを読んで下さい。

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

企業様からコンタクトを頂いたときと、こちらからぜひともお会いしたいと思ってisAuthorizedフラグを立てたときの2回、通知用のメールを送っています。
nodemailerを使用してふつうに送ります。

functions/src/index.ts
exports.sendRegisterConfirmation = functions.firestore
  .document('user/{newUserUid}')
  .onCreate((snapshot, context) => {
    const data = snapshot.data()
    const targetEmailAddress = data.mailAddress;
    const companyName = data.companyName;
    const pic = data.personInCharge;

    const mailOptions = {
      to: targetEmailAddress,
      from: '"sosmii@自動送信"',
      subject: 'hello-sosmii お問い合わせありがとうございました',
      text:
        `
          ${companyName} ${pic} 様

          https://hello-sosmii.firebaseapp.com/ にて、お問い合わせを頂きありがとうございました!
          このメールは登録されたメールアドレスが有効であるかどうかを確認するためのものです。

          頂いたリクエストには全てsosmii本人が目を通し、ぜひお会いしたい企業様に下記の権限を付与しております。
          ・全ての個人情報へのアクセス
          ・おおっぴらには言いにくい本音へのアクセス
          ・面談予約

          付与された際には、こちらのアドレスにその旨を記載したメールを送信致します。
          お会いできる日を楽しみにしております。


          sosmii
        `
    };

    return mailTransport.sendMail(mailOptions);
  });

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

ElementUIのDatePickerを使用しました。
こんなこと

2日連続でアポを入れると疲れてしまうので、外出しなければならないアポは1日おきにするのと、準備の時間を整えるためにSkypeも1日に2件までにしてあります。

を実現するためのロジックを実装したせいで、無駄に長くなってしまっていますが、
CFからは既に埋まってしまった日付を連絡するために、ElementUI用にデータを加工して送っているだけです。

functions/src/Appointment.ts
components/modals/appointment/MakeAppointment.vue

駆け足になりましたが、以上で大体の機能の解説はおしまいです。

さいごに

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

決まりました!
転職の時に使用したコードはそれなりにひどいものだったのですが、反してウケは上々でした。
ポテンシャルがあるとみなされたのか、なんでもやってみる精神性が評価されたのか、それとも鋼のメンタルが良かったのか...。

次は転職のときの顛末ポエムでも、Qiita以外のどこかに書こうと考えています。


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

  2. こうしないとSPAなのにリロードが必要とかいう意味分からんことになります。なってました。 

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

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

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away