1-JavaScript-ECMAScript核心-函数

about

摘自MDS

一般来说,一个函数是可以通过外部代码调用的一个“子程序”(或在递归的情况下由内部函数调用)。像程序本身一样,一个函数由称为函数体的一系列语句组成。值可以传递给一个函数,函数将返回一个值。

在 JavaScript中,函数是头等(first-class)对象,因为它们可以像任何其他对象一样具有属性和方法。它们与其他对象的区别在于函数可以被调用。

另外,从广义上来说,JavaScript中,一切皆对象。

函数的定义与调用

函数必须先定义再使用(先这么理解,因为会有函数的提升)。

// 通过function关键字声明函数
// 函数名可包含字母、数字、下划线和美元符号(规则与变量名相同)。
function 函数名() {
    // 函数体/代码块/语句
}

// 函数表达式,将函数对象赋值给变量
let func = function(){};

// 调用就是函数名加括号的方式
函数名();
func();

另外,js禁止一个以function开头的函数没有名字。即使用function定义函数时要有函数名。但函数表达式不受此限制,因为function前面还有变量。

函数的返回值

默认的,如果一个函数中没有使用return语句,则它默认返回undefined。要想返回一个特定的值,则函数必须使用 return 语句来指定一个要返回的值。(使用new关键字调用一个构造函数除外)。

function func1() {
    console.log('func1');
}

function func2() {
    console.log('func1');
    return 'func2';
    // return "s1", true .... return可以返回多个值,以逗号分隔
    // return后面的代码不执行
    // alert("return后面的代码不执行");
}

res1 = func1(); // func1
res2 = func2(); // func2
console.log(res1); // undefined
console.log(res2); // func2

返回值可以是任意类型。

函数的参数

函数可以传参。

// 按位置传参,普通的位置参数,实参按照从左到右依次传给形参
function func(a, b) { // 形参,形式参数
    console.log(a, b); 
}
// 如果不为形参传值,那么打印a,b会得到两个undefined
func(); // undefined undefined

// 按位置传参
func(1, 2); // 实参,实际参数值,按照位置,1传给a;2传给b;

// 可以在形参位置给默认值,默认参数表示,这个形参你要传参,就用你传的值;不传就用默认值
// 当然,a和b都可以定义默认参数
function func(a, b=10) {
    console.log(a, b);
}
func(1, 2); // 1 2
func(1); // 1 10

// 动态参数


小结:

  • 形参可以接受任意类型的实参。
  • 一般的,实参都是按照位置从左到右依次传参的。
  • 如果实参数量少于形参或者不传,那么形参的值默认为undefined。
  • 如果实参的数量比形参多,多余的那部分实参就没啥用。

补充:

function func(a, b) {
    // a++相当于是在修改变量,而修改变量则不会影响其他的变量,所以外部的a的值还是10
    // 函数内部a的值加一
    a++; // 在内存中从新开辟一个块内存空间存储加一后的值,然后赋值给当前的a
    // b.name = "赵开"; 实在修改对象中的属性,无论是在函数内外,都指向同一个对象,所以都受影响
    b.name = "赵开";
    // 但下面这种操作就不会相互影响
    // b = {name: "王开"};  这相当于是为b重新赋值,跟函数外部的b没关系,也不会相互影响
    console.log(a, b); // 11 { name: '赵开' }
}
let a = 10;
let b = {name:"张开"};
func(a, b);
console.log(a, b); // 10 { name: '赵开' }

补充,到底写不写分号

之前说,js语句后面以分号作为结束,又说你不写的话js在执行时也会帮你加上。

但总有些时候,自动的总要付出代价,因为它不能保证百分百没问题。

// 下面带分号,执行都没问题
(function () {
    console.log('匿名函数1');
}());

(function () {
    console.log('匿名函数2');
}());

// --------------------------------------------------
// 不带分号,让js自动帮忙添加
(function () {
    console.log('匿名函数1');
}())

(function () {  // 上面的函数正常执行,但执行到此报错: (intermediate value)(intermediate value)(...) is not a function
    console.log('匿名函数2');
}())

报错原因是,匿名函数1最后并没有自动加上分号,所以把匿名函数2的外部的括号当成加括号执行,而匿名函数2这个函数被当成参数传递进去.....结果匿名函数2执行的时候,相当于是匿名函数1的返回值再加括号执行,但这个返回值明显不是函数,所以报错........

除非你写成这样:

(function () {
    console.log('匿名函数1');
    return function () {
        console.log('匿名函数3');
    }
}())

(function () {
    console.log('匿名函数2');
}())

越来越复杂了,所以总结一句话,分号还是要加的。

当然,连续写两个匿名函数的几率比较小,但因为分号引起的这些不必要的报错就太不至于了,所以,还是加分号吧。

方法

首先来说,任何值都可以成为对象的属性,函数也不例外。

let obj = {
    test: function () {
        console.log('我是obj对象中的test方法')
    },
    name: "张开"
};

