MVVM双向绑定全量版整理

2019 年 11 月 25 日

MVVM双向绑定全量版整理

随着各大前端框架的崛起,前端技术热词不断演进,我们只有知道发展的原因,才能去理解各项技术的优劣,根据应用的实际情况做出最合适的技术栈选择。

当前前端领域的前沿特性,双向绑定必占一席,双向绑定是怎么来的?各大框架如何实现双向绑定?我们怎样做出选择?本文对此作了全面整理说明。


发展背景


早期的 Web 开发主要基于 MVC 模式,MVC 即Model-View-Controller的缩写,表示模型-视图-控制器,一个标准的 Web 应用组成如下:


  • View用来把数据以某种方式呈现给用户;

  • Model数据;

  • Controller接收并处理来自用户的请求,将Model返回给用户。



这种 MVC 架构模式对于简单的应用是合适的,符合软件架构分层的思想。但随着 H5 的发展以及当前各种页面复杂操作行为及数据的出现,MVC 暴露出了痛点问题:


  1. 代码中大量调用DOM API时,处理繁琐,使得代码难以维护。

  2. 当Model频繁发生变化时,开发者需要主动更新到View;用户操作导致Model发生变化时,同样需要将数据同步到Model中,很难维护复杂多变的数据状态。


MVVM 是Model-View-ViewModel的缩写,VM 代替了 C,改变了通信方向,View 与 Model 不发生联系,通过 ViewModel 传递。可以做到 View 层用户操作时,ViewModel 层数据同步更新,ViewModel 数据变化时,支持同步更新到 View 层,提升了数据频繁变化时的代码可维护性。这里的 View 与 ViewModel 之间的双向同步过程,我们称之为双向绑定。



用一张动图来感受一下,表单 change 产生数据变化时自动更新 ViewModel, ViewModel 因外界事件导致数据改变时会同步到 View。



当前热门的前端框架 Angular 和 Vue 都主打 MVVM 模式,建立视图层与视图模型层之间的数据连接,可以轻松实现表单变化的数据反馈到模型层。而 React 框架则推荐单向数据流,使用自身 render 机制完成视图渲染,实际上只担任了 View 层。我们来看一看各个不同框架对此都做了怎样的工作。


Angular 的脏值检测


脏值检测是 Angular 的数据更新思路,”脏值“意为dirty data,表示当前数据与上一轮 UI 更新的数据不同,Angular 通过监听数据异步更新,采用比较不同 component 组件中方式更新 DOM。总结起来, 主要有如下几种情况可能改变数据:


  • 用户输入事件,比如click事件;

  • 请求服务端数据(XHR);

  • 定时事件,比如setTimeout,setInterval。


上述三种情况都有一个共同点,即这些导致绑定值发生改变的事件都是异步发生的,如下为 JavaScript 的异步机制:



左边表示将要运行的代码,这里的 stack 表示 JavaScript 的运行栈,而 WebApi 则是浏览器中提供的一些 JavaScript 的 API,TaskQueue 表示 JavaScript 中任务队列,因为 JavaScript 是单线程的,异步任务在任务队列中执行。如果这些异步的事件在发生时能够执行 Angular 重写的异步事件,通知到 Angular 框架,那么 Angular 就能及时的检测到变化。Angular 在这里使用了 Zone.js 做异步处理的脏值检测,细节可以查看《Zone.js究竟是如何工作的》以及angular/zone.js源码


Angular 的每一个 Component 都对应有一个changeDetector,意为变化检测器。由于我们的多个 Component 是一个树状结构的组织,一个 Component 对应一个 changeDetector,所以 changeDetector 之间同样是一个树状结构的组织。


我们用一个图例来进行说明检测到脏值变化后的更新过程,下图每一个模块都是一个 changeDetector 变化检测器,红色区块表示有 UI 更新的变化检测器,左侧为脏值变化检测前,右侧为有脏值变化时的 UI 更新。根据上述 EventLoop 机制,Angular 框架层捕获到异步事件对一个 component 数据更新后,从根组件开始,通知当前组件链路及以下链路,进行 View 层更新(OnPush 模式)。因此,无论是从 V 层出发的用户输入事件,还是 VM 处产生的定时、Http 事件,都能保证数据更新、界面更新!




但是自 Angular1 推出脏值检测机制以来,在性能上一直饱受质疑,原因如下:


  1. 如果有一个ComponentA数据修改,影响了下路ComponentB。

  2. 下路的componentB数据在更新时触发了数据调整机制,下路Component更新的短时间过程中,又影响了ComponentA。那么最终是否会形成ComponentA、ComponentB的检测循环?


对于以上问题,Angular1 的解决方式是,如果有 10 次往复循环,就不再进行继续检测。无论是性能还是结果,都不能令人满意。因此 Angular2 以脱胎换骨的方式进行了重构,支持 dev 开发模式与 prod 线上模式,prod 线上模式只进行单向数据流检测,并支持设置ChangeDetectionStrategy变化检测策略,具备局部组件 View 层更新能力,因此性能上有了明显突破。



Vue 的数据劫持+发布订阅


Vue 的双向绑定策略基础是数据劫持,在 Vue2.0 中使用了 ES5 语法 Object.defineProperty,来劫持各个属性的 setter/getter,在数据变动时发布消息给订阅者(Wacther), 触发相应的监听回调。先来看一下这个 ES5 特性,我们可以通过 Object.defineProperty 这个方法,直接在一个对象上定义一个新的属性,或者修改已存在的属性,最终这个方法会返回该对象,如下为简单说明,对该特性不了解的同学可以查看《JavaScript 高级程序设计》的第六章,或者在线访问MDN Web文档


var o = {};var value = 1;Object.defineProperty(o, 'a', {  get: function() { return value; },  set: function(newValue) { value = newValue; },  enumerable: true,  configurable: true});o.a; // 1o.a = 2;o.a; // 2
复制代码


结合这一特定与发布订阅机制,可以实现完整的双向绑定。如下所示,Observer 数据监听器能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者,内部采用 Object.defineProperty 的 getter 和 setter 来实现。


Compile 指令解析器,它的作用对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。


Watcher 订阅者, 作为连接 Observer 和 Compile 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数。


Dep 消息订阅器,内部维护了一个数组,用来收集订阅者(Watcher),数据变动触发notify 函数,再调用订阅者的 update 方法。


当执行 new Vue() 时,Vue 就进入了初始化阶段,一方面会遍历 data 选项中的属性,用 Object.defineProperty 将它们转为 getter/setter,实现数据变化监听功能;另一方面,Vue 的指令编译器 Compile 对元素节点的指令进行扫描和解析,初始化视图,并订阅 Watcher 来更新视图, 此时 Wather 会将自己添加到消息订阅器中(Dep),初始化完毕。当数据发生变化时,Observer 中的 setter 方法被触发,setter 会立即调用 Dep.notify(),Dep 开始遍历所有的订阅者,并调用订阅者的 update 方法,订阅者收到通知后对视图进行相应的更新。



使用 Object.defineProperty 这个特性存在一些明显的缺点,总结起来大概是下面两个:


  1. Object.defineProperty无法监控到数组下标的变化,当监控数组数据对象的时候,实质上就是监控数组的地址,地址不变也就不会被监测到。为了解决这个问题,经过Vue内部处理后可以使用push、pop、shift、unshift、splice、sort、reverse来监听数组。

  2. Object.defineProperty只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历。Vue 2.x里,是通过 递归 + 遍历 data 对象来实现对数据的监控的,如果属性值也是对象那么需要深度遍历,显然如果能劫持一个完整的对象是才是更好的选择。


