一、前端框架到底要解决什么?

假设没有 Vue,我们写一个页面:

1
2
<div id="temperature"></div>
<button id="btn">+1</button>

JS:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let temperature = 80;

const el = document.querySelector("#temperature");
const btn = document.querySelector("#btn");

function render() {
el.innerText = temperature;
}

btn.onclick = () => {
temperature++;
render();
};

render();

本质上我们做了三件事:

1
2
3
4
5
6
7
8
9
10
11
12
数据
temperature = 80

↓

用户操作
temperature++

↓

操作 DOM
页面变成 81

小页面没问题。

但项目大了,问题就来了:

数据变化以后,到底哪些 DOM 要更新?

如果全部自己管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
temperature 变了
↓
找温度 DOM
↓
更新

status 变了
↓
找状态 DOM
↓
更新

alarm 变了
↓
找告警 DOM
↓
更新

最后大量代码不是在描述业务,而是在:

手动维护“数据和页面之间的同步关系”。

这就是 Vue 最核心的问题背景。


二、Vue 做出的第一个核心设计:声明式 UI

传统思路:

数据变了以后,我告诉浏览器具体怎么修改 DOM。

Vue 希望你变成:

我告诉 Vue:这个页面在当前数据状态下应该长什么样。

例如:

1
2
3
4
5
<template>
<div>
温度:{{ temperature }}
</div>
</template>

你只描述:

1
2
3
页面
=
温度:temperature

然后:

1
temperature.value = 90;

Vue 负责:

1
2
3
4
5
发现数据变化
↓
找到依赖这个数据的 UI
↓
更新页面

所以思想发生了变化:

1
2
3
4
5
6
7
8
9
10
11
12
以前:

数据变化
↓
我手动操作 DOM


Vue:

数据变化
↓
框架负责更新 UI

这就是:

声明式(Declarative)

你负责说:

“页面应该是什么。”

框架负责:

“怎么把页面变成这样。”


三、那么 Vue 怎么知道数据变了?

这就出现 Vue 第二个核心设计:

响应式系统

例如:

1
const temperature = ref(80);

页面使用:

1
<div>{{ temperature }}</div>

Vue 建立了一个关系:

1
2
3
4
5
temperature
│
│ 页面使用了它
↓
Temperature UI

以后:

1
temperature.value = 90;

Vue知道:

1
2
3
4
5
temperature 发生变化
↓
依赖它的地方需要重新处理
↓
更新相应 UI

所以响应式系统解决的是:

状态变化以后,谁需要知道?

可以把它理解成:

1
2
3
4
5
6
7
状态
↓
追踪依赖
↓
状态变化
↓
触发相关更新

这其实是一个很通用的软件设计思想。


四、为什么需要 ref / reactive?

因为普通 JavaScript:

1
2
3
let temperature = 80;

temperature = 90;

Vue不能凭空知道:

“刚才这个变量变了,我应该更新哪里。”

所以需要把状态纳入 Vue 的响应式体系。

例如:

1
const temperature = ref(80);

或者:

1
2
3
4
const device = reactive({
temperature: 80,
speed: 3000
});

可以粗略理解成 Vue 给数据增加了:

1
2
3
4
5
6
7
读取时
↓
记录谁依赖我

修改时
↓
通知依赖我的逻辑

Vue 3 对响应式对象的重要底层能力就是 JavaScript 的 Proxy。

例如概念上:

1
2
3
4
5
6
7
8
9
10
11
12
13
const device = new Proxy(data, {
get(target, key) {
// 记录依赖
return target[key];
},

set(target, key, value) {
target[key] = value;

// 通知相关依赖
return true;
}
});

真实 Vue 实现复杂得多,但思想就是这个。


五、现在出现第三个问题:页面这么大,不能全写一起吧?

于是 Vue 设计了:

组件系统

比如 HMI:

1
2
3
4
5
6
7
8
9
10
11
整个页面
│
├── Header
│
├── DeviceStatus
│
├── TemperatureChart
│
├── AlarmPanel
│
└── ParameterPanel

每一个都是 Component(组件)。

为什么?

