面试官:什么是数据响应式?我:要不给你手写一个?

什么是响应式数据?

用过Vue的同学一定对这个概念不陌生,熟悉的小伙伴可以直接看第二章。

对于传统前端开发来说,如果要实现这么一个需求:页面中显示一个数字,会根据每秒一次的请求结果实时更新。那代码可能是这样的:

setInterval((() => {
  const numElement = document.querySelector("#numBox");
  return () => {
    axios.get('number/get').then((data) => {
      numElement.innerHTML = data.number;
    })
  }
})(), 1000);

这么看起来也挺简便的,但这只是修改一个页面元素的内容,如果这一次要更新十个或者更多元素的内容呢?代码量就要蹭蹭往上涨,可维护性蹭蹭往下掉。

setInterval((() => {
  const numElement = document.querySelector("#numBox");
  const numElement1 = document.querySelector("#numBox1");
  const numElement2 = document.querySelector("#numBox2");
  const numElement3 = document.querySelector("#numBox3");
  const numElement4 = document.querySelector("#numBox4");
  const numElement5 = document.querySelector("#numBox5");
  ...
  const numElement10 = document.querySelector("#numBox10");
  return () => {
    axios.get('number/get').then((data) => {
      numElement.innerHTML = data.number;
      numElement1.innerHTML = data.number1;
      numElement2.innerHTML = data.number2;
      numElement3.innerHTML = data.number3;
      numElement4.innerHTML = data.number4;
      numElement5.innerHTML = data.number5;
      ...
      numElement10.innerHTML = data.number10;
    })
  }
})(), 1000);

如此下去,这个方法全在维护数据和DOM之间的关系去了,业务逻辑逐渐没淹没在其中。

但如果使用Vue这样的数据响应式框架:

<div>
  <p>{{ numbers.number }}</p>
  <p>{{ numbers.number1 }}</p>
  <p>{{ numbers.number2 }}</p>
  <p>{{ numbers.number3 }}</p>
  ...
  <p>{{ numbers.number10 }}</p>
</div>
setInterval(() => {
  axios.get('number/get').then((result) => {
    this.data.numbers = result;
  })
}, 1000);

效果立竿见影,代码量不会因为数据增多而增多。而之所以叫“响应式数据”,就是在于只要数据更新,页面就会同步进行更新。这样就只用维护数据,不用再费尽心思维护数据和DOM之间的关系啦。

响应式数据对于前端开发人员来说,确实大大提高了开发效率,但是响应式是如何实现的呢?这也是前端面试的一个高频问题,接下来我就带你一步步搞懂响应式的实现逻辑。

如何实现响应式?

响应式做了什么事情?其实无外乎就是在数据变化的时候,执行对应的逻辑(更新页面)而已。

那怎么实现这个功能呢?别急,我们一步一步来。

构建基础页面

先来制作这样一个页面:页面中会显示state.num的值,点击按钮后会对state.num进行+1,并在页面上对数字进行更新展示。

<div id="numBox"></div>
<button id="btn">+1</button>

<script>
  const state = { num: 1, numLength: 1 };

  function refreshNum() {
    document.querySelector("#numBox").innerHTML = state.num;
  }

  refreshNum();

  document.querySelector("#btn").addEventListener("click", function () {
    state.num += 1;
    refreshNum();
  });
</script>

屏幕录制2023-09-19 11.28.18.2023-09-19 11_28_53.gif

代码很简单就能实现这个功能,但这并不是响应式的:我们在每次数字变化后,依然还要手动调用refreshNum方法对页面进行更新,我们要把这个改造成自动的。

加入Object.defineProperty

<div id="numBox"></div>
<button id="btn">+1</button>

<script>
  const state = { num: 1, numLength: 1 };

  function refreshNum() {
    document.querySelector("#numBox").innerHTML = state.num;
  }

  refreshNum();

  document.querySelector("#btn").addEventListener("click", function () {
    state.num += 1;
    // 去除了refreshNum方法调用
  });

  let value = state['num']
  // 新增了Object.defineProperty方法
  Object.defineProperty(state, 'num', {
    get() {
      return value;
    },
    set(newVal) {
      value = newVal;
      refreshNum()
    },
  });
</script>

看起来大体上的代码依然没变,只是把点击事件中对refreshNum的调用删掉,并且新增了一个 Object.defineProperty()

运行代码,依然实现了同样的功能,页面数字随着按钮的点按而变化。

屏幕录制2023-09-19 11.28.18.2023-09-19 11_28_53.gif

这乍一看,我们好像已经实现了响应式:数据只要一改变,页面就会自动更新。
确实,这里是Vue响应式原理中最核心部分之一:Object.defineProperty(),完成了这一步,就打通了半条响应式的任督二脉。
那为什么新增了Object.defineProperty()就不需要去手动调用refreshNum方法了呢?

什么是Object.defineProperty

Object.defineProperty() 静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。

简单来说,Object.defineProperty()可以给对象定义/修改新属性,比如:

const object1 = {
  property1: 12
};

Object.defineProperty(object1, 'property2', {
  value: 42
});

