阅读视图

发现新文章,点击刷新页面。

一文通透 Vue动态组件体系:插槽|数据监听|组件通信|动态切换|缓存—闭环

疏通Vue动态组件体系:插槽、数据监听、组件通信、动态组件与缓存,完整知识闭环

不知道大家有没有这种感觉,学 Vue 的时候知识点总是东一块西一块。 插槽单独学、监听单独记、组件通信挨个背,代码调用会写,但脑子一团乱麻。 只懂怎么用API,完全搞不懂每个知识点在整个框架体系里处在什么位置、互相有什么联系。

我觉得学习不能只停留在会敲代码,更要理清底层逻辑、打通知识脉络,搭建属于自己的认知体系。 写这篇文章,更多是学习梳理、复盘感悟,把整条组件化完整思路串通透。

本文会顺着最简单的逻辑,由浅入深、从内到外,完整串联整套动态体系: 结构动态 → 数据动态 → 组件数据互通 → 组件整体切换 → 组件状态缓存 全程通俗易懂、逻辑闭环,读完彻底搞懂Vue组件动态底层思想。

一、为什么我们需要动态组件

最朴素直白地理解: 写死固定不变的页面,就是静态组件。 页面长啥样,打开就永远啥样,结构不动、数据不动、内容不动,呆呆板板,僵硬得不行。

动态,顾名思义就是页面会变化、内容会刷新、视图会跟着数据自动改动。 用户点击、数据更新、状态切换、内容联动,页面可以灵活做出响应,这就是动态。

所以动态能力,是Vue组件开发的灵魂所在。 Vue设计插槽、数据监听、组件通信、组件切换一系列API,归根结底,都是为了一件事: 让组件灵活可变,让页面活起来。

二、结构动态:插槽 Slot,灵活自定义组件DOM

想要组件不再死板,最先要解决的就是布局结构固化的问题,插槽就是 Vue 用来实现结构分发的核心方案

简单理解: 插槽就是在子组件中预留空位,允许父组件自由传入任意DOM结构,灵活改变子组件内部布局。

Vue 一共提供三类插槽,覆盖绝大多数开发场景:

- 默认插槽:基础内容分发

- 具名插槽:多区域精准布局

- 作用域插槽:子组件存数据,父组件自定义渲染结构

下面简单学习了解一下

一、默认插槽

作用:实现父子组件之间 HTML DOM 结构传递子组件预留占位位置,父组件可传入任意标签内容

Vue2 代码示范

👉 子组件 Child.vue

<template>
  <div class="card-box">
    <h4>我是子组件内部固定标题</h4>
    <!--
      默认插槽
      作用:预留一个空白位置
      用来接收父组件传递过来的任意DOM结构
    -->
    <slot></slot>
  </div>
</template>

<script>
export default {
  name: "Child"
}
</script>

  👉 父组件 Parent.vue

<template>
  <div class="parent">
    <h3>父组件页面</h3>
    <!--
      子组件标签内部所有内容
      都会被分发到子组件 <slot> 位置渲染
    -->
    <Child>
      <p>我是父组件传入的段落内容</p >
      <button>父组件自定义按钮</button>
    </Child>
  </div>
</template>

<script>
import Child from './Child.vue'
export default {
  components: { Child }
}
</script>

Vue3 代码示范:默认插槽 Vue2 和 Vue3 语法完全一致,无需改动

 

二、具名插槽

作用一个组件多个渲染区域通过插槽名字,精准分发不同位置的DOM结构 多用于页面布局:头部、侧边、主体、底部

Vue2 代码示范

👉 子组件 Child.vue

<template>
  <div class="layout">
    <!-- 头部插槽,命名 header -->
    <slot name="header"></slot>

    <!-- 主体内容插槽,命名 main -->
    <slot name="main"></slot>

    <!-- 底部插槽,命名 footer -->
    <slot name="footer"></slot>
  </div>
</template>

<script>
export default {
  name: "Child"
}
</script>

👉 父组件 Parent.vue

<template>
  <div>
    <Child>
      <!-- slot="名称" 匹配子组件对应插槽 -->
      <div slot="header"> 页面头部区域</div>
      <div slot="main"> 页面主体内容</div>
      <div slot="footer"> 页面底部信息</div>
    </Child>
  </div>
</template>

<script>
import Child from './Child.vue'
export default {
  components: { Child }
}
</script>

Vue3 代码示范

👉 子组件 Child.vue写法不变

👉 父组件 Parent.vue

Vue3 彻底废弃 slot="" 行内写法,统一使用  v-slot:名称 ,简写  #名称 ,必须包裹 template

