Vue2源码学习笔记之实例化

在前面我们学习 Vue 的实例化时,单独用了两章说明 Vue 的选项合并的内容,下面开始真正学习 Vue 的实例化。 继续看 init 函数下面的代码,在文件 core/instance/init.js 中。 在前面学习中只是提到在非生产环

目录
  1. initProxy
  2. Initial Function
    1. initLifecycle
    2. initEvents
    3. initRender
    4. callHook
    5. initState
  3. 参考资料

在前面我们学习 Vue 的实例化时,单独用了两章说明 Vue 的选项合并的内容,下面开始真正学习 Vue 的实例化。

继续看_init函数下面的代码,在文件core/instance/init.js中。

/* istanbul ignore else */
// ! 初始化代理
if (process.env.NODE_ENV !== "production") {
  initProxy(vm);
} else {
  vm._renderProxy = vm;
}

在前面学习中只是提到在非生产环境和生产环境的代码实现结果必须一致,所以initProxy的函数的作用应该为实例对象vm添加属性_renderProxyvm

真的是这样吗?

initProxy#

查看下initProxy的代码,在core/instance/proxy.js

let initProxy;
 
if (process.env.NODE_ENV !== "production") {
  // ...
  initProxy = function initProxy(vm) {
    if (hasProxy) {
      // determine which proxy handler to use
      const options = vm.$options;
      const handlers =
        options.render && options.render._withStripped
          ? getHandler
          : hasHandler;
      vm._renderProxy = new Proxy(vm, handlers); // ! 使用代理
    } else {
      vm._renderProxy = vm; // ! 和生产环境一样直接赋值
    }
  };
}

这里必须是在非生产环境中,先判断宿主环境(这里都指浏览器)是否支持Proxy语法,如果不支持,那么就和生产环境一样,直接赋值_renderProxyvm

如果支持,首先声明变量options存储合并后的选项vm.$options现在初始化实例的各类属性方法都和选项options相关

然后声明常量handlers存储代理使用的处理器,最后使用new创建一个Proxy实例对象,用_renderProxy属性来代理实例对象vm,并且传入handlers

handlers有两个值,分别是getHandlerhasHandler。需要通过条件判断来获取,而条件语句中的options.render._withStripped只有在测试环境中才会出现。所以handlers一般都是指hasHandler

下面看下hasHandler的代码。

const hasHandler = {
  has(target, key) {
    const has = key in target;
    const isAllowed =
      allowedGlobals(key) ||
      (typeof key === "string" &&
        key.charAt(0) === "_" &&
        !(key in target.$data));
    if (!has && !isAllowed) {
      if (key in target.$data) warnReservedPrefix(target, key);
      else warnNonPresent(target, key);
    }
    return has || !isAllowed;
  },
};

hasHandler是一个对象,主要拦截了代理对象vmhas相关属性,比如拦截in操作符,还有with函数,先看具体的代码逻辑。

首先,获取声明常量has存储key in target运行的结果,然后声明变量isAllowed,这个变量是判断属性key是否允许访问。只有key是全局访问 API 时或者是以_开头但不是在data选项中的 Vue 的属性时,isAllowed才为true

如果hasisAllowed都不为真,还需要判断key是否是data选项上的属性,如果是的话,会提醒以_$开头的属性 不会 被 Vue 的实例对象代理,因为使用_$开头的属性可能会和 Vue 的内置属性或者方法的名称冲突。如果不是的话,会提醒在模板中使用了key值,但是在实例对象中并没有定义这个值。

最后返回has || !isAllowed,即只有has为假且isAllowed为真时才会输出false,其他情况都输入true

举一个例子,假如访问一个没有定义在实例对象上(或原型链上)的属性的属性时(满足!hastrue)。

此时如果这个属性还是全局属性时(满足!isAllowedfalse),就不会报错,不会执行那两个warn函数,但是最后会输出false,即key in target的值为false,说明属性不在target上。

