Сравнение строк без учёта диакритических знаков

Узнайте, как сравнивать строки без учёта диакритических знаков с помощью нормализации в JavaScript и Intl.Collator

Введение

При разработке приложений с поддержкой нескольких языков часто возникает необходимость сравнивать строки, содержащие акцентные знаки. Например, пользователь, ищущий «cafe», ожидает увидеть результаты по запросу «café». Проверка имени пользователя «Jose» должна совпадать с «José». Обычное сравнение строк считает их разными, но логика вашего приложения должна воспринимать их как одинаковые.

В JavaScript есть два подхода для решения этой задачи. Можно нормализовать строки и удалить акцентные знаки или воспользоваться встроенным API для сравнения строк с определёнными правилами чувствительности.

Что такое акцентные знаки

Акцентные знаки — это символы, которые размещаются над, под или через буквы, чтобы изменить их произношение или значение. Такие знаки называются диакритическими. Примеры: острый акцент в «é», тильда в «ñ» и умлаут в «ü».

В Unicode такие символы могут быть представлены двумя способами. Либо один кодовый пункт обозначает полный символ, либо несколько кодовых пунктов объединяют базовую букву и отдельный акцентный знак. Например, буква «é» может храниться как U+00E9 или как «e» (U+0065) плюс комбинирующий острый акцент (U+0301).

Когда игнорировать акцентные знаки при сравнении

Поиск — самый частый случай, когда требуется сравнение без учёта акцентных знаков. Пользователи, вводящие запросы без акцентов, ожидают найти контент с акцентированными символами. Например, поиск по «Muller» должен находить «Müller».

Проверка пользовательского ввода также требует такой возможности, например, при проверке, существуют ли уже такие имена пользователей, email-адреса или другие идентификаторы. Вы хотите предотвратить дублирование аккаунтов для «maria» и «maría».

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

Удаление акцентных знаков с помощью нормализации

Первый способ — привести строки к нормализованному виду, где базовые буквы и акцентные знаки разделены, а затем удалить акценты.

Юникод-нормализация переводит строки в стандартную форму. Вариант NFD (каноническая декомпозиция) разделяет составные символы на базовые буквы и отдельные диакритические знаки. Например, строка "café" превращается в "cafe" плюс отдельный символ острого акцента.

После нормализации можно удалить диакритические знаки с помощью регулярного выражения. Диапазон Юникода U+0300–U+036F содержит все такие знаки.

