みんなの「作ってみた」

Nuxt.js+Firebaseのサーバーレス開発 axiosでのDB操作

2019/09/09

machio77777
machio77777
横浜在住の30代 Software Engineer。AWS上でのLAMP(PHP+vue/react)開発やJava言語での業務系システム開発に従事しています / 興味のあること → web / ブロックチェーン / セキュリティー / テクノロジーの活用で世の中を良くすること / 古い記事はあまり参考にしないで下さいmm

最近サーバーレス技術で話題のFirebaseに触れてみました。

一口にFirebaseと言っても色んな機能がありますが、今回はNuxt.js(axios利用)からFirebaseへの簡易CRUD操作を行い、簡易Webシステムのgithub-pagesデプロイ迄やってみたので、作業メモを投稿します。

ちなみにNuxt.js開発ではNuxt.jsビギナーズガイドを参考にしています。

今回作ったもの

私は福岡出身なのですが、将来的に福岡にUターンしたい思いが強いので、福岡に本社・支社を持つIT企業の下調べがてら企業情報をメモれるWebサイトを作ってみました(福岡に限らず登録出来ますが)

福岡に本社or支社を持つIT企業

フロントエンドはNuxt.jsでFirebaseのRealtime Databaseにデータ連携させています。

左側に登録済みの会社一覧を表示させ、リンク(会社名)押下で右側に詳細情報を表示。


新規登録と編集で会社情報の登録と更新を実行。


3日くらいの突貫で開発したので色々イケてない部分も多いですが...

Nuxt.js環境構築

Nuxt.jsプロジェクトを初期化して、必要なモジュールのインストール。

yarn create nuxt-app
yarn add @nuxtjs/axios
yarn add @nuxtjs/proxy

nuxt.config.jsに以下を追記。

modules: [
  '@nuxtjs/axios',
  '@nuxtjs/proxy'
],

axios: {
  // 自分のfirebaseアカウントを設定
  baseURL: 'https://nuxt-blog-service-xxxxx.firebaseio.com'
}

本当は認証とかまで実装したかったのですが、今回はCRUD操作だけなので設定はこれだけ。

レイアウト設定

ヘッダーとフッターのみコンポーネントを自作して呼び出し。

app/layouts/defalt.vue
<template>
  <div class="container">
    <TheHeader />
    <nuxt />
  </div>
</template>

<script>
import TheHeader from '~/components/common/TheHeader.vue'
import TheFooter from '~/components/common/TheFooter.vue'
export default {
  components: {
    TheHeader,
    TheFooter
  }
}
</script>

<style scoped>
.container {
  margin: 0 auto;
  max-width: 1300px;
}
</style>

一覧表示

初期表示時にFirebaseから会社情報を取得後、個別の会社情報リンク先を押下すると、vuexで管理しているデータを表示させています。

app/pages/index.vue
<template>
  <div class="main-container">
    <SearchArea />
    <div v-show="loading" class="loader"></div>
    <div v-show="!loading" class="item">
      <CompanyList />
      <CompanyDetail />
    </div>
  </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
import SearchArea from '~/components/pages/SearchArea.vue'
import CompanyList from '~/components/pages/CompanyList.vue'
import CompanyDetail from '~/components/pages/CompanyDetail.vue'
export default {
  components: {
    SearchArea,
    CompanyList,
    CompanyDetail
  },
  computed: {
    ...mapGetters({'loading' : 'loading'})
  }
}
</script>

<style scoped>
.main-container {
  padding: 30px 70px;
  min-height: 100vh;
}
.item {
  display: flex;
}
@media (max-width: 480px){
  .main-container {
    padding: 5px 10px;
  }
  #list {
    display: none;
  }
}
</style>

画面左側のリスト表示ロジック

app/components/pages/CompanyList.vue
<template>
  <div id="list" class="item-contents">
    <div v-for="(company, index) in companys" :key="company.id">
      <div class="list-contents">
        <div class="editCompany button" @click="editData(index)">編 集</div>
        <div class="button" @click="deleteData(index, company.name)">削 除</div>
        <div class="company-link" @click="detailData(index)">{{ company.name }}</div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
export default {
  mounted() {
    this.init()
  },
  computed: {
    ...mapGetters({'companys' : 'companys', 'company' : 'company'})
  },
  methods: {
    async init() {
      await this.fetchCompanys()
      this.clearCompany()
    },
    async detailData(index) {
      this.fetchCompany({ index : index })
    },
    async editData(index) {
      this.fetchCompany({ index : index })
      this.$router.push(`/edit`)
    },
    async deleteData(index, name) {
      try {
        await this.deleteCompany({ index : index })
        this.$notify({
          type: 'success',
          title: '削除成功',
          position: 'bottom-right',
          duration: 1000
        })
      } catch (e) {
        this.$notify.error({
          title: '削除失敗',
          position: 'bottom-right',
          duration: 1000
        })
      }
      this.clearCompany()
    },
    ...mapActions(['fetchCompanys', 'fetchCompany', 'deleteCompany', 'clearCompany'])
  }
}
</script>

