重读《你不知道的JavaScript》(上)- this

this 是什么

this 的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

当一个函数被调用时,会创建一个活动记录(有时候也称为执行上下文)。这个记录会包含函数在哪里被调用(调用栈) 、函数的调用方法、传入的参数等信息。

this 就是记录的其中一个属性,会在函数执行的过程中用到。

this 全面解析

调用位置

调用位置是函数在代码中被调用的位置,而不是声明的位置。

找调用位置最重要的是要分析调用栈(就是为了到达当前执行位置所调用的所有函数)。我们关心的调用位置就在当前正在执行的函数的前一个调用中。

javascript 复制代码
function baz() {
  // 当前调用栈是:baz
  // 因此,当前调用位置是全局作用域
  console.log('baz');
  bar(); // <-- bar的调用位置
}
function bar() {
  // 当前调用栈是:baz -> bar
  // 因此,当前调用位置在baz中
  console.log('bar');
  foo(); // <-- foo的调用位置
}
function foo() {
  // 当前调用栈是:baz -> bar -> foo
  // 因此,当前调用位置在bar中
  console.log('foo');
}
baz(); // <-- baz的调用位置

绑定规则

默认绑定

默认绑定是无法应用其他规则时的默认规则。

在非严格模式下,独立调用函数(直接使用不带任何修饰的函数引用进行调用的函数)调用时会应用 this 的默认绑定,因此 this 指向全局对象。

如果使用严格模式,那么全局对象将无法使用默认绑定,因此 this 会绑定到 undefined

隐式绑定

隐式绑定需要考虑的规则是调用位置是否有上下文对象,或者说是否被某个对象拥有或者包含。

javascript 复制代码
function foo() {
  console.log(this.a);
}
var obj = {
  a: 2,
  foo: foo,
};
obj.foo(); // 2

上述代码的调用位置会使用 obj 上下文来引用函数,也可以说函数被调用时 obj 对象"拥有"或者"包含"它。

当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象。

对象属性引用链中只有最顶层或者说最后一层会影响调用位置。举例来说:

javascript 复制代码
function foo() {
  console.log(this.a);
}
var obj2 = {
  a: 42,
  foo: foo,
};
var obj1 = {
  a: 2,
  obj2: obj2,
};
obj1.obj2.foo(); // 42

上述代码中,obj1.obj2.foo() 的调用位置会使用 obj2 作为函数的上下文,因此 this 绑定到 obj2 对象。

一个最常见的 this 绑定问题就是被隐式绑定的函数会丢失绑定对象,也就是说它会应用默认绑定,比如:

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

虽然 barobj.foo 的一个引用,但是实际上,它引用的是 foo 函数本身,因此此时的 bar 其实是一个不带任何修饰的函数调用,因此应用了默认绑定。

javascript 复制代码
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"

参数传递其实就是一种隐式赋值,因此我们传入函数时也会被隐式赋值,所以也会应用默认绑定。

显式绑定

可以使用函数的 callapply 方法对 this 进行显式绑定。

如果给 callapply 方法传入了一个原始值(字符串类型、布尔类型或者数字类型)来当作 this 的绑定对象,这个原始值会被转换成它的对象形式(也就是 new String(..)new Boolean(..) 或者 new Number(..),这通常被称为"装箱"。

  • 硬绑定:
javascript 复制代码
function foo(something) {
  console.log(this.a, something);
  return this.a + something;
}
// 简单的辅助绑定函数
function bind(fn, obj) {
  return function () {
    return fn.apply(obj, arguments);
  };
}
var obj = {
  a: 2,
};
var bar = bind(foo, obj);
var b = bar(3); // 2 3
console.log(b); // 5

由于硬绑定是一种非常常用的模式,所以在 ES5 中提供了内置的方法 Function.prototype.bind,它的用法如下:

javascript 复制代码
function foo(something) {
  console.log(this.a, something);
  return this.a + something;
}

var obj = {
  a: 2,
};
var bar = foo.bind(obj);
var b = bar(3); // 2 3
console.log(b); // 5

bind 会返回一个硬编码的新函数,并把参数设置为 this 的上下文。

  • API 调用的"上下文"

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

javascript 复制代码
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 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。

  1. 创建(或者说构造)一个全新的对象。
  2. 这个新对象会被执行[[原型]] 连接。
  3. 这个新对象会绑定到函数调用的 this。
  4. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。
javascript 复制代码
function foo(a) {
  this.a = a;
}
var bar = new foo(2);
console.log(bar.a); // 2

使用 new 来调用 foo 时,我们会构造一个新对象并把它绑定到 foo 调用中的 this上。new 是最后一种可以影响函数调用时 this 绑定行为的方法,我们称之为 new 绑定。

优先级

我们可以通过以下顺序来判断 this 的绑定优先级:

  1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。

  2. 函数是否通过 callapply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是 指定的对象。

  3. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。

  4. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。

对象

语法

对象可以通过两种形式定义:声明(文字)形式和构造形式。

类型

在 JavaScript 中一共有六种主要类型(ES6 之前):

  • string
  • number
  • boolean
  • null
  • undefined
  • object

函数就是对象的一个子类型(从技术角度来说就是"可调用的对象")。

JavaScript 有一些内置对象:

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

在字符串、数值和布尔字面量上访问属性或者方法时,引擎自动把字面量转换成对应的对象。

例如:

javascript 复制代码
var str = 'hello';
console.log(str.length); // 'hello' => new String('hello')

var num = 123;
console.log(num.toFixed(2)); // '123' => new Number(123)

var bool = true;
console.log(bool.toString()); // 'true' => new Boolean(true)

nullundefined 没有对应的构造形式,它们只有文字形式。相反,Date 只有构造,没有文字形式。

对于 ObjectArrayFunctionRegExp(正则表达式)来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。

Error 对象很少在代码中显式创建,一般是在抛出异常时被自动创建。也可以使用 new Error(..) 这种构造形式来创建。

内容

对象的内容是由一些存储在特定命名位置的(任意类型的)值组成的,我们称之为属性。

需要强调的一点是,在引擎内部,这些值的存储方式是多种多样的,一般并不会存在对象容器内部。存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。

需要使用 . 操作符或者 [] 操作符来访问对象的属性。. 语法通常被称为"属性访问",[] 语法通常被称为"键访问"。这两种语法的主要区别在于 . 操作符要求属性名满足标识符的命名规范,而 [] 语法可以接受任意 UTF-8/Unicode 字符串作为属性名。

可计算属性名

ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名。

可计算属性名最常用的场景可能是 ES6 的符号(Symbol)。

javascript 复制代码
var myObject = {
  [Symbol.Something]: 'hello world',
};

属性描述符

javascript 复制代码
var myObject = { a: 2 };
Object.getOwnPropertyDescriptor(myObject, 'a');
// {
//     value: 2,
//     writable: true,
//     enumerable: true,
//     configurable: true
//  }
  • writable:表示是否可以修改属性的值。如果设置为 false,对于属性值的修改静默失败,严格模式下会报 TypeError
  • configurable:表示属性是否可配置。只要属性是可配置的,就可以使用 defineProperty 方法来修改属性描述符,也可以对属性进行删除操作,如果设置为 false,则修改属性描述符会报 TypeError,并且删不掉该属性。
  • enumerable:表示属性是否可枚举。如果设置为 falsefor...in 循环不会枚举这个属性。

不变性

  • 对象常量

结合 writable:falseconfigurable:false 就可以创建一个真正的常量属性(不可修改、重定义或者删除)

  • 禁止扩展

可以使用 Object.preventExtensions(obj) 方法来禁止扩展对象,一旦对象被禁止扩展,就不能再添加新属性。在严格模式下,禁止扩展之后尝试添加新属性会报 TypeError

  • 密封

Object.seal(obj) 会创建一个"密封"的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(obj) 并把所有现有属性标记为 configurable:false。 所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以 修改属性的值)。

  • 冻结

Object.freeze(obj) 会创建一个冻结对象,这个方法实际上会在一个现有对象上调用 Object.seal(obj) 并把所有"数据访问"属性标记为 writable:false,这样就无法修改它们的值。

[[Get]]

当我们读取一个对象的属性时,引擎会调用 [[Get]] 操作。

对象默认的内置 [[Get]] 操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。如果没有找到名称相同的属性,那么就会遍历可能存在的 [[Prototype]] 链,也就是原型链。

[[Put]]

如果已经存在这个属性,[[Put]] 算法大致会检查下面这些内容。

  1. 属性是否是访问描述符?如果是并且存在 setter 就调用 setter
  2. 属性的数据描述符中 writable 是否是 false ?如果是,在非严格模式下静默失败,在严格模式下抛出 TypeError 异常。
  3. 如果都不是,将该值设置为属性的值。

如果对象中不存在这个属性,[[Put]] 操作会更加复杂。

Getter 和 Setter

可以使用 gettersetter 部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。

getter 是一个隐藏函数,会在获取属性值时调用。

setter 也是一个隐藏函数,会在设置属性值时调用。

javascript 复制代码
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:只会检查属性是否在对象中,不会检查 [[Prototype]] 原型链。

