你有没有遇到过这样的情况:在data函数里想用this.someProp去取父组件传下来的props,结果打出来是undefined;或者在computed里依赖了某个prop,控制台却直接报Property or method is not defined on the instance。我当时排查了半天,最后发现根本不是代码写错了,而是压根没搞懂props、data函数、computed的执行顺序这回事。
先说结论:Vue组件实例化时,这三个选项的初始化顺序是 props最先,data第二,computed最后。这个顺序直接影响你能在哪里访问什么、不能用什么,也解释了为什么data里访问props有时候能拿到、有时候拿不到,为什么computed总是能同时读到data和props,而methods里的函数却要等到调用时才能确定拿到什么值。这篇就围绕这个顺序,把原理、源码依据、实操验证和常见报错一次性讲透,适合刚接触Vue不久、对选项式API执行机制有点模糊的开发者,也适合写过一阵子Vue但偶尔被“这个为什么是undefined”卡住的人。
1. 内容整体设计与思路拆解
1.1 为什么“执行顺序”这件事值得单独研究
很多初学者学Vue,是先记API再背用法:props用来接收外部数据,data用来定义内部状态,computed用来做派生值。用起来也没觉得有什么问题,直到某天需要在两个选项之间互相引用,才开始意识到“时机”比“语法”更关键。
举个例子。你写了一个子组件,从父组件接收一个userInfo对象,想在data里基于它做一些本地拷贝:
javascript复制props: ['userInfo'],
data() {
return {
localName: this.userInfo?.name
}
}
这段代码在父组件已经把userInfo传进来、并且传进来的值确实有name字段时,大概率能正常工作。但如果你在父组件的data里初始给的是null,等待接口返回后再赋值,子组件的data却不会跟着重新执行,localName就永远停留在undefined。很多人在这里误以为“props顺序不对”,其实顺序没问题,问题出在data函数只在组件初始化时执行一次,它拿到的this.userInfo是那一刻的值。
这就是研究执行顺序的第一个价值:搞清楚每个选项在生命周期里的“生效时刻”,才能判断哪些依赖是安全的、哪些依赖是脆弱的。
1.2 这个主题涉及的范围和延伸方向
顺着执行顺序这个主线,会牵扯出几个相邻知识点:
- 选项式 API 的初始化流程:
props->methods->data->computed->watch,其中methods的初始化在Vue 2源码里排在data前面,但因为没有数据响应式包装,所以平时感知不明显。 - 组合式 API(Vue 3)下的执行差异:
setup统一替代了多个选项的初始化时机,但props仍然是特殊的存在。 - computed的缓存机制与依赖收集:为什么computed放最后却能第一时间感知到data和props的变化。
- 容易混淆的“SQL执行顺序”:这个热词也经常和Vue指令一起出现在搜索词里,后面我会单独用一段来讲它的边界。
整体思路是:先确认顺序结论,再追到源码和官方描述,然后用实际代码验证,最后落到排错和工作规范上。这样读者既能直接拿去解决手头报错,也能建立一套判断“能不能这样写”的思维模型。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 顺序结论:初始化流程到底是什么样的
在Vue 2的选项式API里,一个组件实例从创建到挂载,核心初始化路径大约是:new Vue() -> init -> initLifecycle/initEvents/initRender -> beforeCreate -> initInjections -> initState -> initProvide -> created。真正和数据相关的是initState,它的内部顺序在源码里写成:
javascript复制export function initState (vm: Component) {
vm._watchers = []
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
observe(vm._data = {}, true /* asRootData */)
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
这段代码是顺序问题的“铁证”:先initProps,再initMethods,再initData,然后initComputed,最后initWatch。
所以严格说,完整顺序是 props -> methods -> data -> computed -> watch。但标题里只提了props、data函数、computed这三个,因为它们在数据依赖上最容易产生交集。methods初始化虽然排在data之前,但它只是把函数绑定到实例上,函数体并不立即执行,所以你调用method时早就能访问data、props、computed,这是“声明顺序”和“使用顺序”的区别。
2.2 Vue 3 里的顺序有没有变化
Vue 3的组件初始化同样分成两条路线:使用选项式API时,内部兼容了Vue 2的顺序;使用组合式API时,所有逻辑都在setup里执行,但从组件实例的角度看,props的解析仍然是第一步。
javascript复制// Vue 3 的组件初始化核心步骤(简化)
const instance = {
props: shallowReactive({}),
data: null,
ctx: null
}
// 1. 初始化 props
initProps(instance, rawProps)
// 2. 执行 setup,在 setup 内部可以访问 props
// 3. 处理 data(如果用了选项式)
// 4. 处理 computed
setup(props, context) 的第一个参数就是处理好的props对象。无论你在setup里定义computed还是普通变量,props一定已经准备好。这也是为什么Vue 3官方推荐用toRefs或toRef来解构props,因为响应式props的读取时机从组件创建那一刻就开始了。
真正需要注意的变化是:Vue 3的data依旧是一个返回对象的函数,但如果你在data里尝试访问this.someProp,在setup模式下this并不指向组件实例,而是undefined或者被代理的对象。所以Vue 3里更推荐直接接收props参数,而不是依赖this。
2.3 官方文档和生命周期图里隐藏的线索
Vue官方文档的生命周期图没有把computed单独画成一条线,但initState全阶段发生在beforeCreate之后、created之前。你可以在beforeCreate里访问props吗?可以,因为initState还没有执行,this.$options.props已经经过合并,但this._props的响应式代理还不完整,所以实际开发中不会有人这么干。
有一个很实用的记忆方式:把组件实例化理解成“搭建一个舞台”,props是前台送来的道具清单,data是后台自己准备的服装,computed是导演在开场前根据道具和服装算好的走位图。导演要同时参考道具和服装,所以必须在舞台全部整理完毕后再计算。这个比喻对初学者特别友好,理解后再看源码就不觉得奇怪了。
3. 实操过程与核心环节实现
3.1 用最小案例验证执行顺序
理论说再多,不如自己打点验证。下面是一个极简的Vue 3组件,把每个环节的打印顺序记录下来:
vue复制<template>
<div>{{ computedMsg }}</div>
</template>
<script>
export default {
name: 'OrderTest',
props: {
propMsg: {
type: String,
default: 'from-prop'
}
},
data() {
console.log('data 执行,propMsg =', this.propMsg)
return {
dataMsg: 'from-data'
}
},
computed: {
computedMsg() {
console.log('computed 执行,propMsg =', this.propMsg, 'dataMsg =', this.dataMsg)
return this.propMsg + ' + ' + this.dataMsg
}
},
created() {
console.log('created,propMsg =', this.propMsg, 'dataMsg =', this.dataMsg, 'computedMsg =', this.computedMsg)
}
}
</script>
运行后在控制台看到的顺序是:
code复制data 执行,propMsg = from-prop
computed 执行,propMsg = from-prop dataMsg = from-data
created,propMsg = from-prop dataMsg = from-data computedMsg = from-prop + from-data
这个输出至少说明三件事:
data执行时,this.propMsg已经能取到值,证明props先初始化;computed执行时,this.dataMsg也能取到,证明data先于computed;created在所有数据选项初始化之后,读取computedMsg会触发一次computed求值,但此时computed已经具备完整依赖。
如果用Vue 2实测,输出顺序基本一致,唯一的区别是data里访问this.propMsg的形式和Vue 3略有不同,Vue 2的this指向更直接。
3.2 data函数返回的响应式对象如何与props联动
理解了顺序之后,关键问题来了:data函数里到底能不能安全地使用props?答案分两种情况。
情况一:props的值在父组件中已经确定,且之后不会变化。比如:
javascript复制props: ['initialCount'],
data() {
return {
count: this.initialCount || 0
}
}
这样写没问题,count会拿到初始值。缺点是:父组件的initialCount后续变化,不会同步到count。如果你需要联动,要么改成computed,要么用watch手动同步。
情况二:props的值在父组件中是异步获取的。子组件data函数执行时拿到的是undefined,比如:
javascript复制props: ['userList'],
data() {
return {
localList: this.userList || []
}
}
接口返回前userList是null或undefined,localList被初始化成空数组。等父组件数据到了,子组件data不会重新执行。所以这种写法非常容易出现“父组件明明有数据,子组件列表却空白”的经典Bug。
务实建议:不要在data里“拷贝”props的值,除非你能明确接受它只初始化一次。真要派生,优先computed;真要做本地可修改副本,用watch配合一个data字段,在父值变化时更新本地值。这是经过多次踩坑之后验证过最稳的组合。
3.3 computed读取props和data时的“顺序优势”
computed放最后,反而拥有“完整视野”。它的getter函数在求值时,可以同时读取props和data,而这两者的响应式依赖都会被收集。比如:
javascript复制computed: {
fullName() {
return this.firstName + ' ' + this.lastName
}
}
这里firstName可能来自props,lastName可能来自data。computed在执行时,Vue会建立一个watcher,在getter内部访问firstName、lastName时完成依赖收集。以后无论父组件更新firstName还是自身更新lastName,computed都会重新求值。
这正是顺序设计的好处:computed作为“最终派生层”,放在props、data之后最合理。如果computed先于data初始化,那它求值时this.lastName还不存在,整个派生机制就崩了。
一个常见的错误是把props写进computed的setter。computed默认只有getter,如果你写成:
javascript复制computed: {
propMsg: {
get() { return this.propMsg },
set(val) { this.propMsg = val }
}
}
控制台会报错,因为prop是单向数据流,不允许子组件直接改。这也是“computed报错”里非常典型的一类。正确做法是触发父组件事件,让父组件改。这里的顺序问题延伸到设计原则,就是“谁的数据谁修改”。
3.4 methods、watch 与三个核心选项的执行关系
methods初始化虽然排在data前面,但因为只是绑定函数,不执行函数体,所以你在任何method里访问data、props、computed都是安全的。要注意的是:method函数的执行时机和组件渲染、事件触发、watch回调相关,不一定在created前。
watch初始化排最后,所以watch的回调在初始阶段不会立即执行,只有你配置了immediate: true时才会执行一次。immediate的watch回调执行时,data、props、computed都已经初始化完毕,所以可以在里面安全访问任何数据。这个细节很多人忽略,导致在watch里用了this.xxx但报错,其实不是顺序问题,而是忘了加immediate或者字段名拼错。
再看一个调度细节:computed的变化会影响渲染。首次渲染时,模板里用到的computed会触发求值,getter执行。如果computed里还读取了this.$store或其他全局状态,那它的依赖范围就扩大了。理解这一点能帮你排查“computed为什么不更新”的问题,很多时候不是顺序错,而是依赖项没被收集到。
4. 常见问题与排查技巧实录
4.1 报错一:data 里访问 props 得到 undefined
现象:子组件接收userInfo对象,在data里写this.userInfo.name,页面直接报错或得到undefined。
排查步骤:
- 确认父组件是否真的传了
userInfo。在子组件props里加default,或在created里打印this.userInfo看看; - 确认父组件传值时,
userInfo是否已经是最终值。如果父组件是异步请求数据,子组件的data执行时拿到的就是初始值; - 确认你的Vue版本。Vue 2的
this指向实例,Vue 3的选项式API也支持,但组合式API里不能在普通函数里随便用this。
解决方案就是把“派生值”从data迁到computed:
javascript复制computed: {
displayName() {
return this.userInfo?.name || '默认姓名'
}
}
4.2 报错二:computed 报 Property or method is not defined
这个报错在Vue 2里很常见,高频原因有三个。
第一个是模板里写了computed里不存在的字段,比如:
javascript复制computed: {
fullName() {
return ...
}
}
// 模板中却写了 {{ fullname }}
第二个是在computed的getter里访问了未定义的变量,比如漏写this:
javascript复制computed: {
fullName() {
return firstName + ' ' + lastName // 这里没有this,会去找全局变量
}
}
第三个是在options里根本没有声明,但模板或方法里直接用了。这类报错虽然表面看是“未定义”,但排查时要联想到初始化顺序:如果该字段确实声明了,但报错发生得非常早,也可能是初始化时某个依赖还是undefined导致的连锁问题。
4.3 报错三:依赖项变化后 computed 不更新
computed有缓存,只有依赖的响应式数据变化时才会重新求值。如果你在computed里访问了一个非响应式变量,比如模块内的普通对象、或者用JSON.parse后的普通数组,那它永远不会触发更新。
常见写法:
javascript复制computed: {
filteredList() {
return this.list.filter(item => item.visible)
}
}
如果this.list是props传下来的数组,并且父组件用新数组整体替换,没问题。如果父组件修改的是数组某一项的属性,并且item.visible不是响应式属性,那么computed不会感知。解决方案是用Vue.set/$set,或者保证整个数组被新引用替换。
另一个容易忽略的点:computed里如果用了Date.now()、Math.random()等非响应式值,即使依赖的data没变,它也可能“看起来变了一下”,但刷新时机不可控。不要依赖这种副作用,要写纯函数。
4.4 常见问题速查表
| 场景 | 可行方案 | 注意点 |
|---|---|---|
| data里读取props初始值 | 可以,但仅在初始化时生效 | 父组件异步更新不会同步 |
| computed里读取props | 推荐,响应式联动 | 依赖项变化自动重算 |
| data里读取computed | 不可行,computed未初始化 | 会得到undefined或报错 |
| methods里访问三者 | 随便访问 | 注意函数调用时机 |
| watch里初始访问 | 配置immediate: true | 回调里可安全访问三者 |
| 修改props值 | 禁止 | 触发父组件事件修改 |
| 在setup中解构props | 用toRefs包一层 | 解构后丢失响应式 |
这张表可以当成工作时的“快速判断卡”,遇到写代码犹豫时对照看一眼,能省不少排查时间。
5. 执行顺序带来的编码规范与设计建议
5.1 四条实用编码约定
第一,永远不要在data函数里复制props的值,除非你明确知道这是“初始化快照”。如果你要的是快照,就把字段名标注清楚,比如initialCount,避免后续维护者误以为它会联动。
第二,派生值统一放computed,本地可编辑副本用data+watch组合。比如props传进来一个price,你想做单价、数量、总价的联动,那么总价放computed;你想做一个可编辑的备注字段,初始值来自props,后续需要跟随props变化重置,就用watch监听props字段并更新data。
第三,在computed里不要产生副作用。computed的getter应该只做计算并返回值,不要在里面发请求、改状态、操作DOM。因为computed可能被多次执行、也可能缓存后不执行,副作用会变得难以预测。
第四,在组合式API里优先用computed(() => props.xxx)。在Vue 3的setup中,直接在普通let变量上赋值是不会响应式更新的,必须包成computed或ref/reactive。这点和选项式API的“computed放最后但能访问所有”是同一个设计哲学的延续。
5.2 结合生命周期定位代码逻辑
数据顺序在生命周期中的位置
| 阶段 | props状态 | data状态 | computed状态 |
|---|---|---|---|
| beforeCreate | 已合并但未完整代理 | 未初始化 | 未初始化 |
| 初始化state中 | 先初始化完毕 | 随后初始化 | 最后初始化 |
| created | 可用 | 可用 | 可用 |
| beforeMount | 可用 | 可用 | 可用 |
| mounted | 可用 | 可用 | 可用 |
这个表可以在你纠结“什么时候能访问什么”时快速定位。created之后,三者基本都在线,这也是为什么大部分业务代码写在created或mounted里几乎不会遇到顺序问题。真正容易出问题的,是那些在data函数体、computed getter、watch immediate回调里“抢跑”的代码。
5.3 一个实战示例:父子组件联动计数器
为了把前面的知识点串起来,写一个稍微完整的小例子:父组件控制一个initValue,子组件内部可以修改计数,但父组件重置时,计数要跟着重置。
父组件模板:
vue复制<template>
<div>
<button @click="resetValue">重置</button>
<Counter :init-val="initValue" @change="handleChange" />
</div>
</template>
<script>
export default {
data() {
return { initValue: 5 }
},
methods: {
resetValue() {
this.initValue = 0
},
handleChange(val) {
console.log('子组件计数变化:', val)
}
}
}
</script>
子组件:
vue复制<template>
<div>
<p>当前值:{{ count }}</p>
<button @click="count++">增加</button>
</div>
</template>
<script>
export default {
props: {
initVal: {
type: Number,
default: 0
}
},
data() {
return {
count: this.initVal
}
},
watch: {
initVal(newVal) {
this.count = newVal
}
}
}
</script>
这里的逻辑就是前面说的:data里用props做快照,watch监听props变化做同步。顺序上,props先到,data拿到初始值,watch最后注册回调,父组件后续修改initVal时watch触发并更新count。整个链路干净可靠。
5.4 顺手讲清楚:SQL里的“执行顺序”和这个不是一回事
网上搜“执行顺序”标签时经常混进“mysql执行顺序”,这里顺手区分一下,避免误会。SQL的执行顺序指的是一条查询语句各个子句的逻辑处理顺序,比如FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT,它描述的是数据库引擎如何一步步处理数据集。这和Vue组件里props、data、computed的初始化顺序,一个属于“组件状态建立的先后”,一个属于“查询语句的逻辑处理流程”,两者完全是不同层级的执行顺序问题。
不过有一点思路可以迁移:无论前端还是后端,理解“代码/语句按什么顺序执行”永远是排查诡异问题的钥匙。前端遇到undefined、后端遇到查询结果不对,根源往往都藏在某个执行顺序假设里。
6. 写在最后:我踩过几次坑之后的体会
刚开始写Vue那会儿,我遇到“props传下来页面却不显示”的问题,习惯性先怀疑传参写法、怀疑数据类型,折腾半天才发现是子组件data函数里自作聪明地“拷贝”了props,而props是异步更新的。从那以后我给自己定了条规矩:凡是遇到数据“该变不变”“不该变却变了”的诡异情况,先画一下数据从创建到渲染的路径,看看某个取值动作发生在哪个阶段。
后来看源码确认了props、data、computed的初始化顺序,很多之前靠猜的问题就有了确定性答案。再后来用Vue 3的setup,我发现只要把props参数当第一等公民对待,用computed去包装派生值,用watch去处理同步,几乎不可能踩到顺序相关的坑。
最后再分享一个排查小技巧:遇到“computed报错”或者“data里拿不到props”时,不要急着改代码,先在报错位置前后打两行日志,一行打印this.$options.props,一行打印Object.keys(this.$data),看看当前时刻到底初始化了什么。这种“现场侦查”比任何文档都更有说服力,几次下来你自己就能总结出一套判断顺序的手感。
这个内容后续还可以往两个方向扩展:一个是深入看initState里每个初始化函数的源码实现,另一个是研究自定义指令、mixins、composition API混用时,执行顺序会产生哪些更复杂的组合行为。先把基础顺序吃透,后面遇到再复杂的情况,心里都有底。
