【Vue2.0源码系列】:响应式原理

1,264 阅读8分钟

Vue的经典设计思想就是数据驱动,即:数据改变自动更新视图。为了实现这一目标,Vue需要观察数据的变动,当感知到数据变化后,自动执行对应的视图更新操作函数(常见为render function)。由此实现响应式数据的特性。

总体流程

根据上图我们大概可以了解响应式原理的总体流程。对于响应式数据的具体实现,通过阅读源码可以知道,Vue是通过以下几个核心模块来实现:

  1. Observer
  2. Dep
  3. Watcher
  4. Scheduler

下面分别来看下各个模块的具体作用和解决的问题。

Observer (消息)

核心作用: 将普通数据(对象)转换为响应式数据(对象)

观察的数据,主要分为两种:

  1. 对象自身
  2. 对象属性

Object.defineProperty()

Observer(观察者)属于Vue系统中的核心模块,是用于实现响应式数据的基础构造器(Constructor)。其内部会递归遍历普通对象的每个属性,并利用Javascript提供的对象操作API —— Object.defineProperty(),将其转换为带有 gettersetter 方法的属性。此后,当属性被读取或修改时,分别完成依赖收集变化通知。Vue便拥有了对数据变化自动感知的能力。

created之前完成

需要注意的是:Observer响应式处理过程,发生在Vue生命周期中的 beforeCreatecreated

Vue.observable()

Vue提供了静态方法:Vue.observable(), 手动将普通对象转为响应式对象。例如:

var obj = {
      a: 1,
      b: 2,
      c: {
        d: 3,
        e: 4
      },
      f: [
        {
          a: 1,
          b: 2
        },
        3, 4, 5, 6
      ]
    }

    // 利用Vue提供的静态方法 .observable, 将一个普通对象转化为响应式对象
    var reactiveObj = Vue.observable(obj)
    console.log(reactiveObj)

data

Vue不允许动态添加根级响应式属性,所以需要在组件实例化之前通过配置中的 data 字段,声明所有根级响应式属性,哪怕属性值为 null。由此带来的好处有:

  1. 更易于维护: data对象就像组件的状态结构(schema), 提前声明所有响应式属性,后期有助于开发者理解和修改组件逻辑。
  2. 消除了在依赖项跟踪系统中的一类边界情况。
  3. 使Vue实例能够更好的配合类型检查系统工作。

特殊情况

动态添加或删除属性

由于Vue会在初始化实例时,对所有属性(配置里 data 中存在的属性)执行 getter/setter 的转化。

那么对于动态添加或删除的属性,Vue是无法自动检查其变化。

因此,Vue提供了以下方式来手动完成响应式数据。

  1. 添加:Vue.set(target, key, val)this.$set(target, key, val)
  2. 删除:Vue.delete(target, key)this.$delete(target, key)
  3. 批量操作:this.reactiveObj = Object.assign({}, this.reactiveObj, obj)

举个例子:

<template>
    <div class="demo-wrapper">
        <p>obj.a -> {{ obj.a }}, obj.b -> {{ obj.b }}</p>

        <!-- 非响应式式数据操作 -->
        <!-- 
            <button @click="obj.b = 2">add obj.b</button>
            <button @click="delete obj.a">delete obj.a</button>
        -->
        <!-- 响应式数据操作 -->
        <button @click="$set(obj, 'b', 2)">add obj.b</button>
        &nbsp;
        <button @click="$delete(obj, 'a')">delete obj.a</button>
    </div>
</template>

<script>
export default {
    data() {
        return {
            obj: {
                a: 1,
            },
        };
    },
};
</script>

关于数组

由于js的限制, Vue不能检测到以下数组变动:

  1. 当利用索引直接改变数组项时, 例如:vm.arr[idx] = newValue
  2. 当修改数组长度时 ,例如: vm.arr.length = newLength

举个例子:

<script>
export default {
    data() {
        return {
            arr: [1, 2, 3, 4],
        };
    },
    created() {
        window.lesson4 = this;
    },
    mounted() {
        this.arr[0] = 8; // 不是响应式的
        this.arr.length = 2; //不是响应式的
    }
};
</script>

为了让上述数组操作具有响应式,采用以下方法处理:

<script>
export default {
    data() {
        return {
            arr: [1, 2, 3, 4],
        };
    },
    created() {
        window.lesson4 = this;
    },
    mounted() {
				// 操作一:通过索引修改数组项
        this.$set(arr, 0, 8); // 响应式的
        // 或
        Vue.set(arr, 0, 8);  // 响应式的
        // 或
        this.arr.splice(0, 1, 8) //响应式的
      
        // 操作二:修改数组长度为2
        this.arr.splice(2) // 响应式的
    }
};
</script>

除了可以通过静态方法 Vue.set() 和 实例方法 this.$set() 响应式的修改数组项的值。还可以使用数组方法 - splice()

