☰
Vue 2 实例创建阶段深度解析:beforeCreate 与 created 钩子原理及实操
2026/10/16 1:04:46 网站建设 项目流程

1. 为什么实例创建阶段值得单独拿出来讲

很多人学 Vue 生命周期,习惯把八个钩子从头到尾背一遍,背完就觉得自己会了。但真到项目里遇到问题,比如接口请求该放哪个钩子、为什么data里拿不到props、为什么created里操作 DOM 会报错,才发现自己根本没理解每个阶段到底在干什么。

我打算把生命周期拆成一个系列来讲,这是第一篇,专门聊实例创建阶段。这个阶段涉及的钩子其实不多,核心就是beforeCreate和created,但它是整个组件生命周期的起点,后面所有事情都建立在这个基础之上。搞不清楚这个阶段,后面的挂载、更新、销毁都会跟着糊涂。

这篇文章适合谁看?如果你已经写过一些 Vue 组件,但对生命周期的理解还停留在“背钩子名称”的阶段,那这篇就是写给你的。如果你刚开始学 Vue,也可以看,我会尽量用生活化的类比把原理讲清楚。如果你已经工作了一两年,想重新梳理一下底层逻辑,同样有收获,因为我会补充很多文档里不会写的实操细节和踩坑经验。

先明确一个前提:本文讨论的是 Vue 2.x 的生命周期,Vue 3 虽然用 Composition API 重写了大部分逻辑,但组件实例的创建流程在核心思想上是一脉相承的,理解了 Vue 2 的创建阶段,过渡到 Vue 3 会顺畅很多。

2. 实例创建阶段到底在做什么

2.1 从 new Vue() 说起

当你写下new Vue({...})或者在一个单文件组件里导出export default {...}的时候,Vue 内部做了一件很核心的事情:创建一个组件实例。这个实例在 Vue 2 的源码里叫vm,也就是 ViewModel 的缩写。

你可以把这个过程想象成盖房子。new Vue()相当于拿到了建房许可证,但房子还没开始盖。Vue 在这个阶段做的事情,是先把“建房所需的所有材料”准备好——初始化事件系统、注入依赖、初始化状态(props、methods、data、computed、watch)、初始化 provide/inject 等等。

这些初始化工作不是一锅乱炖,而是有严格的先后顺序。这个顺序直接决定了你在不同钩子里能访问到什么、不能访问到什么。

2.2 创建阶段的钩子执行顺序

在实例创建阶段,Vue 暴露给我们的钩子只有两个:

  • beforeCreate:实例刚被创建,初始化工作还没开始
  • created:实例创建完成,初始化工作已经做完

用一段代码来直观感受:

export default { beforeCreate() { console.log('beforeCreate:', this.$data, this.$props, this.$el) }, created() { console.log('created:', this.$data, this.$props, this.$el) } }

实测下来,beforeCreate里打印this.$data是undefined,this.$props也是undefined,this.$el更是undefined。到了created,this.$data和this.$props都有值了,但this.$el仍然是undefined。

这个结果不是偶然的,它背后对应着 Vue 源码里_init函数的执行流程。我画不了图,但可以用文字把关键顺序列出来:

  1. 给vm打标记,比如_uid、_isVue
  2. 初始化生命周期相关属性($parent、$root、$children等)
  3. 初始化事件系统($on、$emit、$off、$once)
  4. 执行beforeCreate钩子
  5. 初始化 inject
  6. 初始化 state(props、methods、data、computed、watch)
  7. 初始化 provide
  8. 执行created钩子

看到没有,beforeCreate卡在事件初始化之后、状态初始化之前。所以在这个钩子里,你能用$on和$emit,但拿不到data、props、methods。而created卡在状态初始化之后,所以这些都能拿到,但 DOM 还没开始挂载,$el自然不存在。

2.3 为什么 beforeCreate 里拿不到 data

这个问题我被问过很多次。答案其实很简单:因为data的初始化发生在beforeCreate之后。

