[react]-レッスン17


userRootes hookを使用してルーティングを構成する


注意:https://reactrouter.com/docs/en/v6/api#useroutes
元のルータ構成のコードを、以下のようにユーザルータを通過するコードに変更します.App.js
import { useRoutes, Navigate } from 'react-router-dom';
import { Layout, Home, SignIn, SignUp, PageNotFound } from 'pages';

export default function App() {
  const routesElement = useRoutes([
    {
      element: <Layout offset={120} />,
      children: [
        { path: '/', element: <Home /> },
        { path: 'signin', element: <SignIn id='signin' /> },
        { path: 'signup', element: <SignUp id='signup' /> },
        { path: 'page-not-found', element: <PageNotFound /> },
        { path: '*', element: <Navigate to='page-not-found' replace /> },
      ],
    },
  ]);
  return routesElement;
}

useLayoutEffect hookを使用してルーティングを構成する


タイトルを設定すると、タイトル自体が固定設定されているため、画面の通常のストリームから離れます.このため、スクリーンをレンダリングするときにuseLayoutEffectを使用してタイトルの高さを計算する必要があります.
useLayoutEffectはクラスコンポーネントのgetSnapshotBeforeUpdate()の類似時間(更新前)に実行されます.Layout.js
...

export default function Layout({ offset }) {
  const headerRef = useRef(null);
  let [headerHeight, setHeaderHeight] = useState(0);

  useLayoutEffect(() => {
    let { height } = headerRef.current.getBoundingClientRect();
    setHeaderHeight(`${height + offset}px`);
  }, [offset]);

  return (
    <Container>
      <Header ref={headerRef} blur />
      <Wrapper
        as="main"
        css={`
          min-height: 100vh;
          padding-top: ${headerHeight};
        `}
      >
        <Outlet />
      </Wrapper>
    </Container>
  );
}
...

react-onload-iconsの使用


注意:https://www.npmjs.com/package/react-loading-icons

Form素子アレイの利用

import 'styled-components/macro';
import { forwardRef, memo } from 'react';
import { string, bool, object, oneOf } from 'prop-types';
import { A11yHidden } from 'components';
import {
  Form as StyledForm,
  Container,
  Headline,
  Control,
  Label,
  Input,
  IconSuccess,
  IconError,
  ErrorMessage,
  Button,
  Info,
} from './Form.styled';

/* -------------------------------------------------------------------------- */
/* Form                                                                       */
/* -------------------------------------------------------------------------- */

export const Form = memo(
  forwardRef(function Form({ css, ...resetProps }, ref) {
    return <StyledForm ref={ref} css={css} {...resetProps} />;
  })
);

/* -------------------------------------------------------------------------- */
/* FormContainer                                                              */
/* -------------------------------------------------------------------------- */
Form.Container = memo(function FormContainer(props) {
  return <Container {...props} />;
});

/* -------------------------------------------------------------------------- */
/* FormHeadline                                                              */
/* -------------------------------------------------------------------------- */
Form.Headline = memo(function FormHeadline(props) {
  return <Headline {...props} />;
});

/* -------------------------------------------------------------------------- */
/* FormInput                                                                  */
/* -------------------------------------------------------------------------- */

Form.Input = memo(
  forwardRef(function FormInput(
    {
      id,
      label,
      type,
      invisibleLabel,
      error,
      success,
      children,
      controlProps,
      ...restProps
    },
    ref
  ) {
    let descId = `${id}__desc`;
    return (
      <Control {...controlProps}>
        {invisibleLabel ? (
          <A11yHidden as="label" htmlFor={id}>
            {label}
          </A11yHidden>
        ) : (
          <Label htmlFor={id}>{label}</Label>
        )}
        <Input
          ref={ref}
          id={id}
          type={type}
          placeholder={children}
          error={error}
          success={success}
          aria-describedby={descId}
          {...restProps}
        />
        {success && <IconSuccess />}
        {error && <IconError />}
        <ErrorMessage id={descId} role="alert" aria-live="assertive">
          {error}
        </ErrorMessage>
      </Control>
    );
  })
);

Form.Input.defaultProps = {
  type: 'text',
  invisibleLabel: false,
  error: '',
  success: false,
};

Form.Input.propTypes = {
  id: string.isRequired,
  label: string.isRequired,
  type: oneOf(['text', 'email', 'password']),
  invisibleLabel: bool,
  error: string,
  success: bool,
  children: string,
  controlProps: object,
};

/* -------------------------------------------------------------------------- */
/* FormButton                                                                 */
/* -------------------------------------------------------------------------- */

