klass.js是一个oop风格的javascript库,提供Javascript传统面对对象的编程风格。

起因"#"

由于部门是从事电商领域,需要全面兼容不同的客户端,这就造成了我们前架构的“繁杂”,以PC端为例:jQuery + jqoteplus + freemarker-Template + React + klass + sass的混搭风你可能真的很少见过(微笑脸)。为了SEO友好,PC端大部分的页面都是使用传统的模版语言来进行后端渲染,前端复用效率比较低,这也就造成了可能只是一个小的需求修改,我们不得不深入到每一个具体的模版页面去修改,降低了工作效率不说,也很繁琐。好像有点跑题了,关于团队的项目后期有机会再聊~

作为一个电商部门,web页面中有许多公共的部分,例如页面头部的类目列表,顶部的用户信息栏、mini购物车等,这些公共的部分其实我们可以统一抽出来作为公共的业务逻辑进行打包处理。前面可能说了,作为电商网站,可能没法使用当前最新的前端技术栈,我们需要良好的SEO,需要兼容IE8(是的,不要惊讶我们还在兼容IE8),ES6甜甜的语法糖也因为项目在构建中的问题暂时没法使用,回归到ES5中,如何实现公共页面的逻辑统一封装?终于到了本文的正题了-使用klass.js a utility for creating expressive classes in JavaScript 一段在javascript中创建动态类的实用程序。

使用klass"#"

使用klass创建一个类十分简单。

1
2
3
4
5
6
7
8
9
10
11
var Person = klass({
initial: function(name, age){
this.name = name;
this.age = age;
},
sayHello: function(){
console.log("Hello " + this.name); // Hello simmer
}
});
var simmer = new Person("simmer", 23);
simmer.sayHello();

通过调用klass显式的传入一个包含键值对的对象进行,klass默认会寻找其中key为initial的属性作为构造函数借用的方法,这里可以设置实例对象的私有属性(包括静态属性和方法)。为什么说借用构造函数呢?因为真实的构造函数并不是initial,只是在真实的构造函数中通过this.initial.apply(this, arguments)来实现借用构造函数,这点后面会进行说明。除了initial方法之外,其他的属性都将会被添加到构造函数的原型对象中,作为原型的方法被所有的实例所共享。
当然,还有另外定义类的方式:

1
2
3
4
5
6
7
8
9
10
11
var Person = klass(function(name, age){
this.name = name;
this.age = age;
})
.method({
sayHello: function(){
console.log("Hello" + this.name + "who is " + this.age + " years old"); //Hello sunny who is 23 years old
}
});
var sunny = new Person("sunny", 23);
sunny.sayHello();

像上面这种定义类的方法可能更加接近原生的javascript中的构造函数定义类的方法, 构造函数中设置实例对象特有的属性/方法,在Constructor.prototype上添加实例共享方法/属性。
为了使得代码更加靠近传统的oop,组内现有的使用方式是第一种,这种写法也基本与ES6的class保持一致。抛开业务逻辑谈论是没有多少意义的,这里以一个简单的3个类为例子,BaseComponentBaseModuleUcenterModule分别代表一般控件类、页面基类、用户中心基类。

  • 控件类是类似弹窗、toast等基本控件的继承类,封装了基本的控件类方法,包括控件的确定/取消按钮事件等。
  • 页面基类是绝大部分页面的继承类,这里类中封装了页面的公共逻辑,比如显示头部导航栏/侧边栏/底部等,是几乎所有的页面都共享的逻辑,这里的逻辑,只要在特定页面中继承这个基类,可以做到公共业务逻辑的公用。
  • 用户中心类是用户中心的特定类,除了函括大部分页面的公共逻辑之外还有专属的用户信息显示栏,所以UcenterModule是继承自BaseModule并封装了用户中心的统一处理逻辑,用户中心如果要新添加页面就只需要去继承UcenterModule这个类就好了。

那么实际的代码大概是怎么样的呢?以下是简化的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var BaseComponent = klass({
initialize: function(options){
//this is top initial
var _data = options.data || {};
this.config && this.config(_data); // 配置config参数
this.baseComponentInit();
},
baseComponentInit: function(){
//init baseComponent here
}
});
var BaseModule = BaseComponent.extend({
initialize: function(options){
this.supr(options); // call super initial function
this.BaseModuleInit();
},
BaseModuleInit: function(){
// init baseModele here
}
});
var UcenterModule = BaseComponent.extend({
initialize:function(options){
this.supr(options);
this.UcenterModuleInit();
},
UcenterModuleInit:function(){
//init you UcentModule here
}
})

当我们要实现一个弹窗组件的时候,只需要

1
2
3
4
5
6
var Dialog = BaseComponent.extend({
initialize:function(){
this.supr();
// initialize you code from here
}
});

新键一个页面,只需要

