JavaScript教程2

02 | 函数

定义函数

// 定义1
function abs(x) {
    if (x >= 0) {
        return x;
    } else {
        return -x;
    }
}


// 定义2:匿名函数
var abs = function (x) {
    if (x >= 0) {
        return x;
    } else {
        return -x;
    }
};
// 第二种方式按照完整语法需要在函数体末尾加一个;,表示赋值语句结束

调用函数

// 由于JavaScript允许传入任意个参数而不影响调用,因此传入的参数比定义的参数多也没有问题,虽然函数内部并不需要这些参数:
abs(10, 'blablabla'); // 返回10
abs(-9, 'haha', 'hehe', null); // 返回9


// 传入的参数比定义的少也没有问,此时abs(x)函数的参数x将收到undefined,计算结果为NaN。
abs(); // 返回NaN


// 要避免收到undefined,可以对参数进行检查:
function abs(x) {
    if (typeof x !== 'number') {
        throw 'Not a number';
    }
    if (x >= 0) {
        return x;
    } else {
        return -x;
    }
}

arguments

JavaScript还有一个免费赠送的关键字arguments,它只在函数内部起作用,并且永远指向当前函数的调用者传入的所有参数。arguments类似Array但它不是一个Array

// 利用arguments,你可以获得调用者传入的所有参数。也就是说,即使函数不定义任何参数,还是可以拿到参数的值:
function abs() {
    if (arguments.length === 0) {
        return 0;
    }
    var x = arguments[0];
    return x >= 0 ? x : -x;
}

abs(); // 0
abs(10); // 10
abs(-9); // 9



// 实际上arguments最常用于判断传入参数的个数。你可能会看到这样的写法:
// foo(a[, b], c)
// 接收2~3个参数,b是可选参数,如果只传2个参数,b默认为null:
function foo(a, b, c) {
    if (arguments.length === 2) {
        // 实际拿到的参数是a和b,c为undefined
        c = b; // 把b赋给c
        b = null; // b变为默认值
    }
    // ...
}

rest参数

可变参数

rest参数只能写在最后,前面用…标识,从运行结果可知,传入的参数先绑定a、b,多余的参数以数组形式交给变量rest,所以,不再需要arguments我们就获取了全部参数。

function foo(a, b, ...rest) {
    console.log('a = ' + a);
    console.log('b = ' + b);
    console.log(rest);
}

foo(1, 2, 3, 4, 5);
// 结果:
// a = 1
// b = 2
// Array [ 3, 4, 5 ]

foo(1);
// 结果:
// a = 1
// b = undefined
// Array []

小心你的return语句

前面我们讲到了JavaScript引擎有一个在行末自动添加分号的机制,这可能让你栽到return语句的一个大坑:

function foo() {
    return { name: 'foo' };
}

foo(); // { name: 'foo' }
// 如果把return语句拆成两行:

function foo() {
    return
        { name: 'foo' };
}

foo(); // undefined
// 要小心了,由于JavaScript引擎在行末自动添加分号的机制,上面的代码实际上变成了:

function foo() {
    return; // 自动添加了分号,相当于return undefined;
        { name: 'foo' }; // 这行语句已经没法执行到了
}
// 所以正确的多行写法是:

function foo() {
    return { // 这里不会自动加分号,因为{表示语句尚未结束
        name: 'foo'
    };
}

变量作用域

如果一个变量在函数体内部申明,则该变量的作用域为整个函数体,在函数体外不可引用该变量:

如果两个不同的函数各自申明了同一个变量,那么该变量只在各自的函数体内起作用。换句话说,不同函数内部的同名变量互相独立,互不影响:

由于JavaScript的函数可以嵌套,此时,内部函数可以访问外部函数定义的变量,反过来则不行:

如果内部函数和外部函数的变量名重名怎么办?JavaScript的函数在查找变量时从自身函数定义开始,从“内”向“外”查找。如果内部函数定义了与外部函数重名的变量,则内部函数的变量将“屏蔽”外部函数的变量。

变量提升

JavaScript的函数定义有个特点,它会先扫描整个函数体的语句,把所有申明的变量“提升”到函数顶部, 不提升赋值

// 由于JavaScript的这一怪异的“特性”,我们在函数内部定义变量时,请严格遵守“在函数内部首先申明所有变量”这一规则。最常见的做法是用一个var申明函数内部用到的所有变量:

function foo() {
    var
        x = 1, // x初始化为1
        y = x + 1, // y初始化为2
        z, i; // z和i为undefined
    // 其他语句:
    for (i=0; i<100; i++) {
        ...
    }
}

全局作用域

不在任何函数内定义的变量就具有全局作用域。实际上,JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性

alert('Hello JS');
window.alert('Hello JS');

这说明JavaScript实际上只有一个全局作用域。任何变量(函数也视为变量),如果没有在当前函数作用域中找到,就会继续往上查找,最后如果在全局作用域中也没有找到,则报ReferenceError错误。

名字空间

全局变量会绑定到window上,不同的JavaScript文件如果使用了相同的全局变量,或者定义了相同名字的顶层函数,都会造成命名冲突,并且很难被发现。

减少冲突的一个方法是把自己的所有变量和函数全部绑定到一个全局变量中。例如:

// 唯一的全局变量MYAPP:
var MYAPP = {};

// 其他变量:
MYAPP.name = 'myapp';
MYAPP.version = 1.0;

// 其他函数:
MYAPP.foo = function () {
    return 'foo';
};

局部作用域

// 由于JavaScript的变量作用域实际上是函数内部,我们在for循环等语句块中是无法定义具有局部作用域的变量的:
'use strict';

function foo() {
    for (var i=0; i<100; i++) {
        //
    }
    i += 100; // 仍然可以引用变量i
}



// 为了解决块级作用域,ES6引入了新的关键字let,用let替代var可以申明一个块级作用域的变量:
'use strict';

function foo() {
    var sum = 0;
    for (let i=0; i<100; i++) {
        sum += i;
    }
    // SyntaxError:
    i += 1;
}

常量

由于var和let申明的是变量,如果要申明一个常量,在ES6之前是不行的,我们通常用全部大写的变量来表示“这是一个常量,不要修改它的值”:

ES6标准引入了新的关键字const来定义常量,const与let都具有块级作用域:

'use strict';

const PI = 3.14;
PI = 3; // 某些浏览器不报错,但是无效果!
PI; // 3.14

比较 var 与 let 与 const

  • var 如果一个变量在函数体内部申明,则该变量的作用域为整个函数体
  • var 不在任何函数内定义的变量就具有全局作用域。实际上,JavaScript默认有一个全局对象window,全局作用域的变量实际上被绑定到window的一个属性
  • 为了解决块级作用域,用let申明一个块级作用域的变量
  • 为了解决块常量问题,关键字const来定义常量,const与let都具有块级作用域,通常用全部大写的变量来表示
  • 不用var申明的变量会被视为全局变量,为了避免这一缺陷,所有的JavaScript代码都应该使用strict模式。强制通过var申明变量,未使用var申明变量就使用的,将导致运行错误。

解构赋值

从ES6开始,JavaScript引入了解构赋值,可以同时对一组变量进行赋值。

方法

在一个对象中绑定函数,称为这个对象的方法。

this

在一个方法内部,this是一个特殊变量,它始终指向当前对象,也就是xiaoming这个变量。所以,this.birth可以拿到xiaoming的birth属性。

'use strict';

var xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        var y = new Date().getFullYear();
        return y - this.birth;
    }
};

xiaoming.age(); // 25, 正常结果
// 如果以对象的方法形式调用,比如xiaoming.age(),该函数的this指向被调用的对象,也就是xiaoming,这是符合我们预期的。


getAge(); // NaN
// 如果单独调用函数,比如getAge(),此时,该函数的this指向全局对象,也就是window。


var fn = xiaoming.age;
fn(); // Uncaught TypeError: Cannot read property 'birth' of undefined
// 也是不行的!要保证this指向正确,必须用obj.xxx()的形式调用!
// 由于这是一个巨大的设计错误,要想纠正可没那么简单。ECMA决定,在strict模式下让函数的this指向undefined,因此,在strict模式下,你会得到一个错误
// 喜欢重构的你把方法重构了一下
'use strict';

var xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        function getAgeFromBirth() {
            var y = new Date().getFullYear();
            return y - this.birth;
        }
        return getAgeFromBirth();
    }
};

xiaoming.age(); // Uncaught TypeError: Cannot read property 'birth' of undefined

// 结果又报错了!原因是this指针只在age方法的函数内指向xiaoming,在函数内部定义的函数,this又指向undefined了!(在非strict模式下,它重新指向全局对象window!)

// 解决:用一个that变量首先捕获this

var xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        var that = this; // 在方法内部一开始就捕获this
        function getAgeFromBirth() {
            var y = new Date().getFullYear();
            return y - that.birth; // 用that而不是this
        }
        return getAgeFromBirth();
    }
};

xiaoming.age(); // 25

// 用var that = this;,你就可以放心地在方法内部定义其他函数,而不是把所有语句都堆到一个方法中。

小结:

  • 如果以对象的方法形式调用,比如xiaoming.age(),该函数的this指向被调用的对象,也就是xiaoming
  • 如果单独调用函数,比如getAge(),此时,该函数的this指向全局对象,也就是window
  • 在strict模式下让函数的this指向undefined
  • 解决:用一个that变量首先捕获this

apply() 与 call()

虽然在一个独立的函数调用中,根据是否是strict模式,this指向undefined或window,不过,我们还是可以控制this的指向的!

解决this指向问题

// 要指定函数的this指向哪个对象,可以用函数本身的apply方法,它接收两个参数,第一个参数就是需要绑定的this变量,第二个参数是Array,表示函数本身的参数。

function getAge() {
    var y = new Date().getFullYear();
    return y - this.birth;
}

var xiaoming = {
    name: '小明',
    birth: 1990,
    age: getAge
};

// 以对象的方法形式调用,比如xiaoming.age(),该函数的this指向被调用的对象,也就是xiaoming
xiaoming.age(); // 25

// apply()把参数打包成Array再传入
getAge.apply(xiaoming, []); // 25, this指向xiaoming, 参数为空

// call()把参数按顺序传入
getAge.call(xiaoming)
// 调用Math.max(3, 5, 4),分别用apply()和call()实现如下:

Math.max.apply(null, [3, 5, 4]); // 5
Math.max.call(null, 3, 5, 4); // 5

// 对普通函数调用,我们通常把this绑定为null。

装饰器

利用apply(),我们还可以动态改变函数的行为。

JavaScript的所有对象都是动态的,即使内置的函数,我们也可以重新指向新的函数。

现在假定我们想统计一下代码一共调用了多少次parseInt(),可以把所有的调用都找出来,然后手动加上count += 1,不过这样做太傻了。最佳方案是用我们自己的函数替换掉默认的parseInt():

'use strict';

var count = 0;
var oldParseInt = parseInt; // 保存原函数

window.parseInt = function () {
    count += 1;
    return oldParseInt.apply(null, arguments); // 调用原函数
};

高阶函数

JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数。

function add(x, y, f) {
    return f(x) + f(y);
}

map

// 把Array的所有数字转为字符串:
var arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
arr.map(String); // ['1', '2', '3', '4', '5', '6', '7', '8', '9']

reduce

Array的reduce()把一个函数作用在这个Array的[x1, x2, x3…]上,这个函数必须接收两个参数,reduce()把结果继续和序列的下一个元素做累积计算,其效果就是:

[x1, x2, x3, x4].reduce(f) = f(f(f(x1, x2), x3), x4)

// Array求和
var arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
    return x + y;
}); // 25


// 要把[1, 3, 5, 7, 9]变换成整数13579,reduce()也能派上用场:
var arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
    return x * 10 + y;
}); // 13579

练习

// 练习1:reduce()求积
'use strict';
function product(arr) {
  var r = arr.reduce(function (x, y) {
    return x * y;
  });
  return r;
}



// 练习2:不要使用JavaScript内置的parseInt()函数,利用map和reduce操作实现一个string2int()函数
'use strict';
function string2int(s) {
  var tmp_arr = s.split("");
  var arr = tmp_arr.map(function(x){
   return x-'0';
  });
  var r = arr.reduce(function (x, y) {
    return x * 10 + y;
  });
  return r;
}


// 练习3:把用户输入的不规范的英文名字,变为首字母大写,其他小写的规范名字。输入:['adam', 'LISA', 'barT'],输出:['Adam', 'Lisa', 'Bart']。
'use strict';
function normalize(arr) {
    var r = arr.map(function (s) {
        return s[0].toUpperCase() + s.slice(1).toLowerCase();
    })
    return r;
}


// 练习4:利用map()把字符串变成整数
'use strict';
var arr = ['1', '2', '3'];
var r;
r = arr.map(Number);
console.log(r);

一个错误

'use strict';

var arr = ['1', '2', '3'];
var r;
r = arr.map(parseInt);
console.log(r);

/* 原因

由于map()接收的回调函数可以有3个参数:callback(currentValue, index, array),通常我们仅需要第一个参数,而忽略了传入的后面两个参数。不幸的是,parseInt(string, radix)没有忽略第二个参数,导致实际执行的函数分别是:

parseInt('0', 0); // 0, 按十进制转换

parseInt('1', 1); // NaN, 没有一进制

parseInt('2', 2); // NaN, 按二进制转换不允许出现2

可以改为r = arr.map(Number);,因为Number(value)函数仅接收一个参数。
*/

filter

可见用filter()这个高阶函数,关键在于正确实现一个“筛选”函数。

回调函数

filter()接收的回调函数,其实可以有多个参数。通常我们仅使用第一个参数,表示Array的某个元素。回调函数还可以接收另外两个参数,表示元素的位置和数组本身:

var arr = ['A', 'B', 'C'];
var r = arr.filter(function (element, index, self) {
    console.log(element); // 依次打印'A', 'B', 'C'
    console.log(index); // 依次打印0, 1, 2
    console.log(self); // self就是变量arr
    return true;
});
// 利用filter,可以巧妙地去除Array的重复元素:
'use strict';

var
    r,
    arr = ['apple', 'strawberry', 'banana', 'pear', 'apple', 'orange', 'orange', 'strawberry'];

r = arr.filter(function (element, index, self) {
    return self.indexOf(element) === index;
});

console.log(r.toString());
// 去除重复元素依靠的是indexOf总是返回第一个元素的位置,后续的重复元素位置与indexOf返回的位置不相等,因此被filter滤掉了。

练习

// 用filter()筛选出素数

'use strict';
function get_primes(arr) {
  var result = arr.filter(function(x){
    if (x===1) {
        return false;
    }
    if (x===2) {
        return true;
    }
    for (let i=2;i<x;i++) {
       if(x%i===0) {
           return false;
       }
    }
    return true; 
  });
  return result;
}

sort

// 看上去正常的结果:
['Google', 'Apple', 'Microsoft'].sort(); // ['Apple', 'Google', 'Microsoft'];

// apple排在了最后:
['Google', 'apple', 'Microsoft'].sort(); // ['Google', 'Microsoft", 'apple']
//第二个排序把apple排在了最后,是因为字符串根据ASCII码进行排序,而小写字母a的ASCII码在大写字母之后。


// 无法理解的结果:
[10, 20, 1, 2].sort(); // [1, 10, 2, 20]
// 这是因为Array的sort()方法默认把所有元素先转换为String再排序,结果'10'排在了'2'的前面,因为字符'1'比字符'2'的ASCII码小。

解决:

幸运的是,sort()方法也是一个高阶函数,它还可以接收一个比较函数来实现自定义的排序。

// 数字大小排序

'use strict';
var arr = [10, 20, 1, 2];
arr.sort(function (x, y) {
    if (x < y) {
        return -1;
    }
    if (x > y) {
        return 1;
    }
    return 0;
});
console.log(arr); // [1, 2, 10, 20]


//倒序排序
var arr = [10, 20, 1, 2];
arr.sort(function (x, y) {
    if (x < y) {
        return 1;
    }
    if (x > y) {
        return -1;
    }
    return 0;
}); // [20, 10, 2, 1]


