深入剖析Vue源码 - 响应式系统构建(上)

栏目: 编程语言 · 发布时间: 6年前

内容简介:从这一小节开始,正式进入回顾一下之前的内容,在这个系列的开始部分,先介绍了配置选项的合并,数据的代理,生命周期的初始化,接下来是实例的挂载。而在实例挂载前还有意忽略了一个过程,简单回顾一下

从这一小节开始,正式进入 Vue 源码的核心,也是难点之一,响应式系统的构建。这一节将作为分析响应式构建过程源码的入门,主要分为两大块,第一块是针对响应式数据 props,methods,data,computed,wather 初始化过程的分析,另一块则是在保留源码设计理念的前提下,尝试手动构建一个基础的响应式系统。有了这两个基础内容的铺垫,下一篇进行源码具体细节的分析会更加得心应手。

回顾一下之前的内容,在这个系列的开始部分,先介绍了配置选项的合并,数据的代理,生命周期的初始化,接下来是实例的挂载。而在实例挂载前还有意忽略了一个过程, 数据的初始化 (即 initState(vm) )。 initState 的过程,是对数据进行响应式设计的过程,过程会针对 props,methods,data,computedwatch 做数据的初始化处理,并将他们转换为响应式对象,接下来我们会逐步分析每一个过程。

function initState (vm) {
  vm._watchers = [];
  var opts = vm.$options;
  // 初始化props
  if (opts.props) { initProps(vm, opts.props); }
  // 初始化methods
  if (opts.methods) { initMethods(vm, opts.methods); }
  // 初始化data
  if (opts.data) {
    initData(vm);
  } else {
    observe(vm._data = {}, true /* asRootData */);
  }
  // 初始化computed
  if (opts.computed) { initComputed(vm, opts.computed); }
  // 初始化watch
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch);
  }
}
复制代码

7.1 initProps

简单回顾一下 props 的用法,父组件通过属性的形式将数据传递给子组件,子组件通过 props 属性接收父组件传递的值。

// 父组件
<child :test="test"></child>
// 子组件
{
  props: ['test']
}
复制代码

因此分析 props 需要分析父组件和子组件的两个过程,我们先看父组件对传递值的处理。父组件会优先进行实例的挂载, render 解析过程中,遇到子组件的占位符节点 <child :test="test"></child> 时,会创建子类构造器,遇到传递给子组件的属性时,会解析成 { attrs: {test: test}} 的形式并作为 render 函数存在。再对 attrs 属性进行规范校验后,会将校验的结果以 propsData 属性的形式来创建子的 Vnode 。总结来说, props 传递给占位符组件的写法,会以 propsData 的形式作为子组件 Vnode 的属性存在。

