JavaScript 高级特性

144 阅读18分钟

面向对象编程

面向对象的设计思想:

  • 抽象出Class(构造函数)
  • 根据Clsass(构造函数)创建Instance(实例)
  • 指挥Instance(实例)得结果

创建对象的几种方式

  • newObject()构造函数
  • 对象字面量{}
  • 工厂函数
  • 自定义构造函数 在JavaScript基础(2)的对象部分有相关说明。

静态成员和实例成员

使用构造函数方法创建对象时,可以给构造函数和创建的实例对象添加属性和方法,这些属 性和方法都叫做成员。
实例成员:在构造函数内部添加给this的成员,属于实例对象的成员,在创建实例对象后 必须由对象调用。
静态成员:添加给构造函数自身的成员,只能使用构造函数调用,不能使用生成的实例对象 调用。

构造函数的问题

这个问题就是浪费内存。
当两个实例同时生成,同样的方法会被创建两次,相当于同样的函数内容在内存中存在两份。
解决方法1: 将公共的函数提取到构造函数之外。但是也有问题:如果有多个公共函数,需要在外部创建多个函数,可能会造成命名冲突。
解决方法2:将多个公共的函数封装到一个对象。

var fns = {//这是字面量的写法
  sayName : function () {
    console.log(this.name);
  },
  sayAge : function () {
    console.log(this.age);
  }      
};    

但是这样还不够好,还是有冗余,于是有更好的办法来解决
解决方法3:使用原型对象。
JavaScript规定,每一个构造函数都有一个prototype属性,指向构造函数的原型对象。
这个原型对象的所有属性和方法,都会被构造函数的实对象所拥有。
因此,我们可以把所有对象实例需要共享的属性和方法直接定义在prototype对象上,解决内存浪费问题。

原型对象

prototype原型对象
任何函数都具有一个prototype属性,该属性是一个对象。
可以在原型对象上添加属性和方法。
构造函数的prototype对象默认都有一个constructor属性,指向prototype对象所在函数。
通过构造函数得到的实例对象内部会包含一个指向构造函数的prototype对象的指针,实例对象可以直接访问原型对象成员。
构造函数、实例、原型对象三者之间的关系:

// 定义构造函数
function Person(name,age) {
  this.name = name;
  this.age = age;
}
// 获取 构造函数 的 prototype 属性
console.log(Person.prototype);
// 属性值是一个对象,通常叫做原型对象
// 对象内部可以添加一些属性和方法
Person.prototype.type = "human";
Person.prototype.sayHi = function () {
  console.log("hello");
};
// Person.prototype.constructor = Array;
// 构造函数的 原型对象上面都默认有一个 constructor 属性
// console.log(Person.prototype.constructor);
// 创建实例对象
var p1 = new Person("Mike",18);
// 所有的对象都有一个 __proto__ 的属性,是一个指针,
// 指向的就是生成实例对象的 构造函数的原型对象
console.log(p1.__proto__);
console.log(p1.__proto__ === Person.prototype);
// __proto__ 属性并不是一个标准的属性,是浏览器自己根据语法自动生成的
// p1.__proto__.sayHi();
// 在真正开发的过程中,是不会书写 __proto__ 属性的
p1.sayHi();

现在来解决内存问题:

// 自定义构造函数
function Person(name,age) {
  this.name = name;
  this.age = age;      
}
// 更优的解决方法,将所有实例共享的属性和方法,都添加给原型对象
Person.prototype.type = "human";
Person.prototype.sayName = function () {
  // 方法调用时,哪个对象调用,this 指向的就是谁
  console.log(this.name);
};
Person.prototype.sayAge = function () {
  // 方法调用时,哪个对象调用,this 指向的就是谁
  console.log(this.age);
}
// 生成对象实例
var person1 = new Person("Bob",18);
var person2 = new Person("Mike",20);
// 调用原型对象上公用的属性和方法
person2.sayAge();
console.log(person1.sayAge === person2.sayAge);

原型链

