阅读 7422

Vue数据双向绑定

我们从以下几点来了解:

  1. 数据双向绑定帮我们解决了什么问题?
  2. 数据双向绑定是什么?
  3. 数据双向绑定的原理是什么?
  4. 数据双向绑定如何实现的?

可以根据你目前的技术熟悉度,选择自己感兴趣的点。

如果,你已经很熟悉vue框架,那么可以看看我对双向绑定的实现方式,如果你有更好的方式,非常欢迎分享出来。 如果,你不熟悉vue,则可以完整的看一遍,对于之后使用vue多少会有点帮助。

双向绑定帮我们解决了什么问题?

在分析它之前,我们可以先回答一个问题:为什么需要双向绑定呢?让我们把时间移到十年前,来看看前端行业。

Vue的初衷

尤雨溪在谷歌从事原型设计期间,当时他接触到了 AngularJS ,他发现此框架虽然有数据绑定的功能,但是过于臃肿。于是自己写一个轻量的版本,于是 Vuejs 诞生了,可以说 Vuejs 最初的版本借鉴了很多 AngularJS 的思想。

所以双向绑定技术,自然非是 Vue 独有的特性,而是一个公共的解决方案,它目前已经被广泛的框架运用。

那么,我们再把时间再往前推一推,看看 AngularJS 的诞生是为了解决什么问题!

单页应用的出现

虽然 AngularJS 早在 2009 年就出现了,但 single-page application (SPA) 则更早与2003年就开始被讨论,主要实践为在 Web 浏览器中使用 JavaScript 来显示和控制用户界面(UI),实现应用级的程序逻辑以及与Web服务器通信。

而为了实现 SPA ,在 09 年之前主流的解决方案还是基于 jQuery 这类库结合 Ajax 技术进行开发,因为当时的需求与交互还算简单,但应对丰富的需求,中大项目往往都非常臃肿和难以维护了。

解决的问题与好处

而 AngularJS 的诞生,带来了前端的模块化、语义化标签、依赖注入等等,以及我们要讲的“双向数据绑定”功能。直接解决的问题,就是项目的开发效率提升和维护成本降低。

带来的好处主要体现在:

  • 项目质量:开启了前端的工程化的开发模式,以模块和规范化管理前端项目;
  • 研发效率:减少了开发者的代码量,以框架为支持的各类UI框架横空出世;
  • 团队管理:以及模块和组件的开发方式,让不同的角色成员可以独立专注于UI与业务逻辑;

至于数据绑定带来了什么?它就像框架的心脏,是框架诞生的基础。

数据双向绑定是什么?

从单向绑定

有没有想过,我们在做原生页面开发时,有涉及到“数据绑定“的概念吗?答案肯定是有的,比如下面的例子:

<p></p>
复制代码
const data = { value: 'hello' }
document.querySelector('p').innerText = data.value;
复制代码

通过 JavaScript 控制 DOM 的展示,就是数据(Data)到模板(DOM)的绑定,这就是数据单向绑定。

到双向绑定

而双向绑定就是在这个基础上,又扩展了反向的绑定效果,就是模板到数据的绑定。

上面的例子扩展以下:

<input onkeyup="change(event)" />
<p></p>
复制代码
const data = { value: '' }
const change = e => {
    // 更新输入值
    data.value = e.target.value;
    // 且,同步值的展示
    document.querySelector('p').innerText = data.value
}
复制代码

我们将与单向绑定的却别是,数据与模板是相互影响的,一方发生变化,另一方立即做出更新。在这个简单的例子中,我们认识了双向绑定,Vue便是在此概念下进行模块化抽象封装。

双向绑定的构成

上面的例子中,我们看到双向绑定涉及到的“模板”和“数据”,这二者是双向绑定的核心要素,也是前端框架的核心技术点。

就拿 Vue3 升级举例:

  • Virturl DOM,它就是模板操作和管理的升级;
  • 数据绑定,它就是数据逻辑管理的升级;

现在我们认识了数据绑定,也知道了它的意义,接下来就是看看Vue框架是怎么实现它的。不过在分析实现之前,必然先弄清楚实现原理吧。

双向绑定的原理是什么?

认识双向绑定在框架中的作用

因为 Vue 是数据双向绑定的框架,而整个框架的由三个部分组成:

  • 数据层(Model):应用的数据及业务逻辑,为开发者编写的业务代码;
  • 视图层(View):应用的展示效果,各类UI组件,由 template 和 css 组成的代码;
  • 业务逻辑层(ViewModel):框架封装的核心,它负责将数据与视图关联起来;

