返回介绍

主题化功能

发布于 2024-09-11 01:01:38 字数 12805 浏览 0 评论 0 收藏 0

抖音前端技术官网 为例,它的官网有包含默认的样式:

也有暗黑色调的展示:

基础色调变量抽离

主题化功能对 DOM 的结构变化不大,基本是针对色调进行切换。

顺着这个思路,如果定义两套变量,是不是就完成了对两套主题的配置?根据不同的主题,在 html 标签上来固定两个属性来区分,方案就确定了。

在全局样式中定义两套之前使用到的色调,包括字体和背景等颜色,把之前定义的组件样式抽出来放在这里就可以,保证所有的色调都通过变量的方式来引用。

styles/global.css

html[data-theme="dark"] {
  --primary-color: #ffffff;
  --primary-background-color: rgba(14, 14, 14, 1);
  --footer-background-color: rgba(36, 36, 36, 1);
  --navbar-background-color: rgba(0, 0, 0, 0.5);
  --secondary-color: rgba(255, 255, 255, 0.5);
  --link-color: #34a8eb;
}

html[data-theme="light"] {
  --primary-color: #333333;
  --primary-background-color: rgba(255, 255, 255, 1);
  --footer-background-color: #f4f5f5;
  --navbar-background-color: rgba(255, 255, 255, 0.5);
  --secondary-color: #666666;
  --link-color: #0070f3;
}

接下来就是把这些定义的变量去替换原来样式中给的固定色值。

components/footer/index.module.scss

.footer {
  padding: 70px 145px;
  background-color: var(--footer-background-color);
  .topArea {
    display: flex;
    justify-content: space-between;

    .footerTitle {
      font-weight: 500;
      font-size: 36px;
      line-height: 36px;
      color: var(--primary-color);
      margin: 0;
    }

    .linkListArea {
      display: flex;
      .linkArea {
        display: flex;
        flex-direction: column;
        margin-left: 160px;
        .title {
          font-weight: 500;
          font-size: 14px;
          line-height: 20px;
          color: var(--primary-color);
          margin-bottom: 40px;
        }

        .links {
          display: flex;
          flex-direction: column;
          font-weight: 400;
          font-size: 14px;
          line-height: 20px;

          .link {
            color: var(--primary-color);
            cursor: pointer;
            margin-bottom: 24px;
          }

          .disabled {
            color: var(--secondary-color);
            cursor: not-allowed;
            margin-bottom: 24px;
          }
        }
      }
    }
  }

  .bottomArea {
    display: flex;
    justify-content: space-between;
    .codeArea {
      display: flex;
      flex-direction: column;
      .text {
        color: var(--secondary-color);
      }
    }
    .numArea {
      color: var(--secondary-color);
      display: flex;
      flex-direction: column;
      align-items: flex-end;
      font-weight: 400;
      font-size: 14px;
      line-height: 20px;

      span {
        margin-bottom: 12px;
      }

      .publicLogo {
        display: flex;

        .logo {
          margin-right: 4px;
        }
      }
    }
  }
}

components/layout/index.module.scss

.layout {
  background-color: var(--primary-background-color);
  .main {
    min-height: calc(100vh - 560px);
  }
}

components/navbar/index.module.scss

.navBar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background-color: var(--navbar-background-color);
  backdrop-filter: blur(8px);
  width: 100%;
  height: 64px;
  position: sticky;
  top: 0;
  left: 0;
  padding: 20px 32px;
  z-index: 100;
  .logoIcon {
    width: 4.375rem;
    height: 1.25rem;
    background-image: var(--navbar-icon);
    background-size: 4.375rem 1.25rem;
    background-repeat: no-repeat;
  }
  .themeIcon {
    width: 1.25rem;
    height: 1.25rem;
    background-image: var(--theme-icon);
    background-size: 1.25rem 1.25rem;
    background-repeat: no-repeat;
    cursor: pointer;
  }
}

图片主题化配置

对于图片的主题化,有两种方式,一种是针对一般固定不变的图片,采用同样定义的方式。

styles/global.css

html[data-theme="dark"] {
  --primary-color: #ffffff;
  --primary-background-color: rgba(14, 14, 14, 1);
  --footer-background-color: rgba(36, 36, 36, 1);
  --navbar-background-color: rgba(0, 0, 0, 0.5);
  --secondary-color: rgba(255, 255, 255, 0.5);
  --link-color: #34a8eb;
  --navbar-icon: url('../public/logo_dark.png');
  --theme-icon: url('../public/theme_dark.png');
}

