83
Help us understand the problem. What are the problem?

posted at

updated at

Organization

【Rails6】(送信時のリロード無し!)Action CableでSlack風チャットアプリを作成


Railsの学習をされた方なら誰しも一度は作ったであろうTwitterの簡易クローンアプリ。最初に実装したときは大変でした:sweat:

この記事では, Action Cable を利用して, Slack のようなリアルタイムチャットアプリの作成方法,さらに Javascript でいろいろな機能を付けるところまでを解説します!

なお, Action Cable を扱う多くのチャットアプリ記事では,メッセージ送信時にリロードが発生しますが,この記事では送信も非同期で行えるように設計します。

容量制限の都合で見づらいですが,こちらが完成後のチャットアプリです。

chat_app.gif

1. Action Cableがなぜ必要なのか?

Action Cable をご存知ない方もおられると思いますので,簡単に解説したいと思います。通常のTwitterの簡易クローンアプリ(CRUDアプリ)と,この記事で作成するアプリとの大きな違いは次の2点です。


  1. ページ更新せず(リロードせず)にメッセージの新規投稿ができ,投稿一覧に反映される
  2. 他人が投稿したメッセージが,ページ更新無しに投稿一覧に反映される

1つ目は, Ajax を利用することで実装できます。ところが,2つ目は厄介です。

HTTPの仕様により,原則として,リクエストを出さなければサーバー側からデータを取得することはできません。つまり,リアルタイムで「誰かが投稿したメッセージ」を受け取ることはできないのです:cry:

この問題を解決し,低コストで双方向通信できるプロトコルが WebSocket であり,この WebSocket をRailsで簡単に扱える機能が Action Cable なのです!

2. 実装するアプリの仕様について

  • Slack のように 新規メッセージが下に来る 設計

    • Twitter のように,新規メッセージが上に来る仕様よりも難易度が上がります
    • 例えば,ページを開いた時に 一番下に移動させないと新規メッセージが見られない!!! という問題に対処しなければなりません :scream:
  • 未入力時は投稿ボタンを無効化し,色を変化

  • 入力フォームで改行した際に縦幅を広げる

  • 無限スクロール機能(過去メッセージの読み込み機能)を実装

    • 大量のメッセージを全て読み込むと双方の負荷が大きすぎるため
  • 補足

    • ログインページのデザイン部分を整える内容は省略します(以前に書きました
    • Rails だけでなく,Javascript のプログラムもそれなりに書きます
    • CoffeeScript は使用しません。
    • デザイン部分を楽に仕上げるため, Bootstrap4 を積極的に使用します
    • jQuery は極力避けます(便利なときだけ使用します)

3. 開発環境

  • macOS Catalina 10.15.1
  • Ruby 2.6.4
  • Rails 6.0.1
    • Rails 5 の場合は,一部設定が変わります
  • Bootstrap 4.3.1
  • Devise 4.7.1

4. 手順

4-0. 準備

まずはアプリを作成し,すぐに必要となる gem を入れて動作確認をしておきましょう。

  • rails newのオプション部分はお好みで
    • -d postgresql データベースを PostgreSQL に設定
    • -T Minitest 用のファイル・ディレクトリを作成しない
    • --skip-coffee CoffeeScript のセットアップをしない
$ rails new chat_app -d postgresql -T --skip-coffee
$ cd chat_app
  • rails g controllerで余計なファイルを作成しないように,config/initializersに次のgenerators.rbを作成
    • 設定はお好みで
    • ただし,g.javascripts false にしてしまうと, rails g channelroom_channel.js が作成されなくなるので注意
config/initializers/generators.rb
Rails.application.config.generators do |g|
  g.helper false
  g.stylesheets false
  g.javascripts true
  g.template_engine :erb
  g.skip_routes true
  g.test_framework false
end
  • Gemfileに次を追加
Gemfile
# ログイン機能
gem 'devise'

# 日本語化
gem 'rails-i18n', '~> 6.0'
gem 'devise-i18n'

# こちらはお好みです。ログインページにBootstrapが適用され,見た目がマシになります
gem 'devise-bootstrap-views', '~> 1.0'

# こちらもお好みです。動作確認用のランダムメッセージを入れるために使用します
gem 'faker'
$ bundle install

# 念のため動作確認をしておきます
$ rails db:create
$ rails s
  • http://localhost:3000で「Yay! You’re on Rails!」を確認できたら念のためコミットし,ブランチを変えておくのがよいかと思います

4-1. Bootstrap4 の導入

デザイン部分を楽に仕上げるため,この記事では Bootstrap4 を積極的に使用していきます。Rails 6 の場合は次の設定を行いましょう。

$ yarn add bootstrap jquery popper.js
  • environment.jsを次に置き換える
config/webpack/environment.js
const { environment } = require('@rails/webpacker')

const webpack = require('webpack')
environment.plugins.append('Provide', new webpack.ProvidePlugin({
    $: 'jquery',
    jQuery: 'jquery',
    Popper: ['popper.js', 'default']
}))

module.exports = environment
app/javascript/packs/application.js
// 一番下に次を追加
require("bootstrap/dist/js/bootstrap")
// js.erb内でjQueryを使用されたい場合は,「window.$ = jQuery;」も必要です
  • application.cssの拡張子cssscssに変更して,次に置き換える
app/assets/stylesheets/application.scss
/*
 *= require_tree .
 *= require_self
 */

@import "bootstrap/scss/bootstrap";

4-2. 基本設定

誰が投稿したメッセージであるか を特定するには ログイン機能 が必要となります。そこで Devise でログイン機能を付け,ログイン関連のリンクを付けたヘッダーを付けておきます。また,ログイン後のトップページ(チャットルーム)も作成しておきましょう。

  • ヘッダー(ナビバー)を追加し,レスポンシブ対応のためのmetaタグを追加
    • お好みで変更して下さい
    • Bootstrap4 の場合は,ヘッダーのクラスに sticky-top 属性を付けるだけで位置を固定化できます
app/views/layouts/application.html.erb
 (略)
-     <title>ChatApp</title>
+     <title>Slack風チャットルーム</title>
     <%= csrf_meta_tags %>
     <%= csp_meta_tag %>
+    <meta name="viewport" content="width=device-width,initial-scale=1">
 (略)
  <body>
+    <%= render 'shared/header' %>
     <%= yield %>
   </body>
app/views/shared/_header.html.erb
<header class="sticky-top">
  <nav class="navbar navbar-expand-sm navbar-light bg-light">
    <%= link_to "Slack風チャットルーム", root_path, class: 'navbar-brand' %>
    <button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="ナビゲーションの切替">
      <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNav">
      <ul class="navbar-nav">
        <% if user_signed_in? %>
          <li class="nav-item active">
            <%= link_to 'アカウント編集', edit_user_registration_path, class: 'nav-link' %>
          </li>
          <li class="nav-item active">
            <%= link_to 'ログアウト', destroy_user_session_path, method: :delete, class: 'nav-link' %>
          </li>
        <% else %>
          <li class="nav-item active">
            <%= link_to "新規登録", new_user_registration_path, class: 'nav-link' %>
          </li>
          <li class="nav-item active">
            <%= link_to "ログイン", new_user_session_path, class: 'nav-link' %>
          </li>
        <% end %>
      </ul>
    </div>
  </nav>
</header>
  • トップページ用のコントローラとビューを作成
$ rails g controller rooms show
  • トップページを設定
config/routes.rb
Rails.application.routes.draw do
  root 'rooms#show'
end
  • 日本語化とタイムゾーンの変更
config/application.rb
module AssociationTutorial
  class Application < Rails::Application
    # (略)
    # the framework and any gems in your application.

    # ********** 以下を追加 **********
    config.i18n.default_locale = :ja
    config.time_zone = 'Asia/Tokyo'
    # ********** 以上を追加 **********

    # Don't generate system test files.
    config.generators.system_tests = nil
  end
end
  • Deviseでログイン機能を付ける
$ rails g devise:install
$ rails g devise user
$ rails db:migrate
  • 全ページをログイン必須に変更
app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :authenticate_user!
end
  • $ rails sでサーバーを再起動して確認すると,次のような状態になります

スクリーンショット 2019-12-13 8.00.35.png

4-3. メッセージ投稿機能(Ajax)

DHH氏の動画から派生した多くの記事では, Action Cable のデータ送信機能(speakなど)を利用していますが,これを使いますとメッセージ送信時にリロードが発生してしまいます:scream:

そこで,この記事ではメッセージの送信は Ajax を用いて非同期で送信することとします。これにより Action Cable 特有の知識・問題の一部を回避することもできます:smiley:

まずはメッセージ投稿機能を付けます。大雑把な手順を確認しましょう。

  1. messages テーブルとモデルを作成
  2. 投稿一覧ページに,form_withで投稿フォームを作成
  3. コントローラのcreateアクションで投稿内容をデータベースに保存
    • 投稿メッセージを非同期で投稿一覧に反映する部分はここで実装しません

メッセージを保存するためのデータベースとモデルを作成します。

  • user_id は User モデルとの関連付けで必要
  • content はメッセージ内容を保存するカラム
$ rails g model Message user_id:integer content:text
  • 念のためnull: falseを追加しておきます。
db/migrate/日時_create_messages.rb
      t.integer :user_id, null: false
      t.text :content, null: false
$ rails db:migrate
  • モデルに関連付けとバリデーションを入れておきます。
    • メッセージの文字数制限を 500 文字にしていますが,お好みで
app/models/user.rb
 class User < ApplicationRecord
   devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  # 次の一行を追加
  has_many :messages, dependent: :destroy
end
app/models/message.rb
class Message < ApplicationRecord
  # **********以下を追加**********
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 500 }
  # **********以上を追加**********
