这一篇笔记是为正式开始学习 javadcript 设计模式做铺垫,由于许多设计模式都用到了闭包和高阶函数来实现, 所以了解这两个知识点也是必要的.

闭包

闭包简单的来说是函数与函数之间的桥梁, 让一个函数有权访问另一个函数作用域里的变量. 定义说起来还是很生涩的, 要理解闭包我们需要先知道变量的作用域以及变量的生命周期才行.

  • 变量的作用域
    变量的作用域指的是变量的有效范围. 在函数中生命变量时, 如果没有关键字 var、let及const那么这个变量就会变成全局变量; 如果有这个时候的变量就是局部变量, 只有在函数内部才能够访问到.
    1
    2
    3
    4
    5
    6
    let fun = function () {
    let a = 1;
    alert(a)
    }
    fun()
    alert(a) // a is not defined

函数这个时候就像一块单面镜, 里面可以看见外面, 外面不能看到里面. 因为函数在搜索一个变量的时候, 如果函数内没有这个变量, 那么这个搜索过程会随着代码的执行环境创建的作用域链向外搜索直至全局对象.

  • 变量生命周期
    对于全局变量来说, 它的生命周期自然是永久的除非我们手动销毁这个变量; 对于局部变量, 一般来说当函数执行完毕之后该变量就会跟着被销毁. 而闭包则可以延续局部变量的生命周期, 举个栗子:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    let func = function () {
    let a = 0;
    return function () {
    a++;
    alert(a);
    }
    }
    let fn = func();
    fn(); // 1
    fn(); // 2
    fn(); // 3

这里我们可以看到 a 变量不仅在外部可以被访问到而且还一直在递增, 这是因为 fn = func()执行时, fn 保存了 func 返回的匿名函数, 而这个匿名函数是有权 func() 被调用时产生的环境. a 变量就在这个环境中, 所以既然局部变量所在换的环境没有被销毁那么 a 变量也不会被销毁掉, 这个例子就产生了一个闭包结构.

闭包的作用

  • 封装变量
    众所周知全局变量是非常容易引起命名冲突的, 所以我们可以利用闭包把一些不需要暴露在全局的变量封装成私有变量.

假设有一个计算乘积的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const mult = (function (){
// 缓存(封装起来)
let cache = {}
return function () {
let arg = [].join.call(arguments, ',')
// 封装计算方法
let calulate = function () {
let a = 1;
for (let i = 0, len = arguments.length; i < len; i++) {
a = a * arguments[i]
}
}
// 存在缓存则不计算
if (arg in cache) return cache[arg]
return cache[arg] = calulate.apply(null, arguments)
}
})()

console.log(mult(2,2,1)); // 4

将变量 cache 封装在 mult 函数中来缓存已经计算过的参数, 以减少重复计算. 这个缓存机制本来是可以扔在全局环境中的.

  • 延续局部变量生命周期
    平常做埋点会用到的 image 对象来上报数据, 如下:
1
2
3
4
5
const report = function (src) {
let img = new Image()
img.src = src
}
report('http://xxxxx.com/xxx')

这种写法会造成数据丢失, 因为在 HTTP 还未结束时, report 函数已经执行完毕而 img 为 report 函数中的局部变量, 在函数执行完毕后就会随之销毁造成数据丢失的情况. 下面用闭包把变量封装起来, 来解决这一问题:

1
2
3
4
5
6
7
8
9
const report = (function () {
let imgs = []
return function (src) {
let img = new Image();
imgs.push(img);
img.src = src
}
})()
report('http://xxxxx.com/xxx')

闭包实现命令模式

说了这么多就来看看关于闭包的运用吧, 这里用闭包简单实现下命令模式, 顺便预先了解一下命令模式.

1
2
<button id='open' onclick='execute'>执行命令</button>
<button id='close' onclick='undo'>撤销命令</button>
  • 面向对象版
    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
    const TV = {
    open () {
    console.log('TV is open!')
    },
    close () {
    console.log('TV is close!')
    }
    }
    // 接收命令
    const ReceiveCommand = receiver => {
    this.receiver = receiver
    }
    // 打开电视
    ReceiveCommand.prototype.execute = function () {
    this.receiver.open()
    }
    // 关电视
    ReceiveCommand.prototype.undo = functon () {
    this.receiver.close()
    }

    // 设置命令
    const setCommand = function (command) {
    document.getElementById('open').onclick = function () {
    command.execute()
    }
    document.getElementById('close').onclick = function () {
    command.undo()
    }
    }
    // 将命令告诉执行者, 设置命令传入执行者
    setCommand(new ReceiveCommand(TV))

命令模式是把请求发起者和执行命令(执行者)分离开来, 以达到解耦的目的. 在执行命令之前, 把命令放入执行者来告诉执行者要执行什么样的命令, 命令模式大概就是这样啦.

  • 闭包版
    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
    const TV = {
    open () {
    console.log('TV is open!')
    },
    close () {
    console.log('TV is close!')
    }
    }
    // 执行者
    const receiveCommand = receiver => {
    let execute = _ => {
    return receiver.open()
    }
    let undo = _ => {
    return receiver.close()
    }
    return {
    execute: execute,
    undo: undo
    }
    }
    // 发起者
    const setCommand = command => {
    document.getElementById('open').onclick = _ => {
    execute.open()
    }
    document.getElementById('close').onclick = _ => {
    undo.close()
    }
    }
    setCommand(receiveCommand(TV))

闭包明显清晰更多, 也更简洁.

高阶函数

将函数作为参数传递或让函数执行结果返回另一个函数, 满足这两个条件之一的函数都可以称作为高阶函数.

将函数作为参数传递

将函数作为参数传递, 就意味着我们可以将与业务相关的逻辑代码分离出来, 解除业务代码和不变逻辑的耦合, 最常见的就是我们的回调函数. 实际运用中 ajax 异步请求, 当我们不知道返回值在什么时候返回值又需要在返回后做一些处理, 就要用到回调函数了.

1
2
3
4
5
6
7
8
9
let getPersonInfo = function (userId, callback) {
$.ajax(`http://xxx/getPersonInfo?${userId}`, function (data) {
callback && callback(data)
})
}
// 这里的匿名函数就是上面的 callback
getPersonInfo("54332123", function (data) {
console.log(data)
})

这种是我们比较常见的回调函数, 在其他的方面, 也可以将一些请求委托给另一个函数代为执行, 如:
假如我们需要先创建100的 div 节点, 然后把它们隐藏掉

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// count 默认值为100
const appendDiv = count = 100, callback => {
if (count && typeof count === 'number') {
for(let i = 0; i < count; i++){
let div = document.createElement('div')
div.innerHTML = i
document.body.appendChild(div)
// 将业务代码委托执行
callback && callback(div)
}
}
}
// 把改变样式的业务逻辑分离出来
appendDiv(function (el) {
el.style.cssText = `display: none`
})

这样我们就将业务逻辑分离出来了, 以后可能我们想改颜色啦或者位置什么的也方便维护.

函数作为返回值

相比把函数作为参数传递, 可能函数当做返回值返回运用场景更多一些, 让函数返回另一个可执行函数就意味着运算过程可延续.直接来看一些例子吧.

  • 检查类型
1
2
3
4
5
6
7
8
9
10
const Type = {}
for (let i = 0, type; type = ['Array', 'String', 'Number'][i++];) {
Type['is' + type] = function (obj) {
return Object.prototype.toString.call(obj) === '[object '+ type +']'
}
}

console.log(Type.isArray([])) // true
console.log(Type.isString('123')) // true
console.log(Type.isNumber(123)) // true
  • 单例模式
    这里先不讨论单例模式, 只看代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// fn 函数作为参数传递
const getSingle = function (fn) {
let ret;
return function () {
// 如果 ret 不存在, ret 等于 fn 函数
return ret || (ret = fn.apply(this, arguments))
}
}
// 调用这个高阶函数
let getDiv = getSingle(function () {
// 参数也会一起传过来, 这里返回['123']
console.log(arguments);
return document.createElement('div');

})
getDiv('123')

getSingle 这个高阶函数既将函数作为参数传递, 又让函数执行后返回另一个函数.

JavaScript 设计模式与开发实践还有个例子也很有意思
叫做 AOP(面向切面编程), 作用是把与业务逻辑不相关的部分抽离出来再通过动态植入的方式加入业务逻辑的模块中.

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
// 在原函数之前
Function.prototype.before = function (beforeFn) {
let that = this; // 保存原函数的引用
return function () { // A
// 执行新函数并修正 this
beforeFn.apply(this, arguments)
// 执行原函数
return that.apply(this, arguments)
}
}
// 在原函数之后
Function.prototype.after = function (afterFn) {
let that = this
return function () { // B
// 先执行 before 函数
that.apply(this, arguments)
// 再执行新函数
return afterFn.apply(this, arguments)
}
}

let fn = function () {
console.log(2)
}
fn = fn.before(function() {
console.log(1)
}).after(function() {
console.log(3)
})

fn() // 按顺序输出 1 2 3

上面的执行顺序是这样的:

  1. After中的回调函数 B
    因为在最后执行 fn 函数时, fn 已经是 after 函数了, 因此在 fn() 执行时调用的是 after 中返回的匿名函数B.
  2. B 中的 that.apply(this, arguments)
    此时的 that 指向 before 函数
  3. 执行Before中的回调函数 A
  4. A 中的 beforeFn.apply(this, arguments)
    执行 console.log(1)
  5. return that.apply(this, arguments)
    执行 console.log(2)
  6. 回到 after 函数中执行return afterFn.apply(this, arguments)
    执行 console.log(3)

函数柯里化

函数柯里化可以说是高阶函数经典中的经典了, 说到高阶函数应该很快就会想到他了. 我理解的函数柯里化是: 先将参数依次缓存, 在真正需要计算结果时, 才进行计算这样可以减少运算次数.

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
// 通用 curring
const curring = fn => {
let argsArr = []
return (...args) => {
return [...args].length === 0
? fn.apply(null, argsArr)
: [].push.apply(argsArr, [...args])
}
}

// 需要计算的函数
let calculate = (() => {
let money = 0
return (...argsArr) => {
argsArr.forEach(item => {
money += item
})
return money
}
})()

// 柯里化
calculate = curring(calculate)

// 缓存参数, 其实并没有计算
calculate(20)
calculate(40)
calculate(20)

// 真正计算的时候
calculate()

函数节流

在某些场景下函数有可能会被非常频繁地调用, 从而造成很大的性能问题. 例如: window.resize事件、mousemove 事件, 它们触发的频率太高了, 而用户并不需要如此高频率的使用. 因此函数节流就是为了限制函数出发的频率.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 节流
const throttle = (fn, interval = 800) => {
let isFirst = true, timer
// ...args => event
return (...args) => {
let that = this
if (isFirst) {
fn.apply(that, [...args])
return isFirst = false
}
if (timer) return false
timer = setTimeout(() => {
clearTimeout(timer)
timer = null
fn.apply(that, [...args])
}, interval)
}
}

// 传入需要节流的函数和间隔时间
window.onresize = throttle(function () {
console.log(1)
}, 800)

分时函数

上面我们提供了一种限制函数调用频率的解决方案, 现在是另一个问题: 某些函数是由用户主动唤起的, 但是因为一些原因导致函数严重影响页面的性能. 分时函数可以为我们解决这个问题, 我理解的分时函数: 将请求分批处理, 在一定的时间内执行一部分请求, 直到请求全部完成.

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
// 分时函数
const timeChunk = (data, fn, count = 1) => {
let timer, obj
const start = () => {
for (let i = 0; i < Math.min(count, data.length); i++) {
obj = data.shift()
fn.apply(null, obj)
}
}
return () => {
if (data.length === 0) {
clearTimeout(timer)
return
}
timer = setInterval(() => {
start()
}, 200)
}
}

// 模拟数据
let ary = []
for (let i = 0; i < 1000; i++) {
ary.push(i)
}

// 分时化
let renderfriendList = timeChunk(ary, function (n) {
let div = document.createElement('div')
div.innerHTML = n
document.body.appendChild(div)
}, 10)

// 渲染
renderfriendList()

惰性加载

举个例子, 在 web 中为了满足各浏览器之间的差异, 我们会做一些嗅探工作. 异于常规方案, 惰性加载在真正需要时才使用且进入条件分支后在内部重写该函数, 这样重写后的函数就是我们需要的函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
let addEvent = (el, type, handler) => {
if (window.addEventListener) {
// 重写函数
addEvent = (el, type, handler) => {
el.addEventListener(type, handler, false)
}
} else if (window.attachEvent) {
addEvent = (el, type, handler) => {
el.attachEvent('on' + type, handler)
}
}
addEvent(el, type, handler)
}

// 使用
addEvent(btn, 'click', function () {
console.log('click!')
})

Created on 2017-10-26 by Cara