Atelier of Light and Shadow深入浅出Vue.js:AI前端开发指南
1. 为什么你需要这个指南
你是不是也遇到过这样的情况:刚学完Vue基础语法,想做个带AI能力的小工具,结果卡在了怎么把大模型接口接进前端页面上?或者看到别人用Vue做出智能表单、实时翻译界面、图片分析看板,自己却不知道从哪下手?
这本指南不是从“Vue是什么”开始讲起的。它假设你已经会写v-model、能用ref定义响应式变量、知道<script setup>怎么写——但还没真正把AI能力变成自己项目里可触摸的部分。
Atelier of Light and Shadow这个名字听起来有点诗意,其实它代表一种开发理念:前端不只是呈现数据,更要主动参与AI能力的调度与表达。光(Light)是用户可见的交互与反馈,影(Shadow)是背后静默运行的模型调用、状态管理与错误兜底。我们不追求炫技,而是让每一步操作都有明确反馈,每一次失败都有清晰提示,每一处AI调用都像呼吸一样自然。
整篇内容围绕一个真实可运行的目标展开:用Vue构建一个轻量级AI助手界面,支持文本生成、图片描述识别、简单对话记忆。所有代码都能直接复制粘贴,在本地跑起来。不需要配置复杂环境,不用折腾代理或翻墙,也不依赖任何境外服务。
如果你希望学完就能在自己的博客、内部工具或小项目中用上AI能力,而不是停留在“Hello World”阶段,那接下来的内容就是为你准备的。
2. 环境准备:三分钟启动你的AI前端项目
2.1 创建项目骨架
我们跳过Vue CLI的繁琐流程,直接用Vite创建一个极简项目。打开终端,执行:
npm create vite@latest my-ai-app -- --template vue cd my-ai-app npm install这里不推荐用vue create,因为Vite的热更新更快,对AI类需要频繁调试UI和API响应的场景更友好。安装完成后,先别急着跑起来,我们加一个关键依赖:
npm install axiosAxios不是必须的,但比起原生fetch,它对错误处理、请求取消、统一配置更直观,尤其当你后续要对接多个AI接口时,能少写很多重复逻辑。
2.2 项目结构微调
Vite默认生成的结构很干净,我们只做两处调整:
- 在
src/components/下新建AiChatBox.vue和ImageAnalyzer.vue - 在
src/utils/下新建apiClient.js(没有这个目录就手动创建)
apiClient.js是我们所有AI请求的统一出口,内容很简单:
// src/utils/apiClient.js import axios from 'axios' // 模拟后端API地址(实际部署时替换为你的服务地址) const API_BASE = '/api' export const apiClient = axios.create({ baseURL: API_BASE, timeout: 15000, headers: { 'Content-Type': 'application/json' } }) // 全局请求拦截器:自动添加token(如果需要) apiClient.interceptors.request.use(config => { // 这里可以注入认证信息,比如从localStorage读取token const token = localStorage.getItem('ai-token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 全局响应拦截器:统一处理错误 apiClient.interceptors.response.use( response => response, error => { console.error('AI请求失败:', error.response?.data || error.message) return Promise.reject(error) } )注意:这里的/api是Vite开发服务器的代理前缀。实际部署时,你需要在vite.config.js里配置代理,把前端请求转发到你的AI后端服务。我们后面会给出具体配置示例。
2.3 启动并验证
现在运行:
npm run dev打开http://localhost:5173,你应该能看到Vite默认欢迎页。别关掉它——这是我们接下来所有功能的画布。
这个阶段的目标不是写出完美代码,而是确保环境通了、依赖装好了、目录结构理顺了。就像装修房子前先确认水电已通,后面才能放心铺地板、刷墙。
3. 第一个AI功能:文本生成助手(带流式响应)
3.1 功能目标与设计思路
我们不做“一键生成整篇论文”的重型工具,而是做一个专注的文本补全助手:输入半句话,它实时续写,像你打字时的智能联想,但更强大——能保持风格、延续逻辑、甚至模仿语气。
关键点在于流式响应(streaming)。用户不想等3秒后突然弹出一大段文字,而是希望看到文字像打字一样逐字出现。这不仅体验好,也方便我们做加载状态控制和中断操作。
3.2 前端组件实现
在src/components/AiChatBox.vue中,写入以下代码:
<!-- src/components/AiChatBox.vue --> <script setup> import { ref, onMounted } from 'vue' import { apiClient } from '@/utils/apiClient' const inputText = ref('') const outputText = ref('') const isLoading = ref(false) const abortController = ref(null) // 清空输入和输出 const clearAll = () => { inputText.value = '' outputText.value = '' } // 发送请求并处理流式响应 const sendRequest = async () => { if (!inputText.value.trim() || isLoading.value) return isLoading.value = true outputText.value = '' // 创建新的AbortController,用于取消请求 abortController.value = new AbortController() try { const response = await apiClient.post('/generate', { prompt: inputText.value, max_tokens: 128 }, { signal: abortController.value.signal }) // 假设后端返回的是纯文本流(如text/event-stream) // 这里简化处理:后端返回完整字符串,我们模拟流式效果 const fullText = response.data.text || 'AI正在思考...' // 模拟逐字显示效果(实际项目中应由后端推送SSE或WebSocket) let i = 0 const interval = setInterval(() => { if (i < fullText.length) { outputText.value = fullText.substring(0, i + 1) i++ } else { clearInterval(interval) isLoading.value = false } }, 30) } catch (error) { if (error.name === 'AbortError') { console.log('请求已被用户取消') } else { outputText.value = '抱歉,AI暂时无法响应,请稍后重试。' } isLoading.value = false } } // 取消当前请求 const cancelRequest = () => { if (abortController.value) { abortController.value.abort() } } onMounted(() => { // 组件挂载时,给输入框自动聚焦 const inputEl = document.querySelector('.ai-input') if (inputEl) inputEl.focus() }) </script> <template> <div class="ai-chat-container"> <h3> 文本生成助手</h3> <div class="input-section"> <textarea v-model="inputText" class="ai-input" placeholder="输入一句话,比如:'春天来了,花园里...'" rows="3" ></textarea> <div class="button-group"> <button @click="sendRequest" :disabled="isLoading || !inputText.trim()" class="btn-primary" > {{ isLoading ? '生成中...' : '生成文本' }} </button> <button @click="cancelRequest" :disabled="!isLoading" class="btn-secondary" > 取消 </button> <button @click="clearAll" class="btn-clear" > 清空 </button> </div> </div> <div class="output-section"> <h4> AI回复:</h4> <div v-if="isLoading" class="loading-indicator"> <span class="dot"></span> <span class="dot"></span> <span class="dot"></span> </div> <div v-else-if="outputText" class="output-text"> {{ outputText }} </div> <div v-else class="placeholder-text"> 你的AI助手就在这里,输入内容试试看吧 </div> </div> </div> </template> <style scoped> .ai-chat-container { max-width: 800px; margin: 0 auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .ai-input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; resize: vertical; min-height: 80px; } .input-section { margin-bottom: 24px; } .button-group { display: flex; gap: 12px; margin-top: 12px; flex-wrap: wrap; } .btn-primary { background-color: #42b883; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } .btn-primary:disabled { background-color: #ccc; cursor: not-allowed; } .btn-secondary { background-color: #666; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } .btn-secondary:disabled { background-color: #ccc; cursor: not-allowed; } .btn-clear { background-color: #f44336; color: white; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 14px; } .output-section { border: 1px solid #eee; border-radius: 6px; padding: 16px; background-color: #fafafa; } .output-text { white-space: pre-wrap; line-height: 1.6; color: #333; } .placeholder-text { color: #999; font-style: italic; } .loading-indicator { display: flex; align-items: center; gap: 6px; } .dot { width: 8px; height: 8px; background-color: #42b883; border-radius: 50%; animation: pulse 1.5s infinite; } @keyframes pulse { 0% { opacity: 0.4; } 50% { opacity: 1; } 100% { opacity: 0.4; } } </style>这段代码有几个值得注意的设计选择:
- 不依赖第三方UI库:用纯CSS实现按钮、加载动画和响应式布局,避免引入不必要的体积。
- 取消机制完整:使用
AbortController,既能在前端取消请求,也为后续对接真实流式API(如SSE)留好接口。 - 视觉反馈明确:输入框自动聚焦、按钮状态实时变化、加载动画柔和不刺眼、错误提示直接显示在输出区。
- 模拟流式效果:虽然当前是模拟,但代码结构已按真实流式逻辑组织,后续只需替换
sendRequest内部逻辑即可升级。
3.3 在主页面中使用
打开src/App.vue,替换为:
<script setup> import AiChatBox from '@/components/AiChatBox.vue' </script> <template> <div id="app"> <header> <h1>Atelier of Light and Shadow</h1> <p>深入浅出Vue.js:AI前端开发指南</p> </header> <main> <AiChatBox /> </main> </div> </template> <style scoped> #app { max-width: 1000px; margin: 0 auto; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } header { text-align: center; margin-bottom: 40px; } header h1 { color: #2c3e50; margin-bottom: 8px; } header p { color: #7f8c8d; font-size: 16px; } </style>保存后,刷新浏览器,你应该能看到一个简洁的文本生成界面。输入“今天天气真好,我想...”,点击生成,就能看到文字逐字出现的效果。
这不仅是第一个功能,更是整个AI前端开发范式的起点:状态驱动UI、请求生命周期可控、用户体验有温度。
4. 第二个AI功能:图片描述识别(上传+分析一体化)
4.1 为什么图片识别要“一体化”
很多教程教你怎么用<input type="file">选图,再用fetch上传,最后等后端返回JSON。但真实场景中,用户想要的是“点一下,立刻知道这张图在说什么”。
所以我们把三个动作压缩成一个:点击上传区域 → 自动读取图片 → 显示缩略图 → 发起识别请求 → 展示文字描述。中间没有“请等待”弹窗,没有跳转,没有二次确认。
4.2 实现图片上传与识别
在src/components/ImageAnalyzer.vue中写入:
<!-- src/components/ImageAnalyzer.vue --> <script setup> import { ref, onMounted } from 'vue' import { apiClient } from '@/utils/apiClient' const imageFile = ref(null) const previewUrl = ref('') const description = ref('') const isLoading = ref(false) const error = ref('') const handleFileChange = (event) => { const file = event.target.files[0] if (!file) return // 验证文件类型 if (!file.type.match('image.*')) { error.value = '请选择一张图片文件(JPG、PNG等)' return } // 生成预览URL const reader = new FileReader() reader.onload = (e) => { previewUrl.value = e.target.result description.value = '' error.value = '' } reader.readAsDataURL(file) imageFile.value = file } const analyzeImage = async () => { if (!imageFile.value || isLoading.value) return isLoading.value = true error.value = '' try { // 使用FormData上传图片(兼容老式后端) const formData = new FormData() formData.append('image', imageFile.value) const response = await apiClient.post('/analyze-image', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) description.value = response.data.description || 'AI已识别出这张图片的内容。' } catch (err) { console.error('图片分析失败:', err) error.value = '图片分析失败,请检查网络或重试' } finally { isLoading.value = false } } // 拖拽上传支持 const dragOver = (e) => { e.preventDefault() } const drop = (e) => { e.preventDefault() const files = e.dataTransfer.files if (files.length) { const fileInput = document.getElementById('fileInput') const dataTransfer = new DataTransfer() dataTransfer.items.add(files[0]) fileInput.files = dataTransfer.files // 触发change事件 fileInput.dispatchEvent(new Event('change', { bubbles: true })) } } </script> <template> <div class="image-analyzer"> <h3>🖼 图片描述识别</h3> <div class="drop-area" @dragover="dragOver" @drop="drop" @click="$refs.fileInput.click()" > <input ref="fileInput" id="fileInput" type="file" @change="handleFileChange" accept="image/*" class="file-input" /> <div v-if="previewUrl" class="preview-container"> <img :src="previewUrl" alt="预览图" class="preview-img" /> </div> <div v-else class="drop-placeholder"> <p> 点击此处上传图片</p> <p class="hint">或直接拖拽图片到此区域</p> </div> </div> <div v-if="previewUrl" class="action-section"> <button @click="analyzeImage" :disabled="isLoading" class="btn-primary" > {{ isLoading ? '识别中...' : '分析这张图' }} </button> </div> <div v-if="description || error" class="result-section"> <h4> AI识别结果:</h4> <div v-if="error" class="error-message">{{ error }}</div> <div v-else-if="description" class="description-text"> {{ description }} </div> </div> </div> </template> <style scoped> .image-analyzer { max-width: 800px; margin: 0 auto; padding: 20px; } .drop-area { border: 2px dashed #42b883; border-radius: 8px; padding: 40px 20px; text-align: center; cursor: pointer; transition: all 0.2s; background-color: #f9f9f9; } .drop-area:hover { background-color: #f0fff4; border-color: #2c845d; } .file-input { display: none; } .drop-placeholder { margin: 20px 0; } .drop-placeholder p { margin: 0; color: #34495e; } .hint { font-size: 14px; color: #7f8c8d; margin-top: 8px; } .preview-container { margin: 20px 0; } .preview-img { max-width: 100%; max-height: 300px; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.08); } .action-section { margin: 20px 0; } .result-section { margin-top: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 6px; border-left: 4px solid #42b883; } .description-text { white-space: pre-wrap; line-height: 1.6; color: #2c3e50; } .error-message { color: #e74c3c; font-weight: 500; } </style>这个组件的关键创新点在于交互节奏的掌控:
- 用户点击上传区,直接唤起系统文件选择器,无需找隐藏的
<input>标签; - 拖拽支持让操作更符合直觉,特别是设计师或内容创作者习惯拖图;
- 预览图即时显示,让用户确认选对了文件;
- “分析这张图”按钮只在有图时才出现,避免误点;
- 错误提示直接嵌入结果区,不打断流程。
它不是一个孤立的图片上传器,而是AI能力在视觉维度上的自然延伸。
5. 进阶技巧:让AI前端更健壮、更专业
5.1 状态管理:用composable封装AI逻辑
随着功能增多,把所有API调用逻辑堆在组件里会越来越难维护。Vue 3的组合式函数(composable)是解耦的理想方案。
在src/composables/useAiApi.js中创建:
// src/composables/useAiApi.js import { ref, computed } from 'vue' import { apiClient } from '@/utils/apiClient' export function useAiApi() { const loadingStates = ref({}) const setLoading = (key, value) => { loadingStates.value[key] = value } const isLoading = computed(() => { return (key) => !!loadingStates.value[key] }) const handleError = (key, error) => { console.error(`AI请求${key}失败:`, error) // 这里可以集成错误监控上报 } // 封装文本生成逻辑 const generateText = async (prompt, options = {}) => { const key = 'generateText' setLoading(key, true) try { const response = await apiClient.post('/generate', { prompt, ...options }) return response.data } catch (error) { handleError(key, error) throw error } finally { setLoading(key, false) } } // 封装图片分析逻辑 const analyzeImage = async (file) => { const key = 'analyzeImage' setLoading(key, true) try { const formData = new FormData() formData.append('image', file) const response = await apiClient.post('/analyze-image', formData) return response.data } catch (error) { handleError(key, error) throw error } finally { setLoading(key, false) } } return { generateText, analyzeImage, isLoading, setLoading } }然后在AiChatBox.vue中改用:
// 在<script setup>顶部 import { useAiApi } from '@/composables/useAiApi' const { generateText, isLoading } = useAiApi() // 替换sendRequest方法为: const sendRequest = async () => { if (!inputText.value.trim()) return try { const result = await generateText(inputText.value, { max_tokens: 128 }) outputText.value = result.text || '' } catch (error) { outputText.value = '生成失败,请稍后重试。' } }这样做的好处很明显:业务逻辑和UI完全分离;加载状态全局可读;错误处理集中管理;未来新增AI功能只需在composable里加一个方法,组件调用零成本。
5.2 错误边界:优雅降级不崩溃
AI服务不稳定是常态。网络抖动、模型超时、后端维护都会导致请求失败。与其让整个页面白屏或报错,不如提前设计降级策略。
在src/components/AiFallback.vue中创建一个通用错误边界组件:
<!-- src/components/AiFallback.vue --> <script setup> import { ref, onErrorCaptured, defineProps } from 'vue' const props = defineProps({ fallbackMessage: { type: String, default: 'AI服务暂时不可用,已切换至离线模式' } }) const hasError = ref(false) const errorInfo = ref(null) onErrorCaptured((error, instance, info) => { hasError.value = true errorInfo.value = { error, info } console.warn('AI组件捕获错误:', error, info) return false // 阻止错误向上传播 }) </script> <template> <div v-if="hasError" class="fallback-container"> <div class="fallback-icon"></div> <p class="fallback-message">{{ fallbackMessage }}</p> <button @click="$emit('retry')" class="btn-retry">重试</button> </div> <slot v-else></slot> </template> <style scoped> .fallback-container { text-align: center; padding: 30px; background-color: #fff8f8; border: 1px solid #ffebee; border-radius: 6px; margin: 20px 0; } .fallback-icon { font-size: 24px; margin-bottom: 12px; } .fallback-message { color: #c00; margin: 0 0 16px; } .btn-retry { background-color: #42b883; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; } </style>然后在App.vue中包裹AI组件:
<template> <div id="app"> <header>...</header> <main> <AiFallback @retry="refreshPage"> <AiChatBox /> </AiFallback> <AiFallback @retry="refreshPage"> <ImageAnalyzer /> </AiFallback> </main> </div> </template> <script setup> import { useRouter } from 'vue-router' const router = useRouter() const refreshPage = () => { router.go(0) // 刷新当前页面 } </script>这不是“修bug”,而是把不确定性变成确定的用户体验:用户永远知道发生了什么,也知道下一步该做什么。
6. 总结:从代码到工程思维的跨越
写完这两个功能,你可能已经能跑通一个带AI能力的Vue应用。但真正的成长不在代码本身,而在你开始思考的那些问题:
- 当用户连续点击五次“生成文本”,后端会不会被压垮?要不要加防抖?
- 图片上传失败时,是提示“网络错误”,还是更具体的“图片太大,请压缩到5MB以下”?
- 如果AI返回的结果包含敏感词,前端该过滤还是交由后端处理?
- 用户说“把刚才那段话改成更正式的语气”,这个“刚才”怎么在前端准确指代?
这些问题没有标准答案,但每个答案都在塑造你作为AI前端工程师的专业度。
Atelier of Light and Shadow的真正含义,是你在写v-model时想到数据流向,在写axios时考虑错误兜底,在写CSS时琢磨加载动画的节奏——光与影的协作,让技术不再是冰冷的指令,而成为有呼吸、有温度的体验。
不必追求一步到位。先让第一个文本生成跑起来,再给它加上取消按钮;先让图片识别能用,再优化拖拽体验;先保证核心流程稳定,再打磨边缘case。工程能力是在一个个小闭环中长出来的,不是在宏大计划里规划出来的。
你现在拥有的,已经比三个月前的自己多了一套可复用的AI前端开发模式。接下来,选一个你最想解决的实际问题,用今天学到的方法去动手做。哪怕只是给公司内部系统加一个智能搜索建议,那也是属于你的Atelier的第一件作品。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。