欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

javascript函数全解

程序员文章站 2022-07-03 10:33:34
...

0.0 概述

本文总结了js中函数相关的大部分用法,对函数用法不是特别清晰的同学可以了解一下。

1.0 简介

同其他语言不同的是,js中的函数有2种含义。

普通函数:同其他语言的函数一样,是用于封装语句块,执行多行语句的语法结构。

构造函数:不要把它当作函数,把它当作class,内部可以使用this表示当前对象。

【注】后续代码基于ES6&ES7标准,笔者是在nodejs v10.7.0环境下运行(你也可以选择其他支持ES6的node版本)。

1.1 函数的声明

虽然普通函数和构造函数,含义有所不同,可是声明方法却完全一样。

1.1.0 函数声明

function sort(arr) {
    let ret = [...arr];
    let length = ret.length;
    for (let i = 0; i < length; i++) {
        for (let j = i + 1; j < length; j++) {
            if (ret[i] > ret[j]) {
                [ret[j], ret[i]] = [ret[i], ret[j]];
            }
        }
    }
    return ret;
}
复制代码

1.1.1 函数表达式

let sort = function (arr) {
    let ret = [...arr];
    ...
    ...
    return ret;
}
复制代码

函数表达式和普通函数声明的区别在于,普通函数声明会提升,函数表达式不会提升

“提升”的意思是说: 在函数声明前就可以调用这个函数。不必先声明后调用。

js会在运行时,将文件内所有的函数声明,都提升到文件最顶部,这样你可以在代码任意位置访问这个函数。

而现在根据ES6标准,使用var修饰的函数表达式会提升,使用let修饰的则不会提升。

1.1.2 使用Function构造函数声明

let sort = new Function("arr", `
    function sort(arr) {
        let ret = [...arr];
        let length = ret.length;
        for (let i = 0; i < length; i++) {
            for (let j = i + 1; j < length; j++) {
                if (ret[i] > ret[j]) {
                    [ret[j], ret[i]] = [ret[i], ret[j]];
                }
            }
        }
        return ret;
    }
 `);
复制代码

这种使用Function构造方法创建的函数,同函数声明产生的函数是完全相同的。

构造函数接收多个字符串作为参数,最后一个参数表示函数体,其他参数表示参数名

像上面这个例子和1.1.0中的声明完全相同。

这种声明方式,没有发现有什么优点,并不推荐使用。

1.2 闭包

闭包,简单说就是在函数中声明的函数,也就是嵌套函数。它能够延长父作用域部分变量的生命周期。

闭包可以直接使用其所在函数的任何变量,这种使用是引用传递,而不是值传递,这一点很重要。

let f = function generator() {
    let arr = [1, 2, 3, 4, 5, 6, 7];
    let idx = 0;
    return {
        next() {
            if (idx >= arr.length) {
                return { done: true };
            } else {
                return { done: false, value: arr[idx++] };
            }
        }
    }
}
let gen = f();
for (let i = 0; i < 10; i++) {
    console.log(gen.next());
}

复制代码

上面的代码中,generator函数中的闭包next()可直接访问并修改所在函数中的变量arridx

一般说来,闭包需要实现尾递归优化。

尾递归是指,如果一个函数,它的最后一行代码是一个闭包的时候,会在函数返回时,释放父函数的栈空间。

这样一来,依赖闭包的递归函数就不怕栈溢出了(nodejs在64位机器上可达到1万多层的递归才会溢出,有可能是根据内存情况动态计算的)。

ES6明确要求支持尾递归。

而据网络上资料说,nodejs需要在严格模式下,使用--harmony选项,可以开启尾递归。

然而我使用下列代码发现,并没有开启(nodejs版本为v10.3.0)。


// File: test.js
// Run: node --harmony test.js

"use strict"

function add(n, sum) {
    if (n == 0) {
        console.trace();
        return sum;
    } else {
        return add(n - 1, sum + n);
    }
}
console.log(add(10, 0));
/*
输出为:
Trace
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:5:11)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
    at add (/Users/hongyuwang/Desktop/javascript/learn/learn.js:8:10)
55
*/

复制代码

1.3 匿名函数

我们经常在js的代码中看见下面这种写法:

(function(){
	...
	...
	...
})();
复制代码

将一个匿名函数直接执行,如果刚接触js的同学可能觉得这是脱裤子放屁。

但是这个匿名函数的最大作用在于作用域隔离,不污染全局作用域。

如果没有匿名函数包裹,代码中声明的所有变量都会出现在全局作用域中,造成不必要的变量覆盖麻烦和性能上的损失。

ES6中这种写法可以抛弃了,因为ES6引入了块作用域

{
	...
	...
	...
}

复制代码

作用和上面的匿名函数相同。

另外ES6中增加了一种匿名函数的写法:

//ES6以前的写法
function Teacher(name){
	this.name = name;
	var self = this;
	setTimeout(function(){
		console.log('Teacher.name = ' + self.name);
	}, 3000);
}

//现在这样写
function Student(name){
	this.name = name;
	setTimeout(() => {
		console.log('Student.name = ' + this.name);
	}, 3000);
}

复制代码

新的匿名函数的在写法上有2处不同:

  • 去掉了function关键字
  • 在参数列表和函数体之间增加了=>符号

而它也带来了一个巨大的好处:

匿名函数中的this对象总是指向声明时所在的作用域的this,不再指向调用时候的this对象了。

这样我们就可以像上面的例子那样,很直观地使用this,不用担心出现任何问题。

所以比较强烈推荐使用新的匿名函数写法。

1.4 构造函数和this

1.4.1 基本面向对象语法

下面来介绍构造函数,js没有传统面向对象的语法,但是它可以使用函数来模拟。

了解js面向对象机制之前,可以先看一下,其他标准面向对象语言的写法,比如java,我们声明一个类。

class Person{
	//构造函数
	Person(String name, int age){
		this.name = name;
		this.age = age;
		Person.count++;
	}
	//属性
	String name;
	int age;
	//setter&getter方法
	String getName(){
		return this.name;
	}
	void setName(String name){
		this.name = name;
	}
	int getAge(){
		return this.age;
	}
	void setAge(int age){
		this.age = age;
	}
	//静态变量
	static int count = 0;
	//静态方法
	public int getInstanceCount(){
		return Person.count;
	}
}
复制代码

由此可知,一个类主要包含如下元素:构造函数属性方法静态属性静态方法

在js中,我们可以使用js的构造函数,来完成js中的面向对象。

js的构造函数就是用来做面向对象声明(声明)的。

构造函数的声明语法同普通函数完全相同。

//构造函数
function Person(name, age){
	//属性
	this.name = name;
	this.age = age;
	
	//setter&getter
	this.getName = function(){
		return this.name;
	}
	this.setName = function(name){
		this.name = name;
	}
	this.getAge = function(){
		return this.age;
	}
	this.setAge = function(age){
		this.age = age;
	}
	
	Person.count++;
}

//静态变量
Person.count = 0;

//静态方法
Person.getInstanceCount = function(){
	return Person.count;
}
复制代码

可以发现,构造函数中同普通函数相比,特别的地方在于使用了this,同其他面向对象的语言一样,this表示当前的实例对象。

把我们用js声明的类与java的类相对比,二者除了写法不同之外,上述关键元素也都包含了。

1.4.2 prototype

js使用上面的方法声明了类之后,就可以使用new关键字来创建对象了。

let person = new Person("kaso", 20);
console.log("person.name=" + person.getName() + ", person.age=" + person.getAge());
//输出:person.name=kaso, person.age=20
let person1 = new Person("jason", 25);
console.log("person.name=" + person.getName() + ", person.age=" + person.getAge());
//输出:person.name=jason, person.age=25
复制代码

创建对象,访问属性,访问方法,都没问题,看起来挺好的。

但是当我们执行一下这段代码,会发现有些不对:

console.log(person.getName === person1.getName);
//输出:false
复制代码

原来构造函数在执行的时候,会将所有成员方法,为每个对象生成一份copy,而对于类成员函数来说,保留一份copy就足够了,而不同的对象可以用this来区分。上面的做法很明显,内存被白白消耗了。

基于上述问题,js引入了prototype关键字并规定:

存储在prototype中的方法和变量可以在类的所有对象*享。

因此,上面的构造函数可以修改成这样:

function Person(name, age){
	this.name = name;
	this.age = age;
	
	Person.count++;
}

Person.prototype.getName = function(){
	return this.name;
}

Person.prototype.setName = function(name){
	this.name = name;
}

Person.prototype.getAge = function(){
	return this.age;
}

Person.prototype.setAge = function(age){
	this.age = age;
}

Person.count = 0;

Person.getInstanceCount = function(){
	return Person.count;
}

复制代码

运行效果和之前的写法相同,只是这次创建不同的对象时,成员方法不再创建多个副本了。

需要注意的是,成员变量不需要放到prototype中,可以想想为什么。

1.4.3 apply和call

js函数中绕不过的一个问题就是,方法里面的this到底指向哪里?

最官方的说法是:this指向调用此方法的对象。

对于类似于java这种面向对象的语言来讲,this永远指向所在类的对象实例。

对于js中也是这样,如果我们规规矩矩地像上一节介绍的那样使用,this也会指向所在类的对象实例。

但是,js也提供了更为灵活的语法,它可以让一个方法被不同的对象调用,即使不是同一个类的对象,也就是可以将同一个函数的this,设为不同的值。

这是一个极为灵活的语法,可以完成其他语言类似接口(interface)扩展(extension)模版(template)的功能。

实现此功能的方法有2个:applycall,二者实现的功能完全相同,即改变函数的this指向,只是函数传递参数方式不同。

call接受可变参数,同函数调用一样,需将参数一一列出。
apply只接受2个参数,第一个就是新的this指向的对象,第二个参数是原参数用数组保存起来。
代码如下:

let obj = {
	print(a, b, c){
		console.log(`this is obj.print(${a}, ${b}, ${c})`);
	}
}

let obj1 = {
	print(a, b, c){
		console.log(`this is obj1.print(${a}, ${b}, ${c})`);
	}
}

function test(a, b, c){
	this.print(a, b, c);
}

test.apply(obj, [1, 2, 3]);
test.call(obj, 4, 5, 7);

test.apply(obj1, [1, 2, 3]);
test.call(obj1, 4, 5, 7);

/* 输出:
this is obj.print(1, 2, 3)
this is obj.print(4, 5, 7)
this is obj1.print(1, 2, 3)
this is obj1.print(4, 5, 7)
*/
复制代码

1.4.4 继承

面向对象3大特征:封装,继承,多态,其中最重要的就是继承,多态也依赖于继承的实现。可以说实现了继承,就实现了面向对象。

java中的继承很简单:

class Student extends Person{
    ... ...
}
复制代码

Student继承之后自动获得Person的所有成员变量和成员方法。

因此,我们在实现js继承的时候,主要就是获取到父类的成员变量和成员方法。

最简单的实现就是,将父类的成员变量和方法直接copy到子类中。

这需要做2件事:

  • 为了copy成员方法,可以将Student的prototype指向父类的prototype
  • 为了copy成员属性,子类构造函数需要调用父类构造函数
function Student(name, age){
	Person.call(self, name, age);
}

Student.prototype = Person.prototype;

复制代码

上面代码可以达到继承的目的,但是会产生两个问题

  • 如果我向Student中添加新的成员方法时,会同时加入到父类中
  • 多层次继承无法实现,即当所调用的方法在父类中找不到的时候,不会去父类的父类中去查找

所以我们不能直接将Person.prototype直接给Student.prototype。

经过思考,一个可行方案是,令子类prototype指向父类的一个对象,即像这样:

Student.prototype = new Person();
复制代码

这样做,可以解决上面的2个问题。

但是它仍然有些瑕疵:会调用2次父类构造函数,造成一定的性能损失。

所以我们的终极继承方案是这样的:

function Student(name, age){
	Person.call(self, name, age);
}

function HelpClass(){}
HelpClass.prototype = Person.prototype;
Student.prototype = new HelpClass();
复制代码

上面关键代码的意义在于,用一个空的构造函数代替父类构造函数,这样调用了一个空构造函数的代价会小于调用父类构造函数。

另外上述代码可以用Object.create函数简化:

function Student(name, age){
	Person.call(self, name, age);
}

Student.prototype = Object.create(Person.prototype);
复制代码

这就是我们最终的继承方案了。可以写成下面的通用模式。

function extend(superClass){
	function subClass(){
		superClass.apply(self, arguments);
	}
	subClass.prototype = Object.create(superClass.prototype);
	
	return subClass;
}

let Student = extend(Person);

let s = new Student('jackson', '34');

console.log("s.getName() = " + s.getName() + ", s.getAge() = " + s.getAge());

//输出为:s.getName() = jackson, s.getAge() = 34

复制代码

当然实现一个完整的继承还需要完善其他诸多功能,在这里我们已经解决了最根本的问题。

1.5 generator函数和co

generator是ES6中提供的一种异步编程的方案。有点像其他语言(lua, c#)中的协程。

它可以让程序在不同函数中跳转,并传递数据。

1.5.1 基本用法介绍

看下面的代码:

function *generatorFunc(){
   console.log("before yield 1");
   yield 1;
   console.log("before yield 2");
   yield 2;
   console.log("before yield 3");
   let nextTransferValue = yield 3;
   console.log("nextTransferValue = " + nextTransferValue);
}

let g = generatorFunc();
console.log("before next()");
console.log(g.next());
console.log(g.next());
console.log(g.next());
console.log(g.next(1024));
/*输出:
before next()
before yield 1
{ value: 1, done: false }
before yield 2
{ value: 2, done: false }
before yield 3
{ value: 3, done: false }
nextTransferValue = 1024
{ value: undefined, done: true }
*/
复制代码

可以看到generator函数有3要素:

  • 需要在函数名字前面,加上*
  • 需要在函数体中使用 yield
  • 调用的时候需要使用 next()函数

另外还有一些其他规则:

  • generator函数内的第一行代码,需要在第一个next()执行后执行
  • 函数在执行next()时,停顿在yield处,并返回yield后面的值,yield后的代码不再执行。
  • next() 返回的形式是一个对象:{value: XXX, done: false},这个对象中,value表示yield后面的值,done表示是否generator函数已经执行完毕,即所有的yield都执行过了。
  • next() 可以带参数,表示将此参数传递给上一个yield,因为上次执行next()的时候,代码停留在上次yield的位置了,再执行next()的时候,会从上次yield的位置继续执行代码,同时可以令yield表达式有返回值。

从上述介绍中可以看出,generator除了在函数中跳转之外,还可以通过next()来返回不同的值。

了解过ES6的同学应该知道,这种next()序列,特别符合迭代器的定义。

因此,我们可以很容易把generator的函数的返回值组装成数组,还可以用for..of表达式来遍历。

function *generatorFunc(){
   yield 1;
   yield 2;
   yield 3;
}

let g = generatorFunc();
for(let i of g){
	console.log(i);
}

/*
输出:
1
2
3
*/
复制代码
function *generatorFunc(){
   yield 1;
   yield 2;
   yield 3;
}

let g = generatorFunc();
console.log(Array.from(g));

/*
输出:
[1, 2, 3]
*/
复制代码

除了上述规则外,generator还有一个语法yield *,它可以连接另一个generator函数,类似于普通函数间调用。用于一个generator函数调用另一个generator函数,也可用于递归。

function *generatorFunc(){
    yield 3;
    yield 4;
    yield 5;
}

function *generatorFunc1(){
    yield 1;
    yield 2;
    yield * generatorFunc();
    yield 6;
}
 
let g = generatorFunc1();
console.log(Array.from(g));

/*
输出:
[1, 2, 3, 4, 5, 6]
*/

复制代码

除了获取数组外,我们还可以使用generator的yieldnext特性,来做异步操作。

js中的异步操作我们一般使用Promise来实现。

请看下列代码及注释。

let g = null;
function *generatorFunc(){
	//第一个请求,模拟3s后台操作
    let request1Data = yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    }).then((d) => {
    	 //令函数继续运行,并把promise返回的数据通过next传给上一个yield,代码会运行到下一个yield
        g.next(d);
    });

	 //输出第一个请求的结果
    console.log('request1Data = ' + request1Data);

	 //同上,开始第二个请求
    let request2Data = yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    }).then((d) => {
        g.next(d);
    });
	
	 //第二个请求
    console.log('request2Data = ' + request2Data);
 }
 
 g = generatorFunc();
 g.next();
 console.log('completed');
 /*
 输出:
 completed(马上输出)
 request1Data = 123(3s后输出)
 request2Data = 456(6s后输出)
 */
复制代码

我们换一种写法:

let g = null;

function *request1(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    }).then((d) => {
        g.next(d);
    });
}

function *request2(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    }).then((d) => {
        g.next(d);
    });
}

function *generatorFunc(){
    let request1Data = yield *request1();
    console.log('request1Data = ' + request1Data);
    let request2Data = yield *request2();
    console.log('request2Data = ' + request2Data);
 }
 
 g = generatorFunc();
 g.next();
 console.log('completed');
 /*
 输出同上
 */
复制代码

运行结果是相同的,所以我们可以看到,generator函数能够把异步操作写成同步形式,从而避免了回调地狱的问题。

异步变成同步,不知道能够避免多少因为回调,作用域产生的问题,代码逻辑也能急剧简化。

1.5.2 generator函数的自动运行

虽然我们可以通过generator消除异步代码,但是使用起来还是不太方便的。

需要把generator对象提前声明保存,然后还要在异步的结果处写next()

经过观察发现,这些方法的出现都是有规律的,所以可以通过代码封装来将这些操作封装起来,从而让generator函数的运行,就像普通函数一样。

提供这样功能的是co.js(可以点这里跳转),大神写的插件,用于generator函数的自动运行,简单的说它会帮你自动执行next()函数,所以借助co.js,你只需要编写yield和异步函数即可。

使用co.js,上面的异步代码可以写成这样:

let co = require('./co');

function *request1(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    });
}

function *request2(){
    return yield new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    });
}

function *generatorFunc(){
    let request1Data = yield *request1();
    console.log('request1Data = ' + request1Data);
    let request2Data = yield *request2();
    console.log('request2Data = ' + request2Data);
 }
 co(generatorFunc);
 console.log('completed');
 /*
 输出同上
 */
复制代码

可以看到,借助co.js你只需要写yield就能够把异步操作写成同步调用的形式。

注意,请使用promise来进行异步操作。

1.6 async和await

使用generator + Promise + co.js可以较为方便地实现异步转同步。

而js的新标准中,上面的操作已经提供了语法层面的支持,并将异步转同步的写法,简化成了2个关键字:awaitasync

同样实现上节中的异步调用功能,代码如下:


async function request1(){
    return await new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("123");
        }, 3000);
    });
}

async function request2(){
    return await new Promise((resolve, reject) => {
        setTimeout(()=>{
            resolve("456");
        }, 3000);
    });
}

async function generatorFunc(){
    let request1Data = await request1();
    console.log('request1Data = ' + request1Data);
    let request2Data = await request2();
    console.log('request2Data = ' + request2Data);
 }

 generatorFunc();
 
 console.log('completed');
 
 /*
 输出同上
 */
复制代码

await/async使用规则如下:

  • await只能用在async函数中。
  • await后面可以接任何对象。
  • 如果await后面接的是普通对象(非Promise,非async),则会马上返回,相当于没写await。
  • 如果await后面是Promise对象,await会等待Promise的resolve执行后,才会继续向下执行,然后await会返回resolve传递的参数。
  • 如果await后面是另一个async函数,则会等待另一个async完成后继续执行。
  • 调用一个async函数会返回一个Promise对象,async函数中的返回值相当于调用了Promise的resolve方法,async函数中抛出异常相当于调用了Promise的reject方法。
  • 通过上一条规则可知,虽然await/async使用了Promise来执行异步,但是我们却可以在使用这两个个关键字的时候,不写任何的Promise。
  • 另外,如果await后面的表达式可能抛出异常,则需要在await语句上增加try-catch语句,否则异常会导致程序执行中断。

await/async本身就是用来做异步操作转同步写法的,它的规则和用法也很明确,只要牢记上面几点,你就能用好它们。


//抛出异常的async方法
async function generatorFunc1(){
    console.log("begin generatorFunc1");
    throw 1001;
}

//async方法返回的是Promise对象,使用Promise.catch捕获异常
generatorFunc1().catch((e) => {
    console.log(`catch error '${e}' in Promise.catch`);
})

//正常带返回值的async方法
async function generatorFunc2(){
    console.log("begin generatorFunc2");
    return 1002;
}

//async方法返回的是Promise对象,使用Promise.then获取返回的数据
generatorFunc2().then((data)=>{
    console.log(`data = ${data}`);
})

//await后带的async方法若抛出异常,可以在await语句增加try-catch捕获异常
async function generatorFunc3(){
    console.log("begin generatorFunc3");
    try{
        await generatorFunc1();
    }catch(e){
        console.log(`catch error '${e}' in generatorFunc3`);
    }
}

generatorFunc3();

console.log('completed');
/* 输出:
begin generatorFunc1
begin generatorFunc2
begin generatorFunc3
begin generatorFunc1
completed
catch error '1001' in Promise.catch
data = 1002
catch error '1001' in generatorFunc3
*/
复制代码

--完--