【Vue 故地重游】03.细读 Vue2 核心源码

2,283 阅读10分钟

写在前面

  Vue3发布已有 9 个月,相比Vue2确实做了太多优化,于是想着重新再仔细全面地研究一下Vue2源码,然后对比Vue3做个整理,方便以后复习查阅。

  so,今天就从 Vue2 开始吧!

预准备

  1. 项目地址:github.com/vuejs/vue

  2. 环境需要

    • 2.1. 全局安装 rollup
    • 2.2. 修改 dev 脚本,package.json
      "dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:webfull-dev",
      
  3. 开始调试

    • 3.1. 打包脚本: npm run dev
      这里会看到多个版本的 vue 文件,

      • runtime:运行时,不包含编译器
      • common:cjs 规范,用于webpack1环境
      • esm:ES 模块,用于webpack2+环境
      • umd:兼容 cjs 和 amd,用于浏览器

      需要注意的是,平时我们是用vue-cli基于webpack环境,webpack借助vue-loader就提前完成了编译工作,因此不需要 vue 的编译器模块,所以使用的都是 runtime.esm 版本

    • 3.2. 页面使用

      <script src="../../dist/vue.js"></script>
      
  4. 文件结构关键部分

    • src
      • compiler 编译器相关
      • core 核心代码
        • components 通用组件,如 keep-alive
        • global-api 全局 api,如$set$delete
        • instance 构造函数等
        • observer 响应式相关
        • util
        • vdom 虚拟 dom

初始化流程

  研究源码的第一步就是从初始化入手,这个阶段的内容大多都很抽象,我们并不知道将来有什么具体作用,所以也是最容易劝退的一个环节。可以先在脑海中留个印象,后续很多流程都会回到这里的某个方法继续深究,在这个过程中不断加深记忆。

  所谓初始化,就是Vue从无到有的过程,先后经历定义 Vue 的全局属性及方法创建实例、定义实例的属性及方法执行数据响应式挂载 dom

从源码探究流程

首先找到入口文件夹/src/platforms/web/,可以看到多个入口文件

  • entry-compiler.js
  • entry-runtime-with-compiler.js
  • entry-runtime.js
  • entry-server-basic-renderer.js
  • entry-server-renderer.js

entry-runtime-with-compiler.js进入,可以获得最全面的内容,包括运行时编译器两大部分

注入编译器:entry-runtime-with-compiler.js /src/platforms/web/

  • 作用

    • Vue实例上的$mount方法注入编译器,该编译器的作用是将 template 转成 render 函数
  • 核心源码

    if (!options.render) {
      // 先经过 多根/dom和字符串情况 的处理,变成单根的字符串形式
      // ...
      if (template) {
        const { render, staticRenderFns } = compileToFunctions(
          template,
          {
            outputSourceRange: process.env.NODE_ENV !== 'production',
            shouldDecodeNewlines,
            shouldDecodeNewlinesForHref,
            delimiters: options.delimiters,
            comments: options.comments,
          },
          this
        );
        options.render = render;
      }
    }
    return mount.call(this, el, hydrating);
    

      template 有两种情况,字符串dom选择器,但最终都会处理成字符串,如果 template 是多根元素,经过compileToFunctions处理只保留第一个节点,这也是 template 必须要用单根的原因

      这一步的关键是获得render函数,后续的组件渲染更新都会用到这个方法。通常在webpack环境中并不需要注入编译器这一步,因为webpack在编译阶段借助vue-loader单文件中的template转成render函数,从而减少生产环境Vue文件的体积和编译的时间。

注入 web 运行时: /src/platforms/web/runtime/index.js

所谓 web 运行时,其实就是注入 web 平台 特有的方法,因为还要考虑Weex,所以单独分出了这个模块

  • 作用:

    • 为实例定义patch方法,用于 组件更新
    • 为实例上的$mount方法额外扩展mountComponent方法,用于将组件渲染到浏览器
  • 核心源码

    Vue.prototype.__patch__ = inBrowser ? patch : noop;
    
    Vue.prototype.$mount = function (el?: string | Element, hydrating?: boolean): Component {
      el = el && inBrowser ? query(el) : undefined;
      return mountComponent(this, el, hydrating);
    };
    

    实例添加的这两个方法和 挂载 息息相关,在后续的初始化更新流程会用到。

定义全局 API:initGlobalAPI /src/core/index.js

  • 作用:初始化 Vue 的静态方法,

    • Vue.util 中的方法(mergeOptionsdefineReactive
    • Vue.observe
    • Vue.use
    • Vue.mixin
    • Vue.extend
    • Vue.component/directive/filter
  • 核心源码

    Vue.set = set;
    Vue.delete = del;
    Vue.nextTick = nextTick;
    initUse(Vue); // 实现Vue.use函数
    initMixin(Vue); // 实现Vue.mixin函数
    initExtend(Vue); // 实现Vue.extend函数
    initAssetRegisters(Vue); // 注册实现Vue.component/directive/filter
    

    这一步是注入全局方法,通常在开发项目的入口文件用的会很多,比如挂载全局组件添加指令使用插件等。

定义实例相关: src/core/instance/index.js

  • 作用

      1. 定义Vue构造器,
      1. Vue实例注入API
  • 核心源码

    function Vue(options) {
      this._init(options);
    }
    
    initMixin(Vue); // 初始化 this._init 方法
    // 其他实例属性和方法由下面这些方法混入
    stateMixin(Vue);
    eventsMixin(Vue);
    lifecycleMixin(Vue);
    renderMixin(Vue);
    

    我们熟知的实例方法基本都来自这里

    1. stateMixin 定义$data$props$set$delete$watch

    2. eventsMixin 定义$onemitoffonce

    3. lifecycleMixin 定义_update$forceUpdate$destory

    4. renderMixin 定义$nextTick_render

      实例注入方法,基本平时工作用都有所涉及,开发项目用的最多的实例 方法都来自这里

实例的初始化:this._init src/core/instance/init.js

  • 作用

    • 调用 mergeOptions 合并 Vue.optionsnew Vue传入的参数,赋值给vm.$options
    • 伴随着生命周期的执行,为实例添加属性、添加事件监听(组件通信)、初始化渲染相关、执行数据响应式等等,最后调用$mount将组件渲染到页面
  • 核心源码

    vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor), options || {}, vm);
    initLifecycle(vm); // 实例属性:$parent,$root,$children,$refs
    initEvents(vm); // 监听_parentListeners
    initRender(vm); // 插槽解析,$slots,$scopeSlots,  $createElement()
    callHook(vm, 'beforeCreate');
    // 接下来都是和组件状态相关的数据操作
    // inject/provide
    initInjections(vm); // 注入祖辈传递下来的数据
    initState(vm); // 数据响应式:props,methods,data,computed,watch
    initProvide(vm); // 提供给后代,用来隔代传递参数
    callHook(vm, 'created');
    
    // 如果设置了 el,则自动执行$mount()
    if (vm.$options.el) {
      vm.$mount(vm.$options.el);
    }
    

    我们熟知的实例方法基本都来自这里

    1. initLifecycle 定义vm.$parentvm.$rootvm.$refsvm.$children

    2. initEvents 定义vm._eventsupdateComponentListeners(vm.$listeners)

    3. initRender 定义vm._cvm.$createElement

    4. initInjections 依次执行resolveInjectdefineReactive

    5. initState 定义initPropsinitMethodsinitDatainitComputedinitWatch

    6. initProvide 定义vm._provide

        同样也是定义实例上的属性及方法,与上一步不同的是,上个过程定义的api大多是平时工作写业务用的,这个过程提供的方法基本都为后续的源码服务,另外initLifecycle中定义的属性平时写组件库的小伙伴可能很熟悉。