Form.Button = memo(
  forwardRef(function FormButton({ submit, reset, css, ...restProps }, ref) {
    let buttonType = 'button';
    if (submit) buttonType = 'submit';
    if (reset) buttonType = 'reset';

    return <Button ref={ref} type={buttonType} css={css} {...restProps} />;
  })
);

Form.Button.defaultProps = {
  submit: false,
  reset: false,
  css: null,
};

Form.Button.propTypes = {
  submit: bool,
  reset: bool,
  css: string,
};

/* -------------------------------------------------------------------------- */
/* FormInfo                                                                   */
/* -------------------------------------------------------------------------- */

Form.Info = memo(
  forwardRef(function FormInfo(props, ref) {
    return <Info ref={ref} {...props} />;
  })
);
2つの高次素子を用いて素子アレイを構成する:memoおよびforwardRefuserMemoではなくmemoを使用して構成部品を構成する

ページごとにコードを印刷


注意:https://reactjs.org/docs/code-splitting.html#code-splitting
React.Suspenceと一緒にlazyを使う
不活性=>非同期
Suspenceはfallbackを使用する必要があります
import { lazy, Suspense } from 'react';
import { useRoutes, Navigate } from 'react-router-dom';
import { Loading } from 'components';

// Sync. Loaded Components
// import { Layout, Home, SignIn, SignUp, PageNotFound } from 'pages';
// Lazy Loaded Components (Async.)
const Layout = lazy(() => import('./pages/Layout/Layout'));
const Home = lazy(() => import('./pages/Home/Home'));
const SignIn = lazy(() => import('./pages/SignIn/SignIn'));
const SignUp = lazy(() => import('./pages/SignUp/SignUp'));
const PageNotFound = lazy(() => import('./pages/PageNotFound/PageNotFound'));

// App
export default function App() {
  const routesElement = useRoutes([
    {
      element: <Layout offset={120} />,
      children: [
        { path: '/', element: <Home /> },
        { path: 'signin', element: <SignIn id='signin' /> },
        { path: 'signup', element: <SignUp id='signup' /> },
        { path: 'page-not-found', element: <PageNotFound /> },
        { path: '*', element: <Navigate to='page-not-found' replace /> },
      ],
    },
  ]);

  return (
    <Suspense fallback={<Loading message='페이지가 로딩 중입니다...' />}>{routesElement}</Suspense>
  );
}
すべてのページをバンドルするのではなく、必要に応じてページにjsをロードできます(Networkタブ)
react routerのlazy loading
現在のreactive lazyは、正常に動作するにはdefaultとしてページをエクスポートする必要があります.したがって,ジャンプを行うためには,この点を考慮してコードを記述する必要がある.
注意:https://reactrouter.com/docs/en/v6/examples/lazy-loading

検証フォーム

validator.js
/* eslint-disable no-useless-escape */

/* -------------------------------------------------------------------------- */
// 아이디 체크 유틸리티
// ▸ 5 ~ 20자 — 영문, 숫자 조합
/* -------------------------------------------------------------------------- */

export const isId = (value, { min = 4, max = 19 } = {}) => {
  const regExp = new RegExp(`^[a-z]+[a-z0-9]{${min},${max}}$`, 'g');
  return regExp.test(value);
};

/* -------------------------------------------------------------------------- */
// 이메일 체크 유틸리티
/* -------------------------------------------------------------------------- */