javascript 复制代码
var myObject = {
  a: 2,
};
'a' in myObject; // true
'b' in myObject; // false
myObject.hasOwnProperty('a'); // true
myObject.hasOwnProperty('b'); // false

判断属性是否可枚举:

  1. 不可枚举的属性不会出现在 for...in 循环中。
  2. propertyIsEnumerable 会检查给定的属性名是否直接存在于对象中(而不是在原型链上)并且满足 enumerable:true
  3. Object.keys 会返回一个数组,包含所有可枚举的属性名。

遍历

普通的对象没有内置的 @@iterator,所以无法自动完成 for..of 遍历。

当然,你可以给任何想遍历的对象定义 @@iterator,举例来说:

javascript 复制代码
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

只要迭代器的 next() 调用会返回 { value: ... }{ done: ... }for..of就可以遍历它。

甚至可以定义一个"无限"迭代器,它永远不会"结束"并且总会返回一个新值(比如随机数、递增值、唯一标识符,等等)。

javascript 复制代码
var randoms = {
  [Symbol.iterator]: function () {
    return {
      next: function () {
        return { value: Math.random() };
      },
    };
  },
};
var randoms;
_pool = [];
for (var n of randoms) {
  randoms_pool.push(n);
  // 防止无限运行!
  if (randoms_pool.length === 100) break;
}

原型

[[prototype]]

JavaScript 中的对象有一个特殊的 [[Prototype]] 内置属性,其实就是对于其他对象的引用。几乎所有的对象在创建时 [[Prototype]] 属性都会被赋予一个非空的值。

如果无法在对象本身找到需要的属性,就会继续访问对象的 [[Prototype]] 链。

可以通过 Object.create 来创建一个新对象,并指定它的 [[Prototype]]

javascript 复制代码
var anotherObject = { a: 2 }; // 创建一个关联到anotherObject 的对象
var myObject = Object.create(anotherObject);
myObject.a; // 2

object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype

属性的设置和屏蔽

给一个对象设置属性并不仅仅是添加一个新属性或者修改已有的属性值。

javascript 复制代码
myObject.foo = 'bar';

接下来具体分析一下这条语句做了什么:

  • 如果 myObject 对象中包含名为 foo 的普通数据访问属性,这条赋值语句只会修改已有的属性值。

  • 如果 foo 不是直接存在于 myObject 中,[[Prototype]] 链就会被遍历,类似[[Get]] 操作。如果原型链上找不到 foofoo 就会被直接添加到 myObject 上。

  • 如果属性名 foo 既出现在 myObject 中也出现在 myObject[[Prototype]] 链上层,那么就会发生屏蔽。myObject 中包含的 foo 属性会屏蔽原型链上层的所有 foo 属性,因为 myObject.foo 总是会选择原型链中最底层的 foo 属性。

  • 如果 foo 不直接存在于 myObject 中而是存原型在于原型链上层时会出现的三种情况:

    • 如果在 [[Prototype]] 链上层存在名为 foo 的普通数据访问属性,并且没有被标记为只读(writable:false),那就会直接在 myObject 中添加一个名为 foo 的新属性,它是屏蔽属性。
    • 如果在 [[Prototype]] 链上层存在 foo,但是它被标记为只读(writable:false),那么无法修改已有属性或者在 myObject 上创建屏蔽属性。如果运行在严格模式下,代码会抛出一个错误。否则,这条赋值语句会被忽略。总之,不会发生屏蔽。
    • 如果在 [[Prototype]] 链上层存在 foo 并且它是一个 setter,那就一定会调用这个 setterfoo 不会被添加到(或者说屏蔽于)myObject,也不会重新定义 foo 这个 setter

如果你希望在第二种和第三种情况下也屏蔽 foo,那就不能使用 = 操作符来赋值,而是使用 Object.defineProperty来向 myObject 添加 foo

相关推荐
睡觉z3 小时前
Haproxy搭建web群集
前端
孩子 你要相信光5 小时前
前端如何通过 Blob 下载 Excel 文件
前端·javascript
IT猫咪酱5 小时前
【前端】yarn install error
前端
喜欢打篮球的普通人5 小时前
Flang:LLVM Fortran 前端简介
前端
喵喵侠w5 小时前
腾讯地图Web版解决热力图被轮廓覆盖的问题
前端·javascript
qq_2786672866 小时前
ros中相机话题在web页面上的显示,尝试js解析sensor_msgs/Image数据
前端·javascript·ros
烛阴6 小时前
JavaScript并发控制:从Promise到队列系统
前端·javascript
zhangxingchao7 小时前
关于《黑马鸿蒙5.0零基础入门》课程的总结
前端
zhangxingchao7 小时前
Flutter的Widget世界
前端