VueでHireRooのReactのアーキテクチャを表現してみた

目次

こんにちは。Himenonです。

2024/10/29 の Vue Fes Japan 2024 After Talk で本当は話すはずだった内容を記事にします。※当日はコロナに感染してしまって参加できませんでした。

https://yappli.connpass.com/event/330660/

TODO アプリを題材に HireRoo のアーキテクチャを Vue と React で再現する

HireRoo はメインの Web フロントエンドフレームワークとしては React を利用していますが、これを Vue で表現したらどうなるか、という話をしようと思います。わかりやすい実装例としてはやはり TODO アプリだと思いますので、それを題材にコードを比較していこうと思います。

TODO アプリの簡単な仕様としては、

  • TODO アイテムがユーザー入力文字列を元に追加できる
  • 追加された TODO アイテムが削除できる

という程度のもので、スタイリングは今回のスコープには含めません。サンプルコードは以下にあります。

HireRoo のフロントエンドのアーキテクチャの簡単な説明

まず、HireRoo のアーキテクチャについて簡単に説明します。最も大きな構造を取っているのは、コンテナ層とプレゼンテーション層という 2 つの層です。

コンテナ層

ビジネスロジックを実装する。バックエンドと、クライアントとの情報のやり取りを行う仲介役の責務を持ち、取得したデータをプレゼンテーションのインターフェースに沿って渡すところまでを担当する。

プレゼンテーション層

ユーザーの入力イベントを収集、コンテナ層に伝える責務を持つ。プレゼンテーションのインターフェース(props)を露出するだけで、ビジネスロジックについては知らない。

ざっくりとした図で説明すると次のような構成を取っています。

React でコンテナ層とプレゼンテーション層を表現するとどうなるか

この実装を端的に表現した実装例は次のコードで、useGenerateProps というコンテナ層の実装が Presentation 層の I/F に依存して実装しているところです。

import { type Widgets } from "../../presentation";

import { TodoListStore } from "./store";
export type GenerateTodoListPropsArgs = {};

export const useGenerateProps = (
  _args: GenerateTodoListPropsArgs
): Widgets.TodoListProps => {
  // presentation層のI/Fの実装になっている

  const todoItems = TodoListStore.useTodoItems();
  return {
    addTodoForm: {
      onSubmit: (fields) => {
        TodoListStore.addTodo({
          id: self.crypto.randomUUID().toString(),
          title: fields.title,
        });
      },
    },
    items: todoItems.map((todoItem): Widgets.TodoListProps["items"][number] => {
      return {
        title: todoItem.title,
        removeButton: {
          onClick: () => {
            TodoListStore.removeTodo(todoItem.id);
          },
        },
      };
    }),
  };
};

HireRoo のアプリケーションのコード内ではこの構成のコードがたくさんあります。

これを Vue で表現するとどうなるか、実際にやってみたので、そちらを中心に詳解していきます。

Vue でコンテナ層とプレゼンテーション層を表現するとどうなるか

前述した useGenerateProps という関数と、それに対応する Presentation のコードを Vue 向けに書き換えたのが以下のコードになります。

どのように React のコードを Vue に書き換えたの方針を説明すると、「Vue らしさ」みたいな部分は考慮せず既存(HireRoo のアーキテクチャ)のコードベースをどれくらい書き換えずに Vue に転用できるかを念頭に書き換えています。「アーキテクチャ」という部分はフレームワークの外側にある概念なので転用可能なはずで、それをそれぞれのフレームワークに落とし込む、というのが今回のチャレンジです。

さて、ここから書き換えた上での考察をやっていこうと思います。

具体的に React から Vue にどう書き換えたのか?

コード全体の読解については、サンプルコードを公開していますのでそちらをご確認ください。

Container から Presentation に対する Props の受け渡し方の書き方はどう変化したか

React における Props の渡し方は通常の JavaScript の関数の引数として渡すことができます。一方で Vue は拡張子が.vue でおわる単一ファイルコンポーネント(別名 SFC)<script> ブロックの中で Props を定義します。これを <template> ブロックの中で参照することで props を参照できます。

