实现一个简易的响应式系统

865 阅读5分钟

不管你有没有用过 Vue,你都会经常听到 Vue 是一个响应式的库。最近看了一下 Vue 的源码,实现了一个简易版的响应式系统。

首先,看下面的例子

<template>
  <div id="app">
    <p class="name">{{fullName}}</p>
  </div>
</template>

<script>
var vm = new Vue({
  el: '#app',
  data: {
    firstName: 'liu',
    lastName: 'laohan'
  },
  computed: {
    fullName() {
      return this.firstName + ' ' + this.lastName
    }
  }
})
</script>

了解过 Vue 的都知道,当 firstName 或者 lastName 的值发生变化时,fullName 的值都会发生变化,并且视图也会更新。

你可能会好奇,Vue 是怎么知道 firstName 或者 lastName 发生变化,并且也保证 fullName 也得到变化的呢?

这跟我们平时看到的 javascript 的执行并不相同阿,例如

let firstName = 'liu';
let lastName = 'laohan';
let fullName = firstName + ' ' + lastName;

lastName = 'datou';

console.log(`name: ${fullName}`);

你可能想都不用想就知道上面的 log 会打印出什么内容

>> name: liu laohan

但在 Vue 中,我们希望的是 lastName 的内容发生变化时,fullName 的值也更新,即希望上面的 log 会打印出

>> name: liu datou

不幸的是,上面的 js 并不会是响应式的,如果我们希望 fullName 的内容是响应式的,还需要做一些其他的事情。

现在,我们要解决的问题就是把计算 fullName 的过程保留起来,然后在 lastName 发生变化时,再次执行一次这个计算的过程。

计算 fullName 的过程,其实也就是一个函数嘛,我们可以实现如下

let firstName = 'liu';
let lastName = 'laohan';
let fullName = '';

target = () => {
  fullName = firstName + ' ' + lastName;
};

record();
target();

这样子,我们就把计算 fullName 的过程封装在一个匿名函数中,然后调用 record 函数。

record 函数的实现方式也很简单

let storage = [];

function record() {
  storage.push(target);
}

现在我们把计算的过程存储在变量 storage 中了。当 lastName 的值发生变化时,我们只需要 replay 一下就可以了

lastName = 'datou';
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou

看起来是不是很简单,很容易的就实现了我们要达到的效果。 完整代码如下

let firstName = 'liu';
let lastName = 'laohan';
let fullName = '';
let target = null;
let storage = [];

function record() {
  storage.push(target);
}

function replay() {
  storage.forEach(cb => cb());
}

target = () => {
  fullName = firstName + ' ' + lastName;
};

record();
target();

lastName = 'datou';
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou

大概总结一下,其实就是以下两点:

  1. 记录目标值的计算过程,如上述的 target 函数,记录 fullName 的求值过程
  2. 在影响目标值的变量(如 firstName、lastName)发生变化时,重新计算目标值

可以看到,上面的实现方式是很简单粗暴的。如果之后 lastName 的值再次变化时,要想 fullName 的值还是响应的,那就要如下:

lastName = 'dahei';
replay();

每次变量 lastName 发生变化时,都要在后面跟着调用 replay 函数。这会使代码看起来很冗余。

接下来,我们尝试着用别的方式来实现响应式系统。

在之前,我们已经可以实现 fullName 随着 lastName 值变化而变化,如果我们想在 firstName 改变时,fullName 的值也跟着变化。按照上面的实现,增加一些代码,也是可以实现。只是你会发现,每次改变变量时,都要手动地在后面加一行 replay() 是很繁琐的。而作为一个喜欢偷懒的人来说,这很让人反感。

Object.defineProperty

如果你没有听过或者不了解 Object.defineProperty() ,可以看看 这里Object.defineProperty()方法允许我们修改一个对象的现有属性,我们要用到的就是属性描述符中的 getset

get: 一个给属性提供 getter 的方法,如果没有 getter 则为 undefined。当访问该属性时,该方法会被执行 set: 一个给属性提供 setter 的方法,如果没有 setter 则为 undefined。当属性值修改时,触发执行该方法。

如上所述,当我们访问一个对象的属性时,getter 方法会被调用,当修改一个属性的值时,setter 方法会被调用。可以看看下面的一个 demo:

const info = {
  firstName: 'liu',
  lastName: 'laohan',
};

Object.defineProperty(info, 'lastName', {
  get() {
    console.log('getter');
  },
  set() {
    console.log('setter');
  },
});

info.lastName; // => getter
info.lastName = 'datou'; // => setter

可以看到,当我们访问属性时,get 方法被调用了。而修改属性的值时, set 方法就被调用了。 你可能会疑问,这对实现响应式系统有什么用呢?还记得上面我们每次修改 lastName 时都在后面跟着调用 replay() 吗,这就是因为我们不知道什么时候 lastName 的值改变了,现在我们就可以通过 set 方法是否被调用而知道属性值是否发生变化了。那既然 get 方法被调用了,就表示这个属性是被访问了,是不是我们也可以通过在 get 方法里存储依赖这个属性的目标值的计算过程呢,答案是肯定的。

const info = {
  firstName: 'liu',
  lastName: 'laohan',
};

Object.keys(info).forEach(key => {
  let internalValue = info[key];
  Object.defineProperty(info, key, {
    get() {
      // todo: 存储依赖该属性的目标值的计算过程,如之前的record
      return internalValue;
    },
    set(val) {
      internalValue = val;
      // todo: 重新调用目标值的计算过程,如之前的 replay
    },
  });
});

现在,差的就是在 get 和 set 方法里面实现类似我们之前的 record 和 replay 方法了。 在这个 demo 中,直接使用上面的方法也不会有什么问题。但为了有更好的复用,我们换种实现方式。

class Dep {
  constructor() {
    this.subs = [];
  }
  depend() {
    if (target && !!this.subs.includes(target)) {
      this.subs.push(target);
    }
  }
  notify() {
    this.subs.forEach(sub => sub());
  }
}

这次,我们就把目标值的计算过程存储到 subs 数组中了,然后用 notify 代替之前的 replay。 在实际应用中,target 的值会发生变化的。比如在 vue 中,target 有时候是更新组件,有时候是更新 computed 的值。所以在这里用一个 watcher 函数封装一下 target 的行为。

watcher(() => {
  fullName = firstName + lastName;
});

把上面的几个点整合在一起,完整的代码就是

const info = {
  firstName: 'liu',
  lastName: 'laohan',
};

let target = null;

class Dep {
  constructor() {
    this.subs = [];
  }
  depend() {
    if (target && !!this.subs.includes(target)) {
      this.subs.push(target);
    }
  }
  notify() {
    this.subs.forEach(sub => sub());
  }
}

Object.keys(info).forEach(key => {
  let internalValue = info[key];

  const dep = new Dep();

  Object.defineProperty(info, key, {
    get() {
      dep.depend();
      return internalValue;
    },
    set(val) {
      internalValue = val;
      dep.notify();
    },
  });
});

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

watcher(() => {
  const fullName = info.firstName + info.lastName;
});

参考链接

Build a Reactivity System

欢迎讨论

地址