<style scoped>
#list {
  width: 35vw;
  min-height: 60vh;
}
.list-contents {
  margin: 15px 0;
  padding-bottom: 15px;
  border-collapse:separate;
  border-spacing: 15px 0;
  border-bottom: solid 1px #c0c0c0;
}
.list-contents div {
  display:table-cell;
  vertical-align: middle;
}
.editCompany {
  background-color: #6495ed;
}
.company-link {
  color: #6495ed;
}
.company-link:hover {
  color: #ff69b4;
}
</style>

会社情報の表示。

app/components/pages/CompanyDetail.vue
<template>
  <div id="detail" class="item-contents" v-if="company === null">
    <p style="padding: 15px 0 15px 15px;">会社名を選択してください!!</p>
  </div>
  <div id="detail" class="item-contents" v-else>
    <div>
      <p class="column">◾️企業名</p>
      <p class="value">{{ company.name }}</p>
    </div>
    <div>
      <p class="column">◾️リンク先</p>
      <p class="value"><a :href=company.link target="_blank">{{ company.link }}</a></p>
    </div>
    <div id="address-area">
      <p class="column">◾️住所</p>
      <p class="value">{{ company.address }}</p>
    </div>
    <div>
      <p class="column">◾️業務内容</p>
      <p class="value">{{ company.job }}</p>
    </div>
    <div id="usedSkills">
      <p class="column">◾️使われている技術</p>
      <div class="skill" v-for="skill in company.usedSkills" :key=skill.key>
        <div class="value skill-img">
        <div style="text-align:center;"><img :src=skill.path /></div>
          <p>{{ skill.key }}</p>
        </div>
      </div>
    </div>
    <div class="r-skill">
      <p class="column">◾️求められるスキル</p>
      <p class="value">{{ company.requiredSkill1 }}</p>
      <p class="value">{{ company.requiredSkill2 }}</p>
      <p class="value">{{ company.requiredSkill3 }}</p>
      <p class="value">{{ company.requiredSkill4 }}</p>
      <p class="value">{{ company.requiredSkill5 }}</p>
    </div>
    <div class="r-skill">
      <p class="column">◾️歓迎されるスキル</p>
      <p class="value">{{ company.welcomedSkill1 }}</p>
      <p class="value">{{ company.welcomedSkill2 }}</p>
      <p class="value">{{ company.welcomedSkill3 }}</p>
      <p class="value">{{ company.welcomedSkill4 }}</p>
      <p class="value">{{ company.welcomedSkill5 }}</p>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'
export default {
  computed: {
    ...mapGetters({'company' : 'company'})
  }
}
</script>

<style scoped>
#detail {
  width: 65vw;
}
.column {
  margin: 10px;
  font-weight: bold;
}
.value {
  margin: 10px 10px 10px 20px;
  font-size: 13px;
}
.value div {
  margin: 0px !important;
}
.value div img {
  width: 30px;
  height: 30px;
}
.skill {
  display: inline-block;
  margin: 10px 15px 10px 15px;
}
.skill-img {
  margin: 0px !important;
}
.skill-img p {
  font-size: 10px;
  text-align: center;
}
@media (max-width: 480px){
  #detail {
    width: 100vw;
  } 
  #address-area {
    width: 85vw;
  }
  #address-area .value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .r-skill {
    width: 85vw;
  }
  .r-skill .value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
}
</style>

登録更新

会社情報は更新でも利用するので、Company.vueに集約。

app/pages/new.vue
<template>
  <div class="main-container">
    <div id="contents">
      <Company />
      <ul style="justify-content: center; margin-bottom:0px;">
        <li><button type="button" style="width:110px;" @click="regist">新 規 登 録</button></li>
      </ul>
    </div>
  </div>
</template>

<script>
import { mapActions } from 'vuex'
import Company from '~/components/pages/Company.vue'
export default {
  components: {
    Company
  },
  methods: {
    async regist() {
      try {
        await this.registCompany()
        this.$router.push(`/`)
      } catch (e) {
        this.$notify.error({
          title: '登録失敗',
          position: 'bottom-right',
          duration: 1000
        })
      }
    },
    ...mapActions(['registCompany'])
  }
}
</script>

<style scoped>
.main-container {
  padding: 80px 250px 30px;
}
#contents {
  margin: 10px;
  padding: 30px 30px;
  background-color: #ffffff;
  border: solid 1px #c0c0c0;
  border-radius: 2px;
  box-shadow: 2px 2px 2px rgba(0,0,0,0.4);
}
ul {
  display: flex;
  margin-bottom: 20px;
}
@media (max-width: 480px){
  .main-container {
    padding: 5px 10px;
  }
}
</style>

