likes
comments
collection
share

「原生练手」✨将深浅色模式切换逻辑抽离成hooks,体验hooks的魅力!

作者站长头像
站长
· 阅读数 20

最近项目开发的过程中经常有抽离hooks提高代码复用性的场景,在我个人看来,抽离hooks主要有以下好处:

  1. 提高代码复用性,对于存在明显重复代码的场景,这能够很大程度上减少重复代码,解放你的双手,停止重复劳动
  2. 即便是没有复用性的场景,也可以起到一个语义化的作用,将某一个逻辑抽离成hooks,起一个语义化的名字,这样一来别人看到你的代码,就能够见名知意,知道对应的功能是什么,而不用去理解实现细节
  3. 抽离hooks的过程中,我们往往能够发现一些可扩展点,在保证原来功能的基础上进行扩展,让其功能更加丰富,提高可扩展性

今天我们就以深浅色模式切换这个功能为例,体验以下抽离hooks的整个流程是怎样的

场景搭建

为了方便,这里我直接使用vitevanilla模板,也就是原生开发,

pnpm create vite dark-light-toggler --template vanilla-ts

然后我们在src目录下创建一个hooks目录,用于存放我们的hooksstyles存放全局的样式

思考深浅色模式切换的原理

首先我们要知道如何去区分深色和浅色模式,我的思路是在html根结点上添加一个.dark.light的类名去标识当前的模式,然后css中根据这两个类名显示对应的颜色

由于考虑到其他的功能或许有可能也会在html根节点上添加类名,所以我们不能够说简单的将根节点的className设置成light或者dark,而应当通过classListaddremove去控制,不应当影响到别的功能对根节点类名的操作

其次,我们还需要有一个按钮,点击它能够切换深色模式和浅色模式,还需要能够根据设备的深浅色模式自动选择默认的深浅色模式,这个可以通过媒体查询prefers-color-scheme去实现,稍后讲实现时会说到

编码实践

页面结构

理解了原理后我们可以开始实战了,首先编写以下整个页面结构

<!DOCTYPE html>
<html lang="en" wh-full class="light">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Dark Light Toggler</title>
  </head>
  <body wh-full>
    <!-- 右上角深色模式切换 -->
    <section fixed top-10 right-20 text-2xl cursor-pointer>
      <div i-ic-twotone-wb-sunny></div>
    </section>

    <div id="app" flex-center wh-full font-mono>
      <div w-sm h-sm i-uim:rocket></div>
    </div>

    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

结构不复杂,就是右上角一个图标按钮,中间一个图标让界面看起来不会那么空荡,根节点上初始化一个类名light,也就是默认是浅色模式

这里样式的编写我没有使用css,而是使用了antf大神的UnoCSS原子化css框架,并且结合Attributify的方式,可以直接以html attribute的方式编写原子化样式,再结合vscodeunocss插件,开发体验简直不要太爽!

有了页面结构后,再编写一下根节点的.dark.light样式,主要就是修改背景色

html {
  transition: background-color 1s ease;
}

html.dark {
  background-color: #1b2430;
}

html.light {
  background-color: #e7f6f2;
}

目前的效果如下:

「原生练手」✨将深浅色模式切换逻辑抽离成hooks,体验hooks的魅力!

实现深浅色模式切换逻辑

接下来我们编写深浅色模式切换逻辑,主要就是获取根节点上的类名,点击按钮时切换类名即可,于是我们可以写出第一版实现

import '@unocss/reset/antfu.css'
import 'uno.css'
import '@/styles/index.scss'

const oDarkToggler = document.getElementById('dark-toggler')

const init = () => {
  bindEvent()
}

const bindEvent = () => {
  oDarkToggler?.addEventListener('click', () => {
    // 获取当前主题
    const currentTheme = document.documentElement.className

    if (currentTheme === 'light') document.documentElement.className = 'dark'
    else if (currentTheme === 'dark')
      document.documentElement.className = 'light'
  })
}

init()

很简单粗暴,就是获取当前主题,然后取反进行切换,于是现在的效果如下

「原生练手」✨将深浅色模式切换逻辑抽离成hooks,体验hooks的魅力!

右上角的图标并没有切换,为了方便切换图标,我们给图标加个id,并且原子化样式由attribute形式改成类名形式,方便修改

<!-- 右上角深色模式切换 -->
<section fixed top-10 right-20 text-2xl cursor-pointer>
  <div id="dark-toggler" class="i-ic-twotone-wb-sunny"></div>
</section>

然后加上图标类名的切换

  oDarkToggler?.addEventListener('click', () => {
    // 获取当前主题
    const currentTheme = document.documentElement.className

    // 切换根节点类名和按钮图标
    if (currentTheme === 'light') {
      document.documentElement.className = 'dark'
      oDarkToggler.className = 'i-material-symbols:dark-mode'
    } else if (currentTheme === 'dark') {
      document.documentElement.className = 'light'
      oDarkToggler.className = 'i-ic-twotone-wb-sunny'
    }
  })

