Vue2脚手架工程化与Axios集成

Vue CLI 工程化开发:SFC、过滤器与 Axios 请求封装

导读:本文深度讲解 Vue 2 工程化开发的三大核心支柱------单文件组件(SFC)的构建原理、过滤器(filter)的完整使用体系与 Vue 3 迁移策略、以及 Axios 从基础用法到生产级封装的完整链路。所有概念均有对应的 .vue 代码段与可运行 HTML 示例,并配合 D2 流程图直观呈现工作机制。适合已掌握 Vue 基础指令、希望进入工程化实战的前端开发者。


目录

  • 零、导读与学习价值
    • [0.1 示例覆盖清单](#0.1 示例覆盖清单 "#01-%E7%A4%BA%E4%BE%8B%E8%A6%86%E7%9B%96%E6%B8%85%E5%8D%95")
    • [0.2 核心名词速查](#0.2 核心名词速查 "#02-%E6%A0%B8%E5%BF%83%E5%90%8D%E8%AF%8D%E9%80%9F%E6%9F%A5")
    • [0.3 为什么要学本篇](#0.3 为什么要学本篇 "#03-%E4%B8%BA%E4%BB%80%E4%B9%88%E8%A6%81%E5%AD%A6%E6%9C%AC%E7%AF%87")
  • [一、Vue 单文件组件(SFC)的工作原理](#一、Vue 单文件组件(SFC)的工作原理 "#%E4%B8%80vue-%E5%8D%95%E6%96%87%E4%BB%B6%E7%BB%84%E4%BB%B6sfc%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86")
  • [二、render 函数与 compiler 的关系](#二、render 函数与 compiler 的关系 "#%E4%BA%8Crender-%E5%87%BD%E6%95%B0%E4%B8%8E-compiler-%E7%9A%84%E5%85%B3%E7%B3%BB")
  • [三、过滤器 filter:数据格式化的声明式方案](#三、过滤器 filter:数据格式化的声明式方案 "#%E4%B8%89%E8%BF%87%E6%BB%A4%E5%99%A8-filter%E6%95%B0%E6%8D%AE%E6%A0%BC%E5%BC%8F%E5%8C%96%E7%9A%84%E5%A3%B0%E6%98%8E%E5%BC%8F%E6%96%B9%E6%A1%88")
    • [3.1 局部过滤器与全局过滤器](#3.1 局部过滤器与全局过滤器 "#31-%E5%B1%80%E9%83%A8%E8%BF%87%E6%BB%A4%E5%99%A8%E4%B8%8E%E5%85%A8%E5%B1%80%E8%BF%87%E6%BB%A4%E5%99%A8")
    • [3.2 管道链式调用](#3.2 管道链式调用 "#32-%E7%AE%A1%E9%81%93%E9%93%BE%E5%BC%8F%E8%B0%83%E7%94%A8")
    • [3.3 过滤器作为 Vue 插件安装](#3.3 过滤器作为 Vue 插件安装 "#33-%E8%BF%87%E6%BB%A4%E5%99%A8%E4%BD%9C%E4%B8%BA-vue-%E6%8F%92%E4%BB%B6%E5%AE%89%E8%A3%85")
    • [3.4 Vue 3 迁移方案](#3.4 Vue 3 迁移方案 "#34-vue-3-%E8%BF%81%E7%A7%BB%E6%96%B9%E6%A1%88")
  • [四、Axios 完整封装指南](#四、Axios 完整封装指南 "#%E5%9B%9Baxios-%E5%AE%8C%E6%95%B4%E5%B0%81%E8%A3%85%E6%8C%87%E5%8D%97")
    • [4.1 基础用法:GET 与 POST](#4.1 基础用法:GET 与 POST "#41-%E5%9F%BA%E7%A1%80%E7%94%A8%E6%B3%95get-%E4%B8%8E-post")
    • [4.2 Loading 状态与错误处理](#4.2 Loading 状态与错误处理 "#42-loading-%E7%8A%B6%E6%80%81%E4%B8%8E%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86")
    • [4.3 生产级封装:统一 baseURL 与错误码处理](#4.3 生产级封装:统一 baseURL 与错误码处理 "#43-%E7%94%9F%E4%BA%A7%E7%BA%A7%E5%B0%81%E8%A3%85%E7%BB%9F%E4%B8%80-baseurl-%E4%B8%8E%E9%94%99%E8%AF%AF%E7%A0%81%E5%A4%84%E7%90%86")
  • 五、请求拦截器与响应拦截器
    • [5.1 拦截器的底层执行链原理](#5.1 拦截器的底层执行链原理 "#51-%E6%8B%A6%E6%88%AA%E5%99%A8%E7%9A%84%E5%BA%95%E5%B1%82%E6%89%A7%E8%A1%8C%E9%93%BE%E5%8E%9F%E7%90%86")
    • [5.2 Token 注入与统一错误处理](#5.2 Token 注入与统一错误处理 "#52-token-%E6%B3%A8%E5%85%A5%E4%B8%8E%E7%BB%9F%E4%B8%80%E9%94%99%E8%AF%AF%E5%A4%84%E7%90%86")
    • [5.3 取消请求](#5.3 取消请求 "#53-%E5%8F%96%E6%B6%88%E8%AF%B7%E6%B1%82")
    • [5.4 跨域与开发代理(devServer.proxy)](#5.4 跨域与开发代理(devServer.proxy) "#54-%E8%B7%A8%E5%9F%9F%E4%B8%8E%E5%BC%80%E5%8F%91%E4%BB%A3%E7%90%86devserverproxy")
  • [六、组件拆分实战:TodoList 架构分析](#六、组件拆分实战:TodoList 架构分析 "#%E5%85%AD%E7%BB%84%E4%BB%B6%E6%8B%86%E5%88%86%E5%AE%9E%E6%88%98todolist-%E6%9E%B6%E6%9E%84%E5%88%86%E6%9E%90")
  • 总结

零、导读与学习价值

0.1 示例覆盖清单

序号 知识点 所在章节
1 Vue CLI 脚手架创建与项目结构
2 SFC 三段式结构(template/script/style)与 vue-loader 处理
3 render 函数原理,为何脚手架用 render 代替 template
4 局部过滤器:价格格式化、日期格式化
5 全局过滤器:跨组件共享过滤器
6 过滤器管道链式调用
7 过滤器作为插件安装(Vue.use)
8 Axios GET 请求与 params 传参
9 Axios 渲染数据列表
10 Loading 状态 + 异常处理(try/catch)
11 Axios 封装:统一 baseURL、响应拦截器
12 将 Axios 挂载为 Vue 插件($axios)
13 请求拦截器 Token 注入
14 响应拦截器统一错误码处理
15 取消请求(CancelToken)
16 TodoList 静态页面拆分组件
17 TodoList 添加任务(Header → App)
18 TodoList 删除/切换状态(Main → App)
19 TodoList Footer 全选与统计
20 localStorage 持久化 + .sync 修饰符

0.2 核心名词速查

术语 一句话解释
SFC(Single File Component) 将 template、script、style 合为一个 .vue 文件的组件格式
vue-loader Webpack 插件,将 .vue 文件拆解并分别编译各语言块
render 函数 直接生成虚拟 DOM 的函数,跳过模板字符串编译步骤
compiler Vue 内置的模板编译器,将 template 字符串转为 render 函数
Runtime-only 版本 不含 compiler 的 Vue 构建产物,体积更小,脚手架默认使用
filter(过滤器) Vue 2 中对数据做格式化处理的管道函数,Vue 3 已废弃
管道符(|) 过滤器语法中连接数据与过滤函数的分隔符
Vue 插件 拥有 install 方法的对象或函数,通过 Vue.use() 安装
Axios 基于 Promise 的 HTTP 客户端,同时支持浏览器与 Node.js
拦截器(interceptor) 在请求发出前或响应到达前插入处理逻辑的钩子
CancelToken Axios 提供的请求取消机制(新版改用 AbortController)
.sync 修饰符 Vue 2 语法糖,等价于 :prop + @update:prop 两件事同时做

0.3 为什么要学本篇

  1. 工程化是门槛:面试中 "你们用脚手架吗?" 几乎是必问题。了解 vue-loader、SFC 结构、render 函数,才能回答得有底气。
  2. 过滤器是高频考点:即使 Vue 3 已废弃,面试官仍会考"过滤器原理"和"Vue 3 如何替代",因为它考的是你对 Vue 设计哲学的理解。
  3. Axios 封装是生产必备:每个正式项目都需要统一的请求层,Token 注入、错误码映射、Loading 管理、取消重复请求------这四件事做好了,项目的网络层就稳了。
  4. 组件拆分是思维训练:TodoList 拆分案例考查的是"数据放在哪、谁拥有状态、如何向上通信"这三个工程化思维的根本问题。

一、Vue 单文件组件(SFC)的工作原理

名词解释

  • SFC(Single File Component) :单文件组件,扩展名为 .vue,将结构(<template>)、逻辑(<script>)、样式(<style>)组合在一个文件内。
  • vue-loader :Webpack 的 loader,专门处理 .vue 文件。它将 SFC 拆解为三个独立的块分别处理,最终组合成可运行的 JavaScript 模块。
  • scoped 样式<style scoped> 让样式只作用于当前组件,vue-loader 通过为元素添加唯一属性(如 data-v-xxxxxxxx)并在 CSS 选择器上追加属性选择器来实现样式隔离。

概念与底层原理

一个 .vue 文件被 vue-loader 处理时,经历以下步骤(参考 vue-loader 官方文档):

  1. 拆块 :vue-loader 将 .vue 文件拆分为 <template><script><style> 三块,各块作为独立的"虚拟模块"传递给对应的 loader 处理。
  2. 编译 template<template> 块交给 vue-template-compiler,将 HTML 模板字符串编译为 render 函数(JavaScript 代码)。
  3. 处理 script<script> 块交给 Babel 等 JS loader 处理,最终导出组件配置对象。
  4. 处理 style<style> 块交给 css-loaderstyle-loader 等处理;若有 scoped 属性,vue-loader 额外注入 CSS 属性选择器。
  5. 合并 :vue-loader 将编译后的 render 函数注入组件配置对象的 render 属性,得到完整的组件定义,与普通 JS 模块一样被 Webpack 打包。

【代码注释】该流程图展示一个 .vue 文件从源码到最终打包产物的完整路径。蓝色源文件先被紫色的 vue-loader 拆成三个黄色语言块,每块交给橙色的专用 loader 处理(template → vue-template-compiler 转 render 函数、script → babel-loader、style → css-loader/style-loader),最后绿色阶段把 render 函数注入组件对象并交给 Webpack 打包。关键点是:<template> 不是在运行时解析的------它在构建阶段 就已被编译为 render 函数。这正是脚手架能使用 Vue Runtime-only 版本(体积减少约 30%)的原因:编译工作在构建时已经完成,运行时不再需要 compiler。底层上 vue-loader 并非简单字符串替换,而是为每个块生成一个带 query 参数的「虚拟模块请求」(如 App.vue?vue&type=template),再由 Webpack 的 module.rules 路由到对应 loader,这套机制依赖 Webpack 的 inline loader + resourceQuery 能力。

入门示例:第一个 SFC 组件

下面展示脚手架项目中最典型的父子组件结构。注意文件拆分方式------每个 .vue 文件承载单一职责。

vue 复制代码
<!-- src/components/Child.vue -->
<template>
  <div>
    <!-- props 接收父组件传入的数字,带类型验证 -->
    <h3>子组件接收:{{ num }}</h3>
  </div>
</template>

<script>
export default {
  name: "Child",
  // props 使用对象形式:可以做类型检查、设默认值
  props: {
    num: {
      type: Number,
      default: 100
    }
  },
  mounted() {
    // mounted 是最常用的发起请求、操作 DOM 的生命周期钩子
    console.log("Child 挂载完毕,props.num =", this.num);
  }
}
</script>

<style scoped>
/* scoped 关键字保证此 h3 样式不会污染父组件或兄弟组件 */
h3 {
  color: green;
}
</style>

【代码注释】props 写成对象形式而非数组形式,是生产项目的最佳实践:type 在开发环境下自动告警类型错误,default 让组件在没有传参时仍能正常渲染,避免页面报错。scoped 的底层实现:编译后,该 h3 选择器会变成 h3[data-v-xxxxxxxx],只命中当前组件的元素。

vue 复制代码
<!-- src/App.vue -->
<template>
  <div>
    <!-- 点击 h3 改变 num,观察子组件是否响应 -->
    <h3 @click="num++">App 当前值:{{ num }}</h3>
    <hr />
    <!-- :num 是 v-bind 简写;将父组件 data 传给子组件 -->
    <Child :num="num" />
  </div>
</template>

<script>
// @ 是 src 目录的别名,由 vue.config.js 配置的 Webpack alias
import Child from "@/components/Child";

export default {
  name: "App",
  data() {
    return {
      num: 1
    }
  },
  components: {
    Child  // 注册为局部组件,只在 App.vue 中可用
  }
}
</script>

<style lang="less" scoped>
/* lang="less" 告诉 vue-loader 先用 less-loader 处理 */
div {
  h3 {
    color: red;   /* Less 嵌套语法,编译后变为 div h3 { color: red } */
  }
}
</style>

【代码注释】import Child from "@/components/Child" --- @ 是 Webpack 配置的路径别名,指向 src/ 目录。vue-loader 处理 App.vue 时,<template> 中的 <Child :num="num" /> 标签大写开头,被识别为组件引用 (而非原生 HTML 标签),并与 components.Child 对象关联。市面应用 :所有基于 Vue CLI 的项目(B 站、饿了么管理后台、企业级 CRM 系统等)均采用这种 @/ 路径引用方式。

实战示例:main.js 入口

脚手架项目的 src/main.js 是 Webpack 的入口文件,也是整个 Vue 应用的启动点:

js 复制代码
// src/main.js
import Vue from "vue";
import App from "@/App";    // 导入根组件

// 关键:使用 render 函数而非 template 字符串
// 原因见下一章详解
new Vue({
  render: h => h(App)
}).$mount("#app");           // 挂载到 public/index.html 中的 #app 元素

【代码注释】render: h => h(App)render: function(createElement) { return createElement(App) } 的箭头函数简写。hcreateElement 的约定俗成的别名(来自德语 Hyperscript)。.$mount("#app") 等价于配置 el: "#app",但 $mount 更灵活,可以在异步逻辑完成后再挂载。

【实战要点】

  • 经典应用场景main.js 是注册全局插件(Vue.use(VueRouter)Vue.use(Vuex))的标准位置;也是在 beforeCreate 钩子中配置 Axios 实例、事件总线的地方。
  • 常见坑 :在 main.js 中对 new Vue({...})beforeCreate 里调用 Vue.filterVue.use,是因为此时 Vue 构造函数已就绪但实例尚未完全初始化,是最安全的全局注册时机。若在实例外部调用 Vue.use,有时会因模块加载顺序问题出现插件未被正确安装的 bug。
  • 性能与最佳实践 :按需引入第三方 UI 库组件(如 Element UI 的 import { Button } from 'element-ui'),避免全量引入导致包体积过大。

【本章小结】

概念 作用 关键点
SFC(.vue 文件) 组件的书写载体 三段式:template / script / style
vue-loader 处理 .vue 文件 拆块 → 各自编译 → 合并
scoped 样式 局部样式隔离 添加 data-v-xxx 属性选择器
@/src 别名 路径简化 Webpack alias,避免 ../../ 地狱
$mount 手动挂载 支持异步挂载,比 el 更灵活

记忆口诀:"三段合一文件,vue-loader 拆开编,scoped 防污染,@ 省路径麻烦。"

【面试考点】

Q1:SFC 中 <style scoped> 的实现原理是什么?它有哪些局限性?

A:vue-loader 在编译时为当前 SFC 生成一个唯一的哈希 ID(如 data-v-3ba0d82a),然后:① 为组件模板中的所有元素添加该属性;② 将 <style scoped> 中的每条 CSS 规则追加属性选择器(如 h3 变为 h3[data-v-3ba0d82a])。局限性:无法修改子组件内部样式 ------子组件的根元素会带该属性,但其内部元素不会。解决方案是使用 ::v-deep(Vue 2)或 :deep()(Vue 3)深度选择器。

Q2:vue-loader 的作用是什么?它与 Webpack 的 loader 机制有何关系?

A:vue-loader 是专为 .vue 文件设计的 Webpack loader。Webpack 的 loader 机制是:对匹配规则(test: /\.vue$/)的文件,依次应用配置的 loader 进行转换。vue-loader 的特殊之处在于它内部会拆解 .vue 文件,并为各个块(template、script、style)分别创建虚拟请求,再交由对应的 loader(如 vue-template-compilerbabel-loadercss-loader)处理,最终将各块结果合并为一个可执行的 JS 模块。


二、render 函数与 compiler 的关系

名词解释

  • Runtime + Compiler 版本 :完整版 Vue,包含模板编译器,可在运行时将 template 字符串编译为 render 函数。体积约 30KB(gzip)。
  • Runtime-only 版本 :不含编译器,需要在构建时(借助 vue-loader)提前编译模板。体积约 20KB(gzip),Vue CLI 脚手架默认使用此版本
  • createElement(h 函数):接收标签名(或组件选项对象)、数据对象、子节点,返回虚拟 DOM 节点(VNode)。

概念与底层原理

Vue 提供两种方式描述组件界面:

方式一:template 字符串(需要 compiler)

arduino 复制代码
template 字符串
  ↓ (需要 compiler 编译器,运行时做或构建时做)
render 函数
  ↓
VNode(虚拟 DOM)
  ↓
真实 DOM

【代码注释】方式一的关键在"谁来编译":若使用 Runtime + Compiler 完整版,编译发生在浏览器运行时(性能有损耗);若使用脚手架 + vue-loader,编译发生在构建时(构建产物中已是 render 函数,运行时无额外开销)。

方式二:直接写 render 函数(无需 compiler)

复制代码
render 函数(开发者直接编写)
  ↓
VNode(虚拟 DOM)
  ↓
真实 DOM

【代码注释】方式二跳过了模板字符串这一层抽象,直接产出虚拟 DOM,效率最高。代价是代码可读性较差(尤其是嵌套结构复杂时),因此只在需要极致控制或动态构建 UI 时才直接编写 render 函数,日常开发仍以 SFC template 为主。

脚手架使用 Runtime-only 版本的 Vue:没有 compiler。因此:

  • 组件内的 <template> 由 vue-loader 在构建阶段 编译,运行时已是 render 函数;
  • main.js 中不能写 template: '<App/>'(因为运行时没有 compiler 来解析);
  • 只能用 render: h => h(App) 的方式挂载根组件。

【代码注释】该图对比两个 Vue 构建产物:蓝色 Runtime+Compiler 完整版同时支持 template 字符串(运行时编译)和 render 函数;紫色 Runtime-only 版本去掉了 compiler,红色节点标出"不支持 template 字符串",只能走 render 函数或构建时编好的模板。黄色节点点出二者的本质差异------是否打包 compiler,Runtime-only 因此省下约 10KB。两个版本的核心差异是体积与能力的权衡。Runtime-only 省去了约 10KB 的 compiler 代码,换来的代价是:开发者必须通过 vue-loader(构建工具)或手写 render 函数来定义模板。对于生产项目,这个取舍是合理的------构建工具是必备的,编译工作在上线前做一次即可。

入门示例:render 函数的三种写法

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>render 函数示例</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app"></div>
</body>
<script>
// 定义一个组件选项对象
const Counter = {
  data() {
    return { num: 1 }
  },
  methods: {
    add() { this.num++ }
  },
  // 这个 template 由 compiler 在运行时编译(使用了完整版 Vue)
  template: `<button @click="add">计数:{{ num }}</button>`
};

new Vue({
  // 写法一:直接写标签字符串(比 template 优先级高)
  // render(h) { return h('h3', '你好,render!') }

  // 写法二:传入组件对象(最常见于 main.js 挂载根组件)
  // render(h) { return h(Counter) }

  // 写法三:箭头函数简写(脚手架 main.js 的标准写法)
  render: h => h(Counter)
}).$mount("#app");
</script>
</html>

【代码注释】h 函数的完整签名是 createElement(tag, data?, children?):第一个参数可以是 HTML 标签字符串('div')、组件选项对象,或异步组件函数;第二个参数是 VNode 数据对象(含 attrsclassstyleon 等);第三个参数是子 VNode 数组或文本字符串。市面应用:Ant Design Vue、Element UI 等 UI 库内部大量使用 render 函数,因为 render 函数比模板字符串更灵活,可以进行复杂的条件判断和动态生成子节点。

实战示例:render 与 template 优先级对比

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>render 优先级</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">原始 HTML 内容(将被替换)</div>
</body>
<script>
new Vue({
  // template 与 render 同时存在时,render 优先级更高
  // 最终渲染的是 render 生成的 h3,而非 template 中的 p
  template: `<p>来自 template 的内容</p>`,
  render(h) {
    // createElement 参数:标签、属性对象、子内容
    return h('h3', { style: { color: 'blue' } }, 'render 函数优先级更高!')
  }
}).$mount('#app');

// 结论:render > template > el 挂载元素的原始内容
</script>
</html>

【代码注释】Vue 的渲染优先级由高到低:render 函数 > template 字符串 > el 挂载元素的 outerHTML。这个设计保证了最高性能的 render 函数始终优先,同时兼容了渐进迁移场景(先用 template,再优化为 render)。

【实战要点】

  • 经典应用场景:需要在 JavaScript 中以编程方式动态构建复杂 UI 时使用 render 函数。例如,根据后端返回的 schema 动态渲染表单、动态创建弹窗组件。
  • 常见坑 :在 Runtime-only 的脚手架项目中,误在 new Vue({}) 的根实例配置对象里写 template 字符串,会因为缺少 compiler 而报错 [Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available.。解决:① 改用 render 函数;② 或将 vue.config.js 的 alias 指向完整版 Vue(不推荐,增大体积)。
  • 性能:render 函数比 template 效率略高,因为省略了运行时的编译步骤。在性能极端敏感的场景(如长列表、高频渲染组件)可以选择手写 render 函数。

【本章小结】

版本 包含 compiler 大小 适用场景
Runtime + Compiler 约 30KB 直接用 <script> 标签引入,无构建工具
Runtime-only 约 20KB Vue CLI 脚手架项目,模板在构建时编译

记忆口诀:"脚手架用 Runtime-only,模板构建时已编好;main.js 用 render h => h(App),不写 template 是原因妙。"

【面试考点】

Q1:为什么 Vue CLI 脚手架的 main.js 中要用 render 函数而不是 template?

A:Vue CLI 默认使用 Vue 的 Runtime-only 版本(vue/dist/vue.runtime.esm.js),该版本不含模板编译器(compiler)。template 字符串必须经过 compiler 编译才能变成 render 函数,运行时若没有 compiler 则无法处理。SFC 中的 <template> 由 vue-loader 在构建阶段 预先编译好,但 new Vue({}) 根实例的配置对象是在运行时执行的,此时已无 compiler 可用。因此,根实例必须直接使用 render 函数。这样做的好处是减少了约 10KB 的体积(compiler 占完整版的约 1/3)。

Q2:render: h => h(App) 中的 h 是什么?createElement 的三个参数分别是什么?

A:hcreateElement 的约定别名(取自 hyperscript------"生成 HTML 的脚本"),是 Vue 用来创建 VNode(虚拟节点)的工厂函数。它的完整签名是 createElement(tag, data?, children?):① 第一个参数 tag 是 HTML 标签名字符串(如 'div')、组件选项对象、或返回上述类型的异步函数;② 第二个参数 data 是 VNode 的数据对象,承载 attrspropsclassstyleon(事件监听)等配置(可省略);③ 第三个参数 children 是子节点,可以是文本字符串,也可以是 VNode 数组。返回值是一个 VNode,最终由 Vue 的 patch 算法 diff 后转为真实 DOM(参考 Vue 2 渲染函数文档)。

Q3:手写 render 函数和写 template 在性能上有差异吗?日常开发该怎么选?

A:有,但通常可忽略。本质差异在于"模板字符串 → render 函数"这一步编译发生在何时:脚手架项目里 SFC 的 <template> 在构建阶段就被 vue-loader 编译成了 render 函数,运行时和手写 render 函数几乎没有区别,性能差距微乎其微。手写 render 真正的优势不在性能,而在表达力------可以用完整的 JavaScript 逻辑(循环、条件、动态标签名)来构造 VNode,适合根据 schema 动态生成 UI、封装高度可配置的 UI 库组件。日常开发应优先用 SFC template(可读性好、有模板编译期优化如静态节点提升),仅在"模板语法表达不了"的动态场景才下沉到 render 函数。


三、过滤器 filter:数据格式化的声明式方案

名词解释

  • 过滤器(filter) :Vue 2 中一类特殊的函数,专门用于对模板中的数据做格式化处理,通过管道符 | 语法调用。
  • 管道符(|:受 Unix Shell 管道语法启发,将左侧的值作为右侧函数的第一个参数传入。
  • 局部过滤器 :在组件配置对象的 filters 属性中定义,只能在当前组件使用。
  • 全局过滤器 :通过 Vue.filter(name, fn) 定义,可在所有 Vue 实例和组件中使用。

概念与底层原理

过滤器的执行时机是模板编译后、虚拟 DOM 生成前 。编译器将 {{ price | currency }} 转换为等效的函数调用:

scss 复制代码
{{ price | currency(2, '¥') }}
// 编译为:
_f("currency")(price, 2, '¥')
// _f 是 Vue 内部的 resolveFilter 函数,按名查找注册的过滤器

【代码注释】_f 是 Vue 编译器生成的内部辅助函数(resolveFilter),它从当前组件的 filters 配置或全局 Vue.options.filters 中按名称找到对应函数并返回。_f("currency")(price, 2, '¥') 的意思是:先找到名为 currency 的过滤器函数,再以 (price, 2, '¥') 为参数调用它。管道符左侧的 price 自动成为第一个参数,(2, '¥') 是模板中手动传递的额外参数。

过滤器查找规则:先局部(组件 filters 对象)后全局(Vue.options.filters,与组件选项合并策略相同。

过滤器函数有两个约束:

  1. this 指向 window,不能访问 Vue 实例数据(因为过滤器设计为纯函数)。
  2. 不能用于 v-for 的迭代值 ,只能用于 {{ }} 插值和 v-bind 属性绑定。

【代码注释】该流程图揭示一个重要事实:蓝色插值表达式经紫色模板编译阶段,被替换成橙色的 _f('currency')(price, 2) 函数调用;随后按黄色「查找顺序」先查组件局部 filters 再查全局 Vue.options.filters(局部就近优先),命中后执行绿色过滤函数并渲染到虚拟 DOM。所以过滤器本质上是在模板编译阶段被替换为函数调用 的语法糖,并不是 Vue 响应系统的一部分。这也解释了为何过滤器中无法访问 this------它被设计为输入输出确定的纯函数,确保可组合、可测试。

3.1 局部过滤器与全局过滤器

入门示例:局部过滤器定义与使用

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>过滤器基本使用</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <div v-for="item in goodsList" :key="item.id">
    <p>商品名称:{{ item.goodsName }}</p>
    <!-- 基础过滤器:自动追加货币符号,保留两位小数 -->
    <p>商品价格①:{{ item.goodsPrice | currency }}</p>
    <!-- 带参数的过滤器:第二个参数起才是使用方传递的参数 -->
    <p>商品价格②:{{ item.goodsPrice | currency(3, '$') }}</p>
    <!-- 日期格式化过滤器 -->
    <p>上架时间:{{ item.addTime | date }}</p>
    <hr />
  </div>
</div>
</body>
<script>
new Vue({
  el: "#app",
  data: {
    goodsList: [
      { id: 1, goodsName: "蓝牙耳机", goodsPrice: 129.345, addTime: Date.now() },
      { id: 2, goodsName: "机械键盘", goodsPrice: 399.678, addTime: Date.now() - 998887666 }
    ]
  },
  filters: {
    // 过滤器第一个参数 v 固定是被过滤的值
    // n 和 type 是调用时额外传的参数,可以有默认值
    currency(v, n = 2, type = "¥") {
      return type + v.toFixed(n);
    },
    // 时间戳转为 YYYY-MM-DD HH:mm:ss 格式
    date(t) {
      const d = new Date(t);
      const pad = n => String(n).padStart(2, "0");
      return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
             `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    }
  }
});
</script>
</html>

【代码注释】currency(v, n = 2, type = "¥") --- v 是管道符左侧的值(自动传入),ntype 是模板调用时传递的额外参数。{{ price | currency(3, '$') }} 对应调用 currency(price, 3, '$')padStart(2, "0") 是 ES2017 的字符串补位方法,"9".padStart(2, "0") 返回 "09",确保日期格式整齐。市面应用:电商平台(京东、淘宝商家版)的商品价格展示、后台管理系统的时间戳列格式化,普遍使用此类过滤器。

实战示例:全局过滤器(跨组件共享)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>全局过滤器</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <p>价格:{{ price | currency }}</p>
  <p>时间:{{ ts | date }}</p>
</div>
<div id="root">
  <!-- 同一页面的另一个 Vue 实例也可以使用全局过滤器 -->
  <p>新闻时间:{{ newsTime | date }}</p>
</div>
</body>
<script>
// 全局过滤器:在所有 Vue 实例和组件中都可使用
// 第一个参数:过滤器名称;第二个参数:过滤函数
Vue.filter("date", function(t) {
  const d = new Date(t);
  const pad = n => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
});
Vue.filter("currency", function(v, n = 2) {
  return "¥" + Number(v).toFixed(n);
});

new Vue({ el: "#app", data: { price: 88.5, ts: Date.now() } });
new Vue({ el: "#root", data: { newsTime: Date.now() - 999999 } });
</script>
</html>

【代码注释】Vue.filter 将过滤器挂载到 Vue.options.filters 上,所有实例在创建时都会继承这个选项,因此全局过滤器可以跨实例使用。注意:局部过滤器同名时会覆盖全局过滤器 (就近原则),这个机制和 CSS 选择器的权重类似。市面应用 :在 Vue CLI 项目中,通常在 src/filters/index.js 中集中定义所有过滤器函数,然后在 main.js 批量注册为全局过滤器,避免在每个组件里重复定义。

3.2 管道链式调用

入门示例:多个过滤器串联

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>过滤器链式调用</title>
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
</head>
<body>
<div id="app">
  <!-- 先用 date 过滤器把时间戳转为字符串,再用 slice 截取前 10 字符 -->
  <!-- 链式调用:前一个过滤器的输出是下一个过滤器的输入 -->
  <p>完整时间:{{ ts | date }}</p>
  <p>仅日期部分:{{ ts | date | slice(10) }}</p>
</div>
</body>
<script>
new Vue({
  el: "#app",
  data: { ts: Date.now() },
  filters: {
    date(t) {
      const d = new Date(t);
      const pad = n => String(n).padStart(2, "0");
      return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` +
             `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
    },
    // slice 过滤器接收 date 过滤器的输出字符串,再截取前 n 个字符
    slice(v, n = 10) {
      return v.slice(0, n);
    }
  }
});
</script>
</html>

【代码注释】{{ ts | date | slice(10) }} 的执行链:① date(ts) 返回 "2024-06-14 15:30:22";② slice("2024-06-14 15:30:22", 10) 返回 "2024-06-14"。每个过滤器只需关注自己的转换逻辑,组合使用就能完成复杂的格式转换。这种单一职责 + 组合 的思路是函数式编程的核心理念。市面应用:后台报表系统中,时间字段经常需要"先格式化再截断",链式过滤器比写一个庞大的多功能过滤器更易维护。

3.3 过滤器作为 Vue 插件安装

在 Vue CLI 项目中,过滤器最优雅的注册方式是封装为插件,通过 Vue.use() 安装:

js 复制代码
// src/filters/index.js
const filters = {
  date(t) {
    const d = new Date(t);
    const pad = n => String(n).padStart(2, "0");
    return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ` +
           `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
  },
  currency(v, n = 2, type = "¥") {
    return type + Number(v).toFixed(n);
  }
};

// Vue 插件的两种形式:
// 形式一:对象,包含 install 方法
// export default {
//   install(V) {
//     for (let key in filters) {
//       V.filter(key, filters[key]);
//     }
//   }
// }

// 形式二:函数(Vue.use 会直接调用它并传入 Vue 构造函数)
export default function(V) {
  for (let key in filters) {
    V.filter(key, filters[key]);
  }
}

【代码注释】Vue.use(plugin) 的内部逻辑:若 plugin 是对象,调用 plugin.install(Vue, ...args);若是函数,直接调用 plugin(Vue, ...args)。两种形式等效,选择函数形式更简洁。Vue.use 还会防止重复安装:同一个插件只会被安装一次,这是通过维护一个已安装插件列表实现的。市面应用vue-routervuexaxios 的 Vue 插件封装全都采用这种 install 模式。

js 复制代码
// src/main.js
import Vue from "vue";
import App from "@/App";
import filtersPlugin from "@/filters";

new Vue({
  render: h => h(App),
  beforeCreate() {
    // 在根实例 beforeCreate 中安装插件,是注册全局资源的最佳时机
    Vue.use(filtersPlugin);
  }
}).$mount("#app");

【代码注释】在 beforeCreate 中调用 Vue.use 而非在实例外部,是为了确保 Vue 构造函数已完全就绪(包括 mixin 合并、原型设置等),同时保证插件在所有子组件创建之前注册完毕。市面应用 :Element UI 的全局安装示例 Vue.use(ElementUI) 也是在创建 Vue 实例之前(实际上在 main.js 顶层)调用的,两种写法都可行,但在 beforeCreate 中调用更符合 Vue 的初始化顺序语义。

3.4 Vue 3 迁移方案

Vue 3 已废弃 filter ,原因是过滤器与 JavaScript 表达式长得太像却有不同的行为,容易造成混淆(Vue 3 迁移指南)。

迁移方案对比:

场景 Vue 2 filter Vue 3 替代方案
简单格式化 `{{ price currency }}`
受依赖缓存 `{{ price currency }}`
全局复用 Vue.filter(...) app.config.globalProperties.$filters = {...} 或 provide/inject
纯工具函数 过滤器 独立 JS 工具函数(推荐,框架无关,可单元测试)
vue 复制代码
<!-- Vue 3 推荐写法:computed 代替有缓存需求的 filter -->
<script setup>
import { computed } from 'vue'

const props = defineProps({ price: Number })

// computed 有缓存,price 不变则不重新计算,与 filter 等效
const formattedPrice = computed(() =>
  '¥' + props.price.toFixed(2)
)
</script>
<template>
  <p>价格:{{ formattedPrice }}</p>
</template>

【代码注释】computed 有依赖缓存,等价于 Vue 2 filter 的无状态纯函数特性,且能在模板中像属性一样使用。而 methods 无缓存,每次渲染都会重新调用,适合不需要缓存的场合。Vue 3 的 Composition API 让工具函数的复用更优雅:将格式化函数抽到 src/utils/format.js,在需要的组件中 import 即可,无需借助框架的 filter 机制。

【实战要点】

  • 经典应用场景:过滤器最适合的场景是"同一类型的数据在多个组件中需要以同一格式展示"------如商品价格统一格式化、时间戳统一显示格式。这类需求定义为全局过滤器,一次定义全局可用。
  • 常见坑① :过滤器中 this 指向 window 而非组件实例,因此无法在过滤器中访问组件的 data 或 methods。需要访问组件数据时,应改用 computed。
  • 常见坑② :在 v-for 的迭代变量上使用过滤器语法无效,只能用 methods 包裹。
  • 性能 :过滤器没有缓存,每次渲染都会调用。如果格式化计算开销大且依赖不变,应改用 computed。

【本章小结】

类型 定义位置 作用范围 缓存 Vue 3 支持
局部过滤器 组件 filters 当前组件 已废弃
全局过滤器 Vue.filter() 所有实例和组件 已废弃
替代:computed 组件 computed 当前组件 支持
替代:methods 组件 methods 当前组件 支持
替代:工具函数 独立 JS 文件 跨框架复用 手动 推荐

记忆口诀:"管道符 | 走过滤,this 指 window 无缓存;Vue 3 已废用 computed,纯函数抽成工具库。"

【面试考点】

Q1:Vue 2 过滤器和 computed 计算属性有什么区别?分别适用什么场景?

A:核心差异有三点:① 适用对象 :过滤器作用于一个数据值(通过管道传入),computed 关联多个响应式数据;② 缓存 :computed 有缓存,依赖不变不重新计算;过滤器无缓存,每次渲染都调用;③ 参数 :过滤器可以传额外参数(| filter(arg1)),computed 作为属性使用不接受参数(除非返回函数)。适用场景:过滤器适合"格式化展示"(一个值变多种形式)computed 适合"由多个状态派生出一个结果"

Q2:Vue 3 为什么废弃过滤器?如何迁移?

A:Vue 3 废弃的原因:① 过滤器语法 {{ val | filter }} 与 JS 按位或运算符 | 冲突,在 template 表达式中容易引起歧义;② 过滤器的功能完全可以由 computed、methods 或普通工具函数替代,没有必要为此保留一套专用语法。迁移方案:简单格式化用 methods(无缓存需求);响应式计算用 computed(有缓存需求);跨组件复用的工具函数提取为独立 JS 模块,在组件中 import 使用。


四、Axios 完整封装指南

名词解释

  • Axios :基于 Promise 的 HTTP 客户端,支持浏览器和 Node.js,具有自动转换 JSON、拦截器、取消请求等特性(Axios 官方文档)。
  • axios 实例 :通过 axios.create(config) 创建的独立 axios 对象,有自己的配置和拦截器,不影响全局 axios。
  • baseURL:请求的基础地址,所有请求 URL 都在此基础上拼接,避免每个请求都写完整域名。
  • params :GET 请求的查询参数,axios 自动将其序列化为 ?key=value 格式追加到 URL 末尾。
  • data :POST/PUT 请求的请求体数据,axios 自动将其序列化为 JSON 字符串并设置 Content-Type: application/json

概念与底层原理

Axios 的核心是一个 Promise 链 。每次调用 axios.get()axios.post(),内部会:

  1. 合并配置(实例配置、请求配置、默认配置);
  2. 依次执行所有请求拦截器(后注册的先执行,LIFO 顺序);
  3. 发出实际 HTTP 请求(浏览器中使用 XMLHttpRequest);
  4. 依次执行所有响应拦截器(先注册的先执行,FIFO 顺序);
  5. 返回最终的 Promise。

【代码注释】该图揭示 Axios 请求的完整生命周期:蓝色组件发起请求,紫色「请求拦截器链」按 LIFO 注入 Token、开 Loading,橙色 dispatchRequest 用 XMLHttpRequest 真正发出请求,黄色「响应拦截器链」按 FIFO 提取 res.data、处理业务错误码,最后绿色节点把结果以 Promise resolve/reject 形式回流组件。关键点:请求拦截器和响应拦截器各自形成一条独立的处理管道,它们以 Promise 链的形式串联。若某个拦截器抛出错误,后续拦截器的 fulfilled 回调会被跳过,直接进入 rejected 回调(类似 try-catch)。Axios 还支持 { synchronous: true } 选项让请求拦截器同步执行(跳过 Promise 包装、减少微任务调度开销),以及 runWhen 选项按条件决定是否执行某个拦截器(Axios 拦截器文档)。

4.1 基础用法:GET 与 POST

以下是一个完整可运行的 HTML 示例,演示在纯 HTML 页面中使用 Axios(适合理解核心 API):

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Axios 基础用法</title>
  <!-- CDN 引入 Vue 2 和 Axios -->
  <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
  <button @click="fetchRepos">获取热门仓库</button>
  <ul>
    <li v-for="item in repos" :key="item.id">
      <!-- full_name 格式为 "owner/repo-name" -->
      {{ item.full_name }}(⭐ {{ item.stargazers_count }})
    </li>
  </ul>
</div>
</body>
<script>
new Vue({
  el: "#app",
  data: {
    repos: []
  },
  methods: {
    async fetchRepos() {
      // GET 请求:params 会自动序列化为查询字符串 ?q=vue&sort=stars
      const response = await axios.get("https://api.github.com/search/repositories", {
        params: {
          q: "vue",
          sort: "stars"
        }
      });
      // axios 将响应体包装在 response.data 中
      // GitHub API 的数据在 response.data.items 数组里
      this.repos = response.data.items.slice(0, 5);
    }
  }
});
</script>
</html>

【代码注释】axios.get(url, config) 返回 Promise,resolve 值是 axios 响应对象(包含 datastatusheaders 等字段)。注意 :真正的业务数据在 response.data 里,不是 response 本身。params 选项让 GET 参数声明变得直观,无需手动拼接 URL 字符串,也不会有编码问题。async/await 是 Promise 的语法糖,让异步代码看起来像同步,极大提升可读性。市面应用 :掘金、知乎前端的 API 请求层几乎全部基于 Axios,params 传参和 response.data 解构是最基本的使用模式。

4.2 Loading 状态与错误处理

在实际项目中,每个请求都需要管理三种状态:加载中、成功、失败。以下是脚手架项目中的标准写法(.vue 文件格式):

vue 复制代码
<!-- 对应 src/App.vue 形式的组件 -->
<template>
  <div>
    <!-- 三态互斥展示:Loading → 错误 → 数据 -->
    <p v-if="isLoading">正在拼命加载中......</p>
    <p v-else-if="isError" style="color: red;">请求失败,请稍后重试</p>
    <template v-else>
      <div v-for="item in items" :key="item.id">
        <p>{{ item.full_name }}</p>
      </div>
    </template>
  </div>
</template>

<script>
import axios from "axios";
export default {
  name: "App",
  data() {
    return {
      items: [],
      isLoading: true,   // 初始为 true:组件挂载即开始加载
      isError: false
    }
  },
  async mounted() {
    try {
      // try 块内的 await 若抛出错误,会跳转到 catch 块
      const { data } = await axios.get("https://api.github.com/search/repositories", {
        params: { q: "r", sort: "stars" }
      });
      this.items = data.items;
      this.isLoading = false;         // 成功后关闭 Loading
    } catch (err) {
      this.isLoading = false;         // 失败也要关闭 Loading
      this.isError = true;            // 标记错误状态
      console.error("请求失败:", err.message);
    }
  }
}
</script>

【代码注释】const { data } = await axios.get(...) --- 解构赋值直接取出 response.data,省去 response.data.items 的重复引用。try/catch 必不可少:网络超时、服务端 5xx、跨域等情况都会导致 Promise reject,不捕获则会产生 Unhandled Promise Rejection 警告,在生产环境中会静默失败,用户看到的是永久转圈。市面应用:所有 B 端管理系统(ERP、CRM)的数据表格组件都采用这种"Loading/Error/Data 三态"模式,配合骨架屏(Skeleton)可大幅提升感知性能。

4.3 生产级封装:统一 baseURL 与错误码处理

以下是脚手架项目的标准 Axios 封装结构,接近真实生产项目:

js 复制代码
// src/request/index.js ------ 基础 Axios 封装
import axios from "axios";

// 创建独立实例,不影响全局 axios
const instance = axios.create({
  // 统一 baseURL:所有请求都会以此为前缀
  baseURL: "https://api.github.com",
  // 超时时间:超过 10s 自动取消,避免请求悬挂
  timeout: 10000
});

// 响应拦截器:提取业务数据,简化调用方代码
instance.interceptors.response.use(
  res => {
    // 直接返回 res.data,调用方不再需要写 .data
    return res.data;
  },
  err => {
    // 统一处理网络层错误
    console.error("[Axios Error]", err.message);
    return Promise.reject(err);
  }
);

export default instance;

【代码注释】使用 axios.create() 创建实例而非直接修改全局 axios.defaults,是因为大型项目往往需要同时对接多个后端服务 (主服务、上传服务、第三方 API),每个服务有不同的 baseURL 和配置;用实例隔离互不干扰。响应拦截器里 return res.data 让调用方能直接拿到业务数据:const data = await request.get('/search/repositories') 而不是 const { data } = await request.get(...)

js 复制代码
// src/request/gitHub.js ------ 将 axios 挂载为 Vue 插件
import instance from "./index";

// 导出一个 Vue 插件函数
export default function(Vue) {
  // 将配置好的 axios 实例挂载到 Vue 原型上
  // 所有组件可通过 this.$axios 访问
  Vue.prototype.$axios = instance;
}

【代码注释】将 axios 实例挂载为插件,实现"一次配置、全局可用"。插件函数接收 Vue 构造函数作为参数,通过 Vue.prototype.$axios = instance 将封装好的实例注入到所有组件的原型链上。这与 Vue Router 的 Vue.prototype.$router、Vuex 的 Vue.prototype.$store 遵循完全相同的注入模式。市面应用 :大量开源 Vue 2 后台模板(如 vue-element-admin)都将 axios 实例通过这种插件方式注入,让组件能以 this.$request.get(...) 形式调用而无需单独引入。

js 复制代码
// src/main.js ------ 安装插件
import Vue from "vue";
import App from "@/App";
import gitHubAxios from "@/request/gitHub";

new Vue({
  render: h => h(App),
  beforeCreate() {
    // 安装 axios 插件,全局注入 $axios
    Vue.use(gitHubAxios);
  }
}).$mount("#app");

【代码注释】Vue.use(gitHubAxios)beforeCreate 中执行,而非在 new Vue({}) 之前直接在顶层调用------两种写法效果相同,但在 beforeCreate 中调用更能体现"随 Vue 实例初始化一起完成插件安装"的意图。$mount("#app") 将根 Vue 实例挂载到 public/index.htmlid="app" 的 div 元素上,这个 div 在 HTML 模板中预先占位,挂载后其内容被 Vue 渲染的 DOM 替换。

vue 复制代码
<!-- 组件中使用 this.$axios -->
<script>
export default {
  data() {
    return { items: [], isLoading: true, isError: false }
  },
  async mounted() {
    try {
      // 响应拦截器已提取 .data,这里直接解构业务字段
      const { items } = await this.$axios.get("/search/repositories", {
        params: { q: "r", sort: "stars" }
      });
      this.items = items;
      this.isLoading = false;
    } catch (err) {
      this.isLoading = false;
      this.isError = true;
    }
  }
}
</script>

【代码注释】Vue.prototype.$axios = instance 将 axios 实例挂载为 Vue 实例属性,所有组件的 this 都继承自 Vue.prototype,因此所有组件都能直接通过 this.$axios 访问,无需在每个组件里 import axios。注意命名约定:Vue 官方属性用 $ 前缀(如 $data$router)表示"框架注入的属性",自定义注入也应遵循此惯例(如 $axios$bus)以区分普通属性。市面应用 :国内大多数 Vue 2 项目(包括开源的若依框架 ruoyi-vue、Ant Design Pro Vue)都采用这种 Vue.prototype.$axios 的注入方式。

【实战要点】

  • 经典应用场景axios.create() 封装是每个 Vue 2 项目的标配。典型结构:src/request/index.js(实例 + 拦截器)→ src/api/user.js(业务接口函数)→ 组件 this.$axiosimport userApi from '@/api/user'
  • 常见坑 :直接修改 axios.defaults.baseURL 会污染全局 axios,影响同一应用中其他也 import axios 的地方。务必用 axios.create()
  • 性能与最佳实践 :请求超时(timeout)应始终配置,避免因后端接口 hang 住导致前端页面长时间卡在 Loading 状态;生产项目超时通常设置为 10-30s,具体视业务而定。

【本章小结】

步骤 代码位置 关键配置
创建实例 src/request/index.js baseURLtimeout
响应拦截 同上 提取 .data、处理错误
插件封装 src/request/gitHub.js Vue.prototype.$axios
安装插件 main.js beforeCreate Vue.use(plugin)
组件调用 任意组件 mounted this.$axios.get(...)

记忆口诀:"create 建实例隔配置,baseURL 加 timeout 别忘记;响应拦截剥 data 层,prototype 挂 $axios 全局取。"

【面试考点】

Q1:为什么使用 axios.create() 而不直接使用全局 axios?

A:全局 axios 是单例,修改 axios.defaults 会影响所有请求。大型项目通常需要对接多个域名或服务(如主 API、上传 OSS、第三方支付),每个服务有不同的 baseURL、timeout、请求头;用 axios.create() 为每个服务创建独立实例,各自配置各自的拦截器,互不干扰。另外,独立实例便于单元测试时 mock。

Q2:axios 的 GET 用 params、POST 用 data,二者底层有什么区别?响应为什么要 .data 取一层?

A:① params 会被 axios 序列化为查询字符串拼到 URL 后面(?q=vue&sort=stars),跟在 URL 里随请求行发送,适合 GET;data 是请求体(request body),axios 默认把 JS 对象序列化为 JSON 字符串并自动设置 Content-Type: application/json,随请求体发送,适合 POST/PUT。GET 写 data 在大多数场景会被忽略(HTTP 规范不鼓励 GET 带 body)。② 响应要 .data 取一层,是因为 axios 把一次 HTTP 响应封装成了一个响应对象 ,包含 data(响应体)、status(状态码)、statusTextheadersconfigrequest 等元信息;真正的业务数据在 response.data 里。所以生产封装常在响应拦截器里 return res.data,让调用方少写一层(参考 Axios 响应结构文档)。

Q3:把 axios 实例挂到 Vue.prototype.$axios 和在每个组件里 import 一个封装实例,两种方式怎么选?

A:两者底层是同一个实例,区别在使用心智和工程组织。① Vue.prototype.$axios = instance:所有组件 this.$axios.get(...) 即用,无需 import,方便但隐式依赖(看代码不知道实例从哪来),且对 <script setup>、组合式函数、纯 JS 工具里拿不到 this 的场景不友好。② import request from '@/request':显式依赖、IDE 可跳转、利于 tree-shaking 和单测 mock,是 Vue 3 + Composition API 时代的主流做法。实践中常见的组织:底层 src/request/index.js 导出实例 → src/api/*.js 按模块封装成业务接口函数 → 组件只 import 业务 api 函数,既显式又收敛了 URL 与参数。


五、请求拦截器与响应拦截器

名词解释

  • 请求拦截器:在请求发出前执行的钩子,常用于注入认证 Token、添加公共请求头、开启全局 Loading。
  • 响应拦截器 :在响应到达业务代码前执行的钩子,常用于提取 data 字段、统一处理错误码、关闭 Loading。
  • 拦截器链(interceptor chain):Axios 内部用 Promise 链实现拦截器,请求拦截器从后往前执行(LIFO),响应拦截器从前往后执行(FIFO)。
  • CancelToken :Axios 提供的取消请求机制;新版 Axios(>= 0.22.0)推荐改用 Web 标准的 AbortController

5.1 拦截器的底层执行链原理

Axios 的拦截器通过 Promise 链实现,核心伪代码如下:

ini 复制代码
// Axios 内部 dispatchRequest 的简化逻辑
let chain = [dispatchRequest, undefined]; // [发出请求函数, 错误处理占位]

// 请求拦截器:unshift 到链头部(后注册先执行)
requestInterceptors.forEach(interceptor => {
  chain.unshift(interceptor.fulfilled, interceptor.rejected);
});

// 响应拦截器:push 到链尾部(先注册先执行)
responseInterceptors.forEach(interceptor => {
  chain.push(interceptor.fulfilled, interceptor.rejected);
});

// 最终 chain 结构(假设各有一个拦截器):
// [req.fulfilled, req.rejected, dispatchRequest, undefined, res.fulfilled, res.rejected]

// 用 Promise 依次执行 chain
let promise = Promise.resolve(config);
while (chain.length) {
  promise = promise.then(chain.shift(), chain.shift());
}
return promise;

【代码注释】这段伪代码解释了"为什么多个请求拦截器后注册的先执行":因为用 unshift 将新拦截器插到链的最前面。若注册了两个请求拦截器 A 和 B(A 先注册,B 后注册),执行顺序是 B → A → 发请求 → 响应拦截器。这个顺序对 Token 注入很重要------如果 Token 刷新逻辑在 B 中,格式化在 A 中,确保 B 先执行才能保证 Token 最新。

【代码注释】该图把上面那段伪代码可视化:灰色 chain 数组以 dispatchRequest(橙色)为中点,紫色请求拦截器用 unshift 插到链头 (后注册的 B 排在 A 前),黄色响应拦截器用 push 追到链尾 (先注册的 A 排在 B 前);随后 Promise.resolve(config) 沿数组用 promise.then(fulfilled, rejected) 依次消费每一对回调,自然形成红色注释里的执行顺序------请求拦截 LIFO、响应拦截 FIFO。这正是 Axios 用一个扁平数组 + Promise 链统一表达「前置处理 → 发请求 → 后置处理」三段式的精巧之处(Axios 拦截器源码机制)。市面应用:理解这个顺序能避免一类典型 bug------把"刷新 Token"和"用 Token 签名"分别写在两个请求拦截器里却顺序写反,导致用旧 Token 签名。

5.2 Token 注入与统一错误处理

以下是接近生产级别的拦截器配置:

js 复制代码
// src/request/index.js ------ 生产级 Axios 封装
import axios from "axios";
import router from "@/router"; // 引入路由,用于跳转登录页

const instance = axios.create({
  baseURL: process.env.VUE_APP_BASE_API || "https://api.example.com",
  timeout: 15000,
  headers: { "Content-Type": "application/json" }
});

// ===== 请求拦截器 =====
instance.interceptors.request.use(
  config => {
    // 从 localStorage 取 Token,注入到请求头
    const token = localStorage.getItem("token");
    if (token) {
      // Authorization: Bearer <token> 是 JWT 认证的标准格式
      config.headers["Authorization"] = `Bearer ${token}`;
    }
    // 可以在此开启全局 Loading(如 NProgress.start())
    return config;       // 必须返回 config,否则请求中断
  },
  error => {
    // 请求配置出错(极少见)
    return Promise.reject(error);
  }
);

// ===== 响应拦截器 =====
instance.interceptors.response.use(
  res => {
    // HTTP 状态码 2xx 进入此回调
    const { code, data, message } = res.data;

    // 根据业务约定的 code 字段判断业务是否成功
    if (code === 200 || code === 0) {
      return data;          // 只返回 data,调用方不需要再解构
    }

    // Token 过期:跳转登录页
    if (code === 401) {
      localStorage.removeItem("token");
      router.push("/login");
      return Promise.reject(new Error("登录已过期,请重新登录"));
    }

    // 其他业务错误:统一提示
    // Message.error(message || "操作失败"); // 配合 Element UI 等 UI 库
    return Promise.reject(new Error(message || "未知错误"));
  },
  error => {
    // HTTP 状态码 非2xx 进入此回调
    if (error.response) {
      const { status } = error.response;
      if (status === 401) router.push("/login");
      if (status === 403) router.push("/403");
      if (status === 500) console.error("服务器内部错误");
    } else if (error.code === "ECONNABORTED") {
      console.error("请求超时,请检查网络");
    }
    return Promise.reject(error);
  }
);

export default instance;

【代码注释】config.headers["Authorization"] = \Bearer ${token}`--- JWT(JSON Web Token)认证中,Token 通过Authorization请求头携带,格式为Bearer (参考 [RFC 6750](https://tools.ietf.org/html/rfc6750))。process.env.VUE_APP_BASE_API 是 Vue CLI 环境变量规则:VUE_APP_前缀的变量会被注入到客户端代码。在.env.development中配置VUE_APP_BASE_API=http://localhost:3000`,`.env.production` 中配置生产地址,实现环境切换无需改代码。市面应用 :若依框架(ruoyi-vue)、Ant Design Pro Vue 的 src/utils/request.js 与此结构几乎完全一致,是国内 Vue 项目的事实标准。

5.3 取消请求

防止用户快速切换 Tab 或重复提交时,旧请求的响应覆盖新请求的数据:

js 复制代码
// src/utils/cancelRequest.js ------ 防重复请求工具
import axios from "axios";

const pendingRequests = new Map();

// 生成请求唯一标识(方法 + URL + 参数)
function getRequestKey(config) {
  const { method, url, params, data } = config;
  return `${method}&${url}&${JSON.stringify(params)}&${JSON.stringify(data)}`;
}

// 取消拦截器:添加取消 Token 并记录待处理请求
export function addPendingRequest(config) {
  const key = getRequestKey(config);
  if (pendingRequests.has(key)) {
    // 取消已有的相同请求
    pendingRequests.get(key)("重复请求已取消");
    pendingRequests.delete(key);
  }
  // 新版 Axios >= 0.22.0 推荐用 AbortController
  config.cancelToken = new axios.CancelToken(cancel => {
    pendingRequests.set(key, cancel);
  });
}

// 响应拦截器:请求完成后从 Map 中删除
export function removePendingRequest(config) {
  const key = getRequestKey(config);
  pendingRequests.delete(key);
}

【代码注释】CancelToken 的原理:创建时传入一个 executor 函数,axios 将 cancel 函数作为参数传给 executor;调用 cancel() 时 axios 内部会调用 xhr.abort() 中止 XHR 请求,并让对应 Promise reject。pendingRequests 使用 Map 存储(而非普通对象),是因为 Map 的 key 可以是任意类型,且有 O(1) 的查找性能。市面应用:防抖搜索框(输入时取消上一次请求)、列表页切换筛选条件时取消上一次列表请求,是这个模式最典型的应用场景。

【实战要点】

  • 经典应用场景:请求拦截器的三大用途:① Token 注入(所有需要认证的接口);② 全局 Loading(NProgress 进度条、全屏蒙层);③ 防重复请求(搜索框防抖、按钮防连点)。
  • 常见坑 :在请求拦截器中忘记 return config,请求会中断且没有任何报错,极难排查。每个拦截器的 fulfilled 回调必须返回 config(请求拦截器)或 response(响应拦截器),否则 Promise 链断裂。
  • 性能与最佳实践 :使用 AbortController(浏览器原生 API)代替 CancelToken,因为 CancelToken 已在 Axios v0.22.0 标记为废弃(deprecated),AbortController 是 W3C 标准,兼容性更好(现代浏览器全支持)。

【本章小结】

拦截器类型 执行顺序 常见用途 必须返回
请求拦截器 后注册先执行(LIFO) Token 注入、开 Loading config
响应拦截器 先注册先执行(FIFO) 提取 data、关 Loading、错误码处理 数据或 Promise.reject

记忆口诀:"请求拦截后注册先跑(unshift 进链头),响应拦截先注册先跑(push 到链尾);fulfilled 必返 config 或 res,链断了请求就悄悄没。"

【面试考点】

Q1:Axios 拦截器的执行顺序是什么?

A:Axios 内部用 Promise 链实现拦截器:① 请求拦截器unshift 插入链头,所以后注册的先执行(LIFO,栈顺序);② 中间是实际发请求的函数 dispatchRequest;③ 响应拦截器push 追加链尾,先注册的先执行(FIFO,队列顺序)。整体链路:后请求拦截器 → 先请求拦截器 → 发出请求 → 先响应拦截器 → 后响应拦截器。

Q2:如何实现 axios 请求的取消?新老 API 有何区别?

A:旧 API(CancelToken,已废弃):config.cancelToken = new axios.CancelToken(cancel => { saveCancelFn = cancel }),调用 saveCancelFn() 取消。新 API(推荐):使用 AbortControllerconfig.signal = controller.signal,调用 controller.abort() 取消;AbortController 是浏览器原生 Web API,不依赖 Axios 特有机制,未来兼容性更好。两者都通过内部调用 XMLHttpRequest.abort() 实现网络层中止。

5.4 跨域与开发代理(devServer.proxy)

工程化项目绕不开跨域 :浏览器的同源策略要求协议、域名、端口三者完全一致,而开发时前端跑在 localhost:8080、后端 API 在另一个域名/端口,浏览器会拦截这类跨域请求并报 CORS 错误。Vue CLI 的 devServer.proxy 提供了开发期 的标准解法(Vue CLI 配置参考)。

js 复制代码
// vue.config.js ------ 开发服务器代理配置
module.exports = {
  devServer: {
    proxy: {
      // 匹配以 /api 开头的请求
      "^/api": {
        target: "https://api.example.com", // 真实后端地址
        changeOrigin: true,   // 把请求头 Host 改写为 target 的域名
        pathRewrite: { "^/api": "" }, // 转发前去掉 /api 前缀
        secure: false,        // target 为自签名 HTTPS 时关闭证书校验
        ws: true              // 同时代理 WebSocket
      }
    }
  }
};

【代码注释】代理之所以能绕过 CORS,核心在于真正的跨域请求发生在「dev server → 后端」这一段服务器到服务器之间,而非浏览器到后端 :浏览器只向同源的 localhost:8080/api/... 发请求(同源,不触发 CORS),dev server 收到后用底层的 http-proxy-middleware 把请求转发到 targetchangeOrigin: true 把请求头里的 Host 改成目标域名(很多后端按 Host 路由,不改会 404,且 http-proxy-middleware 默认是 false 故必须显式开启);pathRewrite 用正则去掉只用于「匹配」的 /api 前缀,让 /api/users 实际转发为 /userssecure: false 用于目标是自签名证书的 HTTPS。关键认知 :proxy 只在开发期生效,生产环境必须由后端配置 CORS 响应头,或用 Nginx 等反向代理把前端与 API 收敛到同源(devServer.proxy 工作原理)。市面应用 :几乎所有 Vue CLI 项目的本地联调都依赖这套代理;配合 process.env.VUE_APP_BASE_API 让请求基址在开发(走代理的 /api)与生产(走真实域名)之间无缝切换。

【面试考点】

Q3:开发环境用 devServer.proxy 解决了跨域,为什么生产环境就失效了?正确做法是什么?

A:devServer.proxy 是 Webpack dev server 提供的能力,只在 npm run serve 的开发模式下存在;npm run build 产出的是纯静态文件,没有 Node 代理层,所以生产环境该配置完全不生效。生产解决跨域的正确做法有三种:① 后端在响应头加 Access-Control-Allow-Origin 等 CORS 头;② 用 Nginx 反向代理,把前端静态资源和后端 API 挂在同一域名下的不同 path(如 //api),从根本上变成同源;③ 后端网关统一处理。面试时强调"proxy 是开发期 server-to-server 转发,规避了浏览器同源策略,但不是生产方案"即可。


六、组件拆分实战:TodoList 架构分析

名词解释

  • 组件拆分:将一个大的页面/功能拆解为职责单一的小组件,每个组件管理自己的 UI 和局部状态。
  • 状态提升:当多个组件需要共享同一份数据时,将该数据定义在它们的公共父组件中,通过 props 和事件进行通信。
  • .sync 修饰符 :Vue 2 的语法糖,:taskList.sync="list" 等价于同时写 :taskList="list"@update:taskList="list = $event",用于简化"父传子 + 子通知父更新"的双向通信场景。
  • localStorage 持久化:将数据存入浏览器本地存储,页面刷新后数据不丢失。

概念与底层原理

TodoList 拆分为四个组件的核心决策依据是单一职责原则(SRP)

【代码注释】该架构图体现了 Vue 数据流的核心原则:数据向下流(props),事件向上传(emit)taskList 只在 App.vue 中维护(单一数据源),三个子组件不直接修改它,而是通过 $emit("update:taskList", newList) 通知 App 更新------这是 .sync 修饰符背后的通信协议。这样的架构保证了数据流的可预测性 :任何对 taskList 的修改都发生在 App.vue 的 data 层,避免子组件之间的数据耦合。

入门示例:App.vue 状态中心

vue 复制代码
<!-- src/App.vue ------ TodoList 完整版 -->
<template>
  <div class="todo-container">
    <div class="todo-wrap">
      <!-- .sync 语法糖:等价于 :taskList="taskList" @update:taskList="taskList = $event" -->
      <TodoHeader :taskList.sync="taskList" />
      <TodoMain  :taskList.sync="taskList" />
      <TodoFooter :taskList.sync="taskList" />
    </div>
  </div>
</template>

<script>
import TodoHeader from "@/components/TodoHeader";
import TodoMain   from "@/components/TodoMain";
import TodoFooter from "@/components/TodoFooter";
import "@/assets/css/base.css";

export default {
  name: "App",
  components: { TodoHeader, TodoMain, TodoFooter },
  data() {
    return {
      // 首次加载:从 localStorage 恢复数据;否则初始化为空数组
      taskList: localStorage.getItem("taskList")
        ? JSON.parse(localStorage.getItem("taskList"))
        : []
    }
  },
  updated() {
    // 任何数据变化后(包括 taskList)都将最新状态持久化到 localStorage
    localStorage.setItem("taskList", JSON.stringify(this.taskList));
  }
}
</script>

<style scoped>
.todo-container { width: 600px; margin: 0 auto; }
.todo-container .todo-wrap { padding: 10px; border: 1px solid #ddd; border-radius: 5px; }
</style>

【代码注释】localStorage.getItem("taskList") ? JSON.parse(...) : [] --- 三元运算符实现"有缓存用缓存,没有用默认值"的初始化逻辑。JSON.parse 将 JSON 字符串还原为 JS 对象,JSON.stringify 将 JS 对象序列化为 JSON 字符串。注意:localStorage 只能存字符串,对象/数组必须经过 JSON 序列化。updated 钩子在每次响应式数据变化且视图更新后 执行,是持久化数据的理想时机(因为此时数据已确认变化)。市面应用:便签类、任务管理类应用(Notion、Todoist 的轻量版实现)普遍使用 localStorage 在无后端的情况下实现数据持久化。

实战示例:TodoHeader 添加任务

vue 复制代码
<!-- src/components/TodoHeader.vue -->
<template>
  <div class="todo-header">
    <!-- @keyup.enter 监听回车键,触发添加任务 -->
    <input
      @keyup.enter="addTask"
      type="text"
      placeholder="请输入任务名称,按回车确认"
    />
  </div>
</template>

<script>
export default {
  name: "TodoHeader",
  // 接收父组件传入的 taskList,用于校验重名
  props: {
    taskList: {
      type: Array,
      required: true
    }
  },
  methods: {
    addTask(e) {
      const title = e.target.value.trim();

      // 校验 1:不允许空标题
      if (!title) {
        alert("任务标题不能为空!");
        return;
      }

      // 校验 2:不允许重复标题(some 比 find 更语义化)
      if (this.taskList.some(item => item.title === title)) {
        alert("该任务已存在,请勿重复添加!");
        return;
      }

      // 通过 .sync 协议向父组件提交更新:将新任务插入列表头部
      this.$emit("update:taskList", [
        {
          id: Date.now(),      // 用时间戳作为唯一 ID
          title,
          isChecked: true      // 新任务默认勾选
        },
        ...this.taskList       // 展开原有任务,新任务排在最前
      ]);

      e.target.value = null;   // 清空输入框
    }
  }
}
</script>

<style scoped>
.todo-header input {
  width: 560px; height: 28px; font-size: 14px;
  border: 1px solid #ccc; border-radius: 4px; padding: 4px 7px;
}
.todo-header input:focus {
  outline: none;
  border-color: rgba(82, 168, 236, 0.8);
  box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(82,168,236,.6);
}
</style>

【代码注释】this.$emit("update:taskList", newList) --- .sync 修饰符要求 emit 的事件名固定为 update:<propName>,这是 Vue 2 为双向 prop 绑定设定的命名约定。[{ id: Date.now(), ... }, ...this.taskList] --- 使用展开运算符创建新数组,不是修改原 taskList ,而是创建一个新数组作为 emit 的值。这很重要:直接 push 修改 props 是 Vue 反模式,会导致数据流向混乱,同时触发 Vue 的 props 修改警告。市面应用:表单提交后清空输入框、将新数据插入列表头部(而非尾部,让最新内容优先可见),是几乎所有增删改查场景的标准 UX 模式。

TodoFooter 全选与统计

vue 复制代码
<!-- src/components/TodoFooter.vue -->
<template>
  <div class="todo-footer">
    <label>
      <!-- :checked 绑定计算出的全选状态,@change 触发全选/取消全选 -->
      <input @change="toggleAllChecked" :checked="isAllChecked" type="checkbox" />
    </label>
    <!-- reduce 计算已选数量,array.length 获取总数 -->
    <span>已选中 {{ checkedNum }} / 全部 {{ taskList.length }}</span>
    <!-- 只有存在已选任务时才显示"清除"按钮 -->
    <button @click="clearChecked" v-show="checkedNum > 0" class="btn btn-danger">
      清除已选任务
    </button>
  </div>
</template>

<script>
export default {
  name: "TodoFooter",
  props: ["taskList"],
  computed: {
    // 用 reduce 统计选中数量,比 filter().length 少一次遍历
    checkedNum() {
      return this.taskList.reduce((count, item) => {
        return item.isChecked ? count + 1 : count;
      }, 0);
    },
    // 全选状态:已选数 === 总数且总数 > 0
    isAllChecked() {
      return this.checkedNum === this.taskList.length && this.taskList.length > 0;
    }
  },
  methods: {
    toggleAllChecked() {
      // 全选/取消全选:将所有任务的 isChecked 取反(基于当前全选状态)
      this.$emit("update:taskList", this.taskList.map(item => ({
        ...item,
        isChecked: !this.isAllChecked
      })));
    },
    clearChecked() {
      // 过滤掉已选任务,保留未选任务
      this.$emit("update:taskList", this.taskList.filter(item => !item.isChecked));
    }
  }
}
</script>

【代码注释】reduce 统计选中数是经典的数组归约用法:从初始值 0 开始,遍历数组,每遇到 isChecked === true 的项就 +1。相比 filter(item => item.isChecked).length(遍历两次),reduce 只需一次遍历,在大列表场景下性能更优。isAllChecked 用 computed 而非 data 的原因:全选状态是由 taskList 派生的结论 ,不应该独立存储(否则会出现数据不同步的 bug);computed 会在 taskList 变化时自动重新计算,始终与 taskList 保持一致。市面应用:电商购物车的"全选/取消全选"、数据表格的批量操作,是这种 computed + emit 模式的最典型应用场景。

【实战要点】

  • 经典应用场景:TodoList 的拆分思路可以直接套用到任意增删改查页面------List 组件展示数据、Form/Header 组件输入数据、Footer/Toolbar 组件做批量操作,共享状态放在父组件。
  • 常见坑① :在子组件中直接 this.taskList.push(item) 修改 props,虽然 JavaScript 层面可以成功(数组是引用类型),但 Vue 会发出警告,且破坏了单向数据流,导致调试困难。正确做法:创建新数组,通过 emit 通知父组件。
  • 常见坑②updated 钩子每次数据更新都会执行,在其中做 localStorage.setItem 是可接受的,但不应在其中再次修改 data 数据(会导致无限循环触发 updated)。
  • 性能与最佳实践Date.now() 作为临时 ID 在单用户本地场景是够用的,但不适合多用户或需要与后端同步的场景(会有冲突);正式项目应使用后端返回的 ID 或 UUID 库生成唯一 ID。

【本章小结】

组件 职责 通信方式 关键技术
App.vue 数据中心、持久化 传 props、监听 update 事件 .sync、localStorage、updated
TodoHeader 添加任务 emit update:taskList @keyup.enter、数组展开
TodoMain 展示、删除、切换 emit update:taskList @mouseenter、filter、map
TodoFooter 全选、统计、清除 emit update:taskList reduce、computed、filter

记忆口诀:"状态提升到父级,数据向下事件向上;子组件别改 props,emit update 配 .sync;localStorage 存字符串,JSON 序列化别忘。"

【面试考点】

Q1:Vue 2 的 .sync 修饰符是什么?和 v-model 有什么区别?

A:.sync 是 Vue 2 的语法糖,:prop.sync="val" 展开为 :prop="val" @update:prop="val = $event"v-model 也是语法糖,展开为 :value="val" @input="val = $event"(对组件而言,具体的 prop 和 event 名可通过 model 选项自定义)。区别:v-model 通常用于表单元素或实现双向绑定的单个值;.sync 用于组件 props 的双向通信,且支持同时对多个 props 使用(每个 prop 各一个 .sync),而 v-model 一个组件只能用一个(Vue 3 改为支持多个 v-model)。

Q2:为什么要把 taskList 放在父组件 App.vue 而不是各个子组件中?

A:这是 React/Vue 共同遵循的**状态提升(Lifting State Up)**原则。TodoHeader 需要读 taskList(校验重名),TodoMain 需要渲染 taskList,TodoFooter 需要统计 taskList------三个子组件都依赖同一份数据。如果数据分散存在各组件中,兄弟组件之间通信复杂(需要事件总线或 Vuex),还容易出现数据不一致。把 taskList 提升到三者的最近公共父组件 App.vue,通过 props 向下分发,通过 emit 向上通知修改,数据永远只有一个来源(单一数据源),状态变化可追溯,是 Vue 组件设计的最佳实践。


总结

知识点回顾(思维导图)

【代码注释】思维导图从工程化的核心维度展开:SFC 是代码组织方式,render 函数是 SFC 的底层支撑,过滤器是 Vue 2 数据格式化的声明式方案,Axios 封装是网络层的工程化实践,TodoList 是以上所有概念的综合运用案例。

高频面试题速查

问题 核心答案关键词
SFC 如何被编译? vue-loader → 拆块 → vue-template-compiler → render 函数 → Webpack 打包
为什么 main.js 用 render 而非 template? Runtime-only 无 compiler,template 需运行时编译,SFC 在构建时已编译
render 函数 h 是什么? createElement 别名,接收 tag/组件、data、children,返回 VNode
过滤器的 this 指向? window,不能访问 Vue 实例,是纯函数设计
过滤器和 computed 的区别? filter 无缓存、有参数、纯格式化;computed 有缓存、无参数、支持依赖追踪
Vue 3 为何废弃 filter? 与 JS 按位或运算符 `
axios.create 的作用? 创建独立实例,隔离 baseURL/拦截器配置,支持多后端服务
请求拦截器执行顺序? 后注册先执行(LIFO),Promise 链 unshift 实现
.sync 修饰符的展开形式? :prop="val" @update:prop="val = $event"
状态提升是什么? 将多组件共享数据上移到最近公共父组件,通过 props+emit 通信

学习建议

  1. 先跑起来再看原理 :用 Vue CLI 创建一个项目(vue create my-app),亲手添加一个过滤器、封装一个 axios 实例、拆一个组件,感受工程化带来的好处。
  2. 带问题读源码 :了解 vue-loader 工作原理,去 vue-loader GitHubtransform 关键字,看 template 块是如何被转为 render 函数的。
  3. 过滤器要会迁移 :Vue 3 已废弃 filter,做技术栈升级时,先用脚本(如 vue-codemod)批量替换,再手动逐一验证。
  4. Axios 封装要动手:参考本文的生产级封装,为自己的下一个项目搭建请求层,加入 Token 注入、错误码处理、取消重复请求三件套。
  5. TodoList 要举一反三:TodoList 的四组件架构(状态中心 + 输入 + 列表 + 底部操作)可以直接套用到任何增删改查模块------文章管理、用户管理、权限管理,架构思路完全相同。

参考资料:

相关推荐
lichenyang4531 小时前
IPC、JSVM、UIThread、libuv:ASCF 架构图里最容易混的几个词
前端
用户059540174461 小时前
Redis记忆存储故障恢复测试踩坑实录:手动测试让我漏掉了2个一致性Bug
前端·css
我不是外星人1 小时前
我把 Claude Code 搬到网页!自研高颜值 Web 交互工作台
前端·ai编程·claude
mixuecoding1 小时前
零成本搭建全球科技热点情报站:12 个平台,6 小时,0 元
前端
用户059540174461 小时前
用了3年Mock,才发现Redis记忆存储的测试一直漏掉了60%的边界场景
前端·css
石小石Orz1 小时前
AI具身交互:实现一个会说话的3D虚拟伴侣
前端·人工智能·后端
Muen2 小时前
iOS设计模式-外观Facade
前端
Cobyte2 小时前
21.Vue Vapor 组件的实现原理
前端·javascript·vue.js
前端双越老师2 小时前
我从 0 开发的 AI Agent 智语项目发布了
前端·node.js·agent