美文网首页让前端飞
读《你不知道的JavaScript》笔记(二)

读《你不知道的JavaScript》笔记(二)

作者: 前端辉羽 | 来源:发表于2020-07-17 11:33 被阅读0次

本文是系列(二),记录的是《你不知道的JavaScript》上卷第二部分的1-3章,第一篇连接https://www.jianshu.com/p/23815e221ffe
本文目录

  • 第二部分 this和对象原型
    • 第1章 关于this
    • 第2章 this全面解析
    • 第3章 对象

第二部分 this和对象原型

第1章 关于this

误解:函数中的this指向函数自身
看下面的代码

function foo(num) {
    console.log("foo: " + num); // 记录 foo 被调用的次数
    this.count++;
}
foo.count = 0;
var i;
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo(i);
    }
}
// foo: 6 
// foo: 7 
// foo: 8 
// foo: 9 
// foo 被调用了多少次? 
console.log(foo.count); // 0

console.log 语句产生了 4 条输出,证明 foo(..) 确实被调用了 4 次,但是 foo.count 仍然 是 0。显然this并不指向函数自身。
解决方法之一是使用 foo 标识符替代 this 来引用函数 对象:

function foo(num) {
    console.log("foo: " + num);
    // 记录 foo 被调用的次数 foo.count++; 
}
foo.count = 0
var i;
for (i = 0; i < 10; i++) {
    if (i > 5) {
        foo(i);
    }
}
// foo: 6 
// foo: 7 
// foo: 8
// foo: 9 
// foo 被调用了多少次?
console.log(foo.count); // 4

从某种角度来说这个方法确实“解决”了问题,但可惜它忽略了真正的问题——无法理解 this 的含义和工作原理——而是返回舒适区,且完全依赖于变量 foo 的词法作用域。
另一种方法是强制 this 指向 foo 函数对象:

function foo(num) {
    console.log("foo: " + num);
    // 记录 foo 被调用的次数 
    // 注意,在当前的调用方式下(参见下方代码),this 确实指向 foo
    this.count++;
}
foo.count = 0;
var i;
for (i = 0; i < 10; i++) {
    if (i > 5) {
        // 使用 call(..) 可以确保 this 指向函数对象 foo 本身 
        foo.call(foo, i);
    }
}
// foo: 6 
// foo: 7 
// foo: 8
// foo: 9 
// foo 被调用了多少次?
console.log(foo.count); // 4

这次我们接受了 this,没有回避它。如果你仍然感到困惑的话,不用担心,之后我们会详 细解释具体的原理。

下面这段代码非常的经典

function foo() {
    var a = 2;
    this.bar();
}
function bar() {
    console.log(this.a);
}
foo(); // ReferenceError: a is not defined

这段代码的错误不止一个,非常完美(同时也令人伤感) 地展示了 this 多么容易误导人。
之前我们说过 this 是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调 用时的各种条件。this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

第2章 this全面解析

通常来说,寻找调用位置就是寻找“函数被调用的位置”,但是做起来并没有这么简单, 因为某些编程模式可能会隐藏真正的调用位置。
思考下面的代码:

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo; // 函数别名!
var a = "oops, global"; // a 是全局对象的属性 
bar(); // "oops, global"

虽然 bar 是 obj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的 bar() 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。
一种更微妙、更常见并且更出乎意料的情况发生在传入回调函数时:

function foo() {
    console.log(this.a);
}
function doFoo(fn) {
    // fn 其实引用的是 foo 
    fn(); // <-- 调用位置! 
}
var obj = {
    a: 2,
    foo: foo
};
var a = "oops, global"; // a 是全局对象的属性 
doFoo(obj.foo); // "oops, global"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以结果和上一 个例子一样。
如果把函数传入语言内置的函数而不是传入你自己声明的函数,会发生什么呢?结果是一 样的,没有区别:

function foo() {
    console.log(this.a);
}
var obj = {
    a: 2,
    foo: foo
};
var a = "oops, global"; // a 是全局对象的属性 
setTimeout(obj.foo, 100); // "oops, global"

JavaScript 环境中内置的 setTimeout() 函数实现和下面的伪代码类似:

function setTimeout(fn, delay) {
    // 等待 delay 毫秒 fn(); // <-- 调用位置! 
}

第三方库的许多函数,以及 JavaScript 语言和宿主环境中许多新的内置函数,都提供了一 个可选的参数,通常被称为“上下文”(context),其作用和 bind(..) 一样,确保你的回调 函数使用指定的 this。
举例来说:

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(..) 实现了显式绑定,这样你可以少些一些 代码。如果我们使用forEach不传第二个可选参数,结果会是1 undefined 2 undefined 3 undefined

在 JavaScript 中,构造函数只是一些 使用 new 操作符时被调用的函数。它们并不会属于某个类,也不会实例化一个类。实际上, 它们甚至都不能说是一种特殊的函数类型,它们只是被 new 操作符调用的普通函数而已。
使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

    1. 创建(或者说构造)一个全新的对象。
    1. 这个新对象会被执行 [[ 原型 ]] 连接。
    1. 这个新对象会绑定到函数调用的 this。
    1. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

判断this
现在我们可以根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的 顺序来进行判断:

    1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。 var bar = new foo()
    1. 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。 var bar = foo.call(obj2) - - 3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上 下文对象。 var bar = obj1.foo()
    1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到 全局对象。 var bar = foo()

就是这样。对于正常的函数调用来说,理解了这些知识你就可以明白 this 的绑定原理了。 不过……凡事总有例外。
如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值 在调用时会被忽略,实际应用的是默认绑定规则:

function foo() {
    console.log(this.a);
}
var a = 2;
foo.call(null); // 2

那么什么情况下你会传入 null 呢?
一种非常常见的做法是使用 apply(..) 来“展开”一个数组,并当作参数传入一个函数。 类似地,bind(..) 可以对参数进行柯里化(预先设置一些参数),这种方法有时非常有用:

function foo(a, b) {
    console.log("a:" + a + ", b:" + b);
}
// 把数组“展开”成参数 
foo.apply(null, [2, 3]); // a:2, b:3
// 使用 bind(..) 进行柯里化
var bar = foo.bind(null, 2);
bar(3); // a:2, b:3

这两种方法都需要传入一个参数当作 this 的绑定对象。如果函数并不关心 this 的话,你 仍然需要传入一个占位值,这时 null 可能是一个不错的选择,就像代码所示的那样。
在 ES6 中,可以用 ... 操作符代替 apply(..) 来“展 开”数组,foo(...[1,2]) 和 foo(1,2) 是一样的,这样可以避免不必要的 this 绑定。可惜,在 ES6 中没有柯里化的相关语法,因此还是需要使用 bind(..)。
然而,总是使用 null 来忽略 this 绑定可能产生一些副作用。如果某个函数确实使用了 this(比如第三方库中的一个函数),那默认绑定规则会把 this 绑定到全局对象(在浏览 器中这个对象是 window),这将导致不可预计的后果(比如修改全局对象)。

一种“更安全”的做法是传入一个特殊的对象,把 this 绑定到这个对象不会对你的程序 产生任何副作用。就像网络(以及军队)一样,我们可以创建一个“DMZ”(demilitarized zone,非军事区)对象——它就是一个空的非委托的对象。
如果我们在忽略 this 绑定时总是传入一个 DMZ 对象,那就什么都不用担心了,因为任何 对于 this 的使用都会被限制在这个空对象中,不会对全局对象产生任何影响。
无论你叫它什么,在 JavaScript 中创建一个空对象最简单的方法都是 Object.create(null) ( 详 细 介 绍 请 看 第 5 章 )。Object.create(null) 和 {} 很 像, 但 是 并 不 会 创 建 Object. prototype 这个委托,所以它比 {}“更空”:

function foo(a, b) {
    console.log("a:" + a + ", b:" + b);
}
// 我们的 DMZ 空对象 
var ø = Object.create(null);
// 把数组展开成参数 
foo.apply(ø, [2, 3]); // a:2, b:3 
// 使用 bind(..) 进行柯里化
var bar = foo.bind(ø, 2);
bar(3); // a:2, b:3

使用变量名 ø 不仅让函数变得更加“安全”,而且可以提高代码的可读性,因为 ø 表示 “我希望 this 是空”,这比 null 的含义更清楚。不过再说一遍,你可以用任何喜欢的名字 来命名 DMZ 对象。
另一个需要注意的是,你有可能(有意或者无意地)创建一个函数的“间接引用”,在这 种情况下,调用这个函数会应用默认绑定规则。 间接引用最容易在赋值时发生:

function foo() {
    console.log(this.a);
}
var a = 2;
var o = {
    a: 3,
    foo: foo
};
var p = {
    a: 4
};
o.foo(); // 3 
(p.foo = o.foo)(); // 2

赋值表达式 p.foo = o.foo 的返回值是目标函数的引用,因此调用位置是 foo() 而不是 p.foo() 或者 o.foo()。根据我们之前说过的,这里会应用默认绑定。 注意:对于默认绑定来说,决定 this 绑定对象的并不是调用位置是否处于严格模式,而是 函数体是否处于严格模式。如果函数体处于严格模式,this 会被绑定到 undefined,否则 this 会被绑定到全局对象。

我们来看看箭头函数的词法作用域:

function foo() { // 返回一个箭头函数
    return (a) => { //this 继承自 foo() 
        console.log(this.a);
    };
}
var obj1 = {
    a: 2
};
var obj2 = {
    a: 3
};
var bar = foo.call(obj1);
bar.call(obj2); // 2, 不是 3 !

foo() 内部创建的箭头函数会捕获调用时 foo() 的 this。由于 foo() 的 this 绑定到 obj1, bar(引用箭头函数)的 this 也会绑定到 obj1,箭头函数的绑定无法被修改。(new 也不 行!)
箭头函数最常用于回调函数中,例如事件处理器或者定时器:

function foo() {
    setTimeout(() => {
        // 这里的 this 在此法上继承自 foo() 
        console.log(this.a);
    }, 100);
}
var obj = {
    a: 2
};
foo.call(obj); // 2

第3章 对象

有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。
实际上,JavaScript 中有许多特殊的对象子类型,我们可以称之为复杂基本类型。
函数就是对象的一个子类型(从技术角度来说就是“可调用的对象”)。JavaScript 中的函 数是“一等公民”,因为它们本质上和普通的对象一样(只是可以调用),所以可以像操作 其他对象一样操作函数(比如当作另一个函数的参数)。
数组也是对象的一种类型,具备一些额外的行为。数组中内容的组织方式比一般的对象要 稍微复杂一些。

思考下面的代码:

var myObject = { a: 2 };
myObject.a; // 2 
myObject["a"]; // 2

如果要访问 myObject 中 a 位置上的值,我们需要使用 . 操作符或者 [] 操作符。.a 语法通 常被称为“属性访问”,["a"] 语法通常被称为“键访问”。
这两种语法的主要区别在于 . 操作符要求属性名满足标识符的命名规范,而 [".."] 语法 可以接受任意 UTF-8/Unicode 字符串作为属性名。举例来说,如果要引用名称为 "Super- Fun!" 的属性,那就必须使用 ["Super-Fun!"] 语法访问,因为 Super-Fun! 并不是一个有效 的标识符属性名
在对象中,属性名永远都是字符串。如果你使用 string(字面量)以外的其他值作为属性 名,那它首先会被转换为一个字符串。即使是数字也不例外,虽然在数组下标中使用的的 确是数字,但是在对象属性名中数字会被转换成字符串