而上面的这个分层的架构方案,可以用一个专业术语进行称呼:MVVM。(详细的MVVM知识不在讲解范围内,只需要清楚双向绑定在此架构中作用和位置即可)

这里的控制层的核心功能便是 “数据双向绑定” 。自然,我们只需弄懂它 是什么,便可以进一步了解数据绑定的原理。

理解ViewModel

它的主要职责就是:

  1. 数据变化后更新视图;
  2. 视图变化后更新数据;

那么,就可以得出它主要由两个部分组成:

  1. 监听器(Observer):观察数据,做到时刻清楚数据的任何变化,然后通知视图更新;
  2. 解析器(Compiler):观察UI,做到时刻清楚视图发生的一切交互,然后更新数据;

然后把二者组合起来,一个具有数据双向绑定的框架就诞生了。

双向绑定如何实现的?

实现监听器

确保它是一个独立的功能,它的任务就是监听数据的变化,并提供通知功能。

监听数据变化常见的方式有三种:

  • 观察者模式(发布+订阅);
  • 数据劫持;
  • 脏检查;

第三种,是 AngularJS 最早所提供的监听数据变化方式,而 Vue 是采用前两者的组合,那么让我们来看看它们是怎么被运用的。

观察者模式

观察者模式是一种对象行为模式。它定义对象间的一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。

在观察者模式中,主导的是起通知作用的发布者,它发出通知时并不需要知道谁是它的观察者,可以有任意数目的观察者订阅并接收通知。

举例代码如下:

/**
 * 发布者
 */
function Subject() {
  
  // 单个发布者的所有订阅者
  this.observers = [];
  
  // 添加一个订阅者
  this.attach = function(callback) {
    this.observers.push(callback);
  };
  
  // 通过所有的订阅者
  this.notify = function(value) {
    this.observers.forEach(callback => callback(value));
  };
}

/**
 * 订阅者
 */
function Observer(queue, key, callback) {
  queue[key].attach(callback);
}

// ====

// 手动更新数据
function setData(data, key, value) {
    data[key] = value;

    // 通知此值的所有订阅者,数据发生了更新
    messageQueue[key].notify(value);
}

// ====

// 消息队列
const messageQueue = {};

// 数据
const myData = { value: "" };

// 将每个数据属性添加可订阅的入口
for (let key in myData) {
  messageQueue[key] = new Subject();
}

// 订阅 value 值的变化
Observer(messageQueue, "value", value => {
  console.warn("value updated:", value);
});

// 更新数据
setData(myData, "value", "hello world.");
setData(myData, "value", 100);
setData(myData, "value", true);
复制代码

可以在codepen上查看此运行效果(需要打开左下角的console查看输出,此时还不涉及DOM):codepen.io/nachao/pen/…

消息队列(queue)

我们可以看到,单纯的订阅和发布功能,它们彼此是独立存在的,因此还需要一个消息队列来关联他们。

上面的例子中,消息队列是作为一个全局存储变量,而在框架中则是封装起来的,每个 new Vue() 都有独立的一个队列,在下面我们将具体演示。

数据劫持

其实,就数据监听来说,观察者就已经满足了需求。但是,为什么和Vue不一样呢?因为 Vue 进行了优化,添加了数据劫持。

2009 年发布的 ECMAScript 5 中新增了一个 Object.definePropotype 的特性(具体使用不在此文章讲解范围内,请自行了解 developer.mozilla.org/zh-CN/docs/… ),能够定义对象属性的 getter 和 setter ,这可就厉害了,要知道 JavaScript 中一切皆对象。

那么我们的 setData(myData, 'value', 100); 就可以替换成 myData.value = 100; 的编写方式。从语法和使用上都变的更简单。

以下是变动后的代码:

// 发布者
function Subject() {
  this.observers = [];
  this.attach = function(callback) {
    this.observers.push(callback);
  };
  this.notify = function(value) {
    this.observers.forEach(callback => callback(value));
  };
}

// 订阅者
function Observer(queue, key, callback) {
  queue[key].attach(callback);
}

// ====

// 数据拦截器
function Watcher(data, queue) {
  for (let key in data) {
    let value = data[key];
    Object.defineProperty(data, key, {
      enumerable: true,
      configurable: true,
      get: () => value,
      set: newValue => {
        value = newValue;

        // 通知此值的所有订阅者,数据发生了更新
        queue[key].notify(value);
      }
    });
  }
  return data;
}

// ====

// 消息队列
const messageQueue = {};

// 数据
const myData = Watcher({ value: "" }, messageQueue);

