近々ちょっとしたウェブアプリを作る必要があり、そのための事前調査も兼ねて、ユニバーサルな JavaScript アプリケーションを作るためのフレームワークである Next.js 5.0 と TypeScript・Redux の組み合わせを試してみたので、その過程で調べたことや考えたこと・学んだことをつらつらと書き綴ってみます。似たようなことをやろうとしている方の参考になれば幸いです。また、詳しい方にはアドバイス頂けると嬉しいです。
前提条件
今回もともとやりたかった要件としては以下になります。
- SSR に対応する:React を使うにせよ Vue を使うにせよ Google の FCF を使う関係で SSR が必須です。
- 可能な範囲で高速化する:保守性に難が出ない範囲で高速化したい。可能なら PWA も検討したい。
- ある程度のメンテナンス性:自分も含めてフロントエンド専門でない人が片手間で開発するものになるので、型やステート管理が欲しい。
以上の前提を元に検討したのですが、全てを自作すると少し時間がかかりそうだったので、今回は有り物のフレームワークを利用することにしました。
Next.js or Nuxt.js
SSR にも簡単に対応できるユニバーサルなフレームワークとしては Next.js と Nuxt.js が有名です。大雑把に言えば
- Next.js : React を使ったフレームワーク。ミニマリズムな雰囲気。
- Nuxt.js:Vue を使ったフレームワーク。Vue のエコシステムと統合されたフルスタックな雰囲気。
という感じです。いずれもユニバーサルなアプリケーションを作ることが出来ますし、設定も至って簡単です。ほぼ何もしなくても自動的に Hot Code Reloading・エラー表示・Source Map などに対応してくれます。また、ビルド成果物も自動的に分割してくれるので、極端に大きなバンドルが出来ることもありません。
両者を比較すると Nuxt.js は様々なモジュールがデフォルトで用意されており、機能の豊富さでは Next.js を圧倒しています。特に以下の点は Next.js が備えていない機能です(他にもありますが主要なものを抜粋します)。
Nuxt.js だけが備えている主要な機能
レイアウト
デフォルトでレイアウトをサポートしています。またページ遷移間のトランジションも簡単に実装することが出来るので、リッチなページ遷移を実現できます。Next.js でこれをやるのはコードを読む限りかなり難しそうです。ちなみに Nuxt.js はページ遷移時や API 呼び出し時のプログレスバーの表示までデフォルトでサポートされています。
動的ルーティング
どちらのフレームワークも /pages
以下に配置したコンポーネントが自動的に描画されるようになっていますが、Next.js は動的ルーティングをサポートしていません。どういうことかというと、例えば Nuxt.js では /pages/users/_id.vue
のように特別な規約に従ってファイルを配置することで _id
のような動的なパスパラメータをサポートしていますが、Next.js にはこのような機能はありません。では Next.js の場合はどうするかというと、自前で(express など)サーバー側でルーティングを定義するか、ID をクエリパラメータとして渡すしかありません。
PWA
Nuxt.js は様々な機能をモジュールとして提供しており、これらを使えば大体やりたいことが出来るようになっています。例えば PWA Module を読み込むことで Web App Manifest や Workbox なども簡単に使うことが出来ます。このあたりも Next.js では自作しないといけないところです。
どちらを使うか
以上の差分から考えて、最初にドキュメントを読んで比較した段階では Nuxt.js を使うつもりでした。私自身は React も Vue もプロダクションで利用したことがなく、正直 React であろうと Vue であろうとどちらでも良かったこともあり、であればより機能が豊富な Nuxt.js の方が良いだろうと考えました。Nuxt.js はデフォルトで Vuex によるステート管理もサポートしており、必要な要件は満たしているように見えました。
しかし少し試した結果、結局 Next.js を使うことになりました。細かい点は割愛しますが、主な理由は以下になります。
Vue が想像していたより複雑だった
Vue 特有のテンプレート言語を覚える必要があったり、エラー発生時のスタックトレースから何を直せば良いか分からなくなるなど、最初に考えていたより複雑に感じました。その点 React は render
さえ覚えればあとは普通の JavaScript という感じで、学習コストが低いように感じました。
Vue の TypeScript サポートがまだ未成熟だった
Vue 自体は最近 TypeScript のサポートを強化しているようなのですが、まだ開発環境が少し追いついていないようです。特に .vue
のような SFC(Single File Component)を使うと補完やフォーマットが上手く動かなくなるケースがありました。また Vue で TypeScript を使う場合、vue-class-component を使ってデコレータを多用することになるのですが、JavaScript で書く場合と大きく書き方が違ってくるのも少し気になりました(ただ、これらのライブラリは実装も薄く、API としても自然なもので非常に良く出来ていると感じました)。
Nuxt.js の機能が豊富過ぎた
これは完全に好みの問題だと思うのですが、Nuxt.js が色々やってくれるお陰で逆にどこからどこまでが Nuxt がやってくれている部分なのか把握しきれなくなるときがありました。また、開発者側にあまり自由度がなく、Nuxt のやり方に従う必要がある点をやや不満に感じました。筆者は Ruby で言うところの Rails や Java で言うところの Spring のような巨大なフレームワークがあまり好きではないので、自分で好きなように組み合わせて作れる Next の方が好みでした。とはいえこれは完全に好みの問題なので、元から Vue が好きな人は素直に Nuxt.js を使ったほうが良いと思います。Nuxt 自体はドキュメントも含めて Next より断然良く出来ていると思います。
Next.js の開発を始める
それでは Next.js の開発環境をセットアップしていきます。基本的には何もしなくても開発を始められるのですが、今回は TypeScript を試してみたいという気持ちがあったので(勿論使ったことはありません)、TypeScript を使用出来るようにしてみます。ありがたいことに Next のリポジトリには多数のサンプルプロジェクトが用意されているので、こちらを参考にします。
TypeScript を使えるようにする
TypeScript については 公式のプラグイン が用意されているのでこちらを利用します(他にも SASS や LESS などを使うためのプラグインも用意されています)。サンプル通り設定するだけで使い始めることが出来ましたが、 styled-jsx(Next では特に理由がなければ styled-jsx を使ってスタイルシートを書きます)を使う際に一部型定義が足りなかったため、自前で d.ts
を追加しています。
//
// typings/styled-jsx.d.ts
//
import 'react'
declare module 'react' {
interface StyleHTMLAttributes<T> extends React.HTMLAttributes<T> {
jsx?: boolean
global?: boolean
}
}
これで以下のように TypeScript を使ったコンポーネントが書けるようになりました。TypeScript はコンパイラの設定は少し調べる必要がありますが、基本的には JavaScript と同じなので、文法などの学習コストがほぼ無いところは良いですね。文法については特にドキュメントは読んでおらず、React コンポーネントの型定義を読んだぐらいで自然に書き始められました。
//
// components/layouts/main.tsx
//
const MainLayout: React.SFC<MainLayoutProps> = (props: MainLayoutProps) => {
const { children } = props
const color = '#EE2560'
return (
<div>
<Head>
<title>Next.js 5.0 w/ TypeScript</title>
</Head>
<h1>Next.js 5.0 w/ TypeScript</h1>
{children}
<style jsx>{`
h1 {
color: ${color};
}
`}</style>
</div>
)
}
getInitialProps を使ってみる
Next では React コンポーネントに getInitialProps
というメソッドを定義することで、サーバーサイドで初期 props をロードすることが出来ます。こちらは普通に React.Component
の static なメソッドとして定義することも出来るのですが、最近の React は出来るだけ SFC にして HOC を使うことが好まれているようなので、HOC を作ってみました。
//
// components/pages/hoc.tsx
//
import * as http from 'http'
import * as React from 'react'
import { wrapDisplayName } from 'recompose'
export interface Context {
readonly pathname: string
readonly query: any
readonly asPath: string
readonly req?: http.IncomingMessage
readonly res?: http.ServerResponse
readonly jsonPageRes?: Response
readonly err?: any
}
type InitialProps<P> = Partial<P> | Promise<Partial<P>> | void | Promise<void>
type InitialPropsProvider<P> = (context: Context) => InitialProps<P>
type InitialPropsEnhancer<P> = (component: React.ComponentType<P>) => React.ComponentType<P>
export function initialProps<P>(provider: InitialPropsProvider<P>): InitialPropsEnhancer<P> {
return (BaseComponent: React.ComponentType<P>): React.ComponentType<P> => {
return class extends React.Component<P> {
public static displayName = wrapDisplayName(BaseComponent, 'withInitialProps')
public static async getInitialProps(context: Context) {
const WrappedComponent = BaseComponent as any
if (WrappedComponent.getInitialProps) {
return WrappedComponent.getInitialProps(context)
}
return provider(context)
}
public render() {
return <BaseComponent {...this.props} />
}
}
}
}
これで以下のように初期プロパティをロードできるようになりました。注意点としては、ラップする際に initialProps
を一番外側に配置しないといけない点でしょうか。無理に HOC を使わず React.Component
として実装する方が良いかもしれません。
//
// pages/index.tsx
//
import { compose, defaultProps, pure } from 'recompose'
import { fetchTopStories } from '../api/stories'
import { initialProps } from '../components/pages/hoc'
import Index from '../components/pages/index'
const enhance = compose(
initialProps(async () => {
const stories = await fetchTopStories(3)
return {
env: process.env.NODE_ENV,
stories
}
}),
defaultProps({
stories: []
}),
pure
)
export default enhance(Index)
コンポーネントの配置については考え中です。Next では /pages
にはページコンポーネント以外は置けないようなので(もし置く方法があれば教えて下さい!)今回は以下のようにしています。
-
/pages
: ページコンポーネント。初期プロパティの読み込みなどはこちらでやっています。 -
/components/pages
: ページコンポーネントの本体。基本的に SFC にしています。 -
/components/layouts
: レイアウトコンポーネント。 -
/components/widgets
: 細かなコンポーネント群。
あんまりしっくり来ていないので、後々変えるかもしれません。/pages
と /components/pages
を敢えて分けているのは、一緒にしてしまうと余りにも /pages
直下のコンポーネントが肥大化してしまい、読みづらくテストもしづらくなるようように感じたためです(と言ってもテストは多分書かないのですが……)。
Redux を組み合わせてみる
ちょっとしたツールを作るときに React を使ったことはあるのですが、そういう場合に Redux などのステート管理を導入するのはオーバーエンジニアリングだと感じたので、これまで Redux などを使ったことはありませんでした。しかし今回は比較的規模が大きくなりそうな予感がするので、何かしらのステート管理ライブラリを導入することにしました。
ステート管理のライブラリについて検索してみると、昨今の流行は Redux と Mobx のようです。どちらも Next のリポジトリに公式サンプルが用意されています。少し調べた感じ、Redux の方がシンプルで分かりやすかったため、今回は Redux を使うことにしました。使ってみて具合が悪ければ、シンプルなステート管理システムを自作すれば良いかと考えました。
Next で Redux を使う場合、大雑把に言えば上述の getInitialProps
で Store を初期化し(サーバーサイドでは常に Store を初期化するようにする)、初期ステートをブラウザに引き継ぐようにします。next-redux-wrapper というライブラリが用意されているので、こちらを使えば大体うまくやってくれます。
まず Store を定義します。やり方は色々あるようなのですが、今回は以下のようにしました。
//
// store/index.ts
//
import { createStore } from 'redux'
import { devToolsEnhancer } from 'redux-devtools-extension'
import RootReducer from './root-reducer'
import RootState from './root-state'
export const initializeStore = (initialState: RootState) => {
return createStore(RootReducer, initialState, devToolsEnhancer({}))
}
これを getInitialProps
で利用します。next-redux-wrapper
を使うと、getInitialProps
の引数に store: Store
と isServer: boolean
というプロパティが渡ってくるようになるので、これを使って初期化します。
//
// pages/redux.tsx
//
import withRedux from 'next-redux-wrapper'
import RootState from 'store/root-state'
import * as API from '../api/stories'
import { initialProps } from '../components/pages/hoc'
import { Redux, ReduxProps } from '../components/pages/redux'
import { increment } from '../store/counter/actions'
import { initializeStore } from '../store/index'
import { fetchTopStoryIds } from '../store/stories/actions'
const mapStateToProps = ({ counter, stories }: RootState) => {
return {
counter,
stories
}
}
const mapDispatchToProps = dispatch => {
return {
... // 後述
}
}
const enhance = initialProps<ReduxProps>(async ({ store, isServer }) => {
if (isServer) {
// サーバーでのみインクリメントする(クライアント側で遷移した場合はインクリメントしない)
store.dispatch(increment({ delta: 5 }))
}
const ids = (await API.fetchTopStoryIds()).slice(0, 5)
store.dispatch(
fetchTopStories.done({
params: {},
result: ids
})
)
})
export default withRedux(initializeStore, mapStateToProps, mapDispatchToProps)(enhance(Redux))
Action と Reducer を定義する
Redux の Action と Reducer については typescript-fsa というユーティリティを使うことで、ある程度型安全に記述することが出来るのでこちらを利用しました。こちらを使うと同期/非同期 Action を簡単に定義でき、Reducer もパターンマッチっぽく記述することが出来ます。
//
// store/counter/state.ts
//
export interface CounterState {
counter: number
loading: boolean
}
export const INITIAL_STATE: CounterState = {
counter: 0,
loading: false
}
export default CounterState
//
// store/counter/actions.ts
//
import actionCreatorFactory, { Action, AsyncAction } from 'typescript-fsa'
const actionCreator = actionCreatorFactory('next:example:counter')
export interface CounterParams {
delta: number
}
export const increment = actionCreator<CounterParams>('INCREMENT')
export const decrement = actionCreator<CounterParams>('DECREMENT')
// 非同期アクションを記述した場合は `started` `done` `failed` というアクションが自動的に作成されます。
// `<ARGS, SUCCESS, FAILURE>` という型を与えることが出来ます。今回の場合は成功した場合は `number` を返す Action という意味です。
export const incrementAsync = actionCreator.async<CounterParams, number>('INCREMENT_ASYNC')
export const decrementAsync = actionCreator.async<CounterParams, number>('DECREMENT_ASYNC')
// `AsyncAction` という型は自分で定義しました。
// `export type AsyncAction<P, R, E> = Action<P> | Action<Success<P, R>> | Action<Failure<P, E>>`
export type CounterAction = Action<CounterParams> | AsyncAction<CounterParams, number, any>
//
// store/counter/reducer.ts
//
import produce from 'immer'
import { reducerWithInitialState } from 'typescript-fsa-reducers'
import { decrement, decrementAsync, increment, incrementAsync } from './actions'
import { INITIAL_STATE } from './state'
export default reducerWithInitialState(INITIAL_STATE)
.case(increment, (state, payload) => {
const { delta } = payload
return produce(state, draft => {
draft.counter = state.counter + delta
})
})
.case(incrementAsync.started, (state, _) => {
return produce(state, draft => {
draft.loading = true
})
})
.case(incrementAsync.done, (state, payload) => {
return produce(state, draft => {
draft.counter = state.counter + payload.result
draft.loading = false
})
})
.case(incrementAsync.failed, (state, _) => {
return produce(state, draft => {
draft.loading = false
})
})
このようにある程度型安全に記述は出来るのですが、型に対して網羅的に Reducer を実装出来ているかなどは検査できません。この辺りは TypeScript なので仕方ないところのようです(が、言語仕様についてきちんと調べたわけではないので、もしかしたら出来るのかも?)。
なお上にあるように今回は immer というライブラリを使って次のステートを計算しています。これは ES 2015 の Proxy を使ってイミュータブルに次のステートを計算するためのライブラリですが、スプレッド演算子を使うよりも自然かつ簡単に記述できます。ただ国内であまり情報を見ないのと、出来たばかりのライブラリのようなので実際にプロダクションで利用出来る品質なのかは分かりません。しばらく試してみてから判断してみようかと思います。
非同期処理をどうするか
次に非同期処理を試してみたのですが、Redux を使う場合、非同期処理については大きく以下の流派が存在するようです。
- redux-thunk - Redux の公式ドキュメントで言及されている準公式ミドルウェア。実装は凄く薄い。
- redux-observable - Rx を使うミドルウェア。Epic という概念を持ち込む。
- redux-saga - Generator を使うミドルウェア。Saga という概念を持ち込む。
まず redux-saga については余りにもオーバーエンジニアリング過ぎるように感じて最初に却下しました。単純なことを難しく解決しようとしているように見えます。次に redux-observable は Rx のような大きなライブラリを読み込む必要があることに抵抗を感じました。というわけで redux-thunk を使ってみたのですが、なんだかしっくり来ないので普通に実装することにしました。
コンポーネント側では以下のように props を定義します(分かりづらいですが /redux
という URL にしたので Redux
という名前になっています・・・)。
//
// components/pages/redux.tsx
//
import * as React from 'react'
import CounterState from '../../store/counter/state'
export interface ReduxActions {
increment(delta: number)
decrement(delta: number)
fetchStory(id: number)
}
export interface ReduxProps {
counter: CounterState
actions: ReduxActions
}
そのあと Page 側で actions を注入します。
//
// pages/redux.tsx
//
import * as API from '../api/stories'
import { Redux, ReduxActions, ReduxProps } from '../components/pages/redux'
import { CounterAction, decrement, increment } from '../store/counter/actions'
import { fetchStory, StoriesAction } from '../store/stories/actions'
type ReduxAction = CounterAction | StoriesAction
// ReduxActions の実装
class ReduxActionDispatcher implements ReduxActions {
private dispatch: (action: ReduxAction) => void
constructor(dispatch: (action: ReduxAction) => void) {
this.dispatch = dispatch
}
public increment(delta: number) {
this.dispatch(increment({ delta }))
}
public decrement(delta: number) {
this.dispatch(decrement({ delta }))
}
public async fetchStory(id: number) {
this.dispatch(fetchStory.started(id))
try {
const story = await API.fetchStory(id)
this.dispatch(
fetchStory.done({
params: id,
result: story
})
)
} catch (e) {
this.dispatch(
fetchStory.failed({
params: id,
error: e.message
})
)
}
}
}
// コンポーネントに注入する
const mapDispatchToProps = dispatch => {
return {
actions: new ReduxActionDispatcher(dispatch)
}
}
まだこれで実際にアプリケーションを作ったことがないので上手くいくかは分かりませんが、取り敢えずこれぐらいシンプルな方が分かりやすいかなと思っています。
動的ルーティング
Next.js には Nuxt.js のような動的ルーティングがないので、クエリパラメータではなくパスパラメータでルーティングしたい場合は、自前で実装することになります。とはいっても自前でルーティングを書くとリンクの記述が面倒になるので、幾つかライブラリが用意されています。今回は nextjs-dynamic-routes を使ってみました(next-routes の方が有名なようですが、コードが読み辛かったため却下)。
こんな感じでルーティングを定義してあげて、express などのサーバーアプリケーション側にハンドラを設定すれば上手く取り扱ってくれます。
//
// routes.ts
//
const Router = require('nextjs-dynamic-routes')
const router = new Router()
router.add({
name: 'index',
pattern: '/'
})
router.add({
name: 'story',
pattern: '/stories/:id'
})
module.exports = router
あとはコンポーネント側で以下のように読み込んであげれば、リンクも生成可能です。
import * as React from 'react'
// @ts-ignore
import { Link } from '../../routes'
const MainLayout: React.SFC<MainLayoutProps> = (props: MainLayoutProps) => {
const { children } = props
return (
<div>
<nav>
<dl>
<dt>
<Link prefetch route="index">
<a>Index</a>
</Link>
</dt>
<dd>React Component Page</dd>
<dt>
<Link prefetch route="story" id="16311462">
<a>Next.js 5.0 @ HN</a>
</Link>
</dt>
<dd>Dynamic Routing Page w/ Redux</dd>
</dl>
</nav>
<hr />
{children}
</div>
)
}
ただし、この部分(Link
コンポーネント)は今のところ TypeScript の型チェックなどを使えていません。とりあえずこれで進めてみようと思っているのですが、型チェックに対応したルーターが欲しくなった場合は自前で何かしら実装した方が良さそうです。
余談ですが今回のサンプルアプリケーションでは server.ts
も routes.ts
も実際にはただの JavaScript です。薄いレイヤなので TypeScript を使う必要性を感じられなかったためですが、拡張子だけでも ts
にしておくと、エディタで諸々の恩恵を受けられるため ts
にしています。
動かしてみる
それでは早速動かしてみます。package.json
には以下のようなタスクを定義してみました。
"scripts": {
"clean": "rimraf .next",
"analyze": "cross-env ANALYZE=all yarn run build",
"dev": "yarn run build:next && cross-env NODE_ENV=development nodemon server.ts -p $PORT",
"debug": "yarn run build:next && cross-env NODE_ENV=development nodemon --inspect server.ts -p $PORT",
"build": "yarn run clean && yarn run build:next && yarn run build:server",
"build:next": "next build",
"build:server": "cp ./server.ts .next/server.ts && cp ./routes.ts .next/routes.ts && cp -r static .next/static",
"start": "cross-env NODE_ENV=production node server.ts -p $PORT",
"tslint": "./node_modules/.bin/tslint -c tslint.json -p tsconfig.json",
"precommit": "lint-staged"
}
yarn run dev
でサーバーが起動します。以下のような画面が表示されます。
なおプロダクション用にビルドする際は、サーバー側のモジュールも dist にコピーするようにしています。
余談:開発環境
余談で開発環境についても書いてみます。今回は VS Code を使ってみました。はじめて使ってみたのですが高速に動作しますし、諸々のプラグインが充実しているのでなかなか使いやすいです。また、エディタの設定やデバッガの設定などを簡単に共有できる点も便利です。今回は Prettier と TSLint を使うようにしてみたのですが、VS Code の設定で tslint.autoFixOnSave
を有効にすることで、自動的に TSLint が働いて修正されるようになります(これがなかなか凄い開発体験です)。チームで開発する場合は、こういった設定を簡単に共有できる点も魅力ですね。なお Next.js のデバッグ設定は以下のようにしています。
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}"
},
{
"type": "node",
"request": "launch",
"name": "Launch via Yarn",
"runtimeExecutable": "yarn",
"runtimeArgs": [
"run",
"debug"
],
"port": 9229
}
],
"compounds": [
{
"name": "Debug",
"configurations": ["Launch via Yarn", "Launch Chrome"]
}
]
}
まとめ
TypeScript や Redux・Next.js など、普段使ったことのないものを組み合わせてみましたが、Next.js を使うと決めてからは特段詰まるところもなく、開発を進められそうな感触を得ることが出来ました。今回作ったサンプルアプリケーションはこちらに置いてあります。
作りながら調べたことについてつらつらと書いてみましたが、自分自身はフロントエンドの開発にあまり詳しいわけではないので、もっとこうした方が良いなどのアドバイスがあれば、コメント等で教えて頂けると嬉しいです。