Как стать автором
Обновить

Декларативная фильтрация данных на фронте. JS/TS

Время на прочтение 7 мин
Количество просмотров 8.5K


Часто ли вам приходилось писать обработчики фильтрации для ваших данных? Это могут быть массивы для отрисовки таблиц, карточек, списков — чего угодно.


Когда фильтрация статическая, то тут все просто. Стандартных функций map, filter и reduce вполне достаточно. Но что делать, если данные имеют сложную структуру или вложенность, да еще и правил для фильтра может быть достаточно много. Правила могут повторяться, данные изменяться, и чем больше контролов фильтра будет появляться, тем сложнее и неустойчивее будет код обработчика.


Как же решить проблему возрастающей сложности?


Я сам столкнулся с данной проблемой, разрабатывая приложение, которое работает с огромным количеством данных. Постепенно добавлялись все новые и новые фильтры.


Поначалу все шло хорошо, отдельные обработчики фильтров выполняли свои задачи отлично. Со временем стали появляться страницы с незначительными изменениями в уже готовых фильтрах. Код дублировать не хотелось, так что пришлось выделять особенные варианты фильтра и его обработчика. Типовая страница собиралась за час, но вот обработчики для фильтров писались в два раза дольше. Нужно было что-то делать.


В свободное время я пробовал найти библиотеки, которые могли бы помочь решить эту проблему.
Самые значимые библиотеки, которые удалось найти:


  1. ng-table — отрисовывает таблицы и предоставляет возможность простой фильтрации и сортировки. Фильтрации у нас были намного сложнее.
  2. List.js — как ng-table, только намного меньше функционала.
  3. filter.js — близко к тому, что было нужно, но не хватало гибкости.
  4. Isotope — привязывается к DOM элементам. У нас же просто данные.

Нужно было разработать собственное решение, удовлетворяющее следующим требованиям:


  1. Декларативное описание работы фильтра.
  2. Переиспользуемые правила.
  3. Возможность написания своих правил фильтрации.
  4. Композиция правил как в обычных условиях, с помощью and и or операторов.
  5. Фильтрация вложенных элементов и групп. Вложенность не фиксированная.
  6. Возможность задать стратегию фильтрации (к примеру, мы хотим, чтобы, если группа совпала с фильтром, но не совпал ни один из её элементов, все равно вывелась группа со всеми элементами)

В итоге мы имеем библиотеку 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 можно решить проблему со сложностью обработчиков фильтров и улучшить читабельность кода. Теперь можно визуально сравнить графики сложности обоих подходов.



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


Буду рад вашим комментариям, вопросам и советам.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как вы решаете проблему динамических и сложных фильтраций данных?
33.33% Руками пишу все обработчики 16
12.5% Ramda в помощь 6
4.17% Использую похожую библиотеку 2
4.17% Попробую использовать эту 2
35.42% Пусть сложные данные фильтруют бэкендеры 17
10.42% Со сложными вариантами не встречался 5
Проголосовали 48 пользователей. Воздержались 12 пользователей.
Теги:
Хабы:
+7
Комментарии 12
Комментарии Комментарии 12

Публикации

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн