Next.js 서버사이드렌더링
버전이 다르기 때문에 소스가 강좌와 다를 수 있다.
버전
next: 13.0.4
antd: 5.0.1
소스 : https://github.dev/braverokmc79/node-bird-sns
제로초 소스 : https://github.com/ZeroCho/react-nodebird
69. getStaticPaths
강의 :
리액트
pages/post/[id].js
//post/[id].js import { useSelector } from 'react-redux'; import Head from 'next/head'; import { useRouter } from 'next/router'; import AppLayout from '../../components/AppLayout'; import PostCard from '../../components/PostCard'; import { LOAD_POST_REQUEST } from './../../reducers/post'; import { LOAD_MY_INFO_REQUEST } from './../../reducers/user'; import { END } from 'redux-saga'; import wrapper from '../../store/configureStore'; import axios from 'axios'; const Post = () => { const router = useRouter(); const { id } = router.query; const { singlePost } = useSelector((state) => state.post) // if (router.isFallback) { // return <div>로딩중...</div>; // } return ( <AppLayout> {singlePost && <Head> <title> {singlePost.User.nickname} 님의 글 </title> <meta name="description" content={singlePost.content} /> <meta property="og:title" content={`${singlePost.User.nickname}님의 게시글`} /> <meta property="og:description" content={singlePost.content} /> <meta property="og:image" content={singlePost.Images[0] ? singlePost.Images[0].src : 'https://nodebird.com/favicon.ico'} /> <meta property="og:url" content={`https://nodebird.com/post/${id}`} /> </Head> } {singlePost && <PostCard key={id && id} post={singlePost} />} {singlePost == null && '등록된 게시글이 없습니다.'} </AppLayout> ); }; //getStaticProps 는 다이나믹 패스에서 사용하는데, //다이나믹이니깐 어떤것을 만들어 줘야할지 모른다 따라서, //다음과 해당 id 만 정적인 html 만들어 준다 // export async function getStaticPaths() { // return { // paths: [ // { params: { id: '16' } }, // { params: { id: '17' } }, // { params: { id: '18' } }, // ], // fallback: true, // }; // } //getServerSideProps // export const getStaticProps = wrapper.getStaticProps(async (context) => { // const cookie = context.req ? context.req.headers.cookie : ''; // console.log("context : ::: ", context); // axios.defaults.headers.Cookie = ''; // if (context.req && cookie) { // axios.defaults.headers.Cookie = cookie; // } // context.store.dispatch({ // type: LOAD_MY_INFO_REQUEST, // }); // context.store.dispatch({ // type: LOAD_POST_REQUEST, // data: context.params.id, // }); // context.store.dispatch(END); // await context.store.sagaTask.toPromise(); // }); //새로 고침시 유지 export const getServerSideProps = wrapper.getServerSideProps((store) => async ({ req, res, ...etc }) => { console.log(" reqreqreqreq ", req); const cookie = req ? req.headers.cookie : ''; axios.defaults.headers.Cookie = ''; if (req && cookie) { axios.defaults.headers.Cookie = cookie; } store.dispatch({ type: LOAD_MY_INFO_REQUEST, }); store.dispatch({ type: LOAD_POST_REQUEST, postId: req.url.replace('/post/', '') }); store.dispatch(END); await store.sagaTask.toPromise(); }) export default Post;
70. swr 사용해보기
=> React(Nextjs) 프로젝트는 ★ Redux를 Saga 대체 SWR 사용하기
swr 라이브러리 설치후
npm i swr
다음과 같이 사용하면 된다.
~ const fetcher = (url) => axios.get(url, { withCredentials: true }).then((result) => result.data); const backUrl = 'http://localhost:3065'; const [followersLimit, setFollowersLimit] = useState(3); const [followingsLimit, setFollowingsLimit] = useState(3); const { data: followingsData, error: followingError } = useSWR(`${backUrl}/user/followings?limit=${followingsLimit}`, fetcher); const { data: followersData, error: followerError } = useSWR(`${backUrl}/user/followers?limit=${followersLimit}`, fetcher); ~
백엔드
routes/user.js
~ //팔로워 불러워기 router.get('/followers', isLoggedIn, async (req, res, next) => { //get /user/followers console.log(" 팔로워 불러워기 followers : "); try { //패스포트에 로그인한 user id 값 : req.user.id const user = await User.findOne({ where: { id: req.user.id } }); //3개씩 불러오기 const followers = await user.getFollowers({ limit: parseInt(req.query.limit, 10) }); res.status(200).json(followers) } catch (error) { console.error(error); next(error); } }); // 팔로잉 불러워기 시퀄라이즈에서 다음과 같이 Followings 처리를 해서 getFollowings 적용 됨 router.get('/followings', isLoggedIn, async (req, res, next) => { //get /user/followings try { const user = await User.findOne({ where: { id: req.user.id } }); const followings = await user.getFollowings({ limit: parseInt(req.query.limit, 10) }); res.status(200).json(followings); } catch (error) { console.error(error); next(error); } }); ~
프론트엔드
pages/profile.js
import React, { useEffect, useState, useCallback } from 'react'; import AppLayout from './../components/AppLayout'; import Head from 'next/head'; import { useSelector } from 'react-redux'; import Router from 'next/router'; import FollowList from './../components/FollowList'; import NicknameEditForm from './../components/NicknameEditForm'; import { LOAD_MY_INFO_REQUEST } from '../reducers/user'; import { END } from 'redux-saga'; import wrapper from '../store/configureStore'; import axios from 'axios'; import useSWR from 'swr'; const fetcher = (url) => axios.get(url, { withCredentials: true }).then((result) => result.data); const backUrl = 'http://localhost:3065'; const Profile = () => { const { me } = useSelector((state) => state.user); const [followersLimit, setFollowersLimit] = useState(3); const [followingsLimit, setFollowingsLimit] = useState(3); const { data: followingsData, error: followingError } = useSWR(`${backUrl}/user/followings?limit=${followingsLimit}`, fetcher); const { data: followersData, error: followerError } = useSWR(`${backUrl}/user/followers?limit=${followersLimit}`, fetcher); useEffect(() => { if (!(me && me.id)) { Router.replace('/'); } }, [me && me.id]) const loadMoreFollowings = useCallback(() => { setFollowingsLimit((prev) => prev + 3); }, []); const loadMoreFollowers = useCallback(() => { setFollowersLimit((prev) => prev + 3); }, []); if (!me) { return '내 정보 로딩중...'; } if (followerError || followingError) { console.error(followerError || followingError); return <div>팔로잉/팔로워 로딩 중 에러가 발생합니다.</div>; } return ( <> <Head> <title>프로필 | NodeBird</title> </Head> <AppLayout> <NicknameEditForm /> <div style={{ marginBottom: 20 }}></div> {/* <FollowList header="팔로잉" data={me.Followings} /> <FollowList header="팔로워" data={me.Followers} /> */} <FollowList header="팔로잉" data={followingsData} onClickMore={loadMoreFollowings} loading={!followingsData && !followingError} /> <FollowList header="팔로워" data={followersData} onClickMore={loadMoreFollowers} loading={!followersData && !followerError} /> </AppLayout> </> ); }; //새로 고침시 유지 export const getServerSideProps = wrapper.getServerSideProps((store) => async ({ req, res, ...etc }) => { const cookie = req ? req.headers.cookie : ''; axios.defaults.headers.Cookie = ''; if (req && cookie) { axios.defaults.headers.Cookie = cookie; } store.dispatch({ type: LOAD_MY_INFO_REQUEST, }); store.dispatch(END); await store.sagaTask.toPromise(); }) export default Profile;
71. 해시태그 검색하기
백엔드
routes/hashtag.js
const express = require('express'); const { Op } = require('sequelize'); const { Post, Hashtag, Image, User, Comment } = require('../models'); const router = express.Router(); //GET /hashtag /노드 router.get('/:hashtag', async (req, res, next) => { console.log(" /hashtag /노드 : ", decodeURIComponent(req.params.hashtag)); let hashtag = decodeURIComponent(req.params.hashtag); //다음과 같은 형식으로 파라미터를 받을 경우 // /_next/data/development리액트.json?tag=리액트 if (hashtag.indexOf("tag=") !== -1) { hashtag = hashtag.split("tag="); hashtag = hashtag[1]; console.log("문자열 포함 :hashtag => ", hashtag); } try { const where = {}; if (parseInt(req.query.lastId, 10)) { //초기 로딩이 아닐때 //다음 코드 내용은 id 가 lastId 보다 작은 것 => id < lastId // Op 의미는 연산자 의미 lt 는 < where.id = { [Op.lt]: parseInt(req.query.lastId, 10) } }; const posts = await Post.findAll({ where, limit: 10, order: [['createdAt', 'DESC'],], include: [ { model: Hashtag, where: { name: hashtag }, }, { model: User, attributes: ['id', 'nickname'], }, { model: Image }, { model: Comment, include: [{ model: User, attributes: ['id', 'nickname'] }] }, { model: User, //좋아요 누른 사람 as: 'Likers', attributes: ['id'] }, { model: Post, as: 'Retweet', include: [ { model: User, attributes: ['id', 'nickname'] }, { model: Image } ] } ] }); res.status(200).json(posts); } catch (error) { console.error("posts error : ", error); next(error); } }); module.exports = router;
]
프론트엔드
components/AppLayout.js
import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; import Link from 'next/link' import { Menu, Input, Row, Col } from 'antd'; import styled from 'styled-components'; import { useSelector } from 'react-redux'; import Router from 'next/router'; import UserProfile from './UserProfile'; import LoginForm from './LoginForm'; import useInput from '../hooks/useInput'; const SearchInput = styled(Input.Search)` vertical-align: 'middle' ; `; const AppLayout = ({ children }) => { const [searchInput, onChangeSearchInput] = useInput(''); const { me } = useSelector((state) => state.user); const onClick = useCallback((e) => { if (e.key === "item-4") { if (searchInput) { // onChangeSearchInput("sdfsdf"); console.log("검색어: ", searchInput); Router.push(`/hashtag/${searchInput}`); } } }, [searchInput]); const items = [ { label: <Link href="/" >노드버드</Link>, key: 'item-1' }, me && { label: <Link href="/profile">프로필</Link>, key: 'item-2' }, !me && { label: <Link href="/signup">회원가입</Link>, key: 'item-3' }, { label: <SearchInput enterButton value={searchInput} onChange={onChangeSearchInput} />, key: 'item-4' } ]; return ( <div> <Menu onClick={onClick} mode="horizontal" items={items} /> <Row gutter={24} style={{ marginTop: 20 }}> <Col xs={24} md={6} style={{ marginTop: 20 }}> Hello.Next </Col> </Row> <Row gutter={24} style={{ marginTop: 20 }}> <Col xs={24} md={6} style={{ marginTop: 20 }}> {me ? <UserProfile /> : <LoginForm />} </Col> <Col xs={24} md={16} style={{ marginTop: 20 }} > {children} </Col> </Row> <Row gutter={24} style={{ marginTop: 20 }}> <Col xs={24} md={24} style={{ marginTop: 20 }}> <a href='https://macaronics.net' target="_blank" rel="noreferrer noopener" > Made by macaronics </a> </Col> </Row> </div> ); }; AppLayout.prototype = { children: PropTypes.node.isRequired } export default AppLayout;
72. moment와 next 빌드하기
강의 :
day.js 사용 방법 - JavaScript 날짜 라이브러리
프론트엔드
components/PostCard.js
~ import moment from 'moment'; ~ moment.locale("ko"); const PostCard = ({ post }) => { ~ {post.RetweetId && post.Retweet ? ( <Card cover={post.Retweet.Images[0] && <PostImages images={post.Retweet.Images} />} style={{ background: '#eee' }} > <div style={{ float: 'right' }}> {moment(post.createdAt).format('YYYY.MM.DD')} </div> <Card.Meta ~
빌드처리
eslint 생략
.eslintignore 파일 생성후
*
빌드
$npm run build
=>
Route (pages) Size First Load JS ┌ λ / 1.52 kB 1.28 MB ├ /_app 0 B 1.04 MB ├ ○ /404 1.52 kB 1.04 MB ├ ● /about (2917 ms) 1.92 kB 1.18 MB ├ λ /hashtag/[tag] 568 B 1.27 MB ├ λ /post/[id] 610 B 1.27 MB ├ λ /profile 6.67 kB 1.23 MB ├ λ /signup 29.3 kB 1.21 MB └ λ /user/[id] 804 B 1.27 MB + First Load JS shared by all 1.04 MB ├ chunks/framework-3b5a00d5d7e8d93b.js 45.4 kB ├ chunks/main-abafce5311b78c60.js 31.2 kB ├ chunks/pages/_app-48dc86756f8fa920.js 961 kB ├ chunks/webpack-2c683dfba3390d25.js 1.89 kB └ css/0e8b7820bc287a84.css 194 B λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps) ○ (Static) automatically rendered as static HTML (uses no initial props) ● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
chunks/pages/_app-48dc86756f8fa920.js
=>무엇을 의미하는지 모른다.
다음 라이브러리 설치
$ npm i @next/bundle-analyzer
73. 커스텀 웹팩과 bundle-analyzer
강의 :
next.config.js 설정
$ npm i @next/bundle-analyzer
** production 설정
package.json
"scripts": { "dev": "next dev -p 3060", "build": "ANALYZEER=true NODE_ENV=production next build " },
=> 리눅스 와 맥에서만 적용된다.
따라서, cross-env 설치
$ npm i cross-env
=>
"scripts": { "dev": "next dev -p 3060", "build": "cross-env ANALYZE=true NODE_ENV=production next build " },
next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({ enabled: process.env.ANALYZE === 'true', }); const nextConfig = { reactStrictMode: true, swcMinify: true, //styledComponents: true, compiler: { // Enables the styled-components SWC transform styledComponents: true }, compress: true, webpack(config, { webpack }) { const prod = process.env.NODE_ENV === 'production'; const newConfig = { ...config, mode: prod ? 'production' : 'development', plugins: [ ...config.plugins, new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /^\.\/ko$/), ], }; if (prod) { newConfig.devtool = 'hidden-source-map'; } return newConfig; }, } module.exports = withBundleAnalyzer(nextConfig);
Route (pages) Size First Load JS ┌ λ / 1.52 kB 352 kB ├ /_app 0 B 115 kB ├ ○ /404 1.52 kB 117 kB ├ ● /about (2793 ms) 1.92 kB 261 kB ├ λ /hashtag/[tag] 569 B 351 kB ├ λ /post/[id] 610 B 351 kB ├ λ /profile 6.67 kB 303 kB ├ λ /signup 29.3 kB 289 kB └ λ /user/[id] 805 B 351 kB + First Load JS shared by all 116 kB ├ chunks/framework-3b5a00d5d7e8d93b.js 45.4 kB ├ chunks/main-abafce5311b78c60.js 31.2 kB ├ chunks/pages/_app-08f11cd4ffdb460a.js 37 kB ├ chunks/webpack-579f3e5ed9d92545.js 1.89 kB └ css/0e8b7820bc287a84.css 194 B λ (Server) server-side renders at runtime (uses getInitialProps or getServerSideProps) ○ (Static) automatically rendered as static HTML (uses no initial props) ● (SSG) automatically generated as static HTML + JSON (uses getStaticProps)
댓글 ( 4)
댓글 남기기