Skip to content

深入JavaScript变量、作用域及内存

前端基础第四弹,今天来深入学习Javascript的变量、作用域及内存。JavaScript中的变量是松散类型的,由于没有规定变量必须包含什么数据类型,变量的值和数据类型在脚本生命期内可以改变。

原始值和引用值

JavaScript中有原始值和引用值,上一篇我们深入了JavaScript的数据类型,其中7种原始类型(Undefined、Null、Boolean、String、Number、BigInt、Symbol)所定义的变量就是原始值

而引用类型Object及其衍生(Date、Array等)定义的变量就称为引用值

原始值是按值保存在栈内存中的,而引用值只在栈内存中保存其在堆内存中的地址(指针,指向堆内存)

动态属性

原始值和引用值的定义方式十分类似,都是先定义变量再进行赋值。但是在对值的操作上就存在着很大的区别

  • 引用值可以随时添加、修改、删除属性
  • 原始值不能有属性

复制值

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

原始值按值保存在栈内存中,所以复制时新变量直接复制值,新旧变量之间无关联

引用值在栈内存中保存的是堆内存的地址,所以复制时,新旧变量指向堆内存中的同一地址。

typeof与instanceof

上一篇文章我们提到了typeof,typeof对于确定原始值的类型比较有用,但是对于引用值类型用处不大(都返回Object,除了function)

要想了解变量是什么类型的对象就要使用instanceOf操作符

js
let a = [1,2,3]
console.log(a instanceof Array) // true

执行上下文和作用域

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

全局上下文是最外层的上下文,在浏览器中全局上下文就是window对象,所以所有通过var定义的全局变量和函数都会成为window对象的属性和方法,使用let、const的顶级声明不会定义在全局上下文中。上下文在其所有代码执行完毕后就会被销毁,包括定义在它上面的所有变量和函数(全局上下文只有等到应用退出时才会被销毁)

每个函数调用都有自己的上下文,当函数执行时,函数的上下文被推到上下文栈里,在函数执行完毕后上下文栈会弹出该上下文,将控制权返还给之前的执行上下文

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

作用域链的下一个对象来自包含上下文,再下一个对象来自再下一个包含上下文,以此类推,直到全局上下文

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

js
// 包含两个上下文,全局上下文和changeColor上下文
var color = 'blue'

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

变量声明

ES6之后,JavaScript声明变量发生了翻天覆地的变化,添加了let和const两种定义方式

  • 使用var的函数作用域声明 在使用var声明变量时,变量会自动添加到最近的上下文。在函数中,最接近的上下文就是函数的局部上下文,如果函数未经声明就被初始化,就会被添加到全局上下文

    js
    function add(num1,num2) {
      var sum = num1 + num2
      return sum
    }
    
    let result = add(10,20) // result = 30
    console.log(sum) //这不是有效变量,因为全局上下文中没有sum这个变量
    js
      function add(num1,num2) {
      sum = num1 + num2 // sum未经声明就被初始化,所以被添加到全局上下文,是全局变量
      return sum
    }
    
    let result = add(10,20) // result = 30
    console.log(sum) // 30

变量提升

使用var声明变量时,var声明会被拿到就近作用域的顶部(在另外一文中提过(暂时性死区)

在初始化前使用变量不会抛出错误,而是输出undefined

js
console.log(name) // undefined
var name = 'LIKEWEI'

在实践中,变量提升会导致很多合法但是奇怪的问题

js
var name = 'likewei'
// 等价于
var name;
name = 'likewei'
  • 使用let的块级作用域声明

ES6新增的let关键字和var很相似,但它的作用域是块级的。块级作用域由最近的一堆包含话花括号{}界定。换句话说,if块、while块、function块、甚至连单独的块也是let声明变量的作用域

js
if(true) {
  let a;
}

console.log(a) // ReferenceError: a 没有定义

while(true) {
  let b;
}

console.log(b) // ReferenceError: b 没有定义

function foo() {
  let c;
}

console.log(c) // ReferenceError: c 没有定义

// 这不是对象字面量,而是一个独立的块
// JavaScript 解释器会根据其中国内容识别出它来
{
  let d
}

console.log(d) // ReferenceError: d 没有定义

let和var的另一个不同之处是:let不能重复定义,重复的var定义会被忽略,但是重复定义的let会抛出SyntaxError

js
var a;
var a;
// 不会报错
{ 
    let b
    let b
}
// SyntaxError:标识符 b 已经定义过了

let的行为非常适合在循环中声明迭代变量。使用var声明的迭代变量会泄露到循环外部,这种情况应该避免

js
for(var i = 0 ; i < 10 ; i++) {}
console.log(i) // 10
for(let j = 0 ; j < 10 ; j++) {}
console.log(j) // ReferenceError: j 没有定义

严格来说,let也是有变量提升的,但是由于“暂时性死区”的缘故,不能再声明前使用let定义的变量

  • 使用const的常量声明

除了let,ES6还新增了const关键字,使用const声明的变量必须同时初始化为某个值。一经声明,在其生命周期的任何时候都不能再赋予这个变量新值

js
const a; // SyntaxError; 常量声明时候没有初始化

const b = 4
console.log(b) // 4
b = 3 // TypeError: 给常量赋值

const声明只应用到顶级原语或者对象,也就是说赋值为对象的const不能再被赋值为其他对象,但它的键不受影响

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

const o2 = {}
o2.age = 12
console.log(o2.age) // 12

如果想让整个对象都不能修改,可以使用Object.freeze(),这样再给属性赋值的时候虽然不会报错,但会静默失败。

标识符查找

读取一个标识符时,必须通过搜索确定标识符表示什么。搜索开始于作用域链最前端,以给定的名称搜索对应的标识符。如果在局部上下文中找到该标识符,则搜索停止,变量确定;如果没有找到,则继续沿着作用域链搜索。这个过程一直持续到搜索至全局上下文的变量对象

js
var color = 'blue'

function getColor() {
  return color;
}

console.log(getColor()) // 'blue'


// ------------

var color = 'blue'

function getColor() {
  let color = 'red';
  return color
}

console.log(getColor()) // 'red'

垃圾回收

JavaScript与C、C++等语言不同,开发者不需要手动跟踪管理内存,JavaScript通过自动内存管理实现内存分配和闲置资源回收

基本思路是:确定哪个变量不会再使用,然后释放它占用的内存,这个过程是周期性的,垃圾回收程序每隔一段时间就会自动运行

垃圾回收是一个不完美的方案,因为某些变量是否可用是“不可判定的”

在浏览器发展史上,有两种主要的标记策略

  • 标记清理

    这是最常用的垃圾回收策略。

    当变量进入上下文(比如在函数内部声明一个变量),这个变量就会被加上存在于上下文中的标记;而不在上下文中的变量,逻辑上讲,永远不应该释放他们的内存,因为只要上下文中的代码在运行,就有可能用到他们;

    当变量离开上下文时,加上离开上下文的标记

    给变量加标记的方式有好多种:

    1. 维护"在上下文中"和"不在上下文中"两个列表
    2. 当变量进入上下文时,反转某一位
  • 引用计数(不常用)

内存泄漏

程序的运行需要占用内存,当这些程序没有用到时,还不释放内存,就会引起内存泄漏。也就是说不再用到的内存,没有及时释放,就被称为内存泄漏。而内存泄漏,会让系统占用极高的内存,让系统变卡甚至奔溃。所以会有垃圾回收机制来帮助我们回收用不到的内存。

为什么引用类型的值要存放在堆内存中

众所周知,JS的数据类型分为原始类型和引用类型,原始类型存放于栈空间中,而引用类型只在栈空间中存放堆内存地址,这是为什么呢?

因为JS的执行上下文是由执行上下文栈维护的,如果所有数据都存放在栈空间中维护会影响上下文切换的效率

所以将占用内存较大的引用类型存放在堆内存中

堆内存的分类(待填坑)

一个 V8 进程的内存通常由以下部分组成

  • 新生代内存区(new space) 存放生存时间短的对象
  • 老生代内存区(old space) 存放生存时间长的对象
  • 大对象区(large object space)
  • 代码区(code space)
  • map 区(map space)

针对新生代、老生代内存区,引擎采用了两种不同的垃圾回收机制

性能

垃圾回收程序周期性运行,如果内存中分配了很多变量,则可能造成性能损失,因此垃圾回收的时间调度很重要。尤其是在内存有限的移动设备上,垃圾回收有可能会明显拖慢渲染的速度和帧速率。开发者不知道什么时候运行会收集垃圾,因此最好的办法是在写代码时就做到:无论何时开始收集垃圾,都能让它尽早结束工作

KESHAOYE-知识星球