
Часто ли вам приходилось писать обработчики фильтрации для ваших данных? Это могут быть массивы для отрисовки таблиц, карточек, списков — чего угодно.
Когда фильтрация статическая, то тут все просто. Стандартных функций map, filter и reduce вполне достаточно. Но что делать, если данные имеют сложную структуру или вложенность, да еще и правил для фильтра может быть достаточно много. Правила могут повторяться, данные изменяться, и чем больше контролов фильтра будет появляться, тем сложнее и неустойчивее будет код обработчика.
Как же решить проблему возрастающей сложности?
Я сам столкнулся с данной проблемой, разрабатывая приложение, которое работает с огромным количеством данных. Постепенно добавлялись все новые и новые фильтры.
Поначалу все шло хорошо, отдельные обработчики фильтров выполняли свои задачи отлично. Со временем стали появляться страницы с незначительными изменениями в уже готовых фильтрах. Код дублировать не хотелось, так что пришлось выделять особенные варианты фильтра и его обработчика. Типовая страница собиралась за час, но вот обработчики для фильтров писались в два раза дольше. Нужно было что-то делать.
В свободное время я пробовал найти библиотеки, которые могли бы помочь решить эту проблему.
Самые значимые библиотеки, которые удалось найти:
- ng-table— отрисовывает таблицы и предоставляет возможность простой фильтрации и сортировки. Фильтрации у нас были намного сложнее.
- List.js— как ng-table, только намного меньше функционала.
- filter.js— близко к тому, что было нужно, но не хватало гибкости.
- Isotope— привязывается к DOM элементам. У нас же просто данные.
Нужно было разработать собственное решение, удовлетворяющее следующим требованиям:
- Декларативное описание работы фильтра.
- Переиспользуемые правила.
- Возможность написания своих правил фильтрации.
- Композиция правил как в обычных условиях, с помощью andиorоператоров.
- Фильтрация вложенных элементов и групп. Вложенность не фиксированная.
- Возможность задать стратегию фильтрации (к примеру, мы хотим, чтобы, если группа совпала с фильтром, но не совпал ни один из её элементов, все равно вывелась группа со всеми элементами)
В итоге мы имеем библиотеку awesome-data-filter.
Данная библиотека, используя декларативный подход, позволяет составлять сложные правила обработки ваших данных.
Установка
Сначала поставим библиотеку и опробуем ее в действии.
npm install awesome-data-filterНачнем с простого примера.
Предположим, у нас есть следующий массив пользователей:
const users = [
  {
    age: 31,
    name: "Marina Gilmore",
  },
  {
    age: 34,
    name: "Joyner Mccray",
  },
  {
    age: 23,
    name: "Inez Copeland",
  },
  {
    age: 23,
    name: "Marina Mitchell",
  },
  {
    age: 25,
    name: "Prince Spears",
  },
];И объект со значениями фильтра:
const filterValue = {
  age: 23,
  searchText: "mari",
};Допустим, нужно найти пересечение этих правил.
Используемые правила:
- matchText— поиск подстроки в целевом поле;
- equalProp— полное совпадение значений параметров;
 - betweenDates— проверяет вхождение определенной даты в диапазон;
- equalOneOf— хотя бы один из переданных элементов должен соответствовать переданному правилу;
- someInArray— хотя бы один из вложенных элементов объекта должен соответствовать переданному правилу;
- isEmptyArray— проверка на пустой массив;
- lessThen— значение меньше, чем;
- moreThen— значение больше, чем;
- not— функция отрицания возвращаемого функцией значения.
 
В наших примерах мы будем использовать только matchText и equalProp.
Для получения динамических значений:
- filterField— получение свойства фильтра;
- elementField— получение свойства текущего элемента списка.
import { 
  buildFilter, 
  elementField, 
  filterField, 
} from "awsome-data-filter";
import { matchText, equalProp } from "awsome-data-filter/rules";
import { and } from "awsome-data-filter/conditions";
const filter = buildFilter({
    rules: {
      elementFilter: and([
        matchText(filterField("searchText"), elementField("name")),
        equalProp(filterField("age"), elementField("age")),
      ]),
    },
  });
