Rsbuild 기반 Tistory-react 프레임워크 개발기

profile image pIutos 2024. 8. 19. 01:20

들어가며

Rsbuild 기반의 tistory-react 프레임워크를 개발하게 된 계기와 기술 스택 선정부터 개발을 하며 겪은 많은 문제들을 소개하려 한다.

해당 프레임워크는 아래 깃허브에 오픈소스로 모두 공개되어있다.

https://github.com/eunbae0/tistory-react

 

GitHub - eunbae0/tistory-react: Create tistory skin with React.js

Create tistory skin with React.js. Contribute to eunbae0/tistory-react development by creating an account on GitHub.

github.com

프레임워크를 개발하게 된 계기

우선 필자는 typescript + tailwindcss 개발 환경을 구축하여 티스토리 스킨을 개발한 경험이 있다. 이 당시 개발을 진행하며 스킨 정보 파일(xml)구문을 파악하고, 치환자를 하나하나 파악하고 적용 하느라 개발하는데 많이 애를 먹었었다. 또한 생 HTML 파일을 작성해야해서 스킨을 개발 완료한 시점에서는  무려 1150줄이나 되는 HTML 파일을 들여다 본다고 고생했다.

당시 skin.html파일. 무려 1153줄이나 된다.

그리고 HTML의 live server를 띄우면 아래처럼 티스토리 치환자가 적용되어있고, 메인페이지와 글 상세 페이지가 혼재하는 등 개발하기 불편한 에로사항이 많았다.


최근에 문득 이 경험이 떠올라 React를 이용하여 
개발한 다음 자동으로 HTML, CSS, JS 파일을 빌드해주는 환경을 개발한다면, 그리고  개발 환경에서는 메인 페이지와 글 상세, 태그 페이지 등을 적절히 라우팅 시켜주면 1000줄이 넘어가는 HTML 파일을 작성하지 않아도 되고, 기능을 개발하기 편리한 환경을 만들 수 있지 않을까? 에서 개발을 시작하게 되었다.

개발에 들어가기 전, 계획

목표

우선 크게 3가지 목표를 잡았다.

1. create tistory-react 명령어를 실행하여 쉽게 프로젝트를 시작 가능하도록 할 것.

2. dev환경에서는 라우팅되어 개발하며 HMR 서버가 제공될 것, build환경에서는 이 결과물이 합쳐져서 xml, html, css, js파일을 output으로 내보낼 것.

3. 티스토리 치환자를 완전히 몰라도 깔끔한 문서를 제공해 쉽게 개발이 가능하도록 할 것.

기술 스택 선정하기

- Lerna: cli, core와 같은 패키지를 제공하기 위해 모노레포는 필수 적용사항이었다. 이전에 Lerna를 이용하여 디자인 시스템을 개발한 경험이 있기도 하고 패키지를 개발하기 가장 무난한 Lerna를 선택했다.

- Rsbuild: 번들러는 여러 선택지가 있었다. Vite는 번들링보다는 웹 프로젝트 개발이 중심이기 때문에 선택하지 않았고, 속도 측면에서 esbuild를 선택하고 싶었지만 HMR을 지원하지 않았다. 따라서 속도도 매우 빠르고 HMR을 지원하는 rust기반의 rsbuild를 선택했다.

구현 요구사항

기술 스택을 선정한 다음, 구현시 요구사항을 쭉 적어보았다.

- 모노레포를 이용하여 cli, core, runtime, theme 등의 패키지를 분리하기

- pages/ 하위 폴더에 존재하는 파일의 라우팅 처리(a.k.a. Next.js pages router)

- 개발 환경에서 치환자를 자동으로 적용해주는 가이드를 표시

- build 명령어 실행시 SSG로 HTML 파일을 생성하기

- tistory-react.config 파일의 정의에 따라 XML 파일을 생성하기

- 모든 티스토리 치환자를 React 컴포넌트 형식으로 제공

- cli를 이용하여 프로젝트 시작시 JS, TS 선택 가능

- 문서를 통해 컴포넌트, 치환자를 상세하게 안내

등등.. 많은 요구사항을 정의할 수 있었다. 이후 하나하나 단계별로 개발을 진행했다.

Rspress

Rspress는 Rsbuild 기반의 mdx document 사이트를 제공하는 프레임워크이다.

Tistory-react의 기반이 되는 코드는 대부분 rspress의 코드를 응용하여 작성했다. 개발을 진행하며 rspress의 코드를 분석하며 동작원리를 파악하고, 원하는 기능을 개발했다.

