프론트엔드 데브코스 5기 TIL 54 - 커스텀훅 연습하기(2)

김영현·2023년 12월 11일
0

TIL

목록 보기
63/129

useForm

formik이라는 라이브러리가 훌륭해서 잘 쓰일일이 없다고 한다.
무슨 라이브러리인고 찾아보니, contextAPI기능도 같이 들어있는 폼 관리 라이브러리다.
최소한의 필요한 기능만 들어있어서 사이즈도 작고 필요한 컴포넌트에서 꺼내쓸 수 있는 contextAPI가 있어서 좋은 것 같다.

//useForm.js
const useForm = ({ initialValues, onSubmit, validate }) => {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});
  const [isLoading, setIsLoading] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setValues({ ...values, [name]: value });
  };

  const handleSubmit = async (e) => {
    setIsLoading(true);
    e.preventDefault();
    const newErrors = validate ? validate(values) : {};
    if (Object.keys(newErrors).length === 0) {
      await onSubmit(values);
    }
    setErrors(newErrors);
    setIsLoading(false);
  };

  return { values, errors, isLoading, handleChange, handleSubmit };
};

//사용하는곳
  const { isLoading, errors, handleChange, handleSubmit } = useForm({
    initialValues: {
      email: "",
      password: "",
    },
    onSubmit: async (values) => {
      alert(JSON.stringify(values));
    },
    validate: ({ email, password }) => {
      const errors = {};
      if (!email) errors.email = "이메일을 입력해주세요";
      if (!password) errors.password = "비밀번호를 입력해주세요";
      return errors;
    },
  });

이런식으로 사용하는 곳에서도 에러처리를 용이하게 할수있도록 만드셨다.


useTimeout(2가지)

함수 호출을 통한 방법과 컴포넌트가 로딩된 후 호출되는 방법 2가지를 만들어본다.

//useTimeoutFn.js
const useTimeoutFn = (fn, ms) => {
  const timeoutId = useRef(null);
  const callback = useRef(fn);

  useEffect(() => {
    callback.current = fn;
  }, [fn]);

  const run = useCallback(() => {
    timeoutId.current && clearTimeout(timeoutId.current);

    timeoutId.current = setTimeout(() => {
      callback.current();
    }, ms);
  }, [ms]);

  const clear = useCallback(() => {
    timeoutId.current && clearTimeout(timeoutId.current);
  }, []);

  useEffect(() => clear, [clear]);

  return [run, clear];
};

//useTimeout.js
const useTimeout = (fn, ms) => {
  const [run, clear] = useTimeoutFn(fn, ms);

  useEffect(() => {
    run();
    return clear;
  }, [clear, run]);

  return clear;
};

한번 더 래핑해서 사용한다. 왜?
=> 컴포넌트가 로딩된 후에 작업하려면 useTimeout내부의 useEffect처럼 반복로직이 또 존재함. 따라서 한번 더 래핑해서 처리한다.


useInterval

useTimeout하고 거의 똑같을수밖에 없음.

//useIntervalFn.js
const useIntervalFn = (fn, ms) => {
  const intervalId = useRef(null);
  const callback = useRef(fn);

  useEffect(() => {
    callback.current = fn;
  }, [fn]);

  const run = useCallback(() => {
    intervalId.current && clearInterval(intervalId.current);

    intervalId.current = setInterval(() => {
      callback.current();
    }, ms);
  }, [ms]);

  const clear = useCallback(() => {
    intervalId.current && clearInterval(intervalId.current);
  }, []);

  useEffect(() => clear, [clear]);

  return [run, clear];
};
//useInterval.js
const useInterval = (fn, ms) => {
  const [run, clear] = useIntervalFn(fn, ms);

  useEffect(() => {
    run();
    return clear;
  }, [clear, run]);

  return clear;
};

사실상 이름만 바뀐거라고 보면 된다. 다만 여기서 fn의존성을 분리한건 굉장히 중요포인트임
=> 분리하지 않았다면 콜백함수가 바뀌었을시 처음부터 다시 인터벌을 시작하게 될수 있다.


useDebounce

디바운스는 특정 시간 내 같은 이벤트 호출시 마지막 호출 이벤트만 실행되게 하는 기법이다. 노션클론 과제때 setTimeout을 이용하여 적용했던 기억이 난다.

const useDebounce = (fn, ms, deps) => {
  const [run, clear] = useTimeoutFn(fn, ms);

  useEffect(run, [...deps, run]);

  return clear;
};

진짜 간단하다!
강의에서는 의존성을 매개변수에서 가져와 넣지 말라며 오류가 나던데, 나는 그런 오류가 아니라 run을 안넣었다고 오류가 났다. 그래서 넣어줌.


