Nuxt3 支持服务端渲染给用户带来了更好的首屏体验,但是给只熟悉单页应用开发的前端同学也带来了一定的学习成本。为了更好的 SEO,我也是选择用 Nuxt3 来重构我的 类型小屋 这个用 Vue3 写的项目,然而开发过程中遇到了很多问题,这篇文章先讲一个【切换主题色】功能遇到的问题。
后面应该还会写更多文章来讲下关于 Nuxt3 实战上的一些问题,绝对干货,有兴趣的朋友可以关注我。
服务端渲染基本概念
理解服务端渲染的概念是很重要的,不然面对不同的需求场景,你可能会走很多弯路,简单来说,服务端渲染意味着 网页上面呈现的内容在服务端就已经生成好了。
举个例子,如果我们是一个用 Vue3 写的单页应用,我们首次请求到的根 html 可能是这样的:
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
<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 中写以下代码是会报错的:
ts
<script lang="ts" setup>
console.log(window.localStorage.setItem('key', 'value'))
</script>
主题色切换思路
以下是我做主题色切换的思路。
定义 css 变量
style/theme.css
文件:
css
body {
--color-text: #000;
--color-bg: #fff;
}
body[theme='dark'] {
--color-text: #fff;
--color-bg: #0d1117;
}
当在 body
元素上加上 theme="dark"
这个属性后,我们的主题色就会切换到暗色主题。
不过我们得先把这个主题文件加到 nuxt.config.ts
配置文件中:
ts
export default defineNuxtConfig({
css: ['@/style/theme.css'],
})
引入 pinia
我们需要一个全局的状态管理,好在 Nuxt3 提供了 @pinia/nuxt
这个同时支持服务端和客户端的状态管理模块。我们先来安装一下:
bash
yarn add @pinia/nuxt pinia pinia-plugin-persistedstate
pinia-plugin-persistedstate
这个包是用于持久化的。
安装完成后配置 nuxt.config.ts
文件:
ts
export default defineNuxtConfig({
css: ['@/style/theme.css'],
modules: [
'@pinia/nuxt',
],
})
写入 store
在根目录下创建 store/index.ts
文件,写入以下代码:
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
文件,写入以下代码:
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
吧,写入以下代码:
ts
<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 变量,点击不同的主题色按钮,就可以切换颜色了。
注意看的话,切换不同主题色,body
元素会有一个 theme="dark"
属性的添加与删除,如果有这个属性,就会走暗色的 css 颜色变量,从而实现主题色切换。
读写本地主题色
在 store/theme.ts
文件中,我们改变主题色时,会通过以下代码把用户设置的主题色存到本地,以达到持久化目的:
ts
window.localStorage.setItem('theme', theme)
在用户下次进入页面时,我们在通过以下代码读取到这个值:
ts
window.localStorage.getItem('theme')
我们直接在咱们的 /pages/index.vue
加入以下代码:
ts
<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>
好家伙,一看页面,又报错了:
原因上面我们已经说过了,那好,我放到客户端渲染完成总行了吧,我们尝试着用写:
ts
// onMounted 回调只会在客户端渲染执行
onMounted(() => {
themeStroe.setTheme((window.localStorage.getItem('theme') as ThemeType) || 'light')
})
再去页面,你先点击【暗色】按钮,然后多刷新几遍观察下:
是不是出现了短暂亮色再变成暗色的生硬过渡,这对于用户的体验来说是很差劲的,我这里只是简单一行文字,如果你的网站是全局的主题变换,闪屏会更加明显与难看。
而这就是我们要解决的问题。
防止闪屏
服务端渲染拿不到 window 对象,从而无法访问 window.matchMedia
和 window.localStorage
这些方法,也就无法给 body
元素添加和删除属性。
那有没有一个阶段是,服务端渲染好传给了客户端,但是客户端还未渲染完成,却又拥有浏览器环境。
很幸运,Nuxt3 提供了一个强大的 hook 供我们使用,它就是我们常拿来进行 SEO 优化的 useHead。
直接上我们最重要的代码:
ts
<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 才发现有这个用法的:
然而 Nuxt3 的类型定义似乎没把这个加上,必须加个 @ts-ignore
防止报错,实际功能是有的:
这时候你再去页面,不管先点击【跟随系统】还是【暗色】按钮,重复刷新,你会发现已经没有闪屏了,完美!
demo 代码已上传,大家有兴趣可以看看:
最后
Nuxt3 的开发模式和 Vue3 还是有很多不同的,主要是服务端渲染这个过程和我们以往的心智模型不太一样,所以也是在重构过程中踩了很多坑,后面慢慢出文章,希望能帮助到有需要的朋友。
为什么要写这个?说真的,我翻遍全网,没有一篇是讲这个的。