Skip to content

从零构建一个响应式系统

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

简介

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

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

响应式原理

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

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

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

响应式原理

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

以一个例子开始

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

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

// 数据
const data = { count: 1 }

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

javascript
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方法里,如下所示:

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

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

javascript
// 更新视图
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作为参数的一部分传入,如options.data
  • 其次,在这个类实例化的过程中,我们需要将data处理成响应式。
  • 最后,data改变时,同时能触发视图改变。

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

javascript
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类则是对回调函数的封装,一个回调函数对应一个WatcherDepWatcher是多对多的关系:一个函数可以依赖多个数据,一个数据可以被多个函数依赖。下面我们将分别通过这两个类来完善我们的响应式系统。

什么是依赖?

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

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

响应式原理

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

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

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

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

Watcher

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

javascript
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(也就是回调函数)进行封装:

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

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

Dep

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

javascript
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方法

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

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

javascript
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方法,用于记录两者之间的关系

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

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

总结

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

javascript
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源码来理解整个响应式系统的构建。