Loading

你不知道的JavaScript——this全面解析(上)

之前一直不知道this到底代表啥,只知道它和一般面向对象编程语言中的this一定不同。

JavaScript中的this可以在函数中使用,是编译器通过一些条件在函数被调用时绑定进对应作用域的的一个变量,可以明确知道的是,这个变量一定是一个对象,所以你可以用this.xxx的方式访问一个属性。

可以看到this被自动绑定进了作用域中,所以我们可以把它看成普通变量一样对待即可,没啥大不了的。

图中的this指向了Window对象,也就是浏览器环境下的全局作用域,实际上this可以指向任意位置,具体的规则可以分四种情况。

默认绑定

默认情况下,this指向全局作用域。

function foo(){
    console.log(this.a);
}

var a = 10;
foo(); // 10

在浏览器执行这段代码,可以得到10的结果。

但是,在NodeJS环境下,你会得到一个undefined,这个理论失效了......难道是Node下的this和浏览器上的还不一样??那xx还得学两遍?

NodeJS和浏览器的不同

别太担心,只是NodeJS下的全局作用域和浏览器的有些区别。

如上是浏览器的全局作用域,Window对象直接作为所有js文件的全局作用域,所以每个js文件中使用var定义变量时,是直接定义在Window下的,这样容易造成命名冲突。

如上是NodeJs的做法,它使用了一个global作为全局作用域,为所有文件通用,但每个js文件又有一个单独的作用域,它们使用var定义的东西会定义在这个文件级别的单独作用域中,这样就不会出命名冲突的问题了。

回过头来看代码

function foo(){
    console.log(this.a);
}

var a = 10;
foo(); // undefined

咱说了,默认情况下this被绑定到全局作用域,那就是global,而a现在在哪儿啊?在文件单独的作用域里,那怎么找啊?

function foo(){
    console.log(this.a);
}

global.a = 10;
foo(); // 10

这样是可以的,但如非必要,请不要影响global作用域。

嘶,,你说不要影响全局作用域,那...你使用this不很容易影响全局作用域吗???

对了,注意,严格模式下这个默认行为会被禁止,也就是说,严格模式下,全局变量不会被绑定给this,默认情况下的this是undefined

隐式绑定

隐式绑定出现在调用位置有上下文对象的情况,this指向上下文对象。

function foo(){
    console.log(this.a);
}

var obj = {
    a: 10,
    foo: foo
}

var a = 20; // Turn to a comment in node env
// global.a = 20 // Release in node env

foo(); // 20
obj.foo(); // 10

第一个foo调用,按照刚刚的默认绑定,毫无疑问会寻找全局作用域中的20打印,但第二个obj.foo(),由于我们调用的位置是在obj的上下文中,所以this指向obj,打印10

这说明,隐式绑定优先级 > 默认绑定,这显然是句废话,但先记着。

看下面的代码

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

这应该不难理解,因为调用动作实际是obj2发出的,上下文理应是obj2,所以this也是obj2

再看

function foo() {
    console.log( this.a );
}
var obj = {
    a: 2,
    foo: foo
};
var bar = obj.foo;
var a = "oops, global";
bar(); // "oops, global"

这...打印的结果是全局作用域中的a,而不是obj中的。

因为var bar = obj.foo,这句,只是把foo函数赋值给bar了,这和你直接调用foo又有啥区别捏??嘿嘿。

再看

function foo() {
    console.log( this.a );
}
function doFoo(fn) {
    fn();
}
var obj = {
    a: 2,
    foo: foo
};
var a = "oops, global";
doFoo( obj.foo ); // "oops, global"

这个和上面的原理一样,传参本就是一种隐式赋值。这些都是日常编写代码时容易犯的错误。

所以,这些问题让我感觉默认绑定和隐式绑定是如此的不可靠,一丁点儿的不小心就可能会酿成大错。

显式绑定

JS中的函数对象有两个默认方法,applycall,实际上都是调用这个函数,只是它俩可以在调用的同时传递要绑定的this对象。(这和Java里的invoke需要传递一个上下文实例不差不多吗)。

function foo(){
    console.log(this.a);
}

var obj = {
    a: 2
};

foo.call(obj);

上面我们就把对象obj绑定给本次调用的this了。

前面说了,this一定是个对象类型,所以你这里如果传递基本类型,则会被转换成包装类型。

但这好像仍然解决不了前面方法一被赋值this就会被改变的问题,你不能指望你的用户每次调用你的方法时都使用call或者apply并且自己绑定对象。

一个最简单的办法是提供一个函数帮助用户去完成调用call,绑定this的操作

function bar(){
    foo.call(obj)
}

bar();

bar暴露给用户,无论它的作用域是由于疏忽的赋值操作被修改还是被显式的恶意修改了,都不会影响实际内层foo所绑定的this对象。

可以考虑提供一个通用的方法来做这步操作,而不是把每一个函数都包装一层。

function bind(fn,obj){
    return function(){
        return fn.apply(obj,arguments);
    }
}

function sayHello(name){
    console.log(this.prefix + ',' + name);
}

var speaker = {
    prefix: 'Hello',
}

speaker.sayHello = bind(sayHello,speaker);

speaker.sayHello('Julia'); // Hello,Julia

bind方法用于提供将一个对象obj绑定给函数fn的能力,它返回一个新的方法,这个方法和我们之前的包装方法无异。

这样写代码会更优雅一点,以后我们的每个方法都可以直接使用bind来绑定this,而不用单独提供一个包装方法。

ES5提供了默认的bind方法。

function sayHello(name){
    console.log(this.prefix + ',' + name);
}
var speaker = {
    prefix: 'Hello',
}

speaker.sayHello = sayHello.bind(speaker);
speaker.sayHello('Julia'); // Hello,Julia

效果和我们所写的一样,但更加符合面向对象的编程模式。

使用callapply来绑定的this的优先级毫无疑问要高于隐式绑定和默认绑定。

改写我们之前的代码

function sayHello(name){
    console.log(this.prefix + ',' + name);
}
var speaker = {
    prefix: 'Hello',
    sayHello: sayHello
}
var anotherSpeaker = {
    prefix: 'Hi',
}

speaker.sayHello('Julia'); // Hello,Julia
speaker.sayHello.call(anotherSpeaker,'Julia'); // Hi,Julia

正常调用speaker.sayHello使用的就是当前speaker的上下文对象,这是隐式调用。而当你使用callapply给它绑定一个其他的对象时,this对象会被绑定成你指定的对象。

这也不用故意去记,这是很自然的事。

截至目前,优先级排序为:默认绑定 < 隐式绑定 < 显式绑定

new绑定

JS中的new和其他语言的不同。

new后面可以跟一个函数,然后会自动执行这个函数,并在执行之前做几件事情:

  1. 创建一个新对象
  2. 这个新对象被执行[[原型]]链接 (先不管他是啥)
  3. 这个新对象会被绑定到后面所跟的函数的this
  4. 如果函数没返回其他对象,这个新对象会被默认返回
function foo(a){
    this.a = a;
}

var bar = new foo(2);
console.log(bar.a); // 2

嘿,即使js中new的执行原理看似和传统面向对象语言并不搭边儿,但出来的效果还真和构造函数挺像呢哈。准确点来说应该是构造调用。

因为new创建了一个新对象,所有操作在这个新对象上进行,所以它的优先级理应是最高的,注意这里说它优先级最高的意思是它不会被其他绑定操作所影响,因为这个新对象和之前的老对象已经完全没关系了。

最终的优先级排序为:默认绑定 < 隐式绑定 < 显式绑定 < new绑定

软绑定

默认绑定的行为在严格模式和非严格模式下是不确定的,所以一般情况下应该避免,但如果使用bind来绑定,那么我们将不能再修改函数的this指向。

软绑定解决的就是这个问题,它在函数执行默认绑定时,将this指向一个设定好保底对象上,而不是全局对象,而当函数执行其他绑定时,将执行之前正常的绑定行为。

if(!Function.prototype.softBind){
    // 为函数对象的原型链上添加softBind方法
    Function.prototype.softBind=function(obj){
        var fn=this;
        var args=Array.prototype.slice.call(arguments,1);
        var bound=function(){
            return fn.apply(
                //如果this为空或者被绑定到全局对象,绑定到用户设定好的保底对象上,否则执行正常绑定
                (!this||this===(window||global))?obj:this,
                args.concat.apply(args,arguments)
            );
        };
        bound.prototype=Object.create(fn.prototype);
        return bound;
    };
}

咦,,,NodeJS的种种限制,上面这段经过深思熟虑的各种判断的代码并不能直接运行在Node环境。。。。

下面是softBind的使用示例

function foo(){
    console.log("name: "+this.name);
}

var obj1={name:"obj1"},
    obj2={name:"obj2"},
    obj3={name:"obj3"};

var fooOBJ=foo.softBind(obj1);
fooOBJ();//"name: obj1" 

obj2.foo=foo.softBind(obj1);
obj2.foo();//"name: obj2"

fooOBJ.call(obj3);//"name: obj3"

setTimeout(obj2.foo,1000);//"name: obj1"

第一个调用,因为fooOBJ调用时是默认绑定,所以将默认的obj1绑定到this上。

第二个调用,因为obj2.foo调用时是隐式绑定,所以obj2被绑定到this上。

第三个调用,显式绑定,obj3被绑定在this上。

第四个,因为传参了,有一个隐式的赋值操作,这就会造成调用时实际上this被绑定成全局对象,所以理应软绑定的默认对象发挥作用,被绑定到this上的是obj1

如果在Node下使用setTimeout,会有一个默认的上下文叫Timeout,不会被直接绑定到全局对象,但这个Timeout里又没有name属性,所以输出应该是undefined。坑死。

md我可能真理解不了JS的设计哲学,怎么这么乱套啊....

箭头表达式中的this

箭头表达式继承外部this。

function foo(){
    setTimeout(()=>{
        console.log(this.name);
    },1000);
}
foo.bind({name: 'JJJJ'})() // JJJJ

对象

JS中的对象有两种构造方式,声明形式和构造形式。

var myObj = {
    key: value,
};


var myObj = new Object();
myObj.key = value;

绝大多数情况我们都使用声明形式创建。

类型

JS中的类型大致可以分为三种。

基本类型

  • string
  • number
  • boolean
  • null
  • undefined

这些类型是JS定义的基本类型,有一个Bug导致的例外就是当对null执行typeof null时返回的是object

对象类型

用户自行定义的对象类型,typeof操作符会返回object

JS中有一些内置对象,比如

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

它们是一系列函数,就像上面的内容一样,可以使用new构造。

注意对象类型中含有一些基本类型的包装类,它们不可以直接与基本类型相提并论。

var str1 = 'hello';
var str2 = new String('hello');
console.log(typeof str1); // string
console.log(str1 instanceof String); // false
console.log(typeof str2); // object
console.log(str2 instanceof String); // true

什么??那就是说JS可以获取一个非对象类型数据的属性?毕竟像如下的代码我们可经常用

var str = 'abc';
console.log(str.length);

非对象怎么会有属性呢?JS会在你调用这些方法时自动将它们转换成对象。

当然能用字面量时当然要尽量用字面量,对象会多出很多用于描述对象信息的空间。

复杂基本类型

复杂基本类型是对象类型的子类型,是JS预先定义的一些对象类型,所有能用在对象身上的操作都可以应用到复杂基本类型身上,例如Function。使用typeof操作符会返回它们自己的类型名。

对象属性名

你可以通过两种方式为对象添加属性:

var obj = {};
obj.a = 1;
obj['b'] = 2;

所有对象的属性都是字符串形式的,也就是说,当你使用obj[0]=10时,实际会被转换成obj['0'] = 10

var obj = {};
var obj2 = {};

obj[1] = 10;
obj[obj2] = 10101;
console.log(obj);
// { '1': 10, '[object Object]': 10101 }

可计算属性名

数组

数组也是一个对象,它的下标必须是数值,当你存储一个像数值的东西的时候,他就会把它转换成数值,还有你可以像对待对象一样往数组中添加其他属性。

var arr = [1,2,3];


arr['3'] = 4;
arr['bar'] = 5;
console.log(arr);
console.log(arr[3]);

// [ 1, 2, 3, 4, bar: 5 ]
// 4

对象复制

使用JSON

var newObj = JSON.parse( JSON.stringify( someObj ) );

这样出来的是深复制

Object.assign

这是ES6中定义的方法。第一个参数是要复制到的目标对象,后面的可变长参数是源对象,所有源对象中的所有可枚举的属性会被复制到目标对象,最后目标对象会被返回。

var newObj = Object.assign({},oldObj);

也可以看出这是浅复制。

属性描述符

ES5开始所有属性都具备属性描述符。

var obj = {
    a: 10
};
console.log(
    Object.getOwnPropertyDescriptor(obj,'a')
);

// { value: 10, writable: true, enumerable: true, configurable: true }

writable代表这个属性可否被写入,enumerable代表这个属性是否可以被枚举,比如使用for in循环的时候,不想被扫描到的属性就可以设置成false。configurable代表这个属性可不可以使用defineProperty方法来设置或修改可访问性,还有能不能被删除。

Object.defineProperty可以在创建或修改属性的同时修改访问权限。

var obj = {
    a: 10
};

Object.defineProperty(obj,'a', {
    value: 2,
    writable: false,
    configurable: true,
    emuerable: true
});

console.log(obj.a);
obj.a = 12;
console.log(obj.a);

需要注意的几点:

  1. configurable设置成false后便再无法修改该属性的访问权限,所以该操作不可逆。
  2. configurable设置成false后,唯一的例外是能够再次将writabletrue改成false,其他的访问权限都没法修改,修改后就抛出TypeError
  3. 修改writablefalse的属性,在非严格模式下静默失败,严格模式下TypeError

不变性

不变性指的是维护一个对象的属性不发生改变,其中有浅不变性和深不变性之分,和浅拷贝与深拷贝的关系一致。

浅不变性保证属性不被重新赋值,但当属性是一个引用时,引用中的值可以修改。

深不变性保证属性不被重新赋值,当属性是一个引用时,也同时赋予这个引用深不变性。注意,这是个递归,这保证整个对象链条中的所有属性都不可变。

通常,我们在JS中很少用到深不变性,如果真的需要用到了,考虑是不是哪里的设计出了问题。

对象常量

设置writable: falseconfigurable: false即可创建一个常量属性,不可修改不可删除不可重定义。

禁止扩展

Object.preventExtensions禁止用户向对象中添加新属性。结合上面的设置,可以配置出一个不可变对象。

var finalObj = {};

Object.defineProperty(finalObj,'C',{
    value: 10,
    writable: false,
    configurable: false
});

Object.preventExtensions(finalObj);


console.log(finalObj.C); // 10
finalObj.C = 20;
console.log(finalObj.C); // 10

finalObj.foo = 20; // 严格模式下TypeError
console.log(finalObj.foo); // undefined

如果每一个属性都让我们自己这样设置,太恶心了。。。

JS提供了一些函数,用于方便的设置一些常见的访问规则。

密封

Object.seal调用Object.preventExtensions,并且对每个属性标记为configurable: false

相当于你无法再对这个对象进行任何配置,也不能新增属性,但能够修改。

冻结

Object.freeze调用Object.seal,并且对这个对象的所有属性设置writable: false

相当于在密封的效果上还限定了不可修改。

注意这两个方法都是浅不变,或者准确的说是它们不会限制属性中的对象引用的不变性。

如果想,可以递归调用Object.freeze,不过慎用。

[[GET]]

[[GET]]是JS在获取一个对象的属性时做的操作,当你获取一个对象的属性时,[[GET]]会做如下操作:

  1. 寻找该对象有没有相应属性,如果有,返回这个属性的值,结束寻找
  2. 遍历寻找该对象的原型链,如果原型链中有相同名称的属性,返回这个属性的值,结束寻找。
  3. 返回undefined,结束寻找。

这里说一个昨天遇到的小问题

var obj = {};

console.log(obj.a); // undefined
console.log(a); // Reference Error

昨天我对于这个行为很费解,就是明明我们访问的都是未定义的属性,有的时候是undefined,有的时候却异常退出呢?昨天我都无能狂怒了,差点砸了电脑。

其实这个问题在上一篇笔记你不知道的JavaScript——作用域和闭包 异常里就可以找到解释。

obj.a是一个二级访问,obj是一个一级访问,一级访问由编译器接管,这里如果遇到一个未定义的变量,编译器就会抛出一个ReferenceError,而二级访问由对象属性访问规则接管,根据规则,显然是返回undefined

[[PUT]]

[[GET]]相反,它用于设置属性值。

下面看看如果已经存在这个属性时,[[PUT]]的执行过程:

  1. 属性是否是访问描述符(一会介绍)?如果是并存在setter则调用setter接管后面的过程。
  2. writable是否为false,如果是根据是否严格模式执行对应的操作。
  3. 如果上面两条都不满足,将这个值设置为属性的值。

Getter和Setter

ES5开始支持对属性提供gettersetter

如果一个属性提供了gettersetter,它就是一个访问描述符,如果没有提供,就是普通的obj.key = value的话,那它就是一个数据描述符。编译器不关心访问描述符的writablevalue

settergetter的语法不在此展开。

存在性

一个属性为undefined,可能是因为它根本没被定义,也可能是因为它本就被赋值成undefined。这会给你判断一个对象中到底有没有一个属性造成麻烦,因为它等于undefined不一定代表它没有这个属性。

JS有一系列判断属性是否存在的方法,以下是区别:

  • prop in Object 会检测属性是否在对象或对象的原型链中。
  • hasOwnProperty 会检测属性是否在对象中,不会检测原型链。
  • propertyIsEnumerable 会检测属性是否在对象中,并且可枚举,不会检测原型链
  • Object.keys 返回对象的可枚举属性数组
  • Object.getOwnPropertyNames 返回对象的属性数组,不管属性是否可枚举

遍历

第一次接触JS应该是几年前了,期间一直对这门语言提不起兴趣,很大的原因可能就是它跟其他语言差太多了。比如其中一个难以理解的特性就是为什么for in循环取出来的是下标,那for in循环岂不是没什么优势了吗?

for(var i in array){
    var value = array[i];
}

但是看完上面的内容,这个问题好像已经不算个问题了,好像他就应该如此一样。

因为js的数组就跟对象一样啊,对对象使用for in操作遍历出来的是对象的属性名,那么对数组使用for in当然得到的也是数组的属性名啊,数组的属性名就是下标嘛。之前感觉很蠢的设计变得理所应当。

JS中的遍历大概有如下几种:

  1. for(var i=0;i<arr.length;i++) 传统遍历
  2. for(var i in arr) forin遍历
  3. forEach()every()等辅助迭代器
  4. for(var v of array) es6中的forof遍历,这个会取数组中的值,而不是下标

参考

posted @ 2021-07-14 15:54  yudoge  阅读(134)  评论(0编辑  收藏  举报