[ vue 响应式实现原理简析 ] - 二:数据劫持和触发更新

258 阅读4分钟

系列文章

分析

前面我们已经实现了对于 html 的解析和渲染,能够从数据到视图了(这里有一个缺陷:如果文本节点是诸如:{{ var1 }} - {{ var2 }} 之类的格式,测无法成功解析和渲染,这个问题暂且略过,等到整个系统实现之后再来解决。)

现在摆在我们眼前的问题是,实现更改数据,能够更新视图的效果。想要实现这样的效果,我们可以利用发布订阅模式:

  • 实现一个 Observer 模块:用于监视所有的数据,当数据发生改变的时候发出通知
  • 实现一个 Watcher 模块:接收所有通知,当接到通知的时候触发对应的 Updater 模块去更新视图

实现

Observer

我们先来看看 Observer 模块的实现。

既然我们要对所有的数据进行劫持监听,那自然在 wm 实例创建的时候,就要对其进行初始化:

import Compile from "./compile.js";
import Observer from "./observer.js";

class Wvue {
  constructor(options = {}) {
    this.$options = options;
    this.$el = options.el;
    this.$data = options.data;

    if (!this.$el) throw new Error("请指定挂载点");
    
+    this._initObserve();
    this._initCompile();
  }

  _initCompile() {
    new Compile(this.$el, this);
  }

+  _initObserve() {
+    new Observer(this.$data);
+  }
}

export default Wvue;
+class Observer {
+  constructor(data) {
+    this.data = data;
+    }
+}
+export default Observer;

接下来我们遍历 data 对象,对其中的每一个属性进行监听:

class Observer {
  constructor(data) {
    this.data = data;

+    this.observe(this.data);
  }

+  observe(data) {
+    if (data && typeof (data === "object")) {
+      Object.keys(data).forEach(key => {
+        this.defineReactive();
+      });
+    }
+  }

+  defineReactive() {}
}
export default Observer;

这里有一个小问题,那就是 data 中的属性,有可能自身也是一个 object,不过有了前面的经验,很容易想到,这里使用递归即可:

class Observer {
  constructor(data) {
    this.data = data;

    this.observe(this.data);
  }

  observe(data) {
    if (data && typeof data === "object") {
      Object.keys(data).forEach(key => {
        this.defineReactive(data[key]);
      });
    }
  }

  defineReactive(value) {
+    this.observe(value);
  }
}
export default Observer;

值得一提的是,这里的结束条件在 observe 中,而不是在 defineReactive 中。

接下来我们对数据进行劫持,vue 2.x 中的方法是 Object.defineproperty,3.0 中的方法是 Proxy。(至于为什么要从 defineproperty 换成 Proxy,我们可以放在后面聊聊),这里我们就先通过最早的 defineproperty 来实现数据劫持,关于这个方法,还不太熟悉的同学可以看看官方文档:Object.defineproperty

class Observer {
  constructor(data) {
    this.data = data;

    this.observe(this.data);
  }

  observe(data) {
    if (data && typeof data === "object") {
      Object.keys(data).forEach(key => {
M        this.defineReactive(data, key, data[key]);
      });
    }
  }

M  defineReactive(obj, key, value) {
    this.observe(value);
+    Object.defineProperty(obj, key, {
+      enumerable: true,
+      configurable: false,
+      // 利用访问器完成数据劫持
+      get() {
+        console.log("get Value");
+        return value;
+      },
+      set(newVal) {
+        console.log(`set value, new value = ${newVal}`);
+        if (newVal !== value) {
+          value = newVal;
+        }
+      }
+    });
  }
}
export default Observer;

效果如下:

img-01

可以看到,这里我们已经完成了数据的劫持,每当数据发生变化,或者被访问的的时候我们都能做出相应的反应。不过当前我们的劫持还有一个问题,比如用户直接将 user 这个结构体进行重新赋值:user = { name : "Alice" },那么这个 user 将不再被劫持:效果如下:

img-02

并且从结构也可以看出来,新的 user 已经没有访问器了:

img-03

所以我们需要在新的值被设定之前,对其进行劫持监听:

class Observer {
  constructor(data) {
    this.data = data;

    this.observe(this.data);
  }

  observe(data) {
    if (data && typeof data === "object") {
      Object.keys(data).forEach(key => {
        this.defineReactive(data, key, data[key]);
      });
    }
  }

  defineReactive(obj, key, value) {
+    const _this = this;
    this.observe(value);
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: false,
      // 利用访问器完成数据劫持
      get() {
        return value;
      },
      set(newVal) {
+        _this.observe(newVal);
        if (newVal !== value) {
          value = newVal;
        }
+        return true;
      }
    });
  }
}
export default Observer;

当然,这里也可以使用箭头函数来修复 this 指向:

set: newVal => {
        _this.observe(newVal);
        if (newVal !== value) {
          value = newVal;
        }
        return true;
      }

效果如下:

img-03

依赖收集

完成了数据劫持,当下摆在眼前的问题变成了视图更新。那么这里我们要考虑一个问题:我们对所有的数据都进行了劫持,那么每一个数据发生改变的时候,是不是都有必要去通知监视器触发更新呢?

显然,问题的答案是否定的。如果我们修改的数据,在页面中任何地方都没有被用到,当它发生修改的时候,自然没有必要去触发视图的更新。所以这里我们需要一个模块,实现依赖收集,只有视图中使用到的数据,才添加依赖,当数据发生变化的时候去触发更新。那么这个模块核心功能有两个:

  • 依赖收集
  • 通知更新

代码如下:

+class Dep {
+  constructor() {
+    this.watchers = [];
+  }
+  addWater(w) {
+    this.watchers.push(w);
+  }
+  notify() {
+    this.watchers.forEach(w => {
+      w.updater();
+    });
+  }
+}
+export default Dep;

那么接下来就是上面提到的灵魂拷问,那些数据需要添加依赖呢?其实换个角度,这个问题很简单,页面上需要用到的数据需要添加依赖,而页面上需要用到的数据一定是在 compile 阶段去 data 中获取值的数据。那么就很明显了,我们只要在 get 访问器中添加依赖即可:

import Dep from "./dep.js";

class Observer {
  constructor(data) {
    this.data = data;

    this.observe(this.data);
  }

  observe(data) {
    if (data && typeof data === "object") {
      Object.keys(data).forEach(key => {
        this.defineReactive(data, key, data[key]);
      });
    }
  }

  defineReactive(obj, key, value) {
    this.observe(value);

+    const dep = new Dep();
    Object.defineProperty(obj, key, {
      enumerable: true,
      configurable: false,
      // 利用访问器完成数据劫持
      get: _ => {
+        dep.addWater("watcher");
        return value;
      },
      set: newVal => {
        this.observe(newVal);
        if (newVal !== value) {
          value = newVal;
        }
        return true;
      }
    });
  }
}
export default Observer;

Watcher

为什么这里添加的 watcher 是字符串呢?

原因自然是......watcher 还没写得嘛。

所以我们现在开始分析 wathcer。wathcer 的核心功能是当收到数据变化的通知的时候,去更新视图,那么自然 watcher 要和 updater 绑定起来,而前面的 compile 也是和 updater 绑定起来的,显然,当初始化视图结束之后就与建立 watcher 联系是不错的方案:

import Watcher from "./watcher.js";

const compileUtil = {
  text(node, exp, wm) {
    const value = this._getValue(exp, wm);
    updaterUtil.text(node, value);
    new Watcher();
  },

  html(node, exp, wm) {
    const value = this._getValue(exp, wm);
    updaterUtil.html(node, value);
    new Watcher();
  },
  module(node, exp, wm) {
    const value = this._getValue(exp, wm);
    updaterUtil.module(node, value);
    new Watcher();
  },
  on(node, exp, wm, event) {
    const fn = wm.$options.methods && wm.$options.methods[exp];
    node.addEventListener(event, fn.bind(wm), false);
    new Watcher();
  },

  _getValue(exp, wm) {
    return exp.split(".").reduce((d, c) => {
      return d[c];
    }, wm.$data);
  }
};