console.log(obj); // { test: [Function: test], name: '张开' }
console.log(obj.test); // [Function: test]
obj.test(); // 我是obj对象中的test方法

// 更简便的写法,这是es6的新语法,简便但要考虑到是否兼容的问题
let obj = {
    test() {
        console.log('我是obj对象中的test方法')
    },
    xx(){
        console.log('我是obj对象的xx方法')
    },
    name: "张开",
};

如果一个对象的属性是函数,那么我们可以称该函数为这个对象的方法(method),调用这个函数就称为调用某某对象的某某方法。

另外,就目前来说,函数和方法并没有本质上的区别,只是称呼上的不同。

命名空间与作用域

作用域,指的是变量起作用的范围。

在js中, 作用域可以分为:

  • 全局作用域,即在哪都能起作用。
  • 局部作用域,即只能在某个局部起作用。
    • 块作用域,只能在某个代码块起作用。
    • 函数作用域,只能在函数内部起作用。

当定义一个变量之后,js在创建、查找、使用、修改这个变量名都是在一个"地方"进行的,我们称这个"地方"为——命名空间,或称为名称空间。当程序执行变量所对应的代码时,作用域指的就是命名空间。

全局作用域

全局作用域

  • 所有直接在script标签中定义的(变量),都属于全局作用域的"势力范围"。

  • 全局作用域在页面加载时创建,网页关闭时销毁。

  • 全局作用域下定义的变量、对象、函数等都属于全局的、公共的,即可应用于全局的,能在任意位置调用。

  • 全局作用域绑定到了浏览器的窗口对象window上。

    • 在全局作用域中,所有使用var声明的变量,还有定义的函数,都会作为全局对象window的属性或者方法保存。
    • 函数实际上就是window对象的方法。

PS:let声明的变量没有绑定到window对象上,而是绑定到了一个特殊地方,为了数据的安全性,我们访问不到那个地方。

var a = 10;
function func(){
    console.log('func');
}
console.log(a); // 10
console.log(window.a); // 10
func(); // func
window.func(); // func

变量/函数的提升

在js中,使用var声明的变量,会在所有的代码执行前被创建。如下示例中,由于变量a在所有的代码前被声明,但并没有被赋值,所以打印的a是undefined。

// var a;   // 相当于在最开始把变量进行声明

console.log(a); // undefined
var a = 10;

上面的var的这种现象,被称为变量的提升。

来看函数是否也能提升:

func1(); // func1
func2(); // TypeError: func2 is not a function

// 声明函数
function func1() {
    console.log('func1');
}

// 函数表达式
var func2 = function () {
    console.log('func2');
};

以function开头定义的函数,整体也会提升,即在所有的代码之前就声明好了,且它与变量提升有区别的是:

  • 变量提升,只声明,不赋值。
  • 函数提升,声明完就能使用。

上例中的函数表达式,由于由var声明,它属于变量提升,不属于函数提升。

练习

下面打印的结果是:

console.log(a);
var a = 1;
console.log(a);

function a() {
    return 'func1';
}

console.log(a);

a = 3;

console.log(a);

function a() {
    return 'func2';
}

console.log(a);

a = function () {
    return 'func3';
};

console.log(a);

/*
[Function: a] func2
1
1
3
3
[Function: a]  func3
*/

这里个题看着复杂,理解起来也......比较麻烦,思路就是把提升的都往上提升了,相当于整理下代码,下面是整理好的:

var a;
function a() {
    return 'func1';
}

function a() {
    return 'func2';
}
console.log(a); // func2
a = 1;
console.log(a); // 1
console.log(a); // 1
a = 3;
console.log(a); // 3
console.log(a); // 3

a = function () {
    return 'func3';
};
console.log(a); // func3

函数的作用域

函数作用域:

  • 生命周期:函数作用域在函数调用时创建,调用结束后销毁。
  • 每次调用函数时,都会产生一个新的函数作用域。
  • 每个函数作用域都是独立的。
  • 在函数作用域中使用let或var声明的变量是局部变量,且只能在函数内部访问,外部无法访问。但要注意的是,在函数中不适用var或let进行声明变量,这个变量就会变成全局变量,能够被外部访问到。
  • 在函数作用域能访问全局作用域中的变量,但全局作用域下访问不了函数作用域中的变量。
  • 变量和函数的提升,在函数作用域中同样适用,但也只能在当前作用域下提升,即不会提升到上层作用域中。
function func() {
    // a、b两个变量在全局不能访问,但 c 可以
    var a = 10;
    let b = 20;
    c = 30;
}
// 每次调用函数,都会产生新的函数作用域,所以,下面两个func的执行,它们产生的函数作用域都是独立的
func();
func();


