🦌

シッカソンで開発したwebアプリ「Deer Quest」で考えたNuxtベストプラクティス

2023/12/03に公開

アプリはこちら
https://shikkason.web.app/

どんなwebアプリかはシッカソンの配信を見ていただくとして、ここでは今回開発したwebサービスDeer Questを開発する上で、新たに考えたNuxtの書き方についてお話しします。

webエンジニアの助けになれば幸いです。

※ なお私自身は本職は全くエンジニアやIT関係ない企業に勤めており、独学&個人趣味でエンジニアやってます
基本的な思想として、ガッチガッチに設計を固めてトラブルを起こさないチーム開発的な方法ではなく、
一人でできる力を最大限引き延ばす書き方をしています。

用いたツール

firebase(store、storage、functions、hosting)

普段webアプリを作成する時に使ってるサーバーレス開発プラットフォーム

大多数の人間が、AWSかAzureかfirebaseを選ぶと思います

LINE BOT

これは今回のハッカソンの開発条件にあったため。

当初はLINE BOTでチャットで受け答えしながらクエスト登録や検索をしていくデザインを考えていました。

しかし、LINEで受け答えするには直前のユーザーとの会話をデータベースに保存しとかなければならず、条件分岐がとんでもない数になってしまいます。

LINE BOTの受け答え
// line関係
import {
    ClientConfig,
    MessageAPIResponseBase,
    messagingApi,
    webhook,
  } from '@line/bot-sdk';

import * as dotenv from 'dotenv'
dotenv.config()

import {FieldValue} from 'firebase-admin/firestore'
import {db} from './firebase'
import {Quest, User} from './class'

// Setup all LINE client and Express configurations.
const clientConfig: ClientConfig = {
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN || '',
};

const client = new messagingApi.MessagingApiClient(clientConfig);

function 都道府県と市町村を分解(location:string){
    var prefName, cityName
    if(location.includes('神奈川県')||location.includes('和歌山県')||location.includes('鹿児島県')){
        prefName = location.slice(0,4)
        cityName = location.slice(4)
    }else{
        prefName = location.slice(0,3)
        cityName = location.slice(3)
    }
    return {prefName:prefName,cityName:cityName}
}

async function replyQuestsWithCarousel(event:webhook.Event, questsSnap:FirebaseFirestore.QuerySnapshot,text?:string){
  const quests = questsSnap.docs.map(d=>{
    const quest = new Quest({id:d.id,...d.data()})
    return {
      text:quest.title || 'タイトルなし',
      actions:[{
        type: "message",
        label: "詳細",
        text: d.id
      }]
    }
  })

  await client.replyMessage({
    replyToken: event.replyToken as string,
    messages: [
      {
        type:'text',
        text: text || 'クエストを選んでください'
      },
      {
        type: 'template',
        altText: "this is a carousel template",
        template: {
            type: "carousel",
            columns: quests,
        }
      }
    ],
  })
}

async function replyQuest(){

}

export default async (event: webhook.Event): Promise<MessageAPIResponseBase | undefined> => {
  // Process all variables here.
  
  if (event.type !== 'message' || !event.message) {
    return;
  }

  const sentText = (event.message as {text:string}).text
  console.log('受け取ったメッセージ:',sentText)

  // const {address,latitude,longitude} = event.message.type === 'location'? event.message:{address:'',latitude:0,longitude:0}

  let replyText:string = ''
  let quickReplyTexts:string[] = []
  // reportHandler(event)

  const lineUserId = event.source?.userId as string

  var uid:string, user;

  const lineUserSnap =  await db.collection('users').doc(lineUserId).get()
  if(lineUserSnap.exists){
    uid = lineUserSnap.id
    user = lineUserSnap.data()!
  }else{
    const usersSnap = await db.collection('users').where('lineUserId','==',lineUserId).get()
    if(usersSnap.empty){
      return
    }
    uid = usersSnap.docs[0].id
    user = usersSnap.docs[0].data()!
  }
  let {lineBotState,lineEdittingId} = user

  if(sentText==='リセット'){
    lineBotState = 0
    await db.doc(`users/${uid}`).update({lineEdittingId:null})
    replyText = 'リセットしました'
  }

  switch(lineBotState){
    case 0:
      switch(sentText){
        case 'クエストをさがす':{
          // クエスト一覧をリッチメニューで返す
          const questsSnap = await db.collection('quests').get()
          await replyQuestsWithCarousel(event,questsSnap)
          lineBotState = 11
          console.log(2,replyText)
          break
        }
        case 'クエストを作成':{
          // 自分が作成したクエスト一覧をリッチメニューで返す
          const questsSnap = await db.collection('quests').where('user','==',uid).get()
          await replyQuestsWithCarousel(event,questsSnap,'編集するクエストを選んでください。追加する場合は「追加」と入力ください')
          lineBotState = 21
          break
        }
        case '攻略中クエスト':{
          const questsSnap = await db.collection('quests').where('party','array-contains',uid).get()
          await replyQuestsWithCarousel(event,questsSnap)
          lineBotState = 31
          break
        }
        case '通知設定':
          replyText = 'タイトルをつけてください'
          lineBotState = 41
          break;
        
        case 'プロフィール':
          replyText = 'ユーザー名を入力ください'
          lineBotState = 51
          break;
        
        case '設定問い合わせ':
          replyText = '設定メニューを選んでください'
          lineBotState = 61
          break;

        default :
          replyText = 'メニューから選んでください。動作がおかしくなった時は「リセット」のメッセージを送れば、最初のメニューに戻ります。'
          lineBotState = 0
      }
      break
    
    case 11:{ // 詳細を見たいクエストのidを受け取って、そのクエストの詳細内容を返す
      const questSnap = await db.collection('quests').doc(sentText).get()
      const quest = new Quest(questSnap.data())
      const userSnap = await db.doc(`users/${quest.user}`).get()
      const user = new User(userSnap.data())
      replyText = `
      ${quest.title}
      ${quest.content}
      場所:${quest.prefecture + quest.city}
      契約料:${quest.contractFee}
      報酬:${quest.rewardPoint}
      投稿者:${user.name}
      `
      quickReplyTexts = ['契約する','戻る']      
      lineEdittingId = sentText
      lineBotState++
      break;      
    } 

    case 12:{ // 契約するor戻るを受け取る
      if(sentText==='契約する'){
        // questのpartiesに現在のユーザーのIDを入れる
        await db.doc(`quests/${lineEdittingId}`).update({
          party:FieldValue.arrayUnion(uid)
        })
        const questSnap = await db.doc(`quests/${lineEdittingId}`).get()
        const quest = new Quest(questSnap.data())
        replyText = `
        クエストを受けました。
        ${quest.title}
        クエスト依頼者からの連絡をお待ちください
        `
        lineBotState = 0
      }else if(sentText==='戻る'){
        const questsSnap = await db.collection('quests').get()
        await replyQuestsWithCarousel(event,questsSnap)
        lineBotState = 11
      }else{
        replyText = '不正な文字が入力されました。もう一度やり直してください'
        lineBotState = 0
      }
      break;
    }

    case 21: // 編集するクエストidを受け取って、クエスト編集する
      if(sentText==='追加'){
        const newQuest = new Quest({user:uid})
        const docRef = await db.collection('quests').add({...newQuest})
        lineEdittingId = docRef.id
      }else{
        lineEdittingId = sentText
      }
      replyText = 'クエストのタイプは?'
      quickReplyTexts = ['狩猟','調査','建設']
      lineBotState++
      break;
    
    case 22: // クエストのタイプを受け取って、クエストのタイトルを返す
      await db.doc(`quests/${lineEdittingId}`).update({type:sentText})
      replyText='クエストのタイトルを入力ください'
      lineBotState++
      break;

    case 23: // クエストのタイトルを受け取って、都道府県と市町村を返す
      await db.doc(`quests/${lineEdittingId}`).update({title:sentText})
      replyText = 'クエストが行われる場所(〇〇県〇〇市〇〇区)を入力ください'
      lineBotState++
      break;
    
    case 24:{ // クエストの地域を受け取って、内容を返す
      const {prefName, cityName} = 都道府県と市町村を分解(sentText)
      await db.doc(`quests/${lineEdittingId}`).update({prefecture:prefName,city:cityName})
      replyText='クエスト内容を入力ください'
      lineBotState++
      break;
    } 
    case 25: // クエストの内容を受け取って、確認画面を返す
      await db.doc(`quests/${lineEdittingId}`).update({content:sentText})

      const questSnap = await db.doc(`quests/${lineEdittingId}`).get()
      const quest = new Quest(questSnap.data())
      replyText = `
      以下の内容でクエストを作成しました。
      タイプ:${quest.type}、
      タイトル:${quest.title}、
      場所:${quest.prefecture+quest.city}、
      内容:${quest.content}
      `
      lineBotState = 0
      break;

    case 51: //ユーザー名を受け取って、写真登録を返す
      await db.doc(`users/${uid}`).update({name:sentText})
      replyText = '自己紹介を入力ください'
      lineBotState = 53
      break

    case 52: //写真を受け取って、自己紹介を返す
      break

    case 53: // 自己紹介を受け取って、居住地を返す
      await db.doc(`users/${uid}`).update({introduction:sentText})
      replyText = 'お住まいの都道府県と市町村を入力ください。例:〇〇県〇〇市〇〇区'
      lineBotState++
      break

    case 54: // 居住地を受け取って、メニューに戻る
      const {prefName,cityName} = 都道府県と市町村を分解(sentText)
      await db.doc(`users/${uid}`).update({living:prefName,city:cityName})

      const userSnap = await db.doc(`users/${uid}`).get()
      const {name,introduction, living, city} = userSnap.data()!

      replyText = `プロフィール設定が完了しました。
      名前:${name}、自己紹介:${introduction}、居住地:${living + city}`
      lineBotState = 0
      break

    case 60: // 設定問い合わせで何をするか聞く
      if(sentText==='PC版とのアカウント統合'){
        replyText = 'PCのユーザー画面で表示された6桁の番号を入力ください'
        lineBotState = 61
        break;
      }
    case 61: // PC版の6桁番号を受け取る。
      const authUserSnap = await db.collection('users').where('lineEdittingId','==',Number(sentText)).get()
      if(authUserSnap.empty){

      }else{
        // PC版のuidに統合する
        const authUserId = authUserSnap.docs[0].id
        const authUserData = authUserSnap.docs[0].data()
        const newUser = Object.assign(user,authUserData) as InstanceType<typeof User>
        newUser.lineUserId = lineUserId
        newUser.lineBotState = 0
        const batch = db.batch()
        batch.set(db.doc(`users/${authUserId}`),newUser)
        // line版のuserDocは消去        
        batch.delete(db.doc(`users/${uid}`))
        await batch.commit()

        await client.replyMessage({
          replyToken: event.replyToken as string,
          messages: [{
            type: 'text',
            text: '統合が完了しました'
          }],
        }); 
        return
      }

      lineBotState = 0
      break;
  }

  // ここから最終アクション

  await db.doc(`users/${uid}`).update({lineBotState:lineBotState})

  if(!lineBotState){
    await db.doc(`users/${uid}`).update({lineEdittingId:null})
  }

  if(lineEdittingId){
    await db.doc(`users/${uid}`).update({lineEdittingId:lineEdittingId})
  }

  if(replyText && quickReplyTexts.length){
      await client.replyMessage({
        replyToken: event.replyToken as string,
        messages: [{
          type: 'text',
          text: replyText,
          quickReply:{
            items:quickReplyTexts.map(text=>({
              type:'action',
              action:{
                type:'message',
                label:text,
                text:text
              }
            }))
          }
        }],
      }); 
  }else if(replyText){
    await client.replyMessage({
      replyToken: event.replyToken as string,
      messages: [{
        type: 'text',
        text: replyText
      }],
    });    
  }

};