渲染:$mount 与 mountComponent /src/core/instance/lifecycle.js

$mount在初始化时分别被扩展了编译器方法和运行时方法,核心在与运行时为其扩展了mountComponent,这是组件挂载的入口🚩

  • 作用:组件初始化和更新最重要的流程之一,为updateComponent赋值更新函数,创建Watcher

  • 核心源码

    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
      // ...
    } else {
      updateComponent = () => {
        vm._update(vm._render(), hydrating);
      };
    }
    
    // we set this to vm._watcher inside the watcher's constructor
    // since the watcher's initial patch may call $forceUpdate (e.g. inside child
    // component's mounted hook), which relies on vm._watcher being already defined
    new Watcher(
      vm,
      updateComponent,
      noop,
      {
        before() {
          if (vm._isMounted && !vm._isDestroyed) {
            callHook(vm, 'beforeUpdate');
          }
        },
      },
      true /* isRenderWatcher */
    );
    

      在 Vue 中,所有的渲染都由 Watcher(render Watcher) 完成,包括初始化渲染组件更新,所以这一步结束后,可以在页面上看到真实 dom 的样子。

流程梳理

  1. 首先初始化全局的静态方法,components、filter、directive。set、delete 等,
  2. 然后定义Vue 实例的方法,
  3. 接着执行init方法进行实例的初始化,伴随着生命周期的进行执行初始化属性、事件监听、数据响应式,最后调用$mount将组件挂载到页面上

总结与思考

  1. 为什么$mount要经过扩展?

    为了方便跨平台开发,因为Vue2新增了Weex,所以这一步是向平台注入特有的方法

  2. mergeOptions中发现有个监听事件绑定的操作用于组件通信时,其通信机制是怎样的?
    当前组件在mergeOptions时有一个属性parentListener用来存放父组件通过 props 绑定的事件,组件会通过$on将注册到自身,在使用时直接$emit触发即可

  3. 生命周期的名称及应用:

    • 2.1. 分类列举

      • 初始化阶段:beforeCreate、created、beforeMount、mounted
      • 更新阶段:beforeUpdate、updated
      • 销毁阶段:beforeDestroy、destroyed
    • 2.2. 应用:

      • created 时,所有数据准备就绪,适合做数据获取、赋值等数据操作
      • mounted 时,$el 已生成,可以获取 dom;子组件也已挂载,可以访问它们
      • updated 时,数值变化已作用于 dom,可以获取 dom 最新状态
      • destroyed 时,组件实例已销毁,适合取消定时器等操作

数据响应式

预先准备

响应式原理

  借助 Object.defineReactive,可以对一个对象的某个key进行 getset 的拦截,get 时进行 依赖收集set触发依赖

Object.defineReactive有两个缺陷

  1. 无法监听动态添加的属性
  2. 无法监听数组变化

三个概念 及 发布订阅模式

(ps:这里只做原理简析,下文会具体提到详细的 dep 和 watcher 互相引用 等细节问题。)

  1. Observer
    发布者:每个对象(包含子对象)有一个 Observer 实例,内部存在一个 dep,用于管理多个Watcher,当数据改变时,通过 dep 通知 Watcher 进行更新
  2. Dep
    发布订阅中心:内部管理多个Watcher
  3. Watcher
    订阅者:执行组件的初始化和更新方法

流程一览

官网的流程图 avatar

初始化时进行数据响应式操作和创建组件 Watcher

  • 前者为对象的每个key进行getter/setter拦截,并创建dep
  • 组件 Watcher负责组件的渲染和更新。

  组件 Watcher在创建时会执行一次组件的render函数,从而间接触发相关keygetter方法,将Watcher收集到keydep中, 当我们更改key的值时会触发keysetter方法,通过keydep通知Watcher进行更新。

从源码探究流程

  还记得吗,初始化执行了_init方法,其中有一个函数initState,这便是数据响应式的入口

initState /src/core/instance/state.js

  • 作用

    • 初始化 propsmethodsdatacomputedwatch,并进行 响应式处理
  • 核心源码

    const opts = vm.$options;
    // 1.props
    if (opts.props) initProps(vm, opts.props);
    // 2.methods
    if (opts.methods) initMethods(vm, opts.methods);
    // 3.data
    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);
    }
    

      可以看到,这里响应式处理的对象是_data,因此_data将来是一个响应式对象,很多Vue 组件都借助了这个特点来获取响应式内容,比如Vuex

      这里需要特别关注initData方法,其核心功能是对我们平时写的data进行数据响应化处理

    function initData(vm: Component) {
      const keys = Object.keys(data);
      let i = keys.length;
      while (i--) {
        const key = keys[i];
    
        proxy(vm, '_data', key); // 将 响应式数据 代理到 this 上面
      }
      // 执行数据响应化
      observe(data, true /* asRootData */);
    }
    

      我们之所以可以直接在组件内部通过this使用data中的属性,是因为这里做了一个proxy(vm, '_data', key)的操作,proxy并没有多复杂,只是把_data的操作直接交给vm处理

    function proxy(target: Object, sourceKey: string, key: string) {
      sharedPropertyDefinition.get = function proxyGetter() {
        return this[sourceKey][key];
      };
      sharedPropertyDefinition.set = function proxySetter(val) {
        this[sourceKey][key] = val;
      };
      Object.defineProperty(target, key, sharedPropertyDefinition);
    }
    

观察的入口:observe /src/core/observer/index.js

  • 作用

    • 不重复地数组(除 VNode 以外的)对象 创建 Observer实例
  • 源码

    export function observe(value: any, asRootData: ?boolean): Observer | void {
      if (!isObject(value) || value instanceof VNode) {
        return;
      }
      let ob: Observer | void;
      if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
        ob = value.__ob__;
      } else if (
        shouldObserve &&
        !isServerRendering() &&
        (Array.isArray(value) || isPlainObject(value)) &&
        Object.isExtensible(value) &&
        !value._isVue
      ) {
        ob = new Observer(value);
      }
      if (asRootData && ob) {
        ob.vmCount++;
      }
      return ob;
    }
    

    (ps:开发环境下会对propsmethods校验,避免命名冲突)

      这里会有个细节,根据对象是否包含__ob__选择是否复用 Observer ,而__ob__是哪来的呢,其实是new Observer时操作的

Observer /src/core/observer/index.js

  • 作用

    • 对象/数组 创建 Observer 实例,并挂载到对象的__ob__属性上,
    • 创建 dep,用于数组的响应式Vue.set时使用
  • 核心源码

    class Observer {
      constructor(value: any) {
        this.dep = new Dep();
    
        // 指定ob实例
        def(value, '__ob__', this);
        if (Array.isArray(value)) {
          // 覆盖原型
          if (hasProto) {
            protoAugment(value, arrayMethods);
          } else {
            copyAugment(value, arrayMethods, arrayKeys);
          }
          // 观察数组
          this.observeArray(value);
        } else {
          // 观察对象
          this.walk(value);
        }
      }
      walk(obj: Object) {
        const keys = Object.keys(obj);
        for (let i = 0; i < keys.length; i++) {
          defineReactive(obj, keys[i]);
        }
      }
    
      observeArray(items: Array<any>) {
        for (let i = 0, l = items.length; i < l; i++) {
          observe(items[i]);
        }
      }
    }
    

      因为数组元素无法直接被Object.defineProperty拦截,后面会单独处理。但数组元素可能是对象,因此需要观察里面的元素。

观察对象:defineReactive /src/core/observer/index.js

  • 作用:

    • 通过Object.defineProperty对象key进行拦截,
    • 为对象的key创建dep,用于key发生变化时通知更新
  • 核心源码

    function defineReactive(obj: Object, key: string, val: any, customSetter?: ?Function) {
      const dep = new Dep();
    
      let childOb = observe(val);
    
      Object.defineProperty(obj, key, {
        get: function reactiveGetter() {
          const value = getter ? getter.call(obj) : val;
          if (Dep.target) {
            dep.depend();
            if (childOb) {
              childOb.dep.depend();
              if (Array.isArray(value)) {
                dependArray(value);
              }
            }
          }
          return value;
        },
        set: function reactiveSetter(newVal) {
          const value = getter ? getter.call(obj) : val;
          /* eslint-disable no-self-compare */
          if (newVal === value || (newVal !== newVal && value !== value)) {
            return;
          }
          // #7981: for accessor properties without setter
          // ...
          val = newVal;
          childOb = observe(newVal);
          dep.notify();
        },
      });
    }
    

    getter 负责为 自己和子元素添加依赖setter 负责两件事

    • 内容有变化时通知 dep 更新 watcher
    • 观察新设置的值(新设置的值可能也是个对象)

      或许你会有个小疑问,执行setter时为什么只改形参val呢?

      其实这是JavaScript闭包 特性,我的理解是,闭包为当前函数提供了一个作用域,每次setter被触发都会从当前作用域下取出变量valgetter时返回这个val,所以我们每次操作都值都是当前作用域下的val

观察数组:方法覆盖 /src/core/observer/array.js

  • 作用

    • 数组有 7 个可以改变内部元素的方法,对这 7 个方法扩展额外的功能
        1. 观察新添加的元素,实现数组内部元素数据响应式
        1. 取出数组身上的__ob__,让他的dep通知watcher更新视图,实现数组响应式
  • 核心源码

    // 获取数组原型
    const arrayProto = Array.prototype;
    // 克隆一份
    export const arrayMethods = Object.create(arrayProto);
    
    // 7个变更方法需要覆盖
    const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
    
    /**
     * Intercept mutating methods and emit events
     */
    methodsToPatch.forEach(function (method) {
      // cache original method
      // 保存原始方法
      const original = arrayProto[method];
      // 覆盖之
      def(arrayMethods, method, function mutator(...args) {
        // 1.执行默认方法
        const result = original.apply(this, args);
        // 2.变更通知
        const ob = this.__ob__;
        // 可能会有新元素加入
        let inserted;
        switch (method) {
          case 'push':
          case 'unshift':
            inserted = args;
            break;
          case 'splice':
            inserted = args.slice(2);
            break;
        }
        // 对新加入的元素做响应式
        if (inserted) ob.observeArray(inserted);
        // notify change
        // ob内部有一个dep,让它去通知更新
        ob.dep.notify();
        return result;
      });
    });
    

      还记得吗,在创建Observer实例时特意给对象添加了一个dep,这里可以通过dep调用notify方法通知更新,以此实现数组数据响应式

Dep /src/core/observer/dep.js

  • 作用:

    • 每个实例管理一组 Watcher 实例,可以通知更新
    • 添加Watcher 实例到自己
    • 通知Watcher 实例添加或删除自己(互相添加或删除)
  • 核心源码

    class Dep {
      constructor() {
        this.id = uid++;
        this.subs = [];
      }
    
      // 用于和watcher建立连接
      addSub(sub: Watcher) {
        this.subs.push(sub);
      }
    
      // 用于和watcher取消引用
      removeSub(sub: Watcher) {
        remove(this.subs, sub);
      }
    
      // 用于添加watcher到自己
      depend() {
        if (Dep.target) {
          Dep.target.addDep(this);
        }
      }
    
      // 用于通知watcher更新
      notify() {
        // stabilize the subscriber list first
        const subs = this.subs.slice();
        for (let i = 0, l = subs.length; i < l; i++) {
          subs[i].update();
        }
      }
    }
    

      DepWatcher 相互引用,互相添加是为了处理Vue.set,互相删除是为了处理Vue.delete

Watcher /src/core/observer/watcher.js

  • 作用:

    • 分为 render Watcheruser Watcher
    • user Watcher用于 watchcomputed
    • render Watcher用于组件初始化更新,执行粒度是 render 整个组件
    • 实例存在于对象观察者的dep
    • dep互相添加或删除
  • 核心源码

    class Watcher {
      constructor(
        vm: Component,
        expOrFn: string | Function,
        cb: Function,
        options?: ?Object,
        isRenderWatcher?: boolean
      ) {
        this.vm = vm;
        // 用于 vm.forceUpdate
        if (isRenderWatcher) {
          vm._watcher = this;
        }
        vm._watchers.push(this);
        // options
        if (options) {
          this.deep = !!options.deep;
          this.user = !!options.user;
          this.lazy = !!options.lazy;
          this.sync = !!options.sync;
          this.before = options.before;
        } else {
          this.deep = this.user = this.lazy = this.sync = false;
        }
        this.cb = cb;
        this.id = ++uid; // uid for batching
        this.active = true;
        this.dirty = this.lazy; // for lazy watchers
        this.deps = [];
        this.newDeps = [];
        this.depIds = new Set();
        this.newDepIds = new Set();
        this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : '';
        // parse expression for getter
        // 初始化 的时候参数2如果是一个函数,则直接赋值给getter
        if (typeof expOrFn === 'function') {
          this.getter = expOrFn;
        } else {
          this.getter = parsePath(expOrFn);
          if (!this.getter) {
            this.getter = noop;
          }
        }
        this.value = this.lazy ? undefined : this.get();
      }
    
      // 执行更新,重新收集依赖(初始化与更新都会再次执行这里)
      get() {
        pushTarget(this);
        let value;
        const vm = this.vm;
        try {
          value = this.getter.call(vm, vm);
        } catch (e) {
        } finally {
          // 深度监听
          if (this.deep) {
            traverse(value);
          }
          // 当前Watcher赋值给Dep.target,用于重新收集依赖
          popTarget();
          this.cleanupDeps();
        }
        return value;
      }
    
      // 添加watcher到subs
      addDep(dep: Dep) {
        const id = dep.id;
        // 相互添加引用
        if (!this.newDepIds.has(id)) {
          // watcher添加dep
          this.newDepIds.add(id);
          this.newDeps.push(dep);
          if (!this.depIds.has(id)) {
            // dep添加watcher
            dep.addSub(this);
          }
        }
      }
    
      // 清除依赖,更新和初始化并不会实际执行,因为newDepIds中没有内容
      cleanupDeps() {
        let i = this.deps.length;
        while (i--) {
          const dep = this.deps[i];
          if (!this.newDepIds.has(dep.id)) {
            dep.removeSub(this);
          }
        }
        let tmp = this.depIds;
        this.depIds = this.newDepIds;
        this.newDepIds = tmp;
        this.newDepIds.clear();
        tmp = this.deps;
        this.deps = this.newDeps;
        this.newDeps = tmp;
        this.newDeps.length = 0;
      }
    
      // 组件更新、computed、watch
      update() {
        /* istanbul ignore else */
        // computed
        if (this.lazy) {
          this.dirty = true;
        } else if (this.sync) {
          this.run();
        } else {
          // 异步更新 watcher入队
          queueWatcher(this);
        }
      }
    
      // 同步执行的watcher,async:true
      run() {
        if (this.active) {
          // 如果是组件级别watcher,只走下面get
          const value = this.get();
          if (
            value !== this.value ||
            // Deep watchers and watchers on Object/Arrays should fire even
            // when the value is the same, because the value may
            // have mutated.
            isObject(value) ||
            this.deep
          ) {
            // set new value
            const oldValue = this.value;
            this.value = value;
            if (this.user) {
              try {
                this.cb.call(this.vm, value, oldValue);
              } catch (e) {}
            } else {
              this.cb.call(this.vm, value, oldValue);
            }
          }
        }
      }
    
      // 不立即触发的watcher,immediate:false
      evaluate() {
        this.value = this.get();
        this.dirty = false;
      }
    
      // 和 watcher 互相引用
      depend() {
        let i = this.deps.length;
        while (i--) {
          this.deps[i].depend();
        }
      }
    
      // 取消监听,和 watcher 互相删除 引用,$watch 时使用
      teardown() {
        if (this.active) {
          // remove self from vm's watcher list
          // this is a somewhat expensive operation so we skip it
          // if the vm is being destroyed.
          if (!this.vm._isBeingDestroyed) {
            remove(this.vm._watchers, this);
          }
          let i = this.deps.length;
          while (i--) {
            this.deps[i].removeSub(this);
          }
          this.active = false;
        }
      }
    }
    

      即使只选取了 Watcher 的核心源码,但内容依然很多,主要包括重新收集依赖computedwatch$watch这些过程,忽略这些情况的话,其实Watcher的核心作用只有初始化更新

      值得注意的是get方法,最终的渲染更新都会走到这里,并且里面有一个popTarget方法,这是实现Vue.set的关键。