React で書いた AddTodoForm のコンポーネントの実装

import * as React from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as Validator from "../../../validator";

export type AddTodoFormProps = {
  onSubmit: SubmitHandler<Validator.AddTodoForm>;
};

export const AddTodoForm: React.FC<AddTodoFormProps> = (props) => {
  const { onSubmit } = props;
  const methods = useForm<Validator.AddTodoForm>({
    resolver: zodResolver(Validator.AddTodoForm),
    mode: "onSubmit",
    defaultValues: {
      title: "",
    },
  });

  const formProps: React.JSX.IntrinsicElements["form"] = {
    onSubmit: (event) => {
      methods.handleSubmit((fields) => {
        onSubmit(fields);
        methods.reset();
      })(event);
    },
  };

  const inputProps: React.JSX.IntrinsicElements["input"] = {
    ...methods.register("title"),
  };

  const submitButton: React.JSX.IntrinsicElements["button"] = {
    type: "submit",
    children: "Add Todo",
  };

  return (
    <form {...formProps}>
      <input {...inputProps} />
      <button {...submitButton} />
    </form>
  );
};

AddTodoForm.displayName = "AddTodoForm";

export default AddTodoForm;

Vue で書いた AddTodoForm のコンポーネントの実装

<script setup lang="ts">
import { reactive, defineProps, type FormHTMLAttributes } from "vue";
import { useForm } from "vee-validate";
import * as Validator from "../../../validator";
import { toTypedSchema } from "@vee-validate/zod";

export type AddTodoFormProps = {
  onSubmit: (data: Validator.AddTodoForm) => void;
};

const props = defineProps<AddTodoFormProps>();

const methods = useForm<Validator.AddTodoForm>({
  validationSchema: toTypedSchema(Validator.AddTodoForm),
  initialValues: reactive<Validator.AddTodoForm>({
    title: "",
  }),
  keepValuesOnUnmount: false,
});

const formProps: FormHTMLAttributes = {
  onSubmit: methods.handleSubmit((fields) => {
    props.onSubmit(fields);
    methods.resetForm();
  }),
};

const [title, titleProps] = methods.defineField("title");
</script>

<template>
  <form v-bind="formProps">
    <input name="title" v-model="title" v-bind="titleProps" />
    <button type="submit">Add Todo</button>
  </form>
</template>

Spread Operator を使った props の渡し方はどう変化したか?

私は React において、子コンポーネントに対して props を渡す方法として Spread Operator を利用して展開する方法が気に入っています。これをすることによってコンポーネントの定義部分の見通しが良くなり、Interface コードが散らからないためです。Vue でこれを実現しようとすると、v-bind というディレクティブを利用することで表現できます。仕組み的には v-bind に対してオブジェクトを渡すことで複数の属性をセットできます。

React で書いた TodoItem コンポーネントの実装

import * as React from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as Validator from "../../../validator";

export type AddTodoFormProps = {
  onSubmit: SubmitHandler<Validator.AddTodoForm>;
};

export const AddTodoForm: React.FC<AddTodoFormProps> = (props) => {
  const { onSubmit } = props;
  const methods = useForm<Validator.AddTodoForm>({
    resolver: zodResolver(Validator.AddTodoForm),
    mode: "onSubmit",
    defaultValues: {
      title: "",
    },
  });

  const formProps: React.JSX.IntrinsicElements["form"] = {
    onSubmit: (event) => {
      methods.handleSubmit((fields) => {
        onSubmit(fields);
        methods.reset();
      })(event);
    },
  };

  const inputProps: React.JSX.IntrinsicElements["input"] = {
    ...methods.register("title"),
  };

  const submitButton: React.JSX.IntrinsicElements["button"] = {
    type: "submit",
    children: "Add Todo",
  };

  return (
    <form {...formProps}>
      <input {...inputProps} />
      <button {...submitButton} />
    </form>
  );
};

AddTodoForm.displayName = "AddTodoForm";

export default AddTodoForm;

Vue で書いた TodoItem コンポーネントの実装