但是如果这个属性不是全局属性就会报错,即满足了!has && !isAllowed的条件,最后输出true,即key in target的值为true,说明属性在target上。

说了那么多,为什么在非生产环境时需要使用代理呢?而生产环境就不需要呢?它们有什么区别吗?

先看下在实例的渲染函数__render有这样一行代码。

// ! 渲染虚拟 Node, 传入 vm.$createElement 方法
vnode = render.call(vm._renderProxy, vm.$createElement);

在渲染时指向了我们的代理_renderProxy,再看下选项中的渲染函数vm.$options.render

vm.$options.render = function () {
  // render 函数的 this 指向实例的 _renderProxy
  with (this) {
    return _c("div", [_v(_s(a))]); // 在这里访问 a,相当于访问 vm._renderProxy.a
  }
};

从上面的代码可以发现,显然函数使用with语句块指定了内部代码的执行环境为this,由于render函数调用的时候使用call指定了其this指向为vm._renderProxy,所以with语句块内代码的执行环境就是vm._renderProxy,所以在with语句块内访问a就相当于访问vm._renderProxya属性,前面我们提到过with语句块内访问变量将会被Proxyhas代理所拦截,所以自然就执行了has函数内的代码。最终通过warnNonPresent打印警告信息给我们。

所以使用代理的作用就是为了在开发阶段给我们一个友好而准确的提示

Initial Function#

继续看下面的代码。

// expose real self
vm._self = vm;
initLifecycle(vm); // ! 初始化生命周期相关属性 存储本身实例到父节点 新增属性 $parent $root $children $refs 等
initEvents(vm); // ! 初始化事件相关属性 更新 listeners
initRender(vm); // ! 初始化渲染相关属性, 创建VNode 另新增属性 $attrs 和 $listeners
callHook(vm, "beforeCreate"); // ! 调用 beforeCreate 钩子函数
initInjections(vm); // ! 初始化 injections 选项,必须在初始化状态之前
initState(vm); // ! 初始化状态 按顺序 props => methods => data => computed  => watch
initProvide(vm); // ! 初始化 Provide resolve provide after data/props
callHook(vm, "created"); // ! 调用 created 钩子函数

把实例对象赋值到_self属性中,这个属性和_renderProxy属性有点相似,都是赋值为实例对象。

但是它们的用途并不相同,而且_renderProxy的值在支持proxy的宿主环境时是一个代理对象。

然后调用一系列的初始化函数来初始化实例对象,注意:这些函数在调用时都需要传入实例对象vm

initLifecycle#

查看initLifecycle函数的代码,这个函数初始化生命周期相关的属性。

export function initLifecycle(vm: Component) {
  const options = vm.$options;
  // ...
}

首先是声明变量options存储合并后的选项vm.$options

然后是下面这段代码。

// locate first non-abstract parent
let parent = options.parent;
if (parent && !options.abstract) {
  // ! 查找第一个非抽象的父组件
  while (parent.$options.abstract && parent.$parent) {
    parent = parent.$parent;
  }
  parent.$children.push(vm); // ! 把实例存储到父组件里面
}
 
vm.$parent = parent; // ! 添加父组件属性 $parent
vm.$root = parent ? parent.$root : vm; // ! 新增属性 $root

声明变量parent存储options的父组件parent。然后使用if语句通过循环查找,往组件树上一层层的寻找父组件parent,如果parent的选项中有abstract属性,说明这是一个抽象组件,不满足要求,继续往上找,直到找到选项中没有abstract属性的父组件为止,然后把当前的实例对象存储到父组件的$children属性中,建立父子关系。

什么是抽象组件呢?抽象组件就是不会被渲染成真实 DOM 的组件,比如keep-alivetranslation等内置组件,它们是具有特殊作用的组件,不会被渲染成真实 DOM。

抽象组件还有一个特点就是不会出现在父子关系的路径中。比如组件compA有子组件compB,子组件compB又有子组件compC,但是组件compB是一个抽象组件,那么compC的父组件就不是compB,而是compA