html[data-theme="light"] {
  --primary-color: #333333;
  --primary-background-color: rgba(255, 255, 255, 1);
  --footer-background-color: #f4f5f5;
  --navbar-background-color: rgba(255, 255, 255, 0.5);
  --secondary-color: #666666;
  --link-color: #0070f3;
  --navbar-icon: url('../public/logo_light.png');
  --theme-icon: url('../public/theme_light.png');
}

另一种是配置的图片,可能会频繁变化,这种只需要在 Strapi 中再加一个字段存不同主题的图片,然后在页面逻辑中根据不同的主题去切换就可以。

主题数据注入

针对当前的主题,肯定有个地方需要进行缓存,应该使用哪种客户端缓存机制呢?

主题化功能往往是因为用户更喜欢这种色调,用 localStorage 要更合适,因为相比 sessionStorage 只能保存当前会话的特点,localStorage 可以长期保留,除非用户主动清除,保证下一次访问时也可以保证是之前的主题。

那么应该怎么去注入这个缓存呢,如果随心所欲地去进行缓存注入操作,那页面中可能会分散各种缓存的逻辑,不符合单一职责原则,也不利于统一的维护和相关事件的绑定,所以需要在一处地方聚集主题相关的逻辑,然后再分别注入给每个页面对应的编辑方法。

这里需要用到 React 的 useContext,它具有接受上下文,并将上下文进行注入的能力。

新建 constants/enum

export enum Themes {
  light = 'light',
  dark = 'dark',
}

新建 stores/theme.tsx

import {createContext, FC, useEffect, useState} from 'react';
import {Themes} from '@/constants/enum';

interface IThemeContextProps {
  theme: Themes;
  setTheme: (theme: Themes) => void;
}

interface IThemeContextProviderProps {
  children: JSX.Element;
}

export const ThemeContext = createContext<IThemeContextProps>({} as IThemeContextProps);

const ThemeContextProvider: FC<IThemeContextProviderProps> = ({children}) => {
  const [theme, setTheme] = useState<Themes>(Themes.light);
  useEffect(() =>  {
    const item = localStorage.getItem('theme') as Themes || Themes.light;
    setTheme(item);
    document.getElementsByTagName('html')[0].dataset.theme = item;
  }, []);
  return (
    <ThemeContext.Provider value={{
      theme,
      setTheme: (currentTheme) => {
        setTheme(currentTheme);
        localStorage.setItem('theme', currentTheme);
        document.getElementsByTagName('html')[0].dataset.theme = currentTheme;
      }
    }}>
      {children}
    </ThemeContext.Provider>
  )
}

export default ThemeContextProvider;

ThemeContext 是暴露出的变量,在全局注入后,每个路由页面都可以通过它来获取定义的 theme 和 setTheme 进行相关的业务操作。

ThemeContextProvider 则是注入器,用于给需要的 DOM 进行上下文的注入。

在全局页面注入 context。

pages/_app.tsx

import type { AppProps, AppContext } from 'next/app';
import App from 'next/app';
import Head from 'next/head';
import axios from 'axios';
import ThemeContextProvider from '@/stores/theme';
import { LOCALDOMAIN } from '@/utils';
import type { ILayoutProps } from '@/components/layout';
import Layout from '@/components/layout';
import '@/styles/globals.css'

const MyApp = (data: AppProps & ILayoutProps) => {
  const {
    Component, pageProps, navbarData, footerData
  } = data;
  return (
    <div>
      <Head>
        <title>A Demo for 官网开发实战</title>
        <meta
          name="description"
          content="A Demo for 官网开发实战"
        />
        <link rel="icon" href="/favicon.ico" />
      </Head>
      <ThemeContextProvider>
        <Layout navbarData={navbarData} footerData={footerData}>
          <Component {...pageProps} />
        </Layout>
      </ThemeContextProvider>
    </div>

  )
}

MyApp.getInitialProps = async (context: AppContext) => {
  const pageProps = await App.getInitialProps(context);
  const { data = {} } = await axios.get(`${LOCALDOMAIN}/api/layout`)
  return {
    ...pageProps,
    ...data,
  }
}
export default MyApp;

在 navbar 加一个主题化切换的入口。

components/navbar/index.tsx

import {FC, useContext} from 'react';
import {ThemeContext} from '@/stores/theme';
import {Themes} from '@/constants/enum';
import styles from './index.module.scss';

export interface INavBarProps {}