<template>
  <div>
    <Child>
      <!-- # 是 v-slot: 的简写语法 -->
      <template #header>
        <div>Vue3 专属头部</div>
      </template>

      <template #main>
        <div>Vue3 主体内容区域</div>
      </template>

      <template #footer>
        <div>Vue3 底部</div>
      </template>
    </Child>
  </div>
</template>

<script setup>
import Child from './Child.vue'
</script>

三、作用域插槽(重点)

核心逻辑

数据存放在子组件,DOM结构由父组件自定义编写 子组件向外暴露自己的数据 父组件拿到数据,自由决定标签样式 (业务场景:表格单元格、列表自定义渲染)

Vue2 代码示范

👉 子组件 Child.vue

<template>
  <div class="list-box">
    <!--
      作用域插槽
      :listData 向外抛出子组件内部数据
      把数据传递给父组件使用
    -->
    <slot :listData="userList"></slot>
  </div>
</template>

<script>
export default {
  name: "Child",
  data() {
    return {
      // 数据完全由子组件维护
      userList: [
        { id: 1, name: "张三" },
        { id: 2, name: "李四" },
        { id: 3, name: "王五" }
      ]
    }
  }
}
</script>

👉 父组件 Parent.vue

<template>
  <div>
    <!--
      slot-scope 用来接收子组件传递过来的所有数据
      scope 是自定义接收对象
    -->
    <Child slot-scope="scope">
      <!-- 从scope中取出子组件的数据,自定义渲染结构 -->
      <div>用户姓名:{{ scope.listData.name }}</div>
    </Child>
  </div>
</template>

<script>
import Child from './Child.vue'
export default {
  components: { Child }
}
</script>

Vue3 代码示范

👉 子组件 Child.vue

<template>
  <div class="list-box">
    <!-- 向外暴露子组件内部数据 -->
    <slot :listData="userList"></slot>
  </div>
</template>

<script setup>
// 子组件自身数据
const userList = [
  { id: 1, name: "张三" },
  { id: 2, name: "李四" },
  { id: 3, name: "王五" }
]
</script>

👉 父组件 Parent.vue

Vue3 删除 slot-scope,全部统一插槽语法

<template>
  <div>
    <!-- #default 代表默认作用域插槽,接收子组件数据 -->
    <Child #default="scope">
      <!-- 父组件自由编写DOM,使用子组件数据 -->
      <div style="color:red">
        自定义用户:{{ scope.listData.name }}
      </div>
    </Child>
  </div>
</template>

<script setup>
import Child from './Child.vue'
</script>

对比一下 Vue2 vs Vue3 插槽差异

1. 默认插槽 Vue2、Vue3 语法完全一致,无任何区别

2. 具名插槽

  • Vue2:直接  slot="名字"  写在标签上
  • Vue3:必须使用  #名字 ,外层包裹  template ,不再支持行内slot

3. 作用域插槽

  • Vue2:专用关键字  slot-scope="变量" 
  • Vue3:全部统一为  v-slot / #  语法,大一统

插槽本质上,只改变组件内部DOM,组件本身不会发生变化,属于组件内部结构层面的动态。

三、数据动态:computed 计算属性 & watch 侦听器

解决完结构问题,我们需要让组件内部的数据拥有响应变化的能力,这里就离不开 computedwatch

一、computed 计算属性

依赖已有数据自动生成全新数据,具备缓存特性,被动触发执行,适合数据拼接、数值换算、状态判断等简单数据处理,只支持同步代码。

1. 基本用法代码(Vue3)
<script setup>
import { computed, ref } from 'vue'

// 原始响应式数据
const num1 = ref(10)
const num2 = ref(20)

// 计算属性:依赖现有数据,自动算出新值
const total = computed(() => {
  console.log('计算属性执行了')
  // 依赖 num1 和 num2
  return num1.value + num2.value
})
</script>
2. 主动性 VS 被动性

- computed 是被动触发 :你不去读取它,它永远不执行 只有页面用到、代码读取 total 的时候,它才会计算

3. 依赖关系:多对一

多个原始数据 → 一个计算属性  num1、num2  多个变量,共同生成 一个 total

4. 自带缓存(最核心特性)

只要它依赖的数据没有发生变化,无论你读取多少次 computed,函数只执行一次,直接读缓存,性能极好

5. 只能同步,不能写异步

computed 内部严禁异步请求、定时器 一旦写异步,依赖收集直接失效,整个废掉

6. 本质

数据派生器 根据已有数据,自动推导新数据 属于:数据 → 数据

二、watch 侦听器

主动监听数据变化,数据一旦改变就立刻执行回调函数,无缓存机制,天然支持异步业务逻辑。 日常开发中还有两个高频配置:

