Темный режим с Next.js, TypeScript, стилизованными компонентами и набором инструментов Redux
Одна из наиболее распространенных функций, которые мы реализуем, — это темный режим. Однако, реализовав темный режим один раз в проекте, легко забыть, как он был реализован, потому что какое-то время нет возможности его реализовать. Поэтому я решил собрать статью о методе, который использую чаще всего, чтобы каждый мог проверить его в любое время!
Технический стек
Для реализации используются следующие языки, библиотеки и платформы:
- Next.js
- TypeScript
- Стилизованные компоненты
- Набор инструментов Redux
- cookies-next
Пошаговая реализация
Конфигурации, константы и типы для цветовых тем
Сначала давайте определим константы и типы, необходимые для управления цветовой темой в const/colorTheme.ts
// const/colorTheme.ts
// Types of available color themes
export const colorThemeNames = [
'light',
'dark',
] as const;
// Can't use type ColorThemeName because of circular dependency
export const defaultColorThemeName: typeof colorThemeNames[number] = 'light';
// Cookie key for color theme
export const colorThemeCookieName = 'myAppColorTheme';
и в types/colorTheme.ts
// types/colorTheme.ts
import { colorThemeNames } from '../const/colorTheme';
export type ColorThemeStyle = {
colors: {
text: string
background: string
componentBackground: string
border: string
info: string
infoBg: string
danger: string
dangerBg: string
},
};
export type ColorThemeName = typeof colorThemeNames[number];
/**
* Type guard for ColorThemeName
*
* @param {unknown} val
* @return {*} {val is ColorThemeName}
*/
export const isColorThemeName = (val: unknown): val is ColorThemeName => (
colorThemeNames.includes(val as ColorThemeName)
);
Также нам нужно изменить тип DefaultTheme
в styled.d.ts
вот так:
// styled.d.ts
import 'styled-components';
import { ColorThemeStyle } from './types/colorTheme';
declare module 'styled-components' {
export interface DefaultTheme extends ColorThemeStyle {}
}
Удобно создавать переменные для цветов, которые мы будем использовать.
// const/styles/colors.tsx
export const dryadBark = '#37352f'; // light theme string color
export const white = '#ffffff'; // light theme component color
export const errigalWhite = '#f6f6f9'; // light theme background color
export const gainsboro = '#d9d9d9'; // light theme border color
export const coralRed = '#f93e3d'; // common danger color
export const translucentUnicorn = '#fcecee';
export const softPetals = '#e9f6ef';
export const vegetation = '#48cd90'; // common info color
export const stonewallGrey = '#c3c2c1';
export const astrograniteDebris = '#3b414a'; // dark theme border color
export const aswadBlack = '#141519'; // dark theme background color
export const washedBlack = '#202528'; // dark theme component background color
(Я использовал эту библиотеку для именования этих переменных.)
Затем объявите объекты цветовой темы, тему по умолчанию. config/styles/colorTheme.ts
import { ColorThemeStyle, ColorThemeName } from '../../types/colorTheme';
// colors
import {
dryadBark,
white,
errigalWhite,
gainsboro,
coralRed,
vegetation,
astrograniteDebris,
aswadBlack,
washedBlack,
softPetals,
translucentUnicorn,
} from '../../const/styles/colors';
export const defaultColorThemeName: ColorThemeName = 'light';
export const lightTheme: ColorThemeStyle = {
colors: {
text: dryadBark,
background: errigalWhite,
componentBackground: white,
border: gainsboro,
info: vegetation,
infoBg: softPetals,
danger: coralRed,
dangerBg: translucentUnicorn,
},
};
export const darkTheme: ColorThemeStyle = {
colors: {
text: white,
background: aswadBlack,
componentBackground: washedBlack,
border: astrograniteDebris,
info: vegetation,
infoBg: softPetals,
danger: coralRed,
dangerBg: translucentUnicorn,
},
};
export const themeNameStyleMap: { [key in ColorThemeName]: ColorThemeStyle } = {
light: lightTheme,
dark: darkTheme,
};
export const defaultColorThemeStyle = themeNameStyleMap[defaultColorThemeName];
Настройка набора инструментов Redux
Состояние темного режима должно управляться глобально. Итак, давайте воспользуемся Redux Toolkit для управления глобальным состоянием.
Мы собираемся создать colorThemeSlice
, набрать useDispatch
, ввести useSelector
и настроить store
.
stores/slices/colorThemeSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
// "type" is needed. If no "type", circular dependency error arise
// https://stackoverflow.com/questions/63923025/how-to-fix-circular-dependencies-of-slices-with-the-rootstate
import type { RootState } from '../store';
import { ColorThemeName } from '../../types/colorTheme';
import { defaultColorThemeName } from '../../const/colorTheme';
type ColorThemeState = {
theme: ColorThemeName
};
const initialState: ColorThemeState = {
theme: defaultColorThemeName,
};
const colorThemeSlice = createSlice({
name: 'colorTheme',
initialState,
reducers: {
updateColorTheme: (state, action: PayloadAction<ColorThemeName>) => {
state.theme = action.payload;
},
},
});
// selectors
export const selectColorTheme = (state: RootState) => state.colorTheme.theme;
export default colorThemeSlice.reducer;
// actions
export const {
updateColorTheme,
} = colorThemeSlice.actions;
stores/store.ts
// https://redux-toolkit.js.org/tutorials/typescript
import { configureStore } from '@reduxjs/toolkit';
// reducers
import colorThemeReducer from './slices/colorThemeSlice';
export const store = configureStore({
reducer: {
colorTheme: colorThemeReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
stores/hooks.ts
// https://redux-toolkit.js.org/tutorials/typescript
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';
import { RootState, AppDispatch } from './store';
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Служебные функции для обработки файлов cookie цветовой темы
Темный режим должен сохраняться даже при обновлении страницы. Для этого мы будем использовать файлы cookie.
utils/cookie/colorTheme.ts
import { getCookie, setCookie } from 'cookies-next';
import { OptionsType } from 'cookies-next/lib/types';
import { colorThemeCookieName } from '../../const/colorTheme';
import { ColorThemeName, isColorThemeName } from '../../types/colorTheme';
/**
* Set color theme cookie to persist color theme config
*
* @param {ColorThemeName} value
* @param {OptionsType} [options]
*/
export const setColorThemeCookie = (value: ColorThemeName, options?: OptionsType) => {
setCookie(colorThemeCookieName, value, options);
};
/**
*
*
* @param {OptionsType} [options]
* @return {string} {string}
*/
export const getColorThemeCookie = (options?: OptionsType): string => {
const colorThemeCookie = getCookie(colorThemeCookieName, options);
return isColorThemeName(colorThemeCookie) ? colorThemeCookie : '';
};
Реализация пользовательского хука useColorTheme
В реализации нам потребуется переключить цветовые темы или получить текущую цветовую тему. Давайте поместим эту логику в специальный хук, чтобы ее можно было вызывать в каждом компоненте.
hooks/useColorTheme.ts
import { defaultColorThemeName } from '../const/colorTheme';
import { getColorThemeCookie, setColorThemeCookie } from '../utils/cookie/colorTheme';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { selectColorTheme, updateColorTheme } from '../stores/slices/colorThemeSlice';
import { themeNameStyleMap } from '../config/styles/colorThemes';
import { ColorThemeName, ColorThemeStyle, isColorThemeName } from '../types/colorTheme';
/**
* Custom hook for handling color themes
*
*/
const useColorTheme = () => {
const dispatch = useAppDispatch();
const currentColorTheme = useAppSelector(selectColorTheme);
/**
* Set color theme cookie and state
*
* @param {ColorThemeName} colorThemeName
*/
const setColorTheme = (colorThemeName: ColorThemeName) => {
setColorThemeCookie(colorThemeName);
dispatch(updateColorTheme(colorThemeName));
};
/**
* Initialize color theme cookie and state
*
* @return {void}
*/
const initColorTheme = () => {
const currentColorThemeCookie = getColorThemeCookie();
if (!currentColorThemeCookie || !isColorThemeName(currentColorThemeCookie)) {
setColorTheme(defaultColorThemeName);
return;
}
dispatch(updateColorTheme(currentColorThemeCookie));
};
/**
*
*
* @return {*} ColorTheme
*/
const getCurrentColorThemeState = (): ColorTheme => (
currentColorThemeState
);
/**
*
*
* @return {*} {ColorThemeStyle}
*/
const getCurrentColorThemeStyle = (): ColorThemeStyle => (
themeNameStyleMap[currentColorTheme]
);
return {
setColorTheme,
initColorTheme,
getCurrentColorThemeState,
getCurrentColorThemeStyle,
};
};
export default useColorTheme;
Настройте RTK и стилизованные компоненты в _app.tsx
На данный момент мы реализовали так много функций и логики, но в нынешнем виде мы пока не можем их использовать. Давайте отредактируем _app.tsx
, чтобы убедиться, что Redux Toolkit и стилизованные компоненты доступны для использования.
pages/_app.tsx
import { useEffect, ReactElement, ReactNode } from 'react';
// Next
import { NextPage } from 'next';
import { Router } from 'next/router';
import type { AppProps } from 'next/app';
// Libraries
import { Provider } from 'react-redux';
import { ThemeProvider } from 'styled-components';
import { store } from '../stores/store';
import GlobalStyle from '../components/globalstyles';
import useColorTheme from '../hooks/useColorTheme';
// Layout configuration doc
// https://nextjs.org/docs/pages/building-your-application/routing/pages-and-layouts#with-typescript
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode;
};
type AppPropsWithLayout = AppProps & {
Component: NextPageWithLayout
router: Router // Error if this property doesn't exist
};
/**
*
*
* @param {AppPropsWithLayout} { Component, pageProps }
* @return {*} JSX.Element
*/
const WithThemeProviderComponent = ({ Component, pageProps }: AppPropsWithLayout) => {
const { initColorTheme, getCurrentColorThemeStyle } = useColorTheme();
useEffect(() => {
initColorTheme();
}, []);
return (
<ThemeProvider theme={getCurrentColorThemeStyle()}>
<GlobalStyle />
<Component {...pageProps} />
</ThemeProvider>
);
};
const App = ({ Component, pageProps, router }: AppPropsWithLayout) => {
const getLayout = Component.getLayout ?? ((page) => page);
return (
<Provider store={store}>
{getLayout(
<WithThemeProviderComponent
Component={Component}
pageProps={pageProps}
router={router}
/>,
)}
</Provider>
);
};
export default App;
Давайте реализуем переключатель темного режима
С реализацией к этому моменту мы завершили необходимую подготовку к переключению цветовой темы. Теперь давайте воспользуемся useColorTheme
для реализации компонента DarkModeToggleSwitch
(пока мы пропустим детальную стилизацию).
globalstyles.tsx
...
body {
...
background-color: ${({ theme }) => theme.colors.background};
color: ${({ theme }) => theme.colors.text};
...
}
...
components/DarkModeToggleSwitch.tsx
import { useColorTheme } from '../../../hooks/useColorTheme'
/**
* Dark mode <-> light mode toggle switch
* Update cookie value and global state
*
* @return {*} JSX.Element
*/
const DarkModeToggleSwitch = () => {
const { setColorTheme, getCurrentColorThemeState } = useColorTheme()
const currentColorTheme = getCurrentColorThemeState()
const isDark = currentColorTheme === 'dark'
const toggleDarkTheme = () => {
isDark ? setColorTheme('light') : setColorTheme('dark')
}
return (
<>
...
<input type='checkbox' checked={isDark} onChange={toggleDarkTheme} />
...
</>
)
}
export default DarkModeToggleSwitch
Вот и все. Надеюсь, эта статья была для вас полезна. Помимо этой статьи, существует множество способов реализации переключения цветовой темы. Если у вас есть рекомендуемые методы или статьи, поделитесь ими в комментариях и дайте мне знать!