1. 从一次组件白屏说起为什么实例创建阶段值得单独拎出来讲很多人学 Vue 生命周期习惯把八个钩子函数从头到尾背一遍背完就觉得自己懂了。但真到了项目里遇到一个组件莫名其妙白屏、数据拿不到、this指向不对还是两眼一抹黑。我自己带过几个新人发现一个共性问题大家把生命周期当成面试八股来记而不是当成排查问题的地图来用。这篇文章只聚焦一件事——实例创建阶段也就是从new Vue()被调用的那一刻到beforeMount之前Vue 到底在背后干了哪些活。为什么单独把这一段拎出来因为实例创建阶段是整条生命周期链路的地基。data、methods、computed、watch、props、inject这些东西全部是在这个阶段完成初始化的。你在created里能不能拿到datacomputed为什么依赖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用mergeDataOrFnmethods、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的分工要清晰——数据逻辑放createdDOM 逻辑放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 副作用只做纯计算发请求、改 datawatch 深度监听按需用 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是在哪一步被填充的。