javascript基础修炼(9)——MVVM中双向数据绑定的基本原理
开发者的javascript造诣取决于对【动态】和【异步】这两个词的理解水平。
我的github主页:https://github.com/dashnowords
我的新书上架啦,3天即登京东计算机编程语言类排行榜Top1!!!精选30+JavaScript库,从使用方式,设计原则,原理源码,周边知识等等多维度详细讲解,带你玩转前端花花世界,欢迎选购~
一. 概述
1.1 MVVM模型
MVVM
模型是前端单页面应用中非常重要的模型之一,也是Single Page Application
的底层思想,如果你也因为自己学习的速度拼不过开发框架版本迭代的速度,或许也应该从更高的抽象层次去理解现代前端开发,因为其实最核心的经典思想几乎都是不怎么变的。关于MVVM
的文章已经非常多了,本文不再赘述。
笔者之前听过一种很形象的描述觉得有必要提一下,Model
可以想象成HTML代码
,ViewModel
可以想象成浏览器
,而View
可以想象成我们最终看到的页面, 那么各个层次所扮演的角色和所需要处理的逻辑就比较清晰了。
1.2 数据绑定
数据绑定,就是将视图层表现和模型层的数据绑定在一起,关于MVVM
中的数据绑定,涉及两个基本概念单向数据绑定和双向数据绑定,其实两者并没有绝对的优劣,只是适用场景不同,现×××发框架都是同时支持两种形式的。
双向数据绑定由Angularjs1.x
发展起来,在表单等用户体验高度依赖于即时反馈的场景中非常便利,但并不是所有场景下都适用的,Angularjs
中也可以通过ng-bind=":expr"
的形式来实现单向绑定;在Flux
数据流架构的影响下,更加易于追踪和管理的单向数据流思想出现了,各主流框架也进行了实现(例如redux
,vuex
),在单向数据绑定的框架中,开发者仍然可以在需要的地方监听变化来手动实现双向绑定。
关于Angularjs1.x
中如何通过脏检查机制来实现双向数据绑定和管理,可以参见《构建自己的AngularJS,第一部分:Scope和Digest》一文,讲述得非常详细。
二. 基于数据劫持的绑定
2.1 Vue2.0源码的学习困惑
Vue2.0
版本中的双向数据绑定,很多开发者都知道是通过劫持属性的get/set
方法来实现的,上图已经展示了双向数据绑定的代码框架,分析源码的文章也非常多,许多文章都将重点放在了发布订阅模式的实现上,笔者自己阅读时有两大困扰点:
第一,即使通过defineProperty
劫持了属性的get/set
方法,不知道数据模型和页面之间又是如何联系起来的。(很多文章都是顺带一提而没有详述,实际上这部分对于整体理解MVVM
数据流非常重要)
第二,Vue2.0
在实现发布订阅模式的时候,使用了一个Dep
类作为订阅器来管理发布订阅行为,从代码的角度讲这样做是很好的实践,它可以将订阅者管理(例如避免重复订阅)这种与业务无关的代码解耦出来,符合单一职责的开发原则。但这样做对于理清代码逻辑而言会造成困扰,让发布-订阅相关的代码段变得模糊,实际上将Dep
类与发布者类合并在一起,绑定原理会更加清晰,而在代码迭代中,考虑到更多更复杂的情况时,即使你是框架的设计者,也会很自然地选择将Dep
抽象成一个独立的类。
如果你也在阅读博文的时候出现同样的困惑,强烈建议读完本篇后自己动手实现一个
MVVM
的双向绑定,你会发现很多时候你不理解一些代码,是因为你不知道作者面对了怎样的实际问题。
2.2 从标签开始的代码推演
ps:下文提及的观察者类和发布者类是指同一个类。
2.2.1 示例代码
我们先来写几个包含自定义指令的标签:
<div id="app" class="container">
<input type="text" d-model="myname">
<br>
输入的是:<span d-bind="myname"></span>
<br>
<button d-click="alarm()">广播报警</button>
</div>
<script>
var options = {
el:'app',
data:{
myname:'僵尸'
},
methods:{
alarm:function (node,event) {
window.alert(`一大波【${this.data.myname}】正在靠近!`);
}
}
}
//初始化
var vm = new Dash(options);
</script>
需要实现的功能就如同你在所有框架中见到的那样:input标签的值通过d-model
指令和数据模型中的myname进行双向绑定,span标签的值通过d-bind
指令从myname单向获取,button标签的点击响应通过d-click
绑定数据模型中的alarm()方法。初始化所用到的方法已经提供好了,假如我们要在一个叫做Dash的MVVM
框架中实现数据绑定,那么第一步要做的,是模板解析。
2.2.2 模板解析
DOM标签自身是一个树形结构,所以需要从最外层的
compiler.js——模板解析器类
/**
* 模板编译器
*/
class Compiler{
constructor(){
this.strategy = new Strategy();//封装的策略类,下一节描述
this.strategyKeys = Object.keys(this.strategy);
}
/**
*编译方法
*@params vm Dash类的实例(即VisualModel实例)
*@params node 待编译的DOM节点
*/
compile(vm, node){
if (node.nodeType === 3) {//解析文本节点
this.compileTextNode(vm, node);
}else{
this.compileNormalNode(vm, node);
}
}
/**
*编译文本节点,此处仅实现一个空方法,实际开发中可能是字符串转义过滤方法
*/
compileTextNode(vm, node){}
/**
*编译DOM节点,遍历策略类中支持的自定义指令,如果发现某个指令dir
*则以this.Strategy[str]的方式取得对应的处理函数并执行。
*/
compileNormalNode(vm, node){
this.strategyKeys.forEach(key=>{
let expr = node.getAttribute(key);
if (expr) {
this.strategy[key].call(vm, node, expr);
}
});
//递归处理当前DOM标签的子节点
let childs = node.childNodes;
if (childs.length > 0) {
childs.forEach(subNode => this.compile(vm, subNode));
}
}
}
//为方便理解,此处直接在全局生成一个编译器单例,实际开发中请挂载至适当的命名空间下。
window.Compiler = new Compiler();
2.2.3 策略封装
我们使用策略模式实现一个单例的策略类Strategy
,将所有指令所对应的解析方法封装起来并传入解析器,当解析器递归解析每一个标签时,如果遇到可以识别的指令,就从策略类中直接取出对应的处理方法对当前节点进行处理即可,这样Strategy
类只需要实现一个Strategy.register( customDirective, options)
方法就可以暴露出未来用以添加自定义指令的接口。(细节可参考附件中的代码)
strategy.js——指令解析策略类
//策略类的基本结构
class Strategy{
constructor(){
let strategy = {
'd-bind':function(){//...},
'd-model':function(){//...},
'd-click':function(){//...}
}
return strategy;
}
//注册新的指令
register(customDir,options){
...
}
}
模板解析的工作就比较清晰了,相当于带着一本《解析指南》去遍历处理DOM树,不难看出,实际上绑定的工作就是在策略对应的方法里来实现的,在MVVM
结构种,这一步被称为“依赖收集”。
2.2.4 订阅数据模型变化
以最基本的d-bind
指令为例,通过使用strategy['d-bind']
方法处理节点后,被处理的节点应该具备感知数据模型变化的能力。以上面的模板为例,当this.data.myname
发生变化时,就需要将被处理节点的内容改为对应的值。此处就需要用到发布-订阅模式。为了实现这个方法,需要一个观察者类Observer
,它的功能是观察数据模型的变化(通过数据劫持实现),管理订阅者(维护一个回调队列管理订阅者添加的回调方法), 变化发生时通知订阅者(依次调用订阅者注册的回调方法),同时将提供回调方法并执行视图更新行为的逻辑抽象为一个订阅者类Subscriber
,订阅者实例拥有一个update
方法,当该方法被观察者(同时也是发布者)调用时,就会刷新对应节点的视图,很明显,subscriber
实例需要被添加至指定的观察者类的回调队列中才能够生效。
//发布订阅模式的伪代码
//...
'd-bind':function(node, expr){
//实例化订阅者类
let sub = new Subscriber(node, 'myname',function(){
//更新视图
node.innerHTML = VM.data['myname'];
});
//当观察者实例化时,需要将这个sub实例的update方法添加进
},
//...
subscriber.js——订阅者类
class Subscriber{
constructor(vm, exp, callback){
this.vm = vm;
this.exp = exp;
this.callback = callback;
this.value = this.vm.data[this.exp];
}
/**
* 提供给发布者调用的方法
*/
update(){
return this.run();
}
/**
* 更新视图时的实际执行函数
*/
run(){
let currentVal = this.vm.data[this.exp];
if (this.value !== currentVal) {
this.value = currentVal;
this.callback.call(this.vm, this.value);
}
}
}
2.2.5 数据劫持
在生成一个subscriber
实例后,还要实现一个observer
实例,然后才能够通过调用observer.addSub(sub)
方法来将订阅者添加进观察者的回调队列中。先来看一下Observer
这个类的定义:
observer.js——观察者类
/**
* 发布者类,同时为一个观察者
* 功能包括:
* 1.观察视图模型上数据的变化
* 2.变化出现时发布变化消息给订阅者
*/
class Observer{
constructor(data){
this.data = data;
this.subQueue = {};//订阅者Map
this.traverse();
}
//遍历数据集中各个属性并添加观察器具
traverse(){
Object.keys(this.data).forEach(key=>{
defineReactive(this.data, key, this.data[key], this);
});
}
notify(key){
this.subQueue[key].forEach(fn=>fn.update());
}
}
//修改对象属性的get/set方法实现数据劫持
function defineReactive(obj, key, val, observer) {
//当键的值仍然是一个对象时,递归处理,observe方法定义在dash.js中
let childOb = observe(val);
//数据劫持
Object.defineProperty(obj, key, {
enumerable:true,
configurable:true,
get:()=>{
if (window.curSubscriber) {
if (observer.subQueue[key] === undefined) {observer.subQueue[key] = []};
observer.subQueue[key].push(window.curSubscriber);
}
return val;
},
set:(newVal)=>{
if (val === newVal) return;
val = newVal;
//监听新值
childOb = observe(newVal);
//通知所有订阅者
observer.notify(key);
}
})
}
观察者类实例化时,传入一个待观察的数据对象,构造器调用遍历方法来改写数据集中每一个键的get/set
方法,在读取某个键的值时,将订阅者监听器(细节下一节讲)添加进回调队列,当set
改变数据集中某个键的值时,调用观察者的notify( )
方法找到对应键的回调队列并以此触发。
上面的代码可以应付一般情况,但存在一些明显的问题就是集中式的回调队列管理,subQueue
实际上是一个HashMap结构:
subQueue = {
'myname':[fn1, fn2, fn3],
'otherAttr':[fn11,fn12, fn13],
//...
}
不难看出这种管理回调的方式存在很多问题,遇到嵌套或重名结构就会出现覆盖,这个时候就不难理解Vue2.0
源码中的做法了,在进行数据劫持时生成一个Dep
实例,实例中维护一个回调队列用来管理发布订阅,当数据模型中的属性被set
修改时,调用dep.notify( )
方法来依次调用订阅者添加的回调,当属性被读取而触发get
方法时,向dep
实例中添加订阅者的回调函数即可。
2.2.6 发布订阅的连接
截止目前为止,还有最后一个问题需要处理,就是订阅者实例sub
和发布订阅管理器实例dep
存在于两个不同的作用域里,那么要怎么通过调用dep.addSub(sub)
来实现订阅动作呢?换个问法或许你就发现这个问题其实并不难回答,在SPA
框架中,兄弟组件之间如何通信呢?通常都是借助数据上浮(公用数据提升到共同的父级组件中)或者EventBus
来实现的。
这里的做法是一致的,在策略类中某个指令对应的处理方法中,当我们准备从数据模型this.data
中读取对应的初值前,先将订阅者实例sub
挂载到一个更高的层级(附件的demo中简单粗暴地挂载到全局,Vue2.0
源码中挂载到Dep.target
),然后再去读取this.data[expr]
,这个时候在expr
属性被劫持的get
方法中,不仅可以访问到属于自己的订阅管理器dep
实例,也可以通过Dep.target
访问到当前节点所对应的订阅者实例,那么完成对应的订阅逻辑就易如反掌了。
2.2.7 逻辑整合
了解了上述细节,我们整理一下思路,整体看一下数据绑定所经历的各个环节:
2.2.8 Demo
有关上面示例中d-model
和d-click
指令绑定的实现,本文不再赘述,笔者提供了包含详细注释的完整Demo,有需要的读者可以直接从附件中取用,最后Demo也会存放在我的github仓库。
2.2.9 Vue2.0中有关双向绑定的源码
了解了上述细节,可以阅读《vue的双向绑定原理及实现》来看看 Vue2.0
的源代码中是如何更加规范地实现双向数据绑定的。
2.3 数据劫持绑定存在的问题
基于劫持的数据绑定方法是无法感知数组方法的,Vue2.0
中使用了Hack的方法来实现对于数组元素的感知,其基本原理依旧是通过代理模式实现,在此直接给出源码Vue源码链接:
//Vue2.0中有关数组方法
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
// hack 以下几个函数
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
methodsToPatch.forEach(function (method) {
// 获得原生函数
const original = arrayProto[method]
def(arrayMethods, method, function mutator (...args) {
// 调用原生函数
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2)
break
}
if (inserted) ob.observeArray(inserted)
// 触发更新
ob.dep.notify()
return result
})
})
大致的思路是为Array.prototype
上几个原生方法设置了访问代理,并将订阅管理器的消息发布方法混入其中,实现了对特定数组方法的监控。
三. 基于Proxy的数据绑定
Vue
官方已经确认3.0版本重构数据绑定代码,改为Proxy
实现。
Proxy
对象是ES6
引入的原生化的代理对象,和基于defineProperty
实现数据劫持在思路上其实并没有什么本质区别,都是使用经典的“代理模式”来实现的,只是原生支持的Proxy
编写起来更简洁,整个天然支持对数组变化的感知能力。Proxy
和Reclect
对象基本是成对出现使用的,属于元编程范畴,可以从语言层面改变原有特性,Proxy
可以拦截对象的数十种方法,比手动实现的代理模式要清晰很多,也要方便很多。
基本实现如下:
//使用Proxy代理数据模型对象
let watchVmData = (obj, setBind, getLogger) => {
let handler = {
get(target, property, receiver){
getLogger(target, property);
return Reflect.get(target, property, receiver);
},
set(target, property, value, receiver){
setBind(value);
return Reflect.set(target, property, value);
}
};
return new Proxy(obj, handler);
};
//使用Proxy代理
let data = { myname : 1 };
let value;
let vmproxy = watchVmData(obj, (v) => {
value = v;
},(target, property)=>{
console.log(`Get ${property} = ${target[property]}`);
});
四. What's next
数据绑定只是MVVM
模型中的冰山一角,如果你自己动手实现了上面提及的Demo,一定会发现很多明显的问题,例如订阅者刷新函数是直接修改DOM的,稍有开发经验的前端工程师都会想到需要将变化收集起来,尽可能将高性能消耗的DOM操作合并在一起处理来提升效率,这就引出了一系列我们常常听到的Virtual-DOM(虚拟DOM树)
和Life-Cycle-Hook(生命周期钩子)
等等知识点,如果你对三大框架的底层原理感兴趣,可以继续探索,那是一件非常有意思的事情。
五. 总结
通过原理的学习就会发现学习【设计模式】的重要性,很多时候别人用设计模式的术语交流并不是在装X,而是它真的代表了一些久经验证的思想,仅仅是数据绑定这样一个小小的知识点,就包含了类模式
,代理模式
,原型模式
,策略模式
,发布订阅模式
的运用,代码的实现中也涉及到了单一职责,开放封闭等等开发原则的考量,框架编写是一件非常考验基本功的事情,在基础面前,技巧只能是浮云。