You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Rails7+Stimulus+Kaminari无限滚动带筛选排序异常求助

问题描述

我用Rails 7、Stimulus/Turbo和Kaminari gem给鸡尾酒配方应用做无限滚动。加载全部记录时功能正常,页码递增正确,滚动加载的新页记录顺序没问题。但添加筛选或排序参数后,行为异常:控制器返回的新页会出现2-3页重复,或者页码跳过多页。

通过DevTools观察到,每次向控制器发起Ajax请求时,分页控制器会被多调用一次。比如选择“Any Ingredient”排序后,滚动会加载重复页;再筛选波本酒时,一次滚动会加载3页(顺序混乱)。


相关代码

1. Cocktail Recipes控制器#index(按排序选项、分类、选中食材筛选记录)

def index
  @page = params[:page] || 1
  category_id = params[:categoryId]
  ingredient_ids = params[:ingredientIds] ? [*params[:ingredientIds]].map(&:to_i) : nil
  
  @recipes = Recipe.alphabetical.page(@page)

  case params[:sort_option]
  when ''
    params[:ingredientIds] ?
      @recipes = Recipe.alphabetical.filter_all_recipes(ingredient_ids, category_id).page(@page) :
      @recipes = Recipe.filter_all_by_category(category_id).page(@page)
    respond_to do |format|
      # 添加turbo_stream选项后需要显式调用formats: [:html]
      format.html { render partial: 'recipe_cards', formats: [:html] }
      format.turbo_stream
    end
  when 'All Recipes'
    params[:ingredientIds] ?
      @recipes = Recipe.alphabetical.filter_all_recipes(ingredient_ids, category_id).page(@page) :
      @recipes = Recipe.filter_all_by_category(category_id).page(@page)
    respond_to do |format|
      format.html { render partial: 'recipe_cards', formats: [:html] }
      format.turbo_stream
    end
  when 'Any Ingredient'
    params[:ingredientIds] ?
      @recipes = Recipe.alphabetical.match_any_subset(ingredient_ids, current_user.ingredients, category_id).page(@page) :
      @recipes = Recipe.alphabetical.match_any_ingredient(current_user.ingredients, category_id).page(@page)
    respond_to do |format|
      format.html { render partial: 'recipe_cards', formats: [:html] }
      format.turbo_stream
    end
  when 'All Ingredients'
    params[:ingredientIds] ?
      @possible_recipes = Recipe.match_all_subset(params[:recipeIds], ingredient_ids, category_id).page(@page) :
      @possible_recipes = Recipe.alphabetical.match_all_ingredients(current_user.ingredients, category_id).page(@page)
    respond_to do |format|
      format.html { render partial: 'recipe_cards', formats: [:html] }
      format.turbo_stream
    end
  end
end

2. 分页Stimulus控制器

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {

  // 获取/设置记录获取标志
  static get fetching() { return this.fetching; }
  static set fetching(bool) {
    this.fetching = bool;
  } 

  // 从目标元素获取url和页码
  static get values() { return {
    url: String,
    page: { type: Number, default: 1 },
  };}

  // 添加滚动事件监听器并设置fetching标志为false
  connect() {
    console.log("Pagination Controller Loaded");
    document.addEventListener('scroll', this.scroll);
    this.fetching = false;
  }

  // 将this绑定到控制器而非document
  initialize() {
    this.scroll = this.scroll.bind(this);
  }

  // 当滚动到页面底部时调用loadRecords()
  scroll() {
    if (this.pageEnd && !this.fetching) {
      this.loadRecords(); 
    }
  }

  // 记录获取函数
  async loadRecords() {
    // 从辅助方法获取预配置的url
    const url = getUrl(this.urlValue, this.pageValue);
    
    // 设置fetching标志为true
    this.fetching = true;

    // 向recipes控制器发送turbo_stream请求
    await fetch(url.toString(), {
      headers: {
        Accept: 'text/vnd.turbo-stream.html',
      },
    }).then(r => r.text())
      .then(html => Turbo.renderStreamMessage(html));

    // 设置fetching标志为false
    this.fetching = false;

    // 递增目标元素的页码
    this.pageValue += 1;
  }

  // 设置触发loadRecords()的边界
  get pageEnd() {
    const { scrollHeight, scrollTop, clientHeight } = document.documentElement;
    return scrollHeight - scrollTop - clientHeight < 40; // 可调整为所需阈值
  }
}

