从输入 URL 到页面:一个 Vue 项目的“奇幻漂流”
🧭 从 URL 到页面:一个 Vue 项目的“奇幻漂流”
这是一段你每天都可能经历的旅程:在浏览器输入一个地址,按下回车,几毫秒后,一个 Vue 单页应用就活生生地出现在屏幕上。这背后发生了什么?
Vue 的响应式系统、虚拟 DOM、编译器和“发布‑订阅”主角们——Observer、Dep、Watcher、Patch——是如何协作的?
让我们像侦探一样,一步步追踪这段旅程,用有趣但不失严谨的方式,把整个技术链路掰开揉碎。
🚀 第一站:浏览器 —— 资源的“快递小哥”
输入 URL → DNS 解析 → TCP 连接 → 请求 HTML → 接收响应
当你在地址栏敲下 https://my-vue-app.com,浏览器立刻化身快递调度中心:
-
DNS 查询: 把域名变成 IP 地址(比如
192.0.2.1)。 - TCP 握手: 与服务器建立可靠连接。
- 发送 HTTP 请求: 告诉服务器“我要你的首页”。
-
服务器返回 HTML: 通常一个极简的 index.html,里面只有一个
<div id="app"></div>和一串<script src="/js/chunk-vendor.js">之类的标签。
这时 Vue 还没现身,只是一个空壳 HTML 被浏览器解析。但关键的 JS 文件已经开始下载——它们才是 Vue 的“灵魂”。
📦 第二站:Vue 实例诞生 —— “造物主”的仪式
当浏览器加载并执行完打包后的 JS 文件(通常由 Webpack/Vite 生成),Vue 的舞台正式搭好。
// main.js —— 一切从这里开始
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
这行代码背后,Vue 内部展开了一场精密的初始化交响乐:
🎼 乐章一:合并选项 & 生命周期初始化
- 将传入的
router、store、render等与默认配置合并。 - 设置内部标志(如
_isMounted),调用beforeCreate钩子。
🎼 乐章二:数据响应式 —— Observer 的“大改造”
beforeCreate钩子执行完,执行initState 接着初始化 inject→initState(props→data→computed→watch)→provide
function initState(vm) {
initProps(vm, opts.props);
initMethods(vm, opts.methods); // 处理 methods
initData(vm); // 调用 observe() 将 data 转为响应式
initComputed(vm, opts.computed);// 处理 computed
initWatch(vm, opts.watch); // 处理 watch
}
响应式data这是最精彩的部分。Vue 会遍历 data() 返回的对象,递归地把每一个属性变成响应式:
- Vue 2:用
Object.defineProperty重写 getter/setter,每个属性配一个专属的Dep(依赖管理器)。 - Vue 3:用
Proxy代理整个对象,更强大(能监听属性添加/删除)。
// 简化的响应式模型
data() {
return { count: 0, user: { name: 'Alice' } }
}
// ↓ 响应式数据 内部主要实现
// 1. Observer(观察者)- 数据劫持
/**核心工作:
* - 为对象添加 __ob__ 属性,指向 Observer 实例
* - 对数组:重写 push/pop/shift/unshift/splice/sort/reverse 方法
* - 对对象:调用 defineReactive 将每个属性转换为 getter/setter
*/
class Observer {
constructor(value, shallow = false, mock = false) {
this.value = value;
this.shallow = shallow;
this.dep = new Dep(); // 每个 Observer 持有一个 Dep
this.vmCount = 0;
def(value, '__ob__', this); // 在对象上标记 __ob__
if (isArray(value)) {
// 数组:拦截变异方法
this.observeArray(value);
} else {
// 对象:遍历每个属性,转换为 getter/setter
const keys = Object.keys(value);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
defineReactive(value, key, NO_INITIAL_VALUE, undefined, shallow, mock);
}
}
}
}
function defineReactive(obj, key, val) {
observe(val); // 递归处理嵌套对象
const dep = new Dep(); // 每个属性有自己的依赖管理器
Object.defineProperty(obj, key, {
get() {
if (Dep.target) { // 当前正在执行的 Watcher
dep.addSub(Dep.target); // 依赖收集
}
return val;
},
set(newVal) {
if (newVal !== val) {
val = newVal;
observe(newVal); // 新值如果是对象,也需要转为响应式
dep.notify(); // 派发更新,通知所有 Watcher
}
}
});
}
// Dep 类 是一个依赖收集器,充当发布-订阅模式的调度中心:
class Dep {
constructor() { this.subs = []; }
addSub(watcher) { this.subs.push(watcher); }
notify() { this.subs.forEach(w => w.update()); }
}
// ↓ 经过 Observer
count 拥有了 getter/setter + 一个 Dep
user 对象也被递归改造,name 同样拥有 getter/setter + Dep
同时,computed 和 watch 也会创建对应的 Watcher(观察者)。但此时它们都只是“预备役”,还没有真正去订阅数据。
🎼 乐章三:created 钩子触发
现在 data、computed、methods 都已经可用,但 DOM 还不存在。你可以在 created 里发起异步请求、设置定时器,因为响应式数据已经 ready。
🛠️ 第三站:编译 —— 模板如何变成“渲染函数”?
Vue 有两种方式获得 render 函数:
- 你直接提供了(比如单文件组件里的
<script>导出render)。 - 或者 Vue 需要编译模板——这是最通用的方式。 假设我们有一个模板:
<div id="app">
<p>{{ message }}</p>
<button @click="count++">Click me</button>
</div>
Compiler 会做三件事:
- 解析(Parse): 把模板字符串转换成 AST(抽象语法树)。AST 就是一个 JS 对象,精准描述了 DOM 结构、指令、文本插值等。
- 优化(Optimize): 标记静态节点(比如没有绑定任何动态数据的纯文本)。这一步为后续虚拟 DOM 的 diff 减负。
-
代码生成(Codegen): 从 AST 生成一个可执行的
render函数,类似:
function render() {
with(this) {
return _c('div', { attrs: { id: 'app' } }, [
_c('p', [_v(_s(message))]),
_c('button', { on: { click: () => count++ } }, [_v('Click me')])
])
}
}
注意:编译阶段不会把
{{ message }}替换成具体值,也不会为每个指令绑定更新函数。它只产出render函数,真正的数据替换要到运行时。
##🎬 第四站:首次渲染 —— 从数据到真实 DOM 的“首秀”
🎼 乐章四:mountComponent 组件挂载阶段
created执行结束,开始执行 $mount 进入组件挂载阶段。
$mount
现在 data、computed、methods 都已经可用,但 DOM 还不存在。你可以在 created 里发起异步请求、设置定时器,因为响应式数据已经 ready。
$mount 函数被调用,Vue 创建了一个渲染 Watcher:
Vue.prototype.$mount = function (el, hydrating) {
// ...
return mountComponent(this, el, hydrating)
}
function mountComponent(vm, el, hydrating) {
vm.$el = el;
callHook$1(vm, 'beforeMount');
// 创建更新函数
const updateComponent = () => {
vm._update(vm._render(), hydrating); // render 生成 vnode,update 更新 DOM
};
// 创建渲染 Watcher !!!!!!!!!!!!!在这呢~
new Watcher(vm, updateComponent, noop, {
before() {
if (vm._isMounted && !vm._isDestroyed) {
callHook$1(vm, 'beforeUpdate');
}
}
}, true)
if (vm.$vnode == null) {
vm._isMounted = true;
callHook$1(vm, 'mounted');
}
return vm;
}
class Watcher {
constructor(vm, expOrFn, cb, options, isRenderWatcher) {
this.vm = vm;
this.deps = []; // 当前依赖的 Dep 列表
this.newDeps = []; // 新一轮收集的 Dep 列表
this.depIds = new Set(); // 避免重复添加
this.getter = expOrFn; // 获取值的函数(渲染函数或表达式)
this.value = this.lazy ? undefined : this.get();
}
get() {
pushTarget(this); // 将自己设为 Dep.target
let value;
try {
value = this.getter.call(this.vm, this.vm); // 执行 getter,触发依赖收集
} finally {
popTarget(); // 恢复上一个 Dep.target
this.cleanupDeps(); // 清理不再需要的依赖
}
return value;
}
addDep(dep) {
const id = dep.id;
if (!this.newDepIds.has(id)) {
this.newDepIds.add(id);
this.newDeps.push(dep);
if (!this.depIds.has(id)) {
dep.addSub(this); // 双向绑定:Watcher 订阅 Dep
}
}
}
update() {
if (this.lazy) {
this.dirty = true;
} else if (this.sync) {
this.run();
} else {
queueWatcher(this); // 异步队列更新
}
}
}
updateComponent 内部就是:vm._update(vm._render(), ...)。
渲染 Watcher 会立即执行一次,开启首次渲染之旅。
1️⃣ _render() —— 生成虚拟 DOM (VNode)
调用刚才生成的 render 函数。
在 render 执行过程中,this.message 和 this.count 被读取 → 触发它们的 getter → 依赖收集开始!
- 每个响应式属性的
Dep会检查当前是否有活动的 Watcher(此时就是渲染 Watcher)。 - 如果有,就把这个渲染 Watcher 添加到自己的订阅列表(
subs)中。
// 伪代码:依赖收集
getter() {
if (Dep.target) {
dep.depend() // 把 Dep.target(渲染 Watcher)加入 subs
}
return value
}
结果:message 和 count 现在“认识”了渲染 Watcher。以后它们变了,就知道该通知谁。
render 最终返回一棵 VNode 树——一个轻量级的 JS 对象,描述了 DOM 结构。
2️⃣ _update() —— patch 挂载到真实 DOM
调用 __patch__ 函数,首次渲染时 oldVnode 是挂载点(真实 DOM 元素,比如 <div id="app">),vnode 是新 VNode。
patch 会递归地创建真实 DOM 元素,设置属性、事件监听(比如 @click 被绑定到真正的 click 事件),最后把生成的 DOM 插入到页面中。
页面终于显示了! 🎉
随后 mounted 钩子被调用,你可以在里面操作 DOM 了。
🔄 第五站:交互与响应式更新 —— “自动档”的魔法
用户点击了“Click me”按钮,count++ 被执行。
1️⃣ 数据变化
count 的 setter 被触发,内部调用 dep.notify()。
2️⃣ 派发更新
dep.notify() 会遍历 subs 列表(里面目前有渲染 Watcher),调用每个 Watcher 的 update() 方法。
3️⃣ 异步调度
update() 不会立即重新渲染,而是调用 queueWatcher(this) 把渲染 Watcher 放入一个异步队列。
Vue 通过 nextTick(微任务或降级宏任务)来批量处理更新,避免同一个 Watcher 被重复添加(去重)。
4️⃣ 重新渲染与 Diff
在下一个 tick,队列被清空:
- 渲染 Watcher 执行
run()→ 再次调用 updateComponent。 - 重新执行
render()生成新 VNode(此时 count 已经变成新值,依赖收集会重新建立,旧依赖会被清理)。 - 调用
_update()执行patch(oldVNode, newVNode)。 Diff 算法登场(Vue 2 双端比较 / Vue 3 快速 diff + 最长递增子序列): - 比较新旧 VNode 树,找出最小变化集。
- 只更新变化的部分(比如按钮文本从 “Click me” 变成 “Click me (1)”),而不重新渲染整个列表。
最终真实 DOM 被高效更新,用户看到了新的数字。
随后
updated钩子触发。
🗺️ 完整流程图
URL 输入
↓
DNS 解析 → TCP 连接
↓
HTML 加载 & 解析 JS
↓
new Vue()
├─ 合并选项
├─ beforeCreate(inject → props → )
├─ initInjections → initState(methods → data → computed → watch)
├─── Observer 转换 data(响应式 + Dep)
├─── 初始化 computed / watch(创建 Watcher)
├─ created
└─ $mount
├─ 编译模板 → render 函数(如果没提供)
├─ 创建渲染 Watcher(Vue 2) / Effect(Vue 3)
│ ├─ 执行 _render() → 读取响应式数据 → 依赖收集(数据→Dep→Watcher) → 生成VNode
│ └─ 执行 _update() → patch → 真实 DOM
└─ mounted
↓
用户交互(修改数据)
├─ setter → dep.notify()
├─ 渲染 Watcher 被推入异步队列
├─ nextTick 执行队列
│ ├─ 重新执行 _render() → 新 VNode
│ └─ patch(oldVNode, newVNode) → Diff → 更新 DOM
└─ updated
🧐 一些有趣的细节(常见疑问)
❓ “模板里没用到的数据,会不会也被依赖收集?”
不会。渲染 Watcher 只收集本次渲染实际访问到的数据。如果 v-if 为 false 导致某个分支从未进入,那分支里的数据就不会被收集。当条件变为 true 时,下一次渲染会自动订阅它们。
❓ “v-show 和 v-if 在依赖收集上有什么不同?”
-
v-if:条件为 false 时,该分支根本不渲染 → 不读取内部数据 → 无依赖收集 → 内部数据变化不会触发更新。 -
v-show:只是 CSS 隐藏,DOM 一直存在 → 每次渲染都会读取内部数据 → 依赖始终存在 → 数据变化会触发重新渲染(即使看不见)。
❓ “Observer 在发布‑订阅里是什么角色?”
它是“装修工人”——在初始化时把普通数据改造成带 getter/setter 和 Dep 的响应式对象。它不直接参与发布或订阅,但它是整个系统能够运转的基础。
❓ “Vue 3 比 Vue 2 快在哪?”
- 用
Proxy代替Object.defineProperty,可监听属性添加/删除、数组索引等。 - 编译优化:静态提升、补丁标记、块树 → 让 diff 跳过静态内容。
- 快速 diff + 最长递增子序列 → 减少 DOM 移动次数。
🎯 总结:从 URL 到像素的“奇幻漂流”
| 阶段 | 核心角色 | 产出 |
|---|---|---|
| 资源加载 | 浏览器、HTTP | HTML + JS |
| Vue 初始化 |
Observer、Dep、Watcher
|
响应式数据 + 实例 |
| 模板编译 | Compiler |
render 函数 |
| 首次渲染 | 渲染 Watcher、render、patch
|
真实 DOM |
| 交互更新 |
setter、Dep.notify、调度器、patch + diff |
最小化 DOM 更新 |
总结: 从输入 URL 到 Vue 项目渲染,整个链路是:
URL 输入 → 网络加载(HTML 加载) & 解析 JS → Vue实例初始化(响应式数据、编译)→ 首次渲染 Watcher → 执行 render 生成 VNode → patch 创建真实 DOM → 挂载完成 →用户交互 → 数据变化 → 响应式派发 → 重新渲染 → Diff 更新 DOM
这趟旅程中,Vue 的每一个设计都精妙地平衡了声明式编程的优雅与底层性能的极致。希望这次“共探”,能让你下次启动 Vue 项目时,看到的不只是一个页面,而是一整套精心编排的幕后舞剧。