[슬랙 클론코딩] 에디터 붙이기 1-3 | jsx-parser-react

jung moon chai·2022년 12월 6일
0

지난번 작성에 버그를 먼저 간단히 정리했고 이번에 해결된 문제점을 정리하고자 한다.
에디터를 단순 text로만 출력하기만 해봤지 직접 컴포넌트로 조합하는 방법이 처음이라 참 많이 구글링을 해봤다. 솔직히 맞는 방법인지도 의문이지만 이런 방식으로 해결해봤다. 정도로 정리하고 넘어가자.


1. string 타입을 컴포넌트화 시키기

지난 포스트에서 string을 html로 파싱하는 dangerouslySetInnerHTML와 xss의 최소한의 방어 라이브러리인 dompurify를 소개했었다.
그런데 만들고 보니 잘 작동은 했는데.. 문제가 하나 있었다. 추후 멘션 기능을 붙이기도 해야하고, 기존에 textarea로 작성되있던 곳에서 멘션을 붙이게 되면 해당 문자열을 정규표현식으로 찾아 Link컴포넌트로 리턴해주는 부분이 문제가 되었다.

아니 슨생님 Object라뇨? 원인을 찾기위해 콘솔로 찍어보니..

아... 정규식으로 필터링 되지 않은 태그의 string 혹은 그냥 글자들은 string타입 그대로 나오는데 반해 정규식으로 찾아 Link컴포넌트로 나오는 부분은 그냥 string이 아닌 React element로 나오는것이었다. 그러니 toString해주면 당연히 [Object, Object]로 나오는 것이 당연했다..

그래서 머리를 굴려봤다. 아 그렇다면 내가 얘를 필터링에 거치지 않은 얘들까지 컴포넌트로 만들고, 그 안에 필터링 거친얘들만 Link컴포넌트로 만들면 되겠구나? 싶었다. 라고 너무 안일하게 생각했다.


1-1 String의 컴포넌트화

데이터를 한번더 래핑 해서 컴포넌트화 시킨다는 생각자체는 나쁘지 않았다 생각한다. 근데 또 같은 문제가 있었다. children으로 넘기려면 일단 래핑컴포넌트가 실행이 되어야하고, 그러면 그 속에 children으로 컴포넌트가 실행 되어야 하는데 어느 지점에서 children속에 data.content를 파싱 시킬것이며, 어느지점에서 필터링을 해 줄 것이냐가 문제였다. 어찌보면 래핑 전이나 후나 같은 문제라는 것이다.

const ChatDataWrapper: FC = ({ children }) => {
  const result = () =>
    regexifyString({
      input: children,
      pattern: /@\[(.+?)]\((\d+?)\)|\n/g,
      decorator(match, index) {
        const arr: string[] | null = match.match(/@\[(.+?)]\((\d+?)\)/)!;
        if (arr) {
          return `<Link to='/workspace/${workspace}/dm/${arr[2]}'>@${arr[1]}</Link>`;
        }
        return '\n';
      },
    });
  return <>...</>;
};

에러가 발생한다. input에는 string이 들어가야 하는데 react element가 들어왔다는 오류이다.
이 말인 즉슨 children으로 내려준 props에서 정규식으로 멘션 링크를 필터링 할 수 없다. 라는 결론이 내려졌다.


1-2 jsx-parser-react

회사 업무중에도, 집에 와서도 시간이 될때마다 틈틈히 구글링과 생각을 해보다가 흥미로운 라이브러리를 발견했다. 간단히 말하면 텍스트로 작성된 컴포넌트를 실제 컴포넌트화 시켜 파싱해주는 라이브러리이다. 실감이 잘되지 않는다면 아래 코드를 보도록 해보자.

import JsxParser from 'jsx-parser-react';

const ChildrenComponent = () => {
	return <>글자로 작성될 컴포넌트</>
}
const ParentsComponent = () => {
	const renterComponent = `<p>저는 "<ChildrenComponent />"를 렌더링 해보겠습니다.</p>`
	return (
      <>
      	<JsxParser 
      		components={{ ChildrenComponent }} 
    		jsx={renterComponent} 
		/>
      </>
    )
}

언뜻 보기엔 좀 황당 할 수 있다. 나도 그랬다. 아니 컴포넌트를 string으로 작성한다고?

실제로 그것이 일어났습니다.

components props에 객체로 내가 스트링으로 표현된 컴포넌트를 등록해주고 jsx에서 표현할 string을 넣어주면 그대로 파싱된다. 호오...


1-3 jsx-parser-react로 렌더링

그럼 dangerouslySetInnerHTML로 출력했던 챗을 한번 jsx-parser-react로 렌더링 해보자.

import JsxParser from 'jsx-parser-react';
// 생략
interface WrapType {
  href?: string;
}
const TextWrapper:FC<WrapType> = ({ children, href }) => {
	return href ? <Link to={href}>{children}</Link> : null;
}
const Chat: VFC<Props> = ({ data }) => {
	// 생략
  	const result = useMemo(
    	() =>
      		data.content.startsWith('uploads\\') || data.content.startsWith('uploads/')
      			? `<img src="${BACK_URL}/${data.content}" style='max-height:200px' />`
      			: regexifyString({
                  	input: data.content,
                  	pattern: /@\[(.+?)]\((\d+?)\)|\n/g,
                  	decorator(match, index) {
                    	const arr: string[] | null = match.match(/@\[(.+?)]\((\d+?)\)/)!;
                    	if (arr) {
                      		return `<TextWrapper href='/workspace/${workspace}/dm/${arr[2]}'>@${arr[1]}</Test>`;
                    	}
                    	return '\n';
                  	},
                }),
      	[workspace, data.content]
    );
 	// 생략
  	return (
    	<ChatWrapper>
      		<div className="chat-img">
              	<img src={gravatar.url(user.email, { s: '36px', d: 'retro' })} alt={user.nickname} />
            </div>
			<div className="chat-text">
            	<div className="chat-user">
                	<b>{user.nickname}</b>
                	<span>{dayjs(data.createdAt).format('h:mm A')}</span>
                </div>
				{/* <p dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(result2.toString()) }}></p> */}
				<JsxParser
                  	autoCloseVoidElements
                  	components={{ TextWrapper }}
                  	jsx={typeof result === 'string' ? result : result.join(' ')}
                />
            </div>
      	</ChatWrapper>
    )
}

result가 string으로 나오는 경우엔 정규식으로 패턴을 찾지 못했을때 그대로 출력되기 때문에 그런 경우엔 그냥 그대로 출력하고, 정규식으로 찾은 경우 배열로 나오기 때문에 그대로 join으로 string으로 연결해서 string화 시켜주도록 했다. 그럼 이제 Link컴포넌트가 잘 나오는지, 그리고 Link컴포넌트로서 라우트푸시가 잘 이뤄지는지 한번 확인해보자.

오오.. 페이지를 a태그로 이동하는 것이 아닌 라우트 푸시로 잘 이동 하고있다.


2. 내가 작성 하던 챗 유지

실제 슬랙에서는 어느 DM혹은 어느 채널에서 챗을 작성하다가 서브밋 하지 않고, 다른 채널이나 DM으로 이동 후에 다시 돌아오면 내가 작성하던 챗이 그대로 남아있었다. 나도 그 기능을 만들어 보도록 했다.
뭐.. 이게 서버에 저장되기엔 조금.. 비효율?적이라 생각했다. 못하는건 아니지만 서버에 저장하게 만들려면 내가 작성시마다 서버에 보내거나, 아니면 내가 채널혹은 DM이동할때마다 서버에 내가 작성하다 중단한 텍스트들을 보내야한다. 내가 그렇게 생각 할 정도면 슬랙은 이미 그 이상을 생각하고 있지않을까? 하는 생각에 슬랙의 로컬스토리지를 찬찬히 뜯어보았다. 역시 redux-persist로 로컬스토리지에 저장되어 있었다. 우리 프로젝트는 swr이라 redux를 쓰지 않으므로, 그냥 로컬스토리지로 저장해보도록 하자.