end
  • コントローラ側で,(この時点では)全メッセージを取得することにします
    • Message.all では,いわゆる N+1問題 が発生するので注意(後に補足します)
app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def show
    # 投稿一覧表示に利用
    @messages = Message.includes(:user).order(:id)
    # メッセージ投稿に利用
    @message = current_user.messages.build
  end
end
  • 投稿フォームを作成する前に,コントローラを作成し,ルーティングを設定
$ rails g controller messages create
  • ここで作成されるapp/views/messages/create.html.erbは削除
config/routes.rb
Rails.application.routes.draw do
  devise_for :users
  root 'rooms#show'
  # 次の一行を追加
  resources :messages, only: :create
end
  • メッセージを表示するためのビューと,投稿フォームを作成
    • Bootstrap4 の場合は,フッターのクラスに fixed-bottom 属性を付けるだけで位置を固定化できます
    • Ajax を利用しますので, form_withlocal: true を入れてはいけません!

【注意】 Rails 6.1 から form_with の仕様が変更されました。 6.1 以降では local: false を明記する必要があります。

app/views/rooms/show.html.erb
<div id="message-container">
  <%= render @messages %>
</div>
<%= render 'footer' %>
app/views/rooms/_footer.html.erb
<footer class="fixed-bottom" id="footer">
  <%= form_with model: @message, local: false do |f| %>
    <div class="form-group">
      <!-- ここは自動的に message_content というidが付きます。アンダーバーなので注意! -->
      <%= f.text_area :content, class: 'form-control', rows: '1', maxlength: '500' %>
    </div>

    <div class="form-group">
      <%= f.submit '送信', class: 'btn btn-primary btn-block', id: 'message-button' %>
    </div>
  <% end %>
</footer>
  • <%= render @messages %>に挿入する部分テンプレートを作成
    • messagesディレクトリの中に次の_message.html.erbを作成
    • とりあえず Eメール, 作成日時, メッセージ を表示することにしておきます
app/views/messages/_message.html.erb
<div class="message" id="message-<%= message.id %>">
  <p><%= message.user.email %>: <%= l message.created_at, format: '%Y年%-m月%-d日(%a) %H:%M' %></p>
  <%= simple_format(h message.content) %>
</div>
  • 補足

    • 単純に<%= message.content %>では,改行が反映されません
    • simple_formatメソッド単独では<h1>タグなどが反映されてしまうため,hメソッドで全てのHTMLタグをエスケープしておきます
    • メーセージを書いたユーザーの情報をmessage.userで取得しているので, コントローラで @messages = Message.all にしていると各メッセージに対してSQLが発行されてしまいます(N+1問題)
  • application.scssに最低限度追加しておきます

    • 一番下のメッセージがフッターに隠れないよう padding-bottom は大きめに取っておく必要があります
app/assets/stylesheets/application.scss
// メッセージ一覧
#message-container {
  max-width: 768px;
  margin: 0 auto;
  padding: 1rem 1rem 8rem 1rem;
}

