Vue2 源码简介

初始化流程:new Vue 发生了什么

梳理从 new Vue 到页面渲染的完整初始化流程,理解 _init 与 $mount 内部的执行顺序。

🎯 引言

每个 Vue 项目都从一句 new Vue({...}) 开始,但你有没有想过:这一行代码背后,Vue 到底按什么顺序做了哪些事?

这篇我们沿着 Vue 2.7.16 的源码走一遍初始化主链路。学完之后,你能完整说出从 new Vue 到页面渲染的执行顺序,也能解释一个经典面试题:为什么 beforeCreate 里拿不到 data,created 里却可以


🧱 Vue 构造函数在哪

Vue 的入口构造函数定义在 src/core/instance/index.js,简化后只有几行:

// 简化自 src/core/instance/index.js
function Vue(options) {
    this._init(options);
}

initMixin(Vue); // 挂 _init
stateMixin(Vue); // 挂 $data、$props、$set、$delete、$watch
eventsMixin(Vue); // 挂 $on、$once、$off、$emit
lifecycleMixin(Vue); // 挂 _update、$forceUpdate、$destroy
renderMixin(Vue); // 挂 _render、$nextTick 等

注意一个细节:Vue 2 用的是 function,而不是 ES6 的 class。为什么?

因为 function 更容易把功能按模块拆开。每个 Mixin 函数各自负责往 Vue.prototype 上挂一类方法,分散在不同的文件里维护。比如事件方法都在 eventsMixin 里、生命周期相关都在 lifecycleMixin 里。如果用 class 把所有方法写在一个类里,源码会挤在一个巨大的文件中,很难维护。

所以别看构造函数本身只有一行 this._init(options),真正的逻辑都在各个 Mixin 里。new Vue 的第一步,就是调用 _init


🔍 _init 做了什么

_init 定义在 src/core/instance/init.js,是初始化的总调度。简化后的核心流程:

// 简化自 src/core/instance/init.js
Vue.prototype._init = function (options) {
    const vm = this;

    // 1. 合并选项:把用户传入的 options 和构造函数的默认选项合并
    vm.$options = mergeOptions(Vue.options, options, vm);

    // 2. 初始化生命周期关系:$parent、$children、$refs 等
    initLifecycle(vm);

    // 3. 初始化事件:处理父组件绑定在子组件上的事件监听
    initEvents(vm);

    // 4. 初始化渲染相关:$slots、$scopedSlots、$createElement 等
    initRender(vm);

    // 5. 触发 beforeCreate
    callHook(vm, 'beforeCreate');

    // 6. 初始化注入
    initInjections(vm);

    // 7. 初始化状态:props、methods、data、computed、watch
    initState(vm);

    // 8. 初始化 provide
    initProvide(vm);

    // 9. 触发 created
    callHook(vm, 'created');

    // 10. 如果传了 el,自动挂载
    if (vm.$options.el) {
        vm.$mount(vm.$options.el);
    }
};

这段代码是全文的重点,其中最值得记住的是 5、7、9 三步的相对位置:

  • callHook(vm, 'beforeCreate') 在第 5 步,此时 initState 还没执行,data、methods 都还没挂到实例上。
  • initState(vm) 在第 7 步,data、methods、computed 在这里完成初始化。
  • callHook(vm, 'created') 在第 9 步,此时 data 已经可用了。

这就是 beforeCreate 拿不到 data、created 能拿到 data 的根本原因:不是魔法,只是执行顺序。initState 恰好排在两个钩子之间。

很多文章让你背"生命周期钩子顺序",其实不用背。看懂 _init 这十几行代码,钩子顺序自然就记住了:钩子的触发时机,就是它被写在代码里的位置。

⚡ initState 的内部顺序

上面第 7 步的 initState 内部也有固定顺序,定义在 src/core/instance/state.js

// 简化自 src/core/instance/state.js
function initState(vm) {
    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) initWatch(vm, opts.watch);
}

顺序是:props → methods → data → computed → watch。这个顺序不是随便排的,而是按依赖关系排的:

  • props 先于 data:data 的初始值经常会引用 props,比如 data() { return { count: this.start } }。如果 data 先初始化,this.start 还不存在。
  • methods 先于 data:data 里可能直接调用方法,比如 data() { return { list: this.getDefaultList() } },方法必须先就位。
  • computed 和 watch 排在最后:它们通常依赖 data 和 props,自然要等前面都准备好。

再看 initData 里一个关键动作:

// 简化自 src/core/instance/state.js
function initData(vm) {
    let data = vm.$options.data;
    data = vm._data = typeof data === 'function' ? data.call(vm, vm) : data || {};

    // 把 data 的属性代理到 vm 上,所以能用 this.xxx 访问
    proxy(vm, '_data', key);

    // 让 data 变成响应式
    observe(data);
}

两件事值得注意:一是 proxythis._data.xxx 代理成了 this.xxx,所以我们写代码时不用带 _data;二是最后调用了 observe(data)响应式就是在这一步建立的observe 内部如何实现,是下一篇响应式原理的主角,这里先记住"它发生在 initData 阶段"就够了。


🎬 $mount 与渲染 watcher

_init 结束后,如果传了 el,就进入 vm.$mount(el)

在带编译器的完整版里,$mount 定义在 src/platforms/web/entry-runtime-with-compiler.js,它负责把 template(或 el 的 outerHTML)编译成 render 函数,然后交给 mountComponent 完成真正的挂载:

// 简化自 src/platforms/web/entry-runtime-with-compiler.js
Vue.prototype.$mount = function (el) {
    el = document.querySelector(el);
    const options = this.$options;

    // 没有 render 函数,就把 template 编译成 render
    if (!options.render) {
        const { render } = compileToFunctions(options.template || el.outerHTML);
        options.render = render;
    }

    // 调用运行时版本的 $mount,进入挂载流程
    return mountComponent(this, el);
};

mountComponentsrc/core/instance/lifecycle.js,简化后:

// 简化自 src/core/instance/lifecycle.js
function mountComponent(vm, el) {
    vm.$el = el;

    callHook(vm, 'beforeMount');

    const updateComponent = () => {
        vm._update(vm._render());
    };

    // 创建渲染 watcher:立即执行一次 updateComponent,完成首次渲染
    new Watcher(vm, updateComponent);

    callHook(vm, 'mounted');
    return vm;
}

这里的核心是 updateComponent,它干两件事:

  1. vm._render():执行 render 函数,生成虚拟 DOM(vnode)。
  2. vm._update(vnode):把 vnode 打补丁到真实 DOM,首次渲染时就是创建所有元素。

new Watcher(vm, updateComponent) 创建的就是常说的渲染 watcher。它一创建就立即执行 updateComponent,完成首次渲染;更妙的是,执行过程中读到的 data 属性会被 watcher 记录下来,之后这些属性一变,watcher 就会重新执行 updateComponent,页面自动更新。这就是"数据驱动视图"的落地方式,响应式原理篇会详细展开。

同样注意钩子位置:beforeMount 在创建 watcher 之前(DOM 还没渲染),mounted 在之后(页面已经渲染完成)。


🧵 全流程回顾

把整条链路串起来,从 new Vue 到页面渲染完成,顺序是:

new Vue(options)
  │
  ▼
_init(options)
  ├─ 合并 options
  ├─ initLifecycle / initEvents / initRender
  ├─ ▶ beforeCreate(拿不到 data、methods)
  ├─ initInjections
  ├─ initState:props → methods → data(observe 建立响应式)→ computed → watch
  ├─ initProvide
  ├─ ▶ created(可以拿到 data、methods,但页面还没渲染)
  │
  ▼
$mount(el)(传了 el 时自动调用)
  ├─ 编译 template 得到 render 函数
  │
  ▼
mountComponent
  ├─ ▶ beforeMount
  ├─ new Watcher(vm, updateComponent):_render() 生成 vnode,_update() 渲染到页面
  └─ ▶ mounted(页面渲染完成,可以操作 DOM)

记住三个关键分界点:initState 在 beforeCreate 与 created 之间,new Watcher 在 beforeMount 与 mounted 之间,而模板编译(template → render)发生在 created 之后、beforeMount 之前。


🧾 小节总结

  • Vue 2 用 function 而非 class 定义构造函数,是为了把功能拆成多个 Mixin,分别挂到原型上。
  • _init 是初始化总调度:合并选项、初始化生命周期/事件/渲染,穿插触发 beforeCreate 和 created。
  • beforeCreate 拿不到 data 而 created 可以,因为 initState 恰好排在两个钩子之间执行。
  • initState 按 props → methods → data → computed → watch 的顺序初始化,顺序由依赖关系决定;data 在 initData 中通过 observe 变成响应式。
  • $mount 把 template 编译成 render 函数,mountComponent 创建渲染 watcher 完成首次渲染,beforeMount 在其前、mounted 在其后。

❓ 知识问答

Q1:beforeCreate 里能做什么?

A:此时 data、methods、事件都还没初始化,能做的事情很少。常见用途是做一些和实例数据无关的准备,或者配合插件在很早的时机注入全局逻辑。一般业务代码很少用到它。

Q2:created 里能操作 DOM 吗?

A:不能。created 时 $mount 还没开始,页面上没有渲染出任何元素,this.$el 也拿不到有效值。想操作渲染好的 DOM,应该放到 mounted 里。

Q3:为什么 data 要写成函数返回对象?

A:这和本篇的顺序有关:组件被复用时每个实例都会执行 data.call(vm, vm) 得到一份全新的对象,多个实例之间互不干扰。如果直接写对象,所有实例会共享同一份数据。根实例(new Vue)只有一个,所以写对象也不会出问题。

Q4:没传 el 的话,还会自动渲染吗?

A:不会。_init 末尾判断了 vm.$options.el,没传就停在 created 之后,需要你手动调用 vm.$mount('#app'),后面的流程完全一样。

Q5:template、render、el 同时存在时用哪个?

A:$mount 里优先用已有的 render 函数;没有 render 才会拿 template 去编译;template 也没有就用 el 的 outerHTML 编译。所以优先级是 render > template > el。


🧪 小练习

  1. 用 Node.js v22,通过 npm i vue@2.7.16 安装后在页面中引入(或用 CDN 引入 2.7.16),写一个根实例,在 beforeCreatecreatedbeforeMountmounted 四个钩子里分别打印 this.count(data 中定义)和 document.querySelector('#app').innerHTML,观察哪一步开始能拿到数据、哪一步页面才真正有内容。
App.vue
<script>
export default {
    data() {
        return { count: 1 };
    },
    beforeCreate() {
        // 请在这里编写代码:打印 this.count,观察结果
    },
    created() {
        // 请在这里编写代码:打印 this.count 和 #app 的内容,观察结果
    },
    mounted() {
        // 请在这里编写代码:再次打印 #app 的内容,对比 created 时的差异
    },
};
</script>
  1. 对照本文的流程图,不看答案,自己在纸上默写一遍 _init 的执行顺序,并标出四个钩子触发的位置。

🎉 恭喜你已经掌握了 Vue2 的初始化主链路!下一篇我们深入 observe,看看响应式到底是怎么建立的。