👻

コードリーディング - bulletproof-react

2021/12/29に公開約16,500字

https://github.com/alan2207/bulletproof-react

構造

src

src
|
+-- assets フォルダには、画像やフォントなど、すべての静的ファイルを格納できます。
|
+-- components # アプリケーション全体で使用される共有コンポーネント
|
+-- config # すべてのグローバル設定、環境変数などは、ここからエクスポートされ、アプリで使用されます。
|
+-- features # 機能ベースのモジュール
|
+-- hooks # アプリケーション全体で使用される共有フック
|
+-- lib # アプリケーション用にあらかじめ設定されたさまざまなライブラリのファクトリ
|
+-- providers # アプリケーションの全プロバイダー
|
+-- routes # ルート設定
|
+-- stores # グローバルステートストア
|
+-- test # テスト用ユーティリティとモックサーバ
|
+-- types # アプリケーション全体で使用される基本型
|
+-- utils # 共有ユーティリティ関数

src/features

src/features/awesome-feature
|
+-- api # 特定の機能に関連するAPIリクエストの宣言とapiフックをエクスポート
|
+-- assets # assets フォルダには、特定の機能に関するすべての静的ファイルを格納することができます。
|
+-- components # 特定の機能にスコープされたコンポーネント
|
+-- hooks # 特定の機能に対応するフック
|
+-- routes # 特定のフィーチャーページのルートコンポーネント
|
+-- store # 特定の機能に関する状態ストア
|
+-- types # TS特定機能ドメイン用タイプスクリプトタイプ
|
+-- utils # 特定の機能に関するユーティリティ関数
|
+-- index.ts # 機能のエントリポイントであり、与えられた機能のパブリックAPIとして機能し、機能外で使用されるべきすべてのものをエクスポートする。

最も簡単で保守しやすい方法でアプリケーションを拡張するために、コードの大部分を features フォルダ内に保持し、その中にさまざまな機能ベースのものを含めるようにします。features フォルダには、その機能ごとにドメイン固有のコードを記述します。こうすることで、機能をその機能に限定することができ、その宣言が共有のものと混ざらないようにすることができます。これは、多くのファイルを含むフラットなフォルダー構造よりもはるかに保守しやすくなります。

機能フォルダは、他の機能を含むこともできますし(親機能内でのみ使用する場合)、分離しておくことも可能です。機能のすべては、その機能の公開APIとして動作するindex.tsファイルからエクスポートされる必要があります。他の機能からのインポートのみ使用する必要があります。
次のように使用するのであって、
import {AwesomeComponent} from "@/features/awesome-feature"
次のように使用してはいけません
import {AwesomeComponent} from "@/features/awesome-feature/components/AwesomeComponent

https://github.com/alan2207/bulletproof-react/blob/master/docs/project-structure.md

実装例
https://github.com/alan2207/bulletproof-react/tree/master/src/features/users

パフォーマンス

コード分割

https://github.com/alan2207/bulletproof-react/blob/master/src/routes/protected.tsx

import { Suspense } from 'react';
import { Navigate, Outlet } from 'react-router-dom';

import { Spinner } from '@/components/Elements';
import { MainLayout } from '@/components/Layout';
import { lazyImport } from '@/utils/lazyImport';

const { DiscussionsRoutes } = lazyImport(
  () => import('@/features/discussions'),
  'DiscussionsRoutes'
);
const { Dashboard } = lazyImport(() => import('@/features/misc'), 'Dashboard');
const { Profile } = lazyImport(() => import('@/features/users'), 'Profile');
const { Users } = lazyImport(() => import('@/features/users'), 'Users');

const App = () => {
  return (
    <MainLayout>
      <Suspense
        fallback={
          <div className="h-full w-full flex items-center justify-center">
            <Spinner size="xl" />
          </div>
        }
      >
        <Outlet />
      </Suspense>
    </MainLayout>
  );
};

export const protectedRoutes = [
  {
    path: '/app',
    element: <App />,
    children: [
      { path: '/discussions/*', element: <DiscussionsRoutes /> },
      { path: '/users', element: <Users /> },
      { path: '/profile', element: <Profile /> },
      { path: '/', element: <Dashboard /> },
      { path: '*', element: <Navigate to="." /> },
    ],
  },
];

https://github.com/alan2207/bulletproof-react/blob/master/src/utils/lazyImport.ts

import * as React from 'react';