일단 어떻게 저장하면 찾기도 편하고, 적용하면 좋을지 생각해보았다.
워크스페이스 마다도 유저 id 혹은 채널명이 같을 수 있으므로, 일단 워크스페이스 객체들로 나눠주고, 내가 작성하던 곳이 채널인지 DM인지 에 따라 channel객체와 dm객체로 나누고 그 안에 다시 체널명 혹은 유저id를 키값으로 하는 객체를 만들 것이다.

{
  	워크스페이스명: {
    	channel: {
        	[채널명]: {
            	chat: 작성하던 챗
            }
        },
      	dm: {
        	[유저 id]: {
        		chat: 작성하던챗
        	}
        }
    }
}

이제 최초 workspace 라우트에 접근했을때 기본값을 만들어서 저장해 둘 것이다.

// /layouts/Workspace/index.tsx
// 생략
const Workspace = () => {
	// 생략
  	// 워크스페이스에 변동이 일어날때 마다 실행
  	useEffect(() => {
      // 로컬스토리지에서 chatLog에 저장된 값
      const chatLog = localStorage.getItem('chatLog');
      // 로컬스토리지에 기존에 저장된 값이 있다면 json화 없다면 ''
	  const parseData = chatLog ? JSON.parse(chatLog) : '';
      if (workspace) {
        localStorage.setItem(
          'chatLog',
          JSON.stringify({
            // 기존에 저장되었거나, 없다면 빈값인 chatLog를 얕은복사하여 저장
            ...parseData,
            [workspace]: {
              channel: {
                // 기존에 저장된 값이 있다면 다시 저장
              	...parseData[workspace]?.channel,
              },
              dm: {
                // 기존에 저장된 값이 있다면 다시 저장
              	...parseData[workspace]?.dm,
              },
            },
          }),
        );
      }
    }, [workspace]);
  	// 생략
}

각 워크스페이스들에 접근 했을때 기본값들이 기존의 값들을 유지하며 저장되는지 확인해보자.

워크스페이스에 최초 접근시와 다른 워크스페이스로 이동시에도 기본값들이 잘 저장된다.
이제 챗박스를 사용할때 chatType을 props로 던져 주도록 해보자. 그래야 공용으로 사용하고 있는 챗박스에서 내가 어디에 작성되고 있는지 알 수 있을것 같다. 다만 챗박스컴포넌트에서 useParams를 이용해 id 파라미터를 갖고 있느냐, channel파라미터를 갖고 있느냐를 확인해 알 수는 있지만, 그렇게 하기엔 나중에 추가 될 요소가 있을 것 같기도 하고, 잘못 사용해 두 파라미터가 모두 없는 경우를 우려해 그냥 무조건 props로 던져주기로 했다.

// /pages/Channel/index.tsx
<EditorChatBox
	onSubmitForm={onSubmitForm}
    chat={chat}
    onChangeChat={setChat}
    placeholder={`Message #${channel}`}
    data={channelMembersData}
    chatType="channel"
/>

// /pages/DirectMessage/index.tsx
<EditorChatBox
	onSubmitForm={onSubmitForm}
    chat={chat}
    onChangeChat={setChat}
    placeholder={`Message #${id}`}
    data={[]}
    chatType="dm"
/>

그리고 이제 챗박스에서 onChange함수 안에서 작성해 로컬스토리지에 저장해보도록 하자.

// /components/EditorChatBox/index.tsx
// 생략
import { useParams } from 'react-router';

interface Props {
  onSubmitForm: (e: any) => void;
  chat?: string;
  onChangeChat: (e: any) => void;
  placeholder: string;
  data?: IUser[];
  chatType: string;
}