const NavBar: FC<INavBarProps> = ({}) => {
  const { setTheme } = useContext(ThemeContext);
  return (
    <div className={styles.navBar}>
      <a href="http://localhost:3000/">
        <div className={styles.logoIcon} />
      </a>
      <div className={styles.themeIcon} onClick={(): void => {
        setTheme(localStorage.getItem('theme') === Themes.light ? Themes.dark : Themes.light);
      }}/>
    </div>
  )
}

export default NavBar;

components/navbar/index.module.scss

.navBar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background-color: var(--navbar-background-color);
  backdrop-filter: blur(8px);
  width: 100%;
  height: 64px;
  position: sticky;
  top: 0;
  left: 0;
  padding: 20px 32px;
  z-index: 100;
  .logoIcon {
    width: 4.375rem;
    height: 1.25rem;
    background-image: var(--navbar-icon);
    background-size: 4.375rem 1.25rem;
    background-repeat: no-repeat;
  }
  .themeIcon {
    width: 1.25rem;
    height: 1.25rem;
    background-image: var(--theme-icon);
    background-size: 1.25rem 1.25rem;
    background-repeat: no-repeat;
    cursor: pointer;
  }
}

启动项目,可以看到已经可以实现主题化的功能了。

多进程场景下主题同步

浏览器是多进程的,每个开启的页面都对应到一个进程,这样可以有效地避免页面之间的数据共享及一个报错页面带崩所有页面的情况。

如果用户开了多个页面来访问站点,其中一个页面的主题切换,另一个页面是感知不到的,这样一个浏览器下会有多个主题的页面,对用户体验上来说是不太好的。

出于追求极致考虑,优化一下这个问题,其实也很简单,只需要监听浏览器的缓存修改事件,然后再次执行初始化的操作就好了。

stores/theme.tsx

import {createContext, FC, useEffect, useState} from 'react';
import {Themes} from '@/constants/enum';

interface IThemeContextProps {
  theme: Themes;
  setTheme: (theme: Themes) => void;
}

interface IThemeContextProviderProps {
  children: JSX.Element;
}

export const ThemeContext = createContext<IThemeContextProps>({} as IThemeContextProps);

const ThemeContextProvider: FC<IThemeContextProviderProps> = ({children}) => {
  const [theme, setTheme] = useState<Themes>(Themes.light);
  useEffect(() =>  {
    debugger
    const checkTheme = () => {
      const item = localStorage.getItem('theme') as Themes || Themes.light;
      setTheme(item);
      document.getElementsByTagName('html')[0].dataset.theme = item;
    }
    // 初始化先执行一遍
    checkTheme();
    // 监听浏览器缓存事件
    window.addEventListener('storage', checkTheme);
    return (): void => {
      // 解绑
      window.removeEventListener('storage', checkTheme);
    }
  }, []);
  return (
    <ThemeContext.Provider value={{
      theme,
      setTheme: (currentTheme) => {
        setTheme(currentTheme);
        localStorage.setItem('theme', currentTheme);
        document.getElementsByTagName('html')[0].dataset.theme = currentTheme;
      }
    }}>
      {children}
    </ThemeContext.Provider>
  )
}

export default ThemeContextProvider;

现在尝试打开两个页面,修改其中一个,发现另一个也会同步更新为一样的主题了。

闪烁场景优化

还有一个小问题,因为在服务器端是获取不到当前的主题的,通过 useEffect 钩子来获取主题进行样式的渲染,这样其实会有一个主题切换的过程,在低网速或是快速切换场景下会有比较明显的闪烁,可以在钩子处设置断点查看(当前缓存是黑色主题)。

可以看到走到钩子的时候,是还没办法进行对应主题样式渲染的,应该怎么解决这个问题呢?

只需要在 HTML 中引入对应的 script,确保可以在交互之前进行主题的初始化就行了。

Nextjs 有提供这个能力,修改 _document.tsx,然后引入对应的内部脚本。

import { Html, Head, Main, NextScript } from 'next/document'
import Script from 'next/script';

export default function Document() {
  return (
    <Html lang="en">
      <Head />
      <body>
        <Main />
        <NextScript />
        <Script id="theme-script" strategy="beforeInteractive">
          {
            `const item = localStorage.getItem('theme') || 'light';
             localStorage.setItem('theme', item);
             document.getElementsByTagName('html')[0].dataset.theme = item;
            `
          }
        </Script>
      </body>
    </Html>
  )
}

id 是用于 Nextjs 检索,beforeInteractive 表明这个脚本的执行策略是在交互之前,会被默认放到 head 中。

现在再来试试效果,发现走到钩子的时候已经可以正常去初始化了。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文