Rails で UI コンポーネントを構築する方法

公開: 2024-06-28

大規模な Web アプリケーションで視覚的な一貫性を維持することは、多くの組織に共通する問題です。 当社の Flywheel 製品の背後にある主要な Web アプリケーションは Ruby on Rails で構築されており、毎日約 3 人の Rails 開発者と 3 人のフロントエンド開発者がそれにコードをコミットしています。 当社はデザインにも力を入れており (これは会社としての核となる価値観の 1 つです)、スクラム チームの開発者と緊密に連携する 3 人のデザイナーがいます。

2 人がウェブサイトのデザインで共同作業します

私たちの主な目標は、あらゆる開発者が何の障害もなくレスポンシブなページを構築できるようにすることです。 一般に、障害となるのは、モックアップを構築するためにどの既存コンポーネントを使用すればよいかわからない (これにより、非常に類似した冗長なコンポーネントでコードベースが膨らむことになります)、再利用性についていつ設計者と話し合えばよいかわからない、などが挙げられます。 これは、一貫性のない顧客エクスペリエンス、開発者のフラストレーション、開発者とデザイナー間の異なる設計言語の一因となります。

私たちはスタイル ガイドと、UI パターンとコンポーネントの構築/保守方法を何度か繰り返してきましたが、そのたびに、当時直面していた問題の解決に役立ちました。 私たちは、私たちの新しいアプローチが今後も長く続くと確信しています。 Rails アプリケーションで同様の問題に直面し、サーバー側からコンポーネントにアプローチしたい場合は、この記事がいくつかのアイデアを提供することを願っています。

コード行を表示するコンピューターのモニターの前に座りながら、ひげを生やした男性がカメラに向かって微笑む

この記事では、次のことについて詳しく説明します。

  • 私たちが解決していること
  • コンポーネントの拘束
  • サーバー側でのコンポーネントのレンダリング
  • サーバー側コンポーネントを使用できない場合

私たちが解決しているもの

私たちは UI コンポーネントを完全に制限し、同じ UI が複数の方法で作成される可能性を排除したいと考えました。 顧客には(最初は)わからないかもしれませんが、コンポーネントに制約がないことは、開発者エクスペリエンスを混乱させ、保守を非常に困難にし、全体的な設計変更を行うことを困難にします。

私たちがコンポーネントにアプローチする従来の方法は、特定のコンポーネントを構築するために必要な大量のマークアップをリストしたスタイル ガイドを使用することでした。 たとえば、スラット コンポーネントのスタイル ガイド ページは次のようになります。

スラットコンポーネントのスタイルガイドページ

これは数年間うまく機能していましたが、コンポーネントのバリアント、状態、または別の使用方法を追加すると、問題が発生し始めました。 UI が複雑になると、スタイル ガイドを参照して、どのクラスを使用し、どのクラスを避けるべきか、また、目的のバリエーションを出力するにはどのような順序でマークアップを配置する必要があるかを知るのが面倒になりました。

多くの場合、設計者は特定のコンポーネントに少しの追加や調整を加えます。 スタイルガイドがそれを完全にサポートしていなかったため、その調整を正しく表示するための代替ハック (別のコンポーネントの一部を不適切に共食いするなど) が腹立たしいほど一般的になりました。

制約のないコンポーネントの例

時間の経過とともに不一致がどのように表面化するかを説明するために、Flywheel アプリのコンポーネントの 1 つであるカード ヘッダーの、単純 (かつ不自然) ですが非常に一般的な例を使用します。

デザインのモックアップから新たに作成したカード ヘッダーは次のようになります。 タイトル、ボタン、下枠がある非常にシンプルなものでした。

 .card__header
  .card__header-left
    %h2 バックアップ
  .card__header-right
    = link_to "#" do
      = icon("plus_small")

コードを作成した後、デザイナーがタイトルの左側にアイコンを追加したいと考えていると想像してください。 初期状態では、アイコンとタイトルの間に余白はありません。

 ...
  .card__header-left
    = icon("arrow_backup", color: "gray25")
    %h2 バックアップ
