entry-header-eye-catch.html
entry-title-container.html

entry-header-author-info.html
Article by

自然物から人工物へ - 16年目のピクシブ百科事典を支える最新の技術基盤

こんにちは。ピクシブ百科事典の開発をしております、フロントエンドエンジニアの ahu です。

ピクシブ百科事典 は、「みんなでつくる百科事典」を合言葉に運営されている、オンライン百科事典サービスです。2009年の11月10日にβ版がリリースされ、今年で16周年を迎えました 🎉

そんな歴史あるサービスですが、現在は Next.js v15 と React v19 で動いており、これはピクシブのサービスの中でも屈指の新しさです。今回はそんなピクシブ百科事典の開発体制について、「自然物から人工物へ」という視点から、チーム再編後の直近1年半での取り組みを紹介します。

「自然物」だったピクシブ百科事典と最初の Next.js 化

ピクシブ百科事典の Next.js 化プロジェクトが始まったのは、2023年の2月頃です。それまで百科事典に専任のチームはなく、他プロダクトのメンバーが業務の合間を縫ってメンテナンスを行っている状況でした。しかし、コロナ禍によるアクセス数の増加や、既存のテンプレートエンジンによる実装のメンテナンス面での課題などから、Next.js へのリプレイスが決まりました。

とはいえ、当初のチーム構成はバックエンド1名・フロントエンド1名(他チームとの兼務)という最小構成。記事ページを中心に主要なページの Next.js 化は実現できたものの、表示まで最大で10秒近くかかる記事があるなど、パフォーマンス面で重い課題が残る状態でした。またコードの設計についても、少人数で開発速度を優先した結果、個々の裁量でディレクトリが切られていたり、実装パターンが多様化していたりと、統一感に欠ける状況でした。

パフォーマンス面での制御が難しく、人の意思を反映させにくい様子は、まさに植物が自由に枝葉を広げて育つ「自然物」的な状態でした。

パフォーマンス改善と、人の意思を反映させる最初の一歩

自分が百科事典に関わり始めたのもこのタイミングで、初めての大きなプロジェクトがパフォーマンス改善でした。SSR の負荷が高く、Next.js が動く Kubernetes の pod でコネクションエラーが頻発しており、インフラの増強だけでは対処が難しい状況でした。

いくつか簡単な対策を試す中で、レスポンシブ対応の実装が原因で、同じコンポーネントが2重に SSR されていることに気がつきました。百科事典の記事ページは、デスクトップ版とモバイル版でデザインや機能に大きな差があり、CSS で画面幅に合わせて片方を非表示にすることで、これらを切り替えていました。しかしこの方法では、画面幅が不明な SSR 時は両方のパターンをレンダリングすることになり、それが大きな負荷となっていたのでした。

そこでこの問題を解消するため、プロダクトの将来像についてチームでよく議論した上で、User Agent の情報からデザインを切り替えるような仕組みで全体を書き換えました。レスポンシブ化を頑張る方針もありましたが、UA を使うことで SSR 時にデスクトップ版とモバイル版が出し分けられるようになり、レスポンシブ化以上にモバイルの体験を最適化していく方針が取れるようにもなりました。

とはいえ、全てのコンポーネントをデスクトップ用とモバイル用で完全に作り分けるのは大変です。そこで、React の Context API を使って useIsMobile というフックを作り、現在のリクエストがどちらのデバイスかで部分的にスタイルや挙動を変える方針を採用しました。現在 useIsMobile は80箇所以上で使われており、翻訳の次によく使われるフックになっています。

const LayoutFlagContext = createContext({ isMobile: false });

export const LayoutFlagProvider = ({ children, isMobile }: Props) => {
  const layoutFlagState = useMemo(() => ({ isMobile }), [isMobile]);
  return (
    <LayoutFlagContext value={layoutFlagState}>
      {children}
    </LayoutFlagContext>
  );
};

/**
 * @example
 * const isMobile = useIsMobile();
 * <div className={clsx(isMobile ? "w-full" : "w-1/2")} />
 */
