1. 从一次组件白屏说起:为什么实例创建阶段值得单独拎出来讲
很多人学 Vue 生命周期,习惯把八个钩子函数从头到尾背一遍,背完就觉得自己懂了。但真到了项目里,遇到一个组件莫名其妙白屏、数据拿不到、this指向不对,还是两眼一抹黑。我自己带过几个新人,发现一个共性问题:大家把生命周期当成"面试八股"来记,而不是当成"排查问题的地图"来用。这篇文章只聚焦一件事——实例创建阶段,也就是从new Vue()被调用的那一刻,到beforeMount之前,Vue 到底在背后干了哪些活。
为什么单独把这一段拎出来?因为实例创建阶段是整条生命周期链路的"地基"。data、methods、computed、watch、props、inject这些东西,全部是在这个阶段完成初始化的。你在created里能不能拿到data,computed为什么依赖data却比data晚初始化,methods里的this为什么能指向实例——这些问题的答案,全藏在实例创建阶段的源码逻辑里。搞懂这一段,后面挂载、更新、销毁阶段的理解会顺很多。
这篇文章适合两类人:一类是刚接触 Vue、对生命周期只有模糊印象的初学者,我会用生活化的类比把原理讲透;另一类是写过一两个项目、但没深挖过初始化顺序的开发者,我会把_init内部的执行链路、各选项的初始化次序、以及几个高频踩坑点掰开揉碎讲清楚。全文基于 Vue 2.x 的运行时逻辑展开,Vue 3 的setup体系虽然写法不同,但"先初始化什么、后初始化什么"的底层思路是相通的,我会在关键处做对比说明。
先给一个整体印象:实例创建阶段可以粗暴地分成"四步走"——选项合并、初始化生命周期、初始化事件中心、初始化渲染相关。这四步里,真正和你日常写代码强相关的是第三步和第四步之间的那些initState系列操作。下面我们一步步拆。
2. 实例创建阶段的四步走:从 _init 到 beforeMount 之间发生了什么
2.1 第一步:选项合并,把"家底"先归拢好
当你写下new Vue({ el: '#app', data: {...}, methods: {...} })时,Vue 构造函数内部第一件事不是急着处理data,而是调用this._init(options)。_init里第一个关键动作是mergeOptions,也就是选项合并。
这一步解决什么问题?Vue 允许你通过全局混入(Vue.mixin)、局部混入(mixins选项)、以及组件自身的选项来定义配置。同一个选项可能在多个地方出现,比如全局混入里有个created,组件自身也有个created,那到底执行哪个?答案是都执行,合并成一个数组按顺序调用。而像data、methods这种对象类型的选项,合并策略是"组件自身的覆盖混入的"。
我见过一个真实的坑:某同学在全局混入里定义了一个data属性叫loading,组件自身也定义了loading,结果发现全局混入里的初始值被覆盖了,排查半天以为是响应式失效。其实这就是选项合并的正常行为——组件自身的优先级高于混入。理解合并策略,能帮你快速定位这类"值被谁改了"的问题。
合并策略在源码里是一张策略表,不同选项对应不同函数。data用mergeDataOrFn,methods、computed、props用mergeOptions的默认策略(对象浅合并),生命周期钩子用mergeHook(合并成数组)。你不需要背源码,但要知道钩子会合并成数组、对象选项会覆盖这个结论。
2.2 第二步:初始化生命周期,给实例"上户口"
选项合并完,紧接着是initLifecycle(vm)。这一步做的事情很朴素,但很关键:给实例挂上一堆内部属性,比如$parent、$root、$children、$refs、_isMounted、_isDestroyed等。
为什么叫"上户口"?因为从这一刻起,这个实例才知道自己"爹是谁"($parent)、"家族根在哪"($root)、"有没有孩子"($children初始为空数组)。$parent的赋值逻辑是:如果当前实例不是抽象组件(比如keep-alive、transition这类),就把$parent指向传入的父实例;如果是抽象组件,则跳过,继续往上找。
这里有个容易忽略的点:_isMounted初始为false,_isDestroyed初始为false。这两个标志位在后续的挂载和销毁阶段会被改写,很多"组件是否已挂载"的判断,底层就是看_isMounted。你在created里访问$refs拿到的是空对象,原因也在这里——$refs要等挂载阶段才填充。
2.3 第三步:初始化事件中心,搭好"通信基站"
initEvents(vm)这一步,是给实例创建一个事件中心。注意,这里的事件不是 DOM 事件,而是组件自定义事件,也就是$emit、$on、$off那一套。
具体逻辑是:从vm.$options._parentListeners里取出父组件传下来的事件监听器,然后通过updateComponentListeners注册到当前实例上。这就是为什么子组件里this.$emit('custom-event')能触发父组件@custom-event的原因——监听器在实例创建阶段就已经"接线"好了。
我踩过的一个坑:在beforeCreate里调用this.$emit,发现父组件收不到。原因很简单,initEvents在beforeCreate之后才执行,此时事件中心还没搭好。所以任何依赖$emit/$on的逻辑,最早只能放在created里。这个顺序问题,光看文档很容易忽略。
2.4 第四步:初始化渲染相关,为挂载做准备
initRender(vm)这一步,主要做两件事:一是定义$createElement(也就是render函数里的h参数),二是初始化$slots和$scopedSlots。
$createElement是渲染函数的核心工具,它负责把虚拟节点(VNode)创建出来。你在render(h)里写的h('div', ...),用的就是这个函数。而$slots是从父组件传进来的插槽内容,$scopedSlots是作用域插槽。这一步做完,实例就具备了"渲染能力",但还没真正开始渲染。
到这里,beforeCreate钩子被调用。此时实例上有什么?有$parent、$root、$children、$refs、事件中心、$createElement、$slots。没有data、methods、computed、watch、props。这就是为什么官方文档反复强调:beforeCreate里拿不到data和methods。不是"不推荐拿",是根本还没初始化。
3. initState 内部拆解:data、props、methods、computed、watch 的初始化次序
beforeCreate之后,进入initState(vm)。这是实例创建阶段最核心、也最容易出问题的一环。initState内部的执行顺序是固定的:props → methods → data → computed → watch。这个顺序不是随便定的,每一步都有依赖关系,下面逐个说。
3.1 props 为什么排第一:它是"外部输入",必须先就位
initProps排在第一位,因为props是父组件传进来的数据,属于"外部输入"。子组件的data、computed、watch都可能依赖props,所以它必须先初始化。
initProps做两件事:一是校验props的类型和必填项(开发环境下会报警告),二是把props的值通过defineReactive变成响应式,并代理到vm上。所谓"代理",就是让你能直接this.msg访问,而不用this.$props.msg。
这里有个细节:props的响应式是浅层的。也就是说,如果你传进来一个对象,修改对象内部的属性,Vue 能检测到(因为对象本身被递归响应式化了),但如果你直接替换整个props对象,是不允许的——props是只读的,直接赋值会在开发环境报错。我见过有人试图在子组件里this.someProp = newValue,然后困惑为什么没生效,这就是没理解props的单向数据流。
3.2 methods 紧随其后:为什么它比 data 还早
initMethods排在data之前,很多人觉得奇怪:methods里经常用this.xxx访问data,为什么methods先初始化?
答案是:initMethods只是把函数挂载到vm上,并不执行函数体。函数体什么时候执行?是你调用它的时候。所以methods初始化时,data还没就位也没关系,因为函数没跑。等你在created或事件回调里调用this.someMethod()时,data早就初始化好了。
initMethods里有个校验:如果methods里的某个方法名和props里的属性名冲突,会报警告。因为props已经代理到vm上了,methods再挂同名属性会覆盖。这个校验能帮你避免"方法名和 prop 名撞车"的隐蔽 bug。
3.3 data 的初始化:响应式的起点
initData是重头戏。它做三件事:一是判断data是函数还是对象(组件里必须是函数,根实例可以是对象),二是通过getData拿到数据对象,三是遍历所有 key,通过proxy代理到vm上,并通过observe把数据变成响应式。
为什么组件里的data必须是函数?因为组件可能被复用多次,如果data是对象,多个组件实例会共享同一份数据,一个改了全改。函数每次调用返回新对象,保证每个实例的数据独立。这个点在面试里被问烂了,但真正踩过坑的人才知道它多重要——我曾经把一个组件的data写成对象,结果列表里每个 item 的状态互相干扰,排查了一下午。
initData里还有个细节:data的 key 不能和props、methods重名,否则会报警告。因为props和methods已经代理到vm上了,data再代理同名 key 会冲突。这个校验顺序也解释了为什么props、methods要排在data前面——先占坑的优先,后占的会被警告。
3.4 computed 的初始化:懒执行的响应式
initComputed排在data之后,因为computed依赖data。computed的核心是懒执行 + 缓存:它不会立即求值,而是等你第一次访问this.someComputed时才计算,并把结果缓存起来。只有当依赖的data变化时,缓存才失效,下次访问重新计算。
computed的响应式是通过defineComputed实现的,内部用Watcher的lazy模式。这里有个高频坑:computed里不要做副作用操作,比如发请求、改data。因为computed是懒执行的,你不访问它就不执行,访问时机不确定,副作用会变得不可控。我见过有人在computed里发请求,结果组件渲染时请求发了两次,就是因为computed被访问了两次(一次是模板渲染,一次是某个watch)。
3.5 watch 的初始化:主动监听,立即或延迟
initWatch排在最后,因为watch依赖data和computed。watch的初始化逻辑是:遍历watch选项,为每个 key 创建一个Watcher,并支持immediate、deep、handler等配置。
immediate: true表示立即执行一次回调,deep: true表示深度监听对象内部变化。这里有个性能坑:deep: true会递归遍历对象的所有属性,对象越大越慢。如果只是监听对象某个属性的变化,用字符串路径'obj.key'比deep: true高效得多。我在一个列表页里用deep: true监听一个几百条数据的数组,结果每次更新都卡顿,改成监听具体字段后流畅多了。
watch和computed的区别也值得说一句:computed适合"派生数据",watch适合"响应变化做副作用"。computed有缓存,watch没有。选错了会导致代码冗余或性能问题。
4. created 钩子:实例创建阶段的"终点站"与高频踩坑现场
initState执行完,created钩子被调用。此时实例上data、methods、computed、watch、props全部就位,你可以放心访问this.xxx。但created阶段还没有挂载 DOM,$el还不存在,$refs还是空对象。
4.1 created 里能做什么、不能做什么
能做的:发请求拿初始数据、初始化非响应式变量、注册全局事件(记得在beforeDestroy里解绑)、调用methods里的方法。
不能做的:操作 DOM($el不存在)、访问$refs(还没挂载)、依赖子组件(子组件还没创建)。我见过有人在created里this.$refs.someChild.doSomething(),直接报undefined,就是因为$refs要等挂载后才填充。
一个实用技巧:如果你需要在数据拿到后操作 DOM,不要硬塞在created里,而是放在this.$nextTick里,或者干脆放到mounted。created和mounted的分工要清晰——数据逻辑放created,DOM 逻辑放mounted。
4.2 服务端渲染场景下 created 的特殊性
在服务端渲染(SSR)场景下,created是唯一会被调用的生命周期钩子(beforeCreate也会,但mounted、beforeMount都不会)。因为服务端只负责生成 HTML 字符串,没有 DOM 挂载过程。所以如果你写同构代码,所有数据预取的逻辑必须放在created里,不能放mounted,否则服务端渲染时拿不到数据。
这个点很多人第一次做 SSR 时会踩。我当初把一个接口请求放在mounted里,本地客户端渲染没问题,一上 SSR 就白屏,排查半天才反应过来mounted在服务端根本不执行。
4.3 父子组件 created 的执行顺序
一个高频面试题:父组件和子组件的created谁先执行?答案是父先子后。因为父组件在created之后才进入挂载阶段,挂载阶段才会去创建子组件实例,子组件的created自然排在父组件后面。
但mounted的顺序是子先父后,因为子组件要先挂载完,父组件才能算挂载完成。这个"创建父先子、挂载子先父"的规律,在排查"子组件数据没准备好"这类问题时特别有用。比如父组件在mounted里访问子组件的方法,此时子组件已经挂载好了,是安全的;但如果在父组件created里访问子组件,子组件还没创建,必然失败。
5. 几个真实踩坑案例:从现象反推实例创建阶段的执行逻辑
光讲原理容易忘,下面用三个我实际遇到过的案例,把实例创建阶段的知识点串起来。每个案例都按"现象 → 排查 → 根因 → 修复"的链路走一遍,你可以对照自己的项目看看有没有类似问题。
5.1 案例一:beforeCreate 里拿不到 data,不是 bug 是顺序
现象:某同学在beforeCreate里写console.log(this.msg),输出undefined,他以为data没定义,反复检查data选项,没发现问题。
排查:我让他把console.log挪到created里,输出正常。再让他打印this.$options.data,发现data选项本身是存在的。
根因:beforeCreate在initState之前执行,此时data还没被代理到vm上,this.msg自然是undefined。但this.$options.data是原始选项,一直存在。
修复:需要访问data的逻辑,一律放created或之后。如果确实要在beforeCreate里做点什么,只能操作this.$options里的原始配置,或者做与实例数据无关的全局初始化。
5.2 案例二:computed 依赖 props 却拿到旧值
现象:子组件有个computed依赖props,父组件更新props后,computed的值没变。
排查:检查发现,computed里访问的是this.someProp,而someProp是通过props传进来的对象。父组件更新时,是替换了整个对象引用,但子组件里computed缓存没失效。
根因:computed的缓存依赖响应式系统。如果父组件替换的是对象引用,子组件的props会更新,computed应该失效才对。问题出在父组件更新方式上——他用的是this.obj.key = newValue这种直接改属性的方式,而obj本身没被响应式化(比如是从接口拿的原始数据直接赋值),导致 Vue 检测不到变化。
修复:确保父组件的数据是响应式的(在data里声明),或者用this.$set触发更新。这个案例说明,实例创建阶段的响应式初始化只是"起点",后续更新能不能触发,还取决于数据操作方式。
5.3 案例三:methods 和 props 同名导致的诡异覆盖
现象:子组件里定义了一个方法handleClick,父组件传了一个同名prop,结果调用this.handleClick()时报"不是函数"。
排查:打印this.handleClick,发现是prop的值(一个字符串),不是函数。
根因:initProps先执行,把handleClick代理到vm上;initMethods后执行,发现同名,开发环境报警告,但不覆盖(Vue 的策略是保留先来的)。所以this.handleClick最终是prop的值。
修复:重命名,避免props、methods、data三者之间 key 冲突。Vue 的警告信息其实写得很清楚,只是很多人忽略了控制台的黄色警告。养成看警告的习惯,能省掉一半排查时间。
6. 把实例创建阶段的知识用起来:日常开发中的检查清单
学完原理,最终要落到"怎么用"。我整理了一份实例创建阶段的检查清单,你在写组件或排查问题时可以对照着过一遍。
| 检查项 | 正确做法 | 常见错误 |
|---|---|---|
| 访问 data 的时机 | 放 created 或之后 | 放 beforeCreate |
| 组件 data 写法 | 必须是函数 | 写成对象导致实例共享 |
| props 修改 | 只读,通过父组件改 | 子组件直接赋值 |
| computed 副作用 | 只做纯计算 | 发请求、改 data |
| watch 深度监听 | 按需用 deep,优先路径监听 | 无脑 deep: true |
| 事件监听注册 | created 里注册,beforeDestroy 解绑 | 只注册不解绑,内存泄漏 |
| 父子 created 顺序 | 父先子后 | 假设子先执行 |
| SSR 数据预取 | 放 created | 放 mounted 导致服务端拿不到 |
这份清单不是让你背,而是让你在遇到问题时有个排查方向。比如组件白屏,先看created里有没有报错;数据不更新,先看响应式初始化有没有问题;事件不触发,先看注册时机对不对。
再补充一个经验:善用this.$options调试。当你不确定某个选项有没有被正确合并时,打印this.$options能看到合并后的最终配置。比如全局混入的created和组件自身的created合并后是个数组,打印出来一目了然。这个技巧在排查"混入的钩子没执行"这类问题时特别好用。
最后说一个我个人的习惯:每写一个新组件,我会先在beforeCreate、created里各打一个console.log,确认执行顺序和数据就位情况,再开始写业务逻辑。听起来笨,但能提前暴露 80% 的初始化问题。等组件稳定了再把日志删掉。这个习惯帮我省下了大量"为什么这里拿不到值"的排查时间。
实例创建阶段是 Vue 生命周期的第一站,也是理解后续所有阶段的基础。把_init的四步走、initState的五项次序、created的边界这三件事吃透,你在面对组件初始化相关的问题时,脑子里会有一张清晰的地图,而不是靠猜。下一篇我会接着讲挂载阶段,也就是beforeMount到mounted之间,模板是怎么变成真实 DOM 的,以及$el、$refs是在哪一步被填充的。