文章目录
  1. 一、今日学习内容概览
  2. 二、Pinia 状态管理:Vue 生态的官方推荐
  3. 三、Axios 请求封装:打造企业级 HTTP 客户端
  4. 四、Vue 3 进阶特性
  5. 五、前端工程化:ESLint + Prettier + 环境变量
  6. 六、今日踩坑记录
  7. 七、收获总结
  8. 八、明日学习计划

第三阶段的第二天,今天的目标是把前端开发的"基础设施"打牢。昨天学会了 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-persistedstate

JavaScriptmain.js — 注册 Pinia

import { 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 定义一个完整的 Store

Pinia 的 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']  // 只持久化 filter,articles 从接口重新加载
  }
})

2.3 在组件中使用 Store

Vue SFCviews/ArticleListView.vue

<template>
  <div class="article-list">
    <div class="toolbar">
      <input
        v-model="articleStore.filter.keyword"
        placeholder="搜索文章..."
        @input="articleStore.setFilter({ keyword: $event.target.value })"
      />
      <span class="stats">
        共 {{ articleStore.filteredArticles.length }} 篇 / 总字数 {{ articleStore.totalWordCount }}
      </span>
    </div>

    <div v-if="articleStore.loading" class="loading">加载中...</div>
    <div v-else-if="articleStore.error" class="error">{{ articleStore.error }}</div>

    <ArticleCard
      v-for="article in articleStore.filteredArticles"
      :key="article.id"
      :article="article"
      @delete="articleStore.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()
  }
})
</script>

Pinia vs Vuex 核心区别:

1. 没有 mutations:直接通过 actions 修改 state,更符合直觉。
2. TypeScript 友好:天生支持类型推断,无需额外配置。
3. 更轻量:核心代码仅约 1KB,API 设计更简洁。
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 request

3.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)
</script>

Vue 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 @click="updateTheme({ primaryColor: '#f59e0b' })">
    切换为琥珀色主题
  </button>
</template>

Provide/Inject 使用建议:

适合全局配置、主题、用户认证信息等跨多层级共享的数据。但不要滥用——如果只是父子通信,还是用 props/emits 更清晰。Inject 的数据不具备响应式追踪的透明性,调试时可能增加心智负担。

4.2 Teleport:将组件渲染到 DOM 其他位置

Teleport 允许你把组件的 HTML 渲染到当前组件 DOM 树之外的位置。最常见的场景是 Modal 弹窗、Toast 通知——这些元素需要跳出父容器的 z-index 和 overflow 限制。

Vue SFCcomponents/Modal.vue — Teleport 示例

<template>
  <!-- 将内容传送到 body 末尾,跳出父容器限制 -->
  <Teleport to="body">
    <Transition name="fade">
      <div v-if="visible" class="modal-overlay" @click="closeOnOverlay && close()">
        <div class="modal-content" @click.stop>
          <header>
            <h3>{{ title }}</h3>
            <button class="close-btn" @click="close">×</button>
          </header>
          <div class="modal-body">
            <slot></slot>
          </div>
          <footer v-if="$slots.footer">
            <slot name="footer"></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)
}
</script>

4.3 Suspense:异步组件加载状态

Suspense 是 Vue 3 提供的内置组件,用于在异步依赖(如异步组件、async setup)解析时显示 fallback 内容。

Vue SFCviews/DashboardView.vue — Suspense 示例

<template>
  <div class="dashboard">
    <h2>数据面板</h2>

    <!-- Suspense 包裹异步组件 -->
    <Suspense>
      <!-- 默认插槽:异步组件加载完成后显示 -->
      <template #default>
        <AsyncChart />
        <AsyncStatsTable />
      </template>

      <!-- fallback 插槽:加载中显示 -->
      <template #fallback>
        <div class="skeleton-loading">
          <div class="skeleton-chart"></div>
          <div class="skeleton-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/config@latest

JavaScripteslint.config.js

import 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_URL=http://localhost:8080/api
VITE_APP_TITLE=AI 智能写手(开发版)
VITE_UPLOAD_MAX_SIZE=10485760

Text.env.production — 生产环境

VITE_API_BASE_URL=https://api.smartwriter.com/api
VITE_APP_TITLE=AI 智能写手
VITE_UPLOAD_MAX_SIZE=5242880

JavaScript代码中使用环境变量

// 访问环境变量
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 能够识别别名并提供自动导入提示。

六、今日踩坑记录

坑 1:Pinia 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
  ...
})
坑 2:Axios 拦截器返回数据脱壳后,TypeScript 类型丢失

现象:在响应拦截器中直接返回 response.data.data,导致调用方无法获得正确的类型推断。

原因:拦截器修改了返回值的结构,但 Axios 的泛型类型定义没有同步更新。

解决方案:为 Axios 实例添加类型声明,或使用泛型封装请求方法:

// 封装带类型的请求方法
export const request = <T>(config) => {
  return axiosInstance.request<T, T>(config)  // 双重泛型:响应类型 + 返回类型
}

// 使用
interface Article { id: number; title: string }
const article = await request<Article>({ url: '/articles/1' })
// article 类型为 Article,而非 AxiosResponse<Article>
坑 3:Provide 提供的 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)  // 保持响应式
坑 4:ESLint 和 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,  // 放在最后,覆盖冲突规则
]
坑 5:Vite 环境变量必须以 VITE_ 开头,否则获取不到

现象:在 .env 文件中定义了 API_KEY=xxx,但在代码中 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_KEY=your-secret-key

七、收获总结

今日知识地图

今天的内容是前端工程化的"基础设施大建设"——从状态管理到网络请求,从代码规范到环境配置。如果说昨天学的是 Vue 3 的"语法",那今天学的就是"工程实践"。

1. Pinia 的简洁哲学。相比 Vuex 的 mutations/actions 分离,Pinia 直接通过 actions 修改 state,API 设计更符合直觉。配合 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 生成调用
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