dely Tech Blog

クラシル・TRILLを運営するdely株式会社の開発ブログです

Vue.jsでカスタムディレクティブを使ってユーザーの「見てる」を可視化する

f:id:srrn:20191218131825j:plain

目次

はじめに

こんにちは、dely株式会社 開発部の白石(しらりん)です。
2019年新卒として入社し、現在Webフロントエンドエンジニアを担当しています。

昨日はiOSエンジニアのtakaoさんが、「個人アプリの開発で陥った6つの失敗とそこから学んだやらないことの重要性」という記事を書いてくれました。
本記事は dely Advent Calendar 2019 18日目の記事になります。

qiita.com

本記事のテーマは、タイトル通りVue.js(以下Vue)でユーザーの「見てる」を可視化することです。 delyに入社して実際に行った業務を通して考えたこと・学んだことをなぞりながら書いていきます。

この記事内では以降「ユーザーの見てる」を「インプレッション」と表現します。

とある日のこと

入社して1ヶ月半ほど経った5月半ば、この頃の自分の業務はOJTを兼ねたクラシルWebのリファクタリングが中心で、slim-railsで記述されていたコードをTypeScript + Vue + Node.js(+ vue-server-renderer)でSPA・SSR対応したものに書き換えたりしていました。
そんなある日、Web開発チームにビジネスサイドでWebの広告担当をしているAさんからこんな依頼がきました。

Aさん「(とあるページ)の広告枠が全然ユーザーの目に留まっていない気がする。ユーザーがページのどこを見ているのか調べる仕組みが欲しいです。」

その依頼自体は先輩フロントエンドエンジニアのOさんに来ていたのですが、そのやりとりを盗み聞きしてた自分が、

「そのタスク、おもしろそうなのでください^^」

と迫り奪取に成功、初めて分析系のタスクを担当することになりました。 タスクをもらえたのは良いものの、どう実装しようかなと悩んでいたところにOさんがアドバイスをくれました。

Oさん「これならカスタムディレクティブを使うといいかも。」
自分「?」

Vue歴 ≒ 社会人歴な当時の自分はカスタムディレクティブを知りませんでした。

カスタムディレクティブとは

ディレクティブには「指令・命令」という意味があり、Vueにおけるディレクティブについて以下のサイトではこのように説明されています。

ディレクティブとは、 DOM 要素に対して何かを実行することをライブラリに伝達する、マークアップ中の特別なトークンです。

012-jp.vuejs.org

Vueを使われている方にはお馴染みのv-ifv-forなど、接頭辞にvが付くこれらがVueのディレクティブです。
(-より後ろはディレクティブIDといいます)

そしてこのディレクティブは自分で作ることができ、それをカスタムディレクティブといいます。
詳しい内容は以下を参照してください。

jp.vuejs.org

やりたいこと

ページがユーザーにどこまで読まれているかを計測する必要があったとして、恐らくそれを実現する一番単純な方法は、ページの高さに対してスクロールされた高さで割合を求めることだと思います。しかし、これではスクロールされた位置までに表示されていた要素がユーザーに見られていたかは正確に分かりません。
例えば、とあるページが上から順にa, b, cという要素で構成されている時、cの位置までスクロールされているからといってaとbがユーザーに見られているとは限りません。aとbをスクロールで飛ばしてcを見ているかもしれないからです。

そこで、インプレッションを計測したい特定の要素の高さと幅の指定割合が画面内に指定秒数の間継続的に表示されていることを一度だけ検知できるカスタムディレクティブを実装することにしました。

クラシルのデータ分析基盤

この記事ではカスタムディレクティブを使ってインプレッションを検知するサンプルを実装しますが、データの送信や保存に関する実装には言及しません。参考までにクラシルではこんな感じでやってるよというのを簡単に書いておきます。

f:id:srrn:20191218130320p:plain
構成

クラシルで使用している分析基盤はAWSを利用しており、運用するイベントはスプレッドシートに定義するようにしています。
スプレッドシートに定義したイベントは、特定のプリフィックスをつけたブランチをpushすることでコードを自動生成することができ、そのブランチを取り込み(またはそのブランチ上で)開発していきます。

f:id:srrn:20191217221614p:plain
イメージ図

イベントは、

  • イベントの識別子(必須)
  • イベントのカテゴリ(任意)
  • イベントのパラメーター(必須)

といった感じで値を定義できるようになっており、1行が1イベントになります。イベントパラメーターにはTypeScriptの型を指定することができ、複数イベントで使いまわせるものや膨らみがちなものはイベントパラメーターの型定義用シートがあるのでそちらで管理するようにしています。
その他にも、イベントおよびパラメータの詳細説明や開発ステータス(0だったら実装中、1だったら実装完了(運用可能))などを記述し、実装者以外のメンバーと認識を合わせやすいように運用しています。

この分析基盤を活用し、サービスをより良くするために様々な施策を回しています。

以下は今回のインプレッション計測のイベント定義の例です。

イベント定義シート

列の名前 行の値
イベント識別子 element_imp
イベントの説明 指定した要素のインプレッションを計測するイベント
イベントパラメーター ImpTargetElementName imp_target_element_name
int? index
イベントパラメーターの説明 imp_target_element_name...要素の名前、必須
index...要素が表示される順番(0始まり)、任意

日付や実行されたページのパスなどを生成するベースの分析イベントを継承したイベントが生成される

イベントパラメーター型定義シート

列の名前 行の値
型名 ImpTargetElementName
key carousel_slide
ads
horizontal_scroll_item
card_layout_item
value carousel_slide
ads
horizontal_scroll_item
card_layout_item

↓ 列挙型が生成される

export enum ImpTargetElementName {
  carousel_slide = 'carousel_slide',
  ads = 'ads',
  horizontal_scroll_item = 'horizontal_scroll_item',
  card_layout_item = 'card_layout_item',
}

サンプル実装

それでは実際にサンプルを実装してみます。

カスタムディレクティブのコード

ここでは分かりやすくするために、インプレッションの条件を満たした要素の色を変更するようにしています。

import _Vue, { PluginFunction, VNode } from "vue";
import { DirectiveBinding } from "vue/types/options";
import { ImpTargetElementName } from '../enums/autogen/ImpTargetElementName';

export const impressionDerective = {
  inserted: (el: HTMLElement, binding: DirectiveBinding, vnode: VNode) => {
    const { name, index, time, threshold } = typeof binding.value === 'object'
      ? binding.value
      : { name: binding.value, index: null, time: null, threshold: null };

    const handler = (() => {
      let timer = null as null | number;
      let isExecuted = false;

      return (entries: IntersectionObserverEntry[]) => {
        if (!isExecuted) {
          entries.forEach(entry => {
            if (entry.isIntersecting) {
              const key: keyof typeof ImpTargetElementName = name;
              if (!(key in ImpTargetElementName)) {
                console.error(`${key}はImpTargetElementNameに定義されていません`);
                return;
              }

              timer = window.setTimeout(
                () => {
                  if (timer) {
                    /*
                      データを送信する処理
                      例)
                      vnode.context.$pushLog({
                        name: EventName.element_imp,
                        params: {
                          imp_target_element_name: ImpTargetElementName[key],
                          ...(index == null ? {} : { index }),
                        },
                      });
                    */
                    el.classList.add("isActive");
                  }
                  isExecuted = true;
                  timer = null;
                },
                time || 1000
              );
            } else if (timer) {
              clearTimeout(timer);
              timer = null;
            }
          });
        }
      };
    })();

    const observer = new IntersectionObserver(handler, { threshold: threshold || 0.5 });
    const observeHander = () => observer.observe(el);
    const unobserveHandler = () => observer.unobserve(el);
    const removeAllHandlers = () => {
      el.removeEventListener("impPluginObserve", observeHander);
      el.removeEventListener("impPluginUnobserve", unobserveHandler);
      el.removeEventListener("impPluginRemoveAllHandler", removeAllHandlers);
    };

    el.addEventListener("impPluginObserve", observeHander);
    el.addEventListener("impPluginUnobserve", unobserveHandler);
    el.addEventListener("impPluginRemoveAllHandlers", removeAllHandlers);

    el.dispatchEvent(new CustomEvent("impPluginObserve"));
  },
  unbind: (el: HTMLElement) => {
    el.dispatchEvent(new CustomEvent("impPluginRemoveAllHandlers"));
  }
};

const install: PluginFunction<never> = (Vue: typeof _Vue) => {
  Vue.directive("imp", impressionDerective);
};

export default install;
};

const install: PluginFunction<never> = (Vue: typeof _Vue) => {
  Vue.directive("imp", impressionDerective);
};

export default install;

Vueコンポーネントに適用してみる

ページっぽいコンポーネントを作り、カスタムディレクティブを適用してみます。