console.log(object1.property1, object1.property2);	// 12, 42

上面的这个操作和“直接用大括号定义对象属性”是一样的效果。

Object.defineProperty()方法能做到大括号定义做不到的事:设置对象属性的gettersetter

getter

熟悉面向对象编程的同学应该对gettersetter非常了解,它们俩的功能本身也非常简单:getter就是“访问器”,当一个值被访问的时候就是获取它的getter方法的返回值,比如:

const object1 = {
  property: 0
};

let value = object1.property
Object.defineProperty(object1, 'property', {
  get() {
    return value
  }
});

console.log(object1.property);	// 0

此时,读取object1.property时会收到getter中的返回结果0

可能有同学会说这不是脱裤子放屁吗?我要读取这个值根本不需要来写这个getter也可以正常读取,绕这么大一圈是在玩呢?

其实getter的重点并不是在于读取值本身,而是在于控制读取值的逻辑

如果你要统计object1.property被读取了多少次,这时候getter就能派上用场了:

let count = 0
const object1 = {
  property: 0
};

let value = object1.property;
Object.defineProperty(object1, 'property', {
  get() {
    count++;
    return value;
  }
});

console.log(object1.property, count);	// 0, 1
console.log(object1.property, count);	// 0, 2
console.log(object1.property, count);	// 0, 3

这就是用以前直接定义的方式做不到的事情了,基于这个特性我们可以做更多有趣的事情,但这里我们暂时不延伸,我们先来说说setter

setter

理解了getter再来看setter就好理解多了,getter负责读取值的逻辑,setter自然就是负责设置值的逻辑:每一次设置修改这个值都会通过setter方法。

setter方法接收一个参数,也就是新的值,我们可以这样使用它:

const object1 = {
  property: 0
};

let value = object1.property
Object.defineProperty(object1, 'property', {
  get() {
    return value
  },
  set(newValue) {
    value = newValue
  }
});

object1.property = 1
console.log(object1.property);	// 1

这段代码看起来依然是脱裤子放屁,但和前面说的getter一样,setter的重点在于控制修改值的逻辑,所以我们依然可以在setter方法中做一些别的逻辑,比如统计修改次数:

let count = 0
const object1 = {
  property: 0
};

let value = object1.property
Object.defineProperty(object1, 'property', {
  get() {
    return value
  },
  set(newValue) {
    value = newValue
    count++
  }
});

object1.property = 1
console.log(object1.property, count);	// 1, 1
object1.property = 10
console.log(object1.property, count);	// 10, 2
object1.property = 20
console.log(object1.property, count);	// 20, 3
object1.property = 30
console.log(object1.property, count);	// 30, 4

到这里,我们已经知道gettersetter是怎么玩的了,我们再回过头看前面的“响应式代码”:

<div id="numBox"></div>
<button id="btn">+1</button>

<script>
  const state = { num: 1, numLength: 1 };

  function refreshNum() {
    document.querySelector("#numBox").innerHTML = state.num;
  }
  refreshNum();

  document.querySelector("#btn").addEventListener("click", function () {
    state.num += 1;
  });

  let value = state['num']
  Object.defineProperty(state, 'num', {
    get() {
      return value;
    },
    set(newVal) {
      value = newVal;
      refreshNum()	// 在setter中调用了refreshNum
    },
  });
</script>

反应快的同学肯定已经意识到我们前面的“响应式代码”是如何实现的了:每一次修改值都会调用setter方法,那我们就在setter中调用刷新页面数字的方法refreshNum,以此实现“自动刷新页面”的效果。

恭喜🎉!读到这里的你已经基本了解了Vue2的响应式核心原理的核心知识点Object.defineProperty

但我们发现现在的代码有个问题:现在我们只对state.num进行了响应式处理,但我们应该对state中所有的属性(比如state.numLength)全都进行响应式处理,我们该怎么做?

解决这个问题非常容易,直接上代码:

<div id="numBox"></div>
<button id="btn">+1</button>

<script>
  const state = { num: 1, numLength: 1 };

  function refreshNum() {
    document.querySelector("#numBox").innerHTML = state.num;
  }
  refreshNum();

  document.querySelector("#btn").addEventListener("click", function () {
    state.num += 1;
  });

  function defineReactive(obj) {
    for (const key in obj) {
      let value = obj[key];
      Object.defineProperty(obj, key, {
        get() {
          return value;
        },
        set(newVal) {
          value = newVal;
          refreshNum()
        },
      });
    }
  }
  defineReactive(state);
</script>

处理的逻辑非常简单,就是将state对象进行遍历,对其中的每一个属性都设置它的gettersetter,功能确实也可以正常运行。

但新问题又出现了:setter中调用的refreshNum方法是针对num属性值修改后调用的,而现在给其他属性设置的setter调用的依然也是refreshNum这个方法。

这可不就出问题了吗:现在就算我们只是修改state.numLength属性值,也会调用refreshNum方法对页面进行更新。

所以,我们需要对每一个属性都绑定它自己的setter逻辑,这该怎么做呢?

比如我们希望,每一次numLength变化时都只执行属于它的checkLength方法,如果值大于1就弹出警告框:

function checkLength() {
  if (state.numLength > 1) alert(`it's too long!!`)
}

这还不简单,让函数执行自定义函数,这不就是回调函数的套路嘛。我们熟悉的setTimeout方法,传入一个回调函数和等待时间,就可以在等待时间后由setTimeout来对我们传入的回调函数进行调用,这个我们都知道:

// 普通回调函数示例
function callback(params) {
  console.log('我是回调函数,我被调用啦');
}
setTimeout(callback, 1000);

refreshNumcheckLength不也就是个回调函数,我们想办法把回调函数传入对应的setter方法进行调用不就好啦?

但在setter中调用回调没有那么简单,毕竟setter也不能传入自定义参数,这个回调该怎么传进去呢?

带着这个疑问,我们先来看看我们要执行的这两个方法:

// 需要在state.num的setter中执行的方法
function refreshNum() {
  document.querySelector("#numBox").innerHTML = state.num;
}

// 需要在state.numLength的setter中执行的方法
function checkLength() {
  if (state.numLength > 1) alert(`it's too long!!`)
}

看一看里面的规律:

  1. 我们之所以要在state.num变化后去调用refreshNum方法,是因为refreshNum方法中读取了state.num
  2. 而之所以要在state.numLength变化后去调用checkLength方法,是因为checkLength方法中读取了state.numLength

总结来说,就是方法Func读取了数据data,所以Func的功能是依赖于data的,需要知道data实时的动态,在data变化后需要第一时间通知(调用)Func

也就是说,setter中的回调函数不需要我们手动传入,我们只用通过观察每个回调方法内部的代码,只要记录下方法内部要读取哪些数据,让这些被读取数据的setter调用这个回调方法就可以了。

道理我都懂了,但这该怎么做呢?怎么才能知道方法里面读取了哪些数据呢?

还记得我们前面讲过的getter吗?在每个数据被读取的时候,都会执行它的getter方法,对吧?那我们直接先把回调方法运行一次,在getter中守株待兔,不就知道哪个数据被调用了。

这下思路打开了,我们来试一试:

const state = { num: 1, numLength: 1 };

function defineReactive(obj) {
  for (const key in obj) {
    let value = obj[key];
    Object.defineProperty(obj, key, {
      get() {
        console.log(`我是${key},我被调用啦`);    // 新增输出
        return value;
      },
      set(newVal) {
        value = newVal;
      },
    });
  }
}
defineReactive(state);


function refreshNum() {
  console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);    // 新增输出
  document.querySelector("#numBox").innerHTML = state.num;
}
refreshNum();

function checkLength() {
  console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);    // 新增输出
  if (state.numLength > 1) alert(`it's too long!!`)
}
checkLength();

我们在两个方法中新增的console.loggetter中的console.log按时间顺序输出结果为:

截屏2023-09-20 11.29.58.png

很好,这一看就非常明了了:refreshNum方法读取的数据是state.numcheckLength方法读取的数据是numLength。我们接下来就只要把方法放到对应属性的setter中被调用就好了。

道理我都懂了,但这又该怎么做呢?现在getter虽然知道自己被读取了,但还不知道是谁在读取自己。我们先来解决这个问题。

解决这个问题就用一些简单粗暴的办法吧,逻辑很简单:我们在调用方法前,先把这个方法存到全局变量中,在getter中获取这个全局变量的值,这个值不就是正在读取属性的方法嘛!这不就完事了,思路明确了直接上代码:

const state = { num: 1, numLength: 1 };
let active;	// 用来存储正在执行方法的全局变量

function defineReactive(obj) {
  for (const key in obj) {
    let value = obj[key];
    Object.defineProperty(obj, key, {
      get() {
        console.log(`我是${key},我被${active}调用啦`);
        return value;
      },
      set(newVal) {
        value = newVal;
      },
    });
  }
}
defineReactive(state);


function refreshNum() {
  console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
  document.querySelector("#numBox").innerHTML = state.num;
}
active = refreshNum;		// 调用方法前先给全局变量action赋值
refreshNum();
active = null;

function checkLength() {
  console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
  if (state.numLength > 1) alert(`it's too long!!`)
}
active = checkLength;	// 调用方法前先给全局变量action赋值
checkLength();
active = null;

运行结果是这样的:

截屏2023-09-20 11.42.14.png

非常符合我们的预期,下一步就也很清晰了:getter中已经知道是谁在读取自己,我们把这个值存储起来,下次setter被调用的时候直接拿来用就好了。如果有同学问,为什么不直接在setter里面调用active,你好好想想一定能想明白的🤓。

这…道理我都懂,但这个值存在哪里呢?我们先把注意力集中在这里:

for (const key in obj) {
  let value = obj[key];
  Object.defineProperty(obj, key, {
    get() {
      console.log(`我是${key},我被${active}调用啦`);
      return value;
    },
    set(newVal) {
      value = newVal;
    },
  });
}

这个地方就先不展开讲了,直接说结论:这个for...in的每一次循环都对应一个对象属性,我们需要记录每一个对象属性依赖方法。

