始めに
Creating a React Analytics Logging Libraryこちらの記事を読んだ上で書いている記事になります。記事中ではClassComponentで書かれていたのを本記事ではReactHooksに書き直しているので一見ぐらいの価値はあると思います・・・!!
かなりいいなと思いました!!僕はここにあるようなアンチパターンを実装をしてきたので『これからログを取る方』は是非とも役立ててください。
なお、この記事では『閲覧イベント』を扱っていきます!
イベントロギングしよう!
イベントロギングって大切ですよね。これをもとにプロダクトを改善していったりします。
例えば、『ECサイトにおいて注文数を上げたい!』って思った時にユーザーがどのように行動しているのかをトラッキングするのって重要です。
一般的な調査方法で『なぜ注文しないのか?』を明らかにしていくことを考えると、『ユーザーインタビュー』を行う方法もあったり、『ユーザーテスト』を行うということもあります。
しかし、残念なことにこれらには『結構なコスト』がかかります。また、いろいろ聞かれるものの言いにくいことや覚えていないこととかもありますよね。
ぶっちゃけ、『クッソ微妙な商品だな・・・』と思ったとていても正直に思ったことを言える人もそうそういないと思います。そのため、『ちょっと高いかなぁって😓』って無難にやり過ごしたりすることも多いでしょう。はたまた、『使ってみてわからないこととかありました?』なんて急に聞かれても覚えてなかったりすることもあり、『と、・・特にはなかったですね😳』なんて答えが得られたりすることもあります(そしてインタビュー終わったあとにそういえばあれあったなぁ・・・まぁいっか!😙と思ったりしちゃうことありますよね)。
そういった中、『実際にユーザーがどうやってつかっているか?どこに時間をかけて、何に迷っているのか?』といったことをしるためにイベントのロギングはかなり寄与します。
そして、コストは少なく抑えられますし、取れるデータ数も膨大な数になります。何よりも『真実を継続的を取れる』といった点はアプリケーションの改善には必須になってきます(とはいえユーザーの思考を知ることはできないため定性的な調査ももちろん必要です)。
Creating a React Analytics Logging Libraryこちらの記事では、SlackのデスクトップアプリケーションはReactでかかれており、
- 開発者にとって簡単にロギングできるようにする
- ログデータエラーを減らす
- ユーザーがどうやってみているかのリアルなデータをとる
これらを実現するためにReactではどういう実装方法をするのが良いのか?について書かれています。
今日は『閲覧編』で、この記事はまだ途中で全てのHow to についてかかれてはいませんので更新次第書いていきたいと思ってますー!🤩
前提条件
このようなコンポーネントを仮定します。
// Home.tsx
function Home() {
return (
....
<Section id="welcome">
....
<Banner />
</Section>
...
);
}
ここでは、Sectionコンポーネントの子供としてBannerコンポーネントがいます。
そこで、
- Sectionコンポーネントが閲覧されたか?
- Bannerコンポーネントが閲覧されたか?
というログをとることを題材に説明していきます。
一般的なログの実装方法
では、セクションやバナーといったコンポーネントにどうやってログを入れるのがいいのか?と考えると、真っ先に思いつくのは『ロギングするための関数をインポートして、真っ先にlifecycleやイベントハンドラに紐づける方法』ではないかなと思います。もちろん僕もその一員でした・・!!😇
// Banner.tsx
import { sendLog } from '../utils/analytics';
function Banner() {
useVisible(() => sendLog({ page: 'home', action: 'impression', section: 'welcome' ,component: 'banner' }), []);
return ....
}
例えばこんな感じに。
useVisibleはコンポーネントが閲覧されたというイベントハンドラを実現するための架空のhooksです。あとでちゃんとuseVisibleのようなイベントハンドラの実装方法を書きますのでご安心ください・・!!
この実装自体は悪くないですが、この親コンポーネントであるSection
を見てみましょう。
// Section.tsx
import { sendLog } from '../utils/analytics';
function Section() {
useVisible(() => sendLog({ page: 'home', action: 'impression', section: 'welcome' }), []);
return ....
}
同じようにこのようなコードを書きます。
こうすることで元々の要件
- Sectionコンポーネントが閲覧されたか?
- Bannerコンポーネントが閲覧されたか?
は達成できそうです。
だけど、これでいいのでしょうか・・・?🤔
気持ちを切り替えて見返すと、おんなじような記述が目立ちますね・・・。わかりやすくするためにpropsを使っていませんが、props設計にしても・・・
<Section log={sectionLog}>
...
<Banner log={bannerLog} />
</Section>
// Banner.tsx
function Banner(props:Props) {
useVisible(props.log, []);
return ....
}
このようになります。こうすると見栄えはきれいになりますが結局bannerLog
を定義するめんどくささや、使用するコンポーネント全てにuseVisible
の記述を書かなければならない点などは変わりません。
そこでこれを解決するための方法としてslackの記事で書かれているのはContextAPIを使った書き方です!!
この方法はかなり賢いなと思いました!!
ContextAPIを使ってネストした子要素が親要素を知れるようにする
結論からいきますと、ContextAPIを使うとどのようにロギングできるようになるのかというとこのようになります。
<Log section="section">
<Section>
....
<Log component="banner">
<Banner/>
</Log>
</Section>
</Log>
ログに送信される結果はこのようになります。
"[View]: {"component":"banner","section":"section"}
"[View]: {"section":"section"}
『**え。。。なんかLog増やしただけじゃない?**🤮』って思うかもしれませんが、良い点としては
『Logで囲うだけでログが取れる』という点と
『親のLog情報をこのLogが継承する』という点です。
bannerLog
やsectionLog
といった固有のログ関数を用意する必要もありませんし、コンポーネントを作るたびにuseVisible
を追加するだけのお仕事をする必要もありません。
これが産む具体的な実利は後ほど説明します!!。
ではLogの中身がどうなっているのかをみにいきましょう。
import React, {createContext, ReactNode, useContext, useEffect} from 'react';
import {sendLog} from './sendlog';
export const LogContext = createContext({});
interface Props {
children: ReactNode;
component?: string;
section?: string;
}
type Context = Omit<Props, 'children'>
export default function Log(props: Props) {
const {children, ...directProps} = props;
const logContext = useContext<Context>(LogContext);
useVisible(() => {
sendLog({...directProps, ...logContext});
}, []);
return (
<LogContext.Provider value={{...directProps, ...logContext}}>
{children}
</LogContext.Provider>
);
}
visibleなイベントについてはいったんuseVisibleを使っていますがあとでしっかりとしたものに修正します。
ここではログパラメータの取り回しについて理解いただければと思います。
なぜ親のログが子に引き継がれるのか?というと、LogComponentの配下にLogComponentが出現する場合、親のLogComponentに渡しているpropsを引き継ぐためです。
具体的には下記のコードがその実装例になります。😄
<LogContext.Provider value={{...directProps, ...logContext}}>
{children}
</LogContext.Provider>
このProviderがネストする構造がこのギミックの鍵になっています。これにより階層構造に応じたログを取得できるようになります。
これの何が嬉しいのか?
先ほど
『Logで囲うだけでログが取れる』という点と
『親のLog情報をこのLogが継承する』という点です。
という良さみを述べましたが、具体的に『何がいいのか?』を具体的に言いますと・・・。
- 各コンポーネントの実装に依存しない
- 階層構造に依存しており、propsに依存していない
という点になります。
各コンポーネントの実装に依存しない
これはかなり大きなメリットになります。
Logコンポーネントがロギングの役割を果たしますので、各コンポーネントは下記のようにロギングなど知らんぷりすることができます。
// Banner
export default function() { return ... }
つまり、ロギングするという責務から解放されます。😆
各コンポーネントにロギングするための責務を入れ込んでしまうと『実装忘れたー😇』とか『実装上ここは難しい🧐』などというデメリットがでるだけでなく、何よりおんなじコードがでまくるというDRYに反したものが出来上がってしまいます。
階層構造に依存しており、propsに依存していない
二点目です。まずはいったん、Propsで渡す案を具体的に書いてみましょう。
<Section log={() => sendLog({ section: 'section'})}>
...
<Banner log={() => sendLog({ section: 'section', component='component'}))} />
</Section>
となります。ではここで、Bannerの位置を次のセクションに移したとしましょう。
<Section log={() => sendLog({ section: 'section'})}>
...
</Section>
... ⇩ここも修正
<Banner log={() => sendLog({ section: 'section2', component='component'}))} />
のようになります。すると位置の移動だけでなく、logの修正も必要になってしまいます。
これでは修正のたびに変更する情報が多く存在し、間違えた時に正確なログを取れなくなってしまいます。
では同じようなケースをLogコンポーネントのある世界で行ってみると・・・
<Log section="section">
<Section>
....
</Section>
...
<Log component="banner">
<Banner/>
</Log>
</Log>
コンポーネントの移動だけですみました。
このようにコンポーネントの階層構造に依存しているため、ログの出力内容を気にする必要はないのです。
visibleなイベントの実装
これで最後になります!
先ほどのコードではvisibleなイベントをuseVisibleという架空のhooksを使うことでお茶を濁しました。🙄
最後にここでは、お茶を濁さずvisibleなイベントをハンドリングしていくようにします。
素のInterSectionObserverAPIを使ってもいいんですが、react-intersection-observer
を使った方が楽なのでこちらを紹介します。
ただし、現行バージョンのreact-intersection-observer
はこのあとででてくるdisplay: 'contents'
を使うと動作しない問題があるため、以下の3つの方法で動かすことができます😢。本記事では修正したコードを使って説明します(slackの記事では自前で作ってます)。
-
修正したソースコードを使う
-
yarn add https://github.com/YutamaKotaro/react-intersection-observer#build
こちらで以下のソースコードを使えるようになっています(プルリクを送っているのでマージされれば記事を更新します)
-
-
自前でInterSectionObserverAPIをつかった機能を作成する
- 作る際に必要な機能としては再度発火させない処理になります。
react-intersection-observer
ではこの機能が実装されています。
- 作る際に必要な機能としては再度発火させない処理になります。
-
display: 'contents'を諦める
- かなりしんどいと思っています・・・。😭
コードは下記のようになります。
import React, {createContext, ReactNode, useContext, useEffect, useRef} from 'react';
import {sendLog} from './sendlog';
import {useInView} from 'react-intersection-observer';
export const LogContext = createContext({});
interface Props {
children: ReactNode;
component?: string;
section?: string;
}
type Context = Omit<Props, 'children'>
export default function Log(props: Props) {
const {children, ...directProps} = props;
const logContext = useContext<Context>(LogContext);
const {ref,inView} = useInView({
child: true,
triggerOnce: true,
});
useEffect(() => {
if (inView) {
sendLog({...directProps, ...logContext});
}
},[inView]);
return (
<LogContext.Provider value={{...directProps, ...logContext}}>
<div style={{ display: 'contents' }} ref={ref}>
{children}
</div>
</LogContext.Provider>
);
}
一部抜粋してコードを説明します。
const {ref,inView} = useInView({
child: true,
triggerOnce: true,
});
useEffect(() => {
if (inView) {
sendLog({...directProps, ...logContext});
}
},[inView]);
child
はプルリクを送っているオプションになります(自前の場合はこの機能は不要になります)。これを入れることによって判定を行う要素を子要素にすることができます。style={{ display: 'contents' }}
これがあると永遠に発火しないためこのような処置が必要になります。
triggerOnce
は一度のみ発火する機能になります(自前の場合この機能か、このような機能をLogComponentに入れる必要があります)。
また、
<div style={{ display: 'contents' }} ref={ref}>
こうしているのには理由があって、IntersectionObserverAPIで判定を行うためにはrefが必要になるんですが、そのために何らかのタグを挟んでしまうとレイアウトに影響を及ぼす危険性が高いため、display: 'contents'
をあてることによって干渉を防いでいます。
まとめ
以上で完了ですー!!
これらによってvisibleなイベントいい感じにロギングできるようになりました!!😄
<Log page="order">
<ItemSection> アイテムに関する情報
<Log section="item">
....
<Log component="price">
<Price />アイテムの値段
</Log>
</Log>
</ItemSection>
<PrecautionsSection> アイテムの注意事項
<Log section="precaution">
・・・
<Log component="price">
<Price small /> 送料
</Log>
</Log>
</PrecautionsSection>
...
</Log>
そして、こんな感じの階層構造から『どこの何をみて離脱したのか?』といったことをとっていけるようになりました。しかも、各コンポーネントへの実装をなしに。階層構造でログをとるため、同じ名前のLogをとっても問題ありません。
といった感じにかなり良かった記事なんですがいったんここまでになります!!
次の記事がでたら各種イベントハンドリングについて参考に(めっちゃ)しつつまとめていきたいと思います。
追伸:RNだとどうするのか?はちょっと悩みどころです。IntersectionObserverの変わりはあれどdisplay: 'contents'の代わりになりそうなものがなく、ViewとTextの問題があり、ここまで気軽につかえなさそうな気がしています。何かいい案があれば・・・!!