现在效果如下:

「原生练手」✨将深浅色模式切换逻辑抽离成hooks,体验hooks的魅力!

可以看到图标的切换也正常了,那么就这样就结束了吗?肯定没有,我们还没进入正题呢!

首先先做一个小的重构优化,现在我们的代码看上去已经有很明显的重复了,那就是对根节点类名的赋值,每次修改都要写上一长串,不优雅,而且不够语义化,这里可以先抽离一下

const bindEvent = () => {
  oDarkToggler?.addEventListener('click', () => {
    // 获取当前主题
    const currentTheme = getRootElClassName()

    // 切换根节点类名和按钮图标
    if (currentTheme === 'light') {
      setRootElClassName('dark')
      oDarkToggler.className = 'i-material-symbols:dark-mode'
    } else if (currentTheme === 'dark') {
      setRootElClassName('light')
      oDarkToggler.className = 'i-ic-twotone-wb-sunny'
    }
  })
}

const getRootElClassName = (): string => {
  return document.documentElement.className
}

const setRootElClassName = (className: string): void => {
  document.documentElement.className = className
}

抽离成hooks

现在我们的功能算是完成了,但是考虑一下过几个月后再来看这个代码,肯定是需要花点时间看一看代码才知道这部分代码想要做什么事情,而不能够见名知意,不够语义化,可维护性较低,这时候抽离成hooks就能够帮大忙了

首先创建src/hooks/useToggleDark.ts,其代码如下

interface Options {
  // 切换按钮的 id
  togglerId: string
  // 深色模式的图标类名
  darkIcon: string
  // 浅色模式的图标类名
  lightIcon: string
}

export default function useToggleDarkLight(options?: Options) {
  const togglerId = options?.togglerId
  const darkIcon = options?.darkIcon
  const lightIcon = options?.lightIcon
  let togglerEl: HTMLElement | null = null

  // 如果有传入按钮 id 则需要对按钮图标进行修改
  togglerId && (togglerEl = document.getElementById(togglerId))

  return () => {
    // 获取当前主题
    const currentTheme = getRootElClassName()

    // 切换根节点类名和按钮图标
    if (currentTheme === 'light') {
      setRootElClassName('dark')
      togglerEl && (togglerEl.className = darkIcon!)
    } else if (currentTheme === 'dark') {
      setRootElClassName('light')
      togglerEl && (togglerEl.className = lightIcon!)
    }
  }
}

const getRootElClassName = (): string => {
  return document.documentElement.className
}

const setRootElClassName = (className: string): void => {
  document.documentElement.className = className
}

就是将刚刚的代码复制了过来,但是做了一些改动,由于我们现在是抽离hooks了,就应当尽可能让其和刚才的特定环境解耦,也就是只负责深浅色的切换,而图标的更改其实未必一定会有

比如我希望在setInterval中每两秒自动切换一次深浅色模式的话,这种时候其实我是不需要切换按钮图标的,不过我们可以将按钮图标的切换作为一个可扩展点,允许用户传入配置,当其传入了按钮图标的相关配置后则会自动将图标也进行切换

配置项中包括按钮图标的id,用于获取该元素,其次,由于图标的类名也是可能不同的,所以也要作为配置项,于是得到了如上代码

这样一来,当我们只是希望切换深浅色的时候,调用该hooks返回的函数即可,由于闭包的特性,这些配置项都会被保留下来,这样子可以做什么有趣的事情呢?

我们可以利用其生成一个只切换深浅色的toggleDark函数,也可以再生成一个既切换深浅色,又切换按钮图标的toggleDarkWithIcon函数

import '@unocss/reset/antfu.css'
import 'uno.css'
import '@/styles/index.scss'
import { useToggleDarkLight } from './hooks'

const oDarkToggler = document.getElementById('dark-toggler')

// 只切换深浅色
const toggleDark = useToggleDarkLight()

// 切换深浅色的同时还能切换按钮图标
const toggleDarkWithIcon = useToggleDarkLight({
  togglerId: 'dark-toggler',
  darkIcon: 'i-material-symbols:dark-mode',
  lightIcon: 'i-ic-twotone-wb-sunny',
})

const init = () => {
  // 自动切换深浅色 但是不希望切换图标
  setInterval(toggleDark, 3000)
  bindEvent()
}

const bindEvent = () => {
  // 点击切换深浅色 同时切换按钮图标
  oDarkToggler?.addEventListener('click', toggleDarkWithIcon)
}

init()

抽象再升级 -- 扩展成任意主题的切换

在深浅色切换的基础上,我们可以再扩展,这次我们的目标不局限于深浅色模式的切换了,而是任意主题的切换

再创建一个src/hooks/useThemer.ts

