Vue响应式原理上篇:如何从零构建一个响应式系统?

281 阅读8分钟
  • 响应式系统是什么?
  • 以一个简单例子来说明响应式系统?
  • 什么是依赖?什么是依赖搜集?
  • Watcher 是什么?
  • Dep 是什么?
  • 如何建立Dep和Watcher之间的关系?
  • 完整的实现一个简单的响应式系统?

简介

响应式系统是Vue非常核心的特性之一。每当我们在Vue中改变数据时,视图会自动进行更新,不用我们做额外的处理,极大地提高了我们的开发效率。

那么,Vue又是如何实现响应式系统的呢?其实,响应式系统的核心实现是主要运用了一个方法 - Object.defineProperty ,它会将数据转换成getter/setter形式。 这里借用Vue官网上的一句话可以概括其核心思路:

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

举个例子来讲就是:每当执行渲染流程的时候,会去获取渲染相关的数据。比如我们在模板中定义了{{ name }},渲染的时候就要获取这个name属性。又因为Vue对数据里的name属性进行了getter/setter处理,那么获取name的时候会触发getter,此时就能记录下渲染函数里有个叫做name依赖项。之后在更新name属性的时候,会触发setter。由于已经知道渲染函数中使用了name,那么我们就可以在setter里通知渲染函数进行更新。从而达到数据改变时视图自动更新的目的。

下面是Vue官网的阐述响应式原理的一张图片:

img

该系列响应式原理总共三个章节,通过这三个章节的学习,我们将从源码级别来理解上面这张图的含义。本章节主要通过从零构建一个极简的响应式系统,来了解响应式系统的前身。

以一个例子开始

前面提到的“通过Object.defineProperty将数据转换成getter/setter形式”比较抽象。现在想一想,如果现在要实现每当改变data的count属性时,dom里的count自动更新的功能,我们该如何通过Object.defineProperty来实现?

 // 每当改变data的count属性时,视图里的count自动更新
 // 视图
 <div id="app">{{ count }}</div>
 ​
 // 数据
 const data = { count: 1 }

先让我们来看看Object.defineProperty的使用方法:点击这里查看详细使用方法

 const data = { count: 1 }
 let val = data.count
 Object.defineProperty(data, 'count', {
   get() {
     console.log('get 触发')
     return val
   },
   set(newVal) {
     console.log('set 触发')
     val = newVal
   }
 })
 console.log(data.count) // 先打印 get 触发 ,随后打印 1
 data.count = 2 // 打印 set 触发
 console.log(data.count) // 先打印 get 触发 ,随后打印 2

可以看出,Object.defineProperty相当于对数据做了一层代理:每次获取属性的时候会触发get方法,每次设置属性的时候会触发set方法。

