Javascript难点知识运用递归闭包柯里化等

29次阅读

共计 10908 个字符,预计需要花费 28 分钟才能阅读完成。

前言

ps: 2018/05/13 经指正之后发现惰性加载函数细节有问题, 已改正
在这里也补充一下, 这些都是根据自己理解写的例子, 不一定说的都对, 有些只能查看不能运行的要谨慎, 因为我可能只是将方法思路写出来, 没有实际跑过的.

面向对象编程 && 面向过程编程

面向对象编程(Object Oriented Programming,OOP)

是一种以事物为中心的编程思想, 把构成问题事务分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个事物在整个解决问题的步骤中的行为, 三大特点缺一不可。

特点 作用
封装 将其说明(用户可见的外部接口)与实现(用户不可见的内部实现)显式地分开,其内部实现按其具体定义的作用域提供保护
继承 子类自动共享父类数据结构和方法的机制
多态 相同的操作或函数、过程可作用于多种类型的对象上并获得不同的结果。不同的对象,收到同一消息可以产生不同的结果

面向过程编程(Procedure Oriented Programming, POP)

是一种以过程为中心的编程思想, 分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用就可以了。

百度百科有个很形象的比喻,

例如五子棋:
面向过程的设计思路步骤:
1、开始游戏,
2、黑子先走,
3、绘制画面,
4、判断输赢,
5、轮到白子,
6、绘制画面,
7、判断输赢,
8、返回步骤 2,
9、输出最后结果

面向对象的设计思路步骤:整个五子棋可以分为
1、黑白双方,这两方的行为是一模一样的,
2、棋盘系统,负责绘制画面,
3、规则系统,负责判定诸如犯规、输赢等。
第一类对象(玩家对象)负责接受用户输入,并告知第二类对象(棋盘对象)棋子布局的变化,棋盘对象接收到了棋子的变化就要负责在屏幕上面显示出这种变化,同时利用第三类对象(规则系统)来对棋局进行判定。

面向对象是以功能来划分问题,而不是步骤。同样是绘制棋局,这样的行为在面向过程的设计中分散在了多个步骤中,很可能出现不同的绘制版本,因为通常设计人员会考虑到实际情况进行各种各样的简化。而面向对象的设计中,绘图只可能在棋盘对象中出现,从而保证了绘图的统一。

(更多内容请自行查阅, 本节到此为止了.)

基本类型和引用类型

之前已经写过这个文章, 就不复述了
详情可以参考我之前写的文章关於 Javascript 基本类型和引用类型小知识

执行环境 (execution context) 及作用域(scope)

来自 Javascript 高级程序设计 3:

解析

执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的 变量对象(variable object),环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

全局执行环境是最外围的一个执行环境。根据 ECMAScript 实现所在的宿主环境不同,表示执行环境的对象也不一样。在 Web 浏览器中,全局执行环境被认为是 window 对象 ,因此所有全局变量和函数都是作为 window (全局) 对象的属性和方法创建的。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局执行环境直到应用程序退出——例如关闭网页或浏览器——时才会被销毁)。

每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行之后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript 程序中的执行流正是由这个方便的机制控制着。

当代码在一个环境中执行时,会创建变量对象的一个 作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其 活动对象(activation object)作为变量对象。活动对象在最开始时只包含一个变量,即 arguments 对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。

标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止(如果找不到标识符,通常会导致错误发生).

延长作用域链

  • try-catch 语句的 catch 块
  • with 语句(不推荐)

这两个语句都会在作用域链的前端添加一个变量对象。
对 with 语句来说,会将指定的对象添加到作用域链中。
对 catch 语句来说,会创建一个新的变量对象,其中包含的是被抛出的错误对象的声明。

没有块级作用域

function test() {if (true) {var num = 0;}
  // 打印 if 语句内声明变量
  console.log(num);//0
  for (var i = 0; i < 4; i++) { }
  // 打印 for 语句内声明变量
  console.log(i);//4
}
test()//0 4

在其他类 C 的语言中,由花括号封闭的代码块都有自己的作用域, 在 if ,for 语句执行完毕后被销毁, 但在 JavaScript 中,if ,for 语句中的变量声明会将变量添加到当前的执行环境中。如果向模拟块状作用域的话可以利用闭包等方法, 下文会提到.
(更多内容请自行查阅, 本节到此为止了.)

