こんにちは、フロントエンドエンジニア 兼 バックエンドエンジニアの駒木です。
iOS / Android / バックエンドのライブラリ紹介に引き続き、MirrativのWebフロントエンドで使用しているライブラリをご紹介します!
JSフレームワーク: React with TypeScript / Vue.js
8割以上のアプリケーションはReact + TypeScriptで実装されていますが、数年前に立ち上げた一部のイベントページはVue.js + Vanilla JSで実装されています。
ビルド・バンドルツール: Vite / Parcel / webpack
2021年5月くらいからViteを利用し始め、以後立ち上げたプロジェクトはViteベースとなっています。 Vite 8割、Parcel + webpackが2割といったところでしょうか。 webpack → Parcel → Viteと渡り歩いています。どうかViteが安住の地であってほしい...。
バンドルアセットとキャッシュ管理
manifest.jsonを活用してアセットをGit管理させず、かつ最新のアセットのキャッシュが適切に参照される様に工夫しています。
1.CI環境でViteを介してアセットがビルドされ、バンドルされたアセットとmanifest.jsonがGitHub Releasesにアップロードされる
vite.config.tsサンプル
import { defineConfig } from "vite"; export default defineConfig(({ command }) => ({ ..., base: "./", build: { assetsDir: "assets", // dist/assetsディレクトリ以下にアセットを出力 manifest: true, // manifest.jsonを出力する }, ..., }));
生成される manifest.json (例. foo/dist/manifest.json)
{ ..., "src/assets/icon_achievement.png": { "file": "assets/icon_achievement.d38e34b6.png", "src": "src/assets/icon_achievement.png" }, "index.html": { "file": "assets/index.0eb59938.js", "src": "index.html", "isEntry": true, "css": [ "assets/index.e9d05ff6.css", "assets/other.cdfe7e7d.css" ], "assets": [ ..., "assets/icon_achievement.d38e34b6.png", ..., ] }, "index.css": { "file": "assets/index.e9d05ff6.css", "src": "index.css" } }
2.GitHub Releasesにpushされたアセットがオリジン(GCP)へrsyncされ、CDNからホスティングされる
3.manifest.jsonから読み出されたアセットのパスがDBへupsertされる
asset_vite_manifestテーブルへ書き込まれるレコード
name: foo entry: foo/assets/index.0eb59938.js css_csv: foo/assets/index.e9d05ff6.css,foo/assets/other.cdfe7e7d.css
4.ページレンダー時はDBから読み出されたアセットパスがCDNのURLに変換され、ページテンプレートに埋め込まれる
レコードの entry
/ css
のファイル名にはハッシュ値を持っている為、レコードが書き換わることでリリース直後から最新のアセットへの参照に切り替わります。
foo.html.qtpl
<script type="module" crossorigin src="{%s f.AppCtx.AssetsFileURL(f.CDNViteAssetManifest.Entry) %}"></script> {% for _, css := range f.CDNViteAssetManifest.CSS -%} <link rel="stylesheet" href="{%s f.AppCtx.AssetsFileURL(css) %}"> {% endfor -%}
※ f.CDNViteAssetManifest.Entry
/ f.CDNViteAssetManifest.CSS
は asset_vite_manifestテーブルから読み出された name = foo のレコード
レンダリング結果
<script type="module" crossorigin="" src="https://cdn.mirrativ.com/hoge/foo/assets/index.0eb59938.js"></script> <link rel="stylesheet" href="https://cdn.mirrativ.com/hoge/foo/assets/index.e9d05ff6.css"> <link rel="stylesheet" href="https://cdn.mirrativ.com/hoge/foo/assets/other.cdfe7e7d.css">
プラグイン
@rollup/plugin-yaml
コード内でYAMLの値を参照したい場合に利用しています
vite-plugin-svgr
ViteではデフォルトでSVGファイルをコンポーネントとして扱うことができません。 このプラグインを利用することでSVGファイルをコンポーネントとして扱うことができるので、直接pathの色を変更したりできるようになります。
import { ReactComponent as IconLive } from "assets/icon_live.svg"; export const UserInfo: FC = () => ( <div> ... <IconLive style={{ fill:'red' }} /> </div> );
デザイン
CSSフレームワーク: TailwindCSS / Bootstrap
社内向けの管理アプリケーションはその性質上歴史も長いため、多くの機能がBootstrapベースとなっています。 イベントページはSass/SCSS時代を経てTailwindCSSに落ち着いています。
コンポーネントライブラリ: MUI / Bootstrap
CSSフレームワークにBootstrapが利用されていた経緯からコンポーネントライブラリもBootstrapが採用されていました。 最近は管理アプリケーションの構成が「Perl + テンプレートエンジン」 から 「Go + React MPA」に移行しつつあり、Reactと親和性の高いMUIが利用され始めています。
notistack
通知UI用の状態管理ライブラリ。専用のProviderが用意されています。 以前は通知のUIも状態も自前で管理していたのですが、MUIを導入するタイミングで両方を僕らの期待通りに提供するライブラリを見つけたので導入されました。
アイコンライブラリ: bootstrap-icons / Material Icons
おおよそBootstrapを使ったアプリケーションはbootstrap-icons、MUIならMaterial Iconsといった使い分けです。
その他
color
『HEXで定義されたこの色の、明度70%下げた値が欲しい!』みたいな時に使っています。
<article style={{ background: Color(themeState["--style2"]).darken(0.3).string() }}> hoge </article>
lottie-web
クライアントアプリ向けのアニメーションにLottieが利用されているので、WebアプリでもLottieで利用できるとみんなハッピー!
動画再生: hls.js
配信動画をWeb上で再生できる様にするために利用しています。 Mirrativ上の配信動画はHLS(HTTP Live Streaming)でストリーミングされていますが、PC/MacではHLSのネイティブ再生に対応しているのがSafariだけなのでhls.jsを使って広範な環境で再生できるようにしています。
ルーティング: React Router
v6系のreact-router-domを使用しています。 後述するSentryと併用して、エラーをユーザーに伝達する機能にも活用しています。
状態管理: React hooks + Recoil / Redux
ページ内の状態はhooks、グローバルな状態はRecoilで持つ構成になっています。 RecoilはMetaが作っているだけあり、hooks likeに直感的に書けるので気に入っています。 サービス初期に製造されたアプリケーションではReduxが使用されています。
Recoilを活用した実装例
作業者が素材ファイルをある画面でアップロードを開始した後、他の画面へ遷移してもアップロード進捗を確認できるようにする実装サンプルです。
// ------ atoms export const globalAsyncUploadStates = atom< { key: string; percentage: number; }[] >({ key: "globalAsyncUploadStates", default: [], }); // ------ hooks/useAssetsAsyncUpload.ts // 非同期アップロードを担うCustom Hooks export const useAssetsAsyncUpload: FC = () => { const [asyncUploadStates, setAsyncUploadStates] = useRecoilState( globalAsyncUploadStates ); ... asyncUpload({ path: uploadUrl, file, ... onUploadProgress: (percentage: number) => { if (percentage) { // globalAsyncUploadStatesを更新 setAsyncUploadStates( asyncUploadStatesRef.current.map((s) => s.key === key ? { ...s, percentage } : s ) ); } }, ... }); ... } // ------ App.tsx export const App: FC = () => { const asyncUploadStates = useRecoilValue(globalAsyncUploadStates); return ( <> <Routes> <Route element={<PageFoo />} path="/" /> <Route element={<PageBar />} path="/admin" /> ... </Routes> // globalAsyncUploadStatesでUIを更新 // Routesの外にあるのでどの画面に遷移しても表示される <UploadNavigation uploadStates={asyncUploadStates} /> </> ); };
データフェッチ: React Query
サーバとの通信はReact Queryを介してAPIベースで行っています。 キャッシュ管理が強力、かつhooksベースのAPIが提供されていることでfetchしたデータの管理を考えなくとも良くなるため大変有り難い存在です。
日付・時刻操作: Temporal
moment.js → day.js とライブラリを利用してきたのですが、できるだけライブラリ依存を剥がしてECMAScript標準に乗っかっていきたいという気持ちもあり2022年1月から導入が始まりました。 Temporal自体はまだプロポーザルですがStage 3まで進んでおり、polyfillも用意されているので採用が決まりました。
フロントエンドエンジニアが長年悩まされてきたDateの month 0-index
問題からも解放されます!
Goのtimeと書き味も似ているので、Goでバックエンドのコードを書くメンバーも扱いやすいかと思われます。
ミラティブではpolyfillとして @js-temporal/polyfill を利用して導入しています。
import { Temporal } from "@js-temporal/polyfill"; export function rfc3339ToTime(rfc3339: string): Temporal.ZonedDateTime { return Temporal.Instant.from(rfc3339).toZonedDateTimeISO("Asia/Tokyo"); } const dateTime = rfc3339ToTime("2020-01-01T01:23:45+09:00") console.log(dateTime.epochSeconds) // 1577809425 console.log(dateTime.toLocaleString) // '2020/1/1 1:23:45 JST' console.log(dateTime.month) // 1! 0じゃない!!!
静的解析 / テスト
コードpush時に ESLint / tsc / jest / Prettierによるチェックが走り、エラーがあればreviewdogが知らせてくれるようにしています。
ESlintでextendsしているルールはこれら。
"eslint:recommended", "plugin:react/recommended", "plugin:react-hooks/recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking", "plugin:tailwindcss/recommended", "airbnb", "airbnb/hooks", "prettier",
import/named
/ import/order
をerrorに設定していたりと、所々rulesでカスタマイズしています
エラー監視: Sentry
バックエンド側のエラー監視がSentryなのでフロントエンドも揃えています。
@sentry/react/ErrorBoundary
でキャッチしたエラーをSentryへ送出しています。
管理者向けアプリケーションではさらにユーザーにエラー内容を伝え、日替わりの不具合対応エンジニアへの連絡に活用してもらえるようUIに反映しています。
import React, { FC, ReactElement } from "react"; import { Alert } from "@mui/material"; import { ErrorBoundary } from "@sentry/react"; import { createBrowserRouter } from "react-router-dom"; const AppErrorRender: (errorData: { error: Error; componentStack: string | null; eventId: string | null; resetError(): void; }) => ReactElement = ({ error, componentStack, resetError, }: { error: Error; componentStack: string | null; resetError(): void; }) => ( <Alert onClose={() => resetError()} severity="error"> <pre className="whitespace-pre-wrap break-all"> {`エラーが発生しました\n原因に心当たりがない場合、以下のテキストを #times_engineer_119 に共有してください\nURL: ${ window.location.href }\n${error.toString()}`} {componentStack} </pre> </Alert> ); export const AppErrorBoundary: FC = ({ children }) => ( <ErrorBoundary fallback={AppErrorRender}>{children}</ErrorBoundary> ); // React Routerの RouteObject.errorElement として AppErrorBoundaryを指定 export const router = createBrowserRouter([ { ... errorElement: <AppErrorBoundary />, ... }, ]);
その他ユーティリティ
Classnames
classNameの条件分けや、Propsで渡されたclassNameとのマージなどに活用してます。 Ver.1.0.0は8年前なんですね。公私ともにずっとお世話になっております。
ua-parser-js
user-agentのパーサー。OS / クライアントバージョン別の制御要件が生じた場合はこちらでuser-agentをパースしています。
papaparse
CSVのパーサー。管理系の機能でCSVを扱う際に利用しています。
magic-bytes.js
管理系の機能でアップロードしようとしているファイルのMIMEタイプを確認したい場合などに利用しています。
おわりに
Mirrativは主にクライアントアプリを介して配信を楽しむサービスとして認知されています。 一方、その管理ツールやイベントのイベントページなどはWebアプリケーションとして実装されています。 サービス開始当時からの機能もあったり、時々のトレンドの流行り廃りもあり様々なフレームワークが混在する構成になっています。(それだけの年月を重ねて今がある、ということで!)
特にフロントエンドは流行り廃りが激しく、新しいトレンドについての情報が常に気になってしまう領域です。 ミラティブでも御多分に漏れず、トレンドの調査を含め実験的な取り組みや試験導入を行っています。
ですが一方で忘れてはいけないことは、フロントエンドが担う役割はサービス/システムとユーザーの橋渡しである、ということです。
フロントエンドエンジニアがフォーカスすべきことは、最新トレンドをただ追うことではなく『新しくもたらされる技術・視点によってユーザーの体験が最終的にどう豊かになるのか』その本質を見抜くことであると私は考えています。
これからもミラティブはエンジニア自身が楽しみながら、ユーザーの皆さんにとって最良の体験が得られる場をつくっていきます!
We are hiring!
ミラティブでは ユーザーに最高の体験を届けるために一緒により良い開発体験を追求してくれる エンジニアを募集中です!