Closed32

型(集合)という観点でリファクタを考える

hajimismhajimism

React*TSでコードをきれいに書こう!とするときに考えていることって

  • 単一責任の原則を守りつつ
  • 疎結合に組んでいく

くらいしか自分はなくて、その補助線として型(集合)があるなー、という感覚がある。

今回は補助線の話をもうちょい自分の中で整理したいなと思い、ひとつ例をとってリファクタを何パターンか試してみる。

hajimismhajimism

題材
shadcn/uiのCommand

export const CommandMenu = () => {
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const { setTheme } = useTheme();

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  const runCommand = useCallback((command: () => unknown) => {
    setOpen(false);
    command();
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>

        <CommandGroup heading="Link">
          <CommandItem
            onSelect={() => runCommand(() => router.push("/mypage"))}
          >
            <Link className="mr-2 h-4 w-4" />
            My Page
          </CommandItem>
          <CommandItem onSelect={() => runCommand(() => router.push("/hoge"))}>
            <Link className="mr-2 h-4 w-4" />
            Hoge
          </CommandItem>
          <CommandItem onSelect={() => runCommand(() => router.push("/fuga"))}>
            <Link className="mr-2 h-4 w-4" />
            Fuga
          </CommandItem>
        </CommandGroup>

        <CommandSeparator />
        <CommandGroup heading="Theme">
          <CommandItem onSelect={() => runCommand(() => setTheme("light"))}>
            <SunMedium className="mr-2 h-4 w-4" />
            Light
          </CommandItem>
          <CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
            <Moon className="mr-2 h-4 w-4" />
            Dark
          </CommandItem>
        </CommandGroup>
      </CommandList>
    </CommandDialog>
  );
};
hajimismhajimism

Step 1: Linkでまとめる

まず明らかに目につくのはViewのこの部分。繰り返しが多いのでまとめたくなる。

        <CommandGroup heading="Link">
          <CommandItem
            onSelect={() => runCommand(() => router.push("/mypage"))}
          >
            <Link className="mr-2 h-4 w-4" />
            My Page
          </CommandItem>
          <CommandItem onSelect={() => runCommand(() => router.push("/hoge"))}>
            <Link className="mr-2 h-4 w-4" />
            Hoge
          </CommandItem>
          <CommandItem onSelect={() => runCommand(() => router.push("/fuga"))}>
            <Link className="mr-2 h-4 w-4" />
            Fuga
          </CommandItem>
        </CommandGroup>

        <CommandSeparator />
        <CommandGroup heading="Theme">
          <CommandItem onSelect={() => runCommand(() => setTheme("light"))}>
            <SunMedium className="mr-2 h-4 w-4" />
            Light
          </CommandItem>
          <CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
            <Moon className="mr-2 h-4 w-4" />
            Dark
          </CommandItem>
        </CommandGroup>
hajimismhajimism

LinkとThemeでCommandGroupがふたつあることに気がつく。

Themeはこれ以上増えなさそうだけれど、Linkはどんどん増えていきそうなので、まずはLinkだけでまとめてみる。

hajimismhajimism

イメージこういう感じになるはず。

  const links = [
    { href: "/mypage", label: "My Page" },
    { href: "/hoge", label: "Hoge" },
    { href: "/fuga", label: "Fuga" },
  ];
...

        <CommandGroup heading="Link">
          {links.map((link) => (
            <CommandItem
              key={link.label}
              onSelect={() => runCommand(() => router.push(link.href))}
            >
              <Link className="mr-2 h-4 w-4" />
              {link.label}
            </CommandItem>
          ))}
        </CommandGroup>
hajimismhajimism

これは、こういうLinkという型(集合)を見出してリファクタした、という感覚

type Link = {
  href: Route;
  label: string;
};
hajimismhajimism

Step 2: Commandでまとめる

Linkのほうがまとまったとはいえ、このままでいいのかと少し悩む。

  • Themeの方も何か変更があるかもしれない
  • LinkでもThemeでもないコマンドが追加されるかもしれない

なので、LinkとかThemeとかまだ見ぬXXXとかに関わらない、より大きいまとまりでリファクタしてみる。

hajimismhajimism

一般化したいのはこのCommandItemのところ。

          <CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
            <Moon className="mr-2 h-4 w-4" />
            Dark
          </CommandItem>

これを見ると、例えばこんな感じの型が見えてくる。

type Command = {
  label: string;
  Icon: LucideIcon; // たまたまLucide iconsをつかっているのでこの型だけれども、準ずるものならなんでも
  onSelect: () => void;
};
hajimismhajimism

Commandを軸にリファクタすると、こんな感じになる。LinkもThemeも同じ感じに書けるし、それ以外のコマンドが来ても同じように書ける。

  const linkCommands = [
    {
      label: "My Page",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/mypage")),
    },
    {
      label: "Hoge",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/hoge")),
    },
    {
      label: "Fuga",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/fuga")),
    },
  ];

  const themeCommands = [
    {
      label: "Light",
      Icon: SunMedium,
      onSelect: () => runCommand(() => setTheme("light")),
    },
    {
      label: "Moon",
      Icon: SunMedium,
      onSelect: () => runCommand(() => setTheme("dark")),
    },
  ];

...

       <CommandGroup heading="Link">
          {linkCommands.map((command) => (
            <CommandItem key={command.label} onSelect={command.onSelect}>
              <command.Icon className="mr-2 h-4 w-4" />
              {command.label}
            </CommandItem>
          ))}
        </CommandGroup>

        <CommandSeparator />

        <CommandGroup heading="Theme">
          {themeCommands.map((command) => (
            <CommandItem key={command.label} onSelect={command.onSelect}>
              <c.Icon className="mr-2 h-4 w-4" />
              {command.label}
            </CommandItem>
          ))}
        </CommandGroup>
hajimismhajimism

Step3: CommandGroupでまとめる

↑を見ると、LinkとThemeをさらに抽象化した集合CommandGroupが見えてくる。

export type CommandGroup = {
  heading: string;
  commands: Command[];
};
hajimismhajimism

先程のlinkCommands, themeCommandsを再利用してこんな感じになる。
この先いくらCommandが追加されようとも、commandGroupsを変更するだけでよく、Viewを触る必要がない。十分に抽象化されたと言える。

  const commandGroups = [
    {
      heading: "Link",
      commands: linkCommands,
    },
    {
      heading: "Theme",
      commands: themeCommands,
    },
  ];

...

        {commandGroups.map(({ heading, commands }) => (
          <>
            <CommandGroup heading={heading} key={heading}>
              {commands.map((command) => (
                <CommandItem key={command.label} onSelect={command.onSelect}>
                  <command.Icon className="mr-2 h-4 w-4" />
                  {command.label}
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
          </>
        ))}
hajimismhajimism

Stap1~3までが、「リファクタの補助線として型(集合)がある」というイメージ。
振り返ってみると、ここで言っているリファクタとは「抽象化」にほかならず、どの抽象を選んで整理するかという話でしかない。

hajimismhajimism

1<2<3の順で抽象度が上がっている。

viewとdataの分離という観点で見ると、どれだけdataが増えてもviewに触る必要がない3が抽象化として優れていそうだが、リファクタとして別に1が特別劣っているとは思わない。1は1で簡潔さがあるし、今後linkコマンドしか増える予定がないならあれで十分だ。

hajimismhajimism

抽象度の定規とリファクタとしての価値の定規はまた別、という気持ち。

hajimismhajimism

抽象度と言っているけれど、用語的には凝集度のほうが即しているのかな。
それでいうと1,2は論理的凝集で、3までいくと機能的凝集という感じはする。
ここらへんの用語の当て方はよくわからない...。

hajimismhajimism

ここまででViewをいじってきてこうなったわけだけれども、今度はロジックの方をリファクタしていきたい。


export const CommandMenu = () => {
  const router = useRouter();
  const [open, setOpen] = useState(false);
  const { setTheme } = useTheme();

  const linkCommands = [
    {
      label: "My Page",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/mypage")),
    },
    {
      label: "Hoge",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/hoge")),
    },
    {
      label: "Fuga",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/fuga")),
    },
  ];

  const themeCommands = [
    {
      label: "Light",
      Icon: SunMedium,
      onSelect: () => runCommand(() => setTheme("light")),
    },
    {
      label: "Moon",
      Icon: SunMedium,
      onSelect: () => runCommand(() => setTheme("dark")),
    },
  ];

  const commandGroups = [
    {
      heading: "Link",
      commands: linkCommands,
    },
    {
      heading: "Theme",
      commands: themeCommands,
    },
  ];

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  const runCommand = useCallback((command: () => unknown) => {
    setOpen(false);
    command();
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>

        {commandGroups.map(({ heading, commands }) => (
          <>
            <CommandGroup heading={heading} key={heading}>
              {commands.map((command) => (
                <CommandItem key={command.label} onSelect={command.onSelect}>
                  <command.Icon className="mr-2 h-4 w-4" />
                  {command.label}
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
          </>
        ))}
      </CommandList>
    </CommandDialog>
  );
};

hajimismhajimism

まず目につくのはここらへん。


  const linkCommands = [
    {
      label: "My Page",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/mypage")),
    },
    {
      label: "Hoge",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/hoge")),
    },
    {
      label: "Fuga",
      Icon: Link,
      onSelect: () => runCommand(() => router.push("/fuga")),
    },
  ];

  const themeCommands = [
    {
      label: "Light",
      Icon: SunMedium,
      onSelect: () => runCommand(() => setTheme("light")),
    },
    {
      label: "Moon",
      Icon: SunMedium,
      onSelect: () => runCommand(() => setTheme("dark")),
    },
  ];
  • runCommandは全Command共通なので個別のCommandからは抽出しておきたい
  • linkCommands内、themeCommands内でそれぞれもう少し抽象化できそう

というのがパッと思いつく。

hajimismhajimism

runCommandは全Command共通なので個別のCommandからは抽出しておきたい

全Command共通ということは、最終的にcommandGroupsになるときにrunCommandを合成すれば良いということになる。

なのでこういう関数を書いてみる。

import type { Command, CommandGroup } from "./type";

const setCommandRunner = (
  commands: Command[],
  runCommand: (_command: () => void) => void
): Command[] =>
  commands.map((command) => ({
    ...command,
    onSelect: () => runCommand(command.onSelect),
  }));

// integrate runCommand into each command
export const generateCommandGroups = (
  commangGroups: CommandGroup[],
  runCommand: (_command: () => void) => void
) =>
  commangGroups.map(({ heading, commands }) => ({
    heading,
    commands: setCommandRunner(commands, runCommand),
  }));
hajimismhajimism

これを使うとこういう事になり、個別のCommandからrunCommandを抜くことができた。

  const linkCommands = [
    {
      label: "My Page",
      Icon: Link,
      onSelect: () => router.push("/mypage"),
    },
    {
      label: "Hoge",
      Icon: Link,
      onSelect: () => router.push("/hoge"),
    },
    {
      label: "Fuga",
      Icon: Link,
      onSelect: () => router.push("/fuga"),
    },
  ];

  const themeCommands = [
    {
      label: "Light",
      Icon: SunMedium,
      onSelect: () => setTheme("light"),
    },
    {
      label: "Dark",
      Icon: Moon,
      onSelect: () => setTheme("dark"),
    },
  ];

  const commandGroups = generateCommandGroups(
    [
      {
        heading: "Link",
        commands: linkCommands,
      },
      {
        heading: "Theme",
        commands: themeCommands,
      },
    ],
    runCommand
  );

hajimismhajimism

linkCommands内、themeCommands内でそれぞれもう少し抽象化できそう

  • linkCommands: Iconとrouter.pushが同一
  • themeCommands: labelとthemeの文字列が(ケースを合わせれば)同一、setThemeが同一
  const linkCommands = [
    {
      label: "My Page",
      Icon: Link,
      onSelect: () => router.push("/mypage"),
    },
    {
      label: "Hoge",
      Icon: Link,
      onSelect: () => router.push("/hoge"),
    },
    {
      label: "Fuga",
      Icon: Link,
      onSelect: () => router.push("/fuga"),
    },
  ];

  const themeCommands = [
    {
      label: "Light",
      Icon: SunMedium,
      onSelect: () => setTheme("light"),
    },
    {
      label: "Dark",
      Icon: Moon,
      onSelect: () => setTheme("dark"),
    },
  ];

それぞれカスタムフックに押し込んでいく。

hajimismhajimism

useLinkCommands

こんな感じ。linkを追加したければlinksをいじれば良い。

const useLinkCommands = () => {
  const router = useRouter();

  const links = [
    { href: "/mypage", label: "My Page" },
    { href: "/hoge", label: "Hoge" },
    { href: "/fuga", label: "Fuga" },
  ];

  const linkCommands = links.map(({ href, label }) => ({
    label,
    Icon: Link,
    onSelect: () => router.push(href),
  }));

  return linkCommands;
};
hajimismhajimism

useThemeCommands

こんな感じ。labelとthemeの文字列が同一なのがたまたまだろと思う場合はlabelをべたがきしても良いと思う。

const useThemeCommands = () => {
  const { setTheme } = useTheme();

  const toTitleCase = (word: string) =>
    word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();

  const themes = [
    {
      theme: "light",
      Icon: SunMedium,
    },
    {
      theme: "dark",
      Icon: Moon,
    },
  ];

  const themeCommands = themes.map(({ theme, Icon }) => ({
    label: toTitleCase(theme),
    Icon,
    onSelect: () => setTheme(theme),
  }));

  return themeCommands;
};
hajimismhajimism

ここまでやると、こんな感じになる。

export const CommandMenu = () => {
  const [open, setOpen] = useState(false);

  const linkCommands = useLinkCommands();
  const themeCommands = useThemeCommands();

  const runCommand = useCallback((command: () => unknown) => {
    setOpen(false);
    command();
  }, []);

  const commandGroups = generateCommandGroups(
    [
      {
        heading: "Link",
        commands: linkCommands,
      },
      {
        heading: "Theme",
        commands: themeCommands,
      },
    ],
    runCommand
  );

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>

        {commandGroups.map(({ heading, commands }) => (
          <>
            <CommandGroup heading={heading} key={heading}>
              {commands.map((command) => (
                <CommandItem key={command.label} onSelect={command.onSelect}>
                  <c.Icon className="mr-2 h-4 w-4" />
                  {command.label}
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
          </>
        ))}
      </CommandList>
    </CommandDialog>
  );
};

Commandの生成がHooksに隠蔽されていい感じになったとは思いつつ、そもそも最終的に関心がある切り口はCommandGroupであるので、最初からCommandGroupを返せば良いことに気がつく。

hajimismhajimism

というわけでそれぞれこんな感じになおして

const useLinkCommandGroup = () => {
 ...

  return {
    heading: "Link",
    commands: linkCommands,
  };
};
const useThemeCommandGroup = () => {
...

  return {
    heading: "Theme",
    commands: themeCommands,
  };
};

こうしてあげる。ロジックの方もCommandGroupを軸として整理されて、Viewの抽象と噛み合っている。


  const linkCommandGroup = useLinkCommandGroup();
  const themeCommandGroup = useThemeCommandGroup();

  const commandGroups = generateCommandGroups(
    [linkCommandGroup, themeCommandGroup],
    runCommand
  );
hajimismhajimism

現状はこんなかんじ。

export const CommandMenu = () => {
  const [open, setOpen] = useState(false);

  const runCommand = useCallback((command: () => unknown) => {
    setOpen(false);
    command();
  }, []);

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

  const linkCommandGroup = useLinkCommandGroup();
  const themeCommandGroup = useThemeCommandGroup();

  const commandGroups = generateCommandGroups(
    [linkCommandGroup, themeCommandGroup],
    runCommand
  );

  return (
 ...
hajimismhajimism

ここまで来ると、ロジック層の中でこいつらだけが浮いて見えるようになる。

  const [open, setOpen] = useState(false);

  const runCommand = useCallback((command: () => unknown) => {
    setOpen(false);
    command();
  }, []);

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, []);

少し強引ではあるけれど、「Commandの開閉に関心がある」としてひとまとめにしてしまってもよいだろう。

hajimismhajimism

例えばこんな感じ。

export const useToggleCommandWithKey = (activateKey = "k") => {
  const [open, setOpen] = useState(false);

  // Close Command when running a command
  const runCommand = useCallback((command: () => void) => {
    setOpen(false);
    command();
  }, []);

  useEffect(() => {
    const down = (e: KeyboardEvent) => {
      if (e.key === activateKey && (e.metaKey || e.ctrlKey)) {
        e.preventDefault();
        setOpen((open) => !open);
      }
    };

    document.addEventListener("keydown", down);
    return () => document.removeEventListener("keydown", down);
  }, [activateKey]);

  return { open, setOpen, runCommand };
};
hajimismhajimism

こうなる。だいぶスッキリした。

export const CommandMenu = () => {
  const { open, setOpen, runCommand } = useToggleCommandWithKey();

  const linkCommandGroup = useLinkCommandGroup();
  const themeCommandGroup = useThemeCommandGroup();

  const commandGroups = generateCommandGroups(
    [linkCommandGroup, themeCommandGroup],
    runCommand
  );

  return (
    <CommandDialog open={open} onOpenChange={setOpen}>
      <CommandInput placeholder="Type a command or search..." />
      <CommandList>
        <CommandEmpty>No results found.</CommandEmpty>

        {commandGroups.map(({ heading, commands }) => (
          <>
            <CommandGroup heading={heading} key={heading}>
              {commands.map((command) => (
                <CommandItem key={command.label} onSelect={command.onSelect}>
                  <command.Icon className="mr-2 h-4 w-4" />
                  {command.label}
                </CommandItem>
              ))}
            </CommandGroup>
            <CommandSeparator />
          </>
        ))}
      </CommandList>
    </CommandDialog>
  );
};

hajimismhajimism

改めて、これが「リファクタの補助線として型(集合)がある」というイメージ。

  • CommandGroupを軸としてViewを整理し
  • ロジックの方もCommandGroupを軸とすることで、「CommandGroup1, CommandGroup2, CommandGroup以外」とまとまった

という感じ。

hajimismhajimism

まだまだやれることはありそうで、パッと思いつくのはここらへん。

  1. emptyMessageやplaeholderなども抽象化し、それらやCommandGroupを受け取るPresenterと、CommandGroupを生成するなどのロジックを記述するContainerに分離する
  2. ロジックのところどころに型注釈をつけて関心を明確にする (const useThemeCommandGroup = (): CommandGroupみたいなこと)
hajimismhajimism

1は今回の本題でないけれど、2は本題と関係がある。
「どの抽象を選んで書いたコードですよ」というのが明示的に伝わると良いよね、という話で、それを明確にするために型「注釈」をつける。

hajimismhajimism

今回のCommandMenuは選べる抽象が何段階かあって、題材として面白いと思う。
抽象化の落とし所をどこにするのかはいろんな事情が絡んできそうで、要研究。

このスクラップは2023/06/18にクローズされました