[React] 다크모드 구현 & 새로고침 시 깜빡임

Tinubee·2023년 1월 11일
0
post-thumbnail

다크모드 구현하기

🔥 React + Styled-Components를 사용하여 Light / Dark 모드를 구현해 보자.


1. 설치

npm install --save styled-components

✔︎ styled-components 공식 홈페이지


2. Theme

모든 색상들을 가지고 있는 object 이다.

Typescript를 사용한다면, styled.d.ts 파일을 생성해 준뒤, type을 지정해줘야 한다.
기본적으로 DefaultTheme 인터페이스는 비어 있으므로 확장해야 한다.

//styled.d.ts
import "styled-components";

declare module "styled-components" {
  export interface DefaultTheme {
    textColor: string;
    bgColor: string;
    borderColor: string;
  }
}

theme.ts 파일을 생성해 준뒤, 사용 할 색상을 넣어준다.

//theme.ts
import { DefaultTheme } from "styled-components";

export const darkTheme: DefaultTheme = {
  bgColor: "#2F2F2F",
  textColor: "#e5e5e5",
  borderColor: "#ffffff",
};

export const lightTheme: DefaultTheme = {
  bgColor: "#ffffff",
  textColor: "#000000",
  borderColor: "#1e1e1e",
};

그리고 전역스타일 처리하는 함수( createGlobalStyle ) 를 App.tsx 에 만들어준다.

const GlobalStyle = createGlobalStyle`
body {
  color:${(props) => props.theme.textColor};
  background-color: ${(props) => props.theme.bgColor};
}
`;

그리고 난뒤에 theme이라는 props를 받기위해 App을 themeProvider 로 감싸주면된다. 그렇게 되면 App은 theme에 접근할 수 있다.

//App.tsx
import { createGlobalStyle, ThemeProvider } from "styled-components";
import { darkTheme, lightTheme } from "./theme";
import { RouterProvider } from "react-router-dom";
import router from "./Router";

const GlobalStyle = createGlobalStyle`
body {
  color:${(props) => props.theme.textColor};
  background-color: ${(props) => props.theme.bgColor};
}
`;

function App() {
  return (
    <ThemeProvider theme={darkTheme}> //or theme={lightTheme}
      <GlobalStyle />
      <RouterProvider router={router} />
    </ThemeProvider>
  );
}

export default App;

위와 같이 theme을 darkTheme로 설정하면 darkTheme에 지정한 색상으로 설정이 되고, lightTheme로 설정하면 lightTheme에 지정한 색상으로 설정이 된다.


3. Toggle Button

Light Mode / Dark Mode 를 사용자가 변경할 수 있도록 버튼을 생성해 보자.
전역상태관리를 위해 Recoil 을 사용하였고, 사용할 Icon은 Fontawesome 을 사용하였다.

✔︎ 설치
npm install recoil

npm i --save @fortawesome/react-fontawesome@latest
npm i --save @fortawesome/free-solid-svg-icons
npm i --save @fortawesome/free-regular-svg-icons

Recoil에 관한 자세한 내용은 따로 정리할 예정이다.
지금은 DarkMode구현에 필요한 설정정도만 보도록하자.

Recoil 상태를 사용하는 컴포넌트들은 atom context를 가지는 RecoilRoot 가 필요하다. 해당 Root 이하의 컴포넌트는 모두 같은 전역 상태의 값을 사용할 수 있다.

//index.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "react-query";
import App from "./App";
import { RecoilRoot } from "recoil";

const client = new QueryClient();

const root = ReactDOM.createRoot(
  document.getElementById("root") as HTMLElement
);

root.render(
  <React.StrictMode>
    <RecoilRoot>
      <QueryClientProvider client={client}>
        <App />
      </QueryClientProvider>
    </RecoilRoot>
  </React.StrictMode>
);

이제 값을 저장할 atom을 만들어준다. atoms.ts파일을 생성한뒤 파일 안에 atom을 만들어 준다. atom은 유니크한 key와 default 값 두가지가 필요하다.

//atoms.ts
import { atom } from "recoil";

export const isDarkAtom = atom({
  key: "Dark", // 
  default: false, //기본값
});

isDarkAtom를 읽고 항목 텍스트를 업데이트하고, 완료된 것으로 표시하고, 삭제하는 데 사용하는 setter 함수를 얻기 위해 useRecoilState() 를 사용한다.

//mode.tsx
import { faMoon, faSun } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "react-router-dom";
import { useRecoilState } from "recoil";
import styled from "styled-components";
import { isDarkAtom } from "../../atoms";
import { Tab } from "./Header";

function Mode() {
  const [darkAtom, setDarkAtom] = useRecoilState(isDarkAtom);
  const toggleMode = () => {
    setDarkAtom((prev) => !prev);
  };

  return (
    <ModeContainer>
      {!darkAtom ? (
        <Icon onClick={toggleMode}>
          <FontAwesomeIcon icon={faSun} />
        </Icon>
      ) : (
        <Icon onClick={toggleMode}>
          <FontAwesomeIcon icon={faMoon} />
        </Icon>
      )}
      <Tab>
        <Link to={"/login"}>Login</Link>
      </Tab>
    </ModeContainer>
  );
}