// メッセージ投稿フォーム
footer {
  max-width: 768px;
  padding: 1rem;
  margin: 0 auto;
  background-color: white;
}
  • 動作確認のための seeds.rb を作成します。中身はお好みで。
    • 改行が反映されるかをチェックするため,改行を含むメッセージも追加しておくとよいです
    • 最初は作成するメッセージ個数を少なめにしておきます
db/seeds.rb
# 作成するユーザー・メッセージの個数
user_count = 3
message_count = 3

ApplicationRecord.transaction do
  # テストユーザーが無ければ作成
  user_count.times do |n|
    User.find_or_create_by!(email: "test#{n + 1}@example.com") do |user|
      user.password = 'password'
    end
  end

  # メッセージを全消去した上で,サンプルメッセージを作成。メッセージを作成したユーザーはランダムに決定する
  Message.destroy_all
  user_ids = User.ids
  message_list = []
  message_count.times do |n|
    user_id = user_ids.sample
    line_count = rand(1..4)
    # Fakerで1〜4行のランダムメッセージを作成
    content = Faker::Lorem.paragraphs(number: line_count).join("\n")
    message_list << { user_id: user_id, content: content }
  end
  Message.create!(message_list)
end
puts '初期データの追加が完了しました!'
$ rails db:seed
  • 例えば次のアカウントでログインできるようになります

    • Eメール: test1@example.com
    • パスワード: password
  • この時点で次のような表示になっていると思います

    • メッセージが意味不明なのは Faker の仕様です:sweat_smile:

スクリーンショット 2019-12-08 21.41.42.png

  • メッセージを送信できるようにコントローラを設定
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.create!(message_params)
  end

  private
  def message_params
    params.require(:message).permit(:content)
  end
end
  • 次のcreate.js.erbを作成
app/views/messages/create.js.erb
// フォームに入力した文字列を消去
document.getElementById('message_content').value = ''

これでメッセージを投稿できるようになりました。フォームに例えば「おはよう」と入力して送信ボタンを押します。文字が消えた後,ページを更新(リロード)し,投稿メッセージが反映されればOKです!

なお, create.js.erb に投稿メッセージを表示させる操作は入れません。

投稿したメッセージは本人だけでなく, チャット参加者全員 に追加しなければなりません。ここで Action Cable が登場します。

4.4 Action Cable の設定・確認

Action Cable を使用し,フロント側とサーバー側が監視し合う状態(双方向通信ができる状態)にしましょう。

$ rails g channel Room

# 次の2つが出力されたらOK
#     create  app/channels/room_channel.rb
#     create  app/javascript/channels/room_channel.js
config/routes.rb
 Rails.application.routes.draw do
+  mount ActionCable.server => '/cable'
   devise_for :users
   root 'rooms#show'
 end
  • これだけで監視状態ができあがります!動作確認をしてみましょう。
    • 以下は省略して 4.5 に進んでもOKです
    • Rails 5 の場合は異なる点が複数ありますのでご注意下さい
app/channels/room_channel.rb
 class RoomChannel < ApplicationCable::Channel

   # サーバー側からフロント側を監視できているかを確認できたときに動くメソッド
   def subscribed
+    5.times { puts '***test***' }
   end
   #(略)