因为大型 UI 天然就是:

1
2
3
4
5
6
7
大页面
↓
拆
↓
小组件
↓
小组件继续拆

例如:

1
2
3
4
5
6
7
8
9
App
│
├── Sidebar
├── Header
└── DevicePage
│
├── StatusCard
├── Chart
└── AlarmList

这样解决:

1
2
3
4
5
代码组织
职责划分
组件复用
独立维护
独立测试

所以:

组件化解决的是“大 UI 怎么拆”。

而:

响应式解决的是“数据变了 UI 怎么跟着变”。

这两个不要混。


六、组件之间怎么交流?

组件拆开以后马上产生新问题:

1
2
3
4
5
父组件
│
├── 子组件 A
├── 子组件 B
└── 子组件 C

数据怎么传?

Vue设计:

1
2
3
4
5
父 → 子
props

子 → 父
emit

为什么这么设计?

因为如果大家:

1
2
3
A 随便修改 B
B 随便修改 C
C 随便修改 A

项目很快乱掉。

所以 Vue 倾向于建立比较清楚的数据流。

例如:

1
2
3
4
5
DevicePage
│
│ temperature=82
↓ props
TemperatureCard

子组件如果发生事件:

1
2
3
4
5
TemperatureCard
↓
emit("reset")
↓
DevicePage

所以:

1
2
3
数据向下
↓
事件向上

是一种很重要的组件通信思想。


七、为什么需要 computed?

假设有:

1
temperature = 105

页面还需要:

1
是否高温?

这个状态其实不是独立数据。

因为:

1
2
3
4
5
temperature
↓
计算
↓
isHighTemperature

所以:

1
2
3
const isHighTemperature = computed(() => {
return temperature.value > 100;
});

为什么 Vue 单独设计 computed?

因为这种东西属于:

派生状态。

它不是新的事实。

事实只有:

1
temperature = 105

而:

1
isHighTemperature = true

是从事实算出来的。

所以最好不要:

1
2
temperature.value = 105;
isHighTemperature.value = true;

否则以后很容易:

1
2
3
4
5
temperature = 80

但

isHighTemperature = true

两个状态不一致。

正确思想:

1
2
3
4
5
6
7
原始状态
temperature
↓
computed
↓
派生状态
isHighTemperature

一句话

能从已有状态算出来的东西,尽量不要重复保存一份状态。

这是非常重要的软件设计思想。


八、那为什么又需要 watch?

因为有些事情不是“计算一个新值”。

例如:

1
2
3
设备 ID 变化
↓
重新请求设备信息

或者:

1
2
3
用户修改搜索关键词
↓
重新请求搜索结果

这叫:

副作用(Side Effect)

比如:

1
2
3
watch(deviceId, async (newId) => {
await fetchDevice(newId);
});

所以:

1
2
3
4
5
6
7
8
9
computed
↓
我需要根据状态得到另一个值


watch
↓
某个状态变化以后
我要执行一件事情

这两个特别容易混。

记:

1
2
3
4
5
computed
= 算东西

watch
= 做事情

虽然是简化,但面试非常好用。


九、为什么需要生命周期?

一个组件不是永远存在。

例如:

1
2
3
4
5
6
7
8
9
10
11
进入设备详情页
↓
组件创建
↓
显示
↓
使用
↓
离开页面
↓
组件销毁

于是我们经常需要:

1
2
3
4
5
组件出现的时候
→ 建立 WebSocket

组件离开的时候
→ 关闭 WebSocket

所以:

1
2
3
4
5
6
7
onMounted(() => {
connectWebSocket();
});

onUnmounted(() => {
disconnectWebSocket();
});

生命周期解决的是:

组件处于不同生命阶段时,我需要执行什么逻辑?

你可以类比 Node 服务:

1
2
3
4
5
启动
↓
运行
↓
关闭

组件也是:

1
2
3
4
5
6
7
创建
↓
挂载
↓
更新
↓
卸载

十、到这里 Vue 核心已经出来了

