Как сравнивать строки без учёта регистра

Используйте сравнение с учётом локали для корректного поиска без учёта регистра на разных языках

Введение

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

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

В JavaScript есть API Intl.Collator, который позволяет корректно сравнивать строки без учёта регистра на всех языках. В этом уроке объясняется, почему простое преобразование в нижний регистр не всегда работает, как устроено сравнение с учётом локали и когда стоит использовать тот или иной подход.

Наивный способ с toLowerCase

Самый распространённый способ сравнения без учёта регистра — привести обе строки к нижнему регистру перед сравнением:

const str1 = "Hello";
const str2 = "HELLO";

console.log(str1.toLowerCase() === str2.toLowerCase());
// true

Этот подход работает для ASCII-текста и английских слов. Сравнение считает заглавные и строчные буквы одинаковыми.

Такой способ можно использовать для нестрогого поиска:

const query = "apple";
const items = ["Apple", "Banana", "APPLE PIE", "Orange"];

const matches = items.filter(item =>
  item.toLowerCase().includes(query.toLowerCase())
);

console.log(matches);
// ["Apple", "APPLE PIE"]

Фильтр находит все элементы, содержащие поисковый запрос, независимо от регистра. Это обеспечивает ожидаемое поведение для пользователей, которые не задумываются о капитализации при вводе.

Почему наивный способ не работает для интернационального текста

Метод toLowerCase() преобразует текст по правилам Unicode, но эти правила работают по-разному в разных языках. Самый известный пример — проблема с буквой i в турецком языке.

В английском строчная буква i преобразуется в заглавную I. В турецком есть две разные буквы:

  • Строчная с точкой i становится заглавной с точкой İ
  • Строчная без точки ı становится заглавной без точки I

Эта разница ломает регистронезависимое сравнение:

const word1 = "file";
const word2 = "FILE";

// In English locale (correct)
console.log(word1.toLowerCase() === word2.toLowerCase());
// true

// In Turkish locale (incorrect)
console.log(word1.toLocaleLowerCase("tr") === word2.toLocaleLowerCase("tr"));
// false - "file" becomes "fıle"

При преобразовании FILE в нижний регистр по турецким правилам I становится ı (без точки), получается fıle. Это не совпадает с file (с точкой), поэтому сравнение возвращает false, хотя строки обозначают одно и то же слово.

В других языках есть похожие проблемы. В немецком есть символ ß, который в верхнем регистре становится SS. В греческом несколько строчных форм сигмы (σ и ς), которые обе становятся заглавной Σ. Простое преобразование регистра не учитывает такие языковые особенности.

Использование Intl.Collator с чувствительностью base для регистронезависимого сравнения

API Intl.Collator обеспечивает сравнение строк с учётом локали и настраиваемой чувствительностью. Опция sensitivity определяет, какие различия будут учитываться при сравнении.

Для регистронезависимого сравнения используйте sensitivity: "base":

const collator = new Intl.Collator("en", { sensitivity: "base" });

console.log(collator.compare("Hello", "hello"));
// 0 (strings are equal)

console.log(collator.compare("Hello", "HELLO"));
// 0 (strings are equal)

console.log(collator.compare("Hello", "Héllo"));
// 0 (strings are equal, accents ignored too)

Чувствительность base игнорирует различия в регистре и акценте. Учитываются только базовые буквы. Сравнение возвращает 0, если строки эквивалентны на этом уровне чувствительности.

Этот подход корректно решает проблему с турецкой i:

const collator = new Intl.Collator("tr", { sensitivity: "base" });

console.log(collator.compare("file", "FILE"));
// 0 (correctly matches)

console.log(collator.compare("file", "FİLE"));
// 0 (correctly matches, even with dotted İ)

Collator автоматически применяет турецкие правила преобразования регистра. Оба сравнения определяют строки как эквивалентные, независимо от того, какая заглавная I используется во входных данных.

Использование localeCompare с опцией sensitivity

Метод localeCompare() предоставляет альтернативный способ выполнить сравнение без учёта регистра. Он принимает те же опции, что и Intl.Collator:

const str1 = "Hello";
const str2 = "HELLO";

console.log(str1.localeCompare(str2, "en", { sensitivity: "base" }));
// 0 (strings are equal)

