重温前端框架 Vue
Vue基础
MVVM
- Model(模型): 在 Vue 组件中,这通常对应
data函数返回的对象。它只是纯粹的 JavaScript 对象,包含了你的业务数据。 - View(视图): 这就是 Vue 的模板部分(HTML 代码)。它通过指令(如
{{ }}或v-bind)声明式地描述了数据应该如何呈现。 - ViewModel(视图模型): 这是 Vue 的核心。它负责监听 Model 的变化并自动更新 View,同时也监听 View 的输入事件(如表单输入)并同步给 Model。
1 | |
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 | |
ViewModel 的内心独白: “我要开始循环 1000 个用户了!每循环到一个,我就得停下来问一下:‘哎,这个用户 isActive 是 true 吗?’ 如果是 false,我就把刚造出来的这个 DOM 节点扔掉。” —— 结论: 做了 1000 次判断,白干了很多活。
2. 解决方案 A:外层嵌套 template(先判断,再循环)
如果你是想控制整个列表的显示,就用这个:
1 | |
- 好处:如果
isShow为 false,循环根本不会开始。template标签在渲染后会消失,不会污染 HTML 结构。
3. 解决方案 B:计算属性 computed(先过滤,再循环)—— 最推荐
1 | |
1 | |
- 好处:
- 性能最优:循环的已经是过滤好的纯净数组。
- 逻辑清晰:视图层(View)只负责展示,逻辑交给 JS 处理。
- 解耦:如果
users没变,activeUsers会直接使用缓存,不会重新计算。
双向数据绑定的原理
你提供的文本中提到了 Vue 2 用的 Object.defineProperty 和 Vue 3 用的 Proxy。我们直接用原生 JS 写出它们的核心区别。
1. Vue 2.x 的 Object.defineProperty
这是 Vue 2 响应式系统的核心。它的特点是:必须具体到对象里的某一个特定的 key 进行拦截。
1 | |
2. Vue 3.x 的 Proxy 实例
Vue 3 放弃了对单个特定属性的劫持,改为直接拦截整个对象。
1 | |
computed、watch 和 methods 的实际应用场景对比
文本里提到了它们在“缓存”、“异步”和“使用场景”上的区别,我们把它们放在同一个电商页面的代码实例中来看。
实际业务场景:一个带搜索功能的购物车
1 | |
总结对比:
- 缓存测试:如果你在模板中写了两次
{{ 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)会充当“翻译官”,把这个文件强行拆解:
<template>(看似是 HTML,其实是 JS)- 你以为的: 它是一段插入到页面上的 HTML 代码。
- 真相: 在打包时,模板编译器会把你写的
<div>{{ msg }}</div>翻译成一个纯 JavaScript 的render()函数。这完全脱离了普通 HTML 的范畴。
<script>(组件的灵魂,大脑)- 你以为的: 它就是普通的 JS 脚本。
- 真相: 它导出的其实只是一个配置对象(Options)。Vue 引擎在运行时,会拿着这个对象,去实例化出一个真正的、具有响应式能力的组件实例。
<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
3let 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 不会马上碰真实的网页。
- 它先在 JS 的内存里,光速生成一棵虚拟 DOM 树(本质上是用 JS 对象模拟的 HTML 结构)。
- 把新树和老树进行对比(Diff 算法),找出“究竟是哪个字、哪个标签变了”。
- 最后,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 | |
当你把 methods 和 computed 交给 Vue 时,Vue 在底层初始化这个组件时,会做这样一个操作(伪代码):
1 | |
所以,无论你在哪里调用 methods 里的函数,this 都稳稳地指向组件实例。
数据代理(Data Proxy)
你会发现一个奇怪的现象:我们在 data() 里 return { keyword: '搜索' }。 按理说,访问它应该写成 this.data().keyword 才对,为什么直接写 this.keyword 就能拿到呢?
Vue 的真实做法(偷梁换柱):
- 只执行一次:当组件刚创建时,Vue 会在底层偷偷调用一次你的
data()函数。 - 私藏结果:Vue 把调用后返回的那个真实数据对象,偷偷存到了组件实例的一个内部变量里(在 Vue 2 中,这个变量叫
this._data或this.$data)。 - 建立快捷通道(代理):为了让你少敲键盘,Vue 遍历了
this._data里的所有属性,直接在this的最外层给你铺好了路。
底层大致是这么干的:
1 | |
结论:你访问的 this.keyword,其实是一个“快捷方式”,它背后偷偷指向了那唯一一次 data() 执行后留下的产物。
<template> 里的变量是怎么拿到数据的?
我们在 HTML 模板里写 {{ keyword }},并没有写 this.keyword,为什么页面上能显示出来?
核心真相:<template> 根本不是真正的 HTML,它最终会被编译成一段 JavaScript 函数!
当你运行项目时,Vite 或 Webpack 底层有一个叫 Vue Compiler(编译器)的工具。它会把你的模板“翻译”成下面这样的一段 JS 渲染函数(Render Function):
你的模板:
1 | |
编译后的 JS 真实面目(极简版):
1 | |
(注: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 | |
发生了什么? 经过这层改造后,keywordLength 已经不再是一个可以被你调用的普通函数了,它变成了挂在 this 上的一个属性。 根据 JavaScript 的语法,当你读取 this.keywordLength(或者在模板里写 {{ keywordLength }})时,JS 引擎会自动执行它背后的 get 函数,并把 return 的结果给你。
这就是为什么你不带括号,却能执行函数逻辑的原因!
终极对比验证:如果放在 methods 里呢?
可以做一个对比。如果你把求字数的功能写在 methods 里:
1 | |
Vue 对 methods 的处理非常老实,它不会把你变成 Getter,而是直接原封不动地挂在 this 上:this.getKeywordLength = 你的函数。
Vue中的插槽使用
插槽的本质,就是“父组件传给子组件的回调函数”!
“父级模板里的所有内容都是在父级作用域中编译的;子模板里的所有内容都是在子作用域中编译的。”
为什么会这样?因为我们在上一条聊过,<template> 最终会被编译成一段 JS 函数。
- 父组件的 HTML,变成了父组件里的
renderParent()函数。 - 子组件的 HTML,变成了子组件里的
renderChild()函数。
当我们写 <slot></slot> 时,底层到底发生了什么?
1. 基础插槽(默认插槽)
在父组件里,你写了这样一段代码:
1 | |
在 Vue 编译后,父组件其实是把这行 <p> 包装成了一个函数,当作一个属性传给了子组件:
1 | |
而在子组件里,那个 <slot></slot> 标签,在底层其实就是一句极其简单的 JS 代码:
1 | |
2. 具名插槽(按名字找函数)
理解了上面那点,具名插槽就太简单了。父组件无非就是传了一个包含多个函数的对象给子组件而已。
1 | |
子组件里的 <slot name="header"></slot>,在底层翻译过来就是:
1 | |
作用域插槽(Scoped Slots)是怎么传数据的?
平时我们写代码会觉得很别扭:数据明明在子组件里,为什么要在父组件的 HTML 里去使用它?
1 | |
现在,用我们刚学到的“函数”视角来看,一切都解释得通了!
1. 父组件提供了一个“带参数的函数” 父组件在编译时,发现你需要用到 slotProps,于是它把这个插槽编译成了一个需要接收参数的函数:
1 | |
2. 子组件调用函数,并塞入自己的数据 你在子组件里写了 <slot :user="user"></slot>,这在底层就是子组件在执行父组件传过来的函数时,把自己的内部数据作为参数传了进去!
1 | |
总结
剥开 HTML 标签的外衣:
- 普通插槽:子组件无脑执行父组件传过来的
this.$slots.default()。 - 具名插槽:子组件有选择地执行
this.$slots.名字()。 - 作用域插槽:子组件在执行父组件的函数时,把自己兜里的数据掏出来塞了进去:
this.$slots.default(我的内部数据)。父组件的函数拿到数据后,终于可以完成页面的渲染。
Vue中的key
关于 key ,我们常常在 v-for 中会接触到,当我们需要进行列表循环的时候,如果没有使用 key ,就会有警报提示。
1 | |
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 | |
其实一般用来格式文本,比如我们往往拿到的时间是时间戳,那么就可以很简单的通过过滤器来格式成我们常见的格式。
使用上的我们先要定义过滤的方法,这个可以全局定义或者组件内定义
1 | |
Vue中Scoped原理
1. 核心原理:给标签盖“私有戳”
Vue 通过给 HTML 标签加唯一属性(哈希值),并在 CSS 选择器末尾加上这个属性,实现样式隔离。
1 | |
2. 特点一:父子边界(只能管到子组件的大门)
父组件的 scoped 样式,只能修改子组件的最外层根节点,无法修改子组件内部的元素。
- 原因:Vue 给子组件的根节点同时盖了“父”和“子”两个戳,但子组件内部元素只有“子”的戳。
假设你正在开发一个页面(父组件 Page.vue),并且引入了一个现成的按钮组件(子组件 MyButton.vue)。
子组件很简单,外面是个 button,里面是个 span 图标。
1 | |
我们在父组件里引入了这个按钮,并试图在父组件的 <style scoped> 里改变按钮的样式。
1 | |
当你运行项目后,Vue 会给父组件分配一个哈希码(假设是 data-v-111),给子组件分配另一个哈希码(假设是 data-v-222)。
浏览器最终渲染出来的 HTML 和 CSS 是这样的:
1 | |
3. 特点二:递归组件的“样式泄露”漏洞
如果一个组件自己调用自己(递归),它们会共享同一个哈希戳。如果你写了后代选择器(比如 div p),原本只想改当前层的样式,结果把子子孙孙全改了。
1 | |
Vue中的ref
1. ref 是什么?怎么用?
你可以把 ref 理解为给元素或子组件起了一个**“内部代号”**。只要贴上了这个代号,你就可以在 JS 代码里,通过 this.$refs 这个百宝箱,随时把它“揪”出来。
1 | |
2. 为什么官方说“不要太依赖它”?
因为滥用 ref 会破坏 Vue **“单向数据流”和“组件黑盒”**的优雅性。
- 如果父组件总是用
ref去直接修改子组件的数据,代码会变得极难维护(你不知道数据到底是被谁改掉的)。 - 最佳实践:能用
props传值和emit触发事件解决的,坚决不用ref。只有在调用子组件的独立动作(比如播放视频()、重置表单())时,才推荐使用。也就是子组件暴露出来的公共方法,例如resetFields等
3. 使用 ref 的三大“铁律”(踩坑重灾区)
- 必须等渲染完:如果你在
created()生命周期里去打印this.$refs,你会得到一个undefined。因为这时候 HTML 还没画到屏幕上,你当然抓不到它。它只能在mounted()之后生效。 - 它不是响应式的:
$refs只是当时抓取到的一个快照对象。如果你的子组件因为v-if被销毁了,$refs里不会自动响应,直接去调用可能会报错。 - 终极救兵
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 | |
注意与 Connection: Keep-Alive 进行区分
组件
组件生命周期
第一阶段:浏览器的“独角戏” (Vue 还没真正发力)
当你在浏览器输入网址,回车的那一瞬间,浏览器拿到了一个非常简陋的 HTML 文件(这就是大家常说的 SPA 单页应用)。
1 | |
- HTML 解析与 DOM 树构建:浏览器从上往下解析 HTML,遇到
<body>,遇到<div id="app"></div>,并在内存里建起了一棵非常迷你的真实 DOM 树。 - CSS 解析与 CSSOM:浏览器下载
style.css,解析生成 CSSOM(CSS 对象模型)。 - JS 解析与下载:浏览器下载并解析
bundle.js。 DOMContentLoaded事件触发:当 HTML 被完全加载和解析完毕(且非异步的 JS 也执行完了),这个事件触发。- 💥 重点:在现代 Vue 项目中,
DOMContentLoaded触发时,页面上通常是一片空白的!因为此时真实的 DOM 树里只有一个空的<div id="app"></div>。
- 💥 重点:在现代 Vue 项目中,
第二阶段:Vue 觉醒与数据初始化 (beforeCreate & created)
随着 JS 的执行,Vue 实例开始被创建(new Vue() 或 createApp())。
beforeCreate:Vue 刚在内存里划了一块地盘。created:Vue 把你写的data变成了响应式数据,把methods绑好了。- 此时浏览器的状态:浏览器在旁边无所事事。真实的 DOM 树还是那个空的
div。CSSOM 已经准备好,但布局树(Layout Tree)毫无变化,页面依然空白。此时发起的 AJAX 请求是最高效的,因为它完全不阻塞浏览器的 UI 线程。
- 此时浏览器的状态:浏览器在旁边无所事事。真实的 DOM 树还是那个空的
第三阶段:虚拟 DOM 构建 (beforeMount)
Vue 准备向那个空的 <div id="app"></div> 下手了。
- 模板编译:Vue 将你的
<template>编译成了 JS 的render函数(如果是 Vite 打包,这一步在构建时就做完了)。 beforeMount触发:Vue 马上要执行render函数了。- 生成 Virtual DOM:Vue 执行
render函数,在 JS 的内存里凭空捏造出了一棵虚拟 DOM 树(本质上就是一个巨大的 JS 对象,描述了页面应该长什么样)。- 此时浏览器的状态:真实 DOM 树依然是个空
div。页面依然空白。
- 此时浏览器的状态:真实 DOM 树依然是个空
第四阶段:物理碰撞与真实挂载 (mounted)
这是最激烈、最消耗性能的阶段,Vue 的 JS 代码与浏览器的底层 DOM API 发生了剧烈交火。
- 真实 DOM 操作:Vue 拿着刚才算好的虚拟 DOM 树,调用浏览器原生的
document.createElement、appendChild等 API,把虚拟节点一个个转化成真实的 DOM 节点,并塞进那个原本空的<div id="app"></div>里。 mounted触发:Vue 说:“我的任务完成了,真实的 DOM 节点我都插进去了!”- 💥 极其高频的误区:很多人以为
mounted触发时,用户就已经能在屏幕上看到完整的网页了。错! - 此时浏览器的状态:Vue 的 JS 代码刚刚执行完 DOM 插入。此时,真实的 DOM 树被剧烈改变了!
- 因为 DOM 树变了,浏览器被迫打起精神,马上把新的 DOM 树和之前的 CSSOM 结合,开始构建 布局树(Layout Tree / Render Tree)。
- 💥 极其高频的误区:很多人以为
第五阶段:浏览器的最终宣判 (Layout & Paint)
在 Vue 的 mounted 钩子执行完毕后,JS 引擎的调用栈终于清空,将主线程的控制权交还给了浏览器的渲染引擎。
- Layout(布局/重排 Reflow):浏览器根据布局树,疯狂计算每一个新插进来的
<div>、<p>、<img>在屏幕上的精确坐标和像素大小。 - Paint(绘制/重绘 Repaint):浏览器调用显卡(GPU)或 CPU,把计算好的几何图形变成屏幕上的真实像素颜色。
- 用户看到画面:直到这一刻,白屏结束,用户才真正看到了你写的花里胡哨的页面。
结合起来的硬核时间线总结
| 时间线 | 框架动作 | 浏览器底层动作 | 你能看到什么 |
|---|---|---|---|
| 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 | |
当你读取 offsetHeight 时,浏览器会“被迫”中断当前的闲置状态,立刻提前执行一次 Layout(布局计算),因为如果不算,它根本不知道高度是多少。这叫做**“强制同步布局(Forced Synchronous Layout)”**,非常消耗性能。
关于父子组件的生命周期
初次渲染挂载阶段(从无到有)
同步执行顺序:
- 👨 父
beforeCreate - 👨 父
created - 👨 父
beforeMount(父组件准备好模板了,往下看发现有个子组件,于是暂停,先去搞子组件) - 👦 子
beforeCreate - 👦 子
created - 👦 子
beforeMount - 👦 子
mounted(子组件的真实 DOM 彻底渲染完毕!) - 👨 父
mounted(父组件一看,儿子搞定了,我也宣告挂载完毕!)
二、 更新阶段(数据改变时)
假设父组件传给子组件的 props 发生了变化,触发了重新渲染。
执行顺序:
- 👨 父
beforeUpdate(父组件察觉到数据变了,准备更新) - 👦 子
beforeUpdate(子组件也察觉到传过来的数据变了) - 👦 子
updated(子组件的 DOM 重新渲染完毕) - 👨 父
updated(父组件的 DOM 也跟着宣告更新完毕)
三、 销毁阶段(组件被卸载时)
假设父组件被 v-if="false" 整体干掉了。
执行顺序:
- 👨 父
beforeDestroy(Vue 2) /beforeUnmount(Vue 3) (父组件接到死亡通知,准备自尽前,先去通知儿子) - 👦 子
beforeDestroy/beforeUnmount(子组件接到通知,准备后事) - 👦 子
destroyed/unmounted(子组件彻底死透,从页面移除) - 👨 父
destroyed/unmounted(父组件一看儿子凉了,自己也彻底死透)
实际为什么父已mounted子尚未mounted?
场景一:带异步数据的 v-if (实战中 90% 遇到的情况)
代码背景: 父组件在 created 里发了一个 AJAX 请求去拿用户数据。子组件被 <Child v-if="userInfo" /> 包裹着。
真实发生的执行顺序(时间线):
- 👨 父
beforeCreate-> 👨 父created(在created里,父组件向后端发起请求:“给我userInfo”。注意!发起请求后,Vue 根本不会等后端回话,直接往下走!) - 👨 父
beforeMount(Vue 开始解析模板。看到<Child v-if="userInfo" />。此时由于后端还没回话,userInfo还是 null。Vue 说:“哦,条件为假,这里不需要渲染子组件。”) - 👨 父
mounted(此时父组件直接宣告挂载完成!)【打破规则的时刻出现了:子组件连影子都没有,父组件就已经mounted了】
— 过了 500 毫秒,后端接口终于把数据传回来了 —
- 👨 父组件的
userInfo突然有值了。 触发父组件的响应式更新。 - 👨 父
beforeUpdate - 👦 子组件终于发现
v-if变成 true 了,它开始出生! 👦 子beforeCreate-> 👦 子created-> 👦 子beforeMount-> 👦 子mounted - 👨 父
updated(父组件把新诞生的子组件塞进页面,宣告更新完毕)
结论: 在这种情况下,子组件的 mounted 是在父组件的 updated 阶段才姗姗来迟的。如果你在父组件的 mounted里去抓子组件,肯定抓个空。
场景二:异步组件按需加载 (() => import(...))
代码背景: 为了让网页首屏秒开,你没有用 import Child from './Child.vue',而是用了 components: { Child: () => import('./Child.vue') }。这意味着只有解析到这里时,浏览器才会去单独下载这个子组件的 JS 文件。
真实发生的执行顺序(时间线):
- 👨 父
beforeCreate-> 👨 父created-> 👨 父beforeMount - (Vue 解析到子组件的坑位时,发现它是一个异步组件。Vue 立即向浏览器发号施令:“快去下载
Child.vue的代码!” 同理,Vue 绝不会停下来等下载。Vue 会在原地放一个肉眼看不见的“注释节点”当替身。) - 👨 父
mounted(父组件带着那个假替身,直接宣告挂载完成!)【规则再次被打破】
— 过了 200 毫秒,浏览器的网络线程把 Child.vue 下载好了 —
- 👨 父
beforeUpdate(Vue 发现子组件代码到了,准备把替身换成真身) - 👦 子组件代码就位,开始执行生命周期! 👦 子
beforeCreate-> … -> 👦 子mounted - 👨 父
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 | |
子组件 (ProductCard.vue):
1 | |
2. 跨级/深层组件通信:provide / inject
当你有一个祖先组件,里面嵌套了孙子、重孙子组件,如果用 props 一层层传(Prop Drilling),代码会非常臃肿。这时候用 provide/inject 就能直接“穿透”传递。
场景实例: 根组件设置了全局的 UI 主题(深色/浅色),深层嵌套的一个小按钮需要读取这个主题颜色。
祖先组件 (App.vue):
1 | |
深层后代组件 (DeepButton.vue):
1 | |
3. 全局状态管理:Vuex / Pinia
当你的项目变得庞大,多个不相关的页面或组件需要共享同一份数据(比如:用户的登录状态、购物车里的商品数量)时,用单独的通信方式会变成“面条代码”。这时候需要一个全局的仓库。Pinia 是 Vue 3 的官方推荐状态管理库。
场景实例: 任何组件都可以随时读取或修改购物车的数量。
定义 Store (store/cart.js):
1 | |
任意组件 A 或 B 使用:
1 | |
4. 父组件直接调用子组件方法:ref
有时候,父组件需要硬性指令让子组件做某件事,比如父组件点击“重置”,需要清空子组件里表单的内容。
场景实例: 父组件直接触发子组件内部的清空方法。
父组件:
1 | |
子组件 (ChildForm.vue):
1 | |
Vue事件总线(EventBus)
1 | |
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.query: URL 拼接参数。获取 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 并按下刷新键时,整个过程接力赛一样分为了**“服务器主导”和“前端主导”**两个半场:
上半场:服务器的“偷梁换柱”
- 浏览器非常老实地向服务器发起真实的 HTTP 请求:“请给我
/user/detail这个文件!” - 服务器(不管是你本地的 Webpack/Vite 开发服务器,还是线上的 Nginx)收到请求。它找了一圈发现没这个文件夹。
- 关键点来了:因为配置了 History 模式的“兜底(Fallback)”,服务器会耍个滑头,它把根目录下的
index.html文件当做替身,原封不动地扔给了浏览器,并返回 200 成功状态码。
下半场:Vue 路由的“火速救场”
- 浏览器拿到了
index.html。其实这时候页面是纯白的(里面只有一个<div id="app"></div>和几段引入 JS 的<script>标签)。 - 浏览器赶紧下载并执行这些 JS 代码(也就是你写的 Vue 核心代码)。
- Vue 醒了,Vue-router 开始工作。 路由就像个尽职的侦探,它第一件事就是抬头看一眼浏览器的地址栏:“咦?现在的网址是
/user/detail啊!” - Vue-router 赶紧去你写的
routes: [...]配置表里查字典:“/user/detail对应的是User.vue和嵌套的Detail.vue组件。” - 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 |
pushState, replaceState, onpopstate |
| 服务器配合 | 不需要,直接访问 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 | |
- 细节解释:
state是 Vuex 规定死的关键字。它就是一个普通对象。cartList是你自己起的名字,初始值是个空数组。 - 作用:全公司(所有组件)的购物车数据,只有这一个合法的存放点。别人不许私自存。
2. Getters(计算器:自动算数据)
1 | |
- 细节解释:
getters是死关键字。totalCount是你自己起的名字。 - 参数
state是哪来的? 这是 Vuex 在底层自动塞给你的。只要你写了参数state,Vuex 就会把上面那个货架(state)端过来给你用。 - 作用:只要
cartList里多了一个商品,totalCount就会全自动变成最新长度,不用你手动去算。
3. Mutations(库管员:绝对同步的改数据)
1 | |
- 细节解释:
mutations是死关键字。ADD_ITEM是你自己起的名字(老鸟习惯大写,为了醒目,不强制)。 - 参数解释:
state:老规矩,Vuex 自动端给你的货架。product:这是你一会从组件里传过来的真实数据(比如一部手机)。
- 铁律:这里面绝对不允许出现任何网络请求、
setTimeout等需要等待的代码。必须是干净利落的赋值或数组操作。
4. Actions(外勤业务员:处理异步操作)
1 | |
- 细节解释:
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 | |
- 细节解释:
- 当用户点击网页上的“加入”按钮时,执行
addToCart方法。 commit是 Vuex 死规定的 API。专门用来呼叫 Mutations。'ADD_ITEM'是你要找的库管员的名字。product是你要交给他入库的货(第二参数,也就是前面 Mutation 接收到的数据)。
- 当用户点击网页上的“加入”按钮时,执行
2. 让仓库去发网络请求(组件 -> Action)
1 | |
- 细节解释:
- 当用户点击“立即结账”按钮时,执行
payNow方法。 - 结账需要向服务器发请求,要等。所以不能找库管员(Mutation),必须找外勤业务员(Action)。
dispatch是 Vuex 死规定的 API。专门用来呼叫 Actions。'checkout'是你要找的业务员的名字。
- 当用户点击“立即结账”按钮时,执行
终极复盘:整个流程是如何串起来的?
现在,我们把用户点击“立即结账”按钮后发生的事情,像放电影一样连起来:
- 起点:用户点击按钮触发
payNow函数。 - 呼叫业务员:组件执行
this.$store.dispatch('checkout')。 - 业务员接单:Vuex 仓库里
actions下的checkout函数开始运行,它拿到了工作手机(context)。 - 业务员干活:发起异步请求(
setTimeout等了 1 秒)。 - 业务员摇人:1 秒后请求成功,业务员拿起手机呼叫库管员
context.commit('CLEAR_CART')。 - 库管员干活:
mutations下的CLEAR_CART函数开始运行,直接修改数据state.cartList = []。 - 终点(自动发生):因为
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. Pinia 的写法(极简的 Setup 函数风格):
现在的 Pinia 官方强烈推荐使用类似 Vue 3 setup 的写法,它简直就跟你平时写一个普通的 Vue 组件一模一样!
1 | |
在组件里调用 Pinia:
1 | |
虚拟DOM
一、 到底什么是虚拟 DOM?(图纸与实物)
真实场景带入: 假设浏览器里显示的网页(真实 DOM),是一栋已经建好的精装别墅。 而虚拟 DOM,就是存在你电脑里的这栋别墅的 3D 电子设计图(JS 对象)。
在过去(原生 JS 时代),如果客户想把二楼的 3 扇窗户改成红色,包工头(JS 代码)会直接跑到工地上,砸墙、拆窗户、重新砌砖(直接操作真实 DOM)。如果客户反悔了 10 次,包工头就要在工地上砸 10 次墙,极其耗费体力和时间(性能极差)。
现在有了 Vue 和 React(虚拟 DOM 时代):
- 客户要改窗户颜色。
- 我们绝对不去碰工地(真实 DOM),而是直接在电脑的 3D 设计图(虚拟 DOM) 上点几下鼠标,把颜色改掉。
- 电脑极其迅速地对比出新旧图纸的差异(这叫 Diff 算法)。
- 电脑生成一份极其精简的“施工整改单”(这叫 Patch 补丁)。
- 包工头拿着整改单,去工地上一次性只把那 3 扇窗户换掉。
代码实例验证: 看看文本里给出的代码,真实 DOM 是一堆 HTML 标签,而虚拟 DOM 被剥去了华丽的外衣,它底层就是一个最普通的 JavaScript 对象(字典):
1 | |
二、 虚拟 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 也不一定是高效的。
为什么?
- 首次渲染更慢:本来可以直接建别墅,你非要先在电脑里画一遍 3D 图纸,再建别墅。多出了生成图纸(JS 对象)的开销。
- 极端情况更慢:假设你的页面发生了翻天覆地的彻底重写(毫无复用价值)。这时候,虚拟 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) |
|---|---|---|
| 代码组织 | 按配置项切分(data, methods, computed分离)。 |
按业务逻辑聚合(同一个功能的变量和方法写在一起)。 |
| 逻辑复用 | 使用 mixins(混入)。缺点:命名极易冲突,数据来源如同黑盒,不知道是哪个 mixin 带来的。 |
使用自定义 Hooks(函数)。清晰灵活,完美解决命名冲突和来源不明问题。 |
| 上下文引用 | 强依赖神奇的 this(this.xxx)。 |
彻底抛弃 this,直接通过变量名和纯函数调用(如 ref, reactive)。 |
| TS 支持 | 原生对 TypeScript 支持很差,需要复杂的装饰器。 | 源码就是 TS 写的,天生完美契合 TypeScript 的类型推断。 |
二、 底层引擎的换代:Proxy 替掉 Object.defineProperty
真实痛点还原(Vue 2 的三个致命残疾): 由于 Object.defineProperty 的底层 API 限制,Vue 2 的响应式(数据拦截)是个半残废,它有三个死穴:
- 无法监听属性的新增和删除:你在
data里定义了obj: { a: 1 },后来手欠写了一句this.obj.b = 2或delete this.obj.a,Vue 完全不知道,页面也不会更新(逼得官方搞出了恶心的this.$set和this.$delete)。 - 对数组的支持极差:你直接通过下标改数组
arr[0] = 100,或者改长度arr.length = 0,Vue 也是装死。(Vue 2 是通过暴力重写数组的 7 个方法才勉强实现了数组劫持)。 - 性能开销大(无脑递归):如果你的对象嵌套了 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和created(setup本身就替代了它们),其他钩子全部加上了on前缀(如onMounted)。 - 周边生态同步大换血:
- Vuex 变成了
createStore(不过现在已经被 Pinia 淘汰了)。 - Vue-Router 获取参数变成了调用
useRoute()函数。 - 组件通信:必须通过显式的
defineProps接收父组件参数,并通过defineEmits声明你要向父组件派发的事件,规矩更严,代码更清晰。
- Vuex 变成了