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

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

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

  1. function initState (vm) {
  2. vm._watchers = [];
  3. var opts = vm.$options;
  4. // 初始化props
  5. if (opts.props) { initProps(vm, opts.props); }
  6. // 初始化methods
  7. if (opts.methods) { initMethods(vm, opts.methods); }
  8. // 初始化data
  9. if (opts.data) {
  10. initData(vm);
  11. } else {
  12. observe(vm._data = {}, true /* asRootData */);
  13. }
  14. // 初始化computed
  15. if (opts.computed) { initComputed(vm, opts.computed); }
  16. // 初始化watch
  17. if (opts.watch && opts.watch !== nativeWatch) {
  18. initWatch(vm, opts.watch);
  19. }
  20. }

7.1 initProps

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

  1. // 父组件
  2. <child :test="test"></child>
  3. // 子组件
  4. {
  5. props: ['test']
  6. }

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

  1. // 创建子组件过程
  2. function createComponent() {
  3. // props校验
  4. var propsData = extractPropsFromVNodeData(data, Ctor, tag);
  5. ···
  6. // 创建子组件vnode
  7. var vnode = new VNode(
  8. ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')),
  9. data, undefined, undefined, undefined, context,
  10. { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children },
  11. asyncFactory
  12. );
  13. }

7.1.1 props的命名规范

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

  1. function extractPropsFromVNodeData (data,Ctor,tag) {
  2. // Ctor为子类构造器
  3. ···
  4. var res = {};
  5. // 子组件props选项
  6. var propOptions = Ctor.options.props;
  7. // data.attrs针对编译生成的render函数,data.props针对用户自定义的render函数
  8. var attrs = data.attrs;
  9. var props = data.props;
  10. if (isDef(attrs) || isDef(props)) {
  11. for (var key in propOptions) {
  12. // aB 形式转成 a-b
  13. var altKey = hyphenate(key);
  14. {
  15. var keyInLowerCase = key.toLowerCase();
  16. if (
  17. key !== keyInLowerCase &&
  18. attrs && hasOwn(attrs, keyInLowerCase)
  19. ) {
  20. // 警告
  21. }
  22. }
  23. }
  24. }
  25. }

有必要说一下这一部分的处理,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数据设置为响应式数据。

  1. function initProps (vm, propsOptions) {
  2. var propsData = vm.$options.propsData || {};
  3. var loop = function(key) {
  4. ···
  5. defineReactive(props,key,value,cb);
  6. if (!(key in vm)) {
  7. proxy(vm, "_props", key);
  8. }
  9. }
  10. // 遍历props,执行loop设置为响应式数据。
  11. for (var key in propsOptions) loop( key );
  12. }

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

7.2 initMethods

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

  1. function initMethods (vm, methods) {
  2. var props = vm.$options.props;
  3. for (var key in methods) {
  4. {
  5. // method必须为函数形式
  6. if (typeof methods[key] !== 'function') {
  7. warn(
  8. "Method \"" + key + "\" has type \"" + (typeof methods[key]) + "\" in the component definition. " +
  9. "Did you reference the function correctly?",
  10. vm
  11. );
  12. }
  13. // methods方法名不能和props重复
  14. if (props && hasOwn(props, key)) {
  15. warn(
  16. ("Method \"" + key + "\" has already been defined as a prop."),
  17. vm
  18. );
  19. }
  20. // 不能以_ or $.这些Vue保留标志开头
  21. if ((key in vm) && isReserved(key)) {
  22. warn(
  23. "Method \"" + key + "\" conflicts with an existing Vue instance method. " +
  24. "Avoid defining component methods that start with _ or $."
  25. );
  26. }
  27. }
  28. // 直接挂载到实例的属性上,可以通过vm[method]访问。
  29. vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm);
  30. }
  31. }

7.3 initData

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

  1. function initData(vm) {
  2. var data = vm.$options.data;
  3. // 根实例时,data是一个对象,子组件的data是一个函数,其中getData会调用函数返回data对象
  4. data = vm._data = typeof data === 'function'? getData(data, vm): data || {};
  5. var keys = Object.keys(data);
  6. var props = vm.$options.props;
  7. var methods = vm.$options.methods;
  8. var i = keys.length;
  9. while (i--) {
  10. var key = keys[i];
  11. {
  12. // 命名不能和方法重复
  13. if (methods && hasOwn(methods, key)) {
  14. warn(("Method \"" + key + "\" has already been defined as a data property."),vm);
  15. }
  16. }
  17. // 命名不能和props重复
  18. if (props && hasOwn(props, key)) {
  19. warn("The data property \"" + key + "\" is already declared as a prop. " + "Use prop default value instead.",vm);
  20. } else if (!isReserved(key)) {
  21. // 数据代理,用户可直接通过vm实例返回data数据
  22. proxy(vm, "_data", key);
  23. }
  24. }
  25. // observe data
  26. observe(data, true /* asRootData */);
  27. }

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