export const useIsMobile = () => useContext(LayoutFlagContext).isMobile;

これと合わせて、Node.js と Next.js の更新や styled-components から Tailwind CSS への部分的な移行なども試した結果、最大で10秒近くかかっていた記事の表示は1秒以内に短縮され、Kubernetes で出ていた 503 エラーもほぼ観測されない状態にまで減少しました 🎉

「自然物」を望む形に伸ばしていく、最近の取り組み

百科事典チームでは、パフォーマンス改善後も継続的に、コードの整理やリアーキテクトを目的とした技術基盤の見直しを行なっています。コードは植物のようなもので、日々書き変わっていく中で自然と多様で無秩序な方向に育っていってしまうものです。ここからは、そういったコードの成長に対して、方向性を持たせるために実践してきた最近の取り組みについて紹介します。

OpenAPI の仕様から TypeScript の型定義を生成する

元々百科事典のバックエンドでは、API のドキュメンテーションのために swagger.json が定義されていました。ただフロントエンドではその定義を利用しておらず、API に渡す値の変換を忘れていないかや、API のレスポンスの型定義が本当に正しいのか確認する手間が発生していました。特にレスポンスの型定義の曖昧さは、リファクタリングにおいて不要な処理を書いてしまったり必要な処理を消してしまったりといったトラブルの元になっており、優先的に対処したい問題でした。

現在は openapi-typescript を使って swagger.json から TypeScript の型を生成する仕組みを導入しており、関連ライブラリである swr-openapi も使って、API の呼び出しでパスやパラメーターに間違いがないかチェックできる環境(エディタ上では補完が効きます)を実現しています。

最近さらにこの仕組みを拡張し、getServerSideProps での API 呼び出しを簡潔に書けるようにしたり、レスポンスの再利用のために middleware 的な層を実装したりといった仕組みを導入しました。 www.mizdra.net

デプロイ周りの整理と CI の高速化

現在の百科事典の Next.js は v15 で、Node.js は v22 です。(Next.js も Node.js も先月メジャーバージョンが更新されており、移行作業を進行中です。)これらランタイムのアップデートの他に、不定期ですが CI やデプロイスクリプトの見直しなども行なっています。最近では Docker イメージのビルド周りの設定を見直し、CI の待ち時間を1/4まで減らすことができました。

ビジュアルリグレッションテストの導入

最も最近の取り組みとして、Playwrightreg-cli を使ったビジュアルリグレッションテストを実装しました。既存のコンポーネント単位のスナップショットテストに加え、実際の画面でコンポーネント同士を組み合わせた際の、デザイン的な差分を視覚的に確認することが目的です。CI 上でテストを実行し、プルリクエストのコメントで結果を確認できるようにしています。

ディレクトリ構成の再定義

百科事典には 記事ページ のデザインと似たデザインのページが多くあり、全てのコンポーネントが src/components に置かれていました。すると責任の境界が曖昧になり、それぞれのコンポーネントの役割が拡大しがちになっていました。

そこで Bulletproof React という設計を参考に、features という関心事(ページであったり機能であったり)ごとにディレクトリを区切るよう全体の構成を見直しました。pages 以下を、コンポーネントの配置と表示のルーティングだけをする薄いコントローラーのようにすることで、Next.js 固有の問題の特定などが容易になります。

src
├── pages           # Next.js が特別扱いする場所で、ページ構成を定義する
│   ├── a/[tagName] # 百科事典の記事ページの実装
│   │   ├── DesktopView.tsx
│   │   ├── MobileView.tsx
│   │   └── index.page.tsx
│   └── ...
├── lib             # 広告や翻訳データなど、外部リソースに関するコードを置く
├── features        # ページや機能など、個別の関心事ごとにコードを置く
│   ├── article-common
│   ├── article     # 記事ページに必要な article-common 以外の部分の実装
│   │   ├── components
│   │   ├── hooks
│   │   ├── utils.ts
│   │   └── types.ts
│   ├── history     # 履歴ページに必要な article-common 以外の部分の実装
│   └── ...
├── components      # 全体で使う汎用的なコンポーネントを置く
├── hooks           # フックに依存した汎用的な補助関数(フック)を置く
│   ├── api
│   └── ...
├── utils           # フックに無関係な、その他の汎用的な補助関数を置く
│   ├── ssr         # 特に SSR に関する補助関数を置く
│   └── ...
└── types

