第九章:JavaScript运行时-灵析社区

懒人学前端

一、谈谈对执行上下文的理解?

JavaScript 执行上下文

当一段 JavaScript 代码在运行的时候,它实际上是运行在执行上下文中。下面 3 种类型的代码会创建一个新的执行上下文:

  • 全局执行上下文:只有一个,为存在于 JavaScript 函数之外的任何代码而创建,浏览器中的全局对象就是 window 对象。
  • 函数执行上下文:存在无数个,函数调用时创建。这个上下文就是通常说的“本地上下文”。
  • eval 函数: 指的是运行在 eval 函数中的代码,很少用而且不建议使用。

每一个上下文在本质上都是一种作用域层级。每个代码段开始执行的时候都会创建一个新的上下文来运行它,并且在代码退出的时候销毁掉。JavaScript 引擎通过创建执行上下文栈(Execution Context Stack,ECS) 管理执行上下文。

创建阶段和执行阶段

当我们调用一个函数时,一个新的执行上下文就会被创建。一个执行上下文可分为创建阶段和执行阶段。我们用下面的代码分析一下两个过程:


let x = 10;

function timesTen(a){
    return a * 10;
}

let y = timesTen(x);

console.log(y); // 100

创建阶段

当 JavaScript 引擎第一次执行代码时,它会创建全局执行上下文,在创建阶段会进行以下操作:

  • 创建全局对象,如浏览器中的 window, Node.js 中的 global(第一次)
  • 创建变量和函数,使用 undefined 作为变量的初始值
  • 确定 this 对象指向,第一次执行代码 this 绑定在全局对象上。
  • 确定作用域链

如果 JavaScript 引擎执行上面的代码:

  • 首先,在全局执行上下文中存储变量 x 和 y, 以及函数声明 timesTen
  • 使用 undefined 作为变量 x 和 y 的初始值

执行阶段

代码执行阶段进行以下操作:

  • 变量赋值
  • 函数引用
  • 执行其他代码

函数经过创建阶段和执行阶段,并在执行后返回结果。

如下面图所示:

二、单介绍一下垃圾回收机制

垃圾回收是删除任何其他对象未使用的对象的过程。垃圾收集通常缩写为 "GC"。它是内存生命周期的一部分:

内存生命周期分为:

  • 分配需要的内存
  • 使用分配到的内存(读、写)
  • 不需要时将其释放

垃圾回收需要去寻找内存“不需要”的对象,这里涉及到引用概念。我们也可以简单理解为“链接”。一个对象如果有访问另一个对象的权限(隐式或者显式),叫做一个对象引用另一个对象。

引用计数垃圾回收

这是最初级的垃圾收集算法,只判断“对象有没有其他对象引用到它”。如果没有,对象就会被垃圾回收机制回收。

// 创建两个对象,一个变量 o1 和另一个被引用的属性 a
let o1 = {
    a: {
        b: 2
    }
};

let o2 = o1; // o2 是 o1 的引用

如下图:

我们执行下面代码后:

o2 = 1; 
o1 = null; // 可以被垃圾回收了

限制:循环引用

引用计数垃圾回收无法处理循环引用的问题。因为对象始终互相应用,形成循环,无法被回收。

function f() {
	var o1 = {};
	var o2 = {};
	o1.a = o2; // o1 的属性 a 引用 o2
	o2.a = o1; // o2 的属性 a 引用 o1
	return "azerty";
}
f();

标记清除算法

这个算法把“对象是否不再需要”简化定义为“对象是否可以获得”。算法假定设置一个叫做根的对象,从根开始,找对象的引用,以此类推。循环引用的问题也得以解决。

整个标记清除算法大致过程就像下面这样:

  • 垃圾收集器在运行时会给内存中的所有变量都加上一个标记,假设内存中所有对象都是垃圾,全标记为 0
  • 从各个根对象开始遍历,把不是垃圾的节点改成 1
  • 清理所有标记为 0 的垃圾,销毁并回收它们所占用的内存空间
  • 把所有内存中对象标记修改为 0,等待下一轮垃圾回收

除此之外,JavaScript 引擎还有一些优化处理:

  • 分代收集(Generational collection)—— 对象被分成两组:“新的”和“旧的”。在典型的代码中,许多对象的生命周期都很短:它们出现、完成它们的工作并很快死去,因此在这种情况下跟踪新对象并将其从内存中清除是有意义的。那些长期存活的对象会变得“老旧”,并且被检查的频次也会降低。
  • 增量收集(Incremental collection)—— 如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。因此,引擎将现有的整个对象集拆分为多个部分,然后将这些部分逐一清除。这样就会有很多小型的垃圾收集,而不是一个大型的。这需要它们之间有额外的标记来追踪变化,但是这样会带来许多微小的延迟而不是一个大的延迟。
  • 闲时收集(Idle-time collection)—— 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。

三、如何判断当前脚本运行在浏览器还是 Node 环境中?

在浏览器中,顶级作用域是全局作用域,即可以使用 var 定义一个全局变量。然而 Node.js 却不一样,它的顶级作用域不是全局作用域,var 声明的变量也是该模块的局部变量。

var foo = "foobar";
foo === window.foo; // true

Object.prototype.toString.call() 可以用于判断当前脚本运行的环境:

Object.prototype.toString.call(window); // "[object Window]"
Object.prototype.toString.call(global); // "[object global]"

因此,我们如下判断:

let isBrowser = typeof window !== "undefined" && ({}).toString.call(window) === "[object Window]";
let isNode = typeof global !== "undefined" && ({}).toString.call(global) == "[object global]";

四、JavaScript 事件循环是什么?

JavaScript 是单线程

JavaScript 是单线程编程语言,意味着它一个时间点只能做一件事情,即“一心不可二用”。

JavaScript 引擎执行代码时,从文件的上面开始,到文件结束为止。它创建执行上下文、解析、在执行阶段将函数压入和弹出调用栈等。

如果函数花费太长时间执行,浏览器页面就会“卡死”。这对用户来说体验太糟糕了。我们可以模拟一个执行时间略长的函数

function task(message) {
    // 模拟一段长时间
    let n = 10000000000;
    while (n > 0){
        n--;
    }
    console.log(message);
}

console.log("Start script..."); // "Start script.."
task("Call an API"); // "Call an API"
console.log("Done!"); // "Done"

task 函数要遍历结束后,才会继续执行下面的 console.log("Done!")

事件循环

我们可以将需要长时间执行的代码放到回调函数稍后执行。

console.log("Start script...");

setTimeout(() => {
    task("Download a file.");
}, 1000);

console.log("Done!");

// Start script...
// Done!
// Download a file.

之前提到 JavaScript 是单线程的,然而更准确的说,JavaScript 运行时一次只能做一件事情。

当我们调用 setTimeout() 时,或者向服务器发送一个请求,或者触发 DOM 事件,都是浏览器中 Web API 的一部分。

在我们上面的例子里,调用 setTimeout() 时, Web API 创建一个 1 秒后过期的定时器。


定时器到时间后,JavaScript 引擎将 task 放到的任务队列里面:

更详细的事件循环如下:

  • 从 宏任务 队列中出队并执行最早的任务,宏任务有:

script

DOM 事件,如 mousemove

setTimeout

setInterval

  • 执行所有 微任务:当微任务队列非空时,出队(dequeue)并执行最早的微任务。微任务有:

queueMicrotask(f)

promise

  • 如果有变更,则将变更渲染(render)出来。
  • 如果宏任务队列为空,则休眠直到出现宏任务。
  • 转到步骤 1。

小练习

给出下面代码的输出顺序:


console.log(1);

setTimeout(() => {
	console.log(2);
}, 0);

let promise = new Promise((resolve) => {
	console.log(3);
	resolve();
})
	.then((res) => {
		console.log(4);
	})
	.then((res) => {
		console.log(5);
	});

console.log(6);

答案: 依次输出 1 3 6 4 5 2。按照顺序执行代码:

  • 遇到 console.log 打印 1
  • setTimeout 是 Web API,设置定时器,时间到后加入调用队列中
  • new Promise() 中传递的函数立即执行,因此打印 3
  • .then() 调用都加入任务队列
  • 遇到 console.log 打印 6
  • 查看任务队列,有两个 Promise 创建的微任务,依次打印。
  • setTimeout 的任务加入任务队列。最后执行打印 2。

五、JavaScript 中内存泄漏有哪几种情况?

内存泄漏指申请的内存执行完后没有及时的清理或者销毁,占用空闲内存,内存泄漏过多的话,就会导致后面的进程申请不到内存。因此内存泄漏会导致内部内存溢出。

一般是堆区内存泄漏,栈区不会泄漏。JavaScript 原始数据类型的值保存在栈中,引用数据类型保存在堆中。所以对象、数组等才会发生内存泄漏。

常见的内存泄漏的原因:

  • 隐式全局变量
  • 没有被清除的定时器
  • 游离 DOM 的引用
  • 闭包

我们可以使用下面的方式进行调试:

打开 Chrome 浏览器 -> More Tools -> Developer Tools -> Performance/Memory,一般先在 Performance 面板录制页面内存占用情况随时间变化的图像,对内存泄漏有个直观的判断,然后在 Memory 面板定位问题发生的位置。

1.隐式全局变量

这些场景可能存在内存泄漏隐患:

function foo(arg) {
	bar = "this is a hidden global variable";
}

bar 被添加到 window 对象上了,如果 bar 指向一个巨大的对象或 DOM 节点,那就是安全隐患。

2.没有被清除的定时器

function getData() {
	return "Hello World!";
}

var someResource = getData();
setInterval(function () {
	var node = document.getElementById("Node");
	if (node) {
		node.innerHTML = JSON.stringify(someResource);
	}
}, 1000);

如果后续 idNode 的节点被移除了,定时器里的 Node 变量仍然持有其引用,导致游离的 DOM 子树无法释放。

3.游离DOM的引用


var elements = {
	button: document.getElementById("button")
};
function doStuff() {
	button.click();
}
function removeButton() {
	// button 是 body 的子节点.
	document.body.removeChild(document.getElementById("button"));
	// 因为 elements 对象中缓存了 DOM 节点引用,这里我们始终有对 id 是 button 的引用
}

4.闭包

var theThing = null;
var replaceThing = function () {
	var originalThing = theThing;
	var unused = function () {
		if (originalThing) console.log("hi");
	};
	theThing = {
		longStr: new Array(1000000).join("*"),
		someMethod: function () {
			console.log(someMessage);
		}
	};
};
setInterval(replaceThing, 1000);

定义在 replaceThing 里的函数都实际使用了 originalThing,那就有必要保证让它们都取到同样的对象,即使 originalThing 被一遍遍地重新赋值,所以这些(定义在 replaceThing 里的)函数都共享相同的词法环境。

只要变量被任何一个闭包使用了,就会被添到词法环境中,被该作用域下所有闭包共享。这是闭包引发内存泄漏的关键。

我们可以打开浏览器的开发者工具查看内存数据,选择如下图所示:

点击 Start 按钮后:

图中有两列:

  • Shallow Size:指的是对象本身的内存大小。
  • Retained Size:指的是在删除对象本身及其从 GC 根无法访问的依赖对象后释放的内存大小。

可以看到图中蓝色的条均匀分布,这些蓝色条代表新的内存分配。这些新的内存分配是内存泄漏的候选对象,需要重点关注。除此之外,还可以通过对比内存变化等手段查出具体内存泄漏的原因。

七、JavaScript 的本地存储有哪些方式?

javaScript 本地存储的方法我们主要讲述以下四种:

  • cookie
  • localStorage
  • sessionStorage
  • indexedDB

cookie

cookie 指某些网站为了辨别用户身份而储存在用户本地终端上的数据。是为了解决 HTTP 无状态导致的问题。

作为一段一般不超过 4KB 的小型文本数据,它由一个名称(Name)、一个值(Value)和其它几个用于控制 cookie 有效期、安全性、使用范围的可选属性组成。

localStorage

一个可被用于访问当前源的本地存储空间的 Storage 对象

可以进行以下操作:

// 增加数据
localStorage.setItem('myCat', 'Tom');
// 读取数据
let cat = localStorage.getItem('myCat');
// 移除单个数据
localStorage.removeItem('myCat');
// 移除所有数据
localStorage.clear();

localStorage 有两个缺点:

  • 无法像 Cookie 一样设置过期时间
  • 只能存入字符串,无法直接存对象

sessionStorage

sessionStoragelocalStorage 使用方法基本一致,唯一不同的是生命周期,一旦页面(会话)关闭,sessionStorage 将会删除数据。

indexedDB

MDN 对其的定义如下:

indexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。

优点:

  • 储存量理论上没有上限
  • 所有操作都是异步的,相比 localStorage 同步操作性能更高,尤其是数据量较大时
  • 原生支持储存 JavaScript 的对象
  • 是个正经的数据库,意味着数据库能干的事它都能干

缺点:

  • 操作非常繁琐
  • 本身有一定门槛

应用

在了解了上述的前端的缓存方式后,我们可以看看针对不对场景的使用选择:

  • 标记用户与跟踪用户行为的情况,推荐使用 cookie
  • 适合长期保存在本地的数据(令牌),推荐使用 localStorage
  • 敏感账号一次性登录,推荐使用 sessionStorage
  • 存储大量数据的情况、在线文档(富文本编辑器)保存编辑历史的情况,推荐使用 indexedDB

阅读量:888

点赞量:0

收藏量:0