重温前端框架 Vue

Vue基础

MVVM

  • Model(模型): 在 Vue 组件中,这通常对应 data 函数返回的对象。它只是纯粹的 JavaScript 对象,包含了你的业务数据。
  • View(视图): 这就是 Vue 的模板部分(HTML 代码)。它通过指令(如 {{ }} 或 v-bind)声明式地描述了数据应该如何呈现。
  • ViewModel(视图模型): 这是 Vue 的核心。它负责监听 Model 的变化并自动更新 View,同时也监听 View 的输入事件(如表单输入)并同步给 Model。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<div id="app">
<h1>你好,{{ username }}!</h1>
<input type="text" v-model="username" placeholder="请输入你的名字">
</div>

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
// 【ViewModel 层】:Vue 实例
const { createApp } = Vue;

createApp({
// 【Model 层】:纯数据
data() {
return {
username: '路人甲'
}
}
}).mount('#app');
</script>

Vue的优点和特点

1. 双向绑定的演进(技术细节修正)

 Object.defineProperty (Vue 2) 和 Proxy (Vue 3) 是面试常考点。

  • Vue 2 的局限:它像是在对象属性上装了“监控”,但如果你给对象新增一个属性,或者直接修改数组下标,它可能“看漏了”,需要用 this.$set
  • Vue 3 的 Proxy:它是直接在整个对象外层加了层“代理”,管你新增还是删除,统统逃不过它的眼睛。性能更好,限制更少。

2. 虚拟 DOM 与 Diff 算法

原生 DOM 操作永远是最快的,但前提是你只改一处。 虚拟 DOM 的价值在于:当你一次性通过逻辑修改了 100 个地方时,它会在内存里先算好最优解,最后只对真实网页动“微整形手术”,而不是推倒重来。

3. 组件化:像搭积木一样开发

Vue 的 .vue 文件(SFC, Single File Component)把 HTML、CSS、JS 揉在一起,这种高度内聚让组件像插件一样,哪里需要插哪里。

  • 真正的痛点:不是生态不全,而是生态碎片化。由于 Vue 2 到 Vue 3 的大版本更迭,导致一些老旧的第三方插件无法在 Vue 3 中使用。
  • 企业选择:React 确实在大型跨国项目、大前端混合开发(React Native)上更强势;而 Vue 在国内政企、中小型项目以及追求开发效率的团队中是绝对的主流。

v-show 和 v-if 的区别

想象你在装修一个房间:

  • v-show(拉帘子): 你装了一个衣柜,平时不用时拉上帘子(display: none)。柜子一直都在房间里,只是你看不到。你想用时,拉开帘子就行,非常快。
  • v-if(拆迁办): 你觉得现在不需要衣柜,直接把柜子拆了,木材扔出去(DOM 销毁)。等你需要用时,再叫工人们现场重新组装一个(DOM 重建)。
特性 v-show v-if
手段 修改 CSS 的 display 属性 真实的 DOM 挂载与卸载
初始开销 (不管显不显示,都要先建好柜子) (如果不显示,连材料都不买)
切换开销 (只是拉个帘子) (每次都要施工、招工)
适用场景 频繁切换的状态(如:Tab 页、弹窗提示) 条件不常改变的状态(如:用户权限控制)

v-if 和 v-for 为什么不建议一起使用呢

v-for 优先级更高”是 Vue 2 中的核心逻辑(注意:Vue 3 中 v-if 的优先级反而变高了,但官方依然严禁两者写在一起,因为会导致逻辑混乱)。

1. 错误的例子:浪费劳动力

1
<li v-for="user in users" v-if="user.isActive"> {{ user.name }} </li>

ViewModel 的内心独白: “我要开始循环 1000 个用户了!每循环到一个,我就得停下来问一下:‘哎,这个用户 isActive 是 true 吗?’ 如果是 false,我就把刚造出来的这个 DOM 节点扔掉。” —— 结论: 做了 1000 次判断,白干了很多活。

2. 解决方案 A:外层嵌套 template(先判断,再循环)

如果你是想控制整个列表的显示,就用这个:

1
2
3
<template v-if="isShow">
<li v-for="user in users">{{ user.name }}</li>
</template>
  • 好处:如果 isShow 为 false,循环根本不会开始。template 标签在渲染后会消失,不会污染 HTML 结构。

3. 解决方案 B:计算属性 computed(先过滤,再循环)—— 最推荐

1
2
3
4
5
computed: {
activeUsers() {
return this.users.filter(user => user.isActive);
}
}
1
<li v-for="user in activeUsers">{{ user.name }}</li>
  • 好处
    1. 性能最优:循环的已经是过滤好的纯净数组。
    2. 逻辑清晰:视图层(View)只负责展示,逻辑交给 JS 处理。
    3. 解耦:如果 users 没变,activeUsers 会直接使用缓存,不会重新计算。

双向数据绑定的原理

你提供的文本中提到了 Vue 2 用的 Object.defineProperty 和 Vue 3 用的 Proxy。我们直接用原生 JS 写出它们的核心区别。

1. Vue 2.x 的 Object.defineProperty

这是 Vue 2 响应式系统的核心。它的特点是:必须具体到对象里的某一个特定的 key 进行拦截。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 模拟 Vue 2 的 data
let data = { name: '张三' };

// 模拟 Vue 2 的 Observer (数据劫持)
Object.defineProperty(data, 'name', {
get() {
console.log('【Compile 收集依赖】:有人读取了 name');
return '张三';
},
set(newValue) {
console.log(`【Watcher 触发更新】:name 变成了 ${newValue},赶紧去更新视图!`);
// 实际源码中这里会触发 Watcher 去更新 DOM
}
});

// 测试读取和修改
console.log(data.name); // 触发 get
data.name = '李四'; // 触发 set

// 【致命缺陷暴露】:
data.age = 18; // 新增一个属性,没有任何 console 打印!Vue 2 监听不到,所以需要 this.$set

2. Vue 3.x 的 Proxy 实例

Vue 3 放弃了对单个特定属性的劫持,改为直接拦截整个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 模拟 Vue 3 的 data
let data3 = { name: '张三' };

// 模拟 Vue 3 的响应式代理
let reactiveData = new Proxy(data3, {
get(target, key) {
console.log(`【追踪依赖】:读取了整个对象中的 ${key} 属性`);
return target[key];
},
set(target, key, newValue) {
console.log(`【触发更新】:对象中的 ${key} 属性变为了 ${newValue},更新视图!`);
target[key] = newValue;
return true;
}
});

// 测试读取和修改
console.log(reactiveData.name); // 触发 get
reactiveData.name = '李四'; // 触发 set

// 【解决痛点】:
reactiveData.age = 18; // 新增属性,完美触发 set 打印!无需任何额外 API。

computed、watch 和 methods 的实际应用场景对比

文本里提到了它们在“缓存”、“异步”和“使用场景”上的区别,我们把它们放在同一个电商页面的代码实例中来看。

实际业务场景:一个带搜索功能的购物车

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export default {
data() {
return {
keyword: '', // 搜索框输入的值
price: 100, // 单价
quantity: 2, // 数量
searchResults: [] // 搜索结果
}
},

// 【1. computed 实例:依赖多个值,自动计算,且有缓存】
// 场景:计算购物车总价。
computed: {
totalPrice() {
console.log('计算了一次总价'); // 只要 price 和 quantity 不变,这个 console 绝不会触发第二次
return this.price * this.quantity;
}
},

// 【2. watch 实例:监听一个值,执行复杂的异步操作,无缓存】
// 场景:用户输入关键词,自动向后端发送 AJAX 请求获取商品。
watch: {
keyword(newValue, oldValue) {
console.log(`用户输入了:${newValue},准备发起网络请求...`);
// watch 支持异步操作,这是 computed 做不到的
setTimeout(() => {
// 模拟 Axios 请求后端接口
this.searchResults = ['商品A', '商品B'];
}, 500);
}
},

// 【3. methods 实例:手动触发,无缓存】
// 场景:用户点击“提交订单”按钮。
methods: {
submitOrder() {
// 必须被 @click 等事件主动调用才执行
console.log(`提交订单,总价为:${this.totalPrice}`);
}
}
}

总结对比:

  • 缓存测试:如果你在模板中写了两次 {{ totalPrice }}computed 里的 console.log 只会打印一次。如果你把它写成 methods: { getTotal() { return ... } },并在模板调用两次 {{ getTotal() }},它会计算两次
  • 异步限制:如果你试图在 computed 里写 setTimeout 返回数据,页面上只会显示 undefined,因为 computed 必须同步 return 结果。而 watch 可以在回调里随意执行异步的 API 请求。

.vue 文件的本质

浏览器绝对不认识 .vue 结尾的文件,它只认识 HTML、CSS 和纯正的 JS。 一个 .vue 文件(官方称为 Single-File Component,单文件组件)其实是为了方便开发者阅读而发明的结构化图纸

在开发阶段,打包工具(如 Vite / Webpack)会充当“翻译官”,把这个文件强行拆解:

  1. <template>(看似是 HTML,其实是 JS)
    • 你以为的: 它是一段插入到页面上的 HTML 代码。
    • 真相: 在打包时,模板编译器会把你写的 <div>{{ msg }}</div> 翻译成一个纯 JavaScript 的 render() 函数。这完全脱离了普通 HTML 的范畴。
  2. <script>(组件的灵魂,大脑)
    • 你以为的: 它就是普通的 JS 脚本。
    • 真相: 它导出的其实只是一个配置对象(Options)。Vue 引擎在运行时,会拿着这个对象,去实例化出一个真正的、具有响应式能力的组件实例。
  3. <style scoped>(带作用域的 CSS)
    • 你以为的: 只是写在这个文件里的样式。
    • 真相: 打包工具会在编译时给当前组件的所有 HTML 标签打上一个独一无二的随机码(比如 data-v-f3f3eg9),并把 CSS 选择器也加上这个后缀,从而实现“样式不外泄”,这在原生开发中极其繁琐。