其实你现在已经能看到 Vue 的骨架:

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
Vue
│
├── 响应式系统
│ ↓
│ 管状态变化
│
├── 声明式渲染
│ ↓
│ 状态 → UI
│
├── 组件系统
│ ↓
│ 拆分大型 UI
│
├── 组件通信
│ ↓
│ props / emit
│
├── 派生状态
│ ↓
│ computed
│
├── 副作用
│ ↓
│ watch
│
└── 生命周期
↓
管组件不同阶段

这才是 Vue 本身最核心的一层。


十一、但是还有一个大问题:Vue 怎么更新 DOM?

比如:

1
temperature.value = 80;

变:

1
temperature.value = 81;

最笨的方法:

1
2
3
4
5
数据变化
↓
整个页面全部删掉
↓
重新创建整个页面

当然不行。

所以 Vue 还需要:

渲染系统(Renderer)

你写:

1
2
3
4
5
6
<template>
<div>
<h1>{{ title }}</h1>
<p>{{ temperature }}</p>
</div>
</template>

Vue 编译器会把模板编译成渲染相关代码。

可以粗略理解:

1
2
3
4
5
6
7
8
9
10
11
template
↓
Compiler 编译器
↓
render function
↓
VNode
↓
Renderer
↓
真实 DOM

这里出现一个重要东西:

Virtual DOM,虚拟 DOM。


十二、虚拟 DOM 到底是什么?

不要把它神化。

真实 DOM:

1
2
3
4
<div>
<h1>设备 A</h1>
<p>80°C</p>
</div>

Vue内部可以用 JavaScript 对象描述 UI。

概念上:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
type: "div",
children: [
{
type: "h1",
children: "设备 A"
},
{
type: "p",
children: "80°C"
}
]
}

这类描述就是 VNode。

可以粗略理解:

用 JavaScript 数据结构描述 UI 应该是什么样。

状态变化:

1
2
3
4
5
6
7
旧 VNode
温度 80

↓

新 VNode
温度 81

Vue渲染系统判断:

1
2
3
4
5
6
7
8
h1
没变

div
没变

p 内容
80 → 81

于是:

1
只更新必要的 DOM

十三、为什么 Vue 既需要 Compiler 又需要 Renderer?

你写的是:

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

这是人比较容易写的东西。

但是框架最终需要执行:

1
2
3
创建什么节点
更新什么节点
删除什么节点

所以:

1
2
3
4
5
6
7
8
9
10
11
12
13
你写 Template
↓
Compiler
编译
↓
Render Function
↓
VNode
↓
Renderer
渲染
↓
DOM

职责非常清晰:

1
2
3
4
5
Compiler
= 理解你写的模板

Renderer
= 把 UI 描述真正变成界面

十四、为什么 Vue 还需要调度系统?

假设:

1
2
3
4
temperature.value = 81;
temperature.value = 82;
temperature.value = 83;
temperature.value = 84;

如果每改一次:

1
立即完整更新一次 UI

可能产生很多没必要的工作。

所以 Vue 会对更新进行调度和批处理。

可以粗略理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
状态连续变化

81
82
83
84

↓
把更新任务放进队列
↓
合并/批量处理
↓
最终 UI 反映最新状态

这也是为什么你有时候:

1
2
3
count.value++;

console.log(某个DOM);

DOM 不一定已经立即完成更新。

于是 Vue 提供:

1
await nextTick();

意思可以理解:

等 Vue 把这一轮 DOM 更新处理完。


十五、Vue Router

注意:

Router 并不是响应式系统的一部分。

它解决另外一个大型应用问题:

1
2
3
URL
↓
应该显示哪个页面?

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/dashboard
↓
Dashboard

/devices
↓
DeviceList

/devices/001
↓
DeviceDetail

/alarms
↓
AlarmPage

所以:

Vue Router = 前端路由管理。

大型单页应用 SPA 才需要这一层。


十六、Pinia 又为什么出现?

组件少的时候:

1
2
3
4
5
App
↓
DevicePage
↓
DeviceCard

props 就够了。

但是如果:

1
2
3
4
5
        App
/ | \
Header Device Alarm
| | |
User Status User

很多地方都需要:

1
2
3
4
当前用户
权限
设备连接状态
全局配置

