GAミント至上主義

Web Monomaniacal Developer.

業種特化のシニア求人サイトをNuxtJS + SSR(mode: universal)でつくった

今回作ったサイトシニアジョブ会計事務所

https://accountant.senior-job.co.jp/accountant.senior-job.co.jp

会社で出したプレスリリースはこちら

既存の求人ポータルから、会計事務所系のお仕事に特化させたサイトです。

専門用語でいうとサテライトサイトですね。

会計事務所、税理士事務所向けのお仕事紹介はシニアジョブ誕生のきっかけでもあります。

NuxtJSによる表示や動作の高速化に加えて、会計事務所系の求職者が気になる組織形態による検索機能や、税理士会など既存には無い情報も追加してます。

また検索フォームなども専用にシンプルなものに再設計してます。

技術構成

ざっくりいうとLaravelによるREST APINuxtJSのuniversalモード(SSR + SPA)です。

サーバーサイドは既存のLaravel、DB、ElasticSearchなどをそのまま使いつつ、JSONを返すだけのAPIやそれに必要な処理を新規に追加して、あとはNuxtJSでやることにしました。

バージョン系

記事書いてる時点で主要2つはこのバージョンでした

    "@nuxtjs/composition-api": "^0.25.2",
    "nuxt": "^2.15.7",

選定経緯

既存の方はバックもフロントも昔ながらのLaravelで、一部Vueを使っている程度ですが、
開発効率や今後のメンテナンス考えるとLaravelとbladeによるフロントはいろいろ辛いし、
表示に必要なデータ生成処理も創業当時からの多くの闇が残っており、そのまま使うのは生理的に不可能でした。

あとちょっと見た目変えたいだけなのにLaravel全体デプロイする必要があるのは時間もかかって非効率というのもあり。

そのため、将来的な既存サイトのリニューアルも見据え、APIによるバックとフロントの分離、データ生成処理を書き直すことにしました。

いわゆる技術的負債を多く背負った既存コードを使わず、現時点で必要最低限な設計にすることで、コードの可読性もマシ(自分比)になり、無駄な処理もかなり取り除くことができスピード的にも良くなったかも。

GraphQLとか使いたいけど使わなかった・・・

GraphQL、チャンスあったら使いたい技術ではあるんですが、サーバー側は既存のDBやデータ構造、LaravelとPHPという縛りがあるため今回は見送りました。

あとページあたり1から3リクエストで済みそうだったので、GraphQLのメリットであるリクエストをまとめる恩恵もそんななかったかなぁと。

サーバーサイドも新規で作る際は、TypeScriptでやりたいなぁ。
せめて後々のためにOpen API形式の仕様書的なものは別途用意しようと思ってます。

メンバー

主に3人。自分とデザイナー、Laravel側のAPI追加などをもう1人のエンジニアに手伝ってもらいました。

開発の進め方とか

バックもフロントも自分がメインだったので、事前の要件定義や設計などは脳内だけ、動かして触りながら考えるスタイルでやりました。超小規模チームだから許されるかも。

まずデザイナーさんが作ったAdobe XDの見た目を元にNuxtJS側をつくり、それに必要なAPIも作って呼び出してみて問題があったら修正みたいな流れ。

設計者兼ユーザーとして理想のJSONレスポンスを追求しました。

LaravelのPHPとNuxtJSのTypeScriptの開発を1日に何度も往復するのは脳のスイッチングコスト的にちょっとつらかったです。
特にTypeScript + VSCodeの快適さのあとにPHPはつらい。Nodeサーバーも速いし、もう両方TypeScriptがいいなぁ。

デザインフレームワークにWindi CSSを採用

Tailwind CSS V2と互換性のあるWindi CSSを使用しています。
基本的にTailwind CSS と同じですが、特にレスポンシブのグルーピングが可読性高く変更もしやすいので、めっちゃ便利です。あとshortcutsとか

class="w-55px inline-block lg:(w-105px flex-none mb-20px)"

その他Windi CSS独自の機能はこのページにまとまってます。
Features | Windi CSS


NuxtJSのモジュールが用意されているのはもちろん、
Integration for Nuxt.js | Windi CSS

VueやVite、NuxtJS関係でもおなじみの@antfuさんが開発に参加してるのも決め手のひとつでした。


Windi CSS(Tailwind CSS)を使ってみて