// named imports for React.lazy: https://github.com/facebook/react/issues/14603#issuecomment-726551598
export function lazyImport<
  T extends React.ComponentType<any>,
  I extends { [K2 in K]: T },
  K extends keyof I
>(factory: () => Promise<I>, name: K): I {
  return Object.create({
    [name]: React.lazy(() => factory().then((module) => ({ default: module[name] }))),
  });
}

// Usage
// const { Home } = lazyImport(() => import("./Home"), "Home");

このソースの理解はhttps://ja.reactjs.org/docs/code-splitting.htmlを読むと大分理解できると思います。

名前付きエクスポート
React.lazy は現在デフォルトエクスポートのみサポートしています。インポートしたいモジュールが名前付きエクスポートを使用している場合、それをデフォルトとして再エクスポートする中間モジュールを作成できます。これにより、tree shaking が機能し未使用のコンポーネントを取り込まず済むようにできます。

名前付きインポートの役割をlazyImport関数が担っているのではないかと思います。

APIレイヤー

Use a single instance of the API client
アプリケーションがRESTful APIとGraphQL APIのどちらを消費するかに関係なく、事前に設定され、アプリケーション全体で再利用されるAPIクライアントのsingleインスタンスを用意します。例えば、あらかじめ設定されたsingleのAPIクライアント(axios / graphql-request / apollo-client)インスタンスを持っています。

実装例
https://github.com/alan2207/bulletproof-react/blob/master/src/lib/axios.ts

Define and export request declarations
APIリクエストを勝手に宣言するのではなく、別途定義してエクスポートさせる。RESTfulなAPIであれば、宣言はエンドポイントを呼び出すフェッチャー関数になる。一方、GraphQL APIのリクエストは、react-query、apollo-client、urqlなどのデータ取得ライブラリで消費可能なクエリとミューテーションを介して宣言される。このため、どのエンドポイントが定義され、アプリケーションで利用可能かを追跡することが容易になります。また、データの型安全性を高めるために、レスポンスをタイプ分けして、さらに推論することもできる。また、そこから対応するAPIフックを定義してエクスポートすることもできる。

実装例
https://github.com/alan2207/bulletproof-react/blob/master/src/features/discussions/api/getDiscussions.ts

使用例
https://github.com/alan2207/bulletproof-react/blob/master/src/features/discussions/routes/Discussion.tsx#L14

フォームState

抽象化されたFormコンポーネントと、ライブラリの機能をラップし、アプリケーションのニーズに適応させたすべての入力フィールドコンポーネントを作成します。このコンポーネントは、アプリケーション全体で再利用することができます。

このプロジェクトではreact-hook-formを使用した例

フォームの実装例
https://github.com/alan2207/bulletproof-react/blob/master/src/components/Form/Form.tsx

import { zodResolver } from '@hookform/resolvers/zod';
import clsx from 'clsx';
import * as React from 'react';
import { useForm, UseFormReturn, SubmitHandler, UseFormProps } from 'react-hook-form';
import { ZodType, ZodTypeDef } from 'zod';

type FormProps<TFormValues, Schema> = {
  className?: string;
  onSubmit: SubmitHandler<TFormValues>;
  children: (methods: UseFormReturn<TFormValues>) => React.ReactNode;
  options?: UseFormProps<TFormValues>;
  id?: string;
  schema?: Schema;
};

// TFormValuesには{title: string, body: string} のような形でくる
// Schemaにはtypeof schemaの値がくる。
// const schema = z.object({
//   title: z.string().min(1, 'Required'),
//   body: z.string().min(1, 'Required'),
// });
// z.objectとはHTMLObjectElementとのことだが、そのtypeofの値が ZodType<unknown, ZodTypeDef, unknown> = ZodType<unknown, ZodTypeDef, unknown>になるとのことだがよくわからない。
export const Form = <
  TFormValues extends Record<string, unknown> = Record<string, unknown>,
  Schema extends ZodType<unknown, ZodTypeDef, unknown> = ZodType<unknown, ZodTypeDef, unknown>
>({
  onSubmit,
  children,
  className,
  options,
  id,
  schema,
}: FormProps<TFormValues, Schema>) => {
  const methods = useForm<TFormValues>({ ...options, resolver: schema && zodResolver(schema) });
  return (
    <form
      className={clsx('space-y-6', className)}
      onSubmit={methods.handleSubmit(onSubmit)}
      id={id}
    >
      {children(methods)}
    </form>
  );
};