Vue 在初始化 state 的时候,会做几件事:先把props代理到vm上,然后把methods绑定到vm上,接着初始化data,把data里的属性通过Object.defineProperty变成响应式的,同时代理到vm上。最后初始化computed和watch。

这一整套流程走完,this.xxx才能访问到data里的属性。beforeCreate执行的时候,这套流程还没开始,所以this.msg是undefined,this.$data也是undefined。

注意:如果你在beforeCreate里写了this.msg = 'hello',这个赋值不会报错,但它不会变成响应式的,也不会出现在$data里。因为此时data还没初始化,Vue 根本没地方去存这个值。

2.4 created 里能做什么、不能做什么

created是实例创建阶段的终点,也是很多人最常用的钩子。在这个阶段:

能做的事情:

  • 访问data、props、methods、computed、watch
  • 发起异步请求(比如调接口拿初始数据)
  • 初始化一些非响应式的变量
  • 调用this.$emit触发自定义事件
  • 访问this.$parent、this.$root、this.$children

不能做的事情:

  • 操作 DOM(this.$el还不存在)
  • 访问ref(this.$refs是空对象)
  • 获取子组件的实例(子组件还没挂载)

我见过有人在created里写this.$refs.myInput.focus(),然后报Cannot read property 'focus' of undefined。这就是典型的没搞清楚created和mounted的区别。created阶段 DOM 还没生成,ref自然拿不到。

3. 创建阶段的核心细节与实操要点

3.1 data 初始化的完整链路

data的初始化是创建阶段最核心的一环,值得单独拆开讲。

当你写data() { return { msg: 'hello' } }的时候,Vue 内部做了这么几件事:

第一步,调用data函数拿到原始对象。注意,组件里的data必须是一个函数,这是为了保证每个组件实例都有独立的数据副本。如果你写成对象,多个组件实例会共享同一份数据,改一个全改,这是经典错误。

第二步,遍历这个对象的所有 key,用Object.defineProperty把它们变成 getter/setter。getter 负责收集依赖,setter 负责触发更新。这就是 Vue 2 响应式系统的核心。

第三步,把每个 key 代理到vm上。也就是说,你访问this.msg的时候,实际上访问的是this._data.msg。这个代理是通过Object.defineProperty在vm上定义的。

第四步,递归处理嵌套对象。如果data里的某个属性值是对象,Vue 会递归地把它也变成响应式的。

这个过程有一个关键限制:只有初始化时存在的属性才会被响应式处理。如果你在created之后动态添加一个新属性,比如this.newProp = 'xxx',这个属性不是响应式的,视图不会更新。要解决这个问题,得用this.$set(this, 'newProp', 'xxx')或者Vue.set。

实操心得:我见过很多人在created里从接口拿到数据后,直接this.someNewField = res.data.xxx,然后发现视图不更新。原因就在这里。正确的做法是,在data里先声明好这个字段,哪怕初始值是null或空数组,然后再赋值。

3.2 props 初始化的顺序问题

props的初始化发生在data之前。这意味着在data函数里,你可以访问this.props吗?答案是不行。

看这段代码:

export default { props: ['initialCount'], data() { return { count: this.initialCount // 这里能拿到吗? } } }

实测结果是:能拿到,但有个前提。如果initialCount是父组件传进来的,在data函数执行的时候,props已经初始化完成了,所以this.initialCount是有值的。但如果你在data里访问this.$props,可能会拿到undefined,因为$props的代理可能还没完全建立。

更稳妥的做法是,不要在data里直接依赖props的初始值。如果确实需要基于props计算一个初始值,用computed或者watch更合适。因为props是响应式的,父组件更新时props会变,但data里的初始值不会跟着变。

注意:props是单向数据流,子组件不应该直接修改props。如果你在data里把props的值复制了一份,后续修改data里的副本是可以的,但要注意这会导致数据源不一致,排查问题时容易懵。

3.3 methods、computed、watch 的初始化顺序

这三个的初始化顺序是:methods→data→computed→watch。

methods最先初始化,因为data函数里可能会调用methods里的方法。computed在data之后,因为computed依赖data。watch最后,因为watch需要监听data和computed的变化。

这个顺序带来的一个实际影响是:在computed里可以访问data和methods,在watch里可以访问data、methods和computed。反过来则不行。

我踩过的一个坑是:在data函数里调用了一个computed属性,结果报错说computed未定义。当时很纳闷,后来才想明白,computed的初始化在data之后,data函数执行的时候computed还没挂到vm上。

3.4 provide 和 inject 的初始化时机

inject的初始化在beforeCreate之后、data之前。provide的初始化在data之后、created之前。

这意味着在data函数里,你可以访问inject注入的值,但不能访问provide提供的值。这个细节在跨层级组件通信时很重要。

export default { inject: ['theme'], data() { return { currentTheme: this.theme // 可以拿到 } }, provide() { return { childTheme: this.currentTheme // 这里可以访问 data } } }

实操心得:provide如果写成对象,里面的值不是响应式的。如果想让子组件感知到provide值的变化,需要提供一个返回响应式数据的函数,或者直接提供一个响应式对象。这个坑我在做主题切换功能时踩过,子组件拿到theme后,父组件切换主题,子组件没反应,排查了半天才发现是provide的响应式问题。

4. 创建阶段的完整实操流程

4.1 一个典型的组件创建流程

我拿一个实际项目里的组件来演示。假设有一个用户信息卡片组件,需要在创建时从接口拉取用户数据。

export default { name: 'UserCard', props: { userId: { type: String, required: true } }, data() { return { userInfo: null, loading: false, errorMsg: '' } }, computed: { displayName() { return this.userInfo ? this.userInfo.name : '加载中...' } }, watch: { userId: { immediate: true, handler(newVal) { if (newVal) { this.fetchUserInfo(newVal) } } } }, beforeCreate() { console.log('实例开始创建,此时 data 和 props 都不可用') }, created() { console.log('实例创建完成,可以访问 data 和 props') console.log('userId:', this.userId) console.log('userInfo:', this.userInfo) // 注意:这里不要操作 DOM }, methods: { async fetchUserInfo(id) { this.loading = true try { const res = await api.getUser(id) this.userInfo = res.data } catch (err) { this.errorMsg = err.message } finally { this.loading = false } } } }

这个组件里,我把接口请求放在了watch的immediate处理里,而不是直接放在created里。为什么?因为userId可能会变化,如果只放在created里,后续userId变了不会重新请求。用watch加immediate: true,既能覆盖初始请求,又能响应后续变化。

当然,如果你确定userId不会变,直接放在created里更简单:

created() { this.fetchUserInfo(this.userId) }

两种写法都可以,看具体场景。

4.2 创建阶段发起异步请求的注意事项

在created里发请求是常见做法,但有几个细节要注意。

第一,created里发请求不会阻塞组件挂载。也就是说,请求还没回来,组件已经继续走beforeMount、mounted了。所以你在模板里渲染userInfo的时候,要处理null的情况,否则会报错。

第二,如果请求失败,要有兜底逻辑。我见过有人created里发请求,接口挂了,页面直接白屏,因为模板里写了userInfo.name,而userInfo是null。

第三,如果组件在请求返回前就被销毁了,回调里操作this可能会出问题。虽然 Vue 2 里一般不会报错,但最好在请求回调里判断一下组件是否还活着。可以用一个标记变量:

created() { this._isDestroyed = false this.fetchData() }, beforeDestroy() { this._isDestroyed = true }, methods: { async fetchData() { const res = await api.getData() if (this._isDestroyed) return this.data = res.data } }

4.3 创建阶段与挂载阶段的边界

很多人分不清created和mounted的边界。我用一个表格来对比:

对比项createdmounted
DOM 是否生成否是
能否访问 $el否是
能否访问 $refs否是
能否操作 DOM否是
能否访问子组件实例否是
适合发异步请求是是
适合初始化非 DOM 相关逻辑是是
适合初始化 DOM 相关逻辑否是

简单记:跟 DOM 无关的放created,跟 DOM 有关的放mounted。

但这里有个例外:如果异步请求的数据需要用来渲染 DOM,而且你需要在数据渲染后操作 DOM,那请求可以放created,但 DOM 操作要放在$nextTick里,或者放在mounted里配合watch数据变化。

4.4 服务端渲染场景下的创建阶段

如果你的项目用了服务端渲染(SSR),创建阶段的钩子行为会有所不同。beforeCreate和created在服务端和客户端都会执行,但mounted只在客户端执行。

这意味着,如果你在created里发了请求,服务端渲染时也会发一次,客户端 hydration 时可能又发一次。要避免重复请求,需要把请求逻辑放到mounted里,或者用一些状态管理工具来同步服务端和客户端的数据。

注意:SSR 场景下,created里不要访问window、document这些浏览器特有的对象,否则服务端会报错。如果确实需要,放在mounted里,或者用process.client判断环境。

5. 常见问题与排查技巧实录

5.1 常见问题速查表

问题现象可能原因排查思路解决方案
beforeCreate 里 this.xxx 是 undefineddata 还没初始化确认钩子执行顺序把逻辑移到 created
created 里 this.$el 是 undefinedDOM 还没挂载确认 $el 赋值时机把 DOM 操作移到 mounted
created 里 this.$refs 是空对象ref 还没绑定ref 在挂载阶段才生效把 ref 操作移到 mounted
data 里访问 props 拿到 undefinedprops 初始化顺序问题检查 props 是否已传入用 computed 替代
动态添加的属性不响应式属性不在初始 data 中检查属性是否在 data 里声明用 $set 或提前声明
created 里请求返回后视图不更新数据不是响应式的检查赋值方式确保属性已在 data 中声明
provide 的值子组件不更新provide 不是响应式的检查 provide 写法提供响应式对象或函数

5.2 一个真实的排查案例

之前有个同事问我,他在created里从接口拿到数据后赋值给this.list,但页面上的列表一直是空的。我让他把代码发过来,看到他是这么写的:

created() { api.getList().then(res => { this.list = res.data }) }, data() { return { // 这里没有声明 list } }

问题很明显:list没有在data里声明,所以this.list = res.data这个赋值不是响应式的,视图不会更新。

解决方案有两种:一是在data里声明list: [],二是用this.$set(this, 'list', res.data)。推荐第一种,更清晰。

这个案例告诉我们,创建阶段的所有数据操作,都要确保数据在data里已经声明。这是 Vue 2 响应式系统的基本要求。

5.3 另一个容易忽略的细节

created钩子里,this.$children是空数组。因为子组件还没创建。如果你在created里想访问子组件的方法,是拿不到的。

有次一个同学想在父组件的created里调用子组件的方法,怎么都调不到。我告诉他,子组件的创建发生在父组件的挂载阶段,父组件的created执行时,子组件还不存在。要访问子组件,得在mounted里,而且如果子组件是异步组件,还得用$nextTick或setTimeout等一等。

5.4 创建阶段的性能考量

创建阶段做的事情不少:初始化事件、初始化状态、建立响应式、递归处理数据。如果data里有一个很大的对象,比如一个几千条数据的数组,Vue 会递归地给每个属性加 getter/setter,这个过程是有性能开销的。

我做过一个测试,一个包含 5000 条数据的数组,每条数据有 10 个字段,初始化大概需要 100 多毫秒。如果这个组件在页面上频繁创建销毁,累积起来就很可观了。

优化思路有几个:一是尽量精简data里的数据,不需要响应式的数据不要放在data里;二是用Object.freeze冻结那些不需要响应式的大对象,Vue 检测到冻结对象会跳过响应式处理;三是考虑用虚拟列表,只渲染可见区域的数据。

data() { return { // 这个列表不需要响应式,用 freeze 提升性能 bigList: Object.freeze(hugeArray) } }

实操心得:Object.freeze这个技巧在渲染大量静态数据时特别有用。但要注意,冻结后的数据不能修改,如果后续需要更新,就不能用这个方法。

6. 创建阶段与其他阶段的衔接

6.1 从创建到挂载的过渡

created执行完之后,Vue 会判断当前实例是否有el选项。如果有,就继续走挂载流程;如果没有,就等$mount被手动调用。

挂载流程会先执行beforeMount,然后创建虚拟 DOM、渲染真实 DOM,最后执行mounted。所以created和mounted之间,隔着一整个 DOM 生成的过程。

这个过程中,created里设置的响应式数据会被用来生成虚拟 DOM。如果数据在created里被修改了,虚拟 DOM 会反映最新的值。但如果在created里修改了数据,又想在mounted里基于 DOM 做操作,要注意数据变化可能触发重新渲染,导致mounted里的 DOM 引用失效。

6.2 创建阶段对后续更新的影响

创建阶段建立的响应式系统,决定了后续更新阶段的行为。如果在创建阶段漏掉了某个属性的响应式处理,更新阶段就不会触发重新渲染。

这也是为什么我一直强调,所有需要响应式的数据,都要在data里声明。创建阶段是建立响应式的唯一时机,错过了就得用$set补救,而$set用多了代码会很难维护。

6.3 创建阶段在整体生命周期中的位置

如果把组件生命周期比作一个人的成长,创建阶段就是出生。出生的时候,你有了基本的身体结构和器官(事件系统、状态),但还没有开始跟外界交互(DOM 挂载)。

beforeCreate是出生前的瞬间,什么都还没有。created是出生后的第一声啼哭,基本功能都有了,但还没睁眼看世界。

理解了这个比喻,后面的挂载、更新、销毁就都好理解了。挂载是睁眼看世界,更新是成长变化,销毁是生命终结。

7. 一些个人经验和小技巧

7.1 什么时候用 beforeCreate

说实话,beforeCreate在实际项目里用得很少。我几乎没在业务代码里写过beforeCreate。它更多是给插件开发者用的,比如需要在状态初始化之前做一些全局配置的场景。

如果你在业务代码里发现自己需要写beforeCreate,先停下来想一想,是不是可以用其他方式实现。大多数情况下,created或者mounted更合适。

7.2 created 里不要做太多事情

我见过有人在created里写了几百行代码,初始化各种数据、发好几个请求、注册事件监听。这样写不是不行,但组件会变得很难维护。

我的建议是,created里只做最核心的初始化,比如发一个必要的请求。其他的逻辑拆到methods里,在created里调用。这样代码结构更清晰,也方便测试。

7.3 善用 immediate watch 替代 created 请求

前面提过,用watch加immediate: true可以替代created里的请求逻辑,而且能响应props变化。这个技巧在处理列表页、详情页时特别有用,因为这类页面的id经常变化,需要重新拉数据。

7.4 创建阶段的调试技巧

如果你想直观地看到创建阶段的执行顺序,可以在beforeCreate和created里打上不同的颜色日志:

beforeCreate() { console.log('%c beforeCreate ', 'background: #ff9800; color: white') }, created() { console.log('%c created ', 'background: #4caf50; color: white') }

这样在控制台里一眼就能看出哪个先执行。配合mounted、beforeUpdate等钩子一起打,整个生命周期的流程就一目了然了。

7.5 关于 Vue 3 的补充

虽然这篇讲的是 Vue 2,但 Vue 3 的创建阶段逻辑其实更清晰。setup函数在beforeCreate之前执行,相当于把创建阶段的逻辑都集中到了一个地方。onBeforeMount和onMounted对应挂载阶段。

如果你正在从 Vue 2 迁移到 Vue 3,创建阶段的变化主要是:data、methods、computed都变成了setup里的响应式 API,created的逻辑直接写在setup里就行。理解 Vue 2 的创建阶段,对理解 Vue 3 的setup执行时机很有帮助。

这个系列后面还会讲挂载阶段、更新阶段和销毁阶段,每个阶段都会按这个思路来拆解:先讲原理,再讲实操,最后讲踩坑经验。如果你对某个阶段特别感兴趣,可以留言告诉我,我会在后续文章里重点展开。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询