Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nuxt3 切换主题色且不闪屏该怎么做? #26

Open
vortesnail opened this issue Jan 7, 2025 · 0 comments
Open

Nuxt3 切换主题色且不闪屏该怎么做? #26

vortesnail opened this issue Jan 7, 2025 · 0 comments
Labels

Comments

@vortesnail
Copy link
Owner

Nuxt3 支持服务端渲染给用户带来了更好的首屏体验,但是给只熟悉单页应用开发的前端同学也带来了一定的学习成本。为了更好的 SEO,我也是选择用 Nuxt3 来重构我的 类型小屋 这个用 Vue3 写的项目,然而开发过程中遇到了很多问题,这篇文章先讲一个【切换主题色】功能遇到的问题。

后面应该还会写更多文章来讲下关于 Nuxt3 实战上的一些问题,绝对干货,有兴趣的朋友可以关注我。

服务端渲染基本概念

理解服务端渲染的概念是很重要的,不然面对不同的需求场景,你可能会走很多弯路,简单来说,服务端渲染意味着 网页上面呈现的内容在服务端就已经生成好了

举个例子,如果我们是一个用 Vue3 写的单页应用,我们首次请求到的根 html 可能是这样的:

<html>
  <head>
    ...
    <script src="/main.js"></script>
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

很简单,就一个根元素(id 为 app),以及一个加载其他元素的 main.js 文件,这个文件里面会去初始化 Vue 实例,然后挂载到根元素上。

而服务端渲染的流程是,我们的服务器收到请求后,会在服务端解析 Vue 写的代码,然后把生成的 html 字符串返回给客户端,客户端拿到 html 字符串后,会直接渲染到页面上,比如我们首次请求到的 html 可能是这样的:

<html>
  <head>...</head>
  <body>
    <h1>Hello World</h1>
    <main>I have already mounted on serve-render</main>
  </body>
</html>

服务端渲染的 html 字符串中已经包含了我们需要的内容,直接渲染到页面上。不过随着项目的复杂度上升,你肯定也会在客户端加载一些其他的 js 的,大家别弄混了。

window 对象丢失

在服务端渲染过程中,在解析 vue 代码过程中去获取 window 对象,会发现 window 对象是不存在的,因为 window 这种毕竟是浏览器的概念,在服务端渲染过程中,没有浏览器的执行环境。

所以如果你在 Nuxt3 中写以下代码是会报错的:

<script lang="ts" setup>
console.log(window.localStorage.setItem('key', 'value'))
</script>
image

主题色切换思路

以下是我做主题色切换的思路。

定义 css 变量

style/theme.css 文件:

body {
  --color-text: #000;
  --color-bg: #fff;
}

body[theme='dark'] {
  --color-text: #fff;
  --color-bg: #0d1117;
}

当在 body 元素上加上 theme="dark" 这个属性后,我们的主题色就会切换到暗色主题。

不过我们得先把这个主题文件加到 nuxt.config.ts 配置文件中:

export default defineNuxtConfig({
  css: ['@/style/theme.css'],
})

引入 pinia

我们需要一个全局的状态管理,好在 Nuxt3 提供了 @pinia/nuxt 这个同时支持服务端和客户端的状态管理模块。我们先来安装一下:

yarn add @pinia/nuxt pinia pinia-plugin-persistedstate

pinia-plugin-persistedstate 这个包是用于持久化的。

安装完成后配置 nuxt.config.ts 文件:

export default defineNuxtConfig({
  css: ['@/style/theme.css'],
  modules: [
    '@pinia/nuxt',
  ],
})

写入 store

在根目录下创建 store/index.ts 文件,写入以下代码:

import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import useGlobalStore from './theme'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

export { useProblemStore }
export default pinia

继续创建 store/theme.ts 文件,写入以下代码:

import { defineStore } from 'pinia'

export type ThemeType = 'system' | 'light' | 'dark'

interface GlobalState {
  fakeTheme: ThemeType
  theme: Exclude<ThemeType, 'system'>
}

const useThemeStroe = defineStore('global', {
  state: (): GlobalState => ({
    fakeTheme: 'light',
    theme: 'light',
  }),
  actions: {
    setRealTheme(theme: Exclude<ThemeType, 'system'>) {
      this.theme = theme
      if (theme === 'light') {
        document.body.removeAttribute('theme')
      } else {
        document.body.setAttribute('theme', 'dark')
      }
    },
    setTheme(theme: ThemeType) {
      window.localStorage.setItem('theme', theme)
      this.fakeTheme = theme
      if (theme === 'system') {
        const themeMedia = window.matchMedia('(prefers-color-scheme: dark)')
        this.setRealTheme(themeMedia.matches ? 'dark' : 'light')
        themeMedia.addEventListener('change', (evt) => {
          const t = evt.matches ? 'dark' : 'light'
          this.setRealTheme(t)
        })
      } else {
        this.setRealTheme(theme)
      }
    },
  },
  persist: true,
})

