• ..

NextJS

    5分で "Hello from NextJS"

    とりあえず NodeJS のプロジェクトを作りたいので、以下でpackage.jsonを作っておきます。

    yarn init -y

    依存インストール

    そして、nextをインストールします。nextreactreact-domが共にインストールされていることを期待しているのでその2つもインストールします。

    yarn add next react react-dom

    設定ファイル作成

    next.config.jsというファイルを作ります。とはいっても今はただ"Hello from NextJS"と表示させたいだけなので設定内容はただの空オブジェクトです。

    // next.config.js
    module.exports = {};

    ページ作成

    NextJS では/pagesディレクトリに実際のページとなるファイルを作成していきます。/pages/index.jsxindex.htmlというような意味になるのでこのファイルを作成しましょう。

    // /pages/index.jsx
    export default () => <h1>Hello from NextJS</h1>

    ES で書かれていますが、 NextJS は逆に ES でしか書くことができないのでこれで大丈夫です。

    ページ確認

    ./node_modules/.bin/next dev -p 3333と実行しましょう。サーバーが立ち上がり、localhost:3333へアクセスして"Hello from NextJS"と表示されれば大丈夫です。

    対象になる pages/ ディレクトリを変更(ソースディレクトリの変更)

    nextコマンドの引数の最後はdirです。これはデフォルトでは.が使われて、これで./pagesがページとして処理される形になっています。
    なのでsrc/pagesに変更したい場合は、このように実行するだけです。

    next src
    next build src
    next export src

    他のコマンドでも同じようにsrc指定が必要です。ちなみにnext.config.jsdirを設定でいいのではと思うかもしれませんが、これを書いてる時点のnext@^8.0.3では何故か効きません。

    dir (string) where the Next project is located - default '.'

    らしいですが。。

    ページ間移動時のアニメーションを実装

    react-transition-group_app.jsxの中で使います。_appファイルで定義したコンポーネントはクライアント側で唯一マウントし続けるコンポーネントで、このコンポーネントが受け取るプロパティのComponentに対象のpages/*.jsxファイルのコンポーネントが入る形で来ます。

    以下にフェードイン・アウトを想定した例です。( TypeScript ) router.routeCSSTransitionkeyとして使っています。これはページ移動した時にrouter.routeが切り替わり、古いkeyのものはexitに向かい、新しいkeyのページコンポーネントはenterに向かうような形になります。Componentは1つしかないはずですが、TransitionGroupを使うことでアニメーション動作中に限りそれら新古のページコンポーネントを共存させられます。アニメーション中はTransitionGroupが生かしてくれる為です。

    export default class extends App {
      addEventListener = (node: HTMLElement, done: () => void) => {
        const handle = () => {
          node.removeEventListener('transitionend', handle);
          done();
        };
    
        node.addEventListener('transitionend', handle);
      };
    
      render() {
        const {Component, pageProps, router} = this.props;
    
        return (
          <Container>
            <Provider store={store}>
              <Fade>
                <TransitionGroup>
                  <CSSTransition
                    key={router.route}
                    classNames="fade"
                    timeout={1500}
                    addEndListener={this.addEventListener}
                  >
                    <Component {...pageProps} />
                  </CSSTransition>
                </TransitionGroup>
              </Fade>
            </Provider>
          </Container>
        );
      }
    }

    ちなみにFadestyled-componentsになります。これで.fade-<suffix>クラス名を使いアニメーションを実装します。

    import styled from 'styled-components';
    
    const Fade = styled.div`
      .fade-enter {}
      .fade-enter-active {}
      .fade-enter-done {}
    
      .fade-exit {}
      .fade-exit-active {}
      .fade-exit-done {}
    `;

    Link (next/link) の as 属性は何なのか

    NextJS のドキュメントを見ると以下のように使われている例が載っています。このasが何に使われているのかです。

    <Link href="/post?slug=something" as="/post/something">

    href

    hrefに設定する値は NextJS が見る URL です。
    NextJS では /pages 以下にページファイルを作っていきます。例えば /foo という URL にページを作りたいなら/pages/foo.jsxというファイルを作り、Linkhref属性には/fooを設定すればいいです。

    as

    asはブラウザが見る URL です。 例えば、nantoka.com/hoge/以下で NextJS で作ったサイトを公開したい場合、<Link href="/foo" />ではnantoka.com/foo/にページ遷移してしまい、リロードすると NextJS 外の URL なので正常にサイトを表示できなくなってしまいます。
    そのような場合にas属性に/hoge/fooと指定してあげることで、ブラウザの URL は nantoka.com/hoge/fooとしながら、表示するページは/foo(/pages/foo)とすることができます。

    Polyfil ファイルを Webpack の entry に含める

    例えばこのサイトだとIntersectionObserverを使っていて、これはスマホなどでまだ対応しておらず見れないことが多いので含めています。

    なぜ Webpack の entry に含めたいのか

    <script>での読み込みは Lighthouse のスコアを下げる

    HTMLで以下の Polyfil を読み込むことで、IntersectionObserverを対応していないサイトでも使えるようにできます。

    <script src="https://polyfill.io/v2/polyfill.min.js?features=IntersectionObserver"></script>

    しかしこれは、Minimize Critical Requests Depthとして引っかかってしまいます。

    NextJS の entry ファイルのルートで Polyfil を読み込む

    上のnpm版である、intersection-observerを使います。

    Polyfil は基本エントリーの最初に読み込むファイルの先頭にimportを記載すれば使えるので、_app.[jt]sx?をその形にすれば使えそうです。しかし、この Polyfil は中で window オブジェクトへアクセスしている部分があり NextJS ではその_appファイルも SSR で使われるので、 「windowundefinedだよ」なエラーが起こってしまうのでこれも駄目です。

    // _app.tsx
    import 'intersection-observer';

    NextJS の Webpack-Config の entry に含める

    next.config.jsからwebpack設定を自身の設定で上書きする方法です。

    next.config.jsのデフォルトentry() => Promise<{[x: string]: string[]}>な形をしていて、このオブジェクトを上書きすれば良さそうです。以下は戻り値の例です。

    Promise {
      { 'main.js': [],
        'static/runtime/main.js':
         [ '/Users/nju33/github/blog/node_modules/next/dist/client/next-dev' ],
        'static/development/pages/_app.js': [ './pages/_app.tsx' ],
        'static/development/pages/_error.js':
         [ '/Users/nju33/github/blog/node_modules/next/dist/pages/_error.js' ] } }

    このmain.jsへ含めてます。

    {
      webpack: config => {
        const originalEntry = config.entry;
        config.entry = () => {
          return originalEntry().then(entry => {
            entry['main.js'] = 'intersection-observer';
            return entry;
          }
        }
      }
    }

    ただまだこれだと、サーバーサイド用のビルドファイルにもintersection-observer Polyfil が含まれてしまい、上記と同じエラーが起きてしまうので、含めるのは Client 用のビルドファイルだけにする必要があります。
    幸いにも、next.config.jswebpack関数は第二引数にoptions.isServerというフラグを渡してくれます。これは Client 用のビルド時にはfalseになってくれるフラグです。

    つまり、

    • options.isServertrueのときは、デフォルトのままのentryを返す
    • falseのときは、intersection-observerを含める

    とすれば解決できそうです。config.entryを以下のように改良してみます。

    {
      webpack: (config, {isServer}) => {
        const originalEntry = config.entry;
        config.entry = () => {
          if (isServer) {
            return originalEntry;
          }
    
          return originalEntry().then(entry => {
            entry['main.js'] = 'intersection-observer';
            return entry;
          }
        }
      }
    }

    上のように変更したところ Lighthouse のスコアがあがり、かつIntersectionObserverに対応していない端末も対応することができました。

    getInitialProps と react-redux の connect で渡す props は後者優先

    以下では、getInitialPropsfoo: 1という値で返しているので、this.props.fooには1が入ってきます。が、以下のようにconnectでバインド同じプロパティに対してバインドしてしまうとconnectで渡したmapStateToPropsの値で上書きされてしまいfoo: 9になってしまいます。

    class FooBase extends React.Component {
      static getInitialProps() {
        return {
          foo: 1
        }
      }
    
      render() {
        console.log(this.props.foo)
        // output: 9
    
        return <div>foo</div>
      }
    }
    
    connect(() => ({foo: 9})(FooBase)

    少し考えれば当たり前な気もしますが、少しハマりました。ステート構造を見直すなどするといいかもしれません。

    サーバーサイドでのみデータを通信して取得後、クライアントサイドでもそれを使いレンダリング

    getInitialPropsスタティックメソッドは React のライフサイクルのメソッド群のようにサーバーサイドでもクライアントサイドでも呼び出されます。サーバーサイドで返した値はwindow.__NEXT_DATA__から取得できるので、クライアントサイドではここから値を取得することで通信なしでいける場合があります。

    req か res を使ってサーバーサイドでのみ実行される処理を書く

    (追記@2019-05-26) 以下のような判定をしなくてもprocess.browserという変数が設定されていました。名前から察せれるようにサーバー側ではfalseでブラウザ側ではtrueになるのでこれを使ったほうが楽です。next@^8.1.0での確認です。
    というよりnext@8ぐらいからgetInitialPropsはサーバーでしか動かなくなったかもしれないので以下は不要かもしれません。

    このgetInitialPropsに渡ってくるコンテキストのreqresというプロパティはサーバーサイドだけでしか渡ってこない値です。ですのでこれを使ってサーバーサイド判定ができます。

    以下は例コードです。reqがある場合はサーバーサイドなのでデータを取得する処理を行い、ない場合(クライアントサイド)ではサーバーサイドで返した値をただ渡すことでクライアントサイドでの通信を無くしています。。

    class extends React.Component {
      static async getInitialProps({req}) {
        let data;
        if (req) {
          data = await axios.get('https://xxx.com/xxx')
        } else {
          data = window.__NEXT_DATA__.props.pageProps.data;
        }
    
        return {data};
      }
    }

    ターミナルログを非表示にする

    やりたいこと

    SSR 中のログや、別の所から出したログなどが NextJS ビルとインの(リフレッシュと)ログで消されてしまうので一時的に NextJS のログを表示されないようにしたい。
    ちなみにこれのこと。

     [DONE]  Compiled successfully in 2090ms
     [WARNING]  Compiled with 1 warnings

    解決

    このログは geowarin/friendly-errors-webpack-plugin というプラグインを使って出しているので、このプラグインを取り除きます。
    next.config.jswebpack で設定を以下のように変えます。

    const path = require('path');
    
    module.exports = {
      webpack(config, {dev}) {
        if (dev) {
          config.plugins = config.plugins.filter(
            plugin => !(plugin.constructor.name === 'FriendlyErrorsWebpackPlugin')
          );
        }
    
        return config;
      }
    };

    相対パスな URL に静的ビルドした成果物を置く

    例えばhttps://example.com/gameという URL に成果物をアップロードするためにどこか/gameというディレクトリに置く必要があるとします。 その時に、デフォルト設定の成果物をアップロードしてしまうと、 NextJS は/_next/xxx.jsを読もうとしますが実際には/game/_next/xxx.jsである必要がある為読み込むことができません。

    そんな時には、next.config.jsassetPrefixを設定します。これで生成されるファイルの参照パスの頭に指定したパスを付けることができます。 また、一応NODE_ENV=productionを付けた時だけプレフィックスを付けるという設定にしています。 これは成果物をローカルで確認したい時にローカルサーバーを建てて見るだけで簡単にチェックできるようにするためです。(/game/_next/xxx.jsとかになるとそういうディレクトリ構造でサーバーを建てないと駄目で面倒)

    const isProd = process.env.NODE_ENV === 'production';
    module.exports = {
      assetPrefix: isProd ? 'https://example.com/game' : '',
    };

    package.jsonscriptsは以下のようにします。

    {
      "scripts": {
        "next.build": "next build",
        "next.export:prod": "NODE_ENV=production next export",
        "next.export:dev": "next export",
        "build:prod": "yarn next.build && yarn next.export:prod",
        "build:dev": "yarn next.build && yarn next.export:dev
      }
    }

    あとは適当なbuild.*を実行するといいと思います。

    yarn build:prod # 本番用ビルド
    yarn build:dev  # 確認用ビルド

    Head の chilren として title タグを含む SFC を入れても title 要素にテキストが入らない場合

    問題

    こんなSFCを用意してました。

    export const Meta = ({title}) => {
      return (
        <>
          <meta name="twitter:title" content={title} />
          <meta property="og:title" content={title} />
          <title>{title}</title>
        </>
      );
    };
    

    title: 'foo'として展開後こんな感じで入ってくれません。。

    <meta name="twitter:title" content="foo" />
    <meta property="og:title" content="foo" />
    <title></title>

    解決

    とりあえず直下に持っていけば大丈夫でした。

    <Head>
      <Meta title={title} />
      <title>{title}</title>
    </Head>

    Head コンポーネントの中身は頭に追加される

    問題

    例えば_document.jsx<head>のデフォルトを定義して各ページでそれを上書きしようとしてもできないものがある。

    _document.jsx<head>

    <head>
      <title>foo</title>
    </head>

    pages/index.jsx<Head>

    const Head = require('next/head');
    
    <Head>
      <title>bar</title>
    <Head>

    結果

    <title>bar</title>
    <title>foo</title>

    解決

    ページ毎にすべて<Head>に書く。
    まぁ<title>は何かうまくやってくれるみたいですが、ツイッターカードとかはうまく取得できてませんでした。

    lodash-es を使う

    今はnext@8.0.3が最新です。デフォルト設定そのままだと以下のようになってしまいます。

    export {default as add} from './add.js'
    SyntaxError: Unexpected token export

    直す

    これにはnext-transpile-modulesを使います。next.config.jsを以下のようにすると直るはずです。

    const withTM = require('next-transpile-modules');
    
    module.exports = withTM({
      transpileModules: ['lodash-es'],
    });

    没案

    ちなみに以下のような設定はどちらも駄目でした。。

    lodash-es にも next 設定の babel をかます

    NextJS の Webpack はnode_modules以下をトランスパイルの対象にしていないようで以下のようにしてみましたが駄目でした。

    module.exports = {
      webpack: (config, options) => {
        config.module.rules.push({
          test: /\.js$/,
          include: /node_modules\/lodash-es/,
          use: [
            options.defaultLoaders.babel
          ],
        });
    
        return config;
      }
    }

    自分で設定

    babel-loader, @babel/core, @babel/plugin-transform-modules-commonjs辺りを入れて自分で設定を追加してみましたがこちらも駄目でした。

    module.exports = {
      webpack: config => {
        config.module.rules.push({
          test: /\.js$/,
          include: /node_modules\/lodash-es/,
          use: [
            {
              loader: 'babel-loader',
              options: {
                plugins: ['@babel/plugin-transform-modules-commonjs']
              }
            },
          ],
        });
    
        return config;
      }
    }

    commonjs で esnext なパッケージを一緒に使う

    対象

    cjs で書かれている。つまり以下のように書かれてるもの。例えば、sindresorhus/ky

    module.exports = /* ... */

    esnext なobject-rest-spread-propertiesなどが使われてる。

    // 例: ky/index.js
    returnValue = {...returnValue, [key]: value};

    問題

    • 対象のファイルを Babel で変換しないとエラー
    • 依存のnext-babel-loaderの中でnextが読み込む.babelrcの設定がmodules:falseなので変換できても cjs が解決できない
    • next.config.js上で Babel の設定を上書きできない
    • .babelrcでは処理的に 1 つの設定しか持てない
    • 自分のソースコードは cjs にしたくない
      webpack(config, {isServer}) {
        if (isServer) {
          return config;
        }
    
        config.module.rules.map(rule => {
          if (
            rule.test.test('.js') &&
            Object.prototype.hasOwnProperty.call(rule, 'exclude') &&
            rule.exclude.test('node_modules')
          ) {
            rule.exclude = /node_modules\/(?!ky)/;
            // Module build failed: Error: Duplicate plugin/preset detected.
            rule.options = {
              presets: ['next/babel', {
                'preset-env': {
                  modules: 'commonjs'
                },
              }]
            }
          }
          return rule;
        });
    
        return config;
      },

    解決

    やっぱりこれだけにルール追加するぐらいしかなかった。(読み込んでるプラグインなどはデフォルトの設定と同じもの)

      webpack(config, {isServer}) {
        if (isServer) {
          return config;
        }
    
        config.module.rules.unshift({
          test: /\.jsx?$/,
          include: /node_modules\/ky/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: ['@babel/preset-env'],
              plugins: [
                '@babel/plugin-proposal-class-properties',
                '@babel/plugin-proposal-object-rest-spread',
                '@babel/plugin-transform-runtime',
              ],
            },
          },
        });
    
        return config;
      },

    TypeScript で書く

    next@8.1.1より@zeit/next-typescriptを使わずにいけるようになりました。Next プロジェクトルートにtsconfig.jsonを起き以下を設定します。

    {
      "allowJs": true,
      "skipLibCheck": true,
      "forceConsistentCasingInFileNames": true,
      "noEmit": true,
      "resolveJsonModule": true,
      "isolatedModules": true,
      "jsx": "preserve"
    }

    これらは Next 側で強制的に上書きされてしまうので上記のまま使うようにします。

    あとはそのままページを.tsxで書いていくだけです。

    いくつかの記法が使えない

    以下の記法を使うとエラーになります。(babelが対応してない?)

    • const enum
    • namespace

    注意

    declare module '<package-name>' とそれを参照するtypesRoottsconfig.jsonに設定してしまうと、「そんなモジュールない」というエラーが起きてしまうようです。

    NextJSでブラウザバック時にエラーでページ移動しない時があるので応急処置する

    環境

    • next@^6.1.1

    追記(2019-01-13)

    next@^7にしたら治ったかもしれない…?

    確認した事例

    ブラウザバックした時に時々でる。特に最初にアクセスしたページに移動する時は9割ぐらい起きてると思う。 ログはこんな感じ。

    router.js:533 Uncaught (in promise) TypeError: Cannot read property 'split' of undefined
        at Router.onlyAHashChange (router.js:533)
        at Router._callee2$ (router.js:269)
        at tryCatch (runtime.js:62)
        ...

    該当箇所のコードはこうなっている。

        key: "onlyAHashChange",
        // どうやらここで渡ってきている`as`が`undefined`なせいで、
        // その先の`as.split`の呼び出しで起きてる模様
        value: function onlyAHashChange(as) 
          // 略
    
          var _as$split = as.split('#'),
              _as$split2 = (0, _slicedToArray2.default)(_as$split, 2),
              newUrlNoHash = _as$split2[0],
              newHash = _as$split2[1]; // Makes sure we scroll to the provided hash if the url/hash are the same

    色々調べたがさっぱり分からないので応急処置

    応急処置というのは、上のasnext/routerRouter.beforePopStateで見ることができるので、もしundefinedだったらJavaScriptでページ再読込するというものです。SPAなのにリロードさせちゃうし正直微妙ですが、何も起きないよりはマシです。

    どこか_app.jsxやネストの浅い所コンポーネントのcomponentDidMountかどこかで以下のように書きます。

    Router.beforePopState(({as}) => {
      if (as === undefined) {
        window.location.href = '/';
        return false;
      }
    
      return true;
    });

    うーん、誰か直せた人いたら教えてくださいー;

    更新(2018-09-01)

    asundefinedだったらページを更新せずにURLだけ/に書き換え。

    Router.beforePopState(({as}) => {
      if (as === undefined) {
        Router.push('/', '/', { shallow: true })
        return false;
      }
    
      return true;
    });

    スマートフォンのブラウザで表示されない

    とりあえず、以下で直った。

    <script async src='https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.23.0/polyfill.min.js' />

    .babelrcの設定とかもちょっと調べたけどよく分からず。
    時間ある時にまた調べよう。

    追記

    IntersectionObserverに対応していないのも原因だった。
    僕の環境だけかもしれないけど、エラーも何も表示してくれなくてすごい分かりづらい。BrowserStackも試したけど別の変なエラーがでただけで役に立たなかった。

    追記(2019-01)

    Polyfil ファイルを Webpack の entry に含める

    起こったエラーメモ

    デプロイ時にずっと「Initializing」のまま

    同プロジェクトでデプロイ中のデプロイメントがあると、それが終わるまで最新のデプロイは「Initializing」になるようです。

    now ls <project-name>

    を実行し、いらないデプロイメントを削除してみると治るかもしれません。

    build 時

    UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, open '//../../.svgo.yml'

    (node:1) UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, open '//../../.svgo.yml'
        at Object.fs.openSync (fs.js:646:18)
        at Object.fs.readFileSync (fs.js:551:33)
        at e.exports (/var/task/.next/server/static/iqdZhcAsT3LfSmq39Ilkz/pages/_document.js:206:307121)
        at new u (/var/task/.next/server/static/iqdZhcAsT3LfSmq39Ilkz/pages/_document.js:18:123)
        at TPFN.t.default.r.default.plugin (/var/task/.next/server/static/iqdZhcAsT3LfSmq39Ilkz/pages/_document.js:20:223985)
        at n (/var/task/.next/server/static/iqdZhcAsT3LfSmq39Ilkz/pages/_document.js:20:50105)
        at /var/task/.next/server/static/iqdZhcAsT3LfSmq39Ilkz/pages/_document.js:206:21288
        at <anonymous>

    now(2)へのデプロイ時に、svgo 付近のパス周りで__dirnameundefinedなのが原因かも。

    postcsscssnano_document.tsxで使っていたのでこれをやめて、さらにpostcsscssnanoをアンインストールして依存から外した。これでsvgo依存も無くなり表示できるようになった。

    TypeError: r.isMemo is not a function

    now(2)へ@now/nextでのデプロイ時に。

    https://github.com/zeit/next.js/issues/5750#issuecomment-451607604のとおりに修正で治りました。

    Error: pages/index.js from Terser

    next.config.jsの Webpack の設定で EnvironmentPluginNODE_ENV=production と上書きしていたので、これをやめた。

    TypeError: Cannot read property 'minify' of undefined

    2019-02-04発生。

    terser@3.14.1を指定する

    yarn add terser@3.14.1

    package.jsonresolutions

    {
      "terser": "3.14.1"
    }

    export 時

    Cannot find module 'pages/_document'

    now 用の設定でtarget: 'serverless'していたのでこれを削除で直った。