インプットフィールドの実装例
https://github.com/alan2207/bulletproof-react/blob/master/src/components/Form/InputField.tsx

使用例
https://github.com/alan2207/bulletproof-react/blob/master/src/features/discussions/components/CreateDiscussion.tsx

import { PlusIcon } from '@heroicons/react/outline';
import * as z from 'zod';

import { Button } from '@/components/Elements';
import { Form, FormDrawer, InputField, TextAreaField } from '@/components/Form';
import { Authorization, ROLES } from '@/lib/authorization';

import { CreateDiscussionDTO, useCreateDiscussion } from '../api/createDiscussion';
// type CreateDiscussionDTO = {
//   data: {
//     title: string;
//     body: string;
//   };
// };

const schema = z.object({
  title: z.string().min(1, 'Required'),
  body: z.string().min(1, 'Required'),
});

export const CreateDiscussion = () => {
  const createDiscussionMutation = useCreateDiscussion();

  return (
    <Authorization allowedRoles={[ROLES.ADMIN]}>
...
        <Form<CreateDiscussionDTO['data'], typeof schema>
          id="create-discussion"
          onSubmit={async (values) => {
            await createDiscussionMutation.mutateAsync({ data: values });
          }}
          schema={schema}
        >
          {({ register, formState }) => (
            <>
              <InputField
                label="Title"
                error={formState.errors['title']}
                registration={register('title')}
              />

              <TextAreaField
                label="Body"
                error={formState.errors['body']}
                registration={register('body')}
              />
            </>
          )}
        </Form>
      </FormDrawer>
    </Authorization>
  );
};

@NOTE: よく分からなかった。自分メモ

children: (methods: UseFormReturn<TFormValues>) => React.ReactNode;
Schema extends ZodType<unknown, ZodTypeDef, unknown> = ZodType<unknown, ZodTypeDef, unknown>

コンポーネント

サードパーティのコンポーネントも、アプリケーションのニーズに合わせてラップしておくとよいでしょう。将来、アプリケーションの機能に影響を与えることなく、根本的な変更を加えることが容易になるかもしれません。

https://github.com/alan2207/bulletproof-react/blob/master/src/components/Elements/Link/Link.tsx

import clsx from 'clsx';
import { Link as RouterLink, LinkProps } from 'react-router-dom';

export const Link = ({ className, children, ...props }: LinkProps) => {
  return (
    <RouterLink className={clsx('text-indigo-600 hover:text-indigo-900', className)} {...props}>
      {children}
    </RouterLink>
  );
};

大規模なプロジェクトでは、すべての共有コンポーネントの周りに抽象化されたものを構築するのがよいでしょう。そうすることで、アプリケーションに一貫性が生まれ、メンテナンスが容易になります。間違った抽象化を避けるために、コンポーネントを作成する前に繰り返しを特定します

このプロジェクトではButton, Dialog, Drawer, Link, Markdown Preview, Spinner, Tableを共有コンポーネントとして実装している。

ボタンの実装例

https://github.com/alan2207/bulletproof-react/blob/master/src/components/Elements/Button/Button.tsx

...
// buttonタグがもともと使用できるプロパティを引き継ぎながら、独自のプロパティを追加して型を定義。
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: keyof typeof variants;
  size?: keyof typeof sizes;
  isLoading?: boolean;
} & IconProps;

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      type = 'button',
      className = '',
      variant = 'primary',
      size = 'md',
      isLoading = false,
      startIcon,
      endIcon,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        type={type}
        className={clsx(
          'flex justify-center items-center border border-gray-300 disabled:opacity-70 disabled:cursor-not-allowed rounded-md shadow-sm font-medium focus:outline-none',
          variants[variant],
          sizes[size],
          className
        )}
        {...props}
      >
        {isLoading && <Spinner size="sm" className="text-current" />}
        {!isLoading && startIcon}
        <span className="mx-2">{props.children}</span> {!isLoading && endIcon}
      </button>
    );
  }
);

Button.displayName = 'Button';

React.forwardRefを使用することにより、Buttonを使用する側からrefを渡してdomノードにアクセスすることができる。

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="FancyButton">
    {props.children}
  </button>
));


// You can now get a ref directly to the DOM button:
const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

引用: https://ja.reactjs.org/docs/forwarding-refs.html

このプロジェクトのrefの使用例

フォーカスを当てるために使用

// src/components/Elements/ConfirmationDialog/ConfirmationDialog.tsx
...
  const cancelButtonRef = React.useRef(null);
