一次搞定作用域闭包和原型

发现自己一直在追求新技术的道路上狂奔,却忽视了最基础的东西。本来我是觉得上手一个新技能很重要,这样在做东西的时候不会被淘汰。最近一个大神点醒了我:新出来的框架/库那么多,一个人精力也很有限,很容易在追新的道路上越来越累,也越来越迷茫。当你掌握了所有基础的东西之后,你就会发现新的东西无非就是在基础上面的拓展。这样在学习使用一个新的框架的时候,根据文档API很快就能上手。我觉得很有道理。

基础知识

函数也是对象,是Function类型的实例,函数名实际上是一个指向函数对象的指针。

1、一个函数可以有多个名字:

function sum (num1, num2) {
return num1 + num2;
}
//这与下面使用函数表达式定义函数的方式几乎相差无几。
var sum = function(num1, num2){
return num1 + num2;
};//这个分号不能少,因为这是一个变量

2、函数是对象,函数名是指针:

function sum(num1,num2){
return num1+num2;
}
alert(sunm(10,10));//20
var anotherSum=sum;
anotherSum(10,10);//20
sum=null;
anotherSum(10,10);//20
//即使将sum设置为null仍可以正常调用函数
//使用不带括号的函数名是访问函数指针,而非调用函数

3、函数可以作为参数传递给另一个函数:因为函数名本身就是一个变量,所以函数可以作为值来使用。还可以将一个函数作为另一个函数的结果返回。

function callSomeFunction(someFunction,someArgument){
return someFunction(someArgument)
}
//使用:
function add(num){ return num+10}
var result=callSomeFunction(add,10);
alert(result)//20

4、函数内部属性:

(1)在函数内部有两个特殊的对象:arguments和this,arguments是一个类数组对象,包含传入函数中的所有参数。arguments还有一个叫callee的属性,该属性是一个指针,指向拥有这个arguments对象的函数。

//arguments.length是实参长度,函数名.length是形参
function factorial(num){
if(num<=1){
return 1;
}else{
return num*factorial(num-1)
}
}
//解耦函数名可以替换成arguments.callee
function factorial(num){
if(num<=1){
return 1;
}else{
return num*arguments.callee(num-1)
}
}

(2)函数对象的另一个属性是caller:这个属性保存着调用当前函数的函数的引用,如果是在全局作用域中调用当前函数,它的值为null。

function outer(){
inner()
}
function inner(){
alert(inner.caller);//inner.caller也可以用arguments.callee.caller替换来实现松耦合
}
outer();//得到 function outer(){inner()},因为outer()调用了inner(),所以inner.caller就指向outer();

5、函数属性和方法:

(1)每个函数都有两个属性:length和prototype

length表示每个函数希望接收的命名参数的个数。

function sayName(name){
alert(name)
}
alert(sayName.length);//1,只有一个参数

prototype是保存所有实例方法的所在,即toString()和valueOf()等方法都是保存在prototype名下,只不过是通过各自对象的实例访问罢了。prototype属性不可枚举,不能通过for in 访问。

(2)每个函数都有两个非继承而来的方法:call()和apply(),用途都是在特定的作用域中调用函数,实际上等于设置函数体内this对象的值。

call()方法比较常用,接收两个参数,第一个参数是this,其余参数都直接传递给函数,即参数必须逐个列出来:

fn.call()接收两个参数,第一个参数表示fn运行时的this,第二个参数表示传递给fn的参数,apply()同理。

function sum(num1,num2){
return num1+num2;
}
function callSum(num1,num2){
return sum.call(this,num1,num2)
}
callSum(10,10);//20

6、其他继承的方法如toString()、toLocaleString()、valueOf()都返回函数的代码。


作用域

JS中的作用域分为全局作用域、函数作用域和块级作用域(ES6新增)。作用域在定义一个函数时就确定好了,不会在执行中改变。

全局作用域:在浏览器中是window,在node环境中是global。

函数作用域:属于这个函数的全部变量都可以在整个函数范围内使用及复用。

*块级作用域:现在是通过let和{}来实现。只在{}代码块内有效,也就不存在变量提升的问题。let和块级作用域还有一些特殊的地方需要注意,这里不再展开。

作用域和执行上下文密不可分:

  • 执行上下文是一段程序运行所需要的最小数据的集合。
  • 作用域是当前上下文环境中,按照具体规则能够访问到的标识符(变量)的范围。

变量和函数的优先级:

函数是一等公民,所以函数声明会被提升到当前作用域顶端,再然后变量声明会提升,函数声明和变量声明冲突时会覆盖变量声明。变量的声明会提升但是赋值不会提升。

this:这是一个麻烦的东西。要记住,this只有执行时才可以确定指代的是谁,定义时是无法确认的。

使用场景:

  • 作为构造函数调用(此时this指向新生成的对象)
  • 作为对象属性执行(此时this指向这个对象)
  • 作为普通函数执行(此时this指代全局)
  • call/apply/bind(call用的较多,此时this指代call内部的参数,如果为空则指向全局)

例子:

var a={
name:'A',
fn:function(){
console.log(this.name)
}
}
a.fn();//this===a
a.fn.call({name:'B'});//this==={name:'B'}
var fn1=a.fn;
fn1();//this===window

闭包

当函数可以记住并访问所在的作用域时,就产生了闭包,即使函数是在当前的作用域之外执行。

闭包的作用:(1)从外部访问局部变量(2)将变量的值保存在内存中

function outer(){
var n=100;
function inner(){
console.log(n);//100
}
return inner;//将函数return出去即可实现从外部读取内部的变量
}
var fn=outer();
fn(101);//全局变量优先级低于局部变量

上面的代码,inner()的作用域能够访问outer()的内部作用域,然后inner所引用的函数对象被当做返回值传递到外部。outer()被执行后,其内部的返回值(也就是inner()函数)被赋值给变量fn,实际上是通过不同的标识符引用了内部的函数inner()。这就是inner()在自己定义的作用域之外的地方执行。

JS的垃圾回收机制会销毁执行之后的函数内部作用域,但是由于闭包的存在,阻止了回收,因为被从外部调用了。这种从外部引用函数内部作用域的情况就叫闭包。

不是例子的栗子:

for(var i=0;i<10;i++){
setTimeout(function(){
console.log(i)
},i*1000)
}

上面的循环运行结果是什么?是不是预期是每隔一秒log出0-9的数字,然而并不是,而是10个10。因为setTimeout()是一个异步方式执行的函数,它会在所有的循环之后才会执行,即使时间变为0(面试知识点,但别这么做~~)。那我再改进一下:

for(var i=0;i<10;i++){
(function(){
setTimeout(function(){
console.log(i)
},i*1000)
})()//立即执行函数
}

我立即执行,但是隔一秒出来了10个10,显然也不对。因为这个IIFE(立即执行函数的作用域是空的)。最后我们改进一下:

for(var i=0;i<10;i++){
(function(i){
setTimeout(function(){
console.log(i)
},i*1000)
})(i)//立即执行函数
}

这回才真正如你所愿。

栈内存和堆内存:栈内存一般存储大小固定的数据,一般是值类型数据。堆内存一般存储大小不固定的数据,一般是引用类型数据。引用类型的指针一般存储在栈内存中。

内存泄露:JS会在创建变量时自动分配内存,在不被使用时自动释放,这个过程称为垃圾回收。如果没有正常回收则为内存泄露。一般情况如下:

  • 意外创建的全局变量
  • 被遗忘的定时器setInterval或者回调函数
  • 闭包阻止内存回收
  • DOM的引用(如果不用了要赋值为null)

原型

  • 所有的引用类型都有对象特性,即可自由扩展属性(null除外)。
  • 每个引用类型都有一个__proto__(隐式原型)属性,属性值是一个普通的对象。
  • 每个函数也都有一个prototype(显式原型)属性,属性值也是一个普通的对象。

    prototype的属性值指向函数的原型对象,这个原型对象又有一个constructor属性,这个属性包含一个指向prototype属性所在函数的指针。默认只有一个constructor属性,其他的属性和方法都是从Object继承而来。

  • 所有的引用类型的__proto__指向它的构造函数的prototype属性值,即隐式原型===显式原型。

    当调用构造函数创建一个实例后,实例内部包含一个__proto__属性,该属性的值指向构造函数的原型对象。即连接只存在于实例与构造函数的原型之间,而不是实例与构造函数之间。换句话说,prototype就是通过调用构造函数而创建的那个对象实例的原型对象。

  • 当试图得到一个引用类型(或对象)的某个属性时,如果这个对象本身没有这个属性,那么会去它的__proto__(即它的构造函数的prototype)中查找。

理解原型对象

由上面最后一条引申出原型链:查找某个对象的属性或方法时,先从对象自身查找,找不到会往上到它的构造函数的原型prototype上查找,直到找到或者null。

确定是本身的对象和方法还是原型上的,可以用isPropertyOf()

继承

JS的继承主要是依靠原型链来实现的,基本思想是利用原型让一个引用类型继承另一个引用类型的属性和方法。

