각도/반응両方を経験して得られた堅牢なアプリケーション設計

사진은 All Bong 에서 Unsplash

概要


アプリケーション開発において設計は非常に重要です.
本記事では 대기사항アプリを例に、これまで取り組んできた 반응하다アプリケーションのアーキテクチャや実装パターンを紹介します.
このアーキテクチャは筆者の 모서리의가 있다を用いたアプリケーション開発の経験が元になっており、모서리의가 있다のオピニオンや 모서리의가 있다コミュニティで紹介された設計手法が含まれています.

コンセプト


コンポーネントとロジックの分離を基本とし、依存関係を単方向にします.

下記に実装例を示します.
  • 각도アプリケーションに適用した場合
      https://github.com/puku0x/todo-angular
  • 반응アプリケーションに適用した場合
      https://github.com/puku0x/todo-react
  • Vue.회사 명アプリケーションに適用した場合
      https://github.com/puku0x/todo-vue
  • 데이터


    アプリケーション内で扱うデータは用途に応じて区別しましょう.

    🤔なぜ? 
    GET リクエストと POST リクエストで型が違うことはよくあります。また、API の仕様変更に柔軟に対応するためです。


    모델


    アプリケーションへの「入力」を表現するデータ型です.定数や미국 석유 학회のレスポンス等が該当します.
    interface Todo {
      id: number;
      title: string;
      completed: boolean;
    }
    

    DTO(데이터 전송 객체)


    アプリケーションからの「出力」を表現するデータ型です.미국 석유 학회リクエスト等が該当します.
    interface TodoCreateDto {
      title: string;
    }
    
    interface TodoUpdateDto {
      id: number;
      title: string;
      completed: boolean;
    }
    

    参考: CQRS, NestJS


    서비스


    ドメインに関するビジネスロジックは 서비스に記述します.

    実装は関数やオブジェクトでも構いませんが、 class を用いた DI 회사パターンは強力なのでお勧めです.
    export class TodoService {
      constructor(private readonly http: HttpClient) {}
    
      fetchAll(offset?: number, limit?: number): Promise<Todo[]> {
        return this.http.get(url, params).then(/* 一覧データ */);
      }
    
      fetch(id: number): Promise<Todo> {
        return this.http.get(url, params).then(/* 詳細データ */);
      }
    
      create(todo: TodoCreateDto): Promise<Todo> {
        return this.http.post(url, body).then(/* 登録データ */);
      }
    
      update(id: number, todo: TodoUpdateDto): Promise<Todo> {
        return this.http.put(url, body).then(/* 更新データ */);
      }
    
      remove(id: number): Promise<number> {
        return this.http.delete(url).then(/* 削除されたデータのID */);
      }
    }
    
    // Axios や Fetch API のラッパー
    export class HttpClient {
      ...
    }
    
    서비스を実装する際は単一責任の原則を心がけましょう. CQRS に倣って入力と出力で分けても良いです.
    この他、汎用的なロジックはユーティリティとして分離する場合があります.
    export function debounce<T>(fn: (args: T) => void, delay: number) {
      let id: number | undefined;
      return (args: T) => {
        clearTimeout(id);
        id = window.setTimeout(() => fn(args), delay);
      };
    }
    

    参考: Introduction to services and dependency injection - Angular


    백화점


    アプリケーション全体で使用する状態は 백화점に保存します.백화점の実装は 모서리의가 있다では NgRx, 반응では Redux Toolkit + React Redux を使うと良いでしょう.

    状態はイミュータブルかつ、감속기が副作用を持たないように実装しましょう.フォームの状態は後述する 프로그램 진행자内で保持するのをお勧めします.
    アプリケーションによっては 백화점が必要ない場合もあります.将来的に実装方法が変わる場合に備え、後述する 외관等の中間層を作っておくと良いでしょう.

    외관


    외관は 백화점の実装をコンポーネントから隠すための中間層です.

    모서리의가 있다では 서비스では 연결として実装すると良いでしょう.
    export const useTodoListFacade = (arg: { offset?: number; limit?: number }) => {
      const { offset, limit } = arg;
      const history = useHistory();
      const location = useLocation();
      const dispatch = useDispatch<AppDispatch>();
      const todos = useSelector(todosSelector);
      const isFetching = useSelector(isFetchingSelector);
    
      const fetchAll = useCallback((arg: { offset?: number; limit?: number; } = {}) => {
        return dispatch(fetchAllTodos(arg)).then(unwrapResult);
      }, [dispatch]);
    
      const changeOffset = useCallback(
        (offset: number) => {
          const params = new URLSearchParams(location.search);
          params.set('offset', `${offset}`);
          history.push(`/todos?${params}`);
        },
        [history, location.search]
      );
    
      const changeLimit = useCallback(
        (limit: number) => {
          const params = new URLSearchParams(location.search);
          params.set('limit', `${limit}`);
          history.push(`/todos?${params}`);
        },
        [history, location.search]
      );
    
      useEffect(() => {
        fetchAll({ offset, limit });
      }, [offset, limit, fetchAll]);
    
      return {
        isFetching,
        todos,
        changeOffset,
        changeLimit,
        fetchAll,
      } as const;
    };
    
    외관から 서비스を呼び出すこともあります.

    参考: NgRx + Facades: Better State Management


    프로그램 진행자


    표상 성분内のロジックを抽出したものが 프로그램 진행자です.

    프로그램 진행자にはフォームの値やローカルな状態を持たせましょう.
    interface FormValues {
      title: string;
      completed: boolean;
    }
    

    参考: Formik, React Hook Form


    모서리의가 있다では 서비스では 연결として実装すると良いでしょう.
    export const useTodoUpdatePresenter = (arg: { todo: Todo; onUpdate?: (todo: TodoUpdateDto) => void; }) => {
      const { todo, onUpdate } = arg;
      // const [counter, setCounter] = useState(0);
    
      // フォーム初期値
      const initialValues = useMemo(() => {
        return {
          title: todo.title,
          completed: todo.completed;
        } as FormValues;
      }, [todo]);
    
      // バリデーション用
      const validationSchema = useMemo(() => {
        return Yup.object().shape({
          title: Yup.string().required('Title is required.')
        });
      }, []);
    
      const formik = useFormik({
        enableReinitialize: true,
        initialValues,
        validationSchema,
        onSubmit: (values) => {
          const value = {...} as TodoUpdateDto;
          onUpdate && onUpdate(value);
        },
      });
    
      // const increment = useCallback(() => {
      //   setCounter(counter + 1);
      // }, [counter]);
    
      // const decrement = useCallback(() => {
      //   setCounter(counter - 1);
      // }, [counter]);
    
      return {
        ...formik,
        // counter,
        // increment,
        // decrement,
      } as const;
    };
    

    参考: Model-View-Presenter with Angular


    매개 변수


    매개 변수は 라우터から 통합 리소스 포지셔닝 주소パラメータを取得し、페이지 구성 요소に渡します.

    모서리의가 있다では 서비스では 연결として実装すると良いでしょう.
    import { useLocation } from 'react-router-dom';
    
    export const useTodoListParams = () => {
      const location = useLocation();
      const params = new URLSearchParams(location.search);
      const limitParam = params.get('limit') || '10';
      const offsetParam = params.get('offset') || '0';
    
      return {
        limit: +limitParam,
        offset: +offsetParam,
      } as const;
    };
    
    ページネーションの状態や検索条件は 통합 리소스 포지셔닝 주소パラメータに保存しましょう.
    /users?offset=0&limit=10
    

    페이지 구성 요소


    페이지 구성 요소は 매개 변수から取得したデータを 컨테이너 구성 요소に渡します.

    冗長に見えますが컨테이너 구성 요소以下では既に 통합 리소스 포지셔닝 주소パラメータが解決されている」という状況を作り出すことでデバッグやテストを容易にする狙いがあります.
    import { TodoListContainer } from './containers';
    import { useTodoListParams } from './todo-list.params';
    
    export const TodoListPage = memo(() => {
      const { offset, limit } = useTodoListParams();
    
      return <TodoListContainer offset={offset} limit={limit} />;
    });
    
    페이지 구성 요소は使い回さず 통합 리소스 포지셔닝 주소毎に作成しましょう.
    /users/1
    
    interface RouterParams {
      id: number;
    }
    
    export const useTodoDetailParams = () => {
      const { id } = useParams<RouterParams>();
    
      return { id } as const;
    };
    
    import { TodoDetailContainer } from './containers';
    import { useTodoDetailParams } from './todo-detail.params';
    
    export const TodoDetailPage = memo(() => {
      const { id } = useTodoDetailParams();
    
      return <TodoDetailContainer id={id} />;
    });
    

    参考: Angular Web アプリケーションの最新設計手法


    컨테이너 구성 요소


    페이지 구성 요소がパースした値を入力として受け取ります.

    외관経由で 백화점の状態を 표상 성분に渡したり、행동を 파견하다したりします.
    import { TodoUpdate } from '../components';
    
    type Props = {
      id: number;
    };
    
    export const TodoUpdateContainer = (props: Props) => {
      const { id } = props;
      const { update } = useTodoUpdateFacade({ id });
    
      return todo ? <TodoUpdate todo={todo} onUpdate={update} /> : null;
    };
    
    통합 리소스 포지셔닝 주소パラメータの変更は 외관で行いましょう.

    参考: Presentational and Container Components


    표상 성분


    모델を描画するコンポーネントです.

    前述した 프로그램 진행자やユーティリティ、서비스内の静的メソッドを呼ぶ場合がありますが、基本的に표상 성분にはロジックを書かず描画に専念させましょう.
    import { useTodoUpdatePresenter } from './todo-update.presenter';
    
    type Props = {
      todo: Todo;
      onUpdate?: (todo: TodoUpdateDto) => void;
    };
    
    export const TodoUpdate: React.FC<Props> = (props) => {
      const { todo, onUpdate } = props;
    
      const {
        errors,
        values,
        handleChange,
        handleSubmit,
        ...
      } = useTodoUpdatePresenter({ todo, onUpdate });
    
      return <>...</>
    }
    

    参考: Presentational and Container Components


    スタイルガイド


    ほぼ Angular coding style guide と同じです.これは、반응하다に足りないオピニオンを 모서리의가 있다から取り入れることで意思決定コストを下げるという狙いがあります.

    命名規則


    Angular coding style guide に倣い、ファイル名は 카바브 사건に統一しましょう.この命名規則は検索性に優れるため 모서리의가 있다以外のプロジェクトでも有用です.
  • 모델: xxx.model.ts
  • 서비스: xxx.service.ts
  • 연결: xxx.hook.ts
  • 사회자: xxx.presenter.ts
  • 입면: xxx.facade.ts
  • 매개변수: xxx.params.ts
  • 상점
  • 상태: xxx.state.ts
  • 선택기: xxx.selector.ts
  • 감속기: xxx.reducer.ts
  • 액션: xxx.action.ts
  • 케이블 연결 구성 요소: xxx.route.tsx
  • 페이지 구성 요소: xxx.page.tsx
  • 컨테이너 구성 요소: xxx.container.tsx
  • 표상 성분: xxx.component.tsx
  • 테스트: xxx.service.spec.ts, xxx.component.spec.tsx
  • このほか、 class 名やコンポーネント名は PascalCase、関数は 낙타 껍질に統一しましょう.
    コンポーネント名のサフィックスは 반응하다の場合だと冗長なので消してしまって良いかもしれません.
    // Angular
    @Component({...})
    export class TodoListContainerComponent {}
    @Component({...})
    export class TodoListComponent {}
    
    // React
    export const TodoListContainer: React.FC = () => {...}
    export const TodoList: React.FC = () => {...}
    

    参考: Angular, TypeScript Deep Dive


    ディレクトリ構成


    모델, 서비스, 상점, 페이지を起点にドメイン別にディレクトリを分けましょう.ユニットテストはテスト対象となるファイルと同じディレクトリに配置します(コロケーション).アプリケーション全体で共有するコンポーネントやユーティリティは shared 等に入れると良いでしょう.
    - src/
      - models/
        - todo/
          - todo.model.ts
          - index.ts
        - index.ts
      - services/
        - todo/
          - todo.service.ts
          - todo.service.spec.ts
          - index.ts
        - index.ts
      - store/
        - todo/
          - actions/
            - todo.action.ts
            - todo.action.spec.ts
            - index.ts
          - reducers/
            - todo.reducer.ts
            - todo.reducer.spec.ts
            - index.ts
          - selectors/
            - todo.selector.ts
            - todo.selector.spec.ts
            - index.ts
          - states/
            - todo.state.ts
            - index.ts
          - index.ts
        - index.ts
      - pages/
        - todo/
          - todo-create/
            - components/
              - todo-create/
                - todo-create.component.tsx
                - todo-create.component.spec.tsx
                - todo-create.presenter.ts
                - todo-create.presenter.spec.tsx
                - index.ts
              - index.ts
            - containers/
              - todo-create/
                - todo-create.container.tsx
                - todo-create.container.spec.tsx
                - todo-create.facade.ts
                - todo-create.facade.spec.tsx
                - index.ts
              - index.ts
            - todo-create.page.tsx
            - todo-create.page.spec.tsx
            - todo-create.params.ts
            - todo-create.params.spec.tsx
            - index.ts
          - todo-detail/
          - todo-list/
          - todo-update/
          - todo.route.tsx
          - index.ts
        - index.ts
      - shared/
        - components/
        - hooks/
        - utils/
        - index.ts
    

    参考: Angular, React


    その他推奨する規約


    타자 스크립트自体の書き方に関しては TypeScript Deep Dive 等を参考にします.基本は ESLint/TSLintと 더 예쁘다によって自動的に決定されるため混乱は少ないと思われます.
  • 기본 내보내기ではなく、이름 지정 내보내기を使いましょう.
  • 参考: なぜ default export を使うべきではないのか? - LINE ENGINEERING

  • enum ではなく、협회型を使いましょう.
  • 参考: さようなら、TypeScript enum - Kabuku Developers Blog

  • any ではなく、 unknown を使いましょう.
  • その他


    라우팅 구성 요소

    react-router-dom を利用する場合、ルーティング用のコンポーネントを作成する場合があります.모서리의가 있다の xxx-routing.module.ts に相当します.
    import { TodoCreatePage } from './todo-create';
    import { TodoDetailPage } from './todo-detail';
    import { TodoListPage } from './todo-list';
    import { TodoUpdatePage } from './todo-update';
    
    export const TodoRoute: React.FC = () => {
      return (
        <Suspense fallback={<div>loading...</div>}>
          <Switch>
            <Route exact path="/todos" component={TodoListPage} />
            <Route exact path="/todos/new" component={TodoCreatePage} />
            <Route exact path="/todos/:id" component={TodoDetailPage} />
            <Route exact path="/todos/:id/edit" component={TodoUpdatePage} />
          </Switch>
        </Suspense>
      );
    };
    
    バンドルの肥大化を防ぐため、라우팅 구성 요소は必ず動的インポートしましょう.페이지 구성 요소も同様にすると良いでしょう.
    export const TodoPage = React.lazy(() =>
      import('./todo.route').then((m) => ({ default: m.TodoRoute }))
    );
    
    アプリケーション全体のルーティングを管理するコンポーネントに渡します.
    export const App: React.FC = () => {
      return (
        <Suspense fallback={<div>loading...</div>}>
          <Switch>
            <Route path="/todos" component={TodoPage} />
            <Route path="/users" component={...} />
            <Route path="/settings" component={...} />
          </Switch>
        </Suspence>
      );
    };
    

    t 구성json

    any を許可しないようにしましょう.
    "compilerOptions": {
      "strict": true
    }
    

    원자 설계


    非推奨です.アプリケーションの実装に持ち込まないようにしましょう.
    원자 설계はコンポーネント指向を理解するのに有用ですが、コロケーションが崩れたり、粒度に関する不要な議論が増えるなどのデメリットがあります.
    원자 설계のような設計手法が必要になるのは 사용자 인터페이스ライブラリを構築する時と考えられますが、その場合のディレクトリ構成は以下のようにすると良いでしょう.
    - libs/
      - ui-components/
        - button/
          - button.component.tsx
          - button.component.spec.tsx
          - index.ts
        - icon/
        - input/
        - search-input/
        - select/
            - option/
              - option.component.tsx
              - option.component.spec.tsx
              - index.ts
          - select.component.tsx
          - select.component.spec.tsx
          - index.ts
        - index.ts
    
    components/molecules のように 粒度だけでディレクトリを分ける のは絶対にやめましょう.

    参考: AtomicDesign 境界線のひき方


    ビルドツール


    react 응용 프로그램 만들기を使ってビルドした場合、마성이공대학ライセンスに違反するため、튀어나오다して webpack.config.js を修正するか、 Nx 等の他ツールに 移行 するのを強くお勧めします.

    参考: React License Violation


    終わりに


    반응하다を始めた当初、アプリケーションをどのように設計すれば良いか分からず苦労しましたが、過去に携わった 모서리의가 있다アプリケーションでの設計手法や 모서리의가 있다コミュニティを通して得た知識が役に立ちました.
    本記事で紹介したアーキテクチャは 반응하다アプリケーション用に作成しましたが、もちろん 모서리의가 있다アプリケーションにも適用可能です.これから 모서리의가 있다や 반응하다で開発を始める際の参考になれば幸いです.
  • 각도アプリケーションに適用した場合
      https://github.com/puku0x/todo-angular
  • 반응アプリケーションに適用した場合
      https://github.com/puku0x/todo-react
  • Vue.회사 명アプリケーションに適用した場合
      https://github.com/puku0x/todo-vue
  • 좋은 웹페이지 즐겨찾기