Общий алгоритм
Независимо от языка порядок операций один и тот же. Нарушение порядка — источник большинства ошибок.
- Привести строку к нижнему регистру.
- Заменить буквы по таблице.
- Удалить символы, которые не должны превращаться в разделитель: кавычки, апострофы, точки.
- Заменить все оставшиеся посторонние символы на разделитель.
- Схлопнуть повторяющиеся разделители в один.
- Срезать разделители в начале и в конце строки.
PHP
function translit(string $text, string $sep = '-'): string
{
$map = [
'а'=>'a','б'=>'b','в'=>'v','г'=>'g','д'=>'d','е'=>'e','ё'=>'e',
'ж'=>'zh','з'=>'z','и'=>'i','й'=>'j','к'=>'k','л'=>'l','м'=>'m',
'н'=>'n','о'=>'o','п'=>'p','р'=>'r','с'=>'s','т'=>'t','у'=>'u',
'ф'=>'f','х'=>'h','ц'=>'c','ч'=>'ch','ш'=>'sh','щ'=>'shh',
'ъ'=>'','ы'=>'y','ь'=>'','э'=>'e','ю'=>'yu','я'=>'ya',
];
$text = mb_strtolower($text, 'UTF-8');
$text = strtr($text, $map);
$text = preg_replace('/[^a-z0-9]+/u', $sep, $text);
$text = preg_replace('/' . preg_quote($sep, '/') . '+/', $sep, $text);
return trim($text, $sep);
}
Ключевой момент — mb_strtolower вместо strtolower. Обычная функция не понимает многобайтовые символы и оставит кириллицу в верхнем регистре, из-за чего замена по таблице не сработает.
JavaScript
function translit(text, sep = '-') {
const map = {
а:'a', б:'b', в:'v', г:'g', д:'d', е:'e', ё:'e', ж:'zh', з:'z',
и:'i', й:'j', к:'k', л:'l', м:'m', н:'n', о:'o', п:'p', р:'r',
с:'s', т:'t', у:'u', ф:'f', х:'h', ц:'c', ч:'ch', ш:'sh',
щ:'shh', ъ:'', ы:'y', ь:'', э:'e', ю:'yu', я:'ya'
};
return String(text)
.toLowerCase()
.split('')
.map(ch => (ch in map ? map[ch] : ch))
.join('')
.replace(/[^a-z0-9]+/g, sep)
.replace(/^-+|-+$/g, '');
}
Здесь важно, что перебор идёт по символам, а не через цепочку из тридцати трёх replace. Цепочка работает медленнее и легко ломается, если случайно поставить замены в неверном порядке.
Python
import re
MAP = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh',
'з':'z','и':'i','й':'j','к':'k','л':'l','м':'m','н':'n','о':'o',
'п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'c',
'ч':'ch','ш':'sh','щ':'shh','ъ':'','ы':'y','ь':'','э':'e',
'ю':'yu','я':'ya',
}
def translit(text, sep='-'):
text = ''.join(MAP.get(ch, ch) for ch in str(text).lower())
text = re.sub('[^a-z0-9]+', sep, text)
return text.strip(sep)
Словарь с методом get и значением по умолчанию сразу решает вопрос с латинскими буквами и цифрами: они проходят через функцию без изменений.
Четыре типовые ошибки реализации
- Замена до приведения к нижнему регистру. Заглавные буквы не попадут в таблицу и останутся кириллицей в адресе.
- Цепочка замен в неверном порядке. Если «ш» обрабатывается раньше «щ» посимвольно — всё в порядке, но при последовательных заменах по строке «щ» может превратиться в
shhдважды. - Забытая обрезка краёв. Фраза с точкой в конце даст адрес вида
zagolovok-, а лишний дефис на конце — источник дублей. - Отсутствие проверки на пустой результат. Заголовок из одних цифр или символов даст пустую строку, и страница окажется по адресу родительского раздела.
Отдельно про уникальность. Функция транслита не обязана следить за повторами. Проверку на существующий адрес делает вызывающий код: если такой уже занят, добавляется числовой суффикс.
Тесты, которые стоит прогнать
| Вход | Ожидаемый результат | Что проверяет |
|---|---|---|
| Щука в борще | shhuka-v-borshhe | Спорную букву |
| Ёжик и Йогурт | ezhik-i-jogurt | ё и й |
| Цены: от 1 000 руб. | ceny-ot-1-000-rub | Знаки и цифры |
| iPhone 16 Pro | iphone-16-pro | Латиницу без изменений |
| Подъезд, объявление | podezd-obyavlenie | Твёрдый знак |
| Пробелы вокруг | probely-vokrug | Обрезку краёв |
Сверить свою функцию с эталоном можно на генераторе: он реализует ровно эту таблицу и те же шаги очистки.
Где вызывать функцию
Формировать адрес нужно один раз — при создании записи, а не при каждом отображении страницы. Полученное значение сохраняется в базе и дальше используется как есть.
Причина простая: если адрес пересчитывается динамически, любая правка заголовка молча изменит URL страницы, которая уже проиндексирована. Именно так возникают внезапные ошибки 404, для которых потом приходится настраивать редиректы.
Уникальность адреса
Функция транслита сознательно не занимается проверкой повторов — иначе она перестанет быть предсказуемой. Уникальность обеспечивает вызывающий код, и делается это в момент сохранения записи.
base = translit(title)
slug = base
n = 2
while slug_exists(slug):
slug = base + '-' + str(n)
n += 1
Важная деталь: проверять нужно с исключением текущей записи. Иначе при повторном сохранении статьи её собственный адрес будет считаться занятым и получит суффикс.
Производительность на больших объёмах
Для одной страницы скорость не имеет значения, но при импорте каталога на сто тысяч позиций разница между реализациями становится заметной.
- Цепочка из 33 замен по строке — самый медленный вариант: строка перебирается 33 раза.
- Посимвольный проход со словарём — один проход, работает быстрее в разы.
- Готовая таблица перевода (
strtrв PHP,str.translateв Python) — быстрее всего, но требует одинаковой длины замен или подготовки таблицы.
Отдельно стоит вынести регулярные выражения из тела цикла: скомпилированное выражение переиспользуется, а не собирается заново на каждой строке.
Что стоит добавить к базовой функции
- Ограничение длины. Обрезать не по символам, а по границе слова:
sadovye-kacheliлучше, чемsadovye-kach. - Удаление служебных слов — предлогов и союзов, которые не несут смысла в адресе.
- Обработку пустого результата — вернуть запасной вариант вместо пустой строки.
- Поддержку дополнительных букв — і, ї, є, ґ, ў, о которых подробно в статье про украинские и белорусские буквы.
Частые вопросы
Почему в PHP нужен mb_strtolower, а не strtolower?
Обычная функция работает с однобайтовыми символами и не приводит кириллицу к нижнему регистру. В результате заглавные русские буквы не находятся в таблице замен.
Нужно ли проверять уникальность адреса внутри функции?
Нет, это задача вызывающего кода. Функция транслита должна оставаться предсказуемой: одна и та же строка всегда даёт один и тот же результат.
Как обрабатывать латиницу и цифры?
Они проходят без изменений: в таблице их нет, поэтому символ остаётся как есть. Проверяется тестом с названием вроде iPhone 16 Pro.
Готовый адрес можно собрать прямо сейчас: откройте генератор ЧПУ — он транслитерирует фразу по таблице Яндекса, уберёт служебные слова и покажет длину адреса.