-
[React] React.js와 firebase를 사용하여 간단한 메모 앱 만들기[1] - 리스트 출력하기Frontend 2019. 8. 29. 22:25
바닐라코딩 2차 테스트가 끝났다! 예에~!
그동안 배웠던 내용, 특히 어려웠던 리액트 리덕스(Redux)를 중점적으로 공부하려고
간단한 메모 앱을 구현해보았다.
시험 하루 전날이랑 오늘이랑 해서 만드는데 2일 정도 걸렸던거 같다.
공부한거 복습용으로 급하게 만든거라 기능은 별로 없다.
[기능]
- 페이지를 로딩하면 첫 화면에 메모 작성 버튼과 메모 리스트가 있다.
- 메모 작성 버튼을 클릭하면 메모를 작성할 수 있는 form이 나온다.
- 메모 작성 후 제출 버튼을 누르면 메모가 바로 저장된다.
- 첫 화면의 메모 리스트는 메모 제목과 내용, 메모를 저장한 날짜/시간이 표시된다.
- 메모는 삭제할 수 있어야 한다.
- 메모를 클릭하면 상세 페이지로 이동한다.
- 상세 페이지에서 다시 앱 로고를 클릭하면 리스트 화면으로 돌아온다.
npx create-react-app my-app cd my-app npm start
첫 시작은 create-react-app으로 시작하였다.
필요한 모듈들을 깔아준다.
"dependencies": { "@fortawesome/fontawesome-svg-core": "^1.2.22", "@fortawesome/free-regular-svg-icons": "^5.10.2", "@fortawesome/react-fontawesome": "^0.1.4", "date-fns": "^2.0.1", "firebase": "^6.2.4", "lodash": "^4.17.15", "path-to-regexp": "^3.0.0", "react": "^16.9.0", "react-dom": "^16.9.0", "react-redux": "^7.1.0", "react-router": "^5.0.1", "react-router-dom": "^5.0.1", "react-scripts": "3.1.1", "redux": "^4.0.4", "redux-form": "^8.2.6", "redux-logger": "^3.0.6" }
앱에 사용된 모든 아이콘은 fontawesome을 사용했다.
react-fontawesome을 깔면 svg 아이콘들을 component처럼 import해서 사용할 수 있는데 매우 편하다!
게다가 spin이나 pulse같은 애니메이션까지 줄 수 있어서
로딩 이미지를 찾는대신 fontawesome 아이콘으로 간단하게 구현하였다. 매우 굿굿!
date-fns 라이브러리는 날짜를 원하는 형식으로 변환할때 매우 간단하게 사용할 수 있다.
한국어 지원도 완벽히 된다.
firebase는 매우 매우 구현하기 간단하기 때문에 연습용 프로젝트에 쓰기 딱 좋은 것 같다.
lodash는 Array나 Object 등을 iterate해서 원하는 형태로 바꿀 때 매우 유용하게 쓰이는 것 같다.
그 외에도 엄청 많은 메소드들이 있다. debounce나 throttle도 자주 쓰게 되는 것 같다.
react-router는 페이지 전환을 할 때마다 새로고침을 하지 않고 여러 화면들을 싱글 페이지 어플리케이션(SPA)으로 구현할 수 있게 해주는 라이브러리이다. 예를 들어서 우리가 구현할 메모 앱은 메모 리스트 화면과 각 메모를 클릭했을 때 나오는 상세 페이지 화면이 존재하는데 각 페이지별 주소를 따로 만들어 그 주소에 따라 다른 컴포넌트를 렌더링하는 방식이다.
redux는 지난번 포스팅에서 설명했듯이 리액트의 상태를 관리해준다!
https://im-developer.tistory.com/158
src/index.js
import React from 'react'; import ReactDOM from 'react-dom'; import { applyMiddleware, createStore } from 'redux'; import { Provider } from 'react-redux'; import logger from 'redux-logger'; import { HashRouter as Router } from 'react-router-dom'; import rootReducer from './reducers/reducers'; import App from './containers/App'; import './index.css'; const store = createStore(rootReducer, applyMiddleware(logger)); ReactDOM.render( <Provider store={store}> <Router> <App /> </Router> </Provider>, document.getElementById('root') );
redux와 router를 사용할거니까 필요한 부분들을 전부 import해준다.
그리고 containers폴더에 App.js를 만들어서 원래 있던 App.js 대신 넣어준다.
원래 있던 App.js는 component 폴더로 옮기고 containers/App.js에 연결시켜줄거다.
reducer도 필요하니까 reducers폴더에 reducer 파일을 만들어서 rootReducer라는 이름으로 꽂아준다.
containers/App.js
import { connect } from 'react-redux'; import App from '../components/App'; const mapStateToProps = state => { return { }; } const mapDispatchToProps = dispatch => ({ }); export default connect( mapStateToProps, mapDispatchToProps )(App);
일단 아무것도 넣기 전 App container의 모습.
App 컴포넌트와 connect를 시켰기 때문에
App component와 App container는 자동으로 리덕스 세상에 연결되었다.
이제 App component는 Presentational component가 되어
props로 인자를 받아서 단순히 화면을 출력해주는 용도로만 사용될 것이고
App container는 Container component가 되어서
우리가 만들 메모 앱의 상태를 관리해주는 역할로 사용될 것이다.
reducers/reducer.js
import { combineReducers } from 'redux'; import loadReducer from './loadReducer'; export default combineReducers({ loadReducer });
reducer 함수를 만들어준다.
여러 개의 reducer가 생길 수 있으므로 reducer 함수는 따로 파일로 빼고
모든 리듀서를 combine해서 export해주는 함수를 만든다.
이제 메모 앱에서 어떤 액션이 발생하면 dispatch되어 reducer 함수가 실행될 것이고
여러가지 액션 타입에 의해 state가 변경되어 container -> component에 전달될 것이다.
이제 우리가 가장 먼저 할 일은 데이터를 fetch하는 것이다.!
firebase에 미리 초기 데이터를 만들어두었다고 가정하겠다.
(혹은 json파일로 만들어서 데이터라고 치고 나중에 firebase를 끼워도 된다.
constants/actionTypes.js
export const GET_MEMO_LIST_SUCCESS = 'GET_MEMO_LIST_SUCCESS'; export const GET_MEMO_LIST_FAILURE = 'GET_MEMO_LIST_FAILURE';
데이터를 비동기로 가져오는 작업에 대한 actionType들을 미리 정의한다.
성공적으로 데이터를 가져올 수도 있고 실패할 수도 있으므로 성공했을 경우와 실패했을 경우를 적었다.
actions/index.js
import * as types from '../constants/actionTypes'; export const getMemoListSuccess = (memoList) => ({ type: types.GET_MEMO_LIST_SUCCESS, memoList: memoList }); export const getMemoListFailure = (error) => ({ type: types.GET_MEMO_LIST_FAILURE, error });
이제 action을 적을 차례이다.
메모 데이터를 가져오는 데 성공하면 메모 데이터를 state에 추가해야하니
memoList를 인자로 넣어 객체로 리턴하는 액션 함수를 만든다.
실패했을 경우엔 error 객체를 인자로 받아 객체를 만드는 액션 함수를 만든다.
reducers/loadReducer.js
const initState = { isLoading: true, isError: false, memoList: null }; const loadReducer = (state = initState, action) => { switch (action.type) { case types.GET_MEMO_LIST_SUCCESS: return { isLoading: false, isError: false, memoList: action.memoList }; case types.GET_MEMO_LIST_FAILURE: return { isLoading: false, isError: true, memoList: state.memoList }; default: return state; } };
이제 액션 타입에 따라 state를 바꿔준다.
데이터 fetch에 성공하면 isLoading을 false로 만들어주고 memoList에 데이터를 추가한다.
에러가 발생하면 isError를 true로 만들어준다.
api/api.js
import * as firebase from "firebase/app"; import "firebase/database"; import { getMemoListSuccess, getMemoListFailure } from '../actions/index'; const firebaseConfig = { ... your firebase api key, etc ... }; firebase.initializeApp(firebaseConfig); const database = firebase.database();
이제 firebase랑 연동해서 데이터를 실시간으로 가져오도록 할 것이다.
export const getMemoListApi = (dispatch) => { database.ref('memos/').on('value', snapshot => { const memoList = snapshot.val(); const convertedList = memoList ? Object.keys(memoList).map(id => ({id, ...memoList[id]})).sort((l, r) => Number(r.id) - Number(l.id)) : []; dispatch(getMemoListSuccess(convertedList)); }, error => { dispatch(getMemoListFailure(error)); }); };
firebase에서 데이터를 가져오기 위한 함수를 선언한다.
memos 경로에 timestamp값을 key로 하여 데이터를 저장해두었다.
그렇기때문에 Object type인 메모 데이터를
timestamp key를 이용하여 시간 순서대로 정렬된 배열로 변환한다.
데이터를 잘 가져왔으면 getMemoListSuccess() 액션 함수에 배열을 넣고
리턴되는 객체를 dispatch한다.
만약 error가 발생하면 두 번째 콜백 함수에서 error처리를 해주면 된다.
containers/App.js
import { connect } from 'react-redux'; import { getMemoListApi } from '../api/api'; import * as action from '../actions/index'; import { camelizeKey } from '../utils/utils'; import App from '../components/App'; const mapStateToProps = state => { const { memoList } = state.loadReducer; if (!memoList) { return { ...state.loadReducer }; } return { ...state.loadReducer, memoList: camelizeKey(memoList) }; } const mapDispatchToProps = dispatch => ({ onMemoListLoad: () => { getMemoListApi(dispatch); } }); export default connect( mapStateToProps, mapDispatchToProps )(App);
이제 아까 리듀서 함수를 실행하고 나면 container로 reducer 함수가 리턴해주는 객체가 들어온다.
그러면 결과물을 잘 가공하여 props로 변환하는 과정을 거친다.
모든 데이터는 key가 snake_case로 들어있으므로 camelCase로 변환하는
utility 함수를 만들어서 사용한 후 prop으로 리턴해주었다.
그리고 prop으로 넘겨줄 함수도 container에서 만든다.
component에서 componentDidMount() 단계에서
데이터 구독 요청을 하기 위한 함수를 만든다.
componets/App.js
import React from 'react'; import { Route, Switch, Redirect } from 'react-router'; import { Link } from 'react-router-dom'; import { faFish } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import MemoList from './MemoList'; import './css/App.css'; class App extends React.Component { componentDidMount() { this.props.onMemoListLoad(); } render() { const { isLoading, isError, memoList } = this.props; if (isLoading) { return ( <div className="loading-box"> <div className="align-center"> <FontAwesomeIcon icon={faFish} size="8x" spin /> </div> </div> ); } if (isError) { return ( <div className="error-box"> <div className="align-center"> <FontAwesomeIcon icon={faFish} size="3x" /> <p className="warning">SORRY.</p> <p>SOMETHING WENT WRONG</p> <button type="button" className="btn-back" onClick={() => { window.location.reload(); }} > RETRY </button> </div> </div> ); } return ( <div className="app-container"> <header className="memo-header"> <h1 className="app-title"> <Link to="/"> WRITE ANYTHING. </Link> </h1> </header> <div className="memo-container"> <Switch> <Redirect exact from="/" to="/memos" /> <Route exact path="/memos" render={routerProps => ( <MemoList {...routerProps} memoList={memoList} isSending={isSending} onMemoDelete={onMemoDelete} /> )} /> <Route exact path="/memos/:memoId" render={routerProps => { const memoId = routerProps.match.params.memoId; const targetMemo = memoList.find(memo => memo.id === memoId); return ( <MemoDetail {...routerProps} /> ) }} /> </Switch> </div> </div> ); } } export default App;
이제 container에서 넘겨준 state들을 prop으로 받아서 렌더링해주기만 하면 된다!
isLoading이 true이면 loading 컴포넌트를 렌더링하고
isError가 true이면 error 컴포넌트를 렌더링한다.
일단 저렇게 jsx로 길게 썼지만 나중에 따로 컴포넌트로 빼서 끼우면 깔끔하다.
메모 리스트와 메모 상세 페이지는 Router를 이용하여 처리해줄 것이다.
component/MemoList.js
import React, { useState } from 'react'; import { Link } from 'react-router-dom'; import { faPen, faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import MemoWrite from '../containers/MemoWrite'; import { setTimeForm } from '../utils/utils'; const MemoList = props => { const { memoList } = props; const [ isMemoWriteOpened, setMemoWriteStatus ] = useState(false); const hasMemo = memoList && memoList.length > 0; return ( <> <div className="chk-write-wrap"> <label className="chk-write-label"> <FontAwesomeIcon icon={faPen} /> <span className="sr-only"> Write </span> <input className="chk-write" value={isMemoWriteOpened} type="checkbox" onChange={() => setMemoWriteStatus(!isMemoWriteOpened)} /> </label> </div> {isMemoWriteOpened && <MemoWrite />} <ul className="memo-list"> {!hasMemo && ( <li className="no-memo"> <div className="align-center"> <h2 className="title">No Memos</h2> <p> Write anything you want! </p> </div> </li> )} {hasMemo && memoList.map(memo => ( <li key={memo.id}> <Link to={`/memos/${memo.id}`}> <h2 className="memo-title">{memo.title}</h2> <div className="memo-content">{memo.content}</div> <span className="memo-date"> {setTimeForm(memo.createdAt)} </span> </Link> <button type="button" className="btn-delete" > <FontAwesomeIcon icon={faTimes} /> <span className="sr-only" > delete </span> </button> </li> )) } </ul> </> ); }; export default MemoList;
메모 리스트는 functional component로 만들었다.
일단 상단에 입력 폼이 있는데, 그 부분은 따로 MemoWrite라는 컴포넌트로 만들었다.
상단에 버튼이 있고 클릭 여부에 따라서 입력 폼이 보여졌다 안보여졌다 해야하는데
그 부분은 체크박스로 구현하기로 했다.
isMemoWriteOpened라는 state를 만들고 처음에는 false를 default value로 설정한다.
checkbox에 value는 isMemoWriteOpened state에 의해 좌우되도록 한다.
checkbox에 onChange이벤트를 걸어서 체크될 때마다 state의 불린 값을 바꿔준다.
근데 생각해보니 button으로 했어도 됐는데
굳이 checkbox로 했다는 생각이;;
그리고 memoList 배열에 데이터가 있으면
그 데이터를 순회하면서 li 태그를 렌더링한다.
여기서 중요한 것은 a태그 대신에 router의 Link를 사용하는것!
클릭하면 /memos/memo.id로 넘어가도록 한다.
데이터가 없으면 데이터 없다는 걸 표시해주는 페이지를 렌더링한다.
components/MemoDetail.js
import React from 'react'; import { setTimeForm } from '../utils/utils'; const MemoDetail = props => { const { targetMemo: { id, title, content, createdAt } } = props; const convertNewlineSpace = content => { return content.split('\n').map((line, i) => ( <p key={i}> {line.split(' ').map((word, i) => <span key={i}>{word} </span>)} </p> )) }; return ( <div className="memo-detail" key={id} > <h3 className="memo-title">{title}</h3> <div className="memo-content"> {convertNewlineSpace(content)} </div> <div className="data-area"> <span className="memo-date"> {setTimeForm(createdAt)} </span> </div> </div> ); }; export default MemoDetail;
이제 리스트를 클릭하면 상세 페이지를 출력해보도록 하자!
나는 데이터에 있는 space나 new line을 잘 반영해주고 싶기 때문에
데이터로 들어온 글을 '\n'기준으로 잘라서 <p>태그에 넣어주었고,
공백을 기준으로 다시 잘라서 <span>태그와 함께 를 넣어주었다.
이제 여기까지 하면 리스트가 원하는대로 출력된다!
글 작성과 삭제는 다음 포스팅에서!
참고자료:
https://ko.reactjs.org/docs/create-a-new-react-app.html
https://firebase.google.com/docs/web/setup?hl=ko
https://reacttraining.com/react-router/web/guides/quick-start
https://redux.js.org/basics/usage-with-react
https://deminoth.github.io/redux/basics/UsageWithReact.html반응형'Frontend' 카테고리의 다른 글
[React] React-Map-Gl 라이브러리를 사용하여 지도 띄우고 마커 표시하기 (2044) 2019.08.30 [React] React.js와 firebase를 사용하여 간단한 메모 앱 만들기[2] - 메모 저장 및 삭제하기 (609) 2019.08.30 [React] 리덕스 (Redux) 이해하기 (609) 2019.08.24 [React] React Hook을 이용한 data fetching (609) 2019.08.18 [React] Component Life Cycle / 컴포넌트 생명주기 (609) 2019.08.17 COMMENT