Это даёт тот же результат, что и использование Intl.Collator с чувствительностью base. При таком сравнении регистр игнорируется, и для эквивалентных строк возвращается 0.

Это можно использовать при фильтрации массивов:

const query = "apple";
const items = ["Apple", "Banana", "APPLE PIE", "Orange"];

const matches = items.filter(item =>
  item.localeCompare(query, "en", { sensitivity: "base" }) === 0 ||
  item.toLowerCase().includes(query.toLowerCase())
);

console.log(matches);
// ["Apple"]

Однако localeCompare() возвращает 0 только для точных совпадений на выбранном уровне чувствительности. Частичное совпадение, как в includes(), не поддерживается. Для поиска подстроки всё равно нужно использовать преобразование к нижнему регистру или реализовать более сложный алгоритм поиска.

Выбор между чувствительностью к регистру и к акцентам

Опция sensitivity принимает четыре значения, которые управляют различными аспектами сравнения строк:

Чувствительность к основе (base)

Чувствительность к основе игнорирует и регистр, и акценты:

const collator = new Intl.Collator("en", { sensitivity: "base" });

console.log(collator.compare("cafe", "café"));
// 0 (accents ignored)

console.log(collator.compare("cafe", "Café"));
// 0 (case and accents ignored)

console.log(collator.compare("cafe", "CAFÉ"));
// 0 (case and accents ignored)

Это обеспечивает самое лояльное сопоставление. Пользователи, которые не могут ввести символы с акцентами или пропускают их для удобства, всё равно получат корректные совпадения.

Чувствительность к акцентам

Чувствительность к акцентам игнорирует регистр, но учитывает акценты:

const collator = new Intl.Collator("en", { sensitivity: "accent" });

console.log(collator.compare("cafe", "café"));
// -1 (accents matter)

console.log(collator.compare("cafe", "Café"));
// -1 (accents matter, case ignored)

console.log(collator.compare("Café", "CAFÉ"));
// 0 (case ignored, accents match)

В этом случае буквы с акцентами и без считаются разными, но регистр не учитывается. Используйте это, если различие в акцентах важно, а регистр — нет.

Как выбрать подходящую чувствительность для вашей задачи

Для большинства задач, где требуется сравнение без учёта регистра, чувствительность base обеспечивает лучший пользовательский опыт:

  • Поиск, когда пользователь вводит запросы без акцентов
  • Сопоставление имён пользователей, где регистр не важен
  • Поиск с неточным совпадением, когда нужна максимальная гибкость
  • Валидация форм, где Smith и smith должны совпадать

Используйте чувствительность к акцентам, когда:

  • В языке важно различать символы с акцентами
  • В ваших данных есть как символы с акцентами, так и без, и они имеют разный смысл
  • Вам нужна нечувствительная к регистру, но чувствительная к акцентам проверка

Поиск без учёта регистра с помощью includes

API Intl.Collator сравнивает строки целиком, но не поддерживает поиск подстрок. Для поиска без учёта регистра всё равно нужно комбинировать сравнение с учётом локали с другими подходами.

Один из вариантов — использовать toLowerCase() для поиска подстроки, но принять его ограничения для интернационального текста:

function caseInsensitiveIncludes(text, query, locale = "en") {
  return text.toLowerCase().includes(query.toLowerCase());
}

const text = "The Quick Brown Fox";
console.log(caseInsensitiveIncludes(text, "quick"));
// true

Для более сложного поиска, который корректно работает с интернациональным текстом, нужно перебирать все возможные позиции подстроки и использовать collator для каждого сравнения:

function localeAwareIncludes(text, query, locale = "en") {
  const collator = new Intl.Collator(locale, { sensitivity: "base" });

  for (let i = 0; i <= text.length - query.length; i++) {
    const substring = text.slice(i, i + query.length);
    if (collator.compare(substring, query) === 0) {
      return true;
    }
  }

  return false;
}

const text = "The Quick Brown Fox";
console.log(localeAwareIncludes(text, "quick"));
// true

Этот подход проверяет каждую возможную подстроку нужной длины и использует сравнение с учётом локали для каждой. Он корректно работает с интернациональным текстом, но работает медленнее, чем простой includes().

Особенности производительности при использовании Intl.Collator

Создание экземпляра Intl.Collator включает загрузку данных локали и обработку опций. Если нужно выполнить несколько сравнений, создайте collator один раз и используйте повторно:

// Inefficient: creates collator for every comparison
function badCompare(items, target) {
  return items.filter(item =>
    new Intl.Collator("en", { sensitivity: "base" }).compare(item, target) === 0
  );
}

// Efficient: creates collator once, reuses it
function goodCompare(items, target) {
  const collator = new Intl.Collator("en", { sensitivity: "base" });
  return items.filter(item =>
    collator.compare(item, target) === 0
  );
}

Более эффективный вариант — создать collator заранее перед фильтрацией. Каждое сравнение использует один и тот же экземпляр, что избавляет от повторной инициализации.

Для приложений, где часто выполняются сравнения, создавайте экземпляры collator при запуске приложения и экспортируйте их для использования по всему коду:

// utils/collation.js
export const caseInsensitiveCollator = new Intl.Collator("en", {
  sensitivity: "base"
});

export const accentInsensitiveCollator = new Intl.Collator("en", {
  sensitivity: "accent"
});

// In your application code
import { caseInsensitiveCollator } from "./utils/collation";

const isMatch = caseInsensitiveCollator.compare(input, expected) === 0;

Такой подход максимизирует производительность и обеспечивает единое поведение сравнения во всём приложении.

Когда использовать toLowerCase и когда Intl.Collator

Для приложений только на английском, где вы контролируете содержимое текста и знаете, что там только ASCII-символы, toLowerCase() даёт приемлемый результат:

// Acceptable for English-only, ASCII-only text
const isMatch = str1.toLowerCase() === str2.toLowerCase();

Этот подход простой, быстрый и знакомый большинству разработчиков. Если ваше приложение действительно никогда не работает с международным текстом, дополнительная сложность, связанная с учётом локали при сравнении, может быть не нужна.

Для международных приложений или приложений, где пользователи могут вводить текст на любом языке, используйте Intl.Collator с подходящей чувствительностью:

// Required for international text
const collator = new Intl.Collator(userLocale, { sensitivity: "base" });
const isMatch = collator.compare(str1, str2) === 0;

Это обеспечивает корректную работу независимо от того, на каком языке говорят или пишут пользователи. Незначительные потери в производительности при использовании Intl.Collator оправданы, чтобы избежать некорректных сравнений.

Даже если сейчас ваше приложение поддерживает только английский, использование сравнения с учётом локали с самого начала упростит интернационализацию в будущем. Добавление новых языков не потребует изменений в логике сравнения.

Практические примеры использования сравнения без учёта регистра

Сравнение без учёта регистра встречается во многих типовых сценариях:

Сопоставление имени пользователя и email

Пользователи вводят имена и email-адреса с разным регистром букв:

const collator = new Intl.Collator("en", { sensitivity: "base" });

function findUserByEmail(users, email) {
  return users.find(user =>
    collator.compare(user.email, email) === 0
  );
}

const users = [
  { email: "[email protected]", name: "John" },
  { email: "[email protected]", name: "Jane" }
];

console.log(findUserByEmail(users, "[email protected]"));
// { email: "[email protected]", name: "John" }

Это позволяет найти пользователя независимо от того, как он написал email.

Автодополнение поиска

В автодополнении нужно подбирать варианты без учёта регистра введённого текста:

const collator = new Intl.Collator("en", { sensitivity: "base" });

function getSuggestions(items, query) {
  const queryLower = query.toLowerCase();

  return items.filter(item =>
    item.toLowerCase().startsWith(queryLower)
  );
}

const items = ["Apple", "Apricot", "Banana", "Cherry"];
console.log(getSuggestions(items, "ap"));
// ["Apple", "Apricot"]

Это даёт подсказки независимо от регистра, в котором пользователь вводит текст.

Сопоставление тегов и категорий

Пользователи добавляют теги или категории к контенту с разным регистром букв:

const collator = new Intl.Collator("en", { sensitivity: "base" });

function hasTag(item, tag) {
  return item.tags.some(itemTag =>
    collator.compare(itemTag, tag) === 0
  );
}

const article = {
  title: "My Article",
  tags: ["JavaScript", "Tutorial", "Web Development"]
};

console.log(hasTag(article, "javascript"));
// true

Это сопоставляет теги независимо от различий в регистре.