— 本教程讲解在 Bricks 构建器里用 Isotope 实现:给无限滚动查询循环容器启用 AJAX 筛选、添加排序功能、添加列表视图切换器,以及禁用筛选时的 CSS 动画。 ## 目录 - 环境要求
-
结论 ## 环境要求 - 第三部分 的所有功能都已正常运行 ## 无限滚动查询循环容器的 AJAX 筛选 听起来很难实现,其实比看起来简单得多。功劳属于 Jenn Lee——她解释了怎么使用 Bricks 创建的 AJAX 端点,并找到了正确的 AJAX 函数,让 isotope 在新内容加载时被触发。 第一步:在查询循环容器里启用无限滚动设置,并给
posts_per_page设一个上限: 作者在服务器上开启这个设置时遇到过几个报错——循环因为某些未知原因没有查询到正确的post_id,无限循环返回了错误。所以务必先确认不开 isotope 时无限循环能正常工作,再进行下一步。 把下面的代码粘贴到init.js里以启用 AJAX 筛选(具体粘贴位置见文末的最终代码):const open = window.XMLHttpRequest.prototype.open; function XMLOpenReplacement() { this.addEventListener("load", function () { let current_url = new URL(this.responseURL); if (current_url.pathname.includes("/load_query_page")) { //reload the items iso.reloadItems(); //set the padding if (wrapper.dataset.gutter) { isotopeSelector = wrapper.querySelectorAll('.isotope-selector'); isotopeSelector.forEach(elm => elm.style.paddingBottom = isotopeGutter + 'px'); } //rearrange the container iso.arrange(); } }); return open.apply(this, arguments); } window.XMLHttpRequest.prototype.open = XMLOpenReplacement;简单说,这个函数利用了 Bricks 创建的名为load_query_page的端点。作者创建了一个 EventListener 函数,每次这个 AJAX 端点被加载时就触发 isotope 相关函数。 isotope 相关函数很基础:首先重载所有项目(reloadItems),让 isotope 容器包含所有选择器——包括新加载的;然后如果设置了 gutter,就给所有项目补上 gutter 内边距;最后重新排列(arrange)容器、修正布局。就这些。 ## 禁用 CSS 动画 有一件事困扰着作者:每次通过 AJAX 加载新项目时,新项目会带一个 transform 动画,像是从顶部飞到底部的新位置。 如果这也让你不爽,可以在isotopeOptions变量里加一行,直接停掉所有选择器的动画:var isotopeOptions = { transitionDuration: 0, };## 排序功能 不少人要求加排序功能,现在就做。 这次添加一个下拉选择框(select),选项是要应用的排序方式:按最新、最旧、A 到 Z、Z 到 A。把这段代码放进一个代码元素: ```
注意作者稍微调整了 DOM 结构,把下拉框(以及网格/列表视图按钮)放到了 isotope 容器上方: 现在进入 init.js,把新输入存进变量:
var sorting = wrapper.querySelector(‘#sorting’);
if (!sorting) { console.log(‘No Sorting input found. Make sure your sorting dropdown input has the ID “#sorting”’);
}
需要在 `isotopeOptions` 变量里声明排序标准。本例添加 `name`(对应"A to Z"和"Z to A")和 `date`(对应"Newest"和"Oldest"):
var isotopeOptions = { getSortData: { name: (el) => { return el.querySelector(‘.post-title’).textContent; }, date: (el) => { return el.querySelector(‘.post-date’).textContent; } },
};
可以看到两个函数都查询了元素内部的子元素并抓取其文本内容:`name` 取文章标题(Post Title),`date` 取文章日期(Post Date)。记得给 Bricks 元素加上 `.post-title` 和 `.post-date` 类。 最后一步是创建 EventListener 函数,每次下拉框的值改变时就触发排序:
// Event Listener for sorting if (sorting) { sorting.addEventListener(‘change’, (e) => { switch (e.target.value) { case “newest”: iso.arrange({ sortBy: ‘date’, sortAscending: false }); break; case “oldest”: iso.arrange({ sortBy: ‘date’, sortAscending: true }); break; case “a_to_z”: iso.arrange({ sortBy: ‘name’, sortAscending: true }); break; case “z_to_a”: iso.arrange({ sortBy: ‘name’, sortAscending: false }); break; default: return; } })
}
每次下拉框值改变时,switch 函数会检查对应的情况。如果值匹配某个 case,就会用 `sortBy` 条件和通过 `sortAscending` 指定的升序/降序重新排列 isotope 容器。 ## 列表视图 这个很直接。在新的顶栏容器里加两个按钮,分别设置 ID 为 `grid-view` 和 `list-view`。 在 `init.js` 里查询这两个按钮:
var gridView = wrapper.querySelector(‘#grid-view’);
// Show a message in console if no grid-view buttons have been found
if (!gridView) { console.log(‘No grid-view button found. Make sure that your grid-view button has the ID “#grid-view”’)
} var listView = wrapper.querySelector(‘#list-view’);
// Show a message in console if no list-view buttons have been found
if (!listView) { console.log(‘No list-view button found. Make sure that your list-view button has the ID “#grid-view”’)
}
然后给它们各加一个 EventListener 函数:
//Event Listerner for grid-view
if (gridView) { gridView.addEventListener(‘click’, (e) => { e.preventDefault(); gridView.classList.add(‘filterbtn–active’); listView.classList.remove(‘filterbtn–active’); isotopeContainer.classList.remove(‘list’); iso.arrange(); })
} //Event Listerner for list-view
if (listView) { listView.addEventListener(‘click’, (e) => { e.preventDefault(); listView.classList.add(‘filterbtn–active’); gridView.classList.remove(‘filterbtn–active’); isotopeContainer.classList.add(‘list’); iso.arrange(); })
}
脚本会在按钮被点击时切换 `filterbtn--active` 类。注意这里复用了第二部分创建的激活类,你也可以创建自己的专属类并在 Bricks 构建器里设置样式。 但最重要的是:在 isotope 容器上切换一个 `list` 类。现在只需在页面设置里插入下面的 CSS:
.isotope-container.list { –col: 1;
}
只改 CSS 变量 `--col`,就重新计算了 isotope 容器的全部尺寸。 可选:可以通过 `.isotope-container.list` 类随心所欲地设置列表视图样式。不过 Bricks 目前还不支持这个功能,CSS 得手动写。 作者示例里加的 CSS 如下:
@media screen and (min-width: 1200px) { /* ARTICLE / .isotope-container.list article { flex-direction: row !important; align-items: stretch; gap: 20px; } / LEFT COL / .isotope-container.list article>a { max-width: 45%; align-self: stretch !important; flex-basis: 100%; } /RIGHT COL / .isotope-container.list article>div { flex-basis: 100%; } / FEATURE IMAGE / .isotope-container.list article>a>img { aspect-ratio: unset; position: absolute; top: 50%; left: 50%; bottom: 0; right: 0; width: 100%; height: 100%; transform: translate(-50%, -50%); } / READ MORE BUTTONS / .isotope-container.list article>div>a { margin: 0; align-self: end; } .isotope-container.list article>div>a>div { border-radius: 10px 0 10px 0 !important; }
}
搞定! ## 最终代码 这是 `init.js` 文件的最终代码:
window.addEventListener(‘DOMContentLoaded’, () => { const isotopeWrappers = document.querySelectorAll(‘.isotope-wrapper’); // Stop the function if there is no isotope wrapper detected if (isotopeWrappers.length < 1) { return console.log(‘No isotope wrapper found. Make sure to add the “.isotope-wrapper” class to the main isotope wrapper’); }; // Default variable // buttons var filterRes = true; var filterSelector = “”; // search var filterSearch = true; var qsRegex // range var filterRange = true; var filterRangeValue = ‘’; // checkbox var filterCheckbox = true; var filterCheckboxValue = ‘’; // radio var filterRadio = true; var filterRadioValue = ‘’; // Loop inside each isotope wrapper isotopeWrappers.forEach(wrapper => { // Set variable and Error Handling var isotopeContainer = wrapper.querySelector(‘.isotope-container’); if (!isotopeContainer) { return console.log(‘No isotope container found. Make sure to add the “.isotope-container” class to the container of your selectors’); } var isotopeSelector = isotopeContainer.querySelectorAll(‘.isotope-container .isotope-selector’); if (isotopeSelector.length < 1) { return console.log(‘No isotope selector found. Make sure to add the “.isotope-selector” class to all your selector’); } var buttons = wrapper.querySelectorAll(“.filterbtn-container .filterbtn”); // Show a message in console if no buttons have been found if (buttons.length < 1) { console.log(‘No filter wrapper or filter buttons found. Make sure your filter wrapper has the class “.filterbtn-wrapper” and all your filter buttons have the class “.filterbtn”’); } var quickSearch = wrapper.querySelector(‘#quicksearch’); // Show a message in console if no search input have been found if (!quickSearch) { console.log(‘No QuickSearch found. Make sure your search input has the ID “#quicksearch”’); } var range = wrapper.querySelector(‘#range’); // Show a message in console if no range slider have been found if (!range) { console.log(‘No Range found. Make sure your range input has the ID “#range”’); } var checkboxes = wrapper.querySelectorAll(‘.checkbox’); // Show a message in console if no checkboxes have been found if (checkboxes.length < 1) { console.log(‘No checkbox found. Make sure your checkbox input has the class “.checkbox”’); } var radios = wrapper.querySelectorAll(‘.radio’); // Show a message in console if no radio buttons have been found if (checkboxes.length < 1) { console.log(‘No radio found. Make sure your radio input has the class “.radio”’); } var sorting = wrapper.querySelector(‘#sorting’); if (!sorting) { console.log(‘No Sorting found. Make sure your sorting dropdown input has the ID “#sorting”’); } var gridView = wrapper.querySelector(‘#grid-view’); // Show a message in console if no grid-view buttons have been found if (!gridView) { console.log(‘No grid-view button found. Make sure that your grid-view button has the ID “#grid-view”’) } var listView = wrapper.querySelector(‘#list-view’); // Show a message in console if no list-view buttons have been found if (!listView) { console.log(‘No list-view button found. Make sure that your list-view button has the ID “#grid-view”’) } // Gutter Settings through data-gutter if (wrapper.dataset.gutter) { var isotopeGutter = parseInt(wrapper.dataset.gutter); wrapper.style.setProperty(‘–gutter’, isotopeGutter + ‘px’); isotopeSelector.forEach(elm => elm.style.paddingBottom = isotopeGutter + ‘px’); } else { // Default option var isotopeGutter = 0; console.log(‘No data-gutter attribute has been found on your isotope container. Default set to 0.’); }; // Layout Settings through data-filter-layout if (wrapper.dataset.filterLayout) { var isotopeLayoutHelper = wrapper.dataset.filterLayout; } else { // Default option var isotopeLayoutHelper = ‘fitRows’; console.log(‘No data-filter-layout attribute has been found on your isotope container. Default set to “fitRows”.’); }; // init Isotope var isotopeOptions = { itemSelector: ‘.isotope-selector’, layoutMode: isotopeLayoutHelper, //transitionDuration: 0, /Uncomment to disable animations getSortData: { name: (el) => { return el.querySelector(‘.post-title’).textContent; }, date: (el) => { return el.querySelector(‘.post-date’).textContent; } }, filter: (itemElem1, itemElem2) => { const itemElem = itemElem1 || itemElem2; // tags/buttons if (buttons.length > 0) { filterRes = filterSelector != ‘’ ? itemElem.dataset.filter.includes(filterSelector) : true; } // quicksearch if (quickSearch) { filterSearch = qsRegex ? itemElem.textContent.match(qsRegex) : true; } // range if (range) { filterRange = filterRangeValue != ‘’ ? parseInt(itemElem.dataset.range) <= filterRangeValue : true; } // checkboxes if (checkboxes.length > 0) { filterCheckbox = filterCheckboxValue != ‘’ ? filterCheckboxValue.includes(itemElem.dataset.checkbox) : true; } // radio if (radios.length > 0) { filterRadio = filterRadioValue != ‘*’ ? filterRadioValue.includes(itemElem.dataset.radio) : true; } return filterRes && filterSearch && filterRange && filterCheckbox && filterRadio; } }; // Set the correct layout switch (isotopeLayoutHelper) { case ‘fitRows’: isotopeOptions.fitRows = { gutter: isotopeGutter }; break; case ‘masonry’: isotopeOptions.masonry = { gutter: isotopeGutter }; break; } var iso = new Isotope(isotopeContainer, isotopeOptions); // debounce so filtering doesn’t happen every millisecond const debounce = (fn, threshold) => { var timeout; threshold = threshold || 200; return function debounced() { clearTimeout(timeout); var args = arguments; var _this = this; function delayed() { fn.apply(_this, args); } timeout = setTimeout(delayed, threshold); }; }; // Event Listener for buttons if (buttons.length > 0) { buttons.forEach(elem => elem.addEventListener(“click”, (event) => { event.preventDefault(); // get the data-filter attribute from the filter button var filterValue = event.target.getAttribute(“data-filter”); filterSelector = filterValue; // filter results iso.arrange(); })); }; // Event Listener for search input if (quickSearch) { quickSearch.addEventListener(‘keyup’, debounce((event) => { qsRegex = new RegExp(quickSearch.value, ‘gi’); //filter the store list iso.arrange(); }, 200)); } // Event Listener for range slider if (range) { const rangeFilterFN = (e) => { filterRangeValue = parseInt(e.target.value); //filter the store list iso.arrange(); } range.addEventListener(‘input’, debounce((e) => { rangeFilterFN(e); }, 200)); range.addEventListener(‘keyup’, debounce((e) => { rangeFilterFN(e); }, 200)); } // Event Listener for checkboxes if (checkboxes) { checkboxes.forEach(checkbox => { checkbox.addEventListener(‘click’, debounce(() => { arrCheckbox = []; checkboxes.forEach(cb => { if (!cb.checked) { return; } arrCheckbox.push(cb.value); }) filterCheckboxValue = arrCheckbox; //filter the store list iso.arrange(); }, 200)); }) } // Event Listener for radio buttons if (radios) { radios.forEach(radio => { radio.addEventListener(‘change’, debounce(() => { arrRadio = []; radios.forEach(radio => { if (!radio.checked) { return; } arrRadio.push(radio.value); }) filterRadioValue = arrRadio; //filter the store list iso.arrange(); }, 200)); }) } // Event Listener for sorting if (sorting) { sorting.addEventListener(‘change’, (e) => { switch (e.target.value) { case “newest”: iso.arrange({ sortBy: ‘date’, sortAscending: false }); break; case “oldest”: iso.arrange({ sortBy: ‘date’, sortAscending: true }); break; case “a_to_z”: iso.arrange({ sortBy: ‘name’, sortAscending: true }); break; case “z_to_a”: iso.arrange({ sortBy: ‘name’, sortAscending: false }); break; default: return; } }) } //Event Listener for grid-view if (gridView) { gridView.addEventListener(‘click’, (e) => { e.preventDefault(); gridView.classList.add(‘filterbtn–active’); listView.classList.remove(‘filterbtn–active’); isotopeContainer.classList.remove(‘list’); iso.arrange(); }) } // Event Listener for list-view if (listView) { listView.addEventListener(‘click’, (e) => { e.preventDefault(); listView.classList.add(‘filterbtn–active’); gridView.classList.remove(‘filterbtn–active’); isotopeContainer.classList.add(‘list’); iso.arrange(); }) } // Event Listener for filter buttons const radioButtonGroup = (buttonGroup) => { buttonGroup.addEventListener(“click”, (event) => { buttons.forEach(btn => btn.classList.remove(“filterbtn–active”)); event.target.classList.add(“filterbtn–active”); }); }; // change is-checked class on buttons for (var i = 0, len = buttons.length; i < len; i++) { var buttonGroup = buttons[i]; radioButtonGroup(buttonGroup); }; setTimeout(() => { iso.arrange() }, 300); // AJAX FILTER const open = window.XMLHttpRequest.prototype.open; function XMLOpenReplacement() { this.addEventListener(“load”, function () { let current_url = new URL(this.responseURL); if (current_url.pathname.includes(“/load_query_page”)) { //reload the items iso.reloadItems(); //set the padding if (wrapper.dataset.gutter) { isotopeSelector = wrapper.querySelectorAll(‘.isotope-selector’); isotopeSelector.forEach(elm => elm.style.paddingBottom = isotopeGutter + ‘px’); } //rearrange the container iso.arrange(); } }); return open.apply(this, arguments); } window.XMLHttpRequest.prototype.open = XMLOpenReplacement; }); });
>
- [Bricks 中使用 Core Framework](/blog/core-framework-in-bricks/)
- [Bricks 内容切换器:纯 CSS 方案(radio + :has 实现标签切换)](/blog/a-css-approach-to-content-switcher-in-bricks/)
- [Bricks 用 ACF Checkbox 字段渲染自定义 SVG 图标列表](/blog/acf-checkbox-svg-bullets/)
## 结论 除非社区再提出什么特殊需求,这篇教程就为 Isotope 系列画上句号了——教你如何在不引入任何第三方插件的情况下,把智能筛选整合进下一个 Bricks 项目。希望你喜欢!
## 常见误区与性能提示
几个坑:一是 AJAX 筛选每次都重查全量数据,量大时卡,配合缓存或只在前端过滤已取到的数据;二是无限循环(infinite scroll)和分页混用,爬虫只抓第一屏、内页不收录,要收录就保留分页或做结构化加载;三是禁用动画没关干净,切换时仍闪。Isotope 适合「纯前端、不引第三方插件」的智能筛选,但数据量大、要 SEO 收录时,得权衡是否上服务端筛选。
## 延伸阅读
- 想要现成外贸独立站,从零搭出能收询盘的站?看 [铲子铺 chanzipu 的外贸建站教程](https://chanzipu.com/)(同用 Bricks)
## 常见问题(FAQ)
**AJAX 筛选卡顿?** 量大时缓存查询结果,或只在已取到的数据里前端过滤,别每次打全量请求。
**无限滚动影响收录吗?** 会,爬虫只抓第一屏,要收录就保留分页或做可被爬取的结构化加载。
**动画关不掉?** 检查禁用动画选项是否真正生效,切换仍闪就重新初始化 Isotope。
**Isotope 和服务器端筛选怎么选?** 数据小、纯前端交互用 Isotope;要 SEO 收录、数据大就上服务端筛选更稳。
- 相关阅读:[Bricks Setup Guide](/blog/bricks-setup-guide/)
延伸阅读
Bricks 内容切换器:纯 CSS 方案(radio + :has 实现标签切换)
实战技巧:Bricks 内容切换器:纯 CSS 方案(radio + :has 实现标签切换)——css 实操步骤+踩坑提醒,适合外贸独立站与 WordPress 开发者。
tipsBricks 中同步两个可嵌套轮播(主轮播 + 缩略图联动)
实战技巧:Bricks 中同步两个可嵌套轮播(主轮播 + 缩略图联动)——slider 实操步骤+踩坑提醒,适合外贸独立站与 WordPress 开发者。
tipsBricks 手风琴(Nestable):第一项桌面端展开、移动端收起
实战技巧:Bricks 手风琴(Nestable):第一项桌面端展开、移动端收起——Bricks 实操步骤+踩坑提醒,适合外贸独立站与 WordPress 开发者。
tips把色板选择器移到颜色弹窗顶部
实战技巧:把色板选择器移到颜色弹窗顶部——Bricks 实操步骤+踩坑提醒,适合外贸独立站与 WordPress 开发者。
