getInitialProps を自作する
Next.js の初期の特徴的な API である getInitialProps() だが、これを非 Next.js 環境のコンポーネントに自作して生やすと SPA の設計として治安が良くなるのでおすすめという話をする。
以下の話は、もちろん新規開発で Next が選択できるシチュエーションでは採用する必要はない(というか、自前でやる分普通にコストが高いのでやめたほうが良いと思う)。
が、react-router や universal-router でできたアプリケーションを改善したい、あるいは年季の入ったコードベースを SPA 化したいという状況では比較的役に立つと思う(あとこの形にしておくと Next.js に移行しやすくなりそう)。
広義のレガシー改善ガイドとして見て欲しい。
以下、universal-router を例に説明する。
まず以下のような型を用意する。
code:typescript
import { QueryParams } from 'universal-router'
declare type SpaPage<Props> = React.ComponentType<Props> & {
getInitialProps?: <Params extends QueryParams = QueryParams>(
ctx: Context<Params>
) => Props | Promise<Props>
}
React.ComponentType はクラスコンポーネントと関数コンポーネントのどちらでも良いことを表すユニオン型。
そこに getInitialProps が生えたものを SpaPage 型と呼ぶことにする。getInitialProps は同期でも非同期でもありうる。
getInitialProps の引数は仮に Context と名付けたが、これは採用しているルーティングライブラリによって変わりうる( URL やクエリパラメータのパース結果が生えていると想定して欲しい)。
現実にはルーターの内部状態などいろいろな値が渡ってくるだろうが、とりあえず最低 params と query があるものと想定していただけると良い。react-router なら matchPath の結果とかがくるようにしても良いと思う。
code:typescript
import { ParsedQs } from 'qs'
import { QueryParams } from 'universal-router'
interface Context<P extends QueryParams = QueryParams> {
params: P
query: ParsedQs // qs.parse() した結果
}
各ページのコンポーネントが次のような形をしていると仮定する。
code:typescript
// ./pages/users/items/index.tsx
/**
* @url /users/:user_id/items?page=〇〇
*/
const UserItemsIndex: SpaPage<{ user: User, items: Item[] }> = ({ user, items }) => {
return (
<>
<UserProfile user={user} />
<ItemList items={items /}>
</>
)
}
UserItemsIndex.getInitialProps = async ({ query, params }) => {
return {
user: await api.getUserById(Number(params.user_id)),
items: await api.getItemsList({ page: Number(query.page) })
}
}
export default UserItemsIndex
早い話、ルーティングのたびにこれを呼ぶことができれば良い。
code:typescript
async (なんかルーターのコンテキスト) => {
const page = await import(コンポーネントのファイル名)
const props = await page.default.getInitialProps?.(なんかルーターのコンテキスト)
return <page.default {...props} />
}
UniversalRouter ならばこれは config の action に相当する
code:typescript
new UniversalRouter([
{
path: '/users/:user_id/items',
async action(...args) {
const page = await import('./pages/users/items/index')
const props = await page.default.getInitialProps?.(...args)
return <page.default {...props} />
}
}
])
これで完成。もし DRY にしたければ次のように高階関数にしておくと良い。
code:typescript
import { Route, QueryParams } from 'universal-router'
type SpaImporter<P = {}> = () => Promise<{ default: SpaPage<P> }>
type ActionParams = Parameters<Route'action'> const spaPage = <Q extends QueryParams>(importer: SpaImporter) => async (...args: ActionParams) => {
const page = await importer()
const props = await page.default.getInitialProps?.(...args)
return <page.default {...props} />
}
code:typescript
new UniversalRouter([
{
path: '/users/:user_id/items',
action: spaPage(() => import('./pages/users/items/index'))
}
])
……以上の仕組みを SPA on Rails で運用したら快適になった。実際はここで Context の値を好きに変更できるようにする仕組み( さっき何も言わずに qs.parse の結果が Context に出てきたけど、あれを注入する部分 )が面倒だったりするのだが、それはまた別の機会に。