[React] 3주차 개발일지

123902 단어 ReactReact

스파르팅코딩클럽 '프론트엔드의 꽃, 리액트' 3주차 강의를 듣고 작성한 글입니다.

SPA(Single Page Application)

: 서버에서 주는 html이 1개 뿐인 어플리케이션

라우팅

: 브라우저 주소에 따라 다른 페이지를 보여주는 것

설치 : yarn add react-router-dom

  • index.js에 추가
import { BrowserRouter } from "react-router-dom";

ReactDOM.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById("root")
);
  • App.js
import { Route } from "react-router-dom";
import { withRouter } from "react-router";

...

    return (
      <div className="App">
        <div>
          // 링크 연결
          <Link to="/">Home으로 가기</Link>
          <Link to="/cat">Cat으로 가기</Link>
        </div>
        
        // 아래와 같이 Route 사용
        <Route path="/" exact component={Home} />
        <Route path="/cat" component={Cat} />
        
        <button onClick={() => {
          //push() : 페이지 이동
          this.props.history.push('/cat');
        }}>
          /cat으로 가기
        </button>
        
        <button onClick={()=>{
          // goBack(): 뒤로가기
          this.props.history.goBack();
        }}>뒤로가기
        </button>
      </div>
    );
  }
}

export default withRouter(App);
  • exact 추가하면 Home 컴포넌트 중복 제거
  • history 객체를 props로 받아오려면, withRouter 설정

리덕스(Redux)

: 데이터를 한 군데 몰아넣고, 여기저기에서 꺼내볼 수 있게 해주는 것.

설치 : yarn add redux react-redux

1) State : 저장하고 있는 상태값 (= 데이터)
2) Action : 상태에 변화가 필요할 때 발생하는 것
3) ActionCreator : 액션 생성 함수. 액션을 만들기 위해 사용
4) Reducer : 리덕스에 저장된 상태를 변경하는 함수
5) Store : 리덕스를 적용하기 위해 만드는 것
6) dispatch : 애션을 발생시키는 역할

  • 리덕스의 특징
    1) store는 1개만 사용
    2) store의 state(데이터)는 오직 action으로만 변경 가능
    3) 어떤 요청이 와도 리듀서는 같은 동작을 해야 함
  • 상태관리 흐름
    리덕스 Store를 Component에 연결한다.
    Component에서 상태 변화가 필요할 때 Action을 부른다.
    Reducer를 통해서 새로운 상태 값을 만들고,
    새 상태값을 Store에 저장한다.
    Component는 새로운 상태값을 받아온다.

[Toy Project] BucketList

  • 리덕스 사용해서 버킷 리스트 데이터 삭제하기

  • ./redux/modules/bucket.js

// Actions
const LOAD = "bucket/LOAD";
const CREATE = "bucket/CREATE";
const DELETE = "bucket/DELETE";

const initialState = {
  list: ["영화관 가기", "매일 책읽기", "수영 배우기"],
};

// Action Creators
export const loadBucket = (bucket) => {
  return { type: LOAD, bucket };
};

export const createBucket = (bucket) => {
  // bucket = text
  return { type: CREATE, bucket };
};

export const deleteBucket = (bucket) => {
  return { type: DELETE, bucket };
};

// Reducer
export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    // do reducer stuff
    case "bucket/LOAD":
      return state;

    case "bucket/CREATE": {
      const new_bucket_list = [...state.list, action.bucket];
      return { list: new_bucket_list };
    }

    case "bucket/DELETE": {
      const bucket_list = state.list.filter((l, idx) => {
        if (idx !== action.bucket) {
          return l;
        }
      });
      return { list: bucket_list };
    }

    default:
      return state;
  }
}
  • ./redux/configStore.js
import { createStore, combineReducers } from "redux";
import bucket from "./modules/bucket";
import { createBrowserHistory } from "history";

export const history = createBrowserHistory();
const rootReducer = combineReducers({ bucket });

const store = createStore(rootReducer);

export default store;
  • App.js
import React from "react";
import styled from "styled-components";

import { withRouter } from "react-router";
import { Route, Switch } from "react-router-dom";

import BucketList from "./BucketList";
import Detail from "./Detail";
import NotFound from "./NotFound";

import { connect } from "react-redux";
import { loadBucket, createBucket } from "./redux/modules/bucket";

const mapStateToProps = (state) => {
  return { bucket_list: state.bucket.list };
};

const mapDispatchToProps = (dispatch) => {
  return {
    load: () => {
      dispatch(loadBucket());
    },

    create: (bucket) => {
      dispatch(createBucket(bucket));
    },
  };
};

