Skip to content

企业级前端场景题集合

本文收集了企业开发中常见的前端场景题,主要以 Vue 3 Composition API 视角实现,涵盖不同类型、功能和方向的实际问题。

1. 防止重复请求/按钮重复点击

需求分析

在表单提交、数据保存等场景中,用户可能因网络延迟、误操作等原因重复点击按钮,导致多次发送相同请求,引发数据不一致或性能问题。

实现方案

创建一个通用的 useThrottleFn Hook 来限制函数的执行频率:

vue
<template>
  <button @click="handleSubmit" :disabled="loading">
    {{ loading ? '提交中...' : '提交' }}
  </button>
</template>

<script setup>
import { ref } from 'vue'
import { useThrottleFn } from './hooks'

const loading = ref(false)

const submitForm = async () => {
  if (loading.value) return
  loading.value = true
  
  try {
    // 模拟 API 请求
    await new Promise(resolve => setTimeout(resolve, 1000))
    console.log('表单提交成功')
  } catch (error) {
    console.error('表单提交失败', error)
  } finally {
    loading.value = false
  }
}

// 使用节流 Hook,限制 2 秒内只能执行一次
const handleSubmit = useThrottleFn(submitForm, 2000)
</script>
javascript
// hooks.js
import { ref } from 'vue'

export function useThrottleFn(fn, delay) {
  const lastCall = ref(0)
  
  return function(...args) {
    const now = Date.now()
    if (now - lastCall.value < delay) {
      return
    }
    lastCall.value = now
    return fn.apply(this, args)
  }
}

进阶方案:结合 Axios 拦截器

javascript
// api.js
import axios from 'axios'

const pendingRequests = new Map()

const generateKey = (config) => {
  return `${config.method}-${config.url}-${JSON.stringify(config.params)}-${JSON.stringify(config.data)}`
}

// 请求拦截器
axios.interceptors.request.use(config => {
  const key = generateKey(config)
  
  // 如果请求已存在,取消之前的请求
  if (pendingRequests.has(key)) {
    pendingRequests.get(key).cancel()
  }
  
  // 创建新的取消令牌
  const source = axios.CancelToken.source()
  config.cancelToken = source.token
  pendingRequests.set(key, source)
  
  return config
}, error => {
  return Promise.reject(error)
})

// 响应拦截器
axios.interceptors.response.use(response => {
  const key = generateKey(response.config)
  pendingRequests.delete(key)
  return response
}, error => {
  if (axios.isCancel(error)) {
    console.log('请求已取消:', error.message)
  } else {
    const key = generateKey(error.config)
    pendingRequests.delete(key)
  }
  return Promise.reject(error)
})

export default axios

2. 无限滚动加载

需求分析

在列表页中,为了提升性能和用户体验,需要实现滚动到底部自动加载更多数据的功能。

实现方案

vue
<template>
  <div class="infinite-scroll-container" ref="containerRef" @scroll="handleScroll">
    <div v-for="item in list" :key="item.id" class="list-item">
      {{ item.content }}
    </div>
    <div v-if="loading" class="loading">加载中...</div>
    <div v-if="noMore" class="no-more">没有更多数据了</div>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue'

const containerRef = ref(null)
const list = ref([])
const page = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const noMore = ref(false)

const hasMore = computed(() => {
  return !loading.value && !noMore.value
})

const loadData = async () => {
  if (!hasMore.value) return
  
  loading.value = true
  try {
    // 模拟 API 请求
    await new Promise(resolve => setTimeout(resolve, 1000))
    const newData = Array.from({ length: pageSize.value }, (_, i) => ({
      id: (page.value - 1) * pageSize.value + i + 1,
      content: `第 ${page.value} 页,第 ${i + 1} 条数据`
    }))
    
    list.value = [...list.value, ...newData]
    page.value++
    
    // 模拟数据加载完毕
    if (page.value > 5) {
      noMore.value = true
    }
  } catch (error) {
    console.error('加载数据失败', error)
  } finally {
    loading.value = false
  }
}

const handleScroll = () => {
  if (!containerRef.value || !hasMore.value) return
  
  const { scrollTop, clientHeight, scrollHeight } = containerRef.value
  // 当滚动到底部 50px 内时加载更多
  if (scrollHeight - scrollTop - clientHeight < 50) {
    loadData()
  }
}

onMounted(() => {
  loadData()
})
</script>

<style scoped>
.infinite-scroll-container {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ccc;
}

