第1章:面向对象的JavaScript
-
动态类型和鸭子类型
编程语言按照数据类型大体可以分为两类:
① 静态类型语言:在编译时便已确定变量的类型。
② 动态类型语言:变量类型要到程序运行的时候,待变量被赋予某个值之后,才会具有某种类型。
【鸭子类型】:如果它走起路来像鸭子,叫起来像鸭子,那么它就是鸭子。
鸭子类型指导我们只关注对象的行为,而不关注对象本身,也就是关注 HAS-A,而不是 IS-A。
☛ 在动态类型语言的面向对象设计中,鸭子类型的概念至关重要。利用鸭子类型的思想,我们不必借助超类型的帮助,就能轻松地在动态类型语言中实现一个原则:“面向接口编程,而不是面向实现编程。”
-
多态
【多态的实际含义】:同一操作作用于不同的对象上面,可以产生不同的解释和不同的执行结果。即,给不同的对象发送同一个消息的时候,这些对象会根据这个消息分别给出不同的反馈。
【多态背后的思想】:是将“做什么”和“谁去做以及怎样去做”分离开来,也就是将“不变的事物”与“可能改变的事物”分离开来。
静态类型的面向对象语言通常被设计为可以“向上转型”,使用继承得到多态效果,是让对象表现出多态性的最常用手段。
JavaScript 对象的多态性是与生俱来的,并不需要诸如向上转型之类的技术来取得多态的效果。
将行为分布在各个对象中,并让这些对象各自负责自己的行为,这正是面型对象设计的优点。
对象的多态性提示我们,“做什么”和“怎么去做”是可以分开的。
-
封装
封装的目的是将信息隐藏,封装应该被视为“任何形式的封装”,即,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等。
(1)封装数据
JavaScript 只能依赖变量的作用域来实现封装特性,而且只能模拟出 public 和 private 这两种封装性。
var myObject = (function() { var __name = 'sven'; // 私有(private)变量 return { getName: function() { // 公开(public)函数 return __name; } } })(); console.log(myObject.getName()); // 'sven' console.log(myObject.__name); // 'undefined'
(2)封装实现
封装使得对象之间的耦合变松散,对象之间只通过暴露的 API 接口来通信。
(3)封装类型
封装类型是静态语言中一种重要的封装方式。一般而言,封装类型是通过抽象类和接口来进行的。
而 JavaScript 本身是一门类型模糊的语言,在封装类型方面没有能力,也没有必要做的更多。
(4)封装变化
从设计模式的角度出发,封装在更重要的层面体现为封装变化。
通过封装变化的方式,把系统中稳定不变的部分和容易变化的部分隔离开来,在系统的演变过程中,我们只需要替换那些容易变化的部分,如果这些部分是已经封装好的,替换起来也相对容易。
这可以很大程度地保证程序的稳定性和可扩展性。
-
原型模式和基于原型的 JavaScript 对象系统
(1)使用克隆的原型模式
原型模式是通过克隆来创建对象的。使用原型模式,我们只需要调用负责克隆的方法,便能完成同样的功能。
【原型模式的实现关键】:语言本身是否提供了 clone 方法,ECMAScript 5 提供了
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); // 在不支持 Object.create 方法的浏览器中,可以使用以下代码: Object.create = Object.create || function(obj) { var F = function() {}; F.prototype = obj; return new F(); }
(2)克隆是创建对象的手段
原型模式的真正目的并非在于需要得到一个一模一样的对象,而是提供了一种便捷的方式去创建某个类型的对象,克隆只是创建这个对象的过程和手段。
(3)体验 Io 语言
原型模式不仅仅是一种设计模式,也是一种编程范型。JavaScript 就是使用原型模式来搭建整个面向对象系统的。
在 JavaScript 语言中不存在类的概念,对象也并非从类中创建出来的,所有的 JavaScript 对象都是从某个对象上克隆而来的。
(4)原型编程范型的一些规则
【原型编程中的一个重要特性】:当对象无法响应某个请求时,会把该请求委托给它自己的原型。
【原型编程范型至少包括以下基本规则】:
- 所有的数据都是对象。
- 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它。
- 对象会记住它的原型。
- 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型。
(5)JavaScript 中的原型继承
① 所有的数据都是对象
JavaScript 在设计时,模仿 Java 引入了两套类型机制:基本类型和对象类型。基本类型包括
undefined
、number
、boolean
、string
、function
、object
。按照 JavaScript 设计者的本意,除了
undefined
之外,一切都应是对象。为了实现这一目标,number
、boolean
、string
这几种基本类型数据也可以通过“包装类”的方式变成对象类型数据来处理。我们不能说在 JavaScript 中所有的数据都是对象,但可以说绝大部分数据都是对象。 JavaScript 中的跟对象是
Object.prototype
空对象,每个对象都是从它克隆而来的,它就是它们的原型。var obj1 = new Object(); var obj2 = {}; console.log(Object.getPrototypeOf(obj1) === Object.prototype); // true console.log(Object.getPrototypeOf(obj2) === Object.prototype); // true
② 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
JavaScript 的函数既可以作为普通函数被调用,也可以作为构造器被调用。当使用
new
运算符来调用函数时,此时的函数就是一个构造器。③ 对象会记住它的原型
就 JavaScript 的真正实现来说,其实并不能说对象有原型,而只能说对象的构造器有原型。对象把请求委托给它的构造器的原型。。
对象的
__proto__
的隐藏属性,默认会指向它的构造器的原型对象,即{Constructor}.prototype
。实际上,__proto
就是对象跟“对象构造器的原型”联系起来的纽带。④ 如果对象无法响应某个请求,它会把这个请求委托给它自己的原型
(6)原型继承的未来
很多时候,设计模式其实都体现了语言的不足之处。Peter Norvig 曾说,设计模式是对语言不足的补充,如果要使用设计模式,不如去找一门更好的语言。
虽然大多数主流浏览器都提供了
Object.create
方法,但通过其来创建对象的效率并不高,通常比通过构造函数创建对象要慢。另外,通过设置构造器的
prototype
来实现原型继承的时候,除了根对象Object.prototype
本身之外,任何对象都会有一个原型。而通过Object.object(null)
可以创建出没有原型的对象。ECMAScript 6 带来了新的 Class 语法。但其背后仍是通过原型机制来创建对象的。示例代码:
class Animal { constructor(name) { this.name = name; } getName() { return this.name; } } class Dog extends Animal { constructor(name) { super(name); } speak() { return "woof"; } } var dog = new Dog('Scamp'); console.log(dog.getName() + ' says ' + dog.speak());
第2章:this、call 和 apply
-
this
在 JavaScript 中,
this
总是指向一个对象,而具体指向哪一个对象是在运行时基于函数的执行环境动态绑定的,而非函数被声明时的环境。this
的指向大致可以分为以下4种:- 作为对象的方法调用。
- 作为普通函数调用。
- 构造器调用。
Function.prototype.call
或Function.prototype.apply
调用
☛ 丢失的
this
var obj = { myName: 'sven', getName: function() { return this.myName; } }; // 作为对象的方法调用 console.log(obj.getName()); // 'sven' // 作为普通函数调用 var getName2 = obj.getName; console.log(getName2()); // 'undefined'
-
call 和 apply
【区别】:传参方式不同
【用途】:
(1)改变
this
的指向(2)
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() { alert(this.name); }.bind(obj); func();
// 完整版实现
Function.prototype.bind = function() { var self = this, // 保存原函数 context = [].shift.call(arguments), // 需要绑定的 this 上下文 args = [].slice.call(arguments); // 剩余的参数转成数组 return function() { return self.apply(context, [].concat.call(args, [].slice.call(arguments))); // 执行新的函数的时候,会把之前传入的 context 当做函数体内的 this // 并且组合两次分别传入的参数,作为新函数的参数 } }; var obj = { name: 'sven' }; var func = function(a, b, c, d) { alert(this.name); // 输出:sven alert([a, b, c, d]); // 输出: [1, 2, 3, 4] }.bind(obj, 1, 2); func(3, 4);
(3)借用其他对象的方法
// 方法1
var A = function(name) { this.name = name; }; var B = function() { A.apply(this, arguments); }; B.prototype.getName = function() { return this.name; }; var b = new B('sven'); console.log(b.getName()); // 输出:sven
// 方法2:借用 Array.prototype 对象操作 arguments
Array.prototype.slice // 转成真正的数组 Array.prototype.shift // 截去 arguments 列表中的头一个元素 Array.prototype.push // 往 arguments 中添加一个新元素
第3章:闭包和高阶函数
在 JavaScript 版本的设计模式中,许多模式都可以用闭包和高阶函数来实现。
-
闭包
闭包的形成与变量的作用域以及变量的生存周期密切相关。
(1)变量的作用域:
就是指变量的有效范围,最常指函数中声明的变量作用域。
(2)变量的生存周期:
① 全局变量:生存周期是永久的,除非主动销毁这个全局变量。
② 局部变量:当退出函数时,局部变量即失去它们的价值,会随着函数调用的结束而被销毁。
★ 闭包的经典应用:
<div>1</div> <div>2</div> <div>3</div> <div>4</div> <div>5</div> <script> var nodes = document.getElementsByTagName('div'); // 无论点击哪个div,结果都是5 // 因为div节点的onclick事件是被异步触发的, // 当事件被触发时,for循环早已结束,此时变量i的值已经是5 // 所以在div的onclick事件函数中顺着作用域链从内到外查找变量i时,查找到的值总是5 for (var i = 0, len = nodes.length; i < len; i++) { nodes[i].onclick = function() { console.log(i); } } </script>
☞ 解决方法:
for (var i = 0, len = nodes.length; i < len; i++) { (function(i) { nodes[i].onclick = function() { console.log(i); } })(i); }
在闭包的帮助下,把每次循环的
i
值都封闭起来。当在事件函数中顺着作用域链从内到外查找变量i
时,会先找到被封闭在闭包环境中的i
。(3)闭包的更多作用
① 封装变量
/*var mult = function() { var a = 1; for (var i = 0, len = arguments.length; i < len; i++) { a = a * arguments[i]; } return a; };*/ // 改进1: // 对于那些相同的参数来说,每次都进行计算是一种浪费 // 加入缓存机制来提高函数的性能 /*var cache = {}; var mult = function() { var args = Array.prototype.join.call(arguments, ','); if (cache[args]) { return cache[args]; } var a = 1; for (var i = 0, len = arguments.length; i < len; i++) { a = a * arguments[i]; } return cache[args] = a; }*/ // 继续改进2:减少页面中的全局变量 /*var mult = (function() { var cache = {}; return function() { var args = Array.prototype.join.call(arguments, ','); if (cache[args]) { return cache[args]; } var a = 1; for (var i = 0, len = arguments.length; i < len; i++) { a = a * arguments[i]; } return cache[args] = a; } })();*/ // 继续改进4:提炼函数是代码重构中的一种常见技巧 var mult = (function() { var cache = {}; var calculate = function() { var a = 1; for (var i = 0, len = arguments.length; i < len; i++) { a = a * arguments[i]; } return a; }; return function() { var args = Array.prototype.join.call(arguments, ','); if (args in cache) { return cache[args]; } return cache[args] = calculate.apply(null, arguments); } })(); console.log(mult(1, 2, 3, 4));
② 延续局部变量的寿命
// 把 img 变量用闭包封闭起来,解决请求丢失的问题 var report = (function() { var imgs = []; return function(src) { var img = new Image(); imgs.push(img); img.src = src; } })();
(4)闭包和面向对象设计
对象以方法的形式包含了过程,而闭包则是在过程中以环境的形式包含了数据。通常用面向对象能实现的功能,用闭包也能实现。反之亦然。
// 闭包实现 var extent = function() { var value = 0; return { call: function() { value++; console.log(value); } } }; var extent = extent(); extent.call(); // 1 extent.call(); // 2 // 面向对象写法1 var extent = { value: 0, call: function() { this.value++; console.log(this.value); } }; extent.call(); // 1 extent.call(); // 2 // 面向对象写法2 var Extent = function() { this.value = 0; }; Extent.prototype.call = function() { this.value++; console.log(this.value); } var extent = new Extent(); extent.call(); // 1 extent.call(); // 2
(5)用闭包实现命令模式
在面向对象版本的命令模式中,预先植入的命令接收者被当成对象的属性保存起来;而在闭包版本的命令模式中,命令接收者会被封闭在闭包形成的环境中。
(6)闭包与内存管理
-
局部变量本来应该在函数退出的时候被解除引用,但如果局部变量被封闭在闭包形成的环境中,那么这个局部变量就能一直生存下去。从这个意义上,闭包的确会使一些数据无法被及时销毁。
-
使用闭包的一部分原因是我们选择主动把一些变量封闭在闭包中,因为可能在以后还需要使用这些变量,把这些变量方闭包中和放在全局环境中,对内存方面的影响是一致的,这里并不能说是内存泄漏。
-
如果将来要回收这些变量,可以手动把这些变量设为 null。
-
跟闭包和内存泄漏有关系的地方是,使用闭包的同时比较容易形成循环引用,如果闭包的作用域链中保存着一些DOM节点,这时候可能造成内存泄漏。但这本质上并非由闭包造成的。
-
同样,如果要解决循环引用带来的内存泄漏问题,我们只需要把循环引用中的变量设为 null 即可。
-
-
高阶函数
高阶函数是指至少满足下列条件之一的函数:
- 函数可以作为参数被传递;
- 函数可以作为返回值输出。
(1)函数作为参数传递
① 回调函数
回调函数的应用不仅只在异步请求中,当一个函数不适合执行一些请求时,我们也可以把这些请求封装成一个函数,并把它作为参数传递给另外一个函数,“委托”给另外一个函数来执行。
var appendDiv = function(callback) { for (var i = 0; i < 100; i++) { var div = document.createElement('div'); div.innerHTML = 1; document.body.appendChild(div); if (typeof callback === 'function') { callback(div); } } }; appendDiv(function(node) { node.style.display = 'none'; });
② Array.prototype.sort
把用什么规则去排序(可变的)的部分封装在函数参数里,动态传入。
// 从小到大排序 [1, 4, 3].sort(function(a, b) { return a - b; });
(2)函数作为返回值输出
① 判断数据的类型
// 判断数据类型1 var isType = function(type) { return function(obj) { return Object.prototype.toString.call(obj) === '[object ' + type + ']'; } }; var isString = isType('String'); var isArray = isType('Array'); var isNumber = isType('Number'); console.log(isArray([1, 2, 3])); // 判断数据类型2:循环语句,批量注册这些 isType 函数 var Type = {}; for (var i = 0, type; type = ['String', 'Array', 'Number'][i++];) { (function(type) { Type['is' + type] = function(obj) { return Object.prototype.toString.call(obj) === '[object ' + type + ']'; } })(type); } Type.isArray([]); Type.isString('str');
② getSingle
// 既把函数当作参数传递,又让函数执行后返回了另外一个函数 var getSingle = function(fn) { var ret; return function() { return ret || (ret = fn.apply(this, arguments)); }; }; var getScript = getSingle(function() { return document.createElement('script'); }); var script1 = getScript(); var script2 = getScript(); alert(script1 === script2); // true
(3) 高阶函数实现 AOP
AOP(面向切面编程)的主要作用是把一些跟核心业务逻辑模块无关的功能抽离出来,这些跟业务逻辑无关的功能通常包括日志统计、安全控制、异常处理等。
好处:(1)可以保持业务逻辑模块的纯净和高内聚性;(2)可以很方便地服用日志统计等功能模块。
通常在 JavaScript 中实现 AOP,都是指把一个函数“动态织入”到另外一个函数之中。
Function.prototype.before = function(beforefn) { var __self = this; return function() { beforefn.apply(this, arguments); return __self.apply(this, arguments); } }; Function.prototype.after = function(afterfn) { var __self = this; return function() { var ret = __self.apply(this, arguments); afterfn.apply(this, arguments); return ret; } } var func = function() { console.log(2); }; func = func.before(function() { console.log(1); }).after(function() { console.log(3); }); func();
(4) 高阶函数的其他应用
① currying
currying 又称部分求值。一个 currying 的函数首先会接受一些参数,接受了这些参数之后,该函数并不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数被真正需要求值的时候,之前传入的所有参数都会被一次性用于求值。
var currying = function(fn) { var args = []; return function() { if (arguments.length === 0) { return fn.apply(this, args); } else { [].push.apply(args, arguments); return arguments.callee; } } }; var cost = (function() { var money = 0; return function() { for (var i = 0, len = arguments.length; i < len; i++) { money += arguments[i]; } return money; } })(); var cost = currying(cost); // 转化成curring函数 cost(100); cost(200); cost(300); cost(); // 600
② uncurring
在 JavaScript 中,当我们调用对象的某个方法时,其实不用去关心该对象原本是否被设计为拥有这个方法,这是动态类型语言的特点(鸭子类型思想)。
同理,一个对象未必只能使用它自身的方法,可以让它去借用一个原本不属于它的方法。
// 实现1 Function.prototype.uncurrying = function() { var self = this; return function() { var obj = Array.prototype.shift.call(arguments); return self.apply(obj, arguments); }; }; var push = Array.prototype.push.uncurrying(); var obj = { 'length': 1, '0': 1 }; push(obj, 2); console.log(obj); // 实现2 Function.prototype.uncurrying = function() { var self = this; return function() { return Function.prototype.call.apply(arguments); }; };
③ 函数节流
【函数被频繁调用的场景】:
- window.onresize 事件
- mousemove 事件
- 上传进度
【实现代码】:
// 原理:将即将被执行的函数用 setTimeout 延迟一段时间执行。 // 如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求。 var throttle = function(fn, interval) { var __self = fn, // 保存需要被延迟执行的函数引用 timer, // 定时器 firstTime = true; // 是否是第一次调用 return function() { var args = arguments, __me = this; if (firstTime) { // 如果是第一次调用,不需要延迟执行 __self.apply(__me, args); return firstTime = false; } if (timer) { // 如果定时器还在,说明前一次延迟执行还没有完成 return false; } timer = setTimeout(function() { clearTimeout(timer); timer = null; __self.apply(__me, args); }, interval || 500); }; };
④ 分时函数
// 原理:让创建节点的工作分批进行 // 比如把1秒钟创建1000个节点,改为每隔200毫秒创建8个节点 var timeChunk = function(arr, fn, count) { var obj, t; var len = arr.length; var start = function() { for (var i = 0; i < Math.min(count || 1, arr.length); i++) { var obj = arr.shift(); fn(obj); } }; return function() { t = setInterval(function() { if (arr.length === 0) { // 如果全部节点都已经被创建好 return clearInterval(t); } start(); }, 200); // 分批执行的时间间隔,也可以用参数的形式传入 }; };
⑤ 惰性加载函数
// 原理:在第一次进入条件分支之后,在函数内部会重写这个函数 // 重写之后就是我们期望的addEvent函数 // 在下一次进入addEvent函数的时候,addEvent函数里不再存在条件分支语句 var addEvent = function(elem, type, handler) { if (window.addEventListener) { addEvent = function(elem, type, handler) { elem.addEventListener(type, handler, false); } } else if (window.attachEvent) { addEvent = function(elem, type, handler) { elem.attachEvent('on' + type, handler); } } addEvent(elem, type, handler); };
-
小结
在 JavaScript 中,很多设计模式都是通过闭包和高阶函数实现的。相对于模式的实现过程,我们更关注的是模式可以帮助我们完成什么。