-  immediate :页面首次加载立即执行监听

-  deep:开启深度监听,能够监听到对象、数组内部属性变化

1. 用法代码(含 deep、immediate)
<script setup>
import { watch, ref } from 'vue'

const count = ref(0)

// 监听 count 变化
watch(
  count,
  (newVal, oldVal) => {
    // 数据一变,立刻进入这里
    console.log('数据变化了', newVal)
  },
  {
    immediate: true, // 页面一加载立刻执行一次
    deep: true // 深度监听对象、数组内部变化
  }
)
</script>
2. 主动性 VS 被动性

- watch 是主动监听 只要我监听的数据发生改变 不管你用不用、读不读 自动立刻触发函数

3. 依赖关系:一对多

一个被监听数据 可以触发 一大堆业务逻辑、请求、操作、修改其他变量

一个数据变动 → 触发无数行为

4. 无缓存

数据变一次,执行一次 变多少次,跑多少次 不存在缓存

5. 天生支持异步

watch 里面随便写: 接口请求、定时器、复杂判断、大量业务代码 完全没问题

6. 本质

数据变化监视器 盯着一个值,变了就做事 属于:数据变化 → 行为动作

简单区分:需要加工数据用 computed,数据变化要做业务操作用 watch。

二者搭配使用,让组件数据可以自动计算、实时监听、随时更新,真正实现数据动态响应。

四、数据互通:Vue 四大组件通信方案

插槽控制结构、监听控制数据,但每个组件都是独立作用域,数据相互隔离无法共享。 想要多个组件联动变化,就必须掌握全套组件通信方式。

四种通信清晰划分为两大层级,方便理解与选用:

第一层级:基础点对点通信

1.  props + $emit  — 父子直系通信

2.  provide + inject  — 祖孙跨层通信

第二层级:全局架构级通信

3.  EventBus  事件总线 — 无关组件轻量通信

4.  Pinia  全局状态仓库 — 大型项目统一状态管理

第一层级:基础点对点组件通信详解

特点:组件与组件直接一对一、一对多传值 语法简单、使用频率最高、代码完整、细节拉满

1. 父子组件通信 props + $emit

Vue 最正统、最基础、使用最多的父子通信方式

1.1 父向子传值 — props

抽象概念

  • 数据流向:单向自上而下 父组件 → 子组件
  • 主动被动关系:父组件主动推送数据,子组件被动接收数据
  • 数据映射关系:一对多 一个父组件,可以同时给多个子组件传递同一份数据
  • 数据流特性:单向数据流 数据源头在父组件,子组件只能读取,不允许直接修改 props 数据
  • 使用范围:仅限直接父子嵌套组件

代码示例

👉 父组件(数据发送方)

<template>
  <!-- 通过自定义属性,把数据传递给子组件 -->
  <Child :msg="parentMsg" />
</template>

<script setup>
// 引入子组件
import Child from './Child.vue'

// 父组件内部定义响应式数据
const parentMsg = "我是来自父组件的传递数据"
</script>

  👉 子组件(数据接收方)

<template>
  <!-- 直接使用父组件传递过来的数据 -->
  <div>接收父组件数据:{{ msg }}</div>
</template>

<script setup>
// 显性声明需要接收父组件哪些参数
const props = defineProps(['msg'])
</script>
1.2 子向父传值 — $emit 自定义事件

抽象概念

  • 数据流向:自下而上 子组件 → 父组件
  • 主动被动关系:子组件主动触发事件,父组件被动监听、接收数据
  • 数据映射关系:一对多 一个子组件触发事件,可以被多个上层父组件监听
  • 底层逻辑:子组件自定义事件,触发事件时携带自身数据向上抛出

代码示例

👉 子组件(数据发送方)

<template>
  <!-- 点击触发方法,向父组件发送数据 -->
  <button @click="sendChildData">把数据传给父组件</button>
</template>

<script setup>
// 定义当前组件需要向外派发的自定义事件
const emit = defineEmits(['getChildInfo'])

// 子组件自身私有数据
const childInfo = "这里是子组件内部数据"

// 触发事件,携带数据向上传递
const sendChildData = () => {
  // 参数1:事件名称  参数2:要传递的数据
  emit('getChildInfo', childInfo)
}
</script>

👉 父组件(数据接收方)

<template>
  <!-- 监听子组件抛出的自定义事件,触发对应回调函数 -->
  <Child @getChildInfo="handleGetData" />
</template>

<script setup>
// 回调函数,接收子组件传递过来的所有数据
const handleGetData = (value) => {
  console.log('成功接收子组件数据:', value)
}
</script>