// 默认情况下,对字符串排序,是按照ASCII的大小比较的,现在,我们提出排序应该忽略大小写,按照字母序排序。要实现这个算法,不必对现有代码大加改动,只要我们能定义出忽略大小写的比较算法就可以:

var arr = ['Google', 'apple', 'Microsoft'];
arr.sort(function (s1, s2) {
    x1 = s1.toUpperCase();
    x2 = s2.toUpperCase();
    if (x1 < x2) {
        return -1;
    }
    if (x1 > x2) {
        return 1;
    }
    return 0;
}); // ['apple', 'Google', 'Microsoft']

// 忽略大小写来比较两个字符串,实际上就是先把字符串都变成大写(或者都变成小写),再比较。

最后友情提示,sort()方法会直接对Array进行修改,它返回的结果仍是当前Array:

var a1 = ['B', 'A', 'C'];
var a2 = a1.sort();
a1; // ['A', 'B', 'C']
a2; // ['A', 'B', 'C']
a1 === a2; // true, a1和a2是同一对象

函数作为返回值

高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回。

闭包

注意到返回的函数在其定义内部引用了局部变量arr,所以,当一个函数返回了一个函数后,其内部的局部变量还被新函数引用,所以,闭包用起来简单,实现起来可不容易。

返回闭包时牢记的一点就是:返回函数不要引用任何循环变量,或者后续会发生变化的变量

function count() {
    var arr = [];
    for (var i=1; i<=3; i++) {
        arr.push(function () {
            return i * i;
        });
    }
    return arr;
}

var results = count();
var f1 = results[0];
var f2 = results[1];
var f3 = results[2];

f1(); // 16
f2(); // 16
f3(); // 16

//全部都是16!原因就在于返回的函数引用了变量i,但它并非立刻执行。等到3个函数都返回时,它们所引用的变量i已经变成了4,因此最终结果为16。


// 如果一定要引用循环变量怎么办?方法是再创建一个函数,用该函数的参数绑定循环变量当前的值,无论该循环变量后续如何更改,已绑定到函数参数的值不变:
function count() {
    var arr = [];
    for (var i=1; i<=3; i++) {
        arr.push((function (n) {
            return function () {
                return n * n;
            }
        })(i));
    }
    return arr;
}

var results = count();
var f1 = results[0];
var f2 = results[1];
var f3 = results[2];

f1(); // 1
f2(); // 4
f3(); // 9


// 创建一个匿名函数并立刻执行
(function (x) { return x * x }) (3);
// 在没有class机制,只有函数的语言里,借助闭包,同样可以封装一个私有变量。我们用JavaScript创建一个计数器:

'use strict';

function create_counter(initial) {
    var x = initial || 0;
    return {
        inc: function () {
            x += 1;
            return x;
        }
    }
}

var c1 = create_counter();
c1.inc(); // 1
c1.inc(); // 2
c1.inc(); // 3

var c2 = create_counter(10);
c2.inc(); // 11
c2.inc(); // 12
c2.inc(); // 13

// 在返回的对象中,实现了一个闭包,该闭包携带了局部变量x,并且,从外部代码根本无法访问到变量x。换句话说,闭包就是携带状态的函数,并且它的状态可以完全**对外隐藏**起来。
// 闭包还可以把多参数的函数变成单参数的函数。例如,要计算xy可以用Math.pow(x, y)函数,不过考虑到经常计算x2或x3,我们可以利用闭包创建新的函数pow2和pow3:

'use strict';

function make_pow(n) {
    return function (x) {
        return Math.pow(x, n);
    }
}

// 创建两个新函数:
var pow2 = make_pow(2);
var pow3 = make_pow(3);

console.log(pow2(5)); // 25
console.log(pow3(7)); // 343

箭头函数

//箭头函数
x => x * x

// 另一种格式
x => {
    if (x > 0) {
        return x * x;
    }
    else {
        return - x * x;
    }
}


// 箭头函数相当于匿名函数,并且简化了函数定义
function (x) {
    return x * x;
}
// 如果参数不是一个,就需要用括号()括起来

// 两个参数:
(x, y) => x * x + y * y

// 无参数:
() => 3.14

// 可变参数:
(x, y, ...rest) => {
    var i, sum = x + y;
    for (i=0; i<rest.length; i++) {
        sum += rest[i];
    }
    return sum;
}


// 如果要返回一个对象,就要注意,如果是单表达式,这么写的话会报错:
// SyntaxError:
x => { foo: x }
//因为和函数体的{ ... }有语法冲突,所以要改为:

// ok:
x => ({ foo: x })

箭头函数的this

// 现在,箭头函数完全修复了this的指向,this总是指向词法作用域,也就是外层调用者obj:

var obj = {
    birth: 1990,
    getAge: function () {
        var b = this.birth; // 1990
        var fn = () => new Date().getFullYear() - this.birth; // this指向obj对象
        return fn();
    }
};
obj.getAge(); // 25


//由于this在箭头函数中已经按照词法作用域绑定了,所以,用call()或者apply()调用箭头函数时,无法对this进行绑定,即传入的第一个参数被忽略:
var obj = {
    birth: 1990,
    getAge: function (year) {
        var b = this.birth; // 1990
        var fn = (y) => y - this.birth; // this.birth仍是1990
        return fn.call({birth:2000}, year);
    }
};
obj.getAge(2015); // 25
//请使用箭头函数简化排序时传入的函数

'use strict'
var arr = [10, 20, 1, 2];

arr.sort((x, y) => {
    if (x < y) {
        return -1;
    }
    if (x > y) {
        return 1;
    }
    return 0;
});
console.log(arr); // [1, 2, 10, 20]

generator

generator和函数不同的是,generator由function定义(注意多出的号),并且,除了return语句,还可以用yield返回多次。

function* foo(x) {
    yield x + 1;
    yield x + 2;
    return x + 3;
}
// 产生斐波那契数列的函数
function fib(max) {
    var
        t,
        a = 0,
        b = 1,
        arr = [0, 1];
    while (arr.length < max) {
        [a, b] = [b, a + b];
        arr.push(b);
    }
    return arr;
}

// 测试:
fib(5); // [0, 1, 1, 2, 3]
fib(10); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
// 函数只能返回一次,所以必须返回一个Array。但是,如果换成generator,就可以一次返回一个数,不断返回多次。用generator改写如下:
'use strict'

function* fib(max) {
    var
        t,
        a = 0,
        b = 1,
        n = 0;
    while (n < max) {
        yield a;
        [a, b] = [b, a + b];
        n ++;
    }
    return;
}


// 调用generator对象有两个方法,一是不断地调用generator对象的next()方法:
var f = fib(5);
f.next(); // {value: 0, done: false}
f.next(); // {value: 1, done: false}
f.next(); // {value: 1, done: false}
f.next(); // {value: 2, done: false}
f.next(); // {value: 3, done: false}
f.next(); // {value: undefined, done: true}
// next()方法会执行generator的代码,然后,每次遇到yield x;就返回一个对象{value: x, done: true/false},然后“暂停”。返回的value就是yield的返回值,done表示这个generator是否已经执行结束了。如果done为true,则value就是return的返回值。当执行到done为true时,这个generator对象就已经全部执行完毕,不要再继续调用next()了。


// 第二个方法是直接用for ... of循环迭代generator对象,这种方式不需要我们自己判断done:
for (var x of fib(10)) {
    console.log(x); // 依次输出0, 1, 1, 2, 3, ...
}

generator和普通函数相比,有什么用?

因为generator可以在执行过程中多次返回,所以它看上去就像一个可以记住执行状态的函数,利用这一点,写一个generator就可以实现需要用面向对象才能实现的功能。例如,用一个对象来保存状态,得这么写:

// 用对象的属性来保存状态,相当繁琐。
var fib = {
    a: 0,
    b: 1,
    n: 0,
    max: 5,
    next: function () {
        var
            r = this.a,
            t = this.a + this.b;
        this.a = this.b;
        this.b = t;
        if (this.n < this.max) {
            this.n ++;
            return r;
        } else {
            return undefined;
        }
    }
};

generator还有另一个巨大的好处,就是把异步回调代码变成“同步”代码。这个好处要等到后面学了AJAX以后才能体会到。