const { elements } = filter(
    filterValue,
    {
      groups: [],
      elements: users,
    },
);
console.log(elements);
// elements: [{ age: 23, name: "Marina Mitchell" }]Полученная функция filter принимает объект со значениями фильтра и фильтруемые данные в формате groups и elements.
Так как группы обрабатываются отдельно от элементов, они вынесены в отдельное поле. Внутри групп также могут находиться элементы.

В данном случае, так как у нас плоский список элементов, передаем только elements.
Если же заменим and на or, то получим объединение результатов работы 2х правил.
import { 
  buildFilter, 
  elementField, 
  filterField, 
} from "awsome-data-filter";
import { matchText, equalProp } from "awsome-data-filter/rules";
import { or } from "awsome-data-filter/conditions";
const filter = buildFilter({
    rules: {
      elementFilter: or([
        matchText(filterField("searchText"), elementField("name")),
        equalProp(filterField("age"), elementField("age")),
      ]),
    },
  });
const { elements } = filter(
    filterValue,
    {
      groups: [],
      elements: users,
    },
);
console.log(elements);
// elements: 
// [
//   {
//     age: 31,
//     name: "Marina Gilmore",
//   },
//   {
//     age: 23,
//     name: "Inez Copeland",
//   },
//   {
//     age: 23,
//     name: "Marina Mitchell",
//   }
// ]Благодаря функциям filterField, elementField мы можем динамически передавать параметры в созданные правила.
Так же есть функция constValue для передачи константных значений.
Условия могут вкладываться друг в друга or(..., matchText, [and([..., matchText, ...]), or([..., ...])])
Также фильтр может работать со вложенными элементами и группами. Рассмотрим на примере ниже:
const dataList = [
  {
    groupName: "first group",
    list: [
      { age: 31, name: "Marina" },
      { age: 23, name: "Fabio" },
    ],
  },
  {
    groupName: "second group",
    groups: [
      {
        groupName: "third group",
        list: [],
        groups: [
          {
            groupName: "fourth group",
            list: [{ age: 42, name: "Li" }],
          },
        ],
      },
    ],
    list: [
      { age: 41, name: "Marina" },
      { age: 29, name: "Inez" },
      { age: 33, name: "Marina" },
    ],
  },
  {
    groupName: "fifth group",
    list: [
      { age: 21, name: "Dmitriy" },
      { age: 22, name: "Li" },
      { age: 45, name: "Mitchell" },
    ],
  },
];В таком случае можно передать в конфиг фильтра информацию об обходе данной структуры объекта в поле traversal: 
import { 
  buildFilter, 
  elementField, 
  filterField, 
} from "awsome-data-filter";
import { matchText } from "awsome-data-filter/rules";
const filter = buildFilter({
    traversal: {
      getChildrenFunc: group => group.list, // как получить конечные элементы
      setChildrenFunc: (group, list) => ({ ...group, list }), // как записать конечные элементы в группу
      getGroupsFunc: group => group.groups, // как получить вложенные группы
      setGroupsFunc: (group, groups) => ({ ...group, groups }), // как записать вложенные группы
    },
    rules: {
      elementFilter: matchText(filterField("searchText"), elementField("name")),
    },
  });
const filterValue = {
  searchText: "li",
};
const { groups } = filter(filterValue, {
  groups: dataList, // группы с вложенными элементами и группами
  elements: [], // как элементы можно передавать только плоские списки
});
console.log(groups);
// groups: 
//[
//  {
//    groupName: "second group",
//    groups: [
//      {
//        groupName: "third group",
//        list: [],
//        groups: [
//          {
//            groupName: "fourth group",
//            list: [{ age: 42, name: "Li" }],
//          },
//        ],
//      },
//    ],
//    list: [],
//  },
//  {
//    groupName: "fifth group",
//    list: [
//      { age: 22, name: "Li" },
//    ],
//  },
//]До этого момента передавался только elementFilter параметр, который отвечает за правила фильтрации элементов. Также есть groupFilter для групп.
import { 
  buildFilter, 
  elementField, 
  filterField, 
} from "awsome-data-filter";
import { matchText } from "awsome-data-filter/rules";
const filter = buildFilter({
    traversal: {
      getChildrenFunc: group => group.list, // как получить конечные элементы
      setChildrenFunc: (group, list) => ({ ...group, list }), // как записать конечные элементы в группу
      getGroupsFunc: group => group.groups, // как получить вложенные группы
      setGroupsFunc: (group, groups) => ({ ...group, groups }), // как записать вложенные группы
    },
    rules: {
      elementFilter: matchText(filterField("searchText"), elementField("name")),
      groupFilter: matchText(filterField("groupName"), elementField("groupName")),
    },
  });