因为,Vue对一些可以改变数组自身内容的操作API,如:splice()、sort()、push()、pop()、reverse()、shift()、unshift() 等进行了拦截和重写。从而在开发者使用这些API时,可以触发响应式数据,进而更新视图。

<script>
  export default{
    data() {
      return {
        arr: [1,2,3,4]
      }
    },
    mounted() {
      console.log(this.arr._proto_ === Array.prototype)  // => false
      console.log(this.arr._proto_._proto_ === Array.prototype)  //=> true
    }
  }
</script>

图示如下:

Dep (通知)

Dep, 含义 Dependency,”依赖“的意思。 是响应式数据 用于做 依赖管理 的模块(Class)。

Dep解决的问题

  1. 触发响应式属性 getter时(访问属性), 如何收集依赖 —— 谁在用“我”?
  2. 触发响应式属性 setter时(修复属性),如何派发更新 —— 通知用“我”的谁?

依赖管理流程,分析如下:

  1. 在将对象转为响应式对象时,会为对象自身和每个属性设置一个实例化dep (dep = new Dep()),用于管理依赖。
  2. 通过 Dep.target 设置一个全局唯一性的当前执行的Watcher
  3. 当属性被访问时(触发 prop getter),会调用 dep.depend(),将当前全局Watcher添加到依赖中
  4. 当属性修改时(触发 prop setter), 会调用 dep.notify(),遍历当前收集的依赖(Watcher),并调用每个Watcherupdate() API,然后Watcher内部会完成更新操作(更新函数)的执行。

其中涉及到一个新的概念 —— Watcher, 下一小节详细了解。

Watcher (谁)

问题: 响应式对象属性被访问时,对应的getter如何知道谁访问的?或者说:Dep如何知道谁用了自己?

想象这样一个场景:当某个函数执行时,用到了某个响应式数据。js是无法直接知道是这个函数使用了这个响应式数据,从而也就无法在数据更新时,再次找到这个函数重新执行,完成更新。

Object.defineProperty(obj, 'a', {
 get() {
 		dep.depend()  
   // dep.depend() -> Dep.target.addDep(dep)  -> dep.addSup(Dep.target) -> dep.subs添加了一个watcher
 },
 set() {
 	  dep.notify()
   // dep.notify() -> 遍历 dep.subs -> 调用每个依赖的watcher的 watcher.update() -> 每个watcher把自己交给一个 Scheduler(调度器) -> scheduler把watcher队列放入事件循环的微任务队列中异步执行。
 }
})

// 举例: 组件渲染时,render函数如何被收集的
function render() {
	 // 这个render函数调用时,用到了响应式数据 obj.a
	 console.log(obj.a)
}

// ################################ ################################ 
// 🙋问题来了: obj.a的如何知道是render函数调用时用到了自己?
//################################ ################################ 

// 解决方案: Watcher

// ① 用 watcher包装render
var watcher = new Watcher(render)
// ② 第一次渲染时
watcher.run()
// ③ 此时
Dep.target = watcher   // Dep.target 通过一个 栈 去管理
// ④ watcher.run() -> render()
render()
// ⑤ render() 执行期间 访问响应式数据,开始收集依赖 (各个数据会将当前的wather -> Dep.target,存储到自己的 dep.subs里)
dep.depend() -> ... -> dep.addSub(Dep.target)
// ⑥ 数据更新时, 对应数据的dep通知依赖的watcher更新
dep.notify() -> ... -> dep.subs ... watcher.update() -> queueWatcher
// ⑤ 待更新的watcher将自己交个scheduler去执行,放到微任务队列中执行。
scheduler -> nextTick -> queueWatcher -> watcher.run

为了解决上面的问题,Vue引入一种巧妙方法,引入新的概念—— Watcher。具体做法是: Dep 不是直接收集和派发更新操作(函数),而是收集派发 Watcher,然后通过Watcher执行更新操作(函数) 。所以,每个更新操作(函数)都应该创建一个Watcher来包装一下,通过Watcher代理执行更新操作。

Watcher可以理解为当数据更新时,那些需要执行的更新操作(如:render function)的代理执行对象

原理图示如下:

总结一下:

  1. 每个Vue组件实例,都至少对应一个 watcher, 该watcher中记录了该组件的render函数

  2. watcher首先执行一次 render函数,过程中,会收集依赖(在render函数中使用到的响应式数据就会记录这个watcher)

  3. 数据更新时,Dep会通知自身收集的所有Watcher,然后由各个Watcher去完成具体执行的更新操作(函数)。更新函数执行后,界面重新渲染。并且又会重新进行依赖的收集。 参考调用流程:

    props setter -> dep.notify() -> depWatcher.update() -> nextTickWatcherRun -> render function -> updateComponent

Scheduler

  问题:当多个响应式数据更新时,是否有必要多次执行同一个watcher对应的更新操作?

