JS高级用法01
课程目标
- 学习JS原型和原型链
- 词法作用域和动态作用域
- 执行上下文
- 变量对象
- 作用域链
- this
- 闭包
知识要点
JS原型&原型链
prototype
prototype是只有函数才具有的属性,例如:
function Person(){}
Person.prototype.name = 'zhangSan';
var person1 = new Person();
var person2 = new Person();
console.log(person1.name)//zhangSan
console.log(person2.name)//zhangSan
函数的prototype属性指向了一个对象,这个对象是调用该构造函数而创建的实例的原型,也就是例子中person1和person2的原型。
-
什么是原型?
每一个JavaScript对象(null除外)在创建的时候会关联另一个对象,这个被关联的对象就是原型,每一个对象都会从原型"继承"属性。
_proto_
每一个JavaScript对象(除了null)都具有的一个属性,_proto_,这个属性会指向该对象的原型。
function Person() {
}
var person = new Person();
console.log(person.__proto__ === Person.prototype); // true
绝大部分浏览器都支持这个非标准的方法访问原型,然而它并不存在于Person.prototype中,实 际上,它是来自于 Object.prototype ,与其说是一个属性,不如说是一个 getter/setter, 当使用 obj.__proto__ 时,可以理解成返回了 Object.getPrototypeOf(obj)。
constructor
每个原型都有一个 constructor 属性指向关联的构造函数
function Person() {
}
console.log(Person === Person.prototype.constructor); // true
function Person() {
}
var person = new Person();
console.log(person.__proto__ == Person.prototype) // true
console.log(Person.prototype.constructor == Person) // true
console.log(Object.getPrototypeOf(person) === Person.prototype) // true
function Person() {
}
var person = new Person();
console.log(person.constructor === Person); // true
当获取person.constructor时,其实person并没有constructor属性,当不能读取到constructor属性时,会从person的原型Person.prototype中读取,所以:
person.constructor === Person.prototype.constructor
实例&原型
当读取实例的属性时,如果找不到,就回查找与对相关联的原型中的属性,如果还查不到,就继续找,直到找到最顶层为止。
例:
function Person() {
}
Person.prototype.name = 'zhangSan';
var person = new Person();
person.name = 'zhangSan';
console.log(person.name) // zhangSan
delete person.name;
console.log(person.name) // zhangSan
在这个例子中,给实例对象person添加了name属性,所以打印person.name的时候,结果为zhangSan; 但是删除了person的原型也就是peison._proto_后,会继续向上层Person.prototype中查找,结果为zhangSan。
那么问题来了,如果在最顶层还是没有找到呢?原型的原型又是什么呢?
原型的原型
原型对象就是通过Obiect构造函数生成的,实例的_proto_指向的是构造函数的prototype,所以最终关系图为:
原型链
由于Object.prototype没有原型,所以属性查找到Object.prototype时,就停止查找了。 所以原型链的关系图可以更新为:
继承
继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对 象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继 承,委托的说法反而更准确些。
词法作用域和动态作用域
作用域
作用域是指程序源代码中定义为变量的区域。
作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。
JavaScript采用词法作用域(静态作用域)。
静态作用域和动态作用域
因为JavaScript采用的是词法作用域,函数的作用域在函数定义的时候就决定了。
而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。
var value = 1;
function foo(){
console.log(value);
}
function bar(){
var value = 2;
foo();
}
bar();
分析上面例子的执行过程
- 执行
foo()函数,先从foo函数内部查找是否有局部变量value,如果没有,查找上层代码,也就是window中的全局变量value = 1,所以结果为1。 - 执行
foo()函数,依然是从foo函数内部查找是否有局部变量value。如果没有,就从调用函数的作用 域,也就是bar()函数内部查找value变量,所以结果会打印 2。 前面我们已经说了,JavaScript采用的是静态作用域,所以这个例子的结果是 1。
动态作用域
bash是动态作用域
value=1
function foo () {
echo $value;
}
function bar () {
local value=2;
foo;
}
bar //结果为2
面试题:
//case1
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();//local scope
// case 2
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();//local scope
JavaScript 函数的执行用到了作用域链,这个作用域链是在函数定义的时候创建的。嵌套的函数 f() 定
义在这个作用域链里,其中的变量 scope 一定是局部变量,不管何时何地执行函数 f(),这种绑定在执
行 f() 时依然有效。
执行上下文
顺序执行
var foo = function () {
console.log('foo1');
}
foo(); // foo1
var foo = function () {
console.log('foo2');
}
foo(); // foo2
function foo() {
console.log('foo1');
}
foo(); // foo2
function foo() {
console.log('foo2');
}
foo(); // foo2
JavaScript引擎并非一行一行的分析和执行程序,而是一段一段地分析执行。
可执行代码
JavaScript 的可执行代码( executable code)的类型:
- 全局代码
- 函数代码
eval代码
当执行到一个函数的时候,就会进行准备工作,这里的“准备工作”,用个更专业一点
的说法,就叫做"执行上下文( execution context)"。
执行上下文栈
JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文,栈结构遵循先入后出规则。
回顾上文面试题
两段代码的执行结果虽然相同,但是执行上下文栈的变化不一样
模拟第一段代码:
ECStack.push(<checkscope> functionContext);
ECStack.push(<f> functionContext);
ECStack.pop();
ECStack.pop();
模拟第二段代码:
ECStack.push(<checkscope> functionContext);
ECStack.pop();
ECStack.push(<f> functionContext);
ECStack.pop();
变量对象
当 JavaScript 代码执行一段可执行代码(executable code)时,会创建对应的执行上下文(execution context)。
每个执行上下文都有三个重要属性:
- 变量对象(
Variable object,VO); - 作用域链(
Scope chain); - this;
变量对象是与执行上下文相关的数据作用域,存储了上下文中定义的变量和函数声明。
全局上下文
- 全局对象是预定义的对象,作为JavaScript的全局函数和全局属性的占位符。通过使用全局对象。可以访问其他所有预定义的对象、函数、属性。
- 在顶层JavaScript代码中,可以用关键字
this引用全局对象,因为全局对象是作用域链的头,所有非限定性的变量和函数名都会作为该对象的属性来查询。
简单来说:
- 可以通过
this引用,在客户端JavaScript中,全局对象就是Window对象。 - 全局对象是由Object构造函数实例化的一个对象。
- 全局上下文中的变量对象就是全局对象。
函数上下文
在函数上下文中,用活动对象(activation object,AO)来表示变量对象。
活动对象和变量对象其实是一个东西,只是变量对象是规范上或者说是引擎上实现的,不可以在JavaScript环境中访问,只有进入一个执行上下文中,这个执行上下文的变量对象才会被激活,只有被激活的变量对象,也就是活动对象上的各种属性才能被访问。
活动对象是在进入函数上下文时刻被创建的,它通过函数的arguments属性初始化。arguments 属性值
是 Arguments 对象。
执行过程
执行上下文的代码会分成两个阶段进行处理:分析和执行,我们也可以叫做:
- 进入执行上下文;
- 代码执行;
进入执行上下文
进入执行上下文时,这时候还没有执行代码;
变量对象会包括:
-
函数的所有形参(如果是函数上下文)
- 由名称和对应值组成的一个变量对象的属性被创建;
- 没有实参,属性值设置为
undefined
-
函数声明
- 由名称和对应值(函数对象(
function-object))组成一个变量对象的属性被创建; - 如果变量对象已经存在相同名称的属性,则完全替换这个属性;
- 由名称和对应值(函数对象(
-
变量声明
- 由名称和对应值(
undefined)组成一个变量对象的属性被创建; - 如果变量名称跟已经声明的形参或函数相同,则变量声明不会干扰已经存在的这类属性
例:
function foo(a) { var b = 2; function c() {} var d = function() {}; b = 3; } foo(1);在进入执行上下文之后,这时候的AO是:
AO = { arguments:{ 0:1, length:1 } a:1, b:undefined, c:reference to function c(){}, d:undefined }在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值
当代码执行完成后,AO是:
AO = { arguments:{ 0:1, length:1 } a:1, b:2, c:reference to function c(){}, d:reference to FunctionExpression "d" }总结:
- 全局上下文的变量初始化是全局对象;
- 函数上下文的变量初始化只包含Arguments对象;
- 在进入执行上下文的时候会给变量对象添加形参、函数声明、变量声明等初始的属性值;
- 在代码执行阶段,会再次修改变量的属性值;
思考题
example 1
function foo() { console.log(a); a = 1; } foo(); // ??? function bar() { a = 1; console.log(a); } bar(); // ???第一段会报错:
Uncaught ReferenceError: a is not defined。 第二段会打印:1。 这是因为函数中的 "a" 并没有通过var关键字声明,所有不会被存放在AO中。 第一段执行 console 的时候,AO的值是:AO = { arguments: { lengthL 0 } }没有a的值,去全局找,全局也没有,所以报错
当第二段执行console时,全局对象已经被赋给了a属性,可以从全局找到a的值,所以会打印1
example 2
console.log(foo); function foo(){ console.log("foo"); } var foo = 1;会打印函数,而不是undefined
因为在进入执行上下文时,首先会处理函数声明,其次会处理变量声明,如果变量名称跟已经声明 的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性。
- 由名称和对应值(
作用域链
当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法 层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样 由多个执行上下文的变量对象构成的链表就叫做作用域链。
函数的作用域
函数的作用域在函数定义的时候就决定了。
因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以 理解 [[scope]] 就是所有父变量对象的层级链,但是注意:[[scope]] 并不代表完整的作用域链!
function foo() {
function bar() {
...
}
}
函数创建时,各自的[[scope]]为:
foo.[[scope]] = [
globalContext.VO
];
bar.[[scope]] = [
fooContext.AO,
globalContext.VO
];
函数激活
函数激活时。进入函数上下文,创建AO/VO后,就会将活动对象添加到作用域链的最前端。
这时候执行上下文的作用域链,命名为Scope
Scope = [AO].concat([[Scope]]);
作用域链创建完毕
总结
总结一下函数执行上下文中作用域链和变量对象的创建过程:
var scope = "global scope";
function checkscope(){
var scope2 = 'local scope';
return scope2;
}
checkscope();
执行过程如下:
-
checkscope函数被创建,保存作用域链到内部属性[[scope]]checkscope.[[scope]] = [ gloablContext.VO ]; -
执行
checkscope函数,创建checkscope函数执行上下文,checkscope函数执行上下文被压入执行上下文栈ECStack = [ checkscopeContext, globalContext ]; -
checkscope函数并不是立即执行,第一步:复制函数[[scope]]属性创建作用域链checkscopeContext = { Scope:checkscope.[[scope]] } -
第二步:用
arguments创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明 -
第三步:将活动对象压入
checkscope作用域链顶端checkscopeContext = { AO: { arguments: { length: 0 }, scope2: undefined }, Scope: [AO, [[Scope]]] } -
准备工作结束,开始执行函数,随着函数的执行,修改AO的属性值
checkscopeContext = { AO: { arguments: { length: 0 }, scope2: 'local scope' }, Scope: [AO, [[Scope]]] } -
查找到scope2的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
ECStack = [ globalContext ];
this
ECMAScript 的类型分为语言类型和规范类型。
ECMAScript 语言类型是开发者直接使用 ECMAScript 可以操作的。其实就是我们常说的Undefined,
Null, Boolean, String, Number, 和 Object。
而规范类型相当于 meta-values,是用来用算法描述 ECMAScript 语言结构和 ECMAScript 语言类型
的。规范类型包括:Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, 和 Environment Record。
我们只要知道在 ECMAScript 规范中还有一种只存在于规范中的类型,它们的作用是用来描述语言底层
行为逻辑。
这里要讲的重点是便是其中的 Reference 类型。它与 this 的指向有着密切的关联。
Reference
Reference 类型就是用来解释诸如 delete、typeof 以及赋值等操作行为的。
Reference 是一个 Specification Type,也就是 “只存在于规范里的抽象类型”。它们是为了更好
地描述语言的底层行为逻辑才存在的,但并不存在于实际的 js 代码中。
Reference 的构成,由三个组成部分,分别是:
base value;referenced name;strict reference;
base value 就是属性所在的对象或者就是 EnvironmentRecord,它的值只可能是 undefined, an Object, a Boolean, a String, a Number, or an environment record 其中的一种。
referenced name 就是属性的名称。
var foo = 1;
//对应的Reference是:
var fooReference = {
base: EnvironmentRecord,
name: 'foo',
strict: false
};
var foo = {
bar: function(){
return this;
}
};
foo.bar();
//bar对应的Reference是:
var BarReference = {
base: foo,
propertyName: 'bar',
strict: false
};
规范中还提供了获取 Reference 组成部分的方法,比如 GetBase 和 IsPropertyReference。
GetBase
返回 reference 的 base value。
var foo = 1;
var fooReference = {
base: EnvironmentRecord,
name: 'foo',
strict:false
};
GetValue(fooReference) //1
GetValue 返回对象属性真正的值,但是,调用 GetValue,返回的将是具体的值,而不再是一个
Reference
IsPropertyReference
如果 base value 是一个对象,就返回true。
如何确定this的值
-
计算
MemberExpression的结果赋值给ref; -
判断
ref是不是一个Reference类型; a. 如果ref是Reference,并且IsPropertyReference(ref)是true, 那么this的值为GetBase(ref)b. 如果ref是Reference,并且base value值是Environment Record, 那么this的值为ImplicitThisValue(ref)c. 如果ref不是Reference,那么this的值为undefined;
具体分析
计算 MemberExpression 的结果赋值给 ref
MemberExpression :
-
PrimaryExpression// 原始表达式 -
FunctionExpression// 函数定义表达式 -
MemberExpression [ Expression ]// 属性访问表达式 -
MemberExpression . IdentifierName// 属性访问表达式 -
new MemberExpression Arguments// 对象创建表达式
function foo(){
console.log(this)
}
foo();//MemberExpression是foo
function foo(){
return function(){
console.log(this)
}
}
foo()();//MemberExpression是foo()
var foo = {
bar: function(){
return this;
}
}
foo.bar();//MemberExpression是foo.bar
简单理解 MemberExpression 其实就是()左边的部分。
判断 ref 是不是一个 Reference 类型
看规范是如何处理各种 MemberExpression,返回的结果是不是一个Reference类型。
var value = 1;
var foo = {
value: 2,
bar: function () {
return this.value;
}
}
//示例1
console.log(foo.bar());//2
//示例2
console.log((foo.bar)());//2
//示例3
console.log((foo.bar = foo.bar)());//1
//示例4
console.log((false || foo.bar)());//1
//示例5
console.log((foo.bar, foo.bar)());//1
以上是在非严格模式下的结果,严格模式下因为 this 返回 undefined,所以示例 3 会报错。
示例1 foo.bar()
上面的demo中, MemberExpression 计算的结果是 foo.bar,那么 foo.bar 是不是一个
Reference 呢?
var Reference = { 1 base: foo, name: 'bar', strict: false };
如果 ref 是 Reference,并且 IsPropertyReference(ref) 是 true, 那么this的值为 GetBase(ref)该值是 Reference 类型,那么IsPropertyReference(ref)的结果是多少呢?
前面我们说了IsPropertyReference 方法,如果 base value 是一个对象,结果返回 true。
base value 为 foo,是一个对象,所以 IsPropertyReference(ref) 结果为 true。
这个时候我们就可以确定 this 的值:
this = GetBase(ref)
GetBase 也已经铺垫了,获得 base value 值,这个例子中就是foo,所以 this 的值就是foo,示例1的结
果就是 2。
示例2 (foo.bar)()
console.log((foo.bar)());
foo.bar 被 () 包住
实际上 () 并没有对 MemberExpression 进行计算,所以其实跟示例 1 的结果是一样的。
示例3 (foo.bar = foo.bar)()
示例3,有赋值操作符,
因为使用了 GetValue,所以返回的值不是 Reference 类型,
按照之前讲的判断逻辑,如果 ref 不是Reference,那么 this 的值为 undefined
this 为 undefined,非严格模式下,this 的值为 undefined 的时候,其值会被隐式转换为全局对象。
示例4 (false || foo.bar)()
因为使用了 GetValue,所以返回的不是Reference类型,this 为 undefined。
示例5 (foo.bar, foo.bar)()
因为使用了 GetValue,所以返回的不是 Reference 类型,this 为 undefined。
闭包
MDN 对闭包的定义为:
闭包是指那些能够访问自由变量的函数。
那什么是自由变量呢?
自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。
由此,我们可以看出闭包共有两部分组成:
闭包 = 函数 + 函数能够访问的自由变量
所以在《JavaScript权威指南》中就讲到:从技术的角度讲,所有的JavaScript函数都是闭包。
但是,这是理论上的闭包,其实还有一个实践角度上的闭包。
ECMAScript中,闭包指的是:
- 从理论角度:所有的函数。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简 单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量,这个时候使用最外 层的作用域;
- 从实践角度:以下函数才算是闭包: a. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回); b. 在代码中引用了自由变量;
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
var foo = checkscope();
foo();
执行过程:
- 进入全局代码,创建全局执行上下文,全局执行上下文压入执行上下文栈;
- 全局执行上下文初始化;
- 执行
checkscope函数,创建checkscope 函数执行上下文,checkscope执行上下文 被压入执行上下文栈; checkscope 执行上下文初始化,创建变量对象、作用域链、this等;checkscope 函数执行完毕, checkscope执行上下文从执行上下文栈中弹出;- 执行 f 函数,创建 f 函数执行上下文,f 执行上下文被压入执行上下文栈;
- f 执行上下文初始化,创建变量对象、作用域链、this等;
- f 函数执行完毕,f 函数上下文从执行上下文栈中弹出;
实践角度上闭包的定义:
- 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回);
- 在代码中引用了自由变量;
补充知识
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
data[0]();
data[1]();
data[2]();
答案是都是 3,让我们分析一下原因:
当执行到 data[0] 函数之前,此时全局上下文的VO为:
globalContext = {
VO: {
data: [...],
i: 3
}
}
当执行 data[0] 函数的时候,data[0] 函数的作用域链为:
data[0]Context = {
Scope: [AO, globalContext.VO]
}
data[0]Context 的 AO 并没有 i 值,所以会从 globalContext.VO 中查找,i 为 3,所以打印的结果就 是 3。 data[1] 和 data[2] 是一样的道理。
所以改成闭包:
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}
data[0]();
data[1]();
data[2]();
当执行到 data[0] 函数之前,此时全局上下文的 VO 为:
globalContext = {
VO: {
data: [...],
i: 3
}
}
跟没改之前一模一样。
当执行 data[0] 函数的时候,data[0] 函数的作用域链发生了改变:
data[0]Context = {
Scope: [AO, 匿名函数Context.AO globalContext.VO]
}
匿名函数执行上下文的AO为:
匿名函数Context = {
AO: {
arguments: {
0: 0,
length: 1
},
i: 0
}
}
data[0]Context 的 AO 并没有 i 值,所以会沿着作用域链从匿名函数 Context.AO 中查找,这时候就会
找 i 为 0,找到了就不会往 globalContext.VO 中查找了,即使 globalContext.VO 也有 i 的值(值为3),
所以打印的结果就是0。
data[1] 和 data[2] 是一样的道理。