// 将每个数据属性都添加到观察者的消息队列中
for (let key in myData) {
  messageQueue[key] = new Subject();
}

// 订阅 value 值的变化
Observer(messageQueue, "value", value => {
  console.warn("value updated:", value);
});

// 更新数据
myData.value = "hello world.";
myData.value = 100;
myData.value = true;
复制代码

可以在codepen上查看运行效果:codepen.io/nachao/pen/…

当然,ES2015 已经出来有点时间了,所以应尽用新的解决方案 Proxy(具体的使用请自行了解哦 developer.mozilla.org/zh-CN/docs/… ),讲到它主要是因为 Vue3 就采用了此方案。至于性能和具体的差异,之后有机会再单独分享,这里就不细说,让我们看看新的语法怎么写呢?

代码:

// 发布者
function Subject() {
  this.observers = [];
  this.attach = function(callback) {
    this.observers.push(callback);
  };
  this.notify = function(value) {
    this.observers.forEach(callback => callback(value));
  };
}

// 订阅者
function Observer(queue, key, callback) {
  queue[key].attach(callback);
}

// ====

// 数据拦截器 - 代理方式
function ProxyWatcher(data, queue) {
  return new Proxy(data, {
    get: (target, key) => target[key],
    set(target, key, value) {
      target[key] = value;

      // 通知此值的所有订阅者,数据发生了更新
      queue[key].notify(value);
    }
  });
}

// ====

// 消息队列
const messageQueue = {};

// 数据
const myData = ProxyWatcher({ value: "" }, messageQueue);

// 将每个数据属性都添加到观察者的消息队列中
for (let key in myData) {
  messageQueue[key] = new Subject();
}

// 订阅 value 值的变化
Observer(messageQueue, "value", value => {
  console.warn("value updated:", value);
});

// 更新数据
myData.value = "hello world.";
myData.value = 100;
myData.value = true;
复制代码

可以在codepen上查看运行效果:codepen.io/nachao/pen/…

以上,我们已经完成双向绑定中的数据层的功能,现在任何的数据变化,我们都可以及时的知道,并关联任何想要做的事情,比如更新视图。

接下来就是模板的操作了,也就是 Compile 模板解析功能。

模板解析(实现视图到数据的绑定)

对于DOM的操作,目前常见的方式有:

  • 原生或者基于库的DOM操作;
  • 将DOM转换为Virtual DOM,然后进行对比与更新;
  • 使用原生的Web Component技术;

以上三种的差异与性能等,之后有机会再单独分享。在Vue中使用的是 Virtual DOM 的方式,因为它比直接操作 DOM 所消耗的性能要少很多,也不存在 Web Component 的兼容性。

这三中方式的本质都是更新 DOM 的展示效果,只是方式不同而已,为了更简单的说明双向绑定的原理,我们就采用第一种方式。虚拟DOM是有很多独立的第三方库,如果有兴趣同学可以去研究哦。

解析器与DOM操作

这里的主要任务是:

  • 解析模板中所有的特定特性,例如:v-model、v-text、{{ }}语法等;
  • 关联数据展示到DOM;
  • 关联事件绑定到DOM;

因此,下面的功能只能需要满足:展示数据到模板上。

代码如下:

<div id="app">
  <input v-model="value" />
  <p v-text="value"></p>
</div>
复制代码
// 模板解析
function Compile(el, data) {

  // 关联自定义特性
  if (el.attributes) {
    [].forEach.call(el.attributes, attribute => {
      if (attribute.name.includes('v-')) {
        Update[attribute.name](el, data, attribute.value);
      }
    });
  }

  // 递归解析所有DOM
  [].forEach.call(el.childNodes, child => Compile(child, data));
}

// 自定义特性对应的事件
const Update = {
  "v-text"(el, data, key) {

    // 初始化DOM内容
    el.innerText = data[key];
  },
  "v-model"(input, data, key) {

    // 初始化Input默认值
    input.value = data[key];

    // 监听控件的输入事件,并更新数据
    input.addEventListener("keyup", e => {
      data[key] = e.target.value;
    });
  }
};

// ====

// 数据
const myData = { value: "hello world." };

// 解析
Compile(document.querySelector("#app"), myData);
复制代码

可以在codepen上查看运行效果:codepen.io/nachao/pen/…

目前我们定义的 DOM 解析,并没有关联数据监听,让我们来完成它!

完整的双向绑定

代码如下:

// 发布者
function Subject() {
  this.observers = [];
  this.attach = function(callback) {
    this.observers.push(callback);
  };
  this.notify = function(value) {
    this.observers.forEach(callback => callback(value));
  };
}

// 订阅者
function Observer(queue, key, callback) {
  queue[key].attach(callback);
}

// ====

// 数据拦截器 - 代理方式
function ProxyWatcher(data, queue) {
  return new Proxy(data, {
    get: (target, key) => target[key],
    set(target, key, value) {
      target[key] = value;

      // 通知此值的所有订阅者,数据发生了更新
      queue[key].notify(value);
    }
  });
}

// ====

// 模板解析
function Compile(el, data) {

  // 关联自定义特性
  if (el.attributes) {
    [].forEach.call(el.attributes, attribute => {
      if (attribute.name.includes('v-')) {
        Update[attribute.name](el, data, attribute.value);
      }
    });
  }

  // 递归解析所有DOM
  [].forEach.call(el.childNodes, child => Compile(child, data));
}

// 自定义特性对应的事件
const Update = {
  "v-text"(el, data, key) {

    // 初始化DOM内容
    el.innerText = data[key];

    // 创建一个数据的订阅,数据变化后更新展示内容
    Observer(messageQueue, key, value => {
        el.innerText = value;
    });
  },
  "v-model"(input, data, key) {

    // 初始化Input默认值
    input.value = data[key];

    // 监听控件的输入事件,并更新数据
    input.addEventListener("keyup", e => {
      data[key] = e.target.value;
    });

    // 创建一个订阅
    Observer(messageQueue, key, value => {
      input.value = value;
    });
  }
};

// ====

// 消息队列
const messageQueue = {};

// 数据
const myData = ProxyWatcher({ value: "hello world." }, messageQueue);

// 将每个数据属性都添加到观察者的消息队列中
for (let key in myData) {
    messageQueue[key] = new Subject();
}

// ====

// 解析+关联
Compile(document.querySelector("#app"), myData);
复制代码

可以在codepen上查看运行效果:codepen.io/nachao/pen/…

如此,一个非常简单的 MVVM 功能就完成了。当然,它仅仅是为了讲解原理非编写的,如果要做成向 Vue 这样的成熟框架,就需要将各个核心封装成模块,进行更扩展性的定义。

如果再简单的封装一下,看上去就是一个极简的Vue了。codepen.io/nachao/pen/…

代码如下:

// 观察者功能
// 发布者
function Subject() {
  this.observers = [];
  this.attach = function(callback) {
    this.observers.push(callback);
  };
  this.notify = function(value) {
    this.observers.forEach(callback => callback(value));
  };
}
// 订阅者
function Observer(queue) {
  this.queue = queue
  this.add = function(key, callback) {
    this.queue[key].attach(callback);
  }
}

// ====

// 数据拦截器
// 监听数据更新 - 代理方式
function ProxyWatcher(data, queue) {
  return new Proxy(data, {
    get: (target, key) => target[key],
    set(target, key, value) {
      target[key] = value;

      // 通知此值的所有订阅者,数据发生了更新
      queue[key].notify(value);
    }
  });
}

// ====

// 模板解析
function Compile(el, vm) {

  // 关联自定义特性
  if (el.attributes) {
    [].forEach.call(el.attributes, attribute => {
      if (attribute.name.includes('v-')) {
        Update[attribute.name](el, vm.data, attribute.value, vm);
      }
    });
  }

  // 递归解析所有DOM
  [].forEach.call(el.childNodes, child => Compile(child, vm));

  return el
}

// 自定义特性对应的事件
const Update = {
  "v-text"(el, data, key, vm) {

    // 初始化DOM内容
    el.innerText = data[key];

    // 创建一个数据的订阅,数据变化后更新展示内容
    vm.observer.add(key, value => {
      el.innerText = value;
    });
  },
  "v-model"(input, data, key, vm) {

    // 初始化Input默认值
    input.value = data[key];

    // 创建一个订阅
    vm.observer.add(key, value => {
      input.value = value;
    });

    // 监听控件的输入事件,并更新数据
    input.addEventListener("keyup", e => {
      data[key] = e.target.value;
    });
  }
};

// ====

// 封装
function Vue({ el, data }) {

  // initProxy
  this.messageQueue = {};
  this.observer = new Observer(this.messageQueue)
  this.data = ProxyWatcher(data, this.messageQueue);

  // initState
  for (let key in myData) {
    this.messageQueue[key] = new Subject();
  }

  // initRender
  // initEvents
  this.el = Compile(el, this);
}

// ====

// 数据
const myData = { value: "hello world." };

// 实例
const vm = new Vue({
  el: document.querySelector("#app"),
  data: myData
});
复制代码
文章分类
阅读
文章标签