所以我们可以利用闭包的特性,将依赖方法记录在循环的体产生块级作用域中,这样setter也可以获取到当前属性需要调用的方法。

就像这样:

for (const key in obj) {
  let value = obj[key];
  let dep = [];    // 新增dep数组,用于记录依赖方法
  Object.defineProperty(obj, key, {
    get() {
      dep.push(active);    // 将调用getter时全局变量active中存储的方法记录到dep数组中
      console.log(`我是${key},我被${dep[0]}调用啦`);
      return value;
    },
    set(newVal) {
      value = newVal;
      dep[0]();    // 值改变时调用dep中存储的方法
    },
  });
}

现在这段代码做了这么几件事:

  1. 我们在循环体内定义一个用let声明的变量dep,这样每一次循环都会产生一个块级上下文,也就是每个对象属性都会对应一个dep变量;
  2. getter被调用时,我们将全局变量action的值赋值给局部变量depaction的值也就是此时正在读取当前属性的方法;
  3. setter被调用时,setter调用dep方法,此时dep的内容就是第2步中存储的回调方法,此时回调函数被正确调用。

看起来我们已经完成了这个逻辑!我们来把代码补全试试看:

const state = { num: 1, numLength: 1 };
let active;	// 存储正在执行方法的全局变量

function defineReactive(obj) {
  for (const key in obj) {
    let value = obj[key];
    let dep = [];
    Object.defineProperty(obj, key, {
      get() {
        dep.push(active);
        console.log(`我是${key},我被${dep[0]}调用啦`);
        return value;
      },
      set(newVal) {
        value = newVal;
        dep[0]();
      },
    });
  }
}
defineReactive(state);


function refreshNum() {
  console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
  document.querySelector("#numBox").innerHTML = state.num;
}
active = refreshNum;		// 调用方法前先给全局变量action赋值
refreshNum();
active = null;

function checkLength() {
  console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
  if (state.numLength > 1) alert(`it's too long!!`)
}
active = checkLength;	// 调用方法前先给全局变量action赋值
checkLength();
active = null;

document.querySelector("#btn").addEventListener("click", function () {
  state.num += 1;
});

屏幕录制2023-09-19 11.28.18.2023-09-19 11_28_53 3.gif
此时代码正常执行,我们已经快要完成最核心的响应式逻辑!

但是现在代码看起来有些怪怪的:

active = refreshNum;		// 调用方法前先给全局变量action赋值
refreshNum();
active = null;

active = checkLength;	// 调用方法前先给全局变量action赋值
checkLength();
active = null;

每个方法调用前后还要做存储全局变量的操作,这太繁琐了,我们先把这个逻辑优化一下。

我们新建一个方法watcher,我们将要依赖于响应式数据的方法都传给watcher方法来管理,使用起来就会方便很多:

const state = { num: 1, numLength: 1 };
let active;	// 存储正在执行方法的全局变量

function defineReactive(obj) {
  for (const key in obj) {
    let value = obj[key];
    let dep = [];
    Object.defineProperty(obj, key, {
      get() {
        dep.push(active);
        console.log(`我是${key},我被${dep[0]}调用啦`);
        return value;
      },
      set(newVal) {
        value = newVal;
        dep[0]();
      },
    });
  }
}
defineReactive(state);

// 新建watcher方法,用来管理需要依赖响应式数据的方法
function watcher(func) {
  active = func;		// 调用方法前先给全局变量action赋值
  func();
  active = null;
}

function refreshNum() {
  console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
  document.querySelector("#numBox").innerHTML = state.num;
}

function checkLength() {
  console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
  if (state.numLength > 1) alert(`it's too long!!`)
}

watcher(refreshNum)
watcher(checkLength)

document.querySelector("#btn").addEventListener("click", function () {
  state.num += 1;
});

还差一小步了!

不知道你有没有注意到,现在numLength变量还没有被修改过,没有任何逻辑会修改到numLength,所以依赖于numLengthcheckLength方法除了初始化之外从来没有被执行过。

这是因为我们还缺失了一个逻辑:当state.num发生变化时,调用后续新建的calculateLength方法对state.num进行字符长度计算。比如:1的字符长度1、10的字符长度2、100的字符长度3;并且把计算结果赋值给state.numLength;而state.numLength发生变化时,就调用checkLength方法进行长度检查,长度大于1就会进行弹窗提示。

所以要做到这一点,我们要先把calculateLength方法加入进来,并完成初次调用。

需要注意的是,因为refreshNumcalculateLength两个方法都是依赖于state.num的,所以state.num属性的dep中会有两个回调函数,我们需要对setter中调用回调函数的逻辑做一点点优化:

const state = { num: 1, numLength: 1 };
let active;

function defineReactive(obj) {
  for (const key in obj) {
    let value = obj[key];
    let dep = [];
    Object.defineProperty(obj, key, {
      get() {
        active && dep.push(active);		// active为空时不存储
        console.log(`我是${key},我被${dep}调用啦`);
        return value;
      },
      set(newVal) {
        value = newVal;
        dep.forEach((watcher) => watcher());		// 遍历dep列表,依次执行
      },
    });
  }
}
defineReactive(state);