/*
从全局角度来说,func函数其内部的局部作用域;
从func角度来说,它自己的作用域可以称为局部/本地作用域,而test是其内部的局部作用域;
从test来说,它自己的作用域可以称为局部/本地作用域,
	func作用域是它的上层作用域,可以理解为"全局作用域",但func的外部的作用域才是真正的全局作用域;
为了避免混淆,别的语言中,test函数的上层作用域被称为嵌套作用域(Enclosing)。
*/
function func() {
    // 由于在函数内部也会存在变量和函数提升,所以,a 和 test 都能正常运行
    console.log('func中的a', a); // func中的a undefined
    test();

    var a = 10;
    let b = 20;
    function test() {
        var c = 30;
        let d = 40;
        // 在test中,访问上层的func的作用域中的a和b,由于var声明的变量能提升,let不能,所以a能打印;b不能打印
        console.log('test中的a ', a); // test中的a  undefined
        // console.log('test中的b', b); // ReferenceError: Cannot access 'b' before initialization
    }
    // 同理,外部作用域不能访问内部作用域中的变量,所以下面的打印也报错
    // console.log('func中的c', c); // ReferenceError: c is not defined
}
// 每次调用函数,都会产生新的函数作用域,所以,下面两个func的执行,它们产生的函数作用域都是独立的
func();

另外,对于局部作用域来说,可以访问外部的作用域中的变量,也可以修改。

var a = 10;
let b = 20;
function func() {
    console.log(a); // 10
    console.log(b); // 20
    a = 30;
    b = 40;
    console.log(a); // 30
}
func();
console.log(a); // 30
console.log(b); // 40

块作用域

我们通过一对{}来定义一个块,那么这个块内部的作用域就是块作用域,在es6之前,块作用域概念比较模糊,因为在块作用域中:

  • 可以访问和修改外部的变量。
  • 外部也可以访问其内部通过var声明的变量。
  • 块作用域中通过var声明的变量可以提升到全局去,而函数中不行。
console.log(c); // undefined
console.log(d); // undefined
var a = 10;
var b = 20;

{
    console.log('++++', a, b); // ++++ 10 20
    var c = 30;
    var d = 40;
    a++;
    b++;
    console.log('----', a, b); // ---- 11 21
}
console.log('****', a, b); // **** 11 21
console.log('####', c); // #### 30
console.log('####', d); // #### 40

琢磨来琢磨去,要这块作用域有何用?所幸,es6中,新增了let,这让块作用域有了些许作用:

  • 在块作用域中,可以访问和修改全局作用域的变量。
  • 外部无法访问内部通过let声明的变量,而且let也不能变量提升。
// console.log(c); // ReferenceError: c is not defined
// console.log(d); // ReferenceError: d is not defined
let a = 10;
let b = 20;

{
    console.log('++++', a, b); // ++++ 10 20
    // console.log('++++', c); // ReferenceError: Cannot access 'c' before initialization
    // console.log('++++', d); // ReferenceError: Cannot access 'd' before initialization
    let c = 30;
    let d = 40;
    a++;
    b++;
    console.log('----', a, b); // ---- 11 21
}
console.log('****', a, b); // **** 11 21
// console.log('####', c); // console.log('####', c); // #### 30
// console.log('####', d); // ReferenceError: d is not defined

作用域链

所谓的作用域链,也就是指的是变量的查找顺序,如下示例中,在函数f2中查找打印a的值,它首先现在它的局部作用域中找,找到就打印;它的局部作用域中如果没有就去它的上一层作用域f1中找,有就打印,没有就在往上一层作用域func中找,找到打印,没有就再往上一层全局作用域中找,找到打印,如果全局作用域中没有的话,就报错了。

let a = 10;

function func() {
    let a = 20;
    function f1() {
        let a = 30;
        function f2() {
            let a = 30;
            console.log(a);
        }
        f2();
    }
    f1();
}
func();

再来看,如下示例,问:打印a的结果是多少?

let a = 22;

function f1() {
    // a的结果是22
    console.log(a);
}

function f2() {
    let a = 33;
    f1();
}

f2();

结果是22,那为啥是22呢?

在js中,作用域也叫做词法作用域,简单来说,函数作用域由它定义的位置来决定,和调用位置无关。如上例,函数f2在定义的时候位于全局作用域中,相当于他就绑定到了全局作用域上了,你在任何地方调用f2,f2的上一级作用域都是全局作用域。

练习

let a = 10;
function func() {
    a = 20;
    console.log(a); // 20
}
func();
console.log(a); // 20

// -------------------------------------------

let a = 10;
function func() {
    let a = 20;
    console.log(a); // 20
}
func();
console.log(a); // 10

// -------------------------------------------

let a = 10;
function func() {
    a = 20;
    var a = 30;
    console.log(a); // 30
}
func();
console.log(a); // 10

// -------------------------------------------

let a = 10;
function func(a) {
    a = 20;
    console.log(a); // 20
}
func();
console.log(a); // 10

// -------------------------------------------

let a = 10;
function func(a) {
    console.log(a); // 10
    a = 20;
    console.log(a); // 20
}
func(a);
console.log(a); // 10

this

在调用函数时,浏览器会向函数中传递一个隐式的参数this,this是谁呢?答案是:谁调用的函数this就是谁。

function func() {
    let name = "func";
    console.log(this);
}

let obj1 = {
    let name: "obj1",
    fn: func
};

let obj2 = {
    let name: "obj2",
    fn: func
};

// func()等于window.func(),所以func中的this是window对象
func(); // window

// 以方法的形式调用函数时,this是这个调用方法的对象
obj1.fn(); // { name: 'obj1', fn: [Function: func] }
obj2.fn(); // { name: 'obj2', fn: [Function: func] }

arguments

在函数中,除了this之外,还有一个隐含参数arguments,它是一个类数组对象(伪数组),类数组对象和数组的操作方式基本一致,只是不能调用数组的方法。

在函数执行时,所有的实参都会存储在arguments中。有了arguments,即使不在形参位置填写参数,也能接收并使用实参。

它的应用场景也非常广泛,比如适用于实参数量不定的场景,如实现有个加法计算器:

function customSum() {
    // console.log(arguments);
    let res = 0;
    for (let i = 0; i < arguments.length; i++) {
        console.log(arguments[i]);
        res += arguments[i];
    }
    return res;
}
res = customSum(44, 33, 88, 22);
console.log(res);
/*
44
33
88
22
187
*/

匿名函数

匿名函数,既没有名字的函数,它怎么用?如何调用?

// 声明一个匿名函数
(function () {
    console.log('匿名函数被执行....');
})(); // 加括号执行

// 另一种写法
(function () {
    console.log('匿名函数被执行....');
}()); // 执行的括号放到外层括号的内部

匿名函数由于没有名字,所以没法重复执行,所以匿名函数只执行一次。

所以匿名函数也叫做自执行函数,更学术的叫法叫做函数表达式(IIFE,Immediately Invoked Function Expression)。

箭头函数

es6的新语法,让函数的定义和调用更加简单,通常适用于回调函数中,且箭头函数往往应用于简单的需求中,

/*
* 语法:
*   (arg1, arg2....) => 返回值
* 如果只有以参数,可以这么写
*       arg => 返回值
* 如果函数中有多行代码逻辑的话,就需要这么干,它跟普通函数是一样的
*       (arg1, arg2....) =>{
*           // 代码逻辑写这里
*       }
* 如果返回值是个对象的时候,需要将对象用括号包起来
*       (arg1, arg2....) => (object)
* */

// 例如我们定义一个求和函数,用普通函数这么些
function sum1(a, b) {
    return a + b;
}

console.log(sum1(2, 4)); // 6

// 用箭头函数可以这么写
// 将箭头函数赋值给sum2,然后通过sum2传值调用
let sum2 = (a, b) => a + b;
console.log(sum2(2, 4)); // 6


// 一个参数的形式的应用场景也挺广泛的
let arr = ['孙悟空', '猪八戒'];
// forEACH循环数组
arr.forEach(function (item) {
    console.log(item);
});

// 箭头函数应用在forEach中
arr.forEach((item) => console.log(item));
// 一个参数,括号也可以省略
arr.forEach(item => console.log(item));


// 如果箭头函数中有多行逻辑代码的话,需要用花括号括起来
arr.forEach(item => {
    console.log(item);
});

// 多如果返回值是对象的话,还需要加小括号进行标识,否则无法识别
// 另外,如果一个形参都没有,那么需要用一个空的括号来保证箭头函数的完整性
let func = () => ({name: "孙悟空"});
func();

箭头函数中的this

箭头函数中的this在函数创建时就已经确定了,它是由外层函数的作用域中的this来决定的。外层的this是谁,它的this就是谁。

let f1 = () => alert(this);  // window对象
// f1();

let f2 = function () {
  alert(this);
};
let obj = {
    f1: f1,
    f2:f2,
};
// obj.f1(); // 以方法.属性的形式调用f1,f1的this是window
// obj.f2(); // 以方法.属性的形式调用f2,f2的this是object
// 上面两个this的不同,是因为箭头函数的this在定义时就决定了, 它被定义在全局作用域中,所以它的this是window
// 而f2这个普通函数则遵循以方法.属性的方式调用,它的this就是object


// 再来确认一下
let obj2 = {
    test: function () {
        function fn1() {
            alert(this);
        }
        // 它的this取决于调用方式,直接以函数的形式调用,它的this就是window,即上层作用域是全局作用域
        // 而不是说fn1在obj2.test中调用,它的作用域就是obj2.test
        fn1();

        let fn2 = () => alert(this);

        // 虽说fn2和fn1调用方式一样,但fn2的this是object,这是因为它(fn2)的this由它定义的位置决定
        // 即它的上层作用域是object,所以它的this就是object
        fn2();
    }
};
obj2.test();

闭包函数

来个需求,每当函数调用一次,就记录一次,累加调用,记录也累加。

function wrapper() {
    let count = 0;
    function inner() {
        count++;
        console.log(count);
    }
    return inner;
}

let func = wrapper();
func(); // 1
func(); // 2
func(); // 3

闭包函数是指在函数(wrapper函数)内部定义的函数(inner函数),称为内部函数,该内部函数包含对嵌套作用域的引用,而不是全局作用域的引用。那么,该内部函数称为闭包函数。

闭包主要用来隐藏一些不希望被外部"看到"的变量。

闭包的构成:

  • 闭包函数必须内部有嵌套的函数。
  • 内部函数必须要访问外部函数的变量。
  • 必须将内部函数作为返回值返回。

闭包的生命周期:

  • 当外部函数调用时,闭包就产生了。
  • 外部函数每次调用都会产生一个闭包。
  • 当内部函数被销毁时,闭包也销毁了。

工厂方法

工厂方法,就像工厂一样,给原材料,这个工厂就会经过一番如此这般之后,给你返回对象。

function createPerson(name, age, gender, addr) {
    let obj = new Object();
    obj.name = name;
    obj.age = age;
    obj.gender = gender;
    obj.addr = addr;
    obj.sayHello = function () {
        console.log(`大家好,我是${this.name}`);
    };
    return obj;
}

function createAnimal(name, age, gender, addr) {
    let obj = new Object();
    obj.name = name;
    obj.age = age;
    obj.gender = gender;
    obj.addr = addr;
    obj.sayHello = function () {
        console.log(`大家好,我是${this.name}`);
    };
    return obj;
}
let swk = createAnimal('孙悟空', 18, 'male', '花果山');
let zbj = createAnimal('猪八戒', 18, 'male', '高老庄');
let shs = createPerson('沙和尚', 18, 'male', '流沙河');
let ts = createPerson('唐僧', 18, 'male', '长安');
console.log(swk);
swk.sayHello();
console.log(typeof swk); // object
console.log(typeof ts); // object

但是函数工厂也有缺陷,因为工厂函数内部创建对象是使用Object方法来做的,那么它创建的每个对象的类型都是Object。即,如上例中的几个猴子、人、猪他们的类型都是Object,而不是分为人类和动物。

也因此,在创建对象方面,有一个比工厂方法更好的手段——使用构造函数。

构造函数

构造函数?类?

构造函数是专门用来创建对象的函数。

构造函数的定义和和普通函数没有区别,但建议构造函数的函数名首字母大写(还不是强制的!)。

构造函数和普通函数的区别在于调用方式:

  • 一个函数如果直接加括号调用,那么它就是普通的函数。
  • 如果一个函数适用new关键字调用,那它就了不得了,它是构造函数。
function Person1() {}
function Person2() {}
// 定义之后,构造函数和普通函数一样
console.log(typeof Person1); // function
console.log(typeof Person2); // function

// 但调用方式的不同,就能看出来区别了
let p1 = Person1();
let p2 = new Person2();
// Person1函数没有显式的return,所以默认返回值是undefined
console.log(p1); // undefined
// 构造函数返回一个对象
console.log(p2); // Person2 {}

构造函数的执行流程:

  • new创建一个新的对象。
  • 将这个新的对象设置为函数中的this。
  • 执行构造函数中的代码(这一步是我们自己可以添加的,其它几步都是new来完成的)。
  • 然后返回该对象。

构造函数/类/实例

构造函数也被称为类,那么,通过构造函数创建的对现象,称为类的实例。

通过同一个类创建的对象,就是同一类对象。

function Person(name, age) {
    this.name = name;
    this.age = age;
    this.sayHello = function () {
        console.log(`大家好,我是${this.name}`);
    }
}

function Animal(name, age) {
    this.name = name;
    this.age = age;
    this.sayHello = function () {
        console.log(`大家好,我是${this.name}`);
    }
}

let swk = new Animal('孙悟空', 18);
let ts = new Person('唐僧', 18);
// 可以看到,孙悟空属于动物类;而唐僧属于人类
console.log(swk); // Animal { name: '孙悟空', age: 18, sayHello: [Function] }
console.log(ts); // Person { name: '唐僧', age: 18, sayHello: [Function] }

// 也可以通过instanceof运算符来判断一个实例是否是指定类的实例
console.log(swk instanceof Person); // false
console.log(swk instanceof Animal); // true
console.log(ts instanceof Person); // true
console.log(ts instanceof Animal); // false

目前来说,如上例这么创建的对象已经差不离了,但问题还是有的。

怎么说呢?每个实例都是独立的,所以每个实例中的方法也是独立,但是如上例的sayHello方法的功能都是一样的。就像学校中的每个学生都要上厕所、吃饭,难道学校还能为每个学生单独建立一个厕所和食堂么!不现实!

所以,我们要优化代码啊,思路就是将sayHello函数写外面吧,然后去引用:

// 将公共的方法写到全局去,然后谁用谁引用
function sayHello() {
        console.log(`大家好,我是${this.name}`);
    }

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

function Animal(name, age) {
    this.name = name;
    this.age = age;
    this.sayHello = sayHello;
}

let swk = new Animal('孙悟空', 18);
let ts = new Person('唐僧', 18);
console.log(swk.sayHello === ts.sayHello); // true

现在代码优化到这里,其实基本上已经差不多了,非要较劲儿的可能会说,你什么都往全局作用域中写,会污染全局的名称空间啊!!!的确是这样的!但是谁都不喜欢事儿多的人,不要理他!!我就写怎么了?!!

咳咳,我们可以用原型来解决上面的瑕疵。

原型

prototype

每个函数中都有一个属性——原型(prototype),该属性指向一个对象,叫做原型对象。

原型对象在普通函数中没有作用,它的作用在构造函数(下文以类相称)中。

通过类创建的实例,也会有个隐含的属性(__proto__),指向类的原型对象。

function Person() {}
// hasOwnProperty判断对象是否具有指定属性
console.log(Person.hasOwnProperty('prototype')); // true

let p1 = new Person();
let p2 = new Person();
let p3 = new Person();
console.log(p1.__proto__ === Person.prototype); // true
console.log(p2.__proto__ === Person.prototype); // true
console.log(p3.__proto__ === Person.prototype); // true

// 为对象添加属性
p1.name = "孙悟空";
p1.eat = function () {
    console.log(`${this.name}的吃方法!`);
};

// 向原型中添加属性和方法
Person.prototype.name = "女娲";
Person.prototype.sayHello = function () {
    console.log(`大家好,我是${this.name}`);
};
// 通过实例访问name属性
console.log(p1.name); // 孙悟空
console.log(p2.name); // 女娲
console.log(p3.name); // 女娲
p1.sayHello(); // 大家好,我是孙悟空
p2.sayHello(); // 大家好,我是女娲

参考上例的打印和下图,我们可以得出:

  • 原型对象,就是一个特殊的名称空间,它被绑定到了当前类的名称空间中。
  • 这个名称空间用来存储实例的公共的属性或者方法。
  • 相较于在上一小节中工厂方法中将公共的方法写在全局的名称空间中,使用原型不会污染全局的名称空间,让类和对象的结构更加合理。
  • 实例的属性或者方法的查找:如果实例中有同名的属性或者方法,就优先使用自己的,自己没有就自动的通过__proto__去原型对象中找,找到即返回,找不到的情况我们后续详细说。

原型链

原型的原型

作用域链的本质是变量查找的顺序,而原型链则指的是对象查找属性或者方法的顺序。也就是本小节我们主要研究的目的,对象查找属性或者方法,都经过了哪些步骤?查找不到怎么办这个问题的细节。

原型对象其实也有自己的原型对象,这句话有点绕,但我们可以通过示例反推出来它。

举个例子,通过构造对象构造出的实例,可以给它添加属性、方法之外,它本身也有自己的默认的方法和属性,那这些属性或者方法存在哪?

来看示例:

function Person() {}
let p1 = new Person();
p1.name = "孙悟空";
Person.prototype.sayHello = function () {
    console.log(`大家好,我是${this.name}`);
};

// 首先我们来确认name和sayHello是存在对象自己的命名空间内还是在对象的原型中
console.log(p1.hasOwnProperty('name')); // true
console.log(p1.hasOwnProperty('sayHello')); // false
// 通过上面的打印结果,可以发现name在自己的名称空间中,而sayHello不是,那sayHello存在哪了呢?
// 存在了对象的原型中,怎么查看呢?可以通过 对象.__proto__ 来查看,下面的打印结果告诉我们猜测正确
console.log(p1.__proto__.hasOwnProperty('sayHello'));  // true

// 新的问题来了:name和sayHello是我们自己定义的,那么对象默认的哪些比如hasOwnProperty它存在哪呢?
// 原型中肯定没有
console.log(p1.__proto__.hasOwnProperty('hasOwnProperty'));  // false
// 按照之前说的原型对象有自己的原型对象,那么如何找到原型的原型呢?
// 通过还是通过__proto__来做,即:
// 对象.__proto__  找到的是对象的原型
// 对象.__proto__.__proto__ 找到的是对象的原型的原型,来看实例
console.log(p1.__proto__.__proto__);  // 返回一个对象,有好多方法,其中就包括hasOwnProperty方法
console.log(p1.__proto__.__proto__.hasOwnProperty('hasOwnProperty'));  // true

// 上面的论证都没问题,那有一个新的疑问,原型有自己的原型,那么对象的原型的原型也有自己的原型....这不是没完没了么
// 我们来看看对象的原型的原型的原型.....到底有没有头
console.log(p1.__proto__.__proto__.__proto__); // null
// 如上打印结果,对象的原型的原型的原型返回了null,再往下找原型就报错了,如下打印,这说明有头
// console.log(p1.__proto__.__proto__.__proto__.__proto__); // TypeError: Cannot read property '__proto__' of null

通过上面的论证,我们知道原型对象虽然有自己的原型对象,但它总是有头的,即null就头,那null是怎么来的呢?

类的祖先:Object

这里直接说结论,所有类的祖先是Object,在js中,可以说是一切皆对象,那么Object对象的原型是一个null,即表示到头了。

console.log(Object.prototype.__proto__); // null

// 也可以使用instanceof来证明
console.log(p1 instanceof Person); // true
console.log(p1 instanceof Object); // true

