Redux 연동하기
버전이 다르기 때문에 소스가 강좌와 다를 수 있다.
버전
next: 13.0.4
antd: 5.0.1
소스 : https://github.dev/braverokmc79/node-bird-sns
13. 리덕스 설치와 필요성 소개
설치
$ npm i next-redux-wrapper
$ npm i react-redux redux redux-promise redux-thunk
중앙데이타저장소 Store
- 컴포넌트에서 공통적으로 쓰이는 데이타가 흩어져있기 때문에 부모 컴포넌트에서 데이타를 받아서 자식 컴포넌트에게 각각 보내줘야한다
- 컴포넌트끼리 데이타를 전달하는 과정도 매우 복잡하고 오류가 발생하기도 쉽다.
- 규모가 있는 서비스라면 중앙데이타저장소 Store를 최소 한 개 만들어 중앙에서 모든 데이타를 관리하고 보내주는 것이 편하다
- 중앙데이타저장소는 Redux, React의 contextedAPI, Mobax, Apolo 등이 있다
Redux
- Redux는 원리가 간단하고 모든 수정사항을 기록한다.
- 모든 히스토리가 기록되어 에러 추적이 쉽고 안정적이다.
- 하지만 코드량이 많아진다.
- 중앙은 앱이 커지면 데이타를 쪼개는 작업이 필요한데 Redux는 reducer들을 쪼개는 기능을 제공해주어 편리하다 (reducer란 전달받은 action에 따라 상태를 어떻게 업데이트할지 정의해주는 함수)
- 사용법이 쉬워 초보자에게 추천되지만 전체적으로 가장 많이 사용되는 라이브러리기도 하다
Mobax
- Mobax는 코드량이 매우 적어 생산성을 높일 수 있다
- 하지만 실수를 했을 때 추적이 어렵다
- 초보를 벗어나면 생산성을 위해 추천되는 라이브러리지만 Redux보다는 사용률이 낮다
Context API
- 앱 규모가 작을 때 권장된다
- 큰 규모의 프로젝트는 차라리 리덕스나 모백스를 사용하는 것이 낫다
- 중앙데이타저장소는 서버에서 데이타를 받아오는데 이 과정은 비동기다
- 서버가 고장나거나 네트워크 에러가 생기면 데이타가 안 올수 있다
- 실패에 대비하기위해 요청, 성공, 실패 3단계를 직접 구현해야한다.
- 3단계 구현 코드를 컴포넌트마다 넣어줘야해 의도치 않은 코드 중복이 발생
- 위의 코드만 밖으로 따로 빼낼 수 있지만 비동기 요청이 많으면 context API도 리덕스와 모백스와 비슷해진다
- 따라서 리덕스나 모백스 등이 알아서 처리해주는 것이 편하다
useEffect(() => { axios.get('/data') .then(() => { setState(data); }) .catch(() => { setError(error); }) })
Redux && redux-wrapper
설치
넥스트가 설치되어있는 front 디렉토리에서 리덕스 설치
npm i reduxRedux의 손쉬운 사용을 위해 redux-wrapper 설치 (Redux와 사용법이 약간 다르다)
npm i next-redux-wrapperfront/store/configureStore.js 파일 생성
import { createWrapper } from 'next-redux-wrapper'; import {createStore} from 'redux'; const configureSotre = () => { const store = createStore(reducer); return store; }; const wrapper = createWrapper(configureSotre, { debug: process.env.NODE_ENV === 'development,' }); export default wrapper;
- _app.js 파일 수정
- wrapper 불러오기
- wrapper를 사용해서 내보내기
- Nodebird는 앱 이름
import wrapper from '../store/configureStore.js` . . . export default wrapper.withRedux(Nodebird); //wrapper로 감싸줘야 프로젝트의 모든 컴포넌트와 페이지에 적용된다
- 예전에는 _app.js의 return 내부를 Provider로 감싸야했는데 지금은 redux가 알아서 감싸주니 비워두어야한다 (사용자가 감싸면 중복되어 에러 발생)
//before return ( <Provider store={store}> </Provider> ); //from redux@6 return ( <> </> );
14. 리덕스의 원리와 불변성
Redux의 원리
진행 과정
- 앱의 상태를 객체 형식으로 작성한다 state (좌)
- 변경하고 싶은 내용을 action으로 만든다 (우)
- action의 이름을 적는 type
- 변경사항을 적는 data
- action을 dispatch한다
- dispatch된 action을 reducer에 따라 처리한다 (아래)
- reducer는 action 어떻게 처리할지 정의해준다
- 예를 들어 switch를 값을 변경해주는 것이다
- case에 action의 type을 적어준다
- 바꾸고싶은 값에 action.data를 넣어준다
action을 생성해서 무엇으로 변경할지 적어준다
- 변경사항마다 action을 만들어줘야한다
{ type: 'CHANGE_NICKNAME' // action이름 data: 'boogicho' // 변경될 데이타값 } { type: 'CHANGE_AGE' data: 30, }
15. 리덕스의 원리와 불변성
pages/_app.js
~ import wrapper from '../store/configureStore'; ~ export default wrapper.withRedux(NodeBird);
$ npm i redux-devtools-extension
store/configureStore.js
import { createWrapper } from 'next-redux-wrapper'; import { compose, applyMiddleware, legacy_createStore as createStore } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension'; import reducer from '../reducers'; const configureSotre = () => { const middlewares = []; const enhancer = process.env.NODE_ENV === 'production' ? compose(applyMiddleware(...middlewares)) : composeWithDevTools(applyMiddleware(...middlewares)); const store = createStore(reducer, enhancer); return store; }; const wrapper = createWrapper(configureSotre, { debug: process.env.NODE_ENV === 'development,' }); export default wrapper; // import Reducer from './_reducers'; // import { applyMiddleware, legacy_createStore as createStore } from 'redux'; // import promiseMiddleware from 'redux-promise'; // import ReduxThunk from 'redux-thunk'; // const createStoreWithMiddleware = applyMiddleware(promiseMiddleware, ReduxThunk)(createStore); // const devTools = window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(); // createStoreWithMiddleware(Reducer, devTools)
reducer/index.js
const initialState = { user: { isLoggedIn: false, user: null, signUpdata: {}, loginData: {} }, post: { mainPosts: [] } } export const loginAction = (data) => { return { type: "LOG_IN", data } } export const logoutAction = () => { return { type: "LOG_OUT" } } //(이전상태,액션) => 다음 상태 const rootReducer = (state = initialState, action) => { switch (action.type) { case 'LOG_IN': return { ...state, user: { ...state.user, isLoggedIn: true, user: action.data } } case 'LOG_OUT': return { ...state, user: { ...state.user, isLoggedIn: false, user: null } } default: return state; } } export default rootReducer;
components/AppLayout.js
import { useSelector } from 'react-redux'; ~ const AppLayout = ({ children }) => { const isLoggedIn = useSelector((state) => state.user.isLoggedIn); ~ ~ <Col xs={24} md={6} className="mt-20"> {isLoggedIn ? <UserProfile /> : <LoginForm />} </Col> ~
componets/UserProfile.js
import React, { useCallback } from 'react'; import { Card, Avatar, Button } from 'antd'; import { useDispatch } from 'react-redux'; import { logoutAction } from '../reducers'; const UserProfile = () => { const dispatch = useDispatch(); const onLogOut = useCallback(() => { dispatch(logoutAction(false)); }, []); ~
16. 미들웨어와 리덕스 데브툴즈
강의 :
$ npm i redux-devtools-extension
import { createWrapper } from 'next-redux-wrapper'; import { compose, applyMiddleware, legacy_createStore as createStore } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension'; import reducer from '../reducers'; const configureSotre = () => { const middlewares = []; const enhancer = process.env.NODE_ENV === 'production' ? compose(applyMiddleware(...middlewares)) : composeWithDevTools(applyMiddleware(...middlewares)); const store = createStore(reducer, enhancer); return store; }; const wrapper = createWrapper(configureSotre, { debug: process.env.NODE_ENV === 'development,' }); export default wrapper; // import Reducer from './_reducers'; // import { applyMiddleware, legacy_createStore as createStore } from 'redux'; // import promiseMiddleware from 'redux-promise'; // import ReduxThunk from 'redux-thunk'; // const createStoreWithMiddleware = applyMiddleware(promiseMiddleware, ReduxThunk)(createStore); // const devTools = window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(); // createStoreWithMiddleware(Reducer, devTools)
17. 리듀서 쪼개기
reducers/index.js
import { HYDRATE } from 'next-redux-wrapper'; import { combineReducers } from 'redux'; import user from './user'; import post from './post'; //(이전상태,액션) => 다음 상태 const rootReducer = combineReducers({ index: (state = {}, action) => { switch (action.type) { case HYDRATE: console.log(' HYDRATE ', action); return { ...state, ...action.payload }; default: return state; } }, user, post }); export default rootReducer;
reducers/user.js
export const initialState = { isLoggedIn: false, user: null, signUpdata: {}, loginData: {} } export const loginAction = (data) => { return { type: "LOG_IN", data } } export const logoutAction = () => { return { type: "LOG_OUT" } } const reducer = (state = initialState, action) => { switch (action.type) { case 'LOG_IN': return { ...state, isLoggedIn: true, user: action.data } case 'LOG_OUT': return { ...state, isLoggedIn: false, user: null } default: return state; } } export default reducer;
reducers/post.js
const initialState = { mainPosts: [], } const reducer = (state = initialState, action) => { switch (action.type) { default: return state; } } export default reducer;
[Next.js] Hydrate란?
Hydrate란, Server Side단에서 렌더링 된 정적 페이지와 번들링된 js파일(Webpack)을 클라이언트에게 보낸 뒤, 클라이언트 단에서 HTML 코드와 React인 js 코드를 서로 매칭시키는 과정을 말합니다.
일단, Client Side Rendering인 기본 React에서는 js파일만을 이용하여 public/index.html의 기본 뼈대만 있는 내용을 제외하고 src/index.js의 자바스크립트 코드에서 모든 화면을 렌더링 한 뒤, HTML DOM 요소 중 root라는 엘리먼트를 찾아 하위로 주입을 하여 웹 화면이 구성합니다.
Next.js에서는 클라이언트에게 웹 페이지를 보내기 전에 Server Side 단에서 미리 웹 페이지를 Pre-Rendering 합니다.
Pre-Rendering하면 HTML document가 생성되고, 이 파일을 클라이언트에게 전송합니다.
이때, 클라이언트가 받은 웹페이지는 단순한 웹 화면만 보여주는 HTML일 뿐이고 자바스크립트 요소는 하나도 없는 상태입니다. 이는 웹 화면을 보여주고 있지만, 특정 JS 모듈 뿐만 아니라 단순 클릭과 같은 이벤트 리스너들도 각 웹페이지의 DOM 요소에 적용되어 있지 않은 상태를 말합니다.
- React.js : Html과 JS파일을 한꺼번에 보내고 클라이언트가 js 코드를 통해 웹 화면을 렌더링
- Next.js : Pre-Rendering된 웹 페이지를 클라이언트에게먼저 보내고, React가 번들링된 자바스크립트 코드들을 클라이언트에게 전송함.
참조 : https://narup.tistory.com/230
18. 더미데이터와 포스트폼 만들기
강의 :
pages/index.js
import React from 'react'; import AppLayout from './../components/AppLayout'; import { useSelector } from 'react-redux'; import PostCard from './../components/PostCard'; import PostForm from './../components/PostForm'; const Index = () => { const { isLoggedIn } = useSelector((state) => state.user); const { mainPosts } = useSelector((state) => state.post); return ( <AppLayout> {isLoggedIn && <PostForm />} {mainPosts.map((post) => <PostCard key={post.id} post={post} />)} </AppLayout> ); }; export default Index;
components/PostCard.js
import React from 'react'; const PostCard = () => { return ( <div> PostCard </div> ); }; export default PostCard;
components/PostForm.js
import React, { useCallback, useRef, useState } from 'react'; import { Form, Input, Button } from 'antd'; import { useSelector, useDispatch } from 'react-redux'; import { addPost } from '../reducers/post'; const PostForm = () => { const { imagePaths } = useSelector((state) => state.post); const dispatch = useDispatch(); const imageInput = useRef(); const [text, setText] = useState(''); const onChangeText = useCallback((e) => { setText(e.target.value); }, []); const onSubmit = useCallback(() => { dispatch(addPost); setText(""); }, []); const onClickImageUpload = useCallback(() => { imageInput.current.click(); }, [imageInput.current]); return ( <Form style={{ margin: '10px 0 20px' }} encType="multipart/form-data" onFinish={onSubmit}> <Input.TextArea value={text} onChange={onChangeText} maxLength={140} placeholder="어떤 신기한 일이 있었나요?" /> <div> <input type="file" multiple hidden ref={imageInput} style={{ display: "none" }} /> <Button onClick={onClickImageUpload}>이미지 업로드</Button> <Button type="primary" htmlType='submit' style={{ float: 'right' }} >짹짹</Button> </div> <div> { imagePaths.map((v) => ( <div key={v} style={{ display: "inline-block" }}> <img src={v} style={{ width: '200px' }} alt={v} /> <div> <Button>제거</Button> </div> </div> )) } </div> </Form> ); }; export default PostForm;
19. 게시글 구현하기
components/PostCard.js
import React, { useState, useCallback } from 'react'; import { Card, ButtonGroup, Button, Avatar, Image, Popover, Space } from 'antd'; import { RetweetOutlined, HeartOutlined, MessageOutlined, EllipsisOutlined, HeartTwoTone } from '@ant-design/icons'; import PropTypes from 'prop-types' import { useSelector } from 'react-redux'; import PostImages from './PostImages'; const PostCard = ({ post }) => { const id = useSelector((state) => state.user.me?.id); const [liked, setLiked] = useState(false); const [commentFormOpened, setCommentFormOpened] = useState(false); const onToggleLike = useCallback(() => { setLiked((prev) => !prev); }, []); const onToggleComment = useCallback(() => { setCommentFormOpened((prev) => !prev); }, []) const content = ( <div> {id && post.User.id === id ? ( <> <Button>수정</Button> <Button type='danger'>삭제</Button> </> ) : ( <Button>신고</Button> ) } </div > ); return ( <div style={{ marginBottom: 30 }}> <Card cover={post.Images[0] && <PostImages images={post.Images} />} actions={[ <RetweetOutlined key="retweet" />, liked ? <HeartTwoTone key="heart" twoToneColor="#ebef96" onClick={onToggleLike} /> : <HeartOutlined key="heart" onClick={onToggleLike} />, <MessageOutlined key="comment" onClick={onToggleComment} />, <Popover content={content} title="더보기" key="popover" style={{ textAlign: "center" }}> <EllipsisOutlined /> </Popover> ]} > <Card.Meta avatar={<Avatar>{post.User.nickname[0]}</Avatar>} title={post.User.nickname} description={post.content} /> <Image /> <Button></Button> </Card > {commentFormOpened && ( <div> 댓글 부분 </div> )} {/* <CommentForm /> <Comments /> */} </div > ); }; PostCard.propTypes = { post: PropTypes.shape({ id: PropTypes.number, User: PropTypes.object, content: PropTypes.string, createdAt: PropTypes.object, Comment: PropTypes.arrayOf(PropTypes.object), Images: PropTypes.arrayOf(PropTypes.object) }).isRequired } export default PostCard;
components/PostImages.js
import React from 'react'; const PostImages = () => { return ( <div> PostImages </div> ); }; export default PostImages;
20. 댓글 구현하기
components/PostCard.js
import React, { useState, useCallback } from 'react'; import { Card, Button, Avatar, Image, Popover, List } from 'antd'; import { Comment } from '@ant-design/compatible'; import { RetweetOutlined, HeartOutlined, MessageOutlined, EllipsisOutlined, HeartTwoTone } from '@ant-design/icons'; import PropTypes from 'prop-types' import { useSelector } from 'react-redux'; import PostImages from './PostImages'; import CommentForm from './CommentForm'; //import Comment from './Comment'; const PostCard = ({ post }) => { const id = useSelector((state) => state.user.me?.id); const [liked, setLiked] = useState(false); const [commentFormOpened, setCommentFormOpened] = useState(false); const onToggleLike = useCallback(() => { setLiked((prev) => !prev); }, []); const onToggleComment = useCallback(() => { setCommentFormOpened((prev) => !prev); }, []) const content = ( <div> {id && post.User.id === id ? ( <> <Button>수정</Button> <Button type='danger'>삭제</Button> </> ) : ( <Button>신고</Button> ) } </div > ); return ( <div style={{ marginBottom: 30 }}> <Card cover={post.Images[0] && <PostImages images={post.Images} />} actions={[ <RetweetOutlined key="retweet" />, liked ? <HeartTwoTone key="heart" twoToneColor="#ebef96" onClick={onToggleLike} /> : <HeartOutlined key="heart" onClick={onToggleLike} />, <MessageOutlined key="comment" onClick={onToggleComment} />, <Popover content={content} title="더보기" key="popover" style={{ textAlign: "center" }}> <EllipsisOutlined /> </Popover> ]} > <Card.Meta avatar={<Avatar>{post.User.nickname[0]}</Avatar>} title={post.User.nickname} description={post.content} /> <Image /> </Card > {commentFormOpened && ( <div> <CommentForm post={post} /> <List header={`${post.Comments.length} 개의 댓글`} itemLayout="horizontal" dataSource={post.Comments} renderItem={(item) => ( <li> <Comment author={item.User.nickname} avatar={<Avatar>{item.User.nickname[0]}</Avatar>} content={item.content} /> </li> )} /> </div> )} {/* <CommentForm /> <Comments /> */} </div > ); }; PostCard.propTypes = { post: PropTypes.shape({ id: PropTypes.number, User: PropTypes.object, content: PropTypes.string, createdAt: PropTypes.object, Comment: PropTypes.arrayOf(PropTypes.object), Images: PropTypes.arrayOf(PropTypes.object) }).isRequired } export default PostCard;
21. 이미지 구현하기
components/PostImages.js
import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import { PlusOutlined } from '@ant-design/icons'; const PostImages = ({ images }) => { const [showImageZoom, setShowImagesZoom] = useState(false); const onZoom = useCallback(() => { setShowImagesZoom(true); }, []); if (images.length === 1) { return ( <> <img role="presentation" src={images[0].src} alt={images[0].src} onClick={onZoom} width="50%" /> </> ) } else if (images.length === 2) { return ( <> <div> <img role="presentation" src={images[0].src} alt={images[0].src} onClick={onZoom} style={{ width: "50%" }} /> <img role="presentation" src={images[1].src} alt={images[1].src} onClick={onZoom} style={{ width: "50%" }} /> </div> </> ) } else if (images.length > 2) { return ( <> <div> <img role="presentation" src={images[0].src} alt={images[0].src} onClick={onZoom} style={{ width: "50%" }} /> <div role="presentation" style={{ display: 'inline-block', width: '50%', textAlign: 'center', verticalAlign: 'middle' }} onClick={onZoom} > <PlusOutlined /> <br /> {images.length - 1} 개의 사진 더보기 </div> </div> </> ); } }; PostImages.propTypes = { image: PropTypes.object.isRequired } export default PostImages;
22. 이미지 캐루셀 구현하기(react-slick)
강의 :
슬릭 슬라이드 :
$ npm i react-slick $ npm install slick-carousel
components/ImagesZoom/index.js
import React, { useState } from 'react'; import PropTypes from 'prop-types'; import Slick from 'react-slick'; import "slick-carousel/slick/slick.css"; import "slick-carousel/slick/slick-theme.css"; import styled, { createGlobalStyle } from 'styled-components'; const Overlay = styled.div` position:fixed; z-index:5000; top:0; left:0; right:0; bottom:0; `; const Header = styled.header` height:44px; background:white; position:relative; padding:0; text-align:center; & h1{ margin:0; font-size:17px; color:#333; line-height:44px; } & button{ position:absolute; right:0; top:0; padding:15px; line-height:10px; cursor:pointer; background: #faad14; color: #fff; border: 1px solid #fa8c16; } `; const SlickWrapper = styled.div` height:calc(100% -44px); background:rgba(0, 0, 0, 0.9); border:none !important; height: 100%; `; const ImageWrapper = styled.div` padding:32px; text-align:center; cursor:pointer; &img{ margin:0 auto; max-height:750px; display: inline-block !important; } `; const Indicator = styled.div` text-align :cetner; & > div{ width:75px; height:30px; line-height:30px; border-radius:15px; background:#313131; display:inline-block; text-align:center; top:60px; background:#fff; position: relative; } `; const Global = createGlobalStyle` .slick-slide{ display:inline-block; } .ant-card-cover{ transform:none !important; } .slick-dots li button:before{ color:#fff; opacity:1; } .slick-dots li.slick-active button:before { opacity: 1; color:#faad14; } .slick-prev, .slick-nex{ width: 50px; height: 50px; } .slick-prev { left: 15%; } .slick-next{ right:15%; } `; const ImagesZoom = ({ images, onClose }) => { const settings = { dots: true, infinite: true, speed: 1000, //autoplay: true, autoplayspeed: 1000, slidesToShow: 1, slidesToScroll: 1, arrows: false, pauseOnHover: true, }; const [currentSlide, setCurrentSlide] = useState(0); return ( <Overlay> <Global /> <Header> <h1>상세 이미지 {images.length}</h1> <button onClick={onClose} >x</button> </Header> <SlickWrapper> <div> <Slick // settings={settings} initialSlide={0} afterChange={(slide) => setCurrentSlide(slide)} infinite={true} dots={true} arrows={true} slidesToShow={1} slidesToScroll={1} > {images && images.map((v) => { return ( <ImageWrapper key={v.src}> <img src={v.src} alt={v.src} /> </ImageWrapper> ) })} </Slick> <Indicator> <div> {currentSlide + 1} {' / '} {images.length} </div> </Indicator> </div> </SlickWrapper> </Overlay> ); }; ImagesZoom.propTypes = { images: PropTypes.arrayOf(PropTypes.object).isRequired, onClose: PropTypes.func.isRequired, } export default ImagesZoom;
23. 글로벌 스타일과 컴포넌트 폴더 구조
강의 :
components/ImagesZoom/styles.js
import styled, { createGlobalStyle } from 'styled-components'; export const Overlay = styled.div` position:fixed; z-index:5000; top:0; left:0; right:0; bottom:0; `; export const Header = styled.header` height:44px; background:white; position:relative; padding:0; text-align:center; & h1{ margin:0; font-size:17px; color:#333; line-height:44px; } & button{ position:absolute; right:0; top:0; padding:15px; line-height:10px; cursor:pointer; background: #faad14; color: #fff; border: 1px solid #fa8c16; } `; export const SlickWrapper = styled.div` height:calc(100% -44px); background:rgba(0, 0, 0, 0.9); border:none !important; height: 100%; `; export const ImageWrapper = styled.div` padding:32px; text-align:center; cursor:pointer; &img{ margin:0 auto; max-height:750px; display: inline-block !important; } `; export const Indicator = styled.div` text-align :cetner; & > div{ width:75px; height:30px; line-height:30px; border-radius:15px; background:#313131; display:inline-block; text-align:center; top:60px; background:#fff; position: relative; } `; export const Global = createGlobalStyle` .slick-slide{ display:inline-block; } .ant-card-cover{ transform:none !important; } .slick-dots li button:before{ color:#fff; opacity:1; } .slick-dots li.slick-active button:before { opacity: 1; color:#faad14; } .slick-prev, .slick-nex{ width: 50px; height: 50px; } .slick-prev { left: 15%; } .slick-next{ right:15%; } `;
components/ImagesZoom/index.js
import React, { useState } from 'react'; import PropTypes from 'prop-types'; import Slick from 'react-slick'; import "slick-carousel/slick/slick.css"; import "slick-carousel/slick/slick-theme.css"; import { Overlay, Global, Header, SlickWrapper, ImageWrapper, Indicator } from "./styles"; const ImagesZoom = ({ images, onClose }) => { const settings = { dots: true, infinite: true, speed: 1000, //autoplay: true, autoplayspeed: 1000, slidesToShow: 1, slidesToScroll: 1, arrows: false, pauseOnHover: true, }; const [currentSlide, setCurrentSlide] = useState(0); return ( <Overlay> <Global /> <Header> <h1>상세 이미지 {images.length}</h1> <button onClick={onClose} >x</button> </Header> <SlickWrapper> <div> <Slick // settings={settings} initialSlide={0} //afterChange={(slide) => setCurrentSlide(slide)} beforeChange={(slide) => setCurrentSlide(slide)} infinite={true} dots={true} arrows={true} slidesToShow={1} slidesToScroll={1} > {images && images.map((v) => { return ( <ImageWrapper key={v.src}> <img src={v.src} alt={v.src} /> </ImageWrapper> ) })} </Slick> <Indicator> <div> {currentSlide + 1} {' / '} {images.length} </div> </Indicator> </div> </SlickWrapper> </Overlay> ); }; ImagesZoom.propTypes = { images: PropTypes.arrayOf(PropTypes.object).isRequired, onClose: PropTypes.func.isRequired, } export default ImagesZoom;
24. 게시글 해시태그 링크로 만들기
강의
:
정규식 사이트
components/PostCard.js
import PostCardContent from './PostCardContent'; ~ <Card.Meta avatar={<Avatar>{post.User.nickname[0]}</Avatar>} title={post.User.nickname} description={<PostCardContent postData={post.content} />} /> ~
components/PostCardContent.js
import React from 'react'; import PropTypes from 'prop-types'; import Link from 'next/link'; const PostCardContent = ({ postData }) => { //첫번째 게시글 #해시태그 #익스프레스 return ( <div> {postData.split(/(#[^\s#]+)/g).map((v, index) => { if (v.match(/(#[^\s#]+)/)) { return <Link key={index} href={`/hashtag/${v.slice(1)}`}>{v}</Link> } return v; })} </div> ); }; PostCardContent.propTypes = { postData: PropTypes.string.isRequired } export default PostCardContent;
댓글 ( 4)
댓글 남기기