この欠点について他のチームに良いやり方はないか聞きましたが、特に良い方法はなく、みんな力づくでifとかswitchで条件分岐させまくってチャットを作成しているようです。

LINE社がスポンサーについているわけでもないのに、なぜ開発条件にLINE bot縛りがあったのかは今もわかりません。

しかもLINE botは2023年6月の大改悪で、無料プランだとpostメッセージ投稿が月200通と、趣味にすら使いにくいモノになっています。

私のチームだけでなく他のチームの制作物を見ても、LINE Botでなければ作れないようなものではなく、わざわざ使いにくいLINE Botを使うより自由にツールを選べた方が、より完成度の高いものを作れたのではないかと思います。

フロントエンド開発 Nuxt3、TailwindCss

これらは私がフロントエンド開発をするときに、第一選択として使うフレームワークです。

世の中的にはReactベースのNextを使う人のほうが多いのかもしれませんが、個人的にReactはクソショボエンジニアがバカの一つ覚えのように使っている印象があって、あまり印象が良くないです。

この先しばらくNuxtもTailwindCssもメジャーフレームワークであり続けるでしょうし、特に事情がなければこの2つを使って開発します

新しい書き方

型をクラスで定義

Nuxt3を使うようになってから、クラスを使わないようになりました。

理由は、Nuxt3はtypescriptで記述できるため、classを使うメリットがなくなったからです。

