Vue2 和 Vue3 响应式原理-00

52 阅读3分钟

1. 为什么需要响应式?

现在我们有这样一个需求,有一个按钮,需要在这个按钮点击时 couter 的数量要加一,并显示着页面中,实现如下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <h1></h1>
      <button>+1</button>
    </div>
    <script>
      const h1El = document.querySelector("h1");
      const buttonEl = document.querySelector("button");

      let counter = 0;
      h1El.textContent = counter;

      buttonEl.addEventListener("click", () => {
        counter++;
        console.log(
          "🚀 ~ file: 1.html:24 ~ buttonEl.addEventListener ~ counter:",
          counter
        );
      });
    </script>
  </body>
</html>

Screen-2023-03-24-161710(1).gif

我们发现这个 counter 的值是被我们改变了,但是页面并没有响应,关键是这段代码 h1El.textContent = counter, 我们需要在每次 counter 更新的时候及时的将最新的值渲染到页面中。我们对这个代码进行优化。注意,这里我们将渲染的行为抽离出为一个 render 函数,以方便我们后续重新渲染页面。在每次按钮点击后我们都需要重新的执行 render 函数。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <h1></h1>
      <button>+1</button>
    </div>
    <script>
      const h1El = document.querySelector("h1");
      const buttonEl = document.querySelector("button");

      let counter = 0;

      function render() {
        h1El.textContent = counter;
      }

      render();

      buttonEl.addEventListener("click", () => {
        counter++;
        render();
        console.log(
          "🚀 ~ file: 1.html:24 ~ buttonEl.addEventListener ~ counter:",
          counter
        );
      });
    </script>
  </body>
</html>

这里我们可以思考一下,能不能继续优化这个过程,当我们执行 counter++ 时能不能自动执行这个 render 函数。 其实这个就是响应式需要做的事,我对响应式的理解就是,当一个值更新时,依赖这个值的其他行为都会实时的拥有这个值的最新值并重新执行这个行为。如我们可以用 vue 中的 computed 来理解,当 computed 的回调函数中依赖的 ref 值更新时,其 computed 的值也会实时的更新。在实现响应式之前我们可以先了解一下 js 中 Proxy 的使用。

2. Proxy 的作用

MDN 对 Proxy 的解释 developer.mozilla.org/zh-CN/docs/…

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

简单的说 Proxy 会根据初始的对象,生成一个代理对象,当你操作这个代理对象,其中的一些行为可以被捕捉,以方便我们可以在这个行为中执行我们想要执行的一些事,这里我们主要了解: handler.get() 属性读取操作的捕捉器。和 handler.set() 属性设置操作的捕捉器。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const target = {
        counter: 0,
      };

      const targetP = new Proxy(target, {
        get(target, property, receiver) {
          console.log("-----------get--------------");
          console.log("🚀 ~ file: 1.html:17 ~ get ~ target:", target);
          console.log("🚀 ~ file: 1.html:17 ~ get ~ property:", property);
          console.log("🚀 ~ file: 1.html:17 ~ get ~ receiver:", receiver);
          return Reflect.get(target, property);
        },
        set(target, property, value, receiver) {
          console.log("-----------set--------------");
          console.log("🚀 ~ file: 1.html:24 ~ set ~ target:", target);
          console.log("🚀 ~ file: 1.html:24 ~ set ~ property:", property);
          console.log("🚀 ~ file: 1.html:24 ~ set ~ value:", value);
          console.log("🚀 ~ file: 1.html:24 ~ set ~ receiver:", receiver);
          return Reflect.set(target, property, value);
        },
      });

      console.log("counter: ", targetP.counter);

      setTimeout(() => {
        targetP.counter = 1;
        console.log("counter: ", targetP.counter);
      }, 1000);
    </script>
  </body>
</html>

Screen-2023-03-24-165622.gif

可以看到这里我们在 1s 后改变执行 targetP.counter = 1 其对应 get 里的代码会执行并且执行对应的赋值操作,这里的 Reflect 其实是对对象操作的一种补充,类似于 target[property] = value;这里有关 ProxyReflect 的具体用法,可以参考 MDN 的文档,ProxyReflect