...

理想的には、カード ヘッダーの CSS でこの問題を解決できますが、この例では、別の開発者が「ああ、わかった!」と思ったとします。 マージンヘルパーがいくつかあります。 タイトルにヘルパー クラスを付け加えておきます。」

 ...
  .card__header-left
    = icon("arrow_backup", color: "gray25")
    %h2.--ml-10 バックアップ
...

技術的にはモックアップと同じように見えますね?! 確かにそうですが、1 か月後、別の開発者がアイコンのないカード ヘッダーを必要としたとします。 最後の例を見つけてコピー/ペーストし、アイコンを削除するだけです。

繰り返しますが、それは正しいように見えますよね? 文脈を無視して、デザインに鋭い目を持たない人にとっては、確かにそうかもしれません。 しかし、オリジナルと並べて見てください。 タイトルの左マージンがまだ残っているのは、マージン左ヘルパーを削除する必要があることに気づいていなかったためです。

この例をさらに一歩進めて、別のモックアップで下枠のないカード ヘッダーが要求されたとします。 スタイルガイドで「ボーダレス」と呼ばれる状態を見つけて、それを適用するかもしれません。 完璧!

その後、別の開発者がそのコードを再利用しようとする可能性がありますが、この場合、実際には境界線が必要になります。 仮に、彼らがスタイルガイドに記載されている適切な使用法を無視しており、borderless クラスを削除するとボーダーが与えられることを認識していないとしましょう。 代わりに、水平方向の罫線が追加されます。 タイトルと境界線の間に余分なパディングができることになるので、hr にヘルパー クラスを適用すると、出来上がりです。

元のカード ヘッダーにこれらすべての変更が加えられたため、コード内では混乱が生じています。

 .card__header.--ボーダーレス
  .card__header-left
    %h2.--ml-10 バックアップ
  .card__header-right
    = link_to "#" do
      = icon("plus_small")
  %hr.--mt-0.--mb-0

上の例は、拘束されていないコンポーネントが時間の経過とともにどのように乱雑になるかを示すためのものであることに留意してください。 私たちのチームの誰かがカード ヘッダーのバリエーションを出荷しようとした場合、それは設計レビューまたはコード レビューによって捕らえられるはずです。 しかし、このようなものは時々亀裂をすり抜けてしまうので、私たちは物事を防弾にする必要があります。


コンポーネントの拘束

上に挙げた問題はコンポーネントによってすでに明らかに解決されていると思われるかもしれません。 それは正しい仮定です。 React や Vue などのフロントエンド フレームワークは、まさにこの目的のために非常に人気があります。 これらは UI をカプセル化するための素晴らしいツールです。 ただし、私たちが必ずしも好まない問題が 1 つあります。それは、UI を JavaScript でレンダリングする必要があることです。

私たちの Flywheel アプリケーションは、主にサーバーでレンダリングされる HTML を使用するバックエンドが非常に多いですが、幸いなことに、コンポーネントはさまざまな形式で提供できます。 結局のところ、UI コンポーネントは、マークアップをブラウザーに出力するスタイルとデザイン ルールをカプセル化したものです。 この認識により、JavaScript フレームワークのオーバーヘッドを発生させずに、コンポーネントに対して同じアプローチを採用できるようになります。

以下で制約付きコンポーネントを構築する方法について説明しますが、制約付きコンポーネントを使用することで得られた利点をいくつか紹介します。

  • コンポーネントを組み合わせるのに間違った方法はありません。
  • コンポーネントはすべての設計思考を行います。 (オプションを渡すだけです!)
  • コンポーネントを作成するための構文は非常に一貫性があり、推論が簡単です。
  • コンポーネントで設計変更が必要な場合は、コンポーネント内で一度変更すれば、どこでも確実に更新されるようになります。

サーバー側でのコンポーネントのレンダリング