数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性:

var myArray = ["foo", 42, "bar"];
myArray.baz = "baz";
console.log(myArray.length); // 3 
console.log(myArray.baz); // "baz"
console.log(myArray) //["foo", 42, "bar", baz: "baz"]

可以看到虽然添加了命名属性(无论是通过 . 语法还是 [] 语法),数组的 length 值并未发 生变化。
你完全可以把数组当作一个普通的键 / 值对象来使用,并且不添加任何数值索引,但是这 并不是一个好主意。数组和普通的对象都根据其对应的行为和用途进行了优化,所以最好 只用对象来存储键 / 值对,只用数组来存储数值下标 / 值对。
注意:如果你试图向数组添加一个属性,但是属性名“看起来”像一个数字,那它会变成 一个数值下标(因此会修改数组的内容而不是添加一个属性):

var myArray = ["foo", 42, "bar"];
myArray["3"] = "baz";
myArray.length; // 4 
myArray[3]; // "baz"

在 ES5 之前,JavaScript 语言本身并没有提供可以直接检测属性特性的方法,比如判断属 性是否是只读。

var myObject = {
    a: 2
};
Object.getOwnPropertyDescriptor(myObject, "a");
// { 
// value: 2,
// writable: true,
// enumerable: true,
// configurable: true
// }

如你所见,这个普通的对象属性对应的属性描述符(也被称为“数据描述符”,因为它 只保存一个数据值)可不仅仅只是一个 2。它还包含另外三个特性:writable(可写)、 enumerable(可枚举)和 configurable(可配置)。
在创建普通属性时属性描述符会使用默认值,我们也可以使用 Object.defineProperty(..) 来添加一个新属性或者修改一个已有属性(如果它是 configurable)并对特性进行设置。
举例来说:

var myObject = {};
Object.defineProperty(myObject, "a", {
    value: 2,
    writable: true,
    configurable: true,
    enumerable: true
});
myObject.a; // 2

我们使用 defineProperty(..) 给 myObject 添加了一个普通的属性并显式指定了一些特性。 然而,一般来说你不会使用这种方式,除非你想修改属性描述符。
注意:即便属性是 configurable:false,我们还是可以 把 writable 的状态由 true 改为 false,但是无法由 false 改为 true。除了无法修改,configurable:false 还会禁止删除这个属性。

不可变性

在 JavaScript 程序中很少需要深不可变性,但是我们需要做一下了解:
1. 对象常量
结合 writable:false 和 configurable:false 就可以创建一个真正的常量属性(不可修改、 重定义或者删除)
2. 禁止扩展
如 果 你 想 禁 止 一 个 对 象 添 加 新 属 性 并 且 保 留 已 有 属 性, 可 以 使 用 Object.prevent Extensions(..):

var myObject = {
    a: 2
};
Object.preventExtensions(myObject);
myObject.b = 3;
console.log(myObject.b); // undefined

3. 密封
Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。(不可以修改属性,但是可以修改值)
4. 冻结
Object.freeze(..) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(..) 并把所有“数据访问”属性标记为 writable:false,这样就无法修改它们 的值。(不可以修改值,但是可以修改属性)

思考下面的代码:

var myObject = { // 给 a 定义一个 getter 
    get a() {
        return 2;
    }
};
Object.defineProperty(
    myObject, // 目标对象 
    "b", // 属性名
    {
        // 描述符 
        // 给 b 设置一个 getter 
        get: function () {
            return this.a * 2
        },
        // 确保 b 会出现在对象的属性列表中 
        enumerable: true
    });
console.log(myObject.a); // 2 
console.log(myObject.b); // 4

不管是对象文字语法中的 get a() { .. },还是 defineProperty(..) 中的显式定义,二者 都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数, 它的返回值会被当作属性访问的返回值:

var myObject = {
    // 给 a 定义一个 getter
    get a() {
        return 2;
    }
};
myObject.a = 3;
console.log(myObject.a); // 2

由于我们只定义了 a 的 getter,所以对 a 的值进行设置时 set 操作会忽略赋值操作,不会抛 出错误。而且即便有合法的 setter,由于我们自定义的 getter 只会返回 2,所以 set 操作是 没有意义的。
为了让属性更合理,定义getter的同时还应当定义 setter。通常来说 getter 和 setter 是成对出现的(只定义一个的话 通常会产生意料之外的行为)

存在性

myObject.a 的属性访问返回值可能是 undefined,但是这个值有可能 是属性中存储的 undefined,也可能是因为属性不存在所以返回 undefined。那么如何区分 这两种情况呢?
我们可以在不访问属性值的情况下判断对象中是否存在这个属性:

var myObject = {
    a: 2
};
("a" in myObject); // true 
("b" in myObject); // false
myObject.hasOwnProperty("a"); // true 
myObject.hasOwnProperty("b"); // false

in 操作符会检查属性是否在对象及其 [[Prototype]] 原型链中。相比之下, hasOwnProperty(..) 只会检查属性是否在 myObject 对象中,不会检查 [[Prototype]] 链。
看起来 in 操作符可以检查容器内是否有某个值,但是它实际上检查的是某 个属性名是否存在。对于数组来说这个区别非常重要,4 in [2, 4, 6] 的结 果并不是你期待的 True,因为 [2, 4, 6] 这个数组中包含的属性名是 0、1、 2,没有 4。

之前介绍 enumerable 属性描述符特性时我们简单解释过什么是“可枚举性”,现在详细介绍一下:

var myObject = {};
Object.defineProperty(myObject, "a",
    // 让 a 像普通属性一样可以枚举 
    {
        enumerable: true,
        value: 2
    });
Object.defineProperty(myObject, "b",
    // 让 b 不可枚举 
    {
        enumerable: false,
        value: 3
    });
console.log(myObject.b); // 3 
console.log("b" in myObject); // true
console.log(myObject.hasOwnProperty("b")); // true // ....... 
for (var k in myObject) {
    console.log(k, myObject[k]);
} // "a" 2
console.log(Object.keys(myObject)) //["a"]
console.log(Object.getOwnPropertyNames(myObject)) //["a", "b"]

可以看到,myObject.b 确实存在并且有访问值,但是却不会出现在 for..in 循环中(尽管 可以通过 in 操作符来判断是否存在)。原因是“可枚举”就相当于“可以出现在对象属性 的遍历中”。
Object.keys(..) 会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(..) 会返回一个数组,包含所有属性,无论它们是否可枚举。

注意:
在数组上应用 for..in 循环有时会产生出人意料的结果,因为这种枚举不 仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用 for..in 循环,如果要遍历数组就使用传统的 for 循环来遍历数值索引。
使用 for..in 遍历对象是无法直接获取属性值的,因为它实际上遍历的是对象中的所有可 枚举属性,你需要手动获取属性值。
那么如何直接遍历值而不是数组下标(或者对象属性)呢?幸好,ES6 增加了一种用来遍 历数组的 for..of 循环语法(如果对象本身定义了迭代器的话也可以遍历对象):

var myArray = [1, 2, 3];
for (var v of myArray) {
    console.log(v);
}
// 1 
// 2 
// 3

for..of 循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的 next() 方法来遍历所有返回值。
和数组不同,普通的对象没有内置的 @@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

小结:
属性不一定包含值——它们可能是具备 getter/setter 的“访问描述符”。此外,属性可以是 可枚举或者不可枚举的,这决定了它们是否会出现在 for..in 循环中。

该看121页了 3.4遍历

相关文章

网友评论

    本文标题:读《你不知道的JavaScript》笔记(二)

    本文链接:https://www.haomeiwen.com/subject/fviphktx.html