useAsync

비동기로직을 제거하기 위한 훅. 이 역시 작동하는 함수와 컴포넌트에서 쓰는 훅으로 나눠둠.

//useAsyncFn.js
const useAsyncFn = (fn, deps) => {
  const lastCallId = useRef(0);
  const [state, setState] = useState({
    isLoading: false,
  });

  const callback = useCallback((...args) => {
    const callId = ++lastCallId.current;
    if (!state.isLoading) {
      setState({ ...state, isLoading: true });
    }
    return fn(...args).then(
      (value) => {
        callId === lastCallId.current && setState({ value, isLoading: false });
        return value;
      },
      (error) => {
        callId === lastCallId.current && setState({ error, isLoading: false });
        return error;
      }
    );
    //eslint-disable-next-line
  }, deps);

  return [state, callback];
};
//useAsync.js
const useAsync = (fn, deps) => {
  const [state, callback] = useAsyncFn(fn, deps);

  useEffect(() => {
    callback();
  }, [callback]);

  return state;
};

lastCallId를 사용한 이유는 여러 콜백이 들어왔을때 맨 마지막으로 들어온 콜백의 값을 저장하기 위함이다.

의문점은 왜 async-await대신 then을 쓰신걸까? 제일 궁금하다. 이따 슬랙으로 질문드려봐야겠다!


useHotKey

무진장 길다...차근차근 살펴보자

const ModifierBitMasks = {
  alt: 1,
  ctrl: 2,
  meta: 4,
  shift: 8,
};

const ShiftKeys = {
  "~": "`",
  "!": "1",
  "@": "2",
  "#": "3",
  $: "4",
  "%": "5",
  "^": "6",
  "&": "7",
  "*": "8",
  "(": "9",
  ")": "0",
  _: "-",
  "+": "=",
  "{": "[",
  "}": "]",
  "|": "\\",
  ":": ";",
  '"': "'",
  "<": ",",
  ">": ".",
  "?": "/",
};

const Aliases = {
  win: "meta",
  window: "meta",
  cmd: "meta",
  command: "meta",
  esc: "escape",
  opt: "alt",
  option: "alt",
};

const getKeyCombo = (e) => {
  const key = e.key !== " " ? e.key.toLowerCase() : "space";

  let modifiers = 0;

  if (e.altKey) modifiers += ModifierBitMasks.alt;
  if (e.ctrlKey) modifiers += ModifierBitMasks.ctrl;
  if (e.metaKey) modifiers += ModifierBitMasks.meta;
  if (e.shiftKey) modifiers += ModifierBitMasks.shift;

  return { modifiers, key };
};

const parseKeyCombo = (combo) => {
  const pieces = combo.replace(/\s/g, "").toLowerCase().split("+");
  let modifiers = 0;
  let key;
  for (const piece of pieces) {
    if (ModifierBitMasks[piece]) {
      modifiers += ModifierBitMasks[piece];
    } else if (ShiftKeys[piece]) {
      modifiers += ModifierBitMasks.shift;
      key = ShiftKeys[piece];
    } else if (Aliases[piece]) {
      key = Aliases[piece];
    } else {
      key = piece;
    }
  }

  return { modifiers, key };
};

const comboMatches = (a, b) => a.modifiers === b.modifiers && a.key === b.key;

const useHotKey = (hotkeys) => {
  const localKeys = useMemo(
    () => hotkeys.filter((hotkey) => !hotkey.global),
    [hotkeys]
  );
  const globalKeys = useMemo(
    () => hotkeys.filter((hotkey) => hotkey.global),
    [hotkeys]
  );

  const invokeCallback = useCallback(
    (global, combo, callbackName, e) => {
      for (const hotkey of global ? globalKeys : localKeys) {
        if (comboMatches(parseKeyCombo(hotkey.combo), combo)) {
          hotkey[callbackName](e);
        }
      }
    },
    [localKeys, globalKeys]
  );

  const handleGlobalKeyDown = useCallback(
    (e) => {
      invokeCallback(true, getKeyCombo(e), "onKeyDown", e);
    },
    [invokeCallback]
  );

  const handleGlobalKeyUp = useCallback(
    (e) => {
      invokeCallback(true, getKeyCombo(e), "onKeyUp", e);
    },
    [invokeCallback]
  );

  const handleLocalKeyDown = useCallback(
    (e) => {
      invokeCallback(
        false,
        getKeyCombo(e.nativeEvent),
        "onKeyDown",
        e.nativeEvent
      );
    },
    [invokeCallback]
  );

  const handleLocalKeyUp = useCallback(
    (e) => {
      invokeCallback(
        false,
        getKeyCombo(e.nativeEvent),
        "onKeyUp",
        e.nativeEvent
      );
    },
    [invokeCallback]
  );

  useEffect(() => {
    document.addEventListener("keydown", handleGlobalKeyDown);
    document.addEventListener("keyup", handleGlobalKeyUp);

    return () => {
      document.removeEventListener("keydown", handleGlobalKeyDown);
      document.removeEventListener("keyup", handleGlobalKeyUp);
    };
  }, [handleGlobalKeyDown, handleGlobalKeyUp]);

  return { handleKeyDown: handleLocalKeyDown, handleKeyUp: handleLocalKeyUp };
};

