PLAIDのインターンを通して ~ KARTE接客におけるユーザ体験の向上 ~

はじめに

こんにちは。エンジニアインターンの Daiki Sakuma(@daiki_skm)です。
8月初めから9月半ばまでの約1ヶ月半、PLAID でソフトウェアエンジニアとしてインターンをしていました。
周りの方々の支えもあってとても濃い時間を過ごすことができたため、文字として残しておきたいなと思い、つらつらと記事を書いています。
この記事では実際に PLAID でのインターンを通して、技術や思考性の変化といった観点から学んだことを書き記していこうと思います。

自己紹介

現在(2022年9月)、コンピュータ専門の大学に在学していてプログラミング自体は大学から始めました。そんな中プログラミングにハマり出したのは学部2年生の頃からで、趣味で Web アプリケーションを開発したり、インターンシップや開発アルバイトをしたり、ハッカソンや ISUCON に参加したりなど、楽しくエンジニアリングしていました。
基本的にコンピュータに関連すること全般好きです。その中でも特に JavaScript 周りが好きです。

なぜ PLAID のインターンに参加したのか

技術書や記事を読んだり、開発をする中で Web フロントエンドの重要性や面白さに気づいていきました。そこから漠然と「フロントエンド領域の技術力を上げたい、知識を深めたい」と思っていました。また、Core Web Vitals のようなパフォーマンス指標の影響もあり、Web フロントエンドのパフォーマンスチューニングにも興味がありました。
そんな中インターンを探していると、Wantedly で PLAID の夏インターンの募集要項を見て、とてもワクワクしたと同時に参加したいと強く思い応募しました。また、PLAID Engineer Blog を読む中で技術レベルの高さを感じ、さらに楽しみにしていました。

KARTE Action Experience チームにジョイン

PLAID ではメインプロダクトとして KARTE を提供しています。KARTE とは、あらゆるサービスの CX(顧客体験)を向上させる SaaS です。Web サイトに訪問したユーザーの行動をリアルタイムに解析し、一人ひとりに合わせた体験の提供を可能にします。
具体的には、接客と呼ばれる、デザインテンプレートを活用して自分たちの Web サイトに訪問したユーザーに適したポップアップやダイアログを提供することができます。また、そこからユーザーの行動を可視化して改善のヒントを見つけ、分析して施策実施をするといった継続的に改善の PDCA を回すことができるプロダクトです。

