✂️

バンドルサイズを削りやすい React コンポーネント設計

2022/12/04に公開

多くのライブラリは利便性のためにたくさんの機能を持っていて、その全てを活用するユーザーはほぼいません。一般的なライブラリにおいては、その中から必要な機能のみをバンドルに含めるための設計プラクティスが普及しており、Firebase JS SDK v9 での変更はその代表例でしょう。しかし、コンポーネントライブラリではそのようなプラクティスが発達しておらず、多くのアプリケーションでバンドルサイズに無視できない影響を与えています[1]

そこで、バンドルサイズを削りやすいコンポーネント設計を考えます。ここでは例として、以下のようにフェードインするタグコンポーネントを Framer Motion を使って実装することを考えます[2]。フェードインの有無は何らかの方法で切り替えられるものとし、フェードインしない場合に、その関連コードをバンドルから削るようにします。

❌ Boolean プロパティで切り替え

実装例

import { FC, PropsWithChildren } from "react";
import { motion } from "framer-motion";
import { tagStyle } from "../style";

type Props = PropsWithChildren<{ fadeIn?: boolean }>;

export const Tag: FC<Props> = ({ children, fadeIn = false }) => {
  return fadeIn ? (
    <motion.div animate={{ opacity: [0, 1] }} transition={{ duration: 3 }}>
      <span style={tagStyle}>{children}</span>
    </motion.div>
  ) : (
    <span style={tagStyle}>{children}</span>
  );
};

まず最初に想起するのがこの実装だと思います。この場合、fadeIn が常に false だったとしても、Framer Motion への参照が切れないため、motion の実装がバンドルに含まれてしまいます。モジュールの依存関係は以下のようになります。

🚨 Dynamic Import する

実装例

type Props = PropsWithChildren<{ fadeIn?: boolean }>;

export const Tag: FC<Props> = ({ children, fadeIn = false }) => {
  return fadeIn ? (
    <Suspense fallback={null}>
      <MotionDiv animate={{ opacity: [0, 1] }} transition={{ duration: 3 }}>
        <span style={tagStyle}>{children}</span>
      </MotionDiv>
    </Suspense>
  ) : (
    <span style={tagStyle}>{children}</span>
  );
};
export const MotionDiv = lazy(async () => {
  return {
    default: (await import("framer-motion")).motion.div,
  };
});

前述の実装の発展形です。Framer Motion を dynamic import することでバンドルには含めず、fadeIn が true の時のみ遅延ロードすることができます。

しかし、非同期での読み込みになるため、読み込み中のフォールバックを考慮する必要があり、ページ読み込み時に発生するアニメーションなど、チラつきが問題になる場合があります。

⭕️ 参照をプロパティで注入する

実装例

type Props = PropsWithChildren<{
  animation?: ComponentType<PropsWithChildren>;
}>;

export const Tag: FC<Props> = ({ children, animation: Animation }) => {
  return Animation ? (
    <Animation>
      <span style={tagStyle}>{children}</span>
    </Animation>
  ) : (
    <span style={tagStyle}>{children}</span>
  );
};
export const FadeInAnimation: FC<PropsWithChildren> = ({ children }) => {
  return (
    <motion.div animate={{ opacity: [0, 1] }} transition={{ duration: 3 }}>
      {children}
    </motion.div>
  );
};

Framer Motion への参照をプロパティとして注入します。利用側では以下のように呼び出します。

main.tsx
<Tag animation={FadeInAnimation}>Animated with animation props</Tag>

これにより Tag コンポーネントが直接的に Framer Motion に依存しなくなり、animation プロパティが渡されない場合は Framer Motion への参照がなくなるため、バンドルに含まれなくなります。

⭕️ 参照を React Context で注入する

実装例

export const Tag: FC<PropsWithChildren> = ({ children }) => {
  const context = useContext(AnimationContext);
  return context.motion ? (
    <context.motion.div
      animate={{ opacity: [0, 1] }}
      transition={{ duration: 3 }}
    >
      <span style={tagStyle}>{children}</span>
    </context.motion.div>
  ) : (
    <span style={tagStyle}>{children}</span>
  );
};
import { createContext } from "react";
import type { motion } from "framer-motion";

export const AnimationContext = createContext<{ motion?: typeof motion }>({});

プロパティで注入できるということは、当然 React Context でも注入できます。利用側では以下のように呼び出します。

main.tsx
<AnimationContext.Provider value={{ motion: motion }}>
  <main>
    <Tag>Animated with context</Tag>
  </main>
</AnimationContext.Provider>

コンポーネントライブラリにおいては、Context で全体のテーマなどを設定することが多く、それと合わせると取り回しが良さそうです。

この場合も、AnimationContext を介して Framer Motion を参照するため、Provider を抜けば Framer Motion への依存もなくなります。

終わりに

利用者がバンドルサイズを削りやすいコンポーネント設計を考察しました。ここでは React を用いましたが、他のフレームワーク・ライブラリでも同様に考えられると思います。

やはり依存ライブラリがバンドルサイズに与える影響は大きく、いかにモジュールグラフ上での直接的な参照を減らすか、という視点で考えるとインパクトが大きそうです。

ここでは簡単のために motion を直接取り回していましたが、実際にはオプション値なども合わせて取りうるため、よしなにインターフェースを定義することになるでしょう。実はこれは Chakra UI のバンドルサイズ削減活動の一環で取り組んでいたもので、(マージされませんでしたが)現実には以下のようなデザインを考えていました。

https://github.com/chakra-ui/chakra-ui/pull/6368

余談:このテクを真の Dependency Injection と銘打とうと思ったのですが、普通に紛らわしいのでやめました。

脚注
  1. Chakra UI での例: https://github.com/chakra-ui/chakra-ui/issues/4975 ↩︎

  2. この程度のアニメーションであれば CSS Animation で十分ですが、例なので勘弁してください ↩︎

Discussion