VueのSFCで書いているとtemplateとstyleのブロックを往復することが結構あると思いますが、Tailwindのclassで済ませる形式だとほぼほぼtemplate内だけでさくさく書けて気持ちいいです。文字数も少ないしね。

可読性も慣れると特に問題ない気がします。

特定のul、のliになにかしたいときだけは、li一つずつに書くのは無駄なので、 styleブロックに書きましたがなるべく@applyで書くようにしました。
background-imageとか、ベンダープレフィックスは書けないのでSCSS書いたり、addUtilitiesしたりしましたが、割合的にはごくわずか。

あと仕組み上、使用しているclassから逆算?して必要なCSSを出力してくれるので、無駄なデータや処理を省けて、ユーザー側の速度向上にも繋がるのが嬉しいですね。

サーバー

AWSのFargateを使ってます。
Lambdaでもできそうでもあるけど、いろいろ問題が起きる予感しかないので、他でも使っていたFargateを使いました。
Dockerなのでローカルでもテストしやすいです。

Dockerfileも非常にシンプルで済みました。Nodeを16 にするのは躊躇してます。

FROM node:14-slim as install
RUN mkdir /app/
WORKDIR /app/
COPY package.json yarn.lock /app/
RUN apt-get update && apt-get install -y g++ build-essential python3
RUN yarn install --frozen-lockfile

FROM node:14-slim
RUN mkdir /app/
WORKDIR /app/
COPY --from=install /app/node_modules /app/node_modules
COPY src/ /app/src/
COPY \
  package.json \
  .eslintrc.js \
  .prettierrc \
  nuxt.config.js \
  stylelint.config.js \
  tsconfig.json \
  windi.config.ts\
  /app/

RUN yarn build
CMD ["yarn", "start"]

デプロイは他でも使ってるCircle CIを利用。公式Orbsが便利でほとんど項目設定だけで済みました。

orbs:
  aws-ecr: circleci/aws-ecr@6.15.3
  aws-ecs: circleci/aws-ecs@2.1.0
  aws-cli: circleci/aws-cli@2.0.0

それ以外のAWS側のいろいろは手作業でやると相変わらずつらい。みんなTerraform使ってる意味わかった。

ローディング中スケルトンスクリーン

次のページに行くときとかのこういうやつ(最初のページはSSRされるので出ません)。


既存のコンポーネントをコピーして、テキストのところにグレー入れたり、動きのアニメーション入れるだけで簡単にできました。
それを以前だったらぐるぐるアニメーション画像を表示する代わりにいれるだけ。
CSSでスケルトンスクリーンを表現する | TECH BOX

読み込み前後の形が似てるだけでこんなにストレスが減るんだなぁと実装中に実感しました。

SSRでよかったこと

やっぱ初期の描画は速いなぁ。
APIから取得した部分でもCLSも起きにくい。

ferret-plus.com

サーバーもNuxtJS任せでビルドしてnuxt startするだけなので楽ちんです。

お世話になったNuxtJSモジュール

NuxtJSの強みでもあるモジュールにはお世話になってます

www.npmjs.com

設定だけで使えるプロキシで、APIは全部経由させてます。
ドメイン違いを気にしなくていいのが一番ですが、パスをシンプルにしたり、別でS3に書き出したsitemap.xmlにつなげる、なんて使い方もできました。

www.npmjs.com
フロント界隈でお馴染みの@potato4dさんがメイン開発者のもの。
ステージング環境に使いました。別に見られてもいいんですが、検索エンジン避けの意味合いが強いかも(robotsタグ出し分けより楽だし確実)
設定もこれ以上できないくらいシンプルでいいですね。

トップ( / )を見てたロードバランサーのヘルスチェックが当然通らなくて、なかなか切り替わらないという凡ミスもしましたが、このモジュールは静的ファイルには関与しないので、対象パスを/favicon.co とすることで回避できました。

www.npmjs.com
Google Tag Managerのやつ。まだGAだけですが、SPA動作時のログ送信は自分でやるのは大変なので助かります。
後回しにしててリリース前日の設置でした。

pageTracking: true

して、↓を参考にGTM側も設定して無事動きました。
Nuxt.jsで@nuxtjs/gtmを使ったGAの設定 | TOMILOG

www.npmjs.com
これはもう言うこと無いか

NuxtJS + SSR で困ったこと

@nuxtjs/composition-api

@nuxtjs/composition-apiはバージョンがリリース時点で0.25.2と1未満なだけあり、やっぱりまだ時期尚早感もりもりでした。

