JavaScript横向知识总结系列(3)一函数

152 阅读14分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

1.函数声明与调用

1.1常用声明方式

  • 函数通过关键字function来声明,function 函数名(参数列表){函数体}函数声明也会提升

如果实参的个数多余形参的个数,则多余的实参会被忽略;

如果实参的个数少于形参的个数,没有被传递实参的形参取默认值undefined


函数表达式(匿名函数)声明函数

函数实际上是对象(将在下文详细说明) , 每个函数都是Function 类型的实例 ,所以函数名就是指向函数对象的指针

因此,函数表达式就是创建一个函数,不赋予函数名,而是将该函数赋值给一个变量(变量保存的其实是指向该函数的指针)

语法:let 变量名 = function(参数列表){};,这个创建函数的方式与上面的普通函数声明其实是等价的


Function 构造函数

上面说过函数实际上是对象,因此函数可以通过构造函数的形式定义,这个构造函数接收任意多个字符串参数,最后一个参数始终会被当成函数体,而之前的参数都是新函数的参数

let sum = new Function("num1", "num2", "return num1 + num2"); // 不推荐

函数体想象为一个对象,把函数名想象为对这个函数进行引用的指针

1.2箭头函数

箭头函数为ES6定义的新语法,与函数表达式类似,只是将关键字function替换成=>

语法:let 变量名 = (参数列表) => {};

没有参数或只有一个参数不需要括号;

大括号可以省略:此时箭头后面就只能有一行代码(一个表达式),且会隐式返回这个表达式的值

箭头函数不能使用 argumentssuper new.target,也不能用作构造函数。此外,箭头函数也没有prototype属性

关于箭头函数更详细的用法将另写文章说明

2.函数参数

2.1arguments对象

函数内部有一个arguments对象,负责收集函数调用时传入的实参(注意:只有传入的实参才会被收集,也就是说默认参数的值不会被收集)

arguments是一个类数组对象,除了包含可索引的实参以外:

  • 还有callee属性指向该函数,使得我们可以通过callee来递归地调用函数(通过callee递归调用,即使函数改名也不用修改递归调用代码)
  • 还有一个迭代器,通过Symbol.iterator获得使得该类数组对象可以被迭代(比如使用for...of语句)

类数组对象指具有length属性,且可通过索引访问元素。

function func(n1, n2, a1, defaultArg='default') {
  console.log(defaultArg); //default
  console.log(arguments);
  //Arguments(3) [1, 2, 'a', callee: (...), Symbol(Symbol.iterator): ƒ]
  for(let i of arguments) console.log(i)
  //1
  //2
  //a
}
func(1, 2, 'a');

2.2默认参数

函数默认参数为ES6新增语法,函数在定义形参时可定义默认参数,当该参数没有收到实参时则被赋值为默认参数值。

function func(arg1, arg2, arg3 = arg1 + arg2) {
  console.log(arg1, arg2, arg3); 
}
func(1,2); //1 2 3

默认参数值可以是任意表达式(只要有返回值即可)。

可以把实参到形参的传递看做是在一块独立的作用域中执行代码,因此默认参数值的表达式可以引用之前的形参。如上代码arg3在计算默认参数值时就可以引用传入arg1arg2的1和2。

当默认参数被传入了实参时,其赋值表达式就不会执行,如下

let add = (n1, n2) => {
  console.log('执行了add');
  return n1 + n2;
}
function func(arg1, arg2, arg3 = add(arg1, arg2)) {
  console.log(arg1, arg2, arg3); 
}
func(1,2, 4); //1 2 4
func(1,2);
// 执行了add
// 1 2 3

2.2剩余参数

...既可以作为扩展运算符,也可以作为剩余运算符,用于函数形参列表中收集传入的多余实参。

function func(arg1, ...args) {
  console.log(arg1); // 1
  console.log(args); // 2 3 4
}
func(1,2,3,4);

可以看到,实参1被第一个形参arg1收集,剩余的实参2, 3, 4则都被args收集存入一个数组中。

需要注意的是,...args收集的是剩余的所有实参,因此该参数只能定义在形参列表的末尾。

由于箭头函数没有arguments对象,因此利用剩余参数可以模拟arguments对象

let func = (...args) => console.log(args);
func(1, 2, 3); // [1, 2, 3]

3.函数对象

从1.1节函数表达式也能声明函数可以看出,函数也是一个对象,可以赋给一个变量,当使用A()则表现除函数的特性,当使用A.b则表现出对象的特性。

每个对象都有其构造函数,函数对象的构造函数为Function,其原型上有一些属性和方法,包括length属性可以获得函数的形参数量,name属性可以获得函数定义时的函数名

function func(n1, n2) {};
let bar = func;
console.log(bar.length, bar.name); // 2 'func'

Function原型上还有三个比较重要的方法applycallbind,他们的作用都是为函数绑定新的this对象(关于this对象在第五节进行更详细说明),传入的第一个参数为要绑定的对象,当为null或者undefined时,则this的指向服从默认(隐式)绑定规则。三者的区别如下:

  1. 首先callapply都是以指定的this对象以及函数参数来调用函数,二者的区别为:
    • call需要分开传入要调用的函数的参数
    • apply你将要传入的参数放在一个数组中传入。
function add(n1, n2) {
    console.log(this.base + n1 + n2);
}
const obj = {
  base: 1
}
// call方法在Function构造函数原型上,因此通过 . 的方式调用
add.call(obj, 2, 3); // 6
add.apply(obj, [2, 3]); // 6
  1. bind与其的它两个的区别为:
    • 只为函数绑定this对象,但不执行函数,而是返回“包装”后的函数,从此该包装函数的每次调用的this都为之前所绑定的对象
    • bind绑定this对象的优先级最高,不会被callapply所修改
function add(n1, n2) {
    console.log(this.base + n1 + n2);
}
const obj1 = {
  base: 1
}
const obj2 = {
  base: 2
}
// call方法在Function构造函数原型上,因此通过 . 的方式调用
add = add.bind(obj1, 2, 3); 
add(); //6
//apply绑定obj2失效
add.apply(obj2, [2, 3]); // 6

4.作用域与闭包

JavaScript的变量值(或者数据)的存储不是杂乱的揉在一个大箱子里,而是分门别类的放在一个一个小盒子里,这些小盒子是由函数块或者ES6新增的代码块{}形成,它们就是作用域。小盒子与其它小盒子之间是隔离开的,但有一套规则使得这些小盒子关联起来,互相关联的盒子就形成了作用域链。当代码在执行时会被按照规则分配一个小盒子,称为代码的执行上下文,小盒子里的内容可以被随意访问,其它盒子里的内容需要沿着作用域链按规则访问。

在函数中,基于作用域规则的应用就是闭包。闭包的概念很简单,从表现形式来说就是如果一个函数A中引用了另一个函数的变量,那么这个函数A就是闭包。换句话说,闭包其实就是将作用域链的规则以一种更直接的方式给描述出来,关于闭包的应用将在日后编辑此文章添加。

关于作用域更详细内容请查看文章详解JavaScript作用域与闭包

5.this对象

this是一个变量,该变量不用我们定义,在函数体中可以直接访问到,该变量指向一个对象,具体指向哪个对象与函数的调用方式有关。例如:正常情况下,this指向全局对象。全局对象在浏览器中表现为window,在nodejs中为global,可通过globalThis访问。

//let a = 1; //注意:使用let关键字定义的变量不会放在window对象上
window.a = 1;
function b() {
  console.log(this.a);
}
b() // 1

JavaScript作用域是词法作用域,或者说一种静态的作用域,在编译时便能知晓。而this更像是一种动态的作用域,this最重要的一个特性是我们可以在函数内通过this访问到函数外的变量,且由于this指向的对象是可以变化的,因此能访问的变量也是变化的,this所指向对象的变化与函数调用方式有关。

5.1this指向问题

this 的指向可以改变,通过四种调用模式来判断。

  1. 函数调用模式:函数不是某个显式对象的属性,直接作为函数来调用,this指向window,全局上下文
var value = 10;
let obj = {
    value: 100,
    method: function() {
        let foo = function() {
            console.log(this.value); //10
            console.log(this); //window
        }
        foo();
        return this.value;//100,this指向调用它的对象
    }
    }
obj.method();
/*注意:全局value值一定要使用var定义,使用let定义的变量不会放在window对象中,无法通过
window.value访问到*/
//由于foo()函数没有显示调用它的对象,所以其this指向window对象。
  1. 方法调用模式:this指向调用它的对象
  2. 构造器调用模式:如果一个函数用 new 调用时,函数执行前会新创建一个对象,this 指向这个新创建的对象
  3. apply 、 call 和 bind 调用模式: 显示的指定调用函数的 this
  4. 箭头函数:箭头函数本身不具备this对象,它的this对象被绑定为定义时所处的上下文中的this对象。(不能由call、apply、bind改变)(一定要注意是定义时,如果是在一个函数中定义一个箭头函数,该函数未执行时就不算定义箭头函数,函数执行时才算)
var a = 'outer';

let obj = {
  a: 'inner',
  foo: () => {
    console.log(this.a);//outer
  },
}
obj.foo()
//foo箭头函数是在定义obj这个变量时定义的,此时上下文为全局上下文,所以this指向window

function printThis() {
  // 函数未执行,所以箭头函数还未定义
  return print = () => console.log(this);
}
printThis.call([1])(); //[1]
printThis.call([2])(); //[2]
//注意是定义时

虽然this指向规则不多,但还有许多弯弯绕绕的场景,需要多多练习才能熟悉


例题

var _name = 'global';
var obj = {
  func() {
    const innerFunc = () => {
      console.log(this._name);
    }
    return innerFunc;
  },
  _name: 'local',
}
obj.func()(); //local

var func = obj.func;
func()(); //global

obj.func.bind({_name: 'newObj'})()(); // newobj
//等价于 newO = obj.func.bind({_name: 'newObj'});newO()()

obj.func.bind()()(); //***** global *******
//bind没有传绑定对象时,指向原来的this,然后返回一个新函数,相当于全局函数

obj.func.bind({_name: 'bindObj'}).apply({_name: 'applyObj'})(); //***bindObj***
//this一旦被bind后,就不会再被call和apply修改

5.2绑定优先级

函数调用模式和方法调用模式又称为隐式绑定,apply 、 call 和 bind 调用模式又称为显式绑定。

  • 当隐式绑定和显示绑定同时存在时,显示绑定优先级更高,显示绑定生效
  • 当使用new关键字时 其优先级高于任何其它方式,也就是说函数的this仍然是创建的空对象
  • 箭头函数的this是不变的,任何方法都不能改变。
//---------------------------------------显示绑定
let obj1 = {
  a: '隐式绑定'
}
function func1() {
  console.log(this.a)
}
let obj2 = {
  a: '显式绑定'
}
obj1.func1 = func1;
obj1.func1.call(obj2) //' 显式绑定'
// --------------------------------------new

function func1() {
  this.a = 'new'
}
let obj = {}
func1 = func1.bind(obj)
let newObj = new func1();
console.log(newObj); //{'a': 'new'}
console.log(obj); //{}
//---------------------------------------箭头函数
let obj = {
  desc: 'func的this',
  func: function() {
      return () => console.log(this);
  }
}
obj.func()(); // {desc: 'func的this', func: ƒ} 

6.其它函数应用

JavaScript中的函数实现了高阶函数的能力,因此衍生出了许多应用,下面简要说明并在日后详细补充

6.1高阶函数

只要函数满足了下面两个条件中的任意一个,就可被称为高阶函数:

  • 函数自身可以被作为(函数调用的实参)参数来传递
  • 函数自身可以被作为(函数的)返回值输出

将函数作为参数来传递

一个函数可以编写一套逻辑的代码,如果这套逻辑中有变化的部分,那么将函数作为参数来传递使得我们可以将变化的部分也抽离出来写在一个函数当中,分离其变化与不变的部分。

比如常见的定时器是高阶函数,定时逻辑是不变的部分,定时完成后要执行的逻辑是变化的部分,因此通过回调函数的方式将这两部分分离。

比如数组中的APIArray.prototype.sortArray.prototype.map等也都是高阶函数。对于sort来说,将数组元素顺序调转是固定不变的部分,而使用什么规则来排序元素是变化的部分,也可以变化部分抽离成回调函数的方式。


将函数作为返回值输出

将函数作为返回值输出最常用的一个场景就是闭包:我们已经有一个函数或多个函数实现了不同的功能,现在我们想在这些函数的基础上添加一些共同的功能,这些功能依赖一些数据,但我们又不能直接修改这些函数的源代码为每个函数都添加一份数据与新功能代码,因此我们可以定义一个函数将这些函数包装,生成一个闭包实现新功能,并将实现新功能的函数返回

这个场景最常见的应用是节流, 如下所示

间隔多少毫秒后触发回调

function throttle(sec, func) {
    let timer = null;
    return function (...args){
      let that = this;
      if(!timer) {
        timer = setTimeout(() => {
          timer = null;  //func写在定时器的回调里执行就是每次触发都要等待sec才能执行
          func.appply(that, args);//func写在定时器的外面,则是只要与上一次执行差距sec则立即执行
        }, sec || 50)
      }; 
    };
};

关键点:1.注意节流的特征是,对于频繁触发的事件,每次触发必须间隔一段时间,如果已经有事件触发了,且时间小于阈值,则这次不触发。2. 因此与防抖不同的地方在于节流不会取消上一次触发事件的执行,且该次不会执行


高阶函数还有许多应用,如函数柯里化、函数式编程等等(当然也能把函数柯里化视作函数式编程的一部分)

6.2函数柯里化

curry 指的是:只传递给函数A(curry函数)一部分参数,该函数会返回一个新的函数B并通过闭包的方式记住传入函数A的参数,待到需要的时候才会利用这些参数进行计算。

如下的addCurry函数便是柯里化函数,返回一个新的函数,引用了传入的实参x。

function addCurry(x) {
  return function (y) {
    return x + y
  }
}
let addOne = addCurry(1);
console.log(addOne(2)); // 3
let addTwo = addCurry(2);
console.log(addTwo(2)); //4

curry函数还有一个更通用的写法,只要函数调用时传入参数(可传入任意多参数)便返回一个新的函数并保存参数,待到函数调用不传入参数时才执行具体的计算逻辑。

对于一个addcurry函数,可以实现如下效果

add(1)(2)(3)() = 6; 
add(1, 2, 3)(4)() = 10; 
add(1)(2)(3)(4)(5)() = 15;

具体实现如下:使用闭包存储所有的参数,并检测在传入参数为0个时计算返回和

function add() {
    let allArguments = [...arguments]
    return function close(...newArgs) {
      if(newArgs.length == 0) {
        let res = 0
        allArguments.forEach(v => res += v)
        return res
      } else {
        allArguments.push(...newArgs)
        return close
      }
    }
}
console.log(add(1,2,3)(4)(5)()); // 15

6.3函数式编程

函数式编程是一种编程范式,类似于常见的面向对象编程、面向过程编程等都是一种编程范式。

函数式编程指所有代码都由函数构成,且这些函数都是“纯函数”。

“纯函数”指的是只要入参相同,则函数不管调用多少次都会返回相同的结果(即相同的输入总会得到相同的输出),且函数的执行没有“副作用“。“副作用”指函数的执行不会对函数体之外的任何变量产生影响,包括入参。比如数组中的sort方法就会产生副作用,因为它会改变调用它的数组的元素的顺序。


函数式编程有自己独特的优势,但编写纯函数较为麻烦,为此我们需要许多工具来辅助实现纯函数,柯里化便是其中的一种。关于函数式编程更多的知识可访问如下链接深入了解

llh911001.gitbooks.io/mostly-adeq…