【JS手写系列】手写实现函数柯里化、闭包
靡不有初,鲜克有终
不积跬步无以至千里
1、函数柯里化
1.1、什么是柯里化?
在数学和计算机科学中,柯里化是一种将使用多个参数的一个函数转换成一系列使用一个参数的函数的技术。
看个🌰:
- 一个接收3个参数的普通函数,在进行柯里化后, 柯里化版本的函数接收一个参数并返回接收下一个参数的函数, 该函数返回一个接收第三个参数的函数。
- 最后一个函数在接收第三个参数后, 将之前接收到的三个参数应用于原普通函数中,并返回最终结果。
// 一个接收三个参数的普通函数
function sum (a, b, c) {
console.log(a + b + c)
}
// 用于将普通函数转化为柯里化版本的工具函数
function curry (fn) {
// ...内部实现省略,返回一个新函数
}
// 获取第一个柯里化后的函数
let A = curry(sum);
// 返回一个接收第二个参数的函数
let B = A(1);
// 返回一个接收第三个参数的函数
let C = B(2);
// 接收到最后一个参数,将之前所有的参数应用到原函数中,并运行
C(3) // print : 6
而对于Javascript
语言来说,我们通常说的柯里化函数的概念,与数学和计算机科学中的柯里化的概念并不完全一样。
在数学和计算机科学中的柯里化函数,一次只能传递一个参数;
而我们Javascript
实际应用中的柯里化函数,可以传递一个或多个参数。
看个🌰:
//普通函数
function fn (a, b, c, d, e) {
console.log(a, b, c, d, e)
}
//生成的柯里化函数
let _fn = curry(fn);
_fn(1, 2, 3, 4, 5); // print: 1,2,3,4,5
_fn(1)(2)(3, 4, 5); // print: 1,2,3,4,5
_fn(1, 2)(3, 4)(5); // print: 1,2,3,4,5
_fn(1)(2)(3)(4)(5); // print: 1,2,3,4,5
对于已经柯里化后的 _fn
函数来说:
- 当接收的参数数量与原函数的形参数量相同时,执行原函数;
- 当接收的参数数量小于原函数的形参数量时,返回一个函数用于接收剩余的参数,直至接收的参数数量与形参数量一致,最后再执行原函数。
1.2、手写实现
1.2.1、饭前甜点
看具体代码之前,提前理解清楚几个小知识点,再看代码更佳🍺
-
Funcxxx.length
length
是函数对象的一个属性值,指该函数有多少个必须要传入的参数,即形参的个数- 形参的数量不包括剩余参数个数,仅包括第一个具有默认值之前的参数个数
-
function fn1 () { } // 0 function fn2 (name) { } // 1 function fn3 (name, age) { } // 2 function fn4 (name = 'test') { } // 0 function fn5 (name, age = 18) { } // 1 function fn6 (name, age = 18, gender) { } // 1 function fn7 (name = 'test', age, gender) { } // 0
-
Array.prototype.slice.call(arguments)
- 作用:将函数接收的所有参数(类数组)转化为真实数组
-
this、call、apply、bind相关
-
可以看我的这几篇文章
-
1.2.2、具体实现
// 原始函数
function Fn_init (a, b, c) {
console.log('最终的结果:', a * b * c)
}
// 柯里化函数
function curryization (fn, params) {
// 获取函数参数长度
const lth = fn.length
params = params || []
console.log('params', params);
return function (...args) {
// 收集fn函数的参数
// newArgs = params.concat(Array.prototype.slice.call(arguments))
newArgs = params.concat(args)
console.log('newArgs', newArgs);
if (newArgs.length < lth) {
// 继续执行curryization柯里化函数,继续收集参数,this指向window
return curryization.call(this, fn, newArgs)
} else {
// 所有参数收集完毕,整体执行源函数,this指向window
return fn.apply(this, newArgs)
}
}
}
const curryFunc = curryization(Fn_init)
curryFunc(2)(3, 4) // 24
// curryFunc(2, 3)(4) // 24
// curryFunc(2, 3, 4) // 24
// curryFunc(2)(3)(4) // 24
结果:
2、闭包
2.1、简单理解
- 闭包是指有权访问另一个函数作用域中的变量的函数 ——《JavaScript高级程序设计》
- 能够访问其他函数内部变量的函数,被称为 闭包。
简单来说,闭包就是函数内部定义的函数,被返回了出去并在外部调用;
本质就是上级作用域内变量的生命周期,因为被下级作用域内引用,而没有被释放;
我们可以用代码来表述一下:
function foo () {
const a = 2;
function bar () {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 这就形成了一个闭包
我们可以简单剖析一下上面代码的运行流程:
- 编译阶段,变量和函数被声明,作用域即被确定。
- 运行函数
foo()
,此时会创建一个foo
函数的执行上下文,执行上下文内部存储了foo
中声明的所有变量函数信息。 - 函数
foo
运行完毕,将内部函数bar
的引用赋值给外部的变量baz
,此时baz
指针指向的还是bar
,因此哪怕它位于foo
作用域之外,它还是能够获取到foo
的内部变量。 baz
在外部被执行,baz
的内部可执行代码console.log
向作用域请求获取a
变量,本地作用域没有找到,继续请求父级作用域,找到了foo
中的a
变量,返回给console.log
,打印出2
。
闭包的执行看起来像是开发者使用的一个小小的 “作弊手段” ——绕过了作用域的监管机制,从外部也能获取到内部作用域的信息。
闭包的这一特性极大地丰富了开发人员的编码方式,也提供了很多有效的运用场景。
🚀🚀🚀闭包用途:
- 能够访问函数定义时所在的词法作用域(阻止其被回收)
- 私有化变量
- 模拟块级作用域
- 创建模块
2.2、应用场景
闭包的应用,大多数是在需要维护内部变量的场景下。
1、return
返回一个函数
let n = 10
function fn(){
let n =20
return function f() {
n++;
console.log(n)
}
}
const x = fn()
x() // 21
这里的
return f()
就是一个闭包,存在上级作用域的引用。
2、函数作为参数
let param = 'testA'
function funA () {
let param = 'testB'
return function fo () {
console.log(param)
}
}
function funB (fn) {
let param = 'testC'
fn()
}
funB(funA()) // testB
3、IIFE
(立即执行函数)
let param = 'test';
(function fn(){
console.log(param)
})() // test
同样也是产生了闭包
fn()
,存在window
下的引用param
。
4、循环赋值
for (var i = 0; i < 10; i++) {
(function (j) {
setTimeout(function () {
console.log(j)
}, 1000)
})(i)
} // 0 ~ 9
for (var i = 0; i < 10; i++) {
setTimeout(function () {
console.log(i)
}, 1000)
} // 10个10
- 因为存在闭包的原因上面能依次输出0~9,闭包形成了10个互不干扰的私有作用域。
- 将外层的自执行函数去掉后就不存在外部作用域的引用了,输出的结果就是连续的 10。
- 为什么会连续输出10,因为
JS
是单线程的遇到异步的代码不会先执行(会入栈),等到同步的代码执行完i++
到 10时,异步代码才开始执行此时的i=10
输出的都是 10。
5、使用回调函数就是在使用闭包
window.name = 'test'
setTimeout(() => {
console.log(window.name);
}, 1000)
6、节流防抖
// 节流
function throttle (fn, timeout) {
let timer = null
return function (...arg) {
if (timer) return
timer = setTimeout(() => {
fn.apply(this, arg)
timer = null
}, timeout)
}
}
// 防抖
function debounce (fn, timeout) {
let timer = null
return function (...arg) {
clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, arg)
}, timeout)
}
}
7、柯里化
function curry (fn, len = fn.length) {
return _curry(fn, len)
}
function _curry (fn, len, ...arg) {
return function (...params) {
let _arg = [...arg, ...params]
if (_arg.length >= len) {
return fn.apply(this, _arg)
} else {
return _curry.call(this, fn, len, ..._arg)
}
}
}
let fn = curry(function (a, b, c, d, e) {
console.log(a + b + c + d + e)
})
fn(1, 2, 3, 4, 5) // 15
fn(1, 2)(3, 4, 5)
fn(1, 2)(3)(4)(5)
fn(1)(2)(3)(4)(5)
2.3、问题
从上面的介绍中我们可以得知,闭包的使用场景非常广泛,那我们是不是可以大量使用闭包呢?不可以,因为闭包过度使用会导致性能问题,还是看之前演示的一段代码:
function foo () {
const a = 2;
function bar () {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 这就形成了一个闭包
乍一看,好像没什么问题,然而,它却有可能导致 内存泄露。
我们知道,javascript
内部的垃圾回收机制用的是引用计数收集:即当内存中的一个变量被引用一次,计数就加一。垃圾回收机制会以固定的时间轮询这些变量,将计数为 0
的变量标记为失效变量并将之清除从而释放内存。
上述代码中,理论上来说, foo
函数作用域隔绝了外部环境,所有变量引用都在函数内部完成,foo
运行完成以后,内部的变量就应该被销毁,内存被回收。然而闭包导致了全局作用域始终存在一个 baz
的变量在引用着 foo
内部的 bar
函数,这就意味着 foo
内部定义的 bar
函数引用数始终为 1
,垃圾运行机制就无法把它销毁。更糟糕的是,bar
有可能还要使用到父作用域 foo
中的变量信息,那它们自然也不能被销毁... JS 引擎无法判断你什么时候还会调用闭包函数,只能一直让这些数据占用着内存。
这种由于闭包使用过度而导致的内存占用无法释放的情况,我们称之为:内存泄露。
2.3.1、内存泄露
内存泄露 是指当一块内存不再被应用程序使用的时候,由于某种原因,这块内存没有返还给操作系统或者内存池的现象。内存泄漏可能会导致应用程序卡顿或者崩溃。
造成内存泄露的原因有很多,除了闭包以外,还有 全局变量的无意创建。开发者的本意是想将变量作为局部变量使用,然而忘记写 var
导致变量被泄露到全局中:
function foo() {
b = 2;
console.log(b);
}
foo(); // 2
console.log(b); // 2
还有 DOM
的事件绑定,移除 DOM
元素前如果忘记了注销掉其中绑定的事件方法,也会造成内存泄露:
const wrapDOM = document.getElementById('wrap');
wrapDOM.onclick = function (e) {console.log(e);};
// some codes ...
// remove wrapDOM
wrapDOM.parentNode.removeChild(wrapDOM);
2.3.2、内存泄露的排查手段
- performance
- memory
2.3.3、内存泄露的解决方案
-
使用严格模式,避免不经意间的全局变量泄露:
"use strict"; function foo () { b = 2; } foo(); // ReferenceError: b is not defined
-
关注
DOM
生命周期,在销毁阶段记得解绑相关事件:const wrapDOM = document.getElementById('wrap'); wrapDOM.onclick = function (e) {console.log(e);}; // some codes ... // remove wrapDOM wrapDOM.onclick = null; wrapDOM.parentNode.removeChild(wrapDOM);
或者可以使用事件委托的手段统一处理事件,减少由于事件绑定带来的额外内存开销:
document.body.onclick = function (e) { if (isWrapDOM) { // ... } else { // ... } }
-
避免过度使用闭包。
大部分的内存泄漏还是由于代码不规范导致的。
代码千万条,规范第一条,代码不规范,开发两行泪。
2.4、经典面试题
🚀🚀🚀来看一个经典面试题
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i)
})
}
// 得到:5 5 5 5 5
解决方式:
👇
第一种 闭包
for (var i = 0; i < 5; i++) {
(() => {
var privateI = i;
setTimeout(() => {
console.log(privateI);
}, 0);
})()
}
for (var i = 0; i < 5; i++) {
(function () {
var privateI = i;
setTimeout(() => {
console.log(privateI);
}, 0);
})()
}
// 或者👇
// bind本身返回一个函数(未执行),其实也是利用了闭包
for (var i = 1; i <= 5; i++) {
// 缓存参数
setTimeout(function (i) {
console.log('bind', i) // 依次输出:1 2 3 4 5
}.bind(null, i), i * 1000);
}
第二种 使用let
for(let i=0;i<5;i++){
setTimeout(()=>{
console.log(i);
}, 0);
}
第三种 使用setTimeout
的第三个参数
for(var i=0;i<5;i++){
setTimeout((i)=>{ // 注意:这个地方一定要传递这个i
console.log(i);
}, 0, i);
}
MDN
官方文档:
developer.mozilla.org/zh-CN/docs/…
第四种 使用Promise
for(var i=0;i<5;i++){
Promise.resolve(i).then(i=>{
setTimeout(()=>{
console.log(i);
}, 0);
})
}
第五种 try catch
for (var i = 0; i < 5; i++) {
try {
throw i
} catch (i) {
setTimeout(() => {
console.log(i);
}, 0);
}
}
2.5、总结
- 有权访问另一个函数内部变量的函数,我们称为 闭包。闭包的本质是利用了作用域的机制,来达到外部作用域访问内部作用域的目的。
- 闭包的使用场景非常广泛,然而过度使用会导致闭包内的变量所占用的内存空间无法释放,带来 内存泄露 的问题。
- 我们可以借助于
chrome
开发者工具查找代码中导致了内存泄露的代码。- 避免内存泄露的几种方法:避免使用全局变量、谨慎地为
DOM
绑定事件、避免过度使用闭包。最重要的,还是代码规范。
🚀🚀🚀
都看到这儿了,可以点个赞再🏃♂️
优质参考链接👇:
转载自:https://juejin.cn/post/7189179406647525431