会社情報入力ロジック。

app/components/pages/Company.vue
<template>
  <div id="company">
    <ul>
      <li class="column">会社名.</li>
      <li><input id="name" type="text" v-model=name @change="onChange"></li>
    </ul>
    <ul>
      <li class="column">リンク.</li>
      <li><input id="link" type="text" v-model=link @change="onChange"></li>
    </ul>
    <ul>
      <li class="column">住所.</li>
      <li><input id="address" type="text" v-model=address @change="onChange"></li>
    </ul>
    <ul>
      <li class="column">業務内容.</li>
      <li><input id="job" type="text" v-model=job @change="onChange"></li>
    </ul>
    <ul>
      <li class="column">使われている技術.</li>
      <li><div><input id="usedSkillsSearch" type="text" v-model=usedSkillsSearch ref="usedSkillsSearch"></div></li>
      <li style="margin-left:10px;"><button type="button" @click="addSkill()">追 加</button></li>
    </ul>
    <ul>
      <li class="column"></li>
      <li>
        <div id="usedSkills" v-if="usedSkills.length !== 0">
          <div v-for="usedSkill in usedSkills" :key=usedSkill.key>
            <span>{{ usedSkill.key }}</span>
            <span @click="deleteSkill(usedSkill.key)"></span>
          </div>
        </div>
      </li>
    </ul>
    <ul style="margin-bottom:10px;">
      <li class="column">求められるスキル.</li>
      <li>
        <div><input class="requiredSkill" type="text" v-model=requiredSkill1 @change="onChange"></div>
        <div><input class="requiredSkill" type="text" v-model=requiredSkill2 @change="onChange"></div>
        <div><input class="requiredSkill" type="text" v-model=requiredSkill3 @change="onChange"></div>
        <div><input class="requiredSkill" type="text" v-model=requiredSkill4 @change="onChange"></div>
        <div><input class="requiredSkill" type="text" v-model=requiredSkill5 @change="onChange"></div>
      </li>
    </ul>
    <ul style="margin-bottom:10px;">
      <li class="column">歓迎されるスキル.</li>
      <li>
        <div><input class="welcomedSkill" type="text" v-model=welcomedSkill1 @change="onChange"></div>
        <div><input class="welcomedSkill" type="text" v-model=welcomedSkill2 @change="onChange"></div>
        <div><input class="welcomedSkill" type="text" v-model=welcomedSkill3 @change="onChange"></div>
        <div><input class="welcomedSkill" type="text" v-model=welcomedSkill4 @change="onChange"></div>
        <div><input class="welcomedSkill" type="text" v-model=welcomedSkill5 @change="onChange"></div>
      </li>
    </ul>
  </div>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'
export default {
  data () {
    return {
      name : null,
      link : null,
      address : null,
      job : null,
      usedSkillsSearch : null,
      usedSkills : [],
      requiredSkill1 : null,
      requiredSkill2 : null,
      requiredSkill3 : null,
      requiredSkill4 : null,
      requiredSkill5 : null,
      welcomedSkill1 : null,
      welcomedSkill2 : null,
      welcomedSkill3 : null,
      welcomedSkill4 : null,
      welcomedSkill5 : null
    }
  },
  computed: {
    ...mapGetters({ 'company' : 'company' })
  },
  mounted() {
    if (this.company !== null) {
      this.init()
      this.createObject()
    }
  },
  methods: {
    init() {
      this.name = this.company.name
      this.link = this.company.link
      this.address = this.company.address
      this.job = this.company.job
      this.usedSkillsSearch = this.company.usedSkillsSearch
      this.usedSkills = (this.company.usedSkills === undefined) ? [] : this.company.usedSkills
      this.requiredSkill1 = this.company.requiredSkill1
      this.requiredSkill2 = this.company.requiredSkill2
      this.requiredSkill3 = this.company.requiredSkill3
      this.requiredSkill4 = this.company.requiredSkill4
      this.requiredSkill5 = this.company.requiredSkill5
      this.welcomedSkill1 = this.company.welcomedSkill1
      this.welcomedSkill2 = this.company.welcomedSkill2
      this.welcomedSkill3 = this.company.welcomedSkill3
      this.welcomedSkill4 = this.company.welcomedSkill4
      this.welcomedSkill5 = this.company.welcomedSkill5
    },
    createObject() {
      const targetData = {
        name : this.name,
        link : this.link,
        address : this.address,
        job : this.job,
        usedSkills : this.usedSkills,
        requiredSkill1 : this.requiredSkill1,
        requiredSkill2 : this.requiredSkill2,
        requiredSkill3 : this.requiredSkill3,
        requiredSkill4 : this.requiredSkill4,
        requiredSkill5 : this.requiredSkill5,
        welcomedSkill1 : this.welcomedSkill1,
        welcomedSkill2 : this.welcomedSkill2,
        welcomedSkill3 : this.welcomedSkill3,
        welcomedSkill4 : this.welcomedSkill4,
        welcomedSkill5 : this.welcomedSkill5
      }
      this.createTargetData({ company : targetData })
    },
    addSkill() {
      if (this.usedSkillsSearch === null || this.usedSkillsSearch === '') {
        this.$refs.usedSkillsSearch.focus();
        return
      }
      if (this.usedSkills.find(item => item.key === this.usedSkillsSearch)) {
        this.usedSkillsSearch = ''
        this.$refs.usedSkillsSearch.focus();
        return
      }
      const skillLower = this.usedSkillsSearch.toLowerCase()
      let path = null
      try {
        path = require(`../../assets/img/${skillLower}.svg`)
      } catch(e) {
        path = null
      }
      const params = { key : this.usedSkillsSearch, path : path }
      this.usedSkills.push(params)
      this.usedSkillsSearch = ''
      this.createObject()
      this.$refs.usedSkillsSearch.focus();
    },
    deleteSkill(skillKey) {
      for (let i in this.usedSkills) {
        if (this.usedSkills[i].key === skillKey) {
          this.usedSkills.splice(i, 1)
          this.createObject()
          break;
        }
      }
    },
    onChange() {
      this.createObject()
    },
    ...mapActions(['createTargetData'])
  }
}
</script>

