JS 作为面向对象的一门语言,拥有和其他面向对象语言一样的三大特征,即封装(encapsulation)、继承(inheritance )和多态(polymorphism )。关于继承的概念和实现,在本系列不在赘述,有兴趣的同学可以看看JS入门难点解析12-原型链与继承。
封装的目的是将信息隐藏,狭义的封装是指封装数据,广义的封装还包括封装实现,封装类型和封装变化。
这其实也是网上各处资料里面对封装最常见的定义了。主要目的就是隐藏数据信息,包括属性和方法的私有化。下面我们以一个用户对象的例子,一起来了解一下JS如何进行数据的封装。
假设我们要开发一个网站,需要一个用户对象的构造函数。我们可能会写如下代码:
// version1
function User(name, age) {
// 定义用户信息
this.name = name;
this.age = age;
// 定义用户行为
this.sayWords = function(words){console.log(words);}
}
好了,User构造函数定义好了,我们只要传入name,age,就可以新建一个User实例了,每个实例对象拥有自己的name,age,并且可以发言:
var user1 = new User('ZhangSan', '23');
console.log(user1.name); // 'ZhangSan'
user1.sayWords('hi'); // 'hi'
var user2 = new User('LiSi', '26');
console.log(user2.name); // 'LiSI'
user1.sayWords('hello'); // 'hello'
另外,每个用户随时可以修改自己的名字和年龄,也可以重新定义sayWords方法,且互不干扰:
user1.name = 'Mr. Zhang';
console.log(user1.name); // 'Mr. Zhang';
console.log(user2.name); // 'LiSi'
user1.sayWords = function(words) {console.log(`Mr. Zhang: ${words}`);}
user1.sayWords('hi'); // 'Mr. Zhang: hi'
user2.sayWords('hello'); // 'hello'
大家看到这里其实很清楚,name,age,sayWords其实就是实例属性和实例方法,这些属性和方法可以被实例直接访问和调用,所以叫做公有属性和公有方法。
实际使用中,我们不会将实例方法写在构造函数中,因为方法的功能是一样的,我们没必要定义多次,而是将其放在了构造函数的原型中,像下面这样:
// version2
function User(name, age) {
// 定义用户信息
this.name = name; // 公有属性
this.age = age; // 公有属性
}
// 定义用户行为
User.prototype.sayWords = function(words){console.log(words);} // 公有方法
需要注意的是,此时如果在实例中企图修改sayWords方法,并不影响原型中定义的sayWords方法,而是在实例中新建了sayWords方法,并覆盖了原型中的同名方法。
User对象在目前看来没有什么问题,但是如何去唯一识别该用户呢,用户的name这里是可以随意修改的昵称,无法用来识别用户,所以在创建User实例的时候,我们要求用户输入唯一的用户名id,并且不允许改动。如下:
// version3
function User(name, age, id) {
// 定义用户信息
var id = id;
this.name = name; // 公有属性
this.age = age; // 公有属性
}
// 定义用户行为
User.prototype.sayWords = function(words){console.log(words);} // 公有方法
现在我们再来看一下
var user1 = new User('ZhangSan', '23', 'ZhangSan333');
console.log(user1.id); // undefined
user1.id = 'ZhangSan666';
console.log(user1.id); // 'ZhangSan666'
怎么回事,我不仅不能读取id,反而还能随意修改id?这和需求完全相反了啊。其实真实原因是你不仅不能读取id,也无法操作在构造函数中定义的id。要验证这点很容易,首先我们提供一个方法允许用户实例访问该id,然后验证一下直接使用实例修改id是否修改了构造函数中的id。
// version4
function User(name, age, id) {
// 定义用户信息
var id = id;
this.name = name; // 公有属性
this.age = age; // 公有属性
this.getId = function() {return id;}
}
// 定义用户行为
User.prototype.sayWords = function(words){console.log(words);} // 公有方法
再来看一下:
var user1 = new User('ZhangSan', '23', 'ZhangSan333');
console.log(user1.id); // undefined
console.log(user1.getId()); // 'ZhangSan333'
user1.id = 'ZhangSan666';
console.log(user1.id); // 'ZhangSan666'
console.log(user1.getId()); // 'ZhangSan333'
可以发现user1.id和user1.getId()的值并不一样。事实上,user1.id只是新建的一个实例属性而已,并不是构造函数里的变量id。
到这里,我们可以看到,id只能通过getId方法去访问。这里的id就是构造函数内部的私有属性,getId就是特权方法。假设你要定一个不允许用户随意修改的方法,也可以参照私有属性的设置方法来定义。另外,一般私有属性和私有方法,我们会约定在前面加下环线来标识:
// version5
function User(name, age, id) {
// 定义用户信息
var _id = id; // 私有属性
var _sayHi = function(){console.log('hi');} // 私有方法
this.name = name; // 公有属性
this.age = age; // 公有属性
this.getId = function() {return id;} // 特权方法
this.sayHi = function(){return _sayHi;} // 特权方法
}
// 定义用户行为
User.prototype.sayWords = function(words){console.log(words);} // 公有方法
私有属性和私有访问,是我们可以将一些信息封装起来,并设置不同的访问和操作权限。
现在,再考虑一个新的需求。一群用户被创建以后,你想按某个规律去查看这群用户的信息,比如说年龄。那么,这个排序方法需要让每个实例都拥有吗?显然是不需要的,我们只需要让构造函数对象拥有该方法即可。当然我们为了方便确定构造函数,也可以给它起个名字。就像下面这样:
// version6
function User(name, age, id) {
// 定义用户信息
var _id = id; // 私有属性
var _sayHi = function(){console.log('hi');} // 私有方法
this.name = name; // 公有属性
this.age = age; // 公有属性
this.getId = function() {return _id;} // 特权方法
this.sayHi = function(){return _sayHi;} // 特权方法
}
User.name = 'User'; // 静态属性
User.sortByAge = function(...arguments){return arguments.sort((a, b)=>{return a.age - b.age;})} // 静态方法
// 定义用户行为
User.prototype.sayWords = function(words){console.log(words);} // 公有方法
我们来一起看一下效果:
var user1 = new User('ZhangSan', '23', 'ZhangSan333');
var user2 = new User('LiSI', '26', 'LiSi666');
var user3 = new User('WangWu','19' , 'WangWu888');
console.log(User.name);
User.sortByAge(user1, user2, user3).forEach(item => {console.log(item.name);}) // 'WangWu' 'ZhangSan' 'LiSi'
当然,有人会想这里每次实例化一个对象,都会新建一个特权方法,浪费空间。是不是可以把特权方法也放到原型中呢?下面我们来用闭包尝试一下:
// version7
(function() {
// 定义私有属性和私有方法
var _id; // 私有属性
var _sayHi = function(){console.log('hi');} // 私有方法
// 定义公有属性
User = function(name, age, id) {
_id = id;
this.name = name; // 公有属性
this.age = age; // 公有属性
}
// 定义静态属性和静态方法
User.name = 'User'; // 静态属性
User.sortByAge = function(...arguments){return arguments.sort((a, b)=>{return a.age - b.age;})} // 静态方法
// 定义特权方法和公有方法
User.prototype.getId = function() {return _id;} // 特权方法
User.prototype.sayHi = function(){return _sayHi;} // 特权方法
User.prototype.sayWords = function(words){console.log(words);} // 公有方法
})();
我们来一起看一下效果:
var user1 = new User('ZhangSan', '23', 'ZhangSan333');
var user2 = new User('LiSI', '26', 'LiSi666');
var user3 = new User('WangWu','19' , 'WangWu888');
console.log(User.name);
User.sortByAge(user1, user2, user3).forEach(item => {console.log(item.name);}) // 'WangWu' 'ZhangSan' 'LiSi'
我们发现是没有问题的。但是需要注意的一点是,这时候这里的私有属性和私有方法并不是实例单独拥有,而是所有实例共享的属性和方法了。可以看如下代码:
user1.getId(); // 'WangWu'
所以,在《JS高级程序》中也把这里的私有变量和私有方法称作静态私有变量和静态私有方法。其实我觉得这里的定义都是有道理的,在前面我们将静态私有属性和静态私有方法挂载到构造函数上,所有实例都无法访问,和将静态私有属性和静态私有方法被所有实例共享。本质上,都是希望这类属性和方法记录的是该类型的类型变量和类型方法。
封装的目的是将信息隐藏,也就是说封装并不仅仅是指数据信息的封装,还有实现,隐藏类型以及在设计层面上对变化的封装。
这一点其实很好解释。封装可以使对象内部的变化对其他对象而言是透明的,对象只对自己的行为负责。对象之间通过暴露API接口来进行通信,其他对象和用户不需要关心API的实现细节,是的对象之间的耦合变松散。你可以随意修改一个API的实现,只要它对外表现的行为一致。
举个很简单的例子,我们封装一个计算传入参数2倍的一个例子:
// 实现一:
function doubleNum(num) {return num*2;}
// 实现二:
function doubleNum(num) {return num+num;}
不管你是用哪种实现方法,只要调用该API我能获得传入参数的2倍即可。
封装类型是静态语言中一种重要的封装方式,一般而言,封装类型是通过抽象类和接口进行的。但是在JavaScript中,并没有所谓的抽象类和接口,也并不需要。因为JS本身就是一门类型模糊的语言,不需要其使用类型封装。
这一点是从设计模式的角度出发,封装在设计模式层面体现为封装变化。设计模式最重要的一点在于,找到变化并封装之。通过对变化的封装,把系统中稳定不变的部分和容易变化的部分分离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已封装好的,替换起来就相对容易。这可以最大程度地保证程序的稳定性和可扩展性。
BOOK-《JavaScript设计模式与开发实践》 第一部分
BOOK-《JavaScript高级程序设计》第三版 第7章