这里有一个问题:就是options.parent是从哪里来的?我们只知道合并后的选项是vm.$options,那么选项中的parent属性是从哪里来的呢?我们在之前的组件的初始化过程中并没有设置这个parent属性。其实,这是 Vue 设置的,是 Vue 自动给实例对象添加了父组件parent,具体如何实现自动添加父组件,这里暂时不讲解。

然后把父组件赋值给实例对象的$parent属性,再新增一个属性$root,如果存在父组件就把父组件的$root赋值给这个属性。如果没有的话,就赋值为实例对象本身。这个属性是用来存储根组件。如果实例对象有父组件就存储父组件的根组件,没有父组件说明这个实例对象就是根组件,保存实例对象本身即可。

继续看下面的代码。

vm.$children = [];
vm.$refs = {};
 
vm._watcher = null;
vm._inactive = null;
vm._directInactive = false;
vm._isMounted = false;
vm._isDestroyed = false;
vm._isBeingDestroyed = false;

这里初始化一些实例属性的值,其中有我们熟悉的子组件数组$children,还有引用元素或者组件的集合对象$refs

注意,Vue 的实例对象里$开头的属性是暴露给用户使用的,而以_开头的属性是 Vue 的私有属性,不会暴露给用户,这两类属性都属于 Vue 的内置属性,所以用户自定义的属性不能以$或者_开头,否则不会代理到实例对象中,这样为了防止和 Vue 的内置属性名冲突。

initEvents#

接下来查看initEvents函数的代码,这个函数初始化事件相关的属性。

export function initEvents(vm: Component) {
  vm._events = Object.create(null);
  vm._hasHookEvent = false;
  // init parent attached events
  const listeners = vm.$options._parentListeners;
  if (listeners) {
    updateComponentListeners(vm, listeners);
  }
}

首先添加了两个实例属性_events_hasHookEvent_events初始值一个没有原型对象的空对象,而_hasHookEvent初始值为false

然后是声明变量listeners存储vm.$options._parentListeners,再判断这个listeners是否存在,如果存在,调用updateComponentListeners并传入vmlisteners更新组件的listeners

我们现在还不知道_parentListeners是从哪里来的,这里先不管。

initRender#

接下来查看initRender函数的代码,这个函数初始化渲染相关的属性。

先看下函数前面的几行代码。

export function initRender(vm: Component) {
  vm._vnode = null; // the root of the child tree
  vm._staticTrees = null; // v-once cached trees
  // ...
}

首先,添加了两个实例属性_vnode_staticTrees,它们的初始值都是null

继续看下面的代码。

const options = vm.$options;
const parentVnode = (vm.$vnode = options._parentVnode); // the placeholder node in parent tree
const renderContext = parentVnode && parentVnode.context;
vm.$slots = resolveSlots(options._renderChildren, renderContext);
vm.$scopedSlots = emptyObject;