// ------------- 辅助函数 ----------------

// 从酒柜显示选项中获取选中的食材ID并返回数组
function getIngredientIds() {
  var ingredientIds = [...$('.cabinet-spirits').val(),
  ...$('.cabinet-modifiers').val(),
  ...$('.cabinet-sugars').val(),
  ...$('.cabinet-garnishes').val()];
  return ingredientIds;
}

// 如果有食材ID,将其作为数组附加到searchParams
function appendIngredientIds(url) {
  var ingredientIds = getIngredientIds();
  if (ingredientIds.length != 0) {
    ingredientIds.map(i => url.searchParams.append('ingredientIds', i));
  }
  return url;
}

// 配置url的searchParams并返回url
function getUrl(urlValue, pageValue) {
  var url = new URL(urlValue);
  url.searchParams.set('page', pageValue);
  url.searchParams.append('sort_option', $('.sort-options').val());
  url = appendIngredientIds(url);
  return url;
}

3. index.turbo.erb

<%= turbo_stream.append 'recipes' do %>
  <% @recipes.each do |recipe| %>
    <%= render partial: "recipe_card", locals: { recipe: recipe } %>
  <% end %>
<% end %>

4. 目标渲染div

<div class="container-fluid mt-2 mx-3">
  
  <div id="recipes" class="row row-cols-lg-5 row-cols-md-4 row-cols-sm-3 g-2" 
                    data-controller='pagination'
                    data-pagination-target='recipes'
                    data-pagination-url-value='<%= recipes_url %>'
                    data-pagination-page-value='<%= 2 %>'>

    <% @recipes.each do |recipe| %>
      <%= render partial: "recipe_card", locals: { recipe: recipe } %>
    <% end %>
  </div>
</div>

问题分析与解决方案

核心问题1:多个Stimulus控制器实例绑定滚动事件

每次筛选/排序后,Turbo重新渲染#recipes元素时,会初始化新的分页控制器实例,但旧实例未移除绑定的scroll事件。多个实例同时监听滚动,触发条件后会同时调用loadRecords,导致重复请求或页码跳变。

解决方法:在控制器的disconnect方法中移除滚动事件监听器:

disconnect() {
  document.removeEventListener('scroll', this.scroll);
}

核心问题2:筛选/排序后未重置页码

切换筛选或排序选项时,pageValue未重置为初始值(如2),新筛选结果从旧页码开始加载,加上多实例的页码值不一致,导致加载混乱。

解决方法:

  1. 在筛选/排序触发事件中(比如下拉框change事件),重置分页控制器的pageValue:
$('.sort-options').on('change', function() {
  const paginationController = Stimulus.getControllerForElement(document.getElementById('recipes'));
  if (paginationController) {
    paginationController.pageValue = 2;
  }
  // 同步替换#recipes内容为新筛选结果的第一页
});
  1. 确保每次筛选/排序后,#recipes内的内容被替换为新结果的第一页,避免旧内容残留。

核心问题3:fetching为静态属性

当前fetching是控制器类的静态属性,所有实例共享同一值。多实例存在时,一个实例设置fetching = true会影响所有实例,导致加载触发逻辑混乱。

解决方法:将fetching改为实例属性:

export default class extends Controller {
  connect() {
    console.log("Pagination Controller Loaded");
    document.addEventListener('scroll', this.scroll);
    this.fetching = false; // 实例属性
  }

  async loadRecords() {
    const url = getUrl(this.urlValue, this.pageValue);
    
    this.fetching = true; // 使用实例属性

    // ...请求代码

    this.fetching = false;
    this.pageValue += 1;
  }
}

额外问题:All Ingredients分支变量名不统一

控制器All Ingredients分支中赋值的是@possible_recipes,但index.turbo.erb渲染的是@recipes,会导致该分支下加载新页无内容,需统一变量名:

when 'All Ingredients'
  params[:ingredientIds] ?
    @recipes = Recipe.match_all_subset(params[:recipeIds], ingredient_ids, category_id).page(@page) :
    @recipes = Recipe.alphabetical.match_all_ingredients(current_user.ingredients, category_id).page(@page)
  respond_to do |format|
    format.html { render partial: 'recipe_cards', formats: [:html] }
    format.turbo_stream
  end

内容的提问来源于stack exchange,提问作者baconsocrispy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.14 20:25:30