思考:为什么实例对象可以调用构造函数的prototype原型对象的属性和方法? 当实例对象调用方法时,先查找实例方法,没找到再通过__proto__指针去找Person原型对象的方法,没找到又会再通过Person原型对象的__proto__指针去找Object原型对象的方法,没找到的话最后再通过Object原型对象的_proto__指针得到null值,查找结束。只要其中一层有该方法,实例对象就可以调用成功了。

原型链查找机制:
每当代码读取某个对象的某个属性时,都会执行一次搜索,目标是具有给定名字的属性:
1. 搜索从对实例本身开始
2.如果在实例中找到了具有给定名字的属性,则返回该属性的值
3.如果没有找到,则继续搜索指针指向的原型对象,在原型对象中查找具有给定名字的属性
4.如果在原型对象中找到了这个属性,则返回该属性的值

实例对象读写原型对象成员

  1. 读取:
  • 先在自己身上找,找到即返回
  • 自己身上找不到,则沿着原型链向上查找,找到即返回
  • 如果一直到原型链的末端还没有找到,则返回undefined
  1. 值类型成员写入(实例对象.值类型成员 = xx):
  • 当实例期望重写原型对象中的某个普通数据成员时实际上会把该成员添加到自己身上
  • 也就是说该行为实际上会屏蔽掉对原型对象成员的访问
  1. 引用类型成员写入(实例对象.引用类型成员 = xx):
  • 同上
  1. 复杂类型成员修改(实例对象.成员xx=xx):
  • 同样会先在自己身上找该成员,如果自己身上找到则直接修改
  • 如果自己身上找不到,则沿着原型链继续查找,如果找到则修改
  • 如果一直到原型链的末端还没有找到该成员,则报错(实例对象.undefined.xx = xx)
// 自定义构造函数
function Person(name,age) {
  this.name = name;
  this.age = age;     
}
// 将所有实例共享的属性和方法,都添加给原型对象
Person.prototype.type = "human";
Person.prototype.sayName = function () {
  console.log(this.name);
};
// 添加一个新的属性给原型对象,值是一个对象类型
Person.prototype.address = {
  city : "北京"
};
// 生成对象实例
var person1 = new Person("Bob",18);
var person2 = new Person("Mike",20);
// 读取 属性和方法
// console.log(person1.type);
// console.log(person1.city);
// console.log(person1.sayName);
// person1.sayName();
// 通过实例对象添加新成员,会直接添加给自己,会屏蔽掉对原型对象的访问
person1.sex = "male";
person1.sayAge = function () {
  console.log(this.age);
};
// 如果通过实例对象更改原型对象的属性和方法,会直接添加给自己,会屏蔽掉对原型对象的访问
person1.type = "person";
person1.sayName = function () {
  console.log(this.name);
}
console.dir(person1);
// 通过实例对象更改原型对象中复杂类型数据中的内容,还是会进行原型链的查找
person1.address.city = "上海";

更简单的原型语法

前面在原型对象每添加一个属性和方法就要书写一遍Person.prototype。
为减少不必要的输入,更常见的做法是用一个包含所有属性和方法的对象字面量来重写整个原 型对象,将Person.prototype重置到一个新的对象。
注意:原型对象会丢失constructor成员,所以需要手动将constructor指向正确的构造函数。

// 直接使用一个对象字面量对 原型对象进行赋值
Person.prototype = {
  constructor : Person,  // 需要手动 将 constructor 属性指向正确的构造函数
  type : "human",
  sayName : function () {
    console.log(this.name);
  }
};

原型对象使用建议

在定义构造函数时,可以根据成员的功能不同,分别进行设置:

  • 私有成员(一般就是非函数成员)放到构造函数中
  • 共享成员(一般就是函数)放到原型对象中
  • 如果重置了prototype记得修正constructor的指向

原生构造函数的原型对象

JS原生构造函数的原型对象, 所有函数都有prototype属性对象。

  • Object.prototype
  • Function.prototype
  • Array.prototype
  • String.prototype
  • Number.prototype ... 可在控制台输出或查看MDN手册查得

扩展原型数组的方法

