Advanced Course 주특기 4강 - React

박경준·2021년 7월 5일
0

advanced course - react

목록 보기
4/8

심화 주특기 4강!

무한 스크롤

// post.js
const initialState = {
  list: [],
  paging: { start: null, next: null, size: 3 },
  is_loading: false,
};

...

const LOADING = "LOADING";
...

const setPost = createAction(SET_POST, (post_list, paging) => ({
  post_list,
  paging,
}));
const loading = createAction(LOADING, (is_loading) => ({ is_loading }));

const getPostFB = (start = null, size = 3) => {
  return function (dispatch, getState, { history }) {
    let _paging = getState().post.paging;
    if (_paging.start && !_paging.next) {
      // 시작정보가 기록되었는데 다음 가져올 데이터가 없다면? 앗, 리스트가 끝났겠네요!
      // 그럼 아무것도 하지말고 return을 해야죠!
      return;
    }
    dispatch(loading(true));
    const postDB = firestore.collection("image_community");

    let query = postDB.orderBy("insert_dt", "desc");
    if (start) {
      query = query.startAt(start);
    }

    query
      .limit(size + 1)
    // 사이즈보다 1개 더 크게 가져옵시다.
    // 3개씩 끊어서 보여준다고 할 때, 4개를 가져올 수 있으면? 앗 다음 페이지가 있겠네하고 알 수 있으니까요.
    // 만약 4개 미만이라면? 다음 페이지는 없겠죠! :)
      .get()
      .then((docs) => {
      let post_list = [];
      let paging = {
        // 시작점에는 새로 가져온 정보의 시작점을 넣고,
        // next에는 마지막 항목을 넣습니다.
        // (이 next가 다음번 리스트 호출 때 start 파라미터로 넘어올거예요.)
        start: docs.docs[0],
        next:
        docs.docs.length === size + 1
        ? docs.docs[docs.docs.length - 1]
        : null,
        size: size,
      };

      docs.forEach((doc) => {
        let _post = doc.data();

        let post = Object.keys(_post).reduce(
          // reduce 쓰는법 참고!!!
          (acc, cur) => {
            if (cur.indexOf("user_") !== -1) {
              return {
                ...acc,
                user_info: { ...acc.user_info, [cur]: _post[cur] },
                // [cur] 이렇게 써야 cur의 변수 값이 들어가짐. 그냥 cur 쓰면 문자열이 들어감...?!
                // value 가져올때도 _post.cur라고 쓰면 안되고 _post[cur] 라고 써야함
                // reduce만의 특징인듯?
              };
            }
            return { ...acc, [cur]: _post[cur] };
          },
          { id: doc.id, user_info: {} }
        );

        post_list.push(post);
      });
      if (docs.docs.length === size + 1) {
        post_list.pop();
      }

      dispatch(setPost(post_list, paging));
    });
  };
};

...

export default handleActions(
  {
    [SET_POST]: (state, action) =>
      produce(state, (draft) => {
        draft.list.push(...action.payload.post_list);
        draft.paging = action.payload.paging;
        draft.is_loading = false;
      }),
      
    ...
      
    [LOADING]: (state, action) =>
      produce(state, (draft) => {
        draft.is_loading = action.payload.is_loading;
      }),
  },
  initialState
);
yarn add lodash
// InfinityScroll.js
import React from "react";
import _ from "lodash";
import { Spinner } from "../elements";

const InfinityScroll = (props) => {
  const { children, callNext, is_next, loading } = props;

  const _handleScroll = _.throttle(() => {
    if (loading) {
      // 로딩중이면 리턴
      return;
    }

    const { innerHeight } = window;
    const { scrollHeight } = document.body;
    const scrollTop =
      (document.documentElement && document.documentElement.scrollTop) ||
      document.body.scrollTop;
    // 크로스 브라우징 때문에 이렇게 두가지로 적음
    if (scrollHeight - innerHeight - scrollTop < 200) {
      callNext();
    }
  }, 300);

  const handleScroll = React.useCallback(_handleScroll, [loading]);
  // loading이 변할때만 _handleScroll 초기화
  
  React.useEffect(() => {
    if (loading) {
      return;
    }
    if (is_next) {
      // 다음 페이지가 있을때만 handleScroll 붙여주기
      window.addEventListener("scroll", handleScroll);
    } else {
      window.removeEventListener("scroll", handleScroll);
    }
    return () => window.removeEventListener("scroll", handleScroll);
    // 컴포넌트가 소멸할때 이벤트 리스너 없애주기
  }, [is_next, loading]);
  // is_next 또는 loading이 변할때마다 useEffect 동작

  return (
    <React.Fragment>
      {props.children}
      {is_next && <Spinner size="120" />}
    </React.Fragment>
  );
};