Vue.set

  说了这么多遍Vue.set,是不是以为实现起来会很复杂,相反核心源码只有两行,其实流程大部分都在Watcher中实现了。

function set(target: Array<any> | Object, key: any, val: any): any {
  defineReactive(ob.value, key, val);
  ob.dep.notify();
}

  调用Vue.set后,首先为key创建dep,然后取出对象身上的__ob__通知更新,更新时来到Watcher,从updateget,最终先执行popTarget,将当前的render Watcher赋值给Dep.target,然后调用组件的render函数,间接触发keygetter方法,完成收集依赖更新视图

流程梳理

  1. Vue初始化时调用this._init,其中initState方法用于初始化响应式数据
  2. 首先将_data代理到实例上,方便开发者通过this调用,然后对_data进行响应式处理,为每个被观察的对象创建观察者实例,并添加到__ob__属性上
  3. 每个key拥有一个depgetter时收集依赖(watcher),setter时通知依赖(watcher)更新
  4. 每个对象也拥有一个dep,用于数组更新和实现Vue.set
  5. 对于数组采用方法覆盖7 个方法在执行时扩展一个额外的操作,观察新增的元素,然后让数组的__ob__通知watcher进行更新

总结与思考

  1. depwatcher 的关系为什么设计成多对多?

    • 首先要明白的概念是,watcher包含render Watcheruser watcher
    • 其次,一个key拥有一个dep
      • 一个key可能通过props绑定给多个组件,这就有多个render Watcher
      • 如果在组件中使用了computedwatch,这就又添加了多个user Watcher
      • 到这里,depwatcher是一对多
    • Vue2 很重要的一点是,render Watcher的更新粒度是整个组件,对于一个组件,通常有多个可以触发更新的 key,又因为一个key有一个dep,所以这种情况下depwatcher是多对一的关系
    • 综合上面两种情况,depwatcher被设计成多对多的关系是最合适的
  2. 为什么需要Vue.set,其使用场景需要注意什么?

    • 存在的意义:因为Object.defineProperty无法动态监听,当增加key时需要手动设置成响应式。
    • 注意:添加 key 的这个对象必须是响应式 🚩,因为Vue.set关键的一步是取出对象身上的dep触发更新完成收集依赖,如果对象不是响应式数据就不存在dep,因此无法完成依赖收集
  3. 综合数据响应式原理,感觉最复杂的部分在于处理 数组新增 key 的情况,大量逻辑都在Watcher中,导致Watcher的源码读起来很麻烦,这也是后来Vue3着重优化的一部分。后续专门整理下Vue3的变化以作对比 🚩

批量异步更新

  上个模块着重整理了Watcher,他负责组件的初始化渲染更新更新阶段为了更高效率地工作,Vue采用了批量异步更新策略

  首先考虑为何要批量呢?数据响应式让我们可以通过更改数据触发视图自动更新,但如果一个函数中多次更改了数据呢,多次触发更新会很损耗性能,所以Vue将更新设置成了“批量”,即在一次同步任务中的所有更改,只会触发一次更新,既然更新同步任务划分了界限,那么更新自然而然就被放到了异步中处理。

  回顾Watcherupdate函数源码

// 组件更新 computed watch
update() {
  /* istanbul ignore else */
  // computed
  if (this.lazy) {
    this.dirty = true;
  } else if (this.sync) {
    this.run();
  } else {
    // 异步更新 watcher入队
    queueWatcher(this);
  }
}

  所以入口是queueWatcher方法

预先准备

  1. 浏览器中的 事件循环模型

  2. Tick 是一个微任务单元

    avatar

从源码探究流程

更新的入口:queueWatcher /src/core/observer/scheduler.js

  • 作用

    • 不重复地向queue中添加watcher
    • 一次更新流程中只调用一次nextTick(flushSchedulerQueue)
  • 核心源码

    function queueWatcher(watcher: Watcher) {
      const id = watcher.id;
      if (has[id] == null) {
        has[id] = true;
        if (!flushing) {
          queue.push(watcher);
        } else {
          // if already flushing, splice the watcher based on its id
          // if already past its id, it will be run next immediately.
          // ...
        }
        // queue the flush
        if (!waiting) {
          waiting = true;
    
          nextTick(flushSchedulerQueue);
        }
      }
    }
    

      flushSchedulerQueue用于批量执行queue中的任务,用waiting加锁的意义在于,nextTick可能会开启异步任务,因此只尝试开启一次。flushSchedulerQueue更新结束后会重置waitingfalse,用于下一次更新使用。