app/javascript/channels/room_channel.js
 import consumer from "./consumer" 

 consumer.subscriptions.create("RoomChannel", {

   // フロント側からサーバー側を監視できているかを確認できたときに動く関数
   connected() {
+    console.log('test')
   },
   // (略)

$ rails s でサーバーを再起動し,タブを更新して確認してみて下さい。接続確認できたタイミングでメッセージが出力されるはずです。

  • コンソールに***test***が5回出力されていればOKです

スクリーンショット 2019-12-09 8.10.59.png

  • ChromeのデベロッパーツールのConsoleタブを開き,testと表示されていればOKです

スクリーンショット 2019-12-09 8.14.08.png

チェックができたら,追加した 5.times { puts '***test***' }console.log('test')削除して下さい

4.5 チャット機能の実装

Action Cable を利用して チャット参加者全員 が投稿メッセージをリアルタイムで受信し,ページに反映できるように設定していきましょう!

【補足】 最初に予告しましたとおり, Action Cable の送信機能は使用しません

app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # 配信する部屋名を決定
    stream_from "room_channel"
  end

  def unsubscribed
  end
end
app/controllers/messages_controller.rb
class MessagesController < ApplicationController
  def create
    @message = current_user.messages.create!(message_params)
  # ********** 以下を追加 **********
    # 投稿されたメッセージをチャット参加者に配信
    ActionCable.server.broadcast 'room_channel', message: @message.template
  # ********** 以上を追加 **********
  end
end
app/models/message.rb
class Message < ApplicationRecord
  belongs_to :user
  validates :user_id, presence: true
  validates :content, presence: true, length: { maximum: 500 }
  # ********** 以下を追加 **********
  # 投稿されたメッセージをメッセージ用の部分テンプレートでHTMLに変換
  def template
    ApplicationController.renderer.render partial: 'messages/message', locals: { message: self }
  end
  # ********** 以上を追加 **********
end

【補足】 Jobを作成している記事を多く見かけますが,少なくともHerokuにデプロイする場合は必要ないようです。

【参考】 (stackoverflow) ActionCable: Why put broadcasts in a separate job? For forms why not broadcast from controllers?

app/javascript/channels/room_channel.js
import consumer from "./consumer"

// turbolinks の読み込みが終わった後にidを取得しないと,エラーが出ます。
document.addEventListener('turbolinks:load', () => {

    // js.erb 内で使用できるように変数を定義しておく
    window.messageContainer = document.getElementById('message-container')

    // 以下のプログラムが他のページで動作しないようにしておく
    if (messageContainer === null) {
        return
    }

    consumer.subscriptions.create("RoomChannel", {
        connected() {
        },

        disconnected() {
        },

        received(data) {
            // サーバー側から受け取ったHTMLを一番最後に加える
            messageContainer.insertAdjacentHTML('beforeend', data['message'])
        }
    })
})

これで,チャット機能の基礎は完成です!

サーバーを再起動し,2つのタブでhttp://localhost:3000を開き,片方に「おはよう」と入力して送信してみて下さい。
もう片方でも「おはよう」が表示されたならOKです。
(2種類のブラウザを使い,別のユーザーでログインした状態でチェックするとなおよいです)

4.6 機能の改善

さて,現状では複数の問題があります。

  1. メッセージが多くなると,ページを開いたときに最新メッセージが見えなくなってしまう:scream:

  2. フォームが空欄でも投稿ボタンを押すと送信できてしまう:sweat_smile:

    • モデル側でバリデーションは入っているが,サーバーに無用な負荷をかけてしまう
  3. フォームが一行では複数行のメッセージを書きづらい:sweat:

    • 最初から複数行の幅をとると,メッセージの見える範囲が狭くなる(スマホだと致命的)

順番に解決していきましょう!

:small_red_triangle_down: メッセージが多くなると,ページを開いたときに最新メッセージが見えなくなってしまう
:arrow_right: ページを開いたときにページの一番下に移動させればよい!

app/javascript/channels/room_channel.js
// (略)
  consumer.subscriptions.create("RoomChannel", {
// (略)
  })
// ********** 以下を追加 **********
    const documentElement = document.documentElement
    // js.erb 内でも使用できるように変数を決定
    window.messageContent = document.getElementById('message_content')
    // 一番下まで移動する関数。js.erb 内でも使用できるように変数を決定
    window.scrollToBottom = () => {
        window.scroll(0, documentElement.scrollHeight)
    }

    // 最初にページ一番下へ移動させる
    scrollToBottom()
// ********** 以上を追加 **********
}
  • さらにメッセージ投稿後に,投稿した最新メッセージが見られるように一番下へ移動させます
app/views/messages/create.js.erb
// フォームに入力した文字列を消去
messageContent.value = ''
// 一番下へスクロール
scrollToBottom()

:small_red_triangle_down: フォームが空欄でも投稿ボタンを押すとサーバーにリクエストが出せてしまう
:arrow_right: フォームが空欄なら投稿ボタンを無効化すればよい!

  • Bootstrap4 を導入しているので,クラスに disable を追加することでボタンを無効化できます
    • disabled 属性の追加・削除でもよいのですが,メッセージ送信後にボタンを無効化できないので採用しません(form_withで作成したボタンは,メッセージ送信直後に自動でdisabled属性が追加され,送信完了後にdisabled属性が削除されます。そのためか,create.js.erbや,room_channel.jsreceived 関数でdisabled属性を追加しても無効化されてしまいます)
app/views/rooms/_footer.html.erb
 <!-- 最初はボタンを無効化するため,クラスに disabled を追加 -->
- <%= f.submit '送信', class: 'btn btn-primary btn-block', id: 'message-button' %>
+ <%= f.submit '送信', class: 'btn btn-primary btn-block disabled', id: 'message-button' %>
  • フォームに入力されたときに,空欄でなければボタンを有効化,空欄なら無効化します
app/javascript/channels/room_channel.js
    // (略)
    // ********** 以下を追加 **********
    const messageButton = document.getElementById('message-button')

    // 空欄でなければボタンを有効化,空欄なら無効化する関数
    const button_activation = () => {
        if (messageContent.value === '') {
            messageButton.classList.add('disabled')
        } else {
            messageButton.classList.remove('disabled')
        }
    }

    // フォームに入力した際の動作
    messageContent.addEventListener('input', () => {
        button_activation()
    })

    // 送信ボタンが押された時にボタンを無効化
    messageButton.addEventListener('click', () => {
        messageButton.classList.add('disabled')
    })
    // ********** 以上を追加 **********
})
  • さらに,ボタンが無効化されているときは灰色にします