※以下の画像は接客の例です。(引用元: https://karte.io/product/web/)
Untitled.png

僕が所属していたチームは、この接客テンプレートを編集して、リアルタイムでプレビューを表示することができる Web エディタの開発を行なっていました(今後この記事の中では Action Editor と呼ばれています)。KARTE を利用するユーザーが、GUI 操作によって直感的に編集するもしくは、Svelte や TSX で書かれたファイルを直接編集することができます。実際にどのように実装されているかはフロントエンドでフロントエンドをビルドする
を参照ください。

どのような開発を行ったのか

Action Editor は非エンジニアの方でも簡単にテンプレートを編集することができるのですが、もっと編集の際の体験をよくしたいという思いがあります。それを実現するためにはさまざまな方法があると思うのですが、今回のインターンでは Web パフォーマンスの点でアプローチしました。一概に Web パフォーマンスといっても複数の要因がある中で、今回の場合はテンプレートをビルドしてリアルタイムでプレビューする際のロジックに焦点を当てています。
具体的には、テンプレートをリアルタイムでプレビューする際、現状以下のような動作をしていました。

  1. テンプレートに変更があるたびにブラウザ上で動く rollup コンパイラでビルド
  2. 生成された JavaScript ファイルを使用して新たに iframe を作成
  3. 既存の iframe とリプレイス

そんな中、テンプレートは Svelte を使用していたため、hydration を適応すれば iframe を毎回作成する必要がなくなりそうということで、その実装に至りました。この hydration の案は、自分がアサインされる前に mizchi さんに提案していただき、また、デモ実装をしてもらっていたため、自分はそのデモコードを理解し、実際にプロダクトに落とし込むということを行いました。
※ 以下の mizchi さんのリポジトリが Svelte + hydrate のデモコードになります。https://github.com/mizchi/svelte-hydrate-demo

Server Side Rendering と hydration の関係

まず、hydration を適応するにあたって、Server Side Rendering(SSR)との関係が密接であるため、そこに重点を置いて説明させていただきます。また、SSR についてより詳しいことに関しましては、Vue.js の公式ドキュメントに記載されている Why SSR? を参照ください。

※ 今回の SSR についての説明にあたって、React Conf 2021 での Shaundai Person の講演「Streaming Server Rendering with Suspence」を参考にさせていただきました。

SSR は通常、以下のように動作します。

Untitled(1).png

(引用元: React Conf 2021 「Streaming Server Rendering with Suspence」 by Shaundai Person)

  1. ブラウザがページリクエストを受信すると、サーバー上でアプリケーションのデータを取得する。
  2. 取得したデータを用いて、サーバー上でコンポーネントを HTML にレンダリングする。
  3. その HTML がクライアントに送信され、ブラウザでレンダリングされる。
  4. ユーザはページのコンテンツを見ることができる。(レンダリングはされているが JavaScript が適用されていないためインタラクティブではない。)
  5. クライアント上でアプリケーション内のコンポーネント用の JavaScript を読み込む。
  6. クライアント上でそれらのロジックをブラウザでレンダリングされている HTML に接続していく。
  7. サイトがインタラクティブになりユーザーが利用できるようになる。

一般的に、この 5・6 の部分のプロセスを hydration といいます。よって、通常 hydration は SSR の際に使用されるものです。しかし、今回のプロダクトの場合はブラウザ上でビルドしているため、少し特殊な使い方になっています。また、Svelte の公式ドキュメントにも記述されている通り、既存の DOM に変更があった場合、新しい要素を作成するのではなく、hydration によって Svelte 上で更新されるようになります。

行ったタスクの詳細

今回のインターンでの大きなタスクとして、Svelte ファイルを hydration に適用するかつ、それに依存しているシステムを hydration に対応できるように実装しました。また、接客を使用する本番環境と接客を編集するプレビュー環境が存在するのですが、今回の hydration 対応はプレビュー環境のみのため、その辺りを考慮して実装する必要がありました。

以下は、チームで開発に取り組んでいるKARTEの新しい接客のアーキテクチャです。図中の赤い印が実装した部分になります。
de.jpeg

詳細に入る前に上記の図の構成について、以下で簡単に説明しておきます。

  • action-editor: Action Editor のことを指しています。接客テンプレートを編集して、リアルタイムでプレビューを表示することができる Web エディタです。
  • action-compiler: 接客を本番環境(Web サイト上)やプレビュー環境(Action Editor)で表示するため、接客テンプレートをビルドする役割を担っています。
  • action-sdk: 接客テンプレートで使用される Svelte コンポーネントや JavaScript を指しています。これらのファイルがビルドされて CDN から配信されています。
  • karte-action-template: 接客テンプレートのコードが記載されている JSON ファイルを指しています。action-sdk (CDN)から関数やコンポーネントをインポートして使用しています。
    ※ karte-action-template の例
    スクリーンショット2022-09-2016.03.54.png

以上が、新しい接客のアーキテクチャです。その中で自分は以下の3つを実装しました。

  • action-editor の環境(プレビュー環境)の場合のみテンプレートを hydrate オプションでビルド & iframe 周りのロジック修正
  • action-sdk で hydration に対応した API の実装 & hydrate オプションでビルドされた JavaScript ファイルを CDN から配信(E2Eテスト含む)
  • karte-action-template で hydration に対応した API を使用して実装 & リファクタリング

action-editor & action-compiler 周り

action-compiler では JSON 形式のテンプレートをビルドしているのですが、プレビュー環境のみ Svelte compile のオプションで hydrate 可能になるように指定しました。

const compileOptions = {
  hydratable: true,
  sveltePath: 'https://cdn.skypack.dev/svelte',
};

また、それに伴い、action-sdk 側から hydration 対応されたものを使用しなければならないため、rollup コンパイル時に rollup プラグインの alias を使用して、skypack の URL を hydrate 対応のものにリプレイスするようにしました。

alias({
  entries: [
    {
      find: 'https://cdn.skypack.dev/@plaidev/karte-action-sdk',
      replacement: 'https://cdn.skypack.dev/@plaidev/karte-action-sdk/hydrate',
    },
  ],
})

iframe 周りのロジックの修正としては、テンプレートに変更があった場合、それをビルドしたコードを既存の iframe に送信してダイナミックインポートすることで、hydration によって更新をしてもらうといった風になっています。

// ビルドしたファイルを postMessage で iframe に送信するロジック
escapedCode = btoa(unescape(encodeURIComponent(code)));
const oldIframeElm = target.querySelector('iframe');
if (!oldIframeElm || !iframeInst || iframeInst.className === 'fail-iframe') {
  // iframe内で使用されるコードの要求をpostMessageで受け取る
  sendCodeAfterInitIframe(escapedCode);
  iframeInst = createSuccessHtml(opts, payload);
  target.appendChild(iframeInst);
} else {
  iframeInst?.contentWindow?.postMessage(
    {
      type: 'preview:update_preview',
      value: escapedCode,
    },
    '*',
  );
}
// iframe 内
<script type="module">
  window.parent.postMessage({ type: 'preview:init' }, '*');

  window.addEventListener('message', function handler(event) {
    const target = document.getElementById("preview");
    if (event.origin !== location.origin) {
      return;
    }
    if (event.data?.type === 'preview:update_preview') {
      const escapedCode = event.data.value
      import("data:text/javascript;base64," + escapedCode).then(async (mod) => {
        await mod.default({ ...Object.assign(${payloadStr}, {${expandedPayload}}), target });
      });
    }
  }, false);
</script>

action-sdk 周り

ビルド時には rollup.js を使用していたため、設定ファイルで hydration 対応版を追加しました。rollup.js は設定ファイルを JavaScript で記述することができるため、以下のように配列形式で設定できます。便利!

// rollup.config.js
export default [
  {
    input: path.resolve(__dirname, './src/index.ts'),
    output: {
      format: 'es',
      file: path.resolve(__dirname, './dist/index.es.js'),
    },
    plugins: [
      svelte({
        emitCss: false,
        preprocess: preprocess(),
        compilerOptions: {
          css: true,
        },
      }),
      replace({
         IS_HYDRATE: false,
      }),
    ],
  },
  // hydration 対応版
  {
    input: path.resolve(__dirname, './src/index.ts'),
    output: {
      format: 'es',
      file: path.resolve(__dirname, './dist/hydrate/index.es.js'),
    },
    plugins: [
      svelte({
        emitCss: false,
        preprocess: preprocess(),
        compilerOptions: {
          css: true,
          hydratable: true,
          sveltePath: 'https://cdn.skypack.dev/svelte',
        },
      }),
      replace({
         IS_HYDRATE: true,
      }),
    ],
  },
]

その後、ビルドされた hydration 版の JavaScript ファイルを npm や skypack で配信するために、package.json の exports にパス指定しました。

また、それに伴い、テンプレートで使用する hydration 対応の API を実装・提供するようにしました。背景としては、hydration 対応する場合、コンポーネントをインスタンス化する際に hydrate オプションを指定する必要があるのですが、テンプレート自体 aciton-editor 上でコード編集もできるため、クライアントの方々には hydration を意識せずに使用できる必要がありました。よって、Svelte コンポーネントを引数に渡すと、それをインスタンス化したものを操作できるような関数が入っているオブジェクトを返す API を作成しました。また、その API 関数の中ではプレビュー環境の場合のみ、hydration 対応をするように、rollup プラグインの replace と Dead Code Elimination を使用して実装しました。

// rollup.config.js
export default [
  {
    plugins: [
      replace({
         IS_HYDRATE: false,
      }),
    ],
  },
  // hydration 対応版
  {
    plugins: [
      replace({
         IS_HYDRATE: true,
      }),
    ],
  },
]
// global.d.ts
declare let IS_HYDRATE: boolean;
/**
 * Create an application instance.
 *
 * @param App - An entry point of svelte component.
 * @param options - An {@link AppOptions | options}
 * @return An {@link App | app} instance
 */
export const createApp = <Props, Variables>(
  App: typeof SvelteComponentDev,
  options: AppOptions<Props, Variables> = {
    send: () => {},
    props: {} as Props,
    variables: {} as Variables,
  },
): App => {
  let app: SvelteComponentDev | null = null;

  const close = () => {
    if (app) {
      app.$destroy();
      app = null;
    }
  };

  const appArgs: ComponentOptions<Props & Variables> = {
    target: null,
    props: {
      send: options.send,
      close,
      data: {
        ...options.props,
        ...options.variables,
      },
    },
  };

  if (IS_HYDRATE) {
    const win = ensureModalRoot(false);
    appArgs.target = win;
    appArgs.hydrate = true;
  } else {
    const win = ensureModalRoot(true);
    appArgs.target = win;
  }

  return {
    close,
    show: () => {
      if (app) {
        return;
      }
      options.send('message_open');
      app = new App(appArgs);
    },
  };
};

このような hydration 版の配信に加えて e2e テストの実装も行いました。具体的には、今までの e2e テストでは skypack(CDN) から配信されているものを使用していたのですが、ローカルでビルドされたファイルを参照してテストできるよう修正しました。e2e テストの対象を skypack からローカルファイルに変更した理由としては、action-sdk を開発していく中で、実際に skypack(本番環境)から配信される前の状態をテストして、事前に予期しない変更がないか・破壊的な変更がないか検知できるようにすることが挙げられます。

karte-action-template 周り

先ほどの action-sdk で実装した API を使用して、コード変更&リファクタリングをしました。これを使用することで、クライアントの方々が hydration を意識することなく実装できるようになりました。

Before:

import type { KarteAction, Variables } from "./gen";
import { props } from "./gen";
import App from "./App.svelte";
import {
  ensureModalRoot,
  onScroll,
  onTime,
} from "https://cdn.skypack.dev/@plaidev/karte-action-sdk";
import "https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver";

  --- 略 ---

const action: KarteAction = options => {
  const { close, showApp } = makeShowApp(options.variables, options.send);

  --- 略 ---
};

export default action;

After:

import type { KarteAction } from "./gen";
import { props } from "./gen";
import App from "./App.svelte";
import { onScroll, onTime, createApp } from "https://cdn.skypack.dev/@plaidev/karte-action-sdk";
import "https://polyfill.io/v3/polyfill.min.js?features=IntersectionObserver";

  --- 略 ---

const action: KarteAction = options => {
  // makeShowApp でやっていたことを createApp としてAPI提供
  // 関数の中で、プレビュー環境の場合は hydration 対応してくれる
  const { close, show } = createApp(App, {
    props: props,
    send: options.send,
    variables: options.variables,
  });

  --- 略 ---
};

export default action;

実装してみて

まず、それぞれのディレクトリごとの責務や依存関係を理解することに苦戦しました。そんな中、アーキテクチャについてホワイトボードで説明をしていただいたり、タスクをしていく中でコードを実行しながら、このコード部分はUI上のこの部分なんだなとか、チームメンバーの方々との会話のなかであったりと、少しずつ理解していきました。また、今回のタスクでは普段やることがないような実装をさせていただいてとても貴重な体験でした。具体的には、rollup コンパイラを利用してフロントエンドでビルドするのはもちろん、npm や skypack からコード配信をしたり、API の仕様を JSDoc で書いたり、Dead Code Elimination を使用したりなどです。

また、実装前を Chrome の Developer Tools の performance で計測してみると、何も描画されない瞬間(約66.7ms)があったことから、この部分が視覚からなくなったということになります。

Untitled(2).png

何を見て、何を感じて、何を学んだのか。

目的を再確認・再思考し、行動に移すことの大切さ

インターンを通して、一度立ち返って目的を再確認したり、今の状況や状態に合わせて再思考することの重要さを身をもって感じることができました。また、目的を再確認・再定義したときに、そこから行動に移すことの大切さも改めて実感しました。

インターンの初めは、技術力を上げたい一心で黙々とコードを読み書きしていました。その中でわからないことが出てきた時に質問をして、コミュニケーションを取るといったように作業していました。ただ、どうしても業務がスムーズに進みませんでした。なぜなら、質問するだけの必要最低限のコミュニケーションだと、チームメンバーの方々のそれぞれの得意分野や強みがわからず、誰に質問をしたら良いのかわからなかったり、チームメンバーの方々がコードに対してどれくらいの共通認識があるのかわからず、既知の部分を長く喋っていたりしたからです。

そんな中、メンターの iggy さんとの面談の際に「何のためにインターンに来たんだっけ?何を得たいんだっけ?を意識した方がいいかもしれない。」というアドバイスをいただきました。これをきっかけに目的を再確認・再思考してみました。当初、インターンに来た目的は「パフォーマンスチューニングをすることでユーザ体験をよくしつつ、その過程で技術力を上げたい。」でした。もちろんこれ自体は悪いことではなく、ただ視点が狭いような気がしました。言ってしまえば、規模は違えど個人開発でも達成することは可能です。そこで視点を変えて、このインターンでしか得られないものってなんだろうと考えました。そうした時に真っ先に思い浮かんだのが「チームメンバーの方とのコミュニケーションはここでしかできない。」でした。当時はあまり深く考えずに、キャリアを聞いてみるとか、楽しく雑談するみたいなことまでしか考えていなかったのですが、これがのちのインターン期間でいい影響を及ぼしました。

これをきっかけに積極的にコミュニケーションをとる中で、メンバーの方々の強みや得意領域を知ることができて、どの方にどの質問をしたらいいかわかるようになったり、今まではテキスト形式でしか質問をしなかったのをハドルやオンラインカンファレンス、雑談ベースで質問するようになったり、チームの雰囲気を知ることができて、いい意味でラフさを持って接することができるようになったり、など作業効率が上がりました。また、心の距離が縮まったことで質問をしやすくなったり、単純に日々の業務がさらに充実するようになりました。

これらの経験から、改めてコミュニケーションの大切さを実感しました。コミュニケーションを取る→その人の得意分野や強みを知れる→どう接すればいいのかわかる→質問がしやすくなる→生産性が向上する、といったことを肌で感じながら学ぶことができました。

理解のレベルを引き上げることの重要性

インターンを通して、プロダクトと技術両方の面で、理解のレベル(詳細度)を今以上に引き上げることの重要性をひしひしと感じました。背景として以下のようなきっかけがありました。

  • チームメンバーの方に実装内容を説明するときに、うまく言葉にできなかった。
  • hydration 対応をしていく中でコードリーディングが甘く、アーキテクチャごとの依存関係を正確に把握できずに、スムーズに作業が進まなかった。
  • そのプロダクトが将来どういう方向を目指しているのかを把握しておらず、短期的な実装になっていた。

これらのこともあって、コミュニケーションを積極的に取ったり、もう数歩先の深いところまでコードリーディングをするようになりました。また、今まではドキュメントを読んで理解するだけだったのですが、デモコードを書いてみたり、中の実装を見に行ったりといったことをするようになりました。それによって、プロダクトやコードレベルでの依存関係について理解が深まり、論理的にコードを書いたり説明できるようになったり、コードですんなり理解できない部分(その会社独自の技術や実装)を理解できるようになったりすることで、作業がスムーズに進むようになりました。

インターンを振り返って

インターン前や前半の自分 → 現在の自分

  • 「インターンを通して精神面や技術面で成長したい。」「パフォーマンスチューニングに興味があり、それによってユーザ体験をよくすると同時に、さまざまなことを学びたい。」と考えていて、どちらかというと「学ぶ」という方に重きを置いている自分がいました。
    → もちろん「学んで成長する」ということは大事ですが、そちらに重心を置きすぎるのは良くないと感じるようになりました。あくまで「プロダクトやチームに対してどのように貢献し、価値を生み出すのか」というマインドを持った上での、それらとのバランスの必要性を実感しました。理由としては、実際にインターンをする中で、チームメンバーの方々が自分の強みや興味・関心を理解し、それらを活かした行動をすることでプロダクトやチームに対して貢献していたことを肌身で感じることができたからです。結果として、それによって得られたもので自分は成長し、学ぶことができると考えるようになりました。
  • チームメンバーの一員として活動する中で、質問をする際、無意識に先生と生徒のようなスタンスをとってしまっていました。
    → もちろん、尊敬をしているがゆえにこのようなスタンスになってしまうのはわかるのですが、どちらかというと一緒にプロダクトをよくする仲間というイメージでいることの重要性を感じました。また、質問の際はチームとして前に進むために相談するというマインドを持つことの重要性も感じました。

インターンを終えて

最後まで読んでくださってありがとうございました。1ヶ月半という短い期間でしたが、挑戦させて下さったメンバーの方々と環境に、感謝の気持ちでいっぱいです。また、初めの1ヶ月間はオフラインだったため、メンバーの方々とお昼を食べに行ったり、雑談をしたりなど素直にとても楽しい時間を過ごすことができました。

今回のインターンを通して、自分に何が足りないのか、これからどうしていきたいのかなど明確に知ることができました。また、エンジニアとしてどういうマインドでプロダクトに関わっていけばいいかを肌身をもって感じることができ、以前と比べて明らかに精神的にも技術的にも成長することができました。そして、今まで以上にフロントエンドや技術が好きになりました。

チームメンバーの皆さんをはじめ、PLAIDの社員の方々、インターン生の方々、本当にありがとうございました。おかげさまで、とても充実した時間を過ごすことができました。

最後に

CX(顧客体験)プラットフォーム「KARTE」を運営するPLAIDでは、「 KARTE自体の開発に興味がある!」「最高のCXを生み出すプロダクトを開発したい!」というエンジニアを募集しています。詳しくは弊社採用ページまたはWantedlyをご覧ください。

また、インターンも積極的に募集中です!ぜひ一歩前に踏み出して応募してみてください!