理解 Vue 的运行机制

理解 Vue 的运行机制

概述

在 Web 开发的早期,网页大多是静态的:HTML 构建骨架,CSS 妆点外观,JavaScript 仅用于实现简单的交互逻辑。然而,随着现代应用对实时性的要求越来越高——例如股票看板、实时监控数据——我们需要一种机制,让数据变化能够实时、自动地映射到界面上。

在桌面应用开发(如 WPF)中,我们习惯了数据绑定带来的便利。Vue.js 正是 Web 前端的“WPF 运行时”:它是一个渐进式的动态框架,通过声明式的数据绑定,让我们专注于数据逻辑,而将繁琐的 DOM 操作交给框架处理。

这篇博客通过介绍脚本引入和构建工具两种使用 Vue 的方式,厘清 Vue 的运行机制。

脚本引入模式

Vue 示例

Vue 的使用方式非常灵活。现代项目通常使用构建工具(如 Vite, Webpack),但在学习原理时,最直观的方式是直接通过 <script> 标签引入。这种方式不需要编译,浏览器直接执行,非常适合理解“运行时”发生了什么。

下面是一个混合了“传统 JS 操作”与“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
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>Vue 运行机制示例</title>
<!-- 引入 Vue 3 全局脚本 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>

<!-- 区域 A:原生 JS 控制 -->
<h2>👋 普通 JavaScript 区域</h2>
<p>当前时间:<span id="plain-time"></span></p>

<hr>

<!-- 区域 B:Vue 接管的宿主容器 -->
<div id="app">
<!-- Vue 组件占位符 -->
<my-panel></my-panel>
</div>

<script>
// ====== 1. 传统方式:直接操作 DOM ======
// 缺点:逻辑与视图紧耦合,需要手动查找元素并修改
setInterval(() => {
document.querySelector("#plain-time").textContent =
new Date().toLocaleTimeString();
}, 1000);


// ====== 2. 模拟数据源服务 ======
function fakeSensor(onData) {
setInterval(() => {
const value = (Math.random() * 100).toFixed(2);
onData(value); // 触发回调
}, 800);
}


// ====== 3. 定义 Vue 组件 (ViewModel) ======
const MyPanel = {
// 视图模板 (View)
template: `
<div>
<h2>🌱 Vue 控制的区域 (响应式)</h2>
<p>计数器:{{ counter }}</p>
<p>传感器数据:{{ sensorValue }}</p>
</div>
`,
// 逻辑核心 (ViewModel Constructor)
setup() {
// 从全局 Vue 对象中解构出所需 API
const { ref, onMounted } = Vue;

// 定义响应式数据
const counter = ref(0);
const sensorValue = ref('--');

// 逻辑:计数器自增
setInterval(() => {
counter.value++; // 修改 value,界面自动更新
}, 1000);

// 逻辑:订阅传感器数据
fakeSensor(value => sensorValue.value = value);

// 关键:将数据暴露给模板使用
return { counter, sensorValue };
}
};


// ====== 4. 启动 Vue 应用 ======
const app = Vue.createApp({}); // 创建应用实例
app.component('my-panel', MyPanel); // 注册组件
app.mount('#app'); // 挂载到 DOM
</script>
</body>
</html>

运行图景:浏览器中发生了什么?

第一阶段:浏览器原生解析

  1. HTML 解析:浏览器从上到下解析 HTML。它看到了 <div id="app"> 和里面的 <my-panel>
  2. 陌生标签:此时,浏览器并不认识 <my-panel>,它只是将其作为一个普通的自定义元素保留在 DOM 树中,界面上不会显示该组件的内容,或者显示为空白。
  3. 脚本加载:浏览器加载 Vue 的库文件,然后执行底部的 <script> 代码。

第二阶段:Vue 的挂载 (Mounting)

当代码执行到 app.mount('#app') 时,Vue 正式介入。这句话的意思是:“找到 id 为 app 的元素,从此以后,这里面的内容归 Vue 管。”

Vue 内部会执行以下流程:

  1. 接管容器:Vue 锁定 #app 元素。
  2. 解析模板:Vue 扫描容器内的 HTML,发现了 <my-panel> 标签。
  3. 组件实例化:Vue 根据注册表中 my-panel 对应的 MyPanel 对象,创建组件实例。
  4. 初始化响应式:运行组件的 setup() 函数,创建 ref 数据,建立依赖追踪系统。
  5. 渲染 (Render)
    • Vue 将组件的 template 编译为渲染函数。
    • 执行渲染函数,生成 虚拟 DOM (Virtual DOM)
    • 根据虚拟 DOM,生成真实的 HTML 结构。
  6. 替换与显示:Vue 用生成的真实 DOM 替换掉页面上原本的 <my-panel> 占位符。