.list-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.loading, .no-more {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

3. 表单验证与提交

需求分析

实现一个包含多种输入类型的表单,支持实时验证、错误提示和表单提交功能。

实现方案

vue
<template>
  <form @submit.prevent="handleSubmit">
    <div class="form-item">
      <label>用户名</label>
      <input v-model="formData.username" @blur="validateField('username')" />
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
    
    <div class="form-item">
      <label>邮箱</label>
      <input v-model="formData.email" @blur="validateField('email')" />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
    
    <div class="form-item">
      <label>密码</label>
      <input type="password" v-model="formData.password" @blur="validateField('password')" />
      <span v-if="errors.password" class="error">{{ errors.password }}</span>
    </div>
    
    <button type="submit" :disabled="loading">提交</button>
  </form>
</template>

<script setup>
import { ref, reactive } from 'vue'

const formData = reactive({
  username: '',
  email: '',
  password: ''
})

const errors = reactive({
  username: '',
  email: '',
  password: ''
})

const loading = ref(false)

const validateField = (field) => {
  switch (field) {
    case 'username':
      if (!formData.username.trim()) {
        errors.username = '用户名不能为空'
      } else if (formData.username.length < 3) {
        errors.username = '用户名至少3个字符'
      } else {
        errors.username = ''
      }
      break
    case 'email':
      if (!formData.email.trim()) {
        errors.email = '邮箱不能为空'
      } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
        errors.email = '邮箱格式不正确'
      } else {
        errors.email = ''
      }
      break
    case 'password':
      if (!formData.password) {
        errors.password = '密码不能为空'
      } else if (formData.password.length < 6) {
        errors.password = '密码至少6个字符'
      } else {
        errors.password = ''
      }
      break
  }
}

const validateForm = () => {
  let isValid = true
  Object.keys(formData).forEach(field => {
    validateField(field)
    if (errors[field]) {
      isValid = false
    }
  })
  return isValid
}

const handleSubmit = async () => {
  if (!validateForm() || loading.value) return
  
  loading.value = true
  try {
    // 模拟 API 请求
    await new Promise(resolve => setTimeout(resolve, 1000))
    console.log('表单提交成功', formData)
  } catch (error) {
    console.error('表单提交失败', error)
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.form-item {
  margin-bottom: 20px;
}

label {
  display: block;
  margin-bottom: 5px;
  font-weight: bold;
}

input {
  width: 300px;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.error {
  color: red;
  font-size: 12px;
  margin-top: 5px;
  display: block;
}

button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}

button:disabled {
  background-color: #ccc;
  cursor: not-allowed;
}
</style>

4. 数据状态管理(Pinia)

需求分析

在中大型应用中,需要一个集中的状态管理方案来管理共享数据,如用户信息、全局配置等。

实现方案

javascript
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // 状态
  const userInfo = ref(null)
  const token = ref(localStorage.getItem('token'))
  
  // Getters
  const isLogin = computed(() => !!token.value)
  const userName = computed(() => userInfo.value?.name || '')
  
  // Actions
  const login = async (credentials) => {
    try {
      // 模拟 API 请求
      await new Promise(resolve => setTimeout(resolve, 1000))
      const mockUser = {
        id: 1,
        name: '张三',
        email: 'zhangsan@example.com'
      }
      const mockToken = 'mock-jwt-token'
      
      userInfo.value = mockUser
      token.value = mockToken
      localStorage.setItem('token', mockToken)
      
      return true
    } catch (error) {
      console.error('登录失败', error)
      return false
    }
  }
  
  const logout = () => {
    userInfo.value = null
    token.value = null
    localStorage.removeItem('token')
  }
  
  const fetchUserInfo = async () => {
    if (!token.value) return
    
    try {
      // 模拟 API 请求
      await new Promise(resolve => setTimeout(resolve, 1000))
      const mockUser = {
        id: 1,
        name: '张三',
        email: 'zhangsan@example.com',
        avatar: 'https://example.com/avatar.jpg'
      }
      userInfo.value = mockUser
    } catch (error) {
      console.error('获取用户信息失败', error)
      logout()
    }
  }
  
  return {
    userInfo,
    token,
    isLogin,
    userName,
    login,
    logout,
    fetchUserInfo
  }
})
vue
<!-- 使用示例 -->
<template>
  <div>
    <div v-if="userStore.isLogin">
      <p>欢迎,{{ userStore.userName }}!</p>
      <button @click="userStore.logout">退出登录</button>
    </div>
    <div v-else>
      <button @click="handleLogin">登录</button>
    </div>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'
import { useUserStore } from '../stores/user'

const userStore = useUserStore()

const handleLogin = async () => {
  await userStore.login({
    username: 'zhangsan',
    password: '123456'
  })
}

onMounted(() => {
  // 页面加载时获取用户信息
  userStore.fetchUserInfo()
})
</script>

5. 响应式数据监听与处理

需求分析

在某些场景下,需要监听数据的变化并执行相应的逻辑,如表单数据变化时自动保存草稿。

实现方案

