React製サービスにおけるGTMとの付き合い方

## モダンフロント技術におけるGTMの問題
ここでいうモダンフロント技術は、VueやReactなど仮想DOMを用いられた技術を指します。

### 仮想DOMなので「DOMが存在しているとは限らない」
要素の表示や要素のクリックなどのトリガーに関して、`class名`やら`id名`などDOMの情報に対して条件を絞りイベントを制御すると思いますが、必ずしもGTMがその仮想DOMが正しく検知できるとは限らないです。
仮想は消失したり入れ替わったりするものなので。

そのため、GTMの既存の要素の表示や要素のクリックに依存しない設計を理想とします。

### GTMのページビュートリガーが使えない
モダンフロント系の技術では、`React Router`やら`vue-router`などの仕組みによって、
サーバーサイドを介さずにクライアントサイドのみでページの変更を行なっているケースが多いです。

つまり、History APIで更新しているだけなので、ページビュートリガーが機能しないです。

仮に既存のGTMのトリガーを使うとしても、初期ロード時は「ページビュー」、遷移時は「履歴の変更」が発火するため、
2つのトリガーを1つのタグに対して付与する必要があります。
※ 正しく発火する保証はしきれないかなとおもいます...重複するケースがありそうな気もしてます。

:::note
履歴の変更は、URLの一部が変更されたとき、またはpushState APIときを検知するトリガーです。
:::

## モダンフロント技術におけるGTMとの付き合い方
既存のトリガーがすこぶる使えなさそうで「じゃあどうすればいい？GTM使わなければいい？」って思った人もいると思いますが、
ここで`カスタムイベント トリガー`の登場です！

### カスタムイベント トリガーとは？
カスタムイベントトリガーは、キー名`event` / 値`イベント名`で`dataLayer.push`されたものを検知するトリガーです。

下記のように設定します。

```javascript
dataLayer.push({
  event: "page_view" // eventパラメータにイベント名をいれる
})
```

![スクリーンショット 2021-12-24 4.00.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/142322/a5db7582-b370-acae-867f-fd45666d6cdc.png)

▼ ドキュメント

https://support.google.com/tagmanager/answer/7679219?hl=ja

つまり、`ページビュー`やら`要素のクリック`などをコード側で検知して、
このデータレイヤーをpushしてあげれば良いわけです！！


## 設計

:warning: 注意

※ Next.jsを使ってるため、`React router`を使ってる人はよしなにメソッドを置き換えてください...
※ 処理をシンプルにしてます。サービスで運用しているものはもう少し複雑だったりします。特にデータレイヤーで送るものに関しては、拡張してください。

### 事前準備: dataLayer.pushの処理を別関数でわける
型定義したかったり、初期化処理を閉じ込めたかったりしたので、
`dataLayer.push`の処理を分けて使ってます。

```tsx
type PageViewEvent = {
  event: 'page_view';
  pagePath: string;
};

type InViewEvent = {
  event: 'inView';
  label: string;
};

type ClickEvent = {
  event: 'click';
  label: string;
};

export type DataLayerType =
  | PageViewEvent
  | InViewEvent
  | ClickEvent

export const pushDataLayer = (data: DataLayerType): void => {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push(data);
};
```
この処理を、`'@/libs/analytics'`から`import`できるようにしています。

### ページビュー
下記Componentを`pages/_app`に読み込みます。
(Componentじゃなくても、hooksでも良いと思います。)

ちょっと複雑に見えますが、やってることは至ってシンプルで、