// 创建子组件过程
function createComponent() {
  // props校验
  var propsData = extractPropsFromVNodeData(data, Ctor, tag);
  ···
  // 创建子组件vnode
  var vnode = new VNode(
    ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
    data, undefined, undefined, undefined, context,
    { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
    asyncFactory
  );
}
复制代码

7.1.1 props的命名规范

回过头看检测 props 规范性的过程。其中 attrs 前面分析过,是编译生成 render 函数针对属性的处理,而 props 是针对用户自写 render 函数的属性值。接下来会对命名规则做判断。

function extractPropsFromVNodeData (data,Ctor,tag) {
  // Ctor为子类构造器
  ···
  var res = {};
  // 子组件props选项
  var propOptions = Ctor.options.props;
  // data.attrs针对编译生成的render函数,data.props针对用户自定义的render函数
  var attrs = data.attrs;
  var props = data.props;
  if (isDef(attrs) || isDef(props)) {
    for (var key in propOptions) {
      // aB 形式转成 a-b
      var altKey = hyphenate(key);
      {
          var keyInLowerCase = key.toLowerCase();
          if (
            key !== keyInLowerCase &&
            attrs && hasOwn(attrs, keyInLowerCase)
          ) {
            // 警告
          }
        }
    }
  }
}
复制代码

有必要说一下这一部分的处理, HTML对大小写是不敏感的,所有的浏览器会把大写字符解释为小写字符,因此我们在使用DOM中的模板是,cameCase(驼峰命名法)的props名需要使用其等价的 kebab-case (短横线分隔命名) 命代替 即: <child :aB="test"></child> 需要写成 <child :a-b="test"></child>

7.1.2 响应式数据props

刚才说到分析 props 需要两个过程,这里我们再看看子组件对 props 的处理,在创建子组件 createComponent 的过程中,和初始化根组件一样,会经历子组件的选项合并和初始化过程,在 深入剖析Vue源码 - 选项合并(上) 一节,我们知道,子组件 props 选项最终会统一成 {props: { test: { type: null }}} 的写法。选项合并和写法规范是初始化 props 的前提,接下来会执行 initProps , initProps 做的事情,简单概括一句话就是,将组件的 props 数据设置为响应式数据。

function initProps (vm, propsOptions) {
  var propsData = vm.$options.propsData || {};
  var loop = function(key) {
    ···
    defineReactive(props,key,value,cb);
    if (!(key in vm)) {
      proxy(vm, "_props", key);
    }
  }
  // 遍历props,执行loop设置为响应式数据。
  for (var key in propsOptions) loop( key );
}
复制代码

其中 proxy(vm, "_props", key);props 做了一层代理,用户通过 vm.XXX 可以代理访问到 vm._props 上的值。 针对 defineReactive ,本质上是利用 Object.defineProperty 对数据的 getter,setter 方法进行重写,具体的原理可以参考 深入剖析Vue源码 - 数据代理,关联子父组件 这一节的内容,在这小节后半段也会有一个基本的实现。

7.2 initMethods

initMethod 方法和这一节介绍的响应式没有任何的关系,他的实现也相对简单,主要是保证 methods 方法定义必须是函数,且命名不能和 props 重复,最终会将定义的方法都挂载到根实例上。

function initMethods (vm, methods) {
    var props = vm.$options.props;
    for (var key in methods) {
      {
        // method必须为函数形式
        if (typeof methods[key] !== 'function') {
          warn(
            "Method \"" + key + "\" has type \"" + (typeof methods[key]) + "\" in the component definition. " +
            "Did you reference the function correctly?",
            vm
          );
        }
        // methods方法名不能和props重复
        if (props && hasOwn(props, key)) {
          warn(
            ("Method \"" + key + "\" has already been defined as a prop."),
            vm
          );
        }
        //  不能以_ or $.这些Vue保留标志开头
        if ((key in vm) && isReserved(key)) {
          warn(
            "Method \"" + key + "\" conflicts with an existing Vue instance method. " +
            "Avoid defining component methods that start with _ or $."
          );
        }
      }
      // 直接挂载到实例的属性上,可以通过vm[method]访问。
      vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
    }
  }
复制代码

7.3 initData

data 在初始化选项合并时会生成一个函数,只有在执行函数时才会返回真正的数据,所以 initData 方法会先执行拿到组件的 data 数组,并且会对对象每个属性的命名进行校验,保证不能和 props,methods 重复。最后的核心方法是 observe , observe 方法是将数据对象标记为响应式对象,并对对象的每个属性进行响应式处理。与此同时,和props的代理处理方式一样, proxy 会对 data 做一层代理,直接通过 vm.XXX 可以代理访问到 vm._data 上挂载的对象属性。

function initData(vm) {
  var data = vm.$options.data;
  // 根实例时,data是一个对象,子组件的data是一个函数,其中getData会调用函数返回data对象
  data = vm._data = typeof data === 'function'? getData(data, vm): data || {};
  var keys = Object.keys(data);
  var props = vm.$options.props;
  var methods = vm.$options.methods;
  var i = keys.length;
  while (i--) {
    var key = keys[i];
    {
      // 命名不能和方法重复
      if (methods && hasOwn(methods, key)) {
        warn(("Method \"" + key + "\" has already been defined as a data property."),vm);
      }
    }
    // 命名不能和props重复
    if (props && hasOwn(props, key)) {
      warn("The data property \"" + key + "\" is already declared as a prop. " + "Use prop default value instead.",vm);
    } else if (!isReserved(key)) {
      // 数据代理,用户可直接通过vm实例返回data数据
      proxy(vm, "_data", key);
    }
  }
  // observe data
  observe(data, true /* asRootData */);
}
复制代码

最后讲讲 observe , observe 具体的行为是将数据对象添加一个不可枚举的属性 __ob__ ,标志对象是一个响应式对象,并且拿到每个对象的属性值,重写 getter,setter 方法,使得每个属性值都是响应式数据。详细的代码我们后面分析。

7.4 initComputed

和上面的分析方法一样, initComputedcomputed 数据的初始化,不同之处在于以下几点:

    1. computed 可以是对象,也可以是函数,但是对象必须有 getter 方法,因此如果 computed 中的属性值是函数时都要进行验证。
    1. 针对 computed 的每个属性,要创建一个监听的依赖,也就是实例化一个 watcher , watcher 的定义,可以暂时理解为数据使用的依赖本身,一个 watcher 实例代表多了一个需要被监听的数据依赖。

除了不同点, initComputed 也会将每个属性设置成响应式的数据,同样的,也会对 computed 的命名做检测,防止与 props,data 冲突。

function initComputed (vm, computed) {
  ···
  for (var key in computed) {
      var userDef = computed[key];
      var getter = typeof userDef === 'function' ? userDef : userDef.get;
      // computed属性为对象时,要保证有getter方法
      if (getter == null) {
        warn(("Getter is missing for computed property \"" + key + "\"."),vm);
      }
      if (!isSSR) {
        // 创建computed watcher
        watchers[key] = new Watcher(vm,getter || noop,noop,computedWatcherOptions);
      }
      if (!(key in vm)) {
        // 设置为响应式数据
        defineComputed(vm, key, userDef);
      } else {
        // 不能和props,data命名冲突
        if (key in vm.$data) {
          warn(("The computed property \"" + key + "\" is already defined in data."), vm);
        } else if (vm.$options.props && key in vm.$options.props) {
          warn(("The computed property \"" + key + "\" is already defined as a prop."), vm);
        }
      }
    }
}
复制代码

显然 Vue 提供了很多种数据供开发者使用,但是分析完后发现每个处理的核心都是将数据转化成响应式数据,有了响应式数据,如何构建一个响应式系统呢?前面提到的 watcher 是什么东西?构建响应式系统还需要其他的东西吗?我们继续往下读。

7.5 极简风的响应式系统

Vue的响应式系统构建是比较复杂的,直接进入源码分析构建的每一个流程会让理解变得困难,因此我觉得在尽可能保留源码的设计逻辑下有必要用最小的代码构建一个最基础的响应式系统。 Dep,Watcher,Observer 概念的初步认识,也有助于下一篇对响应式系统设计细节的分析。

7.5.1 框架搭建

我们以 MyVue 作为类响应式框架,框架的搭建不做赘述,我们模拟 Vue 源码的实现思路,实例化 MyVue 时会传递一个选项配置,精简的代码只有一个 id 挂载元素和一个数据对象 data 。模拟源码的思路,我们在实例化时会先进行数据的初始化,这一步就是响应式的构建,我们稍后分析。数据初始化后开始进行真实 DOM 的挂载。

var vm = new MyVue({
  id: '#app',
  data: {
    test: 12
  }
})
// myVue.js
(function(global) {
  class MyVue {
      constructor(options) {
        this.options = options;
        // 数据的初始化
        this.initData(options);
        let el = this.options.id;
        // 实例的挂载
        this.$mount(el);
      }
      initData(options) {
      }
      $mount(el) {
      }
    }
}(window))
复制代码

7.5.2 设置响应式对象 - Observer

首先引入一个类 Observer ,这个类的目的是将数据变成响应式对象,利用 Object.defineProperty 对数据的 getter,setter 方法进行改写。在数据读取 getter 阶段我们会进行 依赖的收集 ,在数据的修改 setter 阶段,我们会进行 依赖的更新 (这两个概念的介绍放在后面)。因此在数据初始化阶段,我们会利用 Observer 这个类将数据对象修改为相应式对象,而这是所有流程的基础。

class MyVue {
  initData(options) {
    if(!options.data) return;
    this.data = options.data;
    // 将数据重置getter,setter方法
    new Observer(options.data);
  }
}
// Observer类的定义
class Observer {
  constructor(data) {
    // 实例化时执行walk方法对每个数据属性重写getter,setter方法
    this.walk(data)
  }

  walk(obj) {
    const keys = Object.keys(obj);
    for(let i = 0;i< keys.length; i++) {
      // Object.defineProperty的处理逻辑
      defineReactive(obj, keys[i])
    }
  }
}
复制代码

7.5.3 依赖本身 - Watcher

我们可以这样理解,一个Watcher实例就是一个依赖,数据不管是在渲染模板时使用还是在用户计算时使用,都可以算做一个需要监听的依赖,watcher中记录着这个依赖监听的状态,以及如何更新操作的方法。

// 监听的依赖
class Watcher {
  constructor(expOrFn, isRenderWatcher) {
    this.getter = expOrFn;
    // Watcher.prototype.get的调用会进行状态的更新。
    this.get();
  }

  get() {}
}
复制代码

哪个时间点会实例化 watcher 并更新数据状态呢?显然在渲染数据到真实 DOM 时可以创建 watcher$mount 流程前面章节介绍过,会经历模板生成 render 函数和 render 函数渲染真实 DOM 的过程。我们对代码做了精简, updateView 浓缩了这一过程。

class MyVue {
  $mount(el) {
    // 直接改写innerHTML
    const updateView = _ => {
      let innerHtml = document.querySelector(el).innerHTML;
      let key = innerHtml.match(/{{(\w+)}}/)[1];
      document.querySelector(el).innerHTML = this.options.data[key]
    }
    // 创建一个渲染的依赖。
    new Watcher(updateView, true)
  }
}
复制代码

7.5.4 依赖管理 - Dep

watcher 如果理解为每个数据需要监听的依赖,那么 Dep 可以理解为对依赖的一种管理。数据可以在渲染中使用,也可以在计算属性中使用。相应的每个数据对应的 watcher 也有很多。而我们在更新数据时,如何通知到数据相关的每一个依赖,这就需要 Dep 进行通知管理了。并且浏览器同一时间只能更新一个 watcher ,所以也需要一个属性去记录当前更新的 watcher 。而 Dep 这个类只需要做两件事情,将依赖进行收集,派发依赖进行更新。

let uid = 0;
class Dep {
  constructor() {
    this.id = uid++;
    this.subs = []
  }
  // 依赖收集
  depend() {
    if(Dep.target) {
      // Dep.target是当前的watcher,将当前的依赖推到subs中
      this.subs.push(Dep.target)
    }
  }
  // 派发更新
  notify() {
    const subs = this.subs.slice();
    for (var i = 0, l = subs.length; i < l; i++) { 
      // 遍历dep中的依赖,对每个依赖执行更新操作
      subs[i].update();
    }
  }
}

Dep.target = null;
复制代码

7.5.5 依赖管理过程 - defineReactive

前面的 Observer 实例化最终会调用 defineReactive 重写 getter,setter 方法。这个方法开始会实例化一个 Dep ,也就是创建一个数据的依赖管理。在重写的 getter 方法中会进行依赖的收集,也就是调用 dep.depend 的方法。在 setter 阶段,比较两个数不同后,会调用依赖的派发更新。即 dep.notify

const defineReactive = (obj, key) => {
  const dep = new Dep();
  const property = Object.getOwnPropertyDescriptor(obj);
  let val = obj[key]
  if(property && property.configurable === false) return;
  Object.defineProperty(obj, key, {
    configurable: true,
    enumerable: true,
    get() {
      // 做依赖的收集
      if(Dep.target) {
        dep.depend()
      }
      return val
    },
    set(nval) {
      if(nval === val) return
      // 派发更新
      val = nval
      dep.notify();
    }
  })
}
复制代码

回过头来看 watcher ,实例化 watcher 时会将 Dep.target 设置为当前的 watcher ,执行完状态更新函数之后,再将 Dep.target 置空。这样在收集依赖时只要将 Dep.target 当前的 watcher pushDepsubs 数组即可。而在派发更新阶段也只需要重新更新状态即可。

class Watcher {
  constructor(expOrFn, isRenderWatcher) {
    this.getter = expOrFn;
    // Watcher.prototype.get的调用会进行状态的更新。
    this.get();
  }

  get() {
    // 当前执行的watcher
    Dep.target = this
    this.getter()
    Dep.target = null;
  }
  update() {
    this.get()
  }
}
复制代码

以上所述就是小编给大家介绍的《深入剖析Vue源码 - 响应式系统构建(上)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Spark SQL内核剖析

Spark SQL内核剖析

朱锋、张韶全、黄明 / 电子工业出版社 / 2018-8 / 69.00元

Spark SQL 是 Spark 技术体系中较有影响力的应用(Killer application),也是 SQL-on-Hadoop 解决方案 中举足轻重的产品。《Spark SQL内核剖析》由 11 章构成,从源码层面深入介绍 Spark SQL 内部实现机制,以及在实际业务场 景中的开发实践,其中包括 SQL 编译实现、逻辑计划的生成与优化、物理计划的生成与优化、Aggregation 算......一起来看看 《Spark SQL内核剖析》 这本书的介绍吧!

JSON 在线解析
JSON 在线解析

在线 JSON 格式化工具

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具

HSV CMYK 转换工具
HSV CMYK 转换工具

HSV CMYK互换工具