개발/Project(React-다이어리)

React - 페이지 구현(1) (최상위 컴포넌트 (App.js), Home 페이지 작성)

잇(IT) 2023. 9. 25. 20:04
728x90

- 위 사진은 최종 결과물의 홈화면이다

 

 

- 아래는 코드에 대한 분석이다.


- App.js는 React 애플리케이션의 핵심 컴포넌트 중 하나로, 전체 애플리케이션의 렌더링 구조를 정의하는 역할을 한다.

- React 애플리케이션은 컴포넌트들의 계층 구조로 이루어지며, App.js는 이 계층 구조의 최상위 컴포넌트이다.

 

- App.js

import React, { useEffect, useReducer, useRef } from "react";

import "./App.css";
import { BrowserRouter, Link, Route, Routes } from "react-router-dom";

import Home from "./pages/Home";
import New from "./pages/New";
import Edit from "./pages/Edit";
import Diary from "./pages/Diary";

// useReducer
const reducer = (state, action) => {
  let newState = [];
  switch (action.type) {
    case "INIT": {
      return action.data;
    }
    case "CREATE": {
      // const newItem = {
      //   ...action.data,
      // };
      // newState = [newItem, ...state];
      // 위 아래 두 코드가 같은 코드이다.
      newState = [action.data, ...state];
      break;
    }
    case "REMOVE": {
      newState = state.filter((it) => it.id !== action.targetId);
      break;
    }
    case "EDIT": {
      newState = state.map((it) =>
        it.id === action.data.id ? { ...action.data } : it
      );
      break;
    }
    default:
      return state;
  }

  localStorage.setItem(`diary`, JSON.stringify(newState));
  return newState;
};

// Context
export const DiaryStateContext = React.createContext();
export const DiaryDispatchContext = React.createContext();

// App()
function App() {
  // --------- localStorate에 저장하고 불러오는 법
  // useEffect(() => {
  //   // localStorage.setItem("item1", 10); -> localStorage에 저장하는 방법
  //   // const item1 = localStorage.getItem("item1"); -> LocalStorage에 지정하는 방법
  // }, []);

  const [data, dispatch] = useReducer(reducer, []);

  useEffect(() => {
    const localData = localStorage.getItem("diary");
    if (localData) {
      const diaryList = JSON.parse(localData).sort(
        (a, b) => parseInt(b.id) - parseInt(a.id)
      );

      if (diaryList.length >= 1) {
        dataId.current = parseInt(diaryList[0].id) + 1;
        dispatch({ type: "INIT", data: diaryList });
      }
    }
  }, []);

  const dataId = useRef(0);
  //CREATE
  const onCreate = (date, content, emotion) => {
    dispatch({
      type: "CREATE",
      data: {
        id: dataId.current,
        date: new Date(date).getTime(),
        content,
        emotion,
      },
    });
    dataId.current += 1;
  };
  //REMOVE
  const onRemove = (targetId) => {
    dispatch({ type: "REMOVE", targetId });
  };
  //EDIT
  const onEdit = (targetId, date, content, emotion) => {
    dispatch({
      type: "EDIT",
      data: {
        id: targetId,
        date: new Date(date).getTime(),
        content,
        emotion,
      },
    });
  };

  // return
  return (
    <DiaryStateContext.Provider value={data}>
      <DiaryDispatchContext.Provider value={{ onCreate, onEdit, onRemove }}>
        <BrowserRouter>
          <div className="App">
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/new" element={<New />} />
              <Route path="/edit/:id" element={<Edit />} />
              <Route path="/diary/:id" element={<Diary />} />
              {/* /:id와 같이 작성하는 것이 pathVariable 방식이다. */}
            </Routes>
          </div>
        </BrowserRouter>
      </DiaryDispatchContext.Provider>
    </DiaryStateContext.Provider>
  );
}

export default App;

전체 코드


- 코드 분석

 

- useReducer

// useReducer
const reducer = (state, action) => {
  let newState = [];
  switch (action.type) {
    case "INIT": {
      return action.data;
    }
    case "CREATE": {
      // const newItem = {
      //   ...action.data,
      // };
      // newState = [newItem, ...state];
      // 위 아래 두 코드가 같은 코드이다.
      newState = [action.data, ...state];
      break;
    }
    case "REMOVE": {
      newState = state.filter((it) => it.id !== action.targetId);
      break;
    }
    case "EDIT": {
      newState = state.map((it) =>
        it.id === action.data.id ? { ...action.data } : it
      );
      break;
    }
    default:
      return state;
  }

  localStorage.setItem(`diary`, JSON.stringify(newState));
  return newState;
};

