前段时间有个同事在群里发了一个问题,说他写的组件里 data() 函数返回的某个字段一直拿不到值,控制台打了 console.log 明明是数字,渲染出来却是 undefined。我扫了一眼代码就发现他把 props 里传进来的字段,直接在 data() 里赋值给另一个字段,然后他另一个 computed 又依赖这个 data 字段,结果整个链路全断了。这个事如果不去看 Vue 的初始化顺序,真的会让人排查半天。
其实 props、data 函数、computed 这三者在 Vue 组件初始化时是有严格的执行顺序的,而且这个顺序不是随便定的,它背后是一条完整的依赖链:谁被谁依赖,谁就先出场。这篇文章我就把这条链彻底拆开讲明白,包括源码层面的顺序、为什么必须按这个顺序来、常见的报错场景,以及 Vue 3 里有什么变化。无论你是刚学 Vue 的新手,还是已经被各种诡异 bug 折磨过的老手,这篇文章都会让你以后遇到这类问题再也不慌。
1. 从一次报错说起:三个选项到底按什么顺序初始化
1.1 一个常见的"未定义"报错
先还原一下同事当时的场景。他写了一个子组件,父组件通过 props 传了一个 initialCount 进来,然后他在 data() 函数里想把它转成组件的内部状态:
vue复制<script>
export default {
name: 'Counter',
props: {
initialCount: {
type: Number,
default: 0
}
},
data() {
return {
count: this.initialCount
}
},
computed: {
doubleCount() {
return this.count * 2
}
}
}
</script>
这段代码本身是能正常工作的,因为 props 在 data() 函数执行之前就已经初始化好了,所以 this.initialCount 是拿得到值的。但问题在于他把 computed 也掺和进来了,在 data() 里试图访问一个 computed 字段:
vue复制<script>
export default {
props: {
initialCount: {
type: Number,
default: 0
}
},
data() {
return {
base: this.initialCount, // OK
total: this.doubleBase // 这里拿不到! this.doubleBase 是 undefined
}
},
computed: {
doubleBase() {
return this.initialCount * 2
}
}
}
</script>
这里 data() 函数执行的时候,computed 还没初始化,所以 this.doubleBase 一定是 undefined。你把这个 undefined 存进 data 里,后面不管怎么渲染都是 undefined,而且 Vue 不会给你任何警告,因为这个值是你自己赋进去的,它不知道你赋错了。
1.2 从源码看真实的初始化顺序
Vue 2 的组件初始化过程中,有一个很关键的函数叫 initState,它决定了所有选项的初始化顺序。源码里大致是这么写的:
js复制export function initState(vm) {
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)
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
注意看这个 if 的顺序,这就是核心答案:
| 初始化步骤 | 处理的选项 | 说明 |
|---|---|---|
| 第一步 | props |
父组件传入的原始数据,最先被处理 |
| 第二步 | methods |
方法定义,不涉及响应式依赖,排在前面 |
| 第三步 | data |
组件内部状态,可以访问 this.props |
| 第四步 | computed |
计算属性,依赖 props 和 data,所以最后 |
| 第五步 | watch |
侦听器,放在最后 |
所以 props、data 函数、computed 三者的真实执行顺序是:props 最先,data 函数其次,computed 最后。methods 和 watch 只是顺带排在它们中间和最后,你主要记住前三个的先后顺序就够了。
1.3 为什么顺序必须是 props → data → computed
这个顺序不是拍脑袋定的,它本质上是一个依赖关系的拓扑排序。你可以把三个选项想象成三层:
props是源数据,由父组件传入,不依赖任何组件内部的东西。data是组件内部的初始状态,它经常需要借助props来初始化,比如count: this.initialCount,所以它必须在props之后。computed是派生数据,它可以依赖props,也可以依赖data,还能依赖别的computed,所以它必须在最末尾,确保它要用的东西都已经准备好了。
如果你把顺序调换一下,比如让 data 先执行,那 data() 函数里 this.initialCount 就是 undefined,你所有基于 props 的初始化逻辑全都得崩。让 computed 先执行,那 computed 引用 data 里的字段也就拿不到了。
这段源码逻辑是我在实际排查问题时最常用的工具。遇到"某个字段渲染出来是 undefined"的诡异 bug,我的第一反应就是先去判断这个字段是在哪个选项里被赋值的,它引用的其他字段是否已经初始化。这个思维模式比单纯记住"props 在前、computed 在后"要有用得多,因为实际项目里的依赖链远比这个例子复杂。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. props 先行的原因:谁被依赖,谁就先出场
2.1 用 data 接收 props 的常见姿势
既然 props 最先初始化,那最直接的好处就是:你可以在 data() 函数里安全地读取 props 的值。这是 Vue 官方推荐的"props 作为初始值"模式,也是我在业务里用得最多的:
js复制props: {
userId: {
type: String,
required: true
}
},
data() {
return {
// 把 props 的值作为组件的内部初始状态
formData: {
userId: this.userId,
nickname: '',
avatar: ''
}
}
}
这个模式特别适合做表单编辑场景。父组件传给子组件一个对象或 ID,子组件在初始化时把这个值拷贝到自己的 data 里,之后用户随便怎么改表单都不会污染父组件的数据。这个"拷贝一份"的思路很关键,因为 props 是单向数据流,你理论上不应该直接修改它。
不过这里有一个大坑需要提醒:data() 函数只在组件初始化时执行一次,所以 props 后续如果更新了,data 里拷贝过来的值不会跟着更新。
2.2 props 更新后,data 不会同步,怎么办
我经常遇到有同学这样写代码,然后发现"父组件传的值变了,子组件里面显示的却是旧值"。原因就是上面说的,data 只初始了一次。这时候有三个解决方案,各有适用场景:
| 方案 | 代码示例 | 适用场景 |
|---|---|---|
用 computed 替代 |
computed: { currentId() { return this.userId } } |
只需要展示,不需要修改 |
用 watch 同步 |
watch: { userId(newVal) { this.formData.userId = newVal } } |
需要在 props 变化后触发其他副作用 |
用 key 强制重建组件 |
<Child :key="userId" /> |
整个组件需要根据 props 完全重置 |
第三个方案是我最喜欢用的,尤其是在"父组件切换数据源,子组件要完整重新加载"的场景下。给子组件加一个 :key,每当 userId 变化,Vue 就会销毁旧子组件并创建新子组件,这样 data() 函数会重新执行,props 的新值自然就被拷贝进去了。这比用 watch 一个个去同步字段省心得多,而且不会出现"个别字段没同步到"的漏网之鱼。
2.3 反例:在 props 里引用 data 会怎样
有人可能会问,既然 data 可以读 props,那 props 能不能引用 data?答案是不能,而且不是"能不能"的问题,是根本没有这个语法。props 是一个对象定义,它不是函数,没有 this 上下文,你写 props: { a: this.someData } 直接就是语法错误。
深层原因还是依赖方向的问题:props 是父组件传入的"既定事实",它是组件的最上游;data 是组件自己生成的"私有状态",属于下游。下游可以基于上游做初始化,但上游永远不可能依赖下游。这个单向依赖的思想贯穿了整个 Vue 数据流设计,理解了它,你就理解了很多 Vue 的设计选择。
3. data 函数为什么非得是函数
3.1 组件复用与对象共享陷阱
很多新手一开始不理解,为什么 Vue 组件里的 data 必须是一个返回对象的函数,而根实例的 data 可以是一个普通对象。原因是:组件会被创建多个实例,如果 data 是一个普通对象,那么所有实例会共享同一个对象引用。
举个例子你就明白了:
js复制// 错误写法:如果这样写,所有组件实例共享同一个 data 对象
data: {
count: 0
}
// 正确写法:每次创建组件实例,data 函数都会返回一份新的对象
data() {
return {
count: 0
}
}
假如你写了一个计数器组件,页面上渲染了三个计数器。你用的是第一种错误写法,那三个计数器其实是同一个 count,你点其中一个加号,另外两个也跟着变,瞬间全乱了。用函数式写法,每次创建实例都会调用一次 data() 函数,每次都返回一个全新的对象,三个计数器各自维护自己的 count,互不干扰。这类似于"每次去食堂打饭,都给你一份新的盒饭",而不是"所有人都吃同一份盒饭"。
3.2 data 函数里的 this 指向谁
data() 函数在执行时,Vue 会把 this 指向当前的组件实例,所以你在 data() 函数里能通过 this 访问到已经初始化好的 props 和 methods。但要注意,data() 函数不能写成箭头函数:
js复制// 错误写法
data: () => {
return {
count: this.initialCount // this 不是组件实例
}
}
// 正确写法
data() {
return {
count: this.initialCount
}
}
箭头函数没有自己的 this,它继承的是外层作用域的 this,在模块作用域里外层 this 可能是 undefined 或者 window,总之不是组件实例。这样 this.initialCount 就是 undefined。这个错误非常隐蔽,因为代码看起来没什么问题,运行也不报错,但数据就是出不来。我排查过好几个类似的问题,最后都发现是箭头函数惹的祸。
3.3 data 初始化时发生了什么
data() 函数返回一个普通对象之后,Vue 内部的 initData 会遍历这个对象的所有属性,通过 Object.defineProperty(Vue 2)或 Proxy(Vue 3)把每个属性转换成响应式数据。这就是为什么你在 data 里定义的数据一变,模板就会自动更新。
有一点需要特别注意:响应式转换是在 data() 函数返回之后才发生的,所以在 data() 函数内部,你拿到的 this.xxx 还是普通值,不是响应式的。这听起来有点抽象,但对日常开发的影响不大,你只要知道在 data() 里访问 this 是安全的就够了。
4. computed 的关键特性:懒执行与缓存机制
4.1 computed 不是初始化时就计算的
这是很多人对 computed 最大的误解:以为 computed 在组件初始化时就会把所有计算属性执行一遍。实际上 computed 是惰性求值的,它只有在被访问的时候才会执行计算逻辑。
看一个例子:
js复制computed: {
expensive() {
console.log('我被执行了')
return this.base * 100
}
},
created() {
console.log('created 钩子执行了,但 expensive 还没被访问')
}
在这个例子里,只要 created 钩子里没有访问 this.expensive,模板里也没有渲染 expensive,那 console 里的"我被执行了"永远不会打印。知道这个特性有什么用呢?它可以帮你省下不必要的计算。如果你的页面有几个大计算量的 computed,但没有在首屏用到,它们是完全可以不执行的,这是 computed 相比 methods 的一大优势。
4.2 缓存机制:依赖不变不重新计算
computed 的第二个核心特性是缓存。每个 computed 属性内部都有一个 dirty 标志位(Vue 2 的 Watcher 上有个 dirty 属性;Vue 3 的 computed 也有类似的实现),初始值是 true。
- 首次访问时,
dirty为true,执行计算函数,拿到结果,把dirty改成false,并把结果存起来。 - 之后再次访问时,发现
dirty是false,直接把缓存的结果返回,不再执行计算函数。 - 当计算函数里依赖的响应式数据发生变化时,Vue 会把
dirty重新置为true,等下一次访问时再重新计算。
这个机制特别适合处理复杂计算。比如你有一个 computed 需要对一个上千项的数组做 filter 和 reduce,如果每次访问都重新算一遍,性能会很难看。有了缓存,只要数组不变化,无论模板里访问多少次,都只算一遍。用大白话说,computed 就是一个"会记住上次结果的懒汉",你不叫它,它不动;你叫它,它先看看手里的数据变了没,没变就直接把上次的结果丢给你。
4.3 computed 里写异步代码为什么会失效
我见过不少同学在 computed 里写异步逻辑,比如:
js复制computed: {
asyncUserInfo() {
return fetch('/api/user').then(res => res.json())
}
}
然后发现模板里渲染出来的是一个 Promise 对象,完全不是想要的数据。原因就是 computed 的 getter 函数必须是同步的,它要求你执行完就立刻返回一个值。你虽然不是"立刻返回",但返回的是一个 Promise,Vue 不知道怎么处理它,只能把它当作普通值渲染出来。
那异步数据应该放哪?答案是放在 data 里,配合 watch 或者直接在钩子里请求:
js复制data() {
return {
userInfo: null
}
},
created() {
fetch('/api/user').then(res => res.json()).then(data => {
this.userInfo = data
})
}
这样请求是异步的,数据回来之后赋值给 data,模板自动更新,一切正常。如果你非要在 computed 里做异步转换,也不是绝对不行,但你需要用 async/await 配合 watch 去捕获变化,复杂度高很多,没必要。
5. 初始化顺序与生命周期钩子的配合
5.1 beforeCreate 和 created 之间发生了什么
生命周期钩子的执行时机正好能印证 props、data、computed 的初始化顺序。看这张表:
| 生命周期钩子 | props 是否可用 | data 是否可用 | computed 是否可用 |
|---|---|---|---|
beforeCreate |
否 | 否 | 否 |
created |
是 | 是 | 是 |
beforeMount |
是 | 是 | 是 |
mounted |
是 | 是 | 是 |
beforeCreate 钩子触发时,initState 还没执行,所以 this 上什么都没有。等到 created 钩子触发时,props、data、computed、watch 都已经初始化完毕了。换句话说,created 是你第一个可以安全访问组件内部数据的地方。
5.2 在 created 里调用 computed 属性
有些人会在 created 钩子里初始化一些东西,比如从 computed 里拿一个值存到 data 里:
js复制created() {
this.total = this.computedTotal // 这里可以拿到
}
这个是没问题的,因为 created 触发时 computed 已经初始化好了,你访问 this.computedTotal 会触发它的计算函数并返回结果。但要注意,这只是一次性赋值,不是响应式绑定。之后 computedTotal 依赖的数据变了,this.total 并不会跟着变。如果你想要的是"跟着变"的效果,应该把 total 直接定义成 computed,而不是通过赋值来"复制"。
5.3 组件更新阶段,computed 什么时候重新执行
组件初始化之后,props 和 data 可能随时变化。当 computed 依赖的响应式数据发生变化时,Vue 的响应式系统会通知对应的 computed watcher,把它的 dirty 标志置为 true。注意,此时 computed 并不会立即重新计算,而是等下一次被访问时才重新计算。这个"延迟"设计使得如果某个 computed 没有在模板中使用,也不会因为没有意义的重新计算而浪费性能。
在模板渲染阶段,Vue 会读取所有模板中用到的 computed 属性,如果此时 dirty 是 true,就重新计算;如果是 false,直接用缓存。所以你会看到一个现象:数据变了,但页面没有立刻更新,而是在下一个渲染周期才更新,这正是 Vue 响应式系统的"批处理"机制在起作用。
6. 生产环境常见报错与排查实录
6.1 在 data 里访问一个 computed 字段
这个问题我在 1.1 节已经演示过了。这里再强调一次项目里的典型报错表现:data 里某个字段是 undefined,但你明明在 computed 里定义了同名或同逻辑的属性。排查思路很简单:data() 函数执行时 computed 还没初始化,所以任何在 data() 里访问 this.xxx(且 xxx 是 computed)的写法都是不安全的。直接把 data 里那个字段删掉,改成在 computed 里计算,或者把计算逻辑移到 data 之前能用到的地方。
6.2 computed 报错:引用了未定义的 data 属性
有一种常见报错是:computed 函数里访问了一个不存在的 data 字段,渲染时直接报错或者显示 NaN。
js复制data() {
return {
price: 100
// 注意:没定义 quantity
}
},
computed: {
total() {
return this.price * this.quantity // this.quantity 是 undefined
}
}
this.quantity 是 undefined,100 * undefined 的结果是 NaN。如果 quantity 不是你手滑少写了,而是异步数据还没加载回来,那更常见的情况是渲染先出现 NaN 或报错,等数据回来后才正常。推荐的解决方式是在 computed 里做空值保护:
js复制computed: {
total() {
return this.price * (this.quantity || 0)
}
}
6.3 直接修改 props 引发的警告
Vue 的单向数据流要求你不应该直接修改 props。如果你写了 this.count = 100,而 count 是 props 里的字段,Vue 会在控制台输出一条警告:
code复制Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders.
这条警告的本质还是响应式流程的问题:父组件重新渲染时,props 会被重新赋值,你改掉的值会被覆盖。所以正确做法是:要么在 data 里拷贝一份来改,要么通过 $emit 发事件通知父组件修改源数据。
6.4 执行顺序相关问题速查表
| 报错或异常现象 | 常见原因 | 解决方案 |
|---|---|---|
data 里某个字段是 undefined |
在 data() 里访问了 computed |
不要这样写,把逻辑移到 computed |
props 变化后 data 不更新 |
data() 只在初始化时执行一次 |
用 computed 或 watch 处理 |
模板中渲染出 NaN |
computed 里引用了未定义的字段 |
做空值保护,检查字段名 |
修改 props 后控制台警告 |
违反了单向数据流 | 用 data 拷贝或 $emit 事件 |
data() 里的 this 是 undefined |
data 写成了箭头函数 |
改成普通函数写法 |
computed 渲染出 [object Promise] |
在 computed 里写了异步逻辑 |
把异步请求放到 created 或 watch 中 |
我在实际项目中,只要有组件出现"渲染值不对"的问题,第一步永远是先对照这张表排查,而不是去看模板语法。因为 80% 的诡异 bug 都出在数据初始化的时机和响应式更新上,而不是你写的表达式或者模板写法有问题。
7. Vue 3 里有哪些变化
7.1 setup 的引入改变了初始化时机
Vue 3 引入了 Composition API,核心入口是 setup 函数。它的执行时机是在 beforeCreate 之前、props 解析完成之后。也就是说,当 setup 执行时,props 已经可用,但 data、computed(如果是 Options API 写法)还没初始化。
js复制setup(props, context) {
// 这里可以安全使用 props
console.log(props.initialCount)
return {}
}
setup 接收的第一个参数是 props,这也是"props 最先初始化"的直接体现。在 setup 内部,你不能像 Options API 那样用 this 去访问组件实例,因为 setup 执行时组件实例还没有完全创建好。
7.2 响应式系统从 defineProperty 变为 Proxy
Vue 3 的响应式底层从 Object.defineProperty 换成了 Proxy,这带来了一些好处,比如可以侦测对象新增属性和数组索引变化。但 props、data、computed 的执行顺序逻辑在 Options API 下并没有变。
Vue 3 的 computed 用法变成了:
js复制import { ref, computed } from 'vue'
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
computed 传入一个 getter 函数,返回一个只读的 ref 对象。它的缓存和惰性求值逻辑和 Vue 2 完全一致,你在 Vue 2 里踩过的坑,在 Vue 3 里也要注意规避。
7.3 在 setup 中还要不要关心执行顺序
用 setup 配合 ref、reactive、computed 写代码时,执行顺序的问题变得更加隐性问题。比如你在 setup 里这样写:
js复制setup(props) {
// 这里 data 概念变成了普通变量
const base = ref(props.initialCount)
// computed 依赖的是 ref,不是 this
const doubleBase = computed(() => base.value * 2)
return { base, doubleBase }
}
这里其实不存在初始化顺序的问题了,因为 base 和 doubleBase 都是普通 JavaScript 变量,你只要保证"先定义,再使用"即可。但有一个接近 "props → data → computed" 的问题仍然存在:如果要在初始化时根据 props 生成某个派生值,这个逻辑放在 ref 初始化里是最安全的,因为此时 props 已经解析完成。如果你试图把派生逻辑放在一个更早的地方,比如模块顶层,那依然拿不到 props。
7.4 script setup 语法下的使用体验
如果你用 <script setup> 语法,Vue 3 的编译器会自动帮你处理很多样板代码,你直接在顶层定义 ref 和 computed,它们就能在模板里使用:
vue复制<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
initialCount: {
type: Number,
default: 0
}
})
const base = ref(props.initialCount)
const doubleBase = computed(() => base.value * 2)
</script>
在这种写法下,你同样需要注意"不要在 base 定义之前去访问 base"这类错误。虽然它是纯 JavaScript 的时序问题,不算 Vue 的初始化顺序问题,但调试思路是一样的——遇到异常先看定义顺序。
最后分享一点排查经验
我这几年前端开发下来,遇到和 props、data、computed 执行顺序相关的 bug 其实不算少。最大的体会是:这个问题一旦理解了,很多看起来莫名其妙的渲染问题就有了明确的排查方向。你不需要把源码全部背下来,但至少要记住依赖链的底层逻辑——谁被谁依赖,谁就先初始化。这个原则不仅适用于 props、data、computed,也适用于 watch、methods,甚至适用于你在 setup 里组织的变量顺序。
另外再分享一个小工具技巧:排查组件初始化问题时,在 beforeCreate、created、mounted 这几个钩子里分别打印一次 Object.keys(this) 或者 this 本身,你就能亲眼看到数据是分阶段挂载到组件实例上的。我第一次这么做的时候,瞬间就明白了为什么 created 之前访问不到 data,这个直观的体验比看十遍文档都有用。以后遇到类似问题,你也可以先试试这个办法。