管理队列:nextTick /src/core/util/next-tick.js

  • 作用

    • flushSchedulerQueue添加到callbacks
    • 尝试开启(一次同步任务只开启一次) 异步任务
  • 核心源码

    function nextTick(cb, ctx) {
      // 存入callbacks数组
      callbacks.push(function () {
        // 错误处理
        if (cb) {
          try {
            cb.call(ctx);
          } catch (e) {
            handleError(e, ctx, 'nextTick');
          }
        }
      });
      if (!pending) {
        pending = true;
        // 启动异步任务
        timerFunc();
      }
    }
    

      timerFunc是基于平台的真正的异步函数,在初始化时定义,一旦调用会直接在真正的任务栈中添加异步任务,所以用pending加锁的意义是为了保证只添加一个异步任务

      或许你也会疑问,上一步不是加锁了吗,这里两个状态表示的意义不同,上面waiting表示已经添加任务后,进入等待阶段,后面再有watdher要更新只往队列加,但是不能再尝试向队列加执行任务了,除非用户触发vm.$nextTick;而这里的pending表示异步更新即将执行,请不要催促。所以不可以用一个哦~

真正的异步函数:timerFunc /src/core/util/next-tick.js

  • 作用
    • 这是基于平台的,真正执行异步任务,根据浏览器兼容性选择支持的异步函数
  • 核心源码
    if (typeof Promise !== 'undefined' && isNative(Promise)) {
      const p = Promise.resolve();
      timerFunc = () => {
        p.then(flushCallbacks);
        // In problematic UIWebViews, Promise.then doesn't completely break, but
        // it can get stuck in a weird state where callbacks are pushed into the
        // microtask queue but the queue isn't being flushed, until the browser
        // needs to do some other work, e.g. handle a timer. Therefore we can
        // "force" the microtask queue to be flushed by adding an empty timer.
        if (isIOS) setTimeout(noop);
      };
      isUsingMicroTask = true;
    }
    // else if...
    
      这里会根据浏览器的兼容性选择最适合的异步任务,优先级为:promise > MutationObserver > setImmediate(虽然是宏任务,但优于 setTimeout) > setTimeout

流程梳理

  在一次同步任务中,当执行setter时,会取出dep执行notify从而触发queueWatcher
queueWatcher不重复地向queue中添加watcher,然后加锁执行nextTick,
nextTick会向微任务队列中添加一个flushCallbacks(即flushSchedulerQueue)。

  在 js 任务栈 中的情况大致如下:

  1. setter一旦被触发,微任务队列就推入 方法flushCallbacks,整个过程只存在一个
  2. 若当前同步任务没有结束,如果用户执行vm.$nextTick,只会向callbacks中加任务,不会再产生新的flushCallbacks
  3. 如果用户手动执行了微任务,则向浏览器的微任务队列中推入一个微任务,在flushCallbacks后面
  4. 同步任务执行完毕,浏览器自动从微任务队列中取出flushCallbacks用户产生的微任务 一次性执行

思考与总结

  1. queuecallbacksflushCallbacksflushSchedulerQueue的关系

    • flushCallbacks,是真正执行的的异步任务,作用是刷新 callbacks
    • callbacks中存放的是flushSchedulerQueue
    • flushSchedulerQueue:刷新 queue 的函数
    • queue中存放的是watcher

      他们之间是这样的关系:callbacks = [flushSchedulerQueue: () => while queue:[watcher1,watcher2,watcher3], $nextTick]

  2. 善用$nextTick在代码中的位置
      如果我们的业务需要在更新时先获取到旧的 dom 内容,然后再进行新的 dom 操作。或许这时候可以不滥用data,毕竟响应式数据有开销嘛,可以在 修改 data 之前执行$nextTick,like this,

    receiveNotice() {
      this.$nextTick(() => {
        console.log(this.refs.map) // 更新之前的dom
      })
      this.updateMap(); // 该方法触发dom更新
      console.log(this.refs.map) // 最新的dom
    }
    

VNode与patch

  由于render Watcher的更新粒度是整个组件,为了避免全量更新而引入了虚拟 dom。其本质是用js 对象来描述dom 结构的一种数据结构虚拟 dom不仅避免让我们直接操作dom,还可以抽象出dom 的实现层来做跨平台开发

从源码探究流程

初始化流程的最终阶段

  Vue 初始化最后一步是调用$mount,执行mountComponent
  可记否,mountComponent核心源码如下:

function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
) {
  updateComponent = () => {
    vm._update(vm._render(), hydrating);
  };

  new Watcher(vm, updateComponent, ...)
}

  Watcher 实例化时将传入的第二个参数(updateComponent)赋值给get并执行一次,真正的渲染函数就在_render_update

_render /src/core/instance/render.js

  • 作用

    • 产生虚拟 dom
  • 核心源码

    Vue.prototype._render = function (): VNode {
      const vm: Component = this;
      const { render } = vm.$options;
    
      // render self
      let vnode;
      try {
        vnode = render.call(vm._renderProxy, vm.$createElement);
      } catch (e) {
        handleError(e, vm, `render`);
      }
      // 必须是单根节点
      if (Array.isArray(vnode) && vnode.length === 1) {
        vnode = vnode[0];
      }
    
      return vnode;
    };
    

      (上文)初始化阶段 /src/platforms/web/entry-runtime-with-compiler.js$mount扩展来render方法,render 函数(转成别名函数之前)形式如下

    render(h) {
      return h("div", { attrs: { id: 'demo' } }, "Hello World")
    }
    

      render函数接收的参数h就是上面源码中传入的vm.$createElement,是经过标准化处理高阶函数
    vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

      createElement核心代码如下

    function createElement(
      context: Component,
      tag: any,
      data: any,
      children: any
    ): VNode | Array<VNode> {
      if (Array.isArray(data) || isPrimitive(data)) {
        children = data;
        data = undefined;
      }
      return _createElement(context, tag, data, children);
    }
    
    function _createElement(
      context: Component,
      tag?: string | Class<Component> | Function | Object,
      data?: VNodeData,
      children?: any
    ): VNode | Array<VNode> {
      // vnode生成
      let vnode, ns;
      if (typeof tag === 'string') {
        let Ctor;
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);
        // 浏览器节点
        if (config.isReservedTag(tag)) {
          vnode = new VNode(
            config.parsePlatformTagName(tag),
            data,
            children,
            undefined,
            undefined,
            context
          );
        } else if (
          (!data || !data.pre) &&
          isDef((Ctor = resolveAsset(context.$options, 'components', tag)))
        ) {
          // component 自定义组件的情况
          // 上面的代码获取了自定义组件的构造函数
          vnode = createComponent(Ctor, data, context, children, tag);
        } else {
          // 浏览器节点
          vnode = new VNode(tag, data, children, undefined, undefined, context);
        }
      } else {
        // component 自定义组件的情况
        // direct component options / constructor
        vnode = createComponent(tag, data, context, children);
      }
      if (Array.isArray(vnode)) {
        return vnode;
      } else if (isDef(vnode)) {
        if (isDef(ns)) applyNS(vnode, ns);
        if (isDef(data)) registerDeepBindings(data);
        return vnode;
      } else {
        return createEmptyVNode();
      }
    }
    

      如果是浏览器标签,就直接通过new VNode创建虚拟 dom,如果是自定义组件,则需要获取到组件构造函数(Ctor),通过createComponent获取组件虚拟 dom

    总之最终都会产生虚拟 dom

_update /src/core/instance/lifecycle.js

  • 作用

    • 保存最新的虚拟 dom,用于下次更新时取出来使用
    • 调用__patch__最终在页面上渲染 真实 dom
  • 核心源码

    Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
      const prevVnode = vm._vnode;
      vm._vnode = vnode;
      // Vue.prototype.__patch__ is injected in entry points
      // based on the rendering backend used.
      if (!prevVnode) {
        // initial render
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
      } else {
        // updates
        vm.$el = vm.__patch__(prevVnode, vnode);
      }
    };
    

    可以看到,首次渲染更新都执行__patch__,区别为:

    • 首次渲染时传入的旧节点是真实 dom
    • 更新时传入的新旧节点都是虚拟 dom

