Nuxtを使ったプロジェクトで考えたこと
氏名:ドンキー(田原一樹)
所属:レアジョブ/APP・UXチーム
フロントエンドエンジニア
自己紹介
元営業(IT関係無い)→ 色々あって → 今
本日お話すること
新規プロジェクトにて0ベースでフロントエンドを構築していくにあたり開発を
より楽しく・スピードも損なわず(と思いたい)実装を進めていくために
弊社内で取り組んでいることについて
・Vuexの利用を止めた
・Composition APIを利用した実装について
・BFF(&Mock server)を利用した実装について
Nuxtを採用した理由
・弊社(チーム)にVueでの実装知見があった(ここが大きい)
・SEO観点・パフォーマンスの面からSSRを組み込みたかった
・マイクロサービス化の検討からBFFを採用する可能性があった(後述)
実装していく中でチームで決めていったこと😏
Vuex を使わない
その1
極力
理由
実装を行っていく中でVuexの責務を超えた運用をしてしまう
可能性がありルール等で厳密化するより無くした方が煩雑になることを
防げると考えたため。
ここでいう責務とはグローバルかつ単一のデータストアとすること、データストアへの限定的なアクセス方法を守った運用で実装を進めていくことと考えており、
・コンポーネントを超えて共有される情報を管理すること
・そうしたストアオブジェクトへの更新を安全にすること
つまり、コンポーネントを超えて共有される情報へ、安全にアクセスする方法を提供することがVuexのそもそもの目的であるという認識をチーム内で共通認識として持ちました。
どうしたか?
Provide・Injectを使ったStoreパターンを構成する方針でstoreを作成
+Atomic Designに沿ったDirectory構成の意味合いを変更
- Atoms(共通化するUIパーツの最小単位)
- Molecules(Atomsの集合体)
- Organisms(Moleculesの集合体)
--- ここまでは基本的な考え方 --- - Templates
(画面に表示されるUI集合(基本的なPage)) -
Provider
(画面内で共通として利用したい関数やstate(データの状態)をtemplate配下で利用できるようにする為のラッパー) - Pages(TemplatesとProviderをラップ)
Provider
pages
└── index.vue
components
├── atoms
├── molecules
├── organisms
├── provider
└── templates
構成
pages
provider
pages
templates
atoms/molecules/organisms
pages
<template>
<SampleProvide>
<SamplaTemplate />
</SampleProvide>
</template>
<script lang="ts">
import { defineComponent } from '@nuxtjs/composition-api'
import SampleProvide from '~/components/provider/sampleProvide/index.vue'
import SamplaTemplate from '~/components/templates/sampleTemplate/index.vue'
export default defineComponent({
components: {
SampleProvide,
SamplaTemplate
}
})
</script>
provider
<template>
<div>
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent, provide } from '@nuxtjs/composition-api'
import { SampleApiKey } from '~/compositionKey/useProvideKey'
import useSampleApi from '~/composition/api/product/useSample'
export default defineComponent({
setup(_props: any, { root }: any) {
provide(SampleApiKey, useSampleApi())
}
})
</script>
templates
<template>
<div>
<h1>Sample</h1>
<div>
{{ response }}
</div>
<button @click="apiGetTrigger()">ボタンを押してください</button>
</div>
</template>
<script lang="ts">
import {
defineComponent,
inject
} from '@nuxtjs/composition-api'
import { SampleApiKey } from '~/compositionKey/useProvideKey'
import { SampleApiStore } from '~/composition/api/product/useSampleApi'
export default defineComponent({
setup(_props) {
const { response, apiGetTrigger } = inject(SampleApiKey) as SampleApiStore
return {
apiGetTrigger,
response
}
}
})
</script>
useSamplApi
import { toRefs, reactive } from '@nuxtjs/composition-api'
import { NuxtAxiosInstance } from '@nuxtjs/axios'
import useApi from '~/composition/useApi'
const useSampleApi = (axios: NuxtAxiosInstance) => {
const sampleState = reactive<{
response: any
error: Error | null
isLoading: boolean
}>({
response: [],
error: null,
isLoading: false
})
const apiGetTrigger = async () => {
const { response, otherError, isLoading, getData } = useApi(
axios,
`https://hogehogehoge.com`
)
sampleState.isLoading = isLoading as any
await getData()
sampleState.response = response as any
sampleState.error = otherError as any
}
return { ...toRefs(sampleState), apiGetTrigger }
}
メリット
デメリット
・ページやComponentに紐づく対のカスタムFunctionで管理が簡潔する。
・配下コンポーネントにもstateの状態が共有される為、
props drilling等で子コンポーネントに連続で渡していかなくて良い。
・provideのcomponent内のみで扱われる為、ページ遷移時にglobalなデータとして残り続けず
揮発性を持てる。
・慣れの問題(若干最初は戸惑った)
・どうしてもGlobalで横断した状態(データ)を持っておきたい場合がある。
・前述のDIを使った方法でもglobalで保持することは可能であるがmiddleware内でstateの状態にアクセスしたい場合については上手い方法がなかった為、一部Vuexを利用するに至った。
Composition APIを利用
その2
理由
新しいものを使いたいというミーハー心
以下の3点を解決する為に
・TypeScriptのサポート
・ロジックの再利用の難しさ
・アプリケーションが巨大になると、コードの把握が難しくなる
を解決する為に導入
前述の通りVuexを使わない実装で作業を実施していく方針であった為、機能としての恩恵を得る為に導入しました。
(また、プロジェクト立ち上がり当時まだVue3がリリース前ではありましたが、Nuxt Composition API は存在していた為)
こだわりたかった
ところ
API RequestをカスタムHookにする
・せっかくComposition APIを使うのでAPI requestについても汎用性高く作りたかった。
・実際にCRUDの処理をするところ(axios-module)をimportする形でAPI側の各Contextに沿った形でリクエストを投げられるようにしたかった。
・$axios.$get(...みたいな形で実際にrequestをする処理をたくさん書きたくなかった。
・この方が再利用性挙げられると思った。
・error ハンドリングの共通化・拡張性が比較的容易にできると思った。
Component
useXXXApi
useApi
・利用したいContextの
useXXXApiを呼ぶだけ
・Context毎に別れたendpoint毎にファイルを作成しており
Componentから呼ばれる
・useXXXApiにimportされており
実際にCRUD処理の
axios requestをする
import { reactive, toRefs } from '@nuxtjs/composition-api'
import { NuxtAxiosInstance } from '@nuxtjs/axios'
type Options = {
headers: {
'X-transaction-ID'?: string
'x-api-key'?: string
Authorization: string
'Content-Type'?: string
}
}
type Params = {
[key: string]: any
}
type baseState = {
response: {}
otherError: Error | null
isLoading: boolean
}
const useApi = (
$axios: NuxtAxiosInstance,
url: string,
params?: Params,
options?: Options
) => {
const state = reactive<baseState>({
response: {},
otherError: null,
isLoading: false
})
// GET
const getData = async () => {
state.isLoading = true
try {
const res = await $axios.$get(url, options)
state.response = res
} catch (error) {
state.otherError = error
} finally {
state.isLoading = false
}
}
// POST
const postData = async () => {
state.isLoading = true
try {
const res = await $axios.$post(url, params, options)
state.response = res
} catch (error) {
state.otherError = error
} finally {
state.isLoading = false
}
}
// PUT
const putData = async () => {
state.isLoading = true
try {
const res = await $axios.$put(url, params, options)
state.response = res
} catch (error) {
state.otherError = error
} finally {
state.isLoading = false
}
}
// DELETE
const deleteData = async () => {
state.isLoading = true
try {
const res = await $axios.$delete(url, params)
state.response = res
} catch (error) {
state.otherError = error
} finally {
state.isLoading = false
}
}
return { ...toRefs(state), getData, postData, putData, deleteData }
}
useApi
import { toRefs, reactive } from '@nuxtjs/composition-api'
import { NuxtAxiosInstance } from '@nuxtjs/axios'
import useApi from '~/composition/useApi'
const useSampleApi = (axios: NuxtAxiosInstance) => {
const sampleState = reactive<{
response: any
error: Error | null
isLoading: boolean
}>({
response: [],
error: null,
isLoading: false
})
const apiGetTrigger = async () => {
const { response, otherError, isLoading, getData } = useApi(
axios,
`https://hogehogehoge.com`
)
sampleState.isLoading = isLoading as any
await getData()
sampleState.response = response as any
sampleState.error = otherError as any
}
return { ...toRefs(sampleState), apiGetTrigger }
}
useSampleApi
templates
<template>
<div>
<h1>Sample</h1>
<div>
{{ response }}
</div>
<button @click="apiGetTrigger()">ボタンを押してください</button>
</div>
</template>
<script lang="ts">
import {
defineComponent,
inject
} from '@nuxtjs/composition-api'
import { SampleApiKey } from '~/compositionKey/useProvideKey'
import { SampleApiStore } from '~/composition/api/product/useSampleApi'
export default defineComponent({
setup(_props) {
const { response, apiGetTrigger } = inject(SampleApiKey) as SampleApiStore
return {
apiGetTrigger,
response
}
}
})
</script>
メリット
デメリット
・共通のerrorハンドリングについてはuseApiに一元管理できるので楽。
・APIのContext毎にFileを作成するようにしているのでどのメソッドでどんなデータが取得できるか名称から分かりやすくできる。
・userアクション等で取得する場合は@click='useSampleApi()'等で良く、事前取得としてSSRで呼び出したい場合はuseFetch(async () => await useSampleApi()) を書くだけで良いので便利
・context毎にFileを生成するので数が多くなる
・連続で何本かAPIにrequestをかけるにresponseやisLoadingをendpoint毎に管理しないと挙動がおかしくなる為、若干扱いでハマった
serverMiddleware(BFF)を使う
その3
理由
・フロントエンド開発を行っていく上で、Mock APIサーバーが有った方がやはり楽であった為、当初はMirageを組み込んだ上で開発。
・SSR時にMirageへのrequestが行えない為、Local開発時に扱いに困っていたがBFF導入の話が進捗した為、serverMiddlewareにてMock API サーバーとBFFサーバーを担う方針に変更。
ASIS
TOBE
apiServer
├── api
│ ├── endpoint.js
│ └── index.js
├── axios_for_api.js
├── index.js
└── mockDB
構成
・endpoint.js(express routerの route受け口)
・api/index.js(express route設定を登録)
・axios_for_api.js(axiosの設定等)
・index.js(serverMiddlewareを利用する為の設定)
・mockDB
※ServerMiddleware内もTSで書きたかったんですが現在の設定ではbuild時にerrorとなった為、一先ずJSで書くことにしました。
この部分についても別途弊社blogでの記事にしたいと思います。
const express = require('express')
const helmet = require('helmet')
const cors = require('cors')
const route = require('./api/index')
const app = express()
const constants = require('./constants')
app.use(
cors({
credentials: true,
optionsSuccessStatus: 200
})
)
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(helmet())
app.use(route)
module.exports = {
path: '/api/',
handler: app,
prefix: false
}
apiServer/index.js
const router = require('express').Router()
const endpoints = require('./endpoint')
router.use(endpoints)
module.exports = router
apiServer/api/index.js
const router = require('express').Router()
const axios = require(`${relativePath}/apiServer/axios_for_api`)
/**
* @method GET
*/
/* sampleなので全てのpathパターンが通過するようになっていますが、対象のpathパターンでアグリゲートする処理などを
* routerに追記していくイメージ
*/
router.get(`*${ENV_TYPE_PATH}*`, async (req, res) => {
await requestHandle(req, res)
})
const requestHandle = async (req, res) => {
if (process.env.NODE_ENV === 'local') {
sendMockJsonResponse(
req.method,
axios.getUrlFromOriginalUrl(req.originalUrl),
res
)
} else {
const response = await axios.sendRequest(req)
sendJsonResponse(response.headers, response.status, response.data, res)
}
}
endpoint.js
const Axios = require('axios')
const Utils = require('./util')
const constants = require('./constants')
const METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
OPTIONS: 'OPTIONS'
}
const HTTP_STATUSES = {
OK: 200,
CREATED: 201,
ACCEPTED: 202,
NO_CONTENT: 204,
BAD_REQUESET: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500
}
const axios = Axios.create({
baseURL: 'https:hogehoge'
})
const sendRequest = async (req, givenUrl = null, queryParam = null) => {
const url = getUrlForApi(req, givenUrl, queryParam)
if (req.method.toUpperCase() === METHODS.GET) {
return await axios
.get(url, { headers: { ...req.headers } })
.catch((e) => axiosErrorHandle(e))
} else if (req.method.toUpperCase() === METHODS.POST) {
return await axios
.post(url, req.body, { headers: { ...req.headers } })
.catch((e) => axiosErrorHandle(e))
} else if (req.method.toUpperCase() === METHODS.PUT) {
return await axios
.put(url, req.body, { headers: { ...req.headers } })
.catch((e) => axiosErrorHandle(e))
} else if (req.method.toUpperCase() === METHODS.DELETE) {
return await axios
.request({
url,
method: req.method,
headers: { ...req.headers },
data: JSON.stringify(req.body)
})
.catch((e) => axiosErrorHandle(e))
}
}
const axiosErrorHandle = (e) => {
if (e.response) {
return e.response
}
return {
headers: {},
status: HTTP_STATUSES.INTERNAL_SERVER_ERROR,
data: { code: 'system_error', message: 'System Error' }
}
}
const getUrlFromOriginalUrl = (originalUrl) => {
const basePathRegExp = new RegExp(
Utils.escapeRegExp(constants.API_BFF_BASE_PATH) + '(.*)'
)
return '/' + originalUrl.match(basePathRegExp)[1]
}
const getUrlForApi = (req, givenUrl = null, queryParam = null) => {
let url = null
if (givenUrl) {
url = givenUrl
} else {
url = getUrlFromOriginalUrl(req.originalUrl)
}
if (queryParam) {
const q = new URLSearchParams(queryParam)
url = url + '?' + q.toString()
}
return url
}
module.exports = {
sendRequest,
getUrlFromOriginalUrl,
METHODS,
HTTP_STATUSES
}
axios_for_api.js
メリット
デメリット
・比較的容易にNuxt Appと同居する形でBFFが実装できるので便利。
・APIからの返却値を気にすることなくexpress内でdataのアグリゲート等ができるようになる。
・同居するのでrepository内で管理するコード量が増える。
・サーバーサイドの知見が無いと実装していくにあたり、難しい部分がある。
→ キャッシュ・認証等
まとめ
・Vueの知見がチーム内にあったのでNuxtでもあまり迷うことはなかった。
・色々と標準装備が充実しているのでやりたいことがある程度決まっている時は使いやすい。
・すでにやりたいことに関しては誰かやってたりするので参考になる。
・Nuxt自体重量級だと思うのでVueで書いてるときよりやっぱり重い。
今後知りたいこと(知見が貯ればアウトプットしたいこと)
・serverMiddlewareでの導入方法ではなく実用例(それに伴うハマりポイントなど)
・SSR時などexpressが動く際のLogデータの集積についてのベストプラクティス
・多種多様なURLパターンに対応する場合のComponent設計について
ご清聴ありがとうございました!
Nuxtを使ったプロジェクトで考えたこと
By Kazuki Tahara
Nuxtを使ったプロジェクトで考えたこと
- 1,166