上面代码冗余部分较多,这里可以抽离一个 bind 方法来进行绑定和统一的 updater:

import Watcher from "./watcher.js";

const compileUtil = {
  text(node, exp, wm) {
M    this.bind(node, exp, wm, "text");
  },
  html(node, exp, wm) {
M    this.bind(node, exp, wm, "html");
  },
  module(node, exp, wm) {
M    this.bind(node, exp, wm, "module");
  },
  on(node, exp, wm, event) {
    const fn = wm.$options.methods && wm.$options.methods[exp];
    node.addEventListener(event, fn.bind(wm), false);
  },
+  bind(node, exp, wm, dir) {
+    const value = this.getValue(exp, wm);
+    updaterUtil[dir](node, value);
+    new Watcher();
+  },
  getValue(exp, wm) {
    return exp.split(".").reduce((d, c) => {
      return d[c];
    }, wm.$data);
  }
};

接下来我们来实现 watcher。首先分析一下,watcher 需要什么参数呢?我们知道,watcher 的核心功能是当接到通知的时候触发视图更新,那么自然它需要 exp,和 wm 来找到节点变量和值,接下来由于我们在 updater 中绑定 watcher,那就可以通过回调的方式获取到 watcher 中的新值,那么 watcher 需要的参数就定下来了:

import Watcher from "./watcher.js";

const compileUtil = {
  text(node, exp, wm) {
    this.bind(node, exp, wm, "text");
  },

  html(node, exp, wm) {
    this.bind(node, exp, wm, "html");
  },

  module(node, exp, wm) {
    this.bind(node, exp, wm, "module");
  },

  on(node, exp, wm, event) {
    const fn = wm.$options.methods && wm.$options.methods[exp];
    node.addEventListener(event, fn.bind(wm), false);
  },

  bind(node, exp, wm, dir) {
    const value = this.getValue(exp, wm);
+    const updaterFn = updaterUtil[dir];
+    updaterFn && updaterFn(node, value);
    new Watcher(wm, exp, value => {
+      updaterFn && updaterFn(node, value);
    });
  },

  getValue(exp, wm) {
    return exp.split(".").reduce((d, c) => {
      return d[c];
    }, wm.$data);
  }
};

const updaterUtil = {
  text(node, value) {
    node.textContent = value;
  },
  html(node, value) {
    node.innerHTML = value;
  },
  module(node, value) {
    node.value = value;
  }
};
export default compileUtil;
+class Watcher {
+  constructor(wm, exp, cb) {
+    this.wm = wm;
+    this.exp = exp;
+    this.cb = cb;
+  }
+}
+export default Watcher;

watcher 能在数据发生变化的时候触发视图更新,自然我们要去获取老值,然后从通知中获取新值,如果二者不同则触发回掉更新视图:

import compileUtil from "./utils.js";

class Watcher {
  constructor(wm, exp, cb) {
    this.wm = wm;
    this.exp = exp;
    this.cb = cb;
+    this.oldVal = this._getOldVal();
  }
+  _getOldVal() {
+    return compileUtil.getValue(this.exp, this.wm);
+  }
+  updater() {
+    const newVal = compileUtil.getValue(this.exp, this.wm);
+    if (this.oldVal !== newVal) {
+      this.cb(newVal)
+    }
+  }
}
export default Watcher;

watcher 的核心功能到这里基本差不多了,前面提到,我们在数据劫持的过程中,利用访问器将 watcher 收集起来,那么怎么获取这个 watcher 呢?new 肯定是不行的,因为是新的实例了,所以这里有个小技巧,在 _getOldVal 的时候,将 watcher 绑定在 Dep 上,这样就可以获取到 Watcher 了:

_getOldVal() {
+    Dep.target = this;
M    const oldVal = compileUtil.getValue(this.exp, this.wm);
+    Dep.target = null;
+    return oldVal;
}

这样,我们在依赖收集的时候,就可以利用 Dep.target 将 watcher 添加进数组中了:

get: _ => {
M    Dep.target && dep.addWater(Dep.target);
     return value;
}

有了监视器,有了更新方法,按理来说应该已经能实现数据驱动视图的更新了,结果如下:

img-04