ai全栈应用开发day09

📅 2026/7/23 14:56:25
ai全栈应用开发day09
Pinia 状态管理、Axios 请求封装与 Vue 3 进阶特性文章目录一、今日学习内容概览二、Pinia 状态管理Vue 生态的官方推荐三、Axios 请求封装打造企业级 HTTP 客户端四、Vue 3 进阶特性五、前端工程化ESLint Prettier 环境变量六、今日踩坑记录七、收获总结八、明日学习计划第三阶段的第二天今天的目标是把前端开发的基础设施打牢。昨天学会了 Vue 3 的核心语法和组件化但一个真实的企业级项目远不止这些——你需要管理全局状态Pinia、与后端 API 通信Axios、处理跨层级数据传递Provide/Inject、以及保证代码质量和可维护性ESLint/Prettier。今天的学习让我深刻体会到前端工程化的复杂度丝毫不亚于后端。从状态管理到请求拦截从代码规范到环境配置每一个环节都直接影响着团队协作效率和项目的长期可维护性。一、今日学习内容概览Pinia 状态管理Store 定义与使用、State/Getters/Actions、模块化拆分、持久化存储Axios 请求封装实例创建、请求/响应拦截器、统一错误处理、与后端 Spring Boot 联调Vue 3 进阶特性Provide/Inject 跨层级通信、Teleport 传送门、异步组件与 Suspense前端工程化ESLint 代码规范、Prettier 格式化、环境变量配置.env、Vite 路径别名阶段里程碑今天是第三阶段第 2 天 / 共 20 天。经过今天的学习智能写手前端项目的基础设施已初步搭建完成状态管理、API 通信、代码规范三件套全部就位。二、Pinia 状态管理Vue 生态的官方推荐在 Vue 2 时代Vuex 是状态管理的事实标准。但 Vuex 的 mutations 和 actions 分离设计、以及类型支持不佳的问题一直让开发者诟病。Vue 官方在 Vue 3 时代推出了Pinia——一个更轻量、更直观、TypeScript 友好的状态管理库现在已经成为 Vue 生态的推荐方案。2.1 安装与 Store 定义Bash终端# 安装 Pinia npm install pinia # 如果需持久化存储页面刷新不丢失 npm install pinia-plugin-persistedstateJavaScriptmain.js — 注册 Piniaimport { createApp } from vue import { createPinia } from pinia import piniaPluginPersistedstate from pinia-plugin-persistedstate import App from ./App.vue const app createApp(App) const pinia createPinia() pinia.use(piniaPluginPersistedstate) app.use(pinia) app.mount(#app)2.2 定义一个完整的 StorePinia 的 Store 定义非常直观——就是一个函数返回 state、getters 和 actions。没有 mutations直接通过 actions 修改 state更符合直觉。JavaScriptstores/article.js — 文章状态管理import { defineStore } from pinia import { ref, computed } from vue import { fetchArticles, createArticle, deleteArticleById } from /api/article // 定义 Store第一个参数是 ID第二个是 setup 函数 export const useArticleStore defineStore(article, () { // State const articles ref([]) const loading ref(false) const error ref(null) const filter ref({ status: all, keyword: }) // Getters const publishedArticles computed(() { return articles.value.filter(a a.status PUBLISHED) }) const filteredArticles computed(() { let result articles.value if (filter.value.status ! all) { result result.filter(a a.status filter.value.status) } if (filter.value.keyword) { result result.filter(a a.title.includes(filter.value.keyword) ) } return result }) const totalWordCount computed(() { return articles.value.reduce((sum, a) sum (a.wordCount || 0), 0) }) // Actions async function loadArticles() { loading.value true error.value null try { const data await fetchArticles() articles.value data } catch (err) { error.value err.message console.error(加载文章失败:, err) } finally { loading.value false } } async function addArticle(articleData) { const newArticle await createArticle(articleData) articles.value.unshift(newArticle) return newArticle } function removeArticle(id) { articles.value articles.value.filter(a a.id ! id) return deleteArticleById(id) } function setFilter(newFilter) { filter.value { ...filter.value, ...newFilter } } function $reset() { articles.value [] loading.value false error.value null filter.value { status: all, keyword: } } return { articles, loading, error, filter, publishedArticles, filteredArticles, totalWordCount, loadArticles, addArticle, removeArticle, setFilter, $reset } }, { // 持久化配置刷新页面后状态不丢失 persist: { key: article-store, paths: [filter] // 只持久化 filterarticles 从接口重新加载 } })2.3 在组件中使用 StoreVue SFCviews/ArticleListView.vuetemplate div classarticle-list div classtoolbar input v-modelarticleStore.filter.keyword placeholder搜索文章... inputarticleStore.setFilter({ keyword: $event.target.value }) / span classstats 共 {{ articleStore.filteredArticles.length }} 篇 / 总字数 {{ articleStore.totalWordCount }} /span /div div v-ifarticleStore.loading classloading加载中.../div div v-else-ifarticleStore.error classerror{{ articleStore.error }}/div ArticleCard v-forarticle in articleStore.filteredArticles :keyarticle.id :articlearticle deletearticleStore.removeArticle / /div /template script setup import { onMounted } from vue import { useArticleStore } from /stores/article import ArticleCard from /components/ArticleCard.vue const articleStore useArticleStore() onMounted(() { if (articleStore.articles.length 0) { articleStore.loadArticles() } }) /scriptPinia vs Vuex 核心区别1.没有 mutations直接通过 actions 修改 state更符合直觉。2.TypeScript 友好天生支持类型推断无需额外配置。3.更轻量核心代码仅约 1KBAPI 设计更简洁。4.模块化更简单每个 Store 独立定义无需手动注册模块。三、Axios 请求封装打造企业级 HTTP 客户端Axios 是前端最流行的 HTTP 客户端库。但在真实项目中直接使用axios.get()是远远不够的——你需要统一处理请求头、错误提示、Loading 状态、Token 刷新、请求重试等。今天我把 Axios 封装成了一个可复用的模块。3.1 基础封装创建 Axios 实例JavaScriptutils/request.js — Axios 实例与拦截器import axios from axios import { useUserStore } from /stores/user import { ElMessage } from element-plus // 创建 axios 实例 const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000, headers: { Content-Type: application/json } }) // 请求拦截器 request.interceptors.request.use( (config) { // 从 Pinia Store 获取 token注意要在请求时动态获取不能缓存 const userStore useUserStore() const token userStore.token if (token) { config.headers.Authorization Bearer ${token} } // 添加时间戳防止缓存 if (config.method get) { config.params { ...config.params, _t: Date.now() } } return config }, (error) { return Promise.reject(error) } ) // 响应拦截器 request.interceptors.response.use( (response) { // 直接返回业务数据脱壳 const res response.data // 如果后端统一包装了响应格式 { code, data, message } if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, (error) { // 统一错误处理 const { response } error if (response) { switch (response.status) { case 401: ElMessage.error(登录已过期请重新登录) // 清除 token 并跳转到登录页 const userStore useUserStore() userStore.logout() window.location.href /login break case 403: ElMessage.error(没有权限执行此操作) break case 404: ElMessage.error(请求的资源不存在) break case 500: ElMessage.error(服务器内部错误) break default: ElMessage.error(response.data?.message || 网络异常) } } else if (error.code ECONNABORTED) { ElMessage.error(请求超时请检查网络) } else { ElMessage.error(网络连接失败) } return Promise.reject(error) } ) export default request3.2 API 模块拆分按照业务模块拆分 API 文件每个模块一个文件清晰可维护。JavaScriptapi/article.js — 文章相关接口import request from /utils/request export const fetchArticles (params) { return request.get(/articles, { params }) } export const fetchArticleById (id) { return request.get(/articles/${id}) } export const createArticle (data) { return request.post(/articles, data) } export const updateArticle (id, data) { return request.put(/articles/${id}, data) } export const deleteArticleById (id) { return request.delete(/articles/${id}) } // 文件上传Content-Type 为 multipart/form-data export const uploadImage (file) { const formData new FormData() formData.append(file, file) return request.post(/upload/image, formData, { headers: { Content-Type: multipart/form-data } }) }3.3 与 Spring Boot 后端联调前后端联调时跨域是最常见的问题。昨天在 Vite 中配置了代理今天补充后端的 CORS 配置。JavaSpring Boot 跨域配置import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class CorsConfig { Bean public WebMvcConfigurer corsConfigurer() { return new WebMvcConfigurer() { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }; } }四、Vue 3 进阶特性4.1 Provide / Inject跨层级组件通信当组件层级很深时props 逐层传递prop drilling会变得非常繁琐。Provide/Inject 允许祖先组件向后代组件注入依赖无论中间隔了多少层。Vue SFCApp.vue — 提供全局主题配置script setup import { provide, readonly } from vue // 提供响应式数据后代组件可 inject 使用 const themeConfig ref({ primaryColor: #06d6a0, darkMode: true, fontSize: 14 }) // 用 readonly 包裹防止后代直接修改只能通过提供的方法修改 provide(themeConfig, readonly(themeConfig)) // 提供修改方法 const updateTheme (config) { themeConfig.value { ...themeConfig.value, ...config } } provide(updateTheme, updateTheme) /scriptVue SFCcomponents/DeepChild.vue — 注入使用script setup import { inject } from vue // 注入祖先提供的数据 const themeConfig inject(themeConfig) const updateTheme inject(updateTheme) // 提供默认值防止祖先未 provide 时报错 const userInfo inject(userInfo, { name: 访客, role: guest }) /script template div :style{ color: themeConfig.primaryColor } 当前主题色: {{ themeConfig.primaryColor }} /div button clickupdateTheme({ primaryColor: #f59e0b }) 切换为琥珀色主题 /button /templateProvide/Inject 使用建议适合全局配置、主题、用户认证信息等跨多层级共享的数据。但不要滥用——如果只是父子通信还是用 props/emits 更清晰。Inject 的数据不具备响应式追踪的透明性调试时可能增加心智负担。4.2 Teleport将组件渲染到 DOM 其他位置Teleport 允许你把组件的 HTML 渲染到当前组件 DOM 树之外的位置。最常见的场景是 Modal 弹窗、Toast 通知——这些元素需要跳出父容器的 z-index 和 overflow 限制。Vue SFCcomponents/Modal.vue — Teleport 示例template !-- 将内容传送到 body 末尾跳出父容器限制 -- Teleport tobody Transition namefade div v-ifvisible classmodal-overlay clickcloseOnOverlay close() div classmodal-content click.stop header h3{{ title }}/h3 button classclose-btn clickclose×/button /header div classmodal-body slot/slot /div footer v-if$slots.footer slot namefooter/slot /footer /div /div /Transition /Teleport /template script setup defineProps({ visible: { type: Boolean, required: true }, title: { type: String, default: 提示 }, closeOnOverlay: { type: Boolean, default: true } }) const emit defineEmits([update:visible]) const close () { emit(update:visible, false) } /script4.3 Suspense异步组件加载状态Suspense 是 Vue 3 提供的内置组件用于在异步依赖如异步组件、async setup解析时显示 fallback 内容。Vue SFCviews/DashboardView.vue — Suspense 示例template div classdashboard h2数据面板/h2 !-- Suspense 包裹异步组件 -- Suspense !-- 默认插槽异步组件加载完成后显示 -- template #default AsyncChart / AsyncStatsTable / /template !-- fallback 插槽加载中显示 -- template #fallback div classskeleton-loading div classskeleton-chart/div div classskeleton-table/div /div /template /Suspense /div /template script setup import { defineAsyncComponent } from vue // 异步加载组件代码分割按需加载 const AsyncChart defineAsyncComponent(() import(/components/Chart.vue) ) const AsyncStatsTable defineAsyncComponent(() import(/components/StatsTable.vue) ) /script五、前端工程化ESLint Prettier 环境变量5.1 ESLint 代码规范配置ESLint 是 JavaScript/TypeScript 的静态代码分析工具可以在编码阶段发现潜在错误和风格问题。Bash终端# 安装 ESLint 及 Vue 插件 npm install -D eslint eslint-plugin-vue vue/eslint-config-prettier # 初始化配置生成 eslint.config.js npm init eslint/configlatestJavaScripteslint.config.jsimport js from eslint/js import pluginVue from eslint-plugin-vue import skipFormatting from vue/eslint-config-prettier/skip-formatting export default [ { name: app/files-to-lint, files: [**/*.{js,mjs,jsx,vue}], }, { name: app/files-to-ignore, ignores: [**/dist/**, **/dist-ssr/**, **/coverage/**], }, js.configs.recommended, ...pluginVue.configs[flat/essential], { rules: { // 自定义规则 vue/multi-word-component-names: off, // 允许单单词组件名 no-console: process.env.NODE_ENV production ? warn : off, no-debugger: process.env.NODE_ENV production ? error : off, } }, skipFormatting, ]5.2 Prettier 代码格式化JSON.prettierrc{ semi: false, singleQuote: true, tabWidth: 2, trailingComma: es5, printWidth: 100, arrowParens: avoid, endOfLine: lf, vueIndentScriptAndStyle: true }JSONpackage.json — 脚本配置{ scripts: { dev: vite, build: vite build, preview: vite preview, lint: eslint . --ext .js,.vue --fix, format: prettier --write \src/**/*.{js,vue,css,html}\ } }5.3 环境变量配置Vite 使用 dotenv 加载环境变量文件名格式为.env.[mode]。Text.env.development — 开发环境# 必须以 VITE_ 前缀开头才能被客户端代码访问 VITE_API_BASE_URLhttp://localhost:8080/api VITE_APP_TITLEAI 智能写手开发版 VITE_UPLOAD_MAX_SIZE10485760Text.env.production — 生产环境VITE_API_BASE_URLhttps://api.smartwriter.com/api VITE_APP_TITLEAI 智能写手 VITE_UPLOAD_MAX_SIZE5242880JavaScript代码中使用环境变量// 访问环境变量 console.log(import.meta.env.VITE_API_BASE_URL) console.log(import.meta.env.VITE_APP_TITLE) // 判断当前模式 if (import.meta.env.DEV) { console.log(当前是开发环境) } // 所有可用的环境变量 const envConfig { baseURL: import.meta.env.VITE_API_BASE_URL, title: import.meta.env.VITE_APP_TITLE, maxSize: parseInt(import.meta.env.VITE_UPLOAD_MAX_SIZE), mode: import.meta.env.MODE, // development | production dev: import.meta.env.DEV, // boolean prod: import.meta.env.PROD // boolean }5.4 Vite 路径别名配置JavaScriptvite.config.js — 路径别名import { defineConfig } from vite import vue from vitejs/plugin-vue import path from path export default defineConfig({ plugins: [vue()], resolve: { alias: { : path.resolve(__dirname, ./src), components: path.resolve(__dirname, ./src/components), views: path.resolve(__dirname, ./src/views), stores: path.resolve(__dirname, ./src/stores), api: path.resolve(__dirname, ./src/api), utils: path.resolve(__dirname, ./src/utils), } }, server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })路径别名 VS Code 智能提示配置jsconfig.json或tsconfig.json中的compilerOptions.paths让 VS Code 能够识别别名并提供自动导入提示。六、今日踩坑记录坑 1Pinia Store 在组件外调用时获取不到实例现象在 Axios 拦截器中调用useUserStore()报错 getActivePinia was called with no active Pinia。原因Pinia 需要在 Vue 应用实例化之后才能使用。如果在模块顶层直接调用 useUserStore()此时 Pinia 还未挂载。解决方案在拦截器内部动态获取 Store而不是在模块顶层缓存实例// ❌ 错误模块顶层缓存 const userStore useUserStore() // 报错 // ✅ 正确在函数内部动态获取 request.interceptors.request.use((config) { const userStore useUserStore() // 此时 Pinia 已就绪 const token userStore.token ... })坑 2Axios 拦截器返回数据脱壳后TypeScript 类型丢失现象在响应拦截器中直接返回response.data.data导致调用方无法获得正确的类型推断。原因拦截器修改了返回值的结构但 Axios 的泛型类型定义没有同步更新。解决方案为 Axios 实例添加类型声明或使用泛型封装请求方法// 封装带类型的请求方法 export const request T(config) { return axiosInstance.requestT, T(config) // 双重泛型响应类型 返回类型 } // 使用 interface Article { id: number; title: string } const article await requestArticle({ url: /articles/1 }) // article 类型为 Article而非 AxiosResponseArticle坑 3Provide 提供的 ref 对象在 Inject 后失去响应式现象祖先组件 provide 了一个 ref后代组件 inject 后在模板中使用但数据更新时视图不刷新。原因如果 provide 的是ref.value而不是 ref 本身后代拿到的是原始值没有响应式能力。解决方案provide ref 本身或 reactive 对象后代通过.value访问// ❌ 错误提供了原始值 provide(count, count.value) // ✅ 正确提供 ref 本身 provide(count, count) // 后代组件 const count inject(count) console.log(count.value) // 保持响应式坑 4ESLint 和 Prettier 规则冲突导致格式化后报 lint 错误现象保存文件时 Prettier 自动格式化为单引号但 ESLint 报错要求双引号或者 Prettier 添加分号ESLint 禁止分号。原因ESLint 和 Prettier 都有代码风格规则两者规则不一致时就会冲突。解决方案安装vue/eslint-config-prettier或eslint-config-prettier它会关闭 ESLint 中与 Prettier 冲突的规则让 Prettier 全权负责代码格式ESLint 专注于代码质量检查// eslint.config.js 中 import skipFormatting from vue/eslint-config-prettier/skip-formatting export default [ // ...其他配置 skipFormatting, // 放在最后覆盖冲突规则 ]坑 5Vite 环境变量必须以 VITE_ 开头否则获取不到现象在.env文件中定义了API_KEYxxx但在代码中import.meta.env.API_KEY返回 undefined。原因Vite 为了安全考虑默认只暴露以VITE_开头的环境变量给客户端代码。解决方案环境变量名前加VITE_前缀或者在vite.config.js中配置envPrefix自定义前缀// vite.config.js export default defineConfig({ envPrefix: APP_, // 现在 APP_ 开头的变量也会被暴露 }) // .env APP_API_KEYyour-secret-key七、收获总结今日知识地图今天的内容是前端工程化的基础设施大建设——从状态管理到网络请求从代码规范到环境配置。如果说昨天学的是 Vue 3 的语法那今天学的就是工程实践。1. Pinia 的简洁哲学。相比 Vuex 的 mutations/actions 分离Pinia 直接通过 actions 修改 stateAPI 设计更符合直觉。配合pinia-plugin-persistedstate实现状态持久化配合 setup 语法糖实现完整的类型推导——这套组合已经让我彻底告别 Vuex 了。2. Axios 拦截器的威力。以前写前端项目时每个请求都要手动加 token、手动处理错误提示。封装了拦截器之后这些逻辑全部自动化了。特别是统一错误处理——401 跳转登录、403 提示权限不足、500 提示服务器错误——用户体验提升了一个档次。3. Provide/Inject 的适用边界。这个特性让我想起了后端开发中的上下文传递。适合主题配置、全局用户状态等穿透多层组件的场景但不适合普通父子通信——过度使用会让数据流变得难以追踪。4. 工程化是团队协作的基石。ESLint Prettier 的配置花了将近一个小时但一旦配好整个项目的代码风格就统一了。环境变量的规范使用让开发/测试/生产三套配置清晰分离。这些看不见的工作恰恰是区分玩具项目和企业级项目的关键。明天将进入第三阶段更硬核的内容——开始搭建智能写手前端项目的页面框架包括登录页、文章列表页、创作页的 UI 实现以及 Element Plus 组件库的使用。八、明日学习计划Day 10 · 2026-07-12 — Element Plus 组件库与页面开发实战Element Plus 组件库安装与主题定制、常用组件Table/Form/Dialog/Menu、图标使用登录页面开发表单验证、JWT Token 存储、路由守卫集成文章列表页ElTable 分页与排序、搜索过滤、批量操作创作页面富文本编辑器集成、Markdown 预览、AI 生成调用