Vue 到底和原生 JS 有什么不同?

1. 编程范式:命令式 vs 声明式(核心不同)

  • 原生 JS(命令式)—— 你是“微操大师” 你要亲自指挥每一步。

    “找到 id 为 btn 的按钮,给它加个点击事件,点击后找到 id 为 box 的 div,把它的 innerText 改为 ‘你好’,再把它的颜色改成红色。”

  • Vue(声明式)—— 你是“大老板” 你只管定状态,不管怎么执行。

    你在图纸上声明 <div :style="{color: myColor}">{{ msg }}</div>。然后你只需要在 JS 里写 this.msg = '你好'; this.myColor = 'red';。 不同点: Vue 内部封装了所有的 DOM 操作逻辑。你彻底告别了 document.getElementById 和 addEventListener

2. 变量的性质:死变量 vs 活变量(响应式魔法)

  • 原生 JS:变量是“死”的(无感知)

    1
    2
    3
    let a = 1;
    let b = a + 1; // b 是 2
    a = 10; // a 变了,但 b 依然是 2!因为普通的 JS 没有自动追踪的能力。
  • Vue:变量是“活”的(响应式系统) 当你把数据交给 Vue 的 data() 后,Vue 用 Object.defineProperty(Vue 2) 或 Proxy (Vue 3) 把这些变量变成了监控对象。 不同点: 在 Vue 里,只要 a 变了,依赖 a 的计算属性(相当于 b)会自动重新计算,依赖 a 的页面视图会自动刷新。这就叫数据劫持 + 依赖追踪

3. 页面更新机制:直接改真实 DOM vs 虚拟 DOM

  • 原生 JS: 一旦你修改 DOM(比如往列表塞 1000 个数据),浏览器会立刻在页面上进行重绘和重排,性能消耗极大。
  • Vue: 当数据变化时,Vue 不会马上碰真实的网页。
    1. 它先在 JS 的内存里,光速生成一棵虚拟 DOM 树(本质上是用 JS 对象模拟的 HTML 结构)。
    2. 把新树和老树进行对比(Diff 算法),找出“究竟是哪个字、哪个标签变了”。
    3. 最后,Vue 像外科手术一样,只把改变的那一丁点地方在真实网页上更新掉。

一个 Vue 组件的完整运行

把上面所有的知识串联起来,当你打开一个用 Vue 写的网页时,底层经历了这 4 个阶段:

阶段 发生了什么事(底层原理)
1. 编译期 (Build Time) Vite 把 .vue 文件的 <template> 翻译成 JS 的 render 函数,拆离 CSS,把代码变成浏览器认识的纯 JS/CSS 文件。
像 Vite 这样的工具,看到了你的 MyComponent.vue 文件。它把这个文件大卸八块:
- 提取 <template>,编译成前面说的 render 函数。
- 提取 <script> 里的 export default 对象。
- 将 render 函数悄悄塞进你的 export default 对象里。 此时,你的对象变成了:{ data: ..., methods: ..., render: function(){...} }
2. 初始化 (Init) 网页加载 JS,Vue 开始执行。Vue 拿到你的 export default {} 配置单,给 data 加上 Proxy 监控,把 methods 绑好 this,创建一个组件实例。
3. 挂载 (Mount) Vue 运行刚才编译好的 render 函数,生成了内存里的虚拟 DOM。接着把虚拟 DOM 变成真实的 HTML,塞到页面上的 <div id="app"> 里。用户看到了界面。
4. 更新 (Update) 用户点击按钮,修改了 this.keyword。监控(Proxy)立刻察觉,通知 Watcher。Watcher 通知 render 函数重新执行一遍。Vue 对比新旧虚拟 DOM,最后修改真实网页。闭环完成。

举个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<template>
<div style="padding: 20px;">
<h1>我的 Vue 实验田 🧪</h1>
<input v-model="keyword" placeholder="搜点什么..." />
<p>你正在搜索:{{ keyword }}</p>
<p>字数统计:{{ keywordLength }} 个字</p>
</div>
</template>

<script>
// 这个文件默认匿名导出一个JavaScript 对象
export default {
data() { // ES6 对象方法的简写。它本质上是一个叫 data 的函数;data: function()
// data 带括号,因为它是一个“数据工厂函数”,专门负责给每一个新诞生的组件实例发一份独一无二的初始数据。
return {
keyword: ''
}
},
computed: {
keywordLength() {
// 在普通的 JavaScript 里,this 的指向非常混乱,谁调用它,它就指向谁。但在 Vue 的 Options API(选项式写法)里,this 永远、绝对、被死死地绑定为了“当前组件的实例(Vue Instance)”。
return this.keyword.length
}
}
}
</script>

当你把 methods 和 computed 交给 Vue 时,Vue 在底层初始化这个组件时,会做这样一个操作(伪代码):

1
2
3
4
5
// Vue 源码底层的大致逻辑
for (let key in options.methods) {
// 强行把每一个 method 里的 this,都绑死在当前组件实例(vm)上
this[key] = options.methods[key].bind(vm);
}

所以,无论你在哪里调用 methods 里的函数,this 都稳稳地指向组件实例。

数据代理(Data Proxy)

你会发现一个奇怪的现象:我们在 data() 里 return { keyword: '搜索' }。 按理说,访问它应该写成 this.data().keyword 才对,为什么直接写 this.keyword 就能拿到呢?

Vue 的真实做法(偷梁换柱):

  1. 只执行一次:当组件刚创建时,Vue 会在底层偷偷调用一次你的 data() 函数。
  2. 私藏结果:Vue 把调用后返回的那个真实数据对象,偷偷存到了组件实例的一个内部变量里(在 Vue 2 中,这个变量叫 this._data 或 this.$data)。
  3. 建立快捷通道(代理):为了让你少敲键盘,Vue 遍历了 this._data 里的所有属性,直接在 this 的最外层给你铺好了路。

底层大致是这么干的:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. Vue 内部先拿到你的数据
const realData = this.data(); // { keyword: '搜索' }
this.$data = realData;

// 2. Vue 在 this 上给你铺路(代理)
Object.defineProperty(this, 'keyword', {
get() {
return this.$data.keyword; // 当你问 this 要 keyword 时,Vue 实际上去 $data 里拿给你
},
set(newValue) {
this.$data.keyword = newValue; // 当你修改 this.keyword 时,Vue 实际上改的是 $data
}
});

结论:你访问的 this.keyword,其实是一个“快捷方式”,它背后偷偷指向了那唯一一次 data() 执行后留下的产物。

<template> 里的变量是怎么拿到数据的?

我们在 HTML 模板里写 {{ keyword }},并没有写 this.keyword,为什么页面上能显示出来?
核心真相:<template> 根本不是真正的 HTML,它最终会被编译成一段 JavaScript 函数!
当你运行项目时,Vite 或 Webpack 底层有一个叫 Vue Compiler(编译器)的工具。它会把你的模板“翻译”成下面这样的一段 JS 渲染函数(Render Function):

你的模板:

1
2
3
<template>
<div>{{ keyword }}</div>
</template>

编译后的 JS 真实面目(极简版):

1
2
3
4
5
6
7
function render() {
// Vue 2 中,底层会使用 with(this) 语法,让你在内部可以直接省去 this 前缀
with(this) {
return createElement('div', keyword);
// 因为包裹在 with(this) 里,这里的 keyword 就等同于 this.keyword!
}
}

(注:Vue 3 废弃了 with 语法,但在编译时会自动帮你把 keyword 补全为 _ctx.keyword,原理是一样的。)

结论: <template> 在打包时已经被翻译成了 JS 代码。在这些 JS 代码执行时,它们依然处在组件实例(this)的上下文中,所以它们能轻松读到你的变量。

为什么 <template> 里 keywordLength 不带括号?

这也是一个绝妙的问题。在 computed: { keywordLength() { return ... } } 中,它明明是个函数,为什么在模板里像变量一样用?

核心真相:Vue 把你的普通函数,变成了一个“属性(Property)的 Getter”。

在 JavaScript 中,有一种特殊的属性叫做 访问器属性(Accessor Property)。当你读取它时,它会自动触发一个函数。Vue 就是利用了这个特性。

当 Vue 读取你的 computed 配置时,它做了这样的事:

1
2
3
4
5
6
7
8
9
10
// 你写的 computed 函数:
const userComputedFunction = function() {
return this.keyword.length;
}

// Vue 底层对你的 this(组件实例)做了改造:
Object.defineProperty(this, 'keywordLength', {
// 把你写的函数,作为这个属性的 get 方法
get: userComputedFunction
});

发生了什么? 经过这层改造后,keywordLength 已经不再是一个可以被你调用的普通函数了,它变成了挂在 this 上的一个属性。 根据 JavaScript 的语法,当你读取 this.keywordLength(或者在模板里写 {{ keywordLength }})时,JS 引擎会自动执行它背后的 get 函数,并把 return 的结果给你。

这就是为什么你不带括号,却能执行函数逻辑的原因!

终极对比验证:如果放在 methods 里呢?

可以做一个对比。如果你把求字数的功能写在 methods 里:

1
2
3
4
5
6
7
8
9
10
export default {
data() { return { keyword: '搜索' } },

methods: {
// 这是一个普通的实例方法
getKeywordLength() {
return this.keyword.length;
}
}
}

Vue 对 methods 的处理非常老实,它不会把你变成 Getter,而是直接原封不动地挂在 this 上:this.getKeywordLength = 你的函数

