Kohei Asai

Kohei Asai

2019/08/02に投稿
  • English
  • 日本語
  • TypeScriptでNext.js 9を触った感想

    最後にNext.jsを触った時はバージョンが7か8の頃でした。その時TypeScriptで書くには多くのワークアラウンドが必要で、そのための作業とフレームワークに助けによる恩恵とが釣り合わず使うのをやめてしまいました。あるフリーランスの仕事でバージョン9の調査をすることになり、ひととおり網羅する過程でこのウェブサイトを作りました。その間に思ったことや感じたことをまとめてみます。

    ほぼTypeScriptネイティブ

    基本的にTypeScriptですべて型付けされています。バージョン8以前はTypeScriptのプラグインをインストールしてnext.config.js.babelrcを書き足す必要がありましたが、そういった作業も必要なくなりまるでTypeScriptネイティブのようです。裏でBabelが動いているものの、それを意識しないといけないことはほとんどないです。

    Babelとの兼ね合いでtsconfig.json"esModuleInterop": trueにする必要がありますが、設定されていない場合はNext.jsの開発用サーバーを起動した時に自動的に書き足されます。

    基本的にUniversalに書くが、getInitialPropsから呼ばれるものはIsomorphicに書いてもよい

    Next.jsの上に書くすべてのコードはブラウザでもNodeでも実行されます。初回アクセス時には各RouteコンポーネントのgetInitialProps()を起点としてNodeでサーバーサイドレンダリングのために実行されます。その後はシングルページアプリケーションとして振る舞い、ページ遷移があれば該当するRouteコンポーネントのgetInitialProps()がブラウザ上で実行されます。ですのでReactコンポーネントを含め、あらゆるコードはUniversalに書かれていなければなりません。

    Universal: プラットフォームに依存しないコードのこと。windowrequire("fs")のようなブラウザやNodeのみのAPIやモジュールを参照せず、JavaScriptの言語標準のAPIのみを使う。

    Isomorphic: プラットフォームの違いを吸収しているコード。windowオブジェクトを参照する前にif (typeof window !== "undefined")するなどしてエラーが発生しないようにする。windowが使えない場合はNodeで利用できる代替手段を使って同じことをする。

    しかし、getInitialProps()はサーバーサイドレンダリングのために実行されている時のみ特定の引数reqresを伴って呼ばれます。この有無でプラットフォームを判別して、プラットフォームの違いを吸収するようなコードを書くようにすることもできます。いわばIsomorphicです。次にあるのはIsomorphicに言語の検知をする例です (書いたけど仕様を見直した結果ボツになっちゃったコードです...) 。

    import * as http from 'http';
    import { NextPageContext } from 'next';
    
    SomePageRoute.getInitialProps = ({ req } :NextPageContext) => {
      const locale = decideLocale(req);
    }
    
    // returns the most desired locale
    function decideLocale(req?: http.IncommingMessage): string {
      if (req) {
        // access to HTTP request headers if there's req
        const acceptLanguage = req.headers["accept-language"];
    
        if (acceptLanguage) {
          const requestedLocales = acceptLanguage.split(",").map(part => {
            const [locale, priority] = part.trim().split(";q=");
    
            return { locale, priority: parseInt(priority) };
          });
    
          // sort requested locales by their priority
          requestedLocales.sort((a, b) => b.priority - a.priority);
    
          return requestedLocales.find(({ locale }) => locale !== '*') || DEFAULT_LOCALE;
        }
      }
    
      if (typeof navigator !== 'undefined') {
        // navigator is only available on the browser
        return navigator.languages[0]
      }
    
      return DEFAULT_LOCALE;
    }
    
    const DEFAULT_LOCALE = "en-US"

    _document.tsx_app.tsxは書いた方がよい

    この2つはpages/ディレクトリに置くものの、Routeにならない特殊なファイルです。_document.tsxを書くと吐き出されるHTMLファイルの構成を変えることができ、_app.tsxを書くとすべてのRouteコンポーネントがここで書いた<App>コンポーネントによってラップされるようになります。

    Next.jsのデフォルトのHTMLは<html>langアトリビュートがセットされていないので、_document.tsxは必須です。自分でセットしましょう (サンプル) 。また、<meta name="viewport" ><meta name="theme-color" >のようにページによって変わらないものも_document.tsxに書くとよいです (普通のRouteコンポーネントの中でもnext/head<Head>コンポーネントを使うと<head>の中身を変更できます) 。

    import * as React from "react";
    import Document, { DocumentContext, Html, Head, Main, NextScript } from "next/document";
    
    interface Props {
      locale: "en-US" | "ja-JP";
    }
    
    class CustomDocument extends Document<Props> {
      render() {
        return (
          <Html lang={this.props.locale.split("-")[0]}>
            <Head>
              <meta name="viewport" content="width=device-width,height=device-height" key="viewport" />
              <link rel="shortcut icon" href="/static/shortcut-icon.png" key="shortcutIcon" />
              <meta name="theme-color" content="#087da1" key="themeColor" />
            </Head>
    
            <body>
              <Main />
    
              <NextScript />
            </body>
          </Html>
        );
      }
    }
    
    export default CustomDocument;

    _app.tsxは全ての画面で共通のナビゲーションバーなどを表示する際に便利です。他にもアプリケーション全体で使うオブジェクトをContext経由で渡す際に、Providerを置く場所としても最適です (よくあるReactでのDependency Injectionのパターンですね) 。例としてこのウェブサイトでは「現在のロケール」や「翻訳用のプレースホルダーテキスト」などをセットしています (サンプル) 。ウェブアプリケーションであればログイン状態などが最たる例になるでしょう。

    import * as React from "react";
    import NextApp, { AppContext, Container } from "next/app";
    import Session from "(somewhere...)";
    import getAuthenticationSession from "(somewhere...)";
    
    interface Props {
      session: Session;
      pageProps: any;
    }
    
    class App extends NextApp<Props> {
      render() {
        const { session, pageProps, Component } = this.props;
    
        return (
          <Container>
            <SessionContext.Provider value={session}>
              <Component {...pageProps} />
            </SelfUrlContext.Provider>
          </Container>
        );
      }
    
      static async getInitialProps({ Component, ctx }: AppContext) {
        const componentGetInitialProps = Component.getInitialProps || (() => Promise.resolve());
    
        const [session, pageProps] = await Promise.all([
          getAuthenticationSession(),
          componentGetInitialProps(ctx),
        ]);
    
        return {
          session,
          pageProps
        };
      }
    }
    
    export default App;

    API Routesは便利だが用途をよく考えよう

    /pages/api/*.tsxは特殊なRouteで、Reactコンポーネントを持たない代わりにHTTPレスポンスをどう返すかを記述し、さもウェブサーバーを書くかのようにWeb APIとしてのエンドポイントを作ることができます。

    このAPI Routesを使うとクライアントサイドもサーバーサイドも兼ねた真にフルスタックなアプリケーションを作ることもできますが、Next.jsではUniversalなコードを書くように心掛ける必要のある箇所が多く、あまりに多くAPI Routesを作ってしまうと必要以上に複雑になりかねません。

    あくまでフロントエンドを補佐するようなAPIを作る用途に限定して使うといいと思います。同じNodeで書くにしても、別のAPIサーバーを作る形にした方がNodeに限定したコードを書くことができるようになり、利用できるAPIの制限がなくなりますし、クライアントアプリケーションとして配信されるバンドルサイズのことを気にしなくて良くなります。

    SitemapやRSS/Atomフィードもレスポンスできる

    pages/sitemap.xml.tsxのような名前のRouteコンポーネントファイルを作ると、パスは/sitemap.xmlとして機能します。また、RouteコンポーネントのgetInitialProps()の中でres.send()すると単なるHTTPエンドポイントとして機能し、どんなHTTPレスポンスでも返せます (サンプル) 。あらゆるRouteは<Link>で指定されていない限りはクライアントサイドレンダリングで辿り着くことはないので、Routeコンポーネント本体はnullを返せばOKです。これを利用するとSitemapやRSS/Atomフィードなども配信できます (例として、このウェブサイトのAtomフィードはこちらです) 。

    シングルトンも検討する

    すべてのRouteはpages/_app.tsxを通ります。ここでDIコンテナを用意してReactのPropsやContextを通じて各Routeコンポーネントに注入するようにすると一見上手くいくように見えます。しかし、ページごとにサーバーサイドレンダリングされ、その後シングルページアプリケーションとしてのエントリポイントにもなることを思い出してください。DIコンテナにすべてのページで必要な依存物をスーパーセット的に詰め込んでしまうと、サーバーサイドレンダリングでそのRouteにとって無駄な処理をしてしまうどころか、Next.jsが自動的にコード分割をできなくなってしまい、HTTPで配信されるJSファイルのサイズも肥大化してしまいます。

    直接依存させるか共通のシングルトンのオブジェクトを用意するなどして、各Routeで必要なだけ参照するようにするという形を取ることも時には検討しましょう。その場合でも多くのテストフレームワークではモジュールごとモックする仕組みを持っているのでなんとかなります。

    Next.jsとNowの関係

    Next.jsはIaaSのNowを提供しているZEITがメインで開発しています。NowにはNext.js向けの自動設定が施されていて、Next.jsアプリケーションをNowにデプロイするとビルドの後に自動的にpages/**/*.tsxごとにCloud Functionが作成されエントリポイントごとにサーバーサイドレンダリングされるようになります。基本的にNowにデプロイすればめちゃくちゃ上手く動いてくれるという感じです。

    ではGCPやAWS、Netlifyなどでどうかというと、そのあたりの処理は自分で書かなければなりません。Next.jsではカスタムのサーバーサイドのシングルエントリポイントを作れるので、それをAppEngineやEC2上で動かすのがGCPやAWSでの現実解となると思います。Netlifyの場合はnext exportで静的なHTMLとJSを生成した上で静的に配信し、ブラウザでシングルページアプリケーションとして動作させます。この場合はgetInitialProps()を使った動的なサーバーサイドレンダリングは行えません。

    ロックインというほどじゃないのですが、この辺りはZEITのビジネスモデルが上手いなーと思います。

    まとめ

    ウェブアプリケーション用途はもちろん、ウェブサイト用途でも妥協なく快適に作れてすごくいい感じです。

    Nowが使えるなら、非常に上手く動くウェブアプリケーションをゼロコンフィグで作れます。何らかの都合でNowが使えなくてもサーバーサイドレンダリングなしで使えばいいと思います。その際にも、Routing、<head>内の要素を変更する機能、静的ファイルの配信、CSS in JSなどが入ったWebpack設定不要のReactフルスタックフレームワークと考えれば充分に便利なものです。