見出し画像

noteのフロントエンドリアーキテクチャの進捗を報告します

noteのフロントエンドリアーキテクチャとは以前「フロントエンドapp分割」として紹介していたプロジェクトの延長にあるものです。


そもそもフロントエンドリアーキテクチャとはなにか

noteは現在APIをRuby on Railsで、Webフロントエンドは主にNuxtで実装されています。Nuxtフレームワークの設計上VueとVuexを多用していくわけですが、多くの機能が密結合しWeb Vitalsの悪化や、生産性の低下、スケールしにくい状態、障害を引き起こす原因になるなどの影響が発生しています。

そこで機能毎に適切な粒度でアプリケーションを分割しつつ上記の問題を解決してくプロジェクトが始まりました。それがフロントエンドリアーキテクチャです。

ちなみに分割するアプリケーションで使うフレームワークはNextを採用しています。これはNuxt v3への移行が困難を極めるため、新たな設計のもとでReactで書き直す判断に至りました。

分割したページはアカウント設定など

現状既にNextで分割済みのページは以下の通りです。

この他にエディター機能は https://editor.note.com で提供していますが、こちらは本プロジェクトとは別の進行をしています。

上記のページを別アプリに切り出せたことで本体であるNuxtアプリから一部のコードが不要になり削除したところ、ビルド時間が7%ほど短縮されました。
たった3〜4ページでもこれほどの影響があるというのは結構な驚きでした。

また上記の4ページはStaticファイルをS3から配信していますので、SSRを辞めています。SSRはサーバーコストもかかりますし、セッションをサーバーサイドで扱うリスクも存在します。またログイン済みユーザーしか使わないページなのでSEOを考慮する必要もありません。
今後もこのような要件のページでは積極的にSSRを排除していく方針です。

分割に際して開発したライブラリ

さて、分割に際して複数のアプリ間で共有して使う機能をいくつかライブラリ化してGithub Packagesで配信しています。
その代表的なものを少し紹介します。

note-universal-router

これはアプリ間で正しくページ遷移できるようにするライブラリです。
noteは基本SPAなので、同一アプリ間ではブラウザのhistory APIを利用してページ遷移を行いたいのですが、別のアプリへ遷移する場合は当然サーバーへリクエストを投げたくなるわけです。

そのあたりの条件分岐をその場その場で書いていくわけにもいかず、かなり複雑化することが予想されたので、宣言的にRouteを定義することで解決することにしました。
例えば記事詳細ページとアカウント設定ページは以下のように定義されています。

// 記事詳細 routes/core/noteDetail.ts
export const noteDetail: Route<{ noteKey: string; userNoteId: string }> = {
  originType: 'core',
  path({ noteKey, userNoteId }) {
    return `/${userNoteId}/n/${noteKey}`
  }
}
// アカウント設定ページ routes/settings/account.ts
export const settingAccount: Route = {
  originType: 'setting',
  path: '/settings/account'
}

記事詳細ページはpath内にパラメターがありますので関数で定義されています。対してアカウント設定ページはどのユーザーも同じpathですので関数ではなく文字列を定義しています。(実際は独自ドメインの設定なども考慮する必要がありもっと複雑です)

こうして定義したRouteファイルをruntimeで必要な分だけimportしてRouterを作成する関数に現在のorigin情報などとセットで渡すことで、ライブラリが自動で https://〜から始まるURLを作ってくれたり、/settings/accountのようなパスだけを返すということをやってくれます。

import { noteDetail } from '@pocake/note-universal-router/coreRoutes'
import { settingAccount } from '@pocake/note-universal-router/settingRoutes'
import { createRouter } from '@pocake/note-universal-router'

const router = createRouter(
  { noteDetail, settingAccount },
  { currentEnv: process.env.NODE_ENV, currentOrigin: window.location.origin }
)
router.getLinkUrl('noteDetail', { noteKey: 'example', userNoteId: 'hoge' })
// => その状況に応じた完全なURLかパスのみが返ります

実際には記事詳細とアカウント設定は同じ https://note.com で始まるのですが、originTypeで指定している値が異なるので別アプリと認識され、両者を行き来する場合でも https://note.com/〜 というURLを取得する事ができます。