export default useThemeStroe

然后我们随便在 /pages 目录下随便创建一个页面,就叫 /pages/index.vue 吧,写入以下代码:

<template>
  <h1 class="title">
    I am index page
  </h1>
  <button @click="themeStroe.setTheme('system')">跟随系统</button>
  <button @click="themeStroe.setTheme('dark')">暗色</button>
  <button @click="themeStroe.setTheme('light')"> 亮色</button>
</template>

<script lang="ts" setup>
import { useThemeStroe } from '@/store'

const themeStroe = useThemeStroe()
</script>

<style scoped>
.title {
  background-color: var(--color-bg);
  color: var(--color-text);
}
</style>

OK,可以看到引入了 css 变量,点击不同的主题色按钮,就可以切换颜色了。

change-theme.gif

注意看的话,切换不同主题色,body 元素会有一个 theme="dark" 属性的添加与删除,如果有这个属性,就会走暗色的 css 颜色变量,从而实现主题色切换。

读写本地主题色

store/theme.ts 文件中,我们改变主题色时,会通过以下代码把用户设置的主题色存到本地,以达到持久化目的:

window.localStorage.setItem('theme', theme)

在用户下次进入页面时,我们在通过以下代码读取到这个值:

window.localStorage.getItem('theme')

我们直接在咱们的 /pages/index.vue 加入以下代码:

<script lang="ts" setup>
import { useThemeStroe } from '@/store'
import type { ThemeType } from '@/store/theme'

const themeStroe = useThemeStroe()

themeStroe.setTheme((window.localStorage.getItem('theme') as ThemeType) || 'light')
</script>

好家伙,一看页面,又报错了:

image

原因上面我们已经说过了,那好,我放到客户端渲染完成总行了吧,我们尝试着用写:

// onMounted 回调只会在客户端渲染执行
onMounted(() => {
  themeStroe.setTheme((window.localStorage.getItem('theme') as ThemeType) || 'light')
})

再去页面,你先点击【暗色】按钮,然后多刷新几遍观察下:

录屏2025-01-07 13 46 08

是不是出现了短暂亮色再变成暗色的生硬过渡,这对于用户的体验来说是很差劲的,我这里只是简单一行文字,如果你的网站是全局的主题变换,闪屏会更加明显与难看。

而这就是我们要解决的问题。

防止闪屏

服务端渲染拿不到 window 对象,从而无法访问 window.matchMediawindow.localStorage 这些方法,也就无法给 body 元素添加和删除属性。

那有没有一个阶段是,服务端渲染好传给了客户端,但是客户端还未渲染完成,却又拥有浏览器环境。

很幸运,Nuxt3 提供了一个强大的 hook 供我们使用,它就是我们常拿来进行 SEO 优化的 useHead

image

直接上我们最重要的代码:

<script lang="ts" setup>
// 其他代码保持不变,onMounted 也要留着,给客户端渲染用

useHead({
  script: [
    {
      // @ts-ignore
      body: true,
      children: `
      const theme = window.localStorage.getItem('theme') || 'light';
      if (theme === 'system') {
        const themeMedia = window.matchMedia('(prefers-color-scheme: dark)')
        if (themeMedia.matches) {
          document.body.setAttribute('theme', 'dark')
        } else {
          document.body.removeAttribute('theme')
        }
      } else {
        if (theme === 'dark') {
          document.body.setAttribute('theme', 'dark')
        } else {
          document.body.removeAttribute('theme')
        }
      }`,
    },
  ],
})
</script>

这段代码的作用是,插入一个脚本,这个脚本的功能和我们 store/theme.ts 的类似,拿到用户配置的主题变量,根据这个变量去决定在 body 元素上添加或删除 theme="dark" 属性。

需要注意的是 body: true 这个配置,目的是把脚本挂在到 body 元素下,而不是 head 元素下,我也是找到官方的这个 issue 才发现有这个用法的:

nuxt/nuxt#13069

然而 Nuxt3 的类型定义似乎没把这个加上,必须加个 @ts-ignore 防止报错,实际功能是有的:

image

这时候你再去页面,不管先点击【跟随系统】还是【暗色】按钮,重复刷新,你会发现已经没有闪屏了,完美!

demo 代码已上传,大家有兴趣可以看看:

点我查看 demo

最后

Nuxt3 的开发模式和 Vue3 还是有很多不同的,主要是服务端渲染这个过程和我们以往的心智模型不太一样,所以也是在重构过程中踩了很多坑,后面慢慢出文章,希望能帮助到有需要的朋友。

为什么要写这个?说真的,我翻遍全网,没有一篇是讲这个的。

@vortesnail vortesnail added the vue label Jan 7, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant