今天我们主要讲的是 JavaScript 开发的一个很重要的开发思维,组件化开发。那么什么叫组件化开发呢?
“把图形、非图形的各种逻辑均抽象为一个统一的概念(组件)来实现开发的模式”。

前端组件化
如果你不认同,你可以去看 yui2 里的那些组件 list 吧,他们是实际的解决了 web 开发中需要遇到的一系列特别问题,比如截图,比如搜索提示,比如懒加载,比如表单验证,比如跨域的图片上传,比如一个表情控件,日期日历控件等
更多精彩内容请看 web 前端中文站
http://www.lisa33xiaoq.net 可按 Ctrl + D 进行收藏
关于JavaScript 的组件化实例,可以看看下面几个效果,加深对组件化的理解:
1、比如我们要实现这样一个组件,就是一个输入框里面字数的计数。这个应该是个很简单的需求。

输入框
2、看见这个 checkbox 了吧,它就是一个组件,只不过是浏览器提供给我们的。他的作用就是点了会有个对勾,表示我选中了你。

checkbox
3、再看一个

下拉框
我们来看看,下面的各种写法。
为了更清楚的演示,下面全部使用 jQuery 作为基础语言库。
1.最简单的写法
嗯 所谓的入门级写法呢,就是完完全全的全局函数全局变量的写法。(就我所知,现在好多外包还是这种写法)
代码如下:
< !DOCTYPE html > <html > <head > <meta charset = "utf-8" > <title > test < /title>
<script src="http:/ / code.jquery.com / jquery - 1.9.1.min.js "></script>
<script>
$(function() {
var input = $('#J_input');
//用来获取字数
function getNum(){
return input.val().length;
}
//渲染元素
function render(){
var num = getNum();
//没有字数的容器就新建一个
if ($('#J_input_count').length == 0) {
input.after('<span id="J_input_count "></span>');
};
$('#J_input_count').html(num+'个字');
}
//监听事件
input.on('keyup',function(){
render();
});
//初始化,第一次渲染
render();
})
</script>
</head>
<body>
<input type="text " id="J_input "/>
</body>
</html>"
这段代码跑也是可以跑的,但是呢,各种变量混乱,没有很好的隔离作用域,当页面变的复杂的时候,会很难去维护。目前这种代码基本是用不了的。当然少数的活动页面可以简单用用。
2.作用域隔离
让我们对上面的代码作些改动,使用单个变量模拟命名空间。
var textCount = {
input:null,
init:function(config){
this.input = $(config.id);
this.bind();
//这边范围对应的对象,可以实现链式调用
return this;
},
bind:function(){
var self = this;
this.input.on('keyup',function(){
self.render();
});
},
getNum:function(){
return this.input.val().length;
},
//渲染元素
render:function(){
var num = this.getNum();
if ($('#J_input_count').length == 0) {
this.input.after('<span id="J_input_count"></span>');
};
$('#J_input_count').html(num+'个字');
}
}
$(function() {
//在 domready 后调用
textCount.init({id:'#J_input'}).render();
})
这样一改造,立马变的清晰了很多,所有的功能都在一个变量下面。代码更清晰,并且有统一的入口调用方法。
但是还是有些瑕疵,这种写法没有私有的概念,比如上面的 getNum,bind 应该都是私有的方法。但是其他代码可以很随意的改动这些。当代码量特别特别多的时候,很容易出现变量重复,或被修改的问题。
于是又出现了一种函数闭包的写法:
var TextCount = (function(){
//私有方法,外面将访问不到
var _bind = function(that){
that.input.on('keyup',function(){
that.render();
});
}
var _getNum = function(that){
return that.input.val().length;
}
var TextCountFun = function(config){
}
TextCountFun.prototype.init = function(config) {
this.input = $(config.id);
_bind(this);
return this;
};
TextCountFun.prototype.render = function() {
var num = _getNum(this);
if ($('#J_input_count').length == 0) {
this.input.after('<span id="J_input_count"></span>');
};
$('#J_input_count').html(num+'个字');
};
//返回构造函数
return TextCountFun;
})();
$(function() {
new TextCount().init({id:'#J_input'}).render();
})
这种写法,把所有的东西都包在了一个自动执行的闭包里面,所以不会受到外面的影响,并且只对外公开了 TextCountFun 构造函数,生成的对象只能访问到 init,render 方法。这种写法已经满足绝大多数的需求了。事实上大部分的jQuery插件都是这种写法。
3.面向对象
上面的写法已经可以满足绝大多数需求了。
但是呢,当一个页面特别复杂,当我们需要的组件越来越多,当我们需要做一套组件。仅仅用这个就不行了。首先的问题就是,这种写法太灵活了,写单个组件还可以。如果我们需要做一套风格相近的组件,而且是多个人同时在写。那真的是噩梦。
在编程的圈子里,面向对象一直是被认为最佳的编写代码方式。比如 java,就是因为把面向对象发挥到了极致,所以多个人写出来的代码都很接近,维护也很方便。但是很不幸的是,javascript 不支持 class 类的定义。但是我们可以模拟。
下面我们先实现个简单的 javascript 类:
var Class = (function() {
var _mix = function(r, s) {
for (var p in s) {
if (s.hasOwnProperty(p)) {
r[p] = s[p]
}
}
}
var _extend = function() {
//web 前端中文站 lisa33xiaoq.net
//开关 用来使生成原型时,不调用真正的构成流程 init
this.initPrototype = true
var prototype = new this()
this.initPrototype = false
var items = Array.prototype.slice.call(arguments) || []
var item
//支持混入多个属性,并且支持{}也支持 Function
while (item = items.shift()) {
_mix(prototype, item.prototype || item)
}
// 这边是返回的类,其实就是我们返回的子类
function SubClass() {
if (!SubClass.initPrototype && this.init)
this.init.apply(this, arguments)//调用 init 真正的构造函数
}
// 赋值原型链,完成继承
SubClass.prototype = prototype
// 改变 constructor 引用
SubClass.prototype.constructor = SubClass
//www.lisa33xiaoq.net
// 为子类也添加 extend 方法
SubClass.extend = _extend
return SubClass
}
//超级父类
var Class = function() {}
//为超级父类添加 extend 方法
Class.extend = _extend
return Class
})()
这是拿 John Resig 的 class 简单修改了下。
这边只是很简陋的实现了类的继承机制。如果对类的实现有兴趣可以参考我另一篇文章 javascript oo 实现
我们看下使用方法:
//继承超级父类,生成个子类 Animal,并且混入一些方法。这些方法会到 Animal 的原型上。
//另外这边不仅支持混入{},还支持混入 Function
var Animal = Class.extend({
init:function(opts){
this.msg = opts.msg
this.type = "animal"
},
say:function(){
alert(this.msg+":i am a "+this.type)
}
})
//继承 Animal,并且混入一些方法
var Dog = Animal.extend({
init:function(opts){
//并未实现 super 方法,直接简单使用父类原型调用即可
Animal.prototype.init.call(this,opts)
//修改了 type 类型
this.type = "dog"
}
})
//new Animal({msg:'hello'}).say()
new Dog({msg:'hi'}).say()
使用很简单,超级父类具有 extend 方法,可以继承出一个子类。子类也具有 extend 方法。
这边要强调的是,继承的父类都是一个也就是单继承。但是可以通过 extend 实现多重混入。详见下面用法。
有了这个类的扩展,我们可以这么编写代码了:
var TextCount = Class.extend({
init:function(config){
this.input = $(config.id);
this._bind();
this.render();
},
render:function() {
var num = this._getNum();
if ($('#J_input_count').length == 0) {
this.input.after('<span id="J_input_count"></span>');
};
$('#J_input_count').html(num+'个字');
},
_getNum:function(){
return this.input.val().length;
},
_bind:function(){
var self = this;
self.input.on('keyup',function(){
self.render();
});
}
})
$(function() {
new TextCount({
id:"#J_input"
});
})
这边可能还没看见 class 的真正好处,不急我们继续往下。
4.抽象出 base 类
可以看到,我们的组件有些方法,是大部分组件都会有的。
比如 init 用来初始化属性。
比如 render 用来处理渲染的逻辑。
比如 bind 用来处理事件的绑定。
当然这也是一种约定俗成的规范了。如果大家全部按照这种风格来写代码,开发大规模组件库就变得更加规范,相互之间配合也更容易。
这个时候面向对象的好处就来了,我们抽象出一个 Base 类。其他组件编写时都继承它。
var Base = Class.extend({
init:function(config){
//自动保存配置项
this.__config = config
this.bind()
this.render()
},
//可以使用 get 来获取配置项
get:function(key){
return this.__config[key]
},
//可以使用 set 来设置配置项
set:function(key,value){
this.__config[key] = value
},
bind:function(){
},
render:function() {
},
//定义销毁的方法,一些收尾工作都应该在这里
destroy:function(){
}
})
base 类主要把组件的一般性内容都提取了出来,这样我们编写组件时可以直接继承 base 类,覆盖里面的 bind 和 render 方法。
于是我们可以这么写代码:
var TextCount = Base.extend({
_getNum:function(){
return this.get('input').val().length;
},
bind:function(){
var self = this;
self.get('input').on('keyup',function(){
self.render();
});
},
render:function() {
var num = this._getNum();
//www.lisa33xiaoq.net
if ($('#J_input_count').length == 0) {
this.get('input').after('<span id="J_input_count"></span>');
};
$('#J_input_count').html(num+'个字');
}
})
$(function() {
new TextCount({
//这边直接传 input 的节点了,因为属性的赋值都是自动的。
input:$("#J_input")
});
})
可以看到我们直接实现一些固定的方法,bind,render 就行了。其他的 base 会自动处理(这里只是简单处理了配置属性的赋值)。
事实上,这边的 init,bind,render 就已经有了点生命周期的影子,但凡是组件都会具有这几个阶段,初始化,绑定事件,以及渲染。当然这边还可以加一个 destroy 销毁的方法,用来清理现场。
此外为了方便,这边直接变成了传递 input 的节点。因为属性赋值自动化了,一般来说这种情况下都是使用 getter,setter 来处理。这边就不详细展开了。
5.引入事件机制(观察者模式)
有了 base 应该说我们编写组件更加的规范化,体系化了。下面我们继续深挖。
还是上面的那个例子,如果我们希望输入字的时候超过 5 个字就弹出警告。该怎么办呢。
小白可能会说,那简单啊直接改下 bind 方法:
var TextCount = Base.extend({
...
bind:function(){
var self = this;
self.get('input').on('keyup',function(){
if(self._getNum() > 5){
alert('超过了 5 个字了。。。')
}
self.render();
});
},
...
})
的确也是一种方法,但是太 low 了,代码严重耦合。当这种需求特别特别多,代码会越来越乱。
这个时候就要引入事件机制,也就是经常说的观察者模式。
注意这边的事件机制跟平时的浏览器那些事件不是一回事,要分开来看。
什么是观察者模式呢,官方的解释就不说了,直接拿这个例子来说。
想象一下 base 是个机器人会说话,他会一直监听输入的字数并且汇报出去(通知)。而你可以把耳朵凑上去,听着他的汇报(监听)。发现字数超过 5 个字了,你就做些操作。
所以这分为两个部分,一个是通知,一个是监听。
假设通知是 fire 方法,监听是 on。于是我们可以这么写代码:
var TextCount = Base.extend({
...
bind:function(){
var self = this;
self.get('input').on('keyup',function(){
//通知,每当有输入的时候,就报告出去。
self.fire('Text.input',self._getNum())
self.render();
});
},
...
})
$(function() {
var t = new TextCount({
input:$("#J_input")
});
//监听这个输入事件
t.on('Text.input',function(num){
//可以获取到传递过来的值
if(num>5){
alert('超过了 5 个字了。。。')
}
})
})
fire 用来触发一个事件,可以传递数据。而 on 用来添加一个监听。这样组件里面只负责把一些关键的事件抛出来,至于具体的业务逻辑都可以添加监听来实现。没有事件的组件是不完整的。
下面我们看看怎么实现这套事件机制。
我们首先抛开 base,想想怎么实现一个具有这套机制的类。
//辅组函数,获取数组里某个元素的索引 index
var _indexOf = function(array,key){
if (array === null) return -1
var i = 0, length = array.length
for (; i < length; i++) if (array[i] === item) return i
return -1
}
var Event = Class.extend({
//添加监听
on:function(key,listener){
//this.__events 存储所有的处理函数
if (!this.__events) {
this.__events = {}
}
if (!this.__events[key]) {
this.__events[key] = []
}
if (_indexOf(this.__events,listener) === -1 && typeof listener === 'function') {
this.__events[key].push(listener)
}
return this
},
//触发一个事件,也就是通知
fire:function(key){
if (!this.__events || !this.__events[key]) return
var args = Array.prototype.slice.call(arguments, 1) || []
var listeners = this.__events[key]
var i = 0
var l = listeners.length
for (i; i < l; i++) {
listeners[i].apply(this,args)
}
return this
},
//取消监听
off:function(key,listener){
if (!key && !listener) {
this.__events = {}
}
//不传监听函数,就去掉当前 key 下面的所有的监听函数
if (key && !listener) {
delete this.__events[key]
}
if (key && listener) {
var listeners = this.__events[key]
var index = _indexOf(listeners, listener)
(index > -1) && listeners.splice(index, 1)
}
return this;
}
})
var a = new Event()
//添加监听 test 事件
a.on('test',function(msg){
alert(msg)
})
//触发 test 事件
a.fire('test','我是第一次触发')
a.fire('test','我又触发了')
a.off('test')
a.fire('test','你应该看不到我了')
实现起来并不复杂,只要使用 this.__events 存下所有的监听函数。在 fire 的时候去找到并且执行就行了。
这个时候面向对象的好处就来了,如果我们希望 base 拥有事件机制。只需要这么写:
var Base = Class.extend(Event,{
...
destroy:function(){
//去掉所有的事件监听
this.off()
}
})
//于是可以
//var a = new Base()
// a.on(xxx,fn)
//
// a.fire()
是的只要 extend 的时候多混入一个 Event,这样 Base 或者它的子类生成的对象都会自动具有事件机制。
有了事件机制我们可以把组件内部很多状态暴露出来,比如我们可以在 set 方法中抛出一个事件,这样每次属性变更的时候我们都可以监听到。
到这里为止,我们的 base 类已经像模像样了,具有了 init,bind,render,destroy 方法来表示组件的各个关键过程,并且具有了事件机制。基本上已经可以很好的来开发组件了。
6.richbase
我们还可以继续深挖。看看我们的 base,还差些什么。首先浏览器的事件监听还很落后,需要用户自己在 bind 里面绑定,再然后现在的 TextCount 里面还存在 dom 操作,也没有自己的模板机制。这都是需要扩展的,于是我们在 base 的基础上再继承出一个 richbase 用来实现更完备的组件基类。
主要实现这些功能:
事件代理:不需要用户自己去找 dom 元素绑定监听,也不需要用户去关心什么时候销毁。
模板渲染:用户不需要覆盖 render 方法,而是覆盖实现 setUp 方法。可以通过在 setUp 里面调用 render 来达到渲染对应 html 的目的。
单向绑定:通过 setChuckdata 方法,更新数据,同时会更新 html 内容,不再需要 dom 操作。
我们看下我们实现 richbase 后怎么写组件:
var TextCount = RichBase.extend({
//事件直接在这里注册,会代理到 parentNode 节点,parentNode 节点在下面指定
EVENTS:{
//选择器字符串,支持所有 jQuery 风格的选择器
'input':{
//注册 keyup 事件
keyup:function(self,e){
//单向绑定,修改数据直接更新对应模板
self.setChuckdata('count',self._getNum())
}
}
},
//指定当前组件的模板 www.lisa33xiaoq.net
template:'<span id="J_input_count"><%= count %>个字</span>',
//私有方法
_getNum:function(){
return this.get('input').val().length || 0
},
//覆盖实现 setUp 方法,所有逻辑写在这里。最后可以使用 render 来决定需不需要渲染模板
//模板渲染后会 append 到 parentNode 节点下面,如果未指定,会 append 到 document.body
setUp:function(){
var self = this;
var input = this.get('parentNode').find('#J_input')
self.set('input',input)
var num = this._getNum()
//赋值数据,渲染模板,选用。有的组件没有对应的模板就可以不调用这步。
self.render({
count:num
})
}
})
$(function() {
//传入 parentNode 节点,组件会挂载到这个节点上。所有事件都会代理到这个上面。
new TextCount({
parentNode:$("#J_test_container")
});
})
/**对应的 html,做了些修改,主要为了加上 parentNode,这边就是 J_test_container
<div id="J_test_container">
<input type="text" id="J_input"/>
</div>
*/
看下上面的用法,可以看到变得更简单清晰了:
事件不需要自己绑定,直接注册在 EVENTS 属性上。程序会自动将事件代理到 parentNode 上。
引入了模板机制,使用 template 规定组件的模板,然后在 setUp 里面使用 render(data)的方式渲染模板,程序会自动帮你 append 到 parentNode 下面。
单向绑定,无需操作 dom,后面要改动内容,不需要操作 dom,只需要调用 setChuckdata(key,新的值),选择性的更新某个数据,相应的 html 会自动重新渲染。
下面我们看下 richebase 的实现:
var RichBase = Base.extend({
EVENTS:{},
template:'',
init:function(config){
//存储配置项
this.__config = config
//解析代理事件
this._delegateEvent()
this.setUp()
},
//循环遍历 EVENTS,使用 jQuery 的 delegate 代理到 parentNode
_delegateEvent:function(){
var self = this
var events = this.EVENTS || {}
var eventObjs,fn,select,type
var parentNode = this.get('parentNode') || $(document.body)
for (select in events) {
eventObjs = events[select]
for (type in eventObjs) {
fn = eventObjs[type]
parentNode.delegate(select,type,function(e){
fn.call(null,self,e)
})
}
}
},
//支持 underscore 的极简模板语法
//用来渲染模板,这边是抄的 underscore 的。非常简单的模板引擎,支持原生的 js 语法
_parseTemplate:function(str,data){
/**
* http://ejohn.org/blog/javascript-micro-templating/
* https://github.com/jashkenas/underscore/blob/0.1.0/underscore.js#L399
*/
var fn = new Function('obj',
'var p=[],print=function(){p.push.apply(p,arguments);};' +
'with(obj){p.push(\'' + str
.replace(/[\r\t\n]/g, " ")
.split("<%").join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t").join("');")
.split("%>").join("p.push('")
.split("\r").join("\\'") +
"');}return p.join('');")
return data ? fn(data) : fn
},
//提供给子类覆盖实现
setUp:function(){
this.render()
},
//用来实现刷新,只需要传入之前 render 时的数据里的 key 还有更新值,就可以自动刷新模板
setChuckdata:function(key,value){
var self = this
var data = self.get('__renderData')
//更新对应的值
data[key] = value
if (!this.template) return;
//重新渲染
var newHtmlNode = $(self._parseTemplate(this.template,data))
//拿到存储的渲染后的节点
var currentNode = self.get('__currentNode')
if (!currentNode) return;
//替换内容
currentNode.replaceWith(newHtmlNode)
self.set('__currentNode',newHtmlNode)
},
//使用 data 来渲染模板并且 append 到 parentNode 下面
render:function(data){
var self = this
//先存储起来渲染的 data,方便后面 setChuckdata 获取使用
self.set('__renderData',data)
if (!this.template) return;
//使用 _parseTemplate 解析渲染模板生成 html
//子类可以覆盖这个方法使用其他的模板引擎解析
var html = self._parseTemplate(this.template,data)
var parentNode = this.get('parentNode') || $(document.body)
var currentNode = $(html)
//保存下来留待后面的区域刷新
//存储起来,方便后面 setChuckdata 获取使用
self.set('__currentNode',currentNode)
parentNode.append(currentNode)
},
destroy:function(){
var self = this
//去掉自身的事件监听
self.off()
//删除渲染好的 dom 节点
self.get('__currentNode').remove()
//去掉绑定的代理事件
var events = self.EVENTS || {}
var eventObjs,fn,select,type
var parentNode = self.get('parentNode')
for (select in events) {
eventObjs = events[select]
for (type in eventObjs) {
fn = eventObjs[type]
parentNode.undelegate(select,type,fn)
}
}
}
})
主要做了两件事,一个就是事件的解析跟代理,全部代理到 parentNode 上面。另外就是把 render 抽出来,用户只需要实现 setUp 方法。如果需要模板支持就在 setUp 里面调用 render 来渲染模板,并且可以通过 setChuckdata 来刷新模板,实现单向绑定。
7.结语
有了 richbase,基本上组件开发就没啥问题了。比如组件自动化加载渲染,局部刷新,比如父子组件的嵌套,再比如双向绑定,再比如实现 ng-click 这种风格的事件机制。
当然这些东西已经不属于组件里面的内容了。再进一步其实已经是一个框架了。实际上最近比较流行的 react,ploymer 还有我们的 brix 等等都是实现了这套东西。
web 前端中文站点评:
在 Javascript 的开发中,组件化其实和模块化的意义相当,大概是根据功能、业务进行代码划分,使到这部分的代码可以被复用,例如 $、_ 这些工具库就是将功能进行模块化。在近一两年中,ng、react 等在我们开发的 view 层中引入一些自定义标签就可以渲染出整个组件,大家会觉得组件化这东西比较新颖,其实本质上和我们以往的模块化并无差别。