第五期:你了解JS的函数吗?

165 阅读17分钟

函数是什么

函数的定义:函数就是一个固定的程序段,有一个入口和一个出口。入口用于传参,出口用于返回结果。 说白了,函数就是语言提供的一个功能,可以把一段指定的代码包装起来,用于代码复用、信息隐藏和组合调度。

Function Object(函数对象)

JavaScript中的函数严格意义上不算是函数,它是一个对象:Function Object(函数对象)。是JavaScript中的头等对象,具有一切对象的特征,跟对象的主要区别就是可以被调用。这样我们就很容易理解JavaScript中的函数的特点了(作为参数、添加属性、调用方法等)。

函数对象与函数指针相比,有两个优点:

  1. 编译器可以内联执行函数对象的调用
  2. 函数对象内部可以保持状态。

函数定义

对象的内置函数DefineOwnProperty: DefineOwnProperty(p, desc, throw) 其功能与defineProperty()类似,throw参数表示是否抛出 TypeError 错误

函数声明

这是最常见的定义函数的方法,在编译阶段,函数声明会被优先提升(记录到VO中,连同函数内容一起),可以在定义前执行。

函数表达式

var demo = function(arg1, arg2) {
    Code…
}

函数表达式与函数声明的主要区别是将一个方法使用赋值语句给一个变量。这种定义方式函数不会被提升,无法前置执行。

箭头函数

ES6新增的箭头函数简化了函数定义的方式,简洁的方式是代码可读性更好,继承式的this提供更便利的操作。

var demo = (arg1, arg2) => {
    code...
}

函数对象的创建

当我们使用function关键字或者箭头函数的方式来定义一个函数时,实际上是创建了一个Function Object(函数对象)。 创建的流程如下:

  1. 声明几个变量:
    • FormalParameterList 形参列表(一个字符串)
    • FunctionBody 函数体
    • Scope 作用域(词法环境,执行上下文)
    • Strict 是否使用严谨模式
  2. 定义一个EcmaScript Object:F
  3. 设置除[[Get]]外的其他内置函数(跟正常的Object一样)
  4. 设置[[Class]] (原生类型)属性的为"Function"
  5. 将[[Prototype]]属性指向Function prototype(原生的Function对象的原型)
  6. 设置[[Call]]属性
  7. 设置[[Construct]]属性
  8. 设置属于函数的特殊内置函数[[Get]] 函数对象的[[Get]]函数跟普通对象的[[Get]]主要区别是:当获取函数对象的'caller'属性并且当前是严谨模式的时候,返回一个TypeError的错误
  9. 设置[[HasInstance]](返回一个对象是不是在这个对象的原型上)属性
  10. 设置[[Scope]]属性指向Scope
  11. 定义一个names的列表,从左往右解析FormalParameterList字符串,将形参存入names
  12. 将[[FormalParameters]]属性指向names
  13. 将[[Code]]属性设置为FunctionBody
  14. 将[[Extensible]](是否允许向当前对象添加属性)设置为true
  15. 定义一个变量len,为names的长度(形参的长度)
  16. 使用内置的[[DefineOwnProperty]]函数设置F的length属性:
    F.DefineOwnProperty('length', {
        value: len,
        witable: false,
        enumerable: false,
        configurable: false
    }, false);
  1. let proto = new Object()
  2. 设置proto的constructor属性:
    proto.DefineOwnProperty('constructor', {
        value: F,
        witable: true,
        enumerable: false,
        configurable: true
    }, false);
  1. 设置F的prototype属性:
    proto.DefineOwnProperty('prototype', {
        value: proto,
        witable: true,
        enumerable: false,
        configurable: false
    }, false);
  1. 如果是严谨模式(Strict === true):

    • let thrower = ThrowTypeError
    • 设置F的'caller'和'arguments'属性:
        F.DefineOwnProperty('caller', {
            get: thrower,
            set: thrower,
            enumerable: false,
            configurable: false
        }, false);
    
        F.DefineOwnProperty('arguments', {
            get: thrower,
            set: thrower,
            enumerable: false,
            configurable: false
        }, false);
    
  2. 返回F

函数对象的特殊性 函数对象有几个只属于函数的属性:

  • [[Code]]
  • [[Scope]]
  • [[HasInstance]]
  • [[Call]]
  • [[Construct]]

函数对象区别于普通对象的两个最特别的属性:[[Call]] [[Construct]]

[[Call]] 当改属性被调用的时候,就会运行与此对象关联的代码([[Code]])。当我们是使用(*)来运行一个函数的时候,实际上就是调用这个函数的[[Call]]方法

当使用一个参数列表和this值来调用[[Call]]的时候,会执行这几个步骤: 假定F是一个函数对象

  1. 为函数代码创建一个使用F的[[FormalParameters]]属性(形参列表)、传入的实参列表args和this值的执行上下文:funcCtx
  2. 获取FunctionBody的执行结果:result(FunctionBody为F的[[Code]]属性的值) 当F的[[Code]]不存在或者为空的时候,result=undefined(normal, undefined, empty)
  3. 退出当前的执行环境funcCtx,回到之前的执行环境
  4. 如果 result.type 是 throw 就 throw result.value
  5. 如果 result.type 是 return 就 return result.value
  6. 其他情况下 result.type 为 normal,return undefined

[[Construct]] 这个内置方法由构造器生成,当一个函数被当成构造函数使用的时候(使用new来生成一个对象的时候)这个属性会被使用。

当函数对象F的[[Construct]]方法时(传入的参数数量0到n),执行以下步骤:

  1. 创建一个新对象obj(ECMAScript object)
  2. 为obj设置对象所有的内置方法
  3. 设置obj的[[Class]]属性为"Object"
  4. 设置obj的[[Extensible]]属性为true
  5. 定义一个新对象proto,指向F的prototype属性
  6. 如果proto是一个对象,将obj的[[Prototype]]属性指向proto
  7. 如果proto不是一个对象,obj的[[Prototype]]属性指向Object.prototype
  8. 调用F的[[Call]]方法,将obj做为this值,并将传给[[Construct]]的参数列表传入,获取执行结果result
  9. 如果result是对象,返回result
  10. 返回obj

函数的参数

形参和实参

初学者容易把这两个指的是认为是同一个东西,但是它们还是有区别的(特别是在JavaScript中)。

  • 形参(形式参数) 指的是我们在定义函数的时候所列举的参数,没有实际的值,可以把它看做是一个占位符。
  • 实参(实际参数) 指的是在调用函数的时候传给函数的值,会被函数内部的代码使用。

针对JavaScript的特点,其实形参是在VO阶段被生成的,而实参是在AO阶段的,是对形参的一种赋值的手段。

var user = {};
// name、age这时候是形参
var initUser = function(name, age) {
    // 这时候对于setName函数,name就是实参
    setName(name);
    // 这时候对于setAge函数,age就是实参
    setAge(name);
}

// name这时候是形参
function setName(name) {
    user.name = name;
    console.log('user name is ' + name);
}

// age这时候是形参
var setAge = age => {
    user.age = age;
    console.log('user age is ' + age);
}

// sk和30就是实参
initUSer('sk', 30);

JavaScript的参数的特点就是形参的个数和实参的个数是可以不一致的(大部分其他语言都是会报错的,除非设置了默认参数)

  • 形参数量大于实参时:按照顺序将实参赋值给形参,剩余的参数为undefined
  • 形参数量小于实参时:按照顺序将实参赋值给形参,剩余的实参在非箭头函数中可以通过arguments变量来获取。

arguments

除了箭头函数外,其他的函数都可以使用arguments变量来获取所有的实参。 值得注意的是arguments并不是一个数组,它是一个类数组,除了length属性外,没有其他数组的方法可以使用。

function demo(a, b, c) {
  console.log(arguments);
  console.log(arguments.length);
  arguments.indexOf('a'); // Uncaught TypeError: arguments.indexOf is not a function
}

demo(1, 2, 3, 4, 5, "a", "b", "c");
/* arguments:
    0: 1
    1: 2
    2: 3
    3: 4
    4: 5
    5: "a"
    6: "b"
    7: "c"
    callee: ƒ demo(a, b, c)
    length: 8
    Symbol(Symbol.iterator): ƒ values()
*/

修改arguments 在非严谨模式下: arguments跟参数其实是绑定的,修改arguments或者修改传入实参,都会产生影响。不管传入的是基础类型还是复杂类型。 在严谨模式下,则互不影响

function demo(a, b, c) {
  arguments[0] = 9;
  console.log(a); // 9
  b.d = "abc";
  console.log(arguments[1]); // { k: 2, d: 'abc' }
  arguments[2] = { empty: [], arr: [3, 4, 5] };
  console.log(c); // { empty: [], arr: [ 3, 4, 5 ] }
}

demo(1, { k: 2 }, [1, 2, 4], 4, 5, "a", "b", "c");

function demo2(a, b, c) {
  var a = 22;
  console.log(arguments[0]);
}

demo2(1, 2, 3, 4, 5, "a", "b", "c"); // 22

function demo3(a, b, c) {
  'use strict'
  a = 22;
  arguments[1] = 0;
  console.log(arguments[0]);
  console.log(a);
  console.log(arguments[1]);
  console.log(b);
}

demo3(1, 2, 3, 4, 5, "a", "b", "c"); // 1, 22, 0, 2

那么这两者怎么实现绑定呢?

arguments object 当执行控制流进入函数作用域的时候,会创建属于这个函数的参数对象(arguments object): arguments 但是不是所有情况都会创建这个对象:

  • arguments被单做参数名
  • 函数作用域中有名为arguments的变量
function demo1(arguments) {
  console.log(arguments);
  console.log(arguments[0]);
}

demo1(1, 2, 3, 4, 5, "a", "b", "c"); // 1、undefined

function demo2(a, b, c) {
  var arguments = a;
  console.log(arguments);
  console.log(arguments[0]);
}

demo2(1, 2, 3, 4, 5, "a", "b", "c"); // 1、undefined

参数对象是使用一个内部的抽象函数CreateArgumentsObject来创建的,调用的前提是:对应函数的代码是合法(代码是可执行的)

创建的流程如下:

  1. 设置变量:names:形参的名称列表,args:传递给函数的实参列表,env:当前函数的环境变量(作用域),strict:表示是否是严格模式(true/false)
  2. 获取args的长度len
  3. 创建一个新的native对象:objobj = new Object(),为其设置所有对象共有的内置方法
  4. 设置obj的[[Class]]属性设置为Arguments
  5. 设置Object为标准的内置Object的constructor,这样后面所有的Object的constructor属性都可会指向Object
  6. obj的[[Prototype]]属性设置为Object prototype
  7. 使用内部函数[[DefineOwnProperty]]为obj设置length属性:
    obj.DefineOwnProperty(
       'length',
       {
       value: len, // 值为len
       writable: true, // 可写
       enumerable: false, // 不可枚举
       configurable: true // 可配置
       }, false); // false表示不抛出错误
    
  8. 通过new Object()的方式创建新对象map
  9. 定义一个空的列表(内置的一种类型)mappedNames
  10. 开始遍历args,将参数放入obj中,设置对应的属性:
    • let index = len - 1
    • 在index >=0 时重复以下步骤:
      • 设置临时变量val,并让其等于args的第index位的元素
      • 使用内部函数[[DefineOwnProperty]]为obj设置ToString(index)属性:
            obj.DefineOwnProperty(
                ToString(index),
                {
                    value: val, // 值为val
                    writable: true, // 可写
                    enumerable: true, // 可枚举
                    configurable: true // 可配置
                },
                false // false表示不抛出错误
            );
        
      • 如果index小于names的长度,执行以下操作:
        • 设置namenames的第index位的元素
        • 如果是非严谨模式并且name不在列表mappedNames中(防止覆盖)
          • 为列表mappedNames添加元素name

          • 定义gMakeArgGetter(name, env)的操作结果(MakeArgGetter为内置的抽象方法,后面解析)

          • 定义pMakeArgSetter(name, env)的操作结果(MakeArgSetter为内置的抽象方法,后面解析)

          • 使用内部函数[[DefineOwnProperty]]为map设置属性ToString(index):

                map.DefineOwnProperty(
                    ToString(index),
                    {
                        get: g,
                        set: p,
                        configurable: true // 可配置
                    },
                    false // false表示不抛出错误
                );
            
    • index = index - 1
  11. 如果mappedNames不为空,执行以下操作:
    • 设置obj的[[ParameterMap]]属性为map[[ParameterMap]]是argument object独有的属性,该属性是参数对象与形参的映射
    • 设置obj的[[Get]], [[GetOwnProperty]], [[DefineOwnProperty]]和[[Delete]]属性(后续解析这些属性的方法)
  12. 严谨模式下,设置objcallercallee为不可访问([[Get]]和[[Set]]均返回错误)
  13. 非严谨模式下,设置objcallee指向当前的执行函数,使用内部函数[[DefineOwnProperty]]来设置这个属性:
    obj.DefineOwnProperty(
        'callee',
        {
            writable: false, // 不可写
            enumerable: false, // 不可枚举
            configurable: true // 可配置
        },
        false // false表示不抛出错误
    )
    
  14. 返回obj

实现的代码如下:(非js代码,用来描述整个流程)

let func = currentFunction; // 指向当前的执行函数
/*
* names: 形参的名称列表
* args: 传递给函数的实参列表
* env: 当前函数的环境变量(作用域)
* strict: 表示是否是严格模式(true/false)
*/
function CreateArgumentsObject(names, args, env, strict) {
    // 实参的数量
    let len = sizeof(args);
    // arguments对象
    let obj = new Object();
    // 设置length属性,不抛出错误
    obj.DefineOwnProperty('length', {
       value: len,
       writable: true,
       enumerable: false,
       configurable: true
    }, false);
    // 定义map对象,用于绑定形参和arguments
    let map = new Object();
    // 定义空列表,存放已设置的形参名称
    let mappingNames = new List();
    // 获取最大的下标
    let indx = len - 1;

    // 开始遍历
    while(indx >= 0) {
        // 获取当前的实参
        let val = args[indx];
        // 设置当前的下标的值,小标必须是字符串:(ToString(indx)
        obj.DefineOwnProperty(ToString(indx), {
            value: val,
            writable: true,  // 可写
            enumerable: true, // 可枚举
            configurable: true // 可配置
        }, false); // 不抛出错误

        // 如果当前下标小于形参长度
        if (indx < sizeof(names)) {
            // 获取对应形参的名称
            let name = names[indx];

            // 非严谨模式下,并且形参名称不在已设置的列表中
            if (strict === false && !~mappingNames.indexOf(name)) {
                // 将形参添加到已设置的形参列表
                mappingNames.push(name);
                // 将形参和环境变量挂钩
                // 生成get方法
                let g = MakeArgGetter(name, env);
                // 生成set方法
                let p = MakeArgSetter(name, env);

                // 设置当前下标的get和set
                map.DefineOwnProperty(ToString(indx), {
                    Set: p,
                    Get: g,
                    Configurable: true
                }, false);
            }
        }

        indx = indx - 1;
    }

    // 如果已设置的形参不为空
    if (!empty(mappedNames)) {
        // 设置[[ParameterMap]]属性为map
        obj['[[ParameterMap]]'] = map;
        // 设置obj的内置方法:[[Get]], [[GetOwnProperty]], [[DefineOwnProperty]], [[Delete]]
    }

    // 非严谨模式
    if (strict === false) {
        // callee属性指向当前执行的函数
        obj.DefineOwnProperty('callee', {
            value: func,
            writable: true,
            enumerable: false,
            configurable: true
        }, false);
    } else {
        // 严谨模式下设置caller和callee为错误
        let thrower = new ThrowTypeError();
        obj.DefineOwnProperty('caller', {
            get: thrower,
            set: thrower,
            enumerable: false,
            configurable: false
        }, false);
         obj.DefineOwnProperty('callee', {
            get: thrower,
            set: thrower,
            enumerable: false,
            configurable: false
        }, false);
    }

    return obj;
}

MakeArgGetter 抽象操作MakeArgGetter在被调用时会通过传入一个字符串name和对应的环境记录(上下文)env创建一个函数对象(function),这个函数对象执行的时候会返回envname绑定的值,该方法的执行步骤如下:

  • 设置body为字符串return ${name};
  • 返回一个没有正式参数列表(FormalParameterList)的方法,其中body为函数体(FunctionBody),env为作用域(Scope),使用严谨模式(Strict:true)

MakeArgSetter 抽象操作MakeArgSetter在被调用时会通过传入一个字符串name和对应的环境记录(上下文)env创建一个函数对象(function),这个函数对象执行是会将传入的值赋给env中的name,该方法的执行步骤如下:

  • 定义变量param赋值为字符串${name}_arg
  • 设置body${name}=${param}(将name的值赋为param的值:env.name=param)
  • 返回一个参数为param,函数体为body,作用域为env,使用严谨模式的方法

非严谨模式下arguments与形参的绑定 在非严谨模式下,arguments对象的属性值的[[Get]]和[[Set]]方法都是使用抽象操作生成的,从上面的两个抽象操作可以看出arguments的属性值是与环境中对应的位置(小标)的形参的绑定关系,在操作这些绑定的参数事,不管是get还是set实际上都是在操作当前函数的形参。

注意: arguments.caller已被废弃

剩余参数

剩余参数的语法允许将不定数量的参数通过一个数组来表示,如果函数的最后一个参数...开头的话,它会将剩余的参数组成一个数组。

// args为剩余参数数组
function demo(a, ...args) {
    console.log(args.length);
}

demo(1, 2, 3, 4, 5, 6); // 4

注意: 剩余参数必须是最后一个参数,这个语法放在任何一个形参前面都会报错。

为了方便获取或者获取指定长度的参数,我们可以通过解构的方式来获取剩余参数

function demo(...[a, b, c]) {
    return a * b * c;
}

demo(0); // NaN
demo(2, 2); // NaN
demo(2, 2, 2); // 8
demo(2, 2, 2, 2); // 8

默认参数

ES6新增了默认参数,个人觉得这是ES6最伟大的改进之一,终于可以不用这么死板的写法了:

function demo(val) {
    val = val || 1;
    val = (typeof val !== 'undefined') ? val : 1;
}

第一种写法其实有挺多隐患的,如果传入的值是非(0,null,false)都会被转成1,这就需要根据实际的要求去修改默认赋值的方式。 第二种没有什么大问题,利用参数的默认值为undefined的特性,但是这串代码看起来就不怎么优雅。 当你的函数有多个参数需要设置默认值,你就头疼了。代码的可读性会变得很差。

默认参数允许在定义参数的时候为参数赋一个默认值,如果调用函数的时候没有为这个参数赋值,则使用默认值。


function demo(val = 1) {
    console.log(val);
}

demo(); // 1

函数使用默认值的条件: 当传入的对应参数的值为undefined或者是为传入使用系统默认值undefined的时候(就是参数的值为undefined的时候)。

function demo(val = 1) {
    console.log(val);
}

demo(); // 1
demo(undefined); // 1

注意: 参数的默认值是不会写入arguments中的

function demo(val = 1) {
    console.log(arguments[0]);
}

demo(); // undefined
demo(2); // 2

函数的调用

我们已经知道函数的调用其实就是调用函数对象的[[Call]]方法。 由上面的[[Call]]方法的执行流程可以知道,当函数被调用的时候,会暂停当前的执行流程(暂停当前作用域的执行),将控制权和参数传递给将要执行函数,这个函数除了接收实参之外,还会接收一个附加参数:this。而this的值取决于函数的调用方式。

来看看函数调用的方式(调用运算符是:()):

  1. 直接调用:demo()
  2. 作为方法被调用:demo.demo()
  3. 作为构造函数:new demo()
  4. 使用call或者apply:demo.apply(null); demo.call(null)

直接调用

直接调用的时候,this会被绑定到全局变量上。

var type = 'function';
function demo() {
    console.log('this is a ' + this.type);
}

demo(); // this is a function

(function() {
    this.type = 'lambda function';
    demo(); // this is a function
})();

var value = 200;
function data() {

  this.val = 300;
  let value = 100;

  function val() {
    console.log(this);
    console.log(this.value);
    console.log(this.val);
  }
  val();
}

data(); // window、200、300

箭头函数也不例外:

var val = 200;
const a = () => {
    console.log(this.val);
}

a(); // 200

function b() {
   this.val = 300;
   a();
}

b(); // 300

作为方法被调用

当函数是对象的一个属性时,我们称这属性为方法。我们使用.运算符或者是[value]值访问的方式来调用函数时,该函数被作为方法调用。这时候函数的this会默认指向该对象。

var name = 'shark';

var call = function() {
    console.log('user name is ' + this.name);
}

var user = {
    name: 'chenshuo',
    call: call
}

call(); // user name is shark
user.call(); // user name is chenshuo

但是箭头函数是不一样的,箭头函数的this是指向定义时的上下文,而且在对象中如果使用key: () => { functionbody }的方式来定义的话,这个方法是指向全局对象。

var name = 'shark';

var call = () => {
    console.log('user name is ' + this.name);
};

var user = {
    name: 'chenshuo',
    call: call,
    showName: () => { // 箭头函数属性
        console.log('show name: ' + this.name);
    },
    callName() { // 相当于className: function(){....}
        console.log('call name: ' + this.name);
    }
};

call(); // user name is shark
user.call(); // user name is shark
user.showName(); // show name: shark
user.callName(); // call name: chenshuo

var person = {
    name: 'skk',
    showName: user.showName
};

person.showName(); // show name: shark

可以看到user.call方法的this也是指向全局的,因为如果箭头函数被非箭头函数的函数包含,则this指向的是最近一层非箭头函数的this;否则,this的值会被设置为全局对象。也就是箭头函数的this在定义的时候确认,指向离它最近的一级作用域的this。(JavaScript中除了全局作用域就是函数作用域)

var name = 'skk';

function user(name) {
    this.name = name;
    return {
        call: () => {
            console.log(this.name);
        }
    }
}

user('cs').call(); // cs

作为构造函数

与其他语言最大的区别是:JavaScript的构造函数就是普通函数(非箭头函数,箭头函数被当成构造函数使用时会报错), 只要使用new关键字来调用函数,这个函数就算一个构造函数了。

function User(name, age) {
    this.name = name;
    this.age = age;

    this.call = function() {
        console.log('my name is ' + this.name);
    }
}

var chens = new User('chens', 20);
var sk = new User('sk', 30);

c.call(); // c
sk.call(); // sk

当一个函数被当成构造函数来调用,这个构造函数会返回一个新的对象: 如果这个构造函数有返回值,返回的是复杂类型,那么返回这个复杂类型,如果不是就返回一个新的对象。

var arr = [1,2,3,4,5];
function User1(name, age) {
    this.name = name;
    this.age = age;

    this.call = function() {
        console.log('my name is ' + this.name);
    }

    return arr;
}

function User2(name, age) {
    this.name = name;
    this.age = age;

    this.call = function() {
        console.log('my name is ' + this.name);
    }

    return 1;
}


var chens = new User1('chens', 20);
console.log(chens); // [1,2,3,4,5]
var sk = new User2('sk', 30);
console.log(sk); // User2 {name: "sk", age: 30, call: ƒ}

apply和call的方式

apply和call提供了一个为函数指定执行上下文的方法。这两个是函数对象的内置方法。

function showInfo(age, from) {
    console.log(`name:${this.name},age:${age},from:${from}`);
}
showInfo(30, '不知道'); // name:,age:30,from:不知道

var cs = {
    name: 'cs',
    age: 20,
    from: 'guangzhou'
};

var sk = {
    name: 'sk',
    age: 28,
    from: 'shenzhen'
};
showInfo.call(cs, cs.age, cs.from); // name:cs,age:20,from:guangzhou
showInfo.apply(sk, [sk.age, sk.from]); // name:cs,age:20,from:shenzhen

两个方法的使用方式都差不多,第一个参数都是指定的上下文对象,如果传入的不是一个对象,会默认指向window。后续的参数都是要传给被调用的函数的参数,两者的区别就在:apply传入的是一个参数数组,call是按一个一个参数传入的。

总结

本文对JavaScript的函数的基础知识做了一些总结,讨论了下Ecmascript对函数的实现规范。希望对大家了解函数有帮助