垃圾收集

JavaScript 具有自动垃圾收集机制,也就是说,执行环境会负责管理代码执行过程中使用的内存。这种垃圾收集机制的原理其实很简单:找出那些不再继续使用的变量,然后释放其占用的内存。为此,垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间),周期性地执行这一操作。

下面我们来分析一下函数中局部变量的正常生命周期。
局部变量只在函数执行的过程中存在。而在这个过程中,会为局部变量在栈(或堆)内存上分配相应的空间,以便存储它们的值。然后在函数中使用这些变量,直至函数执行结束。此时,局部变量就没有存在的必要了,因此可以释放它们的内存以供将来使用。在这种情况下,很容易判断变量是否还有存在的必要;
但并非所有情况下都这么容易就能得出结论。垃圾收集器必须跟踪哪个变量有用哪个变量没用,对于不再有用的变量打上标记,以备将来收回其占用的内存。用于标识无用变量的策略可能会因实现而异,但具体到浏览器中的实现,则通常有两个策略。

  • 标记清除
    当变量进入环境(例如,在函数中声明一个变量)时,就将这个变量标记为“进入环境”。从逻辑上讲,永远不能释放进入环境的变量所占用的内存,因为只要执行流进入相应的环境,就可能会用到它们。而当变量离开环境时,则将其标记为“离开环境”。
    可以使用任何方式来标记变量。比如,可以通过翻转某个特殊的位来记录一个变量何时进入环境,或者使用一个“进入环境的”变量列表及一个“离开环境的”变量列表来跟踪哪个变量发生了变化。说到底,如何标记变量其实并不重要,关键在于采取什么策略。
    垃圾收集器在运行的时候会给存储在内存中的所有变量都加上标记(当然,可以使用任何标记方式)。然后,它会去掉环境中的变量以及被环境中的变量引用的变量的标记。而在此之后再被加上标记的变量将被视为准备删除的变量,原因是环境中的变量已经无法访问到这些变量了。最后,垃圾收集器完成内存清除工作,销毁那些带标记的值并回收它们所占用的内存空间。
  • 引用计数
    引用计数的含义是跟踪记录每个值被引用的次数。当声明了一个变量并将一个引用类型值赋给该变量时,则这个值的引用次数就是 1。如果同一个值又被赋给另一个变量,则该值的引用次数加 1。相反,如果包含对这个值引用的变量又取得了另外一个值,则这个值的引用次数减 1。当这个值的引用次数变成 0 时,则说明没有办法再访问这个值了,因而就可以将其占用的内存空间回收回来。这样,当垃圾收集器下次再运行时,它就会释放那些引用次数为零的值所占用的内存。
    问题一, 循环引用会一直无法回收;
    问题二, 低版本 IE 有一部分对象并不是原生 JavaScript 对象;

(更多内容请自行查阅, 本节到此为止了.)

JavaScript 原型对象与原型链

之前已经写过这个文章, 就不复述了, 特意修改了之前的排版补充之类
詳情可以參考我之前寫的文章關於 Javascript 中的 new 運算符, 構造函數與原型鏈一些理解

递归

一种会在函数内部重复调用自身的写法.

function factorial(num) {if (num <= 1) {return 1;} else {return num * factorial(num - 1);
  }
}
console.log(factorial(5));//120

一开始的常规写法, 但是有个问题是内部调用自身是使用函数名字, 如果在将 factorial 赋值到一个变量之后, 尽管还是调用原 factorial 函数, 但不是期望的调用函数自身的写法了.

function factorial(num) {if (num <= 1) {return 1;} else {return num * factorial(num - 1);
  }
}
var another = factorial;
factorial = null;

console.log(another(5)); //TypeError: factorial is not a function

如上, 实际上是在 another 上调用 factorial, 而且如果 factorial 不存在之后会引起错误.

解决方案:

1, arguments.callee(不推荐)
是一个指向正在执行的函数的指针属性.

function factorial(num) {if (num <= 1) {return 1;} else {return num * arguments.callee(num - 1);
  }
}
var another = factorial;
factorial = null;

console.log(another(5)); // 120

缺点:

  1. 严格模式下,不能通过脚本访问 arguments.callee,访问这个属性会导致错误;
  2. arguments 是庞大且变化的, 每次访问需要消耗大量性能;

2, 命名函数表达式

var factorial = (function f(num) {if (num <= 1) {return 1;} else {return num * f(num - 1);
  }
});
var another = factorial;
factorial = null;

console.log(another(5)); // 120

这种方式在严格模式和非严格模式下都行得通.
(更多内容请自行查阅, 本节到此为止了.)

闭包

定义:

  • 未知来源: 指函数变量可以保存在函数作用域内,因此看起来是函数将变量“包裹”了起来;
  • 未知来源: 指在函数声明时的作用域以外的地方被调用的函数;
  • 官方: 一个拥有许多变量和绑定了这些变量的环境的表达式;
  • Javascript 高级程序设计 3: 闭包是指有权访问其他函数作用域中的变量的函数;
  • JavaScript 语言精粹: JavaScript 中的函数运行在它们被定义的作用域里,而不是它们被执行的作用域里;
  • 阮一峰: 闭包就是能够读取其他函数内部变量的函数;

这是一个很难界定的点, 每个人的说法都不同, 包括各种专业资料, 权威大神, 但是唯一不变的是它们都有提到 访问其他作用域的能力 .
例如这个例子, 不仅可以在外部读取函数内部变量, 还能修改.

function test() {
  var num = 1;
  return {get: function () {console.log(num);
    },
    add: function () {console.log(++num);
    }
  }
}
var result = test();
result.get(); // 1
result.add(); // 2

注意:

1, 匿名函数和闭包函数没有必然关系;
匿名函数: 不需要函数名字, 没污染全局命名空间的风险并且执行后自动销毁环境.
很多人说匿名函数也是闭包的用法, 但是在我看来这只不过是使用匿名函数的写法来写闭包, 让开发省掉多余步骤而已. 例如:

// 闭包写法
function test1() {return function () {console.log(1);
  }
}
test1()(); // 1
// 匿名函数写法
var test2 = (function () {return function () {console.log(1);
  }
})()
test2(); // 1

2, 闭包所保存的是整个变量对象,而不是某个特殊的变量, 所以只能取得包含函数中任何变量的最后一个值。
这就是为什么 for 循环返回的 i 永远是最后一个的原因了

<!DOCTYPE html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <ol>
        <li> 点我吧!!</li>
        <li> 点我吧!!</li>
        <li> 点我吧!!</li>
        <li> 点我吧!!</li>
    </ol>
    <script>
        var btn = document.getElementsByTagName('li');

        for (var i = 0; i < 4; i++) {btn[i].onclick = function () {alert(i);
            };
        }
    </script>
</body>

</html>

我们可以通过创建闭包环境模拟块级作用域让行为符合预期

<!DOCTYPE html>

<head>
    <meta charset="UTF-8">
    <title></title>
</head>

<body>
    <ol>
        <li> 点我吧!!</li>
        <li> 点我吧!!</li>
        <li> 点我吧!!</li>
        <li> 点我吧!!</li>
    </ol>
    <script>
        var btn = document.getElementsByTagName('li');

        for (var i = 0; i < 4; i++) {btn[i].onclick = function (num) {return function () {alert(num);
                }
            }(i);
        }
    </script>
</body>

</html>

3, this 指向问题
this 对象是在运行时基于函数的执行环境绑定的, 闭包的执行环境具有全局性,因此其 this 对象通常指向 window;

function test() {return function () {console.log(this === window);
  }
}
test()(); // true

优点:

  • 封装性好, 避免全局污染;
  • 可以延长变量生命周期, 保存当前变量不被清除;
  • 可以在模拟块级作用域;
  • 在对象中创建私有变量方法, 只暴露出想提供的访问权限;

缺点:

  • 由于闭包会使得函数中的变量都被保存在内存中,内存消耗很大,所以不能滥用闭包,否则会造成网页的性能问题,在 IE 中可能导致内存泄露。解决方法是,在退出函数之前,将不使用的局部变量全部删除(delete 是没用的, 要设成 null)。
  • 闭包会在父函数外部,改变父函数内部变量的值。所以,如果把父函数当作对象(object)使用,把闭包当作它的公用方法(Public Method),把内部变量当作它的私有属性(private value),这时一定要小心,不要随便改变父函数内部变量的值。

(更多内容请自行查阅, 本节到此为止了.)

Currying(柯里化)

在计算机科学中,柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数 (最初函数的第一个参数) 的函数,并且返回接受余下的参数且返回结果的新函数的技术。特点是:

  1. 参数复用;
  2. 提前返回;
  3. 延迟计算 / 运行。

单说有点抽象, 例如你有一个简单的计算函数

function add(a, b) {return a + b}

如果你想再加多写计算量怎么办? 继续加入参加参数,
我们分析下上面的函数有什么特点:
1, 计算方式固定, 返回所有参数相加的结果;
2, 参数不固定, 可能需要增减;

既然计算方法固定, 那我们就只需要在参数上想办法解决, 有没一种方法可以让函数保存参数直到某个时刻再执行? 自然是有的, 而且还是利用之前学过的知识点:
1, 函数作用域内的 arguments 入参变量;
2, 递归;
3, 闭包;

我们可以先看看柯里化后执行过程大概如此

add(a)(b)(c);
======== 相当于 ===========
function add(a) {return function (b) {return function (c) {return a + b + c}
  }
}

实践开始

接下来我们就分阶段写一个方法让原函数转换成这种柯里化函数用法.

我们先写一个最简单的柯里化函数如下

function toCurry(fn) {
  // 转入参数组
  var args = [].slice.call(arguments),
    // 提取执行方法
    fn = args.shift();
  return function () {
    // 拼接上次入参和这次入参执行方法
    return fn.apply(null, args.concat([].slice.call(arguments)));
  };
}

function add(a, b) {return a + b}

console.log(toCurry(add, 1)(2)); // 3

已经初步走向柯里化的道路了, 然后继续扩展延伸,

把接受多个参数的函数变换成接受一个单一参数

function toCurry(fn) {
  // 保存回调函数
  return function curry() {var args = [].slice.call(arguments);
    // 判断目前入参是否达到函数要求入参
    if (args.length < fn.length) {return function () {
        // 递归调用 curry, 拼接当前 arguments 和下次入参的 arguments
        return curry.apply(null, args.concat([].slice.call(arguments)))
      }
    } else {
      // 执行函数
      return fn.apply(null, args)
    }
  }
}

function add(a, b, c) {return a + b + c}

var fn = toCurry(add);
console.log(fn(1, 2)(3)); //6
console.log(fn(1)(2)(3)); //6

目前不管怎么传只要达到数量就会执行, 接下来我们要把固定入参转成自动入参方式, 所以要在执行判断那里下功夫, 我们现在是根据函数的入参 fn.length 来判断是否执行, 这需要在函数预先定义好, 先尝试一下把这个改成人手控制.

// 新增入参数目限制
function toCurry(fn, len) {
  var len = len || fn.length;
  // 保存回调函数
  return function curry() {var args = [].slice.call(arguments);
    // 判断目前入参是否达到函数要求入参
    if (args.length < len) {return function () {
        // 递归调用 curry, 拼接当前 arguments 和下次入参的 arguments
        return curry.apply(null, args.concat([].slice.call(arguments)))
      }
    } else {
      // 执行函数
      return fn.apply(null, args)
    }
  }
}

function add(a, b, c) {return a + b + c}

var fn = toCurry(add, 3);
console.log(fn(1)(2)(3));//6

现在已经可以自定义配置执行时机, 但是可不可以更进一步, 把配置这一步骤都免掉呢? 这个就让大家自由发挥吧, 下面贴出一个网上流传的写法

function add() {var args = [].slice.call(arguments);
  var fn = function () {var newArgs = args.concat([].slice.call(arguments));
    return add.apply(null, newArgs);
  }
  fn.toString = function () {return args.reduce(function (a, b) {return a + b;})
  }
  return fn;
}
console.log(add(1)(2)(3));

这算是一种取巧的写法, 偷换 toString 写法达到执行的目的. 因为我暂时还没什么想法, 就不说了,
接下来还有一个问题, 有些函数需要用到 tihs 指向的时候, 我们已经早就丢失了, 所以在通用函数还要保存指针

