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

  • 响应式系统是什么?

  • 以一个简单例子来说明响应式系统?

  • 什么是依赖?什么是依赖搜集?

  • 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官网的阐述响应式原理的一张图片:

响应式原理

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

以一个例子开始

前面提到的“通过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源码来理解整个响应式系统的构建。

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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容