- useReducer를 사용하여 상태 변화 값이 각 컴포넌트 내부에 존재하지 않아 종속적이지 않게 된다.

- useReducer를 사용하면 함수와 상태를 분리 할 수 있어 각 컴포넌트의 상태 변화에 대한 관리가 유용해지고 가독성이 좋아진다.

- 여러 컴포넌트들을 type을 통해 이름을 지정하고 reducer 함수에서 switch case문을 통해 각 컴포넌트의 상태 관리가 가능해진다.

 

- localStrage의 경우 javascript에서 사용하는 웹 스토리지 기술 중 하나로, 웹 스토리지를 이용하여 데이터를 저장하는 기술 중 하나이다.

- 위 프로젝트의 경우 별도의 DB를 사용하지 않기 새로 고침할 때마다 새롭게 렌더링 되며, 기존의 데이터가 사라지게 된다.

- 새로 고침 때 마다 데이터가 사라지는 것을 방지하기 위해 사용하는 기술 중 하나이다.


- Context

// Context
export const DiaryStateContext = React.createContext();
export const DiaryDispatchContext = React.createContext();

.....

- 기존의 하위 컴포넌트들은, 트리 형식으로 하위 컴포넌트들이 연결되어 있는데, 최상위 컴포넌트에서 생성한 함수를 사용하기 위해선 사용하지 않아도 중간 컴포넌트를 통해서 계속해서 전달했어야 했다.

- 하지만 Context를 사용하게 되면 최상위 컴포넌트와 바로 닿아있지 않아도 어디서든 사용할 수 있는 것이 가능해진다.

 