// 新增入参数目限制
function toCurry(fn, len) {
  var len = len || fn.length;
  // 保存回调函数
  return function curry() {var args = [].slice.call(arguments),
      self = this;
    // 判断目前入参是否达到函数要求入参
    if (args.length < len) {return function () {
        // 递归调用 curry, 拼接当前 arguments 和下次入参的 arguments
        return curry.apply(self, args.concat([].slice.call(arguments)))
      }
    } else {
      // 执行函数
      return fn.apply(self, args)
    }
  }
}
var num = {
  name: 'mike',
  add: function add(a, b, c) {console.log(this.name);
    return a + b + b
  }
}

num.add = toCurry(num.add, 3);
console.log(num.add(1)(2)(3));

好的, 目前除了固定参数那一块还没其他思路, 基本方法时成型了, 另外再给一个延迟执行的例子写法大家看

var add = (function (fn) {var ary = [];
  return function curry() {var args = [].slice.call(arguments);
    if (args.length) {ary = ary.concat(args)
      return curry
    } else {return fn.apply(null, ary)
    }

    return args.length ? curry : fn.apply(null, ary)
  }
})(function () {console.log([].slice.call(arguments).reduce((total, cur) => total += cur));
})

add(1)(2)(3)(); // 6

有参数就保存, 没参数就执行, 这种使用场景也比较多, 根据情况写法不同, 例如这里就没有保存 this 指向.

总的来说, 柯里化主要更改在于传入的参数个数,以及它如何影响代码的结果.
(更多内容请自行查阅, 本节到此为止了.)

惰性载入函数

如果平时有自己写一些兼容不同浏览器差异代码的话, 肯定之后中间夹杂着很多判断分支, 作为强迫症的程序员怎么可以忍受这些东西, 例如:

function addEvent(element, eType, handle, bol) {if (element.addEventListener) {element.addEventListener(eType, handle, bol);
  } else if (element.attachEvent) {element.attachEvent("on" + eType, handle);
  } else {element["on" + eType] = handle;
  }
}

解决方案

1, 在第一次调用的过程中,该函数会被覆盖为另外一个按合适方式执行的函数

function addEvent() {if (document.addEventListener) {addEvent = function (element, eType, handle, bol) {element.addEventListener(eType, handle, bol);
    }
  } else if (document.attachEvent) {addEvent = function (element, eType, handle, bol) {element.attachEvent("on" + eType, handle);
    }
  } else {addEvent = function (element, eType, handle, bol) {element["on" + eType] = handle;
    }
  }
  return addEvent.apply(this, arguments);
}

除了第一次会执行判断, 然后原函数被覆盖成分支流程代码再返回函数

2, 上面提过的匿名自执行闭包写法, 比第一种好处是省去手动触发第一次执行.

var addEvent = (function () {if (document.addEventListener) {return function (element, eType, handle, bol) {element.addEventListener(eType, handle, bol);
    }
  } else if (document.attachEvent) {return function (element, eType, handle, bol) {element.attachEvent("on" + eType, handle);
    }
  } else {return function (element, eType, handle, bol) {element["on" + eType] = handle;
    }
  }
})();

尾调用优化

函数的最后一步是调用另一个函数, 之所以有这种写法就是因为上面说的 执行环境 (execution context) 及作用域(scope), 关键就是最后一步能不能把损耗降到最低, 例如:

// 没错, 又是我们的熟面孔函数
function add() {
  var a = 1,
    b = 2;
  return result(a + b)
}

function result(num) {return num;}
console.log(add()); // 3

由于是函数的最后一步操作,所以不需要保留外层函数的环境变量,因为都不会再用到了,只要直接只保留内层函数的环境变量 (代码 8) 就可以了。

尾递归

还用上面的递归例子, 这是一个一直累积环境变量的写法.

function factorial(num) {if (num <= 1) {return 1;} else {return num * factorial(num - 1);
  }
}
console.log(factorial(5));//120

如果我们能利用尾调用优化写法就可以让它一直保持一层的环境变量记录.

function factorial(num, total) {if (num === 1) return total;
  return factorial((num - 1), total * num);
}
console.log(factorial(5, 1)); //120

缺点

  • 把运算放在方法入参中, 可读性语义化都是个问题;
  • 需要传入初始值;(可以采用上面尾调用方法, 柯里化等写法解决, 实际意义不大)

正文完
 0