function watcher(func) {
  active = func;	
  func();
  active = null;
}

function refreshNum() {
  console.log(`refreshNum方法执行,下面被调用的是我依赖的数据`);
  document.querySelector("#numBox").innerHTML = state.num;
}

// 新建的calculateLength方法对state.num进行字符长度计算,并赋值给state.numLength
function calculateLength() {
  state.numLength = String(state.num).length;
}

function checkLength() {
  console.log(`checkLength方法执行,下面被调用的是我依赖的数据`);
  if (state.numLength > 1) alert(`it's too long!!`)
}

watcher(refreshNum)
watcher(calculateLength)		// 初始化calculateLength方法
watcher(checkLength)

来看看效果:

屏幕录制2023-09-20 14.34.36.2023-09-20 14_35_36.gif

我们已经完全实现了想要的效果!我们刚刚完成了非常重要的响应式核心:依赖收集

依赖收集

上述解决的问题是Vue响应式学习中的一个难点,也就是“依赖收集”。

顾名思义,我们需要对每一个响应式数据的“依赖”进行“收集”,这个“依赖”可以理解为数据更新后需要执行的“回调函数集合”,“收集”也就是我们需要对这些“回调”进行记录,以便在后续数据变化时进行相应的调用。
在Vue中,Vue会对你的“模版代码”先进行解析:

<div>
  <p>{{ numbers.number }}</p>
  <p>{{ numbers.number1 }}</p>
  <p>{{ numbers.number2 }}</p>
  <p>{{ numbers.number3 }}</p>
  ...
  <p>{{ numbers.number10 }}</p>
</div>

Vue会通过watcher调用一个叫做updateComponent的方法,这个方法会把模版代码中所有的对象属性替换为对应的对象属性值,并最终把替换后的结果重新渲染到页面上。这个过程自然就会进入到这些对象属性的getter方法并完成依赖收集。

而后续一旦这些值发生变化,就会在setter中触发调用updateComponent方法,再次完成页面渲染。
虽然实际代码更加复杂,但你已经理解核心的基础响应式逻辑啦!

小节

当然我们完成的是一个最基础版的响应式数据逻辑,Vue中的实际代码比这个要多很多细节和功能,比如watcher在实际源码中也不仅仅是个方法,而是和dep一样是个类,它们内部都有非常复杂的逻辑和实现。

但源码依然是以这个DEMO的核心逻辑作为核心的,理解了前面的内容再去阅读源码会轻松很多。

要值得注意的是,本章节只是讲了普通对象如何实现响应式,但Vue2中对数组进行了特殊处理,处理方式和对象有很大的不同。

为什么数组响应式和对象响应式不一样?

众所周知,在JS中“数组”也是一种对象,那既然对象是利用Object.defineProperty来实现响应式的,按理说数组也一样可以调用这个方法才对!

让我们先来用Object.defineProperty在数组上试试:

<div id="arrayBox"></div>
<button id="btn">+1</button>

<script>
  let array = [1];

  for (const key in array) {
    let value = array[key];
    Object.defineProperty(array, key, {
      set(newVal) {
        value = newVal;
        refreshDocument();
      },
    });
  }

  function refreshDocument() {
    document.querySelector("#arrayBox").innerHTML = `[${array}]`;
  }
  refreshDocument();

  document.querySelector("#btn").addEventListener("click", function () {
    array[0] += 1;
  });
</script>

运行代码,可以看到我们已经通过最基础的Object.defineProperty实现了基础的响应式效果:

录屏2023-10-11 17.07.10.2023-10-11 17_07_58.gif

既然可以实现,那为什么Vue不是通过使用和普通对象一样的Object.defineProperty方式来给数组实现响应式呢?

说到这里,我们就要说到使用Object.defineProperty实现响应式的缺点了:

  1. 只能对现有属性进行监听,对新增属性或删除属性不能直接进行监听;
  2. 需要对监听对象进行属性遍历,性能消耗大;

先说第一点“只能对现有属性进行监听,对新增属性或删除属性不能直接进行监听”,很好理解:我们刚才遍历了array中的所有属性,对array中唯一的属性array[1]进行了Object.defineProperty设置,让array[1]在变化后可以通过setter调用刷新页面的方法refreshDocument。但如果我们动态地给array新增一个属性呢?比如这样:

document.querySelector("#btn").addEventListener("click", function () {
  array.push(2);
});

在点击按钮后,我们不再是修改array[1]的值了,而是通过push方法新增一个数组元素2,我们再来看看效果:
录屏2023-10-11 17.55.55.2023-10-11 17_56_28.gif

响应式果不其然失效了,但这也很合理,毕竟我们只对数组中的第一项进行了Object.defineProperty,而后面新增的值并没有被遍历设置,自然是没有响应式的。

所以在Vue2中,如果要给对象新增属性,直接设置是会让新属性丢失响应式的。比如这样:

data: {
  message: "Hello Vue!";		// this.message是响应式的
}

data.a = 10;		// this.a是非响应式的

这时的data.a是没有响应式的。如果要增加新属性,我们必须要通过Vue.set方法来新增,Vue才会对新属性设置响应式。

