(記事にするほどではないのですが)
Reactを使ったSPAでGoogle Analyticsを使用する場合のパターンをまとめます。
準備
パターン1. react-ga
を使う
メリット
- HTMLにスニペットを追加したりしないでモジュールバンドラで完結する
-
<OutboundLink>
コンポーネントが用意されているほか、多機能ではある - 簡潔に書ける(トラッキングIDをいちいち指定しなくてもいいなど)
デメリット
- 最新の
gtag.js
ではなくanalytics.js
が使用されている - ファイルサイズ(17.8KB)
import ReactGA from 'react-ga';
ReactGA.initialize('トラッキングID');
ReactGA.pageview('任意のpath_name');
パターン2. gtag.js
+ 公式のスニペットを使う
メリット・デメリットはreact-ga
を使う場合の正反対です。
公式のドキュメントにしたがってHTMLの<head>
直下にコードを追加します。
<!-- 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を使用した例です。
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が少しでも変化したらリスナー関数が呼ばれページビューが送信されるので注意が必要です。
クエリパラメータなども含めて送信したい場合は
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が入るがアナリティクスには共通の値を送りたいなど)場合はページ単位のコンポーネントにページビューの処理を持たせます。
クラスコンポーネントの場合
class Page extends Component {
componentDidMount() {
document.title = 'ページタイトル'; // ページタイトルを変更
window.gtag('config', 'トラッキングID', {
'page_path': '/foo' // 任意のパス(あるいはthis.props.location.pathnameなど)
});
}
...
}
FCの場合
function Page(props) {
useEffect(() => {
document.title = 'ページタイトル'; // ページタイトルを変更
window.gtag('config', 'トラッキングID', {
'page_path': '/foo' // 任意のパス(あるいはprops.location.pathnameなど)
});
}, []);
...
}
ページごとのコンポーネントの中でも変化に応じてページビューを送信したい場合はcomponentDidUpdate
を使ったりuseEffect
の第二引数を変えるなどして適宜処理します。
3. ページ単位の高階コンポーネントのcomponentDidMount
あるいはuseEffect
にページビュー処理を持たせる
前述のページタイトルを変更する処理もそうですが、ページ単位のコンポーネントは何かしら共通する部分も出てくるはずです。
そういった場合にreact-ga
のサンプルにあるように、ページを高階コンポーネントでラップするというのも有用なパターンです。
下のサンプルは、react-router
のクエリをパースする高階コンポーネントについでにページビュー処理を追加してみたものです。
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
はReactのレンダリング内で(つまり、通常JSX内で)<head>
の<title>
や<meta>
などを動的に書き換えることができるライブラリです。
あまり難しいことはなく、以下のように使うのですが、
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
時にはタイトルなどは変更されていません。
useEffect(() => {
console.log('title', document.title); // この時点ではまだ変更されていない
window.setTimeout(() => {
console.log('title', document.title); // この時点では変更済み
window.gtag('config', 'トラッキングID', {
'page_path': '/foo'
});
});
}, []);
このようにお馴染みのsetTimeout(,0)
を使ってHelmet
が処理してくれるのを待ってからページビューを処理すると、タイトルが正常に反映されます。
上記のサンプルコードのように、react-helmet
を使う場合はページ単位のコンポーネントに副作用を持たせるわけなので、必然的にページ単位のコンポーネントでページビューの処理も一緒にすることになると思います。
[蛇足] react-helmet
のあまりよくないところ
まずmetaタグで設定しているOGPはJSで動的に設定してもTwitterやFacebookには認識されない(解決したい場合はSSR)というのは周知の事実なので置いておいて、react-helmet
でmetaタグを変更しようとすると、このようにあらかじめHTMLに入っていたmetaタグと重複して追加されてしまいます。
微妙ですがHTMLの方にあらかじめdata-react-helmet
属性を入れておくと追加されずにちゃんと更新されるようです。
<meta name="description" content="デフォルト説明" data-react-helmet="true" />