手写简单版vue,深入理解vue的响应式原理

677 阅读6分钟

 

前言

总所周知,前端最核心的,就是改变页面内容。所以出现了jquery这样的便于我们操作dom的工具。
但是如果开发中数据一改变我们就遍历dom节点,然后进行更新的话,数据一多起来,其实是很不方便的;也不利于我们将工作重心放在业务的处理上,全都在“如何操作dom”上面去了;

那么我们想一想,有没有一种可能,我改变数据之后,页面中使用到这个数据的dom节点就自动更新数据的;


嘿嘿,还真有,那就是利用Object的defineProperty属性;

     

defineProperty介绍

该方法作用是精确地添加或修改对象的属性;
比如:以下的语法含义,就是给obj对象的prop属性添加剂一个值

语法

Object.defineProperty(obj, prop, descriptor)

  • obj 要定义属性的对象。
  • prop 要定义或修改的属性的名称或 Symbol。
  • descriptor 要定义或修改的属性描述符。

详解第三个参数 -- descriptor

descriptor是属性描述符,是一个对象;其中有两个可选键值:

  • get

属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。
默认为 undefined

  • set

属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。
默认为 undefined

看到这个“调用此函数”,有想到什么吗?


对了,就是我们熟悉的回调函数,其触发时机就是我们访问和修改值的时候;
所以我们可以大胆假设:

如果我们想要改变值的时候自动进行dom的更新,那我们只需要在set的回调函数里面加入修改dom的逻辑就好啦~

     

数据劫持

根据上面我们对defineProperty的介绍,我们基于它来实现一个简单的数据绑定吧~

wait wait;为啥要叫数据劫持呢,其实就是因为我们在访问/修改值的时候,拦截了这个逻辑,然后在里面加入了我们自己的一些逻辑(比如更新dom), 也就是对我们的属性进行一个响应式的绑定;

做了一不太恰当的比喻,一辆卡车本来在是装的一车苹果(get),要更改装的东西,也只能往里面加点水果(set),但是我现在硬要在往里面塞点人进去(更新dom); 我们能怎么办,只能把卡车拦截下来,然后手动给塞进去了呗~

在这里插入图片描述

小小比喻,各位看官海涵海涵~

     

先来一个超级基础的劫持

假设有一个对象let obj = {}

然后我们希望我使用obj.aobj.a = 2的时候,都能触发get和set里面的回调,以加入我们自己逻辑;

怎么做呢;

我们看看实现:

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get: function () {
      console.log("get ", key); // 回调函数
      return val;
    },
    // 这里有个闭包 用于缓存新set的值,然后get才能拿到
    set: function (newVal) {
      if (newVal !== val) {
        val = newVal;
        console.log("set", key);  // 回调函数
      } else {
        return;
      }
     
    },
  });
}

然后我们只需要对对象obj进行劫持即可

let obj = { a: { b: "b" }, c: "c" };
observe(obj);
obj.a.b;
obj.a.b = 2;
obj.c;

image.png

如果对象有多个属性怎么办呢

此时,我们简单粗暴的可以

defineReactive(obj, "a", 1);
defineReactive(obj, "b", 2);
defineReactive(obj, "c", 3);

如此循环重复的工作, 当然可以交给代码啦!!

我们可以用Object.keys(obj)来枚举对象所有的key来进行响应式的绑定,看看下面代码 直接就可以把obj = {a:1, b:2 ,c:3}变成响应式的数据;

Object.keys(obj).forEach(function (key) {
    defineReactive(obj, key, obj[key]);
  });

但是遇到嵌套对象呢

比如我们有一个对象obj = {a: { b: 1} }
我们直接用defineReactive,只能把a变成响应式的,b的get/set不会出发回调;

// 我们试试以下结果
Object.keys(obj).forEach(function (key) {
    defineReactive(obj, key, obj[key]);
  })
obj.a;
obj.b;

image.png 所以我们需要进行一个递归操作;

// defineReactive(obj, key, val)
// 我们判断当这个val是对象时,我们继续进行响应式绑定
// 然后就有了以下代码

function defineReactive(obj, key, val) {
  observe(val);
  Object.defineProperty(obj, key, {
    get: function () {
      console.log("get ", key);
      return val;
    },
    set: function (newVal) {
      if (newVal !== val) {
        val = newVal;
         console.log("set", key);
      } else {
        return;
      }
    },
  });
}

function observe(obj) {
  if (typeof obj !== "object" || obj === null) {
    return;
  }
  Object.keys(obj).forEach(function (key) {
    defineReactive(obj, key, obj[key]);
  });
}

现在我们执行代码,结果如下

image.png

如果我们给属性赋值为对象呢

比如我们

let obj = {a:1}
obj.a = {b: 2}
obj.a.b


会输出什么呢,"set a; get b"吗

并不是

image.png

稍微改一下下

// 将set函数稍微修改一下 
set: function (newVal) {
      if (newVal !== val) {
        console.log("set ", key);
        // 如果newVal是对象,再次进行响应式处理
       observe(newVal);
        val = newVal;
      } else {
        return;
      }
 }

image.png

如果给对象新加一个属性呢

同上一条一样,因为没有进行响应式绑定(这是defineProperty天生的不足,它拦截不到新加的属性),所以我们需要再处理一下;Vue是给我们提供了一个新的api,Vue.set(),我们也可以实现一个set函数

function set(obj, key, val) {
  defineReactive(obj, key, val);
}

let obj = { a: 1 };
set(obj, "c", "c");
obj.c;

image.png

数组怎么进行响应式绑定呢

因为数组不能被defineProperty劫持,所以我们得找一个新的方法来进行劫持;

什么东西可以劫持数组的方法呢?

bingo ! 数组原型链,因为数组的方法都在 Array.prototype上,换句话说,我们可以“劫持原型链”

具体方法

  1. 找到数组原型
  2. 覆盖那些能够修改数组的更新方法,使其可以通知更新
  3. 将得到的新的原型设置到数组

代码实现

第一步 找到原型

// 找到数组原型
const originalProto = Array.prototype
// 备份一份, 修改备份
const arrayProto = Object.create(originalProto)

第二步 覆盖

['push', 'pop', 'shift', 'unshift', 'reverse', 'slice', 'sort'].forEach(
  (method) => {
    arrayProto[method] = function () {
      // 原始操作
      originalProto[method].apply(this, arguments)
      // 覆盖操作:通知更新
      console.log('数组执行 =>', method)
    }
  }
)

第三步 响应式绑定


function observe(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return
  }
// 在observe中加入以下数组的逻辑
  if (Array.isArray(obj)) {
    // 覆盖原型, 替换7个更新操作
    obj.__proto__ = arrayProto
    // 对数组内部元素进行响应式绑定
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
      observe(obj[i])
    }
  } else {
    new Observer(obj)
  }
}

之后数组使用原型链上的7个方法进行更新时,就会触发 console.log('数组执行 =>', method),真实的场景这里应该是换成更新dom的逻辑,类似于对象的set

原型链走向

image.png