此时,用户看到了计数器和传感器数值。

1
2
3
4
5
6
7
8
9
10
11
app.mount()

├── 找到 #app
├── 创建 Vue 根实例
├── 建立响应式系统
├── 解析 template
├── 实例化组件
├── 运行 setup()
├── 建立依赖追踪
├── 首次渲染
└── 进入响应式更新循环

为了更好地理解上述过程,我们可以用 WPF 桌面应用开发做类比。Vue 其实就是运行在浏览器里的“渲染引擎 + 框架运行时”。

概念 WPF (桌面端) Vue (Web 端) 作用
View XAML Template (HTML) 声明界面的结构和外观
ViewModel ViewModel Class setup() / Script 只有逻辑和数据,不直接操作 UI
Data Binding {Binding Prop} {{ ref }} 建立数据与 UI 的连接
App Host Application / Window createApp() / Root Component 应用的容器和入口
Runtime .NET CLR Browser JS Engine + Vue 负责代码执行和内存管理

Vue 相关概念

应用与组件的关系

在代码中,我们先创建了 app,然后注册了 component

1
2
const app = createApp({})
app.component('my-panel', MyPanel)

app 是组件的容器,组件不是全局存在的,它必须依赖一个 Vue 应用。就像 WPF 里先有 Application / Window(根),再往里面加控件(UIElement → Button、Grid 等)。app 和组件构建了一个树状结构:

  • App (应用实例):全局上下文。一个页面通常只有一个 App 实例。它管理着所有的全局配置(如路由、状态管理 Pinia、全局组件)。
  • Component (组件):UI 的积木。组件必须依附于 App 存在(或者作为其他组件的子组件)。
1
2
3
4
App (Root)
└── MyPanel (Component)
├── 内部可能还有 ChildComponent
└── 更多子节点...

这种结构保证了数据流向清晰,且组件之间相互隔离,通过明确的接口(Props/Events)通信。

setup():组件的构造函数

setup() 是 Vue 3 Composition API 的入口。它相当于 ViewModel 的构造函数。它的职责非常纯粹:

  1. 定义数据:使用 refreactive 声明需要被追踪的数据。
  2. 定义行为:设置定时器、发起网络请求、监听事件。
  3. 暴露接口:通过 return 将数据和方法暴露给模板。

我们可能好奇的是,为什么这个 ViewModel 必须把要更新绑定的变量放到 return 中?

1
2
3
4
5
setup() {
const counter = ref(0);
// ...
return { counter }; // 必须返回!
}

这是由 JavaScript 的作用域(Scope)决定的。 * setup 只是一个普通的 JavaScript 函数。 * counter 是这个函数内部的局部变量。 * HTML 模板(Template)在编译后,会变成一个渲染函数。这个渲染函数运行在组件实例的上下文中。 * 如果不 return,渲染函数就看不见 counter,也就无法把它显示在界面上。

return 的动作,实际上是将局部变量“挂载”到了组件实例上,让模板引擎能够访问到它。

Ref:响应式的魔法包裹

Vue 的 ref(0) 并不是简单地返回 0,而是返回了一个对象:

1
2
3
4
5
// ref(0) 的概念结构
{
value: 0
// 内部包含依赖收集器 (Dep)
}
  • 在 JS 中:我们需要通过 .value 来读写数据(counter.value++)。这是因为 JavaScript 的原始类型(数字、字符串)是值传递的,如果直接返回数字 0,Vue 就无法追踪它的变化了。我们需要一个对象作为“引用”容器。
  • 在 模板 中:Vue 会自动帮我们“解包”。所以我们在 HTML 里只需要写 {{ counter }},而不需要写 {{ counter.value }}

counter.value 改变时,Vue 内部的通知机制(类似于 WPF 的 PropertyChanged)会被触发,通知虚拟 DOM 重新计算,进而更新界面。

语言基础

对象解构赋值和对象字面量

在 MyPanel 组件的 setup() 方法中,const { ref, onMounted } = Vue; 是 JavaScript 的对象解构赋值,意思就等价于:

1
2
const ref = Vue.ref;
const onMounted = Vue.onMounted;

这里的 const MyPanel = { ... } 中的 {} 是对象字面量语法,它可以快速地定义一个对象:

1
2
3
4
5
6
var server = {
name: 'Server',
restart: function() {
console.log("The " + this.name + " is restarting...");
}
};

也有方法的简写语法糖形式:

1
2
3
4
5
6
7
const server = {
name: 'Server',
restart() {
console.log("The " + this.name + " is restarting...");
}
};
server.restart(); // 输出 "The Server is restarting..."

JavaScript 异步模型

代码中使用了 setInterval 和回调函数,这触及了 JavaScript 最核心的并发模型:单线程 + 事件循环

1
2
3
setInterval(() => {
// ...
}, 1000);

JavaScript 在浏览器中只有一个主线程。它不能像 C# 那样开启多线程来处理后台任务。setInterval 并没有阻塞代码,它只是向浏览器注册了一个“闹钟”。当 1000ms 到达时,浏览器把回调函数扔进“任务队列”。一旦主线程空闲下来,就会从队列里取出这个函数执行。这种机制使得 Vue 组件可以在等待数据(如 fakeSensor 模拟的网络请求)时,依然保持界面响应,不会卡死浏览器。

比如在我们示例中未使用 Vue 的部分,就直接用了异步回调操纵 DOM 来实现动态时间的展示。但是这造成了逻辑和页面(HTML)元素之间的耦合,这其实也是 MVVM 架构想要解决的依赖问题。

Vite 构建:现代化的 Vue 工程结构

基本图像:从单文件到模块化

刚才我们用纯 HTML + 脚本引入的方式演示了 Vue 的核心流程。实际上,使用 Vite 等构建工具开发 Vue 项目,底层逻辑与之前并无二致,区别在于代码的组织方式

在“脚本引入”模式下,所有逻辑(HTML 模板、CSS 样式、JS 逻辑)往往挤在一个 HTML 文件里,难以维护。而在“工程化”模式下,我们将 App、各个组件以及入口 index.html 拆分到不同的文件中,实现了模块化管理。

我们可以使用 Vite 快速初始化一个标准的 Vue 项目:

1
2
3
4
5
6
7
8
9
10
# 1. 创建项目
npm create vite@latest vue-mvvm-demo -- --template vue

cd vue-mvvm-demo

# 2. 安装依赖 (下载 node_modules)
npm install

# 3. 启动开发服务器
npm run dev