app/assets/stylesheets/application.scss
// ボタン無効化時のスタイル
.btn-primary.disabled {
  background-color: #6c757d;
  border-color: #6c757d;
  cursor: auto;
}

:small_red_triangle_down: フォームが一行では複数行のメッセージが書きづらい
:arrow_right: 改行したときにフォームの行数を増やし,行数が減ったときはフォームの行数も減らすようにする

  • フォームにある改行の個数からフォームの行数を決定
    • ただし,最大行数は 10 としておきます
app/javascript/channels/room_channel.js
    // (略)
    // フォームに入力した際に動作
    messageContent.addEventListener('input', () => {
        button_activation()
    // ********** 以下を追加 **********
        changeLineCheck()
    // ********** 以上を追加 **********
    })
    // 送信ボタンが押された時にボタンを無効化し,フォーム行数を1に戻す
    messageButton.addEventListener('click', () => {
        messageButton.classList.add('disabled')
    // ********** 以下を追加 **********
        changeLineCount(1)
    // ********** 以上を追加 **********
    })
    // ********** 以下を追加 **********
    // フォームの最大行数を決定
    const maxLineCount = 10

    // 入力メッセージの行数を調べる関数
    const getLineCount = () => {
        return (messageContent.value + '\n').match(/\r?\n/g).length;
    }

    let lineCount = getLineCount()
    let newLineCount

    const changeLineCheck = () => {
        // 現在の入力行数を取得(ただし,最大の行数は maxLineCount とする)
        newLineCount = Math.min(getLineCount(), maxLineCount)
        // 以前の入力行数と異なる場合は変更する
        if (lineCount !== newLineCount) {
            changeLineCount(newLineCount)
        }
    }

    const changeLineCount = (newLineCount) => {
        // フォームの行数を変更
        messageContent.rows = lineCount = newLineCount
    }
    // ********** 以上を追加 **********
})
app/assets/stylesheets/application.scss
// ユーザーがフォームサイズを自由に変更できる機能を停止させておく
#message_content {
  resize: none;
}

これで,行数が自動で変化するようになります……が,別の問題が発生します。

スクリーンショット 2019-12-13 16.37.43.png

これは,フッターの高さを変化させたのに,padding-bottomを変更していないからです。さらに,変更分だけ上下にスクロールさせた方が親切です。そこで,さらにコードを加えます。

app/javascript/channels/room_channel.js
    // (略)
    // ********** 以下を追加 **********
    const footer = document.getElementById('footer')
    let footerHeight = footer.scrollHeight
    let newFooterHeight, footerHeightDiff
    // ********** 以上を追加 **********

    const changeLineCount = (newLineCount) => {
        // フォームの行数を変更
        messageContent.rows = lineCount = newLineCount
    // ********** 以下を追加 **********
        // 新しいフッターの高さを取得し,違いを計算
        newFooterHeight = footer.scrollHeight
        footerHeightDiff = newFooterHeight - footerHeight
        // 新しいフッターの高さをチャット欄の padding-bottom に反映し,スクロールさせる
        // 行数が増える時と減る時で操作順を変更しないとうまくいかない
        if (footerHeightDiff > 0) {
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
            window.scrollBy(0, footerHeightDiff)
        } else {
            window.scrollBy(0, footerHeightDiff)
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
        }
        footerHeight = newFooterHeight
    // ********** 以上を追加 **********
    }
})