还是声明变量options存储合并后的选项vm.$options,然后再次为实例对象添加三个实例属性$vnode、``$slots$scopedSlots。这里暂时先不去管上面代码中options._parentVnodeoptions._renderChildren`是怎么来的。

继续看下面的代码。

vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false); // ! 编译器渲染模板的渲染方法
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true); // ! 用户手写代码的渲染方法

新增两个实例方法_c$createElement,这两个方法都调用了createElement函数,而且调用这个函数时传入的参数个数也是相同,唯一不同的是传入的第六个参数alwaysNormalize的值不同,_c传入的是false$createElement传入的true。这里先说明下_c方法是编译器渲染模板的渲染方法,而$createElement是用户手写代码时的渲染方法。

比如我们手写render函数的时候,编写下面代码。

export default {
  render(createElement) {
    return createElement("h1", "hello");
  },
};

如果换成$createElement来写的话,是下面这个样子

export default {
  render() {
    return this.$createElement("h1", "hello");
  },
};

继续看最后剩下的代码。

const parentData = parentVnode && parentVnode.data;
 
/* istanbul ignore else */
if (process.env.NODE_ENV !== "production") {
  defineReactive(
    vm,
    "$attrs",
    (parentData && parentData.attrs) || emptyObject,
    () => {
      !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm);
    },
    true
  );
  defineReactive(
    vm,
    "$listeners",
    options._parentListeners || emptyObject,
    () => {
      !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm);
    },
    true
  );
} else {
  defineReactive(
    vm,
    "$attrs",
    (parentData && parentData.attrs) || emptyObject,
    null,
    true
  );
  defineReactive(
    vm,
    "$listeners",
    options._parentListeners || emptyObject,
    null,
    true
  );
}

上面代码中新增实例对象属性$attrs$listeners,使用defineReactive函数来定义属性,defineReactive在后面讲响应式系统时会分析,它的作用是使对象的key值变成响应式的属性。

根据 Node 环境不同,在传入defineReactive函数的第四个参数也不同,这个参数是customSetter,是一个自定义setter函数,是可选的。

在非生产环境中传入的是下面这个函数。

() => {
  !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm);
};

这个函数参数是在非生产环境中传入的,当isUpdatingChildComponentfalse时,会提示$attrs属性时可读的。这个函数在你设置$attrs或者$listeners的值的时候会执行。

先看下isUpdatingChildComponent的代码。

export let isUpdatingChildComponent: boolean = false;
 
// ! 更新子组件方法
export function updateChildComponent(
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  if (process.env.NODE_ENV !== "production") {
    isUpdatingChildComponent = true;
  }
 
  // ...
 
  // update $attrs and $listeners hash
  // these are also reactive so they may trigger child update if the child
  // used them during render
  vm.$attrs = parentVnode.data.attrs || emptyObject;
  vm.$listeners = listeners || emptyObject;
 
  // ...
 
  if (process.env.NODE_ENV !== "production") {
    isUpdatingChildComponent = false;
  }
}

isUpdatingChildComponent默认值是false,当调用updateChildComponent函数时,会先把它的值设置为true,然后执行一段代码后,又把它的值设置为false

我们注意到中间代码中有对$attrs$listeners属性进行赋值的操作,即更新这两个属性,因为在这之前已经设置isUpdatingChildComponent的值为false,所以更新属性时不会提示它们是只读属性。

callHook#

执行完initRender函数后,下面执行的是callHook函数,这是执行生命周期钩子的函数。

callHook(vm, "beforeCreate"); // ! 调用 beforeCreate 钩子函数

这里是执行的beforeCreate生命周期钩子函数。

先看下callHook函数代码。

export function callHook(vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget();
  const handlers = vm.$options[hook];
  const info = `${hook} hook`;
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      invokeWithErrorHandling(handlers[i], vm, null, vm, info);
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit("hook:" + hook);
  }
  popTarget();
}

函数需要传入两个参数,实例对象vm和生命周期钩子函数的名称hook

再看函数体的第一行和最后一行,第一行代码是pushTarget(),最后一行代码popTarget(),看名字应该是目标入栈和出栈,这里先不管它们的逻辑。

继续看下面的代码。,先从合并后的选项vm.$options中通过钩子名称获取钩子的值handlers。从前面的选项的合并中,我们知道handles的值应该是一个数组,数组里面的元素是都是钩子函数。

因为不是每个组件都有生命周期钩子函数的,所以需要先判断下是否存在handlers。如果存在,则遍历这个钩子函数组成的数组,调用invokeWithErrorHandling函数来执行数组里面的函数。

看下invokeWithErrorHandling函数的代码,在core/util/error.js文件中。

export function invokeWithErrorHandling(
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res;
  try {
    res = args ? handler.apply(context, args) : handler.call(context);
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch((e) => handleError(e, vm, info + ` (Promise/async)`));
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true;
    }
  } catch (e) {
    handleError(e, vm, info);
  }
  return res;
}

把钩子函数的执行放到处理错误相关的文件中,主要是为了更好的提示错误。

首先声明变量res,这也是这个函数的返回值。然后开始就使用了try ... catch语句来执行钩子函数,以及捕获可能出现的错误,如果出现错误调用handleError函数来处理。

重新定义变量res,用来存储函数调用的值,通过判断是否有参数args,然后在执行handler函数的时候是否传入参数。再判断函数的输出值res是不是一个Promise对象,如果是的话,使用catch来捕获Promise对象的错误。之后再设置返回值res的属性_handledtrue,表示函数已经被调用过。

函数最后不管有没有出现错误都会返回res。不过我们在使用生命周期钩子函数时,一般很少用到这个返回值。

接下来看剩下的代码。

if (vm._hasHookEvent) {
  vm.$emit("hook:" + hook);
}

vm._hasHookEvent是在initEvent时定义的属性,初始值是false,它是用来判断是否有生命周期钩子的事件监听器。当组件检查到有生命周期钩子的事件监听器时,会把它的值设为true

到底什么是生命周期钩子的事件监听器呢?

<child
  @hook:beforeCreate="handleChildBeforeCreate"
  @hook:created="handleChildCreated"
  @hook:mounted="handleChildMounted"
/>

使用@hook:再加上生命周期的名称来监听组件的生命周期事件。

当实例对象发现有生命周期钩子的事件监听器时,会把_hasHookEvent设置为true,这时就会进入下面的逻辑,实例对象会派发一个名为hook: 生命周期函数名字的事件出去。

vm.$emit("hook:" + hook);

这里有个问题:实例对象是什么时候设置_hasHookEventtrue的呢?这涉及到 Vue 的事件系统,暂时先不管。

initState#

调用完生命周期钩子函数后,接下来是initInjections函数的调用,这个函数是初始化injects选项的函数。这里先不管它,后面和初始化provides的函数一起分析。

现在看下一个initState函数的代码,在core/instance/state.js文件中。

export function initState(vm: Component) {
  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);
  } else {
    observe((vm._data = {}), true /* asRootData */);
  }
  if (opts.computed) initComputed(vm, opts.computed);
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}

这是初始化 Vue 的数据相关的函数。首先增加实例属性vm._watchers,初始值为空数组。声明变量opts存储合并后的选项vm.$options

然后判断opts中是否有props属性,如果有的话调用initProps函数初始化props选项。再判断opts中是否有methods属性,如果有的话调用initMethods函数初始化methods选项。再在判断opts中是否有data属性,如果有的话调用initData函数初始化data选项。如果没有的话,调用observe监听一个空对象,并传入第二个参数true,表示观测的对象是根组件的data

接下来继续判断opts中是否有computed属性,如果有的话调用initComputed函数初始化computed选项。继续判断opts中是否有watch且它不等于 Firefox 浏览器的watch值,如果满足这两个条件的话,调用initWatch函数初始化watch选项。

小结initState函数统一初始化了propsmethodsdatacomputedwatch这几个数据相关的选项。

注意这些选项初始化的顺序。比如props要比datacomputed更早的初始化,这样就可以使用props的数据来初始化data或者computed选项的数据,计算属性computed的初始化放在data后面也是因为它的值可以依赖data选项的值。

一个很重要的使用场景,Vue 使用单向流数据,规定子组件不能修改父组件传过的props数据,这样会防止从子组件意外改变父级组件的状态,从而导致你的应用的数据流向难以理解。但是在业务场景中,可能需要改变传过来的props的数据,那我们要怎么办呢?

我们可以把它转存为data或者computed选项的数据,作为这些数据的初始值,这样就相当于改变了props的值。

示例代码

props: ['initialCounter', 'size'],
data() {
  return {
    counter: this.initialCounter
  }
},
computed: {
  normalizedSize: function () {
    return this.size.trim().toLowerCase()
  }
}

参考资料#