const questDoc = await getDoc(doc(db,'quests',questId))
const quest:Quest = questDoc.data() as Quest

ただ、この方法では新規でquestを作成する時は、空オブジェクトを作成してやる必要がありました

const questTemp = {id:'',title:'',photo:{url:'',fullPath:''}}
const newQuest = reactive({...questTemp}) // これでquestTempがコピーされて、新規Questとして編集できる

type Quest = typeof questTemp

欠点として各プロパティの型定義が少し面倒なのと、undefinedを定義できなかったことです。

const questTemp = {
  id:'',
  title:'',
  photo:{url:'',fullPath:''},
  photos:[] as {url:'',fullPath:''}[], // 配列はこのように型定義する
  
  completedAt? // これは定義できない
}

今回、QuestやUserなどのオブジェクトはクラスを用いて定義することにしました。

useQuest.ts
import { DocumentSnapshot, getDoc, doc, Timestamp, GeoPoint } from "firebase/firestore"

export default ()=>{
    const questTypes = ['狩猟','調査','建設'] as const

    const processes = ['作成中','審査中','掲示中','契約中','完了'] as const

    const {getUser} = useUser()

    class Quest {
        id? = ''
        createdAt:Date = new Date()
        updatedAt:Date = new Date()
        expireAt:Date = new Date()
        client = ''

        type:typeof questTypes[number] = '狩猟'
        needHuntingLicense = false
        needGunLicense = false

        title = ''
        content = ''
        photos:Photo[] = []
        photo:Photo = {url:'',fullPath:''}        

        prefecture = ''
        city = ''
        address = ''
        latlng? = new GeoPoint(0,0)

        contractFee = 100 // 契約金
        rewardPoint = 1000 // 報酬金
        experience = 1000 // 経験値

        process:typeof processes[number] = '作成中'

        party:string[] = []
        quota = 0 // 募集人数

        // 完了報告
        completeAt?:Date
        numberOfHunt?:number
        completeReport?:String
        contributions?:{uid:string,name:string,degree:number}[]

        constructor(init?: Partial<Quest>){
            Object.assign(this, init )
            for(const key in this){
                if(this[key] instanceof Timestamp){
                    this[key] = (this[key] as Timestamp).toDate()
                }
            }
        }    

        get data():InstanceType<typeof Quest>{
            return Object.entries(this).reduce((res,[key,val])=>{
                return Object.assign(res,val!==undefined? {[key]:val}:{})
            },{} as InstanceType<typeof Quest>)
        }

        get daysLeft(){
            const diff = (this.expireAt.getTime() - new Date().getTime())/(24*60*60*1000)
            return Math.ceil(diff)
        }

        get members(){
            return (async()=>await Promise.all(
                    this.party.map(async uid=>await getUser(uid))
                ))()
        }

        get clientUser(){
            return (async()=> await getUser(this.client))()
        }
    }

    const getQuest = async(id:string)=>{
        const {$db} = useNuxtApp()
        const snap = await getDoc(doc($db,'quests',id))
        return new Quest({id:snap.id,...snap.data()})
    }

    return {Quest, getQuest, processes, questTypes}
}

firebaseからデータを引っ張ってくるときは以下のように書けます

