easycodesniper

blog by chen qiyi

设计模式浅谈

多态

多态的含义是:同一操作作用于不同对象上,可以产生不同的解释和不同的执行结果。也就是说,给不同的对象发送同一个消息,对象会做出不同的反馈

下面的代码就体现着多态性,当我们分别向程序员和老师发出工作的消息时,他们根据此作出了不同的反应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let work = function (person) {
if(person instanceof Coder) {
console.log('coding')
} else if (person instanceof Teacher) {
console.log('teaching')
}
}

const Coder = function (){}
const Teacher = function (){}

work(new Coder()) // coding
work(new Teacher()) // teaching

但这样的多态性无法令人满意,如果现在要新增一个司机类型,那我们就需要修改work函数的代码。修改的代码越多,就存在越多的危险,并且work函数也会随着类型的变多成为一个巨大的函数

多态背后的思想是:将做什么谁去做怎么做分离开来。
在上面的例子中,人都会工作,但是不同的人怎么工作是不同的。把不变的部分隔离出来,把可变的部分封装起来,这就给予了我们扩展程序的能力。

通过下面的改动,将不变的部分隔离出来,那就是所有人都会工作。然后把可变的部分各自封装起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let work = function (person) {
if(person.work instanceof Function) {
person.work()
}
}

const Coder = function (){}
Coder.prototype.work = () => {
console.log('coding')
}
const Teacher = function (){}
Teacher.prototype.work = () => {
console.log('teaching')
}

work(new Coder())
work(new Teacher())

与静态语言类型不同的是,JavaScript的变量类型在运行期是可变的。一个JavaScript对象,既可以是Coder类型,也可以是Teacher类型。这就意味着JavaScript对象的多态性是与生俱来的

封装

封装的目的是将信息隐藏,封装可以是对任何形式内容的封装。也就是说,封装不仅仅是隐藏数据,还包括隐藏实现细节、设计细节以及隐藏对象的类型等

封装使得对象内部的变化对其他对象而言是透明的,其他对象也不关心它的内部实现。

封装变化

通过封装变化的方式,把系统中稳定不变的部分和易于变化的部分隔离开来,我们只需要替换易于变化的部分。这可以很大程度上保证程序的稳定性和可扩展性

原型模式

在以类为中心的面向对象编程语言中,对象总是从类中创建出来的。而在原型编程的思想中,类不是必须的,对象也未必需要从类中创建出来。一个对象是通过克隆另一个对象所得到的。

原型模式的关键在于通过克隆来创建对象,即语言本身是否提供了clone方法。在ES5中提供了Object.create方法来克隆对象

1
2
3
4
5
6
7
8
9
let Person = function() {
this.name = 'easy code sniper'
this.age = 22
}

let person = new Person()

let clonePerson = Object.create( person )
console.log(clonePerson) // { name: 'easy code sniper', age: 22 }

JavaScript中的原型继承

JavaScript遵循原型编程的基本规则:

  • 所有的数据都是对象
  • 要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它
  • 对象会记住它的原型
  • 如果对象无法响应某个请求,它会把这个请求委托给自己的原型

1.所有的数据都是对象

JavaScript有两种类型机制:基本类型 和 对象类型

基于一切都应该是对象的本意(除了undefined之外),基本类型数据可以通过包装类的方式变成对象类型数据

JavaScript中的根对象是Object.prototype对象,Object.prototype对象是一个空的对象。在JS中遇到的每个对象,实际都是从Object.prototype对象克隆而来的

2.要得到一个对象,不是通过实例化类,而是找到一个对象作为原型并克隆它

在JavaScript中我们不需要关心克隆的细节,只是显式地调用let obj = new Object()let obj = {},引擎内部会从Object.prototype上面克隆一个对象出来

JavaScript的函数集可以作为普通函数被调用,也可以作为构造器被调用。当使用new运算符来调用函数时,此时的函数就是一个构造器。用new运算符来创建对象的过程,实际上也只是先克隆Object.prototype对象,再进行一些其他额外操作的过程

模拟new创建对象:

1
2
3
4
5
6
7
8
let objectFactory = function() {
let obj = new Object() // 先克隆一个空对象
Constructor = [].shift.call( arguments ) // 取出参数中的第一项,即外部传入的构造器
obj.__proto__ = Constructor.prototype // 指向正常的原型
let ret = Constructor.apply(obj, arguments) // 基于剩余的arguments给obj设置属性

return typeof ret === 'object' ? ret : obj // 确保构造器总是返回一个对象
}

使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
function Person(name) {
this.name = name
}

Person.prototype.getName = function() {
return this.name
}

let a = objectFactory(Person, 'easy code sniper')

console.log( a.name ); // easy code sniper

console.log( a.getName() ); // easy code sniper

3.对象会记住它的原型

JavaScript给对象提供一个__proto__属性,某个对象的__proto__属性默认会指向它的构造器的原型对象,即Constructor.prototype

__proto__就是 对象 和 对象构造器的原型 联系起来的纽带。这就是我们在objectFactory函数中需要手动给obj对象设置正确的__proto__指向

this 的指向

this 的指向大致可以分为 4 种情况:

  • 作为对象的方法调用
  • 作为普通函数调用
  • 构造器调用
  • callapply 调用

作为对象的方法调用

当函数作为对象的方法被调用时,this 指向该对象

1
2
3
4
5
6
7
8
9
let obj = {
name: "easy code sniper",
getName: function () {
console.log(this === obj); // true
console.log(this.name); // easy code sniper
},
};

obj.getName();

作为普通函数调用

当函数作为普通函数被调用时,this 指向全局对象。在浏览器中,全局对象就是 window;在 Node 环境中,全局对象就是 globalThis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
globalThis.name = "easy code sniper";

function func() {
console.log(this.name);
}

func(); // easy code sniper

// 或者

globalThis.name = "cqy";

let obj = {
name: "easy code sniper",
getName: function () {
console.log(this === obj); // false
console.log(this.name); // cqy
},
};

let func = obj.getName;

func();

构造器调用

除了一些内置函数,大部分 JS 函数都可以当作构造器使用。当使用 new 运算符调用函数时,该函数总是返回一个对象,通常情况下,构造器里的 this 就指向返回的对象

1
2
3
4
5
6
7
let Func = function() {
this.name = 'easy code sniper'
}

let obj = new Func()

console.log(obj.name); // easy code sniper

如果构造器显式的返回一个对象,那么返回的将会是这个对象,this也会指向这个对象

1
2
3
4
5
6
7
8
9
10
let Func = function() {
this.name = 'easy code sniper'
return {
name: 'cqy'
}
}

let obj = new Func()

console.log(obj.name); // cqy

call 和 apply

Function.prototype.callFunction.prototype.apply是定义在Function的原型上的两个方法,用于动态的改变this指向

call和apply的作用一模一样,区别仅在于传入参数的形式不同:

  • apply接受两个参数,第一个参数指定函数体内this的指向,第二个参数会作为参数传递给被调用的函数,类型为数组或类数组
  • call传入参数数量不固定,第一个参数指定函数体内this的指向,从第二个参数开始往后,每个参数被依次传入函数

当使用call或者apply的时候,如果传入的第一个参数为null,函数体内的this会指向默认的宿主对象,在浏览器中则是window

用途

1.改变this指向

call和apply最常见的用途是改变函数内部的this指向

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let obj1 = {
name: 'easy code sniper'
}

let obj2 = {
name: 'cqy'
}

window.name = 'window'

let getName = function() {
console.log(this.name)
}

getName() // window
getName.call(obj1) // easy code sniper
getName.call(obj2) // cqy

2.Function.prototype.bind

大部分高级浏览器都实现了内置的Function.prototype.bind,用来指定函数内部的this指向,即使没有原生的Function.prototype.bind实现,也可以使用call或者apply模拟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Function.prototype.bind = function() {
let self = this // 保存调用bind的原函数
context = [].shift.call(arguments) // 借用数组的shift方法拿到arguments的第一项,即需要指向的this
args = [].slice.call(arguments) // 将剩余参数转化为数组

return function() {
// 指定this为之前传入的context
// 合并两次传入的参数,作为新函数的参数
return self.apply(context, [].concat.call(args, [].slice.call(arguments)))
}
}


let obj = {
name: 'cqy'
}

let func = function(a,b,c,d) {
console.log(this.name)
console.log([a,b,c,d])
}.bind(obj, 1, 2)

func(3, 4) // 输出:cqy [1, 2, 3, 4]

3.借用其他对象的方法

借用方法较为常见的就是:
函数的参数列表arguments是一个类数组对象,它并非真正的数组,所以不能像数组一样,进行排序操作或者往集合里添加一个新的元素。我们常常会借用Array.prototype对象上的方法

比如想往arguments中添加一个新的元素,通常借用Array.prototype.push

1
2
3
4
5
6
let func = function() {
Array.prototype.push.call(arguments, 3)
console.log(arguments);
}

func(1,2) // [1,2,3]

在操作arguments的时候,我们经常非常频繁地找Array.prototype对象借用方法。
想把arguments转成真正的数组的时候,可以借用Array.prototype.slice方法;想截去arguments列表中的头一个元素时,又可以借用Array.prototype.shift方法。

闭包

变量的作用域

在JS中,函数可以用来创造函数作用域。函数就像一层半透明的玻璃,在函数里面可以看到外面的便利,而在函数外面则无法看到函数里面的变量。

这是因为当在函数中搜索一个变量的时候,如果该函数内并没有声明这个变量,那么此次搜索的过程会随着代码执行环境创建的作用域链往外层逐层搜索,一直搜索到全局对象为止。变量的搜索是从内到外的。

变量的生存周期

对于全局变量来说,生存周期是永久的,除非主动销毁这个全局变量

对于函数内的局部变量来说,当退出函数时,这些局部变量就失去了他们价值,随着函数调用的结束而销毁

退出函数后局部变量a将被销毁

1
2
3
4
5
6
7
let func = function() {
let a = 1
console.log(a) // 1
}

func()
console.log(a) // ReferenceError: a is not defined

再看看下面这段代码

1
2
3
4
5
6
7
8
9
10
11
12
let func = function() {
let a = 1
return function() {
a++
console.log(a)
}
}

let f = func()

f() // 2
f() // 3

在这段代码中,退出函数之后,局部变量a并没有消失。当执行let f = func()时,f拿到一个匿名函数的引用,它可以访问到func被调用时产生的环境,而局部变量a一直处在这个环境里。

既然局部变量所在的环境还能被外界访问,这个局部变量就有了不被销毁的理由。在这里产生了一个闭包结构,局部变量的生命看起来被延续了。

闭包的应用

封装变量

闭包可以帮助把一些不需要暴露在全局的变量封装成‘私有变量’,例如下面有一个计算参数乘积的简单函数,并使用全局的cache变量缓存结果来提高性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
let cache = {} // 缓存结果

let mult = function() {
let args = Array.prototype.join.call(arguments, ',') // 将参数拼接成字符串,作为cache中的属性名
if( cache[args] ) { // 如果命中缓存就返回结果
return cache[args]
}
let a = 1
for( let i = 0; i < arguments.length; i++ ) {
a *= arguments[i]
}
return cache[args] = a
}

cache变量仅在mult函数中被使用,与其让他暴露在全局作用域下,不如将它封闭在mult函数内部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let mult = (function() {
let cache = {} // 将cache封装在mult里面
return function() {
let args = Array.prototype.join.call(arguments, ',') // 将参数拼接成字符串,作为cache中的属性名
if( cache[args] ) { // 如果命中缓存就返回结果
return cache[args]
}
let a = 1
for( let i = 0; i < arguments.length; i++ ) {
a *= arguments[i]
}
return cache[args] = a
}
})()

高阶函数

高阶函数是指至少满足下列条件之一的函数:

  • 函数可以作为参数被传递
  • 函数可以作为返回值输出

函数作为参数传递

将函数作为参数进行传递,一个很重要的应用场景就是回调函数。这代表我们将容易变化的业务逻辑抽离出来,把这部分业务逻辑放在函数参数中,这样一来可以分离业务代码中变化与不变的部分

比如我们想在页面中创建一个div节点,然后给这个节点设置一些样式,下面是一种编写代码的方式:

1
2
3
4
5
6
let appendDiv = function() {
let div = document.createElement('div')
div.innerHTML = '我是一个div'
document.body.appendChild(div)
div.style.display = 'none' // 设置样式
}

div.style.display = 'none'的逻辑硬编码在appendDiv里显然不太合理,这使得appendDiv有点太个性化里,成为了一个难以敷用的函数。

如果把div.style.display = 'none'的逻辑抽离出来,用回调函数的形式传入appendDiv,这样appendDiv只要专注于创建节点就行了

1
2
3
4
5
6
7
8
9
10
11
12
let appendDiv = function(callback) {
let div = document.createElement('div')
div.innerHTML = '我是一个div'
document.body.appendChild(div)
if(typeof callback === 'function') {
callback(div)
}
}

appendDiv(function(node) {
node.style.display = 'none'
})

除此之外,Array.prototype.sort方法接受一个函数作为参数,这个函数里面封装了数组元素的排序规则。排序规则是可变的,把可变的封装在函数参数里。

1
2
3
4
5
[1, 5, 3].sort(function(a, b) {
return a - b
})

// 1, 3, 5

函数作为返回值输出

让函数返回一个可执行的函数,意味着运算过程是可延续的,更能体现函数式编程的巧妙

1.判断数据的类型

要判断数据类型,更好的方法是用Object.prototype.toString来计算,Object.prototype.toString.call(obj)返回一个字符串

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let isString = function(obj) {
return Object.prototype.toString.call(obj) === '[object String]'
}

let isArray = function(obj) {
return Object.prototype.toString.call(obj) === '[object Array]'
}

let isNumber = function(obj) {
return Object.prototype.toString.call(obj) === '[object Number]'
}

let isObject = function(obj) {
return Object.prototype.toString.call(obj) === '[object Object]'
}

这些函数的发部分实现都是相同的,不同的是判断部分。为了避免多余的代码,我们可以把这些代表数据类型的字符串作为参数提前传入isType函数

1
2
3
4
5
6
7
8
9
10
let isType = function(type) {
return function(obj) {
return Object.prototype.toString.call(obj) === `[object ${type}]`
}
}

let isString = isType('String')
let isArray = isType('Array')
let isNumber = isType('Number')
let isObject = isType('Object')

高阶函数的其他应用

1.函数柯里化(currying)

一个currying的函数首先会接受一些参数,接受了这些参数后,该函数不会立即求值,而是继续返回另外一个函数,刚才传入的参数在函数形成的闭包中被保存起来。待到函数真正需要求值的时候,之前传入的所有参数都会被一次性用于求值

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
/**
* 用于将函数进行柯里化
* 接受一个参数:即将要被currying的函数
*/
let currying = function(fn) {
let args = []

return function() {
if(arguments.length === 0) {
return fn.apply(this, args)
} else {
[].push.apply(args, arguments)
// arguments.callee 是一个在函数内部可用的属性,它指向当前正在执行的函数。这在匿名函数中特别有用,因为它允许你引用函数本身,而不需要知道函数的名字。
return arguments.callee
}
}
}

let sum = function() {
let res = 0
for(let i = 0; i < arguments.length; i++) {
res += arguments[i]
}
return res
}

// 将sum函数进行柯里化
let curryingSum = currying(sum)

curryingSum(1)

curryingSum(2)

curryingSum(3)

console.log(curryingSum()); // 6

2.函数节流

函数有可能被非常频繁地调用,而造成大的性能问题。函数节流的原理:将即将要被执行的函数用setTimeout延迟一段时间执行。如果该次延迟执行还没有完成,则忽略接下来调用该函数的请求

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
/**
* 节流函数
* fn:需要被延迟执行的函数
* interval:延迟执行的时间
*/
let throttle = function(fn, interval) {
let _self = fn // 保存被延迟执行的函数引用
let timer = null // 定时器
let firstTime = true // 是否是第一次调用

return function() {
let args = arguments
let _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)
}
}

// 使用示例:监听浏览器尺寸变化
window.onresize = throttle(function() {
console.log(1)
}, 500)

3.分时函数

某些函数是用户主动调用的,但因为一些客观原因,这些函数会严重影响页面性能

例如我们要在页面上渲染1000个div节点,在短时间内往页面中大量添加DOM节点会导致浏览器卡顿甚至假死

解决方案之一就是将创建节点的工作分批进行。比如把1秒创建1000个节点,改为每隔200毫秒创建8个节点

设计一个timeChunk函数来分批创建节点,函数接受3个参数:

  • ary:创建节点时需要用到的原始数据
  • fn:封装了创建节点逻辑的函数
  • count:每一批创建的节点数量
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
let timeChunk = function(ary, fn, count) {
let obj, timer
let len = ary.length

let start = function() {
for(let i = 0; i < Math.min(count || 1, len); i++) {
let item = ary.shift()
fn(item)
}
}

return function() {
timer = setInterval(function() {
if(len === 0) { // 如果全部节点都创建好了,取消定时器
return clearInterval(timer)
}
start()
}, 200)
}
}

// 使用示例

let ary = []

for(let i = 0; i <= 1000; i++) {
ary.push(i)
}

let renderList = timeChunk(ary, function(n) {
let div = document.createElement('div')
div.innerHTML = n
document.body.appendChild(div)
}, 8)

renderList()