export default Mode;

이제 다시 App.tsx로 와서 isDarkAtom값을 불러와 ThemeProvider에게 넣어주도록 하자. 이 atom의 항목을 읽기만 한다면 useRecoilValue() 훅을 사용할 수 있다.

//App.tsx
import { createGlobalStyle, ThemeProvider } from "styled-components";
import { darkTheme, lightTheme } from "./theme";
import { useRecoilValue } from "recoil";
import { isDarkAtom } from "./atoms";
import { RouterProvider } from "react-router-dom";
import router from "./Router";

const GlobalStyle = createGlobalStyle`
body {
  color:${(props) => props.theme.textColor};
  background-color: ${(props) => props.theme.bgColor};
  transition: background-color 0.5s, color 0.5s;
}
`;

function App() {
  const isDark = useRecoilValue(isDarkAtom);
  
  return (
   	<ThemeProvider theme={isDark ? darkTheme : lightTheme}>
      <GlobalStyle />
      <RouterProvider router={router} />
    </ThemeProvider>
  );
}

export default App;


4. localStorage

설정한 값을 새로고침 해도 유지할 수 있도록, localStorage에 상태를 저장하도록 해보자.

//mode.tsx
import { faMoon, faSun } from "@fortawesome/free-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "react-router-dom";
import { useRecoilState } from "recoil";
import styled from "styled-components";
import { isDarkAtom } from "../../atoms";
import { Tab } from "./Header";

function Mode() {
  const [darkAtom, setDarkAtom] = useRecoilState(isDarkAtom);
  const toggleMode = () => {
    setDarkAtom((prev) => !prev);
    localStorage.setItem("mode", String(!darkAtom)); //localStorage에 값 저장.
  };

  return (
    <ModeContainer>
      {!darkAtom ? (
        <Icon onClick={toggleMode}>
          <FontAwesomeIcon icon={faSun} />
        </Icon>
      ) : (
        <Icon onClick={toggleMode}>
          <FontAwesomeIcon icon={faMoon} />
        </Icon>
      )}
      <Tab>
        <Link to={"/login"}>Login</Link>
      </Tab>
    </ModeContainer>
  );
}

export default Mode;

그리고 atom의 defalt값도 바꾸어준다.

//atoms.ts
import { atom } from "recoil";

export const isDarkAtom = atom({
  key: "Dark", // 
  default: localStorage.getItem("mode") === "true" ? true : false,
});

이렇게 해주게 되면, 페이지에 나갔다 들어와도 사용자가 선택한 모드로 설정이 된다.

😱 하지만 위에서 보이는 것과 같이 DarkMode에서 새로고침을 하면 잠깐 LightMode가 되었다가 DarkMode가 되는 현상이 일어났다. GlobalStyle에 transition: background-color 0.5s, color 0.5s 을 지우게 되면 깜빡이는 현상은 사라지지만, 변환되는 과정이 마음에 들지 않아서 방법을 찾아 보았다.


5. 페이지 새로고침 시 깜빡이는 현상.

해당부분을 개선하기 위해서는 화면이 완전히 렌더링 되기 전에 배경색을 먼저 넣어 주어야 한다.
먼저 브라우저 렌더링 순서에 대해 알아보자.

✍️ 브라우저 렌더링 순서

  • HTML을 웹서버로부터 받음
  • HTML 파싱 및 CSS, Script 로드
  • DOM, CSSOM 생성
  • 생성된 DOM과 CSSOM으로 렌더링 트리 생성
  • css, 레이아웃
  • 그리기

✍️ HTML Blocking

앞서 렌더링의 2번째 과정에서 script 태그가 해석되는 동안에는 HTML 파싱이 중단된다. 이러한 현상을 HTML Blocking 이라고 한다.

<!-- public/index.html -->
<head>
  <script>
    alert("HTML Blocking")
  </script>
  <title>React App</title>
</head>

이 부분에서 DOM트리가 생성되기 직전에 어떤 테마로 화면을 그려낼지 결정 하면 된다.
아래 코드를 public/index.html 에 적용 시켜 주었다.

 <script type="text/javascript">
      (function() {
        function getInitialColorMode() {
          const isClient = typeof window !== 'undefined';
          if (isClient) {
            const persistedColorPreference = window.localStorage.getItem('mode');
    
            if (persistedColorPreference) {
              return persistedColorPreference;
            }
    
            const systemPreference = window.matchMedia('(prefers-color-scheme: dark)');
            if (systemPreference.matches) {
              return 'true';
            }
            return 'false';
          }
        }
        const colorMode = getInitialColorMode();
        document.documentElement.style.setProperty(
          'background-color',
          colorMode === 'false' ? '#FFFFFF' : '#2F2F2F'
        );
        document.documentElement.style.setProperty(
          'color',
          colorMode === 'false' ? '#000000' : '#e5e5e5'
        );
      })();
    </script>

이제 화면을 그리기 전, 위의 script를 만나 html에 배경색을 미리 넣어주어 깜빡이는 현상을 해결할 수 있다.

🚀 해당 이슈 해결 커밋


💡 참고

profile
✍️ 👨🏻‍💻🔥➜👍🤔 😱

0개의 댓글