So sánh kiến trúc: SPA cồng kềnh đối đầu giải pháp Hotwire
Trong hơn một thập kỷ qua, quy chuẩn frontend luôn mặc định việc chia tách dự án web thành hai codebase riêng biệt: một backend JSON API và một Single-Page Application (SPA) phía client xây dựng bằng React, Vue hoặc Angular. Kiến trúc đó rất hợp lý đối với các tổ chức kỹ thuật quy mô 50 người sở hữu các đội ngũ platform chuyên trách.
Đối với các nhóm nhỏ hơn, mô hình này tạo ra gánh nặng chi phí vận hành rất lớn. Bạn phải nhân đôi các model, duy trì hai tầng validation song song, gỡ lỗi việc trao đổi token cross-origin và quản lý các build pipeline dễ bị lỗi mỗi khi Webpack hoặc Vite nâng cấp phiên bản.
Hotwire (HTML Over The Wire) đi theo hướng tiếp cận hoàn toàn trái ngược. Thay vì gửi payload JSON thô để các JavaScript engine phía client biên dịch vào Document Object Model (DOM), máy chủ sẽ gửi trực tiếp các đoạn HTML đã được render sẵn qua đường truyền mạng. Rails 8 áp dụng Hotwire làm nền tảng mặc định trên bốn tầng cốt lõi:
- Turbo Drive: Chặn các lượt click liên kết và submit form, thay thế nội dung thẻ
<body>mà không cần tải lại toàn bộ trang. - Turbo Frames: Phân rã trang thành các ngữ cảnh cô lập, cập nhật các vùng mục tiêu mà không ảnh hưởng đến phần markup xung quanh.
- Turbo Streams: Thực hiện các biến đổi DOM chính xác (append, prepend, update, replace, remove) qua WebSockets hoặc phản hồi HTTP bằng cách sử dụng các HTML partial tiêu chuẩn.
- Stimulus: Một framework JavaScript gọn nhẹ gắn các hành vi có phạm vi xác định trực tiếp vào thuộc tính HTML, loại bỏ hoàn toàn các cây trạng thái (state tree) phức tạp ở client.
Rails 8 giúp việc truyền tải real-time trở nên đơn giản hơn nữa khi giới thiệu Solid Cable. Adapter Action Cable sử dụng database này xử lý việc gửi nhận tin nhắn pub/sub thông qua SQLite, PostgreSQL hoặc MySQL, xóa bỏ yêu cầu bắt buộc phải có một Redis server chuyên dụng như trước đây.
Đánh giá Hotwire: Điểm mạnh và những đánh đổi trong thực tế
Hotwire không phải là phương thuốc vạn năng. Hiểu rõ các điểm mạnh nhất và giới hạn kỹ thuật của nó sẽ giúp bạn tránh được những lần đập đi xây lại dự án đau đớn giữa chừng.
Những trường hợp Hotwire phát huy tối đa thế mạnh
- Mô hình tư duy thống nhất: Business logic, domain model, validation và route đều nằm chung một nơi. Bạn chỉ cần viết markup một lần duy nhất trong các template ERB tiêu chuẩn.
- Payload gọn nhẹ hơn: Việc truyền HTML được render sẵn giúp cắt giảm bundle asset ban đầu từ mức điển hình 1.2 MB của React runtime xuống còn chưa đầy 45 kB JavaScript nén của Hotwire. Trình duyệt tốn ít thời gian phân tích cú pháp script hơn trên các thiết bị di động cấu hình yếu.
- WebSockets tối giản boilerplate: Chỉ một callback duy nhất trong model là đủ để broadcast các bản cập nhật bản ghi tới hàng trăm tab trình duyệt đồng thời.
- Bàn giao tính năng nhanh hơn: Xây dựng một màn hình CRUD quản trị hay một activity feed chỉ mất vài giờ thay vì vài ngày, bởi bạn không cần phải viết song song các interface TypeScript và slice Redux.
Những trường hợp SPA phía client vẫn chiếm ưu thế
- Quy trình Offline-First: Nếu sản phẩm của bạn đòi hỏi đầy đủ chức năng khi không có kết nối mạng—chẳng hạn như ứng dụng giám sát hiện trường hay công cụ vẽ vector—thì một framework phía client kết hợp bộ nhớ đệm IndexedDB là lựa chọn đúng đắn.
- Tương tác tần suất cao: Các giao diện đòi hỏi thao tác kéo thả 60 fps mượt mà, thao tác trên canvas, hay kéo thanh timeline âm thanh vẫn rất cần các trình quản lý trạng thái chuyên dụng phía client.
- Tài nguyên xử lý của server khi chịu tải nặng: Render các HTML partial tiêu tốn nhiều chu kỳ CPU của server hơn so với việc trả về các chuỗi JSON thô. Khi đạt đến hàng chục nghìn request mỗi giây, bạn cần dự trù phân bổ thêm tài nguyên máy chủ.
Cấu hình Rails 8 cho Hotwire Real-Time
Các bản cài đặt mới của Rails 8 đã tích hợp sẵn Hotwire và tự động cấu hình Solid Cable. Bạn không cần phải cài đặt Redis hay quản lý các background service đi kèm chỉ để chạy WebSockets ở môi trường local.
Bước 1: Khởi tạo ứng dụng
rails new live_pulse --database=sqlite3
cd live_pulse
Rails 8 chạy SQLite ở chế độ Write-Ahead Logging (WAL) theo mặc định, hỗ trợ đọc và ghi đồng thời ngay từ đầu. Hãy kiểm tra file config/cable.yml để xác nhận cấu hình adapter:
development:
adapter: solid_cable
connect_path: "/cable"
test:
adapter: test
production:
adapter: solid_cable
connect_path: "/cable"
Bước 2: Kiểm tra các dependency
Kiểm tra Gemfile của bạn. Hotwire phụ thuộc vào ba gem mà Rails 8 đã bao gồm theo mặc định:
gem "turbo-rails"
gem "stimulus-rails"
gem "solid_cable"
Nếu bạn đang nâng cấp từ một ứng dụng Rails cũ hơn, hãy chạy script cài đặt để gom các dependency và thiết lập importmap:
bin/rails hotwire:install
Triển khai từng bước: Xây dựng Live Feed
Hãy cùng xây dựng một bảng thông báo cộng tác. Khi một kỹ sư đăng thông báo mới, thông báo đó sẽ xuất hiện ngay lập tức trên mọi màn hình đang kết nối mà không cần tải lại trang thủ công.
Bước 1: Tạo Resource Notice
Chạy lệnh scaffold generator để tạo bảng cơ sở dữ liệu, model, các action trong controller và view:
bin/rails generate scaffold Notice title:string content:text
bin/rails db:migrate
Điều hướng người truy cập trực tiếp đến trang danh sách notice bằng cách chỉnh sửa config/routes.rb:
Rails.application.routes.draw do
resources :notices
root "notices#index"
end
Bước 2: Cấu hình Broadcast trong Model
Mở file app/models/notice.rb. Hotwire cho phép bạn liên kết trực tiếp các sự kiện trong vòng đời của ActiveRecord với các lượt truyền tin qua Action Cable:
class Notice < ApplicationRecord
validates :title, presence: true
validates :content, presence: true
broadcasts_to ->(notice) { "notices" }, inserts_by: :prepend
end
Chỉ một khai báo broadcasts_to này đã tự động hóa ba thao tác riêng biệt:
- Create: Khi một bản ghi được tạo, Rails sẽ render
_notice.html.erbvà chèn đoạn markup đó lên đầu container cóid="notices"tới tất cả subscriber đang hoạt động. - Update: Khi một notice được lưu với các thay đổi, Rails sẽ render lại partial và thay thế phần tử hiện có khớp với DOM ID của nó.
- Destroy: Khi một bản ghi bị xóa, Rails sẽ broadcast chỉ thị để loại bỏ node cụ thể đó khỏi DOM.
Bước 3: Subscribe Live Stream trong View
Mở file app/views/notices/index.html.erb và thay thế nội dung bằng layout sau:
<%= turbo_stream_from "notices" %>
<div class="header">
<h1>Thông báo trực tiếp</h1>
</div>
<%= turbo_frame_tag "new_notice_frame" do %>
<div class="form-container">
<h3>Đăng thông báo</h3>
<%= render "form", notice: Notice.new %>
</div>
<% end %>
<div id="notices" class="notices-list">
<%= render @notices %>
</div>
Helper turbo_stream_from "notices" sẽ đảm nhận phần việc nặng nhọc nhất. Nó thiết lập một kênh WebSocket thông qua Solid Cable. Bất cứ khi nào model broadcast các thay đổi, trình duyệt sẽ nhận đoạn HTML snippet và ghép trực tiếp vào document ngay tức thì.
Bước 4: Thêm DOM ID cố định vào Partial
Turbo Streams định vị các phần tử theo ID để thay thế hoặc xóa bỏ chúng. Hãy cập nhật file app/views/notices/_notice.html.erb để sử dụng helper dom_id có sẵn:
<div id="<%= dom_id notice %>" class="notice-card">
<h4><%= notice.title %></h4>
<p><%= notice.content %></p>
<small>Đã đăng: <%= time_ago_in_words(notice.created_at) %> trước</small>
<div class="actions">
<%= link_to "Chỉnh sửa", edit_notice_path(notice) %>
<%= button_to "Xóa", notice_path(notice), method: :delete %>
</div>
</div>
Bước 5: Reset giá trị Form bằng Stimulus
Vì Turbo Drive gửi form thông qua AJAX nên trang không tải lại. Do đó, các ô nhập liệu văn bản vẫn giữ nguyên nội dung sau khi gửi thành công. Một Stimulus controller nhỏ gọn sẽ xử lý vấn đề này chỉ với năm dòng code.
Khởi tạo khung controller:
bin/rails generate stimulus reset-form
Bây giờ hãy cập nhật app/javascript/controllers/reset_form_controller.js. Hãy chắc chắn kiểm tra event.detail.success để các lỗi validation không xóa mất dữ liệu người dùng đã nhập:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
reset(event) {
if (event.detail.success) {
this.element.reset()
}
}
}
Gắn controller vào phần tử form trong app/views/notices/_form.html.erb:
<%= form_with(model: notice, data: { controller: "reset-form", action: "turbo:submit-end->reset-form#reset" }) do |form| %>
<% if notice.errors.any? %>
<div class="errors">
<ul>
<% notice.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :title %>
<%= form.text_field :title %>
</div>
<div>
<%= form.label :content %>
<%= form.text_area :content %>
</div>
<div>
<%= form.submit "Xuất bản" %>
</div>
<% end %>
Khi request hoàn tất thành công, Turbo sẽ kích hoạt sự kiện turbo:submit-end. Stimulus bắt lấy tín hiệu đó và xóa trắng các trường trong form một cách gọn gàng mà không can thiệp vào global state.
Bước 6: Kiểm thử Real-Time Feed
Khởi động web server Puma:
bin/dev
# hoặc
bin/rails server
Mở hai cửa sổ trình duyệt riêng biệt cạnh nhau tại địa chỉ http://localhost:3000. Gửi một thông báo mới ở cửa sổ bên trái. Chỉ trong vòng 50 đến 100 mili-giây, thẻ thông báo sẽ xuất hiện ở đầu feed trên cửa sổ bên phải, đồng thời form bên trái tự động xóa trắng. Nhấn nút xóa ở một trong hai cửa sổ, và quan sát mục đó biến mất đồng bộ trên cả hai màn hình.
Giờ đây bạn đã có một ứng dụng web mang tính phản ứng (reactive) sẵn sàng cho môi trường production, được xây dựng hoàn toàn dựa trên các quy ước của Rails 8—mà không cần viết các kênh WebSocket tùy chỉnh hay duy trì một chuỗi công cụ build frontend phức tạp.