Vue中的插槽使用

插槽的本质,就是“父组件传给子组件的回调函数”!

“父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。”

为什么会这样?因为我们在上一条聊过,<template> 最终会被编译成一段 JS 函数。

  • 父组件的 HTML,变成了父组件里的 renderParent() 函数。
  • 子组件的 HTML,变成了子组件里的 renderChild() 函数。

当我们写 <slot></slot> 时,底层到底发生了什么?

1. 基础插槽(默认插槽)

在父组件里,你写了这样一段代码:

1
2
3
<MyChild>
<p>我是传递的内容</p>
</MyChild>

在 Vue 编译后,父组件其实是把这行 <p> 包装成了一个函数,当作一个属性传给了子组件:

1
2
3
4
5
6
7
// 父组件底层的伪代码
MyChild({
slots: {
// 默认插槽被编译成了一个叫 default 的函数
default: function() { return createElement('p', '我是传递的内容'); }
}
})

而在子组件里,那个 <slot></slot> 标签,在底层其实就是一句极其简单的 JS 代码:

1
2
// 子组件底层执行了父组件传过来的函数!
this.$slots.default()

2. 具名插槽(按名字找函数)

理解了上面那点,具名插槽就太简单了。父组件无非就是传了一个包含多个函数的对象给子组件而已。

1
2
3
4
5
6
7
// 父组件底层的伪代码
MyChild({
slots: {
header: function() { return createElement('h1', '头部'); },
footer: function() { return createElement('button', '尾部'); }
}
})

子组件里的 <slot name="header"></slot>,在底层翻译过来就是:

1
this.$slots.header() 

作用域插槽(Scoped Slots)是怎么传数据的?

平时我们写代码会觉得很别扭:数据明明在子组件里,为什么要在父组件的 HTML 里去使用它?

1
2
3
4
5
<MyChild>
<template v-slot:default="slotProps">
{{ slotProps.user.firstName }}
</template>
</MyChild>

现在,用我们刚学到的“函数”视角来看,一切都解释得通了!

1. 父组件提供了一个“带参数的函数” 父组件在编译时,发现你需要用到 slotProps,于是它把这个插槽编译成了一个需要接收参数的函数

1
2
3
4
5
6
7
8
9
// 父组件底层的伪代码:我定义了一个函数,等子组件来调用,并等着子组件给我传 args
MyChild({
slots: {
default: function(args) {
// 这里的 args 就是你在模板里写的 slotProps
return createElement('div', args.user.firstName);
}
}
})

2. 子组件调用函数,并塞入自己的数据 你在子组件里写了 <slot :user="user"></slot>,这在底层就是子组件在执行父组件传过来的函数时,把自己的内部数据作为参数传了进去

1
2
3
4
5
// 子组件内部的 data
const user = { firstName: '张', lastName: '三' };

// 子组件底层执行插槽,并把 user 作为参数塞进去
this.$slots.default({ user: user })

总结

剥开 HTML 标签的外衣:

  1. 普通插槽:子组件无脑执行父组件传过来的 this.$slots.default()
  2. 具名插槽:子组件有选择地执行 this.$slots.名字()
  3. 作用域插槽:子组件在执行父组件的函数时,把自己兜里的数据掏出来塞了进去:this.$slots.default(我的内部数据)。父组件的函数拿到数据后,终于可以完成页面的渲染。

Vue中的key

关于 key ,我们常常在 v-for 中会接触到,当我们需要进行列表循环的时候,如果没有使用 key ,就会有警报提示。

1
2
3
4
//场景一:循环列表 
<div v-for="num in numbers" :key="index"> {{num}} </div>
//场景二:通过时间戳强制重新渲染
<div :key="+new Date()" >+new Date()</div>

1. key 的本质:虚拟 DOM 的“身份证”

它帮助 Vue 的 Diff 算法在数据变化时,精准、快速地认出每一个 DOM 节点,避免“盲人摸象”。

2. v-for 的避坑指南:绝不轻易用 index

如果在列表中间或头部插入新数据,使用 index 会导致 Vue 认错人(触发就地复用机制)。这不仅会让更新效率变低,还会导致输入框等带有状态的 DOM 元素发生严重的数据错乱 Bug永远优先使用数据自带的唯一 id

3. 高级隐藏玩法:用 key 强制重置组件

如果某个组件(比如复杂的图表或富文本编辑器)卡死了不更新,直接给它绑定一个 :key="时间戳"。只要每次改变这个时间戳,Vue 就会冷酷无情地直接销毁旧组件,重新挂载一个纯净的新组件,瞬间包治百病。

当没有使用的时候,如果我们需要对一个循环渲染的列表插入一个,那么实际上会 “就地复用” 的策略,比如我们需要在最前面插入一个,那么就会后面的全部往后变更,发生多次DOM操作,这是因为 Vue无法跟踪每个节点,而使用 key 后,节点标识,定位到最前面,然后插入修改,只发生一次DOM操作,大大优化了性能。

Vue过滤器

定义过滤器有两种常见的方式: 双花括号插值和 v-bind 表达式

1
2
3
4
<!-- 在双花括号中 --> 
{{ message | capitalize }}
<!-- 在 `v-bind` 中 -->
<div v-bind:id="rawId | formatId"></div>

其实一般用来格式文本,比如我们往往拿到的时间是时间戳,那么就可以很简单的通过过滤器来格式成我们常见的格式