vue
<template>
  <div>
    <h3>自动保存草稿</h3>
    <textarea v-model="content" rows="10" cols="50"></textarea>
    <p v-if="saveStatus" class="save-status">{{ saveStatus }}</p>
  </div>
</template>

<script setup>
import { ref, watch, onMounted } from 'vue'

const content = ref('')
const saveStatus = ref('')

// 模拟从本地存储加载草稿
onMounted(() => {
  const savedDraft = localStorage.getItem('article-draft')
  if (savedDraft) {
    content.value = savedDraft
  }
})

// 使用 watch 监听数据变化,并使用 debounce 优化保存频率
const debouncedSave = debounce(async (newValue) => {
  saveStatus.value = '正在保存...'
  try {
    // 模拟 API 请求
    await new Promise(resolve => setTimeout(resolve, 500))
    localStorage.setItem('article-draft', newValue)
    saveStatus.value = '保存成功'
    
    // 3秒后清除保存状态
    setTimeout(() => {
      saveStatus.value = ''
    }, 3000)
  } catch (error) {
    console.error('保存失败', error)
    saveStatus.value = '保存失败'
  }
}, 1000)

watch(content, (newValue) => {
  debouncedSave(newValue)
})

// 防抖函数
function debounce(fn, delay) {
  let timer = null
  return function(...args) {
    if (timer) {
      clearTimeout(timer)
    }
    timer = setTimeout(() => {
      fn.apply(this, args)
    }, delay)
  }
}
</script>

<style scoped>
.save-status {
  color: #42b983;
  font-size: 14px;
  margin-top: 10px;
}
</style>

6. 组件通信(Props/Emits/Provide/Inject)

需求分析

实现父子组件、跨层级组件之间的通信,如导航菜单与内容区域的联动。

实现方案

vue
<!-- 父组件 App.vue -->
<template>
  <div class="app-container">
    <Sidebar @select="handleSelect" :selectedKey="selectedKey" />
    <Content :content="currentContent" />
  </div>
</template>

<script setup>
import { ref, reactive, provide } from 'vue'
import Sidebar from './components/Sidebar.vue'
import Content from './components/Content.vue'

const selectedKey = ref('home')
const contentMap = reactive({
  home: '首页内容',
  about: '关于我们',
  contact: '联系我们'
})

const currentContent = ref(contentMap[selectedKey.value])

// 提供全局配置
provide('appConfig', {
  theme: 'light',
  version: '1.0.0'
})

const handleSelect = (key) => {
  selectedKey.value = key
  currentContent.value = contentMap[key]
}
</script>

<style scoped>
.app-container {
  display: flex;
}
</style>
vue
<!-- 子组件 Sidebar.vue -->
<template>
  <div class="sidebar">
    <div 
      v-for="item in menuItems" 
      :key="item.key"
      @click="handleClick(item.key)"
      :class="{ active: selectedKey === item.key }"
      class="menu-item"
    >
      {{ item.label }}
    </div>
  </div>
</template>

<script setup>
import { defineProps, defineEmits } from 'vue'

const props = defineProps({
  selectedKey: {
    type: String,
    default: 'home'
  }
})

const emit = defineEmits(['select'])

const menuItems = [
  { key: 'home', label: '首页' },
  { key: 'about', label: '关于我们' },
  { key: 'contact', label: '联系我们' }
]

const handleClick = (key) => {
  emit('select', key)
}
</script>

<style scoped>
.sidebar {
  width: 200px;
  background-color: #f5f5f5;
  padding: 20px;
}

.menu-item {
  padding: 10px;
  cursor: pointer;
  margin-bottom: 10px;
  border-radius: 4px;
}

.menu-item:hover {
  background-color: #e0e0e0;
}

.active {
  background-color: #42b983;
  color: white;
}
</style>
vue
<!-- 子组件 Content.vue -->
<template>
  <div class="content">
    <h2>{{ content }}</h2>
    <p>当前主题: {{ appConfig.theme }}</p>
    <p>版本号: {{ appConfig.version }}</p>
  </div>
</template>

<script setup>
import { defineProps, inject } from 'vue'

const props = defineProps({
  content: {
    type: String,
    default: ''
  }
})

// 注入全局配置
const appConfig = inject('appConfig')
</script>

<style scoped>
.content {
  flex: 1;
  padding: 20px;
}
</style>

7. 图片懒加载

需求分析

在长列表中,为了提升页面加载性能,需要实现图片的懒加载功能,即当图片进入视口时才加载。

实现方案

vue
<template>
  <div class="image-list">
    <div v-for="item in imageList" :key="item.id" class="image-item">
      <img 
        v-lazy="item.url" 
        :alt="item.alt"
        @load="handleImageLoad"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, directive } from 'vue'