<script setup lang="ts">
import { type ButtonHTMLAttributes, defineProps } from "vue";

export type TodoItemProps = {
  title: string;
  removeButton: Pick<ButtonHTMLAttributes, "onClick">;
};

defineProps<TodoItemProps>();
</script>

<template>
  <p>
    <strong>{{ title }}</strong>
    <button v-bind="removeButton">Remove</button>
  </p>
</template>

State の扱いはどう変化したか?

HireRoo では State ライブラリとして Proxy ベースのvaltioを利用しています。そして、Vue 3 においても Proxy ベースの状態管理がライブラリ側から提供されています。この書き換えについて見ていきます。

ファイル構成としては、

  • Action.ts … State を更新するメソッドを蓄積させる
  • exports.ts … index.ts で namespace exports するための踏み台
  • hooks.ts … useXxx 系のメソッドを蓄積させる
  • index.ts … exports 用
  • State.ts … Store で管理する大本の State
  • types.ts … 型定義

という構成をとっています。まず、State.ts を見ていくと次のようになります。

React の State.ts

import { proxy } from "valtio";
import type * as Types from "./types";

export const state = proxy<Types.State>({
  todoItems: null,
});

Vue の State.ts

import { reactive } from "vue";
import type * as Types from "./types";

export const state = reactive<Types.State>({
  todoItems: null,
});

違いとしては、valtio の proxy で wrap するか、vue の reactive で wrap するかの違いしかありませんでした。

Action.ts も比較してみます

React の Action.ts

import { state } from "./State";
import { proxyMap } from "valtio/utils";
import type * as Types from "./types";

export const initialize = () => {
  state.todoItems = proxyMap([
    ["#1", { id: "#1", title: "Vue" }],
    ["#2", { id: "#2", title: "Fes" }],
    ["#3", { id: "#3", title: "After" }],
    ["#4", { id: "#4", title: "Party" }],
    ["#5", { id: "#5", title: "2024" }],
  ]);
};

export const clear = () => {
  state.todoItems = null;
};

export const addTodo = (newTodoItem: Types.TodoItem) => {
  if (!state.todoItems) {
    return;
  }
  if (state.todoItems.has(newTodoItem.id)) {
    return;
  }
  state.todoItems.set(newTodoItem.id, newTodoItem);
};

export const removeTodo = (todoItemId: Types.TodoItem["id"]) => {
  if (!state.todoItems) {
    return;
  }
  state.todoItems.delete(todoItemId);
};

Vue の Action.ts

import type * as Types from "./types";
import { state } from "./State";

export const initialize = () => {
  state.todoItems = new Map([
    ["#1", { id: "#1", title: "Vue" }],
    ["#2", { id: "#2", title: "Fes" }],
    ["#3", { id: "#3", title: "After" }],
    ["#4", { id: "#4", title: "Party" }],
    ["#5", { id: "#5", title: "2024" }],
  ]);
};

export const clear = () => {
  state.todoItems = null;
};

export const addTodo = (newTodoItem: Types.TodoItem) => {
  if (!state.todoItems) {
    return;
  }
  if (state.todoItems.has(newTodoItem.id)) {
    return;
  }
  state.todoItems.set(newTodoItem.id, newTodoItem);
};

export const removeTodo = (todoItemId: Types.TodoItem["id"]) => {
  if (!state.todoItems) {
    return;
  }
  state.todoItems.delete(todoItemId);
};

React 側は一部ライブラリが必要となっていますが、Vue は Pure な JavaScript として書けています。こちらもほとんど違いはありませんでした。

hooks.ts もみていきます。

React の hooks.ts

import { useSnapshot } from "valtio";

import { state } from "./State";

export const useInitialized = () => {
  const snapshot = useSnapshot(state);
  return snapshot.todoItems !== null;
};

export const useTodoItems = () => {
  const snapshot = useSnapshot(state);
  if (snapshot.todoItems === null) {
    throw new Error("初期化してください");
  }
  return [...snapshot.todoItems.values()];
};

Vue の hooks.ts

import { useSnapshot } from "valtio";

import { state } from "./State";

export const useInitialized = () => {
  const snapshot = useSnapshot(state);
  return snapshot.todoItems !== null;
};

export const useTodoItems = () => {
  const snapshot = useSnapshot(state);
  if (snapshot.todoItems === null) {
    throw new Error("初期化してください");
  }
  return [...snapshot.todoItems.values()];
};

React 側は unwrap するために useSnapshot という関数を使っていますが、vue 側はそのまま書けています。

State を管理する責務の中では Proxy ベースの書き方をしていた場合はほとんどの書き換えが不要でした。

State の参照の違い

再掲になりますが、useGenerateProps という関数を見てみます。先程の Proxy ベースの State を参照しているところになります。ここは特段大きな違いはありません

React の useGenerateProps.ts

import { type Widgets } from "../../presentation";

import { TodoListStore } from "./store";
export type GenerateTodoListPropsArgs = {};

export const useGenerateProps = (
  _args: GenerateTodoListPropsArgs
): Widgets.TodoListProps => {
  const todoItems = TodoListStore.useTodoItems();
  return {
    addTodoForm: {
      onSubmit: (fields) => {
        TodoListStore.addTodo({
          id: self.crypto.randomUUID().toString(),
          title: fields.title,
        });
      },
    },
    items: todoItems.map((todoItem): Widgets.TodoListProps["items"][number] => {
      return {
        title: todoItem.title,
        removeButton: {
          onClick: () => {
            TodoListStore.removeTodo(todoItem.id);
          },
        },
      };
    }),
  };
};

Vue の useGenerateProps.ts

import { TodoListStore } from "./store";
import type { Widgets } from "../../presentation";
export type GenerateTodoListPropsArgs = {};

export const useGenerateProps = (
  _props: GenerateTodoListPropsArgs
): Widgets.TodoListProps => {
  const todoItems = TodoListStore.useTodoItems();
  const addTodoForm: Widgets.TodoListProps["addTodoForm"] = {
    onSubmit: (fields) => {
      TodoListStore.addTodo({
        id: crypto.randomUUID(),
        title: fields.title,
      });
    },
  };

  const items = todoItems.map((todoItem) => ({
    title: todoItem.title,
    removeButton: {
      onClick: () => {
        TodoListStore.removeTodo(todoItem.id);
      },
    },
  }));

  return {
    addTodoForm,
    items,
  };
};

ところが、Container の実装を比較してみると、違いがあります。

React の Container.tsx

import * as React from "react";
import { Widgets } from "../../presentation";

import {
  useGenerateProps,
  type GenerateTodoListPropsArgs,
} from "./useGenerateProps";

export type TodoListContainerProps = GenerateTodoListPropsArgs;

export const TodoListContainer: React.FC<TodoListContainerProps> = (props) => {
  const todoListProps = useGenerateProps(props);
  return <Widgets.TodoList {...todoListProps} />;
};

export default TodoListContainer;

Vue の Container.vue

<script setup lang="ts">
import {
  useGenerateProps,
  type GenerateTodoListPropsArgs,
} from "./useGenerateProps.js";
import * as Widgets from "../../presentation/widgets";
import { computed } from "vue";

export type TodoListContainerProps = GenerateTodoListPropsArgs;

const props = defineProps<TodoListContainerProps>();

const todoListProps = computed(() => useGenerateProps(props));
</script>

<template>
  <Widgets.TodoList v-bind="todoListProps" />
</template>

React 側のコードはそのまま Presentation のコンポーネントに対して props を渡していますが、Vue 側は単一コンポーネントファイル( .vue )の中で computed 関数を使って、State を unwrap しています。

※ React の場合は store の中で useSnaspshot を使って unwrap していたものが Vue はこちらで行っている、という形です。

React.useEffect からライフサイクル API への書き換え

HireRoo では FetchContainer というファイルにはデータの初期ロードなどの実装を行っています。サンプルコードでは setTimeout で非同期処理を代用して表現しています。