export default InfinityScroll;

InfinityScroll.defaultProps = {
  children: null,
  callNext: () => {},
  is_next: false,
  loading: false,
};
// Spinner.js
import React from "react";
import styled from "styled-components";

const Spinner = (props) => {
  const { type, size, is_dim } = props;

  return (
    <React.Fragment>
      <SpinnerWrap type={type} is_dim={is_dim}>
        <SpinnerSvg size={size} />
      </SpinnerWrap>
    </React.Fragment>
  );
};

Spinner.defaultProps = {
  type: "inline", // inline, page
  is_dim: false,
  size: 60,
};

const SpinnerWrap = styled.div`
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px 0;
  ${(props) =>
    props.type === "page"
      ? `position: fixed;
        height: 95vh;
        top: 0;
        left: 0;
        padding: 0;
        zIndex: 9999;`
      : ``}
  ${(props) =>
    props.is_dim
      ? `
     background: rgba(0,0,0,0.4); 
     height: 100vh;
  `
      : ``}
`;

const SpinnerSvg = styled.div`
  --size: ${(props) => props.size}px;
  width: var(--size);
  height: var(--size);
  background-image: url(\"data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3Csvg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' style='margin: auto; background: none; display: block; shape-rendering: auto;' width='200px' height='200px' viewBox='0 0 100 100' preserveAspectRatio='xMidYMid'%3E%3Cg transform='rotate(0 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.9166666666666666s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(30 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.8333333333333334s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(60 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.75s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(90 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.6666666666666666s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(120 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.5833333333333334s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(150 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.5s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(180 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.4166666666666667s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(210 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.3333333333333333s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(240 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.25s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(270 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.16666666666666666s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(300 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='-0.08333333333333333s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3Cg transform='rotate(330 50 50)'%3E%3Crect x='47' y='24' rx='3' ry='3.36' width='6' height='12' fill='%23222222'%3E%3Canimate attributeName='opacity' values='1;0' keyTimes='0;1' dur='1s' begin='0s' repeatCount='indefinite'%3E%3C/animate%3E%3C/rect%3E%3C/g%3E%3C/svg%3E\");
  background-size: var(--size);
`;

export default Spinner;
// PostList.js
...
import InfinityScroll from "../shared/InfinityScroll";

...

const is_loading = useSelector((state) => state.post.is_loading);
const paging = useSelector((state) => state.post.paging);

...

return (
  <React.Fragment>
    <InfinityScroll
      callNext={() => {
        dispatch(postActions.getPostFB(paging.next));
      }}
      is_next={paging.next ? true : false}
      loading={is_loading}
      >
      {post_list.map((p, idx) => {
        if (p.user_info.user_id === user_info?.uid) {
            //user_info?.uid -> user_info가 있는지 확인하면서 .uid 가져오기
            return <Post key={p.id} {...p} is_me></Post>;
            } else {
          return <Post key={p.id} {...p}></Post>;
        }
        // p에 있는 각 key, value를 한 번에 props로 넘겨줄때 {...p} 이렇게 쓰면 되네...?
      })}
    </InfinityScroll>
  </React.Fragment>
);

상세페이지 붙이기

// PostDetail.js
...
import { useSelector, useDispatch } from "react-redux";
import { actionCreators as postActions } from "../redux/modules/post";