启动后,浏览器打开提示的本地地址(通常是 http://localhost:5173),你会看到一个运行中的 Vue 页面。

开发 vs 生产

这里的开发服务器(Dev Server)主要用于热更新 (HMR):当你修改代码保存时,浏览器里的页面会瞬间局部刷新,无需手动 F5。

而在实际部署上线时,我们不会把源码上传服务器,而是执行构建命令:

1
npm run build

这个命令会将 Vue 项目编译、压缩成最原始的 HTML + CSS + JavaScript 静态文件(通常输出在 dist 目录)。这些文件更加轻量、兼容性更好,你可以直接把它们丢到 Nginx、Apache 或任何静态文件服务器上运行。

工具链图谱:npm、Vite 与 Vue

初学者容易混淆这几个工具的关系,我们可以这样理解:

graph TD
    npm[npm: 仓库管理员] -->|安装依赖/运行脚本| Vite[Vite: 工厂与运输队]
    Vite -->|编译/热更新| Vue[Vue: 原材料与蓝图]
  • npm (Node Package Manager):它是管家。它不关心 Vue 或 Vite 具体是怎么工作的,它只负责根据 package.json 的清单,把需要的工具包下载到 node_modules 里,并帮你运行定义好的脚本(如 npm run dev)。
  • Vite (Build Tool):它是现代化的构建工厂
    • 开发时:它启动一个本地服务器,利用浏览器原生的 ESM 能力(下文会讲),实现秒级启动和热更新。
    • 构建时:它负责编译 .vue 文件、转换 TypeScript、压缩代码、处理 CSS 模块化,最终产出浏览器能直接运行的静态资源。
  • Vue (UI Framework):它是核心规则。我们按照它的语法规范编写组件(<template>, setup),它提供响应式系统和组件化能力。

当你执行 npm run dev 时,流程如下: 1. npm 读取 package.json 中的 "scripts": { "dev": "vite" }。 2. npm 启动 Vite。 3. Vite 启动开发服务器,拦截浏览器请求。 4. 浏览器请求文件时,Vite 实时编译 .vue 组件并返回给浏览器。 5. Vue 在浏览器中运行,渲染界面。

运行顺序与入口解析

工程化后,项目的入口依然是 index.html(通常位于根目录),但它的内容变得非常干净:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>Vue MVVM + Vite 示例</title>
</head>
<body>
<!-- 1. 静态区域 -->
<h2>👋 Vue 未接管的区域(普通 JS 控制)</h2>
<p>当前时间:<span id="plain-time"></span></p>

<hr />

<!-- 2. Vue 的挂载点 -->
<div id="app"></div>

<!-- 3. 核心入口脚本 -->
<script type="module" src="/src/main.js"></script>
</body>
</html>

什么是 type="module"

注意看这行代码:<script type="module" src="/src/main.js"></script>

这是现代浏览器支持的 ESM (ECMAScript Modules) 标准。 * 在过去,浏览器里的 JS 无法直接相互引用,必须把所有代码打包成一个大文件或者按顺序加载多个 script 标签。 * 有了 type="module",浏览器就看懂了 JavaScript 里的 importexport 语法。 * 这意味着浏览器看到 import { createApp } from 'vue' 时,会主动去请求对应的文件。Vite 正是利用了这个特性,在开发环境下不打包,直接让浏览器按需请求文件,所以启动速度极快。

代码迁移:从脚本到模块

Vite 将逻辑的指挥棒交给了 /src/main.js。让我们看看它里面发生了什么,你会发现它和我们在上面写的 <script> 内容几乎一样,只是被拆分了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/main.js
// 1. 引入 Vue 核心功能
import { createApp } from 'vue'
// 2. 引入根组件(把 HTML 里的模板抽离到了这里)
import App from './App.vue'

// --- 非 Vue 部分:原生 DOM 操作依然有效 ---
setInterval(() => {
const span = document.querySelector('#plain-time')
if (span) {
span.textContent = new Date().toLocaleTimeString()
}
}, 1000)

// --- Vue 部分:创建并挂载 ---
// 这里的 App 是一个组件对象,和之前定义的 const MyPanel 很像
createApp(App).mount('#app')

这就是 “真实生产项目的基本骨架”index.html 提供容器、main.js 负责启动、.vue 文件负责具体的 UI 和逻辑。

单文件组件 (SFC)

现在我们来看核心的变化:.vue 文件。这种文件格式被称为 SFC (Single File Component),即“单文件组件”。它把一个组件所需的 HTML(结构)、JavaScript(逻辑)和 CSS(样式)封装在一个文件里。

我们需要定义根组件 src/App.vue

1
2
3
4
5
6
7
8
9
<template>
<!-- 就像之前的 my-panel 标签 -->
<MyPanel />
</template>

<script setup>
// 直接引入子组件,无需像以前那样 app.component() 全局注册
import MyPanel from './components/MyPanel.vue'
</script>

接着是具体的业务组件 src/components/MyPanel.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
30
31
32
33
34
35
36
37
38
39
40
41
<!-- 1. 模板区 (Template) -->
<template>
<div class="panel">
<h2>🌱 Vue 控制的区域(响应式更新)</h2>
<p>计数器:{{ counter }}</p>
<p>传感器数据:{{ sensorValue }}</p>
</div>
</template>

<!-- 2. 脚本区 (Script Setup) -->
<script setup>
// 这里的代码等同于 setup() 函数内部的代码
import { ref } from 'vue'

function fakeSensor(onData) {
setInterval(() => {
const value = (Math.random() * 100).toFixed(2)
onData(value)
}, 800)
}

const counter = ref(0)
const sensorValue = ref('--')

setInterval(() => {
counter.value++
}, 1000)

fakeSensor(v => (sensorValue.value = v))
// 注意:在 <script setup> 中,无需 return,
// 顶层定义的变量会自动暴露给模板使用,这是 Vue 的语法糖。
</script>

<!-- 3. 样式区 (Style) -->
<style scoped>
/* scoped 表示这里的样式只影响当前组件,不污染全局 */
.panel {
border: 1px solid #ccc;
padding: 10px;
}
</style>

浏览器并不认识 .vue 文件。当我们在 main.jsimport App from './App.vue' 时,Vite(借助插件 @vitejs/plugin-vue)会在幕后把这个 .vue 文件“翻译”成浏览器能看懂的 JS 对象: * <template> 变成了渲染函数。 * <script setup> 变成了 setup() 函数。 * <style> 被提取并插入到页面的 <head> 中。

总结:Vue 应用的完整生命周期

经过前两部分的探索,我们已经从最简单的 <script> 标签引入,进阶到了使用 Vite 进行模块化构建。现在,让我们跳出代码细节,建立一个 Vue 应用从开发部署再到用户浏览器运行的完整宏观图像。

最终产物:一切回归静态

无论我们在开发时写了多么复杂的 .vue 组件,用了多么高级的 TypeScript 语法或 CSS 预处理器,最终交给浏览器的,永远只有“网页三剑客”:HTML、CSS 和 JavaScript

当执行 npm run build 命令时,Vite 会扮演“翻译官”和“打包工”的角色: 1. 翻译:把 .vue 文件里的 <template> 编译成 JS 渲染函数。 2. 打包:把成百上千个模块文件合并、压缩成几个体积极小的 JS 文件。 3. 输出:将所有结果放入 dist(发布)目录。

最终,dist 目录里的结构通常是这样的:

1
2
3
4
5
dist/
├── index.html (入口文件,体积很小,里面只有一个 <div id="app"> 和 script 引用)
└── assets/
├── index.a1b2c.js (包含了 Vue 源码 + 你的业务逻辑,被压缩成了一行)
└── index.d4e5f.css (所有的样式)

这就是 Vue 应用的物理本质:一堆静态文件。

托管与部署:不需要后端逻辑

因为产物是纯静态文件,所以部署 Vue 应用非常简单。不需要像 ASP.NET Core 或 Java Spring Boot 那样运行一个复杂的后端服务程序。

只需要一个简单的静态文件服务器(Web Server): * Nginx:业界最常用的高性能 HTTP 服务器。 * Apache:老牌服务器。 * CDN / 云存储:如 AWS S3, Vercel, Netlify, 阿里云 OSS。

只需要把 dist 文件夹里的内容上传上去,配置 Web Server 指向 index.html,部署就完成了。

运行时图像:CSR(客户端渲染)

那么,一个静态的 HTML 文件,是如何变成动态的、实时更新的应用程序的呢?这个过程被称为 SPA(单页应用)CSR(客户端渲染)

让我们看一遍当用户访问网址时发生的完整流程:

第一步:下载“空壳”

用户在浏览器输入网址。Nginx 返回 dist/index.html

此时,用户看到的只是一个白屏或加载动画,因为 HTML 里只有一个空的 <div id="app">

第二步:加载“大脑”

浏览器解析 HTML,发现 <script src="assets/index.js">,开始下载这个巨大的 JS 文件。这个文件里包含了 Vue 的引擎代码和写的 setup() 逻辑。

第三步:激活(Hydration / Mount)

JS 下载并执行。 1. 启动createApp(App).mount('#app') 被执行。

  1. 接管:Vue 找到页面上的 #app 节点。

  2. 渲染:Vue 根据逻辑生成虚拟 DOM,并转换成真实的 DOM 元素(输入框、按钮、表格等)塞进 #app 里。

    瞬间,页面从空白变成了丰富多彩的应用界面。

第四步:动态更新

从此之后,页面基本不再需要刷新。 * 数据变化:当定时器触发或用户点击按钮,你的 ref 数据发生变化。 * 响应式反应:Vue 监听到变化,重新计算虚拟 DOM,找出差异。 * 局部修补:Vue 只修改变动部分的真实 DOM(例如只修改了 <span> 里的数字)。

全景图

最后,我们将这个过程总结为一张图:

graph TD
    subgraph 开发阶段 [Developer Machine]
        Code[.vue / .js] -->|npm run build| Compiler[Vite 构建]
        Compiler --> Artifacts["dist 目录 (HTML+JS+CSS)"]
    end

    subgraph 部署阶段 [Web Server / Nginx]
        Artifacts -->|上传| Server[静态文件托管]
    end

    subgraph 运行阶段 [User Browser]
        User[用户访问 URL] -->|请求| Server
        Server -->|返回 index.html + JS| Browser[浏览器]
        Browser -->|执行 JS| VueEngine[Vue 运行时启动]
        VueEngine -->|生成 DOM| Screen[显示界面]
        
        Event[用户交互/定时器] -->|修改 ref| Reactivity[响应式系统]
        Reactivity -->|Patch| Screen
    end