class App extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      list: ["영화관 가기", "매일 책읽기", "수영 배우기"],
    };

    this.text = React.createRef();
  }

  componentDidMount() {
    console.log(this.text);
  }

  addBucketList = () => {
    const new_item = this.text.current.value;
    this.props.create(new_item);
  };

  render() {
    return (
      <div className="App">
        <Container>
          <Title>내 버킷리스트</Title>
          <Line />
          <Switch>
            <Route
              path="/"
              exact
              render={(props) => (
                <BucketList
                  bucket_list={this.props.bucket_list}
                  history={this.props.history}
                />
              )}
            />
            <Route path="/detail/:index" component={Detail} />
            <Route
              render={(props) => <NotFound history={this.props.history} />}
            />
          </Switch>
        </Container>
        <Input>
          <input type="text" ref={this.text} />
          <button onClick={this.addBucketList}>추가하기</button>
        </Input>
      </div>
    );
  }
}

const Input = styled.div`
  max-width: 350px;
  min-height: 10vh;
  background-color: #fff;
  padding: 16px;
  margin: 20px auto;
  border-radius: 5px;
  border: 1px solid #ddd;
`;

const Container = styled.div`
  max-width: 350px;
  min-height: 80vh;
  background-color: #fff;
  padding: 16px;
  margin: 20px auto;
  border-radius: 5px;
  border: 1px solid #ddd;
`;

const Title = styled.h1`
  color: slateblue;
  text-align: center;
`;

const Line = styled.hr`
  margin: 16px 0px;
  border: 1px dotted #ddd;
`;

export default connect(mapStateToProps, mapDispatchToProps)(withRouter(App));
  • Detail.js
import React from "react";
import { useSelector, useDispatch } from "react-redux";

import { deleteBucket } from "./redux/modules/bucket";

const Detail = (props) => {
  const dispatch = useDispatch();

  const bucket_list = useSelector((state) => state.bucket.list);
  console.log(bucket_list, props);
  const bucket_index = parseInt(props.match.params.index);

  return (
    <div>
      <h1>{bucket_list[bucket_index]}</h1>
      <button
        onClick={() => {
          dispatch(deleteBucket(bucket_index));
          props.history.goBack();
        }}
      >
        삭제하기
      </button>
    </div>
  );
};

export default Detail;
  • BucketList.js
import React from "react";
import styled from "styled-components";

import { useDispatch, useSelector } from "react-redux";

const BucketList = (props) => {
  const bucket_list = useSelector((state) => state.bucket.list);

  console.log(bucket_list);

  return (
    <ListStyle>
      {bucket_list.map((list, index) => {
        return (
          <ItemStyle
            className="list_item"
            key={index}
            onClick={() => {
              props.history.push("/detail/" + index);
            }}
          >
            {list}
          </ItemStyle>
        );
      })}
    </ListStyle>
  );
};

const ListStyle = styled.div`
  display: flex;
  flex-direction: column;
  height: 100%;
  overflow-x: hidden;
  overflow-y: auto;
`;

const ItemStyle = styled.div`
  padding: 16px;
  margin: 8px;
  background-color: aliceblue;
`;

export default BucketList;
  • Quiz.js
import React from "react";
import styled from "styled-components";
import img from "./ponyo.jpg";
import TinderCard from "react-tinder-card";
import SwipeItem from "./SwiptItem";
import Score from "./Score";

import { useSelector, useDispatch } from "react-redux";
import { addAnswer } from "./redux/modules/quiz";

const Quiz = (props) => {
  const dispatch = useDispatch();
  const answers = useSelector((state) => state.quiz.answers);
  const quiz = useSelector((state) => state.quiz.quiz);

  const num = answers.length;
  const onSwipe = (direction) => {
    let _answer = direction === "left" ? "O" : "X";

    if (_answer === quiz[num].answer) {
      // 정답일 경우,
      dispatch(addAnswer(true));
    } else {
      // 오답일 경우,
      dispatch(addAnswer(false));
    }
  };

  if (num > quiz.length - 1) {
    return <Score {...props} />;
    // return <div>퀴즈 끝!</div>;
  }

  return (
    <QuizContainer>
      <p>
        <span>{num + 1}번 문제</span>
      </p>
      {quiz.map((l, idx) => {
        if (num === idx) {
          return <Question key={idx}>{l.question}</Question>;
        }
      })}

      <AnswerZone>
        <Answer>O</Answer>
        <Answer>X</Answer>
      </AnswerZone>

      {quiz.map((l, idx) => {
        if (idx === num) {
          return (
            <DragItem key={idx}>
              <TinderCard
                onSwipe={onSwipe}
                onCardLeftScreen={onSwipe}
                onCardRightScreen={onSwipe}
                preventSwipe={["up", "down"]}
              >
                <img src={img} />
              </TinderCard>
            </DragItem>
          );
          //   <SwipeItem key={idx} onSwipe={onSwipe} />;
        }
      })}
    </QuizContainer>
  );
};

