通俗易懂的响应式| 8月更文挑战

298 阅读6分钟

这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战

什么是响应式

在面试中,大家经常被问到的一个问题就是,你了解什么是响应式吗?响应式的原理是什么?

类似这种问题,其实我们应该考虑的是说,怎么去说出面试官想要了解的内容,而不是我们认为他想知道的内容。

我觉得对于这种问题,一个通用的回答思路是,

首先,对基本概念有个简单的描述。

然后,从使用层面、原理层面对它进行分析。

最后,对我们额外了解的内容加以补充。

我认为,按照这种模式去回答,一定可以让你获得面试官的青睐。

那么接下来,我会按照这个顺序去解释一下,「什么是响应式」这个问题应该怎么回答。

简单的描述

响应式是一种 Web 开发常用的特性,在很多 Web 框架中都有使用,包括但不限于 Vue2、Vue3、AngularJS、Knockout 等。响应式的本质是当修改数据后,会自动触发一系列的事件,也就是常说的 发布订阅 设计模式。

使用层面 & 原理层面

发布订阅

✏️ 使用层面

因为 响应式 的本质是 发布订阅 设计模式。它在不同 Web 框架中的实现不尽相同,那么我们先来介绍一下 发布订阅 模式的使用方式。

(() => {
    subscribe('event1', (params) => {
      console.log('触发 event1-1: ' + params)
    })
    subscribe('event1', (params) => {
      console.log('触发 event1-2: ' + params)
    })
  
  	publish('event1', '拌饭')
})()

// 触发 event1-1: 拌饭
// 触发 event1-2: 拌饭

我们希望可以通过

  • subscribe 来订阅某个事件,并且传入一个回调函数(缓存该回调函数)
  • publish 来发布某个事件,并且传入一个参数(执行该事件对应的所有回调函数)

🚀 原理层面

我们已经了解了如何去使用 发布订阅 设计模式,现在我们来看看如何去实现。

(() => {
    const topics = new Map()

    // 订阅
    function subscribe (topic, callback) {
        if (topics[topic]) {
            topics[topic].push(callback)
        } else {
            topics[topic] = [callback]
        }
    }


  	// 发布
    function publish (topic, params) {
        (topics[topic] || []).forEach(fn => fn(params))
    }
})()

显而易见,我们需要一个 topics 对象去把所有 事件以及对应的回调函数去保存,所以开始需要声明一个 topics 对象。

最终 topics 的数据结构形如:

const topics = {
  event1: [
    (params) => {/* do something */},
    (params) => {/* do something */},
    /* ... */
  ],
  event2: [
    /* ... */
  ]
}

接下来,我们构造函数: subscribe 用于 订阅事件,将事件及回调函数存入 topics 对象中。publish 用于 发布事件,将参数以此传入当前事件对应的所有回调函数中。

至此,我们就实现了一个 发布订阅 模式。

📚 总结

发布订阅 模式作为响应式的基础,在 Web 开发中随处可见。在下文中的具体实现也会用到 发布订阅 模式。

让我们以 Vue2 来举例说明一下:

Vue2 的响应式

在 Vue2 中,诸多特性都涉及到了响应式,比如 data、computed、watch、renderEffect(data、computed 更新导致页面引用到的部分也发生更新)。这里我们以 data、computed 为例来解释一下其中的响应式原理,其他 api 同理。

✏️ 使用层面

我们通过例子来看看 Vue2 中 data、computed 是如何使用的。

new Vue({
  el: '#app',
  template: '<div>{{computeCount}}</div>',
  data: {
    count: 0,
  },
  computed: {
    computeCount() {
      return '[computed] ' + this.count
    }
  },
  mounted() {
    setInterval(() => {
      this.count += 1
    }, 1000)
  }
})

在线体验:codesandbox.io/s/quirky-he…

运行代码可以看到,每隔 1s 页面中会显示 '[computed] 0...' 。即当 this.count 更新时,computed 中的 computeCount 也自动发生了更新,进而触发 Vue 模板的重新渲染。

🚀 原理层面

这里我会讲清楚 Vue2 中 computed 的实现原理。但在这之前,我们先通过一个个问题逐步地接近这个答案。

日常开发中,在修改一个对象时,我们是如何获取到它的变更的呢?

比如我们想在 this.count 变更时,打印最新的 count 值,这个要怎么实现呢。

在 MDN 中记录了,Object.defineProperty [1] 可以检测到对象中某个属性的更新。

在这个例子中,只需要如下的代码,就可以获取到 this.count 更新后的值:

let value = data.count
Object.defineProperty(data, 'count', {
  get() {
    return value
  },
  set(newValue) {
    if (value !== newValue) {
      // 获取到 count 的最新值
      console.log(newValue)
      value = newValue
    }
  }
})