それでは、コンポーネントを制約するということは何を意味するのでしょうか? 掘り下げてみましょう!

前述したように、アプリケーションで作業している開発者がページのデザイン モックアップを見て、障害なくそのページをすぐに構築できるようにしたいと考えています。 つまり、UI の作成方法は、A) 十分に文書化され、B) 非常に宣言的で推測に頼らない必要があります。

Partials to the Rescue (または私たちはそう思っていました)

私たちが過去に試した最初の試みは、Rails パーシャルを使用することでした。 パーシャルは、Rails がテンプレートでの再利用を可能にする唯一のツールです。 当然のことながら、それらは誰もが最初に手を伸ばすものです。 ただし、これらに依存することには大きな欠点があります。ロジックを再利用可能なテンプレートと組み合わせる必要がある場合、パーシャルを使用するすべてのコントローラーにロジックを複製するか、パーシャル自体にロジックを埋め込むかの 2 つの選択肢があるためです。

パーシャルはコピー/ペーストの重複ミスを防ぎ、何かを再利用する必要がある最初の数回は問題なく機能します。 しかし、私たちの経験から言えば、パーシャルは、より多くの機能とロジックをサポートすることですぐに乱雑になってしまいます。 しかし、ロジックはテンプレート内に存在すべきではありません。

細胞の紹介

幸いなことに、パーシャルに代わるより良い方法があり、コードを再利用しロジックを表示しないようにすることができます。 これは Cells と呼ばれるもので、Trailblazer によって開発された Ruby gem です。 セルは、React や Vue などのフロントエンド フレームワークの人気が高まるずっと前から存在しており、ロジックテンプレートの両方を処理するカプセル化されたビュー モデルを作成できます。 これらは View Model の抽象化を提供しますが、Rails にはそのままでは備わっていません。 実際、私たちはしばらくの間 Flywheel アプリで Cell を使用してきましたが、グローバルで非常に再利用可能な規模ではありませんでした。

最も単純なレベルでは、Cells を使用すると、次のようにマークアップのチャンクを抽象化できます (テンプレート言語には Haml を使用します)。

 %div
  %h1 こんにちは、世界!

再利用可能な View Model (この時点ではパーシャルと非常によく似ています) を作成し、次のように変換します。

 = セル("hello_world")

これは最終的に、セル自体を変更せずにヘルパー クラスや間違った子コンポーネントを追加できない場所にコンポーネントを制限するのに役立ちます。

セルの構築

すべての UI セルを app/cells/ui ディレクトリに置きます。 各セルには、_cell.rb という接尾辞が付いた Ruby ファイルを 1 つだけ含める必要があります。 技術的には、content_tag ヘルパーを使用して Ruby でテンプレートを直接記述することができますが、ほとんどのセルには、コンポーネントによって名前が付けられたフォルダーに存在する、対応する Haml テンプレートも含まれています。

ロジックを持たない超基本的なセルは次のようになります。

 // セル/ui/slat_cell.rb
モジュールUI
  クラス SlatCell < ViewModel
    デフショー
    終わり
  終わり
終わり

show メソッドは、セルをインスタンス化するときにレンダリングされるもので、セルと同じ名前のフォルダー内で対応する show.haml ファイルを自動的に検索します。 この場合、それは app/cells/ui/slat です (すべての UI セルのスコープを UI モジュールに設定します)。

テンプレートでは、セルに渡されたオプションにアクセスできます。 たとえば、セルが = cell(“ui/slat”, title: “Title”,subtitle: “Subtitle”, label: “Label”) のようなビューでインスタンス化されている場合、options オブジェクトを通じてこれらのオプションにアクセスできます。

 // セル/ui/slat/show.haml
.slat
  .slat__inner
    .slat__content
      %h4= オプション[:タイトル]
      %p= オプション[:字幕]
      = アイコン(オプション[:アイコン]、色: "青")

多くの場合、オプションが存在しない場合に空の要素が表示されないように、単純な要素とその値をセル内のメソッドに移動します。

 // セル/ui/slat_cell.rb