export const isEmail = value => {
  const regExp =
    /^[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_\.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i;
  return regExp.test(value);
};

/* -------------------------------------------------------------------------- */
// 패스워드 체크 유틸리티
// ▸ [normal 모드] 6 ~ 16자 — 영문, 숫자 조합
// ▸ [strong 모드] 6 ~ 16자 — 영문, 숫자, 특수문자 최소 한가지 조합
/* -------------------------------------------------------------------------- */

export const isPassword = (value, { min = 6, max = 16, isStrong = false } = {}) => {
  let regExp = null;

  if (!isStrong) {
    regExp = new RegExp(`^(?=.*\\d)(?=.*[a-zA-Z])[0-9a-zA-Z]{${min},${max}}$`);
  } else {
    regExp = new RegExp(
      `^(?=.*[a-zA-z])(?=.*[0-9])(?=.*[$\`~!@$!%*#^?&\\(\\)\-_=+]).{${min},${max}}$`
    );
  }

  return regExp.test(value);
};

/* -------------------------------------------------------------------------- */
// 폰 넘버 체크 유틸리티
// ▸ [normal 모드] 010-9814-1461
// ▸ [withoutHyphen 활성화] 010-9814-1461 or 01098141461
/* -------------------------------------------------------------------------- */

export const isPhoneNumber = (value, withoutHyphen = false) => {
  value = value.toString();

  if (withoutHyphen && value.length < 12) {
    value = value.split('');
    let firstNumber = value.splice(0, 3).join('');
    let lastNumber = value.splice(value.length - 4).join('');
    let middleNumber = value.join('');
    value = `${firstNumber}-${middleNumber}-${lastNumber}`;
  }

  const regExp = /^01(?:0|1|[6-9])-(?:\d{3}|\d{4})-\d{4}$/;

  return regExp.test(value);
};

/* -------------------------------------------------------------------------- */
// 체크 유틸리티
// ▸ 한글, 영문, 대문자, 소문자, 숫자, 공백, 특수문자 (_-/,.)
/* -------------------------------------------------------------------------- */

export const isCheck = (value, checkType = isCheck.types[0]) => {
  const { types } = isCheck;
  let regExp = null;

  switch (checkType) {
    // '영,대소문자,문자사이공백'
    default:
    case types[0]:
      regExp = /^[a-zA-Z][a-zA-Z ]*$/;
      break;
    // '영,대소문자,숫자,문자사이공백,특수문자(-_/,.)'
    case types[1]:
      regExp = /^[a-zA-Z0-9-_/,.][a-zA-Z0-9-_/,.]*$/;
      break;
    // '한영'
    case types[2]:
      regExp = /^[a-zA-Zㄱ-힣][a-zA-Zㄱ-힣]*$/;
      break;
    // '한'
    case types[3]:
      regExp = /[ㄱ-힣]/;
  }

  return regExp.test(value);
};

isCheck.types = [
  '영,대소문자,문자사이공백',
  '영,대소문자,숫자,문자사이공백,특수문자(-_/,.)',
  '한영',
  '한',
];

/* -------------------------------------------------------------------------- */
// validator
/* -------------------------------------------------------------------------- */

const validator = {
  isId,
  isEmail,
  isPassword,
  isPhoneNumber,
  isCheck,
};

export default validator;
SignUp.js
import 'styled-components/macro';
import { useRef, useState, useCallback } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link } from 'react-router-dom';
import { string } from 'prop-types';

import { isInputed, setDocumentTitle, isCheck, isEmail, isPassword } from 'utils';
import { Form } from 'components';
import { signUp } from 'services';

/* -------------------------------------------------------------------------- */
/* SignUp                                                                     */
/* -------------------------------------------------------------------------- */
import 'styled-components/macro';
import { useRef, useState, useCallback } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link, useNavigate } from 'react-router-dom';
import { string } from 'prop-types';

import { isInputed, setDocumentTitle, isCheck, isEmail, isPassword } from 'utils';
import { Form } from 'components';
import { signUp } from 'services';

