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 为什么要学本篇
- 工程化是门槛:面试中 "你们用脚手架吗?" 几乎是必问题。了解 vue-loader、SFC 结构、render 函数,才能回答得有底气。
- 过滤器是高频考点:即使 Vue 3 已废弃,面试官仍会考"过滤器原理"和"Vue 3 如何替代",因为它考的是你对 Vue 设计哲学的理解。
- Axios 封装是生产必备:每个正式项目都需要统一的请求层,Token 注入、错误码映射、Loading 管理、取消重复请求------这四件事做好了,项目的网络层就稳了。
- 组件拆分是思维训练: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 官方文档):
- 拆块 :vue-loader 将
.vue文件拆分为<template>、<script>、<style>三块,各块作为独立的"虚拟模块"传递给对应的 loader 处理。 - 编译 template :
<template>块交给vue-template-compiler,将 HTML 模板字符串编译为render函数(JavaScript 代码)。 - 处理 script :
<script>块交给 Babel 等 JS loader 处理,最终导出组件配置对象。 - 处理 style :
<style>块交给css-loader、style-loader等处理;若有scoped属性,vue-loader 额外注入 CSS 属性选择器。 - 合并 :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) } 的箭头函数简写。h 是 createElement 的约定俗成的别名(来自德语 Hyperscript)。.$mount("#app") 等价于配置 el: "#app",但 $mount 更灵活,可以在异步逻辑完成后再挂载。
【实战要点】
- 经典应用场景 :
main.js是注册全局插件(Vue.use(VueRouter)、Vue.use(Vuex))的标准位置;也是在beforeCreate钩子中配置 Axios 实例、事件总线的地方。 - 常见坑 :在
main.js中对new Vue({...})的beforeCreate里调用Vue.filter或Vue.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-compiler、babel-loader、css-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 数据对象(含 attrs、class、style、on 等);第三个参数是子 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:h 是 createElement 的约定别名(取自 hyperscript------"生成 HTML 的脚本"),是 Vue 用来创建 VNode(虚拟节点)的工厂函数。它的完整签名是 createElement(tag, data?, children?):① 第一个参数 tag 是 HTML 标签名字符串(如 'div')、组件选项对象、或返回上述类型的异步函数;② 第二个参数 data 是 VNode 的数据对象,承载 attrs、props、class、style、on(事件监听)等配置(可省略);③ 第三个参数 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),与组件选项合并策略相同。
过滤器函数有两个约束:
this指向window,不能访问 Vue 实例数据(因为过滤器设计为纯函数)。- 不能用于
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 是管道符左侧的值(自动传入),n 和 type 是模板调用时传递的额外参数。{{ 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-router、vuex、axios 的 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(),内部会:
- 合并配置(实例配置、请求配置、默认配置);
- 依次执行所有请求拦截器(后注册的先执行,LIFO 顺序);
- 发出实际 HTTP 请求(浏览器中使用 XMLHttpRequest);
- 依次执行所有响应拦截器(先注册的先执行,FIFO 顺序);
- 返回最终的 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 响应对象(包含 data、status、headers 等字段)。注意 :真正的业务数据在 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.html 中 id="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.$axios或import userApi from '@/api/user'。 - 常见坑 :直接修改
axios.defaults.baseURL会污染全局 axios,影响同一应用中其他也import axios的地方。务必用axios.create()。 - 性能与最佳实践 :请求超时(
timeout)应始终配置,避免因后端接口 hang 住导致前端页面长时间卡在 Loading 状态;生产项目超时通常设置为 10-30s,具体视业务而定。
【本章小结】
| 步骤 | 代码位置 | 关键配置 |
|---|---|---|
| 创建实例 | src/request/index.js |
baseURL、timeout |
| 响应拦截 | 同上 | 提取 .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(状态码)、statusText、headers、config、request 等元信息;真正的业务数据在 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(推荐):使用 AbortController,config.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 把请求转发到 target。changeOrigin: true 把请求头里的 Host 改成目标域名(很多后端按 Host 路由,不改会 404,且 http-proxy-middleware 默认是 false 故必须显式开启);pathRewrite 用正则去掉只用于「匹配」的 /api 前缀,让 /api/users 实际转发为 /users;secure: 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 通信 |
学习建议
- 先跑起来再看原理 :用 Vue CLI 创建一个项目(
vue create my-app),亲手添加一个过滤器、封装一个 axios 实例、拆一个组件,感受工程化带来的好处。 - 带问题读源码 :了解 vue-loader 工作原理,去 vue-loader GitHub 搜
transform关键字,看 template 块是如何被转为 render 函数的。 - 过滤器要会迁移 :Vue 3 已废弃 filter,做技术栈升级时,先用脚本(如
vue-codemod)批量替换,再手动逐一验证。 - Axios 封装要动手:参考本文的生产级封装,为自己的下一个项目搭建请求层,加入 Token 注入、错误码处理、取消重复请求三件套。
- TodoList 要举一反三:TodoList 的四组件架构(状态中心 + 输入 + 列表 + 底部操作)可以直接套用到任何增删改查模块------文章管理、用户管理、权限管理,架构思路完全相同。
参考资料: