Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Mirrativ のWebフロントエンドで使っているライブラリを紹介する!

こんにちは、フロントエンドエンジニア 兼 バックエンドエンジニアの駒木です。

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も用意されているので採用が決まりました。

Temporal導入決定の現場

フロントエンドエンジニアが長年悩まされてきた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!

ミラティブでは ユーザーに最高の体験を届けるために一緒により良い開発体験を追求してくれる エンジニアを募集中です!

www.mirrativ.co.jp

speakerdeck.com