原文:
zh.annas-archive.org/md5/c48f712983267c8142bd352d97a502a4
译者:飞龙
第五章:构建个人帖子空间并实现身份验证
在我们的 Nuxt 3 指南的本章中,我们专注于一个实战项目:为用户构建一个可以发布内容的个人空间,并集成安全的身份验证。本章架起了使用 Vue 和 Nuxt 进行前端开发与由 Supabase 提供的后端功能之间的桥梁。
我们首先设置 Supabase,这是一个简化用户身份验证的后端服务。这个基础对我们应用程序的安全性至关重要。接下来,我们将此服务与 Nuxt 3 集成,确保我们的前端和后端能够顺畅通信。
接下来,我们的旅程转向用户界面。我们将设计易于使用的登录和注册表单。本节是关于制作既美观又实用的表单,使用 Nuxt UI 和 TailwindCSS。
下一个部分是关于创建一个安全的登录系统。我们将涵盖重要的安全主题,如基于令牌的身份验证,以确保用户数据安全。
本章通过教授用户如何创建和管理自己的帖子来结束。这将使用户在应用程序中拥有一个个人区域来分享他们的想法。
本章是结合前端设计和后端技术的实用指南,打造一个安全、用户友好的网络应用程序。让我们深入探讨!
在本章中,我们将涵盖以下主要主题:
-
为用户身份验证设置初始 Supabase 项目
-
将 Nuxt 3 与 Supabase 集成
-
设计身份验证 UI 和验证输入
-
使用 Supabase 设置用户身份验证
-
创建和管理个人用户帖子
技术要求
本章的代码文件可以在github.com/PacktPublishing/Nuxt-3-Projects/tree/main/chapter05
找到
本章的 CiA 视频可以在packt.link/AYK8X
找到
必要背景:什么是 Supabase?
在我们着手开发我们的项目之前,熟悉 Supabase 及其功能非常重要,这为我们即将构建的内容打下坚实的基础。
-
Supabase 概述: Supabase 是一个开源的 Firebase 替代品,提供了一套处理后端需求(如数据库、身份验证和实时订阅)的工具。它默认使用 PostgreSQL。
-
数据库管理: 在其核心,Supabase 提供数据库服务,允许您高效地创建、读取、更新和删除数据。我们将使用此功能来处理创建用户帖子、更新它们以及删除它们。
-
用户身份验证: 它简化了用户管理的过程,从注册到登录,以及保护用户数据。Supabase 支持多种身份验证方法,包括电子邮件/密码和第三方登录,如 Google 或 GitHub。
重要链接:
在我们开始之前,这里有一些来自 Supabase 的基本资源,我们将广泛使用。这些链接提供了直接访问 Supabase 平台内各种工具的途径。每个链接都对应于我们将与之交互的 Supabase 仪表板的一个特定页面:
这些链接是您通往我们将在本章中使用的工具的直接途径。现在让我们继续到基础步骤:设置一个新的 Supabase 项目。
为用户身份验证设置初始 Supabase 项目
要开始构建我们的 Nuxt 3 应用程序,我们首先需要初始化一个新的 Supabase 项目,以设置我们的数据库和 API。
在创建新的 Supabase 账户后,导航到:supabase.com/dashboard/projects
并点击 我的空间
。在输入项目名称和安全的数据库密码后,Supabase 将开始设置您的新数据库。
设置数据库模式
让我们准备数据库模式。在 Supabase 仪表板中,转到与用户身份验证连接的 profiles
表,并包括一个触发器,当新用户注册时自动生成资料条目。
图 5.1:Supabase 用户管理入门
您会找到一个预先编写的查询。点击 运行 执行它并建立我们的初始表。或者,您也可以编写一个自定义查询来构建数据库,但由于我们的重点是集成 Nuxt,我们将跳过这一步。
现在,转到通过“用户管理入门”创建的 profile
。让我们继续创建一个 posts
表,这次我们将使用 Supabase UI 来创建它。点击“创建新表”按钮,将其命名为 posts
,并按以下配置列:
图 5.2:posts 表格列模式
-
这里是概述:
-
系统自动添加
id
、created_at
列。 -
我们添加了一个必需的
title
列。 -
还有一个可选的
content
列。您可以通过在配置菜单中切换选项使其为可空。 -
author_id
作为外键链接到用户资料。点击链接将显示其当前设置,包括引用表和列。
图 5.3:author_id 外键
在你的数据库表准备就绪后,下一步是获取 API 密钥。这些密钥让您的应用程序能够与 Supabase API 通信。从该页面找到“项目 URL”和anon
密钥。
最后,Supabase 默认启用电子邮件确认功能,因此用户在首次登录前需要确认他们的电子邮件地址。为了测试目的,我们将通过导航到 认证提供者 页面,找到电子邮件提供者设置,然后关闭“确认电子邮件”开关来禁用此功能。
图 5.4:禁用确认电子邮件选项
在完成 Supabase 设置后,我们现在转向将其与 Nuxt 3 集成。在下一节中,我们将创建一个新的 Nuxt 应用程序,并使用名为 “@nuxtjs/supabase
” 的模块将其与 Supabase 集成。
将 Nuxt 3 与 Supabase 集成
现在我们已经设置了 Supabase,是时候关注如何无缝地将这个后端与我们的 Nuxt 3 应用程序集成。
使用 Supabase 集成创建新的 Nuxt 3 项目
首先,创建一个新的 Nuxt 3 项目。打开你的终端并运行:
pnpm dlx nuxi init my-space
cd my-space
pnpm i -D @nuxtjs/supabase
接下来,在项目根目录中创建一个 .env
文件,并添加您的 Supabase URL 和 anon 密钥:
SUPABASE_URL="YOUR_SUPABASE_URL"
SUPABASE_KEY="YOUR_SUPABASE_ANON_KEY"
将 "YOUR_SUPABASE_URL"
和 "YOUR_SUPABASE_ANON_KEY"
替换为您从 Supabase 项目中复制的实际值。
覆盖认证路由
在默认配置中,Supabase 使用 /login
路由进行用户登录,并自动将未认证(或匿名)用户重定向到该路由。然而,为了定制我们应用程序的导航流程和 URL 结构,我们可以在 Nuxt 3 中配置这些认证路由。
要自定义认证路由的行为,我们需要在 nuxt.config.ts
文件中进行调整。具体来说,通过添加配置如下所示的 supabase
对象:
supabase: {
redirectOptions: {
login: 'auth/login',
callback: '',
exclude: ['/auth/*']
}
}
让我们讨论每一个更改:
-
我们已将默认登录路由从
/login
更改为/auth/login
-
callback
选项留空。这是因为回调处理通常与第三方提供者相关联,而我们目前不涉及这一阶段。 -
exclude
选项设置为['/auth/*']
。这个模式意味着/auth/
路径下的所有路由,如/auth/login
和/auth/signup
,都对匿名用户可访问。这种设置特别有利,尤其是在计划将来扩展我们的认证页面(例如,添加密码重置或恢复页面)时,无需每次都更新排除选项。
通过配置这些设置,我们确保我们的应用程序认证流程既用户友好又可扩展,准备好随着项目的增长而容纳更多功能。
现在我们已经自定义了认证路由,接下来让我们设计认证的用户界面并实现输入验证。
认证 UI 和验证输入
为了增强我们认证过程的用户体验,我们首先将为我们的 Nuxt 3 项目安装和配置一些必要的模块。
安装所需模块
打开你的终端,像往常一样安装 @nuxtjs/google-fonts
,以及我们在上一章中学到的 @nuxt/ui
:
pnpm i @nuxtjs/google-fonts -D
pnpm i @nuxt/ui
这些命令将添加 Google Fonts 和 Nuxt UI 到我们的项目中。接下来,我们需要更新我们的 nuxt.config.ts
以包括这些模块并设置一些额外的配置:
modules: ['@nuxtjs/supabase', '@nuxtjs/google-fonts',
'@nuxt/ui'],
googleFonts: {
families: {
Poppins: [400, 500, 700]
}
},
app: {
head: {
title: 'My Space'
}
}
在这里,app
配置将我们应用程序的标题设置为“我的空间。”
为了进一步定制我们的 UI,创建一个 app.config.ts
文件来更新 Nuxt UI 的主颜色并设置 Nuxt UI 组件的默认属性:
export default defineAppConfig({
ui: {
primary: 'teal',
container: {
padding: 'py-6'
}
}
});
与前几章一样,别忘了在你的项目中包含 tailwind.config.ts
。此文件对于定制 TailwindCSS 以满足我们应用程序的样式需求至关重要。
我们下一个任务是创建登录和注册页面的有效布局。
设置认证页面
在 /layouts
目录中创建一个空的布局 default.vue
。这个布局将在项目中的后续部分被使用。
<template>
<slot></slot>
</template>
在相同的目录中创建 auth.vue
布局。这个布局将专门用于与登录和注册等认证相关的页面:
<template>
<div class="grid md:grid-cols-2 min-h-screen
bg-gray-100">
<!-- Image Column -->
<div class="hidden md:flex md:col-span-1 items-center
justify-center bg-white">
<img src="img/auth.svg" class="max-w-md"
/>
</div>
<!-- Form Column -->
<div class="col-span-1 flex justify-center
items-center">
<slot></slot>
</div>
</div>
</template>
此布局在中等和更大屏幕上将屏幕分为两列,一列用于图像(由存储库提供),另一列用于表单。
接下来,覆盖 app.vue
以指定我们将使用布局和页面:
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
然后,在 /pages/auth/
目录中创建一个 login.vue
页面。
<u-card class="md:min-w-96">
<!-- Form Content -->
<div class="text-2xl text-center font-bold mb-1">
Login
</div>
<div class="text-slate-500 text-sm text-center mb-4">
Don't have an account?
<UButton variant="link" to="/auth/signup">
Create one!</UButton>
</div>
<!-- the form -->
</u-card>
页面以一个简单的卡片布局开始,包括标题和导航链接到注册页面。然后我们将使用 Nuxt UI 组件如 UForm
、UFormGroup
和 UInput
为表单元素,这些组件预先样式化并有助于输入验证:
<!-- the form -->
<UForm>
<UFormGroup label="Email" name="email" class="mb-4">
<UInput v-model="user.email" icon="i-heroicons-at-
symbol" />
</UFormGroup>
<UFormGroup label="Password" name="password"
class="mb-4">
<UInput
v-model="user.password"
type="password"
icon="i-heroicons-lock-closed"
/>
</UFormGroup>
<!-- Submit Button -->
<UButton type="submit" block class="mt-8">
Login
</UButton>
</UForm>
Nuxt UI 中的 icon="i-heroicons-at-symbol"
代表 Heroicons 收集中的图标。按照 i-{collection_name}-{icon_name}
的模式,它很容易将任何图标集合从 icones.js.org/
连接到 Nuxt UI。
最后,定义布局并为用户数据设置响应式状态:
<script setup>
definePageMeta({ layout: 'auth' })
const user = ref({
email: '',
password: ''
})
</script>
你现在应该看到一个像这样的登录页面布局:
0 图 5.5:登录页面布局
以类似的方式,创建包含额外字段(密码确认)的 signup.vue
页面:
<UFormGroup
label="Password Confirmation"
name="passwordConfirm"
class="mb-4"
>
<UInput
v-model="user.passwordConfirm"
type="password"
icon="i-heroicons-lock-closed"
/>
</UFormGroup>
并且别忘了将其添加到 user
引用:
const user = ref({
email: '',
password: '',
passwordConfirm: ''
})
现在我们已经设置了认证 UI,让我们使用 Joi 来增强输入验证:
使用 Joi 进行输入验证
Nuxt UI 中的 UForm
组件通过其 schema
属性提供强大的功能。此属性允许您从像 Joi 或 Yup 这样的库中集成验证模式,为您的表单状态提供强制特定验证规则的方式。在我们的示例中,我们将使用 Joi 创建我们的验证模式。您可以从官方网站了解更多关于 Joi 的信息:joi.dev/api/
首先向你的项目中添加 Joi:
pnpm i joi
然后在 login.vue
脚本中,定义登录表单的 Joi 验证模式:
const schema = Joi.object({
email: Joi.string().email({ tlds: false }).required(),
password: Joi.string().min(6).required()
});
此模式确保电子邮件是有效的电子邮件并已填写。我们还确保密码至少有 6 个字符长。我们使用了 { tlds: false }
来禁用 Joi 内置的 TLD 验证。为了在表单组件中实现该模式,我们必须将其调整如下:
<UForm :schema="schema" :state="user">
<!-- Form content -->
</UForm>
schema
属性根据定义的 Joi 模式验证 user
状态。通过将电子邮件输入字段留空并移开焦点来测试验证。应该在输入字段下方出现错误消息:
图 5.6:使用 Joi 和 Nuxt UI 进行输入验证
注册页面将对电子邮件和密码进行类似的验证。此外,我们还将添加一个复杂的密码确认验证:
const schema = Joi.object({
email: Joi.string().required(),
password: Joi.string().min(6).required().label(
'Password'),
passwordConfirm: Joi.valid(Joi.ref('password'))
.label('Confirm Password')
.messages({
'any.only': `passwords don't match`
})
});
在这里,passwordConfirm
字段使用 Joi.ref('password')
确保它与密码字段匹配。 .messages({ 'any.only': 'passwords don't match' })
部分自定义了错误消息,使其清晰且用户友好。当密码不匹配时。
在我们的输入验证设置完成后,下一步是集成 Supabase 的登录和注册功能。
设置 Supabase 用户认证
现在,让我们专注于集成 Supabase 的认证功能,使用他们直接的登录和注册功能来管理我们应用程序的用户访问。
Nuxt 3 中的 useSupabaseClient
组合式是访问 Supabase API 的网关,它促进了您的 Vue 应用程序与 Supabase 服务之间的无缝通信。由 supabase-js
驱动,它提供了对 Supabase 客户端的访问,该客户端使用 .env
文件中的 SUPABASE_KEY
初始化。此客户端连接到数据库。
创建注册函数
在 signup.vue
脚本内部,使用以下方式获取 Supabase 实例:
const supabase = useSupabaseClient()
此实例将用于向 Supabase 发送请求,特别是用于用户认证功能,如 signup
和 signInWithPassword
。
接下来创建一个 onSubmit
函数来处理注册过程:
const loading = ref(false)
const onSubmit = async () => {
loading.value = true
try {
const { error } = await supabase.auth.signUp({
email: user.value.email,
password: user.value.password
})
if (error) throw error
} catch (error) {
const message = error.message
console.log(message);
}
loading.value = false
}
下面是代码分解:
-
将
loading
设置为true
,表示异步操作的开始。 -
从 Supabase 认证 API 调用
signUp
方法。这是我们与 Supabase 交互以注册新用户的地方,从user
引用中提取电子邮件和密码。 -
检查 Supabase 注册过程中是否有错误,将其抛出到
catch
块中。 -
记录注册过程中发生的任何错误(用于测试)。
-
将加载状态重置为 false
此函数有效地管理注册过程,与 Supabase 的认证 API 交互以注册用户。它处理从发送请求到处理响应和错误的整个流程。
为了测试错误处理,暂时从 UForm
中移除 schema
属性,并尝试提交一个空表单。应该在开发工具控制台中打印出错误响应:
图 5.7:Supabase 注册错误
从我们离开的注册过程继续,让我们通过引入 Nuxt UI 通知来增强错误处理。这些通知以更用户友好的方式显示消息,如错误,以提示通知格式。
在你的 app.vue
中包含 UNotifications
组件。这是一个全局设置,允许你在应用的任何位置显示通知:
<template>
<NuxtLayout>
<NuxtPage />
<UNotifications />
</NuxtLayout>
</template>
Nuxt UI 的 useToast
组合式允许你轻松地将提示通知添加到你的应用程序中。我们可以在 signup.vue
脚本中添加以下行来获取吐司实例:
const toast = useToast()
现在,让我们修改注册函数的 catch
块以使用此组合式来显示错误消息:
catch (error) {
toast.add({ title: error.message, color: 'red' });
}
在这里,使用 toast.add
显示提示通知。我们将颜色设置为‘红色’,表示这是一个错误消息。尝试提交一个空表单以触发验证错误。你应该会看到一个显示错误消息的红色提示通知:
图 5.8:Nuxt UNotification 组件
这种视觉反馈是即时且清晰的,使用户更容易理解和反应错误。
继续我们的注册功能,让我们添加一个成功提示通知,并在成功注册后设置导航到主页。在 onSubmit
函数中,更新 try 块以在成功注册后使用 Supabase 登录用户:
const onSubmit = async () => {
loading.value = true
try {
const { error } = await supabase.auth.signUp({
email: user.value.email,
password: user.value.password
})
if (error) throw error
toast.add({ title: 'Welcome!' })
navigateTo('/')
} catch (error) {
toast.add({ title: error.message, color: 'red' })
}
loading.value = false
}
不要忘记将 UForm
中的 schema
属性重置以再次启用验证。
是时候进行测试了!首先,创建一个简单的 index.vue
页面,包含以下内容:
<template>
<div class="bg-gray-100 min-h-screen">
<UContainer>
<h1 class="text-3xl font-semibold text-center
text-gray-800 mb-6">
My Posts
</h1>
</UContainer>
</div>
</template>
在填写正确信息并成功注册后,应显示欢迎提示,并将你重定向到主页:
图 5.9:首页
为了确保身份验证流程正常工作,在私密窗口中打开 http://localhost:3000/
。由于 Nuxt Supabase 内置的中间件检查用户身份验证状态,你应该会自动重定向到 'auth/login/'
。
现在我们已经设置了注册功能,让我们专注于实现登录过程。登录的方法与注册类似,但我们将使用 Supabase 的 signInWithPassword
方法。在你的 login.vue
页面中,定义一个专门用于登录过程的 onSubmit
函数:
const onSubmit = async () => {
loading.value = true;
try {
const { error } =
await supabase.auth.signInWithPassword({
email: user.value.email,
password: user.value.password
});
if (error) throw error;
toast.add({ title: 'Logged in successfully!' });
navigateTo('/');
} catch (error) {
toast.add({ title: error.message, color: 'red' });
} finally {
loading.value = false;
}
};
此函数尝试使用用户的电子邮件和密码登录用户。在成功登录后,用户会看到一个成功提示并重定向到主页。
将 onSubmit
函数链接到你的登录页面中的 UForm
:
<UForm :schema="schema" :state="user" @submit="onSubmit">
<!-- Form fields -->
</UForm>
通过实现此登录功能,我们确保用户可以安全方便地访问他们的账户。整个过程简单明了,使登录体验流畅高效。接下来,我们将深入了解用户如何在我们的应用程序中创建、查看和管理他们自己的帖子,使其成为一个真正互动和个性化的空间。
创建和管理个人用户帖子
在本节中,我们将注意力转向启用用户创建和管理他们的帖子。为了确保这可以顺利运行,我们需要在我们的数据库表 posts
中使用 Supabase 的行级安全(RLS)策略设置特定的规则。
Supabase 中的 RLS 策略就像是数据库的规则。它们有助于控制谁可以查看或更改数据库中的数据。对于我们的应用程序,我们将使用这些策略来确保用户只能访问他们自己的帖子。
为“帖子”表添加 RLS 策略
打开 Supabase 策略页面:。这是您可以控制谁访问数据库中的什么内容的页面。选择 posts
表并开始创建一个新的策略:
-
命名一个清晰明了的名称,例如“个人帖子管理”。
-
对于操作选择“全部”,以便用户可以读取、添加、更新和删除他们的帖子。
-
对于“目标角色”,选择“已认证”以便只有登录用户可以使用此策略。
-
将
(auth.uid() = author_id)
表达式添加到 using 和 with check 表达式中。这确保用户只能查看和交互他们创建的帖子,保持对查看和修改内容的严格用户特定访问。
策略详情应如下所示:
图 5.10:帖子表 RLS 策略
在 posts
表上设置了我们的 RLS 策略后,现在让我们深入了解如何使用 Supabase 的 API 来管理应用程序中的这些帖子。
使用 Supabase API 进行帖子管理
在 Nuxt 3 中使用 useSupabaseClient
可以直接与我们的数据库交互。它是我们在 posts
表上执行操作的首选。
您的项目在 Supabase 上的特定 API 文档提供了可能的查询的详细见解。为了实际查看,请访问:
使用简单的命令:
supabase.from('posts').select()
我们可以获取一个用户特定的帖子数组,多亏了 RLS 策略确保每个用户只能看到他们的内容。要创建或更新帖子,我们可以使用 upsert
:
supabase.from('posts').upsert(/* your post data */)
此函数优雅地处理了新帖子插入和现有帖子更新的操作,通过检查唯一标识符来决定适当的操作。
最后,对于删除帖子,delete
方法与 eq
过滤器的组合确保我们精确地定位并删除预期的帖子:
supabase.from('posts').delete().eq('id', postId)
这种有针对性的方法加强了用户对其帖子的控制,与我们实施的安全措施相一致。
让我们继续开发用于创建和更新帖子信息的用户界面。
构建帖子信息页面
首先,在您的 Nuxt 应用程序中设置一个动态路由。在 /pages/posts/
目录中创建一个名为 [id].vue
的文件。这个由 [id]
表示的动态路由将允许页面根据 URL 处理不同的场景:
-
当路由中的
[id]
设置为create
时,我们知道用户正在尝试创建一个新的帖子。 -
如果
[id]
是实际的帖子 ID,则表示用户打算查看或编辑现有的帖子。在这种情况下,我们将使用此 ID 来获取相关的帖子数据。
这种方法为我们提供了一种灵活且高效的方式来处理新帖子的创建和现有帖子的编辑,所有这些都在一个动态界面上完成。
在模板内部:创建一个简单的布局:
<div class="min-h-screen bg-gray-100">
<UContainer>
<h2 class="mt-6 text-center text-3xl font-extrabold
text-gray-900 mb-8">
Post Information
</h2>
<UCard class="max-w-md mx-auto">
<!-- the form -->
</UCard>
</UContainer>
</div>
表单将如下所示:
<UForm
class="space-y-6"
:schema="schema"
:state="post"
@submit="onSubmit"
>
<UFormGroup label="Title" name="title">
<UInput type="text" v-model="post.title" />
</UFormGroup>
<UFormGroup label="Content" name="content">
<UTextarea type="text" v-model="post.content" :rows="8"
/>
</UFormGroup>
<UButton type="submit" block primary :loading="pending">
Save Changes
</UButton>
</UForm>
在表单下添加一个用于帖子删除的小错误按钮:
<div class="flex justify-center mt-12">
<UButton
color="red"
variant="outline"
:loading="deleteLoading"
@click="deletePost"
>
Delete
</UButton>
</div>
在我们页面的脚本部分,我们设置了帖子状态和验证模式:
<script setup>
import Joi from 'joi'
const user = useSupabaseUser()
const post = ref({
title: '',
content: undefined,
author_id: user.value.id
})
const schema = Joi.object({
author_id: Joi.string().required(),
title: Joi.string().required(),
content: Joi.string().allow('', null)
}).unknown(true) // to allow additional fields like id, created_at
</script>
下面是分解:
-
useSupabaseUser()
提供了已登录用户的信息。 -
post
包含title
、content
和author_id
的数据。 -
schema
定义了必要的字段及其验证规则。 -
.unknown(true)
部分允许表单处理额外的字段,如id
和created_at
而不产生验证错误。
在我们逐步构建脚本块的同时,让我们添加:
const route = useRoute()
const postId = computed(() => route.params.id)
const editMode = computed(() => route.params.id !== 'create')
const toast = useToast()
我们捕获了当前路由详情,这对于确定帖子 ID 和模式(创建或编辑)至关重要。然后我们从路由参数中提取帖子 ID。最后,我们使用 editMode
来确定用户是否处于编辑模式(编辑现有帖子)或创建模式(创建新帖子)。
现在,让我们使用 useLazyAsyncData
处理数据获取。Nuxt 提供了一个很好的可组合函数,用于在浏览器或服务器环境中执行数据获取,称为 useAsyncData
。它在渲染页面之前获取数据,对于服务器端渲染(数据最初必须存在)来说很理想。相比之下,useLazyAsyncData
立即开始页面渲染并在后台加载数据,通过减少等待时间来增强用户体验,这对于加载我们应用程序中的非关键数据(如帖子)特别有用。
So to fetch the data we'll add:
const { pending } = useLazyAsyncData(async () => {
if (!editMode.value) return;
const { data } = await supabase
.from('posts')
.select()
.eq('id', postId.value)
.single();
if (data) post.value = data;
});
在这种情况下,如果用户处于编辑模式,脚本将从 Supabase 获取特定的帖子数据并填充编辑表单。它将帖子 ID 与数据库进行比对并检索相应的帖子详情。否则,post
引用将保持为空。
让我们检查 onSubmit
函数,它处理创建和更新帖子:
const toast = useToast()
const onSubmit = async () => {
pending.value = true
try {
const { error } =
await supabase.from('posts').upsert(post.value)
if (error) throw error
if (!editMode.value) toast.add({ title: 'Post Created
Successfully' })
else toast.add({ title: 'Post Updated Successfully' })
navigateTo('/')
} catch (error) {
toast.add({ title: error.message, color: 'red' })
}
pending.value = false
}
下面是分解:
-
函数执行
upsert
操作,根据帖子的 ID 更新现有帖子或创建新帖子。(如果 ID 未定义则创建,否则更新) -
错误处理已实现,以捕获和显示在
upsert
过程中出现的任何问题。 -
成功反馈通过 toast 通知提供,区分帖子创建和更新。
-
最后,函数将用户导航回主页。
现在,让我们看看 deletePost
函数。与 onSubmit
类似,这个函数包括错误处理和通过 toast 通知的用户反馈,但 Supabase 方法将是:
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId.value)
如果你导航到:,你应该看到这个页面:
图 5.11:项目信息页面
在我们的应用程序中增强用户导航,我们将在 default
布局中引入导航组件:
<nav class="bg-gray-800 text-white py-4">
<div class="container flex justify-between items-center">
<div class="text-xl font-bold uppercase">
<nuxt-link to="/">My space</nuxt-link>
</div>
<div class="flex gap-x-4">
<UButton to="/posts/create">New Post</UButton>
</div>
</div>
</nav>
这个导航栏有两个部分:左侧用于品牌标志或名称链接到主页,右侧有一个创建新帖子的按钮。
接下来,让我们构建帖子列表页面!
显示用户帖子
当我们进入应用程序开发的最后阶段时,我们将更新主页以列出用户创建的所有帖子。按照以下方式更新index.vue
中的脚本部分:
<script setup lang="ts">
const supabase = useSupabaseClient()
const { data: posts } = await useLazyAsyncData(async () => {
let { data } =
await supabase.from('posts').select().returns<Post[]>()
return data
})
</script>
该脚本使用useSupabaseClient
来访问 Supabase API 并获取帖子。useLazyAsyncData
被用来异步加载帖子。它确保页面导航立即发生,而帖子数据在后台加载。
更新页面模板:
<template>
<div class="bg-gray-100 min-h-screen">
<UContainer>
<h1 class="text-3xl font-semibold text-center
text-gray-800 mb-6">
My Posts
</h1>
<post-card v-for="post in posts" :post="post" />
</UContainer>
</div>
</template>
模板包含一个容器,标题为“我的帖子”,并使用post-card
组件渲染每个帖子。因此,让我们在components
文件夹内创建PostCard.vue
组件:
<template>
<UCard class="mb-4 max-w-lg mx-auto">
<nuxt-link :to="`/posts/${post.id}/`">
<div class="uppercase text-primary font-semibold">
{{ post.title }}
</div>
<p class="mt-2 text-gray-500">
{{ post.content || 'no content' }}
</p>
<div class="mt-4 text-gray-400 text-xs
font-semibold">
Created at: {{ dayjs(post.created_at).format(
'MMMM D, YYYY') }}
</div>
</nuxt-link>
</UCard>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
defineProps<{ post: Post }>()
</script>
每个post-card
组件展示帖子的标题、内容和创建日期。使用dayjs
格式化日期为人类友好的格式,增加了日期的亲和力。一个nuxt-link
包裹了卡片,使得轻松导航到每个帖子的详细视图或编辑页面。
在我们的帖子列表和单个帖子卡片组件就绪后,用户现在应该能看到他们创建的所有帖子。点击帖子将导航到其详细页面,在那里内容可以无缝查看、更新或删除。
摘要
Voilà!我们已经成功地在我们的应用程序中创建了一个动态且用户友好的个人帖子管理系统。我们首先建立了一个安全用户认证系统。然后,我们的重点转向了使用户能够创建、编辑和删除帖子,每个步骤都使用 Joi 进行验证以确保数据完整性。这些功能由 Supabase 的行级安全策略和 API 支持,保证了安全和用户特定的交互。
在接下来的章节中,我们将使用 Nuxt 3 增强一个食谱分享网站,重点关注 SEO 优化。我们将深入研究技术,如元数据配置、结构化数据实施、图像优化、SEO 友好 URL 创建和网站地图生成。这些步骤旨在提升网站的搜索引擎存在感,吸引更多有机流量。
实践问题
-
解释为 Nuxt 3 应用程序创建新 Supabase 项目的流程。
-
描述 Supabase 中行级安全(RLS)的目的及其实现方式。
-
useSupabaseClient
组合式函数的好处是什么? -
useAsyncData
和useLazyAsyncData
在 Nuxt 中的区别是什么?何时使用哪一个? -
如何使用 Joi 和 NuxtUI 在表单中验证用户输入?
-
如何处理 Supabase 错误?
-
如何使用 Supabase 通过密码登录?
-
如何使用 Supabase 在表中删除记录?
-
解释如何在 Supabase 中使用
upsert
方法进行帖子管理。
进一步阅读
-
Supabase:
supabase.com/
-
Nuxt UI 表单:
ui.nuxt.com/forms/form
-
Supabase 中的 RLS(行级安全):
supabase.com/docs/guides/auth/row-level-security
-
Joi 验证:
joi.dev/
-
使用 Nuxt 3 和 Supabase 构建用户管理网站:
supabase.com/docs/guides/getting-started/tutorials/with-nuxt-3
-
Nuxt Supabase 模块:
supabase.nuxtjs.org/
第六章:在使用 Nuxt 3 优化 SEO 的同时增强食谱分享网站
在本章中,我们将使用 Nuxt 3 丰富食谱分享网站,重点关注 SEO(搜索引擎优化)以获得更好的在线可见性。我们将从 Nuxt SEO 开始,这是一个强大的手工制作的 Nuxt 模块集合,旨在增强网站对搜索引擎和受众的吸引力。它简化了复杂的 SEO 任务,确保我们的食谱分享网站在搜索结果中排名靠前。
然后,我们将了解元数据,如 Open Graph (OG) 标签,以及如何为每个页面自定义它们。我们还将检查 schema 标记的关键作用。这种结构化数据方法有助于为搜索引擎澄清我们的内容,有助于准确和增强的搜索结果展示。
我们还将介绍一个独特功能——使用 Nuxt 组件为每个食谱创建自定义的 og:image
,使每个分享的食谱在视觉上具有独特性。
此外,我们还将涵盖动态生成 XML 网站地图,这些地图可以引导搜索引擎高效地遍历我们网站的内容。
在本章中,我们将涵盖以下主要主题:
-
为每一页定义元数据
-
实施结构化数据以改善搜索引擎排名
-
优化食谱图片以加快页面加载速度
-
为食谱页面创建自定义的
og:image
-
为食谱分享网站生成动态网站地图
技术要求
本章的代码文件可在 github.com/PacktPublishing/Nuxt-3-Projects/tree/main/chapter06
找到。
本章的 CiA 视频可在 packt.link/YQ8As
找到
必要的背景知识——理解搜索引擎优化(SEO)
当我们准备开始使用 Nuxt 3 增强我们的食谱分享网站并优化 SEO 时,首先了解关键概念至关重要:
-
SEO 基础:SEO 涉及提高网站在搜索引擎中可见性的技术。它关乎使你的网站易于搜索引擎理解,这有助于使其在搜索结果中排名更高。
-
原始协议:此协议增强了社交媒体平台上网络内容的表现方式。通过使用特定的标签,您可以控制内容分享时的显示方式。
-
Schema 标记:Schema 标记就像是你网站内容的详细标签,帮助搜索引擎理解你的网站是关于什么的。例如,在食谱分享网站上使用食谱 schema 可以显著改善其在搜索结果中的外观,直接在搜索列表中显示如成分和烹饪时间等丰富片段。
-
网站地图生成:网站地图是您网站的路线图,引导搜索引擎到达所有重要页面。只需几行代码,Nuxt 3 就可以生成动态网站地图。
这个背景知识将使你能够有效地利用我们的食谱分享网站上的 Nuxt 3 SEO 功能。有了这些知识,我们就可以有效地使用 Nuxt 3 的 SEO 工具了。让我们继续进行我们仓库的实际应用和探索。
探索仓库
让我们花点时间熟悉一下我们仓库根目录下的 chapter06/starter
文件夹。这个文件夹旨在为你提供一个良好的起点,其中包含预定义的组件、基本的页面结构、数据和 TypeScript 接口。这些元素旨在为我们食谱分享网站提供一个坚实的基础,使我们能够主要关注增强其 SEO。
注意,我们不会深入探讨这些资源的每个细节,因为我们的主要重点是了解优化我们食谱分享网站所需的 SEO 概念和实践。
为每个页面定义元数据
首先,让我们像前几章所做的那样设置我们的新网站。将 Tailwind CSS 和 Google Fonts 模块添加到网站中以增强样式。完成这些步骤后,运行网站并花点时间确保一切正常工作。
设置项目结构
为了设置项目,我们将使用来自仓库 starter
文件夹的预定义文件和组件。克隆仓库,然后将 starter
文件夹的整个内容复制到我们新项目的根目录中。如果你的项目中存在与 starter
文件夹中文件名称匹配的现有文件,请用 starter
文件覆盖它们。以下是启动内容的分解:
-
pages/index.vue
:此文件设置主页布局,包括英雄图片和特色食谱等部分 -
components/RecipeCard.vue
和components/RecipeInfo.vue
:这些 Vue 组件用于在主页上以卡片的形式显示食谱及其详情 -
data/recipes.ts
:包含食谱的数据结构,将用于填充你网站上的内容 -
types/index.ts
:为项目中使用的结构提供 TypeScript 定义 -
pages/recipe/[slug].vue
:一个动态路由,根据其 slug 为每个食谱创建单独的页面
通过将 starter
文件夹的内容复制到你的项目中,你可以快速设置食谱分享网站的基础元素,并更多地关注本章概述的 SEO 方面。
开始你的项目以测试进度。你应该能看到你食谱分享网站的主页,类似于以下截图:
图 6.1:主页
点击主页上列出的任何食谱。由于 Nuxt 实现了动态路由,这应该会带你到所选食谱的详细页面。详细页面将显示有关食谱的更多信息,如 pages/recipe/[slug].vue
中定义的那样。
图 6.2:食谱详情页面
在测试了我们的项目并看到我们的主页和食谱详情页面变得生动之后,让我们将注意力转向 SEO。
使用 nuxt SEO 设置网站配置
Nuxt SEO 是一个强大的模块,它增强了 Nuxt 网站的 SEO 功能。它简化了配置过程,并确保与各种 SEO 模块兼容。该模块提供了元标签的简化管理以及元数据的最佳实践,包括自动生成的规范 URL 和开放图元标签。这种设置对于优化你的网站搜索引擎存在感和用户参与度至关重要。
要将模块添加到你的项目中,请运行以下命令:
$ pnpm i -d @nuxtjs/seo
在你的 nuxt.config.js
文件中,将 @nuxtjs/seo
添加到模块数组中,然后添加一个 site
属性。以下是基于你的配置的示例配置:
export default defineNuxtConfig({
// Other Nuxt Configurations
modules: [
'@nuxtjs/seo',
'@nuxtjs/tailwindcss',
'@nuxtjs/google-fonts',
],
site: {
url: 'http://localhost:3000',
name: `Let's Cook - Your Go-To Destination for
Delicious Recipes`,
description: `Dive into a world of flavors with Let's
Cook! Discover a diverse array of mouth-watering
recipes. and find inspiration for your next kitchen
adventure. Join us and elevate your cooking
experience!`,
defaultLocale: 'en'
}
}
如果你打开浏览器开发者工具,你会注意到文档的 head
元素中自动生成的一些标签:
-
UTF-8
:确保你的网站使用UTF-8
字符编码以实现通用字符表示。 -
Viewport
:设置视口以控制你的网站在不同设备上的显示方式,这对于响应式设计至关重要。 -
Favicon
:使用public
文件夹中的favicon.ico
来在浏览器标签中显示你网站的图标。 -
Robots
:指导搜索引擎机器人如何索引你的网站,影响 SEO 和网站可见性。 -
og:type
:定义 OG 的内容类型。默认值是网站。 -
url canonical
:与site.url
配置匹配。有助于防止重复内容问题。 -
og:canonical
:规范 URL 的 OG 版本,这对于社交媒体平台非常重要。 -
title / og:title / og:site_name
:将页面标题设置为site.name
,并在浏览器标签、搜索结果和社交媒体中使用。 -
meta description / og:description
:提供你网站的简要描述。用于搜索引擎列表和社交媒体分享。 -
og:locale
:将区域设置为site.defaultLocale
,指示 OG 的语言和区域设置。
想象一下,只需用四行代码就能增强你网站的 SEO!此外,nuxt/seo
为内部页面提供了一个增强的页面标题功能。如果你没有为页面设置标题,该模块会自动从最后一个 slug 段落生成一个。
例如,如果我们在我们的大蒜食谱页面在浏览器中打开,页面标题会自动变为以下内容:
图 6.3:食谱详情页面标题
注意,URL 是 http://localhost:3000/recipe/steak-with-vegetables,标题是最后一个 slug 的首字母大写版本和主要网站标题的组合。这不是很酷吗?这个功能特别方便于管理众多页面,确保每个页面都有一个描述性、SEO 友好的标题,而无需为每个页面手动输入。现在,让我们将注意力转向自定义页面中覆盖元数据。
覆盖元数据
如果你检查食谱详情页面的元描述,你会注意到它与主页描述相似。理想情况下,我们应该根据每个特定页面的内容来定制它。为了实现这种定制,我们可以利用useSeoMeta
组合式。
useSeoMeta
允许你使用完整的 TypeScript 支持将网站的 SEO 元标签定义为扁平对象。它帮助你避免常见的错误,并确保你的元标签准确有效。
现在,让我们将这个方法应用到我们的pages/recipe/[slug].vue
组件中:
<script lang="ts" setup>
// other script content
const route = useRoute()
const recipe = recipes.find(item => item.slug ===
route.params.slug)
useSeoMeta({
description: recipe?.description,
ogDescription: recipe?.description
})
</script>
刷新页面后,更新的描述应该出现在 head 标签中,反映每个食谱页面的具体内容。
图 6.4:覆盖元描述
现在随着我们的元数据已经为每个食谱页面动态准备并定制,让我们继续学习如何从Schema.org获取结构化数据,以进一步提高我们网站的搜索引擎排名。
实施结构化数据以改善搜索引擎排名
Schema.org在增强搜索引擎对网站可见性方面发挥着关键作用。它为互联网上的结构化数据提供了一个共享的词汇表,使搜索引擎能够更好地理解和显示你的内容。要检查我们网站的预定义结构化数据,请点击网站上的nuxt-devtools
图标,按Ctrl + K或Command + K,然后输入单词Schema
。
图 6.5:Nuxt Devtools
它应该揭示两个关键对象:
-
网站对象:代表整个网站,向搜索引擎提供有关网站性质、重点和一般详细信息的高级信息
-
网页对象:描述你网站上的单个页面,提供更具体的数据,如特定页面的内容
这些对象自动从网站配置中生成,利用了nuxt/seo
模块的力量。进一步推进我们的 SEO 策略,让我们学习如何创建一个食谱节点。
创建一个食谱节点
在 SEO 和结构化数据的情况下,“节点”指的是在网页上表示特定类型内容的一组信息,以搜索引擎可以轻松理解和索引的方式结构化。在我们的案例中,食谱节点是一组特定描述食谱的结构化数据,包括如成分、烹饪时间和营养信息等元素。
如果你打开谷歌并搜索“fettuccine alfredo 食谱”这个术语,你会得到与这个类似的结果:
图 6.6:搜索结果中的食谱节点
注意,结果包括带有图片、准备时间、成分、评分等的食谱列表。这个搜索结果中的卡片指的是一个食谱节点。
nuxt/seo
模块支持创建各种类型的节点,例如用于菜谱的 defineRecipe
。您可以在 https://unhead.unjs.io/schema-org/schema/recipe 了解更多关于菜谱节点的内容。
要实现菜谱节点,导航到 pages/recipe/[slug].vue
并在脚本中添加以下内容:
<script lang="ts" setup>
useSchemaOrg([
defineRecipe({
name: recipe?.title,
description: recipe?.description,
image: recipe?.image,
cookTime: recipe?.cookingTime,
prepTime: recipe?.prepTime,
nutrition: recipe?.nutrition,
recipeYield: recipe?.recipeYield,
recipeCategory: recipe?.recipeCategory,
recipeCuisine: recipe?.recipeCuisine,
aggregateRating: {
ratingValue: recipe?.ratings
},
recipeIngredient: recipe?.recipeIngredient,
recipeInstructions: recipe?.recipeInstructions
})
])
</script>
下面是对前面代码的分解:
-
cookTime
和prepTime
: 分别指定烹饪和准备时间。 -
nutrition
: 详细说明菜谱的营养信息。 -
recipeYield
: 指示菜谱制作的数量或份量,例如“四份”或“三杯”。 -
recipeCategory
和recipeCuisine
: 分别将菜谱分类到特定的类别和菜系,帮助用户根据他们的偏好找到相关的菜肴。 -
aggregateRating
: 显示该菜谱的平均评分值。 -
recipeIngredient
: 一个字符串数组,列出了所需的食材。 -
recipeInstructions
: 一个数组,其中每个元素都是一个对象,详细说明了菜谱的特定步骤。每个对象可以包含以下内容:-
name
: 步骤的标题或简要描述(可选) -
text
: 该步骤的详细说明 -
image
: 该步骤的可选图片 URL,提供视觉指南
-
在您的 pages/recipe/[slug].vue
文件中实现 defineRecipe
函数后,您可以通过重新访问 Nuxt 开发工具来验证其效果:
-
在浏览器中打开您的 Nuxt 项目。
-
在 Nuxt Devtools 中搜索
Schema
部分。 -
在这里,您现在应该会看到一个名为
Recipe
的新对象,代表您正在查看的特定菜谱页面的结构化数据。
这个 Recipe
对象将包含您使用 defineRecipe
定义的 所有结构化数据,例如菜谱的名称、描述、烹饪和准备时间、食材和说明。在 Nuxt Devtools 中的这种可视化是一种很好的方式来确认您的结构化数据已被正确实现并被 Nuxt 识别。您还可以使用 shcema.org 验证器验证该对象:https://validator.schema.org/.
在我们的菜谱节点成功实现并在 Nuxt Devtools 中可见后,现在让我们将注意力转向优化菜谱图片以提高页面加载速度。
优化菜谱图片以加快页面加载速度
为了优化菜谱图片并提高页面加载速度,我们将关注图像优化在网页性能中的重要性。大而未优化的图片可以显著减慢页面加载时间,对用户体验和 SEO 产生负面影响。
要优化 Nuxt 中的图片,我们可以使用 nuxt-img
,这是来自 Nuxt Image 的一个组件,旨在高效地优化和转换图片。它提供了诸如即时图片调整大小、现代格式转换和懒加载(在图片加载时提供一个占位符)等功能,这些都是速度和性能的关键。
在我们的项目中实现 nuxt-img
是优化图片的关键步骤。让我们从安装该包开始:
$ pnpm i -d @nuxt/image
然后,我们将它添加到 nuxt.config.ts
模块中:
export default defineNuxtConfig({
// other nuxt configuration
modules: [
'@nuxtjs/seo',
'@nuxtjs/tailwindcss',
'@nuxtjs/google-fonts',
'@nuxt/image'
],
})
接下来,我们转到 pages/recipe/[slug].vue
文件。在那里,将标准的 <img>
标签替换为 <nuxt-img>
标签,以利用 Nuxt Image 的功能:
<nuxt-img
:src="img/recipe.image"
alt="recipe Image"
class="absolute w-full h-full object-cover"
/>
在实施此更改之前,让我们评估当前图像的大小。打开浏览器开发者工具,导航到图像标签,并在新标签页中打开图像源:
图 6.7:优化前的图像大小
如果你下载图像,你可能会发现它的大小相当可观,可能大约有 4 MB,这对于网络图像来说相当大。现在,让我们将 format="webp"
属性添加到 nuxt-img
组件中:
<nuxt-img
format="webp"
:src="img/recipe.image"
alt="recipe Image"
class="absolute w-full h-full object-cover"
/>
在进行此更改后,重新进行测试。你可能会注意到图像大小现在显著减小,大约减少了 400 KB,这意味着我们在保持质量的同时实现了 90%的大小缩减。
图 6.8:优化后的 Nuxt 图像
Nuxt 图像中的另一个重要功能是 placeholder
。通过在完整大小的图像加载时显示模糊的占位符图像,这显著提高了用户体验。与传统的逐行加载图像的方法相比,这种方法在视觉上更具吸引力。
要利用此功能,只需将 placeholder
属性添加到 nuxt-img
组件中——例如,在 pages/recipe/[slug].vue
中的图像:
<nuxt-img
:src="img/recipe.image"
format="webp"
placeholder
alt="recipe Image"
class="absolute w-full h-full object-cover"
/>
添加此属性后,当你访问页面时,你最初会看到图像的模糊版本,它逐渐变得清晰,最终呈现完整的图像,从而实现更平滑的视觉体验。
图 6.9:Nuxt 图像占位符
现在,让我们进一步讨论通过指定它们的宽度和高度来优化图像,这在显示较小尺寸的图像时特别有用。
nuxt-img
允许你定义宽度和高度属性,确保图像以最适合其显示区域的大小提供服务。这将根据指定的大小创建一个较小的图像,减少不必要的数据传输并提高图像优化。例如,在 components/RecipeCard.vue
文件中,让我们将 img
标签替换为 nuxt-img
并添加 webp 格式、占位符以及 250px
的高度,正如我们在 TailwindCSS 类中指定的:
<nuxt-img
format="webp"
class="w-full h-[250px]"
height="250"
:src="img/recipe.image"
alt="Recipe Image"
placeholder
/>
通过将高度设置为 250
像素,nuxt-img
将图像调整到这些尺寸。这个调整大小操作由 Nuxt Image 在幕后执行,大大减少了文件大小。
在这种情况下,原始图片大小约为 4 MB,首先通过转换为 WebP 格式减小到大约 400 KB,然后,通过指定的高度进一步减小到惊人的约 18 KB 的大小!这种显著减小展示了指定尺寸与 WebP 格式结合使用的效果,展示了 nuxt-img
如何优化图片以增强页面加载速度同时保持图片质量。
图 6.10:指定大小后的 Nuxt 图片
在优化了我们的食谱图片后,我们现在转向一种创新的方法——使用自定义 Nuxt 组件为每个食谱页面自动生成独特的 OG 图像,从而提高它们在社交媒体上的视觉影响力。
为食谱页面创建自定义 og:image
OG 图像(og:image
)在社交媒体上分享内容时的视觉呈现中起着至关重要的作用。例如,当有人从你的网站分享食谱链接时,og:image
通常会作为帖子的视觉亮点出现,吸引注意力并可能为你的网站带来更多流量。
Nuxt 的 useSeoMeta
可组合函数允许你为每个页面添加一个 og:image
链接,如 Nuxt 文档中更详细地解释:nuxt.com/docs/api/composables/use-seo-meta#usage
。
然而,在本节中,我们将探索一个更动态和创造性的解决方案。我们不会为每个食谱手动设计图片,而是创建一个 Nuxt 组件来自动生成 OG 图片。nuxt/seo
模块的这个功能非常有用,可以动态地将我们的组件转换为 og:image
,在创建自定义图形设计时节省大量时间和精力。
我们将首先在模板内的 components/OgImage/CustomTemplate.vue
中创建一个新的组件,通过添加以下内容:
<template>
<div class="h-full w-full flex flex-row relative">
<div class="flex flex-col justify-between w-2/3 p-16
bg-slate-800">
<img src="img/logo.png" width="128" height="128"
class="w-32 object-contain" />
<h1 class="text-[64px] font-black text-white">
{{ title }}
</h1>
<div class="text-2xl leading-10 font-black
text-slate-100 mb-4">
{{ description }}
</div>
<p class="text-2xl font-bold text-primary mb-0">
{{ siteConfig.url }}</p>
</div>
<img :src="img/image" alt="Card Image" class="w-1/3 h-full
object-cover" />
</div>
</template>
模板创建了一个食谱卡片。它结合了网站的标志、食谱的标题和描述、网站 URL 和食谱的图片。
在相同的组件中,添加以下脚本:
<script lang="ts" setup>
const siteConfig = useSiteConfig()
withDefaults(
defineProps<{
title: string
description: string
image?: string
}>(),
{ image: '/images/hero.png' }
)
</script>
在 <script>
部分,我们正在使用 Nuxt 可组合函数动态设置组件的属性:
-
useSiteConfig
: 来自nuxt/seo
模块的这个可组合函数检索在nuxt.config.ts
中定义的网站配置,使我们能够访问全局网站设置,如 URL。 -
withDefaults
: 这个 Vue 函数用于为组件的 props 分配默认值。具体来说,它为没有提供特定食谱图片的场景设置了一个默认图片('/images/hero.png'
),例如在创建主页的 OG 图像时。
要在我们的主页中使用我们的自定义组件,我们首先需要修改 pages/index.vue
中的脚本:
<script setup lang="ts">
defineOgImageComponent('CustomTemplate', {
title: `Let's Cook`
});
</script>
通过这样做,我们为 OG 图片设置了一个自定义标题。由于我们没有指定描述,将使用全局定义在 nuxt.config.ts
site
对象中的默认网站描述。同样,在没有指定菜谱图片的情况下,我们的默认英雄图片将自动选择。
要调试并查看这些更改的结果,请按照以下步骤操作:
-
导航到 http://localhost:3000/。
-
点击 Nuxt Devtools 图标。
-
打开搜索栏(在 Mac 上使用 Ctrl + K 或 Command + K),搜索
og Image
。 -
您现在应该可以看到 OG 图片的预览,它看起来类似于 图 6.11:
图 6.11:Nuxt Devtools – og:image 调试
此过程确认我们的自定义 OG 图片已成功应用于主页。接下来,让我们采用类似的方法为每个菜谱创建一个。
打开 /pages/recipe/[slug].vue
文件,并将其附加到脚本中:
defineOgImageComponent('CustomTemplate', {
title: recipe?.title,
description: recipe?.description,
image: recipe?.image
});
此代码根据特定的菜谱细节动态设置 OG 图片的标题、描述和图片。当您访问菜谱页面并检查 Nuxt Devtools 中的 OG Image 部分,您将看到反映菜谱独特属性的定制图片:
图 6.12:菜谱的 OG 图片
在为每个菜谱个性化 OG 图片后,我们现在继续进行我们的最终任务——为菜谱分享网站生成动态网站地图。
为菜谱分享网站生成动态网站地图
由于我们已经为每个菜谱定制了 OG 图片,我们的下一步和最后一步是为我们的菜谱分享网站生成动态网站地图。网站地图至关重要,因为它们指导搜索引擎发现和索引您网站上所有页面,从而增强 SEO。
虽然 nuxt/seo
模块会自动为静态页面(如主页和关于页面)创建网站地图,但它不会自动检测动态页面(如单个菜谱)。要查看当前网站地图,请访问 localhost:3000/sitemap.xml
。您会注意到它只包含静态页面:
图 6.13:默认的 Sitemap.xml
然而,我们的网站还特色动态菜谱页面,这些页面不会自动包含在此网站地图中。为了解决这个问题,我们需要为 nuxt/seo
提供一种识别这些动态页面的方法。我们将通过设置一个 API 端点来获取菜谱列表来实现这一点。然后,此列表可以输入到 nuxt/seo
中,以动态生成一个包含静态和动态页面的综合网站地图,确保我们的网站对搜索引擎的完全可见性。
在一个典型的项目中,后端的责任包括创建一个外部 API 来列出动态页面。然而,Nuxt 提供了一种更集成的方法——使用其server
目录,我们可以在我们的项目中创建一个内部 API。这种方法特别适用于我们这样的场景,我们需要生成一个动态网站地图。
Nuxt 的服务器目录是一个强大的功能,它使我们能够直接在我们的 Nuxt 应用程序中编写服务器端逻辑。它是一个理想的解决方案,用于内部 API 和服务器端功能,无需单独的后端服务。
要在我们的菜谱分享网站上使用此功能,我们将创建一个名为server/api/__sitemap__/urls.ts
的文件。在这个文件中,我们将使用defineSitemapEventHandler
定义一个网站地图处理程序,它将根据我们的菜谱数据动态生成 URL:
import recipes from '~/data/recipes'
// server/api/__sitemap__/urls.ts
export default defineSitemapEventHandler(() => {
return recipes.map(recipe => ({
loc: `/recipe/${recipe.slug}`,
_sitemap: 'pages'
}));
});
在 Nuxt 的服务器目录中,api
文件夹专门用于创建 API,每个文件路径对应一个 API 路由。这个特性允许我们轻松地将服务器端 API 直接集成到 Nuxt 项目中。在我们的案例中,server/api/__sitemap__/urls.ts
文件将自动转换为可访问的 API 路由。要测试并查看这个新创建的 API 的输出,您可以导航到 http://localhost:3000/api/sitemap/urls。当您访问此 URL 时,它将显示为我们动态菜谱页面生成的 URL 列表:
图 6.14:测试内部 API
要将我们动态生成的菜谱 URL 包含在网站地图中,我们将通过添加以下对象来调整nuxt.config.ts
:
export default defineNuxtConfig({
// other nuxt configuration
sitemap: {
sources: ['http://localhost:3000/api/__sitemap__/urls']
},
})
这行代码指示 Nuxt 使用我们内部 API 的 URL 作为网站地图的来源,将它们与所有自动生成的静态页面结合起来。
现在,在您的浏览器中重新访问localhost:3000/sitemap.xml
。您应该看到所有菜谱以及静态页面现在都已正确地列在网站地图中,确保它们可以被搜索引擎发现:
图 6.15:最终的 sitemap.xml 结果
通过这一最终步骤,我们成功完成了我们的项目,通过优化 SEO、动态 OG 图像和有效的网站地图增强了我们的菜谱分享网站。这一成就标志着我们在创建真实世界 Nuxt 项目旅程中的一个重要里程碑!
摘要
第六章专注于使用 Nuxt 3 提升菜谱分享网站,重点在于 SEO。我们从nuxt/seo
开始,用于网站配置,高效地添加了关键 SEO 元素,如元标签和 OG 协议,增强了网站在搜索引擎中的可见性。
我们的旅程包括实现 defineRecipe
用于结构化数据,提高搜索结果中菜谱的可见性。使用 nuxt-img
在优化图片方面至关重要,显著提高了页面加载速度。一个亮点是通过自定义 Nuxt 组件创建动态 OG 图片,为每个共享的菜谱自动生成独特图片,并丰富我们的社交媒体互动。
我们通过利用 Nuxt 的服务器目录创建内部 API,克服了动态网站地图生成的挑战,确保搜索引擎对整个网站可见。
当我们结束这一章节时,我们为 第七章 准备,即构建一个学习与测试的问答游戏应用。这次冒险将深入创建一个交互式问答游戏应用,将介绍 Nuxt 3 的测试能力,以构建可扩展、无错误的程序。我们将探索单元测试、Pinia 存储测试、端到端测试,进一步增强我们的 Nuxt 3 开发技能。
实践问题
-
你如何使用
nuxt/seo
设置规范 URL? -
你如何在 Nuxt 3 中将图片转换为 WebP 格式?
-
解释如何为图片实现模糊占位符。
-
你如何自定义社交媒体分享的 OG 图片?
-
你如何在 Nuxt Devtools 中测试菜谱节点的实现?
-
在
nuxt-img
中指定宽度和高度的重要性是什么? -
你如何在网站地图中包含动态页面 URL?
-
useSeoMeta
的重要性是什么? -
你如何验证动态生成网站地图的内容?
-
你如何在 Nuxt 页面中添加结构化数据或模式?
进一步阅读
-
Nuxt SEO:
nuxtseo.com/
-
useSeoMeta
:nuxt.com/docs/api/composables/use-seo-meta
-
Nuxt SEO 支持的节点:
nuxtseo.com/schema-org/guides/nodes
-
Nuxt 图像:
image.nuxt.com/
-
来自外部 API 的动态网站地图 URL:
nuxtseo.com/sitemap/guides/dynamic-urls#dynamic-urls-from-an-external-api
第七章:通过构建问答游戏应用程序来学习测试
在本章中,我们将继续我们的旅程,使用 Nuxt 3 构建一个问答游戏应用程序,并深入关注提高我们的测试技能。本章是为那些希望提高单元测试和端到端(E2E)测试技能的开发者量身定制的。
首先,我们将讨论在软件开发中测试的重要性,说明它如何作为任何应用程序的支柱。我们将从单元测试开始,在这里你将学习如何为 Pinia 存储和 Vue 组件构建单元测试,确保每一块逻辑和每一个 UI 元素都按预期工作。
接下来,我们将继续我们的测试策略,了解端到端测试(E2E 测试),模拟真实用户交互以确认问答游戏的整体功能。我们还将提供测试结果和覆盖率的视觉洞察。
在本章中,我们将涵盖以下主要内容:
-
实现问答游戏应用程序
-
为 Pinia 存储编写单元测试
-
为组件编写单元测试
-
为问答游戏编写端到端测试
-
探索 Vitest UI 和测试覆盖率工具
技术要求
本章的代码文件可以在github.com/PacktPublishing/Nuxt-3-Projects/tree/main/chapter07
找到。
本章的 CiA 视频可以在packt.link/tAMjs
找到。
探索仓库
正如我们在上一章中所做的那样,让我们快速回顾一下我们仓库中的starter
文件夹。这个文件夹预先填充了必要的组件、问答存储和 TypeScript 接口,准备好复制到你的新项目中。它作为基础,使我们能够专注于本章的关键任务:在 Nuxt 3 中实现和学习测试策略。
必要的背景知识——测试基础
当我们将重点转向第7 章中的测试时,理解测试的基础知识变得至关重要。测试是软件开发中的一个关键过程,旨在确保你的应用程序在各种情况下都能正确运行。
传统上,开发者习惯于手动测试应用程序,这需要手动努力来查找错误。然而,测试实践的演变通过采用自动化测试显著减少了对手动测试的依赖。自动化测试通过自动化重复性任务引入了效率和一致性。它包括三个主要级别:
-
单元测试:专注于测试单个组件或函数的独立情况,这使得定位错误变得更加容易。
-
集成测试:测试集成单元或组件之间的交互,以确保它们按预期协同工作。虽然本章不会重点介绍集成测试,但它仍然是测试金字塔的一个重要组成部分。
-
端到端测试:从开始到结束模拟真实用户与应用程序的行为和交互,确保整个系统按预期运行。
通过实现自动化测试,我们可以显著减少手动测试的需求,允许以更频繁、更全面的测试运行,且更省力。接下来,让我们看看如何实现测验游戏应用。
实现测验游戏应用
我们将通过设置一个新的 Nuxt 3 项目来启动我们的测验游戏应用。就像我们之前的努力一样,我们将利用starter
文件夹来加速我们的开发过程。一旦您创建了新的项目,请将starter
文件夹的内容复制到其中。如果提示,请同意替换现有文件。
接下来,我们需要安装几个基本的 Nuxt 模块,例如 Pinia、Tailwind CSS 和 Google Fonts。我们已经在之前的章节中讨论了每个模块。请在您的终端中运行以下命令,将这些包添加到您的项目中:
$ pnpm i -D @nuxtjs/google-fonts @nuxtjs/tailwindcss sass
$ pnpm i @pinia/nuxt
在设置好依赖项后,是时候启动我们的项目并看到初始设置的实际效果了。像往常一样执行项目:
$ pnpm dev
运行项目后,您应该会看到测验游戏应用的初始布局:
图 7.1:测验游戏应用
在深入到我们的测验游戏应用测试方面之前,让我们花一点时间来了解构成我们项目骨架的结构和组件。
问题是数据
题目数据位于data/questions.ts
中。它存储了测验问题,每个问题都有多个答案,使得每次用户与应用程序互动时都能动态生成测验。
类型定义
应用程序中使用的结构和数据类型在types/index.ts
中进行了细致的定义:
export default {}
declare global {
type Question = {
id: number
body: string
answers: Answer[]
rightAnswerId: number
}
type Answer = {
id: number
body: string
}
type Result = Question & {
userAnswer: Answer
answerIsRight: boolean
}
}
这里是对类型的概述:
-
rightAnswerId
) -
答案:定义了每个问题答案选项的结构,包括其自己的 ID 和答案正文
-
通过包含用户的所选答案(
userAnswer
)和一个标志(answerIsRight
),指示所选答案是否正确来定义Question
类型
接下来,让我们看看测验存储的内容。
测验 Pinia 存储
这个存储库旨在使用 Pinia 管理游戏状态,从跟踪当前问题到计算玩家的分数。以下是其关键功能的概述。
这个初始部分设置了测验存储并初始化了状态,包括当前问题索引、测验完成状态、结果数组和分数:
const currentQuestionIndex = ref<number>(0)
const quizFinished = ref<boolean>(false)
const result = ref<Result[]>([])
const score = ref<number>(0)
接下来,quiz
是一个计算属性,它从提供的题目数据集中选择五个随机问题,确保每次测验体验的多样性:
const quiz = computed(() =>
selectRandomQuestions(questions, 5))
注意,selectRandomQuestions
是一个存储在utils
文件夹中的实用函数。因此,由于 Nuxt 自动导入功能的强大,它会被自动导入。
为了根据currentQuestionIndex
获取当前问题的实例,我们将创建另一个计算方法:
const currentQuestion = computed(() =>
quiz.value[currentQuestionIndex.value])
现在,为了更新进度,有一个名为updateProgress
的函数:
const updateProgress = (answerId: number) => {
const question = currentQuestion.value
const answerIsRight = question.rightAnswerId === answerId
if (answerIsRight) score.value++
result.value.push({
...question,
userAnswer:
question.answers.find(({ id }) => id === answerId) ??
({ body: 'no answer' } as Answer),
answerIsRight
})
if (currentQuestionIndex.value < quiz.value.length - 1)
currentQuestionIndex.value++
else quizFinished.value = true
}
updateProgress
是一个关键函数,它评估用户的答案是否正确,为正确答案更新分数,并将问题以及用户的答案和正确答案附加到结果数组中。它还确定是否移动到下一个问题或结束测验。
最后,为了在完成测验后重新开始,我们可以使用restartQuiz
函数,该函数允许用户将测验重置到初始状态,清除所有进度和分数,并使用相同的问题集再次尝试:
const restartQuiz = () => {
currentQuestionIndex.value = 0
quizFinished.value = false
result.value = []
score.value = 0
}
确保在 store 文件末尾返回所有引用和函数:
return {
currentQuestionIndex,
quizFinished,
quiz,
currentQuestion,
updateProgress,
restartQuiz,
result,
score
}
现在,让我们继续查看app.vue
的概述。
app.vue
文件
这是app.vue
文件的框架:
<!-- app.vue -->
<template>
<div class="bg-violet-950 min-h-screen text-white">
<div class="container py-12 text-center">
<template v-if="quizStore.quizFinished">
<!-- RESULT HERE -->
</template>
<Question v-else />
</div>
</div>
</template>
<script setup>
const quizStore = useQuizStore()
</script>
此文件包含我们测验游戏的主体布局和流程。我们在script
部分初始化测验存储,以便在组件内访问。template
使用条件渲染方法,通过v-if="quizFinished"
。这个条件检查测验是否结束,基于quizFinished
标志,该标志来自我们的quizStore
。
完成后,它将显示用户在五分中的得分,并列出问题以及用户的答案,以及正确答案,应用不同的背景颜色来指示答案是否正确。还有一个重新开始测验的按钮,允许用户重置测验并再次尝试。
否则,应用将渲染Question.vue
,该组件将负责渲染带答案的问题,捕获用户响应,并更新测验的进度。这很简单。您可以从启动文件中查看其内容。
在探索了我们的测验游戏项目的基石组件之后,我们现在准备好深入单元测试和端到端测试的基础。这些知识使我们具备确保我们的应用程序达到现代 Web 开发所期望的高标准和可靠性的必要技能。
为 Pinia 存储编写单元测试
随着我们继续前进,当前的任务涉及为 Pinia 存储编写单元测试。这一步对于验证应用程序的状态管理逻辑至关重要,并使我们接触到 Pinia 存储中的单元测试原则。
探索 Vitest
Vitest,考虑到 Vite 而设计,提供了一个下一代测试框架,通过集成 Vite 生态系统来增强测试体验,从而实现更快、更高效的测试。它提供了一个与 Jest 兼容的 API,使得迁移和并行测试执行变得容易,从而提高了性能。
与 Jest 相比,Vitest 因其无缝集成、快速设置和执行而突出,使其成为现代 Web 开发的优选选择。更多详情,请访问官方网站:vitest.dev
。
为了确保 Vitest 与 Nuxt 无缝工作,我们将@nuxt/test-utils
集成到我们的设置中。此工具包旨在与各种测试框架和环境协同工作,为我们提供灵活性和强大的测试策略。让我们明确我们设置的关键组件:
-
@nuxt/test-utils
:对于 Nuxt 应用程序至关重要,提供专门的工具和功能,以及与现有测试框架的集成,以在 Nuxt 环境中提供流畅的测试体验。 -
Vitest
:我们选择的测试框架。 -
happy-dom
:一个轻量级的 DOM 模拟库,happy-dom
为测试提供了更准确和更快的浏览器环境模拟。它因其运行涉及 DOM 操作或浏览器 API 交互的测试的高效性而被选中,它能够复制组件在真实浏览器中的行为,而无需实际浏览器。
现在,使用以下命令安装这些工具:
$ pnpm add -D @nuxt/test-utils vitest happy-dom
然后,我们在项目的根目录下创建一个vitest.config.ts
文件。此配置文件对于指定我们的测试环境偏好至关重要:
// vitest.config.ts
import { defineVitestConfig } from
'@nuxt/test-utils/config'
export default defineVitestConfig({
test: {
environment: 'happy-dom'
}
})
通过将环境设置为'happy-dom'
,我们指示 Vitest 使用happy-dom
模拟浏览器环境进行我们的测试。
接下来,我们在项目的根目录下创建一个名为temp.spec.ts
的测试文件。文件名中的.spec
后缀是一个约定,代表“规范”。它表示该文件包含一系列规范(测试),描述了应用程序或特定组件应该如何行为。这种命名约定有助于 Vitest 在运行vitest
命令时自动定位和执行测试,扫描以.spec.ts
或.test.ts
结尾的文件。
现在,让我们用 Vitest 的语法和能力的一个简单测试来填充temp.spec.ts
,以便熟悉 Vitest:
// temp.spec.ts
import { describe, it, expect } from 'vitest'
const sum = (a: number, b: number): number => a + b
describe('Sample Test', () => {
it('should accurately add two numbers', () => {
expect(sum(2, 3)).toBe(5)
})
})
在此示例中,describe
用于将我们的测试分组到公共套件中,it
概述单个测试用例,而expect
则对代码的行为提出断言。在这里,我们创建了一个简单的测试用例,以确保sum
函数正常工作。我们期望sum(2, 3)
的结果等于5
。
要运行我们新创建的测试,我们首先需要对我们项目的package.json
文件进行一些小的调整。通过添加一个新的脚本条目"test"
,我们配置它执行vitest
,然后它运行我们的测试套件:
// package.json
"scripts": {
// other scripts
"test": "vitest"
}
在此脚本到位后,通过运行以下命令执行测试套件:
$ pnpm test
在执行过程中,Vitest 迅速启动,自动扫描项目中的任何测试文件。对于我们的简单测试temp.spec.ts
,Vitest 应该将其识别为唯一的测试文件,识别包含的单个describe
块和其中包含的一个测试用例。如果一切设置正确,你将看到表示测试用例按预期通过的输出:
图 7.2:Vitest 输出
接下来,让我们在 temp.spec.ts
文件中引入一个额外的测试用例,我们预计它会失败,以观察 Vitest 在处理失败测试时的行为:
// temp.spec.ts
import { describe, it, expect } from 'vitest'
const sum = (a: number, b: number): number => a + b
describe('Sample Test', () => {
// previous test
// Add this test case within the same describe block
it('should fail to add two numbers correctly', () => {
expect(sum(2, 2)).toBe(5) // incorrect
})
})
Vitest 会持续监控你的测试文件中的任何更改。因此,当你保存包含故意失败的测试的 temp.spec.ts
文件时,Vitest 会自动重新运行测试。这次,你会在终端输出中注意到,尽管第一个测试用例像以前一样通过,但新的测试用例失败了。这种即时反馈突出了失败的断言,直接在你的终端中提供了出错和出错位置的信息:
图 7.3:带有错误测试的 Vitest 输出
现在,随着 Vitest 主动监控我们的项目以查找更改,让我们继续测试测验存储,以确保其在各种条件下逻辑正确执行。
测试测验存储
我们现在将重点转向测验存储。通过对这个存储进行单元测试,我们旨在确认我们的游戏逻辑和状态管理按预期工作,为提供良好的用户体验奠定坚实的基础。
首先,让我们通过删除 temp.spec.ts
临时测试文件并在 /stores
文件夹中创建 quiz.spec.ts
来清理舞台,该文件夹位于我们的测验存储旁边。为了有效地对 Pinia 存储进行单元测试,我们必须为每个测试建立一个新的 Pinia 实例:
// stores/quiz.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
describe('Quiz Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
})
beforeEach
函数对于保持测试完整性非常重要。这是 Vitest 提供的一个函数,在 describe
块中的每个测试用例之前运行。通过在 beforeEach
中调用 setActivePinia(createPinia())
,我们确保每个测试都与 Pinia 的新实例交互,从而使我们能够独立评估每个测试中测验存储的功能。
在我们的测试设置准备就绪后,是时候编写我们的第一个测试了:
// stores/quiz.spec.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
describe('Quiz Store', () => {
// before each
it('initializes with a set of 5 quiz questions', () => {
const quizStore = useQuizStore()
expect(quizStore.quiz.length).toBe(5)
})
})
这个测试验证了测验存储正确地初始化了包含五个问题的集合,确保我们的游戏以玩家预期的挑战数量开始。在运行测试后,终端应指示测试用例通过。
为了确保我们的测验存储按预期行为,我们需要全面测试每个函数。一个关键方面是验证存储是否正确处理了错误答案。这里的想法是模拟一个玩家选择错误答案的场景,并确认这种操作不会导致他们的分数增加。
这是我们如何实现这个测试的:
// inside the quiz.spec.ts file
it(`doesn't increment the score when a wrong answer is
selected`, () => {
const quizStore = useQuizStore()
const firstQuestion = quizStore.quiz[0]
// get a wrong answer
const wrongAnswerId = firstQuestion.answers.find(
answer => answer.id !== firstQuestion.rightAnswerId
)?.id
// Simulate the action of choosing a wrong answer
if (wrongAnswerId !== undefined) {
quizStore.updateProgress(wrongAnswerId)
expect(quizStore.score).toBe(0)
}
})
为了达到 100% 的覆盖率,我们应该继续使用这种测试方法对测验存储中的每个函数进行测试,确保我们游戏逻辑的每个方面都得到严格的验证。
在不增加错误答案分数的方法之后,我们还将测试积极场景。我们实现了一个类似的测试用例,以确保在选择了正确答案时分数增加 1:
it('increment the score only when the correct answer is
selected', () => {
const quizStore = useQuizStore()
const firstQuestion = quizStore.quiz[0]
const rightAnswerId = firstQuestion.rightAnswerId
// Now try with the correct answer
quizStore.updateProgress(rightAnswerId)
expect(quizStore.score).toBe(1) // Score should increment
by 1
})
接下来,让我们通过一个测试来验证测验的流程,确保在选择了答案后,测验能够移动到下一个问题。这个测试首先检查初始问题索引为 0,模拟回答第一个问题,然后确认测验存储正确更新,以指示下一个问题已准备好:
it('transitions to the next question upon answering', () => {
const quizStore = useQuizStore()
expect(quizStore.currentQuestionIndex).toBe(0)
const firstQuestion = quizStore.quiz[0]
// Select any answer ID from the first question
const anyAnswerId = firstQuestion.answers[0].id
quizStore.updateProgress(anyAnswerId)
// Verify the store has moved to the next question
expect(quizStore.currentQuestionIndex).toBe(1)
})
为了完成对测验流程的测试,我们实现了一个测试来确认在回答最后一个问题后,测验被标记为完成。这个测试遍历所有问题,模拟每个问题的正确答案,并检查测验存储将测验标记为完成。它进一步验证了所有问题都已计入结果,确保游戏按预期结束:
it('marks the quiz as finished when the last question is
answered', () => {
const quizStore = useQuizStore()
// Answer each question
for (let i = 0; i < quizStore.quiz.length; i++) {
const question = quizStore.quiz[i]
quizStore.updateProgress(question.rightAnswerId)
}
// After answering all questions,
// the quiz should be marked as finished
expect(quizStore.quizFinished).toBe(true)
expect(quizStore.result.length).toBe(5)
})
在这些主要测试用例得到覆盖后,我们已经为确保测验存储正确运行奠定了坚实的基础。要查看所有测试用例的完整视图,请参阅项目存储库中可用的完整测试套件。
接下来,让我们将重点转向编写 Question
组件的单元测试,我们将应用类似的严谨性来确保我们的测验游戏应用在各种场景下表现如预期。
为组件编写单元测试
当从测试存储过渡到测试 Vue 组件时,我们的重点转向验证组件在接收属性、与 Pinia 存储交互以及正确渲染时的行为。组件测试可以包括检查组件是否正确显示通过属性传递的数据,对用户输入做出反应,以及与存储无缝集成以进行状态管理。
设置组件测试环境
为了有效地测试 Vue 组件,我们引入了两个关键工具:@vue/test-utils
和 @pinia/testing
:
-
@vue/test-utils
:这个库提供了一组在测试环境中挂载和与 Vue 组件交互的实用工具。mount
函数尤为重要,因为它允许我们独立渲染一个组件,并返回一个包装器对象,我们可以用它来检查渲染输出并模拟用户交互。 -
@pinia/testing
:这为在 Vue 组件中测试 Pinia 存储提供了工具。createTestingPinia
函数用于创建一个模拟的 Pinia 实例,可以在测试中使用。
因此,让我们安装这些库:
$ pnpm add -D @vue/test-utils @pinia/testing
接下来,让我们继续创建组件测试文件。
创建 Question.spec.ts 测试文件
在 /components
文件夹内,创建一个名为 Question.spec.ts
的文件,并将以下代码添加到其中:
// /components/Question.spec.ts
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import QuestionComponent from '@/components/Question.vue'
const wrapper = mount(QuestionComponent, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn
})
]
}
})
下面是代码的分解:
-
由
mount
返回的wrapper
对象封装了挂载的组件,提供了一系列方法和属性来查询和与之交互。这个包装器允许你测试组件的渲染输出,检查其状态,并模拟用户交互,例如点击和输入更改。 -
createSpy
选项与createTestingPinia
结合使用,允许我们传递一个间谍函数(来自 Vitest 的vi.fn
),该函数可以用来监控和验证与存储的交互。间谍可以跟踪对存储引用和方法的调用,提供有关组件如何与存储交互的见解。
在配置好我们的测试环境后,让我们开始编写该组件的第一个单元测试。
编写组件单元测试
首先,让我们看看我们已从 starter
文件中复制的 Question.vue
组件:
<!-- components/Question.vue -->
<template>
<div class="text-center">
<h1 class="text-4xl text-center capitalize font-bold
mb-8">
{{ currentQuestion.body }}
</h1>
<div class="grid grid-cols-2 gap-4">
<button
class="text-2xl font-bold bg-violet-900
hover:bg-violet-800 transition rounded-lg py-5"
v-for="answer of currentQuestion.answers"
@click="answered(answer.id)"
>
{{ answer.body }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
const quizStore = useQuizStore()
const { currentQuestion } = storeToRefs(quizStore)
const answered = (answerId: number) => {
quizStore.updateProgress(answerId)
}
</script>
Question
组件是我们问答游戏应用的关键部分,旨在向用户展示一个问题和其多项选择题的答案。在渲染时,它动态显示从 Pinia 存储中检索到的当前问题的内容,包括问题文本和每个答案选项的一组按钮。用户可以通过点击这些按钮之一来选择答案,触发调用 Pinia 存储中的 updateProgress
函数的 answered
函数,根据所选答案的 ID 更新测验的进度并进入下一个问题。
在测试 Question
组件时,我们的目标是确保它按预期工作:正确显示当前问题和其答案,并适当地响应用户交互。
第一个测试用例侧重于验证 Question
组件是否正确显示了当前问题及其四个相应的答案。在 Question.spec.ts
文件中,添加以下内容:
// components/Question.spec.ts
// …previous code
describe('Question Component', () => {
it('renders current question and answers', () => {
const quizStore = useQuizStore()
const question = quizStore.currentQuestion
expect(wrapper.text()).toContain(question.body)
expect(wrapper.findAll('button')).toHaveLength(4)
// Dynamically assert each answer is rendered
question.answers.forEach(answer => {
expect(wrapper.html()).toContain(answer.body)
})
})
})
以下代码中的关键点:
-
wrapper.text()
: 检查已挂载组件的文本内容,以确保它包含当前问题的正文 -
wrapper.findAll('button')
: 验证恰好渲染了四个按钮(答案) -
wrapper.html()
: 用于检查组件的 HTML 输出,确保每个答案的正文都存在
第二个测试用例验证当用户点击一个答案时,组件的 answered
方法是否被正确地调用,并带有适当的 answerId
。这个测试模拟用户交互并检查组件处理答案的逻辑:
// components/Question.spec.ts
// …previous code
describe('Question Component', () => {
// previous test
it('calls answered method with correct answerId when an
answer is clicked', async () => {
const quizStore = useQuizStore()
// clicking on the first button
await wrapper.findAll('button')[0].trigger('click')
// verify "updateProgress" was called correctly
expect(quizStore.updateProgress).toHaveBeenCalledWith(1)
})
})
代码中的关键点如下:
-
wrapper.findAll('button')[0].trigger('click')
: 模拟用户点击第一个答案按钮。触发函数用于派发 DOM 事件,模仿用户操作。如果你检查问题数据,你会注意到第一个答案的 ID 总是 1。我们将使用这些信息来检查传递给updateProgress
方法的属性。 -
toHaveBeenCalledWith(1)
: 断言在测验存储中的updateProgress
方法被正确的参数调用,即用户的所选答案 ID(在这种情况下,它是 1)。这确保了组件正确地将用户的选择传达给存储。
在运行我们的问答游戏组件和测验存储的单元测试后,你应该在你的终端看到类似以下的结果:
图 7.4:测试组件后的 Vitest 输出
现在,让我们过渡到编写端到端测试,以确保游戏从开始到结束都能无缝运行,就像最终用户所体验的那样。
编写测验游戏的端到端测试
端到端测试是一种从开始到结束测试整个应用程序的技术,模拟真实用户的场景和交互。它确保应用程序在类似生产环境中的行为符合预期,涵盖 UI、数据库、API 和其他服务。端到端测试对于验证所有应用程序组件的集成操作和检测单元或集成测试可能遗漏的问题至关重要。
设置端到端测试环境
要在我们的项目中执行端到端测试,我们必须安装 playwright-core
,这是一个强大的自动化浏览器交互的工具,它能够模拟真实用户行为的测试。它还支持跨多个浏览器进行测试。运行以下命令来安装它:
$ pnpm add -D playwright-core
@nuxt/test-utils
包包含如 setup
和 createPage
等实用工具,以方便使用 Playwright 或其他测试运行器进行端到端测试。在开始您的端到端测试之前,使用 setup
初始化测试上下文是必要的。此函数通过配置必要的 beforeAll
、beforeEach
、afterEach
和 afterAll
钩子来准备 Nuxt 测试环境,确保您的测试在正确设置的 Nuxt 上下文中运行。另一方面,createPage
允许您创建一个配置好的 Playwright 浏览器实例,并可选地导航到运行服务器上的特定路径。我们将使用它从测验页面创建一个实例,以便能够模拟用户操作。
让我们从在项目根目录下创建我们的端到端测试文件 app.spec.ts
开始,然后添加以下代码:
import { describe, expect, it } from 'vitest'
import questions from '~/data/questions'
import { setup, createPage } from '@nuxt/test-utils/e2e'
describe('E2E Testing for the Quiz Feature in app.vue',
async () => {
await setup()
})
初始设置完成后,我们现在可以编写具体的测试用例,这些测试用例将遍历测验游戏。
编写端到端测试
在我们的端到端测试序列中,我们首先验证测验游戏的初始状态,以确保它正确加载供用户使用。这包括检查是否存在问题标题和四个相应的答案按钮,这些是测验功能的基本要素。以下是测试用例:
// app.spec.ts
// ...inside the describe function, under setup
it('Verifies the quiz initial state: one question headline
and four answer buttons', async () => {
const page = await createPage('/')
const h1Count = await page.locator('h1').count()
expect(h1Count).toBe(1)
const buttonCount =
await page.locator('button').count()
expect(buttonCount).toBe(4)
})
下面是代码的分解:
-
createPage('/')
: 此函数初始化一个新的浏览器页面实例,并导航到我们应用程序的根目录,假设测验从这里开始。 -
page.locator('h1').count()
: 这使用 Playwright 的定位器 API 在页面上查找所有<h1>
元素,然后计数它们。我们期望恰好有一个<h1>
元素,通常包含测验问题。 -
page.locator('button').count()
: 同样,这一行查找并计数所有<button>
元素,这些元素应该对应于测验答案选项。预期有恰好四个按钮,每个代表一个可能的答案。
现在让我们继续到下一个测试用例。在这个测试用例中,我们的目标是模拟用户准确回答所有测验问题,以验证应用程序是否正确过渡到结果页面并显示最终分数。这个过程涉及以下步骤:
-
导航到测验
-
遍历问题:对于显示的每个问题,测试根据我们预定义的问题数据找到正确答案,然后模拟点击相应的答案按钮。
-
验证结果页面:在回答所有问题正确后,测验应过渡到结果页面。此页面显示一条消息,表明测验已完成,并显示用户的分数。
-
检查分数:最后一部分验证显示的分数与回答所有问题正确后的预期结果相匹配。
这里是测试用例:
test(––, async () => {
const page = await createPage('/')
for (let i = 0; i < 5; i++) {
const questionText =
await page.locator('h1').textContent()
const question = questions.find(q => q.body ===
questionText)
const answerText = question?.answers.find(a => a.id ===
question.rightAnswerId).
await page.locator(`button:has-text("${answerText}")`)
.click()
}
const finishedText =
await page.locator('h1').textContent()
expect(finishedText).toBe('Finished')
const score = await page.locator('h2').textContent()
expect(score).toContain('5 / 5')
})
以下是一些语法高亮示例:
-
page.locator(button:has-text("${answerText}")).click()
:定位到文本匹配正确答案的按钮,并模拟点击事件 -
expect(finishedText).toBe('Finished')
:检查是否显示了“Finished”文本,这表明测验已完成 -
expect(score).toContain('5 / 5')
:验证最终分数,显示在<h2>
元素中,正确地表明所有问题都已正确回答
注意
在这个测试用例中,我们由于对页面结构的了解,选择了更通用的搜索方法,直接使用元素标签(h1
和h2
)。因为页面上只有一个h1
元素显示测验问题,以及一个单独的h2
元素显示结果。然而,对于更复杂的 UI 或页面上存在多个相同类型的元素时,建议进行更具体的搜索,例如针对具有唯一标识符(如类或 ID)的元素进行搜索。这将提高测试的精确性,确保即使在密集结构的页面上,我们也在与正确的元素进行交互。
一旦我们启动端到端测试,请准备好它可能需要比单元测试更长的时间来完成。这种延迟是因为测试涉及模拟真实浏览器环境,需要在任何交互(如点击答案按钮)发生之前,页面必须完全加载。您的终端输出应如下所示:
图 7.5:端到端测试输出
注意
如果这是您第一次在机器上使用 Playwright 库,您可能会遇到错误:
Looks like Playwright Test or Playwright was just installed or updated ║
║ Please run the following command to download new browsers:
pnpm exec playwright install
在这种情况下,只需在终端中运行显示的命令,然后重试测试。一些开发者报告说,为了使 Playwright 正常工作,还需要运行以下命令:
pnpm exec playwright-core install
现在我们已经完成了端到端测试,让我们将注意力转向探索 Vitest 的交互式 UI 和理解测试覆盖率。这将帮助我们可视化我们的测试努力,并确保我们的应用程序覆盖全面。
探索 Vitest UI 和测试覆盖率工具
在我们掌握 Vitest 测试的旅程中,有两个强大的工具因其增强我们开发工作流程的能力而脱颖而出:Vitest UI 和测试覆盖率工具。Vitest UI 提供了一个交互式界面,用于实时运行测试和可视化结果,这使得管理和调试测试更加容易。同时,测试覆盖率工具提供了关于我们代码哪些部分被彻底测试以及哪些区域可能需要更多关注的见解。
要将这些工具集成到我们的项目中,我们首先需要安装它们:
$ pnpm i -D @vitest/coverage-v8 @vitest/ui
安装好这些包后,我们将调整测试脚本以启用 UI 和覆盖率报告:
// package.json script
"test": "vitest --ui --coverage"
在进行此调整后,停止任何当前正在运行的测试进程并重新启动它们以激活更改。重新启动测试后,您将观察到 Vitest 启动一个 UI 项目,终端中的消息类似于以下内容:
图 7.6:Vitest UI 仪表板 URL
通过在您的网络浏览器中访问此 URL,您将看到 Vitest UI 仪表板。让我们探索 UI 和覆盖率报告,以深入了解我们的测试环境并识别改进的机会。
仪表板界面总结了关键指标,包括运行的总测试数、通过和失败的测试细分、涉及的总测试文件数以及所有测试的执行时间。这个概览提供了项目测试健康状况的清晰快照。
图 7.7:Vitest UI 仪表板
当您探索侧边栏时,您会看到一个包含您项目测试文件列表。点击其中一个,例如app.spec.ts
,将打开该特定测试文件的详细视图。在这个详细视图中,您将看到每个测试用例及其执行状态——通过、失败或跳过。此外,您还可以重新运行此文件中的测试用例。这允许轻松识别哪些测试已成功,哪些可能需要进一步的关注或调试。
图 7.8:Vitest 测试文件
要了解我们代码的测试覆盖率,只需点击左上角(在播放按钮旁边)的覆盖率图标,即可揭示我们项目健康状况的新维度。覆盖率概览在顶部呈现了已测试文件、测试语句、分支、函数和行的摘要。在摘要下方,一个表格列出了每个文件及其覆盖率百分比。
让我们搜索stores
文件夹并点击它。它显示 88.46%的代码和 50%的函数已经过测试。
图 7.9:Vitest 覆盖率 UI
仔细查看此文件夹中的单个文件,可以突出显示未测试的代码段,例如restartQuiz
函数——确认了我们测试覆盖率可以改进的区域。
图 7.10:Vitest 文件覆盖率
这么详细的级别精确地指出了我们需要增强测试的地方。这种识别未测试代码的方法非常有价值,引导我们实现更全面的测试覆盖率,并由此扩展到更可靠的应用程序。
我们对 Vitest UI 和测试覆盖率的探索到此结束,为总结我们在增强测验游戏应用程序的强大测试实践中的旅程奠定了基础。
摘要
第七章 引导我们了解在 Nuxt 3 开发测验游戏应用程序中测试的关键作用。我们首先通过单元测试打下基础,利用 Vitest 测试 Pinia 商店和组件的逻辑,确保它们的可靠性。通过模拟用户交互和断言预期结果,我们验证了应用程序关键部分的功能。
转向端到端(E2E)测试,我们使用了 Playwright 来模拟真实用户场景,从浏览测验问题到完成游戏。这一阶段加强了我们的应用程序的用户体验和功能,突出了测试在识别和纠正潜在问题以避免影响用户方面的重要性。
旅程以探索 Vitest UI 和覆盖率工具结束,这些工具揭示了未测试的代码段,并提供了可视化和交互式的方法来管理我们的测试套件。这不仅提高了我们的测试效率,也加深了我们对于测试覆盖率对应用程序质量影响的了解。
展望未来,第八章 通过在 Nuxt 3 中创建自定义翻译模块来提升我们的技能。下一章将引导我们了解 i18n(国际化)的复杂性,从构建自定义模块到注入必要的组件和功能以实现无缝语言切换和本地化。
实践问题
-
单元测试在 Vue.js 应用程序中的目的是什么?
-
描述在 Nuxt 3 项目中设置 Vitest 的过程。
-
解释如何在 Vitest 中使用
describe
和it
块来构建你的测试。 -
在测试用例中,
expect
扮演什么角色? -
你如何在单元测试中模拟用户交互,例如点击按钮?
-
你如何测试与 Pinia 商店交互的 Vue 组件?
-
你如何在端到端测试中模拟浏览器交互?
-
Nuxt 的测试工具中的
createPage
函数的目的是什么? -
Vitest 的 UI 和覆盖率工具如何帮助提高测试质量?
进一步阅读
-
Vitest 官方网站:
vitest.dev/
-
为什么选择 Vitest:
vitest.dev/guide/why.html#why-vitest
-
Vitest UI:
vitest.dev/guide/ui
-
Vitest 覆盖率:
vitest.dev/guide/coverage
第八章:在 Nuxt 3 单一代码库中创建自定义翻译模块
在 第八章 中,我们将学习如何在 Nuxt 3 单一代码库中构建自定义的 translation
模块。本章旨在指导你通过单一代码库设置和模块化开发的复杂性,为高效管理大型项目提供一个完整的基石。
我们将首先使用 pnpm
工作空间设置单一代码库,强调集中管理多个相互关联项目的优势。你还将学习如何通过详细说明创建、配置和通过组件和插件扩展此模块以添加额外功能的方式,将自定义 translation
模块无缝嵌入到 Nuxt 应用程序中。
本章展示了单一代码库如何促进模块化和可重用性。每个部分的目标是帮助你掌握增强 Nuxt 应用程序的可扩展性和可维护性模块的技能,为未来需要高级架构解决方案的项目做好准备。
本章将涵盖以下主要主题:
-
设置 Nuxt 单一代码库
-
开发自定义
translation
模块 -
配置
translation
模块选项 -
通过插件、组件和可组合函数扩展模块
-
未来方向
技术要求
本章的代码文件可以在 。
本章的 CiA 视频可以在 packt.link/kdT64
找到
重要的背景知识 – 测试基础
在本章中,我们将深入探讨如何为创建自定义的 translation
模块设置 Nuxt 3 单一代码库。单一代码库是一种策略,其中你将所有项目的部分管理在一个单一的仓库中。这就像把所有开发鸡蛋放在一个篮子里,这简化了开发中的许多方面,尤其是对于大型项目。在我们的设置中,单一代码库将不仅包括一个 Nuxt 应用程序,还包括一系列的包和应用程序,每个都为整个系统贡献不同的功能。
单一代码库有以下价值:
统一版本控制:从代码到文档的所有内容都存储在一个地方,这使得跟踪更改和维护版本更加容易。
-
简化依赖管理:项目的所有部分使用相同的依赖项,这意味着它们都会同时更新,从而减少了兼容性问题。
-
增强代码重用性:单一代码库允许团队轻松地在同一仓库内的多个项目中共享通用代码和资源。这促进了包的重用,减少了冗余,并增强了不同应用程序之间的一致性。
现在我们已经了解了单一代码库在高效处理复杂项目中的作用,我们已经为探索它们如何促进广泛应用程序的开发做好了准备。
设置 Nuxt 单一代码库
在本章中,我们首先为我们的 Nuxt 单一代码库建立基础结构。我们不会从典型的 Nuxt 项目创建命令开始,而是首先构建一个支持多包架构的专用环境。
让我们首先为本章的项目创建一个新目录,以保持我们的工作空间组织有序,并与其他项目分开:
$ mkdir chapter08
$ cd chapter08
然后,使用pnpm
初始化一个新的项目。此步骤涉及创建一个package.json
文件,该文件将定义我们的工作空间并管理项目依赖项:
$ pnpm init
现在,您可以在 Visual Studio Code 或您首选的 IDE 中打开新创建的项目目录。
为了将我们的目录指定为单一代码库,我们引入一个pnpm-workspace.yaml
文件。此配置文件在高效管理单个存储库中的多个包方面发挥着至关重要的作用。在此,我们指定了单一代码库中包的位置。我们选择的架构将工作空间分为两个主要目录:
-
packages/*
:此目录保留用于可以在单一代码库中的多个应用程序之间重用的共享库或模块。在我们的情况下,这是翻译
模块将驻留的地方。 -
apps/*
:专门用于容纳可能依赖于任何共享模块的应用程序。我们将放置使用translation
模块的 Nuxt 应用程序。
这里是文件的代码:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
现在,我们已经准备好设置我们将用于在其中包含自定义 Nuxt 模块的网站应用程序。
创建网站应用程序
要在单一代码库中正确设置我们的项目目录,首先创建一个apps
目录:
$ mkdir apps
$ cd apps
在apps
目录中,我们将使用 Nuxt 3 的最新版本创建一个新的 Nuxt 应用程序:
$ pnpm dlx nuxi@latest init demo-website
在初始化 Nuxt 应用程序后,您通常可以通过导航到demo-website
目录并运行pnpm dev
来启动应用程序。然而,利用由pnpm
工作空间提供的单一代码库设置的完整功能,我们可以简化此过程。首先,请确保apps/demo-website/package.json
文件中的项目名称设置适当,以反映我们的特定设置:
// apps/demo-website/package.json
{
"name": "demo-website"
// ...rest of the file
}
在更新项目名称后,你现在可以从单一代码库的根目录执行命令。这是通过使用pnpm
的--filter
选项来完成的,该选项针对特定的子项目。要从单一代码库根目录运行我们的 Nuxt 应用程序,请使用以下命令:
$ pnpm --filter demo-website dev
此命令告诉pnpm
将dev
脚本专门应用于demo-website
项目,从而允许您从中央位置无缝地管理和运行单一代码库中的多个项目。
进一步来说,您可以在根package.json
文件中创建一个自定义脚本,该脚本在幕后调用此命令:
// package.json
{
"scripts": {
"website:dev": "pnpm --filter demo-website dev"
},
}
然后,在根目录中运行以下命令:
$ pnpm website:dev
应用程序应该启动,你应该看到通常的欢迎页面。在我们的应用程序设置完成后,让我们继续创建一个翻译
模块。
开发自定义翻译模块
在我们继续创建自定义translation
模块的过程中,此过程的第一步是为我们的共享包设置一个专门的目录。导航到您的单仓库根目录,创建一个名为packages
的目录。此目录将托管所有我们的共享逻辑,包括新的translation
模块:
$ mkdir packages
$ cd packages
一旦进入packages
目录,我们将使用 Nuxt 的模块模板来启动我们的translation
模块:
$ pnpm dlx nuxi init -t module translation
此命令设置了一个新的模块,它包含 Nuxt 提供的启动模板,包括几个必要的目录和文件:
-
module.ts
:这是我们模块定义的核心文件。它作为入口点,定义了模块的配置和设置。 -
runtime/plugin.ts
:此文件作为示例插件。它是扩展模块以添加额外功能(如 Vue 插件和辅助函数)的地方。 -
playground/
:包含已安装我们的模块的 Nuxt 应用程序。此环境对于测试和演示模块的功能非常有用。 -
test/
:为模块编写测试的目录,以确保其功能性和稳定性。
让我们探索module.ts
文件,了解其结构和组件:
import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit'
export interface ModuleOptions {}
export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'my-module',
configKey: 'myModule',
},
defaults: {},
setup(_options, _nuxt) {
const resolver = createResolver(import.meta.url)
addPlugin(resolver.resolve('./runtime/plugin'))
},
})
此脚本概述了使用defineNuxtModule
的 Nuxt pnpm
模块的基本结构。meta
属性定义了模块的名称和配置键。setup
函数是添加模块特定逻辑的地方,例如注册插件、使用 Nuxt 钩子、添加自动导入目录,甚至扩展路由。
createResolver
函数有助于正确解析路径,确保添加任何 URL 时不会出现与路径解析相关的问题。
接下来,让我们在我们的应用程序中使用此模块。
在我们的应用程序中安装模块
首先,将新创建的模块目录从my-module
重命名为translation
,以更好地反映其用途。这涉及到在模块中更新名称:
// packages/translation/src/module.ts
export default defineNuxtModule<ModuleOptions>({ meta: {
name: 'translation',
configKey: 'translation'
}
// … rest of code
})
还需更新包配置中的名称:
// packages/translation/package.json
{
"name": "translation",
// Rest of configuration
}
然后,确保生成模块的构建文件。为此,请在translation
目录中运行以下命令:
模块根目录:
Packages/translation> $ pnpm dev:prepare
这为开发准备本地文件。
配置好模块后,您现在可以将它添加到我们的演示网站中。从您的单仓库根目录运行以下命令,将模块本地链接到您的应用程序:
$ pnpm --filter demo-website add --workspace translation
--workspace
标志告诉pnpm
从本地工作区解析翻译包,而不是从外部注册表获取。这种设置确保 Web 应用程序识别我们的包,并且模块的任何更改在开发期间都能立即提供给应用程序。
为了确保模块已成功添加,请检查您的演示网站中的package.json
文件:
// apps/demo-website/package.json
{
"dependencies": {
"nuxt": "³.11.2",
"translation": "workspace:^",
"vue": "³.4.21",
"vue-router": "⁴.3.0"
}
}
translation
依赖项现在应列出并指向您的本地工作区。
最后,将模块添加到您的 Nuxt 配置中,以在项目中激活它:
// apps/demo-website/nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['translation'],
})
然后,启动您的应用程序以查看模块的实际效果:
$ pnpm website:dev
查找模块默认设置的控制台输出或其他指示,以确认其正在运行:
图 8.1:翻译插件注入
如果此消息可见,则确认translation
模块的示例插件正在积极地向您的 Nuxt 应用程序注入功能。
完成这些步骤后,您的演示网站现在已成功整合了translation
模块。我们现在可以进一步自定义模块,并定义针对项目需求指定的特定选项。
配置翻译模块选项
在我们开始为我们的translation
模块添加选项之前,了解 Nuxt 模块通常如何提供配置灵活性是至关重要的。类似于官方 Nuxt i18n
模块通过 Nuxt 配置允许配置defaultLocale
和locales
,我们旨在在我们的自定义模块中提供类似的可配置性。这种设置将使用户能够通过模块的选项动态定义和管理区域设置。
在我们的translation
模块中,我们首先定义配置的预期选项。这涉及到在types.ts
中设置一个接口,概述配置选项的结构:
// packages/translation/src/types.ts
export type ModuleOptions = {
defaultLocale: string;
locales?: LocaleOption[];
};
export type LocaleOption = {
name: string;
file: string;
};
在这里,ModuleOptions
允许指定一个defaultLocale
实例和一个locales
实例数组,每个实例都有一个名称和一个指向翻译的文件路径。我们将在稍后讨论如何导入这些文件。
下一步是将这些类型集成到主模块文件中。将定义的类型导入到module.ts
中,并使用它们为模块的配置提供强类型。请确保删除空定义接口:
// module.ts
import type { ModuleOptions } from './types';
export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'translation',
configKey: 'translation',
},
defaults: {
defaultLocale: 'en',
locales: [],
},
setup(options, nuxt) {
// Module setup logic here
},
});
defaults
对象被更新,为defaultLocale
提供后备,并为区域设置提供一个空数组,确保即使没有提供特定配置,模块也可以初始化。
要充分利用模块的功能,将其添加到您的 Nuxt 应用程序的nuxt.config.ts
文件中,并指定如下选项:
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
modules: ['translation'],
translation: {},
})
在指定这些选项后,Nuxt 的智能配置处理应该为translation
选项提供自动完成建议,反映我们的模块选项与 Nuxt 生态系统的集成:
图 8.2:IntelliSense 翻译选项
当调整 Nuxt 配置文件中的设置时,您应该能够看到 IntelliSense 建议,以验证我们的模块选项的正确集成,确认 Nuxt 已识别设置。
在我们的模块中设置基本配置处理之后,接下来的任务是在模块内部实现逻辑,以根据提供的配置选项动态加载和应用指定的区域设置。
在模块内部读取本地文件
让我们将本地文件读取功能集成到我们的 Nuxt 模块中,以有效地处理翻译。此功能将使我们的模块能够动态加载模块配置中指定的翻译文件。
首先定义翻译文件的预期结构。假设每个文件都包含表示本地化字符串的扁平键值对。例如,在演示网站中设置英语和法语本地化文件:
-
英语本地化文件:
// apps/demo-website/locales/en.json { "welcome": "Welcome" }
-
法语本地化文件:
// apps/demo-website/locales/fr.json { "welcome": "Bienvenue" }
然后,修改你的 Nuxt 配置文件并将这些文件添加到locales
数组中:
// nuxt.config.ts
export default defineNuxtConfig({
// other options
translation: {
locales: [
{ name: 'en', file: 'locales/en.json' },
{ name: 'fr', file: 'locales/fr.json' },
],
},
})
现在,让我们回到模块来处理这些文件。首先定义消息的类型:
// packages/translation/types.ts
// other types
export type Messages = {
[key: string]: string;
};
记住,我们假设locales
翻译文件将是一个扁平的键值对。你可以处理嵌套对象,但为了简化过程,我们只会使用一个级别的键。
最后,更新你的模块的setup
函数以遍历区域设置,解析它们的路径,读取它们的内 容,然后将它们解析成可用的格式。以下是你可以这样做的方式:
// packages/translation/module.ts
import { readFileSync } from 'node:fs'
import { defineNuxtModule, createResolver } from '@nuxt/kit'
import type { Messages, ModuleOptions } from './types'
export default defineNuxtModule<ModuleOptions>({
meta: {
name: 'translation',
configKey: 'translation',
},
defaults: {
defaultLocale: 'en',
},
async setup(options, nuxt) {
const localesResolver =
createResolver(nuxt.options.srcDir)
const messages: Messages = {}
for (const locale of options.locales ?? []) {
const filePath = localesResolver.resolve(locale.file)
const fileContents = await readFileSync(filePath,
'utf-8')
const _messages = JSON.parse(fileContents)
messages[locale.name] = _messages
}
nuxt.options.runtimeConfig.public.translation = {
...options,
messages,
}
}
});
这里是一个代码分解:
-
localesResolver
:解析相对于项目源目录的路径,该路径存储在nuxt.options.srcDir
-
readFileSync
:同步读取解析路径的文件内容 -
JSON.parse
:将文件中的 JSON 字符串转换为 JavaScript 对象 -
nuxt.options.runtimeConfig.public.translation
:在 Nuxt 运行时配置中存储消息,通过useRuntimeConfig()
可组合的组件在整个应用程序中访问
为了验证集成,修改主应用程序组件以显示加载的消息:
<!-- apps/demo-website/app.vue -->
<template>
<div>{{ translation }}</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig();
const translation = config.public.translation;
</script>
重新启动你的 Nuxt 应用程序并导航到主页。你现在应该看到显示的翻译选项以及本地化消息:
图 8.3:显示翻译数组输出
现在我们模块可以加载翻译消息,我们将通过添加一个插件来增强其功能,创建一个全局辅助函数。这个函数将允许我们轻松地在 Nuxt 应用程序中检索和显示翻译字符串。
通过插件、可组合的组件和组件扩展模块
我们将首先开发一个可组合的组件来管理用户的偏好语言。这个可组合的组件将帮助从 cookie 中检索正确的区域设置,或者默认为模块选项中配置的区域设置。
在模块内部,为可组合的组件创建一个新文件:runtime/composables/useTranslation.ts
。
按照以下方式开发可组合函数:
import { computed, useCookie, useRuntimeConfig } from '#imports'
export default () => {
const config = useRuntimeConfig()
const translation = config.public.translation
const locale = useCookie('defaultLocale')
const locales = translation.locales
if (!locale.value) locale.value =
translation.defaultLocale
const messages = computed(() => {
const key = locale.value || translation.defaultLocale
return translation.messages[key]
})
return { locale, locales, messages }
}
这里是一个代码分解:
-
useRuntimeConfig
:访问运行时配置,包括翻译设置 -
useCookie
:一个管理 cookie 值的ref
:get
–set
,特别是用于存储用户的区域偏好 -
computed
:根据当前区域设置反应性地计算要使用正确的消息
注意
在开发 Nuxt 模块时,显式地从#imports
导入任何默认在 Nuxt 应用中自动导入的函数或组合式是至关重要的。这种方法确保了模块可以利用 Nuxt 的自动导入功能,而该功能在模块的作用域中并不固有,就像在 Nuxt 应用中那样。
为了确保我们的组合式在 Nuxt 应用中易于访问,我们将自动化其导入。更新module.ts
文件以自动导入composables
目录:
import { defineNuxtModule, createResolver, addImportsDir } from '@nuxt/kit';
export default defineNuxtModule({
meta: {
name: 'translation',
configKey: 'translation',
},
setup(options, nuxt) {
// Existing setup code...
const resolver = createResolver(import.meta.url);
addImportsDir(resolver.resolve('runtime/composables'));
}
});
addImportsDir
自动从指定的目录导入文件,使得组合式可以轻松地供 Nuxt 应用使用,无需手动import
语句。因此,这个文件夹将完全像 Nuxt 应用内部的composables
文件夹一样工作!
组合式准备就绪后,让我们测试其功能。按照以下方式更新app.vue
:
<template>
{{ messages.welcome }}
</template>
<script setup lang="ts">
const { messages } = useTranslation();
</script>
这种设置应该根据默认或用户定义的区域显示欢迎消息。因为我们没有更新默认区域,所以模块将使用'en'
作为默认值,因为它在模块文件中已配置:
图 8.4:基于默认区域的消息数组
现在,让我们确保我们的模块可以尊重 Nuxt 配置中指定的区域设置覆盖:
// nuxt.config.ts
export default defineNuxtConfig({
translation: {
defaultLocale: 'fr',
locales: [
{ name: 'en', file: 'locales/en.json' },
{ name: 'fr', file: 'locales/fr.json' }
]
}
});
通过将defaultLocale
设置为'fr'
并在私有窗口中访问应用(以清除之前的 cookie),应该出现法语翻译,这证明了我们的translation
模块的灵活性和动态能力:
图 8.5:基于默认区域的消息数组
现在,让我们继续前进,通过一个插件增强模块,该插件提供了一个全局的$t 函数,用于直接获取翻译消息。这将简化在整个应用中使用翻译。
创建一个$t 辅助函数
首先,我们在模块的runtime/plugins
目录内创建一个新的translate.ts
文件。这个文件将包含我们翻译函数的逻辑。以下是编写用于获取翻译的插件的步骤:
import useTranslation from '../composables/useTranslation'
import { defineNuxtPlugin } from '#imports'
export default defineNuxtPlugin(async () => {
const { messages } = useTranslation()
// Translator function
const t = (key: string) => {
return messages.value[key] || key // Return the
translated string
or key if not
found
}
return {
provide: { t }
}
})
下面是代码分解:
-
我们正在导入
useTranslation
,它管理翻译状态并根据当前区域提供翻译。 -
我们正在使用
defineNuxtPlugin
定义插件。这里的#imports
别名用于自动解析到 Nuxt 提供的正确工具版本。 -
我们正在实现一个
t
翻译函数,它接受一个键作为参数。它尝试从useTranslation
获取的messages
对象中检索该键的翻译。如果该键不存在翻译,则默认回退到该键本身。 -
我们通过在插件定义函数的末尾添加
return { provide: { t } }
来提供Translator
作为全局辅助函数。通过提供t
,应用程序中的任何组件都可以使用此函数来使用$t
渲染翻译文本。Nuxt 自动将$
添加到由 nuxt 模块提供的任何函数中,以便全局访问。
接下来,通过更新 module.ts
文件将此插件集成到我们的 Nuxt 模块中:
import {
defineNuxtModule,
addPlugin,
createResolver,
addImportsDir,
} from '@nuxt/kit'
export default defineNuxtModule({
setup(_options, nuxt) {
// …previous setup
addPlugin(resolver.resolve('./runtime/plugins/translate'))
},
})
最后,更新您的应用程序的主要组件 app.vue
,以使用 $t
函数:
<template>
<div>{{ $t('welcome') }}</div>
</template>
一旦实现,通过运行应用程序并导航来测试。您应该看到基于活动区域设置的翻译字符串的渲染:
图 8.6:$t 函数输出
对于最终的扩展,我们将创建一个允许用户直接从他们的网络界面切换语言的组件。
设置语言切换组件
我们将使用来自 @nuxt/ui
包的菜单组件创建一个语言切换组件。首先,在我们的模块范围内安装 @nuxt/ui
包。从项目的根目录运行以下命令:
$ pnpm --filter translation add @nuxt/ui
在 module.ts
文件中,验证并安装 @nuxt/ui
(如果它尚未存在于宿主应用程序中),并确保自动导入新的 components
目录。Nuxt Kit 提供了各种辅助函数来实现这一点:
-
hasNuxtModule
:检查@nuxt/ui
是否已安装在宿主应用程序中 -
installModule
:如果未找到,则动态安装@nuxt/ui
-
addComponentsDir
:将包含我们的自定义组件的目录添加到 Nuxt 的自动导入功能中,允许这些组件无需手动导入
这是 module.ts
设置函数的更新版本:
export default defineNuxtModule({
async setup(_options, nuxt) {
// other configuration
if (!hasNuxtModule('@nuxt/ui')) {
await installModule('@nuxt/ui')
}
const resolver = createResolver(import.meta.url)
addComponentsDir({
path: resolver.resolve('runtime/components')
})
}
})
现在,在 runtime/components
目录中创建一个新的 LanguageSwitcher.vue
组件。此组件将利用来自 @nuxt/ui
的 USelectMenu
UI 组件来渲染语言选择的下拉菜单:
<template>
<USelectMenu
v-model="locale"
:options="locales"
value-attribute="name"
option-attribute="name"
/>
</template>
<script setup lang="ts">
import useTranslation from '../composables/useTranslation'
const { locale, locales } = useTranslation()
</script>
下面是对组件的解释:
-
USelectMenu
:来自@nuxt/ui
的 UI 组件,用于渲染下拉菜单。它绑定到locale
响应变量,并根据用户选择更新它。 -
locales
:一个包含可用语言的数组,用于填充下拉选项。
为了确保 LanguageSwitcher
组件正常工作,运行以下命令:
$ pnpm --filter translation dev:prepare
这将使用新组件准备模块。然后,更新您的 Nuxt 应用程序中的 app.vue
文件以使用 LanguageSwitcher
组件:
<!-- apps/demo-website/app.vue -->
<template>
<div>{{ $t('welcome') }}</div>
<LanguageSwitcher />
</template>
刷新您的浏览器以测试功能。您应该看到一个欢迎消息以及包含两个区域设置(en
和 fr
)的选择菜单。使用下拉菜单更改语言应该会动态更新欢迎消息,从而演示应用程序中的响应式翻译更新:
图 8.7:LanguageSelector 组件
注意,我们设法在模块中使用了 @nuxt/ui
包,而无需在宿主应用程序中直接安装,这展示了 Nuxt 模块的灵活性。我们仍然可以在宿主应用程序中安装此包,并且由于 hasNuxtModule
检查函数,这不会导致任何错误。
现在我们已经完成了在 Nuxt 3 中实现自定义模块,让我们展望一下如何在现实世界的应用中进一步精炼和扩展这些概念。
未来方向
在我们结束这一章时,重要的是反思指导我们通过在单仓库结构中构建自定义 i18n
模块之旅的潜在原则。重点不在于应用程序的美学,而在于架构——特别是模块系统的创建。这种方法对于需要高效管理复杂性的大型项目尤其有益。
POS 系统示例
在现实世界的场景中,尤其是在企业环境中,应用程序很少简单。它们通常由许多相互连接的部分组成,例如销售点(POS)系统,这可能包括处理订单、促销、客户管理和更多模块。每个模块都可以设计为独立运行,包含自己的页面、逻辑、组件和状态管理。
在这样的系统中,不同的模块可以独立开发和维护。例如,一个促销模块可能处理所有促销活动和折扣逻辑。如果企业决定彻底改革其促销策略,只需更新或替换促销模块,从而最小化对系统其他部分的风险和干扰。
电子商务平台示例
模块化系统在复杂的电子商务平台上特别有益,在这些平台上,如产品目录管理、订单处理、支付集成和用户资料等不同功能是基本且独立的组件。每个模块都可以单独开发、测试和部署,从而实现灵活的更新和可扩展性。
例如,支付集成模块可能支持各种支付网关并处理所有交易复杂性。如果需要添加新的支付方式或由于监管变化需要对现有支付方式进行更新,开发者可以仅关注此模块。这种模块化方法加快了开发和部署速度,并确保一个区域的更新,如支付处理,不会无意中影响产品目录或用户管理系统等无关部分。
最后的想法
本章的目标是强调模块化架构对大型应用程序开发和可扩展性的变革性影响。通过采用模块化方法,开发者可以有效地管理复杂系统,促进更轻松的更新、测试和扩展。向前看,将这里探索的策略应用于有效地构建项目。
此外,这次旅程突出了 Nuxt 提供的优秀开发者体验,它简化了自定义模块的创建。Nuxt 的框架支持广泛的定制,允许无缝集成选项、组件、插件和组合式组件。这种灵活性确保我们的应用程序满足当前需求,同时也为未来的进步和集成做好了充分准备。
摘要
在本章中,我们重点介绍了在 Nuxt 3 单体仓库中创建自定义translation
模块的过程,该模块旨在简化大规模项目的管理。我们首先使用pnpm
建立单体仓库设置,这使得我们可以将项目结构化为相互关联但独立的多个工作空间。这个基础支持了我们translation
模块的开发,从基本的 Nuxt 模块模板开始。通过配置模块选项,我们定制了模块以处理多种语言。
在进一步增强模块的过程中,我们集成了插件、组件和组合式组件,提供了一种动态且用户友好的方式来切换语言和管理翻译。
这种实际应用展示了模块化架构如何增强代码重用,同时简化增强和可扩展性。关于未来方向的结论性讨论探讨了在其他领域(如电子商务或企业系统)中我们可以如何使用这种模块化方法,展示了所学技术的广泛适用性和灵活性。
本章为希望在其项目中充分利用 Nuxt 模块化能力的开发者提供了一个蓝图。
实践问题
-
描述为 Nuxt 3 项目设置
pnpm
单体仓库的过程。 -
使用单体仓库结构在大型 Nuxt 3 项目中有哪些关键好处?
-
如何在单体仓库中初始化一个新的 Nuxt 3 模块?
-
你如何在同一单体仓库中向 Nuxt 应用程序添加 Nuxt 模块?
-
addPlugin
函数在 Nuxt 模块中做什么? -
runtimeConfig
在 Nuxt 模块中扮演什么角色? -
描述在 Nuxt 模块中使用
createResolver
的目的。 -
解释
hasNuxtModule
和installModule
函数在模块设置中的使用方式。 -
如何在 Nuxt 模块中添加和使用组合式组件?
-
你如何配置 Nuxt 模块,以便在模块在项目中使用时自动从指定目录导入组件?
进一步阅读
-
pnpm
工作空间:pnpm.io/workspaces
-
Nuxt 生命周期钩子:
nuxt.com/docs/guide/going-further/hooks
-
Nuxt 自动导入概念:
nuxt.com/docs/guide/concepts/auto-imports