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),新筛选结果从旧页码开始加载,加上多实例的页码值不一致,导致加载混乱。
解决方法:
- 在筛选/排序触发事件中(比如下拉框
change事件),重置分页控制器的pageValue:
$('.sort-options').on('change', function() { const paginationController = Stimulus.getControllerForElement(document.getElementById('recipes')); if (paginationController) { paginationController.pageValue = 2; } // 同步替换#recipes内容为新筛选结果的第一页 });
- 确保每次筛选/排序后,
#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
相关产品推荐
相关产品推荐