所以,我们可以完善一下之前那张图片,来看下它完整形态:

上图就表示了Person类的实例对象查找属性或者方法的顺序:

  • 先看自己内部(别的语言中称为名称空间)有没有,有就返回;
  • 没有就去实例的原型中找,有就返回;
  • 没有就去原型的原型中找,找到就返回;
  • 没有就去Object的原型中找,而Object的原型是null,表示别找了,到头了,这说明你要找的方法或者属性不存在,就返回一个undefined,表示你找的方法或者属性没有定义,是的返回undefined而不是报错,这点要注意。

PS:总感觉js的类跟别的语言的类有点区别,比较模糊;比如原型这块就像Python中的继承的查找顺序。

重写方法

如果某些类的默认方法的功能不能满足你的需求,那么就可以通过重写该方法以满足你的应用场景。

// 例如你就像看下p1这个实例返回了什么,你用alert的话,得到的结果可能不太如你意
function Person() {}
let p1 = new Person();
let p2 = new Person();
alert(p1); // [object Object]
// 你可能对上面的打印结果不太满意,想改成你自己定制的形式,比如查看实例的详细信息
// 首先alert查看对象的实例,那么内部会调用p1.toString()将实例转为字符串,在展示,但结果也看不懂
// 想要看懂的话,就要对toSting进行重写了

// 下面介绍了两种重写的方式
// 重写的方法要想应用于所有的方法,就通过类的原型去重写
Person.prototype.toString = function () {
    return `Person {name: ${this.name}, age: ${this.age}}`;
};

// 只想重写某个实例的方法,其它实例不受影响,就通过实例去重写
p1.toString = function () {
    return `我是p1对象的toString`;
};

alert(p1); // 我是p1对象的toString
alert(p2); // Person {name: 猪八戒, age: 29}

包装类

JavaScript中内部实现了Number、String、Boolean三个包装类,可以用来直接创建Number、String、Boolean类型的对象。

注意,这三个包装类不是提供给我们创建对象用的,而是js内部用的,所以,本小节重在理解!!!

这里以Number为例:

// 值是基础数据类型Number
let n1 = 10;
let n2 = 10;
// Number对象,值是10
let n3 = new Number(10);
let n4 = new Number(10);
// 所以,虽然alert中显示的都是10,但其实不是这样的
// alert(n1); // 10
// alert(n2); // 10
// alert(n3); // 10
// alert(n4); // 10

// 因为,如果你作比较运算时,有区别了
// 前两个是判断两个基础数据类型的Number值是否相等
// 后两个是判断两个Number对象的内存地址是否相等,那肯定不想等了啊
// console.log(n1 === n2); // true
// console.log(n3 === n4); // false

// Number对象,既然是对象,就能添加属性和方法,来个例子
n3.name = "哈哈哈";
n1.name = '呵,沙雕!';
console.log(n1.name); // undefined
console.log(n3.name); // 哈哈哈
// n1.name,为什么是undefined?这是因为当我们调用一个基本数据类型的属性或者方法时,浏览器的引擎会
// 通过包装类临时将基础数据类型转换为对应的对象,比如将Number类型的n1转换为Number对象,然后调用其属性
// 或者方法,关键点:在临时转为对象时,哪有什么name方法或者属性!所以返回undefined
// 所以console.log(n1.name);和之前定义n1.name = '呵,沙雕!';是两回事,千万不要混为一谈!!!
// 而n3本身就是个对象,对象添加个属性不很正常么!!!
// 那你可能问了,那包装类有啥用啊?!!!当然有用了,你调用人家的默认方法或者属性就用到了啊
// 比如你调用n1.toString(),js内部将n1临时转换为Number对象,然后调用其toString方法,这就没问题了
// 因为Number对象本身内置了toString方法!!

说来说去,包装类就是让我们理解一些js内部的实现或者运行逻辑,让你对js理解更加深刻。

但在开发中,尽可能地不要用包装类。

再谈this,call/apply

在上面的this部分,我们简要说了说this是谁的问题,但其实还没说完,本小节来补充下。

先看示例:

function func() {
    console.log(this);
}

// 以函数形式调用,this是window
// func(); // window

// 以方法形式调用,this是调用的方法对象
// let obj = {fn: func}; // obj对象 {fn: ƒ}
// obj.fn();

// 以构造函数形式调用,this是构造出来的对象
new func(); // {}   new构造出来的空对象

// 是时候来研究新的问题了,我能不能自己决定this是谁?

call/apply

call和apply同为函数对象的方法,即这两个方法需要函数对象来调用。

call:

  • 当我们function.call()时,不传参数的话,就跟函数加括号执行一样,this还是Window对象。
  • call的第一个参数,可以用来指定this,你传谁,函数内部的this就是谁。
function func() {
    console.log(this);
}

// 当我们function.call()时,不传参数的话,就跟函数加括号执行一样,this还是Window对象。
// func.call(); // window

// 传window,this就是window
// func.call(window);