これで,フォーム行数の増減に対して自然な動きをするようになりました!

  • この時点での最終的なroom_channel.jsはこちらです。
    • 変数を書く場所などを整理する作業はお好みで
app/javascript/channels/room_channel.js
import consumer from "./consumer"

// turbolinks の読み込みが終わった後にidを取得しないと,エラーが出ます。
document.addEventListener('turbolinks:load', () => {

    // js.erb 内で使用できるように変数を定義しておく
    window.messageContainer = document.getElementById('message-container')

    // 以下のプログラムが他のページで動作しないようにしておく
    if (messageContainer === null) {
        return
    }

    consumer.subscriptions.create("RoomChannel", {
        connected() {
        },

        disconnected() {
        },

        received(data) {
            // サーバー側から受け取ったHTMLを一番最後に加える
            messageContainer.insertAdjacentHTML('beforeend', data['message'])
        }
    })

    const documentElement = document.documentElement
    // js.erb 内でも使用できるように変数を決定
    window.messageContent = document.getElementById('message_content')
    // 一番下まで移動する関数。js.erb 内でも使用できるように変数を決定
    window.scrollToBottom = () => {
        window.scroll(0, documentElement.scrollHeight)
    }

    // 最初にページ一番下へ移動させる
    scrollToBottom()

    const messageButton = document.getElementById('message-button')

    // 空欄でなければボタンを有効化,空欄なら無効化する関数
    const button_activation = () => {
        if (messageContent.value === '') {
            messageButton.classList.add('disabled')
        } else {
            messageButton.classList.remove('disabled')
        }
    }

    // フォームに入力した際の動作
    messageContent.addEventListener('input', () => {
        button_activation()
        changeLineCheck()
    })

    // 送信ボタンが押された時にボタンを無効化し,フォーム行数を1に戻す
    messageButton.addEventListener('click', () => {
        messageButton.classList.add('disabled')
        changeLineCount(1)
    })
    // フォームの最大行数を決定
    const maxLineCount = 10

    // 入力メッセージの行数を調べる関数
    const getLineCount = () => {
        return (messageContent.value + '\n').match(/\r?\n/g).length;
    }

    let lineCount = getLineCount()
    let newLineCount

    const changeLineCheck = () => {
        // 現在の入力行数を取得(ただし,最大の行数は maxLineCount とする)
        newLineCount = Math.min(getLineCount(), maxLineCount)
        // 以前の入力行数と異なる場合は変更する
        if (lineCount !== newLineCount) {
            changeLineCount(newLineCount)
        }
    }

    const footer = document.getElementById('footer')
    let footerHeight = footer.scrollHeight
    let newFooterHeight, footerHeightDiff

    const changeLineCount = (newLineCount) => {
        // フォームの行数を変更
        messageContent.rows = lineCount = newLineCount
        // 新しいフッターの高さを取得し,違いを計算
        newFooterHeight = footer.scrollHeight
        footerHeightDiff = newFooterHeight - footerHeight
        // 新しいフッターの高さをチャット欄の padding-bottom に反映し,スクロールさせる
        // 行数が増える時と減る時で操作順を変更しないとうまくいかない
        if (footerHeightDiff > 0) {
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
            window.scrollBy(0, footerHeightDiff)
        } else {
            window.scrollBy(0, footerHeightDiff)
            messageContainer.style.paddingBottom = newFooterHeight + 'px'
        }
        footerHeight = newFooterHeight
    }
})

4.7 無限スクロール機能

メッセージ数が少ない間は問題無いのですが,もし1万件メッセージがあったらどうでしょう。最初に全て読み込むのは,クライアント側・サーバー側双方に負荷がかかりますし,SEOにも影響が出てくるでしょう。

そこで,最初の時点では100件だけ読み込み,一番上までスクロールしたとき更に読み込むように設計してみましょう。

非同期でメッセージをさらに読み込むためには Ajax を利用しなければなりませんが,メッセージのフォームと異なり,ボタンがありませんform_withlink_toなどを使うのは不自然です。