// 扩展数组的原型方法
// 直接给原型对象添加一条新的属性
// 不允许更改内置的原型对象
Array.prototype.getEvenSum = function 
    // 获取数组中每一项的方式
    // this[i]
    var sum = 0;
    for (var i = 0 ; i < this.length ;
      if (i % 2 === 0) {
        sum += this[i];
      }
    }
    return sum;
  };
// 定义一个数组
var arr = [2,4,5,7,9];
console.log(arr.getEvenSum());
console.dir(Array.prototype);

随机方块案例

原生JS面向对象编程-随机方块案例

贪吃蛇游戏案例

原生JS面向对象编程-贪吃蛇游戏案例

继承

对象之间的继承

对象之间的继承就是对象拷贝,使用for...in将父对象的属性拷贝给子对象。实现如下:

// 封装一个对象之间继承的函数
function extend(parent, child) {
  for (var k in parent) {
    // 子级有的属性不需要继承
    if (child[k]) {
      continue;
    }
    child[k] = parent[k];
  }
}
// 调用函数实现继承
extend(laoli,xiaoli);
console.log(xiaoli);

原型继承

原型对象就相当于类型,原型对象继承就是类型继承。这种继承有个问题是,继承过来的属性是固定的,不能在最初的时候更改,后面我们将解决这个问题,我们先试下这种原型继承。

// 封装的构造函数就是用来创建一类对象
// 继承指的是 类型 和 类型之间的继承
// 学生类型  老师类型  --> 抽象,提取所有的公共的属性,放到一个 父类型中
// 当前学习阶段,没有一个专门的用来继承的方法
// 人类类型
function Person(name,age,sex) {
  this.name = name;
  this.age = age;
  this.sex = sex;
}
// 学生类型
function Student(score) {      
  this.score = score;
}
// 老师类型
function Teacher(salary) {
  this.salary = salary;
}
// 原型对象,可以将自己的属性和方法继承给将来的实例对象使用
Student.prototype = new Person("zs",18,"男");
Student.prototype.constructor = Student;
// 生成一个实例
var s1 = new Student(89);
var s2 = new Student(100);
console.dir(s1);
console.dir(s2);
console.log(s1.name);
console.log(s1.constructor);

函数的call方法

函数本身就是一种对象,就能够有自己的属性和方法。
call 方法本身是一种执行函数的方法
call 方法在调用函数的时候,有两个功能:

  1. 更改函数内部的 this 指向
  2. 调用函数执行内部代码
    参数: 第一个参数用来指定 this,第二个及以后,就是传的实参
function fn(a,b) {
  console.log(this);
  console.log(a + b);
}
var o = {
  name: "zs"
}
// 普通函数调用
// fn(3,4);
fn.call(o,3,4);

借用构造函数继承属性

之前说过,原型继承的问题是继承的属性是固定值,我们要解决这个问题,就需要借用构造函数来继承属性。
方法是:直接对父类型的构造函数进行一个普通调用。
普通调用过程中,内部的 this 指向的是 window,可以通过 call 方法更改Person 内部的 this。
上面的Student和Teacher对象的例子更改如下:

function Person(name,age,sex) {
  this.name = name;
  this.age = age;
  this.sex = sex;
}
// 学生类型
function Student(name,age,sex,score) {
  Person.call(this,name,age,sex); 
  this.score = score;
}
// 老师类型
function Teacher(name,age,sex,salary) {
  Person.call(this,name,age,sex); 
  this.salary = salary;
}
// 创建学生的实例对象
var s1 = new Student("zs",18,"男",89);
var s2 = new Student("ls",19,"男",92);
console.dir(s1);
console.dir(s2);

构造函数的原型方法继承

要想继承构造函数的原型对象的方法,有两种实现方式:

  1. 对象拷贝 将父对象的原型对象拷贝给继承对象的原型对象。注意跳过constructor的拷贝,保证constructor指向自己。
for (var k in Person.prototype) {
  // 保留自己的 constructor 不要进行继承
  if (k === "constructor") {
    continue;
  }
  Student.prototype[k] = Person.prototype[k];
} 
  1. 原型继承 将原型对象赋值为父级对象的实例,通过父级对象实例来访问父级对象的原型方法。当然,原型对象的constructor属性仍得是指向自己。