...
      <Dialog isOpen={isOpen} onClose={close} initialFocus={cancelButtonRef}>
...
            <Button
              type="button"
              variant="inverse"
              className="w-full inline-flex justify-center rounded-md border focus:ring-1 focus:ring-offset-1 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
              onClick={close}
              ref={cancelButtonRef}
            >
              {cancelButtonText}
            </Button>
...


// src/components/Elements/Dialog/Dialog.tsx
export const Dialog = ({ isOpen, onClose, children, initialFocus }: DialogProps) => {
  return (
    <>
      <Transition.Root show={isOpen} as={React.Fragment}>
        <UIDialog
          as="div"
          static
          className="fixed z-10 inset-0 overflow-y-auto"
          open={isOpen}
          onClose={onClose}
          initialFocus={initialFocus} // フォーカス

コードスタイル

@TODO まだ読んでいない。

Clean Code

https://github.com/ryanmcdermott/clean-code-javascript

https://github.com/mitsuruog/clean-code-javascript/

Naming

https://github.com/kettanaito/naming-cheatsheet

セキュリティ

sanitize

import createDOMPurify from 'dompurify';
import marked from 'marked';

const DOMPurify = createDOMPurify(window);

export type MDPreviewProps = {
  value: string;
};

export const MDPreview = ({ value = '' }: MDPreviewProps) => {

  console.log(value) // 1
  console.log(marked(value)) // 2
  console.log(DOMPurify.sanitize(marked(value))) // 3

  return (
    <div
      className="p-2 w-full prose prose-indigo"
      dangerouslySetInnerHTML={{
        __html: DOMPurify.sanitize(marked(value)),
      }}
    />
  );
};

1の出力

# ディスカッション

## サブタイトル
* item 1
* item 2
<script>alert('discussions')</script>

2の出力

<h1 id="ディスカッション">ディスカッション</h1>
<h2 id="サブタイトル">サブタイトル</h2>
<ul>
<li>item 1</li>
<li>item 2</li>
</ul>
<script>alert('discussions')</script>

3の出力

<h1 id="ディスカッション">ディスカッション</h1>
<h2 id="サブタイトル">サブタイトル</h2>
<ul>
<li>item 1</li>
<li>item 2</li>
</ul>

認証

このプロジェクトの例では、以下のモジュールを利用

react-query
react-query-auth

以下の関数を実装してあげることで、認証機能を実現

  • loadUser: ユーザー取得処理
  • loginFn: ログイン処理
  • registerFn: ユーザー登録処理
  • logoutFn: ログアウト処理

実装例
https://github.com/alan2207/bulletproof-react/blob/master/src/lib/auth.tsx

使用例
https://github.com/alan2207/bulletproof-react/blob/master/src/routes/index.tsx

export const AppRoutes = () => {
  const auth = useAuth();

  const commonRoutes = [{ path: '/', element: <Landing /> }];

  const routes = auth.user ? protectedRoutes : publicRoutes;

  const element = useRoutes([...routes, ...commonRoutes]);

  return <>{element}</>;
};

認可

実装例
https://github.com/alan2207/bulletproof-react/blob/master/src/lib/authorization.tsx

https://github.com/alan2207/bulletproof-react/blob/master/src/features/discussions/components/CreateDiscussion.tsx

https://github.com/alan2207/bulletproof-react/blob/master/src/features/comments/components/CommentsList.tsx#L49

Role based access control (RBAC)

リソースに対して許可されたロールを定義し、ユーザーがリソースにアクセスするために許可されたロールを持っているかどうかをチェックします。良い例は、USERとADMINのロールです。ユーザーにはあるものを制限し、管理者にはアクセスを許可したい場合です。

使用例

<Authorization allowedRoles={[ROLES.ADMIN]}>
...
</Authorization>

Permission based access control (PBAC)

RBACだけでは不十分な場合もある。いくつかの操作は、リソースの所有者のみが許可されるべきです。例えば、ユーザーのコメント。コメントの作成者だけがコメントを削除できるようにすべきです。そのため、より柔軟性の高いPBACを使用するとよいでしょう。

RBACの保護には、許可されたロールをRBACコンポーネントに渡すことで使用できます。一方、より厳密な保護が必要な場合は、ポリシーチェックを渡すことができます。

使用例

<Authorization policyCheck={POLICIES['comment:delete'](user as User, comment)}>
...
</Authorization>

Discussion

ログインするとコメントできます