そこで,JavascriptAjax を利用するためのプログラムを書きます。

  • まず,メッセージを大量に追加しておきます
db/seeds.rb
  # 作成するユーザー・メッセージの個数
  user_count = 3
- message_count = 3
+ message_count = 1000
$ rails db:seed
  • ルーティングとコントローラーを用意し,最初に読み込むメッセージを制限します
config/routes.rb
+  get '/show_additionally', to: 'rooms#show_additionally'
app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  def show
    # 次の一行を変更。最新の100件のみ取得する。
    @messages = Message.includes(:user).order(:id).last(100)
    @message = current_user.messages.build
  end

  # ********** 以下を追加 **********
  def show_additionally
  end
  # ********** 以上を追加 **********
end
  • room_channel.js から Ajax を利用する
    • 表示済みのメッセージの内,最も古いメッセージidを送信しておく
    • data-remote: true を入れることで,show_additionally.js.erbを作動できる
app/javascript/channels/room_channel.js
    // (略)
    // ********** 以下を追加 **********
    let oldestMessageId
    // メッセージの追加読み込みの可否を決定する変数
    window.showAdditionally = true

    window.addEventListener('scroll', () => {
        if (documentElement.scrollTop === 0 && showAdditionally) {
            showAdditionally = false
            // 表示済みのメッセージの内,最も古いidを取得
            oldestMessageId = document.getElementsByClassName('message')[0].id.replace(/[^0-9]/g, '')
            // Ajax を利用してメッセージの追加読み込みリクエストを送る。最も古いメッセージidも送信しておく。
            $.ajax({
                type: 'GET',
                url: '/show_additionally',
                cache: false,
                data: {oldest_message_id: oldestMessageId, remote: true}
            })
        }
    }, {passive: true});
    // ********** 以上を追加 **********
})
app/controllers/rooms_controller.rb
  # (略)
  def show_additionally
    # ********** 以下を追加 **********
    # 追加のメッセージ50件を取得する
    last_id = params[:oldest_message_id].to_i - 1    
    @messages = Message.includes(:user).order(:id).where(id: 1..last_id).last(50)
    # ********** 以上を追加 **********
  end
end
app/views/rooms/show_additionally.js.erb
// 今回は上からメッセージを追加
messageContainer.insertAdjacentHTML('afterbegin', "<%= j(render @messages) %>")
// メッセージが存在するときだけ,更に読み込み可能とする
<% if @messages.present? %>
    showAdditionally = true
<% end %>
  • メッセージが正しく追加されるかを調べるために,一旦,message.idも表示させるようにします
    • 確認後に削除してOKです
app/views/messages/_message.html.erb
<div class="message" id="message-<%= message.id %>">
  <p><%= message.id %>: <%= message.user.email %>: <%= l message.created_at, format: '%Y年%-m月%-d日(%a) %H:%M' %></p>
  <%= simple_format(h(message.content)) %>
</div>

これでメッセージの追加読み込みが可能となります!……が,問題が……
メッセージが追加されたのに,スクロール位置が一番上のままなのです:scream:

そこで,メッセージの追加されたタイミングでスクロールさせ,同じメッセージが見える状態にしておきます。

app/views/rooms/show_additionally.js.erb
// const や let を使うと2回目以降にエラーが発生します
var messageHeight = messageContainer.scrollHeight
// 今回は上からメッセージを追加
messageContainer.insertAdjacentHTML('afterbegin', "<%= j(render @messages) %>")
var newMessageHeight = messageContainer.scrollHeight
window.scrollBy(0, newMessageHeight - messageHeight)
// メッセージが存在するときだけ,読み込み可能とする
<% if @messages.present? %>
    showAdditionally = true
<% end %>

これで読み込み後もメッセージの位置が変わらなくなりました:grinning:

4.8 Herokuにデプロイする場合の注意点

このままHerokuに デプロイしても動作しません。
cable.yml を次のように編集してからデプロイしてみて下さい。

config/cable.yml
production:
-  adapter: redis
-  url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
-  channel_prefix: chat_app_production
+  adapter: async

5. 参考記事・動画

6. サンプルコード

$ git clone https://github.com/T-Tsujii/chat_app.git
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
83
Help us understand the problem. What are the problem?