__patch__: 首次渲染更新 /src/core/vdom/patch.js

真正的patch函数来自工厂函数 createPatchFunction

  • 作用

    • 首次渲染时,调用createElm创建一整棵树,添加到旧节点的兄弟节点位置,然后删除旧节点
    • 更新时调用patchVnode
  • 核心源码

    function createPatchFunction(backend) {
      const { modules, nodeOps } = backend;
      return function patch(oldVnode, vnode, hydrating, removeOnly) {
        if (isUndef(oldVnode)) {
        } else {
          const isRealElement = isDef(oldVnode.nodeType);
          if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // patch existing root node
            // 更新逻辑
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly);
          } else {
            // 初始化逻辑
            if (isRealElement) {
              // either not server-rendered, or hydration failed.
              // create an empty node and replace it
              oldVnode = emptyNodeAt(oldVnode);
            }
    
            // 真实dom
            const oldElm = oldVnode.elm;
            const parentElm = nodeOps.parentNode(oldElm);
    
            // 创建一整棵树
            createElm(
              vnode,
              insertedVnodeQueue,
              // extremely rare edge case: do not insert if old element is in a
              // leaving transition. Only happens when combining transition +
              // keep-alive + HOCs. (#4590)
              oldElm._leaveCb ? null : parentElm,
              nodeOps.nextSibling(oldElm)
            );
    
            // 此时界面上新旧dom都存在
    
            // 删除旧节点
            if (isDef(parentElm)) {
              removeVnodes([oldVnode], 0, 0);
            } else if (isDef(oldVnode.tag)) {
              invokeDestroyHook(oldVnode);
            }
          }
        }
    
        invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
        return vnode.elm;
      };
    }
    

patchVnode:diff 发生的地方 /src/core/vdom/patch.js

  • 作用

    • diff新旧节点,渲染最终的真实 dom到页面
  • 核心源码

    function patchVnode(oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
      if (oldVnode === vnode) {
        return;
      }
      // 获取两节点孩子
      const oldCh = oldVnode.children;
      const ch = vnode.children;
    
      // 属性更新,没有diff,全部更新   ps:vue3静态标记都优化主要在这部分
      if (isDef(data) && isPatchable(vnode)) {
        for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
        if (isDef((i = data.hook)) && isDef((i = i.update))) i(oldVnode, vnode);
      }
    
      // text children
      if (isUndef(vnode.text)) {
        // 新旧节点都有孩子
        if (isDef(oldCh) && isDef(ch)) {
          if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly);
        } else if (isDef(ch)) {
          // 新节点有孩子,创建
          if (process.env.NODE_ENV !== 'production') {
            checkDuplicateKeys(ch);
          }
          if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '');
          addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
        } else if (isDef(oldCh)) {
          // 删除
          removeVnodes(oldCh, 0, oldCh.length - 1);
        } else if (isDef(oldVnode.text)) {
          // 清空文本
          nodeOps.setTextContent(elm, '');
        }
      } else if (oldVnode.text !== vnode.text) {
        // 文本更新
        nodeOps.setTextContent(elm, vnode.text);
      }
    }
    
    • 比较策略:同层比较,深度优先(类似先序遍历)
    • 更新顺序:
      • 处理属性:全量 diff
      • 处理 文本children(二选一)
        1. 新旧节点 都有 children, updateChildren
        2. 旧节点 没有 children,则先清空 旧节点 的文本,然后新增 children
        3. 新节点 没有 children移除所有 children
        4. 新旧节点 都没有 children,则文本替换

      最复杂的情况在updateChildren

updateChildren /src/core/vdom/patch.js

  • 作用

    • diff 子节点
  • 核心代码:

    function sameVnode(a, b) {
      return (
        a.key === b.key &&
        ((a.tag === b.tag &&
          a.isComment === b.isComment &&
          isDef(a.data) === isDef(b.data) &&
          sameInputType(a, b)) ||
          (isTrue(a.isAsyncPlaceholder) &&
            a.asyncFactory === b.asyncFactory &&
            isUndef(b.asyncFactory.error)))
      );
    }
    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
      // 4个游标
      let oldStartIdx = 0;
      let newStartIdx = 0;
      let oldEndIdx = oldCh.length - 1;
      let oldStartVnode = oldCh[0];
      let oldEndVnode = oldCh[oldEndIdx];
      let newEndIdx = newCh.length - 1;
      let newStartVnode = newCh[0];
      let newEndVnode = newCh[newEndIdx];
      // 搜索相同节点时使用
      let oldKeyToIdx, idxInOld, vnodeToMove, refElm;
    
      // removeOnly is a special flag used only by <transition-group>
      // to ensure removed elements stay in correct relative positions
      // during leaving transitions
      const canMove = !removeOnly;
    
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(newCh);
      }
    
      // 循环条件时游标不重叠
      while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 调整游标:游标移动可能造成对应节点为空
        if (isUndef(oldStartVnode)) {
          oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
        } else if (isUndef(oldEndVnode)) {
          oldEndVnode = oldCh[--oldEndIdx];
          // 4个else if 是首尾查找
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
          // 头头
          patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
          oldStartVnode = oldCh[++oldStartIdx];
          newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
          // 尾尾
          patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
          oldEndVnode = oldCh[--oldEndIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) {
          // 头尾
          // Vnode moved right
          patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx);
          canMove &&
            nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
          oldStartVnode = oldCh[++oldStartIdx];
          newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) {
          // 尾头
          // Vnode moved left
          patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx);
          canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
          oldEndVnode = oldCh[--oldEndIdx];
          newStartVnode = newCh[++newStartIdx];
        } else {
          // 乱序diff
          // 核心就是从新数组取第一个,然后从旧数组查找,代码太多了先不贴了
        }
      }
      // 循环结束,开始扫尾工作   批量创建或删除
      if (oldStartIdx > oldEndIdx) {
        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
      } else if (newStartIdx > newEndIdx) {
        removeVnodes(oldCh, oldStartIdx, oldEndIdx);
      }
    }
    

    步骤如下:

    1. 优先按照 web 场景 中常见的列表变化进行尝试,
      常见的场景有

      1. 列表头部添加/删除 dom,列表尾部添加/删除 dom,
      2. 列表排序,升序降序的改变,即首尾互换
    2. 若是乱序情况,则按如下处理:

      1. 在新旧节点的头与尾共创建 4 个游标,
      2. 不移动dom的操作:新旧节点 头头对比或尾尾对比
        若满足sameVnode,则 patchVnode,然后游标运动
      3. 可能移动dom的操作:头尾对比,尾头对比
      4. 乱序:两个数组对比,找到节点更新,然后移动到新位置
      5. 扫尾工作:随着循环,四个游标两两靠近,如果最后没有重叠,说明新旧节点数量有变化,所以需要批量添加或批量删除

        有点难理解对吗,这里举个例子吧(默认每个元素都有key),或许会好些

      const oldChildren = [a, b, c, d];
      const newChildren = [a, e, f, b, c, d];
      
      // 1.新旧节点队首元素相同,但没有变化,于是头游标向后运动,比较新旧节点的第二个元素
      const oldChildren = [b, c, d];
      const newChildren = [e, f, b, c, d];
      
      // 2.新旧节点的队首元素不同,队尾元素相同,但没有变化,于是尾游标向前运动
      const oldChildren = [b, c];
      const newChildren = [e, f, b, c];
      
      // 3.新旧节点的队首元素不同,队尾元素相同,但没有变化,于是尾游标继续向前运动
      const oldChildren = [b];
      const newChildren = [e, f, b];
      
      // 4.旧节点没有元素了,进行扫尾工作,新节点但头游标和尾游标之间存在元素,批量增加[e, f]
      const oldChildren = [];
      const newChildren = [e, f];
      