note-api-client

Client側のAPI関数を束ねるライブラリです。以前はcomponentなどにaxiosを使ったリクエスト処理がその都度書かれていて、処理が散らばるのでAPIがどこから呼ばれているかを把握することが困難だったり、http clientライブラリを変更する場合などにも対応が難しい状況でした。

そこでhttp clientをruntimeで指定する方式をとり、API endpointの定義とリクエスト処理を分けるなどの対応をすることでNuxtアプリでもNext内でSWRを使っている場合でも気持ちよく使えるようにしました。

// ユーザーデータを取得するAPIの例
export const fetchUser = defineApi(
  // 最初の引数にpathを生成する関数を定義、pathという名前になります
  (userKey: string) => `/users/${userKey}`,

  // 次にリクエスト処理を定義、processという名前になります
  async (httpClient, path, reqParams, config) => {
    const res = httpClient.get<User>(path, reqParams, config)
    // 必要ならcamelCaseに変換したりする
    return camelCase(res.data)
  }
)

// fetchUserは以下のようなオブジェクトです
{
  path: (userKey: string) => string
  process: (httpClient: HttpClient, path: string, ...) => Promise<User>

  // path, processを一気に実行できる関数
  call: (httpClient: HttpClient, pathParams: [string]) => Promise<User>
}

// 通常は .call() 関数を使う
const userData = await fetchUser.call(
  axios,
  // userKeyを指定
  ['example'],
  // request paramsは特になし
  undefined,
  // configでAPI originを指定するなど
  { headers: { origin: 'http://note.com' } }
)

// Next + SWRの場合は .path() と .process() をそれぞれ使う
const { data } = useSWR(
  () => fetchUser.path('example'),
  (path) => fetchUser.process(axios, path, undefined, config)
)

このライブラリが一番頻繁に更新されていて、最近マイナーバージョンが100に達しました。

note-ui

いわゆるデザインシステムの実装です。ボタンやリストなどの基本的なcomponentはもちろん、色や文字サイズやマージンなどのデザイントークンを定義することであらゆる場面で一貫した体験を提供できるように慎重に設計されています。この実装は僕の担当ではないので別の機会に紹介されるかと思います。

分割で発生している問題点

分割作業が進むにつれて見えてきた問題点がいくつかありますので、代表的なものを紹介します。

ライブラリとアプリが増えてリリースが若干つらい

想像が付きやすいと思いますが、上記のnote-api-clientのバージョンを上げた場合、それを使用しているアプリ側でインストールをする必要があります。また、UI component自体をライブラリ化している場合は二段階にリリースが必要になるケースがあります。

例えば
note main app > note component lib > note-api-client
という依存関係があった場合は、この右から左に向かって一つずつバージョンを上げてリリースをしていく必要があります。

しかしこれは変更の影響範囲をコントールできるという点でメリットでもあるのでネガティブな側面だけではありません。また今後monorepo化を進めていく予定ですので、GitHubリポジトリをいくつも切り替えてPRを作るという辛みは解消されるはずです。
運用フローを自動化するということもできるかもしれません。

WebView対応問題

実はnoteのiOS/Androidアプリで閲覧しているいくつかのページはWebViewを使用してWebフロントエンドを表示している箇所がいくつかあります。
Nuxtでは全ページでSSR処理が走っていますのでhttp request headersを参照してNative Appから閲覧しているかどうかを簡単に判別できました。

しかしこの方法だとフロントエンドのコードに if (webview) のような分岐をいくつも書かなければいけないことが多く、それによりメンテナンス性や可読性の低下を引き起こしていました。
また、既に分割済みのアプリの多くではSSRを辞めていますのでそもそも同じ方法では判別が不可能です。

現在いくつかの解決方法の選択肢から最適なものを試しているところです。

今後の予定

さて次はどこからNextになっていくか、詳しいことは話せませんがアカウント設定ページの移行などでウォーミングアップが済んだような段階です。次はもっと大きな変更に挑戦する予定ですので、より速く安定性の高いサービスを提供できるようになるはずです。
また次の節目で報告記事を書けると思います。