const imageList = ref([
  { id: 1, url: 'https://picsum.photos/300/200?random=1', alt: '图片1' },
  { id: 2, url: 'https://picsum.photos/300/200?random=2', alt: '图片2' },
  { id: 3, url: 'https://picsum.photos/300/200?random=3', alt: '图片3' },
  { id: 4, url: 'https://picsum.photos/300/200?random=4', alt: '图片4' },
  { id: 5, url: 'https://picsum.photos/300/200?random=5', alt: '图片5' },
  { id: 6, url: 'https://picsum.photos/300/200?random=6', alt: '图片6' },
  { id: 7, url: 'https://picsum.photos/300/200?random=7', alt: '图片7' },
  { id: 8, url: 'https://picsum.photos/300/200?random=8', alt: '图片8' },
  { id: 9, url: 'https://picsum.photos/300/200?random=9', alt: '图片9' },
  { id: 10, url: 'https://picsum.photos/300/200?random=10', alt: '图片10' }
])

const loadedCount = ref(0)

// 自定义图片懒加载指令
const vLazy = directive('lazy', {
  mounted(el, binding) {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach(entry => {
          if (entry.isIntersecting) {
            // 图片进入视口,加载图片
            el.src = binding.value
            observer.unobserve(el)
          }
        })
      },
      {
        threshold: 0.1 // 当图片 10% 进入视口时触发
      }
    )
    
    // 初始占位图
    el.src = 'data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22300%22 height=%22200%22%3E%3Crect width=%22100%25%22 height=%22100%25%22 fill=%22%23f5f5f5%22/%3E%3C/svg%3E'
    
    observer.observe(el)
  }
})

const handleImageLoad = () => {
  loadedCount.value++
  console.log(`已加载 ${loadedCount.value}/${imageList.value.length} 张图片`)
}

onMounted(() => {
  console.log('图片列表组件挂载完成')
})
</script>

<style scoped>
.image-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 20px;
  padding: 20px;
}

.image-item {
  aspect-ratio: 3/2;
  overflow: hidden;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.3s ease;
}

img:hover {
  transform: scale(1.05);
}
</style>

8. 国际化(i18n)

需求分析

实现应用的多语言切换功能,支持中英文切换。

实现方案

javascript
// i18n/index.js
import { createI18n } from 'vue-i18n'

// 语言包
const messages = {
  en: {
    hello: 'Hello',
    welcome: 'Welcome to our application',
    language: 'Language',
    home: 'Home',
    about: 'About',
    contact: 'Contact'
  },
  zh: {
    hello: '你好',
    welcome: '欢迎使用我们的应用',
    language: '语言',
    home: '首页',
    about: '关于我们',
    contact: '联系我们'
  }
}

const i18n = createI18n({
  locale: localStorage.getItem('language') || 'zh', // 默认语言
  messages,
  legacy: false, // 使用 Composition API
  globalInjection: true // 全局注入 $t
})

export default i18n
vue
<!-- App.vue -->
<template>
  <div class="app">
    <div class="language-switcher">
      <button @click="switchLanguage('zh')" :class="{ active: currentLocale === 'zh' }">中文</button>
      <button @click="switchLanguage('en')" :class="{ active: currentLocale === 'en' }">English</button>
    </div>
    
    <h1>{{ $t('hello') }}</h1>
    <p>{{ $t('welcome') }}</p>
    
    <nav>
      <router-link :to="'/home'">{{ $t('home') }}</router-link>
      <router-link :to="'/about'">{{ $t('about') }}</router-link>
      <router-link :to="'/contact'">{{ $t('contact') }}</router-link>
    </nav>
    
    <router-view />
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'

const { locale } = useI18n()

const currentLocale = computed(() => locale.value)

const switchLanguage = (lang) => {
  locale.value = lang
  localStorage.setItem('language', lang)
}
</script>

<style scoped>
.language-switcher {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

button {
  padding: 5px 10px;
  border: 1px solid #ccc;
  background-color: white;
  cursor: pointer;
}

button.active {
  background-color: #42b983;
  color: white;
  border-color: #42b983;
}

nav {
  display: flex;
  gap: 20px;
  margin-top: 20px;
}

a {
  text-decoration: none;
  color: #42b983;
}
</style>

总结

本文收集了企业开发中常见的前端场景题,主要以 Vue 3 Composition API 视角实现,涵盖了以下几类场景:

  1. 性能优化类:防止重复请求、无限滚动、图片懒加载
  2. 用户交互类:表单验证与提交、按钮状态管理
  3. 状态管理类:Pinia 数据管理
  4. 组件通信类:Props/Emits/Provide/Inject
  5. 国际化类:i18n 多语言切换

这些场景题涵盖了前端开发的核心知识点,通过实际的代码实现可以更好地理解和掌握 Vue 3 的使用技巧。

Updated at:

Released under the MIT License.