《Javascript高级程序设计(第四版)》红宝书学习笔记(2)(变量、作用域与内存)

个人对第四版红宝书的学习笔记。不适合小白阅读。这是part2。

(记 * 的表示是ES6新增的知识点,记 ` 表示包含新知识点)

第四章:变量、作用域与内存

4.1 原始值与引用值

ECMAScript变量可以包含两种不同类型的数据:原始值引用值。原始值(primitive value)就是最简单的数据,引用值(reference value)则是由多个值构成的对象。

在把一个值赋给变量时,JavaScript引擎必须确定这个值是原始值还是引用值。上一章讨论了6种原始值:Undefined、Nu11、Boolean、Number、String和Symbol。保存原始值的变量是按值(byvalue)访问的,因为我们操作的就是存储在变量中的实际值。

引用值是保存在内存中的对象。与其他语言不同,JavaScript不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用(reference)而非实际的对象本身。为此,保存引用值的变量是按引用(by reference)访问的。

注意:在很多语言中,字符串是使用对象表示的,因此被认为是引用类型。ECMAScript打破了这个惯例。


4.1.1 动态属性

原始值和引用值的定义方式很类似,都是创建一个变量,然后给它赋一个值。不过,在变量保存了这个值之后,可以对这个值做什么,则大有不同。

  • 对于引用值而言,可以随时添加、修改和删除其属性和方法

  • 原始值不能有属性,尽管尝试给原始值添加属性不会报错。

注意,原始类型的初始化可以只使用原始字面量形式。如果使用的是new关键字,则JavaScript会创建一个Object类型的实例,但其行为类似原始值,下面来看看这两种初始化方式的差异:

let name1 "Nicholas":
let name2 = new String("Matt");
name1.age = 27;
name2.age = 26:
console.log(name1.age):     //-> undefined
console.log(name2.age);     //-> 26
console.10g(typeof name1);  //-> string
console.log(typeof name2);  // object

4.1.2 复制值

除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。

1)在通过变量把一个原始值赋到另一个变量时,原始值会被复制到新变量的位置:

let num1 = 5:
let num2 = num1;

这里,num1包含数值5。当把num2初始化为num1时,num2也会得到数值5。这个值跟存储在numl 中的5是完全独立的,因为它是那个值的副本。这两个变量可以独立使用,互不干扰。这个过程如图4-1所示。

2)在把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来,如下面的例子所示:

let obj1 = new Object();
let obj2 = obj1:
obj1.name = "Nicholas";
console.log(obj2.name); // “Nicholas"

在这个例子中,变量obj1保存了一个新对象的实例。然后,这个值被复制到obj2,此时两个变量都指向了同一个对象。在给obj1创建属性name并赋值后,通过obj2也可以访问这个属性,因为它们都指向同一个对象。图4-2展示了变量与堆内存中对象之间的关系:


4.1.3 传递参数

ECMAScript中所有函数的参数都是按值传递的。这意味着函数外的值会被复制到函数内部的参数中,就像从一个变量复制到另一个变量一样。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。

按值传递参数时,值会被复制到一个局部变量(即一个命名参数,或者用ECMAScript的话说,就是arguments对象中的一个槽位)。在按引用传递参数时,值在内存中的位置会被保存在一个局部变量,这意味着对本地变量的修改会反映到函数外部(这在ECMAScript中是不可能的)。

1)“Javascript中函数按值传参”这个性质在原始值上可以很明显的看到:

function addTen(num){
    
    
  num += 10;
  return num;
}
let count = 20;
let result = addTen(count);
console.log(count);   //-> 20,没有变化,原始值没有受到影响
conaole.log(result);  //-> 30

2)而对于引用值来说:

function setName (obj){
    
    
  obj.name = "Nicholas";
}
let person = new Object();
setName (person);
console.log(person.name);  //-> "Nicholas"   
//看的出来,传入person对象之后,person对象多了一个name属性

局部作用域中修改对象,这种变化反映到了全局作用域。但这是不是意味着对象的传参是按引用传参呢?再看下一个例子:

function setName(obj){
    
    
  obj.name ="Nicholas";
    //添加两条语句:
  obj = new Object();
  obj.name = "Greg";
    //这里试图将obj重新定义为一个有着不同name属性的新对象
    //如果person是按引用传递的,那么person应该自动将指针改为指向  name为'Greg'的新对象
}
let person = new Object();
setName(person);
console.log(person.name);  //-> "Nicholas"

person对象的name属性值仍然是"Nicholas",这意味着:函数中参数的值改变后,原始的引用仍然没变。当obj在函数内部被重写时,它变成了一个指向本地对象的指针。而那个对象在函数执行结束时就被销毁了。

注意:ECMAScript中函数的参数就是局部变量。


4.1.4 确定类型

前一章提到的typeof操作符最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一个变量是否为字符串、数值、布尔值或undefined的最好方式。如果值是对象或nu11,那么typeof 会返回"object"。

看的出来,typeof操作符对于引用值的用处不大。所以,ECMAScript提供了一个instanceof操作符:

result = variable instanceof constructor

如果变量是给定引用类型(由其原型链决定,将在第8章详细介绍)的实例,则 instanceof 操作符返回true。如下:

console.log (person instanceof Object);    //变量person是Object 吗?
console.log (colors instanceof Array);     //变量 colors是Array 吗?
console.log (pattern instanceof RegExp);   //变量 pattern是RegExp吗?

instanceof 检测任何引用值和Object构造函数都会返回true,因为所有引用值都是Object的实例。当然,对于原始值而言,instanceof则会返回false。

注意:typeof 操作符在用于检测函数时也会返回“function"。当在 Safari(直到 SafariS)和 Chrome(直到 Chrome 7)中用于检测正则表达式时,由于实现细节的原因,typeof也会返回“function"。ECMA-262 规定,任何实现内部[[Ca11]]方法的对象都应该在typeof检测时返回“function"。因为上述浏览器中的正则表达式实现了这个方法,所以typeof 对正则表达式也返回“function"。在IE和Firefox中,typeof 对正则表达式返回"object"。


4.2 执行上下文与作用域 `

执行上下文(以下简称“上下文”)的概念在JavaScript中是颇为重要的。变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个关联的变量对象(variable object),而这个上下文中定义的所有变量和函数都存在于这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。

1)全局上下文是最外层的上下文。根据 ECMAScript实现的宿主环境,表示全局上下文的对象可能不一样。

在浏览器中,全局上下文就是我们常说的window对象(第12章会详细介绍)。因此所有通过 var定义的全局变量和函数都会成为 window 对象的属性和方法。使用 letconst 的顶级声明不会定义在全局上下文中,但在作用域链解析上效果是一样的。

上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。

2)每个函数调用都有自己的上下文,即函数上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上,在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返还给之前的执行上下文。ECMAScript程序的执行流就是通过这个上下文栈进行控制的。

3)上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序,代码正在执行的上下文的变量对象始终位于作用域链的最前端

如果上下文是函数,则其活动对象(activation object)用作变量对象。活动对象最初只有一个定义变量:arguments(全局上下文中没有这个变量)。作用域链中的下一个变量对象来自包含上下文,再下一个对象来自再下一个包含上下文。以此类推直至全局上下文;全局上下文的变量对象始终是作用域链的最后一个变量对象

代码执行时的标识符解析是通过沿作用域链逐级搜索标识符名称完成的。搜索过程始终从作用域链的最前端开始,然后逐级往后,直到找到标识符。(如果没有找到标识符,那么通常会报错)

看一看下面这个例子:

var color = "blue";

function changeColor(){
    
    
  if(color === "blue"){
    
    
    color = 'red';   
  } else{
    
    
    color = 'blue';
  }
}
changeColor();

对这个例子而言,函数changeColor()的作用域链包含两个对象:一个是它自己的变量对象(就是定义arguments对象的那个),另一个是全局上下文的变量对象。这个函数内部之所以能够访问变量color,就是因为可以在作用域链中找到它。

局部作用域中定义的变量可用于在局部上下文中替换全局变量。

内部上下文可以通过作用域链访问外部上下文中的一切,但外部上下文无法访问内部上下文中的任何东西。上下文之间的连接是线性的、有序的。

注意:函数参数被认为i是当前上下文中的变量,因此也跟上下文中的其他变量遵循相同规则。

PS:上下文还是很好理解的,这里不再多讨论。


4.2.1 作用域链增强

虽然执行上下文主要有全局上下文和函数上下文两种(eva1()调用内部存在第三种上下文),但有其他方式来增强作用域链。某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后会被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:

  • try/catch 语句的catch块
  • with语句

这两种情况下,都会在作用域链前端添加一个变量对象。对with语句来说,会向作用域链前端添加指定的对象;对catch语句而言,则会创建一个新的变量对象,这个变量对象会包含要抛出的错误对象的声明。看下面的例子:

function buildUrl()(
  let qs = "?debug=true";
  with(location){
    
    
    let url = href + qs;   //这里引用href实际是引用location.href
                           //而引用qs时引用的其实是buildUrl()中定义的qs变量
  }
  return url;    //这里url没有定义,因为let声明被限制在了块级作用域
}

这里,with 语句将 location对象作为上下文,因此 location会被添加到作用域链前端。bui1dUrl() 函数中定义了一个变量qs。当 with 语句中的代码引用变量href 时,实际上引用的是location.href,也就是自己变量对象的属性。在引用qs时,引用的则是定义在 bui1dUrl() 中的那个变量,它定义在函数上下文的变量对象上。而在with语句中使用 var声明的变量url会成为函数上下文的一部分,可以作为函数的值被返回;但像这里使用let声明的变量ur1,因为被限制在块级作用域(稍后介绍),所以在with块之外没有定义。


4.2.2 变量声明 *

ES6之后,JavaScript的变量声明经历了翻天覆地的变化。直到ECMAScript5.1,var都是声明变量的唯一关键字。ES6不仅增加了1et和const两个关键字,而且还让这两个关键字压倒性地超越var成为首选。

1)使用 var的函数作用域声明

在使用var声明变量时,变量会被自动添加到最接近的上下文。在函数中,最接近的上下文就是函数的局部上下文。在with语句中,最接近的上下文也是函数上下文。如果变量未经声明就被初始化了,那么它就会自动被添加到全局上下文,如下面的例子所示:

function add(num1,num2){
    
    
  var sum = num1 + num2;
  return sum;
}
let result = add(10, 20);   //-> 30
console.log(sum);   //-> 报错:sum 在这里不是有效变量  这里访问不到函数内部var声明的变量

但如果省略上面例子中的关键字var,那么sum 在add()被调之后就变成可以访问的了,如下所示:

function add(num1,num2){
    
    
  sum = num1 + num2;
  return sum;
}
let result = add(10, 20);   //-> 30
console.log(sum);   //-> 30  可以访问,因为sum未经声明就被初始化,它被直接添加到了全局上下文

注意:未经声明而初始化变量是Javascript编程中一个常见的错误,会导致很多问题。务必注意。

严格模式下,未经声明而初始化变量会报错。

PS:变量提升这里不再提及。

2)使用let的块级作用域声明

块级作用域由最近的一对花括号界定。换言之,if块、while块、function块,甚至是单独的块(也就是仅仅只有两个花括号)也是let变量声明的作用域。

let和var的第二个不同是:在同一作用域不能声明两次。重复的var声明会被忽略,但重复的let声明会抛出SyntaxError。

严格来说,let在Javascript运行时也会被提升,但是由于“暂时性死区”的缘故,(上一章提到过)实际上不能在声明之前使用let变量。

3)使用const的变量声明

const声明只应用到顶级原语或者对象。换言之,赋值为对象的const变量不能再被重新赋值为其他引用值,但对象的键不受限制。如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值时虽然不会报错,但会静默失败:

const o1 = {
    
    };
o1 = {
    
    };    //TypeError:给常量赋值

const o2 = {
    
    };
o2.name = 'Jake';
alert(o2.name);    //-> Jake   //对象的键不受限制

const o3 = Object.freeze({
    
    });
o3.name = 'Jake';     //这一步没有意义,因为整个对象都不可被修改了
alert(o3.name);   //-> undefined  

由于const声明暗示变量的值是单一类型且不可修改,JavaScript运行时编译器可以将其所有实例都替换成实际的值,而不会通过查询表进行变量查找。谷歌的V8引擎就执行这种优化。

注意:开发实践表明,如果开发流程并不会因此而受很大影响,就应该尽可能地多使用const 声明,除非确实需要一个将来会重新赋值的变量。这样可以从根本上保证提前发现重新赋值导致的bug。

4)标识符查找

当在特定上下文中为读取或写入而引用一个标识符时,必须通过搜索确定这个标识符表示什么。搜索开始于作用域链前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到变量名,则继续沿作用域链搜索。(注意,作用域链中的对象也有一个原型链,因此搜索可能涉及每个对象的原型链。)这个过程一直持续到搜索至全局上下文的变量对象。如果仍然没有找到标识符,则说明其未声明。


4.3 垃圾回收 `

JavaScript是使用垃圾回收的语言,也就是说执行环境负责在代码执行时管理内存。JavaScript通过自动内存管理实现内存分配和闲置资源回收。基本思路很简单:确定哪个变量不会使用,然后释放它占用的内存。这个过程是周期性的,即垃圾回收程序每隔一段时间就会自动运行。

浏览器发展史上,用到过两种标记策略:标记清理和引用技术。JavaScript最常用的垃圾回收策略是标记清理

内存管理:

将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不再必要,就把它设置成null,从而释放其引用。这也可以叫做释放引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会被自动解除引用。

不过要注意,解除对一个值得引用并不会导致相关内存被回收。解除引用的关键在于确保相关的值已经不在上下文中了,因此它在下次垃圾回收时会被回收

1)通过letconst声明提升性能

因为const和let都以块(而非函数)为作用域,所以这两个关键字可能会更早的让回收程序介入,尽早回收应该回收的内存。

2)隐藏类和删除操作

根据Javascript所在的运行环境,有时候需要根据浏览器使用的JavaScript引擎来采取不同的性能优化策略。截至2017年,Chrome是最流行的浏览器,使用V8 JavaScript引擎。V8在将解释后的JavaScript代码编译为实际的机器码时会利用“隐藏类”。如果你的代码非常注重性能,那么这一点可能对你很重要。

运行期间,V8 会将创建的对象与隐藏类关联起来,以跟踪它们的属性特征。能够共享相同隐藏类的对象性能会更好,V8会针对这种情况进行优化,但不一定总能够做到。比如下面的代码:

function Article(){
    
    
  this.title = 'Inauguration Ceremony Features Kazoo Band'}
let a1 = new Article();
let a2 = new Article();

V8会在后台配置,让这两个类实例共享相同的隐藏类,因为这两个实例共享同一个构造函数和原型。假设之后又添加了下面这行代码:

a2.author ='Jake';

此时两个Article实例就会对应两个不同的隐藏类。根据这种操作的频率和隐藏类的大小,这有可能对性能产生明显影响。

当然,解决方案就是避免JavaScript的“先创建再补充”(ready-fire-aim)式的动态属性赋值,并在构造函数中一次性声明所有属性,如下所示:

function Article(opt_author){
    
    
  this.title = 'Inauguration Cerenony Features Kazoo Band';
  this.author = opt_author;
}
let al = new Article();
let a2 = new Article('Jake');

这样,两个实例基本上就一样了(不考虑hasOwnProperty的返回值),因此可以共享一个隐藏类从而带来潜在的性能提升。不过要记住,使用delete关键字会导致生成相同的隐藏类片段。看一下这个例子:

function Article(){
    
    
  this.title= 'Inauguration Ceremony Features Kazoo Band';
  this.author ='Jake';
}
let a1 = new Article():
let a2 = new Article();
delete a1.author;

在代码结束后,即使两个实例使用了同一个构造函数,它们也不再共享一个隐藏类。动态删除属与动态添加属性导致的后果一样。最佳实践是把不想要的属性设置为nu11。这样可以保持隐藏类不和继续共享,同时也能达到删除引用值供垃圾

3)内存泄漏

  1. 意外声明全局变量

    也就是局部作用域中声明变量时缺少声明操作符,而导致该变量变成了全局变量。加上var / const / let即可。

  2. 定时器

定时器的回调通过闭包引用外部变量时,如:

let name = 'Jake';
setInterval(()=>{
    
    
    console.log(name);
},100)
//定时器只要一直运行,回调函数中引用的name就会一直占用内存,垃圾回收程序自然不会清理外部变量
  1. 使用闭包

    let outer = () =>{
          
          
        let name = 'Jake';
        return function(){
          
          
            return name;
        };
    };
    //上述代码创建了一个内部闭包,只要outer函数存在就不能清理name,因为闭包一直在引用它。如果name的内容很大,就会出现很大的问题了
    

4)静态分配与对象池

猜你喜欢

转载自blog.csdn.net/Lu_xiuyuan/article/details/113002517