Vue 实例都做了什么?------从 new Vue()
到销毁的全链路(Vue 2)
这是一篇面向实战与源码思维的"全景文章"。你可以把它当作:Vue 2 实例生命周期 + 响应式机制 + 渲染更新的一次打通复盘。
new Vue(options)
发生了什么?
目标 :把你传入的 options
(data/props/methods/computed/watch/template 等)变成一个可响应、可渲染、可通信的组件实例 vm
。
高层流程(精简版):
-
构造与选项合并
- 全局构造配置(全局
Vue.mixin/extend
等)与本地options
做 合并 ,得到实例级的vm.$options
。
- 全局构造配置(全局
-
初始化生命周期字段
- 建立父子关系(
$parent/$children
)、根实例$root
、_isMounted/_isDestroyed
等标记。
- 建立父子关系(
-
初始化事件系统
- 准备好组件实例的 自定义事件 能力(
vm.$on/$off/$emit/$once
)。
- 准备好组件实例的 自定义事件 能力(
-
初始化渲染能力
- 挂上
vm.$slots/$scopedSlots
、$createElement
(也就是h()
),为后续render()
做准备。
- 挂上
-
beforeCreate
钩子 (此时:data/props 还未注入) -
initInjections
(优先解析inject
) -
initState
(核心)-
props → methods → data → computed → watch 按顺序初始化为响应式:
- 为
data/props
做 依赖收集 的getter/setter
包装 - 为
computed
创建 惰性 watcher,带缓存 - 为
watch
创建 用户 watcher,建立数据 → 回调 的订阅关系
- 为
-
-
initProvide
(准备provide
) -
created
钩子 (此时:data/props 已可用 ,但还没挂载 DOM)
记忆点:
beforeCreate
前啥都没;created
后数据可用但没有 DOM。
挂载:vm.$mount(el)
如何把数据变成 DOM?
目标 :拿到 render()
,跑一遍渲染,得到 VNode ,再通过 patch 变成真实 DOM。
-
得到
render
函数- 你若提供
render
,直接用; - 否则以
template
(或el
的外层 HTML)经编译器 转成render
(运行时+编译版才行)。
- 你若提供
-
beforeMount
钩子 -
创建"渲染 watcher"(Render Watcher)
- 定义
updateComponent = () => { vm._update(vm._render()) }
- 首次执行:
_render()
生成 VNode →_update()
调 patch 把 VNode 挂到真实 DOM
- 定义
-
mounted
钩子(此时:DOM 已可访问)
关键:渲染 watcher 订阅了所有在
render()
过程中被读取的响应式数据 。数据变 → watcher 被通知 → 重新跑updateComponent
→ 触发增量patch
。
响应式:依赖收集与派发更新
Vue 2 基于 Object.defineProperty
做响应式,核心元素有三类:
-
Dep(依赖桶) :每个可被依赖的数据字段都有一个 Dep,记录"谁在用我"(watchers)。
-
Watcher(观察者) :三种常见
- 渲染 watcher:让视图随数据变化而更新
- 计算属性 watcher(lazy) :按需求值 + 缓存
- 用户 watcher :你写在
watch:{}
或vm.$watch(...)
的那些
-
Scheduler(调度器) :同一个"tick"内多次变更只触发一次批量更新(队列 + 去重),最终在
nextTick
时机统一 flush。
一次更新的链路:
this.xxx = 2
→ 触发该字段的 setter
→ dep.notify()
→ 对应的 watchers 入队
→ 调度器合并、去重
→ 微任务/宏任务 边界的 nextTick
执行 flush
→ 渲染 watcher 再跑 updateComponent
→ VNode diff + patch,最小化 DOM 改动
记忆点:渲染函数里"读过"的响应式数据,才会被订阅。因此"只改没读"的数据不会引起重渲染。
更新:Virtual DOM diff 与最小化 DOM 改动
-
_render()
每次返回一个全新的 VNode 树; -
_update()
内部用patch(oldVnode, vnode)
做 diff:- 比较节点类型/
key
,复用能复用的 DOM 节点; - 只在必要的地方做增删改;
- 组件子树按递归策略更新;
- 高效列表更新依赖 稳定的
key
。
- 比较节点类型/
实战要点:列表项必须有稳定 key,否则 diff 代价大、还可能引起表单输入错乱。
事件、插槽、父子通信
- 事件系统 :
vm.$emit
(子→父)、@xxx
/v-on
(父监听子); - Props/自定义事件:构成了"单向数据流 + 自下而上事件"的通信模式;
- Slots/Scoped Slots :父将模板片段作为插槽传入,子在自己的渲染上下文中渲染;
- Provide/Inject :祖先到后代的依赖注入(跨层级传参),注意不是响应式传递(Vue 2 中需要手动处理变化)。
销毁:vm.$destroy()
-
beforeDestroy
钩子 -
解绑父子关系 ,移除自身
vm
在父的$children
中的引用 -
Teardown 所有 watchers
- 渲染 watcher / 用户 watcher / 计算属性 watcher
-
移除事件监听
-
patch(vnode, null)
:解绑与清理 DOM/指令/子组件 -
destroyed
钩子
销毁后:实例不再响应,不要再访问其响应式数据做副作用。
与 Vue 3 的一眼对比(方便心中有数)
- 响应式 :
defineProperty
→Proxy
(可拦截新增/删除属性、索引访问) - API 风格 :Options API → Composition API (
setup()/ref/reactive
) - 调度器 :更细粒度的依赖追踪与调度;
scheduler
架构更现代 - 模板编译 :更多静态提升、块跟踪,更少无谓 diff
(但本文主线仍以 Vue 2 为准)
关键钩子该放什么?
beforeCreate
:极少用(数据未可用)created
:可请求数据(无 DOM 依赖);可注册watch
beforeMount
:很少单独用mounted
:需要 DOM 时机(测量、第三方 DOM 库)beforeUpdate
:读 更新前 的 DOMupdated
:读 更新后 的 DOM(避免在这里改数据,易引发循环)beforeDestroy
:清理定时器、事件、订阅 、手动unwatch
destroyed
:一般打点/日志
常见"为什么"的速答
- 为什么我给对象新增属性不触发更新?
Vue 2 基于defineProperty
,新增/删除 属性无法被拦截;用this.$set(obj, key, val)
。 - 数组索引赋值不更新?
用this.$set(arr, index, val)
或使用改变长度的变异方法(splice
等)。 - 为什么我改了很多数据只渲染一次?
因为有 调度器队列 与nextTick
合并更新。 - 列表更新错乱?
给v-for
提供 稳定的key
,不要用索引作为key
(除非真的是静态序列)。
代码
javascript
// 1) 选项
const vm = new Vue({
el: '#app',
props: ['id'], // (在根实例中通常在子组件用)
data() {
return { count: 0, list: [] }
},
computed: {
doubled() { return this.count * 2 }
},
watch: {
count: {
immediate: true,
handler(n) { console.log('count ->', n) }
}
},
created() {
// 数据可用,DOM 尚未就绪
this.fetch()
},
mounted() {
// 可以安全访问 DOM/测量尺寸
this.$nextTick(() => console.log(this.$el.offsetWidth))
},
methods: {
fetch() { /* 拉数据,填充 list */ },
inc() { this.count++ }
},
render(h) {
// JSX/模板都可,render 展示"渲染依赖"概念
return h('div', [
h('p', `count=${this.count}, doubled=${this.doubled}`),
h('button', { on: { click: this.inc } }, 'Add')
])
}
})