const QuizContainer = styled.div`
  text-align: center;
  margin-top: 130px;
  & > p > span {
    padding: 8px 16px;
    background-color: #ffe08c;
    border-radius: 30px;
    font-weight: bold;
  }
`;

const Question = styled.h1`
  font-size: 1.5em;
`;

const AnswerZone = styled.div`
  width: 100vw;
  height: 100vh;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1;
`;

const Answer = styled.div`
  width: 50%;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 100px;
  font-weight: 600;
  color: #dadafc77;
`;

const DragItem = styled.div`
  display: flex;
  align-items: center;
  justify-content: center;
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  z-index: 10;

  & img {
    max-width: 130px;
  }
`;

const Finish = styled.p`
  text-align: center;
  margin-top: 300px;
  font-weight: 600;
  font-size: 30px;
`;

export default Quiz;
  • 결과 화면
    - 버킷리스트 아이템 추가 후, 상세 페이지
    • 상세 페이지에서 삭제하기 누르면 뒤로가기와 동시에 리스트에서 삭제됨.

[HW] FriendTest Project

  • App.js
import React from "react";
import "./App.css";
import { Route, Switch } from "react-router-dom";

import Start from "./Start";
import Quiz from "./Quiz";
import Score from "./Score";
import Message from "./Message";
import Ranking from "./Ranking";

import { withRouter } from "react-router";
import { connect } from "react-redux";

const mapStateToProps = (state) => ({
  ...state,
});

const mapDispatchToProps = (dispatch) => ({
  load: () => {},
});

class App extends React.Component {
  constructor(props) {
    super(props);

    this.state = {};
  }

