随着各大前端框架的崛起,前端技术热词不断演进,我们只有知道发展的原因,才能去理解各项技术的优劣,根据应用的实际情况做出最合适的技术栈选择。 当前前端领域的前沿特性,双向绑定必占一席,双向绑定是怎么来的?各大框架如何实现双向绑定?我们怎样做出选择?本文对此作了全面整理说明。
早期的Web开发主要基于MVC模式,MVC即Model-View-Controller
的缩写,表示模型-视图-控制器,一个标准的Web应用组成如下:
这种MVC架构模式对于简单的应用是合适的,符合软件架构分层的思想。但随着H5的发展以及当前各种页面复杂操作行为及数据的出现,MVC暴露出了痛点问题:
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的数据更新思路,”脏值“意为dirty data
,表示当前数据与上一轮UI更新的数据不同,Angular通过监听数据异步更新,采用比较不同component组件中方式更新DOM。总结起来, 主要有如下几种情况可能改变数据:
上述三种情况都有一个共同点,即这些导致绑定值发生改变的事件都是异步发生的,如下为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推出脏值检测机制以来,在性能上一直饱受质疑,原因如下:
对于以上问题,Angular1的解决方式是,如果有10次往复循环,就不再进行继续检测。无论是性能还是结果,都不能令人满意。因此Angular2以脱胎换骨的方式进行了重构,支持dev开发模式与prod线上模式,prod线上模式只进行单向数据流检测,并支持设置ChangeDetectionStrategy
变化检测策略,具备局部组件View层更新能力,因此性能上有了明显突破。
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; // 1
o.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这个特性存在一些明显的缺点,总结起来大概是下面两个:
由于只针对了以上八种方法进行了hack处理,所以其他数组的属性也是检测不到的,还是具有一定的局限性。Vue3.0中使用了ES6语法Proxy,用于取代defineProperty,使用Proxy有以下两个优点:
既然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推荐单向数据流,目标从来不是“让开发者写更少的代码”,而是让“代码结构更加清晰易于维护”。加上有如下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属于后起之秀,没有大厂背景依靠,但由于使用简单,渲染快,在国内市场增速明显,周边生态也已成熟。
无论技术如何实现,只有适合自己当前业务场景的策略才是最好的解决方案。
参考附录:
作者介绍: 飞来,就职于阿里巴巴 CBU 体验技术部,一个以前常写 Angular,刚开始转 React,研究过但没真正写 Vue 的前端人。
领取专属 10元无门槛券
私享最新 技术干货