父子通信总结

- props:属性下发,父传子,负责数据流入 - $emit:事件上抛,子传父,负责数据反馈

一上一下、单向流动、结构规范、日常开发最常用

2. 隔代祖孙通信 provide + inject

抽象底层概念

  • 解决痛点:多层嵌套组件,如果用 props 需要一层一层往下传递,中间组件无辜转发、代码冗余
  • 数据流向:顶层祖先组件 → 所有下层后代组件
  • 主动被动:上层主动提供数据,下层所有后代被动注入获取
  • 映射关系:一对多 一个祖先组件,任意层级的孙子、曾孙子都可以直接拿到数据
  • 核心能力:组件层级穿透,无视中间嵌套层数

👉 顶层祖先组件(提供数据)

<script setup>
import { provide } from 'vue'

// 向外穿透提供数据,所有后代组件均可访问
provide('theme', '全局暗色主题')
</script>

👉 任意深层后代组件(孙子、重孙子)

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

// 直接注入顶层数据,不用管中间嵌套多少层组件
const theme = inject('theme')
</script>

第二层级:全局架构级通信(弱化代码,侧重思想、场景、定位)

不属于简单两个组件点对点传值,偏向项目整体数据流架构,这里简单讲解,不堆砌大量代码

3. 无关组件通信 EventBus 事件总线

抽象概念

1. 适用场景:两个组件不存在任何父子、祖孙嵌套关系,互相独立

2. 底层原理:发布订阅设计模式

  • 发布方:主动发射事件、携带数据
  • 订阅方:监听对应事件,被动接收数据

3. 数据关系:多对多通信

通俗理解

相当于项目里一个公共中转站组件A把数据丢进总线,组件B、C、D监听总线就能拿到数据

(使用场景小型项目、简单兄弟组件临时通信 缺点:事件杂乱难管理,大型项目基本淘汰)

4. 全局状态管理 Pinia

抽象本质

前面所有通信,都是组件和组件之间互相传数据 Pinia 直接改变思路:所有组件统一读写公共数据仓库 (可以看前面的文章有讲解,这里一笔带过)

到这里我们可以总结:

插槽、数据监听、组件通信,全部都是在组件内部做变化。 组件不会被替换,只是结构、数据、内容在动态流转更新。

五、更高维度动态:component :is 整体动态组件

component :is  本身用法十分简单,几乎所有接触过 Vue 项目的人都不陌生。 很多人日常业务中一直在使用,只是对动态组件这个专业名词不够熟悉。

它不是新增语法,也不是复杂API,是 Vue 框架原生自带、从诞生之初就存在的能力。 放在我们整套组件动态体系里看,它有着非常清晰的层级定位:

  • 插槽:负责组件内部结构动态
  • 父子通信:负责组件内部数据动态
  • component 动态组件:负责组件整体层面的动态切换

前面所有知识点,都在优化单个组件内部。 而动态组件,上升到了组件与组件之间的灵活渲染。

六、动态组件优化:keep-alive 组件缓存

我们用 component :is 动态切换组件。 默认情况下:组件一切换,旧组件直接销毁,新组件重新创建。

只要组件离开视线:

  • 组件  onUnmounted  卸载销毁
  • 里面填写的数据、输入框内容、页面状态全部清空
  • 下次切回来,重新执行  onMounted  重新请求、重新初始化

很多业务场景我们并不希望组件被销毁 比如表单填写、搜索列表、浏览页面、标签页切换。

于是 Vue 提供了内置缓存组件:keep-alive

1. 先进行定位和了解

  • 插槽:组件内部结构动态
  • 组件通信:组件数据动态
  • component:is:组件整体动态切换
  • keep-alive:组件切换不销毁、状态保留、生命周期缓存

不写 keep-alive

组件切换:  onMounted  挂载 → 切换 →  onUnmounted  销毁 每次进出都完整创建+销毁

加上 keep-alive

组件不会走挂载、销毁 多出两个专属生命周期钩子:

  •  onActivated 组件被激活、显示
  •  onDeactivated 组件休眠、隐藏

简单大白话: 组件只是藏起来,不是删掉 数据、输入内容、页面状态全部保留。

它不是用来写页面的,专门控制组件生命周期。

2.简单代码示例

直接包裹我们的动态组件即可加上切换按钮完整可运行代码

<template>
  <button @click="currentCom = 'Home'">首页</button>
  <button @click="currentCom = 'User'">用户</button>

  <!-- 缓存组件,切换不销毁 -->
  <keep-alive>
    <component :is="currentCom"></component>
  </keep-alive>
</template>

<script setup>
import { ref } from 'vue'
import Home from '@/components/Home.vue'
import User from '@/components/User.vue'

const currentCom = ref('Home')
</script>

3. keep-alive (两个重要属性)

1. include 只缓存指定组件

<!-- 只缓存 Home 和 User -->
<keep-alive include="Home,User">
  <component :is="currentCom"></component>
</keep-alive>

2. exclude 唯独不缓存某个组件

<!-- 除了 Cart 全都缓存 -->
<keep-alive exclude="Cart">
  <component :is="currentCom"></component>
</keep-alive>

七、全文梳理总结

从头到尾,就围绕一件事来梳理,就是:怎么让 Vue 组件不再死板固定,一步步变得灵活、动态、好用。

最开始我们认识了插槽 slot,它只负责在组件内部动手脚,让一个组件里面的结构、标签内容可以自由自定义,不用把组件写死。

之后学习了父子组件传值,解决了组件之间数据互通的问题,让组件内部的数据也能流动变化。

紧接着我们了解了  component + is  动态组件。 这个东西大家平时写项目天天用,只是专业名词可能见得少。Vue 很早就自带了。作用也很直白:不再固定渲染某一个组件标签,通过变量直接切换项目里不同的 vue 文件,实现一整个组件整体替换。

最后登场的 keep-alive,可以说是动态组件的最佳搭档。 组件一切换默认就会销毁重建,页面数据、填写内容全部清空。而 keep-alive 专门用来缓存组件状态,让组件只是隐藏休眠,不会真正销毁,既保留页面数据,又优化页面性能。搭配  include 、 exclude  还能精准控制哪些组件需要缓存、哪些不需要。

整体梳理一条完整链路:

插槽 → 改变组件内部结构

组件通信 → 流转组件内部数据

动态组件 → 切换整个组件本体

keep-alive → 缓存组件生命周期与页面状态

把零散知识点梳理通顺、理清底层逻辑,简单白话分享出来,一起学习吃透 Vue 组件思想。

前端请求三部曲:Ajax / Fetch / Axios 演进与 Vue 工程化封装

从 Ajax → Fetch → Axios:前端网络请求演进史与工程化封装

前言

本篇是 Vue项目实战三板斧系列第一篇,专门聊聊前端最基础的网络请求。

不少同学上来就用 axios,会写但不太明白它到底是怎么来的。 这篇我就带大家简单走一遍进化路线:从最原始的 Ajax,到原生 Fetch,再到我们现在常用的 Axios,一步步看清它们的优缺点,最后一起封装一套简洁、好维护的工程化请求方案。 不求花里胡哨,只求看完能真正理解“我们为什么要这么写请求”。  

一、最原始的网络请求:原生 XMLHttpRequest

要说网络请求,老祖宗必须是 XMLHttpRequest,也就是我们常说的 Ajax。 它实现了页面不刷新就能拿数据,在当年简直是黑科技。

1.1 原生手写 Ajax(最底层写法)

// 1. 创建一个 ajax 实例
const xhr = new XMLHttpRequest();

// 2. 配置请求:请求方式、地址、异步(true)
xhr.open('GET','/api/data',true);

// 3. 监听请求状态变化(旧版常用写法)
xhr.onreadystatechange = function(){
  // readyState === 4 表示请求完成
  if(xhr.readyState === 4){
    // status 200~299 代表请求成功
    if(xhr.status >= 200 && xhr.status < 300){
      // 把后端返回的 JSON 字符串转成对象
      const result = JSON.parse(xhr.responseText);
      console.log('请求成功',result)
    }else{
      console.log('请求失败',xhr.status);
    }
  }
}

// 网络异常、跨域失败时触发
xhr.onerror = function(){
  console.log('网络异常或跨域错误')
}

// 4. 发送请求
xhr.send()

1.2 简单封装一下 Ajax

原生写法太啰嗦,我们简单封装一版,方便复用。

// 封装一个自己的 ajax 函数
function myajax(options) {
  // 1. 创建请求实例
  const xhr = new XMLHttpRequest()

  // 2. 解构配置参数,给默认值
  const {
    method = 'GET',  // 默认 GET 请求
    url,             // 请求地址
    data = null,     // 参数(这里演示无参)
    success,         // 成功回调
    error            // 失败回调
  } = options

  // 3. 初始化请求,转大写防止小写出错
  xhr.open(method.toUpperCase(), url, true)

  /*
    旧写法:onreadystatechange 需要判断 readyState
    新写法:onload 等价于 readyState=4,直接用更简单
  */
  xhr.onload = function () {
    // 判断 HTTP 状态码是否成功
    if (xhr.status >= 200 && xhr.status < 300) {
      // 解析后端返回的 JSON
      const res = JSON.parse(xhr.responseText)
      // 有成功回调就执行
      success && success(res)
    } else {
      // 失败把状态码抛出去
      error && error(xhr.status)
    }
  }

  // 网络异常触发
  xhr.onerror = function () {
    error && error('网络异常或跨域')
  }

  // 发送请求(这里不传参数,避免 GET 报错)
  xhr.send()
}