说完第一点,再来说第二点缺点:

Vue对于数组的响应式处理方式和对于对象的处理方式完全不同,熟悉Vue2的同学一定知道:在Vue2中如果直接修改数组元素,类似上面那样:array[0] += 1,是不会生效的;而你想要让你的修改被响应式监听到,需要使用pushpopsplice等数组原生方法才可以。

这是为什么呢?明明我们刚才用Object.defineProperty来设置数组元素就可以实现监控所有现有属性的修改,为什么Vue反而没有实现这个功能呢?

这就是因为Object.defineProperty的第二个缺点:性能太差。

试想一下,在日常工作中本就经常需要处理大量的数组,有时候一个数组就包含上千条数据,有可能在其中还有数组的嵌套。这时光是对这一个数组,Vue要实现响应式就至少要循环上千次,分别给每个数组元素都设置对应的Object.defineProperty。而如果数组元素有新增或删除,又需要对整个数组重新进行上千次遍历进行设置。

这个性能消耗,想想就害怕!

所以在Vue2中,对于数组的响应式处理走了一条截然不同的路。

数组的响应式实现

在Vue2中,只有调用数组原型上的原型方法来修改数组才可以被响应式监听,比如:

let array = [1];      

function refreshDocument() {
  document.querySelector("#arrayBox").innerHTML = `[${array}]`;
}
refreshDocument();

document.querySelector("#btn").addEventListener("click", function () {
  array.push(2);
});

我们要实现的效果就是,在这段基础代码的基础上,点击按钮后让页面更新显示新的数组,像是这样:

录屏2023-10-12 11.51.08.2023-10-12 11_51_43.gif

要怎么实现这个功能呢?我们先来梳理下思路:

Object.defineProperty来给对象元素添加响应式,是利用gettersetter的特性,在值被修改时通过setter调用对应的回调方法,完成对页面的更新;

而数组只需要在数组原型方法被调用时才需要执行对应的回调来更新页面,所以也就是说我们只需要在push方法被执行时同时执行更新页面的方法就可以实现了,类似这样:

document.querySelector("#btn").addEventListener("click", function () {
  array.push(2);
  refreshDocument();
});

这样确实实现了点击按钮后,页面元素被刷新。

录屏2023-10-12 11.51.08.2023-10-12 11_51_43 2.gif

但是,在Vue里我们可不需要在每次调用数组的push方法后,都手动调用刷新页面的回调方法。我们需要的效果是每一次push方法被调用后,回调方法都自动执行。要实现这个需求,我们要先来看看push方法是怎么被调用的。

何为原型方法?

熟悉原型链的同学可以快速略读这个章节。

我们一直在说,Vue中的数组在被调用部分“原型方法”后会更新页面,那这个“原型方法”到底是什么?和“普通方法”有什么区别呢?

先来看个例子:

const arr = [0, 1, 2, 3];
arr.func = function() {
  console.log('func被调用啦');
}
console.log(arr);
arr.func()

截屏2023-10-12 14.09.06.png

从输出结果可以看到,数组arr中除了有最开始定义的[0, 1, 2, 3]之外,还有后面新增的func方法。而我们调用arr.func也是可以正常运行func方法的。

但如果这时我们运行一个不存在的方法,比如arr.f()

截屏2023-10-12 14.12.53.png

这时自然代码就报错:“arr.f不是一个方法”,这也是自然,我们没有定义这个方法,当然运行不了。

但为什么我们这时候去运行arr.push()就不会报错呢?明明在刚才的console.log(arr)输出结果中我们也没有看到有这个push方法的存在。

截屏2023-10-12 14.26.20.png

可以看到,push方法就被定义在数组的prototype属性中,除此以外还能看到我们熟悉的splicepopmap等数组方法。

这个prototype就是数组的原型,而这些方法就是数组的原型方法。

原型和原型链不是这篇文章的重点,在这里我就简单说一下,如果有同学想看我聊聊原型和原型链的话记得留言告诉我

简单来说,每一个JS对象都通过[[Prototype]]指向一个自己的原型对象,比如每一个“原始数组”指向的“原型对象”都是“Array构造函数”的原型对象,原型对象中的方法就是原型方法;如果在数组上调用push方法,而数组中本身不包含这个方法,就会顺着往[[Prototype]],也就是数组的原型对象里面找,而数组的原型对象里是包含这个push方法的。

所以我们可以在数组中调用到数组本身里“不存在”的push方法。

利用原型实现方法实现回调执行

第一步 大胆尝试

我们现在想要做到的,不过就是在每次push方法被执行后,都调用对应的回调方法。知道了上述原型方法的执行逻辑后,如何做到这一步就有点思路了。

既然是push执行后就执行回调,那我们就像对象响应式中一样,利用Object.definePropertypush方法设置get方法,每当push方法被调用就会触发get方法执行,然后在get方法中调用回调不就好啦?说干就干,我们来试试:

let array = [1];

function refreshDocument() {
  document.querySelector("#arrayBox").innerHTML = `[${array}]`;
}
refreshDocument();

