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

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

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

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

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

今回作ったもの

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

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

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

左側に登録済みの会社一覧を表示させ、リンク(会社名)押下で右側に詳細情報を表示。
nuxt-js-firebase-1.png
nuxt-js-firebase-2.png
新規登録と編集で会社情報の登録と更新を実行。
nuxt-js-firebase-3.png
nuxt-js-firebase-4.png
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形式で登録。
nuxt-js-firebase-5.png
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と連携させると検索もイケてるサービスに出来そう。

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

machio77777
横浜在住の30代ソフトウェアエンジニア / 過去の担当領域はバックエンド(Java / PHP)とフロントエンド(React / Vue / Nuxt)ですが、最近ではマネージメント系の上流工程が多いので、プライベートで調べた事(AWS / Golang / Gatsby)や作った成果物の記事を投稿しています。
https://machio77777.github.io/tana-profile/
Why not register and get more from Qiita?
  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
No 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
ユーザーは見つかりませんでした