Read OSS

Inside new Vue(): Component Initialization and the Lifecycle

Intermediate

Prerequisites

  • Article 1: Architecture and Entry Points
  • Vue 2 component options API (data, computed, watch, lifecycle hooks)
  • JavaScript prototypal inheritance

Inside new Vue(): Component Initialization and the Lifecycle

Every Vue application begins with new Vue(). That single constructor call triggers a carefully orchestrated boot sequence — merging options, wiring parent-child relationships, setting up reactivity, and ultimately creating the render watcher that ties data changes to DOM updates. Understanding this sequence explains why props are available inside data() functions, why lifecycle hooks fire in a specific order, and how Vue manages to create thousands of child components efficiently.

The _init Boot Sequence

As we saw in Part 1, the Vue constructor calls exactly one method: this._init(options). This method, attached by initMixin, is the heart of component initialization:

sequenceDiagram
    participant App as new Vue(options)
    participant Init as _init()
    participant Hooks as Lifecycle Hooks
    
    App->>Init: this._init(options)
    Init->>Init: Assign _uid, _isVue, __v_skip
    Init->>Init: Create EffectScope
    Init->>Init: Merge options (root) or initInternalComponent (child)
    Init->>Init: Set _renderProxy
    Init->>Init: initLifecycle — wire $parent, $children, $root
    Init->>Init: initEvents — setup event system
    Init->>Init: initRender — create _c, $createElement
    Init->>Hooks: callHook('beforeCreate')
    Init->>Init: initInjections — resolve inject
    Init->>Init: initState — props, setup, methods, data, computed, watch
    Init->>Init: initProvide — resolve provide
    Init->>Hooks: callHook('created')
    Init->>Init: If options.el, call $mount

A few details worth calling out. The __v_skip = true flag on line 34 prevents the Vue instance itself from being observed by the reactivity system — without this, Vue would try to make the instance's properties reactive, leading to infinite recursion. The _scope = new EffectScope(true) creates the component's effect scope that will collect all watchers (render watcher, computed watchers, user watchers) for centralized cleanup during $destroy.

The options merging takes two paths. For root instances and components created with new Vue(), it calls the full mergeOptions function. For child components created during rendering (the vast majority), it uses the fast path initInternalComponent.

Options Merging Strategies

The mergeOptions function is one of the most intricate parts of Vue 2. It merges parent and child option objects using a strategy-based system defined in src/core/util/options.ts:

flowchart TD
    MO["mergeOptions(parent, child, vm)"] --> NORM["Normalize props, inject, directives"]
    NORM --> EXT["Apply extends & mixins recursively"]
    EXT --> FIELDS["For each key in parent ∪ child"]
    FIELDS --> STRAT{"Get strategy<br/>strats[key] || defaultStrat"}
    STRAT --> |"data"| DATA["mergeDataOrFn: returns merge wrapper function"]
    STRAT --> |"lifecycle hooks"| HOOKS["mergeLifecycleHook: concat into arrays"]
    STRAT --> |"components/directives/filters"| ASSETS["mergeAssets: prototype chain inheritance"]
    STRAT --> |"watch"| WATCH["Merge into arrays (watchers don't overwrite)"]
    STRAT --> |"props/methods/computed"| PLAIN["Simple overwrite merge"]
    STRAT --> |"default"| DEF["Child wins if defined, else parent"]

Each option type has its own merge strategy. This is why lifecycle hooks from mixins don't replace component hooks — they're merged into arrays via mergeLifecycleHook. The data strategy wraps both parent and child data functions into a new function that calls both and deep-merges the results. Watch handlers also merge into arrays, so multiple mixins can watch the same property.

The contrast with initInternalComponent is stark. For child components, options were already merged at Vue.extend time, so initInternalComponent simply creates the $options object via Object.create(constructor.options) and copies six properties from the parent VNode — no strategy evaluation, no normalization. This is a critical performance optimization: in a large app with thousands of components, skipping the full merge for each child saves significant time.

Tip: If you're seeing unexpected behavior from mixins, remember that lifecycle hooks are called in merge order (mixin hooks fire before component hooks), while methods, computed, and props are simple overwrites where the component's own option wins.

Component Tree Wiring and Event System

After options merging, initLifecycle establishes the component tree relationships:

graph TD
    ROOT["$root (root instance)"]
    ROOT --> P1["Parent A<br/>$parent = $root"]
    ROOT --> P2["Parent B<br/>$parent = $root"]
    P1 --> C1["Child 1<br/>$parent = Parent A"]
    P1 --> C2["Child 2<br/>$parent = Parent A"]
    P2 --> C3["Child 3<br/>$parent = Parent B"]

The function locates the first non-abstract parent (skipping abstract: true components like <keep-alive> and <transition>), pushes the new instance into the parent's $children array, and sets $root by following the parent chain up. It also initializes $refs as an empty object and sets lifecycle flags (_isMounted, _isDestroyed, _isBeingDestroyed) to false.

Next, initEvents creates the _events registry (an object with null prototype — so no inherited properties) and processes parent-attached listeners. A subtle detail: _hasHookEvent is a boolean flag that tracks whether any hook:* event listeners are registered. This avoids a hash lookup on every lifecycle hook call — the callHook function only emits hook:mounted etc. if this flag is true.

Then initRender sets up the two createElement functions: vm._c (used by compiler-generated render functions, with simple child normalization) and vm.$createElement (used by hand-written render functions, with full normalization). The difference matters for performance — compiled templates produce children in a predictable format that needs less processing.