function removeAccents(str) {
  return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

const text1 = 'café';
const text2 = 'cafe';

const normalized1 = removeAccents(text1);
const normalized2 = removeAccents(text2);

console.log(normalized1 === normalized2); // true
console.log(normalized1); // "cafe"

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

Можно совместить это с переводом в нижний регистр для нечувствительных к регистру и акцентам сравнений.

function normalizeForComparison(str) {
  return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
}

const search = 'muller';
const name = 'Müller';

console.log(normalizeForComparison(search) === normalizeForComparison(name)); // true

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

Сравнение строк с помощью Intl.Collator

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

Объект Intl.Collator сравнивает строки по языковым правилам. Параметр sensitivity определяет, какие различия будут учитываться при сравнении.

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

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

console.log(collator.compare('café', 'cafe')); // 0 (equal)
console.log(collator.compare('Café', 'cafe')); // 0 (equal)
console.log(collator.compare('café', 'caff')); // -1 (first comes before second)

Метод compare возвращает 0, если строки равны, отрицательное число — если первая строка идёт раньше второй, и положительное — если первая идёт после второй.

Это можно использовать для проверки на равенство или для сортировки массивов.

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

function areEqualIgnoringAccents(str1, str2) {
  return collator.compare(str1, str2) === 0;
}

console.log(areEqualIgnoringAccents('José', 'Jose')); // true
console.log(areEqualIgnoringAccents('naïve', 'naive')); // true

Для сортировки можно напрямую передать метод compare в Array.sort.

const names = ['Müller', 'Martinez', 'Muller', 'Márquez'];
const collator = new Intl.Collator('en', { sensitivity: 'base' });

names.sort(collator.compare);
console.log(names); // Groups variants together

API Intl.Collator предоставляет другие уровни чувствительности для разных задач.

Уровень "accent" игнорирует регистр, но учитывает различия в акцентах. "Café" считается равным "café", но не "cafe".

const accentCollator = new Intl.Collator('en', { sensitivity: 'accent' });
console.log(accentCollator.compare('Café', 'café')); // 0 (equal)
console.log(accentCollator.compare('café', 'cafe')); // 1 (not equal)

Уровень "case" игнорирует акценты, но учитывает различия в регистре. "café" считается равным "cafe", но не "Café".

const caseCollator = new Intl.Collator('en', { sensitivity: 'case' });
console.log(caseCollator.compare('café', 'cafe')); // 0 (equal)
console.log(caseCollator.compare('café', 'Café')); // -1 (not equal)

Уровень "variant" учитывает все различия. Это поведение по умолчанию.

const variantCollator = new Intl.Collator('en', { sensitivity: 'variant' });
console.log(variantCollator.compare('café', 'cafe')); // 1 (not equal)

Выбор между нормализацией и сравнением

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

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

Метод Intl.Collator сравнивает строки, не изменяя их. Используйте этот способ, если нужно сравнивать строки напрямую, например, для поиска дубликатов или сортировки списков. Коллатор учитывает языковые правила сортировки, которые простое сравнение строк не поддерживает.

Производительность зависит от задачи. Создать объект коллатора один раз и использовать его для нескольких сравнений — эффективно. Нормализация строк эффективна, если вы нормализуете один раз и сравниваете много раз.

Метод нормализации навсегда удаляет акцентные знаки. Метод сравнения сохраняет оригинальные строки, сравнивая их по заданным правилам.

Фильтрация массивов с учётом акцентов

Частый кейс — фильтрация массива элементов по пользовательскому вводу без учёта акцентных различий.

const products = [
  { name: 'Café Latte', price: 4.50 },
  { name: 'Crème Brûlée', price: 6.00 },
  { name: 'Croissant', price: 3.00 },
  { name: 'Café Mocha', price: 5.00 }
];

function searchProducts(query) {
  const collator = new Intl.Collator('en', { sensitivity: 'base' });

  return products.filter(product => {
    return collator.compare(product.name.slice(0, query.length), query) === 0;
  });
}

console.log(searchProducts('cafe'));
// Returns both Café Latte and Café Mocha

Для поиска по подстроке метод нормализации работает лучше.

function removeAccents(str) {
  return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}

function searchProducts(query) {
  const normalizedQuery = removeAccents(query.toLowerCase());

  return products.filter(product => {
    const normalizedName = removeAccents(product.name.toLowerCase());
    return normalizedName.includes(normalizedQuery);
  });
}

console.log(searchProducts('creme'));
// Returns Crème Brûlée

Этот подход проверяет, содержит ли нормализованное название продукта нормализованный поисковый запрос как подстроку.

Обработка сопоставления текстового ввода

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

const existingUsernames = ['José', 'María', 'François'];

function isUsernameTaken(username) {
  const collator = new Intl.Collator('en', { sensitivity: 'base' });

  return existingUsernames.some(existing =>
    collator.compare(existing, username) === 0
  );
}

console.log(isUsernameTaken('jose')); // true
console.log(isUsernameTaken('Maria')); // true
console.log(isUsernameTaken('francois')); // true
console.log(isUsernameTaken('pierre')); // false

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

Поддержка браузеров и окружений

Метод String.prototype.normalize поддерживается всеми современными браузерами и в среде Node.js. Internet Explorer этот метод не поддерживает.

API Intl.Collator поддерживается всеми современными браузерами и версиями Node.js. В Internet Explorer 11 поддержка частичная.

Оба подхода надежно работают в современных JavaScript-окружениях. Если нужно поддерживать старые браузеры, понадобятся полифиллы или альтернативные реализации.

Ограничения удаления акцентов

В некоторых языках диакритические знаки образуют отдельные буквы, а не просто варианты акцентов. Например, в турецком «i» и «ı» — разные буквы. В немецком «ö» — отдельная гласная, а не акцентированная «o».

В таких случаях удаление акцентов меняет смысл. Подумайте, подходит ли сравнение без учета акцентов для вашей задачи и целевых языков.

Метод с учетом колlation лучше справляется с такими случаями, так как использует правила конкретной локали. Указание нужной локали в конструкторе Intl.Collator обеспечивает корректное сравнение с учетом культуры.

const turkishCollator = new Intl.Collator('tr', { sensitivity: 'base' });
const germanCollator = new Intl.Collator('de', { sensitivity: 'base' });

Всегда учитывайте поддерживаемые языки приложения при выборе стратегии сравнения.