document.querySelector("#btn").addEventListener("click", function () {
  array.push(2);
});

const push = Array.prototype.push;
// 数组的push方法就在Array.prototype原型对象中
Object.defineProperty(Array.prototype, 'push', {
  get() {
    console.log('我是Array的push方法,我被执行了');
    refreshDocument();
    return push;
  }
})

录屏2023-10-25 14.07.30.2023-10-25 14_08_31.gif

可以看到,这个效果被完美实现了:每一次点击按钮都会调用array.push(2),从而触发push方法的get方法中的回调方法refreshDocument被调用。

但是真的就这么简单吗?我们再来看看另一种情况:array现在是我们希望被响应式监听的对象,但我们实际的代码中会有一些“非响应式数据”,也就是一些就算“数据改变”也不会引起“页面改变”的数据,这些数据在被修改后不应该触发回调方法。

但我们看看现在的逻辑:我们直接修改了Array原型对象上的push方法,只要是在这个页面上调用push方法,不论调用者是不是响应式数据,都会引起回调方法refreshDocument被调用。改改代码来看看是不是这样:

<button id="nonreactiveBtn">nonreactiveArray push(20)</button>

<script>
  // 只对data中的值进行响应式监听
  let data = {
    array: [1],
  };

  // 非响应式数组对象
  let nonreactiveArray = [10];

  function refreshDocument() {
    document.querySelector("#arrayBox").innerHTML = `[${data.array}]`;
  }
  refreshDocument();

  // 点击按钮后更改响应式数组的内容
  document.querySelector("#btn").addEventListener("click", function () {
    data.array.push(2);
  });

  // 点击按钮后更改非响应式数组的内容
  document.querySelector("#nonreactiveBtn").addEventListener("click", function () {
    nonreactiveArray.push(20);
  });

  const push = Array.prototype.push;
  Object.defineProperty(Array.prototype, "push", {
    get() {
      console.log(`我是Array的push方法,我被执行了`);
      setTimeout(() => {
        refreshDocument();
      }, 0);
      return push;
    },
  });
</script>

录屏2023-10-25 14.23.30.2023-10-25 14_24_13.gif

可以看到,在我们新增一个非响应式数组并用push方法对其进行修改后,get中的回调依然被执行了。虽然页面看起来没有变化,但其实每一次push的调用都触发了refreshDocument方法的执行,这无疑是没有意义的性能浪费。

第二步 走向正轨

所以Vue中的数组响应式用的并不是这个方式,但排除了一个错误答案后我们离真相又近了一步。Vue2的数组响应式是如何实现的呢?

我们前面说了,如果在array上调用push方法,会调用到array.[[prototype]]中的push方法。既然我们不能直接修改push,那我们可不可以在array.[[prototype]]这个原型对象上动刀子呢?比如我们先把array.[[prototype]]给改成自己写的一个对象。我们来试试:

let data = {
  array: [1],
};

function refreshDocument() {
  document.querySelector("#arrayBox").innerHTML = `[${data.array}]`;
}
refreshDocument();

document.querySelector("#btn").addEventListener("click", function () {
  data.array.push(2);
});

// 通过array.__proto__来获取array.[[prototype]],对其进行修改
data.array.__proto__ = {
  push: function () {
    alert("push");
  }
}

录屏2023-10-25 15.02.34.2023-10-25 15_03_20.gif

可以看到,我们已经通过重写array.[[prototype]]原型对象,来篡改了push方法。这已经完成了重要的第一步。

但现在又出现了新的问题:push方法原本的功能没有了,现在push方法只会调用alert,而不会去给数组新增一位元素。解决这个问题其实很简单,我们只需要在自己写的push方法中调用下Array原型上的push方法就好了:

data.array.__proto__ = {
  push: function (...args) {
	// 使用call方法来调用Array.prototype.push方法,把this设置为当前this。不懂this的同学留言告诉我
    Array.prototype.push.call(this, ...args);
    console.log(data.array);
  }
}

录屏2023-10-25 15.27.50.2023-10-25 15_28_34.gif

可以看到,我们已经实现了重写push方法的同时,依然保留了push方法原本的功能。这下离成功不就只剩一步之遥啦?现在只需要在我们自己写的push方法中调用回调refreshDocument,不就实现响应式了嘛:

// 只对data中的值进行响应式监听
let data = {
  array: [1],
};

function refreshDocument() {
  console.log('refreshDocument被调用啦,现在展示的数组是:', data.array);
  document.querySelector("#arrayBox").innerHTML = `[${data.array}]`;
}
refreshDocument();

document.querySelector("#btn").addEventListener("click", function () {
  data.array.push(2);
});

data.array.__proto__ = {
  push: function (...args) {
    Array.prototype.push.call(this, ...args);
    refreshDocument();
  },
};

录屏2023-10-25 15.41.01.2023-10-25 15_41_33.gif

奇怪的事情又发生了,从控制台的输出结果来看,refreshDocument确实被调用了,数组的值也被正确修改了,但为什么页面上展示的内容变成了这奇怪的样子?