//사용할땐 이렇게
const hotKeys = [
    {
      global: true,
      combo: "ctrl+shift+k",
      onKeyDown: (e) => {
        alert("meta+k");
      },
    },
    {
      combo: "esc",
      onKeyDown: (e) => {
        alert("esc");
      },
    },
  ];
  const { handleKeyDown } = useHotKey(hotKeys);

  return (
    <div>
      핫키 테스트
      <input onKeyDown={handleKeyDown} />
    </div>
  );

자 차근차근 살펴보자. 사실 여러 함수로 쪼개놔서 그렇지 막상 따라가보면 그리 어렵지않다는 걸 알수있음.

  1. hotkeys라는매개변수를 받는다. 이때 매개변수 내부는
    {
      global: 전역에서 감지할 것인지, 특정 노드에서 감지할 것인지,
      combo:{키 조합},
      callback:이벤트 핸들러,
      e:이벤트 객체
    }
    이렇게 네가지 매개변수를 받는다.
  2. 전역과 지역에서 감지할 키를 나눠준다. 이때 useMemo를 사용하여 감지할 키가 바뀌지 않았으면 값을 그대로 사용.
  3. invokecallback으로 콜백을 한번 래핑해준다. 이때 useCallback을 사용하여 감지할 키 값들이 변경되지 않았다면 함수를 그대로 사용한다. 또한 2번에서 나눠준 키를 순회하며 만약 누른 키가 키 조합에 있다면 콜백 함수를 실행한다
    3-1. combomatches함수는 parseKeyCombo(hotkey.combo)와 매개변수로 받아온 combo를 비교하여 불리언을 리턴한다.
    3-2. parseKeyCombo함수는 받아온 핫키(전역or지역)의 combo를 받아와서 공백을 제거한 뒤 +를 기준으로 자름.
    그리고 특수키 + 마지막에 입력된 특수키 제외 키를 리턴한다.
    이때 Shift키와 누른 일반키가 존재할 수 있으므로(특수문자 등) 이 또한 ShiftKeys객체를 기반으로 예외처리를 해준다.
    3-3. 또한 외부에서 넘겨받은 combogetKeyCombo함수를 거쳐온다. 공백예외처리도 해주고 특수조합키(ctrl, alt, meta, shift)를 비트마스크(1,2,4,8)처럼 활용한다.
    => 같은 키를 두번 연속 누르면modifier가 겹칠수 있지만, 핫키는 같은키 두번 연속을 지원하지 않는다.
  4. 결국 특수키+마지막 일반 입력키가 같다면 true를 리턴하게되어 콜백을 실행한다.

짜잔 끝!

e.nativeEvent

handleLocalKeyDown, Up핸들러에 들어간 e.nativeEvnetJS의 이벤트와 똑같이 만들어놓은 리액트의 이벤트 객체다.
리액트는 원래 JS이벤트 객체를 래핑한 리액트만의 이벤트 객체를 사용함. 이때 특정 이벤트는 리액트 이벤트에 매핑되지 않아서 e.nativeEvent를 사용한다.


위가 래핑된 이벤트. 아래가 원래 브라우저 이벤트 객체
사실 keyDown이벤트는 래핑된 이벤트에서 잘 작동한다.
왜 이걸 사용하신걸깜?


느낀점

useHotKey부분이 함수가 쪼개져서 약간 어려웠는데, 차근차근 따라가다보니 잘 이해할수 있었다. 다만 커스텀 훅을 여러 함수로 쪼개 뚝딱 만들수 있냐는건 별개의 얘기. 그래서 연습이 필요한거다.

2월쯤 커리큘럼에 함수형 프로그래밍이있긴한데, 예습을 얼른 해야할것같다...조만간 과제도 있는데, 어떤 방식으로 공부할 지고민이다!

profile
모르는 것을 모른다고 하기

0개의 댓글