- 사용 방법은 위와 같은 createContext()를 통해 Context 객체를 생성한다.

 return (
    <DiaryStateContext.Provider value={data}>
      <DiaryDispatchContext.Provider value={{ onCreate, onEdit, onRemove }}>

- 또 return문 안에 Provider와 value를 통해 전달하고자 하는 컴포넌트들을 내부에 작성해준다.

 

const Home = () => {
  const diaryList = useContext(DiaryStateContext);

- 하위 컴포넌트에서 위와 같이 최상위 컴포넌트에서 전달한 Context를 사용할 수 있다.


- Router

return (
    <DiaryStateContext.Provider value={data}>
      <DiaryDispatchContext.Provider value={{ onCreate, onEdit, onRemove }}>
        <BrowserRouter>
          <div className="App">
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/new" element={<New />} />
              <Route path="/edit/:id" element={<Edit />} />
              <Route path="/diary/:id" element={<Diary />} />
              {/* /:id와 같이 작성하는 것이 pathVariable 방식이다. */}
            </Routes>
          </div>
        </BrowserRouter>
      </DiaryDispatchContext.Provider>
    </DiaryStateContext.Provider>
  );

- <BrowserRouter>를 통해 router 즉, 화면을 이동 시키기 위해 Routes와 Route를 사용하기 위해 감싸준다.

- <Routes>는 <Route> 여러개를 하나에 묶기 위해 사용한다.

- <Route>에서 path는 server의 URL 뒤에 추가되는 경로를 뜻한다.

- 예를 들어 path="/new"와 같이 설정되어 있으면, 기존의 서버 주소가 localhost:3000로 설정되어 있다면 localhost:3000/new 경로로 요청이 들어오게 되면 element에 해당하는 <Home /> 컴포넌트를 불러오겠다는 의미이다.


 

 

 

- Home.js

import { useContext, useEffect, useState } from "react";
import { DiaryStateContext } from "../App";

import MyHeader from "./../components/MyHeader";
import MyButton from "./../components/MyButton";
import DiaryList from "../components/DiaryList";

const Home = () => {
  const diaryList = useContext(DiaryStateContext);
  // App.js로부터 Context를 통해 전달 받은 data를 사용하기 위한 변수이다.
  const [data, setData] = useState([]);
  // 페이지 Home에 보여지는 글들이 계속해서 변하기 때문에 useState를 생성해준다.

  const [curDate, setCurDate] = useState(new Date());
  const headText = `${curDate.getFullYear()}년 ${curDate.getMonth() + 1}월`;

  // 상단 title 바꾸기
  useEffect(() => {
    const titleElement = document.getElementsByTagName("title")[0];
    titleElement.innerHTML = `감정 일기장 - Home`;
  }, []);

  // 날짜 범위 지정하기
  useEffect(() => {
    if (diaryList.length >= 1) {
      const firstDay = new Date(
        curDate.getFullYear(),
        curDate.getMonth(),
        1
      ).getTime();
      // console.log(new Date(firstDay));

      const lastDay = new Date(
        curDate.getFullYear(),
        curDate.getMonth() + 1,
        0,
        23,
        59,
        59
      ).getTime();
      // console.log(new Date(lastDay));

      // 날짜 필터로 범위 지정
      setData(
        diaryList.filter((it) => firstDay <= it.date && it.date <= lastDay)
      );
    }
  }, [diaryList, curDate]);
  // diaryList도 넣어줘야 한다.

  useEffect(() => {
    console.log(data);
  }, [data]);

  const increaseMonth = () => {
    setCurDate(
      new Date(curDate.getFullYear(), curDate.getMonth() + 1, curDate.getDate())
    );
  };

  const decreaseMonth = () => {
    setCurDate(
      new Date(curDate.getFullYear(), curDate.getMonth() - 1, curDate.getDate())
    );
  };

  return (
    <div>
      <MyHeader
        headText={headText}
        leftChild={<MyButton text={"<"} onClick={decreaseMonth} />}
        rightChild={<MyButton text={">"} onClick={increaseMonth} />}
      />
      <DiaryList diaryList={data} />
    </div>
  );
};

export default Home;

전체 코드


 

- 아래는 위 Home.js 코드를 분석할 것이다.

 

const Home = () => {
  const diaryList = useContext(DiaryStateContext);

- useContext를 통해 최상위 컴포넌트에서 생성한 Context 중 원하는 Context와 컴포넌트를 사용할 수 있다.

 

const [data, setData] = useState([]);

  const [curDate, setCurDate] = useState(new Date());
  const headText = `${curDate.getFullYear()}년 ${curDate.getMonth() + 1}월`;

- state를 사용하여 값이 변경될 때마다 새로운 값으로 렌더링한다.

 

- title 바꾸기
// 상단 title 바꾸기
  useEffect(() => {
    const titleElement = document.getElementsByTagName("title")[0];
    titleElement.innerHTML = `감정 일기장 - Home`;
  }, []);

- doument를 통해 현재 웹 페이지의 DOM에 접근할 수 있는 객체이고 모든 html 문서 중 tilte요소 중 첫번째 요소를 선택하기 위한 것이다.

- 우리가 흔히 보는 이 부분을 변경하기 위한 코드이다.

 

- 해당 월에 속한 일기 가져오기
// 날짜 범위 지정하기
  useEffect(() => {
    if (diaryList.length >= 1) {
      const firstDay = new Date(
        curDate.getFullYear(),
        curDate.getMonth(),
        1
        // getMonth() 다음의 1은 첫번째 날을 의미한다.
      ).getTime();
      console.log(new Date(firstDay));

      const lastDay = new Date(
        curDate.getFullYear(),
        curDate.getMonth() + 1,
        0,
        23,
        59,
        59
      ).getTime();
      // getMonth() 다음의 0은 달력은 1일부터 시작하기 때문에 이전달 마지막 날을 의미한다.
      console.log(new Date(lastDay));

      // 날짜 필터로 범위 지정
      setData(
        diaryList.filter((it) => firstDay <= it.date && it.date <= lastDay)
      );
    }
  }, [diaryList, curDate]);
  // diaryList도 넣어줘야 한다.

- 위 코드는 Context로 넘어온 diaryList 객체의 길이가 1 이상 즉, 글이 하나라도 있다면 new Date 객체를 생성해 낸다.

- firstDay의 경우 해당 달의 첫번째 날은 무조건 1일 이기 때문에 위 코드와 같이 작성하였고, 마지막 날의 경우 달마다 다르기 때문에 getMonth() + 1을 통해 현재 달의 다음 달을 선택하고 [, 0]을 사용하게 되면 전달의 마지막 날이 선택되게 된다.

- setData를 filter로 해당 현재 달에 속하는 일기의 데이터를 렌더링 하도록 한다.

- 해당 함수는 [diaryList, curDate]가 두번째 파라미터로 넘어가기 때문에 두 변수가 변경되게 되면 함수가 새롭게 렌더링 된다.


- MyHeader 컴포넌트
const MyHeader = ({ headText, leftChild, rightChild }) => {
  return (
    <header>
      <div className="head_btn_left">{leftChild}</div>
      <div className="head_text">{headText}</div>
      <div className="head_btn_right">{rightChild}</div>
    </header>
  );
};

export default MyHeader;

 

- MyButton 컴포넌트
const MyButton = ({ text, type, onClick }) => {
  const btnType = ["positive", "negative"].includes(type) ? type : "default";

  return (
    <button
      className={["MyButton", `MyButton_${btnType}`].join(" ")}
      onClick={onClick}
    >
      {text}
    </button>
  );
};

MyButton.defaultProps = {
  type: "default",
};
export default MyButton;

- Button 컴포넌트를 생성한다. text, type, onClick 3개의 컴포넌트를 전달 받는다

1. positive, negative 둘 중에 하나의 타입을 받거나 둘 중에 하나의 타입을 받지 않으면 default에 해당한다.

 

- 월 변환 버튼 만들기
const increaseMonth = () => {
    setCurDate(
      new Date(curDate.getFullYear(), curDate.getMonth() + 1, curDate.getDate())
    );
  };

  const decreaseMonth = () => {
    setCurDate(
      new Date(curDate.getFullYear(), curDate.getMonth() - 1, curDate.getDate())
    );
  };

  return (
    <div>
      <MyHeader
        headText={headText}
        leftChild={<MyButton text={"<"} onClick={decreaseMonth} />}
        rightChild={<MyButton text={">"} onClick={increaseMonth} />}
      />
      <DiaryList diaryList={data} />
    </div>
  );
};

- increaseMonth와 decreaseMonth 함수를 통해 버튼 클릭 시 월이 변경되는 로직을 작성한다.

- Home 화면에는 일기의 목록이 보여야하기 때문에 DiaryList 컴포넌트를 포함 시킨다.


- DiaryList 컴포넌트

- DiaryList는 작성된 글들을 화면에 보여주는 컴포넌트다.

import React, { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import MyButton from "./MyButton";
import DiaryItem from "./DiaryItem";

const sortOptionList = [
  { value: "latest", name: "최신순" },
  { value: "oldest", name: "오래된 순" },
];

const filterOptionList = [
  { value: "all", name: "전부 다" },
  { value: "good", name: "좋은 감정만" },
  { value: "bad", name: "안좋은 감정만" },
];

const ControlMenu = React.memo(({ value, onChange, optionList }) => {
  return (
    <select
      className="ControlMenu"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    >
      {optionList.map((it, idx) => (
        <option key={idx} value={it.value}>
          {it.name}
        </option>
      ))}
    </select>
  );
});

const DiaryList = ({ diaryList }) => {
  const navigate = useNavigate();
  const [sortType, setSortType] = useState("latest");
  const [filter, setFilter] = useState("all");

  const getProcessedDiaryList = () => {
    // 감정에 따른 구분을 위한 filter 함수
    const filterCallBack = (item) => {
      if (filter === "good") {
        return parseInt(item.emotion) <= 3;
      } else {
        return parseInt(item.emotion) > 3;
      }
    };

    // 최신순 비교 compare 함수
    const compare = (a, b) => {
      if (sortType === "latest") {
        return parseInt(b.date) - parseInt(a.date);
      } else {
        return parseInt(a.date) - parseInt(b.date);
      }
    };
    const copyList = JSON.parse(JSON.stringify(diaryList));

    const filteredList =
      filter === "all" ? copyList : copyList.filter((it) => filterCallBack(it));

    const sortedList = filteredList.sort(compare);
    return sortedList;
  };
  return (
    <div className="DiaryList">
      <div className="menu_wrapper">
        <div className="left_col">
          <ControlMenu
            value={sortType}
            onChange={setSortType}
            optionList={sortOptionList}
          />
          <ControlMenu
            value={filter}
            onChange={setFilter}
            optionList={filterOptionList}
          />
        </div>
        <div className="right_col">
          <MyButton
            type={"positive"}
            text={"새 일기쓰기"}
            onClick={() => navigate("/new")}
          />
        </div>
      </div>
      {getProcessedDiaryList().map((it) => (
        <DiaryItem key={it.id} {...it} />
      ))}
    </div>
  );
};

DiaryList.defaultProps = {
  diaryList: [],
};

export default DiaryList;

- state
const DiaryList = ({ diaryList }) => {
  const navigate = useNavigate();
  const [sortType, setSortType] = useState("latest");
  const [filter, setFilter] = useState("all");

- 페이지 이동을 위한 navigate, 일기 날짜 순서를 위한 state인 sortType, 일기를 구분하기 위한 filter state를 생성한다.


- return
return (
    <div className="DiaryList">
      <div className="menu_wrapper">
        <div className="left_col">
          <ControlMenu
            value={sortType}
            onChange={setSortType}
            optionList={sortOptionList}
          />
          <ControlMenu
            value={filter}
            onChange={setFilter}
            optionList={filterOptionList}
          />
        </div>
        <div className="right_col">
          <MyButton
            type={"positive"}
            text={"새 일기쓰기"}
            onClick={() => navigate("/new")}
          />
        </div>
      </div>
      {getProcessedDiaryList().map((it) => (
        <DiaryItem key={it.id} {...it} />
      ))}
    </div>
  );
};

- 화면을 보게 되면 위와 같이 검색 조건을 선택 할 수 있는 select 버튼이 있다.

- 첫번 째 ControlMenu는 최신, 오래된 순을 나누는 menu이고, 두번째 ControlMenu는 감정에 따라 나누는 menu에 해당한다.

- 각 ControlMenu의 컴포넌트로 value, onChange, optionList 3가지를 전달한다.

- 그 다음 새로운 일기를 작성하기 위한 버튼을 하나 생성해준다.

 

- 각 옵션 및 ControlMenu
const sortOptionList = [
  { value: "latest", name: "최신순" },
  { value: "oldest", name: "오래된 순" },
];

const filterOptionList = [
  { value: "all", name: "전부 다" },
  { value: "good", name: "좋은 감정만" },
  { value: "bad", name: "안좋은 감정만" },
];

const ControlMenu = React.memo(({ value, onChange, optionList }) => {
  return (
    <select
      className="ControlMenu"
      value={value}
      onChange={(e) => onChange(e.target.value)}
    >
      {optionList.map((it, idx) => (
        <option key={idx} value={it.value}>
          {it.name}
        </option>
      ))}
    </select>
  );
});

- sort,filter OptionList는 각 menu에 들어갈 객체들에 대해 정의되어 있다.

- sort는 최신, 오래된 순을 / filter는 각 감정에 따른 구분을 지어놓았다.

- ControlMenu 컴포넌트의 경우 value, onChange, optionList 3가지를 받아, select 태그를 통해 선택 메뉴를 생성하고, 변화가 일어나면 set 함수에 의해 값이 변경된다.

- 또 optionList를 통해 받아온 객체들을 가지고 각 화면에 보여질 이름들을 지정해준다.

 

- getProcessedDiaryList
const getProcessedDiaryList = () => {
    // 감정에 따른 구분을 위한 filter 함수
    const filterCallBack = (item) => {
      if (filter === "good") {
        return parseInt(item.emotion) <= 3;
      } else {
        return parseInt(item.emotion) > 3;
      }
    };

    // 최신순 비교 compare 함수
    const compare = (a, b) => {
      if (sortType === "latest") {
        return parseInt(b.date) - parseInt(a.date);
      } else {
        return parseInt(a.date) - parseInt(b.date);
      }
    };
    const copyList = JSON.parse(JSON.stringify(diaryList));

    const filteredList =
      filter === "all" ? copyList : copyList.filter((it) => filterCallBack(it));

    const sortedList = filteredList.sort(compare);
    return sortedList;
  };

- 위 함수는 state 함수에 의해 화면이 렌더링 될 때 마다 계속해서 실행된다.

- 위 코드는 이전에 생성한 select 메뉴에 대해 해당 각 선택에 대해 어떤 데이터들을 선별할 것인지 정하게 된다.

 

- 최종적으로 반환하는 값은 sortedList이고, sortedList는 filteredList를 compare를 기준으로 정렬한 객체에 해당한다.

- filteredList는 filter의 값이 all일 경우에는 copyList를 반환하고, all이 아닐경우 filter함수를 통해 filterCallBack 함수를 실행 시켜 데이터를 걸러낸다.

- copyList는 컴포넌트를 통해 넘어온 diaryList를 깊은 복사를 통해 해당 변수가 같은 참조 주소를 바라보는 것이 아닌 참조 주소가 다른 같은 값으로 복사하기 위해서 사용한다.

- 깊은 복사를 하지 않으면, 해당 데이터가 변하면 원본 데이터도 변경되기 때문이다.

- compare 함수의 인터페이스로 개발자가 직접 비교 내용을 작성 할 수 있다. 위 비교 내용은 sortType 즉, 최신, 오래된 순을 지정하게 되는 함수에 해당한다.

 

728x90