Aokashi Room

作った作品の紹介やレビュー、トラブルシューティングとか色々

Gatsby「window is not defined」

Aokashi Home のトップページでは、デバイスの画面サイズに応じてトップ画面を変えています。これをヒーローヘッダーと言うそうですね。

f:id:aokashi:20191130011119p:plain
トップページのヒーローヘッダー

ヒーローヘッダーの UX を修正しようとしたら、 Aokashi Home で使用している Gatsby から差し止められたお話です。

以前の実装について

以前はこのヒーローヘッダーを実装するにあたって、下記の CSS を充てていました *1

.firstScreen {
    height: 100vh;
}

100vh とは、ブラウザー領域の高さそのままです。PC であればこれで良いんですが、スマートフォンの場合だとスクロールする際に一瞬引っかかりが生じてしまうんです。

なぜかと言うと、スマートフォンブラウザーはスクロールすると上部のアドレスバーが隠れる仕組みになっていまして、隠れた分ブラウザー領域が広がるのでスクロールが広がった分上に戻ってしまうそうです。

図を作るのが下手なのでどうなるのか手順で説明しますと・・・

  1. ユーザーがブラウザーをスクロール
  2. ブラウザーがスクロールを検知し、上部のアドレスバーを隠す
  3. アドレスバーを隠したことで、ブラウザー領域の高さ (100vh の実際の値) が更新される
  4. ブラウザー領域の高さが変更されたことを検知し、ヒーローヘッダーの高さも更新 (下に伸びる)
  5. ヒーローヘッダーのサイズ変更により、 ブラウザーで表示されている画面が上にずれ込む

これだと UX 的に良くないので、予め高さをスタイルシートに設定した形に変更してみました。

しかし、これが一筋縄では行かなかったのです。

解決方法

  1. あらかじめブラウザー画面の高さのステートを用意
  2. window.innerHeight から 1. のステートに設定する処理を useEffectcomponentDidMount で実装
  3. 1. のステートの値がブラウザー画面の高さになる

React の Hooks を活用すると下記になります。

  const [screenHeight, setScreenHeight] = useState(0)
  useEffect(() => {
    setScreenHeight(window.innerHeight);
  }, [])

  return (
    <>
      <div style={{ minHeight: `${screenHeight}px` }}>
        ...
      </div>
    </>
  )

実行したこと

1. window.innerHeight からレンダリング時に渡す

  // スマートフォンではスクロール操作でアドレスバーが隠れてしまい、 UX 的に良くないので先に高さを設定して割り当てる
  const firstScreenStyle = {
    minHeight: `${window.innerHeight}px`
  }

  return (
    <>
      <Helmet>
        <body className={styles.indexBody} />
      </Helmet>
      <div className={`${styles.firstScreen} container`} style={firstScreenStyle}>
        <div className={styles.title}>
          <Img fluid={data.file.childImageSharp.fluid} alt={data.site.siteMetadata.title} />
        </div>
        ...
      </div>
    </>
  )

Aokashi Home では Gatsby を使用して Web サイトを生成しています。 JavaScript も扱えるので、簡単だーと思いながら上図のように、メソッド内にそのまま割り当てました。

これだと develop 時には動くので安心かと思いきや、ビルド時にコケました。 WebpackError: ReferenceError: window is not defined と出てきます。

これについて調べたところ、ちゃんと Issue にもありました。

github.com

これは自分の解釈も含まれるのですが、ビルドの際は Node.js の中で動いている関係で、ブラウザーの操作に関わる DOM 関係の変数が入っていないことから、 window が無いとエラーが発生するそうです。

2. スタイルシートを条件付きに変える

前述のことから、 window があるか無いかを確かめるだけでも良いのではと思い、実装を変更しました。

  // スマートフォンではスクロール操作でアドレスバーが隠れてしまい、 UX 的に良くないので先に高さを設定して割り当てる
  const firstScreenStyle = window !== undefined ? {
    minHeight: `${window.innerHeight}px`
  } : {}

しかし、それでも WebpackError: ReferenceError: window is not defined と出てきます。

Gatsby さん神経過ぎませんかねえ・・・。

3. ステートから入れる

(ここから急に React の要素が増えます。)

前述の Issue を見ると、 componentDidMount 内で使用すれば、ビルド時には確認されないとのことでした。

componentDidMount とは、 React のコンポーネントで予め用意されているメソッドのことで、 (誤解を恐れずに言うと) コンポーネントが作成されて初期化が終わったタイミングで呼び出されます。おそらく、初期化の中では DOM に関わる処理があるために componentDidMount はビルド時には確認されないのでしょう。

なので、コンポーネント内にヒーローヘッダーの高さのステートを定義して、 componentDidMount のところでステートを更新すれば良さそうだと思いました。

ところが、今のトップページのレイアウトは関数コンポーネントなので componentDidMount は使用できません。

そこで React の Hooks を借りてみると、 useEffect を使用することで実現可能だと分かりました。

ja.reactjs.org

  const [screenHeight, setScreenHeight] = useState(0)
  useEffect(() => {
    setScreenHeight(window.innerHeight);
  }, [])

  return (
    <>
      <Helmet>
        <body className={styles.indexBody} />
      </Helmet>
      <div className={`${styles.firstScreen} container`} style={{ minHeight: `${screenHeight}px` }}>
        <div className={styles.title}>
          <Img fluid={data.file.childImageSharp.fluid} alt={data.site.siteMetadata.title} />
        </div>
        ...
      </div>
    </>
  )

いけました 👏

分かったこと

  • ヒーローヘッダーは直感で実装できても副作用が起こる
  • Gatsby はビルド時と閲覧時で環境が違うので DOM に関わる変数を扱う際は要注意
  • React Hooks の useEffect はクラスコンポーネントで言う componentDidMountcomponentDidUpdate

おまけ: どうしても window の存在可否を使いたい方に

React わからん! どうしても window の存在チェックで実装したいんだ! という場合は typeof window !== "undefined" でできるはずです。動かしたことが無いので保証はしませんが・・・。

  // スマートフォンではスクロール操作でアドレスバーが隠れてしまい、 UX 的に良くないので先に高さを設定して割り当てる
  const firstScreenStyle = typeof window !== "undefined" ? {
    minHeight: `${window.innerHeight}px`
  } : {}

下記を参考にしました。

https://www.terrier.dev/blog/2018/20180405000000-gatsby-window-error-referenceerror-window-is-not-defined/www.terrier.dev

2020年5月10日追記: useEffect の仕様について

useEffect は第2引数を指定しないと useEffect の第1引数のメソッドを高頻度で実行することになるため、パフォーマンスの悪化に繋がるとの警告メッセージをもらいました。

詳しく言うと、第2引数は useEffect の第1引数を実行するために監視する変数を配列形式に指定するそうで、 指定した変数が変化した場合だけ useEffect の第1引数のメソッドが実行されます。指定することで、不必要な処理の実行が省けるためパフォーマンスの向上が見込めます。

そのため、上記のソースコードも追記修正をしました。

2020年11月22日追記

結局どう解決したのか明確な情報が含まれていなかったので解決方法を追記しました。

*1:実際は sass で行っているのですが