这次我们实现一个themer,它可以管理主题,包括对主题的初始化、获取当前主题、设置当前主题,以及把刚才的深浅色模式作为单独的功能集成进来,我们可以先定义它的接口

interface Themer {
  initTheme: () => void
  getCurrentTheme: () => Theme
  setCurrentTheme: (theme: Theme) => void
  toggleDarkTheme: () => void
}

themer的实现如下

interface Options {
  // 切换按钮的 id
  togglerId: string
  // 深色模式的图标类名
  darkIcon: string
  // 浅色模式的图标类名
  lightIcon: string
}

export default function useThemer(options?: Options) {
  const togglerId = options?.togglerId
  const darkIcon = options?.darkIcon
  const lightIcon = options?.lightIcon
  let togglerEl: HTMLElement | null = null

  // 如果有传入按钮 id 则需要对按钮图标进行修改
  togglerId && (togglerEl = document.getElementById(togglerId))

  const themer: Themer = {
    initTheme: () => {
      // 使用媒体查询判断当前系统设备是什么模式 自动初始化为对应模式的主题
      const systemIsDark = window.matchMedia(
        '(prefers-color-scheme: dark)',
      ).matches

      if (systemIsDark) {
        themer.setCurrentTheme('dark')
      } else {
        themer.setCurrentTheme('light')
      }
    },
    // 没有设置主题时默认是 light 主题
    getCurrentTheme: () => (getRootElDataTheme() as Theme) ?? 'light',
    setCurrentTheme: (theme: Theme) => {
      const currentTheme = themer.getCurrentTheme()

      // 采用 classList 添加和移除类名的方式 避免影响其他同样会操作根节点类名的操作
      // 先将原来的主题移除
      document.documentElement.classList.remove(currentTheme)

      // 再设置新主题
      setRootElDataTheme(theme)
      document.documentElement.classList.add(theme)
    },
    // 在 light 和 dark 主题之间切换
    toggleDarkTheme: () => {
      const currentTheme = themer.getCurrentTheme()
      switch (currentTheme) {
        case 'light':
          themer.setCurrentTheme('dark')
          togglerEl && (togglerEl.className = darkIcon!)
          break
        case 'dark':
          themer.setCurrentTheme('light')
          togglerEl && (togglerEl.className = lightIcon!)
          break
      }
    },
  }

  return themer
}

const getRootElDataTheme = () => {
  return document.documentElement.dataset.theme
}

const setRootElDataTheme = (theme: Theme): void => {
  document.documentElement.dataset.theme = theme
}

注释已经写的很清楚了,就不赘述了,直接来体验一下吧

import { useThemer } from '@/hooks'

export const useThemerDemo = () => {
  const { initTheme, toggleDarkTheme } = useThemer({
    togglerId: 'dark-toggler',
    darkIcon: 'i-material-symbols:dark-mode',
    lightIcon: 'i-ic-twotone-wb-sunny',
  })

  const oDarkToggler = document.getElementById('dark-toggler')

  const init = () => {
    initTheme()
    bindEvent()
  }

  const bindEvent = () => {
    oDarkToggler?.addEventListener('click', toggleDarkTheme)
  }

  init()
}

效果和前面的是一样的,但是由于我们扩展了任意主题的特性,怎么能不尝鲜一下呢,在页面中再添加一个按钮,用于切换到自定义主题

<!-- 右上角深色模式切换 -->
<section fixed top-10 right-20 text-2xl cursor-pointer flex gap-3>
  <div id="dark-toggler" class="i-ic-twotone-wb-sunny"></div>
  <div i-icon-park-twotone:theme></div>
</section>

那么怎么切换主题呢?别忘了我们的themer中有一个setCurrentTheme的方法,通过它就可以切换根节点上的主题类名,然后我们再在scss中编写全局的主题样式即可生效

const { initTheme, toggleDarkTheme, setCurrentTheme } = useThemer({
  togglerId: 'dark-toggler',
  darkIcon: 'i-material-symbols:dark-mode',
  lightIcon: 'i-ic-twotone-wb-sunny',
})
oThemeToggler?.addEventListener('click', () =>
  setCurrentTheme('plasticine'),
)

这里我添加了一个名为plasticine的主题,我们来配置一下该主题

html.plasticine {
  background-color: #748da6;
  color: #f2d7d9;
}

效果如下:

「原生练手」✨将深浅色模式切换逻辑抽离成hooks,体验hooks的魅力!

可以看到,扩展主题成功

总结

怎么样,通过这样一个简单的深浅色切换到主题切换扩展的小案例,你能体会到hooks的强大之处吗,它能够提高代码复用性,即便是没有提高复用性的场景,也可以提高代码的可读性,屏蔽具体细节,更加语义化,方便多人维护

其次,在抽离hooks的过程中,我们也能够很自然的发现可扩展点,做进一步优化,总之,hooks yyds!