📚

Nuxt3でSSRする場合に発生する問題

2024/12/03に公開

経緯

現在「推しボ!」という一言音声コンテンツ特化型のSNSを開発、提供しております。
https://app.oshivo.fun

SNS共有ボタンが欲しい、とのご要望で、各コンテンツページを動的ルーティングで作成しつつ、OGPの対応をすることになりました。
最近はサーバにVercelを使用しているのでSSRの敷居は随分と下がったのですが、これまでSPAで動かしていたサービスを急にSSRにしたために結構問題も。
今回は発生した問題について、不確定な部分もありますが共有していこうと思います。

問題1:Cannot stringify arbitrary non-POJOs

SSRにした場合に最初に出会ったエラーが上記。調べたところ、例えば以下のような場所が原因になってました。

/composables/index.ts
let original = useState('original', (): Original => new Original());

「useState」はまあご存じと思います。useState使ってなくても調べた感じ多分Vuexとかでも起こると思います。
ここでは「Original」というオリジナルクラスを例に挙げてますが、要は自作クラスをuseStateに突っ込もうとすると、「POJOsじゃないやつを文字列にできない」と怒られます。

POJOsとは

POJOsというのを初めて聞いたのですが、POJOが「Plain old Java object」ということで、もしかして私はJAVAを触っていたのか...
そんなはずはないけれど、当て字なのかそういう言い回しをするものなのか、まあとにかくここでは自作クラスに対しての用意されているクラス(JSにおいては実態は多分オブジェクトだけど)を言っていると思われます。

解決策

何というか明確な解決策が無いような気がしはするのですが、

そもそも論な解決策1つ目:自作クラスを使わない
そもそもNuxt3ではComposableを活用すべきで、クラスを使うべきではない、というような勢力も存在します。

強引な解決策2つ目:処理をクライアントでする
SSRの場合にのみこの問題は発生します。つまり<ClientOnly>やら「import.meta.server」やらを使ってSSRのサーバ側の処理ではスキップしておいて、後からクライアントで動かせば基本的には解決です。

単純な解決策3つ目:ペイロードで云々する
そもそもペイロードという単語がそこそこ出てくる割にしっくりこないのですが、取り合えず今回においてはサーバからクライアントへの受け渡しだと思ってます。
本件については以下のNuxtのissueで話されてます。
https://github.com/nuxt/nuxt/issues/21832

ちょっとわかりにくいのですが、兎にも角にも以下のファイルを作成し、各オリジナルクラスに「toJson」メソッドを用意してあげれば解決します。

/plugins/custom-payload-type.ts
export default definePayloadPlugin((nuxtApp) => {
  definePayloadReducer('JSONifiable', data => data && typeof data === 'object' && 'toJSON' in data && JSON.stringify(data.toJSON()))
  definePayloadReviver('JSONifiable', data => JSON.parse(data))
})
Original.ts
export class Original {
  [index: string]: any;
  id?: number;
  constructor() {}
  toJSON(): Original {
    return {...this}
  }
}

何をやっているのか、詳しく調べる気もないので適当な予想ですが、まずサーバサイドでレンダリングする際にオリジナルクラスのインスタンスを作成します。
作成したらそれをクライアントに送る必要がありますが、そのままでは送れないのでいったんテキストに変換(Stringify)してから送ります。
今回のエラーはオリジナルクラスインスタンスをStringifyしたいけれど未知のクラスなのでどうやって処理すればよいかわからない、と言っているものと思います。
そこでプラグインで「definePayloadPlugin」を作成し、オリジナルクラスのStringify、及び復元方法を教えてあげます。
この時具体的には、「toJson」メソッドを持っているObjectはそのtoJsonを呼んでJSONにしてからStringifyすることで文字列化できる、という寸法です。
TSのクラスは結局はObjectであって、クラスを取り去ってあげても問題は無いので。

参考:https://nuxt.com/blog/v3-4#payload-enhancements

問題2:logObj.date.getTime is not a function

次に遭遇するのがこの「logObj.date.getTime is not a function」エラーです。ただ、はっきり言ってこのエラーは意味が良くわかりません。というか、エラーハンドリングの失敗で、全然関係ない文法のエラーの際にこのエラーが出るような気がします。当然、クライアントでは問題ない、サーバーサイド特有の構文エラーの場合です。つまり、ここで出てくるlogObjとかは探しても特に意味が無いと思います。

ではどのような時にこのエラーが出るのか、正直しっかりと検証はできておりませんが、私が感じたパターンを説明します。

パターン1:改行、3項演算子

まずこのエラーに遭遇したのは、3項演算子を使用してかなり長い式になったために途中で改行していた場合だったと思います。当然クライアントでは問題がありませんでしたが、SSR時にはエラーが出ました。IF文に修正したら治ったので、3項演算子か途中改行に問題があったものと思います。なんだかんだフロントのTS(JS、ES)とサーバのNode(等)が別物だと思い知りますね。

パターン2:keyとvalueが同じ時の省略

クライアントではオブジェクトのkeyとvalueが等しい時、以下の書き方ができます。

let obj = {name: name}
let same = {name}

この省略形を使用した際にエラーが発生したと思います。つまり、サーバサイドではこの省略形には対応していないということではないでしょうか。

終わりに

はい、結構感覚頼りの記事でしたが、同様の記事も見つからず、まずは共有ということで記事にしました。
これまでのVercelが無いころはそもそもSSR環境を見つけるのが難しかったですが、Vercel以後、SSRはかなり簡単になったと思います。とはいえ、ではSSRに大きなメリットがあるか、と言われれば正直私は無いと思ってます。もし0.1秒の起動時間を競うならそもそもネイティブアプリにすべきです。
が、Nodeで動的ルーティングでOGPを設定したければ、基本的にはSSRをせざるを得ません。その場合、<ClientOnly>でごまかすこともできませんので、あれこれ気を使うことになるかと思います。
PHPが以外に懐かしい,,,

Discussion