Преобразование ключей в Rails: подчеркивания в дефисы
Объяснение автоматического преобразования ключей хешей из foo_bar в foo-bar при передаче данных из Ruby в JavaScript в Rails. Механизм и причины.
Почему в Rails при передаче данных из Ruby в JavaScript ключи хешей с подчеркиваниями (например, ‘foo_bar’) автоматически преобразуются в ключи с дефисами (например, ‘foo-bar’)?
Когда в Ruby on Rails вы передаёте данные из Ruby-контроллера или хелпера в JavaScript через HTML data-атрибуты, ключи хешей автоматически undergo преобразование: foo_bar превращается в foo-bar. Это не баг и не случайность — за этим стоит метод dasherize из ActiveSupport, который вызывается внутри ActionView::Helpers::TagHelper при генерации HTML-атрибутов. Причина проста: HTML-спецификация использует дефисы в составных атрибутах (data-foo-bar), а Ruby предпочитает snake_case. Rails автоматически «мостит» этот разрыв.
Содержание
- Механизм преобразования: что происходит под капотом
- Почему именно дефисы: логика дизайна
- Data-атрибуты как мост между Ruby и JavaScript
- Как контролировать поведение преобразования
- Практические примеры и подводные камни
- Источники
- Заключение
Механизм преобразования: что происходит под капотом
Допустим, в представлении вы пишете что-то вроде:
content_tag(:div, "Привет", data: { user_id: 42, foo_bar: "baz" })
На выходе получается:
<div data-user-id="42" data-foo-bar="baz">Привет</div>
Кто именно заменяет подчёркивания на дефисы? Цепочка вызовов выглядит так:
content_tag(илиtag.div) формирует хеш опций для HTML-элемента.- Внутри вызывается
tag_optionsиз модуляActionView::Helpers::TagHelper, который отвечает за сериализацию хеша Ruby в строку HTML-атрибутов. - Для каждого ключа хеша вызывается метод
dasherizeизActiveSupport::Inflector, который заменяет все символы_на-.
Сам метод dasherize элементарен — он делает именно то, что звучит: foo_bar.dasherize возвращает "foo-bar". Но вызывается он не вами напрямую, а внутри фреймворка при рендеринге тегов.
И это происходит только при генерации HTML-атрибутов. Если вы сериализуете тот же хеш в JSON через to_json, ключи останутся в snake_case — если вы явно не попросите иное.
Почему именно дефисы: логика дизайна
HTML-спецификация исторически использует дефисы в составных именах атрибутов. data-custom-value, aria-hidden, http-equiv — повсеместная практика. Подчёркивания в именах HTML-атрибутов технически допустимы, но считаются плохим стилем и могут вызвать проблемы с некоторыми парсерами.
Rails, будучи фреймворком с сильным мнением (convention over configuration), принимает решение за вас. Вместо того чтобы заставлять каждого разработчика вручную писать data-foo-bar в Ruby-коде (что противоречит snake_case-конвенции Ruby), фреймворк позволяет писать естественно для Ruby и автоматически адаптирует для HTML.
Это проявление более широкого принципа Rails: каждый слой должен использовать свои конвенции, а фреймворк берёт на себя трансляцию между ними. Ruby-код — snake_case, HTML-атрибуты — kebab-case, JavaScript — camelCase. И Rails обеспечивает бесшовное преобразование на границах.
Data-атрибуты как мост между Ruby и JavaScript
Чаще всего эта трансформация всплывает именно при работе с data-* атрибутами, потому что именно они — основной механизм передачи серверных данных в клиентский JavaScript в классических Rails-приложениях.
# app/views/users/show.html.erb
<%= tag.div id: "user-card",
data: {
user_id: @user.id,
full_name: @user.full_name,
is_admin: @user.admin?
} %>
Результат:
<div id="user-card" data-user-id="123" data-full-name="Иван Иванов" data-is-admin="false"></div>
А в JavaScript вы обращаетесь к этим данным так:
const card = document.getElementById('user-card');
const userId = card.dataset.userId; // camelCase в JS!
const fullName = card.dataset.fullName;
const isAdmin = card.dataset.isAdmin === 'true';
Заметьте интересный момент: Rails превращает full_name в data-full-name (через dasherize), а браузер при чтении через dataset автоматически конвертирует обратно — в fullName (camelCase). Получается цепочка из трёх разных конвенций, и каждая ссылка в этой цепочке делает свою работу корректно.
Это поведение детально описано в официальном руководстве Rails по работе с JavaScript, где объясняется, как data-* атрибуты служат связующим звеном между сервером и клиентом.
Как контролировать поведение преобразования
Иногда автоматическое преобразование мешает. Например, если вы передаёте данные в JavaScript-библиотеку, которая ожидает определённый формат ключей. Вот основные способы контроля.
Отключить dasherize для конкретного тега
Если вам нужно передать ключи «как есть» (с подчёркиваниями), можно обойти TagHelper и сформировать HTML-строку вручную:
tag.div id: "widget",
data: { foo_bar: "baz" }.map { |k, v| "data-#{k}=#{v}" }.join(" ")
Грязно, но работает для разовых случаев.
Использовать to_json напрямую
Когда вы передаёте данные в JavaScript через инлайн-скрипт или через gon-подобную библиотеку, вы работаете с JSON, а не с HTML-атрибутами — и тут dasherize не участвует:
<%= javascript_tag do %>
window.userData = <%= raw({ foo_bar: "baz", nested_key: 123 }.to_json) %>;
<% end %>
Результат — ключи останутся в snake_case. Если нужен camelCase, используйте опцию camelize:
{ foo_bar: "baz" }.to_json(camelize: :lower)
# => '{"fooBar":"baz"}'
Кастомный хелпер
Для повторяющихся случаев напишите свой хелпер, который формирует data-атрибуты без dasherize:
module DataAttrHelper
def raw_data_attrs(hash)
hash.transform_keys { |k| "data-#{k}" }
.map { |k, v| "#{k}=\"#{ERB::Util.html_escape(v)}\"" }
.join(" ")
end
end
Подобные подходы активно обсуждаются на Stack Overflow, где разработчики делятся workaround-ами для нестандартных ситуаций.
Практические примеры и подводные камни
Ситуация 1: Stimulus и data-атрибуты
С приходом Stimulus в стандартный стек Rails 7, data-атрибуты стали ещё важнее. Stimulus-контроллеры ссылаются на targets и values через data-*:
<%= tag.div data: {
controller: "hello",
hello_target: "greeting",
hello_name_value: @user.name
} %>
Результат:
<div data-controller="hello" data-hello-target="greeting" data-hello-name-value="Иван"></div>
Здесь dasherize работает как надо — Stimulus ожидает именно data-hello-name-value, а не data-hello_name_value.
Ситуация 2: Массивы в data-атрибутах
tag.div data: { ids: [1, 2, 3] }
# => <div data-ids="[1,2,3]"></div>
Значение сериализуется через to_json, а ключ — через dasherize. Два разных механизма, два разных пути преобразования. Не путайте их.
Ситуация 3: Вложенные хеши
tag.div data: { user: { first_name: "Иван", last_name: "Иванов" } }
# => <div data-user="{"first_name":"Иван","last_name":"Иванов"}"></div>
dasherize применяется только к верхнему уровню ключей. Вложенные ключи остаются в snake_case, потому что они сериализуются в JSON-строку как значение атрибута. Это частый источник путаницы — разработчики ожидают, что first_name тоже превратится в first-name, но этого не происходит.
Источники
- Ruby on Rails Guides — Официальное руководство по работе с JavaScript в Rails, включая data-атрибуты: https://guides.rubyonrails.org/working_with_javascript_in_rails.html
- ActionView::Helpers::TagHelper — Документация API модуля, отвечающего за генерацию HTML-тегов и преобразование ключей: https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html
- ActiveSupport::Inflector — Документация метода dasherize и других методов трансформации строк: https://api.rubyonrails.org/classes/ActiveSupport/Inflector.html
- Stack Overflow — Обсуждения сообщества о контроле преобразования ключей при передаче данных в JavaScript: https://stackoverflow.com
Заключение
Автоматическое преобразование foo_bar → foo-bar в Rails — это не магия и не ошибка. Это осознанное архитектурное решение, которое позволяет разработчикам писать Ruby-код в snake_case (как принято в Ruby-сообществе), при этом генерируя валидный HTML с kebab-case атрибутами (как принято в HTML). Метод dasherize из ActiveSupport вызывается внутри TagHelper каждый раз, когда Rails формирует HTML-тег из хеша опций. Понимание этого механизма избавляет от множества «странностей» при работе с data-атрибутами, Stimulus-контроллерами и передаче данных в JavaScript. Если поведение по умолчанию вам не подходит — используйте to_json напрямую, пишите кастомные хелперы или формируйте атрибуты вручную.
В Ruby on Rails преобразование ключей хешей с подчеркиваниями в дефисы при передаче данных в JavaScript происходит из-за соглашения о именовании в JavaScript. JavaScript традиционно использует camelCase для именования переменных и функций, в то время как Ruby предпочитает snake_case. Rails автоматически преобразует snake_case ключи в camelCase при сериализации данных в JSON для использования в JavaScript. Это поведение реализовано через хелпер to_json, который применяет преобразование ключей для лучшей совместимости между двумя языками.
Преобразование ключей хешей с подчеркиваниями в дефисы в Rails при передаче данных в JavaScript регулируется опцией :camelize в методе to_json. По умолчанию, Rails использует :camelize => true, что приводит к преобразованию ключей вида ‘foo_bar’ в ‘FooBar’. Однако, если вы хотите получить именно ‘foo-bar’, вы можете использовать опцию :dashify => true или настроить сериализацию JSON вручную. Это поведение было введено для согласования с соглашениями об именовании в JavaScript, где camelCase является стандартом.
Преобразование ключей хешей из snake_case в camelCase при передаче данных из Ruby в JavaScript в Rails - это стандартное поведение, которое можно контролировать. В Rails 5+ вы можете использовать опцию camelize: false в методе to_json, чтобы отключить это преобразование. Например: my_hash.to_json(camelize: false) вернет JSON с исходными snake_case ключами. Это полезно, когда вам нужно сохранить точное соответствие ключей между Ruby и JavaScript кодом.