由于只针对了以上八种方法进行了 hack 处理,所以其他数组的属性也是检测不到的,还是具有一定的局限性。Vue3.0 中使用了 ES6 语法 Proxy,用于取代 defineProperty,使用 Proxy 有以下两个优点:


  1. 可以劫持整个对象,并返回一个新对象;

  2. 有13种劫持操作。


既然 Proxy 能解决以上两个问题,而且 Proxy 作为 ES6 的新属性在 Vue2.x 之前就有了,为什么 Vue2.x 不使用 Proxy 呢?一个很重要的原因就是,Proxy 是 ES6 提供的新特性,兼容性不好,并且这个属性无法用 polyfill 来兼容。


Vue 的双向绑定策略成为当前考察前端人员技术功底的重点,我们以 Object.defineProperty 特性实现一个简单的双向绑定,实现最初的 hello everyone 效果。


<!DOCTYPE html><html lang="en">    <head>        <title>双向绑定最最最初级demo</title>        <meta charset="UTF-8">    </head>    <body>        <div id="app">            <input type="text" id="txt">            <p id="show-txt"></p>            <button onClick="changeData()">更新数据</button>        </div>    </body>    <script>        var obj={}        Object.defineProperty(obj,'txt',{            get:function(){                return obj            },            set:function(newValue){                document.getElementById('txt').value = newValue                document.getElementById('show-txt').innerHTML = newValue            }        })        document.addEventListener('keyup',function(e){            obj.txt = e.target.value        })        changeData = function() {            obj.txt = 'hello world';        }    </script></html>
复制代码


由于Object.defineProperty默认只能劫持值类型数据,对引用类型数据的内部修改无法劫持,需要重写覆盖原原型方法,以 Array 为例,如下可以支持到 7 种数组方法:


let arr = [];let arrayMethod = Object.create(Array.prototype);['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {    Object.defineProperty(arrayMethod, method, {        enumerable: true,        configurable: true,        value: function () {            let args = [...arguments]            Array.prototype[method].apply(this, args);            console.log(`operation: ${method}`);        }    })});arr.__proto__ = arrayMethod;arr.push(1);  // 劫持到了push方法
复制代码


相对完整的仿 Vue 双向绑定实现,来自双向绑定数组源码


<!DOCTYPE html><html lang="en">    <head>        <title>双向绑定支持数组监听</title>        <meta charset="UTF-8">    </head>    <body>        <div id="app">            <div id='list'></div>            <input type="button" value="添加" onclick="btnAdd()" />            <input type="button" value="删除" onclick="btnDel()" />        </div>    </body>    <script>        //数据源        let vm = {            list: [1, 2, 3, 4]        }        //用于管理watcher的Dep对象        let Dep = function () {            this.list = [];            this.add = function (watcher) {                this.list.push(watcher)            };            this.notify = function (newValue) {                this.list.forEach(function (fn) {                    fn(newValue)                })            }        };        // 模拟compile,通过对Html的解析生成一系列订阅者(watcher)        function renderList() {            let listContainer = document.querySelector('#list');            let contentList = '';            vm.list.forEach(function (item) {                contentList = contentList + `<div><h3>${item}</h3></div>`            })            listContainer.innerHTML = contentList;        }        //将解析出来的watcher存入Dep中待用        let dep = new Dep();        dep.add(renderList)        //核心方法        function initMVVM(vm) {            Object.keys(vm).forEach(function (key) {                let value = vm[key];                if (Array.isArray(value)) {                    observeArray(vm, key)                }            })        }        function observeArray(vm, key) {            let arrayMethod = bindWatcherToArray();            vm[key].__proto__ = arrayMethod;        }        function bindWatcherToArray() {            let arrayMethod = Object.create(Array.prototype);            ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {                Object.defineProperty(arrayMethod, method, {                    enumerable: true,                    configurable: true,                    value: function () {                        let args = [...arguments]                        Array.prototype[method].apply(this, args);                        console.log(`operation: ${method}`)                        dep.notify();                    }                })            });            return arrayMethod        }        //页面引用的方法        function btnAdd() {            vm.list.push(Math.random())        }        function btnDel() {            vm.list.pop()        }        //初始化数据源        initMVVM(vm)        //初始化页面        dep.notify();    </script></html>
复制代码


React 的工具辅助


React 推荐单向数据流,目标从来不是“让开发者写更少的代码”,而是让“代码结构更加清晰易于维护”。加上有如下 Redux 的状态管理方案,可以很清楚地了解应用中状态数据,体现其单向数据流的优势。



由于 React 推荐单向数据流,没有上述 Angular 和 Vue 的双向绑定特性,如果出现似表单类用户视图和存储数据有同步的业务场景,我们需要怎么实现?Redux 的重型状态管理不适合应用于每个场景。一般在 React 里的表单,我们可以监听 “change” 事件来实现数据变更,默认写法是从数据源(通常是 DOM)读取并在我们的某个组件调用 setState() , 如下代码为常规的表单使用方式:


var NoLink = React.createClass({  getInitialState: function() {    return {message: 'Hello!'};  },  handleChange: function(event) {    this.setState({message: event.target.value});  },  render: function() {    var message = this.state.message;    return <input type="text" value={message} onChange={this.handleChange} />;  }});
复制代码


以上写法每出现一个表单,就需要绑定一个事件,在只有少量表单时还能使用,一旦表单增多,维护大量 value 和 onChange 成了 React 的痛点。React 官方也提供了一种方案 ReactLink:设置如上代码描述的通用数据回流模式的语法糖,或者 “linking” 某些数据结构到 React state,做一层对 onChange 和 setState() 模式的薄包装。它没有根本性地改变你的 React 应用里数据如何流动,下面是 ReactLink 提供的使用方式:


var LinkedStateMixin = require('react-addons-linked-state-mixin');var WithLink = React.createClass({  mixins: [LinkedStateMixin],  getInitialState: function() {    return {message: 'Hello!'};  },  render: function() {    return <input type="text" valueLink={this.linkState('message')} />;  }});
复制代码


实际项目中,我们几乎不会使用官方提供的 createClass 方案,毕竟写法受限。如何减少 value 与 onChange 的使用,简化 React 下的表单开发,成为了大量轮子制造工程的出发点。下述各团队产出的 form 表单解决方案都给出了一定的方式,以及其他各种平台下的开源轮子数不胜数,可以选择一两种进行了解。



追根究底,都是对大量的 value 与 onChange 进行整合,将表单 DOM 的使用方式简化,由辅助函数统一控制。我们可以通过处理函数通用化,来模拟文中提到的动态双向绑定效果。


import React, {Component} from 'react'export default class Hello extends Component {  state = { val: '' };  handleInput = _event => {    let event = _event;    let elem = event.target;    let value = elem.value;    if (elem.attributes.bindField !== null) {      let attr = elem.attributes.bindField.value;      this.setState(state => state[attr] = value);    }  }  updateValue = (getFiled, getValue) => {    let fieldNodeList = [...document.querySelectorAll('[bindField]')];    let fieldNode = fieldNodeList.find(node => node.attributes[0].nodeValue === getFiled);    fieldNode.value = getValue;    let attr = fieldNode.attributes.bindField.value;    this.setState(state => state[attr] = getValue);  }  render() {    return (      <div>        <input onInput={this.handleInput} bindField={'val'} />        <p>{this.state.val}</p>        <button onClick={() => this.updateValue('val', 'hello world')}>设置</button>      </div>    )  }}
复制代码


写在最后


关于 MVVM 双向绑定的策略百花齐放,各有利弊,Angular 与 Vue 通过不同策略直接将双向绑定特性植入框架中,React 推荐单向数据流,但也确实存在不少需要减少编码的双向绑定场景,因此涌现了大量双向绑定辅助库。


Angular 框架大而全,主要由 Google 团队维护,从特性上看适用于中后台应用,每个关键模块都有官方主导,因此更为稳定。React 实际上仅为一个视图层 Js 库,由 Facebook 推出,但经过社区整合,已经形成了完整的生态。Vue 属于后起之秀,没有大厂背景依靠,但由于使用简单,渲染快,在国内市场增速明显,周边生态也已成熟。


无论技术如何实现,只有适合自己当前业务场景的策略才是最好的解决方案。


参考附录:


  1. 《Vue.js 和 MVVM 小细节》

  2. 《angular系列之变化检测(Change Detection)》

  3. 《vue3.0尝鲜 – 摒弃Object.defineProperty,基于 Proxy 的观察者机制探索》

  4. 《实现一个简单的双向绑定》

  5. 《react官网双向绑定辅助工具》


作者介绍:


飞来,就职于阿里巴巴 CBU 体验技术部,一个以前常写 Angular,刚开始转 React,研究过但没真正写 Vue 的前端人。


2019 年 11 月 25 日 18:293402

评论

发布
暂无评论
发现更多内容

ARTS|Week 6 合并有序列表、团队、MIME类型和IIS

Puran

LeetCode ARTS 打卡计划

源码分析 | 数据异构Canal 初探

小新

解读 java 并发队列 BlockingQueue

猿灯塔

Java

Java架构-Apache POI Excel

猿灯塔

在Windows上使用IIS来托管站点

Puran

windows IIS Server

SQLite你用对了吗

这小胖猫

sqlite 数据库 选型

授权专利争夺正当时

CECBC区块链专委会

数据隐私 授权专利 平台应用服务

区块链+金融赋能高原特色农业重点产业

CECBC区块链专委会

打破信息孤岛 区块链+咖啡 特色农业 咖云链

如何搭建一个Zookeeper集群

Rayjun

大数据 zookeeper 分布式

【思考】互联网厂商争夺企业市场

superman

企业中台 互联网

专科程序员与本科程序员之间有什么区别?薪资待遇又差多少?

码农月半

spring 程序员 程序员人生 Java 面试 程序员成长

架构师训练营 -week5 命题作业

J.Spring

极客大学架构师训练营

Git【入门】这一篇就够了

JavaPub

spring

一文搞懂分布式消息中间件设计

小隐乐乐

消息队列

你真的理解透彻高并发了吗?来看看架构师眼里的高并发

小谈

Java 面试 高并发 高并发系统设计

面试中必问的JVM应该怎么学(面试题含答案)

猿灯塔

PHP实现一致性哈希算法

任小龙

80%会问到的18个Dubbo面试题,快来看看你都掌握了吗

小新

Java 程序员 架构 面试 dubbo

1.2w字 | 初中级前端 JavaScript 自测清单 - 1

pingan8787

Java 前端 Web

面试官:既然CPU有MESI,为什么 JMM 还需要volatile关键字?

犬来八荒

Java JVM 硬件 java面试

如何站在架构师的角度做框架

小新

Java 集合 框架

第五周作业

秦宝齐

学习

饿了么4年,阿里2年:我的总结与思考

程序员生活志

工作经验

架构师训练营第五周学习总结

张明森

一篇告诉你什么是Spring

JavaPub

spring

LeetCode | 7. Merge Two Sorted Lists 合并两个有序列表

Puran

Python C# 算法 LeetCode

游戏夜读 | 简单认识一下爬虫

game1night

今天来聊聊如何挑书

封不羁

读书 个人感想

spring 那点事儿——让你少走弯路

爱java爱自己

Spring Cloud Spring Boot

ConcurrentHashMap里面也有死循环

无予且行

Java jdk Java 面试 jdk8

第一个Spring程序(代码篇)

JavaPub

spring

MVVM双向绑定全量版整理-InfoQ