从 jQuery 的一统江湖再到 Angular 的异常火爆,我们可以看到工程师们对于开发效率孜孜不倦的追求,大家都渴望着能够快速从这个充满纷争的互联网时代脱颖而出。尽管说“天下武功,唯快不破”,但是我们不该忘记一个事实,那就是一个长期产品的维护成本要远远高于开发的成本。相比与单纯的代码量的减少,良好的整体设计更应该足够简单,容易被他人完全正确的理解、可以快速的定位和处理bug、能够轻易的改变结构或者添加功能、支持轻松的编写测试。
下文中我会以 Component 为例,来讲讲我们是如何基于 KISS原则 来实现整体的前端模块化设计(以下简称前端模块化设计),从而帮助产品更好的应对将来代码维护和升级需要的。
先来明确下前端模块化这个概念,以及它所针对的问题。
什么是前端模块化设计
它所针对的不仅仅是常用前端小组件的设计,而是整个产品的所有前端部分的全部模块化。
不仅仅是JS代码的模块化,一个模块可以包含js、css、html以及图片等其它资源。
不同模块间的依赖和调用关系都应该是清晰简单的,事件流程也应该是可以被容易读懂和理解的。
前后端要做到完全分离,后端不再提供渲染功能,仅仅做为数据的提供者(rest接口),否则无法确保调整的可靠性。
它只针对应用型产品,对于追求性能和 SEO 的展示性页面来说,前端的功能是有限的,用不着做整体的模块化设计。
它不是企业级组件,所谓企业级组件一般都是前后端一体的、跟具体某个业务紧密联系的,而前端模块化只包括前端,它只对应一个产品的一个部分,不一定对应某个具体业务(不同业务同时依赖一个模块是很常见的)。
为什么要做前端模块化设计
我们的产品需要针对不同客户进行界面定制调整,它需要足够灵活应对各种组件上的变化。
我们希望我们的产品能够带给用户 app 一样的流畅访问体验,而不是不停的重复刷新页面。
我们的产品需要支持不同的设备,我们希望大部分模块是可重用的,而不必再去重新开发。
我们的前后端是分离的,前端额外还承载了所有的渲染、路由、以及组件生命周期管理的逻辑,我们希望它简单可靠。
前端模块化设计的方法
-
MVX模式。我们的界面只由三种简单的模块构成,M是Model,V是View,X就是其它。
Model,负责定义各种模型,以及向后端发送对应的资源增删改查请求并完成对应模型的转化。
View,使用模板并提供渲染方法、绑定对应Model、创建并管理子view、响应各种事件、发送事件,以上只有第一点是必须的。
其它包含通用的组件,例如:tip、menu、dialog等等,以及各种全局通用的模块,提供例如错误提示、多语言、保存获取当前用户信息等功能。
-
严格依赖关系。简单来说就是层次化,具体包含以下几点:
通用组件和全局模块绝不应当依赖任何Model和View。
Model只能依赖全局模块和通用组件。
View可以依赖任何组件,但它绝不可以依赖除了自身创建的子View之外的任何View组件(保证独立性)。
以下是一个真实项目中的模块依赖关系,可以看到清晰的层次关系
(其中user、client、board、widget是model) 合理划分界面模块。可以参考 Sencha的这篇文章,简言之就是不能太大,太大承载的逻辑太多,也不能太小,太小则过于碎片化,增大管理的难度。我们的做法是先按逻辑上互相独立的大区域做成最底层的view,逻辑上应当独立的块做成独立的view,对于中间的view层则最开始尽量少做。如果发现由于业务的变更中间的view层承接了太多功能再进行拆分,因为view只会被它的上一层创建者调用,而且它依赖的模块因为使用 CMD 也能非常清楚的看到,所以重构起来还是非常容易的。
可控的消息机制。父view可以直接调用子view的方法,但是子view发生变化,需要通知其它的view该怎么办呢?我们不喜欢 pagebus 那种不可控的全局消息模式,所以我们采用了类似浏览器事件冒泡的简单的事件冒泡方式,而且限制了子view的事件只能由上一层接受,如果还需要向上传递则让上一层继续发送事件,实践中这种需求还是非常少的。
代码示例
- 用户模块
var model = require('model');
/**
* User model.
*/
module.exports = model('User')
.attr('id')
.attr('name')
.attr('email')
- 视图模块
var dom = require('dom');
var User = require('user');
var Emitter = require('emitter');
var template = require('./template');
//parent为渲染该view的dom节点
function UsersView(parent) {
var el = dom(template);
el.appendTo(parent);
this.el = el;
el.on('click', '.add', this.addUser.bind(this));
//load all users
User.all(function(err, users){
if(err) throw err;
users.each(function(user){
this.appendUser(user);
}.bind(this));
}.bind(this));
}
Emitter(UsersView.prototype);
UsersView.prototype.addUser = function(){
var user = new User({
name: this.el.find('[name="name"]').value(),
email: this.el.find('[name="email"]').value()
});
user.save(function(err, user){
if(err) throw err;
this.appendUser(user);
//向父层发送通知
this.emit('new', user);
}.bind(this));
}
UsersView.prototype.appendUser = function(user){
//渲染新用户,此处省略
}
module.exports = UsersView;
- 父视图模块
var template = require('./template');
var dom = require('dom');
//模块名采用小写加中横线命名,因为文件夹采用小写命名更好
var UserView = require('user-view');
function MainView(parent) {
var el = dom(template);
el.appendTo(parent);
this.userView = new UserView(el.get(0));
//事件接受
this.userView.on('new', function(user){
console.log('new user ' + user.name);
}.bind(this));
}
通过遵循简单的方法和约定,我们可以做到不论是框架整体,还是具体到某个调用,某个流程都非常容易被开发者所理解,从而极大的提升了后期继续开发和维护的效率。
下一篇我会谈谈我们基于应用模块化所做的各种约定,它们可以让整个前端体系保持简单一致,有效的避免冲突,并且帮助我们快速定位和处理各种问题。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。