デフォルトタイトル
  options[:title]以外の場合は戻ります
  content_tag :h4、オプション[:title]
終わり
デフォルトのサブタイトル
  options[:subtitle]以外の場合は戻ります
  content_tag :p、オプション[:subtitle]
終わり
// セル/ui/slat/show.haml
.slat
  .slat__inner
    .slat__content
      = タイトル
      = サブタイトル

UIユーティリティを使用したセルのラッピング

これが大規模に機能するという概念を証明した後、セルを呼び出すために必要な無関係なマークアップに取り組みたいと思いました。 まったく流れが悪く、覚えるのが難しいのです。 そこで、そのための小さなヘルパーを作成しました。 これで、 = ui “name_of_component” を呼び出して、オプションをインラインで渡すことができます。

 = ui "slat"、タイトル: "タイトル"、サブタイトル: "サブタイトル"、ラベル: "ラベル"

オプションをインラインではなくブロックとして渡す

UI ユーティリティをもう少し詳しく見てみると、多数のオプションをすべて 1 行に並べたセルは非常にわかりにくく、まったく見苦しいものであることがすぐに明らかになりました。 以下は、多くのオプションがインラインで定義されたセルの例です。

 = ui “slat”、title: “Title”、subtitle: “Subtitle”、label: “Label”、link: “#”、tertiary_title: “Tertiary”、disabled: true、チェックリスト: [“Item 1”、“Item 2」、「項目 3」]

これは非常に面倒なので、Cells セッター メソッドをインターセプトしてハッシュ値に変換し、オプションにマージする OptionProxy というクラスを作成する必要があります。 それが複雑に聞こえるかもしれませんが、心配しないでください。私にとってもそれは複雑です。 以下は、当社の上級ソフトウェア エンジニアの 1 人である Adam が作成した OptionProxy クラスの要点です。

以下は、セル内で OptionProxy クラスを使用する例です。

 モジュールUI
  クラス SlatCell < ViewModel
    デフショー
      OptionProxy.new(self).yield!(オプション、&ブロック)
      素晴らしい()
    終わり
  終わり
終わり

これで、面倒なインライン オプションをより快適なブロックに変えることができます。

 = ui "slat" do |slat|
  - slat.title = "タイトル"
  - slat.subtitle = "サブタイトル"
  - slat.label = "ラベル"
  - slat.link = "#"
  - slat.tertiary_title = "三次"
  - slat.disabled = true
  - slat.checklist = ["項目 1"、"項目 2"、"項目 3"]

ロジックの紹介

ここまでの例には、ビューに表示される内容に関するロジックが含まれていませんでした。 それが Cells が提供する最も優れた点の 1 つですので、それについて話しましょう。

slat コンポーネントに固執すると、リンク オプションが存在するかどうかに基づいて、全体をリンクとしてレンダリングしたり、div としてレンダリングしたりする必要があります。 これは、div またはリンクとしてレンダリングできる唯一のコンポーネントだと思いますが、Cell の力を示す非常に優れた例です。

以下のメソッドは、オプション[:link]の存在に応じて、 link_to ヘルパーまたは content_tag ヘルパーのいずれかを呼び出します。

 デフォルトコンテナ(&ブロック)
  タグ =
    if オプション[:リンク]
      [:link_to、オプション[:link]]
    それ以外
      [:コンテンツタグ、:div]
    終わり
  send(*tag, クラス: “slat__inner”, &block)
終了

これにより、テンプレート内の .slat__inner 要素をコンテナ ブロックに置き換えることができます。

 .slat
  = コンテナは行う
  ...

私たちがよく使用する Cells のロジックのもう 1 つの例は、条件付きで出力するクラスです。 無効なオプションをセルに追加するとします。 セルの呼び出しでは、disabled: true オプションを渡して、全体が無効な状態 (クリックできないリンクでグレー表示) に変わるのを観察できること以外は、何も変更されません。

 = ui "slat" do |slat|
  ...
  - slat.disabled = true