Student.prototype = new Person();
Student.prototype.constructor = Student;

组合继承

组合继承就是,属性在构造函数内继承,方法通过原型继承。 将来使用面向对象编程方法的时候,遇到两个有重复部分的构造函数,就可以使用组合继承来节约代码量和方便理解。

function Person(name,age) {
  this.name = name;
  this.age = age;
}
Person.prototype.sayHi = function () {
  console.log("你好");
}
// 生成一个子类型
function Teacher(name,age,salary) {
  // 继承父类的属性
  Person.call(this,name,age);
  this.salary = salary;
}
// 方法继承,通过原型对象继承
Teacher.prototype = new Person();
Teacher.prototype.constructor = Teacher;
// 生成老师的一个实例
var t1 = new Teacher("wang",45,10000);
console.dir(t1);
console.log(t1.name);

原型链如下,

函数的定义方式

  • 函数声明
  • 函数表达式
  • new Function构造函数

函数声明和函数表达式

函数声明与函数表达式的区别:

  • 函数声明必须有名字
  • 函数声明会函数提升,在预解析阶段就已创建,声明前后都可以调用
  • 函数表达式类似于变量赋值
  • 函数表达式可以没有名字,例如匿名函数
  • 函数表达式没有函数提升,在执行阶段创建;必须在表达式执行之后才可以调用。 所以我们建议,一般使用函数声明的方式。
    注意:在现代浏览器中,if等语句内部进行函数声明,实行的是变量声明提升,但是在一些低版本浏览器中(如ie8),实行的是函数声明提升。
console.log(fn);// 现代浏览器输出为undefined
// ie8输出为fn() {
//         console.log("fn-false");
//       },因为两个同名函数声明,后面的声明会覆盖前面的函数声明
if (true) {
  function fn() {
    console.log("fn-true");
  }
} else {
  function fn() {
    console.log("fn-false");
  }
}

我们可以使用函数表达式来解决这种兼容问题。

var fn;
if (true) {
      fn = function () {
        console.log("fn-true");
      }
    } else {
      fn = function () {
        console.log("fn-false");
      }
    }
fn();

但是,其他情况下我们还是建议使用函数声明的方式。

new Function构造函数

函数本身也是一种对象,可以调用属性和方法。因此,我们可以使用new Function构造函数的方法来创建(定义)函数。

var fun = new Function('a','b','var a = 1;console.log(a+b)');

等价于

function fn(a,b) {
  var a = 1;
  console.log(a + b);
}

因为要解析字符串,在使用new Function定义函数时,比使用函数声明效率要低,而且new Function定义函数不便于理解查看,我们只做一个了解。所以,
我们建议使用函数声明定义函数。

函数的调用和this

// 1. 普通的函数,是通过 给函数名或者变量名添加 () 方式执行
// 内部的 this 默认指向 window
function fun() {
  console.log(this);
}
fun();
// 2.构造函数,是通过 new 关键字进行调用
// 内部的 this 指向的是将来创建的实例对象
function Person(name) {
  this.name = name;
  console.log(this);
}
var p1 = new Person("zs");
Person();
// 3.对象中的方法,是通过对象打点调用函数,然后加小括号
// 内部的 this 默认指向的是调用的对象自己
var o = {
  sayHi: function () {
    console.log("haha");
  },
  fn: fun
}
// this 的指向是要联系执行的上下文,在调用的时候,是按照什么方式调用,指向是不一样的
o.fn();
// o.sayHi();
// 4.事件函数,不需要加特殊的符号,只要事件被触发,会自动执行函数
// 事件函数的内部 this 指向的是事件源
// document.onclick = function () {
//   console.log("事件");
// };
// 5.定时器和延时器中的函数,不需要加特殊的符号,只要执行后,在规定的时间自动执行
// 默认内部的 this 指向的是 window
// setInterval(function () {
//   console.log("time");
// },1000);

函数的调用方式决定了this的指向不同:

call、apply和bind 方法

call()方法调用一个函数,其具有一个指定的this值和分别地提供的参数(参数的列表)。 注意:该方法的作用和apply()方法类似,只有一个区别,就是call()方法接受的是若干个参 数的列表,而apply()方法接受的是一个包含多个参数的数组。
语法:fun.call(thisArg,argl,arg2,arg3,...)
thisArg 在fun函数运行时指定的this值,如果指定了null或者undefined则内部this指向window
argl,arg2, 指定的参数列表

function fun(a,b,c,d) {
  console.log(this);
  console.log(a + b + c + d);
}
// 函数内部在调用时,this有自己默认的指向
// console.dir(fun);
// fun(3,4);
// call 方法
// 1.功能:第一个可以指定函数的 this,第二个可以执行函数并传参
// 2.参数:第一个参数,传入一个指定让 this 指向的对象,
//         第二个参数及以后,是函数参数的列表
// 3.返回值:就是函数自己的返回值
// 4.测试
var o = {
  name: "zs"
}
// fun.call(o,1,2);
// apply 方法
// 1.功能:第一个可以指定函数的 this,第二个可以执行函数并传参
// 2.参数:第一个参数,传入一个指定让 this 指向的对象,
//         第二个参数是函数的参数组成的数组
// 3.返回值:就是函数自己的返回值
// 4.测试
// fun.apply(o,[4,5]);
// bind 方法
// 1.功能:第一个可以指定函数的 this,bind 方法不能执行函数,
//         但是可以传参
// 2.参数:第一个参数,传入一个指定让 this 指向的对象,
//         第二个参数及以后,是函数参数的列表
// 3.返回值:返回一个新的指定了 this 的函数,也可以叫绑定函数
// 4.测试
var fn = fun.bind(o,2,3);
console.log(fn);
fn(6,7);

call()应用

可以利用call方法借用其他对象原型的方法给本对象使用。

// 构造函数扩展原型对象方法
// Array.prototype.getSum = function () {
  // this 的指向
// };
// 数组中的方法
// var arr = [1,2,3,4];
// 使用的是 Array 构造函数的原型对象上的方法
// 方法内部的 this 指向的就是 arr 数组对象,操作的也是 arr 的对象
// arr.push();
// arr.splice();
// arr.getSum();
// {} 的对象自己是没有 push 方法的
// 类数组对象 getElementsByTagName
var o = {
  0: 10,
  1: 20,
  2: 30,
  length: 3
};
// console.log(o[0])
// 增加一项新的数据
// o["3"] = 40;
// o.length = 4;
// 利用数组中的 push 方法,指定内部的this 为对象 o,
// 就可以处理类数组对象的数据
Array.prototype.push.call(o,50);
console.log(o);

apply()的应用

apply()方法可以借用其他对象方法,利用数组传参来处理数组问题。适用于将数组拆散进行操作的问题。

// apply 方法可以指定一个函数的 this,并且通过数组方式进行传参
// fun.apply(this,[1,2]);
// 定义一个数组,利用 apply 方法,可以将它拆开进行操作
var arr = [1,3,4,6,8];
// 想借用一些现在内置在js 中的方法
// console.log(Math.max(1,3,5,7,9));
// 利用 apply 方法,将数组传给 max 的第二个参数
// console.log(Math.max.apply(Math,arr));
console.log(1,3,4,6,8);
console.log.apply(console,arr);// 相当于console.log(1,3,4,6,8);

bind()方法的应用

bind方法可以在不执行方法的时候就更改this的指向。

// 想修改的是定时器的函数内部的 this
var o = {
  name: "zs",
  age: 18,
  s: function () {
    setInterval(function () {
      console.log(this.age);
    }.bind(this),1000);
  }
}
// o.s();
// 更改 事件函数中的 this
document.onclick = function () {
  console.log(this);
}.bind(o);

函数的其他成员

// 自己打印输出一个函数
function fun() {
  console.log(1);
}
console.dir(fun);// 输出function对象
// 看一下函数内部的成员
function fn(a,b) {
  // 实际应用中,会在函数内部直接使用 一个 arguments 的关键字
  console.log(arguments);
  // console.log(arguments.callee);// 返回的是函数本身,实际直接使用函数名调用,不会调用该属性
  // 存储的是函数在调用时,传入的所有 实参 组成的一个类数组对象
  console.log(fn.arguments);
  // 函数的调用者,函数在哪个作用域调用,caller 就是谁,
  // 如果在全局调用,值就是 null
  console.log(fn.caller);
  // length 指的是形参的个数
  console.log(fn.length);
  // 函数的名字
  console.log(fn.name);
}
// function test() {
//   fn(1,2,3,4);
// }
// test();
// 灵活使用 arguments 类数组对象,可以记录所有的实参
// 模仿制作一个max方法
function max() {
  // 判断实参中最大的数
  var nowMax = arguments[0];
  for (var i = 1 ; i < arguments.length;i++) {
    if (arguments[i] > nowMax) {
      nowMax = arguments[i];
    }
  }
  return nowMax;
}
console.log(max(1,4,7,9));

高阶函数

  • 函数可以作为参数
  • 函数可以作为返回值
// 1.函数作为另一个函数的参数
// 定义一个函数,吃饭的函数,吃完饭之后,可以做其他的事情,看电影、
function eat(fn) {
  console.log("吃晚饭");
  // 接下来的要做的事情是不固定的
  fn();
}
eat(function () {
  console.log("看电影");
});
// 2.函数作为一个函数的返回值
// 需求:通过同一段代码实现以下效果
// 输出 100 + m
// 输出 1000 + m
// 输出 10000 + m
function outer(n) {
  return function inner(m) {
    console.log(m + n);
  }
}
// 在外部执行 inner 函数
//  100 + m
var fun = outer(100);
fun(3);
fun(13);
fun(23);
var fun1 = outer(1000);
fun1(3);

函数闭包

MDN上关于闭包的文档解释: 一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。

另一种理解:

观察闭包

从广义上来说,定义在全局的函数也是一个闭包,只是我们没办法将这样的函数拿到更外面的作用域进行调用,从而观察闭包的特点。
闭包是天生存在的,不需要额外的结构,但是我们为了方便观察闭包的特点,需要利用一些特殊结构将一个父函数内部的子函数拿到父函数外部进行调用,从而观察闭包的存在。

// 体会闭包
// 将一个内部函数拿到父函数的外面,
// 观察是否还能调用父函数内部的变量
function outer() {
  var a = 10;
  function inner() {
    console.log(a);
  }
  // 将inner 函数作为返回值
  return inner;
}
// 在outer函数的外面,是不能直接访问 a 变量
// outer();
// console.log(a);
// 将 outer 执行的结果,赋值给一个变量
var inn = outer();
// console.log(inn);
// 在全局调用 inn,按道理应该查找全局的 a变量
inn();
// 输出的真正结果是 10,来自于 outer 函数内部的变量,这就是闭包的特点

闭包的理解和应用

闭包的用途:

  • 可以在函数外部读取函数内部成员
  • 当外部读取函数内部成员时,会让函数内部成员始终存活在内存中

闭包的问题

闭包会有作用域问题,导致出现意料之外的结果。

// 给数组中的每一项赋值一个 函数
var arr = [];
for (var i = 0 ; i <= 10 ; i++) {
  // 自调用函数
  arr[i] = function () {
    console.log(i);
  };
}
// 目的:调用数组对应的项,输出它的对应下标
arr[0]();// 输出11
arr[1]();// 输出11
arr[2]();// 输出11

解决方法就是使用自调用函数。

// 给数组中的每一项赋值一个 函数
var arr = [];
for (var i = 0 ; i <= 10 ; i++) {
  // 自调用函数
  (function (i) {
    arr[i] = function () {
      console.log(i);
    };
  })(i);
}
// 目的:调用数组对应的项,输出它的对应下标
arr[0]();// 输出0
arr[1]();// 输出1
arr[2]();// 输出2

总结

JavaScript 高级特性需要好好理解,不然很容易出错