1.3 Ajax 的缺点(为啥我们不用它了)

缺点一:配置繁琐,全手动判断

详细解释:每发送一个请求,都要重复创建 XMLHttpRequest 实例、调用 open 配置请求、监听状态/错误、调用 send 发送请求,步骤多且冗余。而且要手动判断 readyState 请求状态、手动判断 status HTTP状态码、手动执行 JSON.parse 解析后端返回的字符串,没有任何自动处理逻辑,代码量极大,每写一个请求都要重复大量代码。

缺点二:回调一多直接回调地狱

详细解释: Ajax基于回调函数处理结果,一旦遇到连续多个依赖请求(比如先获取用户ID,再用ID获取详情,再用详情获取订单),就需要在success回调里嵌套下一个myajax请求。代码会层层嵌套、缩进不断加深,可读性极差,后期根本无法维护和修改,这就是典型的回调地狱问题。

  
// 回调地狱示例
myajax({
  url:'/api/user',
  success(res){
    // 第一层回调
    myajax({
      url:`/api/detail?id=${res.id}`,
      success(res){
        // 第二层回调
        myajax({
          url:`/api/order?did=${res.detailId}`,
          success(res){
            // 第三层回调,代码彻底混乱
          }
        })
      }
    })
  }
})
缺点三:没有拦截器、没有超时、没有统一处理

详细解释: 原生XHR没有全局请求/响应拦截机制,每个请求都要单独写错误处理、单独加请求头、单独处理返回结果。比如要给所有接口加token,必须在每个 xhr.open 之后,手动写 setRequestHeader ;想要设置请求超时,需要额外写定时器手动中断请求,无法做到一处配置、全局生效。

缺点四:不支持 Promise

详细解释: 原生Ajax不支持Promise语法,无法使用 async/await 、 then/catch 这种现代化异步写法,只能用传统回调函数。异步流程完全不可控,代码书写不优雅,也无法和现代前端的异步语法接轨,和后续的Fetch、Axios生态完全脱节。

总结:理解底层即可,真实项目没人直接写原生 Ajax。

 

二、现代浏览器原生:Fetch API

时代在进步,浏览器终于看不下去了,推出了Fetch。基于 Promise,告别回调,写法清爽多了。不用从头开始造,省时省力。

2.1 GET 请求(带参数拼接)

// 定义参数
const params = {
  id: 123,
  name: "text"
}

// 把对象转成 ?id=123&name=text 这种格式
const query = new URLSearchParams(params).toString();

// 发送请求
fetch(`/api/user?${query}`)
  .then(res => {
    // fetch 很坑:只有网络失败才 reject,404/500 依然走 then
    if (!res.ok) throw new Error("请求失败:" + res.status)
    // 解析 JSON
    return res.json()
  })
  .then(data => {
    console.log("获取数据成功", data)
  })
  .catch(err => {
    console.error("请求异常", err)
  })

2.2 POST 请求

// fetch 的 post 请求
fetch("/api/user", {
  method: "POST",
  headers: {
    // 必须声明传递 JSON 格式
    "Content-Type": "application/json"
  },
  // 对象转 JSON 字符串
  body: JSON.stringify({
    username: "admin",
    password: "123456"
  })
})
  .then(res => {
    if (!res.ok) throw new Error(res.status)
    return res.json()
  })
  .then(data => {
    console.log("请求成功", data)
  })
  .catch(err => {
    console.error("请求失败", err)
  }) 

2.3 async/await 语法糖更香

// 用 async/await 让代码看起来像同步
async function fetchData() {
  try {
    // 请求参数
    const postData = {
      username: "zhangsan",
      password: "123456"
    }

    // 发送请求
    const response = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(postData)
    })

    // 判断请求是否成功
    if (!response.ok) {
      throw new Error("请求失败,状态码:" + response.status)
    }

    // 解析数据
    const result = await response.json()
    console.log("请求成功", result)
  } catch (error) {
    // 统一捕获错误
    console.error("请求错误", error)
  }
}

// 执行
fetchData();

2.4 简单封装一版 Fetch