無効なオプションが true の場合、目的の無効な外観を得るために必要なテンプレート内の要素にクラスを設定できます。

 .slat{ クラス: possible_classes("--無効": オプション[:無効]) }
  .slat__inner
    .slat__content
      %h4{ クラス: possible_classes("--alt": オプション[:無効]) }= オプション[:タイトル]
      %p{ クラス: possible_classes("--alt": オプション[:無効]) }=
      オプション[:字幕]
      = アイコン(オプション[:アイコン]、色: "グレー")

従来は、無効な状態で全体を正しく動作させるには、どの個々の要素に追加のクラスが必要かを覚えておく (またはスタイル ガイドを参照する) 必要がありました。 セルを使用すると、1 つのオプションを宣言して、面倒な作業を代わりに行うことができます。

注: possible_classes は、Haml でクラスを条件付きで適切に適用できるようにするために作成したメソッドです。


サーバー側コンポーネントを使用できない場合

セルのアプローチは、私たちの特定のアプリケーションと私たちの仕事のやり方にとって非常に役立ちますが、それが私たちの問題を 100% 解決したとは言いがたいです。 私たちは今でも JavaScript (その多く) を記述し、アプリ全体にわたって Vue でかなりの数のエクスペリエンスを構築しています。 75% の確率で、Vue テンプレートは依然として Haml 内に存在し、Vue インスタンスを包含要素にバインドします。これにより、引き続きセル アプローチを利用できます。

ただし、コンポーネントを単一ファイルの Vue インスタンスとして完全に制約する方が合理的である場合は、Cell を使用できません。 たとえば、選択リストはすべて Vue です。 でも、それでいいと思うよ! Cells コンポーネントと Vue コンポーネントの両方でコンポーネントのバージョンを重複させる必要性は実際には発生していないため、一部のコンポーネントは 100% Vue で構築され、一部のコンポーネントは Cells で構築されていても問題ありません。

コンポーネントが Vue で構築されている場合、DOM 内でコンポーネントを構築するには JavaScript が必要であり、これを行うために Vue フレームワークを利用していることを意味します。 ただし、他のほとんどのコンポーネントでは JavaScript は必要ありません。必要な場合は、DOM がすでに構築されている必要があり、イベント リスナーをフックして追加するだけです。

セルのアプローチを進めていく中で、セル コンポーネントと Vue コンポーネントの組み合わせを実験して、コンポーネントを作成および使用する唯一の方法を確立するつもりです。 まだどんな感じか分からないので、到着したら橋を渡ってみます!


私たちの結論

これまでに、最もよく使用される約 30 個のビジュアル コンポーネントをセルに変換しました。 これにより、生産性が大幅に向上し、開発者は自分たちが構築しているエクスペリエンスが正しく、一緒にハッキングされていないという検証の感覚を得ることができます。

私たちのデザインチームは、アプリのコンポーネントとエクスペリエンスが Adob​​e XD でデザインしたものと 1 対 1 であることにこれまで以上に自信を持っています。 コンポーネントへの変更や追加は、デザイナーとフロントエンド開発者とのやり取りのみで処理されるようになりました。これにより、チームの残りのメンバーは集中力を維持でき、デザインのモックアップに合わせてコンポーネントを微調整する方法を知る必要がなくなります。

私たちは UI コンポーネントを制約するアプローチを常に繰り返していますが、この記事で説明したテクニックが私たちにとって何がうまく機能しているかを垣間見ることができれば幸いです。


私たちと一緒に働きましょう!

当社の製品に携わるすべての部門は、当社の顧客と収益に有意義な影響を与えます。 カスタマー サポート、ソフトウェア開発、マーケティング、またはその間のあらゆる分野で、私たちは皆、人々が本当に夢中になれるホスティング会社を構築するという使命に向かって協力しています。

私たちのチームに参加する準備はできていますか? 募集中です! こちらからお申し込みください。