const filterValue = {
  searchText: "li",
  groupName: "fi",
};
const { groups } = filter(filterValue, {
  groups: dataList,
  elements: [],
});
console.log(groups);
// groups:
//[
//  {
//    groupName: "first group",
//    list: [
//      { age: 31, name: "Marina" },
//      { age: 23, name: "Fabio" },
//    ],
//  },
//  {
//    groupName: "second group",
//    groups: [
//      {
//        groupName: "third group",
//        list: [],
//        groups: [
//          {
//            groupName: "fourth group",
//            list: [{ age: 42, name: "Li" }],
//          },
//        ],
//      },
//    ],
//    list: [],
//  },
//  {
//    groupName: "fifth group",
//    list: [
//      { age: 22, name: "Li" },
//    ],
//  },
//]Группа с названием first group появилась в выборке, и так как фильтр не нашел совпадения по элементам, но нашел совпадение по группе, мы видим отображение всех вложенных элементов списка.
В случае с fifth group совпадение было и по элементам, и по группе, поэтому оставляем только один элемент.
Подобные зависимости фильтрации между группами и элементами называются стратегией фильтрации. По умолчанию указана следующая стратегия:
const standardStrategy: StrategiesFilterInterface = {
  elementHandler: ({ // стратегия на обработку конечных элементов
    element,
    tools: { 
        isGroupFilterIsActive, // есть ли фильтр по группам
        applyElementFilter // функция фильтрации элемента
    },
  }) => {
    if (isGroupFilterIsActive) return null;
    if (!applyElementFilter) return element;
    return applyElementFilter(element, true) ? element : null;
  },
  groupHandler: ({
    element: group,
    originalElement: originalGroup,
    tools: {
      isGroupFilterIsActive,
      applyElementFilter,
      getIsGroupFilterHaveMatch,
      getGroupsFunc,
      getChildrenFunc,
      setChildrenFunc,
    },
  }) => {
    let newChildren = [];
    let originalChildren = [];
    const children = getChildrenFunc(group);
    const childrenExists = !!children;
    // если элементы есть фильтруем их
    if (children) {
      originalChildren = [...children];
      newChildren = originalChildren.filter(element =>
        applyElementFilter
          ? applyElementFilter(element, !isGroupFilterIsActive)
          : !isGroupFilterIsActive,
      );
    }
    // если совпадений по элементам нет, но есть по группе, возвращаем исходный объект
    if (!newChildren.length && getIsGroupFilterHaveMatch(group)) {
      return originalGroup;
    }
    // если совпадения по элементам есть, записываем их в группу
    if (childrenExists) {
      group = setChildrenFunc(group, newChildren);
    }
    // проверка вложенных групп
    const newGroups = getGroupsFunc(group);
    const isGroupsExists = !!(newGroups && newGroups.length);
    const isElementExists = !!(newChildren && newChildren.length);
    // если нет вложенных элементов и групп, то удаляем группу
    return isElementExists || isGroupsExists ? group : null;
  },
};Если стандартная стратегия не подходит для вашего случая, можно написать свою и передать ее в поле фильтра filterStrategy.
Заключение
Благодаря использованию библиотеки awesome-data-filter можно решить проблему со сложностью обработчиков фильтров и улучшить читабельность кода. Теперь можно визуально сравнить графики сложности обоих подходов.

Если на вашем проекте не используется много фильтров и проект сам по себе небольшой, то вам не нужна эта библиотека. Остальным же можно пробовать внедрять ее постепенно.
Буду рад вашим комментариям, вопросам и советам.
 
           
 


reforms
А что думаете по поводу шаблонных языков в качестве фильтров для поиска и решения Вашей проблемы? Для указанных в статье данных это могло бы выглядеть так:
dpereverza Автор
Можно и так сделать, но тогда написание собственных правил намного усложняется.
Проброс данных через шаблонные строки работает с плоскими правилами. Если же мы погрузимся вглубь объекта или фильтра, шаблонные строки не очень помогут.
Пример