initState: The Initialization Order That Matters

The initState function processes options in a deliberate order:

flowchart TD
    IS["initState(vm)"] --> P["1. initProps<br/>Validate, defineReactive, proxy to vm"]
    P --> S["2. initSetup (Composition API)<br/>Call setup(props, context)"]
    S --> M["3. initMethods<br/>Bind methods to instance"]
    M --> D["4. initData<br/>Call data(), observe result"]
    D --> C["5. initComputed<br/>Create lazy watchers"]
    C --> W["6. initWatch<br/>Create user watchers"]
    
    P -.->|"props available"| S
    P -.->|"props available"| D
    M -.->|"methods available"| D

This ordering creates a dependency chain: data() is called as a function with the instance as context, so it can reference this.myProp and this.myMethod. But computed properties can't reference other computed properties defined later (though in practice the lazy evaluation means they usually work). Watch handlers are created last because they may reference any of the above.

The props initialization in initProps validates each prop value, makes it reactive via defineReactive, and proxies it so this.myProp reads from this._props.myProp. The development build adds a custom setter that warns when you mutate a prop directly.

For data, the initData function calls the data function, checks for naming conflicts with props and methods, proxies each key from this._data to the instance, and then calls observe(data) to make it reactive. The observe call is where the magic happens — but we'll save that for Part 3.

Vue.extend and Sub-Constructor Caching

When you register a component as a plain options object, Vue internally calls Vue.extend to create a sub-constructor. This function sets up proper prototypal inheritance:

flowchart TD
    VUE["Vue (cid: 0)"] --> SUB["Sub = VueComponent (cid: 1)"]
    SUB -->|"prototype = Object.create(Vue.prototype)"| PROTO["Inherits _init, _render, _update, etc."]
    SUB -->|"Sub.options = mergeOptions(Vue.options, extendOptions)"| OPTS["Pre-merged options"]
    SUB -->|"Copies static methods"| STATIC["extend, mixin, use, component, directive, filter"]
    
    OPTS -->|"Props on prototype"| PROXY["proxy(Sub.prototype, '_props', key)<br/>One defineProperty per prop, not per instance"]
    OPTS -->|"Computed on prototype"| COMP["defineComputed(Sub.prototype, key, userDef)<br/>One defineProperty per computed, not per instance"]

The caching optimization is elegant: extendOptions._Ctor[SuperId] = Sub. If the same options object is passed to Vue.extend multiple times (which happens on every re-render of a parent that references a child component), the cached sub-constructor is returned immediately. This is why the options object identity matters — if you generate a new options object each time, you lose the cache.

Another performance win: props and computed properties are defined on the sub-constructor's prototype rather than on each instance. This means Object.defineProperty is called once per component definition, not once per instance. The instance-level proxy in initProps only handles props added at instantiation time.

mountComponent and the Render Watcher

After _init completes (with beforeCreate and created hooks fired), $mount is called if an el option is provided. The $mount method ultimately calls mountComponent:

sequenceDiagram
    participant Mount as $mount(el)
    participant MC as mountComponent
    participant W as new Watcher
    participant R as _render()
    participant U as _update()
    
    Mount->>MC: mountComponent(vm, el)
    MC->>MC: vm.$el = el
    MC->>MC: callHook('beforeMount')
    MC->>W: new Watcher(vm, updateComponent, ...)
    Note over W: isRenderWatcher = true
    W->>R: updateComponent → vm._render()
    R-->>W: returns VNode tree
    W->>U: vm._update(vnode)
    Note over U: __patch__ → DOM
    MC->>MC: callHook('mounted')

The core of this is updateComponent, defined as:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

This function is passed as the getter to a new Watcher instance. Because the watcher evaluates this getter, it reads reactive data — which registers the watcher as a subscriber. When any of those reactive properties change, the watcher is notified and re-runs updateComponent, producing a new VNode tree and patching the DOM.

This is the fundamental contract: one render watcher per component, running _render() then _update(), automatically re-triggered by the reactivity system. The before hook on the watcher calls beforeUpdate — but only if the component is already mounted (avoiding a false beforeUpdate during initial render).

A subtle ordering detail: mounted is only called synchronously for the root instance (when vm.$vnode == null). For child components, mounted is called later via the component VNode's insert hook — after the component's DOM has actually been inserted into the parent. This means child mounted hooks fire bottom-up (deepest child first), while created hooks fire top-down.

Tip: The lifecycle hooks array in src/shared/constants.ts includes Vue 2.7 additions like renderTracked and renderTriggered, backported from Vue 3. These fire in development mode when reactive dependencies are tracked or trigger a re-render — invaluable for debugging reactivity issues.

From Construction to First Render

Let's trace the complete sequence from new Vue() to pixels on screen:

  1. _init runs: options merge, tree wiring, event setup, render init
  2. beforeCreate fires — no reactive data yet
  3. initInjections resolves inject values
  4. initState sets up props → setup → methods → data → computed → watch
  5. initProvide resolves provide values
  6. created fires — reactive data exists, but no DOM
  7. $mount is called (either from _init if el is provided, or manually)
  8. Template is compiled to render function (full build only)
  9. mountComponent is called
  10. beforeMount fires
  11. Render watcher is created, evaluates updateComponent
  12. _render() produces VNode tree
  13. _update() calls __patch__() to create/update DOM
  14. mounted fires

In the next article, we'll dive into the reactivity system that powers step 11's dependency tracking — how Observer, Dep, and Watcher work together to make this automatic re-rendering possible.