const EditorChatBox: FC<Props> = ({
  onSubmitForm, 
  chat, 
  onChangeChat, 
  placeholder, 
  data, 
  chatType
}) => {
  // 생략
  const { workspace, channel, id } = useParams<{ workspace: string; channel: string; id: string }>();
  // 생략
  const onEditorChange = useCallback(
    (value) => {
      onChangeChat(value);
      let keyName = '';
      if (chatType === 'dm') keyName = id;
      else if (chatType === 'channel') keyName = channel;
      const getStorage = localStorage.getItem('chatLog') || '';
      const getData = JSON.parse(getStorage);
      const setData = cloneDeep(getData);
      setData[workspace][chatType] = {
        ...setData[workspace][chatType],
        [keyName]: {
          chat: value,
        },
      };
      localStorage.setItem('chatLog', JSON.stringify(setData));
    },
    [chat, channel, onChangeChat, workspace],
  );
}


잘 저장되었다. 그럼 이제 각 채널 및 dm에 접근 했을때 chat을 useInput 커스텀 훅스로 만들었었는데, 그 부분에 초기값을 로컬스토리지에서 찾아 값이 있다면 초기값으로 넣어주도록 해보자.

// /pages/Channel/index.tsx
const chatLog = localStorage.getItem('chatLog');
const parseData = chatLog ? JSON.parse(chatLog) : '';
useEffect(() => {
  if (parseData[workspace].channel[channel]) setChat(parseData[workspace].channel[channel].chat);
  else setChat('');
}, [channel]);

// /pages/DirectMessage/index.tsx
const chatLog = localStorage.getItem('chatLog');
const parseData = chatLog ? JSON.parse(chatLog) : '';
useEffect(() => {
  if (parseData[workspace].dm[id]) setChat(parseData[workspace].dm[id].chat);
  else setChat('');
}, [id]);

그렇다면 이제 마지막으로 초기화 해줄 차례이다. 그런데 생각해보면 에디터 박스에서 서브밋을 하게 되면 setChat으로 빈값으로 되돌리고 그렇게되면 chat이 다시한번 업데이트되게 되면서 챗박스도 리렌더링 되어 로컬스토리지에 저장된 chat값이 빈값이 되어서 그냥 둬도 크게 문제 될 것 같지는 않다. 코드를 중간중간 생략하고 일부분만 작성하고 해서 보기가 복잡하니 깃에 업로드해서 url을 따로 남기고 배포도 해서 서버에 올려보도록 하자.


3. 남은 버그사항 정리

  1. Chat 컴포넌트에서 Link컴포넌트를 리턴하는 경우 글자 출력시 toString()을 하게 되면 [object, obejct]로 출력되는 부분, 스트링으로 a태그를 출력하게되면 history push가 되지 않고 새로 페이지를 로드하게 되는데 history.push로 해결이 될 가능성은 보이긴 한다.
  2. 기존 textarea로 되어있는 부분을 br태그로 줄넘김이 아닌 br태그 인식시 p태그로 감싸게 해야하는 부분
  3. 멘션기능 일단 tinymce는 멘션기능이 유료라서 어떻게 할지 좀 고민을 해봐야 하는부분인데 1번 버그와 어느정도 연관이 있다.
  4. 기존 슬랙에서는 챗을 쓰다가 채널및 dm을 이동하게 되면 폼이 비었다가, 다시 작성하던 채널이나 dm으로 돌아오면 작성하던 챗이 그대로 남아있다. 실제 슬랙도 뜯어본 결과 로컬스토리지에 저장하고 있는 것을 확인 했고, 이 부분은 적용 예정이다.

다음은 기존에 textarea로 작성한 부분에 대한 태그 및 컴포넌트 처리와 멘션기능을 어떻게 해볼지 머리를 좀 굴려보자..

profile
고급개발자되기

0개의 댓글