不要な実装の洗い出しと package.json の整理

Knip というツールを使い、未使用の export や宣言、package.json の不要な記述や記載漏れを定期的にチェックしています。これにより、コード全体の健全さを保つだけでなく、不要なコンテキストを排除することで、AI によるコード補完などの精度向上も期待できると考えています。

ライブラリの更新や移行作業

package.json を整理するだけでなく、ライブラリのアップデートや置き換えなども進めています。最近では classnames というスタイル関連のライブラリを、後発でより軽量な clsx に置き換えました。ついでに prettier-plugin-tailwindcss によるクラスの並び替えが効果的に動作するよう、ts-morph を使って clsx("w-full", "flex") のような記法を clsx("w-full flex") に置き換える、 codemod 的なものを用意したりもしました。

import { Project, SyntaxKind } from "ts-morph"; // @27.x
import { globSync } from "node:fs";

async function mergeOrphanedClassNames(filePath: string) {
  const project = new Project();
  const sourceFile = project.addSourceFileAtPath(filePath);

  const calls = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
  for (const call of calls) {
    if (call.getExpression().getText() !== "clsx") continue;

    const args = call.getArguments();

    // 既にスペースで区切られている箇所には触れず、孤立しているものだけを1つにまとめる
    let mergePosition: number | undefined;
    let mergedLiteral: string | undefined;
    const mergedArgPositions: number[] = [];

    for (let i = 0; i < args.length; i++) {
      const arg = args[i];
      if (!arg.isKind(SyntaxKind.StringLiteral)) continue;

      const literal = arg.getLiteralValue();
      if (literal.includes(" ")) continue;

      if (mergePosition === undefined) {
        mergePosition = i;
        mergedLiteral = literal;
      } else {
        mergedLiteral += ` ${literal}`;
        mergedArgPositions.push(i);
      }
    }

    if (mergePosition !== undefined && mergedLiteral !== undefined) {
      args[mergePosition].replaceWithText(`"${mergedLiteral}"`);
    }

    // マージされた引数を削除するが、削除によりインデックスがずれないよう後ろから削除する
    mergedArgPositions.reverse().forEach((index) => call.removeArgument(index));
  }

  await sourceFile.save();
}

globSync("src/**/*.ts{,x}").forEach(async (file) => {
  await mergeOrphanedClassNames(file);
});

その他、Storybook v9 関連で Jest から Vitest への移行なども実施しました。

Architecture Decision Record(ADR)と読書会

日々コードを書く中で、「こうあってほしい」「こうあるのが正しいのでは?」と感じたことを Notion に起票する仕組みも運用しています。内容をエンジニア全員の定例で確認し、提案された解決策や議論の過程も記録に残すことで、低コストで技術選定の理由を参照できる環境を作っています。

また技術書やドキュメントの読書会も行っており、これまで「リーダブルコード」「The Elm Architecture」「サバイバルTypeScript」などを読みました。テストやツールによる整備だけでなく、チームの文化の側面からも良い設計の定義をすり合わせる活動を続けています。

おわりに

以上、人の意思を反映させにくいという意味で「自然物」的だったピクシブ百科事典を、管理しやすい「人工物」的な姿に寄せていく取り組みについて紹介しました。無秩序な方向に広がろうとするコードに対して、不要な部分を取り除き、成長の方向を矯正する仕組みを整えるのは、まるで庭木の管理のようだなと思います。

これらの改善は、スレッド機能 のような大規模な機能追加や、さらなる Next.js 化といった日々のプロジェクトと並行して進められてきました。こうした日々の積み重ねが、現在の「人の意思を反映させやすい」開発体制を支えています。

icon
ahu
フロントエンドエンジニアとして2021年に新卒入社。 ゲームとプログラミングが好き。