  render() {
    return (
      <div className="App">
        <Switch>
          <Route path="/" exact component={Start} />
          <Route path="/quiz" component={Quiz} />
          <Route path="/score" component={Score} />
          <Route path="/message" component={Message} />
          <Route path="/ranking" component={Ranking} />
        </Switch>
      </div>
    );
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(withRouter(App));
  • Start.js
import React from "react";
import img from "./ponyo.jpg";
import { useDispatch, useSelector } from "react-redux";
import { addUserName } from "./redux/modules/rank";

const Start = (props) => {
  const dispatch = useDispatch();
  const name = useSelector((state) => state.quiz.name);
  const input_text = React.useRef(null);

  return (
    <div className="container">
      <div className="outter">
        <img className="scc-img" src={img} />
        <h1>
          나는 <span>{name}</span>에 대해 얼마나 알고 있을까?
        </h1>
        <input
          ref={input_text}
          className="text-box"
          type="text"
          placeholder="내 이름"
        />
        <button
          className="button"
          onClick={() => {
            dispatch(addUserName(input_text.current.value));
            props.history.push("/quiz");
          }}
        >
          시작하기
        </button>
      </div>
    </div>
  );
};

export default Start;
  • Score.js
import React from "react";
import styled from "styled-components";

import { useSelector, useDispatch } from "react-redux";
import { addRank } from "./redux/modules/rank";

const Score = (props) => {
  const name = useSelector((state) => state.quiz.name);
  const score_texts = useSelector((state) => state.quiz.score_texts);
  const answers = useSelector((state) => state.quiz.answers);

  let correct = answers.filter((answer) => {
    return answer;
  });

  let score = (correct.length / answers.length) * 100;

  let score_text = "";

  Object.keys(score_texts).map((s, idx) => {
    if (idx === 0) {
      score_text = score_texts[s];
    }
    score_text = parseInt(s) <= score ? score_texts[s] : score_text;
  });

  return (
    <ScoreContainer>
      <Text>
        <span>{name}</span>
        퀴즈에 <br />
        대한 내 점수는?
      </Text>
      <MyScore>
        <span>{score}</span><p>{score_text}</p>
      </MyScore>
      <Button
        onClick={() => {
          props.history.push("/message");
        }}
        outlined
      >
        {name}에게 한마디
      </Button>
    </ScoreContainer>
  );
};

const ScoreContainer = styled.div`
  display: flex;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  padding: 20px;
  box-sizing: border-box;
  flex-direction: column; //세로로 객체 배열
  justify-content: center;
  align-items: center;
`;

const Text = styled.h1`
  font-size: 1.5em;
  margin: 0px;
  line-height: 1.7;
  text-align: center;
  & span {
    background-color: #ffe08c;
    padding: 5px 10px;
    border-radius: 30px;
  }
`;

const MyScore = styled.div`
  & span {
    border-radius: 25px;
    padding: 5px 10px;
    background-color: #ffe08c;
  }
  font-weight: 600;
  font-size: 2em;
  margin: 25px;
  text-align: center;

  & > p {
    margin: 20px 0px;
    font-size: 18px;
    font-weight: 550;
  }
`;
const Button = styled.button`
  color: white;
  padding: 10px 20px;
  background-color: #6799ff;
  border-radius: 30px;
  margin: 10px;
  border: 1px solid #b2ccff;
  width: 70vw;
`;

export default Score;
  • Ranking.js
import React from "react";
import styled from "styled-components";

import { useSelector, useDispatch } from "react-redux";
import { resetAnswer } from "./redux/modules/quiz";

const Ranking = (props) => {
  const dispatch = useDispatch();
  const _ranking = useSelector((state) => state.rank.ranking);

  const ranking = _ranking.sort((a, b) => {
    return b.score - a.score;
  });

  return (
    <RankContainer>
      <Topbar>
        <p>
          <span>{ranking.length}</span>의 사람들 중 당신은?
        </p>
      </Topbar>

      <RankWrap>
        {ranking.map((r, idx) => {
          return (
            <RankItem key={idx} highlight={r.current ? true : false}>
              <RankNum>{idx + 1}</RankNum>
              <RankUser>
                <p>
                  <b>{r.name}</b>
                </p>
                <p>{r.message}</p>
              </RankUser>
            </RankItem>
          );
        })}
      </RankWrap>

      <Button
        onClick={() => {
          dispatch(resetAnswer());
          window.location.href = "/";
        }}
      >
        다시 하기
      </Button>
    </RankContainer>
  );
};

const RankContainer = styled.div`
  width: 100%;
  padding-bottom: 100px;
`;

const Topbar = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  min-height: 50px;
  border-bottom: 1px solid #ddd;
  background-color: #fff;
  & > p {
    text-align: center;
  }

  & > p > span {
    border-radius: 30px;
    background-color: #fef5d4;
    font-weight: 600;
    padding: 4px 8px;
  }
`;

const RankWrap = styled.div`
  display: flex;
  flex-direction: column;
  width: 100%;
  margin-top: 58px;
`;

const RankItem = styled.div`
  width: 80vw;
  margin: 8px auto;
  display: flex;
  border-radius: 5px;
  border: 1px solid #ddd;
  padding: 8px 16px;
  align-items: center;
  background-color: ${(props) => (props.highlight ? "#ffd6aa" : "#ffffff")};
`;

const RankNum = styled.div`
  text-align: center;
  font-size: 2em;
  font-weight: 600;
  padding: 0px 16px 0px 0px;
  border-right: 1px solid #ddd;
`;

const RankUser = styled.div`
  padding: 8px 16px;
  text-align: left;
  & > p {
    &:first-child > b {
      border-bottom: 2px solid #212121;
    }
    margin: 0px 0px 8px 0px;
  }
`;

const Button = styled.button`
  position: fixed;
  bottom: 5vh;
  left: 0;
  padding: 8px 24px;
  background-color: ${(props) => (props.outlined ? "#ffffff" : "#dadafc")};
  border-radius: 30px;
  margin: 0px 10vw;
  border: 1px solid #dadafc;
  width: 80vw;
`;

export default Ranking;
  • Message.js
import React from "react";
import img from "./ponyo.jpg";
import { useDispatch, useSelector } from "react-redux";
import { addRank } from "./redux/modules/rank";

const Message = (props) => {
  const dispatch = useDispatch();
  const name = useSelector((state) => state.quiz.name);
  const answers = useSelector((state) => state.quiz.answers);
  const user_name = useSelector((state) => state.rank.user_name);

  const input_text = React.useRef(null);

  let correct = answers.filter((answer) => {
    return answer;
  });

  let score = (correct.length / answers.length) * 100;

  return (
    <div
      style={{
        display: "flex",
        height: "100vh",
        width: "100vw",
        overflow: "hidden",
        padding: "16px",
        boxSizing: "border-box",
      }}
    >
      <div
        className="outter"
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          flexDirection: "column",
          height: "100vh",
          width: "100vw",
          overflow: "hidden",
          padding: "0px 10vw",
          boxSizing: "border-box",
          maxWidth: "400px",
          margin: "0px auto",
        }}
      >
        <img
          src={img}
          style={{ width: "80%", margin: "-70px 16px 48px 16px" }}
        />
        <h1 style={{ fontSize: "1.5em", margin: "0px", lineHeight: "1.4" }}>
          <span
            style={{
              backgroundColor: "#fef5d4",
              padding: "5px 10px",
              borderRadius: "30px",
            }}
          >
            {name}
          </span>
          에게 한마디
        </h1>
        <input
          ref={input_text}
          type="text"
          style={{
            padding: "10px",
            margin: "24px 0px",
            border: "1px solid #dadafc",
            borderRadius: "30px",
            width: "100%",
          }}
          placeholder="한 마디 적기"
        />
        <button
          onClick={() => {
            let rank_info = {
              score: parseInt(score),
              name: user_name,
              message: input_text.current.value,
              current: true,
            };
            dispatch(addRank(rank_info));
            props.history.push("/ranking");
          }}
          style={{
            padding: "8px 24px",
            backgroundColor: "#dadafc",
            borderRadius: "30px",
            border: "#dadafc",
          }}
        >
          한마디하고 랭킹 보러 가기
        </button>
      </div>
    </div>
  );
};

export default Message;
  • ./redux/modules/quiz.js
//Actions
const GET_QUIZ = "quiz/GET_QUIZ";
const ADD_ANSWER = "quiz/ADD_ANSWER";
const RESET_ANSWER = "quiz/RESET_ANSWER";

const initialState = {
  name: "포뇨",
  score_texts: {
    60: "우린 친구! 앞으로도 더 친하게 지내요!",
    80: "우와! 우리는 엄청 가까운 사이!",
    100: "우린 둘도 없는 단짝! :)",
  },

  answers: [],

  quiz: [
    { question: "포뇨는 5살이다.", answer: "O" },
    { question: "포뇨는 주황색 머리다.", answer: "O" },
    { question: "포뇨는 물을 좋아한다.", answer: "O" },
    { question: "포뇨는 라면을 좋아한다.", answer: "O" },
  ],
};

export const getQuiz = (quiz_list) => {
  return { type: GET_QUIZ, quiz_list };
};

export const addAnswer = (answer) => {
  return { type: ADD_ANSWER, answer };
};

export const resetAnswer = () => {
  return { type: RESET_ANSWER };
};

//Reducer
export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case "quiz/GET_QUIZ": {
      return { ...state, quiz: action.quiz_list };
    }

    case "quiz/ADD_ANSWER": {
      return { ...state, answers: [...state.answers, action.answer] };
    }

    case "quiz/RESET_ANSWER": {
      return { ...state, answers: [] };
    }

    default:
      return state;
  }
}
  • ./redux/modules/rank.js
// Actions
const ADD_USER_NAME = "rank/ADD_USER_NAME";
const ADD_USER_MESSAGE = "rank/ADD_USER_MESSAGE";
const ADD_RANK = "rank/ADD_RANK";
const GET_RANK = "rank/GET_RANK";

const initialState = {
  user_name: "",
  user_message: "",
  user_score: "",
  score_text: {
    60: "우린 친구! 앞으로도 더 친하게 지내요!",
    80: "우와! 우리는 엄청 가까운 사이!",
    100: "우린 둘도 없는 단짝! :)",
  },

  ranking: [{ score: 40, name: "최수빈", message: "안녕 포뇨!" }],
};

export const addUserName = (user_name) => {
  return { type: ADD_USER_NAME, user_name };
};

export const addRank = (rank_info) => {
  return { type: ADD_RANK, rank_info };
};

export const addUserMessage = (user_message) => {
  return { type: ADD_USER_MESSAGE, user_message };
};

export const getRank = (rank_list) => {
  return { type: GET_RANK, rank_list };
};

//Reducer
export default function reducer(state = initialState, action = {}) {
  switch (action.type) {
    case "rank/ADD_USER_NAME": {
      return { ...state, user_name: action.user_name };
    }

    case "rank/ADD_USER_MESSAGE": {
      return { ...state, user_message: action.user_message };
    }

    case "rank/ADD_RANK": {
      return { ...state, ranking: [...state.ranking, action.rank_info] };
    }

    case "rank/GET_RANK": {
      return { ...state, ranking: action.rank_list };
    }

    default:
      return state;
  }
}
  • 결과 화면

❗ 아직 세부 스타일이 깔끔하게 적용되지 않음. 좀 더 스타일 신경써서 완성시켜야 함.

❗ Quiz 화면에서 스와이프를 한번 할 때 화면이 두번 넘어가는 에러 수정해야 함.

좋은 웹페이지 즐겨찾기