/* -------------------------------------------------------------------------- */
/* SignUp                                                                     */
/* -------------------------------------------------------------------------- */
export default function SignUp({ id, ...restProps }) {
  const formRef = useRef(null);
  const navigate = useNavigate();

  const [name, setName] = useState('');
  const [nameError, setNameError] = useState('');
  const [nameSuccess, setNameSuccess] = useState(false);

  const [email, setEmail] = useState('');
  const [emailError, setEmailError] = useState('');
  const [emailSuccess, setEmailSuccess] = useState(false);

  const [password, setPassword] = useState('');
  const [passwordError, setPassWordError] = useState('');
  const [passwordSuccess, setPassWordSuccess] = useState(false);

  const [passwordConfirm, setPasswordConfirm] = useState('');
  const [passwordConfirmError, setPasswordConfirmError] = useState('');
  const [passwordConfirmSuccess, setPasswordConfirmSuccess] = useState(false);

  const handleChange = useCallback(
    e => {
      const { name, value } = e.target;

      switch (name) {
        case 'name':
          // 조건: 사용자 입력 값이 한글인가요?
          //      입력 값의 갯수는 2개 이상인가요?
          let isValidName = isCheck(value, isCheck.types[3]);
          let isValidLength = value.length > 1;
          if (!isValidName) {
            setNameError('이름은 한글만 입력이 가능');
            setNameSuccess(false);
          } else {
            setNameError('');
            setNameSuccess(true);
          }

          if (!isValidLength) {
            setNameError('2글자 이상 입력해야 한다.');
            setNameSuccess(false);
          }

          setName(value);

          break;
        case 'email':
          if (isEmail(value)) {
            setEmailError('');
            setEmailSuccess(true);
          } else {
            setEmailError('이메일 형식으로 입력하세요.');
            setEmailSuccess(false);
          }
          setEmail(value);
          break;
        case 'password':
          if (isPassword(value, { min: 8 })) {
            setPassWordError('');
            setPassWordSuccess(true);
          } else {
            setPassWordError('비밀번호는 영문, 숫자 조합 8자리 이상 입력해야 합니다.');
            setPassWordSuccess(false);
          }

          setPassword(value);
          break;
        case 'passwordConfirm':
          if (password === value) {
            setPasswordConfirmError('');
            setPasswordConfirmSuccess(true);
          } else {
            setPasswordConfirmError('입력한 비밀번호와 동일하지 않습니다.');
            setPasswordConfirmSuccess(false);
          }
          setPasswordConfirm(value);
          break;
        default:
      }
    },
    [password]
  );

  const handleSubmit = useCallback(
    e => {
      e.preventDefault();

      console.log(formRef.current); // <form />

      // DOM form 요소 <- FormData()
      const formData = new FormData(formRef.current);

      // 서버에 전송할 객체
      const requestData = {};

      // 폼의 각 컨트롤이 사용자로부터 입력 받은 값을 순환해서 수집
      for (const [key, value] of formData.entries()) {
        if (key !== 'passwordConfirm') {
          requestData[key] = value;
        }
      }

      console.log(requestData);

      // [서비스] 서비스를 통해 서버에 로그인 요청, 응답
      signUp(requestData)
        .then(response => {
          navigate('/');
          // [라우팅] 홈페이지로 이동 또는 로그인 한 사용자 페이지로 이동
          // 프로그래밍 방식의 내비게이팅
        })
        .catch(({ message }) => console.error(message));
    },
    [navigate]
  );

  const handleReset = useCallback(e => {
    setName('');
    setEmail('');
    setPassword('');
    setPasswordConfirm('');
  }, []);

  let isAllInputed =
    isInputed(name) && isInputed(email) && isInputed(password) && password === passwordConfirm;

  return (
    <>
      <Helmet>
        <title>{setDocumentTitle('회원가입')}</title>
      </Helmet>

      <Form.Container>
        <Form.Headline id={id}>회원가입 폼</Form.Headline>
        <Form ref={formRef} aria-labelledby={id} onSubmit={handleSubmit}>
          <Form.Input
            id='userName'
            label='이름'
            autoComplete='user-name'
            name='name'
            value={name}
            onChange={handleChange}
            error={nameError}
            success={nameSuccess}
          >
            이름을 작성합니다. (영문, 숫자 조합 6자리 이상)
          </Form.Input>
          <Form.Input
            type='email'
            id='userMail'
            label='이메일'
            autoComplete='user-email'
            name='email'
            value={email}
            onChange={handleChange}
            error={emailError}
            success={emailSuccess}
          >
            이메일 주소를 올바르게 입력하세요.
          </Form.Input>
          <Form.Input
            type='password'
            id='userPass'
            label='패스워드'
            autoComplete='current-password'
            name='password'
            value={password}
            onChange={handleChange}
            error={passwordError}
            success={passwordSuccess}
          >
            비밀번호를 입력하세요. (영문, 숫자 조합 8자리 이상)
          </Form.Input>
          <Form.Input
            type='password'
            id='userPassConfirm'
            label='패스워드 확인'
            autoComplete='current-confirm-password'
            name='passwordConfirm'
            value={passwordConfirm}
            onChange={handleChange}
            error={passwordConfirmError}
            success={passwordConfirmSuccess}
          >
            입력한 비밀번호와 동일한 번호를 다시 입력하세요.
          </Form.Input>
          <Form.Button callToAction type='submit' disabled={!isAllInputed}>
            회원가입
          </Form.Button>
          <Form.Button type='reset' onClick={handleReset}>
            초기화
          </Form.Button>
        </Form>

        <Form.Info>
          이미 회원가입 했다면? <Link to='/signin'>로그인</Link> 페이지로 이동해 로그인하세요.
        </Form.Info>
      </Form.Container>
    </>
  );
}

SignUp.propTypes = {
  id: string.isRequired,
};
USStateを使用してステータス管理を行い、最終的にreact routerのuseNavigateで会員に入った後、ページを移動することができます.
validatorというutilを単独で作成することなく、ライブラリを使用する方法があります.
⬇️
注意:https://www.npmjs.com/package/validator

Context API


ReactアプリケーションでContext APIを使用してステータスを管理する方法を学習します.

残念なことに,props,callbackを用いた素子状態共有法は簡単なスキームから逸脱すると現実的ではない.アプリケーションは、多くのコンポーネントがステータスになっているか、他のコンポーネントと共有(同期)して対話する必要があります.
しかし、コンポーネントがアプリケーションを単独で所有している状態のため、管理上の困難が生じる.自分の状態をサブエレメントに転送→転送→転送し,サブエレメントからコールバック←コールバック←し,複雑で困難にする.
素子間の関係が複雑になるとprops,callbackは管理しにくいという問題が発生する.

