Inside new Vue(): Component Initialization and the Lifecycle
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, andpropsare 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.tsincludes Vue 2.7 additions likerenderTrackedandrenderTriggered, 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:
_initruns: options merge, tree wiring, event setup, render initbeforeCreatefires — no reactive data yetinitInjectionsresolvesinjectvaluesinitStatesets up props → setup → methods → data → computed → watchinitProvideresolvesprovidevaluescreatedfires — reactive data exists, but no DOM$mountis called (either from_initifelis provided, or manually)- Template is compiled to render function (full build only)
mountComponentis calledbeforeMountfires- Render watcher is created, evaluates
updateComponent _render()produces VNode tree_update()calls__patch__()to create/update DOMmountedfires
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.