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

什么是响应式数据?

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

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

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

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

javascript 复制代码
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这样的数据响应式框架:

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

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

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

如何实现响应式?

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

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

构建基础页面

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

html 复制代码
<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>

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

加入Object.defineProperty

html 复制代码
<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()

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

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

什么是Object.defineProperty

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

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

javascript 复制代码
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方法的返回值,比如:

javascript 复制代码
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就能派上用场了:

javascript 复制代码
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方法接收一个参数,也就是新的值,我们可以这样使用它:

javascript 复制代码
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方法中做一些别的逻辑,比如统计修改次数:

javascript 复制代码
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是怎么玩的了,我们再回过头看前面的"响应式代码":

html 复制代码
<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)全都进行响应式处理,我们该怎么做?

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

html 复制代码
<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就弹出警告框:

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

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

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

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

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

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

javascript 复制代码
// 需要在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中守株待兔,不就知道哪个数据被调用了。

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

javascript 复制代码
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按时间顺序输出结果为:

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

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

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

javascript 复制代码
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;

运行结果是这样的:

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

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

javascript 复制代码
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也可以获取到当前属性需要调用的方法。

就像这样:

javascript 复制代码
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步中存储的回调方法,此时回调函数被正确调用。

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

javascript 复制代码
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;
});

此时代码正常执行,我们已经快要完成最核心的响应式逻辑!

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

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

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

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

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

javascript 复制代码
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中调用回调函数的逻辑做一点点优化:

javascript 复制代码
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)

来看看效果:

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

依赖收集

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

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

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

相关推荐
崔庆才丨静觅13 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅14 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅14 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅14 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment14 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅15 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊15 小时前
jwt介绍
前端
爱敲代码的小鱼15 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax