js 函数

85 阅读10分钟

执行环境、作用域链、以及 javascript 中的玄学“闭包”。在原型一节中提到了 javascript 的内置对象 Function,你是否了解该对象的用法,是否见过 new Function(params) ?

一 定义函数及变量、函数提升

定义一个函数常见的有两种方式:

1. 函数声明

function fn(){}

该方式的一个重要特征是 函数声明提升,并且函数整体得到提升,在声明之前就可调用执行。

console.log(fn); // fn(){}
function fn(){}

2. 函数表达式

var fn = function(){}

该方式实际是把一个 匿名函数 赋值给一个变量。变量也会提升,但只是声明提升,变量值保留在原处。

console.log(fn); // undefined
var fn = function(){}

js 解析有预编译期和执行期,在预编译期会对所有声明的变量和函数进行处理,将它们提升到自己作用域的顶部。对于普通变量而言,仅仅将声明提升,对于变量的值留在原处;而对于函数(具名函数),则将整个函数体一起提升。

注:通过 new Function() 也可以创建一个函数,该方式在本文最后讲。

二 参数传递

基本类型数据保存在内,引用类型数据保存在内,引用类型的复制实际指向同一个对象,但是当引用类型的数据作为一个参数传给函数的时候,是如何传递的?

传递引用类型的参数时,会把这个值在内存中的地址复制给一个局部变量,这个局部变量指向了中的引用,因此这个局部变量的变化会引起全局的变化,跟一般的引用类型复制一样。

function setName(obj) {
    obj.name = 'AAA';
}
var person = new Object();
setName(person);
console.log(person.name); // 'AAA'

函数的参数都是按值传递的,而不是按引用传递,如果在函数内部改变参数的引用,参数便变成一个局部变量,函数执行完之后销毁

function setName(obj) {
    obj.name = 'AAA';
    obj = new Object(); // 原始的引用并未改变,当赋给 obj 另一个引用时,obj 成了一个局部变量,函数执行完之后销毁。
    obj.name = 'BBB';
}
var person = new Object();
setName(person);
console.log(person.name); // 'AAA'

三 执行环境

js 执行环境分为“全局执行环境”和“局部执行环境”,全局执行环境一般为 window 对象, 函数的执行环境都是局部执行环境。执行环境也就相当于作用域

执行环境中定义的所有变量和函数都保存在变量对象中,当执行一个函数时,该函数执行环境中的变量和子函数都保存在活动对象中(此时活动对象即为变量对象)。

当代码在一个环境中执行时,会创建变量对象的作用域链,其用途是保证对执行环境有权访问的所有变量和函数的有序访问,作用域链的前端始终都是当前执行代码所在环境的变量对象,作用域链的下一个变量对象是其包含环境的变量对象。

调用一个函数时,函数的环境会被推入一个环境栈中,函数作用域被创建,函数执行完毕,环境销毁,函数作用域也被销毁。

var color = 'white';
function fn1(){
    var age = 20;
    function fn2(){
        console.log(color); // white
    }
}

fn2 的执行环境中并没有 color 变量,便会按照作用域链向上找,首先会进入 fn1 的执行环境,如果找不到继续向上查找,直至到 window 全局执行环境,如果在 window 全局执行环境中也找不到可能会报错(Uncaught ReferenceError: color is not defined)。对于 fn2 来说,其作用域链包含3个对象:fn2 本身变量对象,fn1 变量对象和全局变量对象。

每个环境都可以向上搜索作用域链,以查询变量和函数名;但任何环境都不能通过向下搜索作用域链。

/**
 * 当bind()执行完毕后,i=3;
 * 执行buttons元素中的函数时,函数内并没有i变量,顺着作用域链向上找,找到的i=3,所以输出都是3
 */
var buttons = [{}, {}, {}];
function bind(){
    for(var i=0; i<buttons.length; i++){
        buttons[i].fun = function(){
            console.log(i);
        }
    }
}
bind();
buttons[0].fun(); // 3
buttons[1].fun(); // 3
buttons[2].fun(); // 3

四 闭包

何为闭包?《javascript高级程序设计》中给的定义是:能够读取其他函数内部变量的函数。

根据上述作用域链原理,在如下代码中,函数 fn2 就是闭包。

var scope = 'global-scope';
function fn1(){
    var scope = 'local-scope';
    var age = 20;
    function fn2() {
        console.log(age++, scope); 
    }
    return fn2();
}
fn1(); // 20  local-scope

当调用函数时闭包(此处指 fn2)所指向的作用域链和定义函数时的作用域链不是同一个作用域链时,结果会有什么不同呢?

var scope = 'global scope';
function fn1(){
    var scope = 'local scope';
    var age = 20;
    function fn2() {
        console.log(age++, scope);
    }
    return fn2; // 此处与上处不同
}
fn1()(); // 20  local-scope

fn1()() 直接调用的实际是 fn2(),似乎与 fn1 没有关系,然而输出的值仍是 fn1 中的局部变量。

再来看另一种情况,作用域链只能向上搜索不能向下搜索,有没有方法在函数外部访问函数内部的变量呢

function counter() {
    var n = 0;
    return {
        count: function(){ return n++; },
        reset: function(){ n = 0; }
    }
}
var c = counter(), d = counter();
c.count(); // 0
d.count(); // 0
c.reset();
c.count(); // 0
d.count(); // 1

如上,将内部函数 count() 和 reset() 作为 counter() 的返回值,这样在 counter() 外部就可以访问 counter 的内部变量 n,并且可以修改内部变量,count() 和 reset() 共享同一个作用域链。每创建一个实例,就会创建一个新的作用域链和新的私有变量,因此 c 实例和 d 实例互不干扰。

一个函数在执行完毕后,执行环境就会被销毁,函数作用域和局部的活动对象自然也就销毁了,会被当做垃圾回收掉,内存中仅保存全局作用域。

但是,如果存在嵌套函数(即闭包),如上例,count 的作用域链包含了 counter 的活动对象和全局变量对象,counter 在执行完毕后,counter 执行环境的作用域链会被销毁(《javascript高级程序设计》如是说,但是《javascript权威指南》说作用域链并没销毁,也是是指count的作用域链没被销毁),但由于 count 的引用,counter 的活动对象不会被销毁,继续存在于内存中,当执行到 d.count() 时,counter 的活动对象变化了,再次调用时,局部变量 n 不是原来的值了。

闭包最大用处有两个:读取函数内部变量;把某些变量的值保持在内存中。

使用闭包要注意的问题:

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

以上,所谓闭包实际就是一个函数,但关联及作用域及作用域链,因此也有这种说法:一个函数以及其能访问到的变量共同组成的环境,称之为闭包。

五 立即执行函数(IIFE)

IIFE 的特点是,不会产生任何全局变量,从而避免污染全局变量,但无法重复执行。对于插件来说,是非常合适的,不会产生任何全局变量,同时一次引用就够了。

一般的匿名函数及调用:

var f = function(param){
 console.log("f", param);
}
f('1'); // 调用

f 实际上是一个变量,将变量用函数体替换后:

// 最后的()实际是调用, 如同一般函数一样,()中是可以传值的
function(param){
 console.log("f", param);
}('1');

这种方式实际会报错。Javascript 引擎看到 function 关键字之后,认为后面跟的是函数声明语句,不应该以圆括号结尾;为了让 Javascript 引擎认为这是一个表达式而不是函数声明,一般会把函数体用 () 包裹起来:

// 最后的()实际是调用
(function(param){
 console.log("f", param);
})('1');

jQuery 方法扩展:

// 最后的()实际是调用
(function($){
 $.fn.echarts = function(){
     // code
 }
})(jQuery);

$.fn是指jQuery的命名空间,实际上会在 jQuery 的原型上添加方法,在 jQuery 的环境中,可以重复调用,而且避免了污染其他变量。

六 call / apply / bind

三者的共同点是可以改变函数体内的 this 指向。 call 与 apply 的区别在于,call 可以接受多个参数,用逗号分开,而 apply 接受一个数组形式的参数。 bind 接收的参数与 call 相同,而与两者的区别在于,bind 返回一个函数,不会立即执行。 示例:

var name = 'Jack', age = 20;
var otherPerson = {
    name: 'Tom',
    age: 25
};
var obj = {
    name: 'John',
    age: this.age,
    objFun: function(a, b) {
        console.log(this.name + ',' + this.age + '岁,来自' + a + '去往' + b);
    }
}

obj.objFun.call(null, '北京', '上海'); // Jack,20岁,来自北京去往上海
obj.objFun.call(otherPerson, '北京', '上海'); // Tom,25岁,来自北京去往上海
obj.objFun.apply(otherPerson, ['北京', '上海']); // Tom,25岁,来自北京去往上海
obj.objFun.bind(otherPerson, '北京', '上海')(); // Tom,25岁,来自北京去往上海

七 Function

通过 Function 构造函数的方式定义一个函数的语法如下:

let func = new Function ([arg1, arg2, ...argN], functionBody);

生成一个匿名函数,然后赋值给 func。其中 arg1, arg2, ...argN 是可选的,为匿名函数的参数, functionBody 为匿名的函数体。 new Function() 里的参数,最后一个总为生成的函数的函数体。

let sum = new Function('a', 'b', 'return a + b');
// 相当于
let sum = function(a, b) { return a + b;}

sum(1, 2); // 3

// 没有参数的情况
let fun = new Function('console.log("hello Function")');
// 相当于
let fun = function(){ console.log("hello Function") }

与我们已知的其他方法相比,这种方法最大的不同在于,它实际上是通过运行时通过参数传递过来的字符串创建的。它的应用场景非常特殊,比如在复杂的 Web 应用程序中,我们需要从服务器获取代码或者动态地从模板编译函数时才会使用。

在闭包中的应用

由 new Function() 生成的函数,其词法作用域指向全局执行环境,而非局部执行环境,所以在闭包中使用它时要格外注意。

function getFunc() {
  let value = "test";
  let func = new Function('console.log(value)'); // 此处的变量指向全局环境的变量,而非局部变量
  return func;
}

getFunc()(); // Uncaught ReferenceError: value is not defined

发布前代码压缩的问题

在将 javaScript 代码发布到生产环境之前,很多时候需要使用 压缩程序(minifier) 对其进行压缩,假设一个函数有 let userName,压缩程序把它替换为 let a,因为新函数的创建及执行发生在代码压缩以后,变量名已经被替换了,此时很有可能无法找到重命名的 userName。

因此向 new Function 创建出的新函数传递数据时,必须以显式传参的方式进行参数传递。

此外,new Function 在架构上很差并且容易出错,但这也不妨碍在某些情况下它很有用。


参考: 《javascript高级程序设计》 《javascript权威指南》 阮一峰日志 JavascriptInfo