nextjs에서 quill editor를 적용하면서 발생한 문제를 해결한 과정을 기록했다.
quill-editor
를 사용하는 컴포넌트가 있는 페이지를 nextjs 앱에서 로드하면 다음과 같이 document is not defined
에러가 식별된다.
뭘까?
일단 nextjs
를 사용하면서 window
의 특정 객체에 접근하고자 할 때 자주 접했던 not defined
에러가 생각났다.
useEffect
등 컴포넌트가 마운트 된 후가 아니면 localStorage
같은 브라우저 객체에 접근할 때 반드시 typeof window !== 'undefined
로 윈도우 객체가 있을 때를 보장해주어야 했다.
react-quill 이슈를 타고타고 가다보니 quill editor
는 document
객체를 조작해 동작한다는 것을 알게되었다.
따라서
ssr
인 nextjs에서 document
객체가 로드 된 후에 에디터를 사용할 수 있게끔 보장해주어야 한다.
document
가 있을 때 해당 에디터 컴포넌트를 불러오게 해보았다.
import EditorComponent from '@/components/editor';
...
...
{window && window.document && <EditorComponent />}
여기서 EditorComponent
는 ReactQuill
을 사용하는 커스텀 에디터 컴포넌트다.
하지만 여전히 같은 에러가 발생했다.
생각해보니 nextjs에서 import
로 모듈을 불러오는 것도 서버사이드로 하는데 quill 에디터
는 애초에 라이브러리 자체가 document
객체를 활용하고 조작한다고 하니 안되는 것이 당연했다.
Dynamic Import?
Next.js supports lazy loading external libraries with import() and React components with next/dynamic. Deferred loading helps improve the initial loading performance by decreasing the amount of JavaScript necessary to render the page. Components or libraries are only imported and included in the JavaScript bundle when they're used.
클라이언트 측에서 모듈을 로드하도록 하여 초기 페이지 로드 성능을 개선할 수 있다고 말하고 있다.
즉 dynamic import
를 적용하면 클라이언트에서 동적으로 모듈을 로드하기 때문에 document
객체를 활용할 수 있을 것이다.
import dynamic from 'next/dynamic';
const EditorComponent = dynamic(() => import('@/components/editor'), {
loading: () => <div>...loading</div>,
ssr: false,
});
...
<EditorComponent />
ssr
로 렌더링 되는 위의 UI와 다르게 정말로 클라이언트 사이드에서 불러온다!
위의 방법도 괜찮지만 에디터를 사용할 때 마다 dynamic
하게 컴포넌트를 불러와야 하는 것을 신경써야 되고 로딩 컴포넌트 등도 구현해서 사용하게 되면 매번 작성해야되니 유지보수적인 측면에서 안좋을 것 같다.
어차피 quill-editor
자체가 서버사이드에서 불러와 사용할 수 없는 모듈이니 해당 컴포넌트 자체를 동적으로 가져오게 설정하는 것이 좋아보였다.
import dynamic from 'next/dynamic';
import ReactQuill, { ReactQuillProps } from 'react-quill';
import 'react-quill/dist/quill.snow.css';
interface ForwardedQuillComponent extends ReactQuillProps {
forwardedRef: React.Ref<ReactQuill>;
}
const QuillNoSSRWrapper = dynamic(
async () => {
const { default: QuillComponent } = await import('react-quill');
const Quill = ({ forwardedRef, ...props }: ForwardedQuillComponent) => (
<QuillComponent ref={forwardedRef} {...props} />
);
return Quill;
},
{ loading: () => <div>...loading</div>, ssr: false },
);
export default QuillNoSSRWrapper;
ref
를 사용해 Quill Component
객체에 접근하는 부분들이 있기 때문에 prop
으로 받아
QuillComponent (ReactQuill 타입)
로 ref를 넘겨주게끔 구현하였고
동적으로 ReactQuill
컴포넌트를 로드하는 wrapper를 구성했다.
import QuillNoSSRWrapper from './QuillNoSSRWrapper';
const EditorComponent = () => {
const quillInstance = useRef<ReactQuill>(null);
// ..구현부 생략
return (
<QuillNoSSRWrapper
forwardedRef={quillInstance}
value={contents}
onChange={setContents}
modules={modules}
theme="snow"
placeholder="내용을 입력해주세요."
/>
);
};
export default EditorComponent;
실제 에디터 컴포넌트는 다음과 같이 구성된다.
사용하는 쪽 코드에서 동적 임포트를 신경쓸 필요 없게 되었고,
반드시 클라이언트 쪽에서 불러와야 하는 ReactQuill
에 대해서만 동적 임포트가 적용된 상태라 추후 추가적인 동적 임포트를 하던 안하던 선택지가 좀 더 생긴게 아닌가 생각한다.
quill-editor
의 유틸성 추가모듈들이 많이 있다. 이미지 압축, 이미지 복붙 등
해당하는 라이브러리들을 사용할 때 에디터에 등록해줘야 하는데 이런 라이브러리들 또한 결국 quill-editor
기반으로 document
객체를 조작하는 듯 하다.
그냥 등록하면 같은 오류가 난다.
역시나 dynamic import
를 적용해줘야 nextjs
에서 사용할 수 있다.
quill-image-compress
라이브러리를 예시로 적용해보았다.
const QuillNoSSRWrapper = dynamic(
async () => {
const { default: QuillComponent } = await import('react-quill');
// 해당 라이브러리 dynamic import
const { default: ImageCompress } = await import('quill-image-compress');
// Quill에 모듈 등록
QuillComponent.Quill.register('modules/imageCompress', ImageCompress);
const Quill = ({ forwardedRef, ...props }: ForwaredQuillComponent) => (
<QuillComponent ref={forwardedRef} {...props} />
);
return Quill;
},
{ loading: () => <div>...loading</div>, ssr: false },
);
const modules = useMemo(
() => ({
toolbar: {
// 생략...
},
imageCompress: {
quality: 0.7,
maxWidth: 222,
maxHeight: 222,
debug: true, // default
suppressErrorLogging: false,
insertIntoEditor: undefined,
},
}),
[],
);
읽을 때도 quill
로 불러와야할텐데 실제 컨텐츠(데이터)는 ssr로 api를 패치해 불러온다.
물론 이런식으로 그냥 dangerouslySetInnerHTML
속성을 사용해 HTML
스타일을 반영해서 넣어도 되지만 뭔가 쎄?하다. 린트에서 경고를 보여준다는 건 뭔가 좋지 않다는 것일 것입니다.
게다가 작성할 때 quill
의 class로 스타일링이 되어있어 정상적으로 스타일이 적용된 채로 보여주려면 적절한 class
와 css
파일을 또 불러오거나 커스텀해서 컴포넌트로 만들어야 한다.
이는 매우 귀찮을 수 있고 라이브러리 의도대로 스타일이 잘 나오지 않을 확률이 있기 때문에 읽을 때도 dynamic import
를 적용했다.
단 동적으로 클라이언트 사이드에서 가져와 보여주면서도 로딩 중임을 보여주고 싶지 않았다.
이미 서버 사이드에서 데이터를 모두 가져오는데 검색엔진에 적절한 태그 위치에 노출되지 않는 것도 싫었고 로드 시 로딩중 UI를 보여주다가 긴 컨텐츠들을 팍! 하고 보여주고 싶지 않았다.
export const QuillNoSSRReader = ({ content }: { content: string }) => {
const Result = dynamic(
async () => {
const { default: QuillComponent } = await import('react-quill');
return () => <QuillComponent theme="bubble" readOnly value={content} />;
},
{
loading: () => (
<div className="quill">
<div className="ql-container ql-bubble ql-disabled">
<div className="ql-editor" data-gramm="false" dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
),
ssr: false,
},
);
return Result;
};
앞서 만들었던 QuillNoSSRWrapper
를 Writer
로 이름을 변경하고 Reader
를 따로 만들어주었다.
로딩중일 때만 최대한 같은 스타일로 dangerouslySetInnerHTML
를 통해 컨텐츠를 보여주다가
동적 임포트가 되었을 경우 ReactQuill
을 통해 보여주게 했다.
아무래도 XSS에 대한 방어도 그렇고 보여지는 것도 그렇고 ReactQuill
에서 의도한대로 사용하는 것이 훨씬 나을 것이다.
일부러 loading
함수에 로딩중임
텍스트를 추가했는데 빼면 로딩중일 때도 에디터를 통해 불러왔을 때와 아예 똑같은 UI가 나타난다!
해결에 도움이 되었습니다. 감사합니다!!