# 探究 Vue 中的 MVVM

Vue 最吸引人的功能就是双向绑定,数据改变会触发视图的改变,视图改变同样也会触发数据的改变。下面我们一起探究这其中的原理吧。

# 监测数据变化

要想实现双向绑定,第一步要解决的问题就是如何知道数据变化了,即数据劫持。JavaScript 中访问器属性带给了我们思路。

# 访问器属性

访问器属性的特点:

  • 获取属性的值时调用 get 函数
  • 设置属性的值时调用 set 函数

因此无论是对属性的取值还是赋值操作,我们都可以执行自定义的逻辑代码。

# Observe

下面定义一个类用于将对象的属性变为可监测

class Observe {
  constructor(data) {
    // 判断是否为对象
    if (Object.prototype.toString.call(data) !== '[object Object]') {
      return;
    }

    // 遍历 data 中的每个属性,以此转化为访问器属性
    for(let key in data) {
      this.defineReactive(data, key, data[key]);
    }
  }
  // 变为可监测
  defineReactive(data, key, value){
    Object.defineProperty(data, key, {
      configurable: true, // 可配置
      enumerable: true,   // 可枚举
      get() {
        return value;
      },
      set(newValue) {
        // 值改变
        if (value !== newValue) {
          console.log(`属性 ${key} 改变了`);
          value = newValue;
        }
      }
    });
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

下面我们测试一下:

var data = {
  a: 1
}
// 将对象的属性转为可监测
new Observe(data);

data.a = 10;
console.log(data.a);
1
2
3
4
5
6
7
8

控制台打印: 图片1

# 处理属性对象

不难看出上面的 Observe 类只遍历了对象第一层属性,若属性是个对象,那么不能监测这个对象内部的属性。如下:

var data = {
  // 属性 a 的值为一个带有属性 b 的对象
  a: {
    b: 1
  }
}
new Observe(data);
// 修改属性 b
data.a.b = 2;
// 修改属性 a
data.a = 2;
1
2
3
4
5
6
7
8
9
10
11

此时,修改属性 b 的值控制台不打打印任何信息,而修改属性 a ,控制台依然会打印“属性 a 改变了”。

为了能够深层遍历每一个属性,我们采用递归的方式。修改 defineReactive 函数:

defineReactive(data, key, value){
  // 处理子属性
  new Observe(data[key]);

  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      return value;
    },
    set(newValue) {
      if (value !== newValue) {
        console.log(`属性 ${key} 改变了`);
        value = newValue;
      }
    }
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

再使用上面的测试代码测试一下,控制台打印“属性 b 改变了”。

# 页面编译

上面完成了监测数据变化的功能,下面我们来编写编译指令和模板。我们以下面这个 HTML 为例:

<div id="app">
  <input type="text" v-model="person.name" />
  <div>姓名:{{ person.name }}</div>
  <div>年龄:{{ person.age }}</div>
</div>

<script>
  var vm = new Vue({
    el: "#app",
    data: {
      person: {
        name: 'randy',
        age: '25'
      }
    }
  });
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# Vue 构造函数

先写一个简单的 Vue 构造函数。

class Vue{
  constructor(options) {
    // 将传入的属性都放到 Vue 实例上,便于获取
    this.$options = options;
    this.$el = options.el;
    this.$data = options.data;

    // 将 data 对象的属性设为可监测
    new Observe(this.$data);
  }
}
1
2
3
4
5
6
7
8
9
10
11

打印 Vue 实例,可以看到带有 options、el 和 $data 属性,并且 $data 对象中的每个属性都变为了可监测。查看页面: 图片2

# 编译函数

现在我们需要使用 data 对象中的数据去替换掉 HTML 代码中指令和模板语法,使其页面显示正常的数据。思路:拿到 HTML 元素,逐个替换里面的内容,然后将最终的 HTML 代码重新输出到页面。

  • 定义一个编译函数,参数:el 和 vm
    • el: Vue 挂载的根元素
    • vm: Vue 实例
class Compiler{
  constructor(el, vm) {}
}
1
2
3
  • 获取需要编译的 HTML 元素
    注意:el 既可以是 class 选择器,也可以是 HTMLElement 。
class Compiler{
  constructor(el, vm) {
    this.el = this.isHTMLElement(el)?el:document.querySelector(el);
    // 打印
    console.log(this.el);
  }

  // 判断是否为 HTML 元素
  isHTMLElement(node) {
    return node.nodeType === 1;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
  • Vue 构造器增加编译功能
export default class Vue{
  constructor(options) {
    // 省略之前的代码...

    new Compiler(this.$el, this);
  }
}
1
2
3
4
5
6
7

控制台会打印根元素。

  • 处理 v-model 指令

    • 增加 complie 方法,传入父元素和 Vue 实例
    class Compiler{
      // 省略之前的代码...
    
      compile(node, vm) {}
    }
    
    1
    2
    3
    4
    5
    • 遍历子节点,处理元素节点
    compile(node, vm) {
      // 获取子节点,返回值包括:
      // 文本节点(nodeType:3)
      // 元素节点(nodeType:1)
      let childNodes = node.childNodes;
      childNodes.forEach((childNode) => {
        // 判断是否为元素节点
        if(this.isElementNode(childNode)) {
          this.compileElement(childNode, vm);
        }
      });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12

    我们把 childNodes 打印出来看看: 图片3 childNodes 是一个类数组,可以看到父元素中一共有7个子节点,其中3个是元素节点:input、div、div,另外4个是文本节点,也就是我们在代码中换行符(见图中红框)。

    我们只需要处理元素节点,因此增加一个 isElementNode 函数去判断是否为元素节点。

    class Compiler{
      // 省略之前的代码...
    
      isElementNode(node) {
        return node.nodeType === 1;
      }
    }
    
    
    1
    2
    3
    4
    5
    6
    7
    8
    • 增加 compileElement 函数,处理带有 v-model 的元素节点
     class Compiler{
      // 省略之前的代码...
    
      compileElement(node, vm) {
        // 1. 获取元素节点的所有属性
        let attrs = node.attributes;
        [...attrs].forEach((attr) => {
          let {name, value} = attr;
          // 2. 判断属性中是否带有 v-model
          if (this.isDirective(name)) {}
        });
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    • 增加 isDirective 函数,判断属性是否为指令
    class Compiler{
      // 省略之前的代码...
    
      isDirective(attrName) {
        return attrName.startsWith("v-");
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    • 根据表达式取值并赋值给元素节点
    compileElement(node, vm) {
      let attrs = node.attributes;
      [...attrs].forEach((attr) => {
        let {name, value} = attr;
        if (this.isDirective(name)) {
          // 此时 value 为 person.name
          // 4. 从 data 对象中获取值
          let val = value.split('.').reduce((data, current) => {
            return data[current];
          }, vm.$data);
          // 5. 给元素节点重新赋值
          node.value = val;
        }
      });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15

    再看页面,输入框中以及填入“randy” 图片4

  • 处理模板语法

    回看 compile 函数,不难发现当前我们只能遍历到根节点的子节点,对于像

    <div>{{ person.name }}</div>

    这种元素,我们需要获取到其子元素,才能处理模板语法。因此修改 compile 函数:

    compile(node, vm) {
      let childNodes = node.childNodes;
      childNodes.forEach((childNode) => {
        if(this.isElementNode(childNode)) {
          this.compileElement(childNode, vm);
          // 递归 compile 函数,处理元素含有子节点
          this.compile(childNode, vm);
        } else {
          // 处理文本节点
          this.compileText(childNode, vm);
        }
      });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    • 增加 compileText 函数
    compileText(node, vm) {
      // 获取文本节点中的内容
      let content = node.textContent;
      // 判断是否为模板语法
      if(/\{\{(.+?)\}\}/.test(content)) {
        // 替换后的内容
        let newContent = content.replace(/\{\{(.+?)\}\}/g, (...args) => {
          let expr = args[1].trim();
          return expr.split('.').reduce((data, current) => {
            return data[current];
          }, vm.$data);
        });
        // 设置文本节点的内容
        node.textContent = newContent;
      }
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16

    首先我们利用正则表达式 /\{\{(.+?)\}\}/ 去匹配模板语法,然后使用 replace 函数去获取 {{}} 中的内容。这里需要注意一点:replace 中的正则表达式比上面的正则表达式在末尾多了一个 g,也就是 replace 中使用的全局搜索。为什么要使用全局搜索呢?下面我们先来试试去掉 g 的效果: 图片5 此时是能正常匹配的。但是我们实际写代码的时候,可能会这样写

    <div>{{ obj.a }} {{ ob.b }}</div>

    在一个 HTML 元素使用多个模板语法,再使用上面的正则匹配就会有问题: 图片6 使用全局搜索 图片7 下面我们再看看 replace 函数中指定的参数函数,该用法可以参考 replace 中指定一个函数作为参数 (opens new window)。这个函数中 args[1] 就是 中的表达式,接着根据这个表达式我们去 data 中取值然后返回,最后将替换后的字符串重新作为文本节点的内容。看看页面: 图片8
    至此我们完成了 Vue 中页面编译的功能。

# 处理回流和重绘

虽然上面完成了基本功能,都是存在一个问题:每次去处理 HTML 元素上的指令和模板语法的时候都会引起浏览器回流和重绘,这就太影响性能了。所以,我们可以先将 HTML 元素保存起来,等处理完所有的指令和模板语法之后再一次性去渲染页面。我们先暂时使用 DocumentFragment 来实现这个功能。

class Compiler {
  constructor(el, vm) {
    this.el = this.isElementNode(el)?el:document.querySelector(el);
    // 将 HTML 节点存入到 DocumentFragment 文档片段中
    let fragment = this.nodeToFragment(this.el);
    // 生成最终的 HTML
    this.compile(fragment, vm);
    // 插入到父元素中
    this.el.appendChild(fragment);
  }
  
  nodeToFragment(node) {
    let fragment = document.createDocumentFragment();
    let childNode;
    while(childNode = node.firstChild) {
      fragment.appendChild(childNode);
    }
    return fragment;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 增加一个工具模块

目前我们的编译逻辑只是针对于 v-model 和模板语法,但如果要扩展新的功能那么我们的 compileElement 中的逻辑就会被变得很臃肿,因此提供一个工具类去处理各个指令的逻辑,这样就便于扩展了。

let CompileUtil = {
  // 取值
  getValue(expr, vm) {
    return expr.split(".").reduce((data, key) => {
      return data[key]
    }, vm.$data);
  },
  // v-model 赋值
  model(node, expr, vm) {
    node.value = this.getValue(expr, vm);
  },
  // 模板语法
  text(node, expr, vm) {
   let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getValue(args[1].trim(), vm);
    });
    node.textContent = content;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

修改 compileElementcompileText

// 编译元素节点
compileElement(node, vm) {
  // 获取元素所有属性
  let attrs = node.attributes;
  // 将类数组转为数组
  [...attrs].forEach((attr) => {
    let {name, value} = attr;
    // 判断是否带有 v-* 指令
    if(this.isDirective(name)) {
      let [,directive] = name.split("-");
      CompileUtil[directive](node, value, vm);
    }
  });
}
compileText(node, vm) {
  let content = node.textContent;
  // 判断是否为模板语法
  if(/\{\{(.+?)\}\}/g.test(content)) {
    CompileUtil['text'](node, content, vm);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

# 双向绑定

以上我们完成了数据监测和页面编译,现在我们需要实现:1、修改 data 数据后页面跟着变化;2、在输入框输入数据后 data 数据也跟着变化。在 Vue 中实现双向绑定采用的是发布-订阅模式。

# Watcher

Watcher 是一个订阅者,订阅的是数据变化,当知道数据发生变化后,那 Watcher 就去更新视图。这也是观察者模式。

class Watcher{
  constructor(vm, expr, callback) {
    this.vm = vm;
    this.expr = expr;
    this.callback = callback;
  }
}
1
2
3
4
5
6
7

接受三个参数:

  • vm : Vue 实例
  • expr : 取值表达式,例如:person.nameperson.age
  • callback : 回调函数,即得知数据变化后,要做什么

# 增加一个 update 函数

比较数据旧值和数据改变后的新值,若两者不相等,那么执行 callback 函数

class Watcher{
  constructor(vm, expr, callback) {
    this.vm = vm;
    this.expr = expr;
    this.callback = callback;
  }
}
update() {
  let value = CompileUtil.getValue(this.expr, this.vm);
  if(this.oldValue != value) {
    this.callback(value);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13

# 增加一个 get 函数

获取旧值

class Watcher{
  constructor(vm, expr, callback) {
    this.vm = vm;
    this.expr = expr;
    this.callback = callback;
    this.oldValue = this.get();
  }
}
get() {
  let value = CompileUtil.getValue(this.expr, this.vm);
  return value;
}
update() {
  let value = CompileUtil.getValue(this.expr, this.vm);
  if(this.oldValue != value) {
    this.callback(value);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 创建实例

最好的订阅时机就是在页面编译的时候,因为此时的 data 对象未发生任何改变。

let CompileUtil = {
  model(node, expr, vm) {
    let fn = this.updater['modelUpdate'];
    // 创建一个观察者
    new Watcher(vm, expr, (newValue) => {
      fn(node, newValue);
    });

    let value = this.getValue(expr, vm);
    fn(node, value);
  },
  text(node, expr, vm) {
    let fn = this.updater['textUpdate'];
    let content = expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      // 创建一个观察者
      new Watcher(vm, args[1].trim(), () => {
        fn(node, this.getContentValue(expr, vm));
      });
      return this.getValue(args[1].trim(), vm);
    });
    fn(node, content);
  },
  getContentValue(expr, vm) {
    return expr.replace(/\{\{(.+?)\}\}/g, (...args) => {
      return this.getValue(args[1].trim(), vm);
    });
  },
  updater: {
    // model 赋值
    modelUpdate(node, value) {
      node.value = value;
    },
    // text 赋值
    textUpdate(node, value) {
      node.textContent = value;
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

# Dep 函数

当前我们有了数据变化的订阅者,还需要一个汇总这些订阅者的容器。当发布数据变化的时候,这个容器会调用这些订阅者的更新操作以便更新视图。

class Dep{
  constructor(){
    // 订阅者数组
    this.subs = [];
  }
  // 收集订阅者
  addSub(watcher) {
    this.subs.push(watcher);
  }
  // 发布数据变化
  notify(){
    // 调用每一个订阅者的更新
    this.subs.forEach((watcher) => watcher.update());
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 收集订阅者

有了装订阅者的容器,那么我们就该考虑在什么时候去收集这些订阅者了。回到 Watcher 类,当我们实例化的时候 new Watcher() ,构造函数中会执行 this.oldValue = this.get(),注意这是一个取值操作,而现在 data 对象的所有属性都是访问器属性,即每次取值都会调用属性对应的 get 方法,因此在 get 方法中我们去收集订阅者。

defineReactive(data, key, value){
  // 处理子属性
  new Observe(data[key]);
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      let dep = new Dep();
      dep.addSub(???);
      return value;
    },
    set(newValue) {
      if (value !== newValue) {
        value = newValue;
      }
    }
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

因为 addSub 方法接受一个 watcher 参数,而 get 中如何去拿到相应的 watche 呢?看下面的解决方法(我是想不出来):

export default class Watcher{
  constructor(vm, expr, callback) {
    // 省略代码...
  }
  get() {
    // 给 Dep 类定义一个 target 属性,然后指向当前 watcher
    Dep.target = this;
    let value = CompileUtil.getValue(this.expr, this.vm);
    // 重置
    Dep.target = null;
    return value;
  }
  update() {
    // 省略代码...
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

对应 get 的修改:

get() {
  let dep = new Dep();
  if (Dep.target) {
    dep.addSub(Dep.target);
  }
  return value;
}
1
2
3
4
5
6
7

因为 JavaScript 是单线程的,因此 get 每次都能正确收集到本属性对应的观察者。

# 发布数据变化

根据上面的思路,我们现在只需要在 set 函数中去发布数据变化的通知即可。

defineReactive(data, key, value){
  // 处理子属性
  new Observe(data[key]);
  let dep = new Dep();
  Object.defineProperty(data, key, {
    configurable: true,
    enumerable: true,
    get() {
      if (Dep.target) {
        dep.addSub(Dep.target);
      }
      return value;
    },
    set(newValue) {
      if (value !== newValue) {
        value = newValue;
        dep.notify();
      }
    }
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

我们要 let dep = new Dep() 放到外部去,不然 *set 是无法获取 dep 实例对象。查看页面,并且修改 data 数据: gif01

# 处理输入框

目前可以通过改变数据触发页面的改变了,不过在输入框输入后数据并没有发生改变,即视图不能驱动数据的改变。下面让我们来完成这个功能把。

# 添加输入事件

我们需要给 input 输入框添加一个输入事件,然后在事件回调函数中去修改 data 对象对应的属性值。

let CompileUtil = {
  // 省略上方代码...
  model(node, expr, vm) {
    let fn = this.updater['modelUpdate'];
    // 创建一个观察者
    new Watcher(vm, expr, (newValue) => {
      fn(node, newValue);
    });
    // 增加输入事件
    node.addEventListener('input', (e) => {
      var value = e.target.value;
      // 改变属性的值
      this.setValue(expr, vm, value);
    });

    let value = this.getValue(expr, vm);
    fn(node, value);
  },
  // 省略下方代码....
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 增加赋值方法

let CompileUtil = {
  // 赋值
  setValue(expr, vm, value) {
    expr.split(".").reduce((data, key, index, arr) => {
      // 找到对应的属性,然后赋值
      if(index === arr.length - 1) {
        data[key] = value;
      }
      return data[key];
    }, vm.$data);
  },

  // 省略下方代码....
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在输入框中输入值:
gif02

# 总结

以上内容主要围绕了监测数据(Observe)页面编译(Compiler)双向绑定(Watcher、Dep) 讨论实现一个 MVVM 的响应系统,旨在讲清楚思路,若有错误还望谅解。