const questDoc = await getDoc(doc(db,'quests',questId))
const quest:Quest = new Quest({...questDoc.data(),id:questDoc.id})

// もしquestDoc.data()に'title'がなかったとしても、補える
questDoc.data().title //undefined
quest.title //''

型定義のためasを使う必要がないですし、もしstoreから引っ張ってきたレコードに'title'がなかったとしても(モデルが決まっていない開発初期にはよくあります)、インスタンス作成時にtitleを補ってくれるので、余計なエラーに悩まされることがなくなります。

また、storeに保存する必要のない値(例えばexpirateDate 締切日 が登録されていれば計算で出せるdaysLeft 残日数)はgetterで取り出せるようにしておけば、コンポーネント側で記述する必要がなく、非常にスッキリと書くことができます(コンポーネントのscriptは、状態やレコードの保存などのロジックを書くべきで、残日数みたいなちょちょっと計算するようなものを書くべきではありません)

同様に、party(これはクエストに参加するユーザーのuidを配列にしたもの)をもとに、ユーザー情報を抜き取るmembersや、client(これはクエストの依頼人のuid)からdoc(users/uid)を取り出すclientUserもgetterに入れています

編集状態は[id]/edit

例えばクエストの詳細表示を示すには、
pages/quest/[id].vue
というページを設け、これを編集するにはisEditという状態をtrueにしていました

<script setup>

const questId = useRoute().params.id
const quest = reactive(await getQuest(questId))

cosnt idEdit=ref(false)
<script>

このやり方だと、templateのHTML要素にisEditがtrueの時とfalseの時の両方のスタイルを書く必要があり、tailwindを使っているとどうしても長くなってしまいます。

そこで、詳細表示はpages/quest/[id].vue
編集するならpages/quest/[id]/edit.vue
というようにページ自体を分けてしまうことで、templateをシンプルに書くことにしました。

Flutterに影響を受けたページレイアウト

普通webページを作成する時は、app.vueにヘッダーやサイドバーを書いて、pagesには<main></main>に当たる部分を書くと思います
(Nuxt2の名残かな)

app.vue

<template>
<nav id='header'></nav>

<div class='flex'>
    <aside id='sidebar'>・・・</aside>
    <NuxtPage></NuxtPage>
</div>
</template>

このやり方では、app.vueが各ページのレイアウトに干渉してしまう問題がありました。

そこで今回のやり方では、各ページにAppBarとContainerを設けています
(もちろんAppBarやDrawerはcomponentsで定義する必要があります)

app.vue

<NuxtPage></NuxtPage>

pages/quest.vue

<template>
<AppBar>クエスト一覧</AppBar>

<Drawer>
  <template>
    この中にサイドバーメニュー
  </template>
</Drawer>

<Container>
  この中にコンテンツ
</Container>

こうすると、app.vueとpagesコンポーネントが独立するので、例えば「このページだけちょっと違うレイアウトにしたい」という時にも調整しやすくなります。

LINE連携

LINE Messanger APIには、メッセージを送ったユーザーのuserIDが含まれています。
このuserIDとfirebase authのuidを連携させることで、自分が契約したクエストに更新があった時に、LINE通知が来るようにしました。

どうやってLINE userIDとauthのuidを関連づけるかですが、やり方自体は非常にシンプルで、web版で特定のコードを発行し、firebase storeのusers/特定ユーザーのlineEdittingIdにそのコードを入れておきます。(その際、念のためlineBotStateに1を入れておきます)

LineBotでそのコードを打ち込んだら、lineEdittingIdにそのコードが入っており、かつineBotState=1のユーザーを見つけ出し、lineUserIdに userIdを入れます。

これでlineUserIdとauth uidの関連付けができますので、あとはLINEで何かしらメッセージを送ったり、postメッセージを送る時は、lineUserIdを持っているユーザーを探し出せば良いです。

最後に

今回賞は逃しましたが、どのチームも着眼点、技術ともに素晴らしいと思いました。

ここ最近、爬虫類マッチングアプリ Coconutsや、ラブシーン回避アプリ Mizaruなど、ソフト系の開発が多かったので、そろそろハード系もやりたいです。

罠のIoTなど興味ありますので、ぜひ一緒にやりましょう。

Discussion