关于前端:vue源码分析响应式系统一

13次阅读

共计 9695 个字符,预计需要花费 25 分钟才能阅读完成。

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

7.1 数据初始化

回顾一下之前的内容,咱们对 Vue 源码的剖析是从初始化开始,初始化 _init 会执行一系列的过程,这个过程包含了配置选项的合并,数据的监测代理,最初才是实例的挂载。而在实例挂载前还有意疏忽了一个重要的过程,数据的初始化 (即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 {
    // 如果没有定义 data,则创立一个空对象,并设置为响应式
    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.2 initProps

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

// 父组件
<child :test="test"></child>
var vm = new Vue({
  el: '#app',
  data() {
    return {test: 'child'}
  }
})
// 子组件
Vue.component('child', {template: '<div>{{test}}</div>',
  props: ['test']
})

因而剖析 props 须要剖析父组件和子组件的两个过程,咱们先看父组件对传递值的解决。依照以往文章介绍的那样,父组件优先进行模板编译失去一个 render 函数,在解析过程中遇到子组件的属性,:test=test会被解析成 {attrs: {test:test}} 并作为子组件的 render 函数存在,如下所示:

with(){..._c('child',{attrs:{"test":test}})}

render解析 Vnode 的过程遇到 child 这个子占位符节点,因而会进入创立子组件 Vnode 的过程,创立子 Vnode 过程是调用 createComponent, 这个阶段咱们在组件章节有剖析过,在组件的高级用法也有剖析过,最终会调用new Vnode 去创立子 Vnode。而对于props 的解决,extractPropsFromVNodeData会对 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.2.1 props 的命名标准

先看检测 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.2.2 响应式数据 props

方才说到剖析 props 须要两个过程,后面曾经针对父组件对 props 的解决做了形容,而对于子组件而言,咱们是通过 props 选项去接管父组件传递的值。咱们再看看子组件对 props 的解决:

子组件解决 props 的过程,是产生在父组件 _update 阶段,这个阶段是 Vnode 生成实在节点的过程,期间会遇到子 Vnode, 这时会调用createComponent 去实例化子组件。而实例化子组件的过程又回到了 _init 初始化,此时又会经验选项的合并,针对 props 选项,最终会对立成 {props: { test: { type: null}}} 的写法。接着会调用 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 办法进行重写,具体的原理能够参考数据代理章节的内容,在这大节后半段也会有一个根本的实现。

7.3 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.4 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.5 initComputed

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

  1. computed能够是对象,也能够是函数,然而对象必须有 getter 办法, 因而如果 computed 中的属性值是对象时须要进行验证。
  2. 针对 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.6 极简风的响应式零碎

Vue的响应式零碎构建是比较复杂的,间接进入源码剖析构建的每一个流程会让了解变得艰难,因而我感觉在尽可能保留源码的设计逻辑下, 用最小的代码构建一个最根底的响应式零碎是有必要的。对 Dep,Watcher,Observer 概念的初步意识,也有助于下一篇对响应式零碎设计细节的剖析。

7.6.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.6.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.6.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.6.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.6.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()
  }
}

7.6.6 后果

一个极简的响应式零碎搭建实现。在精简代码的同时,放弃了源码设计的思维和逻辑。有了这一步的根底,接下来深入分析源码中每个环节的实现细节会更加简略。

7.7 小结

这一节内容,咱们正式进入响应式零碎的介绍,后面在数据代理章节,咱们学过 Object.defineProperty, 这是一个用来进行数据拦挡的办法,而响应式零碎构建的根底就是数据的拦挡。咱们先介绍了Vue 外部在初始化数据的过程,最终得出的论断是,不论是 data,computed, 还是其余的用户定义数据,最终都是调用Object.defineProperty 进行数据拦挡。而文章的最初,咱们在保留源码设计思维和逻辑的前提下,构建出了一个简化版的响应式零碎。残缺的性能有助于咱们下一节对源码具体实现细节的剖析和思考。

正文完
 0