2019/09/09
最近サーバーレス技術で話題のFirebaseに触れてみました。
一口にFirebaseと言っても色んな機能がありますが、今回はNuxt.js(axios利用)からFirebaseへの簡易CRUD操作を行い、簡易Webシステムのgithub-pagesデプロイ迄やってみたので、作業メモを投稿します。
ちなみにNuxt.js開発ではNuxt.jsビギナーズガイドを参考にしています。
私は福岡出身なのですが、将来的に福岡にUターンしたい思いが強いので、福岡に本社・支社を持つIT企業の下調べがてら企業情報をメモれるWebサイトを作ってみました(福岡に限らず登録出来ますが)
フロントエンドはNuxt.jsでFirebaseのRealtime Databaseにデータ連携させています。
左側に登録済みの会社一覧を表示させ、リンク(会社名)押下で右側に詳細情報を表示。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操作だけなので設定はこれだけ。
ヘッダーとフッターのみコンポーネントを自作して呼び出し。
<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で管理しているデータを表示させています。
<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>
画面左側のリスト表示ロジック
<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>
会社情報の表示。
<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に集約。
<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>
会社情報入力ロジック。
<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>
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
}
}
export const actions = {
async fetchCompanys({ commit }) {
const companys = await this.$axios.$get(`companys.json`)
commit('setCompanys', { companys })
}
}
export const actions = {
async registCompany({ commit }) { |
const targetData = this.getters['targetData'] |
await this.$axios.$post(`/companys.json`, targetData) |
}
}
export const actions = {
async updateCompany({ commit }) {
const targetData = this.getters['targetData']
const index = this.getters['index']
await this.$axios.$put(`/companys/${index}.json`, targetData)
}
}
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と連携させると検索もイケてるサービスに出来そう。
あとは認証周りでしょうか…それらを差し引いても可能性を感じます。