清楚了这个之后,实现上面设置属性自动更新的功能就比较容易了。我们可以直接将更新视图的操作放在set方法里,如下所示:

 Object.defineProperty(data, 'count', {
   get() { //... },
   set(newValue) {
     const $app = document.querySelector('#app')
     $app.innerHTML = "count:" + newValue
   }
 })

这样每次设置data.count的时候,就会触发set方法,同时找到对应的视图元素进行更新。但是这样写的话不具备通用性,因为data上可能不仅仅只有一个count属性,如果有其他的属性同样需要进行响应式处理。所以我们将上面的函数进行封装,使得它能够对任意的属性都能进行自动更新处理。点击这里查看效果

 // 更新视图
 const updateComponent = () => {
   // 这里直接将data里的 键和值 拼接,当做html进行渲染
   // 仅做演示,实际上的更新流程复杂得多。
   let html = "";
   Object.entries(data).forEach(([key, value]) => {
     html += `${key}: ${value} <br/>`;
   });
   const $app = document.querySelector("#app");
   $app.innerHTML = html;
 };
 ​
 // 封装将数据变为响应式的函数
 const defineReactive = (obj, key) => {
   let val = obj[key];
 ​
   Object.defineProperty(obj, key, {
     get() {
       return val;
     },
     set(newValue) {
       // 在set的时候做一下优化,如果值没有发生变化,那么就不更新DOM。
       if (newValue === val) {
         return;
       }
       val = newValue;
       // 更新 DOM
       updateComponent();
     }
   });
 };
 ​
 // 将 data 中的 count, title 属性设置为响应式
 const data = { count: 0, title: "1" };
 defineReactive(data, "count");
 defineReactive(data, "title");

通过defineReactive方法,我们可以对不同的key进行响应式处理。但是上面的代码比较零散,我们现在进一步将其封装到一个类里面。这个类需要满足三点要求:

  • 首先,这个类需要将data作为参数的一部分传入;
  • 其次,在这个类实例化的过程中,我们需要将data处理成响应式;
  • 最后,data改变时,同时能触发视图改变

根据上述三点要求,我们可以构建一个简单的类,叫做IVue点击这里查看运行效果

 class IVue {
   constructor(options) {
     // 1. 需要将data作为参数的一部分传入
     // options 的形式为 { id: '#app', data: {...} }
     this.$options = options;
 ​
     // 2. 需要将data处理成响应式
     this.initData();
   }
 ​
   // 对data响应式处理
   initData() {
     // 遍历 data 的 key,均处理成响应式
     const data = this.$options.data || {};
     Object.keys(data).forEach((key) => {
       this.defineReactive(data, key);
     });
   }
 ​
   // 对某个key就行响应式处理
   defineReactive(obj, key) {
     const self = this;
     let val = obj[key];
 ​
     Object.defineProperty(obj, key, {
       get() {
         return val;
       },
       set(newValue) {
         if (newValue === val) {
           return;
         }
         val = newValue;
         self.updateComponent();
       }
     });
   }
 ​
   // 更新 dom
   updateComponent() {
     let html = "";
     const data = this.$options.data;
     Object.entries(data).forEach(([key, value]) => {
       html += `${key}: ${value} <br/>`;
     });
     // 这里的 id 就是传入的 id
     const el = this.$options.id;
     const $app = document.querySelector(el);
     $app.innerHTML = html;
   }
 }
 ​
 const vm = new IVue({
   id: "#app",
   data: {
     count: 0,
     title: "1"
   }
 });
 ​
 const data = vm.$options.data;
 data.count = data.count + 1  // 触发视图更新
 data.title = data.title + 1  // 触发视图更新

IVue封装完毕,运行效果和未封装时一模一样,perfect!有没有觉得IVue有点眼熟?哈哈哈,它就是“究极简化版”的Vue

回过头来看看,我们已经达成了修改数据触发视图自动更新的目的。但实际上还存在很多比较明显的问题,比如:

  1. 首先,这里只能触发视图更新,如果有更多的回调函数需要触发是无法完成的。比如在Vue中,computedwatch也都会跟随数据的修改而变化。
  2. 其次,任何响应式数据触发到会导致视图重新渲染,很浪费资源。按道理来讲,只有在渲染时用到的数据改变时,才应该进行重新渲染。

针对问题1,我们需要建立数据回调函数之间的关系,从而能在修改数据时,知道哪些回调函数需要执行。

针对问题2,我们同样需要建立数据渲染函数之间的关系,只有清楚地知道哪些数据是在渲染过程中用到的,那么才能避免不必要的更新。

Vue则是巧妙地使用了两个类来处理这种关系:Dep类和Watcher类。Dep类用来保存数据和回调函数之间的关系,数据可以通过闭包里的Dep来访问其相关函数。Watcher类则是对回调函数的封装,一个回调函数对应一个Watcher**Dep****Watcher**是多对多的关系:一个函数可以依赖多个数据,一个数据可以被多个函数依赖。下面我们将分别通过这两个类来完善我们的响应式系统。

什么是依赖?

在面试时当被问到响应式原理的时候,相信很多人都能回答上“在get的时候搜集依赖,在set的时候触发状态更新”,但是这里的依赖具体指的是什么呢?

这里还是借用Vue官网的那句话:

每个组件实例都对应一个 watcher 实例,它会在组件渲染的过程中把“接触”过的数据 property 记录为依赖。之后当依赖项的 setter 触发时,会通知 watcher,从而使它关联的组件重新渲染。

仔细阅读我们会发现, “接触”过的数据才是依赖!注意,这里不是”函数是数据的依赖“,很多人误以为数据改变,会触发多个函数改变,那么这些函数就是该数据的依赖,这是错误的。

为什么说数据才是依赖呢?举个例子,在组件要渲染的时候,会执行渲染函数,此时肯定是对dom进行处理,而dom里面又存在我们定义好的一些模板数据。因此不同的数据,渲染的结果也不同。换句话来讲,也就是渲染函数是依赖于它使用到的数据的,所以说数据才是依赖。

明白了这个之后,我们就能理解搜集依赖的本质其实就是:函数(如:渲染时指的是渲染函数)执行时到底依赖了哪些数据。只有知道了依赖了哪些数据,后续才能在相应数据改变时,重新执行该函数(即重新渲染视图)。

Watcher

要想知道一个函数依赖于哪些数据,单单通过一个函数是很难办到的,所以我们需要用一个类来管理这个函数,这个类叫做WatcherWatcher类需要满足两点要求:一是能够执行相应的函数,二是需要记录它依赖了哪些数据。所以构建完成后如下所示:

 class Watcher {
    constructor(vm, expOrFn) {
      // 记录依赖了哪些数据
      this.deps = []
      
      // 这里的 vm 指代的上下文环境
      this.vm = vm
      this.getter = expOrFn
      this.value = this.get()
   }
   
   // 用于调用相应函数
   get() {
     const value = this.getter.call(this.vm)
     return value
   }
   
   // 用于更新
   update() {
     // 这里更新只是简单处理
     const value = this.getter.call(this.vm)
     return value
   }
   
   // 依赖了哪些数据就添加哪些数据
   addDep(dep) {
     this.deps.push(dep)
   }
 }

Watcher包含一个get方法,用于执行对应的回调函数。通过Watcher我们可以将渲染函数updateComponent进行封装:

 function $mount() {
   const updateComponent = () => { ... }
   new Watcher(updateComponent)
                                 }

在实例化Watcher的同时,会触发updateComponent的执行,那么接下来就该搜集updateComponent的依赖了。

Dep

要想知道哪些函数依赖某个数据,那么我们需要单独使用一个类来管理这个数据,这个类叫做Dep。它需要能够知道哪些函数依赖了这个数据,并且能够对函数(watcher)进行增加删除处理,另外还能通知函数去执行。代码如下:

 class Dep {
   constructor() {
     this.subs = []
   }
   
   addSub(sub) {
     this.subs.push(sub)
   }
   
   removeSub(sub) {
     this.subs = this.subs.filter((item) => sub !== item)
   }
   
   notify() {
     this.subs.forEach((sub) => { sub.update() })
   }
 }

建立depWatcher之间的关系

现在依赖搜集的类写好了,但是如何在实际中建立起DepWatcher之间的关系呢?

我们知道,实例化Watcher的时候会获取相应的数据,而data上的数据已经做了响应式处理,那么就会触发对应的get方法。此时,我们就可以在get方法进行记录依赖关系。

但是!get方法里如何知道到底是谁使用了该数据呢?这里的方法比较简单,我们在执行函数的时候,将当前的Watcher挂到全局(这里挂载到Dep.target上),这样在get的时候不就能知道当前正在执行什么函数了吗?所以我们首先需要改写Watcher类的get方法

 class Watcher {
   ...
   get() {
     Dep.target = this
     const value = this.getter.call(this.vm)
     Dep.target = null
     return value
   }
 }

随后,我们在get的时候记录这种关系。此外,在set的时候,我们会通过这种关系去通知相应函数进行更新。

   defineReactive(obj, key) {
     const self = this;
     let val = obj[key];
     
     const dep = new Dep()
 ​
     Object.defineProperty(obj, key, {
       get() {
         // 记录函数与数据的关系
         dep.depend()
         return val;
       },
       set(newValue) {
         if (newValue === val) {
           return;
         }
         val = newValue;
         dep.notify()
       }
     });
   }

这里我们还需要改写Dep类,添加一个depend方法,用于记录两者之间的关系

 class Dep {
   ...
   depend() {
     // 如果存在正在执行的函数
     if (Dep.target) {
       // 数据添加函数
       this.addSub(Dep.target)
       // 函数添加数据
       Dep.target.addDep(this)
     }
   }
 }

好了,这样我们就完整的建立起了DepWatcher之间的关系了。另外,还有一点值得注意的是,dep定义的位置非常巧妙。这里通过闭包的形式,使得每个数据都有自己独立的一份dep,数据无论是get还是set的时候都能正常访问这份独立的dep,这种闭包的运用方式值得我们学习。

总结

经过这一章节的学习,我们实现了一个简单的响应式系统,完整的代码如下:

 class Dep {
   constructor() {
     this.subs = []
   }
   
   addSub(sub) {
     this.subs.push(sub)
   }
   
   removeSub(sub) {
     this.subs = this.subs.filter((item) => sub !== item)
   }
   
   notify() {
     this.subs.forEach((sub) => { sub.update() })
   }
   
   depend() {
     // 如果存在正在执行的函数
     if (Dep.target) {
       // 数据添加函数
       this.addSub(Dep.target)
       // 函数添加数据
       Dep.target.addDep(this)
     }
   }
 }
 ​
 class Watcher {
    constructor(vm, expOrFn) {
      // 记录依赖了哪些数据
      this.deps = []
      
      // 这里的 vm 指代的上下文环境
      this.vm = vm
      this.getter = expOrFn
      this.value = this.get()
   }
   
   // 用于调用相应函数
   get() {
     Dep.target = this
     const value = this.getter.call(this.vm)
     Dep.target = null
     return value
   }
   
   // 用于更新
   update() {
     // 这里更新只是简单处理
     const value = this.getter.call(this.vm)
     return value
   }
   
   // 依赖了哪些数据就添加哪些数据
   addDep(dep) {
     this.deps.push(dep)
   }
 }
 ​
 ​
 class IVue {
   constructor(options) {
     // 1. 需要将data作为参数的一部分传入
     // options 的形式为 { id: '#app', data: {...} }
     this.$options = options;
 ​
     // 2. 需要将data处理成响应式
     this.initData();
     
     // 3. 挂载dom,并搜集依赖
     this.$mount()
   }
 ​
   // 对data响应式处理
   initData() {
     // 遍历 data 的 key,均处理成响应式
     const data = this.$options.data || {};
     Object.keys(data).forEach((key) => {
       this.defineReactive(data, key);
     });
   }
 ​
   // 对某个key就行响应式处理
    defineReactive(obj, key) {
     const self = this;
     let val = obj[key];
     
     const dep = new Dep()
 ​
     Object.defineProperty(obj, key, {
       get() {
         // 记录函数与数据的关系
         dep.depend()
         return val;
       },
       set(newValue) {
         if (newValue === val) {
           return;
         }
         val = newValue;
         dep.notify()
       }
     });
   }
   
   $mount() {
     const updateComponent = () => {
       let html = "";
       const data = this.$options.data;
       Object.entries(data).forEach(([key, value]) => {
         html += `${key}: ${value} <br/>`;
       });
       // 这里的 id 就是传入的 id
       const el = this.$options.id;
       const $app = document.querySelector(el);
       $app.innerHTML = html;
     }
     new Watcher(this, updateComponent)
   }
 }
 ​
 const vm = new IVue({
   id: "#app",
   data: {
     count: 0,
     title: "1"
   }
 });
 ​
 const data = vm.$options.data;
 data.count = data.count + 1  // 触发视图更新
 data.title = data.title + 1  // 触发视图更新

一个极简的响应式系统就完成了!但是这还仅仅不够的,因为还有许多细节被我们省略了,下个章节我们将会结合Vue源码来理解整个响应式系统的构建。

最后,如果你觉得这篇文章对你有所帮助,可以一键三连哦!码字不易,你的支持和喜欢是对我最大的鼓励~

img

如果你有任何疑问都可以在评论区留言,我都会一一查看。如果你也是前端的爱好者,可以私信我进群和其他人一起交流,一起在前端的路上学习提升!