由于我们希望当 count 值变更的时候,去通知所有依赖 count 的 computed 函数重新执行。但我们怎么知道哪些 computed 函数依赖了 count 呢?

对于这个问题,我们先剖析一下,什么叫做 函数依赖了某个变量值。

我们知道,如果某个函数执行时使用了这个变量,那么我们就可以称为 这个函数依赖了这个变量。

那对于执行时使用了这个变量 的定义,是不是可以转化为 执行了这个变量的 getter 呢。

我们通过一段代码来理解一下:

(() => {
	const data = { count: 0 }
	Object.defineProperty(data, 'count', {
    get() {
      console.log('依赖了变量 count')
    }
  })
  
  const fn = () => {
    console.log('执行 fn')
    data.count
    console.log('fn 执行结束')
  }
})()

执行上面的代码,可以发现,当 fn 执行时,会调用 data.count,最终触发 count 的 getter 函数。最终打印出了 依赖了变量 count

到此,我们可以总结出,想知道哪些 computed 函数依赖了 count 这个变量,可以通过在 getter 中去做文章。代码如下:

+ let Listener = null

let value = data.count
+ let event = Symbol('')

Object.defineProperty(data, 'count', {
  get() {
+  	Listener && subscribe(event, Listener)

    return value
  },
  set(newValue) {
    if (value !== newValue) {
      // 获取到 count 的最新值
      console.log(newValue)
+     publish(event, null)

      value = newValue
    }
  }
})

const computed = {
  computeCount() {
  	return '[computed] ' + this.count
  }
}

+ const fn = computed.computeCount

+ Listener = fn
+ fn()
+ Listener = null

我们来解释一下这段代码。

首先,创建了一个 Listener 用于存放当前的 computed 函数。

然后我们通过 Symbol 创建了一个唯一的事件名。

还记得上一段提到的 发布订阅 实现吗?我们使用了 subscribe 来订阅了这个 Symbol 事件,然后回调函数传的是当前的 Listener。大家可能会想,这时候 Listener 不是 null 吗?确实,此时的 Listener 不存在,但是这一行代码是写在 count 的 getter 中的,此时 getter 并没有执行,当真正执行的时候,Listener 就会有值了。

之后,我们在 setter 中添加了一行 publish,它会将 getter 时缓存的 Symbol 事件名的所有回调函数全部执行。

最后一段中,我们将 computeCount 这个 computed 函数命名为 fn,然后在执行 fn 前,将它赋给 Listener,这个操作的目的是,在真正执行 fn 的时候,会触发 this.count 的 getter,最终触发 subscribe 的逻辑,实现 count 变量 和 Listener 产生关联。当我们修改 count 值时,就可以借助 发布订阅 模式,通过 publish 通知 computeCount 函数执行。

到此,我们已经讲完了了 Vue2 中关于 computed 的实现。

(() => {
  const topics = new Map();

  // 订阅
  function subscribe(topic, callback) {
    if (topics.has(topic)) {
      topics.set(topic, topics.get(topic).concat(callback));
    } else {
      topics.set(topic, [callback]);
    }
  }

  // 发布
  function publish(topic, params) {
    (topics.get(topic) || []).forEach((fn) => fn(params));
  }

  // Vue2 computed
  let Listener = null;

  function proxy(state) {
    let value = state.count;
    let event = Symbol("");

    let p = Object.defineProperty(state, "count", {
      get() {
        if (Listener) {
          subscribe(event, Listener);
        }

        return value;
      },
      set(newValue) {
        if (value !== newValue) {
          publish(event, null);

          value = newValue;
        }
      },
    });
    return p;
  }

  const component = {
    data: { count: 0 },
    computed: {
      computeCount() {
        const value = "[computed] " + this.count;
        console.log(value);
        return value;
      },
    },
  };

  const state = proxy(component.data);

  const fn = component.computed.computeCount.bind(state);

  Listener = fn;
  fn();
  Listener = null;

  const moutedFn = function () {
    setInterval(() => {
      this.count += 1;
    }, 1000);
  };

  moutedFn.call(state);
})();

📚 总结

在 Vue2 中,响应式的实现关键主要有:

  • 通过 Object.defineProperty 劫持对象的 getter 和 setter
  • 借助 发布订阅 模式将每个对象属性与监听事件联系在一起

Vue2 响应式实现的劣势在于:

  • Object.defineProperty 无法对数组进行有效的监听(Vue2 通过劫持重写 push、unshift 等八个数组方法,但对于下标的修改不能获取到 setter,所以才会有 $set 这个 api 的出现)

参考资料

[1] developer.mozilla.org/zh-CN/docs/…