7.4 initComputed

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

    • computed可以是对象,也可以是函数,但是对象必须有getter方法,因此如果computed中的属性值是函数时都要进行验证。
    • 针对computed的每个属性,要创建一个监听的依赖,也就是实例化一个watcher,watcher的定义,可以暂时理解为数据使用的依赖本身,一个watcher实例代表多了一个需要被监听的数据依赖。除了不同点,initComputed也会将每个属性设置成响应式的数据,同样的,也会对computed的命名做检测,防止与props,data冲突。
  1. function initComputed (vm, computed) {
  2. ···
  3. for (var key in computed) {
  4. var userDef = computed[key];
  5. var getter = typeof userDef === 'function' ? userDef : userDef.get;
  6. // computed属性为对象时,要保证有getter方法
  7. if (getter == null) {
  8. warn(("Getter is missing for computed property \"" + key + "\"."),vm);
  9. }
  10. if (!isSSR) {
  11. // 创建computed watcher
  12. watchers[key] = new Watcher(vm,getter || noop,noop,computedWatcherOptions);
  13. }
  14. if (!(key in vm)) {
  15. // 设置为响应式数据
  16. defineComputed(vm, key, userDef);
  17. } else {
  18. // 不能和props,data命名冲突
  19. if (key in vm.$data) {
  20. warn(("The computed property \"" + key + "\" is already defined in data."), vm);
  21. } else if (vm.$options.props && key in vm.$options.props) {
  22. warn(("The computed property \"" + key + "\" is already defined as a prop."), vm);
  23. }
  24. }
  25. }
  26. }

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

7.5 极简风的响应式系统

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

7.5.1 框架搭建

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

  1. var vm = new MyVue({
  2. id: '#app',
  3. data: {
  4. test: 12
  5. }
  6. })
  7. // myVue.js
  8. (function(global) {
  9. class MyVue {
  10. constructor(options) {
  11. this.options = options;
  12. // 数据的初始化
  13. this.initData(options);
  14. let el = this.options.id;
  15. // 实例的挂载
  16. this.$mount(el);
  17. }
  18. initData(options) {
  19. }
  20. $mount(el) {
  21. }
  22. }
  23. }(window))

7.5.2 设置响应式对象 - Observer

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

  1. class MyVue {
  2. initData(options) {
  3. if(!options.data) return;
  4. this.data = options.data;
  5. // 将数据重置getter,setter方法
  6. new Observer(options.data);
  7. }
  8. }
  9. // Observer类的定义
  10. class Observer {
  11. constructor(data) {
  12. // 实例化时执行walk方法对每个数据属性重写getter,setter方法
  13. this.walk(data)
  14. }
  15. walk(obj) {
  16. const keys = Object.keys(obj);
  17. for(let i = 0;i< keys.length; i++) {
  18. // Object.defineProperty的处理逻辑
  19. defineReactive(obj, keys[i])
  20. }
  21. }
  22. }

7.5.3 依赖本身 - Watcher

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

  1. // 监听的依赖
  2. class Watcher {
  3. constructor(expOrFn, isRenderWatcher) {
  4. this.getter = expOrFn;
  5. // Watcher.prototype.get的调用会进行状态的更新。
  6. this.get();
  7. }
  8. get() {}
  9. }

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

  1. class MyVue {
  2. $mount(el) {
  3. // 直接改写innerHTML
  4. const updateView = _ => {
  5. let innerHtml = document.querySelector(el).innerHTML;
  6. let key = innerHtml.match(/{{(\w+)}}/)[1];
  7. document.querySelector(el).innerHTML = this.options.data[key]
  8. }
  9. // 创建一个渲染的依赖。
  10. new Watcher(updateView, true)
  11. }
  12. }

7.5.4 依赖管理 - Dep

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

  1. let uid = 0;
  2. class Dep {
  3. constructor() {
  4. this.id = uid++;
  5. this.subs = []
  6. }
  7. // 依赖收集
  8. depend() {
  9. if(Dep.target) {
  10. // Dep.target是当前的watcher,将当前的依赖推到subs中
  11. this.subs.push(Dep.target)
  12. }
  13. }
  14. // 派发更新
  15. notify() {
  16. const subs = this.subs.slice();
  17. for (var i = 0, l = subs.length; i < l; i++) {
  18. // 遍历dep中的依赖,对每个依赖执行更新操作
  19. subs[i].update();
  20. }
  21. }
  22. }
  23. Dep.target = null;

7.5.5 依赖管理过程 - defineReactive

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

  1. const defineReactive = (obj, key) => {
  2. const dep = new Dep();
  3. const property = Object.getOwnPropertyDescriptor(obj);
  4. let val = obj[key]
  5. if(property && property.configurable === false) return;
  6. Object.defineProperty(obj, key, {
  7. configurable: true,
  8. enumerable: true,
  9. get() {
  10. // 做依赖的收集
  11. if(Dep.target) {
  12. dep.depend()
  13. }
  14. return val
  15. },
  16. set(nval) {
  17. if(nval === val) return
  18. // 派发更新
  19. val = nval
  20. dep.notify();
  21. }
  22. })
  23. }

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

  1. class Watcher {
  2. constructor(expOrFn, isRenderWatcher) {
  3. this.getter = expOrFn;
  4. // Watcher.prototype.get的调用会进行状态的更新。
  5. this.get();
  6. }
  7. get() {
  8. // 当前执行的watcher
  9. Dep.target = this
  10. this.getter()
  11. Dep.target = null;
  12. }
  13. update() {
  14. this.get()
  15. }
  16. }

7.5.6 结果

一个极简的响应式系统搭建完成。在精简代码的同时,保持了源码设计的思想和逻辑。有了这一步的基础,接下来深入分析源码中每个环节的实现细节会更加简单。