你不知道的JS(上卷)
作用域是什么
1.1编译原理
JavaScript引擎编译的步骤与传统的编译语言类似。程序中的一段源代码在执行前会经历三个步骤。
分词/语法分析:将字符串分解成有意义的代码块。这些代码块被称为词法单元。
解析/语法分析:将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。
代码生成:将树转换成可执行代码的过程。
1.2理解作用域
变量的赋值操作:如果之前没声明过这个变量,编译器则在当前作用域中声明一个变量,然后运行时,引擎会在作用域中查找该变量,如果能够找到则对它赋值。
function foo(a){
var b = a;
return a + b;
}
var c = foo(2);
//找到其中所有LHS查询
//找到其中所有RHS查询
RHS查询:得到某某的值。LHS查询:找到变量容器的本身。
上题答案:
LHS:c = .. 、a = 2 、b = ..
RHS: foo(2.. 、= a 、a .. 、..b
1.3作用域嵌套
当一个块或函数嵌套在另一个块或函数中,就发生了作用域的嵌套。因此在当前域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量。
1.4异常
在变量还没有声明的情况下,这两种查询的行为不一样。
如果RHS查询在所有嵌套的作用域中找不到所需变量,引擎会抛出ReferenceError异常。
如果LHS查询在所有嵌套的作用域中找不到所需变量,就会在全局作用域中创建一个具有该名称的变量,并将其返还给引擎,前提是在非严格模式下。
ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是不合理的。
词法作用域
2.1词法阶段
词法作用域就是定义在词法阶段的作用域。
作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符。这叫做“遮蔽作用”。
2.2欺骗词法
欺骗词法作用域会导致性能下降。
使用eval
eval函数可以接受一个字符串为参数,将其中的内容视为好像在书写时就存在于程序中这个位置的代码。如果eval函数的参数是一个赋值语句,就相当于欺骗词法,真的在那里创建一样,如果外作用域有相同的变量则永远不会被找到。在严格模式中,eval有自己的作用域,意味着其无法修改所在的作用域。
使用with
with通常被当做重复引用同一个对象中的多个属性的快捷方式。下面代码在o2作用域中查找不到a变量,一直到全局也查找不到,所以LHS查询会在全局作用域创建一个a变量。
function foo(obj){
with(obj){
a = 2;
}
}
var o1 = {
a:3
}
var o2 = {
b:3
}
foo(o1);
console.log(o1.a); // 2
foo(02);
console.log(02.a); // undefined
console.log(a); // 泄漏到全局作用域
性能
出现eval()或者with时,引擎只能简单地假设关于标识符位置的判断都是无效的。因为不知道会有什么代码会传进eval,也不知道传递给with用来创建新词法作用域的对象内容到底是什么。
函数作用域和块作用域
函数中的作用域
属于这个函数的全部变量都可以在整个函数的范围内使用及复用。
隐藏内部实现
最小限度的暴露必要内容,而将其他内容都“隐藏”起来。
可以避免同名标识符之间的冲突。
避免冲突的方法:
1.全局命名空间。通常在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作命名空间,所有需要暴露给外界的功能都会成为这个对象的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
2.模块管理。
函数作用域
在任意代码片段外部添加包装函数,可以将内部的变量和函数定义隐藏起来。但是这样必须需要一个具名函数,意味着这个名称本身污染了所在作用域,其次,必须显式的通过函数名来调用这个函数来运行代码。
可使用函数表达式来解决,区分函数声明和函数表达式最简单方法就是看function关键字出现在声明中的位置,如果function是第一个词就是函数声明,否则就是函数表达式。在函数表达式后加()可立即执行。
立即执行函数表达式进阶用法是把它们当做函数调用并传递参数进去。
块作用域
用with从对象中创建出的作用域仅在with声明中而非外部作用域中有效。
用try/catch的catch分句会创建一个块作用域。
使用let,为块作用域显式地创建块可以部分解决代码混乱问题。使附属关系变得更清晰。
使用const,定义常量,在之后如果修改此变量则会报错。
使用块作用域对于闭包及回收内存垃圾的回收机制有关。
提升
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。函数声明会被提升,但是函数表达式不会被提升。
函数优先
函数首先被提升,其次是变量。
作用域和闭包
当函数可以记住并访问所在的词法作用域,就产生了闭包,即使函数是在当前词法作用域之外执行。将函数当作第一级的值进行传递,就是闭包在这些函数中的应用,回调函数,实际上就是在使用闭包。
循环和闭包
for (var i = 1; i <= 5 ; i++){
setTimeout(function timer() {
console.log( i );
},1000*i)
}
正常情况下,我们对这段代码的预期是分别输出1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次6。因为延迟函数的回调会在循环结束时才执行。因此需要用函数表达式自动执行来解决。
for (var i = 1; i <= 5 ; i++){
(function(j){
setTimeout(function timer() {
console.log( i );
},1000*i)
})(i)
}
使用let在循环头部定义变量,这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
模块
需要具备两个条件:
1.必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
2.封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
模块模式用法:
命名将要作为API返回的对象。通过在模块实例的内部保存对公共API对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改它们的值。
在ES6中可以使用import将一个模块中的一个或多个API导入到当前作用域中,并分别绑定在一个变量上。module会将整哥模块的API导入并绑定到一个变量上。export会将当前模块的一个标识符导出为公共API。这些操作可以在模块定义中根据需要使用任意多次。
关于this
this提供了一种更优雅的方式来隐式传递一个对象的引用,因此可以将API设计的更加简洁并且易于复用。
this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。
this的绑定对象规则
1.默认绑定
// 独立函数调用
function foo(){
console.log( this.a );
}
var a = 2;
foo(); // 2
在代码中,foo()是直接使用不带任何修饰的函数引用进行调用的,因此只能使用默认绑定,无法应用其他规则。如果使用严格模式,则不能将全局对象用于默认绑定。
2.隐式绑定
调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含,不过这种说法可能会造成一些误导。
function foo(){
console.log( this.a );
}
var obj = {
a: 2,
foo: foo
};
obj.foo(); // 2
当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。因为调用foo()时this被绑定到obj,因此this.a和obj.a是一样的。
3.显示绑定
使用call()和apply()方法,它们的第一个参数是一个对象,是给this准备的,接着在调用函数时将其绑定到this。
function foo(){
console.log( this.a );
}
var obj = {
a:2
}
foo.call( obj ); // 2
通过foo.call(),我们可以在调用foo时强制把它的this绑定到obj上。
4.new绑定
构造函数只是一些使用new操作符时被调用的函数。它们不会属于某个类,也不会实例化一个类。
使用new来调用函数,或者说发生构造函数调用时,会自动执行下面的操作:
- 创建一个全新的对象。
- 这个新对象会被执行[[Prototype]]连接。
- 这个新对象会绑定到函数调用的this。
- 如果函数没有返回其它对象,那么new表达式中的函数调用会自动返回这个新对象。
优先级
- 函数是否在new中调用(new绑定)?如果是的话this绑定的是新创建的对象。
- 函数是否通过call、apply(显式绑定)或者硬绑定调用?如果是的话this绑定的是指定的对象。
- 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话this绑定的是哪个上下文对象。
- 如果都不是的话,使用默认绑定。如果在严格模式下,就爱绑定到undefined,否则绑定到全局对象。
绑定例外
如果你把null或者undefined作为this的绑定对象传入call、apply或者bind,这些值在调用时会被忽略,实际采用的是默认绑定规则。但是默认绑定会把this绑定到全局对象,可能会修改全局对象。
可以创建一个空的非委托的对象var DMZ = Object.create( null );
this语法
箭头函数不使用this的四种标准规则,而是根据外层(函数或者全局)作用域来决定this。箭头函数的绑定无法被修改,new也不行。
对象
语法
对象可以通过两种形式定义:声明形式和构造形式。
//声明形式
var obj = {
key:value
// ...
}
// 构造形式
var obj = new Object();
obj.key = value;
构造形式和文字形式生成的对象是一样的。唯一的区别是在文字声明中你可以添加多个键值对,但是在构造形式中你必须逐个添加属性。
类型
对象是JavaScript的基础。一共有六种主要类型。
- string
- number
- boolean
- null
- undefined
- object
注意简单基本类型(除object外)本身并不是对象。null有时会被当作一种对象类型,但是这其实只是语言本身的一个bug,即对null执行typeof null
时会返回object,实际上,null本身是基本类型。
内置对象
JavaScript中还有一些对象子类型,通常被称为内置对象。
- String
- Number
- Boolean
- Object
- Function
- Array
- Date
- RegExp
- Error
在JavaScript中,它们实际上只是一些内置函数。这些内置函数可以当做构造函数(由new产生的函数调用)来使用,从而可以构造一个对应子类型的新对象。
内容
对象的内容是由一些存储在特定命名位置的值组成的,我们称为属性。在对象中,属性名永远都是字符串。
可计算属性名
ES6增加了可计算属性名,可以在文字形式中使用[]包裹一个表达式来当作属性名。
var a = "abc";
var obj = {
[a + 'cjx'] : "hello",
[b + 'lcc'] : "world"
};
obj["abccjx"]; // hello
obj["abclcc"]; // world
数组
数组也支持[]访问,数组期望的是数值下标(存储的位置),数组也是对象,可以为数组添加属性。
复制对象
浅复制:复制出的新对象变量的值会复制旧对象中变量的值,但是在新对象中,属性如果是引用,它们和旧对象中引用的对象是一样的。ES6定义了Object.assign()方法来实现浅复制。
深复制:除了复制对象之外,还会复制对象的引用的对象。
属性描述符
普通的对象属性对应的属性描述符,除了包含value还包含另外第三个特性:writable(可写)、enumerable(可枚举)、configurable(可配置)
在创建普通属性使,属性描述符会使用默认值,我们可以使用Object.defineProperty(...)
来添加一个新属性或修改一个已有属性(如果是可配置的)并对特性进行设置。
- Writable
决定是否可以修改属性的值 - Configurable
只要属性是可配置的,就可以使用Object.defineProperty(...)
方法来修改属性描述符。 - Enumerable
属性是否会出现在对象的属性枚举中,如果你不想某些特殊属性出现在枚举中,那就把它设置为enumerable:false
不变性
- 对象常量
结合writable:false
和configurable:false
就可以创建一个真正的常量属性。 - 禁止扩展
如果想禁止一个对象添加新属性并且保留已有属性,使用Object.preventExtensions(..)
- 密封
Object.seal(..)
会创建一个“密封”的对象,这个方法会在一个现有对象上调用Object.preventExtensions(..)
并把所有现有属性标记为configurable:false
。 - 冻结
Object.freeze(...)
会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..)
并把所有“数据访问”属性标记为writable:false
。
[[Get]]
var myObject = {
a : 2
};
myObject.a; // 2
myObject.a
是一次属性访问,但是这条语句并不仅仅是在myObject中查找名字为a的属性。在语言规范中,myObject.a在myObject上实际上是实现了[[GET]]操作(有点像函数调用)。对象默认的内置[[GET]]操作首先在对象中查找是否有名称相同的属性。如果没有找到,会遍历可能存在的[[Prototype]]链。
[[Put]]
[[Put]]被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性。
如果已经存在这个属性,[[Put]]算法大致会检查下面这些内容:
- 属性是否是访问描述符?如果是并且存在setter就调用setter。
- 属性的数据描述符中writable是否是false?如果是在非严格模式下静默失败,在严格模式下抛出TypeError
- 如果都不是,将该值设置为属性的值。
Getter和Setter
对象默认的[[Put]]和[[Get]]操作分别可以控制属性值的设置和获取。
当你给一个属性定义getter、setter或者两者都有时,这个属性会被定义为“访问描述符”(和数据描述符相对)。对于访问描述符来说,JavaScript会忽略他们的value和writable特性。
存在性
我们可以在不访问属性值的情况下判断对象中是否存在这个属性:
var myObject = {
a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty("a"); // true
myObject.hasOwnProperty("b"); // false
in操作符会检查属性是否在对象及其[[Prototype]]原型链中。hasOwnProperty(...)只会检查属性是否在myObject对象中,不会检查原型链。
枚举
enumerable
属性描述符,表示可枚举性。当设置为false时,可以访问值但是却不会出现在for...in循环中,“可枚举”就相当于“可以出现在对象属性的遍历中”。
propertyIsEnumerable(..)
会检查给定的属性名是否直接存在于对象中(而不是原型链中)并且满足enumerable:true
Object.keys(..)
会返回一个数组,包含所有可枚举属性。Object.getOwnPropertyNames(..)
会返回一个数组包含所有属性,无论他们是否可枚举。
in
和hasOwnProperty(..)
的区别在于是否查找[[Prototype]]链,然而Object.keys(..)
和Object.getOwnPropertyNames(..)
都只会查找对象直接包含的属性。
遍历
forEach(..)
会遍历数组中的所有值并忽略回调函数的返回值。every(..)
会一直运行直到回调函数返回false。some(..)
会一直运行直到回调函数返回true。
for...of
循环语法,首先回想被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next()
方法来遍历所有返回值。数组内有内置的@@iterator
,因此for..of可以直接应用在数组上。我们使用内置的@@iterator
来手动遍历数组。
var myArray = [1,2,3];
var it = myArray[Symbol.iterator]();
it.next(); // { value:1, done:false }
it.next(); // {value:2, done:false}
it.next(); // {value:3, done:false}
it.next();// {done:true}
调用迭代器next()
方法会返回形式为{value: .. , done: ..}
的值,value是当前的遍历值,done表示是否还有可以遍历的值。
混合对象“类”
构造函数
类实例是由一个特殊的类方法构造的,这个方法名通常和类名相同,被称为构造函数。
类的继承
定义好一个子类之后,相对于父类来说它就是一个独立并且完全不同的类。子类会包含父类行为的原始副本,但是也可以重写所有继承的行为甚至定义新行为。
多态
任何方法都可以引用继承层次中高层的方法(无论高层的方法名和当前方法名是否相同)。在继承链的不同层次中一个方法名可以被多次定义,当调用方法时会自动选择合适的定义。
原型
[[Prototype]]
JavaScript中的对象有一个特殊的[[Prototype]]内置属性,其实就是对于其他对象的引用。
Object.prototype
所有普通的[[Prototype]]链最终都会指向内置的Object.prototype。
属性设置和屏蔽
如果一个属性既出现在对象中页出现在对象的[[Prototype]]链上层,那么就会发生屏蔽,对象包含的属性总会屏蔽掉原型链上层的属性,因为选择属性总是会选择原型链中最底层的属性。
“类”
所有的函数默认都会拥有一个名为prototype的公有并且不可枚举的属性,它会指向另一个对象。
function Foo(){
//...
}
Foo.prototype; // { }
这个对象通常被称为Foo的原型。
function Foo(){
//...
}
var a = new Foo();
Object.getPrototypeOf(a) === Foo.prototype; // true
调用new Foo()
时会创建a,其中一步就是将a内部的[[Prototype]]链接到Foo.prototype所指向的对象。
构造函数
对构造函数最准确的解释是,所有带new的函数调用。函数不是构造函数,但是当且仅当使用new时,函数调用会变成构造函数调用。
对象关联
[[Prototype]]机制就是存在于对象中的一个内部链接,它会引用其他对象。作用是:如果在对象上没有找到需要的属性或者方法引用,引擎就会继续在[[Prototype]]关联的对象上进行查找。同理,如果在后者中也没有找到需要的引用就会继续查找它的[[Prototype]],以此类推。这一系列对象的链接被称为原型链。
行为委托
Task = {
setID: function(ID) { this.id = ID; },
outputID: function() { console.log( this.id ); }
};
// 让XYZ委托Task
XYZ = Object.create(Task);
XYZ.prepareTask = function(ID,Label){
this.setID(ID);
this.Label = Label;
};
XYZ.outputTaskDetails = function() {
this.outputID();
console.log( this.Label );
};
- 在上面代码中,id和label数据成员都是直接储存在XYZ上(而不是Task)。通常来说,在[[Prototype]]委托中最好把状态保存在委托者(XYZ)而不是委托目标(Task)上。
- 在类设计模式中,我们故意让父类和子类都有相同方法,这样就可以利用重写的优势。在委托行为中则恰好相反:我们会尽量避免在[[Prototype]]链的不同级别中使用相同的命名,否则就需要使用笨拙并且脆弱的语法来消除引用歧义。
- this.setID(ID);XYZ中的方法首先会寻找XYZ自身是否有setID(..),但是XYZ中并没有这个方法名,因此会通过[[Prototype]]委托关联到Task继续寻找,这时就可以找到setID(..)方法。
委托行为意味着某些对象在找不到属性或者方法引用时会把这个请求委托给另一个对象(Task)。
更好的语法
- 在ES6中我们可以在任意对象的字面形式中使用简洁方法声明,所以对象关联风格的对象可以不使用function(和class的语法糖一样)唯一的区别就是对象的字面形式仍然需要使用“,”来分割元素,而class语法不需要。
- 在ES6中可以使用对象的字面形式(这样就可以使用简洁方法定义)来改写之前繁琐的属性赋值语法,然后用
Object.setPrototypeOf(..)
来修改它的[[Prototype]]