前端基础知识-变量、作用域与内存
前言
本系列记录和整理前端的基础知识,希望通过系统性地学习来巩固自己对前端的理解。以《高级程序设计》第 4 版(红宝书),作为学习主体,结合自己工作中遇到的实际案例来了解、理解文中的基础知识点。接下来就一起来看看吧
原始值与引用值
ECMAScript 变量可以包含两种不同类型的数据: 原始值和引用值。原始值就是最简单的数据,引用值则是由多个值构成的对象。
6 种原始值: Undefined、Null、Boolean、Number、String 和 Symbol。保存原始值的变量是按值访问的,因为我们操作的就是存储在变量中的实际值。
引用值是保存在内存中的对象。与其他语言不同,JavaScript 不允许直接访问内存位置,因此也就不能直接操作对象所在的内存空间。在操作对象时,实际上操作的是对该对象的引用而非实际的对象本身。
真题演练
在了解原始值和引用值之前我们来做一道题来看看我们掌握情况,是否能详细说出函数执行的完整流程,为什么是这样的?
题1
function addTen(num) {
num += 10
return num
}
let count = 20
addTen(20) // 打印值
count // 打印值
题2
function setName(obj) {
obj.name = 'Nicholas'
obj = new Object()
obj.name = 'Greg'
}
let person = new Object()
setName(person)
person.name // 打印值
复制值
除了存储方式不同,原始值和引用值在通过变量复制时也有所不同。在通过变量把一个原始值赋值到另一个变量时,原始值会被复制到新变量的位置。请看下面的例子:
let num1 = 5
let num2 = num1
把引用值从一个变量赋给另一个变量时,存储在变量中的值也会被复制到新变量所在的位置。区别在于,这里复制的值实际上是一个指针,它指向存储在堆内存中的对象。操作完成后,两个变量实际上指向同一个对象,因此一个对象上面的变化会在另一个对象上反映出来。
let obj1 = new Object()
let obj2 = obj1
obj1.name = 'Nicholas'
console.log(obj2.name) // 'Nicholas'
传递参数
ECMAScript 中所有函数的参数都是按值传递的。如果是原始值,那么就跟原始值变量的复制一样,如果是引用值,那么就跟引用值变量的复制一样。对很多开发者来说,这一块可能会不好理解,毕竟变量有按值访问和按引用访问,而传参则只有按值传递。
来看下开始的两道题。
题1
分别输出的是 30 和 20。因为 count 是基本数据类型,按值传递的时候就是被复制了一份相同值为 20 存入栈中,函数 addTen 后续的操作并不会改变到 count 。
题2
输出的是 Nicholas。 setName(person)将 person 对象的值传入函数,
- 此时函数内部的 obj 相当于执行了 let obj = person 。
- obj 也会通过引用访问相同的对象。执行 obj.name 修改对象引用也会反映到 person.name,因为这两个变量都指向同一个对象。
- 当执行 obj = new Object() 是,obj 的引用发生改变,指向一个新对象的引用。后续的修改就不会反映到 person 上了。
总的来说,函数的参数都是按值传递的,如果传递的是原始值,函数内部会复制一份原始值。如果是对象也是复制的值,只不过复制的值实际上是一个指针,它指向存储在堆内存中的对象。
确定类型
typeof
typeof 操作符是最适合用来判断一个变量是否为原始类型。更确切地说,它是判断一个变量是否为字符串、数值、布尔值、Symbol、Function 或 undefined 的最好方式。
let s = 'jack';
let b = true;
let i = 22;
let u;
let n = null;
let o = new Object()
let symbol = Symbol()
function fn() {}
typeof s // string
typeof b // boolean
typeof i // number
typeof u // undefined
typeof n // object
typeof o // object
typeof symbol // symbol
typeof fn // function
instanceof
typeof 虽然对原始值很有用,但它对引用值的用处不大。为了解决这个问题,ECMAScript 提供了 instanceof 操作符,语法如下:
result = variable instanceof constructor
如果变量是给定引用类型的实例,则 instanceof 操作符返回 true。
person instanceof Object // 变量 person 是 Object 吗?
colors instanceof Array // 变量 colors 是 Array 吗?
pattern instanceof RegExp // 变量 pattern 是 RegExp 吗?
按照定义,所有引用值都是 Object 实例,因此通过 instanceof 操作符检测任何引用值和 Object 构造函数都会返回 true。如果用 instanceof 检测原始值则始终返回 false。
执行上下文与作用域
变量或函数的上下文决定了它们可以访问哪些数据,以及它们的行为。每个上下文都有一个
关联的变量对象
。而这个上下文中定义的所有变量和函数都存在这个对象上。虽然无法通过代码访问变量对象,但后台处理数据会用到它。
全局上下文是最外层的上下文。根据 ECMAScript 实现的宿主环境,表示全局上下文的对象可能不一样。在浏览器中全局上下文就是我们常说的 window 对象。
变量定义方式
通过 var 定义的全局变量和函数都会成为 window 对象的属性和方法。使用 let 和 const 的顶级声明不会定义在全局上下文中。上下文在其所有代码都执行完毕后会被销毁,包括定义在它上面的所有变量和函数(全局上下文在应用程序退出前才会被销毁)。
执行流
每个函数调用都有自己的上下文。当代码执行流进入函数时,函数的上下文被推到一个上下文栈上,在函数执行完之后,上下文栈会弹出该函数上下文,将控制权返回给之前的执行上下文。
作用域链
上下文中的代码在执行的时候,会创建变量对象
的一个作用域链
。这个作用域链决定了各级上下文中的代码在访问变量和函数时的顺序。
代码正在执行的上下文的变量对象始终位于作用域链的最前端。如果上下文是函数,则其活动对象用作变量对象。活动对象最初只有一个定义变量: arguments。作用域链中的下一个变量对象来自包含上下文,下一个对象来自再下一个包含上下文。以此类推直到全局上下文。
真题演练
由于作用域链是比较重要的知识点,来检验下是否能够完成以下这些题目!
题1
var a = 100;
function fn() {
var a = 200;
console.log(a)
}
fn() // 打印值
题2
var a = 40;
function b() {
console.log(a)
}
function f() {
var a = 20;
b();
}
f(); // 打印值
题3
function fn() {
var a = 100;
return function() {
console.log(a)
}
}
var f1 = fn();
var a = 200;
f1() // 打印值
题4
var a = {
name: 'a',
fn: function() {
console.log(this.name)
}
}
a.fn() // 打印值
var fn = a.fn
fn() // 打印值
揭晓答案
题1
输出 200。
在使用 var 变量声明时,变量会被自动添加到最接近的上下文中。在函数中,最接近的上下文就是函数的局部上下文。打印 a 找到最前端的变量输出 200 。
题2
输出 40。
在函数 b 定义时,最近的上下文中无 a 变量,继续往上找全局上下文中的变量 a 输出 40。此题重点是,何时确定作用域。 JavaScript 采用的是静态作用域,在编译时静态确定的作用域。所以函数 b 在定义时只能访问到全局的变量 a 。
关于静态作用域与动态作用域 可以查看 wiki 文档解释,简单地说动态作用域,就是在执行时确定作用域,静态作用域是在定义时确定。
题3
输出 100。
此题也可以通过静态作用域解释,当函数定义时,内部函数能访问到的 a 变量在 fn 函数的上下文中。由于 a 被内部函数引用,该变量一直没有被销毁。当执行 f1() 函数时输出 100, 而非全局上下文中定义的变量值。
题4
输出 a 和 undefined。
fn 的上下文是函数,其活动对象用作变量对象。包含了 arguments、this、全局上下文。而 this 这个变量是要在执行时候才能确认的。
- a.fn() 执行时,this 指向 a,所以打印 a
- fn() 执行时,this 指向 window, 所以打印 undefined
变量声明
ES6 添加了 let 和 const 两个关键字。
let
Es6 新增 let 关键字跟 var 很相似,但它的作用域是块级的,这也是 JavaScript 中的新概念。块级作用域由最近的一对包含花括号 {} 界定。换句话说, if块、while块、function 块,甚至单独的快也是 let 声明变量的作用域。
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 没有定义
{
let d;
}
console.log(d) // ReferenceError: d 没有定义
let 与 var 的另一个不同之处是在同一个作用域内不能声明两次。重复的 var 声明会被忽略,而重复的 let 声明会抛出 SyntaxError。
var a;
var a;
// 不会报错
{
let b;
let b;
}
// SyntaxError: 标识符 b 已经声明过了
const
使用 const 声明的变量必须同时初始化为某个值,一经声明,在其生命周期的任何时候都不能再重复赋予新值。
const a ; // SyntaxError: 常量声明没有初始化
const b = 3;
b = 4 // TypeError: 给常量赋值
const o1 = {}
o1.name = 'jack' // 可以给对象的键进行赋值
垃圾回收
标记清理
JavaScript 最常用的垃圾回收策略是标记清理。当变量进入上下文,比如在函数内部声明一个变量时,这个变量会被加上存在上下文中的标记。当变量离开上下文时,也会被加上离开上下文的标记。 垃圾回收程序运行的时候,会标记内存中存储的存储的所有变量。然后,它会将所有上下文中的变量,以及被上下文中的变量引用的变量的标记去掉。在此之后再被加上标记的变量就是待删除的了,原因是在任何上下文中的变量都访问不到它们了。随后垃圾回收程序做一次内存清理,销毁带标记的所有值并收回它们的内存。
内存管理
在使用垃圾回收的编程环境中,开发者通常无须关心内存管理。不过由于分配给浏览器内存通常比给桌面软件要少很多,分配给移动浏览器的就更少了。
将内存占用量保持在一个较小的值可以让页面性能更好。优化内存占用的最佳手段就是保证在执行代码时只保存必要的数据。如果数据不必要,那么把它设置为 null,从而释放其引用。这个建议最适合全局变量和全局对象的属性。局部变量在超出作用域后会自动解除引用。
let globalPerson = createPerson('Jack')
// 解除 globalPerson 对值的引用
globalPerson = null
需要注意,解除对一个值的引用并不会自动导致相关内存被回收。接触引用的关键在于确保相关的值已经不在上下文里了,因此它在下次垃圾回收时会被回收。
内存泄漏
- 全局属性
function setName() {
name = 'jake' // 挂载到 window 上,只有页面关闭才会释放内存
}
- 定时器通过 b 包引用外部变量。
let name = 'jake'
setInterval(()=> {
console.log(name)
},1000)
- JavaScript b 包。只要 outer 函数存在就不能清理 name,因为闭包一直在引用着它。假如 name 的内容很大,可能就是一个大问题。
let outer = function() {
let name = 'jake';
return function(){
return name;
}
}
小结
- 原始值大小固定,因此保存在栈内存上,引用值是对象,存在堆内存上。
- 从一个变量到另一个变量复制引用值只会复制指针,因此结果是两个变量指向同一个对象。
- 执行上下文可以总结如下。
-
执行上下文分全局上下文、函数上下文和块级上下文。
-
代码执行流每进入一个新上下文,都会创建一个作用域链,用于搜索变量和函数。
-
函数或块的局部上下文不仅可以访问自己作用域内的变量,而且也可以访问任何包含上下文乃至全局上下文中的变量。
-
变量的执行上下文用于确定什么时候释放内存。 离开作用域的值会被自动标记为可回收,然后在垃圾回收期间被删除。
-
参考
静态作用域与动态作用域 《高级程序设计》第 4 版
转载自:https://juejin.cn/post/7178323252438106169