React は useEffect API を利用して React のコンポーネントのライフサイクルと協調していくのに対し、Vue では onMounted や onBeforeUnmount のようにすでに用意されているライフサイクルに処理をアサインしていく書き方に変わりました。

React の FetchContainer.tsx

import * as React from "react";
import { Widgets } from "../../presentation";

import { TodoListStore } from "./store";
import TodoListContainer from "./Container";

export const TodoListFetchContainer: React.FC = () => {
  const initialized = TodoListStore.useInitialized();

  React.useEffect(() => {
    window.setTimeout(() => {
      TodoListStore.initialize();
    }, 1000);

    return () => {
      TodoListStore.clear();
    };
  }, []);

  if (!initialized) {
    return <Widgets.Loading type="TEXT" />;
  }

  return <TodoListContainer />;
};

TodoListFetchContainer.displayName = "TodoListFetchContainer";

export default TodoListFetchContainer;

Vue の FetchContainer.vue

<script setup lang="ts">
import { onMounted, onBeforeUnmount, computed } from "vue";
import { TodoListStore } from "./store";
import TodoListContainer from "./Container.vue";
import WidgetsLoading from "../../presentation/widgets/Loading/Loading.vue";

const initialized = computed(() => TodoListStore.useInitialized());

onMounted(() => {
  window.setTimeout(() => {
    TodoListStore.initialize();
  }, 0);
});

onBeforeUnmount(() => {
  TodoListStore.clear();
});
</script>

<template>
  <div>
    <WidgetsLoading v-if="!initialized" type="TEXT" />
    <TodoListContainer v-else />
  </div>
</template>

コードの書き換えの比較のまとめ

簡易的なまとめですが、HireRoo のアーキテクチャを書き換えたときの比較です。

ReactVueProps の I/F 関数の引数として定義できる SFC 内で defineProps に食わせることで定義できる props の渡し方 JSX に展開する形式で定義する。Spread Operator で簡略化できる vue のディレクティブを利用して渡すことができる。v-bind を通してまとめてセットすることも可能。State の扱い valtio という外部ライブラリを用いて Proxy ベースの状態管理をしている。ref/reactive 等の API が Vue から提供されており、それを利用した Proxy ベースの状態管理ができる。コンポーネントのライフサイクル機能を useEffect が与えるライフサイクルにアサインしていく。SFC 内で利用できるライフサイクルに対して処理をアサインする。

言葉足らずな説明が多いですが、ほぼ同じような構成で書き換えたとしても React/Vue といったフレームワークから受ける思想の違いは顕著でした。

コードを書き換えてみての所感

さて、ここからは筆者の感想です。まず、アーキテクチャとそのファイル構成を維持しつつ書き換えに挑戦してみましたが、Vue は特に単一ファイルコンポーネント(SFC)に対する思想のインプットが極めて重要であることがわかりました。純粋な TypeScript のファイルではなく、vue ファイルから更に変換されて利用されるという条件下でコードを書くためこれを念頭に設計をする必要があります。

Vue のドキュメントでも関心の分離について言及があります。

関心の分離についてはどうですか?

Vue においてはこれに同意してアーキテクチャも変容させるのがよいと感じられました。一方で、コードの書き方レベルの制約はおそらく useXxx の hooks をコンポーネント内に入れろ、くらいのものだと思います。これはこれで自由が高いですが、useEffect によく言われるように利用者にとって解釈の余地があまりあるため利用の難易度が高いとも言えます。

Vue で特に良かったと感じた部分は State 周りでしょうか。ここらへんは悩みが少なくて非常に良かったと感じています。React もこれくらい簡単だと嬉しいな。改善してほしいなという点は Interface でしょうか。補完を受けつつコードを書くときに、補完される内容が難しすぎて理解できない瞬間がよくありました。SFC の概念とはトレードオフかもしれませんが、普段の JavaScript の知識でスラスラと書けると嬉しいなぁと感じたところです。

まとめ

HireRoo における React のアプリケーションのアーキテクチャを Vue に書き換えてみました。意外にも大きく書き換えず表現はできました。同じアーキテクチャで異なるフレームワークで表現したときの現在位置がわかって楽しかったです。