メッセージ送受信時にスクロール位置を最下部に維持する処理を実装【TypeScript/Vue.js】

サムネイル
※当ブログの記事にはアフィリエイトリンクを使用している場合があります。

概要

SlackやLINE、ChatGPTのようなチャットのUIでは最新のメッセージが最下部になっていることが多いです。

上記のUIでは新規のメッセージが追加されたとき、
元のスクロール位置が最下部 → 最下部を維持
元のスクロール位置が最下部以外 → 元のスクロール位置を維持
という仕様になっています。

動作イメージ

動作イメージ

動作イメージ

この機能をVue.jsで実装したので、実装方法を記載します。

完成品

Vue3(Composition API)で作成しました。

以下のリンク先でコードとプレビューを確認できます。

Vue-SFC-Playground

実装方法

以下の流れで実装します。

  1. メッセージエリアの作成
  2. 最下部へのスクロール
  3. 最下部にスクロールしているかの判定

メッセージエリアの作成

まずは、メッセージを表示する領域とメッセージを追加するためのボタンを用意します。

<script setup lang="ts">
const messages = ref<Array<string>>([]);
const addMessage = (text: string) => {
  messages.value.push(text);
};

const messageAreaElement = ref<HTMLElement>();
</script>

<template>
  <div ref="messageAreaElement" class="message-area">
    <div v-for="message in messages">
      <div>
        {{ message }}
      </div>
    </div>
  </div>
  <button @click="addMessage('メッセージ')">メッセージ追加</button>
</template>

<style scoped>
.message-area {
  width: 240px;
  height: 20vh;
  border: 1px solid;
  overflow-y: scroll;
}
</style>

refオブジェクトを宣言したあと、操作したいDOM要素のref属性に宣言した変数を渡すとDOMにアクセスできるようになります。

最下部へのスクロール

次にメッセージエリアにメッセージが追加されたらスクロール位置を最下部に変更する処理を実装します。

<script setup lang="ts">
...
onMounted(() => {
  const observer = new MutationObserver((mutation) => {
    messageAreaElement.value.scrollTo({
      top: 10000
      behavior: "smooth",
    });
  });
  observer.observe(messageAreaElement.value, {
    childList: true
  });
});
</script>

MutationObserverをchildList: trueとして呼び出すと、DOMに要素が追加されたことを検知できます。

scrollTo()を使うとスクロール位置を変更できます。
スクロール位置はtop:スクロールするピクセル数で指定するのですが、最大値を超えていたらスクロールできる最大値に設定されるため、ここでは仮に大きめな値を入れてあります。(後で正しい値に修正します。)

最下部にスクロールしているかの判定

最下部にスクロールしているかの判定に必要な変数を用意します。

<script setup lang="ts">
...
const clientHeight = ref(0);
const scrollHeight = ref(0);
const scrollTop = ref(0);

onMounted(() => {
  messageAreaElement.value.addEventListener("scroll", (event: Event) => {
    scrollTop.value = event.target.scrollTop;
  });

  const resizeObserver = new ResizeObserver((entries) => {
    const target = entries[0]?.target;
    clientHeight.value = target.clientHeight;
  });
  resizeObserver.observe(messageAreaElement.value);

  const observer = new MutationObserver((mutation) => {
    scrollHeight.value = newScrollHeight;
  });
});
</script>

clientHeightは見た目上の要素の高さです。
resizeObserverを使うことによってclientHeightの変更を検知します。

scrollHeightはoverflowしていて画面上に表示されない部分を含めた要素の中身の高さです。
MutationObserverを使うことによって、メッセージエリアに要素が追加されたときのscrollHeightの変更を検知します。

scrollTopは垂直方向にスクロールされている距離です。
scrollイベントが発生したときにscrollTopの変更を検知します。

次にスクロール位置が最下部になっているかの判定をします。

<script setup lang="ts">
...
const isScrollAtBottom = computed(() => {
  return (
    Math.abs(scrollHeight.value - clientHeight.value - scrollTop.value) < 1
  );
});
...
</script>

