likes
comments
collection
share

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个

作者站长头像
站长
· 阅读数 24

什么是响应式数据?

用过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>

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个

代码很简单就能实现这个功能,但这并不是响应式的:我们在每次数字变化后,依然还要手动调用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()

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

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个

这乍一看,我们好像已经实现了响应式:数据只要一改变,页面就会自动更新。 确实,这里是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按时间顺序输出结果为:

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个

很好,这一看就非常明了了: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;

运行结果是这样的:

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个

非常符合我们的预期,下一步就也很清晰了: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;
});

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个 此时代码正常执行,我们已经快要完成最核心的响应式逻辑!

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

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)

来看看效果:

30K必考面试题:Vue响应式是如何实现的?我来带你手把手写一个

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

依赖收集

上述解决的问题是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中对数组进行了特殊处理,处理方式和对象有很大的不同。涉及到很多原型链的知识,就不在这里展开啦,想要看关于数组的响应式实现的同学可以多多留言!

转载自:https://juejin.cn/post/7280786332712140858
评论
请登录