// 封装一个通用的 fetch 请求函数
async function request(url, options = {}) {
  // 解构参数
  const { method = 'GET', data, headers = {}, ...rest } = options;
  // 方法转大写
  const upperMethod = method.toUpperCase();
  // 最终请求地址
  let fetchUrl = url;

  // 配置 fetch 参数
  let fetchOptions = {
    method,
    headers,
    ...rest
  }

  // GET 请求:参数拼接到地址栏
  if (upperMethod === 'GET' && data) {
    const queryStr = new URLSearchParams(data).toString()
    fetchUrl += `?${queryStr}`
  }

  // POST/PUT/DELETE 处理 JSON 格式
  if (['POST', 'PUT', 'DELETE'].includes(upperMethod) && data) {
    // 设置请求头
    fetchOptions.headers['Content-Type'] = 'application/json';
    // 转 JSON 字符串
    fetchOptions.body = JSON.stringify(data)
  }

  try {
    // 发送请求
    const res = await fetch(fetchUrl, fetchOptions)
    // 判断状态
    if (!res.ok) { throw new Error(`请求错误:${res.status}`) }
    // 解析并返回数据
    return await res.json()
  } catch (err) {
    // 打印并抛出异常,外部可以继续 catch
    console.error('请求失败', err)
    throw err
  }
}

2.5 Fetch 有哪些硬伤?

硬伤一:网络错误才 reject,404 / 500 依然走 then,必须手动判断

详细解释: Fetch 的“成功”只看网络是否发出去,只要浏览器收到了 HTTP 响应,哪怕是 401、404、500 错误, fetch  依然认为请求“成功”,会走进  then  而不是  catch 。 所以你必须每次手动判断  res.ok ,否则会把错误当正常数据处理,导致页面报错。

// 不写这句,404/500 不会进 catch
if (!res.ok) throw new Error("请求失败")
硬伤二:没有请求、响应拦截器,所有逻辑必须手写重复

详细解释: Fetch 原生不支持拦截器。如果你想给所有接口加 token、加请求头、统一处理返回值、统一报错,每个 fetch 都要写一遍,无法像 axios 那样全局配置一次到处生效。

// 每个请求都要重复写一遍
headers: {
  "Content-Type": "application/json",
  Authorization: "Bearer " + token
}

 

硬伤三:无法取消请求,没有 abort 方案(必须额外用 AbortController)

详细解释: 原生 fetch 自身不支持取消请求。想要取消必须手动搭配  AbortController ,写一堆额外代码,切换页面、重复请求时无法自动中断,容易造成内存泄漏、重复请求、旧数据覆盖新数据等问题。

硬伤四:没有自带超时处理,超时要自己写定时器包装

详细解释: Axios 直接配置  timeout: 5000  就可以自动超时中断。Fetch 没有超时配置,想实现超时必须自己包一层  Promise.race  +  setTimeout ,每个请求都要重复造轮子,非常麻烦。

硬伤五:请求 body 不会自动处理,必须手动 JSON.stringify

详细解释: Axios 会自动帮你把对象转成 JSON、自动加  Content-Type: application/json 。 Fetch 完全不处理,你必须手动:

body: JSON.stringify(data)
headers: { "Content-Type": "application/json" }

少一句后端就收不到数据,非常容易漏写。

硬伤六:无法监听请求进度(上传/下载进度很难实现)

详细解释: Axios 自带  onUploadProgress  可以直接监听上传进度做进度条。 Fetch 原生不支持,只能通过  ReadableStream  自己手动解析流,实现复杂、成本极高,普通项目基本没法用。

结论:小 demo 能用,中大型项目顶不住。

三、项目主流方案:Axios 全面上手

前面我们了解了:

  • 最底层:XMLHttpRequest,功能强但写起来巨麻烦
  • 现代原生:Fetch,语法好看,但能力残缺

那有没有一个东西,既保留 XHR 的强大能力,又拥有 Fetch 的 Promise 优雅语法,还把所有坑都填了?

它就是我们现在前端项目的事实标准 —— Axios。

重点来了: Axios 并不是什么新底层技术,它本质上就是对原生 XMLHttpRequest 再次封装、增强、Promise 化之后的终极工具库。 相当于把我们刚才手写的简陋 myajax、简陋 fetch 封装,做到了工业级极致。

它解决了所有痛点:支持 Promise、自动处理 JSON、拦截器、取消请求、超时、进度监听…… 所以现在 Vue、React、小程序、Node 项目里,大家几乎都默认用 Axios。

3.1 基础使用

import axios from 'axios'

// 完整写法
axios({
  method: 'get',
  url: '/user',
  params: { id: 10 }
})
  .then(res => {
    // axios 自动帮你解析了 JSON,直接拿 data
    console.log(res.data)
  })
  .catch(err => {
    console.log('请求失败', err)
  }) 