ステータス共有のトラブルシューティング方法

  • コンテキスト
  • を利用する.

    Reactは、ネストされたコンポーネントのデータ共有の問題を解決するためのコンテキストを提供します.
    ただし、Contextは構成部品の再利用が困難になるため、必要に応じてのみ使用することが望ましい.
  • 状態管理ライブラリ
  • を利用する.

    ステータス管理システムは、ステータス管理の複雑な問題を解決するために設計されています.ステータスは、各コンポーネントが所有する基礎的な問題解決策であり、すべてのステータスを1つのリポジトリで管理できます.
    この方法は多くの問題を解決することができる.これは、複雑なコンポーネントレイヤを上下にナビゲートすることなく、ステータスを共有できるためです.典型的なステータス管理ライブラリ.  ReduxMobxVuexXStateなどがあります.

    認証用のコンテキストの作成

    contexts/auth.js
    import { createContext, Component, useState, useMemo, useContext } from 'react';
    import { Navigate } from 'react-router-dom';
    
    // 1. 컨텍스트 객체 생성
    const AuthContext = createContext();
    
    // console.log(AuthContext);
    
    // 2. 컨텍스트 공급자를 사용해 value를 children(컴포넌트 트리)에 공급
    export const AuthProvider = props => {
      const [authUser, setAuthUser] = useState(null);
    
      // 상태가 변할 때만 렌더링(불필요한 렌더링 방지)
      const contextValue = useMemo(
        () => ({
          authUser,
          setAuthUser,
        }),
        [authUser]
      );
      return <AuthContext.Provider value={contextValue} {...props} />;
    };
    
    // 3.0 Context.Consumer - render props 패턴
    
    // 3.1 Class 컴포넌트 contextType 클래스 멤버 - 고차 컴포넌트(HOC) 활용
    
    // 고차함수(함수컴포넌트) -> 향상된 클래스 컴포넌트
    // React.forwardRef((props, ref) => {})
    // React.memo(() => {})
    // export const withAuth = FuncComp => {
    //   // 함수 컴포넌트 -> 클래스 컴포넌트(리턴, contextType 설정)
    
    //   class AuthHOC extends Component {
    //     static contextType = AuthContext;
    
    //     redner() {
    //       return <FuncComp context={this.context} {...this.props} />;
    //     }
    //   }
    // };
    
    // 위 방법을 사용하는 대신, 커스텀 훅 사용
    // 3.2 컨텍스트 value를 반환하는 커스텀 훅(함수)을 작성
    export const useAuth = () => {
      // 빌트인 훅을 사용하는 특별한 함수 => 커스텀 훅(함수)
      // 함수 컴포넌트 또는 다른 빌트인/커스텀 훅 안에서만 사용 가능
      const contextValue = useContext(AuthContext);
    
      if (!contextValue) {
        throw new Error('useAuth 훅은 Auth Context 내에서만 호출되어야 합니다.');
      }
      return contextValue;
    };
    
    export const useAuthUser = () => useAuth().authUser;
    export const useSetAuthUser = () => useAuth().setAuthUser;
    
    // 4. 인증 라우팅을 보호하는 래퍼 컴포넌트
    export const RequireAuth = ({ children }) => {
      // 인증 사용자입니까?
      const authUser = useAuthUser();
      if (!authUser) {
        // 아니오
        // 로그인 페이지로 이동
        return <Navigate to='/signin' replace />;
      } else {
        return children;
      }
    };
    
    // ⬇️
    
    // 5. constate 라이브러리 (context + state, 불필요한 렌더링 관리)
    index.js
    import './reportWebVitals';
    import { StrictMode } from 'react';
    import { BrowserRouter } from 'react-router-dom';
    import { HelmetProvider } from 'react-helmet-async';
    import { GlobalStyle } from 'styles/global.styled';
    import { render } from 'react-dom';
    
    import { AuthProvider } from 'contexts';
    import App from 'App';
    
    render(
      <StrictMode>
        <GlobalStyle />
        <BrowserRouter>
          <HelmetProvider>
            <AuthProvider>
              <App />
            </AuthProvider>
          </HelmetProvider>
        </BrowserRouter>
      </StrictMode>,
      document.getElementById('root')
    );
    Indexセクションでは、auth関連Contextを受信する要素ツリーを<AuthProvider>で囲み、ContextAPIを使用する.