流程梳理

  1. 首先在patch中宏观把控,首次渲染时,调用createElm创建一整棵树,更新时调用patchVnode
  2. patchVnode的策略是:同层比较,深度优先,先更新属性,然后处理textContentchildren
  3. 处理children会优先比较新旧节点的头头、尾尾、头尾、尾头,最后处理乱序情况,等一切都处理结束,再进行收尾工作,即批量增批量删

思考与总结

1. 为什么 patch 不是直接使用,而是通过一个工厂函数createPatchFunction返回?

  为平台注入特有的 节点操作属性操作 方法

// 接收平台特殊操作,返回平台patch
export function createPatchFunction(backend) {
  let i, j;
  const cbs = {};

  const { modules, nodeOps } = backend;

  return function patch(...) {
    // ...
  };
}

2. 为什么不推荐用 indexkey

两个原因:

  1. sameVnode在判断时,即使dom没有变化也会判定为发生变化,因此所有 dom 都要重新更新一次
  2. 列表重排序的场景可能会出现问题
    比如:
    当列表中出现删除场景时,因为 sameType 的策略是首先比较 key,被删除节点后面的 dom,由于 key(index) 也发生了改变,就会被判定为 dom 发生了改变,首先造成的影响就是 diff 流程变复杂,如果列表并没有太复杂,造成不了太多性能的损耗,但是继续思考,如果那些没有改变的 dom,很不幸操作了一些非响应式引起的变化,比如改变 style,或通过 css 弹出了 Popover,那么当前更新时就会覆盖这些非响应式变化,让用户体验不好,或者误以为产生了 bug

3. 虚拟 dom 适合一切场景吗?

  虚拟 dom 不适合频繁 diff 的场景,比如游戏,游戏画面需要频繁渲染,此时如果使用虚拟 domcpu 会持续占用内存造成卡顿,因此不适合所有场景

4. 我在 debug 时,VNodechildren中有个VNodetag为什么是undefined

  编写 template 时的空格换行符编译器中会被解析成tagundefinedVNode

组件化原理

  组件有两种声明方式

  • 局部:在组件的components中声明即可局部使用
  • 全局:通过Vue.component注册

预先准备

方法 Vue.component 的来源

  初始化 定义全局 API 时调用了initAssetRegisters,该方法用于注册三个全局方法Vue.componentVue.filterVue.directive

其中Vue.component的核心代码:

Vue[type] = function (id, definition) {
  if (type === 'component' && isPlainObject(definition)) {
    definition.name = definition.name || id;
    // 调用 Vue.extend 转换成 VueComponent,将来使用时 new VueComponent 即可
    definition = this.options._base.extend(definition);
  }
};

  Vue.extend返回的是 VueComponent,使用时通过 new VueComponent 即可获取组件实例

  Vue.component最终将自定义组件添加到Vue.options中,Vue.options是全局的components,默认只有 KeepAlive、Transition、TransitionGroup 三个组件

render函数对于 自定义组件 和 浏览器标签 有何区别?

  template 到 编译后的render 变化如下

<template>
  <div id="demo">
    <h1>Vue组件化机制</h1>
    <comp></comp>
  </div>