SSRはやっぱ複雑でつらいなってのが何度もあったんですが、大半は自分の理解不足が原因で、改めて考えると致命的なのは@nuxtjs/composition-apiが原因の以下2つだったような。

useAsync() でのerror()でエラーになること

これは別記事に書きました。asyncData()だったらもちろん問題なかった。
(解決)NuxtJS + Composition APIでsetup()内でerror() 使うとDOMException: Failed to execute 'appendChild' on 'Node' - GAミント至上主義

useFetch()とcomputedが同時に使えないこと

これは下記記事参考。たしかuseFetch内でerror()でも上と同じくダメだったので関係なくなりましたが。
computed超便利なので使いたい(setter?なにそれ)
polidog.jp

でもTypeScriptだとthis無しは快適だし、コード分けやすいし、今後のバージョンアップ、学習面も考えるとComposition APIでやって良かったなぁと思います。

このドキュメントには大変お世話になりました。困ったとき、よく見たら書いてあるじゃんってのが3回はあった。
Nuxt Composition API

NuxtJSのVue3版に関してはまだはっきりしたスケジュール等は見つかりませんでしたが、期待したいです。エコシステム大きくて大変だろうなぁ。

あとViteの開発環境は速度的に素晴らしいのではやくきてくれー。

Vue3のComposition APIは趣味で触ってますが、問題ないと思います。

あとNuxtJSはやっぱ表記ゆれが困るなぁ(この記事はNuxtJSで統一)



これまでシニアジョブでNuxtJSでつくったもの

今回SSRが初だったんですが、SSG、普通のやつ(なんていうんだろ)も作っていたので、3種類コンプリートした気がします。

メディアサイト NuxtJS + microCMS で SSG
シニアタイムズ|専門家によるシニアのための情報誌

簡易受付システム 普通に静的ファイル配信のみ
Google Apps ScriptとNuxtJSで簡易来客受付システムをつくったら実質サーバーコスト無料だった - GAミント至上主義

フロントエンドエンジニア募集してます!

今後もNuxtJS、Vueで新規開発予定がありますので、興味のある人は下記の会社のページから応募か、技術的な質問あれば私までTwitterのDM等で連絡ください。

フロントエンドエンジニア 募集要項|株式会社シニアジョブ 採用サイト

(解決)NuxtJS + Composition APIでsetup()内でerror() 使うとDOMException: Failed to execute 'appendChild' on 'Node'

2021/9/7追記

エラーの原因は、@nuxtjs/composition-apiではなく、error.vueの描画時の問題だったので、error.vueの内容をclient-onlyで囲むことで解決しました!
@nuxtjs/composition-apiは悪くありませんでした。
ずっと放置してたけど他のメンバーに見てもらったら一瞬で解決できて感謝。



追記ここまで

表題で終わってるけど@nuxtjs/composition-apiを使ってsetup内でuseContextのerrorを使うとエラーから逃れられない。

バージョンはこんな感じ。

    "@nuxtjs/composition-api": "^0.24.6",
    "nuxt": "^2.15.7",

本番サーバー時、開発者ツールのconsoleを見ると下記エラーが出ていて、JSがストップしてしまうのでページ遷移などが不可能になる。

DOMException: Failed to execute 'appendChild' on 'Node': This node type does not support this method.

開発サーバー時はこのエラー

vue.runtime.esm.js?2b0e:619 [Vue warn]: The client-side rendered virtual DOM tree is not matching server-rendered content. This is likely caused by incorrect HTML markup, for example nesting block-level elements inside <p>, or missing <tbody>. Bailing hydration and performing full client-side render.

SSR時にコンテンツが見つからないとき404レスポンスをしたくてがんばったけど無理だった。

もともとuseAsyncにメソッド内でerror() を使っていたけど調査のために下記のような最小限の構成を試したら同じ結果だった。

<script lang="ts">
import { defineComponent, useContext } from '@nuxtjs/composition-api'

export default defineComponent({
  // ダメ
  setup() {
    const { error } = useContext()
    error({ statusCode: 404 })
  }
  // 大丈夫
  asyncData({ error }) {
    return error({ statusCode: 404 })
  }
})

ひとまず、error404.vueという上記のasyncData形式で404を返すページを追加し、SSR時でコンテンツがないときは、そこにredirect() することにした。

useAsyncに渡すメソッド内

return $axios
        .$get<ApiResponse>('/api/list')
        .catch(e => {
          // CSR 時はこれでエラーページ出る
          error({
            statusCode: e.response.status,
            message: 'エラーが発生しました'
          })
          // SSR時に下記エラーが起きて、動かなくなってしまうため暫定処理
          // DOMException: Failed to execute 'appendChild' on 'Node': This node type does not support this method.
          if (process.server && e.response.status === 404) {
            redirect('/error404')
          }
        })

Nuxt本体のソースコードも見たけど複雑すぎるし、現在、近々Vue3版が出るという過渡期だと思うので今回はこれで妥協。


NuxtJS + Composition APIのuseAsync()でハマった3つ

新しいサイトの開発でNuxtJS + Composition API + TypeScriptで快適な開発をしてますがuseAsyncは、サーバー、クライアント両方で実行することもあり、理解にちょっと時間がかかっていろいろハマったのでメモ。

基本的にちゃんとドキュメント通りに使えば大丈夫なやつでした。あとasyncDataの代わりなので、そちらをちゃんと理解してればハマりにくそう。 はい、よくわかってません。

composition-api.nuxtjs.org

バージョンとか

"dependencies": {
    "@nuxtjs/axios": "^5.13.4",
    "@nuxtjs/composition-api": "^0.23.4",
    "core-js": "^3.12.1",
    "nuxt": "^2.15.6",
  },

※記事内のコードはてきとうにコピペして作ったので動きません。

useAsyncの中でいろいろしない

サンプルだとこんなシンプルな使い方してますが、ついつい中でrefの値セットしたりしてしまいます。
そうするとよく覚えてないけどサーバー側とブラウザ側で挙動がおかしくなります。

useAsyncの返り値を使いましょう。返り値の型がRefになるのも注意。


ドキュメントの様に、リクエストの結果を入れるだけにするのがよさそうです。

const posts = useAsync(() => $http.$get('/api/posts'))

だめな例1

setup() {
  const items = ref([])
  const fetchJob = () => {
      const response = $axios.$get<ApiResponse>(
        `/api/items`
      )
      items.value = response.items
  }
  useAsync(fetchJob)
  return { items }
}

リクエストの結果からいろいろ取り出して使いたいなってなったらcomputedでやるのがよさそうでした。

よさそうな例1

setup() {
  const fetchItems = () => {
      return $axios.$get<ApiResponse>('/api/items')
  }
  const response = useAsync(fetchItems)

  const items = computed(() => {
    if (!response.value) {
      return []
    }
    return response.value.items ?? []
  })
  return { items }
}

useAsyncにasync functionを渡さない

ドキュメントのサンプル通り、そのままPromiseを返さないとだめそうです。
どんな問題起きるかは忘れた。

だめな例2

setup() {
  const items = ref([])
  const fetchJob = async () => {
      return await $axios.$get<ApiResponse>(
        `/api/items`
      )
  }
  const response = useAsync(fetchJob)
}

ページ遷移のときに再実行が必要な場合はキーをつける

params等から値をとってAPIリクエストするのはよくやると思いますが、そのままだとSSR時は問題なくても、NuxtLink等でのページ遷移時はuseAsyncが動いてくれないことがありました。

ドキュメントの下の方に書いてありますが、再取得が必要な際は、userAsyncの第2引数にキーとなる文字列を渡す必要があります。

https://composition-api.nuxtjs.org/getting-started/gotchas/#keyed-functions

params.value.hogeの変更時にuseAsyncを動かす例

setup() {
  const { $axios, params } = useContext()
  const fetchItems = () => {
      return $axios.$get<ApiResponse>('/api/items', {params: {hoge: params.value.hoge}})
  }
  const response = useAsync(fetchJobs, params.value.hoge)
}

NuxtJS + Composition API + TypeScriptはまだ情報が少なく、ハマりやすいですが、ちゃんとドキュメント読めば結構大丈夫で、それ以上に快適という結論でした。
そこそこの規模の開発が今の所順調にできているので、個人的には現時点ではもうOption APIに戻る必要ないかなぁという感想です。

2021/7/7 追記

APIレスポンスでいろいろやりたいときはuseFetchを使うと良さそう。

https://composition-api.nuxtjs.org/lifecycle/useFetch

だけどcomputedをいっしょに使うと問題が・・・
Nuxt Composition APIのuseFetchとcomputedの問題について | polidog lab