JavaScript高级程序设计学习笔记(二)

最近花了一天看完了第四章(变量、作用域和内存问题)和第五章(引用类型),然后隔天就被同学拉去干活了,身心俱疲。
先把第四章的笔记总结一下,怕回头忘光了哈哈。以下的笔记是书上一些我以前学习的时候,没有太重视的js基础知识,也有一些面试知识的拓展,通过博客回顾并加深理解,希望我的学习过程对你有所帮助。

文章目录

    • 第四章
      • 基本类型和引用类型
      • 复制变量值(基本类型和引用类型)
        • 基本类型值的复制
        • 引用类型值的复制
      • 深浅拷贝(面试考点)
        • ~浅拷贝
        • ~深拷贝
      • 传递参数
      • instanceof
      • 执行环境和作用域
      • JavaScript没有块级作用域
      • 垃圾收集
        • 标记清除
        • 引用计数
      • 管理内存
    • 总结

第四章

基本类型和引用类型

在ECMAScript中有两种不同数据类型的值:基本类型值和引用类型值基本类型值是指简单的数据段,而引用类型值指的是那些可能由多个值构成的对象。
Undefined、Null、Boolean、Number、String这五种基本数据类型都是按值访问的,它们是基本类型值。
而引用类型的值是保存在内存中的对象,在JavaScript中不允许直接访问内存中的位置,也就是不能直接操作对象的内存空间。当我们操作对象的时候,实际上是在对对象的引用进行操作,而不是实际的对象(相当于说有个媒介,然后媒介把更新反映到实际对象上)。

var person=new Object();
person.name="LeoLoge";
console.log(person.name); //"LeoLoge",通过引用给对象添加了动态的属性。

复制变量值(基本类型和引用类型)

基本类型值的复制

先看代码:

var num1=5;
var num2=num1;
var num1=4;
console.log(num1); // 4
console.log(num2); // 5

基本类型值的变量复制是为新变量创建一个新的基本类型值,然后把原变量的值赋给新变量的值(简单理解就是有个新的副本,在堆内存里)。当我操作了num1,num2不会变化。这说明这两个变量在创建后是互相独立,不耦合的,可以随意操作而不相互影响。

引用类型值的复制

先看代码:

var obj1=new Object();
var obj2=obj1;
obj1.name="LeoLoge";
console.log(obj2.name); //"LeoLoge"

当新变量从原变量复制引用类型值的时候,同样也会把存储在变量对象里的值复制一份放到新变量的内存空间里。但不同的是这个副本它是一个指针,这个指针指向了堆内存里的一个对象。当复制结束后,这两个变量实际上引用了同一个对象(可以理解成一个保险柜,但是有两把钥匙,这两把钥匙就是这两个变量的值,它们引用的目标是同一个保险柜)。
这会导致改变其中一个变量,另一个变量也会受影响。

如果你想避免在使用引用类型的时候发生这种耦合的现象,希望新变量指向堆内存里的一个新对象,你可以了解一下深浅拷贝

深浅拷贝(面试考点)

这里拓展一下书上暂时没讲到的深浅拷贝,有助于理解两种变量类型的复制,同时也是面试喜欢考的知识点(虽然我暂时还没被问到过哈哈哈):

~浅拷贝

浅拷贝的一些做法有:

1、Object.assign

let a = {
     
    age: 1
};
let b = Object.assign({
     }, a);
a.age = 2;
console.log(b.age); // 1

2、ES6的展开运算符(…)

let a = {
     
    age: 1
};
let b = {
     ...a};
a.age = 2;
console.log(b.age); // 1

~深拷贝

通常浅拷贝就能解决大部分问题了,但是当我们遇到如下情况就需要使用到深拷贝了。

let a = {
     
    age: 1,
    jobs: {
     
        first: 'FE'
    }
};
let b = {
     ...a};
a.jobs.first = 'native';
console.log(b.jobs.first); // native

浅拷贝只解决了第一层的问题,如果接下去的值中还有对象的话,那么就又回到刚开始的话题了,两者享有相同的引用。要解决这个问题,我们需要引入深拷贝。

解决方法:

1、JSON.parse(JSON.stringify(object))

let a = {
     
    age: 1,
    jobs: {
     
        first: 'FE'
    }
};
let b = JSON.parse(JSON.stringify(a));
a.jobs.first = 'native';
console.log(b.jobs.first); // FE

但是该方法也是有局限性的:
(1)会忽略 undefined
(2)会忽略 symbol
(3)不能序列化函数
(4)不能解决循环引用的对象
先看(1)(2)(3),在遇到函数、undefined、symbol(ES6的新数据类型)的时候,上述方法会忽略掉它们,因为它们没法序列化。比如下面这个例子:

let a = {
     
    age: undefined,
    sex: Symbol('male'),
    jobs: function() {
     },
    name: 'LeoLoge'
};
let b = JSON.parse(JSON.stringify(a));
console.log(b); // {name: "LeoLoge"}

但是通常情况下,复杂数据都是可以序列化的,所以该方法可以解决大部分问题,且在处理深拷贝时,该函数的性能在内置函数中是最快的(如果想序列化这三者,可以考虑引入一些js库,如lodash的深拷贝函数。
最后一点局限性是循环引用,也就是对象里面在套娃,比如说下面这个例子:

let obj = {
     
  a: 1,
  b: {
     
    c: 2,
    d: 3,
  },
};
obj.c = obj.b;
obj.e = obj.a;
obj.b.c = obj.c;
obj.b.d = obj.b;
obj.b.e = obj.b.c;
let newObj = JSON.parse(JSON.stringify(obj));
console.log(newObj);
// Error: Converting circular structure to JSON

对于这种情况,就无法通过该方法进行深拷贝。

2、MessageChannel
MessageChannel的postMessage传递的数据也是深拷贝的,而且还可以拷贝undefined和循环引用的对象。假如你需要拷贝的对象含有内置类型并且不包含函数的时候,就能使用该方法。

// 有undefined + 循环引用
    let obj = {
     
      a: 1,
      b: {
     
        c: 2,
        d: 3,
      },
      f: undefined
    };
    obj.c = obj.b;
    obj.e = obj.a;
    obj.b.c = obj.c;
    obj.b.d = obj.b;
    obj.b.e = obj.b.c;

    function deepCopy(obj) {
     
      return new Promise((resolve) => {
     
        const {
     port1, port2} = new MessageChannel();
        port2.onmessage = ev => resolve(ev.data);
        port1.postMessage(obj);
      });
    }

    deepCopy(obj).then((copy) => {
     
        // `MessageChannel`是异步!
        let copyObj = copy;
        console.log(copyObj, obj)
        console.log(copyObj == obj)
    });

结果如下:
JavaScript高级程序设计学习笔记(二)_第1张图片
MessageChannel的postMessage遇到了循环引用(套娃)和undefined值,也能进行深拷贝。

本来在讲变量复制的,结果跑远了,继续下一个知识点。

传递参数

ECMAScript的函数参数是按值传递的(我理解的是js中的函数参数都是些局部变量,即命名参数,它们拷贝自被传递的值)。通过上面讲到的基本类型值和引用类型值的复制,不难得出一个现象:
在按值传递参数的这种机制下,基本类型值作为参数传递时,参数做任何变化,最后都不会影响到被传递的值(而且参数这个局部变量在退出函数后会被销毁);而引用类型的值作为参数传递时,参数这个局部变量保存的是这个对象在内存中的地址(也就是前面说的钥匙,而非一个新的对象),参数依靠这把钥匙可以间接操作对象,所以参数发生的一些变化,比如说动态增加属性、方法等等,最后都会反映在函数外部。看一下一个简单的例子:

//基本类型的传参
function addTen(num){
     
    num+=10;
    return num;
}
var count=20;
var result=addTen(count);
console.log(count);     //20,没变化
console.log(result);    //30

//引用类型的传参
function setName(obj){
     
    obj.name="LeoLoge";
}
var person=new Object();
setName(person);
console.log(person.name);   //"LeoLoge",被传递的引用类型值变化了。

书上还有一段证明对象是按值传递的例子,我觉得写得挺好的,主要还是要领会引用类型变量作为参数时是怎么按值传递的,可以结合下面的例子理解:

//证明对象是按值传递的
function setName(obj){
     
    obj.name="LeoLoge";
    obj=new Object();   //参数指向另一块新内存
    obj.name="Perl"; 
}
var person=new Object();
setName(person);
console.log(person.name);   //"LeoLoge",变化的是被传递的值指向的对象。

假如引用类型值是按引用传递的,那person将指向一个新的对象,并为这个新对象赋予属性name,在函数退出后,由于按引用传递的特性,参数obj和被传递的person是前者引用后者的关系,它们将保持一致,person也将会指向name为Perl的新对象。(按引用传递可以理解成我用两把钥匙开保险柜,参数的改变,代表了我把保险柜的内容换掉了,而钥匙还是原样。接触过C++这类语言的函数机制的话,就很好理解了)

然而我们看到了,函数执行后person依然指向原来的对象,这说明即使在函数内部改变了参数的值,被传递的引用类型值也没有发生变化(可以理解为参数这个局部变量和被传递的值是互相独立的。它们之间唯一的一点关系,大概就是在函数的最初,它们都指向同一块内存,可以一起操作同一个对象)。而在函数内部创建的新对象也只是个局部对象,由于函数外部没有任何对象去引用它,最后它也就在函数结束后立即被销毁掉了。(简单点理解就是,参数的改变,代表我换了把钥匙,然后这把钥匙是用来开别的保险柜的,原来那个保险柜就不会被影响了)

instanceof

typeof操作符可以确定内置类型,而对于自定义的引用类型,我们可以使用instanceof操作符。
instanceof的原理是通过原型链来识别,如果了解原型链的话其实可以自己实现一个instanceof函数,如下:

function instanceof(left, right) {
     
    // 获得类型的原型
    let prototype = right.prototype
    // 获得对象的原型
    left = left.__proto__
    // 判断对象的类型是否等于类型的原型
    while (true) {
     
    	if (left === null)
    		return false
    	if (prototype === left)
    		return true
    	left = left.__proto__
    }
}

(其实笔者对于原型链也只懂些皮毛,本书第六章面向对象程序设计里对原型链的知识讲得很详细,后面我会再温习一遍)

执行环境和作用域

js代码其实是一块块执行环境套起来的,而执行环境里面比较重要的一个概念就是变量对象(VO),它保存了环境里定义的所有变量和函数,解析器在处理数据时会使用它,而我们编写的代码无法访问到这个对象。

函数也有自己的执行环境,通过被推入环境栈,来确定执行流进入了哪一个函数。当代码在环境中执行的时候,会创建一个变量对象的作用域链,保证对执行环境有权访问的所有变量和函数的有序访问。这个作用域链最前端,就是当前执行的代码所在环境的变量对象VO,当这个环境是函数的时候,函数的活动对象(AO)就作为了此时的VO。

这个作用域链可以帮助我们去做标识符解析(原理其实有点像上面基于原型链实现的instanceof函数的思想)。比方说我们在环境中寻找某个变量进行访问的时候,会从作用域链前端开始,逐级向后回溯,直到找到这个变量标识符为止(找不到的时候,往往就是我们没有声明这个变量,会报错)。

正是因为作用域链的使用,我们在搜索的时候都会先从最内层的执行环境里找到局部变量,而父执行环境里的同名全局变量。

JavaScript没有块级作用域

JavaScript没有块级作用域,这个我一开始是理解不了的,平时代码里也经常遇到一些奇怪的结果。例如:

for(var i=0;i<10;i++){
     
    doSomething(i);
}
console.log(i); //10

在其他类C语言当中,变量i应该只存在于循环之中,退出即销毁。然而在没有块级作用域的js里,它能够在for循环执行完毕后,存在于循环外部的执行环境当中。
JavaScript与其他类C语言的这一差异需要注意。然而在ES6中,有了let关键字之后就没有这个问题了,let定义的变量只会存在于其所在的执行环境中,比如说:

for(let i=0;i<10;i++){
     
    doSomething(i);
}
console.log(i);
//Error: i is not defined

垃圾收集

JavaScript具有自动垃圾收集机制,关键的思想是帮助垃圾收集器,给不再有用的变量打标记,实现被占用内存的回收。书上讲了两种策略(理解思想就好,可以帮助理解V8引擎实现的的GC算法),分别是标记清除和引用计数。

标记清除

当变量进入环境,就打“进入环境”的标记;离开时打上“离开环境”的标记。常用的方式是翻转一个特殊的位来标记变量在环境中的进出。最后只要控制垃圾回收的间隔定期清除被标记“离开环境”的变量即可。

引用计数

这个策略不太常见,书上也不推荐,因为遇到循环引用时就会出大问题。

原理是跟踪记录每个值被引用的次数。但声明了一个变量并将一个引用类型值赋给该变量时,这个值的引用次数就等于1,如果同一个值又被赋给给另一个变量,那就次数+1;相反,如果包含对这个值引用的变量又取得另外一个值了,那引用次数-1,直到这个值不再被任何变量引用,引用次数等于0。垃圾收集器下次运行就会释放引用次数为0的值所占用的内存。

所以无用变量循环引用的时候,就容易堆砌垃圾,引用计数策略就不适用了。要解决这个问题,最好是在不使用变量的时候主动切断变量与它此前引用的值之间的连接。

myObject.element=null;
element.AnotherObject=null;

管理内存

引用计数中避免循环引用的方式,其实是管理内存的一种手段,即通过将值设置为null,来释放其引用,这个做法叫做 解除引用

function createPerson(name){
     
    var localPerson=new Object();
    localPerson.name=name;
    return localPerson;
}
var globalPerson=createPerson("LeoLoge");
// 手工解除globalPerson的引用
globalPerson=null;

这样一来,值不被引用,脱离了执行环境,垃圾收集器下次运行时就会把它回收。

总结

虽说第四章(变量、作用域和内存问题)在书上篇幅很短,但是可以挖掘的东西很多。像我此前编码过程中遇到的很多运行错误,都和这一章提到的JavaScript语言特性有关,主要还是因为没有区分出它和类C语言的差异导致的。
尤其要注意的是两种变量的类型(基本类型和引用类型)。基本类型值被保存在了栈内存中;引用类型值是对象,被保存在了堆内存中。掌握好这两种变量类型,对理解内存分配管理很有帮助。
除此之外就是对执行环境的理解,以及在存在自动垃圾收集机制的JavaScript中如何通过分析局部变量的生命周期来管理内存,提升页面性能

下回记录第五章的笔记,主要是引用类型(对象)的基础知识(一些基本包装类型,用好了的话,就像Java的集合框架一样,是实战开发中的利器)。

本文内容如果有理解有误的地方,还请大家指正!

如果对你产生了帮助,请点个赞给我吧!感谢观看!

你可能感兴趣的:(js,javascript,es6)