3.2 简写 GET / POST

// 简写 GET
axios.get('/user', {
  params: { id: 10 }
}).catch(err => {
  console.log(err)
})

// 简写 POST(自动处理 JSON,不用自己 stringify)
axios.post('/login', {
  username: 'admin',
  password: '123456'
}).catch(err => {
  console.log(err)
})

3.3 async/await 优雅版

// 登录请求
async function login() {
  try {
    const res = await axios.post('/login', {
      username: 'admin',
      password: '123456'
    })
    console.log(res.data)
  } catch (err) {
    // 请求失败、状态码错误都会进这里
    console.log('请求失败', err)
  }
}

 

四、工程化核心:Axios 二次封装(重点)

真实项目里,我们不会到处直接写 axios.get, 必须封装一次,统一处理:token、超时、状态码、错误提示。

4.1 封装 request.js

import axios from 'axios'

// 创建 axios 实例
const request = axios.create({
  baseURL: '/api',    // 统一接口前缀
  timeout: 5000       // 超时时间 5 秒
})

// =================== 请求拦截器 ===================
request.interceptors.request.use(config => {
  // 从本地拿到 token
  const token = localStorage.getItem('token')
  // 如果有 token,就加到请求头里
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  // 必须 return config
  return config
})

// =================== 响应拦截器 ===================
request.interceptors.response.use(
  res => {
    // 直接返回后端数据,页面不用再 .data
    return res.data
  },
  err => {
    // 统一错误提示
    console.log('请求出错', err)
    // 抛出异常,让页面可以自己 catch 处理
    return Promise.reject(err)
  }
)

// 导出实例,页面引入使用
export default request

这一封装,好处直接拉满:

- 统一 baseURL,后期改地址只改一处

- 所有接口自动带 token,不用每个请求写

- 统一错误处理,不用每个接口 catch

- 响应直接返回 data,代码更干净

 

五、接口模块化管理(真正工程化)

封装完 axios 还不够,工程化必须接口模块化。

5.1 按业务拆分文件

src/
└── api/
    ├── request.js   # axios 封装
    ├── user.js      # 用户相关接口
    ├── goods.js     # 商品相关接口
    └── order.js     # 订单相关接口

5.2 user.js 示例

import request from './request'

// 登录接口
export function loginApi(data) {
  return request({
    url: '/login',
    method: 'post',
    data
  })
}

// 获取用户信息
export function getUserInfo() {
  return request({
    url: '/user/info',
    method: 'get'
  })
}

5.3 组件中使用

import { loginApi } from '@/api/user'

async function login() {
  try {
    const res = await loginApi({
      username: 'admin',
      password: '123456'
    })
    console.log('登录成功', res)
  } catch (err) {
    console.log('登录失败')
  }
}

优点:

- 接口统一管理,便于维护

- 页面逻辑更干净

- 方便 mock、方便重复调用

六、在 Vue3 组件中实战使用

Vue

<template>
  <div>
    <button @click="getUser">获取用户信息</button>
  </div>
</template>

<script setup>
import { getUserInfo } from '@/api/user'
import { ref } from 'vue'

const userInfo = ref({})

// 获取数据
const getUser = async () => {
  try {
    const res = await getUserInfo()
    userInfo.value = res
  } catch (err) {
    console.log('请求失败')
  }
}
</script>

可以看到,组件中已经完全看不到底层的  axios  调用,只需要调用封装好的接口方法即可完成数据请求。代码更加简洁清晰,职责更加单一,真体现了前端工程化低耦合、高复用、易维护的优势。

七、总结

这一篇我们完整走完了前端请求进化之路:

1. Ajax(XMLHttpRequest):底层基石,所有请求的根

2. Fetch:浏览器原生 Promise 方案,但能力有限

3. Axios:基于 XHR 深度封装,现代前端工程化最佳实践

工具一直在变,但核心思路没变:

从繁琐难用,到语法简化,再到功能完善。 Axios 之所以成为主流,正是因为它在原生 XHR 的基础上做了大量贴心封装,让我们不用再重复处理各种细节。

而封装和工程化的意义,也远不止“省事”这么简单:

统一的配置、统一的错误处理、按模块拆分接口,本质上都是为了让代码更简洁、更好维护、更容易协作。 一个项目是否规范,往往从请求层就能看出来。

搞懂这些来龙去脉,以后再写接口、做封装,就不再是机械复制代码,而是真正知道自己在做什么、为什么这么做。

这也是 Vue 项目工程化的第一步。 下一篇我们继续三板斧第二篇:VueRouter 路由与路由守卫,配合今天的 token 实现登录鉴权。

❌