使用上的我们先要定义过滤的方法,这个可以全局定义或者组件内定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 组件内定义 
filters: {
capitalize: function (value) {
if (!value) return ''
value = value.toString()
return value.charAt(0).toUpperCase() + value.slice(1)
}
}
// 全局定义
Vue.filter('capitalize', function (value) {
if (!value) return ''
value = value.toString()
return value.charAt(0).toUpperCase() + value.slice(1)
})
new Vue({ // ... })

Vue中Scoped原理

1. 核心原理:给标签盖“私有戳”

Vue 通过给 HTML 标签加唯一属性(哈希值),并在 CSS 选择器末尾加上这个属性,实现样式隔离。

1
2
3
4
5
<div class="box"></div>
<style scoped> .box { color: red; } </style>

<div class="box" data-v-123></div>
<style> .box[data-v-123] { color: red; } </style>

2. 特点一:父子边界(只能管到子组件的大门)

父组件的 scoped 样式,只能修改子组件的最外层根节点,无法修改子组件内部的元素。

  • 原因:Vue 给子组件的根节点同时盖了“父”和“子”两个戳,但子组件内部元素只有“子”的戳。

假设你正在开发一个页面(父组件 Page.vue),并且引入了一个现成的按钮组件(子组件 MyButton.vue)。
子组件很简单,外面是个 button,里面是个 span 图标。

1
2
3
4
5
6
<template>
<button class="btn-root">
<span class="btn-icon">❤️</span>
点击我
</button>
</template>

我们在父组件里引入了这个按钮,并试图在父组件的 <style scoped> 里改变按钮的样式。

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div class="page-box">
<h1>测试页面</h1>
<MyButton class="custom-btn" />
</div>
</template>
<style scoped>
/* 尝试 1:修改子组件的最外层(根节点) */
.custom-btn { background: red; }
/* 尝试 2:修改子组件内部的 span(非根节点) */
.btn-icon { font-size: 50px; }
</style>

当你运行项目后,Vue 会给父组件分配一个哈希码(假设是 data-v-111),给子组件分配另一个哈希码(假设是 data-v-222)。

浏览器最终渲染出来的 HTML 和 CSS 是这样的:

1
2
3
4
5
6
7
<div class="page-box" data-v-111>
<h1 data-v-111>测试页面</h1>
<button class="btn-root custom-btn" data-v-222 data-v-111>
<span class="btn-icon" data-v-222>❤️</span>
点击我
</button>
</div>

3. 特点二:递归组件的“样式泄露”漏洞

如果一个组件自己调用自己(递归),它们会共享同一个哈希戳。如果你写了后代选择器(比如 div p),原本只想改当前层的样式,结果把子子孙孙全改了。

1
2
3
4
5
6
7
8
9
10
11
<div data-v-A> 
<p data-v-A>第一层</p>
<div data-v-A>
<p data-v-A>第二层(被误伤了!)</p>
</div>
</div>
<style>
/* 浏览器看到这个规则会想:只要外面有个 div,里面的 p 带有 data-v-A 就全变红。
于是第一层和第二层的 p 全变红了! */
div p[data-v-A] { color: red; }
</style>

Vue中的ref

1. ref 是什么?怎么用?

你可以把 ref 理解为给元素或子组件起了一个**“内部代号”**。只要贴上了这个代号,你就可以在 JS 代码里,通过 this.$refs 这个百宝箱,随时把它“揪”出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<input ref="myInput" type="text" />
<ChildComponent ref="myChild" />
<button @click="doSomething">开始操作</button>
</div>
</template>

<script>
export default {
methods: {
doSomething() {
// 1. 直接操作底层 DOM(比如强行让输入框获取焦点)
this.$refs.myInput.focus();
// 2. 直接调用子组件内部的方法,或者拿它的数据!
this.$refs.myChild.add();
console.log(this.$refs.myChild.someData);
}
}
}
</script>

2. 为什么官方说“不要太依赖它”?

因为滥用 ref 会破坏 Vue **“单向数据流”“组件黑盒”**的优雅性。

  • 如果父组件总是用 ref 去直接修改子组件的数据,代码会变得极难维护(你不知道数据到底是被谁改掉的)。
  • 最佳实践:能用 props 传值和 emit 触发事件解决的,坚决不用 ref。只有在调用子组件的独立动作(比如 播放视频()重置表单())时,才推荐使用。也就是子组件暴露出来的公共方法,例如resetFields等

3. 使用 ref 的三大“铁律”(踩坑重灾区)

  1. 必须等渲染完:如果你在 created() 生命周期里去打印 this.$refs,你会得到一个 undefined。因为这时候 HTML 还没画到屏幕上,你当然抓不到它。它只能在 mounted() 之后生效。
  2. 它不是响应式的$refs 只是当时抓取到的一个快照对象。如果你的子组件因为 v-if 被销毁了,$refs里不会自动响应,直接去调用可能会报错。
  3. 终极救兵 nextTick:如果你刚用代码修改了数据(比如把 v-if="false" 改成了 true),紧接着下一行代码就去抓 this.$refs你一样会抓个空。因为 Vue 更新 DOM 是需要时间的。这时候必须包一层 this.$nextTick(() => { // 在这里抓取 }),等 Vue 把页面画完再抓。

Vue中$nextTick的使用

因为 vue 中不是数据一发生改变就马上更新视图层的,如果这样会带来比较差的性能优化,而是在 vue 中会添加到任务队列中,待执行栈的任务处理完才执行。所以 vue 中我们改变数据时不会立即触发视图,但是如果我们需要实时获取到最新的DOM,这个时候可以手动调用 nextTick。

Vue中的keep-alive

用 keep-alive 包裹组件时,会缓存不活动的组件实例,而不是销毁,使得我们返回的时候能重新激活。keep-alive 主要用于保存组件状态或避免重复创建。避免重复渲染导致的性能问题。

常见场景 页面的缓存,如上面的,保存浏览商品页的滚动条位置,筛选信息等

1
2
3
4
5
6
7
<template>
<div id="app">
<keep-alive>
<router-view></router-view>
</keep-alive>
</div>
</template>

注意与 Connection: Keep-Alive 进行区分

组件

组件生命周期

第一阶段:浏览器的“独角戏” (Vue 还没真正发力)

当你在浏览器输入网址,回车的那一瞬间,浏览器拿到了一个非常简陋的 HTML 文件(这就是大家常说的 SPA 单页应用)。

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app"></div> <script src="bundle.js"></script>
</body>
</html>
  1. HTML 解析与 DOM 树构建:浏览器从上往下解析 HTML,遇到 <body>,遇到 <div id="app"></div>,并在内存里建起了一棵非常迷你的真实 DOM 树
  2. CSS 解析与 CSSOM:浏览器下载 style.css,解析生成 CSSOM(CSS 对象模型)
  3. JS 解析与下载:浏览器下载并解析 bundle.js
  4. DOMContentLoaded 事件触发:当 HTML 被完全加载和解析完毕(且非异步的 JS 也执行完了),这个事件触发。
    • 💥 重点:在现代 Vue 项目中,DOMContentLoaded 触发时,页面上通常是一片空白的!因为此时真实的 DOM 树里只有一个空的 <div id="app"></div>

第二阶段:Vue 觉醒与数据初始化 (beforeCreate & created)

随着 JS 的执行,Vue 实例开始被创建(new Vue() 或 createApp())。

  1. beforeCreate:Vue 刚在内存里划了一块地盘。
  2. created:Vue 把你写的 data 变成了响应式数据,把 methods 绑好了。
    • 此时浏览器的状态:浏览器在旁边无所事事。真实的 DOM 树还是那个空的 divCSSOM 已经准备好,但布局树(Layout Tree)毫无变化,页面依然空白。此时发起的 AJAX 请求是最高效的,因为它完全不阻塞浏览器的 UI 线程。

第三阶段:虚拟 DOM 构建 (beforeMount)

Vue 准备向那个空的 <div id="app"></div> 下手了。

  1. 模板编译:Vue 将你的 <template> 编译成了 JS 的 render 函数(如果是 Vite 打包,这一步在构建时就做完了)。
  2. beforeMount 触发:Vue 马上要执行 render 函数了。
  3. 生成 Virtual DOM:Vue 执行 render 函数,在 JS 的内存里凭空捏造出了一棵虚拟 DOM 树(本质上就是一个巨大的 JS 对象,描述了页面应该长什么样)。
    • 此时浏览器的状态:真实 DOM 树依然是个空 div。页面依然空白。

第四阶段:物理碰撞与真实挂载 (mounted)

这是最激烈、最消耗性能的阶段,Vue 的 JS 代码与浏览器的底层 DOM API 发生了剧烈交火。

  1. 真实 DOM 操作:Vue 拿着刚才算好的虚拟 DOM 树,调用浏览器原生的 document.createElementappendChild 等 API,把虚拟节点一个个转化成真实的 DOM 节点,并塞进那个原本空的 <div id="app"></div> 里。
  2. mounted 触发:Vue 说:“我的任务完成了,真实的 DOM 节点我都插进去了!”
    • 💥 极其高频的误区:很多人以为 mounted 触发时,用户就已经能在屏幕上看到完整的网页了。错!
    • 此时浏览器的状态:Vue 的 JS 代码刚刚执行完 DOM 插入。此时,真实的 DOM 树被剧烈改变了
    • 因为 DOM 树变了,浏览器被迫打起精神,马上把新的 DOM 树和之前的 CSSOM 结合,开始构建 布局树(Layout Tree / Render Tree)

第五阶段:浏览器的最终宣判 (Layout & Paint)

在 Vue 的 mounted 钩子执行完毕后,JS 引擎的调用栈终于清空,将主线程的控制权交还给了浏览器的渲染引擎。

  1. Layout(布局/重排 Reflow):浏览器根据布局树,疯狂计算每一个新插进来的 <div><p><img> 在屏幕上的精确坐标和像素大小。
  2. Paint(绘制/重绘 Repaint):浏览器调用显卡(GPU)或 CPU,把计算好的几何图形变成屏幕上的真实像素颜色。
  3. 用户看到画面:直到这一刻,白屏结束,用户才真正看到了你写的花里胡哨的页面。

结合起来的硬核时间线总结

时间线 框架动作 浏览器底层动作 你能看到什么
1 构建初始迷你 DOM 树、CSSOM -> 触发 DOMContentLoaded 白屏
2 created JS 执行中,浏览器渲染被阻塞 白屏
3 beforeMount JS 计算生成 虚拟 DOM 白屏
4 Vue 执行 DOM API 操作 真实 DOM 树被疯狂修改 依然白屏(在内存里修改)
5 mounted JS 执行完毕,交出主线程控制权 依然白屏(但马上出图)
6 构建布局树 (Layout Tree) -> 布局计算 -> Paint (绘制) ✅ 看到完整页面

为什么懂这个很重要?(实战价值)

如果你在 mounted 里写了下面这段代码:

1
2
3
4
mounted() {
// 你想获取刚才渲染出来的盒子的真实高度
let height = document.getElementById('my-box').offsetHeight;
}

当你读取 offsetHeight 时,浏览器会“被迫”中断当前的闲置状态,立刻提前执行一次 Layout(布局计算),因为如果不算,它根本不知道高度是多少。这叫做**“强制同步布局(Forced Synchronous Layout)”**,非常消耗性能。

关于父子组件的生命周期

初次渲染挂载阶段(从无到有)

同步执行顺序:

  1. 👨  beforeCreate
  2. 👨  created
  3. 👨  beforeMount (父组件准备好模板了,往下看发现有个子组件,于是暂停,先去搞子组件)
  4. 👦  beforeCreate
  5. 👦  created
  6. 👦  beforeMount
  7. 👦  mounted (子组件的真实 DOM 彻底渲染完毕!)
  8. 👨  mounted (父组件一看,儿子搞定了,我也宣告挂载完毕!)

二、 更新阶段(数据改变时)

假设父组件传给子组件的 props 发生了变化,触发了重新渲染。

执行顺序:

  1. 👨  beforeUpdate (父组件察觉到数据变了,准备更新)
  2. 👦  beforeUpdate (子组件也察觉到传过来的数据变了)
  3. 👦  updated (子组件的 DOM 重新渲染完毕)
  4. 👨  updated (父组件的 DOM 也跟着宣告更新完毕)

三、 销毁阶段(组件被卸载时)

假设父组件被 v-if="false" 整体干掉了。

执行顺序:

  1. 👨  beforeDestroy (Vue 2) / beforeUnmount (Vue 3) (父组件接到死亡通知,准备自尽前,先去通知儿子)
  2. 👦  beforeDestroy / beforeUnmount (子组件接到通知,准备后事)
  3. 👦  destroyed / unmounted (子组件彻底死透,从页面移除)
  4. 👨  destroyed / unmounted (父组件一看儿子凉了,自己也彻底死透)

实际为什么父已mounted子尚未mounted?

场景一:带异步数据的 v-if (实战中 90% 遇到的情况)

代码背景: 父组件在 created 里发了一个 AJAX 请求去拿用户数据。子组件被 <Child v-if="userInfo" /> 包裹着。
真实发生的执行顺序(时间线):

  1. 👨 父 beforeCreate -> 👨 父 created (在 created 里,父组件向后端发起请求:“给我 userInfo”。注意!发起请求后,Vue 根本不会等后端回话,直接往下走!)
  2. 👨 父 beforeMount (Vue 开始解析模板。看到 <Child v-if="userInfo" />。此时由于后端还没回话,userInfo 还是 null。Vue 说:“哦,条件为假,这里不需要渲染子组件。”)
  3. 👨 父 mounted (此时父组件直接宣告挂载完成!)【打破规则的时刻出现了:子组件连影子都没有,父组件就已经 mounted 了】

— 过了 500 毫秒,后端接口终于把数据传回来了 —

  1. 👨 父组件的 userInfo 突然有值了。 触发父组件的响应式更新。
  2. 👨 父 beforeUpdate
  3. 👦 子组件终于发现 v-if 变成 true 了,它开始出生! 👦 子 beforeCreate -> 👦 子 created -> 👦 子 beforeMount -> 👦 子 mounted
  4. 👨 父 updated (父组件把新诞生的子组件塞进页面,宣告更新完毕)

结论: 在这种情况下,子组件的 mounted 是在父组件的 updated 阶段才姗姗来迟的。如果你在父组件的 mounted里去抓子组件,肯定抓个空。

场景二:异步组件按需加载 (() => import(...))

代码背景: 为了让网页首屏秒开,你没有用 import Child from './Child.vue',而是用了 components: { Child: () => import('./Child.vue') }。这意味着只有解析到这里时,浏览器才会去单独下载这个子组件的 JS 文件。

真实发生的执行顺序(时间线):

  1. 👨 父 beforeCreate -> 👨 父 created -> 👨 父 beforeMount
  2. (Vue 解析到子组件的坑位时,发现它是一个异步组件。Vue 立即向浏览器发号施令:“快去下载 Child.vue的代码!” 同理,Vue 绝不会停下来等下载。Vue 会在原地放一个肉眼看不见的“注释节点”当替身。)
  3. 👨 父 mounted (父组件带着那个假替身,直接宣告挂载完成!)【规则再次被打破】

— 过了 200 毫秒,浏览器的网络线程把 Child.vue 下载好了 —

  1. 👨 父 beforeUpdate (Vue 发现子组件代码到了,准备把替身换成真身)
  2. 👦 子组件代码就位,开始执行生命周期! 👦 子 beforeCreate -> … -> 👦 子 mounted
  3. 👨 父 updated (父组件替身替换完毕,宣告更新完成)

其他生命周期阶段

  • beforeUpdate(更新前)

在数据发生改变后,DOM 被更新之前被调用。这里适合在现有 DOM 将要被更新之前访问它,比如移除手动添加的事件监听器。 该钩子在服务器端渲染期间不被调用,因为只有初次渲染会在服务器端进行

该阶段此时实例中的数据已经是最新的啦,但是页面的还未更新

  • update(更新后)

在数据更改导致的虚拟 DOM 重新渲染和更新完毕之后被调用。 当这个钩子被调用时,组件 DOM 已经更新,所以你现在可以执行依赖于 DOM 的操作。然而在大多数情况下,你应该避免在此期间更改状态。如果要相应状态改变,通常最好使用计算属性或 watcher 取而代之。 注意,updated 不会保证所有的子组件也都被重新渲染完毕。如果你希望等到整个视图都渲染完毕,可以在 updated 里使用 vm.$nextTick 该钩子在服务器端渲染期间不被调用

避免在这个阶段更改状态的,因为这样可能会导致更新无限循环,曾经我做过个获取表格的高度,如果页面大小发送改变就更新表格的大小,在该阶段执行导致了页面的无限循环,结果死机啦~

  • activated(激活前)

被 keep-alive 缓存的组件激活时调用。 例如开发者想在这里更新一下列表数据。该钩子在服务器端渲染期间不被调用

  • deactivated(激活后)

被 keep-alive 缓存的组件失活时调用。 该钩子在服务器端渲染期间不被调用

  • beforeDestory(销毁前)

实例销毁之前调用。在这一步,实例仍然完全可用。 该钩子在服务器端渲染期间不被调用

  • destoryed(销毁后)

实例销毁后调用。该钩子被调用后,对应 Vue 实例的所有指令都被解绑,所有的事件监听器被移除,所有的子实例也都被销毁。 该钩子在服务器端渲染期间不被调用

Vue中实现组件通信的方式

1. 父子组件通信:props / emit

这是最经典、最常用的单向数据流模式。父组件通过 props 把数据递给子组件,子组件如果有动作,通过 emit向上通知父组件。
场景实例: 父组件是一个商品列表,子组件是单个商品卡片。子组件被点击时,需要告诉父组件“我被收藏了”。
父组件 (Parent.vue):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<template>
<div>
<h3>父组件:商品列表</h3>
<ProductCard
:item="productData"
@favorited="handleFavorite"
/>
</div>
</template>

<script setup>
import { ref } from 'vue';
import ProductCard from './ProductCard.vue';
const productData = ref({ id: 1, name: '机械键盘', price: 599 });
// 接收子组件传来的数据
const handleFavorite = (productId) => {
console.log(`商品 ${productId} 已被收藏!`);
};
</script>

子组件 (ProductCard.vue):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<template>
<div class="card">
<h4>{{ item.name }}</h4>
<p>价格:{{ item.price }}</p>
<button @click="onClick">收藏</button>
</div>
</template>
<script setup>
// 声明接收 props
defineProps(['item']);
// 声明 emit 事件
const emit = defineEmits(['favorited']);
const onClick = () => {
// 向父组件派发事件,并携带数据 (商品ID)
emit('favorited', 1);
};
</script>

2. 跨级/深层组件通信:provide / inject

当你有一个祖先组件,里面嵌套了孙子、重孙子组件,如果用 props 一层层传(Prop Drilling),代码会非常臃肿。这时候用 provide/inject 就能直接“穿透”传递。

场景实例: 根组件设置了全局的 UI 主题(深色/浅色),深层嵌套的一个小按钮需要读取这个主题颜色。

祖先组件 (App.vue):

1
2
3
4
5
6
7
<script setup>
import { ref, provide } from 'vue';
import DeepNestedComponent from './DeepNestedComponent.vue';
const theme = ref('dark');
// 无论层级多深,后代都可以获取到 'app-theme'
provide('app-theme', theme);
</script>

深层后代组件 (DeepButton.vue):

1
2
3
4
5
6
7
8
<template>
<button :class="themeClass">我是深层按钮</button>
</template>
<script setup>
import { inject } from 'vue';
// 跨越层级,直接获取祖先提供的数据
const themeClass = inject('app-theme');
</script>

3. 全局状态管理:Vuex / Pinia

当你的项目变得庞大,多个不相关的页面或组件需要共享同一份数据(比如:用户的登录状态、购物车里的商品数量)时,用单独的通信方式会变成“面条代码”。这时候需要一个全局的仓库。Pinia 是 Vue 3 的官方推荐状态管理库

场景实例: 任何组件都可以随时读取或修改购物车的数量。

定义 Store (store/cart.js):

1
2
3
4
5
6
7
8
9
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useCartStore = defineStore('cart', () => {
const count = ref(0);
const addToCart = () => {
count.value++;
};
return { count, addToCart };
});

任意组件 A 或 B 使用:

1
2
3
4
5
6
7
8
9
10
11
<template>
<div>
<p>购物车商品数:{{ cartStore.count }}</p>
<button @click="cartStore.addToCart">加入购物车</button>
</div>
</template>
<script setup>
import { useCartStore } from '@/store/cart';
// 直接引入 store,哪里需要哪里调
const cartStore = useCartStore();
</script>

4. 父组件直接调用子组件方法:ref

有时候,父组件需要硬性指令让子组件做某件事,比如父组件点击“重置”,需要清空子组件里表单的内容。
场景实例: 父组件直接触发子组件内部的清空方法。

父组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<ChildForm ref="formRef" />
<button @click="resetChildForm">重置表单</button>
</template>
<script setup>
import { ref } from 'vue';
import ChildForm from './ChildForm.vue';
// 这里的变量名必须与模板里的 ref 属性同名
const formRef = ref(null);
const resetChildForm = () => {
// 直接调用子组件暴露出来的方法
formRef.value.clearData();
};
</script>

子组件 (ChildForm.vue):

1
2
3
4
5
6
7
8
9
10
11
12
<script setup>
import { ref } from 'vue';
const formData = ref('一些输入的数据');
const clearData = () => {
formData.value = '';
};
// 在 Vue 3 <script setup> 中,组件默认是封闭的
// 必须明确暴露出的方法,父组件的 ref 才能访问到
defineExpose({
clearData
});
</script>

Vue事件总线(EventBus)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Handler = (...args: any[]) => void

class Bus {
private events: Record<string, Handler[]> = {}

on(event: string, handler: Handler) {
(this.events[event] || (this.events[event] = [])).push(handler)
}

off(event: string, handler: Handler) {
if (!this.events[event]) return
this.events[event] = this.events[event].filter(h => h !== handler)
}

emit(event: string, ...args: any[]) {
this.events[event]?.forEach(handler => handler(...args))
}
}

const bus = new Bus()
export default bus

Vue-router

一、 $router (路由器对象 —— 负责“动作”)

它是全局的路由实例,包含所有路由的配置项以及跳转方法。

1. 导航/跳转类 (最常用)

  • router.push(location)普通跳转。就像点开一个新网页,会向历史记录里添加一条新记录,点击浏览器的“返回”按钮可以回到上一个页面。
  • router.replace(location)替换跳转。直接把当前的历史记录替换掉。就好比你进了一个新页面,但浏览器不承认你来过,点“返回”会回到更早的页面。(常用于登录后跳转,防止用户点返回又回到登录页)。
  • router.go(n)穿梭。在历史记录中前进或后退 n 步。router.go(-1) 相当于按浏览器的后退键。
  • router.back(): 等同于 router.go(-1),后退一步。
  • router.forward(): 等同于 router.go(1),前进一步。

2. 路由守卫类 (保安/安检员)

  • router.beforeEach(to, from, next)全局前置守卫。每次路由跳转前都会触发。常用于判断用户是否登录,没登录就拦截下来踢回登录页。
  • router.afterEach(to, from)全局后置钩子。路由跳转彻底完成之后触发。常用于跳转后把页面滚动条回到顶部,或者修改页面的 Title。

3. 动态路由与其他高级方法

  • router.addRoutes(routes): 动态添加路由规则(常用于根据后端返回的权限菜单,动态生成能访问的页面)。
  • router.resolve(location): 解析目标位置的 URL 信息,常用于需要打开新窗口(window.open)时获取完整路径。
  • router.onReady() / router.onError(): 路由初始化完成的回调,以及路由发生错误时的回调。

4. 基础属性

  • $router.app: 配置了该 router 的 Vue 根实例。
  • $router.mode: 路由的模式(通常是 hash 即带 # 号的,或者是 history 即不带 # 号的)。
  • $router.currentRoute: 其实这就是 $route,代表当前的路由信息对象。

二、 $route (当前路由信息对象 —— 负责“读取”)

它是一个局部的对象,只要 URL 发生变化,$route 里的信息就会跟着变。你主要用它来读取参数。

1. 核心路径信息

  • $route.path: 当前路由的绝对路径(如 /user/profile)。
  • $route.fullPath: 包含查询参数和 hash 的完整路径(如 /user/profile?id=123#top)。
  • $route.name: 当前路由的名称(如果你在配置路由时写了 name: 'User' 的话)。

2. 参数获取 (最常用)

  • $route.queryURL 拼接参数。获取 URL 中 ? 后面的参数。例如路径是 /search?keyword=手机,那么 $route.query.keyword 就是 '手机'
  • $route.params动态路由参数。获取 URL 路径中的变量。例如定义了路径 /user/:id,实际访问 /user/888,那么 $route.params.id 就是 '888'

3. 其他附属信息

  • $route.hash: 获取当前路由的 hash 值(带有 # 的部分),如果没有就为空字符串。
  • $route.matched: 一个数组,包含了当前路由的所有嵌套层级的匹配记录(常用于生成“面包屑”导航)。
  • $route.redirectedFrom: 如果当前页面是被重定向过来的,这个属性会记录你是从哪个路径被赶过来的。

Vue-router中hash模式和history模式的区别

1. 长相区别(表面)

  • Hash 模式:网址里带个 # 号(例如:www.baidu.com/#/user)。
  • History 模式:网址像正常的网页一样,没有 #(例如:www.baidu.com/user)。

2. 原理区别(底层)

  • Hash 模式:靠监听 # 号后面的变化(onhashchange)。因为浏览器规定,# 后面的东西随便怎么变,都不会重新向服务器发请求,所以前端可以关起门来自己玩。
  • History 模式:靠 HTML5 的新绝招(pushState 和 replaceState)。这俩绝招能做到“悄悄改变浏览器网址,但坚决不向服务器发请求”。

3. 致命的区别(为什么需要后端配合?)

  • Hash 模式很省心:如果你按 F5 刷新页面,浏览器只会把 # 前面的 www.baidu.com 发给服务器。服务器一找,有这个主页,完美返回。
  • History 模式会报错 (404):网址是 www.baidu.com/user。你在前端点按钮跳过去没问题;但是,如果你直接按 F5 刷新页面,浏览器会老老实实地去服务器里找一个叫 /user 的文件夹!服务器根本没这个文件夹,直接给你弹一个 404 找不到页面

4. 为什么刷新后能正常显示子组件?

当你停留在 www.yoursite.com/user/detail 并按下刷新键时,整个过程接力赛一样分为了**“服务器主导”“前端主导”**两个半场:

上半场:服务器的“偷梁换柱”

  1. 浏览器非常老实地向服务器发起真实的 HTTP 请求:“请给我 /user/detail 这个文件!”
  2. 服务器(不管是你本地的 Webpack/Vite 开发服务器,还是线上的 Nginx)收到请求。它找了一圈发现没这个文件夹。
  3. 关键点来了:因为配置了 History 模式的“兜底(Fallback)”,服务器会耍个滑头,它把根目录下的 index.html 文件当做替身,原封不动地扔给了浏览器,并返回 200 成功状态码。

下半场:Vue 路由的“火速救场”

  1. 浏览器拿到了 index.html。其实这时候页面是纯白的(里面只有一个 <div id="app"></div> 和几段引入 JS 的 <script> 标签)。
  2. 浏览器赶紧下载并执行这些 JS 代码(也就是你写的 Vue 核心代码)。
  3. Vue 醒了,Vue-router 开始工作。 路由就像个尽职的侦探,它第一件事就是抬头看一眼浏览器的地址栏:“咦?现在的网址是 /user/detail 啊!”
  4. Vue-router 赶紧去你写的 routes: [...] 配置表里查字典:/user/detail 对应的是 User.vue 和嵌套的 Detail.vue 组件。”
  5. Vue 火速把这两个组件在内存里渲染好,塞进那个原本空荡荡的 <div id="app"></div> 里。

Vue路由传参

  • 传简单的 ID 标识(如查看商品详情): 使用显式 Params ,配置 /detail/:id。网址好看,符合 RESTful 规范。
  • 传搜索条件、分页页码: 使用 Query 。灵活多变,刷新不丢。

前端路由

一、 什么是“前端路由”?

在传统的网页中,如果你把网址从 /home 改成 /about,浏览器会立马闪烁、白屏,并向服务器请求一个全新的 HTML 页面。 
前端路由的本质就是“夺权”。JavaScript 拦截了 URL 的变化,阻止了浏览器向服务器发请求的默认行为,然后通过自己写的逻辑,把当前页面上的旧组件卸载,挂载上新组件。也就是**“网址变了,但页面不刷新,只是局部 UI 发生了替换”**。

实现这种“伪装跳转”,主流有两种模式:Hash 模式 和 History 模式

二、 Hash 模式:简单粗暴的老将

  • 表面特征: 网址后面总是跟着一个 # 号。比如 https://www.example.com/#/login
  • 核心原理(监听机制): 浏览器有一个天生的潜规则——# 号后面的内容无论怎么变,都不会发给服务器。这段文本指出,当 # 后面的值改变时,会触发浏览器的 window.onhashchange 事件。
  • 动作触发实例:
    • 用户点击了一个 <a href="#/user"> 标签。
    • 用户在控制台手动输入 window.location.hash = '/user'
    • 用户点击了浏览器的前进/后退按钮。 只要发生以上行为,JS 监听到 hashchange 事件,就会去渲染对应的 User 页面。
  • 优缺点: 极度省心,兼容性极好(老古董浏览器也能跑),不需要后端做任何配合;缺点就是网址里带个 # 号,显得不专业。

三、 History 模式:优雅但不让人省心的新秀

这是 HTML5 带来的新特性,它允许我们把丑陋的 # 去掉,让网址看起来和传统网页一模一样(如 https://www.example.com/login)。

它的底层依赖于 window.history 对象,这个对象就像是浏览器历史记录的“扑克牌堆”

1. 翻牌操作(移动历史记录)

  • history.back() / history.go(-1):退回上一张牌(后退)。
  • history.forward() / history.go(1):翻到下一张牌(前进)。

2. 核心神技(修改历史记录,且不触发刷新) 文本重点介绍了这两个极其重要的方法:

  • pushState(state, title, url) 偷偷在扑克牌堆顶上新加一张牌。
    • 实例: 你现在在 example.com/A,执行 history.pushState(null, '', '/B')。浏览器地址栏瞬间变成 /B,但页面绝对不会刷新!并且历史记录里多了一步。
  • replaceState(state, title, url) 把最顶上的那张牌替换掉。
    • 实例: 经常用于“登录页跳转到首页”。登录成功后,用 /home 替换掉 /login 的历史记录,这样用户点浏览器的“后退”键,就不会再回到登录页去重新登录了。

四、 最容易踩坑的暗器:popstate 事件

  • 一句话:浏览器和 js 之间的通信桥梁

前端路由要工作,就必须“监听到”网址变了。但是,当你用代码调用 pushState 或 replaceState 改变网址时,浏览器是在装死的,它绝对不会触发 popstate 事件!

  • 触发条件: popstate 极其懒惰,只有当用户去点击浏览器自带的“前进/后退”按钮,或者你用代码调用 history.back() 时,它才会触发。
  • 应对方案: 当我们在项目中点击一个自定义的跳转按钮时,框架底层的实际操作是:先调用 pushState把地址栏网址改了,然后马上手动执行一套 UI 渲染逻辑去更新组件。而 popstate 事件仅仅用来兜底监听用户的“后退/前进”操作。

五、 核心对比总结

为了方便记忆,这两种模式的区别可以概括为下表:

特性对比 Hash 模式 History 模式
外观展示 带有 # 号,较丑陋 干净,常规 URL
核心 API window.onhashchange pushStatereplaceStateonpopstate
服务器配合 不需要,直接访问 index.html 即可 必须配置 Fallback(兜底),否则刷新就 404
兼容性 极强(兼容旧版浏览器) 较好(需要 HTML5 支持)

Vuex

一、Vuex是什么

vuex是基于 vue 框架的一个状态管理库

二、Vuex 解决了什么

  • 多个组件 / 实例依赖于同一状态,兄弟组件之间的状态是无法传递。

比如某个数据,在实例A和实例B中使用了,而这个数据在实例A中修改了,如果我们不做处理,这个状态是不会更新到实例B中的,对于实例B来说,这个数据是没有发生改变的。小型的项目我们还比较好处理,对实例B更新就好了。

Flux 模式的基本思想:

  • 单一数据来源
  • 数据是只读的
  • 更新是同步的

三、五个核心的概念 state / getters / mutations / action / modules

这部分代码写在专门的仓库文件里(通常是 store/index.js)。

1. State(货架:专门放数据)

1
2
3
state: {
cartList: []
}
  • 细节解释state 是 Vuex 规定死的关键字。它就是一个普通对象。cartList 是你自己起的名字,初始值是个空数组。
  • 作用:全公司(所有组件)的购物车数据,只有这一个合法的存放点。别人不许私自存。

2. Getters(计算器:自动算数据)

1
2
3
4
5
getters: {
totalCount: (state) => {
return state.cartList.length;
}
}
  • 细节解释getters 是死关键字totalCount 是你自己起的名字
  • 参数 state 是哪来的? 这是 Vuex 在底层自动塞给你的。只要你写了参数 state,Vuex 就会把上面那个货架(state)端过来给你用。
  • 作用:只要 cartList 里多了一个商品,totalCount 就会全自动变成最新长度,不用你手动去算。

3. Mutations(库管员:绝对同步的改数据)

1
2
3
4
5
mutations: {
ADD_ITEM(state, product) {
state.cartList.push(product);
}
}
  • 细节解释mutations 是死关键字ADD_ITEM 是你自己起的名字(老鸟习惯大写,为了醒目,不强制)。
  • 参数解释
    • state:老规矩,Vuex 自动端给你的货架。
    • product:这是你一会从组件里传过来的真实数据(比如一部手机)。
  • 铁律:这里面绝对不允许出现任何网络请求、setTimeout 等需要等待的代码。必须是干净利落的赋值或数组操作。

4. Actions(外勤业务员:处理异步操作)

1
2
3
4
5
6
7
8
actions: {
checkout(context) {
// 模拟网络请求需要等待 1 秒
setTimeout(() => {
context.commit('CLEAR_CART');
}, 1000);
}
}
  • 细节解释actions 是死关键字checkout 是你自己起的函数名(结账)。
  • 最迷糊的 context 是什么? 当 Vuex 运行这个函数时,它会自动传一个叫 context(上下文)的对象进来。这个对象就像是 Vuex 发给业务员的**“工作手机”**。这部手机里有个按钮叫 commit
  • 为什么写 context.commit('CLEAR_CART') 业务员(Action)等了 1 秒钟,后端说结账成功了,需要清空购物车。但是!业务员没有权限直接去碰货架(state)。他必须拿起工作手机,按下 commit 按钮,呼叫名字叫 CLEAR_CART 的库管员(Mutation):“喂!我活干完了,你去把货架清空一下!”

第二部分:组件如何使用这个大仓库?

这部分代码写在你的 Vue 页面里(比如 ProductList.vue)。

只要你在 Vue 项目里安装并挂载了 Vuex,所有组件都会自动拥有一个超能力对象:this.$store。这就是你联系大仓库的唯一对讲机。

1. 往仓库里存东西(组件 -> Mutation)

1
2
3
4
5
6
7
8
methods: {
addToCart(product) {
// 错误写法:this.$store.state.cartList.push(product) 绝对不行!

// 正确写法:使用 commit 呼叫库管员
this.$store.commit('ADD_ITEM', product);
}
}
  • 细节解释
    • 当用户点击网页上的“加入”按钮时,执行 addToCart 方法。
    • commit 是 Vuex 死规定的 API。专门用来呼叫 Mutations。
    • 'ADD_ITEM' 是你要找的库管员的名字
    • product 是你要交给他入库的(第二参数,也就是前面 Mutation 接收到的数据)。

2. 让仓库去发网络请求(组件 -> Action)

1
2
3
4
5
6
methods: {
payNow() {
// 牵扯到异步请求等待,使用 dispatch 呼叫外勤业务员
this.$store.dispatch('checkout');
}
}
  • 细节解释
    • 当用户点击“立即结账”按钮时,执行 payNow 方法。
    • 结账需要向服务器发请求,要等。所以不能找库管员(Mutation),必须找外勤业务员(Action)。
    • dispatch 是 Vuex 死规定的 API。专门用来呼叫 Actions。
    • 'checkout' 是你要找的业务员的名字

终极复盘:整个流程是如何串起来的?

现在,我们把用户点击“立即结账”按钮后发生的事情,像放电影一样连起来:

  1. 起点:用户点击按钮触发 payNow 函数。
  2. 呼叫业务员:组件执行 this.$store.dispatch('checkout')
  3. 业务员接单:Vuex 仓库里 actions 下的 checkout 函数开始运行,它拿到了工作手机(context)。
  4. 业务员干活:发起异步请求(setTimeout 等了 1 秒)。
  5. 业务员摇人:1 秒后请求成功,业务员拿起手机呼叫库管员 context.commit('CLEAR_CART')
  6. 库管员干活mutations 下的 CLEAR_CART 函数开始运行,直接修改数据 state.cartList = []
  7. 终点(自动发生):因为 state 里的数据变了,Vue 的响应式系统立刻发挥作用,网页上所有用到购物车数据的地方,瞬间变成了 0。

Pinia与Vuex

维度 Vuex 4 (老前辈) Pinia (新王) 核心变化与爽点
Mutations 必须有!负责同步修改数据。 🔪 彻底废弃! 最大的爽点!以前改个数据要写 Action,再 commit 给 Mutation。现在 Pinia 里直接在 Action 里改数据,少写一半代码!
Actions 只能处理异步,不能直接改 State。 同步异步通吃 直接在 Action 里写业务逻辑,拿到数据直接赋值 this.xxx = yyy,简单粗暴。
Modules (模块) 树状嵌套结构,极其复杂,容易晕。 扁平化设计 废弃了嵌套模块。Pinia 里每一个 Store 都是独立的(比如专门建一个 userStore,一个 cartStore),互相调用就像普通函数一样简单。
TypeScript 支持极差,写类型定义像做噩梦。 原生完美支持 Pinia 本身就是 TS 写的,你的状态在编辑器里敲个点(.),所有提示瞬间出来,不用再背 API 名字。

1. Vuex 的写法(又长又臭的 Options API 风格):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// store/index.js
export const store = new Vuex.Store({
state: {
count: 0
},
getters: {
doubleCount: (state) => state.count * 2
},
mutations: {
// 强制规定:必须写个 mutation 才能改 state
SET_COUNT(state, payload) {
state.count += payload
}
},
actions: {
// 异步操作
asyncAdd(context, num) {
setTimeout(() => {
// 必须繁琐地 commit
context.commit('SET_COUNT', num)
}, 1000)
}
}
})

// 组件里调用:
// this.$store.commit('SET_COUNT', 1)
// this.$store.dispatch('asyncAdd', 2)

2. Pinia 的写法(极简的 Setup 函数风格):

现在的 Pinia 官方强烈推荐使用类似 Vue 3 setup 的写法,它简直就跟你平时写一个普通的 Vue 组件一模一样!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// store/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

// 定义一个独立的 store,取个独一无二的名字叫 'counter'
export const useCounterStore = defineStore('counter', () => {
// 1. State: 就是普通的 ref 或 reactive
const count = ref(0)

// 2. Getters: 就是普通的 computed
const doubleCount = computed(() => count.value * 2)

// 3. Actions: 就是普通的 function(根本不管你同步还是异步,随便改!)
async function asyncAdd(num) {
setTimeout(() => {
count.value += num // 🔪 直接改!没有 commit!没有 mutations!
}, 1000)
}

// 最后把这些暴露出去给组件用
return { count, doubleCount, asyncAdd }
})

在组件里调用 Pinia:

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
import { useCounterStore } from '@/store/counter'

// 1. 实例化这个商店
const counterStore = useCounterStore()

// 2. 直接拿来当普通对象用,甚至可以直接改值!
console.log(counterStore.count) // 读数据
counterStore.asyncAdd(2) // 调方法

// 极其奔放:就算你不调 Action,也可以直接改 State(Pinia 默认允许)
// counterStore.count++
</script>

虚拟DOM

一、 到底什么是虚拟 DOM?(图纸与实物)

真实场景带入: 假设浏览器里显示的网页(真实 DOM),是一栋已经建好的精装别墅。 而虚拟 DOM,就是存在你电脑里的这栋别墅的 3D 电子设计图(JS 对象)

在过去(原生 JS 时代),如果客户想把二楼的 3 扇窗户改成红色,包工头(JS 代码)会直接跑到工地上,砸墙、拆窗户、重新砌砖(直接操作真实 DOM)。如果客户反悔了 10 次,包工头就要在工地上砸 10 次墙,极其耗费体力和时间(性能极差)。

现在有了 Vue 和 React(虚拟 DOM 时代):

  1. 客户要改窗户颜色。
  2. 我们绝对不去碰工地(真实 DOM),而是直接在电脑的 3D 设计图(虚拟 DOM) 上点几下鼠标,把颜色改掉。
  3. 电脑极其迅速地对比出新旧图纸的差异(这叫 Diff 算法)。
  4. 电脑生成一份极其精简的“施工整改单”(这叫 Patch 补丁)。
  5. 包工头拿着整改单,去工地上一次性只把那 3 扇窗户换掉。

代码实例验证: 看看文本里给出的代码,真实 DOM 是一堆 HTML 标签,而虚拟 DOM 被剥去了华丽的外衣,它底层就是一个最普通的 JavaScript 对象(字典)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 这是真实的工地(真实 DOM,极其庞大复杂)
<div id="app">
<p class="text">hello</p>
</div>

// 这是电脑里的 3D 图纸(虚拟 DOM,非常轻量)
{
tag: 'div',
props: { id: 'app' },
children: [
{ tag: 'p', props: { className: 'text' }, children: ['hello'] }
]
}

二、 虚拟 DOM 的两大核心优势

1. 性能优化:拦截并合并无用的“瞎折腾”

文本提到:“当更新 10 个节点时,浏览器会计算 10 次… 虚拟 DOM 会将前后两次对比,把补丁一次性打上。”

实例解释: 假设你的代码里写了一个循环,把一个数字从 1 连续加到 1000。

  • 没有虚拟 DOM: 浏览器接到指令,立刻在屏幕上渲染 1,然后擦掉换成 2,再擦掉换成 3… 重复 1000 次渲染,页面直接卡死。
  • 有虚拟 DOM: 框架在内存里的 JS 对象上把数字疯狂加到了 1000(JS 在内存里算数字快如闪电)。算完之后,拿最终的图纸和一开始的图纸一对比:“哦,原来你只是想把数字改成 1000 啊。” 于是,只对真实 DOM 发起唯一的一次修改。
  • 虚拟DOM并不是vue或react的专利
  • 什么时候才知道他加到了1000?因为 vue 有异步更新机制,相当于每次更新都放到了微任务里,但是宏任务没执行完,nextTick可以打破

2. 跨平台能力:这才是它的“终极杀手锏”!

文本提到:“不仅仅局限于浏览器,可以是安卓、IOS、小程序… 解耦了视图层和渲染平台。”

这是这段文本里最有深度的一句话!
实例解释: 真实的 DOM 树(document.createElement('div'))是浏览器独有的东西。如果你想把你的 Vue 代码直接放到手机 App(iOS/Android)里运行,手机根本不认识什么是 <div>,它只认识 <UIView>

但因为有了虚拟 DOM(纯 JS 对象),事情变得豁然开朗: JS 对象是全世界通用的语言!

  • 当你的代码在浏览器里跑时,Vue 把图纸(虚拟 DOM)翻译成网页元素
  • 当你的代码用 Weex 或 React Native 跑在手机端时,框架就把同一份图纸翻译成 iOS/Android 的原生控件
  • 当你在开发微信小程序时,框架就把图纸翻译成小程序的 WXML

这就是“Write Once, Run Anywhere(一次编写,到处运行)”的底层技术基石。

三、 坦白局:虚拟 DOM 的缺陷(打破迷信)

很多人背八股文时会说:“虚拟 DOM 速度快,性能好”。 这段文本非常清醒地指出了:使用虚拟 DOM 也不一定是高效的。

为什么?

  1. 首次渲染更慢:本来可以直接建别墅,你非要先在电脑里画一遍 3D 图纸,再建别墅。多出了生成图纸(JS 对象)的开销。
  2. 极端情况更慢:假设你的页面发生了翻天覆地的彻底重写(毫无复用价值)。这时候,虚拟 DOM 还要傻乎乎地去对比新旧图纸的每一个细节,算得满头大汗后发现:“哎呀,全都不一样,全拆了重建吧!” 这种情况下,虚拟 DOM 的 Diff 计算完全就是浪费 CPU 资源,反而比直接操作原生 DOM 要慢得多。

DIFF 算法

DIFF算法的作用:同层树节点比较的算法

1. 为什么需要“优化”的 DIFF 算法?(传统的痛)

如果用传统的树结构对比算法(循环遍历新旧树),时间复杂度是 O(n³)

  • 大白话: 假设你的页面上有 1000 个节点(这在现代网页很常见)。传统的找茬方法需要对比 1000 * 1000 * 1000 = 10 亿次!哪怕 JavaScript 算得再快,浏览器也会当场卡死白屏。

2. Vue/React 的天才优化:O(n) 复杂度的 DIFF 算法

为了不让浏览器卡死,Vue 和 React 的作者们定下了三条简单粗暴的“铁律”,硬生生把 10 亿次计算降到了 1000 次(时间复杂度降为 O(n))。

这三条铁律(也是文本最后总结的三点)分别是:

  • 铁律一:只做同层级比较(不跨级找人)
    • 场景: 假设旧图纸里,二楼有个马桶;新图纸里,马桶被移到了三楼。
    • DIFF 算法的无情逻辑: 它扫描二楼,发现马桶没了,直接砸掉旧马桶;然后扫描三楼,发现多出个马桶,直接买个新马桶装上
    • 结论: 它绝对不会聪明到去判断“哎呀,原来你是把二楼的马桶搬到三楼了”。跨层级的移动,在 DIFF 眼里一律视为“先删除,后新建”。这极大地砍掉了复杂的跨层级搜索。
  • 铁律二:标签名(Tag)不同,直接毁灭(不墨迹)
    • 场景: 旧图纸是一栋木屋(<ul>,新图纸在这个位置变成了一栋砖房(<ol>
    • DIFF 算法的无情逻辑: 虽然你们里面都住着一样的人(里面的 <li> 没变),但我只要看到外壳标签不一样,我根本不往里面看!直接推平木屋,原地起一栋砖房
    • 结论: 根节点不同,整棵子树直接报废重建,绝不浪费时间向下深度比较。
  • 铁律三:标签名相同,Key 相同,就认为是同一个节点(复用)
    • 场景: 几个双胞胎站成一排,新旧图纸里他们互换了位置。
    • DIFF 算法的聪明逻辑: 虽然长得一样(标签都是 <li>),但我怎么知道谁是谁?如果你给他们每个人发一张身份证(key="id-123")。DIFF 一扫身份证,瞬间就知道:“哦!原来你没变,你只是从第一个走到了第三个位置。”
    • 结论: 拿着旧节点,直接挪动位置复用它,连重绘都省了!

Vue3

Vue3 Composition API和Vue2 Options API有什么不同

  • Vue 2 (Options API) 就像是一个按“物品种类”收纳的抽屉。你把所有的剪刀放在一个格子里(methods),所有的纸放在另一个格子里(data)。当你需要完成“剪纸”这个具体任务时,你必须在几个格子之间来回跳跃寻找。如果代码有 1000 行,你滚动鼠标滚轮会滚到崩溃。
  • Vue 3 (Composition API) 就像是按“任务”收纳的文件夹。你把剪纸需要的剪刀和纸全部放在“剪纸专用文件夹(setup 函数里的一个 hook)”里。代码按业务逻辑高度聚合,一目了然。
对比维度 Vue 2 (Options API) Vue 3 (Composition API)
代码组织 按配置项切分(datamethodscomputed分离)。 按业务逻辑聚合(同一个功能的变量和方法写在一起)。
逻辑复用 使用 mixins(混入)。缺点:命名极易冲突,数据来源如同黑盒,不知道是哪个 mixin 带来的。 使用自定义 Hooks(函数)。清晰灵活,完美解决命名冲突和来源不明问题。
上下文引用 强依赖神奇的 thisthis.xxx)。 彻底抛弃 this,直接通过变量名和纯函数调用(如 refreactive)。
TS 支持 原生对 TypeScript 支持很差,需要复杂的装饰器。 源码就是 TS 写的,天生完美契合 TypeScript 的类型推断。

二、 底层引擎的换代:Proxy 替掉 Object.defineProperty

真实痛点还原(Vue 2 的三个致命残疾): 由于 Object.defineProperty 的底层 API 限制,Vue 2 的响应式(数据拦截)是个半残废,它有三个死穴:

  1. 无法监听属性的新增和删除:你在 data 里定义了 obj: { a: 1 },后来手欠写了一句 this.obj.b = 2 或 delete this.obj.a,Vue 完全不知道,页面也不会更新(逼得官方搞出了恶心的 this.$set 和 this.$delete)。
  2. 对数组的支持极差:你直接通过下标改数组 arr[0] = 100,或者改长度 arr.length = 0,Vue 也是装死。(Vue 2 是通过暴力重写数组的 7 个方法才勉强实现了数组劫持)。
  3. 性能开销大(无脑递归):如果你的对象嵌套了 10 层,Vue 2 会在初始化时直接卡死,因为它要把这 10 层对象全部遍历一遍,挨个打上 defineProperty 的锁。

Vue 3 的救世主:Proxy(代理) Proxy 是 ES6 的新特性。如果说 defineProperty 是给每一扇门(属性)单独配一把锁;那么 Proxy 就是直接给整栋大楼(整个对象)包上了一层带电网的结界。 无论你是增加属性、删除属性,还是修改数组下标,甚至是你深层嵌套的对象,只要你想动这栋楼里的任何东西,一律会被最外层的 Proxy 结界精准拦截。这彻底拔掉了 this.$set 这个毒瘤,且性能得到了质的飞跃。

三、 其他核心进化(闪电速览)

除了上面两个“大魔王”,文本还列举了 Vue 3 带来的诸多极其舒适的优化:

  • Tree-Shaking(摇树优化):Vue 3 是按需引入的。你没用到内置的 <transition> 或 v-model,打包时这些代码就会被“摇”掉,大大缩小了最终上线的包体积。
  • 支持多根节点 (Fragment):Vue 2 的 <template> 里必须有一个唯一的 <div> 祖宗包住所有东西,Vue 3 终于解除封印,可以直接写同级的多个标签了。
  • 编译阶段的终极优化:Vue 3 在把模板编译成真实 DOM 之前,会智能地把那些“永远不会变的静态文本”提升并标记起来。下次执行 DIFF 算法找茬时,直接跳过它们,只对比会动态变化的节点,速度快到飞起。
  • 生命周期更名:在 setup 语法糖里,直接删除了 beforeCreate 和 createdsetup 本身就替代了它们),其他钩子全部加上了 on 前缀(如 onMounted)。
  • 周边生态同步大换血
    • Vuex 变成了 createStore(不过现在已经被 Pinia 淘汰了)。
    • Vue-Router 获取参数变成了调用 useRoute() 函数。
    • 组件通信:必须通过显式的 defineProps 接收父组件参数,并通过 defineEmits 声明你要向父组件派发的事件,规矩更严,代码更清晰。

重温前端框架 Vue
http://example.com/2026/03/31/重温前端框架-Vue/
作者
Lingkai Shi
发布于
2026年3月31日
许可协议