この記事はtraPアドベントカレンダー2023 24日目(12/24)の記事です。
こんにちは@d_etteiu8383です。@eyemono.moeでもあります。めりくり~
2週間ほど前、部内PaaS基盤であるNeoShowcaseのv1.0.0がリリースされました(現在はv1.1.2がリリースされています)。
このリリースではフロントエンドの実装が一新され、新UIが導入されました。
このフロントエンドの実装の大半を私と@tokiが担当しました。本記事ではそのフロントエンドの開発で使用した技術についてご紹介します。
なお、NeoShowcaseについての詳細な紹介は後日別の記事として投稿予定です。しばしお待ちを。
↓技育展2023でNeoShowcaseを紹介した際のスライド↓
使用技術
フロントのpackage.json
:NeoShowcase/dashboard/package.json at main · traPtitech/NeoShowcase
Solid
フロントのフレームワークとしてSolidを使用しています。
いわゆる宣言型UIフレームワークの一つで、仮想DOMを使用していないのが特徴です。
個人的にはReactやVueよりも好きなのですがいまいち流行ってないかも...? という印象です。Solidの良さを伝えるためにもNeoShowcaseでアピールしていきたい。
以前までは私も、まだプロダクトに使える程エコシステムが整っていないと感じていましたが、最近はかなり整ってきていると感じています。2024年はSolidを使いませんか?
NeoShowcaseはPaaSということもあってフォームの入力が多いのですが、後述するModular Formsというライブラリを使うことでフォームの実装がかなり楽になりました。
Solidはやはり状態管理をシンプルに書けるのが使っていて気持ちいいです。
とはいえ不満点もまだあります。Solidには<Show>
という制御フローコンポーネントが用意されており、
<Show when={user()} fallback={<>Loading...</>}>
Hello, {user().name}!
</Show>
のように記述することで、when
がtruthy
なら子要素を、falsy
ならfallback
を表示するということができます。
しかし、solidjs.com/guides/typescript#制御フローの絞り込みでも触れられているのですが、<Show>
コンポーネント内でtype narrowingが行われません。これのためにNeoShowcaseではlinterの設定でnoNonNullAssertionを許容しています。
2024/12/05追記
現在は以下のようにcallback形式で子要素を記述することでtype narrowingが行われます。
<Show when={user()} fallback={<>Loading...</>}>
{(u) => <>Hello, {u().name}!</>}
</Show>
Solid Router
Solid RouterはSolid.jsにおけるroutingライブラリです。React Router等のroutingライブラリと同様の機能を提供していますが、Solid Router v0.10.0でpreload機能が追加されたためご紹介します。
詳細はREADMEのData APIsセクションで紹介されていますが、
- ページ表示に必要なデータの読込に
cache
関数を使用する Route
コンポーネントのload
属性に、1. の関数を実行する関数を渡す
といった書き方をすることで、そのrouteへのリンクのホバー時にデータを事前取得することができます。
/src/libs/api.tsexport const getRepository = cache((id) => client.getRepository({ repositoryId: id }), 'repository')
/src/routes.tsxconst loadRepositoryData: RouteLoadFunc = ({ params }) => {
void getRepository(params.id)
}
<Route path="/repos/:id" component={lazy(() => import('/@/pages/repos/[id]'))} load={loadRepositoryData} />
以下の動画はアプリケーション一覧ページでのpreloadの挙動の例です。各リンクにカーソルをホバーさせると、対応したデータがfetchされます。リンクをクリックしてそのページに遷移した場合、重複したデータのロードは行われず、事前に読み込まれたデータが使用されます。
(つまりReact RouterにおけるRoute
コンポーネントのloader
属性とuseLoaderData, Remixにおけるそれなどと同じことがSolid Routerでもできるようになった)
(NeoShowcase v1.0.0リリース直前にこのSolid Router v0.10.0がリリースされて大幅に書き方が変わったのですがその対応は全部@tokiさんがやってくださいました)
Modular Forms
Modular FormsはSolidに対応したフォームライブラリです。Zod等を用いたバリデーションにも対応しています。
ネストしたオブジェクトや配列を持つフォームも簡単に実装でき、複雑なformの多いNeoShowcaseでは非常に助かりました。
以下はGuide: Add fields to form (SolidJS) | Modular Formsにある公式のサンプルなのですが、Modular Formsではこのように<Field>
コンポーネントを介してformの状態を管理しています。
import { createForm, SubmitHandler } from '@modular-forms/solid';
type LoginForm = {
email: string;
password: string;
};
export default function App() {
const [loginForm, { Form, Field }] = createForm<LoginForm>();
const handleSubmit: SubmitHandler<LoginForm> = (values, event) => {
// Runs on client
};
return (
<Form onSubmit={handleSubmit}>
<Field name="email">
{(field, props) => <input {...props} type="email" />}
</Field>
<Field name="password">
{(field, props) => <input {...props} type="password" />}
</Field>
<button type="submit">Login</button>
</Form>
);
}
ここで重要なのが、<Field>
コンポーネントがDOMに含まれるときのみ、そのフォームフィールドの状態がアクティブになる点です。先述した<Show>
等を使って<Field>
をDOMに含めないようにすることで、そのフィールドをSubmit時に含めないと行ったことができます。
(https://modularforms.dev/solid/guides/add-fields-to-form#active-state)
NeoShowcaseでのアプリビルド設定では、デプロイタイプ(runtimeか静的か)とビルドタイプ(buildpack/command/dockerfile)に応じて送信すべきフィールドが異なるのですが、この仕組みを使うことで比較的シンプルに実装することができました。
Kobalte
KobalteはSolidのヘッドレスUIコンポーネントライブラリです。今年生まれたばかりのライブラリですが、すでに十分に使えるレベルになっています。NeoShowcaseではPopoverやDialogの表示にKobalteを使用しています。アクセシビリティにも配慮されていて良い感じです。
元々はModalやらSelectやらを自前で実装していたのですが、使い勝手が悪かったためKobalteに置き換えました。
先述したModular FormsのドキュメントにKobalteを使用したフォームの作成例が挙げられており、これを参考に実装しています。
macaron
macaronはゼロランタイムのCSS-in-JSライブラリです。
ほとんどvanilla-extractじゃねーか!と思われるかもしれませんが(というか内部でvanilla-extractを使っている)、大きな違いとして、macaronではスタイルとコンポーネントを同じファイルに書くことができます。(vanilla-extractはスタイルを別ファイルに書いてexportし、コンポーネント側でimportする必要がある)
NeoShowcaseでも元々はvanilla-extractを使っていましたが、ファイル分けを強制されるのが辛くてmacaronに乗り換えています。なによりロゴがかわいい。
vanilla-extractではスタイルを適用するためのclass名が生成されますが、macaronでは下記のようにstyled
関数を使って直接コンポーネントを生成することができます。
import { styled } from '@macaron-css/solid'
const MyButton = styled('button', {
base: {
width: 'auto',
display: 'flex',
// ...
selectors: {
'&:disabled': {
cursor: 'not-allowed',
border: 'none !important',
color: `${colorVars.semantic.text.black} !important`,
background: `${colorVars.semantic.text.disabled} !important`,
},
// ...
},
},
variants: {
full: {
true: {
width: '100%',
},
},
type: {
primary: {
background: colorVars.semantic.primary.main,
color: colorVars.semantic.text.white,
selectors: {
// ...
},
},
// ...
},
},
})
// you can use it like this
() => <MyButton full type="primary">Click me!</MyButton>
variantsの使い勝手が非常に良くて非常に良い感じです。
これまで
今年の4月下旬にSysAd班に入り、「Solid好きならNeoShowcaseのフロントやらん?」って感じでNeoShowcaseのフロントの開発に参加することになりました。
(私が開発に参加する前からNeoShowcaseのフロントはSolidで一部実装されていた && もともと私がSolid推しだった)
ということでNeoShowcaseのプロジェクト自体には今年の5月ごろから参加していたのですが、10月頃まで諸々の事情によりあまり活動できていませんでした。ごめんなさい。
5月時点でバックエンドの開発がほぼ完了しており、仮UIとしてまずは機能部分に集中してフロントを完成させることを目標にしていました。
6月に開催を予定していたサークル内ハッカソンでの利用を見越しての仮UI実装でしたが、実際にいくつかのチームが作品の公開にNeoShowcaseを使ってくれました。うれし。
7月頃には@pikachuさんもフロントの開発を進めてくださいました。対照的に私の開発参加が減っていきました。ゆるしてください。
9月頃にはなんか@tokiさんが全部やってました。
10月に入ってから本格的に開発を再開しました。この時点ですでに@tarariraさんによる新UIデザインが上がっており、これを元に実装を進めていきました。めちゃくちゃデザインがかっこよかったので実装するのもめちゃくちゃ楽しかったです。これまで開発に参加できなかった分、積極的にやっていかねば...の精神でガッとやりました。(結局二か月かかったし後半また@tokiさんが全部やってた)
これから
UIの改修はまだまだ続きます。現在レスポンシブ対応が不十分であり、一部ページでボタン等が隠れてしまうのですが、部員から「モバイル端末でもNeoShowcase上で操作したい」との声が上がっているため、レスポンシブ対応を進めていきたいです。アプリケーション・ビルドの情報表示についても、よりわかりやすいものに改修していきたいです。
おわりに
NeoShowcaseのフロントエンドの開発についてご紹介しました。
Solid.jsによる開発は少なくとも私にとっては快適でした。皆さんも2024年はSolid.jsを使ってみてはいかがでしょうか?
traPアドベントカレンダー2023も本日で最終日となりました。他の記事もぜひご覧ください!