3. Object.defineProperty() 的作用

MDN 文档 developer.mozilla.org/zh-CN/docs/…

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

这里我们使用 Object.defineProperty() 来实现 Proxy 中的 getset 功能。其中 Vue2 的响应式主要就是用 Object.defineProperty() 来实现, Vue3 换成了 ProxyProxy 的功能更加强大,如不需要完整的遍历对象的 key来设置每一个key的属性, 使用闭包来存储值。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      const target = {
        counter: 0,
      };

      Object.keys(target).forEach((key) => {
        let value = target[key];

        Object.defineProperty(target, key, {
          get() {
            console.log("-----------get--------------");
            console.log("🚀 ~ file: 1.html:47 ~ Object.keys ~ value:", value);
            return value;
          },
          set(newValue) {
            console.log("-----------set--------------");
            console.log("🚀 ~ file: 1.html:43 ~ set ~ newValue:", newValue);
            value = newValue;
          },
        });
      });

      console.log("counter: ", target.counter);

      setTimeout(() => {
        target.counter = 1;
        console.log("counter: ", target.counter);
      }, 1000);
    </script>
  </body>
</html>

Screen-2023-03-24-172555.gif

3. 实现响应式

以上我们看到我们只能在对象获取或更改其属性值的时候才可以捕获行为,这也是 Vue3 中使用 .value 的方式获取值的原因,就是为了调用其 get 方法来收集依赖。接下来我们回到最初的需求,我们需要改变 counter 的时候自动的执行 render 函数来渲染页面。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <h1></h1>
      <button>+1</button>
    </div>

    <script>
      const h1El = document.querySelector("h1");
      const buttonEl = document.querySelector("button");
      const target = {
        counter: 0,
      };

      const targetP = new Proxy(target, {
        get(target, property, receiver) {
          console.log("-----------get--------------");
          return Reflect.get(target, property);
        },
        set(target, property, value, receiver) {
          console.log("-----------set--------------");
          const id = setTimeout(() => {
            render();
            clearTimeout(id);
          });
          return Reflect.set(target, property, value);
        },
      });

      function render() {
        h1El.textContent = targetP.counter;
      }

      render();

      buttonEl.addEventListener("click", () => {
        targetP.counter++;
      });
    </script>
  </body>
</html>

当我们执行 targetP.counter++ 时,Proxy 捕获其行为执行 render 函数。使用 Object.defineProperty() 如下:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <div>
      <h1></h1>
      <button>+1</button>
    </div>

    <script>
      const h1El = document.querySelector("h1");
      const buttonEl = document.querySelector("button");
      const target = {
        counter: 0,
      };

      // const targetP = new Proxy(target, {
      //   get(target, property, receiver) {
      //     console.log("-----------get--------------");
      //     return Reflect.get(target, property);
      //   },
      //   set(target, property, value, receiver) {
      //     console.log("-----------set--------------");
      //     const id = setTimeout(() => {
      //       render();
      //       clearTimeout(id);
      //     });
      //     return Reflect.set(target, property, value);
      //   },
      // });
      Object.keys(target).forEach((key) => {
        let value = target[key];
        Object.defineProperty(target, key, {
          get() {
            console.log("-----------get--------------");
            return value;
          },
          set(newValue) {
            console.log("-----------set--------------");
            const id = setTimeout(() => {
              render();
              clearTimeout(id);
            });
            value = newValue;
          },
        });
      });

      function render() {
        h1El.textContent = target.counter;
      }

      render();

      buttonEl.addEventListener("click", () => {
        target.counter++;
      });
    </script>
  </body>
</html>

Screen-2023-03-24-175338.gif

这里只是简单的实现了一下响应式,当值改变时,会重新渲染所有的内容,并且我们的 render 函数是固定的,当增加一个新的值时,我们的 render 函数也需要改变。进一步优化,我们其实可以在执行 get 时收集那些地方依赖了这个值,当值改变时只执行依赖这个值的渲染函数,实现细粒化更新。其实我们就可以把一个个的组件看成一个渲染函数,当第一次渲染时收集这个函数的依赖,当值更新时,重新执行依赖中的渲染函数。