直接说结论,refreshDocument方法会把数组array转换成字符串显示在页面上,而将数组正确转换成字符串的toString方法本身是在Array.prototype原型对象中的,我们现在因为改写了array.[[Prototype]],并且我们写的原型对象中并没有toString方法。所以当我们把array转换为字符串时,顺着原型链找到了Object.toString方法,我们看到的页面显示结果就是把数组传入Object.toString方法后得到的结果。

这一段没完全看懂也没关系,你就需要知道我们现在改写的原型对象中只有push方法,导致很多原本数组的方法都不可用了。比如这时我们写一个array.pop()方法调用试试:

截屏2023-10-25 15.53.37.png

这时不出意外地报错了,毕竟我们只写了push方法嘛,别的方法在我们改写的原型方法上就不存在了,自然就会报错“方法不存在”。

那这样岂不是我们要把Array原型对象上所有的方法都重写一遍才行?这为了实现一个响应式付出的代价也太了吧,毕竟Array原型对象中大多数的方法都不会改变数组,重写也没什么意义呀!这时该怎么办?

第三步 走向完善

我们前面说过:“如果在array上调用push方法,会调用到array.[[prototype]]中的push方法”。

但如果我们调用一个array.[[prototype]]上也不存在的方法呢?比如有个在array.[[prototype]]上不存在的方法叫valueOf,我们看看如果我们运行array.valueOf会发生什么:

截屏2023-10-25 15.59.30.png

可以看到,这时并没有因为array.[[prototype]]中没有valueOf方法而报错,而是正常执行了。这是因为如果我们获取一个在对象本身(array)和对象指向的原型对象(array.[[prototype]])中都不存在的属性时,JS并不会轻易放弃,而是会继续顺着array.[[prototype]]向上找。

记得我们前面说过“每一个JS对象都通过[[Prototype]]指向一个自己的原型对象”吗,而array.[[prototype]]也是一个对象,所以它自然也有一个[[prototype]]属性,指向更上一层的原型对象。

所以当我们执行array.valueOf()时,其实是执行到了array.[[prototype]].[[prototype]].valueOf(),这是不是就像一条链条把不同的方法都串联到了一起,这也就是所谓的“原型链”。

所以我们可以利用“原型链”的特点,解决上面“丢失Array原生方法”的问题。

其实代码非常简单,一行就能搞定:

data.array.__proto__ = {
  push: function (...args) {
    Array.prototype.push.call(this, ...args);
    refreshDocument();
  },
  __proto__: Array.prototype  // 将自定义对象的原型对象指向到 Array.prototype
};

此时我们再来看看页面效果:

录屏2023-10-25 16.08.14.2023-10-25 16_08_43.gif

这下不就完美实现了!这时虽然我们都自定义原型对象中依然不存在toString方法,但我们把自定义原型对象的[[Prototype]]指向到了Array.prototype,所以我们其实是通过array.[[Prototype]].[[Prototype]].toString找到了数组本身的toString方法,完美解决了上面的问题。

并且这时也不会影响其它非响应式数组,毕竟我们的操作都是针对data.array进行的!在现在这种实现方式中因为不会对数组元素进行遍历,也不会遇到前面说到的Object.defineProperty定义响应式的“性能太差”的问题,可以说是一箭双雕了!

虽说我们看起来已经很好得实现了数组响应式的功能,但现在的代码只是一个实现数组响应式的最基础版本,有三个主要问题需要完善:

  1. 现在数组修改后调用的回调方法依然是写死固定的,但实际的源码中肯定不会如此简单粗暴,要解决这个问题就要用到上一章节中“依赖收集”的知识;
  2. 现在只对push方法进行了响应式操作,但还需要对包括splicepopshift在内的会对数组本身产生修改的方法都进行改写;
  3. 现在是单独对data.array进行响应式监听,但我们需要对data中所有的数据都进行响应式监听,包括其中的基础类型、对象类型和数组类型的属性值。

结合上一篇文章和这篇文章中的实例代码,尝试着解决这三个问题,写完可以再找一份源码来改一改看看区别,搞定后你对于“Vue2响应式”和“依赖收集”就不会有任何问题啦,但一定要自己动手试一试,最少也要按照顺序把上面的代码一步一步走一遍,一定会让你有所收获!

本章小节

Vue2的响应式原理内容到此就告一段落啦,Vue2的“对象响应式”和“数组响应式”是分别通过Object.defineProperty和“改写原型对象”实现的,实现这些响应式功能的基础逻辑都并不复杂,不过都是在“值改变时调用对应的回调方法”这个逻辑,只是如何利用JS的特性来实现这样的逻辑是我们需要深入理解和学习的。

背了很多八股文却感觉无处可用的同学这下可以长舒一口气:八股文真是能用上的!什么闭包原型链,这下不都用上了。

当然实际的Vue源码比我们写的简易demo还是要复杂多了,但是核心逻辑依然一致,只是在核心逻辑的基础上增加了更多的性能优化、更多不同情况的逻辑判断。当你理解了这两篇文章的内容后再去阅读源码,一定会轻松很多!

有问题的前端知识的小伙伴还请多多评论留言,共同讨论相互学习!😁

  • 11
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值