// 传object,this,就是obj
// let obj = {};
// func.call(obj);  // {}
  • call从第二个参数开始,都是为函数传递实参。
function func(a, b) {
    console.log(this, a, b); // {} "hello" "javascript"
}

let obj = {};
// 将 "hello" 和 "javascript" 从左到右依次为形参传参
func.call(obj, 'hello', 'javascript'); 

好,call说完了!

来说apply,apply和call的作用一样,唯一区别在于为形参传参的形式不太一样,它是以数组的方式传参的。

function func(a, b) {
    console.log(this, a, b); // {} "hello" "javascript"
}

// 当我们function.apply()时,不传参数的话,就跟函数加括号执行一样,this还是Window对象。
// 由于没有给形参传参,就得到两个undefined
// func.apply(); // window undefined undefined

// 传window,this就是window
// func.apply(window); // window undefined undefined

// 传object,this,就是obj
// let obj = {};
// func.apply(obj);  // {} undefined undefined


// 区别来了!!!
let obj = {};
func.apply(obj, ['hello', 'javascript']); // {} "hello" "javascript"
// apply将以数组的形式挨个为形参传参,这是它和call区别之处

递归

程序在调用一个函数的过程中,直接或者间接调用了该函数本身,我们称为递归调用。

如果程序在应用中无限调用自己,这种递归称为无限递归(死递归):

function recursion() {
    recursion();
}
recursion();  // RangeError: Maximum call stack size exceeded

由于递归调用会占用大量的内存空间,所以无限递归会报错,而报错内容则提示超过最大的栈的大小了,所以我们应该避开死递归的出现。

如何避免死递归呢?这就要说递归调用中的两个条件了:

  • 递归条件,对问题进行拆分。
  • 递归结束的条件。
function recursion(n) {
    console.log(n);
    if (n === 0){
        return
    }
    return recursion(n - 1);
}
recursion(100);

递归的主要思想就是对大问题进行拆分为小问题,然后解决小问题,最终达到解决大问题的目的。

练习

来个需求:使用函数,求任意数的阶乘,先来说什么是阶乘:

1! = 1 * 1
2! = 1 * 2
3! = 1 * 2 * 3
4! = 1 * 2 * 3 * 4
5! = 1 * 2 * 3 * 4 * 5
6! = 1 * 2 * 3 * 4 * 5 * 6
7! = 1 * 2 * 3 * 4 * 5 * 6 * 7
8! = 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8
9! = 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9
10! = 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * 10

好了,我们先来个普通的函数求阶乘:

function recursion(num) {
    let n = 1;
    for (let i = 1; i <= num; i++) {
        n *= i;
    }
    return n;
}
let res;
res = recursion(1); // 1
res = recursion(2); // 2
res = recursion(10); // 3628800
console.log(res);

阶乘如何使用递归实现呢?

先说思想,就是每次递归只计算当前数的阶乘:

如要计算6的阶乘,那么:
    recursion(6) --> 6 * recursion(5)
    recursion(5) --> 5 * recursion(4)
    recursion(4) --> 4 * recursion(3)
    recursion(3) --> 3 * recursion(2)
    recursion(2) --> 2 * recursion(1)
    recursion(1) --> 1

递归版:

function recursion(num) {
    if (num === 1) {
        return 1;
    }
    return recursion(num - 1) * num;
}
let res;
res = recursion(1); // 1
res = recursion(2); // 2
res = recursion(10); // 3628800
console.log(res);

练习:斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13

// 求第n位的斐波那契数列
function fibonacci(n) {
    if (n < 2) {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(10)); // 55

小节:

  • 递归必须有明确的结束条件,避免陷入死递归。
  • 递归使代码更加整洁、优美。
  • 递归调用过程晦涩难懂。
  • 递归非常的占用内存空间,因为每层递归都保留当前的层的状态信息,也会造成递归的时间成本更高,效率低下。

定时器

定时器可以分为两种:

  • 固定定时器,即在指定时间到期后,自动执行某个回调函数。
  • 轮询定时器,即周期性执行回调函数。

无论是哪种定时器,都有两个参数:

  1. 回调函数,你要干的事儿用回调函数实现。
  2. 以毫秒为单位的时间。

它们都返回一个定时器对象,方便你在适当的时候,清除定时器。

下面来看基本的用法:

let t1 = setTimeout(function () {
    // 在1000毫秒后,执行这部分代码
}, 1000);

// 清除指定的定时器
clearTimeout(t1);

let t2 = setInterval(function () {
    // 每1000毫秒执行一次这部分代码
}, 1000);
// 清除指定的定时器
clearInterval(t2);

对象的类型

  1. 内建对象,由ES标准所规定的对象:
    • Object/Function/String/Boolen/Number/Array/Match/Date/JSON.....
  2. 宿主对象,由js运行环境所提供的对象
    • Window/Console/Document.....
  3. 自定义对象,由开发人员定义的对象
posted @ 2022-02-15 11:37  听雨危楼  阅读(100)  评论(0编辑  收藏  举报