<style scoped>
ul {
  display: flex;
  margin-bottom: 20px;
}
.column {
  width: 160px;
}
#link {
  width: 300px;
}
#address {
  width: 450px;
}
#usedSkills {
  display: flex;
  flex-wrap: wrap;
  width: 485px;
  padding: 10px;
  border: solid 1px #c0c0c0;
  border-radius: 2px;
  background-color: #f0f8ff;
}
#usedSkills div {
  margin: 3px;
  padding: 7px 10px;
  font-size: 12px;
  background-color: #000000;
  border-radius: 2px;
  box-shadow: 2px 2px 2px rgba(0,0,0,0.4);
}
#usedSkills span {
  color: #ffffff;
}
.requiredSkill {
  width: 500px;
  margin-bottom: 10px;
}
.welcomedSkill {
  width: 500px;
  margin-bottom: 10px;
}
@media (max-width: 480px){
  .column {
    width: 70px;
    margin-right: 10px;
  }
  input {
    width: 220px !important;
  }
  #usedSkills {
    width: 270px;
    padding: 10px;
  }
}
</style>

Firebaseでのデータ構造

今回は会社情報を以下のJSON形式で登録。

usedSkillsは配列形式でデータ登録。

FirebaseへのCRUD操作

axiosで各種メソッド(GET/POST/PUT/DELETE)を実装。

index.jsでVuex実装。

export const state = () => ({
  companys : [],
  company : null,
  targetData : null,
  index : null
})

export const getters = {
  companys : (state) => state.companys,
  company : (state) => state.company,
  targetData : (state) => state.targetData,
  index : (state) => state.index
}

export const mutations = {
  setCompanys(state, { companys }) {
    state.companys = companys
  },
  setCompany(state, { company }) {
    state.company = company
  },
  setTargetData(state, { targetData }) {
    state.targetData = targetData
  },
  setIndex(state, { index }) {
    state.index = index
  }
}
  • 検索(GET)
export const actions = {
  async fetchCompanys({ commit }) {
    const companys = await this.$axios.$get(`companys.json`)
    commit('setCompanys', { companys })
  }
}
  • 登録(POST)
export const actions = {
  async registCompany({ commit }) { |
    const targetData = this.getters['targetData'] |
    await this.$axios.$post(`/companys.json`, targetData) |
  }
}
  • 更新(PUT)
export const actions = {
  async updateCompany({ commit }) {
    const targetData = this.getters['targetData']
    const index = this.getters['index']
    await this.$axios.$put(`/companys/${index}.json`, targetData)
  }
}
  • 削除(DELET)
export const actions = {
  async deleteCompany({ commit }, { index }) {
    await this.$axios.$delete(`/companys/${index}.json`)
    const companys = await this.$axios.$get(`/companys.json?`)
    commit('setCompanys', { companys })
  }
}

今後の課題

全て自前で作ると結構時間がかかりますが、フロントに集中出来るので随分時間は短縮できます。

が、Firebase自体が新しい技術なのでネットのノウハウも少なく、データ構造周りの設計のベストプラクティスが分からず…また今回諦めましたが、Algoliaと連携させると検索もイケてるサービスに出来そう。

あとは認証周りでしょうか…それらを差し引いても可能性を感じます。