PART I this和对象原型
函数作用域和块作用域
区分函数声明和函数表达式
-
区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位置(不仅仅是一行代码,而是整个声明中的位置)。
如果 function 是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。
如:function foo(){…} 和 ( function foo(){});
-
函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处。
-
函数声明:名称标识符会被绑定在所在作用域中,可以直接通过()调用
-
函数表达式:名称标识符被绑定在函数表达式自身的函数而不是所在作用域
换句话说,(function foo(){ … }) 作为函数表达式意味着 foo 只能在 … 所代表的位置中被访问,外部作用域则不行。
-
提升
- 变量和函数声明从它们在代码中出现的位置被“移动”到了最上面,且遵循函数优先的原则,函数声明会被提升到普通变量之前。
foo(); // 1
var foo;
function foo() {
console.log( 1 );
}
foo = function() {
console.log( 2 );
};
-
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
-
如函数声明会被提升,而函数表达式不会被提升
foo();//foo函数的声明和实际函数的隐含值被提升了,因此这一行可以正常执行
function foo() {
console.log( a ); // undefined
var a = 2;
}
foo(); // 不是 ReferenceError, 而是 TypeError!
// 因为这里的foo只是被声明过了,但并没有被赋值
var foo = function bar() {//这里var foo会被提升
// ...
};
- 重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的
闭包
- 一个函数可以在自己定义的词法作用域以外的地方执行
- 闭包可以阻止引擎的垃圾回收。闭包会对所生命的位置的作用域的引用,使得该作用域能够一直存活,以供在之后任何时间进行引用。这个引用就叫做闭包
总结:这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的词法作用域
模块模式
-
模块模式必须具备两个必要条件
- 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块实例)
- 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并且可以访问或者修改私有的状态。
-
现代的模块机制:
var MyModules = (function Manager() { var modules = {}; function define(name, deps, impl) { for (var i=0; i<deps.length; i++) { deps[i] = modules[deps[i]]; } modules[name] = impl.apply( impl, deps ); } function get(name) { return modules[name]; } return { define: define, get: get }; })(); MyModules.define( "bar", [], function() { function hello(who) { return "Let me introduce: " + who; } return { hello: hello }; }); MyModules.define( "foo", ["bar"], function(bar) { var hungry = "hippo"; function awesome() { console.log( bar.hello( hungry ).toUpperCase() ); } return { awesome: awesome }; }); var bar = MyModules.get( "bar" ); var foo = MyModules.get( "foo" ); console.log(bar.hello( "hippo" )); // Let me introduce: hippo foo.awesome(); // LET ME INTRODUCE: HIPPO
-
未来的模块机制
//bar.js function hello(who) { return "Let me introduce: " + who; } export hello; //foo.js // 仅从 "bar" 模块导入 hello() import hello from "bar"; var hungry = "hippo"; function awesome() { console.log(hello( hungry ).toUpperCase()); } export awesome; //baz.js module foo from "foo"; module bar from "bar"; console.log(bar.hello( "rhino" )); // Let me introduce: rhino foo.awesome(); // LET ME INTRODUCE: HIPPO
-
import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上(在我们的例子里是 hello)。
-
module 会将整个模块的 API 导入并绑定到一个变量上(在我们的例子里是 foo 和 bar)。
-
export 会将当前模块的一个标识符(变量、函数)导出为公共 API
-
词法作用域和动态作用域
主要区别:
-
词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定的。(this 也是关注函数如何调用)
-
词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。
明确:JavaScript只有词法作用域,并不具有动态作用域
看看同一段代码的区别:
function foo() {
console.log( a ); // 词法作用域(JavaScript): Output 2(解释:通过RHS引用到了全局作用域中的a)
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
function foo() {
console.log( a ); // 动态作用域: Output 3(解释:从调用链往上找,foo里找不到a,而foo在bar中调用,bar里面找到了 a,好的直接用)
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();
PART II this和对象原型
-
this既不指向函数自身也不指向函数的词法作用域
this实际上是在函数被调用时发生的绑定,它的指向完全取决于函数在哪里被调用
-
参数传递其实就是一种隐式赋值
this绑定分类
-
默认绑定:当函数是直接使用不带任何修饰的函数引用进行调用时,只能使用默认绑定,this指向全局对象,无法应用其他规则
Ps:如果使用strict mode,全局对象将无法使用默认绑定,因此this会绑定到undefined
-
隐式绑定:当函数引用有上下文对象时,隐式绑定规则会把函数调用中的this绑定到这个上下文对象。
-
显示绑定:call apply bind
其中,硬绑定bind,返回一个函数,在这个函数中手动call或apply,强制绑定需要绑定的this,因此无论如何调用bind返回的这个函数,都无法改变那个this。
还有一些API中有上下文这个参数,作用和bind一样
如:
function foo(el) {
console.log( el, this.id );
}
var obj = {
id: "awesome"
};
// 调用 foo(..) 时把 this 绑定到 obj
[1, 2, 3].forEach( foo, obj );
// 1 awesome 2 awesome 3 awesome
这些函数其实就是通过call或apply实现了显示绑定.
-
new绑定:只是被 new 操作符调用的普通函数而已
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
-
创建(或者说构造)一个全新的对象。
-
这个新对象会被执行 [[ 原型 ]] 连接。
-
这个新对象会绑定到函数调用的 this。
-
如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
简单来说,就是new一个实例化对象出来,this绑定到这个对象上。
-
总结 判断this
现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:
-
函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
var bar = new foo()
-
函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是指定的对象。
var bar = foo.call(obj2)
-
函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
var bar = obj1.foo()
-
如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。
var bar = foo()
对象
对象的属性名都是字符串
在对象中,属性名永远都是字符串,如果使用string以外的其他值作为属性名都会首先被转换为一个字符串。
计算属性名
ES6中新增的可计算属性名,可以在文字形式中使用 [] 包裹一个***表达式***来当作属性名:
var prefix = "foo";
var myObject = {
[prefix + "bar"]:"hello",
[prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world
assign方法
ES6中新增的用于浅复制的方法Object.assign(…)
第一个参数是目标对象,之后还可以跟一个或多个源对象
它会遍历一个或多个源对象的所有可枚举(enumerable)的自有键(owned key)并把它们复制(使用 = 操作符赋值)到目标对象,最后返回目标对象
var newObj = Object.assign( {}, myObject );
newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true
属性描述符
从 ES5 开始,所有的属性都具备了属性描述符
var myObject = {
a:2
};
Object.getOwnPropertyDescriptor( myObject, "a" );
// {
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }
-
writable
writable 决定是否可以修改属性的值。
简单来说,可以把 writable:false 看作是属性不可改变。
严格模式下,如果要和 writable:false 一致的话, setter 被调用时应当抛出一个 TypeError错误。
-
enumerable
控制属性是否会出现在对象的属性枚举中,比如for…in循环
也可以通过另一种方式来区分属性是否可枚举:
propertyIsEnumerable(…)会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足 enumerable:true。
-
configurable
只要属性是可配置的,就可以使用 defineProperty(…) 方法来修改属性描述符
不管是不是处于严格模式,尝试修改一个不可配置的属性描述符都会出错
注意:把 configurable 修改成false 是单向操作,无法撤销!
var myObject = { a:2 }; myObject.a = 3; myObject.a; // 3 Object.defineProperty( myObject, "a", { value: 4, writable: true, configurable: false, // 不可配置! enumerable: true } ); myObject.a; // 4 myObject.a = 5; myObject.a; // 5 Object.defineProperty( myObject, "a", { value: 6, writable: true, configurable: true, enumerable: true } ); // TypeError
注意:即便属性是 configurable:false,我们还是可以把 writable 的状态由 true 改为 false,但是无法由 false 改为 true
除了无法修改,configurable:false 还会禁止删除这个属性:
var myObject = { a:2 }; myObject.a; // 2 delete myObject.a; myObject.a; // undefined Object.defineProperty( myObject, "a", { value: 2, writable: true, configurable: false, enumerable: true } ); myObject.a; // 2 delete myObject.a; myObject.a; // 2 delete失败
注意:delete 只用来直接删除对象的(可删除)属性
如果对象的某个属性是某个对象 / 函数的最后一个引用者,对这个属 性执行 delete 操作之后,这个未引用的对象 / 函数就可以被垃圾回 收。
但是,不要把 delete 看作一个释放内存的工具(就像 C/C++ 中那 样),它就是一个删除对象属性的操作,仅此而已。
ES5: Getter和Setter
-
在 ES5 中可以使用 getter 和 setter 部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。
getter 是一个隐藏函数,会在获取属性值时调用。
setter 也是一个隐藏函数,会在设置属性值时调用。
-
对于对象属性的访问会自动调用对应的隐藏函数,取值调用get,赋值调用set,它的返回值会被当作属性访问的返回值:
var myObject = { // 给 a 定义一个 getter get a() { return 2; } }; myObject.a = 3; myObject.a; // 2
由于只定义了a的getter没有定义它的setter,因此set操作会忽略复制操作,所以一般getter和setter都是成对出现
var myObject = { // 给 a 定义一个 getter get a() { return this._a_; }, // 给 a 定义一个 setter set a(val) { this._a_ = val * 2; } }; myObject.a = 2; myObject.a; // 4
存在性
-
in操作符会检查属性是否在对象及其[[Prototype]]原型链中。
hasOwnProperty(…)只会检查属性是否在对应对象中,不会检查原型链
注意:看起来 in 操作符可以检查容器内是否有某个值,但是它实际上检查 的是某个属性名是否存在。
对于数组来说这个区别非常重要,4 in [2, 4, 6] 的结果并不是你期待 的 True,因为 [2, 4, 6] 这个数组中包含的属性名是 0、1、 2,没有 4
遍历
ES6: for…of
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 }
@@iterator 本身并不是一个迭代器对象,而是一个返回迭代器对象的函数
和数组不同,普通的对象没有内置的 @@iterator,所以无法自动完成 for…of 遍历。这样做是为了避免影响未来的对象类型。
当然,可以自己为对象自定义一个@@iterator
var myObject = {
a: 2,
b: 3
};
Object.defineProperty( myObject, Symbol.iterator, {
enumerable: false,
writable: false,
configurable: true,
value: function() {
var o = this;
var idx = 0;
var ks = Object.keys( o );
return {
next: function() {
return {
value: o[ks[idx++]],
done: (idx > ks.length)
};
}
};
}
});
// 手动遍历 myObject
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }
// 用 for..of 遍历 myObject
for (var v of myObject) {
console.log( v );
}
// 2
// 3
类
明确:即使在ES6中,JavaScript实际上还是没有类,JavaScript是提供了一些近似类的语法
类意味着复制,什么都是复制
类被实例化时,它的行为会被复制到实例中
类被继承时,行为也会被复制到子类中
多态(在继承链的不同层次名称相同但是功能不同的函数)并不表示子类和父类有关联,子类得到的只是父类的一份副本。类的继承其实就是复制。
原型
[[Prototype]]
myObject.foo = "bar";
如果 myObject 对象中包含名为 foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。
如果 foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似 [[Get]] 操作。
如果原型链上找不到 foo,foo 就会被直接添加到 myObject 上。
如果属性名 foo 既出现在 myObject 中也出现在 myObject 的 [[Prototype]] 链上层,那么就会发生屏蔽。myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo 属性,因为myObject.foo 总是会选择原型链中最底层的 foo 属性。
-
如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性(参见第 3 章)并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
-
如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
-
如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter(参见第 3 章),那就一定会调用这个 setter。foo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这 个 setter。
ES5-:Object.create(…)和ES6: Object.setPrototypeOf(…)
Object.create(…) 会凭空创建一个“新”对象并把新对象内部的 [[Prototype]] 关联到你指定的对象
function Foo(name) {
this.name = name;
}
Foo.prototype.myName = function() {
return this.name;
}
function Bar(name, label) {
Foo.call(this, name);
this.label = label;
}
//创建一个新的 Bar.prototype 对象并把它关联到 Foo.prototype
//这种写法会抛弃默认的Bar.prototype,需要进行垃圾回收,有轻微的性能损失
Bar.prototype = Object.create(Foo.prototype);
Bar.prototype.myLabel = function() {
return this.label;
}
var a = new Bar("a", "obj a");
console.log(a.myName());
console.log(a.myLabel());
考虑把Bar.prototype = Object.create(Foo.prototype);这句话替换为其他形式想达到同样的效果(其实都不行,下面说说原因)
-
Bar.prototype = Foo.prototype;
这句话只是让Bar.prototype 直接引用 Foo.prototype 对象。
因此当你执行类似 Bar.prototype.myLabel = … 的赋值语句时会直接修改 Foo.prototype 对象本身。
-
Bar.prototype = new Foo();
这句话使用了 Foo(…) 的“构造函数调用”,如果函数 Foo 有一些副作用(比如写日志、修改状态、注册到其他对象、给 this 添加数据属性,等等)的话,就会影响到 Bar() 的“后代”,
ES6 添加了辅助函数 Object.setPrototypeOf(…),可以用标准并且可靠的方法来修
改关联。
Object.setPrototypeOf( Bar.prototype, Foo.prototype );
委托
用对象关联来实现
传统类:定义一个通用父(基)类,可以将其命名为Task,在 Task 类中定义所有任务都有的行为。接着定义子类 XYZ 和 ABC,它们都继承自Task 并且会添加一些特殊的行为来处理对应的任务。伪代码如下:
class Task {
id;
// 构造函数 Task()
Task(ID) { id = ID; }
outputTask() { output(id); }
}
class XYZ inherits Task {
label;
// 构造函数 XYZ()
XYZ(ID, Label) { super(ID);
label = Label; }
outputTask() { super();
output(label); }
}
class ABC inherits Task {
// ...
}
采用对象委托可以这样写:(利用Object.create(…))
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);
};
// ABC = Object.create( Task );
// ABC ... = ...
相比于面向类(或者说面向对象),我会把这种编码风格称为“对象关联”(OLOO,objects linked to other objects)。我们真正关心的只是 XYZ 对象(和 ABC 对象)委托了Task 对象。它 们 是 对 象。XYZ 通 过 Object.create(…) 创建,它的 [[Prototype]] 委托了 Task 对象(参见第 5 章)。
委托-对象关联设计模式总结
用一个控件(父)和按钮(子)的例子来总结一下这种牛逼的设计模式吧
- 传统类的方式:
class Widget {
constructor(width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
}
render($where) {
if (this.$elem) {
this.$elem.css({
width: this.width + "px",
height: this.height + "px"
}).appendTo($where);
}
}
}
class Button extends Widget {
constructor(width, height, label) {
super(width, height);
this.label = label || "Default";
this.$elem = $("<button>").text(this.label);
}
render($where) {
super($where);
this.$elem.click(this.onClick.bind(this));
}
onClick(evt) {
console.log("Button '" + this.label + "' clicked!");
}
}
$(document).ready(function() {
var $body = $(document.body);
var btn1 = new Button(125, 30, "Hello");
var btn2 = new Button(150, 40, "World");
btn1.render($body);
btn2.render($body);
});
- 采用对象关联方式:
$(function() {
var Widget = {
init: function(width, height) {
this.width = width || 50;
this.height = height || 50;
this.$elem = null;
},
insert: function($where) {
if (this.$elem) {
this.$elem.css({
width: this.width + "px",
height: this.height + "px"
}).appendTo($where);
}
}
}
var Button = Object.create(Widget);
//初始化
Button.setup = function(width, height, label) {
this.init(width, height);
this.label = label || "default";
this.$elem = $("<button>").text(this.label);
};
//渲染
Button.build = function($where) {
this.insert($where);
this.$elem.click(this.onClick.bind(this));
}
Button.onClick = function(event) {
console.log("Button '" + this.label + "'clicked!");
}
$(document).ready(function() {
var $body = $(document.body);
//1.创建 2.初始化 3.渲染
var btn1 = Object.create(Button);
btn1.setup(125, 30, "Hello");
var btn2 = Object.create(Button);
btn2.setup(150, 40, "World");
btn1.build($body);
btn2.build($body);
})
});
对象关联设计模式相较于ES6新增的class设计模式的优点:
-
没有像类一样在两个对象中都定义相同的方法名render(…)
相反,我们定义了两个更具描述性的方法名(insert(…) 和 build(…))。同理,初始化方法分别叫作 init(…) 和 setup(…)
-
简单的相对委托调用 this.init(…) 和 this.insert(…)来代替丑陋的显式伪多态调用(Widget.call 和 Widget.prototype.render.call)
-
使用类构造函数的话,你需要(并不是硬性要求,但是强烈建议)在同一个步骤中实现构造和初始化。
而对象关联可以更好地支持关注分离(separation of concerns)原则,创建和初始化并不需要合并为一个步骤。
举例来说,假如你在程序启动时创建了一个实例池,然后一直等到实例被取出并使用时才执行特定的初始化过程。这个过程中两个函数调用是挨着的,但是完全可以根据需要让它们出现在不同的位置。
ES6 的 class 最大的问题在于,(像传统的类一样)它的语法有时会让你认为,定义了一个 class 后,它就变成了一个(未来会被实例化的)东西的静态定义。你会彻底忽略 C 是一个对象,是一个具体的可以直接交互的东西。
在传统面向类的语言中,类定义之后就不会进行修改,所以类的设计模式就不支持修改。但是 JavaScript 最强大的特性之一就是它的动态性,任何对象的定义都可以修改。
总而言之,ES6 的 class 想伪装成一种很好的语法问题的解决方案,但是实际上却让问题更难解决而且让 JavaScript 更加难以理解。