结合上面问题,试想一下:如果一个交给watcher的函数,它里面用到了a、b、c、d四个响应式属性,那么这四个属性都会记录依赖。于是下面的代码将触发4次更新:

this.obj.a = 'new a'
this.obj.b = 'new b'
this.obj.c = 'new c'
this.obj.d = 'new d'

这样显然是不合适的。很明显:为了提升效率,不应该频繁的执行watcher

于是,引入了 Scheduler(调度器)的概念。

Scheduler(调度器)的工作原理:当Dep通知watcher更新后,不能立即执行更新操作,而是将watcher交给一个Scheduler去管理。Scheduler会维护一个队列,用于存放等待被执行的watcher,且同一个watcher仅会存在一次。 然后将队列中的watcher放入事件循环的**微任务(nextTick)**中去执行。

小结:

  1. 组件渲染时,render函数是在事件循环的微任务队列中异步被执行的。

异步更新队列

Vue侦听到数据变化,就会开启一个队列。但是组件不会立即重新渲染,而是先会缓冲在同一个事件循环中的发生的所有数据变化。此时如果同一个watcher被多次触发,只会被推入到队列中一次,这样可以避免不必要的计算和DOM更操作

在下一个事件循环”tick“中, Vue刷新队列并执行实际(已去重的)工作(更新渲染)。

为此,Vue提供了异步更新的监听接口 —— Vue.nextTick(callback)this.$nextTick(callback) 。当数据发生改变,异步DOM更新完成后,callback回调将被调用。开发者可以在回调中,操作更新后的DOM。

举例1

export default {
    data() {
        return {
            a: 1,
            b: 2,
            c: 3,
            d: 4,
        };
    },
    methods: {
        changeAllData() {
            this.$nextTick(function () {
                var pre = document.querySelector("pre");
                console.log(pre.textContent);
            });

            this.a = this.b = this.c = this.d = 10;

            this.$nextTick(function () {
                var pre = document.querySelector("pre");
                console.log(pre.textContent);
            });
        },
    },
    render(h) {
        console.log('render function')
        return h('div', [
            h('pre', `${this.a}, ${this.b}, ${this.c}, ${this.d}`),
            h('button', {
                on: {
                    click: () => {
                        this.changeAllData()
                    }
                }
            }, 'change all data')
        ])
    }
};

上例,通过一个NextTick组件的渲染,了解下 $nextTick的用法。为了方便查看组件渲染时,render函数被调用的过程,在组件定义时,直接给出render函数。当点击按钮后, 会在数据修改前后,使用 $nextTick工具方法,分别写入两个读取界面Dom的函数。结果会发现,第一个 $nextTick 回调函数获取的数据为旧数据,第二个 $nextTick回调函数获取的数据为新数据。

分析一下:

按钮点击后,异步队列的添加步骤是:

  1. 第一个 $nextTick ,会将自己的回调函数(fn1)加入到当前的异步队列中。
  2. 修改数据后, 经过派发更新,Scheduler会将包含了watcher队列执行逻辑的函数(fn2)加入到当前的异步队列中。
  3. 第二个 $nextTick, 已将自己的回调函数(fn3)加入到当前的异步队列中。

当异步队列执行时,会依次执行 fn1 , fn2,fn3。而当fn2执行后,界面才会更新最新数据,所以fn1,fn3获取的界面数据前者为旧数据,后者为新数据。

举例2

<template>
  <span>{{a}}</span>
</template>
<script>
	export default {
    data() {
      return {
        a: 'hello'
      }
    },
    mounted() {
      this.a = 'world'
      console.log(this.$el.textContent)  // -> 'hello'
      this.$nextTick(function() {
        console.log(this.$el.textContent) // -> 'world'
      })
    }
  }
</script>

上面代码,当设置 this.a = 'world' 后,访问DOM元素内容,但完成未更新。此时,立即使用 this.$nextTick() 监听DOM更新,并在监听回调调用时,获取更新后的DOM内容。

另外, this.$nextTick() 其内部尝试使用原生的 Promise.thenMutationObservesetImmediate,如果执行环境不支持,则会采用 setTimeout 替代。并且最终返回一个Promise对象,所以可以使用 async/await 语法替代 callback 的写法。

<script>
	export default {
    data() {
      return {
        a: 'hello'
      }
    },
    // $nextTick 结合 async/await语法使用
    async mounted() {
      this.a = 'world'
      console.log(this.$el.textContent)  // -> 'hello'
      await this.$nextTick()
      console.log(this.$el.textContent) // -> 'world'
    }
  }
</script>

最后

如果觉得有用,顺便点个赞吧!你的支持是我最大的鼓励!

微信关注 “乘风破浪大前端”,发现更多有趣好文的前端知识和实战。

关于本文如有任何意见或建议,欢迎评论区讨论。