const PostDetail = (props) => {
  const id = props.match.params.id;
  const post_list = useSelector((store) => store.post.list);
  const user_info = useSelector((state) => state.user.user);
  const post_idx = post_list.findIndex((p) => p.id === id);
  const post = post_list[post_idx];
  const dispatch = useDispatch();

  React.useEffect(() => {
    if (post) {
      return;
    }

    dispatch(postActions.getOnePostFB(id));
  }, []);

  return (
    <React.Fragment>
      {post && (
        <Post
          {...post}
          is_me={post.user_info.user_id === user_info?.uid ? true : false}
          />
      )}
      <CommentWrite />
      <CommentList />
    </React.Fragment>
  );
};

export default PostDetail;
// post.js
...
const getOnePostFB = (id) => {
  return function (dispatch, getState, { history }) {
    // PostDetail.js
    // 리덕스를 불러와서 사용하는 방식은 /detail 페이지에서 새로고침했을때 리덕스가 날아가기때문에 데이터가 없어짐
    // 그래서 바로 firestore에서 데이터를 가져와 사용함.
    const postDB = firestore.collection("image_community");
    postDB
      .doc(id)
      .get()
      .then((doc) => {
      let _post = doc.data();
      let post = Object.keys(_post).reduce(
        // reduce 쓰는법 참고!!!
        (acc, cur) => {
          if (cur.indexOf("user_") !== -1) {
            return {
              ...acc,
              user_info: { ...acc.user_info, [cur]: _post[cur] },
              // [cur] 이렇게 써야 cur의 변수 값이 들어가짐. 그냥 cur 쓰면 문자열이 들어감...?!
              // value 가져올때도 _post.cur라고 쓰면 안되고 _post[cur] 라고 써야함
              // reduce만의 특징인듯?
            };
          }
          return { ...acc, [cur]: _post[cur] };
        },
        { id: doc.id, user_info: {} }
      );

      dispatch(setPost([post]));
    });
  };
};

...