본격적으로 개발하기 (with. 트러블 슈팅)

pages 하위 폴더에 존재하는 라우팅 하기

구상은 Next.js처럼 pages 하위 폴더에 존재하는 폴더명에 따라 라우팅을 구현하는 것이다.

dev 환경에서는 페이지 경로에 따라 일치하는 컴포넌트가 렌더링 되어야 하고, build 환경에서는 라우팅되는 것이 아니라 한 html 파일에 모든 컴포넌트가 렌더링 되어야 한다. 스킨 등록시 html을 분리해서는 안되기 때문이다.

rspress에서는 페이지 경로마다 문서를 SSR로 렌더링하기 때문에 해당 코드를 분석하고 이용했다.

경로에 맞는 컴포넌트를 불러오기 위해 단순히 App.tsx에서 타깃 경로에 있는 컴포넌트를 불러오는 구조를 상상했지만, 실제로 바로 렌더링하는 것은 불가능했고 아래와 같은 방식으로 구현되어 있었다.

1. 우선 globby 패키지를 이용해 타깃 폴더 목록을 가져온다.

2. RouteService에 각 폴더의 정보를 담아 route를 등록한다.

3. RouteService는 path, 컴포넌트의 ReactNode Object 등이 담긴 RoutesCode를 생성할 수 있다.

4. routeVMPlugin 함수에 3을 이용하여 RoutesCode를 할당한 후, 이를 커스텀 rsbuild plugin의 bundlerChain에 등록한다.

5. 해당 plugin은 rsbuild config에 등록되어 dev, build 등의 함수와 함께 실행된다.

6. rspress/runtime/src/Content.tsx에서 해당 모듈을 이용해 page path에 맞는 컴포넌트를 렌더링한다.

7. App.tsx에서 <Theme.Layout />을 불러와 이를 렌더링한다. Layout 컴포넌트에는 Content 컴포넌트를 렌더링하는 로직이 있다.

한줄 요약하자면 빌드할 때 각 routes에 해당하는 컴포넌트 Object가 담긴 모듈을 생성해, Contents 컴포넌트에서 이를 가져와 사용하는 것이다.

이 방식은 rspress의 모든 route 경로의 문서를 생성하는 방식이므로, 프로젝트에는 코드를 일부 수정하여 반영했다.

1. 타깃 폴더를 불러올 때 원하는 article, layout 등의 경로에 해당하는 파일을 불러오도록 필터링 함수 추가

// @core/RouteService.ts

async init() {
    const globby = (await import('@tistory-react/shared/globby')).globby;

    // 1. Filter page route paths file
    const files = await globby(
      [
        `**/pages/{${this.#pageRoutePaths.join(',')}}/index.{${this.#extensions.join(',')}}`,
        `**/Layout.{${this.#extensions.join(',')}}`,
      ],
      {
        cwd: this.#scanDir,
        absolute: true,
        ignore: [
          '**/node_modules/**',
          `**/.eslintrc.${this.#extensions.join(',')}`,
        ],
      },
    );

    const filesRelativePath = files.sort().map(
      filePath => normalizePath(path.relative(this.#scanDir, filePath)), // ex. src/pages/article/index.tsx
    );

    // 2. Error handling if required file does not exist
    filesRelativePath.forEach(defaultRoutePath => {
      if (!isTistoryRouteFile(defaultRoutePath))
        throw new Error(
          `Required file does not include path: /${defaultRoutePath}`,
        );
    });

    // 3. Generate routeInfo
    filesRelativePath.forEach(relativePath => {
      const absolutePath = path.join(this.#scanDir, relativePath);

      const routeInfo = {
        absolutePath: normalizePath(absolutePath),
        relativePath: relativePath,
        pageName: extractPageName(relativePath),
      };
      this.addRoute(routeInfo);
    });
  }

2. Content 컴포넌트에서 dev, build 환경에 따라 route 컴포넌트 렌더링 여부를 결정하고, App.tsx에 추가

// @runtime/Content.tsx

const { routes } = process.env.__SSR__
  ? (require('virtual-routes-ssr') as typeof import('virtual-routes-ssr'))
  : (require('virtual-routes') as typeof import('virtual-routes'));

export const Content = () => {
  const isSSR = process.env.__SSR__;

  // layout 컴포넌트 정의
  const layoutElement = routes.find(
    route => route.pageName === 'layout',
  )!.element;

  if (!isSSR) { // dev 환경에서는 라우팅 처리
    const { pathname } = useLocation();

    layoutElement.props = { 
      children: routes.find(
        route => route.pageName === removeLeadingSlash(pathname),
      )?.element,
    }; // 현재 route와 일치하는 page를 렌더링 한다.
    
    return layoutElement;
  }

  // build 환경에서는 layout에 감싸진 형태로 컴포넌트 렌더링
  const routesElements = routes
    .filter(route => route.pageName !== 'layout')
    .map(route => route.element);

  layoutElement.props = { children: routesElements };

  return layoutElement;
};

이런 방식으로 원하는 요구사항을 구현할 수 있었다.

children이 포함된 Layout 컴포넌트를 렌더링하기

<Layout>
  <MainPage />
  <ArticlePage />
  <GuestPage />
  <TagsPage />
</Layout>

위처럼 Layout 컴포넌트가 하위 페이지를 감싸는 형태로 구상했다.

export const Content = () => {
  // ...
  const routesElements = routes
    .filter(route => route.pageName !== 'layout')
    .map(route => route.element);

  return <LayoutElement>{routesElements}</LayoutElement>;
  // Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: object.
};

이를 위와 같이 직관적으로 바로 구현했더니 에러가 발생했다. ReactNode Object를 직접적으로 React Element에 할당해서 발생한 오류였다.

export const Content = () => {
  // ...
  const routesElements = routes
    .filter(route => route.pageName !== 'layout')
    .map(route => route.element);

  layoutElement.props = { children: routesElements };

  return layoutElement;
};

Object를 억지로 Element에 할당하지만 않으면 되므로, children에 routesElements를 주기 위해 layoutElement.props 속성에 children 값을 routesElements로 할당하는 방식으로 문제를 해결했다.

onclick 이벤트 표시하기

<textarea
  onclick=""
  // ...
/>

티스토리 스킨에서 제공하는 온클릭 이벤트를 사용하기 위해서는, 위 코드처럼 onclick 이벤트에 각 이벤트에 해당하는 치환자를 string으로 넘겨줘야 한다.

하지만 onClick 이벤트에 string을 할당하면 TS 오류와 함께 리액트에서 실제 DOM으로 변환되면서 onclick 이벤트는 표시되지 않는다.

그렇기 때문에 당연히 onClick, onclick에 string을 할당하면 아예 해당 attribute가 사라지며 표시되지 않는다.

dangerouslySetInnerHTML?

onclick을 string 그대로 표시해주기 위해 여러 방법을 찾던 중, dangerouslySetInnerHTML prop을 활용하는 방법이 보여 이를 적용해 보았다.

export const InputTextArea = (props: RepTextareaProps) => {
  return (
    <div
      ref={ref}
      dangerouslySetInnerHTML={{
        __html: `
        <textarea
          onclick="${COMMENT_INPUT_COMMENT}"
          name="${COMMENT_INPUT_COMMENT}"
          ${JSON.stringify({ ...props })}
        />`,
      }}
    />
  );
};

하지만 이 방법은 온전히 textarea를 반환하는 컴포넌트도 아닐 뿐더러, prop을 사용할 때 리액트 props을 innerHTML에 표시만 하지 적용되지는 않고, 그렇다고 감싸는 div에 적용하면 타깃 Node에 적용되지 않기 때문에 사용할 수 없었다.

custom html attribute 사용하기

data-onclick이라는 attribute에 티스토리 치환자에 해당하는 string을 할당하고, 해당 attribute를 HTML render 단계에서 변환하는 방식으로 문제를 해결했다.

export const InputSubmit = (props: RepInputProps) => {
  return (
    <input
      type="submit"
      value={`${props.value ?? props.label ?? '댓글 달기'}`}
      data-onclick={COMMENT_INPUT_ONCLICK}
    />
  );
};

// build.ts

export async function renderHtml() {
  // ...
  
  // appHtml (renderToString을 통해 html string으로 변환됨)
  
  htmlTemplate // string
    .replace(APP_HTML_MARKER, () => appHtml)
    // ...
    .replaceAll(TEMP_ONCLICK_ATTR, 'onclick');

위처럼 data-onclick attribute에 치환자를 할당한 다음, 변환된 html string에서 'data-onclick'을 찾아 'onclick'으로 변환시키는 로직을 추가했다.

onclick 치환자가 잘 반영되었다.