任意のファイルでVue.use(インポートしたv-impプラグイン)する。

<template>
    <div class="SpRoot">
      <div class="SpRoot-carousel">
        <Carousel
          :per-page="1"
          :autoplay="true"
          :loop="true"
          :autoplayTimeout="5000"
          paginationPosition="bottom-overlay"
          paginationColor="#ccc"
        >
          <Slide v-for="(item, i) in slideItem" :key="i">
            <div
              v-imp="{
                name: 'carousel_slide',
                time: 2000,
                threshold: 1,
                index: i
              }"
              class="SpRoot-carouselSlide"
            >
              Slide {{ i }}
            </div>
          </Slide>
        </Carousel>
      </div>
      <h2>Section Title</h2>
      <div class="SpRoot-horizontalScroll">
        <div
          v-for="(item, i) in horizontalScrollItem"
          :key="i"
          v-imp="{ name: 'horizontal_scroll_item', index: i }"
          class="SpRoot-horizontalScrollItem"
        >
          Card {{i}}
        </div>
      </div>
      <Ads v-imp="'ads_1'" />
      <h2>Section Title</h2>
      <div class="SpRoot-cards">
        <div
          class="SpRoot-card"
          v-for="(item, i) in cardItems"
          :key="i"
        >
          <div v-imp="{ name: 'card_item', index: i }" class="SpRoot-cardContent">Card {{i}}</div>
        </div>
      </div>
    </div>
</template>

インプレッションを取りたい要素にv-impディレクティブを付与し、imp_target_element_nameに定義済みの値を設定します。 デフォルトでは指定した要素の50%が画面内に1秒以上表示されていることをインプレッションとみなすように条件にしています。

<Ads v-imp="'ads'" />

イベントパラメーターにインデックス値を含めたりインプレッションとみなす条件を変更したいときは、v-impディレクティブに対して以下のようにイベント名を含めたオブジェクトでオプションを指定することができます。以下の例では、カルーセルのスライド要素の100%が画面内に2秒間継続的に表示されていた場合をインプレッションとみなすようにオプションを指定しています。

<Slide v-for="(item, i) in slideItem" :key="i">
  <div
    v-imp="{
      name: 'carousel_slide',
      time: 2000,
      threshold: 1,
      index: i
    }"
    class="SpRoot-carouselSlide"
  >
    Slide {{ i }}
  </div>
</Slide>

実際に動かしてみるとこんな感じになります。

ディレクティブを指定した要素の50%が継続的に1秒以上(スライドは100%が2秒以上)画面内に表示された時色が変わったのが確認できました。

クラシルWebに実際に導入してみた結果

f:id:srrn:20191217114437p:plain
実際に導入し、グラフ化してみた

こちらが実際にクラシルWebの本番環境のとあるページに導入し、計測した値をグラフにしたものです。 青い棒はカスタムディレクティブを適用した要素のインプレッション数で、一番左がページトップの要素で左から順にページの上から表示される要素となっています。赤い折れ線はユーザーの離脱率を表しています。 当然ページの下に行くほど離脱率は高くなっていきますがこのページは特にその傾向が強く、目を止めて欲しいコンテンツにたどり着く前に80%近くのユーザーが離脱していることが分かったりしました。

この実装を通して学んだこと

このタスクをこなす以前の自分は「効率的な実装をしたい」とか「ここのUIやアニメーションもっとこだわりたい」とかエンジニアリングに対する意識にばかり頭がいきがちでした。
この機能を実装し、実際にユーザーの動きが目に見える形になって初めて、入社した時から言われていたデータを見ることの重要性を深く理解することができ、より良いサービスづくりをする上でどちらも欠かせないものだと気づきました。

まだまだ自分が見えている範囲は周りの先輩たちに比べて狭いですが、より良いものを作るために必要な技術を伸ばし、データを見る → どうしてそうなるのか考える(法則を見つけるための実装をする) → データを活かす実装をする → データを見る の積み重ねができるエンジニアを目指して頑張ります。

おわりに

現在delyではエンジニアを積極募集中です。フロントエンドエンジニアの方も大大大歓迎なので、興味のあるかたはぜひご応募ください。

www.wantedly.com

delyの開発部について少しでも気になる方は、ぜひこちらも読んでみてください。

明日はAndroidエンジニアのkenzoさんが、「エンジニアは体が資本でしょ。と思って始めた習慣とその続け方」という記事を書いてくれます。お楽しみに!

adventar.org