继承分为两种情况,构造函数的继承和普通对象的继承(即创建一个新的对象)。

1、通过new来实现继承(构造函数)——有缺陷

每个构造函数都有一个原型对象,原型对象都有一个指向构造函数的指针,new出来的实例都有一个指向原型对象的内部指针。
new出来的对象obj.__proto__指向构造函数Obj.prototype,构造函数Obj.prototype.__proto__指向Object.prototype,同时构造函数Obj.prototype还自带一个construcor属性指向构造函数Obj本身。

new操作符的作用:

  1. 创建一个新对象
  2. 将构造函数的作用域赋给新对象(因此this就指向了新对象)
  3. 执行构造函数中的代码(为这个对象添加属性)
  4. 返回新对象

注意:new出来的不同实例上的同名方法不是同一个Function的实例

function Person(age,name){
this.age=age,
this.name=name,
this.sayName=function(){
alert(this.name)
}
}
//可以写成如下形式:
function Person(age,name){
this.age=age,
this.name=name,
this.sayName=new Function('alert(this.name)');//逻辑上是等价的
}
//可以通过如下方法判断:
person1.sayName==person2.sayName;//false

因为new出来的实例不能共享原型的属性和方法,无法达到复用。

2、原型链继承——有缺陷,看例子

function SuperType(){
this.colors=['red','green']
}
function SubType(){}
SubType.prototype=new SuperType();//继承
var instance1=new SubType();
instance1.colors.push('black');
alert(instance1.colors);//'red,green,black'
var instance2=new SubType();
alert(instance2.colors)//'red,green,black'

修改一个实例的属性会导致所有的实例属性都被改变。同时也不能向父类传递参数。

3、组合式继承:使用原型链实现对原型属性和方法的继承,然后通过借用构造函数实现对实例属性的继承,还可以通过call()来传递参数——这是最常用的方法。

function SuperType(name){
this.name=name;
this.colors=['red','green'];
}
SuperType.prototype.sayName=function(){
alert(this.name)
}
function SubType(name,age){
SuperType.call(this.name);//继承属性且传递了一个name参数,第二次调用超类型
this.age=age;
}
//继承方法:
SubType.prototype=new SuperType();//第一次调用超类型,SubType已经得到超类型的属性
SubType.prototype.constructor=SubType;
SubType.prototype.sayAge=function(){
alert(this.age);
}
var instance1=new SubType('Apple','10');//这次又将属性实例化一次,只不过是屏蔽了原型中的同名属性。
instance1.colors.push('black');//这回就不会影响其他实例了
alert(instance1.colors);//'red,green,black'
instance1.sayName();//Apple
instance1.sayAge();//10

这个方法避免了单独使用原型链和构造函数的缺陷,是较常用的实现继承的方式。但是同样不完美,需要调用两次超类型构造函数,一次是创建子类型原型的时候,一次是在子类型内部。

组合式继承的缺陷

4、原型式继承:ES5通过Object.create()方法规范化了原型式继承——类似浅拷贝创建了一个副本,但是同样有缺陷

Object.create()第一个参数用作新对象原型的对象,第二个参数为新对象定义额外属性的对象。

只有一个参数:

var obj = {
name: 'apple',
age: 10
}
var obj1=Object.create(obj);
obj1;//此时obj1.__proto__={name:'apple',age:10}

有第二个参数:可覆盖原来的属性值,也可添加新的属性值

var obj = {
name: 'apple',
age: 10
}
var obj1=Object.create(obj,{
age:{
value:12
}
});
obj1;//此时obj1={age:12},obj1.__proto__={name:'apple'}

包含引用类型值的属性始终都会共享相应的值

5、寄生式继承——略,不推荐。

6、寄生组合式继承——完美继承

通过借用构造函数来继承属性,通过原型链的混成形式来继承方法。基本思路是:不必为了指定子类型的原型而调用超类型的构造函数,我们所需的无非就是超类型原型的一个副本而已。本质上就是使用寄生式继承来继承超类型的原型,然后再将结果指定给子类型的原型。

function Parent(name) {
this.name = name;
}
Parent.prototype.play = function() {
console.log(this.name);
}
function Child(name, age) {
Parent.call(this, name);
this.age = age;
}
Child.prototype = Object.create(Parent.prototype);//创建一个空对象,并将空对象的原型指向Parent
var t=new Child('Apple',10);
t.play();//Apple

参考:
(1)《JavaScript高级程序设计(第三版)》
(2)JavaScript继承方式详解
(3)深入理解javascript原型和闭包系列
(4)JavaScript深入浅出