Skip to content

基础与项目搭建

本页关键词:Composition API、ref/reactive、computed、watch、生命周期、父子通信、provide/inject、Pinia、defineStore、storeToRefs、ElementPlus 按需导入、Axios 三层封装、Vue Router 嵌套路由、SCSS 变量


一、Vue3 Composition API 速览

setup 与 <script setup>

Vue3 提供了两种编写组件逻辑的方式。<script setup> 是 setup 函数的语法糖 — 在这个标签内声明的所有顶层变量和函数会自动暴露给 template,无需像 Options API 那样在 datamethodscomputed 中分散定义。

核心思路:Composition API 让我们按「功能」而非「选项类型」组织代码。比如一个搜索功能的 state、computed、watch、方法可以写在一起,而不是分散在 data/computed/methods/watch 四个选项中。

vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
const addCount = () => count.value++
</script>

reactive vs ref

js
import { reactive, ref } from 'vue'

const state = reactive({ msg: 'hello' })  // 仅接受对象类型,直接解构会丢失响应式
const count = ref(0)                       // 接受任意类型,访问/修改需 .value

两者的关系:ref 内部对对象类型也是调用 reactive 实现的。实际开发中统一使用 ref 可以减少心智负担 — 不用区分"这是对象用 reactive 还是基本类型用 ref"。

面试要点ref 包装基本类型时通过 getter/setter 拦截实现响应式;包装对象类型时底层走的是 reactive(Proxy)。reactive 返回的是 Proxy 对象,直接解构会丢失响应性,需要用 toRefsstoreToRefs

computed

computed 接收一个 getter 函数,返回一个只读的响应式 ref。它的值会基于依赖自动缓存 — 只有依赖变化时才重新计算:

js
import { ref, computed } from 'vue'
const list = ref([1, 2, 3, 4, 5])
const filterList = computed(() => list.value.filter(item => item > 2))
// filterList 会随 list 变化自动更新,但在 list 不变时直接返回缓存值

watch

watch 用于在响应式数据变化时执行副作用(如发请求、操作 DOM)。与 computed 的区别在于:computed 关注「根据已有数据派生新值」,watch 关注「数据变了之后要做什么」。

js
import { ref, watch } from 'vue'

// 侦听单个 ref
watch(count, (newVal, oldVal) => { /* 执行副作用 */ })

// 侦听多个源(数组形式)
watch([count, name], ([newCount, newName], [oldCount, oldName]) => { /* ... */ })

// immediate:创建时立即执行一次回调(常用于初始化需要用到该数据的场景)
watch(source, callback, { immediate: true })

// deep:深层侦听对象内部属性变化(对 reactive 对象默认就是 deep)
watch(state, callback, { deep: true })

生命周期

Composition API 将生命周期钩子改为函数调用形式,可以在 setup 中多次调用同一个钩子(会按顺序执行):

Options APIComposition API说明
beforeCreatesetup() 自身组件实例创建前,此时 data/methods 尚未初始化
createdsetup() 自身组件实例已创建,但 DOM 尚未挂载
mountedonMountedDOM 已渲染,适合发起请求、操作 DOM
unmountedonUnmounted组件销毁,适合清理定时器、事件监听
js
import { onMounted } from 'vue'
onMounted(() => { /* DOM 已就绪,可以获取 ref 引用的 DOM 元素 */ })

父子通信

Vue3 的父子通信通过 definePropsdefineEmits 两个编译器宏实现(不需要 import):

js
// 父传子 — defineProps 声明接收的属性
const props = defineProps({
  title: { type: String, default: '' }
})

// 子传父 — defineEmits 声明要触发的事件
const emit = defineEmits(['change'])
emit('change', payload)

设计思路:单向数据流 — 父组件通过 props 向下传数据,子组件通过 emit 向上通知事件。子组件不应直接修改 props(会破坏数据溯源)。

provide / inject(跨层通信)

当组件层级很深时(比如 Layout > Header > Nav > NavItem),逐层传 props 很繁琐。provide/inject 允许顶层组件「提供」数据,任意后代组件直接「注入」使用:

js
// 顶层组件 — 提供数据(ref 保持响应式)
import { provide, ref } from 'vue'
const data = ref('hello')
provide('key', data)

// 任意后代组件 — 注入使用
import { inject } from 'vue'
const data = inject('key')

模版引用 & defineExpose

通过 ref 可以获取 DOM 元素或子组件实例。在 <script setup> 中,组件默认不暴露任何属性给父组件,需要通过 defineExpose 显式声明:

js
// 获取 DOM 元素
const inputRef = ref(null)
onMounted(() => inputRef.value.focus())

// 子组件暴露方法给父组件
defineExpose({ publicMethod })

面试要点:Composition API 的核心优势 — 按功能组织代码(而非选项分散),composable 函数可复用逻辑,TypeScript 类型推导更好,不依赖 this


二、Pinia 核心用法

为什么用 Pinia

多个组件需要共享同一份数据时(如用户登录信息、购物车、分类列表),如果用 props 层层传递会非常麻烦。Pinia 提供了一个「全局单例仓库」,任何组件都可以直接读写。

定义 Store(组合式写法)

Pinia 支持 Options API 和 Composition API 两种风格定义 Store。项目中统一使用组合式写法,和组件内的写法完全一致 — ref 当 state,computed 当 getter,普通函数当 action:

js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // state — ref()
  const count = ref(0)
  // getter — computed()
  const doubleCount = computed(() => count.value * 2)
  // action — 普通函数(可以是同步/异步)
  const increment = () => count.value++
  const loadData = async () => {
    const res = await axios.get(API_URL)
    list.value = res.data.data.channels
  }

  return { count, doubleCount, increment, loadData }
})

组件中使用

vue
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
// storeToRefs 解构 state/getter 时保持响应式
const { count, doubleCount } = storeToRefs(counterStore)
// action 方法直接解构(不需要 storeToRefs,函数不涉及响应式)
const { increment } = counterStore
</script>

为什么需要 storeToRefs:直接 const { count } = counterStore 会丢失响应式(等于取出了一个普通值)。storeToRefs 把 store 内的 state/getter 转为独立的 ref,解构后仍能响应式更新。但 action 是普通函数,不需要响应式转换。

面试要点:Pinia vs Vuex — 去掉了 mutation(action 直接修改 state),天然支持 Composition API,每个 store 独立模块无需 modules 配置;Store 是单例模式,多个组件 useXxxStore() 拿到的是同一个实例。


三、项目初始化与工程配置

1. 创建项目

bash
npm init vue@latest

脚手架选择 TypeScript + Vue Router + Pinia + ESLint,自动生成标准项目结构。

2. ElementPlus 按需导入 + 主题定制

思路:ElementPlus 组件库很大,全量引入会严重影响打包体积。通过 unplugin-auto-import + unplugin-vue-components 实现按需导入 — 在 template 中直接使用 <el-button> 即可,插件在编译时自动添加 import 语句。

主题定制的原理是通过 SCSS 的 @forward ... with (...) 覆盖 ElementPlus 内部的 SCSS 变量,而非在运行时覆盖 CSS(更高效,包体积更小)。

ts
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'

export default defineConfig({
  plugins: [
    AutoImport({ resolvers: [ElementPlusResolver()] }),
    Components({
      resolvers: [ElementPlusResolver({ importStyle: 'sass' })]  // sass 才能使用主题定制
    }),
    ElementPlus({ useSource: true }),  // 使用源码级 SCSS 而非编译后的 CSS
  ],
  css: {
    preprocessorOptions: {
      scss: {
        additionalData: `
          @use "@/styles/element/index.scss" as *;
          @use "@/styles/var.scss" as *;
        `,  // 每个 SCSS 文件编译时自动注入这两行
      }
    }
  }
})
scss
// styles/element/index.scss — 覆盖 ElementPlus 主题色
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
  $colors: (
    'primary': ('base': #27ba9b),
    'success': ('base': #1dc779),
    'warning': ('base': #ffb302),
    'danger': ('base': #e26237),
    'error': ('base': #cf4444),
  )
)

3. Axios 三层封装

设计思路:将 HTTP 请求分为三层,实现关注点分离:

文件职责
HTTP 层utils/http.js创建 Axios 实例,配置 baseURL / timeout / 拦截器
API 层apis/xxx.js定义具体接口函数,封装 URL 和参数
组件层views/xxx.vue调用 API 函数,处理业务逻辑和 UI
js
// utils/http.js — 基础版本(后续会增加 token 和错误处理)
import axios from 'axios'

const httpInstance = axios.create({
  baseURL: 'http://pcapi-xiaotuxian-front-devtest.itheima.net',
  timeout: 5000
})

// 请求拦截器 — 后续在此注入 token
httpInstance.interceptors.request.use(config => {
  return config
}, e => Promise.reject(e))

// 响应拦截器 — 直接返回 response.data,免去组件层每次 .data 取值
httpInstance.interceptors.response.use(
  res => res.data,
  e => Promise.reject(e)
)

export default httpInstance
js
// apis/layout.js — API 层封装接口函数
import httpInstance from '@/utils/http'
export const getCategoryAPI = () => httpInstance({ url: '/home/category/head' })

为什么响应拦截器做 res.data 解包:后端返回的完整响应体是 { headers, data: { code, result, message } },实际业务只关心 data 部分。在拦截器统一处理后,组件层调用时直接拿到 { code, result, message },减少重复代码。

面试要点:Axios 拦截器是 AOP(面向切面编程)思想的体现 — 请求拦截统一携带 token,响应拦截统一解包数据和处理错误。这种设计使得业务组件完全不需要关心认证和错误处理的通用逻辑。

4. 路由设计

核心原则:整体页面切换(如首页↔登录页)用一级路由,局部区域内容切换(如首页内的 Home/Category)用嵌套子路由。

观察页面结构可以发现:首页、分类页、详情页都共享顶部导航 + 底部 Footer 的外壳(Layout),只有中间内容区不同。因此 Layout 作为一级路由组件,内部通过 <RouterView /> 渲染子路由:

ts
// router/index.ts(初始版本,后续会添加更多路由)
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    {
      path: '/',
      component: Layout,
      children: [
        { path: '', component: Home },       // 默认子路由 — 首页
        { path: 'category', component: Category }
      ]
    },
    { path: '/login', component: Login }     // 登录页不需要 Layout 外壳
  ]
})

5. SCSS 变量自动导入

项目中定义了一组全局 SCSS 变量(品牌色、辅助色、价格色等),通过 vite.config.tsadditionalData 配置自动注入到每个 SCSS 文件中,组件内可以直接使用 $xtxColor 等变量,无需手动 @use

scss
// styles/var.scss — 全局色值变量
$xtxColor: #27ba9b;     // 品牌主色
$helpColor: #e26237;    // 辅助色
$sucColor: #1dc779;     // 成功色
$warnColor: #ffb302;    // 警告色
$priceColor: #cf4444;   // 价格色