本篇文章参考书籍《JavaScript设计模式》–张容铭
前言
同学们, MVC 模式有没有搞搞清楚,可能有点感觉了,但还是不太熟悉对吧,没关系, MVC 之所以被大家传的神乎其神,其实是开启了一种新模式,这种模式,简化了我们开发过程中的一些繁琐操作,让我们可以把精力更多的放在业务上,而 MVC 本身其实还是可以优化的。
从上一节的代码来看, MVC 模式在添加新功能的时候,获取的数据改变了,我们不仅要修改控制器层,还要修改视图层,这对于复杂需求修改成本是很高的。
这对于这一问题,衍生出了今天的主题, MVP 模式
MVP 模式
MVP 即模型(Model)—视图(View)—管理器(Presenter)。
View 层不直接引用 Model 层内的数据,而是通过 Presenter 层实现对 Model 层内的数据访问。即所有层次的交互都发生在 Presenter 层中。
我们知道,在 MVC 模式中,视图层常常因为要渲染页面而直接引用数据层内的数据,对于这一切,控制器是不知情的,那就会导致,在新增需求的时候,不仅要修改视图层,还要修改控制器。
在 MVP 模式中,我们将视图层和数据层解耦,统一交给控制器层管理,这样视图层只负责创建视图模板,数据层只负责管理数据,功能就独立了出来,而剩下的管理数据, UI 视图创建,交互逻辑,动画特效等都交给控制层,这样控制层的功能多了,晋升成为了管理层, C 就变成了 P 。
基本原理我们清楚了,接下来就是如何实现,通过上面描述,我们知道数据层的变化不需要太多,主要是视图层不一样了,我们首先先创建一个单体对象。
// MVP 单体对象
~(function(window) {
//MVP 构造函数
var MVP = function() {
};
//数据层
MVP.model = function() {
};
//视图层
MVP.view = function() {
};
//管理层
MVP.presenter = function() {
};
//MVP入口
MVP.init= function() {
};
//暴露 MVP 对象,这样可在外部访问 MVP
window.MVP = MVP;
}) (window)
数据层变化不大,代码如下:
//数据层与 MVC 相似
MVP.model = function() {
var M = {
};
M.data = {
}
M.conf = {
}
return {
getData: function(m) {
return M.data[m];
},
/**
* 设置数据
* @param m 模块名称
* @param v 模块数据
*/
setData: function(m, v) {
M.data[m] = v;
return v;
},
getConf: function(c) {
return M.conf[c];
},
/**
* 设置配置
* @param c 配置项名称
* @param v 配置项值
*/
setConf: function(c, v) {
M.conf[c] = v;
return v;
}
}
} ()
接下来是改革重点,视图层修改,比如说我们做一个如下的导航条。
为了在管理层中渲染并创建视图,每次渲染都需要视图层提供一个导航视图模板。
var tpl = [
'<li class="{#mnode#} {#choose#} {#last#}" data-mode="{#mode#}">',
'<a id="nav_{#mode#}" class="nav_{#mode#}" href="{#url#}" title="{#text#}">',
'<i class="nav-icon-{#mnode#}"></i>',
'<span>{#text#}</span>',
'</a>',
'</li>',
].join('');
又是这种头疼的字符串模板,写起来超级费劲,各位估计也看烦了。我们可以借鉴一下 Zen Coding 那样,快速创建模板,比如上面的模板转化成 Zen Coding 模式如下:
'li.@mode @choose @last[data-mode=@mode]>a#[email protected]@mode[href=@url title=@text]>i.nav-icon-@mode+span{@text}'
上面这种写法,稍加观察就能理解,对于这种写法,我们需要对视图层做如下处理:
//视图层
MVP.view = MVP.view = function() {
return function(str) {
//将参数字符串转化成期望模板
return html2canvas;
}
} ();
接下来我们要解析字符串并创建视图,对于参数字符串 str ,我们做的第一步是分层,也就是说要确认每一个元素之间的层级关系,我们发现 ‘>’ 表示后面的元素是前面的元素的子元素,而 ‘+’ 表示前面元素与后面元素是兄弟元素。由于兄弟元素处在元素树中的同一层级上,因此我们要先做 ‘>’ 不同层级处理后作 ‘+’ 同一层级处理,最后针对每一个元素做处理并按层级顺序拼接成期望模板字符串。
MVP.view = function() {
//子元素或者兄弟元素替换模板
var REPLACEKEY = '__REPLACEKEY__';
//获取完整元素模板
function getHTML(str, replacePos) {
}
/**
* 数组迭代器
* @param arr 数组
* @param fn 回调函数
*/
function eachArray(arr, fn) {
//遍历数组
for (let i = 0, len = arr.length; i < len; i++) {
//将索引值,索引对应值,数组长度传入回调函数中并执行
fn(i, arr[i], len);
}
}
/**
* 替换兄弟元素模板或者子元素模板
* @param str 原始字符串
* @param rep 兄弟元素模板或者子模版
*/
function formateItem(str, rep) {
//用对应元素字符串替换兄弟元素模板或者子元素模板
return str.replace(new RegExp(REPLACEKEY, 'g'), rep);
}
//模板解析器
return function(str) {
//模板层级数组
var part = str
//去除首位空白符
.replace(/^\s+|\s+$/g, '')
//去除 > 两端空白符
.replace(/^\s+(>)\s+/g, '$1')
//以 > 分组
.split('>'),
//模块视图根模板
html = REPLACEKEY,
//同层元素
item,
//同级元素模板
nodeTpl;
//遍历每组元素
eachArray(part, function(partIndex, partValue, partLen) {
//为同级元素分组
item = partValue.split('+');
//设为同级元素初始模板
nodeTpl = REPLACEKEY;
//遍历同级每一个元素
eachArray(item, function(itemIndex, itemValue, itemLen) {
/**
* 用渲染元素得到的模板去渲染同级元素模板,此处简化逻辑操作
* 如果 itemIndex (同级元素索引) 对应元素不是最后一个 则做兄弟元素处理
* 否则,如果 partIndex (同级索引) 对应的层级不是最后一层 则作为父层级处理
* (该层级有子层级,即该元素是父元素)
* 否则,该元素无兄弟元素无子元素
*/
nodeTpl = formateItem(nodeTpl, getHTML(itemValue, itemIndex === itemLen - 1 ? (partIndex === partLen - 1 ? '' : 'in') : 'add'));
});
//用渲染子层级得到的模板去渲染父层级模板
html = formateItem(html, nodeTpl);
})
//返回期望视图模板
return html;
}
} ();
最后我们要做的就是对一个元素模板的渲染,即 getHTML 方法。 getHTML 方法比较复杂,首先要分清该元素是否拥有子元素,或者拥有兄弟元素,或是最后一个叶子元素 3 中情况。并针对三种情况分别处理。
然后要将元素补成完整元素, 如 div 要转化成 <div></div>
接下来要对元素的特殊属性 id ( # 标识)或 class ( . 标识)做处理。
然后在处理元素的其他属性( [] 内的用空格分割的属性组)。
最后要将可替换内容标识( @ 标识)替换成代码库中模板渲染方法中可嗅探内容标识形式(比如我们引用 A 框架中 formateString 方法的可嗅探内容标识为 {##} )。
/**
* 获取完整元素模板
* @param str 元素字符串
* @param type 元素类型
*/
function getHTML(str, type) {
//简化实现,只处理字符串中第一个{}里面的内容
return str
.replace(/^(\w+)([^\{\}]*)?(\{([@\w]+)\})?(.*?)$/, function(match, $1, $2, $3, $4, $5) {
$2 = $2 || ''; //元素属性参数容错处理
$3 = $3 || ''; //(元素内容)参数容错处理
$4 = $4 || ''; //元素内容参数容错处理
//去除元素内容后面添加的元素属性中的{}内容
//以 str=div 为例,如果div元素有子元素则表示成<div>__REPLACEKEY__</div>
//如果div有兄弟元素则表示成<div></div>__REPLACEKEY__,否则表示成<div></div>
$5 = $5.replace(/\{([@\w]+)\}/g, '');
return type === 'in' ?
'<' + $1 + $2 + $5 + '>' + $4 + REPLACEKEY + '</' + $1 + '>' :
type === 'add' ?
'<' + $1 + $2 + $5 + '>' + $4 + '</' + $1 + '>' + REPLACEKEY :
'<' + $1 + $2 + $5 + '>' + $4 + '</' + $1 + '>'
})
//处理特殊标识#--id属性
.replace(/#([@\-\w]+)/g, ' id="$1"')
//处理特殊标识.--class属性
.replace(/\.([@\-\s\w]+)/g, ' class="$1"')
//处理其他属性组
.replace(/\[(.+)\]/g, function(match, key) {
//元素属性组
var a = key
//过滤其中引号
.replace(/'|"/g, '')
//以空格分组
.split(' '),
//属性模板字符串
h = '';
//遍历属性组
for(var j = 0, len = a.length; j < len; j++) {
//处理并拼接每一个属性
h += ' ' + a[j].replace(/=(.*)/g, '="$1"');
}
//返回属性组模板字符串
return h;
})
//处理可替换内容,可根据不同模板渲染引擎自由处理
.replace(/@(\W+)/g, '(#$1#)');
}
有了模板引擎,我们在管理器中实现就容易多了,为了使管理器更适合我们的 MVP 模式,只要对管理器稍加改动,添加管理器执行方法 init ,这样方便在任何时候执行我们的管理器,不过总体来说还是和 MVC 中的控制器很类似。
//管理器层
MVP.presenter = function() {
var V = MVP.view;
var M = MVP.model;
var C = {
};
return {
//执行方法
init: function() {
//遍历内部管理器
for(var i in C) {
//执行所有管理器内部逻辑
C[i] && C[i](M, V, i);
}
}
};
} ();
完整的 MVP 对象创建出来了,接下来我们创建一个导航,首先为管理器添加导航管理器逻辑。
var C = {
/**
* 导航管理器
* @param M 数据层对象
* @param V 视图层对象
*/
nav: function(M, V) {
//处理导航渲染数据
data[0].choose = 'choose';
data[data.length - 1].last = 'last';
//获取导航渲染模板
var tpl = V('li.@mode @choose @last[data-mode=@mode]>a#[email protected]@mode[href=@url title=@text]>i.nav-icon-@mode+span{@text}');
$
//创建导航容器
.create('ul', {
'class': 'navigation',
'id': 'nav'
})
//插入导航视图
.html(
//渲染导航视图
A.formateString(tpl, data)
)
//导航模块添加到页面中
.appendTo('#container');
//其他交互逻辑与动画逻辑
//...
}
};
假设我们现在可以从后端获取导航模块数据并已经通过 setData 方法设置在数据层中。
M.data = {
//导航模块渲染数据
nav: [
{
text: '新闻头条',
mode: 'news',
url: 'http://www.example.com/01'
},
{
text: '最新电影',
mode: 'movie',
url: 'http://www.example.com/02'
},
{
text: '热门游戏',
mode: 'game',
url: 'http://www.example.com/03'
},
{
text: '进入特价',
mode: 'price',
url: 'http://www.example.com/04'
},
]
}
万事俱备,只欠执行了,接下来为 MVP 对象创建一个快捷执行方法 init 。
//MVP 入口
MVP.init = function() {
this.presenter.init();
}
等到页面加载完毕后我们就可以渲染并创建导航模块了。
window.onload = function() {
//执行管理器
MVP.init();
}
是不是比 MVC 方便多了,有什么需求增加只要修改响应的管理器就可以了。不过现在模块化开发是主流,一个模块的开发是依赖 MVP 对象实现的,因此我们要将 MVP 封装在模块内。
F.module('lib/MVP', function() {
//MVP构造函数
var MVP = function() {
};
//MVP实现
//...
return MVP;
});
有了 MVP 对象模块,我们就可以在其他模块中引用 MVP 模块了,不过目前为止我们还不能使用 MVP 为管理器添加其他控制器模块。所以我们完成 MVP 构造函数,实现通过 MVP (模块名称,模块管理器,服务器端获取的数据) 的方式添加模块。
//MVP构造函数
var MVP = function(modName, pst, data) {
//在数据层中添加 modName 渲染数据模块
MVP.model.setData(modName, data);
//在管理器层中添加 modName 管理器模块
MVP.presenter.add(modName, pst);
};
从上面代码可以看出 MVP 构造函数做了两件事,首先为数据层添加模块数据,然后为管理器曾添加管理器模块,我们已经在数据层 model 中实现了 setData 方法,所以现在只剩下管理器层中的 add 方法有待实现。
//管理器层
MVP.presenter = function() {
//...
return {
/**
* 为管理器添加模块
* @param modName 模块名称
* @param pst 模块管理器
*/
add: function(modName, pst) {
C[modName] = pst;
return this;
}
};
} ();
管理器的 add 方法允许我们以管理器名称 + 模块管理器的形式在管理器对象层中添加模块管理器,这样我们在外部就可以自由的添加模块了。比如创建一个简单的网址模块。
我们可以在外部模块中创建网址模块。
//网址模块
F.module(['lib/MVP', 'lib/A'], function(MVP, $) {
//页面加载完成执行 参考 A 框架(可以在上一节的代码中查看,或者从我上传的资源中查看)
$(function() {
//为 MVP 对象添加一个网址模块
MVP(
//模块名称
'sites',
/**
* 模块控制器
* @param M 数据对象层引用
* @param V 视图对象层引用
* @param modName 模块名称
*/
function(M, V, modName) {
//渲染模板<li><a href="#">{#text#}</a></li>
var tpl = V('li>a[href="#"]{@text}');
$
//创建网址模块容器
.create('ul', {
'class': 'store-nav',
'id': modName
})
//向网址模块容器中插入网址模块视图
.html(
//创建网址模块视图
$.formateString(tpl, M.getData(modName))
)
//插入页面中
.appendTo('#container');
//其他交互与特效...
},
//模块数据
[
'聚划算',
'1号店',
'九块邮',
'优购网',
'爱淘宝',
'1折网',
]
);
});
});
模块创建完毕我们就可以执行所有模块控制器了。
$(function() {
MVP.init();
})
当然对于模块间的通信我们还可以通过观察者模式来实现。
总个小结
MVP 与 MVC 的最大区别就是将视图层与数据层完全解耦,使得对视图层的修改不会影响到数据层,数据层内的数据改动又不会影响到视图层。在管理器中对数据或者视图灵活的调用就可使数据层内的数据与视图层内的视图得到高效复用。
因此, MVP 模式可以实现一个管理器,可以调用多个数据,或者创建多种视图,而且不受限制。