JavaScript 精华概念剖析:函数基础

JavaScript 当中设计得最出色的就是它的函数,几乎接近完美。但是,肯定也是有瑕疵的。

函数即对象

JavaScript 中的函数就是对象。对象是“名/值”对的集合并拥有一个连到原型对象的隐藏连接。对象的原型链最终连接到Object.prototype

函数对象则连接到Function.prototype(该函数对象本身连接到Object.prototype)。

每个函数在创建的时候会附加两个隐藏属性:函数的上下文和实现函数行为的代码

JavaScript创建一个函数对象时,会给该对象设置一个“调用”属性。当JavaScript调用一个函数时,可理解为调用此函数的“调用”属性。

因为函数是对象,所以它可以像其它任何其它值一样被使用,即一等公民。函数可以保存在变量、对象和数组中。函数可以被当做参数传递给其他函数,函数也可以再返回函数。

由于函数是对象,所以函数也可以拥有自己的方法。

函数的与众不同,在于它可以被调用。

函数的创建

var add = function (a, b) {
    return a + b;
};

创建一个函数包含4个部分:

  • 保留字function
  • 可以省略的函数名。函数名可以用它的名字来递归调用自己。这个名字也能被调试器和开发工具用来识别函数。如果没有给函数命名,就像上面的例子一样,它被称为匿名函数
  • 函数参数
  • 函数体

函数的调用

调用一个函数会暂停当前函数的执行,传递控制权和参数給新的函数。除了声明时定义的形式参数,每个函数还接收两个附加参数:thisarguments。参数 this 再面向对象编程中非常重要,它的值取决于调用的模式。传入的参数如果过多,超出的参数值会被忽略;如果传入的参数过少,缺失的值会被替换为 undefined

有4种方法可以调用JavaScript的函数:

  • 函数调用
  • 方法调用
  • 构造器调用
  • apply()call()间接调用

函数调用模式

当一个函数并非一个对象的属性时,那么它就是被当做一个函数来调用的:

var sum = add(3, 4); // sum的值为7

用这个模式调用函数时,this绑定到全局对象。这是语言设计上的一个错误。如果语言设计正确,那么内部函数被调用时,this应该仍然绑定到外部函数的 this 变量。

这个错误的后果:方法不能利用内部函数来帮助它工作,因为内部函数的 this 被绑定了错误的值,所以不能共享该方法对对象的访问权

举个例子,

var myObject = {
  value : 3
};

myObject.double = function () {
  var helper = function () {
    this.value = this.value + this.value;
  }
  helper();
};

myObject.double();
console.log(myObject.value); // 3

结果是:3,不是期待的是6。this.valueNAN (全局环境没有 value 变量)不是 myOject 里的 value

有一个很容易的解决方案:如果该方法定义一个变量并把它赋值为this,内部函数就可以通过那个变量访问到this。不妨将这个变量命名为that

myObject.double = function () {
  var that = this; // 解决方法

  var helper = function () {
    that.value = that.value + that.value ;
  };

  helper();
};

myObject.double();
console.log(myObject.value); // 6

方法调用模式

当一个函数被保存为对象的一个属性时,我们称它为一个方法。当一个方法被调用时,this 被绑定到该对象上

var myObject = {
  value: 0,
  increment: function (inc) {
    this.value += typeof inc === 'number' ? inc : 1;
  }
};

myObject.increment();
console.log(myObject.value); // 1

myObject.increment(2);
console.log(myObject.value); // 3

方法可以使用 this 访问自己所属的对象,所以它能从对象中取值或对对象进行修改。this 到对象的绑定发生在调用的时候。 这个“超级” 延迟绑定(very late binding)使得函数可以对 this 高度复用。

通过 this 可取得它们所属对象的上下文的方法称为公共方法(public method)。

构造器调用模式

JavaScript是一门基于原型继承的无类型语言。这意味着对象可以直接从其他对象继承属性。这 偏离了主流的编程语言的风格,大多数语言都是基于类的语言。尽管原型继承极富表现力,但它没有被广泛理解。JavaScript对它的原型的本质也缺乏信心,所以提供类一套和基于类的语言类型的对象构建语法。

有类型化语言编程经验的程序员很少愿意接受原型继承,并且认为借鉴类型化语言的语法模糊类这门语言真实的原型本质。呵呵,JavaScript 真的是两边都不讨好。

如果在一个函数前面带上 new 来调用,那么背后会创建一个连接到该函数的 prototype 成员的新对象,同时 this 会被绑定到那个新对象上。

new 前缀也会改变 return 语句的行为。一个函数,如果创建的目的就是希望结合 new 前缀调用它就被称为构造器函数

按照约定,构造器函数保存在以大写格式命名的变量里。如果调用构造器函数时没有加上new,可能会发生很糟糕的事情,既没有编译警告也没有运行警告,所以大写的约定非常重要。

var Quo = function (string) {
  this.status = string;
};

Quo.prototype.get_status = function () {
  return this.status;
};

var myQuo = new Quo("confused");
console.log(myQuo.get_status()); // confused

不推荐使用这种形式的构造函数

Apply调用模式

JavaScript是一门函数式的面向对象编程语言,所以函数可以拥有方法

apply 方法构建一个参数数组传递给调用函数。允许选择this的值。

apply 方法接收两个参数,第1个是要绑定给 this 的值,第2个就是一个参数数组。

在ECMAScript 5的严格模式中,call()apply()的第一个实参都会变成 this 的值,哪怕传入的实参是原始值甚至是 nullundefined

var array = [3, 4];
var sum = add.apply(null, array); // sum的值为7

call()apply()类似,只不过它没有把实参放到数组之中:

var sum = add.call(null, 3, 4);

可以将 call()apply() 看做是某个对象的方法,通过调用方法的形式来间接调用。

参数

函数调用时,会有一个隐含的参数,就是 arguments 数组。函数可以通过这个参数访问所有它被调用时传递给它的参数列表,包括那些没有被分配给函数声明时定义的形式参数的多余参数。这使得编写一个无需指定参数个数的函数成为可能:

// 构造一个将大量的值相加的函数
// 函数内部的sum不会和函数外部定义的sum产生冲突
var sum = function () {
  var i, sum = 0;
  for (i = 0; i < arguments.length; i += 1) {
    sum += arguments[i];
  }

  return sum;
};

console.log(sum(4, 8, 15, 16, 23, 42)); // 108

因为语言的设计错误,arguments 并不是一个真正的数组。他只是“类似数组”(array-like)的对象。arguments拥有length属性,但它没有任何数组的方法

返回值

一个函数总是会返回一个值。如果没有指定返回值,则返回undefined。如果函数调用时在前面加上了new前缀,且返回值不是一个对象,则返回this

异常

异常是干扰程序的正常流程的不寻常(但并非完全是出乎意料的)事故。当发现这样的事故时,程序应该抛出一个异常:

var add = function(a, b) {
  if (typeof a !== 'number' || typeof b !== 'number') {
    throw {
      name : 'TypeError',
      message : 'add needs numbers'
    };
  }

  return a + b;
}

throw 语句中断函数的执行,应该会抛出一个 exception 对象,该对象包含一个用来识别异常类型的 name 属性和一个描述性的 message 属性。你也可以添加其他的属性。

var try_it = function () {
  try {
    add("seven");
  } catch (e) {
    console.log(e.name + ": " + e.message);
  }
}
try_it(); // TypeError: add needs numbers

扩充类型的功能

JavaScript允许给语言的基本类型扩充功能。

例如:JavaScript没有专门的整数类型,但有时候需要提取数字中的整数部分。

可以通过 Number.prototype 增加一个 integer 方法,它会根据数字的正负来判断是使用Math.ceiling还是Math.floor

Number.prototype.integer = function () {
  return Math[this < 0 ? 'ceil' : 'floor'](this);
};
console.log((-10 / 3).integer()); // -3

通过给基本类型增加方法,可以极大地提高语言的表现力

因为JavaScript原型继承的动态本质,新的方法立刻被赋予到所有的对象实例上,哪怕对象实例是在方法被增加之前就创建好了。

递归

有三根杆子A,B,C。A 杆上有 N 个 (N>1) 穿孔圆盘,盘的尺寸由下到上依次变小。要求按下列规则将所有圆盘移至 C 杆。
每次只能移动一个圆盘;
大盘不能叠在小盘上面。
提示:可将圆盘临时置于 B 杆,也可将从 A 杆移出的圆盘重新移回 A 杆,但都必须遵循上述两条规则。(维基百科)

问:如何移?最少要移动多少次?

“汉诺塔”问题的递归解法:

var hanoi = function (disc, src, aux, dst) {
  if (disc > 0) {
    hanoi(disc - 1, src, dst, aux);
    console.log("Move disc " + disc + ' from ' + src + ' to ' + dst);
    hanoi(disc - 1, aux, src, dst);
  }
};

hanoi(3, 'Src', 'Aux', 'Dst');

圆盘数量为3时,它返回这样的解法:

Move disc 1 from Src to Dst
Move disc 2 from Src to Aux
Move disc 1 from Dst to Aux
Move disc 3 from Src to Dst
Move disc 1 from Aux to Src
Move disc 2 from Aux to Dst
Move disc 1 from Src to Dst

作用域

作用域对于程序员来讲是一项重要的服务,因为它减少了名称冲突,并且提供了自动内存管理。

很多现代语言都推荐尽可能延迟声明变量,但用在 JavaScript 中会成为糟糕的建议。

因为它缺少块级作用域。所以,最好的做法是在函数体的顶部声明函数中可能会用到的所有变量

JavaScript确实有函数作用域,那意味着定义在函数中的参数和变量在函数外部是不可见的。而在一个函数内部任何位置定义的变量,在该函数内部任何位置都可见。 也就是说,这意味着变量在声明之前甚至已经存在:

var scope = "global";

function f() {
  console.log(scope);
  var scope = "local";
  console.log(scope);
}

你可能会误以为函数第一行会输出“global”,因为代码还没有执行到 var。其实不是,由于函数作用域的特性,局部变量在整个函数体内都是有定义的。因此,最推荐的写法是把变量都定义在函数的头部,这样可以非常清晰地反映变量的作用域,避免混淆。

闭包

作用域的好处是内部函数可以访问定义它们的外部函数的参数和变量(除了 thisarguments)。闭包产生了一个有趣的情形:

内部函数拥有比它外部函数更长的生命周期

var scope = "global scope"; // 全局变量

function checkscope() {
  var scope = "local scope"; // 局部变量
  function f() { return scope; } // 在作用域中返回值
  return f;
}

checkscope()()

根据作用域规则:JavaScript 的作用域链是函数定义的时候创建的。

嵌套的函数 f() 定义在这个作用域链里,其中的变量 scope 一定是局部变量,不管在何时何地地执行函数f()。这种绑定在执行f()时依然有效。因此,最后一行代码返回“local scope”,而不是“global scope”。

闭包这个特性非常强大,它可以捕捉到局部变量(和参数),并一直保存下来,看起来像这些变量绑定到了其中定义它们的外部函数。JavaScript的一个很大的问题就是依赖全局变量,闭包可以捕捉到单个函数调用的局部变量,并将这些局部变量用做私有状态,这也是弥补JavaScript作用域不足的方法之一

function counter() {
  var n = 0;
  return {
    count: function () { return n++; },
    reset: function () { n = 0; }
  };
}

var c = counter();
c.count(); // 0
c.count(); // 1
c.reset();
c.count(); // 0

counter() 函数内的局部变量 n,就相当于一个私有变量,通过闭包的实现,外部是无法随意更改该变量的。

使用闭包这种强大的技术的同时要特别小心那些不希望共享的变量往往不经意地共享给了其它闭包

function constfuncs() {
  var funcs = [];
  for (var i = 0; i < 10; i++) {
    funcs[i] = function() { return i; };
  }
  return funcs;
}
var funcs = constfuncs();
funcs[5]();

上面的代码段中创建了10个闭包,并将它们存储到一个数组中。这些闭包都是在同一个函数调用中定义的,因此它们可以共享变量 i。当 constfuncs() 返回时,变量 i 的值是10,所有的闭包都共享这一个值。

所以,数组中的函数的返回值都是同一个值,这并不是我们想要的结果。记住,嵌套的函数不会将作用域内的私有成员复制一份,也不会对所绑定的变量生成静态快照(static snapshot)。

因此,正确的写法是:

// 这个函数返回一个总是返回v的函数
function constfunc(v) { return function() { return v; }; }
// 创建一个数组用来存储常数函数
var funcs = [];
for (var i = 0; i < 10; i++) {
  funcs[i] = constfunc(i);
}
// 在第5个位置的元素所表示的函数返回值为5
funcs[5]() // => 5

闭包实现

如果你理解了词法作用域的规则,你就能很容易地理解闭包:函数定义时的作用域链到函数执行时依然有效。很多人觉得闭包非常难理解,因为他们在深入学习闭包的实现细节时觉得在外部函数中定义的局部变量在函数返回后就不存在了,那么嵌套的函数如何能调用不存在的作用域链呢?

如果想搞清楚这个问题,需要更深入地了解类似C语言这种更底层的编程语言,并了解基于栈的CPU架构:如果一个函数的局部变量定义在CPU的栈中,那么当函数返回时它们的确就不存在了。

然而,作用域链是一个对象列表,不是绑定的栈。 每次调用JavaScript 函数的时候,都会为之创建一个新的对象用来保存局部变量,把这个对象添加至作用域链中。当函数返回的时候,就从作用域链中将这个绑定变量的对象删除。

  • 如果不存在嵌套的函数,也没有其他引用指向这个绑定对象,它就会被当做垃圾回收掉。
  • 如果定义了嵌套的函数,每个嵌套的函数都各自对应一个作用域链,并且这个作用域链指向一个变量绑定对象
    • 如果这些嵌套的函数对象在外部函数中保存下来,那么它们也会和所指向的变量绑定对象一样当做垃圾回收。
    • 如果这个函数定义了嵌套的函数,并将它作为返回值返回或者存储在某处的属性里,这时就会有一个外部引用指向这个嵌套的函数。它就不会被当做垃圾回收,并且它所指向的变量绑定对象也不会被当做垃圾回收。(其实,就是一个引用计数的问题)

可以看到闭包和垃圾回收之间有着密切的关系,如果使用不慎,闭包很容易造成“循环引用”。当 DOM 对象和 JavaScript对象之间存在循环引用时需要格外小心,在某些浏览器下会造成内存泄漏。

回调

假如由用户交互触发,向服务器发送请求,最终展示服务器的响应。最自然的写法可能是这样:

request = prepare_the_request();
response = send_request_synchronously(request);
display(response);

这种方式的问题在于,网络上的同步请求会使客户端出现等待的情况。如果网络传输或服务器很慢,响应就会非常慢。

更好的方式是发起异步请求,提供一个接收到服务器响应时的回调函数。异步函数立即返回,这样客户端就不会被阻塞:

request = prepare_the_request();
send_request_asynchronously(request, function (response) {
    display(response);
  });

传递一个函数作为参数给 send_request_asynchronously函数,一旦收到响应,它就会被调用。

模块

可以使用函数和闭包来构建模块。通过使用函数产生模块,几乎可以完全摒弃全局变量的使用,从而缓解这个JavaScript 最糟糕的特性带来的影响。

举例来说,假定要给 String 增加一个 deentityify 方法。它是为了寻找字符串中的HTML字符实体并把它替换为对应的字符。这就需要在一个对象中保存字符实体的名字和对应的字符。可以放到全局变量中,但全局变量是魔鬼,会带来更多的问题。可以把它定义在函数的内部,但是会带来运行时的损耗,每次执行该函数的时候都会被求值一次。理想的方式是把它放入闭包之中

String.prototype.deentityify = function () {
  // 字符实体表。映射字符实体的名字到对应的字符。
  var entity = {
    quot: '""',
    lt: '<',
    gt: '>' 
  };

  return function () {
    return this.replace(/&([^&;]+);/g, 
      function (a, b) {
        var r = entity[b];
        return typeof r === 'string' ? r : a;
      }
    );
  };
}();

console.log('<">'.deentityify()); // <"">

模块模式利用了函数作用域和闭包来创建被绑定对象与私有成员的关联,在这个例子中,只有 deentityify 方法有权访问字符实体表这个数据对象。

模块模式的一般形式:一个定义了私有变量和函数的函数;利用闭包创建可以访问私有变量和函数的特权函数;最后返回这个特权函数,或者把它们保存到一个可访问到的地方。使用模块模式就可以摒弃全局变量的使用。对于应用程序的封装,或者构造其他单例对象,模块模式非常有效。

级联

有一些方法没有返回值。如果让这些方法返回 this 而不是undefined,就可以启用级联

在一个级联中,可以单独一条语句依次调用同一个对象的很多方法。

getElement('myBoxDiv')
  .move(350, 150)
  .width(100)
  .heigh(100)
  .color('red')
  .border('10px outset');

级联技术可以产生出极富表现力的接口。它也能给那些构造“全能”接口的人提供一个更优雅的解决方法,一个接口没有必要一次做太多的事情。

柯里化

柯里化,也常被译为“局部”套用,是把多参数函数转换为一系列单参数函数并进行调用的技术。这项技术以数学家Haskell Curry (哈斯凯尔·柯里)命名。

柯里化允许我们把函数与传递给它的参数相结合,产生一个新的函数:

function f(y) { return this.x + y; };
var o = { x : 1 }; // 要绑定的对象
var g = f.bind(o); // 通过g(x)来调用o.f(x)
g(2); // => 3

bind() 是在 ES5 中新增的方法。它不仅仅是将函数绑定到一个对象,还附带一些其他的作用:除了第一个实参,传入 bind() 的实参也会绑定到 this

// 返回两个实参的和
var sum = function(x, y) { return x + y; };

// 创建一个类似sum的新函数,但this的值绑定到null
// 并且第一个参数绑定到1,这个新的函数期望只传入一个实参
var succ = sum.bind(null, 1);
succ(2) // => 3:x已经绑定了1,在这里y绑定了2

// 另外一个做累加计算的函数
function f(y, z) { return this.x + y + z; };
var g = f.bind({ x : 1 }, 2); // 绑定this和y
g(3) // => 6:this.x绑定到1,y绑定到2,z最终绑定到3

记忆

函数可以将先前操作的结果记录在某个对象里,从而避免无谓的重复计算。这种优化被称为为记忆(memoization),也是动态规划依赖的基础。

比如,想要利用递归来计算Fibonacci数列,可以编写函数:

var fibonacci = function (n) {
  return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
};

for (var i = 0; i <= 10; i += 1) {
  console.log('// ' + i + ': ' + fibonacci(i));
}
// 0: 0
// 1: 1
// 2: 1
// 3: 2
// 4: 3
// 5: 5
// 6: 8
// 7: 13
// 8: 21
// 9: 34
// 10: 55

这个函数可以工作,但做了很多无谓的工作。fibonacci函数被调用了453次。循环调用11次,而计算本身调用了442次去计算可能已被计算过的值。如果让该函数具有记忆功能,可以显著减少运算量。

可以通过闭包将结果隐藏存储在memo 数组中。当函数被调用时,这个函数首先检查结果是否已经存在,如果已经存在,就立即返回这个结果。

var fibonacci = function () {
  var memo = [0, 1];
  var fib = function (n) {
    var result = memo[n];
    if (typeof result !== 'number') {
      result = fib(n - 1) + fib(n - 2);
      memo[n] = result;
    }
    return result;
  };
  return fib;
} ();

这个函数返回同样的结果,但它只被调用了29次。调用它11次,它自己计算了18次去取得之前存储的结果。

可以把这种技术进一步推广,编写一个函数辅助构造带有记忆功能的函数。memoizer 函数取得一个初始的 memo 数组和formula 函数。它返回一个管理 memo 存储和在需要时调用formula 函数的 recur 函数:

var memoizer = function (memo, formula) {
  var recur = function (n) {
    var result = memo[n];
    if (typeof result !== 'number') {
      result = formula (recur, n);
      memo[n] = result;
    }
    return result;
  };
  return recur;
}

现在,可以使用 memoizer 函数来定义 fibonacci 函数,提供初始的 memo 数组和 formula 函数:

var fibonacci = memoizer([0, 1], function (recur, n) {
  return recur (n - 1) + recur (n - 2);
});

要产生一个可记忆的阶乘函数,只需要提供基本的阶乘公式即可:

var factorial = memoizer([1, 1], function (recur, n) {
  return n * recur (n - 1);
});

其实,这就是最基础的动态规划。有兴趣,可以看这篇 动态规划基础。

小结

JavaScript 的函数还不止于此,很多细节也没有讨论,也有一些没有提到,比如 ES6 中的箭头函数(lambda函数)、生成器函数和 promise 函数。很多细节(比如上下文)也没有提到,留到下一次吧。总之,要发挥出 JavaScript 函数强大的能力,一定要把 JavaScript 当作函数式语言。

想必你也发现了 JavaScript 先天不足,坑巨多,或许没得救了,TypeScript 才会流行起来,大有一番要取代 JavaScript 的感觉。

你可能感兴趣的:(前端,javascript)