1
2
3
4
5
6
var Page = BaseModule.extend({
initialize: function(){
this.supr();
// initialize you code from here
}
})

新建一个用户中心页面,只需要

1
2
3
4
5
6
var UcenterPage = UcentModule.extend({
initialize: function(){
this.supr();
// initialize you code from here
}
})

看起来,基于oop(Object-oriented programming 面对对象程序设计)编程能够让我们将业务逻辑进行分层,通过抽象出可以复用的公共逻辑,并进行合理的分层就可以大大的提高我们的编码效率。

分而治之,大概说的就是这个意思吧。
在分析源码之前,希望你能带着几个问题和我一同探索klass的实现:

  • klass如何实现类的公共方法?
  • 子类如何保证在初始化的时候对父类进行相应的初始化?
  • klass如何实现类的继承?

源码分析"#"

klass未压缩版的源码加上注释仅仅只有91行,但是实现起来会发现每一行都凝结了智慧的结晶。首先让我们看一下klass模块的封装

klass模块封装"#"

klass模块是通过称之为UMD(Universal Module Definition 通用模块定义)的方式来实现的。具体来说就是一个IIFE(Immediately-invoked function expression 立即执行函数表达式)来暴露命名接口klass

1
2
3
4
5
6
7
(function (name, context, definition){
if (typeof define == 'function') define(definition); // Amd
else if (typeof module != 'undefined') module.exports = definition(); // commonJS
else context[name] = definition();
}('klass', this, function(){
// klass 实现
}))

前端模块化也是一个大家一致在讨论的问题,从全局函数满天飞到后面的基于AMD的requireJs到基于CMD的seaJS到基于Node的commonJs模块,到目前webpack打包一统天下的局面,前端模块化经历了几个时代的发展。UMD是一种通用模块定义方式,相对来说它不是一种标准而是一种更好兼容不同模块化环境的一种最佳体验,具体来说就是它会检测当前是否是作为AMD模块,如果不是检测是否是commonJS模块,都不是的话就暴露为context下的一个接口,在浏览器中contenxt通常就是window

三个函数"#"

上面我提到的三个问题还没忘记吧?现在我们来一个个的解答。不过在解答之前,先介绍源码中的基本函数/变量:

1
2
3
4
5
6
7
8
var context = this
, f = 'function' // f代表 function 字符串
, fnTest = /xyz/.test(function () {xyz}) ? /\bsupr\b/ : /.*/ // fnText 测试方法是否为类入口函数
, proto = 'prototype'; // prototype的字符串 用于通过a.[proto]动态求值属性
function isFn(o) { // 判断o是否是函数
return typeof o === f
}

process函数"#"

解答问题:klass如何实现类的公共方法?

1
2
3
4
5
6
7
8
9
10
function process(what, o, supr) {
for (var k in o) {
if (o.hasOwnProperty(k)) {
what[k] = isFn(o[k]) // 这里的3目运算符是为了判断在子类源对象(即o中)的入口函数是哪个
&& isFn(supr[proto][k]) // 通常入口函数名所有类都保持一致,就像最上面的initialize方法
&& fnTest.test(o[k]) // 也要测试子类入口函数中是否显示的调用父类 e.g.上面在initialize方法中调用 this.supr()
? wrap(k, o[k], supr) : o[k] // 如果使满足条件的入口方法就通过wrap函数包裹住,否则直接将属性赋值到原型对象上
}
}
}

上面的函数是用来处理将方法/属性动态添加到构造函数原型/实例对象上的。
调用的时候像下面这样使用:

1
2
process(proto, o, this);
// proto === 父类原型 o === 子类继承父类传递到extend()里面或klass里面的包含各种类方法的对象

通过调用上面的process函数就可以很方便的实现子类原型上属性/方法的正确设置。

wrap函数"#"

解答问题:子类如何保证在初始化的时候对父类进行相应的初始化?
我们知道在使用klass创建的类中有都有一个默认入口方法,这个入口方法的作用就是初始化该类封装的公共逻辑,那么klass是如何保证在子类中的入口函数中调用this.super()就可以初始化他的上层/上上层父类中封装的逻辑呢?答案就是:闭包。我们来看下面的wrap函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function wrap(k, fn, supr) { // 入口函数的包裹 k代表入口函数key fn代表入口函数 supr代表父类构造函数
return function () {
var tmp = this.supr; // 暂存this实例上的supr属性
this.supr = supr[proto][k]; //supr.prototype.initialize 将父类原型上的入口函数赋值给实例的supr属性
var undef = {}.fabricatedUndefined; // 保证undef 真的等于 undefined 获取空对象不存在的属性会返回"undefined"
var ret = undef;
try {
ret = fn.apply(this, arguments); // 调用入口函数绑定this并传入参数
} finally {
this.supr = tmp; // 将this实例上的supr属性赋值回来
}
return ret // 返回入口函数运行的结果
}
}

上面的process函数的作用有两个:

  • 一个是将相关的属性加载到原型对象/构造函数对象上
  • 找到类的入口函数,并将它包裹一层闭包返回。(这是保证了子类上调用this.supr的调用顺序,一定会指向上一层父类的入口函数而不是上上层超父类的入口函数,希望你没有忘记访问原型链中属性的同名遮蔽效应!)

extend函数"#"

回答:klass如何实现类的继承?==> 通过extend函数来实现类继承。
我们知道在ES5中,继承的实质是通过将子类的原型对象设置为父类的实例来完成的,klass也不例外。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function extend(o, fromSub) {
// must redefine noop each time so it doesn't inherit from previous arbitrary classes
function noop() {}
noop[proto] = this[proto] // 使用空函数作为中介实现原型继承
var supr = this // 保存父类
, prototype = new noop() // 对象指向noop.prototype
, isFunction = isFn(o)
, _constructor = isFunction ? o : this // 判断构造函数
, _methods = isFunction ? {} : o // 方法 如果是函数则返回空的字面量对象 否则将包含各种方法的对象赋值给_methods
function fn() {
if (this.initialize) this.initialize.apply(this, arguments); // 借用initialize作为构造函数
else {
fromSub || isFunction && supr.apply(this, arguments); // 另外一种形式调用则自动调用父类
_constructor.apply(this, arguments); // 调用子类的构造函数
}
}
fn.methods = function (o) {
process(prototype, o, supr); // 将方法复制到原型上
fn[proto] = prototype; // 设置正确的原型
return this; // 返回this实现链式调用
}
fn.methods.call(fn, _methods).prototype.constructor = fn; // 修正constructor
fn.extend = arguments.callee; // 递归回调
fn[proto].implement = fn.statics = function (o, optFn) { // 提供复写原型对象/构造函数上属性的方法
o = typeof o == 'string' ? (function () {// 如果o是属性名字 就包装成对象{key: value}形式
var obj = {};
obj[o] = optFn;
return obj;
}()) : o
process(this, o, supr); // 将o对象的方法赋值到this中 当通过Constructor.statics调用this指的是constructor
return this; // 接上面:当Constructor.prototype.statics this指的是Constructor.prototype
}
return fn; //返回真正的构造函数!
}

上面的extend函数后面有加上我的注释,可以照着代码多看看,如果有错误欢迎指正~,这里简单说下extend函数的大致流程:

  • 首先初始化并赋值必要的变量
  • 声明真实的构造函数fn,并为构造函数设置正确的原型链,修正原型对象上的constructor
  • fn添加extend方法,来让子类递归调用extend实现层层继承
  • fn添加staticsfn.prototpue添加implement方法实现对象属性的覆写
  • 返回fn函数

小结"#"

这里来做个小的总结:
1、为什么要使用oop?答:因为可以更直观的以传统面对对象方式来编程,对于大多数程序员来说理解起来不会费劲。
2、通过将类进行拆分,将公共的逻辑进行封装,可以有效的提升开发效率。

建议"#"

  • klass虽然用着还不错,在通读其源码之后要好好的利用他的优势,避免不利点。Javascript中实现类继承的实质就是通过延长_proto_原型链来完成的,这样造成的查找可能会存在一些同名遮蔽效应带来的问题。举个例子来说:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    var Person = klass({
    initialize: function(options){
    this.init();
    },
    init: function(){
    console.log("Say hello from Person!")
    }
    });
    var Asian = Person.extend({
    initialize:function(options){
    this.supr(options); // this.supr ==> supr.prototype.initialize supr 通过闭包保证了正确的指向
    this.init(options);
    },
    init: function(){
    console.log("Say hello from Asian!")
    }
    });
    var Chinese = Asian.extend({
    initialize: function(options){
    this.supr(options);
    this.init(options);
    },
    init: function(){
    console.log("Say hello from Chinese!"); // Say hello from Chinese! *3!
    }
    });
    new Chinese({
    data: "From china!"
    })

上面的代码相信你可以看出问题来,在实例化Chinese的时候,他会执行入口函数initialize,在initialize函数中呢,klass通过wrap函数将supr(父类构造函数)包裹在闭包中来保证this.supr一定指向Asian.initialize,同理在Asian.initialize中的this.supr指向的是Person.initialize。以此来实现在new Chinese()的时候由继承的顺序分别调用父类的initialize入口方法。但是在Chinese,Asian,Person的入口函数中调用的init方法本想着调用该类的init方法,但是由于javascript原型链的机制,查找init属性的顺序会是Chinese.proptotype=>Asian.prototype=>Person.prototype所以调用的initChinese类上的方法,这个问题是由于javascript的原型链造成的,我们应该知道这个可能存在的问题并且极力避免他。

如有错误,欢迎留言指正。