理論上はscrollHeight - clientHeight - scrollTopが0のときにスクロール位置が最下部になります。
===で判定していないのは、scrollTopは小数を含む可能性があるのに対して、scrollHeightとclientHeightは整数に丸められるため、スクロール量が閾値に十分に近いかで判定する必要があるからです。1

あとはメッセージ追加時にisScrollAtBottomがtrueのときだけスクロール位置を最下部にする処理を実行するようにします。

<script setup lang="ts">
...
onMounted(() => {
  ...
  const observer = new MutationObserver((mutation) => {
    const newScrollHeight = mutation[0].target.scrollHeight;
    if (isScrollAtBottom.value) {
      messageAreaElement.value.scrollTo({
        top: newScrollHeight - clientHeight.value,
        behavior: "smooth",
      });
    }
    ...
});
</script>

完成

全体のコードは以下のようになります。

<script setup lang="ts">
import { ref, watch, computed, onMounted } from "vue";

const messages = ref<Array<string>>([]);
const addMessage = (text: string) => {
  messages.value.push(text);
};

const messageAreaElement = ref<HTMLElement>();
const clientHeight = ref(0);
const scrollHeight = ref(0);
const scrollTop = ref(0);

const isScrollAtBottom = computed(() => {
  return Math.abs(scrollHeight.value - clientHeight.value - scrollTop.value) < 1;
});

onMounted(() => {
  messageAreaElement.value.addEventListener("scroll", (event: Event) => {
    scrollTop.value = event.target.scrollTop;
  });

  const resizeObserver = new ResizeObserver((entries) => {
    const target = entries[0]?.target;
    clientHeight.value = target.clientHeight;
  });
  resizeObserver.observe(messageAreaElement.value);

  const observer = new MutationObserver((mutation) => {
    const newScrollHeight = mutation[0].target.scrollHeight;
    if (isScrollAtBottom.value) {
      messageAreaElement.value.scrollTo({
        top: newScrollHeight - clientHeight.value,
        behavior: "smooth",
      });
    }
    scrollHeight.value = newScrollHeight;
  });
  observer.observe(messageAreaElement.value, { childList: true, subtree: true });
});
</script>

<template>
  <div ref="messageAreaElement" class="message-area">
    <div v-for="message in messages">
      <div>
        {{ message }}
      </div>
    </div>
  </div>
  <button @click="addMessage('メッセージ')">メッセージ追加</button>
</template>

<style scoped>
.message-area {
  width: 240px;
  height: 20vh;
  border: 1px solid;
  overflow-y: scroll;
}
</style>

最後にscrollTo({top: 10000})のtopの指定をscrollHeight - clientHeightとしました。

補足

DOMNodeInsertedについて

DOMの追加はDOMNodeInsertedでも検知できるようですが、こちらは現在deprecatedのようです。2

Intersection Observer APIについて

ここでは記載しませんが、仕様によってはIntersection Observer APIを使った実装もありかもしれません。

Intersection Observer APIを使って、ページの最下部までスクロールしたかを判定する

behaviorオプションについて

scrollTo()のオプションでbehavior: "smooth"とすると、スクロール動作が滑らかになります。

ただし、ChatGPTのような一文字ずつ文字が増加するUIの場合、スクロールが完了する前に次のスクロール位置判定処理が走ってしまい、スクロール位置は最下部に固定されない場合があります。

対策としては、スクロール判定の距離を短くしたり、文字の更新の間隔を長めにするなどが考えられます。
見た目にこだわらないのであれば、smoothオプションを付けないのが楽です。

参考

Footnotes

  1. Element.scrollHeight | 要素が完全にスクロールされたかどうかの判定 ↩
  2. W3C | DOMNodeInserted ↩

この記事をSNSで共有する

関連記事

オススメ記事

このブログを運営している人

アイコン

Webシステムエンジニア / DTMer

あっしゅからー

フリーランスでWebシステムエンジニアをやっています。

趣味ではDTMをしていて、オリジナル曲をニコニコ動画やYouTubeに投稿しています。

Copyright © 2025 あっしゅからー - All right reserved