编程题✅
本主题涵盖 Nuxt 相关的编程实践题,包括组件开发、状态管理、API 集成等实际开发场景。
实现一个支持 SSR 的用户列表组件
答案
题目要求
实现一个支持服务端渲染的用户列表组件,要求:
- 支持分页加载
- 支持搜索功能
- 支持 SSR 和客户端渲染
- 使用 TypeScript
实现方案
<!-- components/UserList.vue -->
<template>
<div class="user-list">
<!-- 搜索框 -->
<div class="search-section">
<input
v-model="searchQuery"
type="text"
placeholder="搜索用户..."
class="search-input"
@input="debouncedSearch"
/>
</div>
<!-- 用户列表 -->
<div class="user-grid">
<div
v-for="user in users"
:key="user.id"
class="user-card"
>
<img :src="user.avatar" :alt="user.name" class="avatar" />
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span class="role">{{ user.role }}</span>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
加载中...
</div>
<!-- 分页 -->
<div v-if="hasMore" class="pagination">
<button
@click="loadMore"
:disabled="loading"
class="load-more-btn"
>
{{ loading ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
interface User {
id: number
name: string
email: string
avatar: string
role: string
}
interface UserListProps {
initialUsers?: User[]
initialPage?: number
initialSearch?: string
}
const props = withDefaults(defineProps<UserListProps>(), {
initialUsers: () => [],
initialPage: 1,
initialSearch: ''
})
// 响应式数据
const users = ref<User[]>(props.initialUsers)
const loading = ref(false)
const hasMore = ref(true)
const currentPage = ref(props.initialPage)
const searchQuery = ref(props.initialSearch)
// 防抖搜索
const debouncedSearch = debounce(async () => {
await searchUsers()
}, 300)
// 搜索用户
const searchUsers = async () => {
loading.value = true
currentPage.value = 1
users.value = []
try {
const { data } = await $fetch('/api/users', {
query: {
page: currentPage.value,
search: searchQuery.value
}
})
users.value = data.users
hasMore.value = data.hasMore
} catch (error) {
console.error('搜索用户失败:', error)
} finally {
loading.value = false
}
}
// 加载更多
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
currentPage.value++
try {
const { data } = await $fetch('/api/users', {
query: {
page: currentPage.value,
search: searchQuery.value
}
})
users.value.push(...data.users)
hasMore.value = data.hasMore
} catch (error) {
console.error('加载更多失败:', error)
currentPage.value-- // 回滚页码
} finally {
loading.value = false
}
}
// 服务端渲染时预加载数据
if (process.server && props.initialUsers.length === 0) {
await searchUsers()
}
</script>
<style scoped>
.user-list {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.search-section {
margin-bottom: 2rem;
}
.search-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
font-size: 1rem;
}
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.user-card {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
text-align: center;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
margin-bottom: 1rem;
}
.role {
display: inline-block;
background: #f0f0f0;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
margin-top: 0.5rem;
}
.loading {
text-align: center;
padding: 2rem;
color: #666;
}
.pagination {
text-align: center;
}
.load-more-btn {
background: #007bff;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 1rem;
}
.load-more-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
API 路由实现
// server/api/users.get.ts
export default defineEventHandler(async (event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const search = query.search as string || ''
const limit = 10
// 模拟数据库查询
const users = await getUserList(page, limit, search)
return {
users: users.data,
hasMore: users.hasMore,
total: users.total
}
})
async function getUserList(page: number, limit: number, search: string) {
// 模拟数据
const allUsers = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
name: `用户${i + 1}`,
email: `user${i + 1}@example.com`,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${i + 1}`,
role: ['管理员', '用户', '编辑'][i % 3]
}))
// 搜索过滤
const filteredUsers = search
? allUsers.filter(user =>
user.name.includes(search) ||
user.email.includes(search)
)
: allUsers
// 分页
const start = (page - 1) * limit
const end = start + limit
const data = filteredUsers.slice(start, end)
return {
data,
hasMore: end < filteredUsers.length,
total: filteredUsers.length
}
}
使用示例
<!-- pages/users.vue -->
<template>
<div>
<h1>用户管理</h1>
<UserList />
</div>
</template>
<script setup>
// 设置页面元数据
definePageMeta({
title: '用户管理',
description: '用户列表页面'
})
</script>
面试官视角
该题考察候选人对 Nuxt SSR 的理解,包括组件设计、状态管理、API 集成等综合能力。通过此题可以评估候选人的实际开发经验。
延伸阅读
实现一个支持深色模式的 Nuxt 应用
答案
题目要求
实现一个支持深色模式的 Nuxt 应用,要求:
- 支持系统主题检测
- 支持手动切换主题
- 主题状态持久化
- 平滑过渡动画
实现方案
-
创建主题模块
// modules/theme-switcher.ts
import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit'
export default defineNuxtModule({
meta: {
name: 'theme-switcher',
configKey: 'themeSwitcher'
},
defaults: {
themes: ['light', 'dark'],
defaultTheme: 'light',
storageKey: 'theme',
enableSystemTheme: true
},
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
// 添加插件
addPlugin({
src: resolver.resolve('./runtime/plugin.client.ts'),
options
})
// 添加组合式函数
addImports({
name: 'useTheme',
from: resolver.resolve('./runtime/composables/useTheme.ts')
})
}
}) -
主题组合式函数
// runtime/composables/useTheme.ts
export const useTheme = () => {
const config = useRuntimeConfig()
const { themes, defaultTheme, storageKey, enableSystemTheme } = config.public.themeSwitcher
const currentTheme = ref(defaultTheme)
const systemTheme = ref<'light' | 'dark'>('light')
// 检测系统主题
const detectSystemTheme = () => {
if (process.client && enableSystemTheme) {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemTheme.value = mediaQuery.matches ? 'dark' : 'light'
// 监听系统主题变化
mediaQuery.addEventListener('change', (e) => {
systemTheme.value = e.matches ? 'dark' : 'light'
if (!localStorage.getItem(storageKey)) {
applyTheme(systemTheme.value)
}
})
}
}
// 应用主题
const applyTheme = (theme: string) => {
if (process.client) {
document.documentElement.setAttribute('data-theme', theme)
currentTheme.value = theme
}
}
// 设置主题
const setTheme = (theme: string) => {
if (themes.includes(theme)) {
applyTheme(theme)
localStorage.setItem(storageKey, theme)
}
}
// 切换主题
const toggleTheme = () => {
const newTheme = currentTheme.value === 'light' ? 'dark' : 'light'
setTheme(newTheme)
}
// 初始化主题
const initTheme = () => {
if (process.client) {
const savedTheme = localStorage.getItem(storageKey)
if (savedTheme && themes.includes(savedTheme)) {
setTheme(savedTheme)
} else if (enableSystemTheme) {
setTheme(systemTheme.value)
} else {
setTheme(defaultTheme)
}
}
}
return {
currentTheme: readonly(currentTheme),
systemTheme: readonly(systemTheme),
setTheme,
toggleTheme,
initTheme,
detectSystemTheme
}
} -
主题插件
// runtime/plugin.client.ts
export default defineNuxtPlugin(() => {
const { initTheme, detectSystemTheme } = useTheme()
// 初始化主题
initTheme()
detectSystemTheme()
}) -
主题切换组件
<!-- components/ThemeSwitcher.vue -->
<template>
<button
@click="toggleTheme"
class="theme-switcher"
:aria-label="`切换到${currentTheme === 'light' ? '深色' : '浅色'}模式`"
>
<Icon
:name="currentTheme === 'light' ? 'mdi:weather-night' : 'mdi:weather-sunny'"
class="theme-icon"
/>
<span class="theme-text">
{{ currentTheme === 'light' ? '深色模式' : '浅色模式' }}
</span>
</button>
</template>
<script setup>
const { currentTheme, toggleTheme } = useTheme()
</script>
<style scoped>
.theme-switcher {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--border-color);
border-radius: 0.5rem;
background: var(--bg-color);
color: var(--text-color);
cursor: pointer;
transition: all 0.3s ease;
}
.theme-switcher:hover {
background: var(--hover-color);
}
.theme-icon {
width: 1.25rem;
height: 1.25rem;
}
.theme-text {
font-size: 0.875rem;
}
</style> -
CSS 变量定义
/* assets/css/theme.css */
:root {
/* 浅色主题 */
--bg-color: #ffffff;
--text-color: #333333;
--border-color: #e5e5e5;
--hover-color: #f5f5f5;
--primary-color: #007bff;
--secondary-color: #6c757d;
--success-color: #28a745;
--warning-color: #ffc107;
--danger-color: #dc3545;
}
[data-theme="dark"] {
/* 深色主题 */
--bg-color: #1a1a1a;
--text-color: #ffffff;
--border-color: #333333;
--hover-color: #2a2a2a;
--primary-color: #0d6efd;
--secondary-color: #6c757d;
--success-color: #198754;
--warning-color: #fd7e14;
--danger-color: #dc3545;
}
/* 平滑过渡 */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
} -
应用配置
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'./modules/theme-switcher'
],
css: ['~/assets/css/theme.css'],
themeSwitcher: {
themes: ['light', 'dark'],
defaultTheme: 'light',
storageKey: 'theme',
enableSystemTheme: true
}
}) -
使用示例
<!-- layouts/default.vue -->
<template>
<div class="layout">
<header class="header">
<nav class="nav">
<NuxtLink to="/">首页</NuxtLink>
<NuxtLink to="/about">关于</NuxtLink>
</nav>
<ThemeSwitcher />
</header>
<main class="main">
<slot />
</main>
<footer class="footer">
<p>© 2024 My App</p>
</footer>
</div>
</template>
<style scoped>
.layout {
min-height: 100vh;
background: var(--bg-color);
color: var(--text-color);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color);
}
.nav {
display: flex;
gap: 2rem;
}
.nav a {
color: var(--text-color);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
transition: background-color 0.3s ease;
}
.nav a:hover {
background: var(--hover-color);
}
.main {
padding: 2rem;
}
.footer {
text-align: center;
padding: 2rem;
border-top: 1px solid var(--border-color);
}
</style>
面试官视角
该题考察候选人对 Nuxt 模块开发、主题系统设计的理解,是构建用户体验良好的应用的重要技能。通过此题可以评估候选人的系统设计能力。
延伸阅读
实现一个支持实时通信的聊天应用
答案
题目要求
实现一个支持实时通信的聊天应用,要求:
- 支持 WebSocket 连接
- 支持消息发送和接收
- 支持在线用户列表
- 支持消息历史记录
实现方案
-
WebSocket 服务端
// server/api/chat/socket.ts
import { Server } from 'socket.io'
import { createServer } from 'http'
export default defineEventHandler(async (event) => {
const server = createServer()
const io = new Server(server, {
cors: {
origin: "*",
methods: ["GET", "POST"]
}
})
// 存储在线用户
const onlineUsers = new Map()
io.on('connection', (socket) => {
console.log('用户连接:', socket.id)
// 用户加入
socket.on('join', (userData) => {
onlineUsers.set(socket.id, userData)
socket.broadcast.emit('userJoined', userData)
io.emit('onlineUsers', Array.from(onlineUsers.values()))
})
// 发送消息
socket.on('sendMessage', (messageData) => {
const user = onlineUsers.get(socket.id)
if (user) {
const message = {
id: Date.now(),
content: messageData.content,
user: user,
timestamp: new Date().toISOString()
}
// 广播消息
io.emit('newMessage', message)
// 保存到数据库
saveMessage(message)
}
})
// 用户断开连接
socket.on('disconnect', () => {
const user = onlineUsers.get(socket.id)
if (user) {
onlineUsers.delete(socket.id)
socket.broadcast.emit('userLeft', user)
io.emit('onlineUsers', Array.from(onlineUsers.values()))
}
})
})
server.listen(3001)
})
// 保存消息到数据库
async function saveMessage(message: any) {
// 这里可以保存到数据库
console.log('保存消息:', message)
} -
聊天组合式函数
// composables/useChat.ts
export const useChat = () => {
const socket = ref(null)
const messages = ref([])
const onlineUsers = ref([])
const currentUser = ref(null)
const isConnected = ref(false)
// 连接 WebSocket
const connect = (userData: any) => {
if (process.client) {
socket.value = new WebSocket('ws://localhost:3001')
socket.value.onopen = () => {
isConnected.value = true
currentUser.value = userData
// 发送加入消息
socket.value.send(JSON.stringify({
type: 'join',
data: userData
}))
}
socket.value.onmessage = (event) => {
const message = JSON.parse(event.data)
switch (message.type) {
case 'newMessage':
messages.value.push(message.data)
break
case 'userJoined':
console.log('用户加入:', message.data)
break
case 'userLeft':
console.log('用户离开:', message.data)
break
case 'onlineUsers':
onlineUsers.value = message.data
break
}
}
socket.value.onclose = () => {
isConnected.value = false
console.log('WebSocket 连接关闭')
}
socket.value.onerror = (error) => {
console.error('WebSocket 错误:', error)
}
}
}
// 发送消息
const sendMessage = (content: string) => {
if (socket.value && isConnected.value) {
socket.value.send(JSON.stringify({
type: 'sendMessage',
data: { content }
}))
}
}
// 断开连接
const disconnect = () => {
if (socket.value) {
socket.value.close()
socket.value = null
isConnected.value = false
}
}
// 获取消息历史
const loadMessages = async () => {
try {
const { data } = await $fetch('/api/chat/messages')
messages.value = data
} catch (error) {
console.error('加载消息失败:', error)
}
}
return {
socket: readonly(socket),
messages: readonly(messages),
onlineUsers: readonly(onlineUsers),
currentUser: readonly(currentUser),
isConnected: readonly(isConnected),
connect,
sendMessage,
disconnect,
loadMessages
}
} -
聊天组件
<!-- components/ChatRoom.vue -->
<template>
<div class="chat-room">
<!-- 在线用户 -->
<div class="online-users">
<h3>在线用户 ({{ onlineUsers.length })}</h3>
<div class="user-list">
<div
v-for="user in onlineUsers"
:key="user.id"
class="user-item"
>
<img :src="user.avatar" :alt="user.name" class="avatar" />
<span>{{ user.name }}</span>
</div>
</div>
</div>
<!-- 消息列表 -->
<div class="messages" ref="messagesContainer">
<div
v-for="message in messages"
:key="message.id"
class="message"
:class="{ 'own-message': message.user.id === currentUser?.id }"
>
<img :src="message.user.avatar" :alt="message.user.name" class="avatar" />
<div class="message-content">
<div class="message-header">
<span class="username">{{ message.user.name }}</span>
<span class="timestamp">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="message-text">{{ message.content }}</div>
</div>
</div>
</div>
<!-- 消息输入 -->
<div class="message-input">
<input
v-model="newMessage"
type="text"
placeholder="输入消息..."
@keyup.enter="sendMessage"
:disabled="!isConnected"
/>
<button
@click="sendMessage"
:disabled="!newMessage.trim() || !isConnected"
>
发送
</button>
</div>
</div>
</template>
<script setup>
const { messages, onlineUsers, currentUser, isConnected, sendMessage: sendChatMessage } = useChat()
const newMessage = ref('')
const messagesContainer = ref(null)
// 发送消息
const sendMessage = () => {
if (newMessage.value.trim()) {
sendChatMessage(newMessage.value)
newMessage.value = ''
}
}
// 格式化时间
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString()
}
// 自动滚动到底部
watch(messages, () => {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
})
</script>
<style scoped>
.chat-room {
display: flex;
height: 600px;
border: 1px solid #ddd;
border-radius: 0.5rem;
overflow: hidden;
}
.online-users {
width: 200px;
background: #f5f5f5;
padding: 1rem;
border-right: 1px solid #ddd;
}
.user-list {
margin-top: 1rem;
}
.user-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 0.25rem;
}
.user-item:hover {
background: #e9ecef;
}
.messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1rem;
}
.message {
display: flex;
gap: 0.75rem;
}
.own-message {
flex-direction: row-reverse;
}
.message-content {
flex: 1;
}
.message-header {
display: flex;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.username {
font-weight: 600;
}
.timestamp {
font-size: 0.75rem;
color: #666;
}
.message-text {
background: #f8f9fa;
padding: 0.75rem;
border-radius: 0.5rem;
}
.own-message .message-text {
background: #007bff;
color: white;
}
.message-input {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #ddd;
}
.message-input input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
}
.message-input button {
padding: 0.75rem 1.5rem;
background: #007bff;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.message-input button:disabled {
background: #ccc;
cursor: not-allowed;
}
</style> -
聊天页面
<!-- pages/chat.vue -->
<template>
<div class="chat-page">
<h1>聊天室</h1>
<div v-if="!isConnected" class="login-form">
<h2>加入聊天室</h2>
<form @submit.prevent="joinChat">
<input
v-model="userForm.name"
type="text"
placeholder="用户名"
required
/>
<input
v-model="userForm.email"
type="email"
placeholder="邮箱"
required
/>
<button type="submit">加入</button>
</form>
</div>
<div v-else class="chat-container">
<ChatRoom />
<button @click="disconnect" class="disconnect-btn">
离开聊天室
</button>
</div>
</div>
</template>
<script setup>
const { connect, disconnect, isConnected, loadMessages } = useChat()
const userForm = ref({
name: '',
email: ''
})
// 加入聊天
const joinChat = () => {
const userData = {
id: Date.now(),
name: userForm.value.name,
email: userForm.value.email,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${userForm.value.name}`
}
connect(userData)
loadMessages()
}
// 页面卸载时断开连接
onUnmounted(() => {
disconnect()
})
</script>
<style scoped>
.chat-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.login-form {
max-width: 400px;
margin: 2rem auto;
padding: 2rem;
border: 1px solid #ddd;
border-radius: 0.5rem;
}
.login-form form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.login-form input {
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 0.25rem;
}
.login-form button {
padding: 0.75rem;
background: #007bff;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.chat-container {
margin-top: 2rem;
}
.disconnect-btn {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: #dc3545;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
</style>
面试官视角
该题考察候选人对实时通信、WebSocket 技术的理解,是构建现代 Web 应用的重要技能。通过此题可以评估候选人的全栈开发能力。
延伸阅读