尾递归
函数调用自身,称为递归。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。
关于递归的性能问题
函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用帧上方,还会形成一个B的调用帧。等到B运行结束,将结果返回到A,B的调用帧才会消失。如果函数B内部还调用函数C,那就还有一个C的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
所以,递归是非常耗内存的。但尾递归只存在一个调用帧,不会发生“栈溢出”错误。
非尾调用的缺点
// 非尾递归的 Fibonacci 数列
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10) // 89
Fibonacci(100) // 超时复制代码
// 递归版的 Fibonacci 数列
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
if( n <= 1 ) {return ac2};
return Fibonacci2 (n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100); // 573147844013817200000
Fibonacci2(1000); // 7.0330367711422765e+208复制代码
嵌套层次稍微变多,就会导致“调用栈”过大导致溢出。
尾调用
尾调用是指一个函数里的最后一个动作是返回一个函数的调用结果的情形,即最后一步新调用的返回值直接被当前函数的返回结果。简单的例子:
function f(x){
return g(x);
}
复制代码
但以下三种情况是不属于尾调用:
// 情况一,实际是先在f函数体内执行完g函数后的所有计算才会被返回
function f(x){
let y = g(x);
return y;
}
// 情况二,返回函数有其他运算
function f(x){
return g(x) + 1;
}
// 情况三,没有返回值
function f(x){
g(x);
}复制代码
尾递归
尾调用自身就是尾递归。function factorial(n, total = 1) {
if (n === 1) return total;
return factorial(n - 1, n * total);
}
factorial(5); // 120复制代码
非严格模式不能实现尾递归
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。 这是因为在正常模式下,函数内部的arguments和caller变量,可以跟踪函数的调用栈。
下面例子演示了栈溢出的情况:
function sum(x, y) {
if (y > 0) {
return sum(x + 1, y - 1);
} else {
return x;
}
}
sum(1, 100000); // 报错复制代码
蹦床函数(trampoline)
蹦床函数的思想是,把递归函数转化成循环执行。
// 蹦床函数
function trampoline(f) {
// 不断执行f返回的函数,直到f返回非函数的值
while (f && f instanceof Function) {
f = f();
}
return f;
}
// 原本递归也改成 返回一个等待执行的函数
function sum(x, y) {
if (y > 0) {
return sum.bind(null, x + 1, y - 1);
} else {
return x;
}
}
// 测试
trampoline(sum(1, 100000)); // 100001
复制代码
尾递归的终极优化方案
function tco(f) {
var value;
var active = false;
var accumulated = [];
return function accumulator() {
accumulated.push(arguments);
if (!active) {
active = true;
while (accumulated.length) {
value = f.apply(this, accumulated.shift());
}
active = false;
return value;
}
};
}
var sum = tco(function(x, y) {
if (y > 0) {
return sum(x + 1, y - 1)
}
else {
return x
}
});
sum(1, 100000); // 100001复制代码
上面代码中,tco函数是尾递归优化的实现,它的奥妙就在于状态变量active。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum返回的都是undefined,所以就避免了递归执行;而accumulated数组存放每一轮sum执行的参数,总是有值的,这就保证了accumulator函数内部的while循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。(摘自阮一峰的ES6)
防抖 和 节流
节流和防抖都是利用定时器,具体描述请查看另一篇文章,这里简单介绍。
防抖(debounce)
防抖,指的是在 n 秒后执行该函数,若在此期间调用了该函数,则会重新计算倒计时。简单说就是,n 秒内只允许执行该函数一次,目的是限制函数的执行次数,如发送请求等。
节流(throttle)
节流,指的是在 n 秒内只能有一个该函数执行, n 秒后可以继续添加。所以,对于连续触发的函数,节流能把函数执行限制在一定时间频率执行。举个简单例子,页面滚动时会不断触发scroll
事件,但我只希望每 300 ms获取一次事件对象,这时候就用上节流函数。
两者对比
两者都是利用定时器,不同的是,在时间内触发函数,防抖函数会将函数和定时器清除,后重新添加计时器;而节流函数则只是忽略新加入的函数,除非计时器结束。
作用域安全的构造函数
构造函数内用到this
关键字,为创建的对象提供属性或方法。假如直接执行构造函数,this
可能会指向对象,就变成了为全局对象添加属性,这并不是我们希望的。
可能产生问题的情况
// 构造函数,期望是创建新对象,并为其添加指定属性
function Person(name, age, job) {
this.name = name;
this.age = age;
this.job = job;
}
// 正确使用:
let person = new Person('lisi', 29, 'worker');
// 错误用法。等同为全局对象添加属性
let person1 = Person('lisi', 29, 'worker');复制代码
解决办法
在构造函数内部判断函数被调用时this
的实例对象。作为普通函数调用,this
会指向执行上下文;作为构造函数调用时,this
会指向新对象。
function Person(name, age, job) {
// 判断 this 的原型对象是否为本函数
if (this instanceof Person) {
this.name = name;
this.age = age;
this.job = job;
} else {
// 直接调用时也返回构造函数
return Person(name, age, job);
// 也可以抛出错误
// throw new Error('该函数是构造函数')
}
}复制代码
惰性载入函数
有这样一种函数,有多个if
语句用于判断执行环境,但执行环境不会改变。可是每次调用该函数都需要通过多个if
语句的判断,造成性能的浪费。解决这类问题的函数就是惰性载入函数。原理就是,符合某个if
语句时,把整个函数替换。下面给出一个简单例子:
function lazyLoad() {
if (someValue === 'typeA') {
lazyLoad = function () {
// 第一种情况...
}
} else if (someValue === 'typeB') {
lazyLoad = function () {
// 第二种情况...
}
} else {
lazyLoad = function () {
// ...
}
}
// 返回执行结果
return lazyLoad()
}复制代码
仿私有变量
JavaScript 中没有私有成员的概念。但可以利用闭包的形式创建"私有成员"。但不建议过多使用,因为对性能有一定影响。
简单例子:
function Fn(prop) {
this.getProp = function () {
return prop
}
this.setProp = function (val) {
prop = val
}
}
// 创建实例
let obj = new Fn();
// 通过指定方法 设定私有成员
obj.setProp('private');
// 通过指定方法 访问私有成员
console.log(obj.getProp());
// 私有成员无法直接访问
console.log(obj.prop);复制代码
静态私有变量
简单例子中,每次使用 new 创建的实例都会创建一组相同的方法,造成冗余。为了解决这个问题,应该把相同的方法"公有化"。
let Fn = (function () {
// 私有变量
let prop = '';
// 构造函数
let Fn = function (val) {
prop = val
}
// 公有/特权方法
Fn.prototype.getProp = function () {
return prop
}
Fn.prototype.setProp = function (val) {
prop = val
}
return Fn
})()
// 创建实例
let obj = new Fn('aaa');
obj.setProp('bbb');
console.log(obj.getProp()); // 'bbb'
console.log(obj.prop); // undefined复制代码
模块模式
模块模式是为单例创建私有变量和特权方法。单例,就是只有一个实例的对象。
简单来说,就是私有成员是唯一的,但必须通过特权/公有方法去访问该私有成员。
let singleton = (function () {
// 私有变量和方法
let privateValue = 'value';
function privateFunction(args) {
console.log(args)
}
// 特权/公有方法和属性
return {
usePrivateFunction(args, context) {
privateFunction.call(context, args)
},
getPrivateValue() {
return privateValue
}
}
})()
// 通过接口访问私有方法
singleton.usePrivateFunction({ a: 1 }); // { a: 1 }
// 访问私有属性
console.log(singleton.getPrivateValue()); // "value"
复制代码
函数创建对象的几种模式
工厂模式
就像工厂流水线一样,对原料进行多次加工,最后输出成品。
原理就是先创建一个空对象,然后为其添加属性方法,最后把对象返回。
function factory(a, b) {
let obj = new Object();
obj.a = a;
obj.b = b;
return obj
}
let obj = factory(1, 2); // { a: 1, b: 2 }
复制代码
构造函数模式
要使用构造函数创建对象,必须使用 new 运算符。这种方式创建对象,会经历以下 4 个阶段:
- 创建一个新对象;
- 将构造函数的作用域赋给新对象(即 this 指向该对象);
- 执行构造函数中的代码;
- 返回新对象。
function Fn(a, b) {
this.a = a;
this.b = b;
}
let obj = new Fn(1,2); // { a: 1, b: 2 }复制代码
原型模式
每个函数都有 prototype 属性,指向一个对象,该对象包含可以由特定类型的所有实例共享的属性和方法。
简单说,所有由该函数构造的实例对象,都共享同一个对象(内含属性/方法)。
function Fn() { }
Fn.prototype.a = 1;
Fn.prototype.b = 1;
let obj = new Fn();
console.log(obj); // {}
// 访问 prototype 的属性
console.log(obj.a); // 1复制代码
寄生构造函数模式
此模式的代码其实和工厂模式一样,只是本模式使用"new"关键字创建。
所谓寄生,就是为宿主添加自己的属性和方法。其实就是创建一个实例对象,然后为其添加属性和方法,只是为此打包成一个构造函数而已。
假设我们需要一个特殊的数组,为其添加指定的属性和方法。如下:
function SpecialArray(){
// 创建数组(宿主)
let arr = new Array();
// 添加值(寄生属性)
arr.push.apply(arr, arguments);
// 添加方法(寄生方法)
arr.toPipedString = function(){
return this.join('|');
}
// 返回被寄生的宿主
return arr
}
// 创建被寄生的实例
let colors = new SpecialArray('red','green','blue');复制代码
要注意的是,使用此方法则无法通过instance
操作符确定对象原型。对于上述例子,运行colors instanceof SpecialArray
得到 false 。
与继承有关的几种构造函数
借用构造函数
顾名思义,就是在子类构造函数的内部调用超类型构造函数,实例化的对象也会包含被借用的属性和方法。(也被成为伪造对象或经典继承)
function Super(foo) {
this.foo = foo;
// 为自身原型对象添加方法
Super.prototype.baz = function () {
return 'baz'
}
}
// 借用构造函数
function Sub(foo, baz) {
Super.apply(this, arguments)
this.subBaz = baz
}
let obj = new Sub('f', 'b');
console.log(obj); // { foo: 'f', subBaz: 'b' }
console.log(obj.baz()); // 报错!
console.log(new Super('z').baz()); // 'baz'复制代码
上述代码了除了演示了如何创建"借用构造函数",还证明了其缺点:无法继承超类型构造函数的原型链。
组合继承 (原型链和借用构造函数的组合)
为了解决借用构造函数的缺点。实现也很简单,为借用构造函数添加超类型构造函数的原型链。
同样是"借用构造函数"示例代码的例子,修改"借用构造函数"部分内容:
// 借用构造函数
function Sub(foo, baz) {
Super.apply(this, arguments)
this.subBaz = baz;
// 添加原型链
Sub.prototype = new Super(foo);
Sub.prototype.constructor = Super;
}复制代码
这个模式是最常用的继承模式,但缺点是:调用了两次超类型构造函数。
原型式继承
其思想就是,传入一个对象,以该对象为原型对象创建一个新对象。其实就是 ES5 的Object.creat()
函数所实现的,具体不展开细说了。
寄生式继承
思路与寄生构造函数和工厂模式类似,即创建一个仅用于封装继承过程的函数,该函数在内部以某种方式来增强对象,最后再像真的是它做了所有工作一样返回对象。
简单说就是,把多个增强某对象的代码打包成一个函数。
function createAnother(original) {
// 调用函数创建一个新对象(此函数不是必须,可以任意形式创建对象)
let clone = object(original);
// 为该对象添加属性或方法,以增强该对象
clone.sayHi = function () {
console.log('Hi');
}
return clone
}
let person = { name: 'lisi', friends: ['a', 'b'] }
let anotherPerson = createAnother(person);
anotherPerson.sayHi(); // 'Hi'复制代码
寄生组合式继承
解决组合继承模式的缺点。
所谓寄生组合式继承,就是通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。
其背后的思路是:不必为了指定子类型的原型而调用超类型的构造函数,为其添加超类型原型的副本即可。
function inheritPrototype(sub, super) {
// 创建对象
let prototype = object(super.prototype);
// 增强对象
prototype.constructor = sub;
// 指定对象
sub.prototype = prototype;
}复制代码