如果一直:

1
2
3
4
5
6
7
props
↓
props
↓
props
↓
props

很麻烦。

于是出现:

全局状态管理。

例如 Pinia:

1
2
3
4
     Pinia Store
/ | \
/ | \
Header Device Alarm

大家读取同一个共享状态。

所以:

1
2
3
4
5
6
7
8
ref/reactive
→ 管局部响应式状态

props/emit
→ 管父子组件通信

Pinia
→ 管跨组件共享状态

这个层次就清楚了。


十七、把 Vue 整个框架从上往下看

现在不要看 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
29
30
31
32
33
34
35
36
37
                     Vue 应用
│
┌───────────────┼───────────────┐
↓ ↓ ↓
状态 UI结构 页面组织
│ │ │
响应式系统 组件系统 Router
│ │
ref/reactive Component
│
├── computed
└── watch

↓
状态变化

↓
调度系统

↓

Template
↓
Compiler
↓
Render Function
↓
VNode
↓
Renderer
↓
真实 DOM


跨组件共享状态
↓
Pinia

这里面真正属于 Vue 核心的部分,和 Vue Router、Pinia 这种生态配套,要有区分。


十八、为什么 Vue 要设计 Composition API?

以前 Vue 2 常见 Options API:

1
2
3
4
5
6
7
8
9
10
11
12
13
export default {
data() {
return {};
},

computed: {},

methods: {},

watch: {},

mounted() {}
};

一个业务功能可能被拆散:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
设备 WebSocket 功能:

data
↓
状态在这里

methods
↓
连接逻辑在这里

watch
↓
监听在这里

mounted
↓
启动逻辑在这里

beforeDestroy
↓
关闭逻辑又在这里

同一个功能散在不同配置区域。

Composition API:

1
2
3
4
5
6
7
8
9
10
11
12
13
function useDeviceSocket() {
const status = ref("offline");

function connect() {}
function disconnect() {}

onMounted(connect);
onUnmounted(disconnect);

return {
status
};
}

这样:

1
2
3
4
5
6
7
8
9
10
11
WebSocket相关
↓
放一起

告警相关
↓
放一起

设备数据相关
↓
放一起

而且可以抽:

1
2
3
useDeviceSocket()
useAlarm()
useDeviceData()

复用逻辑。

所以 Composition API 一个很重要的设计目标就是:

让相关业务逻辑更容易组织在一起,并方便逻辑复用,尤其适合复杂组件。

这也是为什么 Vue 3 项目经常看到:

1
<script setup>

十九、不要把 Vue 记成一堆 API

api可能是:

1
2
3
4
5
6
7
8
9
10
11
12
Vue
=
ref
reactive
watch
computed
props
emit
mounted
router
pinia
...

这样很容易忘。

改成:

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Vue 要解决大型 UI 开发

问题1:
数据变了页面怎么办?
↓
响应式


问题2:
怎么描述 UI?
↓
Template + 声明式渲染


问题3:
大页面怎么拆?
↓
Component


问题4:
组件怎么通信?
↓
props / emit


问题5:
已有状态怎么算新状态?
↓
computed


问题6:
状态变化后要执行其他操作怎么办?
↓
watch


问题7:
组件出现/消失时怎么办?
↓
生命周期


问题8:
怎么高效更新 DOM?
↓
Compiler + VNode + Renderer + Scheduler


问题9:
多个页面怎么办?
↓
Router


问题10:
大量组件共享状态怎么办?
↓
Pinia


问题11:
复杂业务逻辑怎么组织和复用?
↓
Composition API

为什么需要这个东西。比记住api更重要


而是在理解:

Vue 是围绕“状态驱动 UI”这个核心,设计出响应式、组件、编译、渲染、调度等机制,再通过 Router、Pinia 等生态能力支撑完整大型前端应用。

一句话记住

Vue 最核心的思想可以先浓缩成:

1
2
3
4
5
6
7
状态
↓
响应式系统知道它变了
↓
Vue 调度更新
↓
渲染系统更新需要变化的 UI

而:

1
组件化

负责把复杂页面拆开。