《js 设计模式与开发实践》读书笔记 2
我们接下来讲下封装封装的目的是将信息隐藏。一般而言,我们讨论的封装是封装数据和封装实现。我们还会聊下封装类型和封装变化。这个和我们常说的闭包很像。在 java 中就是用 private,public,protected 等关键字来提供不同的访问权限。但 js 并没有提供对这些关键字的支持,除了 let 这种新的方法。我们以前用闭包来创建作用域。
var myObject = (function () {
var _name = 'sven'
return {
getName: function () {
return _name
}
}
})()
console.log(myObject.getName()) //sven
console.log(myObject._name) //undefined
上面这个封装,指的是数据层面的封装。有时候我们喜欢把封装等同于封装数据,但这是一种比较狭义的定义。但是我们的目的是将信息隐藏,封装的不仅仅是隐藏数据,还包括隐藏实现细节,设计细节以及隐藏对象的类型等。
从封装的实现细节来讲,封装使得对象内部的变化对于其他对象而言是不可见的。对象对它自己的行为负责。其他对象或者用户都不关心它的内部实现。封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 来通信。当我们修改一个对象时,可以随意的修改它的内部实现,只要对外的接口没有变化,就不会影响到程序的其他功能。
我们拿迭代器来说明,迭代器的作用是在不暴露一个聚合对象的内部表示的前提下,提供一种方式顺序访问这个聚合对象。我们编写了一个 each 函数,它的作用就是遍历一个聚合对象,使用这个 each 函数的人不用关心它的内部是怎样实现的,只要它提供的功能正确便可以。即使 each 函数修改了内部源代码,只要对外的接口或者调用方法没有变化,用户就不用关心它内部实现的改变。
《设计模式》书中说到:考虑你的设计中哪些地方可能变化,这种方式与关注会导致重新设计的原因相反。它不是考虑什么时候会迫使你的设计改变,而是考虑你怎样才能够在不重新设计的情况下进行改变。这里的关键在于封装发生变化的概念,这是许多设计模式的主题。这话让人有点看不懂哦。但是他的意思大致是找到变化并封装,设计模式这本书中归纳了 23 种设计模式,从意图上区分,这 23 种设计模式分别被划分为创建型模式,结构型模式,和行为型模式。
拿创建型模式来说,要创建一个对象,是一种抽象行为,而具体创建什么对象则是可以变化的,创建型模式的目的就是封装创建对象的变化。而结构型模式封装的是对象之间的组合关系。行为型模式封装的是对象的行为变化。
通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。这可以最大程度地保证程序的稳定性和可扩展性。《设计模式》这本书的副标题是可复用面向对象软件的基础。这本书把大多数笔墨都放在如何封装变化上面,这跟编写可复用的面向对象程序是不矛盾的。我们想办法把程序中变化的部分封装好之后,剩下的即是可复用的部分了。
Brendan Eich(布兰登 艾克)是 js 的创造者和架构师。他在给 js 设计面向对象系统时,借鉴了两门基于原型的语言。之所以选择基于原型的面向对象系统,并不是因为时间匆忙,它设计起来相对简单,而是因为从一个开始 Brendan 就没有打算在 js 中加入类的概念。在以类为中心的面向对象编程语言中,类和对象的关系可以想象成铸模和铸件的关系,对象总是从类中创建而已。而在原型编程的思想中,类不是必需的,对象未必需要从类中创建而来,一个对象是通过克隆另外一个对象所得到的。原型模式不单是一种设计模式,也被称为一种编程泛型。我们讲下原型模式。
从设计模式的角度讲,原型模式是用于创建对象的一种模式,如果我们想要创建一个对象,一种方法是先指定它的类型,然后通过类来创建这个对象。原型模式选择了另外一种方式,我们不再关心对象的具体类型,而是找到了一个对象,然后通过克隆来创建一个一模一样的对象。
设想下,我们编写一个飞机大战的游戏,飞机拥有分身技能,当它使用分身技能时,要在页面创建一些和它一模一样的飞机。如果不使用原型模式,那么创建分身之前,我们必须先保存飞机的当前血量,炮弹登记,防御等级等信息,随后将这些信息设置到新创建的飞机上面,这样才能得到一架一模一样的新飞机。如果使用原型模式,我们只需要调用负责克隆的方法,便能完成同样的功能。原型模式的实现关键,是语言本身是否提供了 clone 方法。es5 提供了 Object.create 方法,可以用来克隆对象。
var Plane = function () {
this.blood = 100
this.attackLevel = 1
this.defenseLevel = 1
}
var plane = new Plane()
plane.blood = 500
plane.attackLevel = 10
plane.defenseLevel = 7
var clonePlane = Object.create(plane)
console.log(clonePlane.blood)
console.log(clonePlane.attackLevel)
console.log(clonePlane.defenseLevel)
// 不支持Object.create方法的浏览器中,则可以使用以下代码:
Object.create =
Object.create ||
function (obj) {
var F = function () {}
F.prototype = obj
return new F()
}
原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。在 js 这种类型模糊的语言中,创建对象非常容易,也不存在类型耦合的问题。从设计模式的角度来讲,原型模式的意义并不算大。但 js 本身是一门基于原型的面向对象语言,它的对象系统就是使用原型模式来搭建的,所以称之为原型编程泛型也许更合适。原型模式不仅仅是一种设计模式,也是一种编程范型。js 就是使用原型模式来搭建整个面向对象系统的。在 js 中不存在类的概念,对象也并非从类中创建出来的,所有的 js 对象都是从某个对象上克隆而来的。
js 也遵循原型编程的基本规则。所有的数据都是对象。要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。对象会记住它的原型。如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。
js 在设计的时候,模仿 java 引入了两套类型机制:基本类型和对象类型。基本类型包括 undefined,number,boolean,string,function,object.按照 js 设计者的本意,除了 undefined 之外,一切都应该是对象。为了实现这个目标,number.boolean.string 这几种基本类型数据也可以通过包装类的方式变成对象类型数据来处理。
js 中的根对象是 Object.prototype 对象。Object.prototype 对象是一个空的对象。在 js 中,我们不需要关心克隆的细节,因为这是引擎内部负责实现的。我只需要显式地调用 var obj1 = new Object() 或者 var obj2 ={}.此时,引擎内部会从 Object.prototype 上面克隆一个对象出来,我们最终得到的就是这个对象。js 给对象提供了一个名为proto的隐藏属性,某个对象的proto属性默认会指向它的构造器的原型对象,即{Constructor}.prototype.在一些浏览器中proto被公开出来,我们可以用这个代码验证。
var a = new Object()
console.log(a.__proto__ === Object.prototype) // true
在 js 中,每个对象都是从 Object.prototype 对象克隆而来的,如果是这样的话,我们只能得到单一的继承关系。每个对象都继承自 Object.prototype 对象,这样的对象系统显然是非常受限的。实际上,虽然 js 的对象最初都是由 Object.prototype 对象克隆而来的,但对象构造器的原型并不仅限于 Object.prototype 上,而是可以动态指向其他对象。这样以来,当对象 a 需要借用对象 b 的能力时,可以有选择性的把对象 a 的构造器的原型指向对象 b,从而达到继承的效果。
var obj = { name: 'sven' }
var A = function () {}
A.prototype = obj
var b = new A()
console.log(b.name) // sven
执行这段代码的时候,引擎做了什么事情。首先,尝试遍历对象 b 中的所有属性,但没有找到 name 这个属性。查找 name 属性的请求被委托给对象 b 的构造器的原型,它被 b.proto记录着并且指向 B.prototype,而 B.prototype 被设置为一个通过 new A()创建出来的对象。在该对象中依然没有找到 name 属性,于是请求被委托给这个对象构造器的原型 A.prototype。在 A.prototype 中找到了 name 属性,并返回它的值。原型链不是无限长的,当请求传递给 Object.prototype,其中也没有想要的属性,Object.prototype 的原型是 null,这时候请求就会打住,返回 undefined.
设计模式在很多时候其实都体现了语言的不足之处。Peter Norvig 说,设计模式是对语言不足的补充,如果要使用设计模式,不如去找一门更好的语言。这句话非常正确。(所以现在很多开发都在使用 Ts 了,当然 ts 不是一种新的语言,而是 js 的一个超类)。不过,作为前端开发,我们没有办法换一门语言,语言本身也在发展,Object.create 就是原型模式的天然实现。使用 Object.create 来完成原型继承看起来更能体现原型模式的精髓。es6 中有新的 class 语法,但其背后仍然是通过原型机制来创建对象。
再来讲下 this.跟别的语言大相径庭的是,js 的 this 总是指向一个对象,而具体指向哪个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。一般 this 的指向大致可以分为以下 4 种。作为对象的方法调用。作为普通函数调用。构造器调用。Function.prototype.call 或 Function.prototype.apply 调用。
作为对象方法调用的案列:this.指向对象。
var obj = {
a: 1,
getA: function () {
console.log(this === obj) // true
console.log(this.a) // 1
}
}
obj.getA()
作为普通函数调用:当函数不作为对象的属性被调用时,也就是我们常说的普通函数方式,此时的 this 总是指向全局对象。在浏览器中的 js 里,这个全局对象时 window 对象。
window.name = 'globalName'
var getName = function () {
return this.name
}
console.log(getName()) // globalName
window.name = 'globalName'
var myObject = {
name: 'sven',
getName: function () {
return this.name
}
}
var getName = myObject.getName
console.log(getName()) //globalName
这种像是在 div 节点的事件函数内部,有一个局部的 callback 方法,callback 作为普通函数调用时,callback 内部的 this 指向了 window,但我们往往是想让它指向该 div 节点。
<html>
<body>
<div id="div1">我是一个div</div>
</body>
<script>
window.id = 'window'
document.getElementById('div1').onclick = function () {
console.log(this.id) // div1
var callback = function () {
console.log(this.id) //window
}
callback()
}
// 简单的解决方法
document.getElementById('div1').onclick = function () {
var that = this
var callback = function () {
console.log(that.id) //window
}
callback()
}
</script>
</html>
js 中没有类,但是可以从构造器中创建对象,同时也提供了 new 运算符,使得构造器看起来更像一个雷。除了宿主提供的一些内置函数,大部分 js 函数都可以当作构造器使用。构造器的外表跟普通函数一模一样,它们的区别在于被调用的方式。当用 new 运算符调用函数时,该函数总会返回一个对象, 通常情况下,构造器里的 this 就指向返回的这个对象。如果构造器显式地返回了一个 object 类型的对象,那么此次运算结果最终会返回这个对象,而不是我们之前期待的 this.如果构造器不显式地返回任何数据,或者返回一个非对象类型的数据,就不会上面的问题。
var MyClass = function () {
this.name = 'sven'
}
var obj = new MyClass()
console.log(obj.name) // sven
var MyClass = function () {
this.name = 'sven'
return {
name: 'anne'
}
}
var obj = new MyClass()
console.log(obj.name) //anne
var MyClass = function () {
this.name = 'sven'
return 'anne'
}
var obj = new MyClass()
console.log(obj.name) //sven
跟普通的函数调用相比,用 Function.prototype.call 或 Function.prototype.apply 可以动态改变传入函数的 this。call 和 apply 方法能很好地体现 javascript 的函数式语言特性,在 js 中,几乎每一次编写函数式语言风格的代码,都离不开 call 和 apply。
var obj1 = {
name: 'sven',
getName: function () {
return this.name
}
}
var obj2 = {
name: 'anne'
}
console.log(obj1.getName()) // sven
console.log(obj1.getName.call(obj2)) // anne
var obj = {
myName: 'sven',
getName: function () {
return this.myName
}
}
console.log(obj.getName()) // sven
var getName2 = obj.getName
console.log(getName2()) // undefined
当调用 obj.getName 时,getName 方法是作为 obj 对象的属性被调用的,此时的 this 指向 obj 对象,所以 obj.getName()输出 sven。当用另外一个变量 getName2 来引用 obj.getName,并且调用 getName2 时,此时是普通函数调用方式,this 是指向全局 window 的,所以程序的执行结果是 undefined.
document.getElementById('div1').onclick = function () {
var func = function () {
console.log(this.id)
}
func.call(this)
}
大部分高级浏览器都实现了内置的 Function.prototype.bind,用来指定函数内部的 this 指向,即使没有原生的 Function.prototype.bind 实现,我们模拟一个。
Function.prototype.bind = function (context) {
var self = this
return function () {
return self.apply(context, arguments)
}
}
var obj = {
name: 'sven'
}
var func = function () {
console.log(this.name) // sven
}.bind(obj)
func()