export default handleActions(
  {
    [SET_POST]: (state, action) =>
      produce(state, (draft) => {
        draft.list.push(...action.payload.post_list);
        draft.list = draft.list.reduce((acc, cur) => {
          // getPostFB, getOnePostFB 에서 모두 dispatch(setPost)를 사용함
          // 이로인해 getOnePostFB에서 한번 불러온 post가 getPostFB에서도 불러와질수 있음.
          // 아래는 이 때문에 중복된걸 제거해주는 로직
          if (acc.findIndex((a) => a.id === cur.id) === -1) {
            return [...acc, cur];
          } else {
            acc[acc.findIndex((a) => a.id === cur.id)] = cur;
            return acc;
          }
        }, []);

        if (action.payload.paging) {
          draft.paging = action.payload.paging;
        }
        draft.is_loading = false;
      }),
      
  ...

firestore에서 복합쿼리 사용하기

링크

댓글 가져오기

// comment.js
import { createAction, handleActions } from "redux-actions";
import { produce } from "immer";
import { firestore } from "../../shared/firebase";
import "moment";
import moment from "moment";

const initialState = {
  list: {},
};

const SET_COMMENT = "SET_COMMENT";

const setComment = createAction(SET_COMMENT, (post_id, comment_list) => ({
  post_id,
  comment_list,
}));

const getCommentFB = (post_id) => {
  return function (dispatch, getState, { history }) {
    if (!post_id) {
      return;
    }

    const commentDB = firestore.collection("comment");

    commentDB
      .where("post_id", "==", post_id)
      .orderBy("insert_dt", "desc")
      // 복합쿼리, 별도의 firestore 설정이 있어야 사용 가능.
      .get()
      .then((docs) => {
      let list = [];

      docs.forEach((doc) => {
        list.push({ ...doc.data(), id: doc.id });
      });
      dispatch(setComment(post_id, list));
    })
      .catch((err) => {
      console.log("댓글 정보를 가져오다가 실패했는디요?");
    });
  };
};

export default handleActions(
  {
    [SET_COMMENT]: (state, action) =>
    produce(state, (draft) => {
      draft.list[action.payload.post_id] = action.payload.comment_list;
      // comment들을 담는 list를 딕셔너리 형으로 만들고
      // post_id를 key, 해당 post의 comment들(딕셔너리)을 배열 형태로 담은 comment_list를 value로 할당.
    }),
  },
  initialState
);

const actionCreators = {
  getCommentFB,
  setComment,
};

export { actionCreators };
// CommentList.js
const CommentList = (props) => {
  const dispatch = useDispatch();
  const comment_list = useSelector((state) => state.comment.list);
  const { post_id } = props;

  React.useEffect(() => {
    if (!comment_list[post_id]) {
      // 매번 firestore에 접근해서 데이터를 가져오면 리소스 낭비가 큼.
      // 해당하는 post_id가 redux 데이터에 없으면 DB에서 한번 가져오고,
      // 그 이후부터 firestore에서 한번 가져온 데이터는 redux에서 데이터를 가져옴 (아래 뷰 코드에서 map으로 가져오는중...)
      dispatch(commentActions.getCommentFB(post_id));
    }
  }, []);

  if (!comment_list[post_id] || !post_id) {
    // 아직 DB에서 데이터가 안넘어왔을때는 오류를 내니까 공백이라도 보여주자.
    return null;
  }

  return (
    <React.Fragment>
      <Grid padding="16px">
        {comment_list[post_id].map((c) => {
          return <CommentItem key={c.id} {...c} />;
        })}
      </Grid>
    </React.Fragment>
  );
};

댓글 작성하기, 댓글 카운트 늘리기

// CommentWrite.js
import React from "react";

import { Grid, Input, Button } from "../elements";

import { useDispatch } from "react-redux";
import { actionCreators as commentActions } from "../redux/modules/comment";

const CommentWrite = (props) => {
  const dispatch = useDispatch();
  const { post_id } = props;
  const [comment_text, setCommentText] = React.useState("");

  const onChange = (e) => {
    setCommentText(e.target.value);
  };

  const write = () => {
    if (comment_text === "") {
      window.alert("댓글을 입력해주세요!");
      return;
    }
    // 입력된 텍스트는 지우기!
    setCommentText("");

    // 파이어스토어에 추가합니다.
    dispatch(commentActions.addCommentFB(post_id, comment_text));
  };

  return (
    <React.Fragment>
      <Grid padding="16px" is_flex>
        <Input
          placeholder="댓글 내용을 입력해주세요 :)"
          _onChange={onChange}
          value={comment_text}
		  // 제출하면 comment_text가 "" 이렇게 되는데, 이걸 이용해서 제출 후 input을 비워주기 위함.
          onSubmit={write}
          is_Submit
          />
        <Button width="50px" margin="0px 2px 0px 2px" _onClick={write}>
        작성
        </Button>
      </Grid>
    </React.Fragment>
  );
};

CommentWrite.defaultProps = {
  post_id: "",
};

export default CommentWrite;
// Input.js
{is_Submit ? (
  // 댓글 작성창 등 submit 후 input value를 비워줘야 할때 사용
  // submit하면 value를 받아올때 setCommentText을 이용해 ""로 받아옴.
  <ElInput
    placeholder={placeholder}
    onChange={_onChange}
    type={type}
    value={value}
    onKeyPress={(e) => {
      if (e.key === "Enter") {
        onSubmit();
      }
    }}
    />
) : (
  <ElInput placeholder={placeholder} onChange={_onChange} type={type} />
  // 로그인, 회원가입 등 input value 초기화 안해도 되는곳에서 사용
)}
// comment.js
...
import firebase from "firebase/app";
import { actionCreators as postActions } from "./post";

...

const addCommentFB = (post_id, contents) => {
  return function (dispatch, getState, { history }) {
    const commentDB = firestore.collection("comment");
    const user_info = getState().user.user;

    let comment = {
      post_id: post_id,
      user_id: user_info.uid,
      user_name: user_info.user_name,
      user_profile: user_info.user_profile,
      contents: contents,
      insert_dt: moment().format("YYYY-MM-DD hh:mm:ss"),
    };

    commentDB.add(comment).then((doc) => {
      const postDB = firestore.collection("image_community");
      const post = getState().post.list.find((l) => l.id === post_id);
      const increment = firebase.firestore.FieldValue.increment(1);
      // 특정 필드의 value에 값을 더하고 싶을때 이렇게 FieldValue.increment() 쓰면 됨.
      comment = { ...comment, id: doc.id };

      postDB
        .doc(post_id)
        .update({ comment_cnt: increment })
        .then((_post) => {
        dispatch(addComment(post_id, comment));

        if (post) {
          dispatch(
            postActions.editPost(post_id, {
              comment_cnt: parseInt(post.comment_cnt) + 1,
            })
            // 겉으로 보기엔 redux에 1을 더해줌. 실제 DB comment_cnt랑 다를수 있음.
            // 새로고침하면 DB의 comment_cnt를 새로 불러옴.
          );
        }
      });
    });
  };
};

...

export default handleActions(
  {

    ...

    [ADD_COMMENT]: (state, action) =>
      produce(state, (draft) => {
        draft.list[action.payload.post_id].unshift(action.payload.comment);
      }),
    
    ...
    
  },
  initialState
);

알람 기능 구현

// NotiBadge.js
import React from "react";
import { Badge } from "@material-ui/core";
import NotificationsIcon from "@material-ui/icons/Notifications";
import { realtime } from "../shared/firebase";
import { useSelector } from "react-redux";

const NotiBadge = (props) => {
  const [is_read, setIsRead] = React.useState(true);
  const user_id = useSelector((state) => state.user.user.uid);
  const notiCheck = () => {
    const notiDB = realtime.ref(`noti/${user_id}`);
    notiDB.update({ read: true });
    props._onClick();
  };

  React.useEffect(() => {
    const notiDB = realtime.ref(`noti/${user_id}`);
    // realtime DB 가져오는법

    notiDB.on("value", (snapshot) => {
      // realtime DB에서 .on()은 변화를 계속 감지함
      if (snapshot.val()) {
        setIsRead(snapshot.val().read);
      }
    });

    return () => notiDB.off();
    // 컴포넌트 사라질대 notiDB 구독 종료
  }, [user_id]);

  return (
    <React.Fragment>
      <Badge
        color="secondary"
        variant="dot"
        invisible={is_read}
        onClick={notiCheck}
        >
        <NotificationsIcon />
      </Badge>
    </React.Fragment>
  );
};

NotiBadge.defaultProps = {
  _onClick: () => {},
};

export default NotiBadge;
// Notification.js
import React from "react";
import { Grid, Text, Image } from "../elements";
import Card from "../components/Card";
import { realtime } from "../shared/firebase";
import { useSelector } from "react-redux";

const Notification = (props) => {
  const user = useSelector((state) => state.user.user);
  const [noti, setNoti] = React.useState([]);

  React.useEffect(() => {
    if (!user) {
      return;
    }

    const notiDB = realtime.ref(`noti/${user.uid}/list`);
    const _noti = notiDB.orderByChild("insert_dt");
    _noti.once("value", (snapshot) => {
      // realtime DB에서 .once()는 한번 데이터 가져오고 끝. .on()처럼 변화를 감지하지 않음.
      if (snapshot.exists()) {
        let _data = snapshot.val();
        let _noti_list = Object.keys(_data)
        .reverse()
        .map((s) => {
          return _data[s];
        });

        setNoti(_noti_list);
      }
    });
  }, [user]);
  return (
    <>
      <Grid padding="16px" bg="#EFF6FF">
        {noti.map((n, idx) => {
          return <Card key={`noti_${idx}`} {...n}></Card>;
        })}
      </Grid>
      </>
  );
};

export default Notification;
// comment.js
if (post) {
  dispatch(
    postActions.editPost(post_id, {
      comment_cnt: parseInt(post.comment_cnt) + 1,
    })
  );

  // 댓글 갯수 업데이트 된 이후 noti DB 업데이트 진행
  if (post.user_info.user_id !== comment.user_id) {
    // realtime DB에 값 추가하는 방법
    const _noti_item = realtime.ref(
      `noti/${post.user_info.user_id}/list`
    );
    // ref로 불러옴

    _noti_item.set(
      // 데이터 추가할때 set을 사용.
      {
        post_id: post.id,
        user_name: comment.user_name,
        image_url: post.image_url,
        insert_dt: comment.insert_dt,
      },
      (err) => {
        if (err) {
          console.log("알림 저장에 실패했어요!");
        } else {
          const notiDB = realtime.ref(
            `noti/${post.user_info.user_id}`
          );
          notiDB.update({ read: false });
        }
      }
    );
  }
}
profile
빠굥

0개의 댓글