* 初期ロード時は1回のみなので`useRef`を使って制御
    * なんでこれで1回のみ発火できるん？って思った人は[【React】useRefの基本的な使い方・活用術・注意点](https://qiita.com/cheez921/items/9a5659f4b94662b4fd1e#%E4%B8%80%E5%9B%9E%E3%81%AE%E3%81%BF%E5%AE%9F%E8%A1%8C%E3%81%97%E3%81%9F%E3%81%84) 参考にしてみてください(告知)
* 履歴の変更は`routeChangeComplete`を検知する

だけです。


```js
import { VFC, useRef, useEffect, useCallback } from 'react';
import { useRouter } from 'next/router';
import { pushDataLayer } from '@/libs/analytics';

export const TrackPageView: VFC = () => {
  const once = useRef(false);
  const { asPath, basePath, events } = useRouter();

  // page_viewイベントを送信
  // pagePathだけじゃなく、pageのタイトルとかも送っていいかもね
  // pageLocationとかの方がGAのパラメータと合ってよかったかも＾＾
  const pushPageView = useCallback((url) => {
    pushDataLayer({
      event: 'page_view',
      pagePath: url,
    });
  }, []);

  // 初期ロード時の処理
  useEffect(() => {
    // routeChangeCompleteが返すurlと同じ形になるよう整形
    const url = `${basePath}${asPath}`.replace(/\/$|\/\?/, (matched) =>
      matched === '/?' ? '?' : ''
    );
    !once.current && pushPageView(url);
    once.current = true;
  }, [pushPageView, prepared, basePath, asPath]);

  // URLに変更があった時の処理
  useEffect(() => {
    if (!prepared) return;
    events.on('routeChangeComplete', pushPageView);

    return () => {
      events.off('routeChangeComplete', pushPageView);
    };
  }, [events, pushPageView, prepared]);

  return null;
};
```

#### 仮想ページビュー
通常の履歴の変更以外にもページビューを送りたい(仮想ページビュー)ケースもあると思います。
基本仮想ページビューを送りたいのは、要素の表示 / 非表示のケースだとは思うので、このように実装しています。

(正直、ページビューじゃなくて、別のイベントでもいいとは思いますがね...！)

```jsx
import { VFC, useRef, useEffect } from 'react';
import { useRouter } from 'next/router';
import { pushDataLayer } from '@/libs/analytics';

type Props = {
  onMount?: boolean;
  onUnMount?: boolean;
  suffix: string;
};

export const TrackVirtualPageView: VFC<Props> = ({
  onMount,
  onUnMount,
  suffix,
}) => {
  const { basePath } = useRouter();

  const mount = useRef(onMount);
  const unMount = useRef(onUnMount);

  const path = useRef(`${basePath}/${suffix}`);

  useEffect(() => {
    if (!prepared) return;

    mount.current &&
      pushDataLayer({
        event: 'page_view',
        pagePath: path.current,
      });

    const unmounted = unMount.current;
    const _path = path.current;
    return () => {
      unmounted &&
        pushDataLayer({
          event: 'page_view',
          pagePath: _path,
        });
    };
  }, [prepared]);

  return null;
};
```

要素が表示されたタイミングで仮想ページビューを送る

```jsx
<TrackVirtualPageView suffix="hoge" onMount />
```

要素が非表示されたタイミングで仮想ページビューを送る

```jsx
<TrackVirtualPageView suffix="hoge" onUnMount />
```

これを、検知したいComponetのどっかに追加すればokです。

### クリックの検知
めっちゃシンプルですが、クリックを検知してデータレイヤーを送るだけの処理をもったComponentを作りました。

```jsx
import React, { VFC, ReactNode, useCallback } from 'react';
import { pushDataLayer } from '@/libs/analytics';

type Props = {
  children: ReactNode;
  label: string;
};

export const TrackOnClick: VFC<Props> = ({ children, label }) => {
  const handleClick = useCallback(() => {
    pushDataLayer({
      event: 'click',
      label,
    });
  }, [label]);

  return <div onClick={handleClick}>{children}</div>;
};
```

これを囲ってあげるだけです。

```jsx
<TrackOnClick label="hoge">
  <Button />
</TrackOnClick>
```


### 要素の表示
要素の表示は、`react-intersection-observer`を使って検知しています。
このライブラリはとても便利で、何秒表示されたから発火する、何pxスクロールされたら発火するなどの制御ができ、
GTMの要素の表示トリガーができる設定を網羅できます。

▼ 参考

https://github.com/thebuilder/react-intersection-observer


```jsx
import React, { VFC, ReactNode, useEffect } from 'react';
import { pushDataLayer } from '@/libs/analytics';
import { useInView, IntersectionOptions } from 'react-intersection-observer';

/**
 * continue - イベントの発火を継続させるか(デフォルトは一回のみ)
 * options
 *   - rootMargin: 上からの距離 (中央は'50%')
 *   - delay: 何秒遅らせるか
 */

type Props = {
  label: string;
  children: ReactNode;
  continuous?: boolean;
  options?: Omit<IntersectionOptions, 'triggerOnce'>;
};

export const TrackInView: VFC<Props> = ({
  children,
  label,
  continuous = false,
  options,
}) => {
  const { ref, inView } = useInView({
    ...options,
    triggerOnce: !continuous,
  });

  useEffect(() => {
    if (!inView) return;

    pushDataLayer({
      event: 'inView',
      label,
    });
  }, [inView, label]);

  return <div ref={ref}>{children}</div>;
};
```

こんな感じに使います。

```tsx
<TrackInView label="voice_5s" options={{ delay: 5000 }} continuous>
  <SomethingComponent />
</TrackInView>
```

---

もし、もっと良い方法があったら教えていただきたいです！！