VISASQ Dev Blog

ビザスク開発ブログ

tooltipで考える手続的、宣言的処理

はじめに

開発部エンジニアの田中(智)です。

最近ではコンポーネント周りの実装に専念しており、tooltipを実装して改めて確認した宣言的なプログラミングの良さについて記事にしてみました。

フロントエンドの手続き処理といえば

本題に入る前に、フロントエンドで手続き処理といいますとjQueryが一世を風靡した時代があったと伝聞したことがあります。

時代の寵児(?)となったjQueryですが実態はjavascriptの延長線上で、いかに 「手続き的なプログラミングを楽にするか」ということを目的に作られたものと認識しております。

自分自身webサイトを作って楽し〜となっていた際は大変お世話になった記憶があります。 しかしながら、今となっては殆ど記憶を喪失してしまったので基本的な記法を掘り起こしてみました。

まずはjavascriptで対象のボタンをクリックした際の処理を記述します。

const button = document.getElementById("awesomeButton");
button.addEventListener("click", function(){
  console.log("AwesomeButton is clicked!!")
});

jQueryですと下記のようになります。

const $awesomeButton = $("#awesomeButton");
$awesomeButton.click(function() {
   console.log('クリックされました!');
})

確かに記述量が減りました。この程度のコードであればそこまで恩恵を感じることは難しいかも知れませんが、実際に記述するプロダクトコードを考えますと、一定の効果がありそうです。

手続き的プログラミングの欠陥と宣言的プログラミングの躍進

jQueryは「どのようにDOMを操作するか」に対応したものという話をいたしました。シンプルなホームページなどは十分に対応できますし、現在もダウンロード数は(累積、かつ傾きは一定とはいえ)右肩上がりです。(2022年 ~ 2024年12月現在)

2022年 ~ 2024年までのnpm trends

参考: https://npmtrends.com/jquery

しかしながら、大規模なwebアプリケーションのフロントエンドはとても複雑です。 はてなブログを例に挙げますと、下記のような機能が実装されています。

  • 記事作成機能
  • 記事下書き機能
  • ブックマーク機能
  • プロフィール機能
  • 通知機能
  • etc....

また、モダンなフロントエンドではフロントエンド自身で状態管理を行い楽観的更新等々を実現することも多いです。
例を挙げますといいね機能はその典型で、即時反映を目的としてAPIの結果を待たずUIに反映することが一般的です。

React19では楽観的更新を行うためのhooksが実装されたりしているようです。
また、VuepiniaReactreduxなど、楽観的更新を実現するためのフレームワークはとても有名です。

useOptimistic: https://ja.react.dev/reference/react/useOptimistic
pinia: https://pinia.vuejs.org/
redux: https://redux.js.org/

フルスタックフレームワークであるRuby on RailsjQueryのgemを用意していたりしますが、フロントエンドのフレームワークであるReact、VueにおいてはjQueryが共存するというケースは現状ほぼないのではと思います。
参考: https://rubygems.org/gems/jquery-rails/versions/4.4.0

以上から、所謂SPAを実現するためには、手続き的処理以外の手段である宣言的な記述が必要だったと伺えます。

tooltipで考える手続的、宣言的処理

本題のtooltipの例を紹介させていただきます。tooltipといいますと、ホバー時にホバー対象の説明をやさしくふわっと表示してくれるようなUIです。

vuetify tooltip

tooltipの実装には、動的な位置の調整が必要です。

まずは愚直に手続き的に実装したコードを紹介いたします。
その次に、宣言的に実装したコードを紹介いたします。

※1 ビザスクではvue.jsを利用しているため、vue.jsでの実装を例としています。
※2 また、import文などを含む一部コードは割愛しております。

手続的ツールチップ

<script setup lang="ts">
const { position = "top" } = defineProps<{
  position?: "top" | "bottom";
}>();
const verticalGap = ref(0);
const horizontalGap = ref(0);
const subjectRef = useTemplateRef<HTMLElement>("subject");
const tooltipRef = useTemplateRef<HTMLElement>("tooltip");
onMounted(() => {
  if (subjectRef.value && tooltipRef.value) {
    /**
     * NOTE: onBeforeMountのタイミングでtooltipの高さを取得するために
     * v-showで`display: none`となる前に一時的に`display: block`に書き換える
     */
    tooltipRef.value.style.display = "block";
    /**
     * 要素に関わらずtooltipの垂直方向の位置を計算する処理
     * topの場合: (tooltipの対象となる要素の高さ * 2 + 余白)を上方向に移動する
     * bottomの場合: (tooltipの対象となる要素の高さ + 余白)を下方向に移動する
     */
    const offset = 10;
    verticalGap.value =
      position === "top"
        ? (subjectRef.value.clientHeight * 2 + offset) * -1
        : subjectRef.value.clientHeight + offset;
    /**
     * 要素に関わらずtooltipの水平方向の位置を計算する処理
     * ((tooltipの要素の横幅 - tooltipの対象となる要素の横幅) / 2)を左方向に移動する
     */
    horizontalGap.value =
      -(tooltipRef.value.clientWidth - subjectRef.value.clientWidth) / 2;
    tooltipRef.value.style.display = "none";
  }
});
const isVisible = ref(false);
const visible = () => (isVisible.value = true);
const hide = () => (isVisible.value = false);
</script>

<template>
  <div :class="$style.wrapper">
    <div ref="subject" :class="$style.subject">
      <slot name="subject" :visible="visible" :hide="hide" />
    </div>
    <div v-show="isVisible" ref="tooltip" :class="$style.tooltip">
      <slot />
    </div>
  </div>
</template>

<style module>
.tooltip {
  top: v-bind("`${verticalGap}px`");
  left: v-bind("`${horizontalGap}px`");
}

/* ... 省略 ... */
</style>
  1. useTemplateRef を利用してDOMを要素を取得( useTemplateRef: https://ja.vuejs.org/api/composition-api-helpers#usetemplateref
  2. position(top, bottom)に応じて計算
  3. refの値(verticalGap, horizontalGap)を更新
  4. styleにv-bindする

実装するのも、解読するのも、保守するのもとても大変なことがわかります。

宣言的ツールチップ

<script setup lang="ts">
import { ref } from "vue";

const { positionY = "top", positionX = "center" } = defineProps<{
  text: string;
  positionY?: "top" | "bottom";
  positionX?: "center" | "right" | "left";
}>();
const isVisible = ref(false);
const visible = () => (isVisible.value = true);
const hide = () => (isVisible.value = false);
</script>

<template>
  <div :class="$style.wrapper">
    <div ref="subject" @mouseover="visible" @mouseout="hide">
      <slot />
    </div>
    <div
      v-show="isVisible"
      ref="tooltip"
      :class="[
        $style.tooltip,
        $positionStyle[positionY],
        $positionStyle[positionX],
      ]"
    >
      {{ text }}
    </div>
  </div>
</template>

/* propsで渡ってきたpositionに応じて宣言的にstyleを切り替える */
<style module="$positionStyle">
.top {
  top: -8px;
  bottom: auto;
  transform: translateY(-100%);
}

.bottom {
  top: auto;
  bottom: -8px;
  transform: translateY(100%);
}

.right {
  left: 0;
}

.left {
  right: 0;
}

/* ... 省略 ... */
</style>

vue.jsのv-bindを利用することで、position(top, bottom, right, left)に応じてcssを振り分けるようになり、DOM操作の記述が抹消しました。 記述量も格段に減り、可読性・保守性も向上したかと思います。

参考: https://ja.vuejs.org/api/sfc-css-features#v-bind-in-css

終わりに

ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう!

https://developer-recruit.visasq.works/