</template>
(function anonymous() {
  with (this) {
    return _c(
      'div',
      { attrs: { id: 'demo' } },
      [_c('h1', [_v('Vue组件化机制')]), _v(' '), _c('comp')],
      1
    );
  }
});

  可以看到对于自定义组件host 组件都采用了同样的处理方法:即createElement(tag) 的方式,由此可见,答案在createElement中(这里的_c就是createElement柯里化处理

  _c _v 为何物?

  在初始化instance/renderhelpers中为实例提供了方法别名   renderList:v-for
  _v 创建文本
  _s 格式化

  然后在initRender中给实例声明一些方法:createElement_c vm._c = (...) => createElement(...)

从源码探究流程

  上个模块VNode中已经整理,在获取虚拟 dom时最终会调用createElementcreateElement处理两种情况

  1. 如果是 浏览器标签,则new VNode(...)
  2. 如果时 Vue 组件,则createComponent(...)

  所以现在我们去看createComponent即可

createComponent /src/core/vdom/create-component.js

  • 作用:返回组件的 虚拟 dom

  • 核心源码

    export function createComponent(
      Ctor: Class<Component> | Function | Object | void,
      data: ?VNodeData,
      context: Component,
      children: ?Array<VNode>,
      tag?: string
    ) {
      let asyncFactory;
      if (isUndef(Ctor.cid)) {
        asyncFactory = Ctor;
        Ctor = resolveAsyncComponent(asyncFactory, baseCtor);
        if (Ctor === undefined) {
          // 异步组件的占位符
          return createAsyncPlaceholder(asyncFactory, data, context, children, tag);
        }
      }
    
      // 组件身上有双向绑定,要额外声明 事件类型 和 属性名称
      if (isDef(data.model)) {
        transformModel(Ctor.options, data);
      }
    
      // 分离原生事件和自定义事件
      const listeners = data.on;
      // replace with listeners with .native modifier
      // so it gets processed during parent component patch.
      data.on = data.nativeOn;
    
      // 安装自定义组件的钩子
      installComponentHooks(data);
    
      // 返回 虚拟dom
      const name = Ctor.options.name || tag;
      const vnode = new VNode(
        `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
        data,
        undefined,
        undefined,
        undefined,
        context,
        { Ctor, propsData, listeners, tag, children },
        asyncFactory
      );
    
      return vnode;
    }
    

      还记得_render函数吗,用于获得虚拟 dom,初始化和更新都会调用这个方法,
      _update做了两件重要的事情

    1. 保存一份虚拟 dom 存到_vnode中,下次直接取出来使用
    2. 调用__patch__,初始化执行createElm,更新执行patchVnode

      因为初始化阶段已经得到虚拟 dom了,patchVnode只做diff,因此组件虚拟 dom真实 dom的关键在createElm

createElm /src/core/vdom/patch.js

  • 作用

      1. 如果是浏览器标签,则创建真实 dom 树
      1. 如果是自定义组件,则调用createComponent
      1. 最终都是:虚拟 dom 转真实 dom
  • 核心源码

    function createElm(vnode, insertedVnodeQueue, parentElm, refElm, nested, ownerArray, index) {
      // 自定义组件
      if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
        return;
      }
    
      // 浏览器标签
      const data = vnode.data;
      const children = vnode.children;
      const tag = vnode.tag;
      if (isDef(tag)) {
        vnode.elm = vnode.ns
          ? nodeOps.createElementNS(vnode.ns, tag)
          : nodeOps.createElement(tag, vnode);
    
        /* istanbul ignore if */
        if (__WEEX__) {
        } else {
          createChildren(vnode, children, insertedVnodeQueue);
          if (isDef(data)) {
            invokeCreateHooks(vnode, insertedVnodeQueue);
          }
          insert(parentElm, vnode.elm, refElm);
        }
      } else if (isTrue(vnode.isComment)) {
        vnode.elm = nodeOps.createComment(vnode.text);
        insert(parentElm, vnode.elm, refElm);
      } else {
        vnode.elm = nodeOps.createTextNode(vnode.text);
        insert(parentElm, vnode.elm, refElm);
      }
    }
    

      到这里发现又回到了createComponent,一想到之前也有遇到类似的情景:$mount函数也有过函数覆盖的情况,于是看了一下文件路径,发现接下来要找的createComponentsrc/core/vdom/patch.js

    • createComponent() - src/core/vdom/create-component.js 组件 vnode 创建
    • createComponent() - src/core/vdom/patch.js 创建组件实例并挂载,vnode 转换为 dom

createComponent src/core/vdom/patch.js

  • 作用:将组件的虚拟 dom转换成真实 dom

  • 核心源码

    function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
      let i = vnode.data;
      if (isDef(i)) {
        const isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
        // 前面安装的钩子在hook中,只有自定义组件有init函数,执行init函数后调用组件的$mount
        if (isDef((i = i.hook)) && isDef((i = i.init))) {
          i(vnode, false /* hydrating */);
        }
        // after calling the init hook, if the vnode is a child component
        // it should've created a child instance and mounted it. the child
        // component also has set the placeholder vnode's elm.
        // in that case we can just return the element and be done.
        if (isDef(vnode.componentInstance)) {
          initComponent(vnode, insertedVnodeQueue);
          insert(parentElm, vnode.elm, refElm);
          if (isTrue(isReactivated)) {
            reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
          }
          return true;
        }
      }
    }
    

      这里执行了钩子函数,于是我们去前面寻找安装的钩子的地方

组件的钩子函数 installComponentHooks /src/core/vdom/create-component.js

  • 作用:安装钩子,注意是安装,不是执行
  • 核心源码
    function installComponentHooks(data: VNodeData) {
      const hooks = data.hook || (data.hook = {});
      // 合并用户和默认的管理钩子
      for (let i = 0; i < hooksToMerge.length; i++) {
        const key = hooksToMerge[i];
        const existing = hooks[key];
        const toMerge = componentVNodeHooks[key];
        if (existing !== toMerge && !(existing && existing._merged)) {
          hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge;
        }
      }
    }
    

  hooksToMerge包含 4 个钩子:initprepatchinsertdestory

  (ps:keepAlive 组件的实现原理关键在init部分:不需要重新创建组件,放在缓存中)

const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode, activeInstance));
child.$mount(hydrating ? vnode.elm : undefined, hydrating);

  init是组件的初始化,可以看到执行了$mount方法,因此自定义组件host 组件在渲染阶段的区别主要是,根组件执行$mount => patch => createElm 向下递归,如果遇到host 组件,直接 createElement(web 平台的情况), 若遇到自定义组件,则调用createComponent,最终会执行组件钩子 函数:init 方法

整体流程梳理

  1. 定义:Vue.component => 通过Vue.extend获取VueComponent => 添加到Vue.options.components中
  2. 初始化:vm._update(vm.render()) => createElement => createComponent => __patch__ => createElm => createComponent => 执行组件的钩子函数 init
  3. 更新:递归到组件时执行组件的钩子函数

思考与总结

  1. 组件化的本质是什么?
      对组件化的本质,我的理解是产生虚拟 dom。因为不管是浏览器标签还是自定义组件,最终都会走向render

  2. 我看到createComponent事件的监听做了单独做了处理,父子组件通信时绑定的事件如何处理的?
      父子组件通过事件通信时,事件的绑定和触发都发生在子组件身上。

  3. 我看到用于 createComponent中,处理钩子函数时专门对KeepAlive做了处理,其实现原理是什么?
      执行 init 如果发现是KeepAlive组件,则尝试从缓存中取,并且由于钩子函数的存在,可以做很好的动效处理。

  4. 全局组件局部组件在实现原理上有何区别?
      初始化Vue 组件时会调用mergeOptions,将Vue.options.components中的 全局组件 合并到 Vue 组件components属性中,以此达到全局使用的目的。

  5. 存在父子关系时,生命周期执行顺序?
      在整理patch得到的结论:create/destory 自上而下(深度优先),mount(从下向上)
      父组件 beforeCreated ->父组件 created ->父组件 beforeMounted ->子组件 beforeCreated ->子组件 created ->子组件 beforeMounted ->子组件 mounted -> 父组件 mounted。

  6. 为什么说尽量少地声明全局组件?
      由Vue 组件化的原理可以看到,通过Vue.component声明的全局组件会先执行Vue.extends创建出VueComponent,然后存放在Vue.options.components中,并且初始化创建Vue 组件时再通过mergeOptions注入到Vue 组件components选项中,因此,如果全局组件过多会占用太多资源和事件,导致首屏加载不流畅或白屏时间过长的问题。

  7. 组件拆分粒度的问题
      在Vue2中,render Watcher的更新粒度是整个组件,所以当组件拆分不合理可能会导致一个组件有大量的虚拟 dom,这时候在 diff 时会变慢,
      其实反观Vue1render Watcher的更新粒度是一个节点,可以精准更新,因此不需要虚拟 dom,这是最理想化的更新。但是由于太多Watcher占用了内存而无法开发大型项目,到了Vue2被摒弃了。快速 diff内存占用总要有所取舍,所以还得具体场景具体分析。

带给我的收获与思考

  1. 大量设计模式的使用

    • 发布订阅模式:数据响应式
    • 工厂模式:createPatchFunction
  2. 闭包

    1. 解析组件模板: 使用了闭包作为缓存,为了重复解析
    2. cached:使用闭包缓存函数
    3. createPatchFunction: 把很多更新用的函数作为闭包
    4. defineReactive:闭包作用域内的变量val
  3. 方法覆盖(扩展)

    数组响应式、$mount方法跨平台

  4. 精巧的工具方法

    诸如类型校验代理密闭对象冻结对象检查是否是原始值extend只执行一次的函数等等,内容太多,看来要单独整理一篇文章了。

  5. 微任务的妙用

    异步更新策略借助的就是浏览器的事件循环同步任务执行完毕后会刷新微任务队列。 让我想到工作中有这么一个场景,当 websocket 推送数据后,页面关联的图表会重新render,每个图表的render都相对耗时,同步执行会导致每次循环都等图表渲染结束才进行下一次循环,造成页面暂时的卡顿。于是我们将render放到微任务中处理,等循环的同步任务结束后会自动执行微任务队列,实现了页面优化。

  6. reactdiff的不同

    Vue2diffreact Fiber之前的diff还是很像的,区别是Vue2diff过程带有一点点智能,表现为会优先处理 web 场景常见的情况,即向列表头部添加元素、向列表尾部添加元素,列表的倒叙排列、升序排列

  • PS:在读源码时发现一个initProxy方法,里面使用了es6proxy,也就是现在Vue3着重优化数据响应式的方案,但该方法只在开发环境下使用了一次,莫非当时就有了proxy代替Object.defineProperty的想法啦?