SPA(React)にGoogleAnalyticsを導入する際のパターン

(記事にするほどではないのですが)
Reactを使ったSPAでGoogle Analyticsを使用する場合のパターンをまとめます。

## 準備

#### パターン1. `react-ga`を使う

##### メリット
* HTMLにスニペットを追加したりしないでモジュールバンドラで完結する
* `<OutboundLink>`コンポーネントが用意されているほか、多機能ではある
* 簡潔に書ける（トラッキングIDをいちいち指定しなくてもいいなど）

##### デメリット
* 最新の`gtag.js`ではなく`analytics.js`が使用されている
* ファイルサイズ(17.8KB)

```js:使い方
import ReactGA from 'react-ga';

ReactGA.initialize('トラッキングID');
ReactGA.pageview('任意のpath_name');
```

#### パターン2. `gtag.js` + 公式のスニペットを使う

メリット・デメリットは`react-ga`を使う場合の正反対です。
[公式のドキュメント](https://developers.google.com/analytics/devguides/collection/gtagjs/?hl=ja)にしたがってHTMLの`<head>`直下にコードを追加します。

```html
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag('js', new Date());

  // gtag('config', 'GA_MEASUREMENT_ID');
</script>
```

最後の行は通常の方法でページビューを送信しているわけですが、これからSPA特有の対応を追加しますので、コメントアウトしています。

## React Routerとの組み合わせ方

通常の（昔ながらの意味でのSSRの）場合と違って考慮する必要があるのは、ページ遷移の時にページビューの送信をどう処理するかという問題です。
が、それほど難しいことではありません。分析の方針にもよると思いますので、ここでもいくつかパターンを挙げるにとどめます。

サンプルコードは全て`react-ga`を使わず`gtag.js`を使っています。
また、トラッキングIDはそのまま入れるように書いていますが、定数ファイルに持たせたり、`create-react-app`の環境変数を使うなどするのがベターだと思います。

#### 1. `history.listen`でページ遷移のたびにそのままページビュー処理をする

HashRouterを使用した例です。

```jsx:App.js
import React, { useRef, useEffect } from 'react';
import { HashRouter as Router, Route } from 'react-router-dom';
import Index from './containers/Index';
import Member from './containers/MemberDetail';

export default function App() {
  const router = useRef(null);

  useEffect(() => {
    router.current.history.listen((location) => {
      window.gtag('config', 'トラッキングID', {
        'page_path': location.pathname
      });
    });
  });

  return (
    <Router ref={router}>
      <div className="app-container">
        <Route path="/" exact component={Index} />
        <Route path="/members/:id" exact component={MemberDetail} />
      </div>
    </Router>
  );
}
```

単純な場合にはこれでも十分かもしれませんが、この場合は送信している`pathname`だけでなく、URLが少しでも変化したらリスナー関数が呼ばれページビューが送信されるので注意が必要です。
クエリパラメータなども含めて送信したい場合は

```js
window.gtag('config', 'トラッキングID', {
  'page_path': `${location.pathname}${location.search}`
});
```

このように`location.search`を含めることで、`page_path`に`/pathname?q=foo`というようにURLがセットされます。

#### 2. ページ単位の`componentDidMount`あるいは`useEffect`にページビュー処理を持たせる

どのみちタイトル（`document.title`）をページごとに変更する場合だったり、ページごとに臨機応変に送信するパスを変えたい（例えばURLにユーザIDが入るがアナリティクスには共通の値を送りたいなど）場合はページ単位のコンポーネントにページビューの処理を持たせます。

**クラスコンポーネントの場合**

```js:Page.js
class Page extends Component {
  componentDidMount() {
    document.title = 'ページタイトル'; // ページタイトルを変更
    window.gtag('config', 'トラッキングID', {
      'page_path': '/foo' // 任意のパス（あるいはthis.props.location.pathnameなど）
    });
  }

  ...
}
```

**FCの場合**

```js:Page.js
function Page(props) {
  useEffect(() => {
    document.title = 'ページタイトル'; // ページタイトルを変更
    window.gtag('config', 'トラッキングID', {
      'page_path': '/foo' // 任意のパス（あるいはprops.location.pathnameなど）
    });
  }, []);

  ...
}
```

ページごとのコンポーネントの中でも変化に応じてページビューを送信したい場合は`componentDidUpdate`を使ったり`useEffect`の第二引数を変えるなどして適宜処理します。

#### 3. ページ単位の高階コンポーネントの`componentDidMount`あるいは`useEffect`にページビュー処理を持たせる

前述のページタイトルを変更する処理もそうですが、ページ単位のコンポーネントは何かしら共通する部分も出てくるはずです。
そういった場合に[`react-ga`のサンプルにある](https://github.com/react-ga/react-ga/blob/master/demo/app/withTracker.jsx)ように、ページを高階コンポーネントでラップするというのも有用なパターンです。
下のサンプルは、`react-router`のクエリをパースする高階コンポーネントについでにページビュー処理を追加してみたものです。

```js:createPage.js
import { createElement, useEffect } from 'react';
import PropTypes from 'prop-types';
import queryString from 'query-string';
import assign from 'lodash.assign';
import { QUERY_STRING_OPTIONS } from '../constants/common';

/**
 * react-routerのlocationからクエリを取得して扱いやすくするためのラッパーコンポーネント
 * GAページビューイベントも送信
 * @param {Component} WrappedComponent
 * @param {string} title
 * @param {string} gaPagePath
 * @returns {function}
 */
export default (WrappedComponent, title, gaPagePath) => {
  function Page(props) {
    useEffect(() => {
      document.title = title;
      window.gtag('config', 'トラッキングID', {
        'page_path': gaPagePath || props.location.pathname
      });
    }, []);

    /**
     * クエリを渡してハッシュ遷移
     * @param {string} path
     * @param {object} query
     */
    function navigateWithQuery(path = '', query) {
      const { history, location } = props;
      let qs = queryString.stringify(query, QUERY_STRING_OPTIONS);
      if (qs) {
        qs = `?${qs}`;
      }
      if (qs !== location.search) {
        history.push(`${path ? `#${path}` : location.pathname}${qs}`);
      }
    }

    const { location } = props;
    const query = queryString.parse((location.search).split('?')[1], QUERY_STRING_OPTIONS);
    return createElement(
      WrappedComponent,
      assign({}, props, { query, navigateWithQuery }),
    );
  }

  Page.propTypes = {
    location: PropTypes.shape({
      hash: PropTypes.string.isRequired,
      search: PropTypes.string.isRequired,
      pathname: PropTypes.string.isRequired,
    }).isRequired,
    history: PropTypes.shape({
      push: PropTypes.func.isRequired,
    }).isRequired,
  };

  return Page;
};
```

## `react-helmet`と組み合わせる場合

[`react-helmet`](https://github.com/nfl/react-helmet)はReactのレンダリング内で（つまり、通常JSX内で）`<head>`の`<title>`や`<meta>`などを動的に書き換えることができるライブラリです。
あまり難しいことはなく、以下のように使うのですが、

```jsx:Page.js
import React, { useEffect } from 'react';
import Helmet from 'react-helmet';

function Page(props) {
  useEffect(() => {
    console.log('title', document.title); // この時点ではまだ変更されていない
    window.gtag('config', 'トラッキングID', {
      'page_path': '/foo'
    });
  }, []);

  return (
    <div className="container">
      <Helmet>
        <title>ページタイトル</title>
        <meta name="description" content="ページの説明" />
      </Helmet>

      ...

    </div>
  );
}
```

微妙なことに、`<Helmet>`コンポーネントをレンダリングした直後、つまりこの場合`componentDidMount`や`useEffect`時にはタイトルなどは変更されていません。

```js:Page.js
  useEffect(() => {
    console.log('title', document.title); // この時点ではまだ変更されていない
    window.setTimeout(() => {
      console.log('title', document.title); // この時点では変更済み
      window.gtag('config', 'トラッキングID', {
        'page_path': '/foo'
      });
    });
  }, []);
```

このようにお馴染みの`setTimeout(,0)`を使って`Helmet`が処理してくれるのを待ってからページビューを処理すると、タイトルが正常に反映されます。
![アナリティクス.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/6eb67a47-5ccf-381a-0587-0b77364e5d97.jpeg)

上記のサンプルコードのように、`react-helmet`を使う場合はページ単位のコンポーネントに副作用を持たせるわけなので、必然的にページ単位のコンポーネントでページビューの処理も一緒にすることになると思います。

#### [蛇足] `react-helmet`のあまりよくないところ

まずmetaタグで設定しているOGPはJSで動的に設定してもTwitterやFacebookには認識されない（解決したい場合はSSR）というのは周知の事実なので置いておいて、`react-helmet`でmetaタグを変更しようとすると、このようにあらかじめHTMLに入っていたmetaタグと重複して追加されてしまいます。

<img width="638" alt="ページタイトル.jpg" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/f67d15af-ca03-1597-4e26-2feae483262a.jpeg">

微妙ですがHTMLの方にあらかじめ`data-react-helmet`属性を入れておくと追加されずにちゃんと更新されるようです。

```html
<meta name="description" content="デフォルト説明" data-react-helmet="true" />
```