【前端复习计划】-运行机制

172 阅读14分钟

真的有必要搞懂 Javascript 的运行机制,这样在你无意中写了一些奇奇怪怪的代码,得出了一些奇奇怪怪的结果后,你才能弄清楚到底发生了什么事情。比如,为什么函数可以先使用,后声明;内存泄漏是为什么?怎么处理?Javascript 是如何处理异步网络请求的...

内存生命周期

Javascript程序在运行时,需要创建并使用变量,使用完之后,还要释放变量所占用的内存空间,整个过程分为下面三个步骤:

  • 分配内存 : 变量是存在于内存中的,所以需要为其分配合适大小的空间
  • 使用内存: 使用内存中的变量,就是读和写操作
  • 释放内存: 把不再使用的变量销毁,从而释放它占用的内存空间

一个变量从产生到使用再到被销毁都是由 Javascript 引擎来管理的

变量的存储方式

栈内存

对于 Javascript 来说,通常将基本类型的变量存储在栈中,基本类型变量指的是NumberStringBooleanNullUndefined 这些类型的变量

这个过程也叫 静态内存分配 ,因为栈中存储的所有基本类型变量,它们的大小和数量在编译时就是确定的,就像上面的 5 个变量,在编译阶段就只会是 5 个变量,不会变多也不会变少,它们占用的空间也是确定的。

基本类型变量是不可改动的(immutable) ,Javascript不会改动原有的值,而是新建一个值来赋予这个变量

堆内存

堆内存用来存放引用类型的变量,为什么不在栈中而是在堆中存放呢?因为,引用类型变量的大小在编译时是不知道的,只能在运行时确定,有可能一个对象开始为空,但随着程序的运行,会变得越来越大,这种情况下显然不适合用栈来存放,所以采用堆来存储引用类型的变量,这个过程也称 动态内存分配

下图展示了一个对象在内存中的存储方式:

对象的所有属性都被存储在堆内存中,而栈内存中存放的实际是这个对象的内存地址(引用)。所以,Javascript中变量的存储方式就是:基本类型的变量存储在栈内存中,对象和函数这种引用类型的变量会将引用地址存放在栈内存中,而真正的变量值则存放在堆内存中


声明提升

变量声明提升

JavaScript 引擎的工作方式是先解析代码,获取所有被声明的变量,然后再一行一行地运行,所有变量声明语句,都会被提升到代码的头部,这就是变量提升(hoisting)

变量提升不会改变实际代码的位置,而是在编译阶段,在内存中将声明提升到头部;也就是说,在写代码的时候,可以先使用,后声明一个变量

console.log(a);

var a = 'Hello World!'

注意:只有通过 var 声明的变量才会被提升,最新的 ES6 规范中,letconst 声明的变量不会被提升,只能先声明,后使用,否则会抛出错误

console.log(a);

const a = 'Hello World!'

// Uncaught ReferenceError: a is not defined

函数声明提升

与变量一样,声明一个函数相当于声明一个变量,函数声明也会被提升

// 先使用,后声明
say() // Hello

function say(){
	console.log('Hello');
}

在严格模式下,如果重复声明一个变量,Javascript 会报错 Uncaught SyntaxError: Identifier 'xxx' has already been declared,而 ES6 默认启用了严格模式,所以不要重复声明同一个变量

垃圾回收

垃圾回收简称 GC (Garbage Collection),是保证程序在内存中稳定运行的必要手段。

Javascript程序在运行时,需要创建并使用许许多多的变量,每个变量都需在内存中都需要占用一定的存储空间,但是计算机的内存的却是有限的,所以不可能无限制的创建变量,这时就要对程序中不再使用变量进行整理然后销毁,从而腾出新的内存空间以保证程序继续稳定的运行下去,这些不再使用的变量就是内存垃圾

在 JavaScript 内存管理中有一个概念叫做 可达性,表示可访问或者可用的变量,它们会被存储在内存中;反之,不可达指的就是不会再被访问的变量,下面有一个例子:

const city = "Shang Hai"

let person = {
	name: 'Jack',
  age: 24,
}

首先要知道,在 Javascript 中,基本类型值存储在栈中,引用类型值存储在堆中,其引用地址则存储在栈中(这点请背下来),所以上面的两个变量用图形来表示如下:

现在,对代码做一点小小的修改

// 重新赋值给 person 对象
person = {
  school: 'PKU',
  major: 'Software Engineering',
}

用图形表示:

现在的情况是:当 person 被重新赋值以后,原来存储在堆中的属性现在与 person 之间就再也没有联系了,栈内存中的 person 的引用地址也发生了变化,也就是说之前的 nameage 这两个变量就变成了不会再被访问的变量——不可达变量。既然这两个变量不可达,那么就需要释放它们所占用的存储空间,也就是进行垃圾回收

JavaScript具有自动垃圾收集机制,也就是说,执行环境会负责管理代码执行过程中使用的内存。而在C和C++之类的语言中,开发人员则需要手动跟踪内存的使用情况。

所谓自动垃圾回收就是:Javascript引擎自动的找出那些不再继续使用的变量,然后释放其占用的内存。垃圾收集器会按照固定的时间间隔(或代码执行中预定的收集时间),周期性地执行这一操作。自动垃圾回收的方式有两种: 标记清除引用计数


引用计数

引用计数的原理是:

记录每个变量被引用的次数,当声明了一个变量,并将一个引用类型值赋给该变量时,这个变量的引用次数就是1;如果同一个变量又被赋给另一个变量,则该变量的引用次数再加1;反之,则这个变量的引用次数减1;当一个变量的引用次数变成 0 时,则表明它是不可达变量,当垃圾收集器下次再运行时,它就会回收引用次数为 0 的变量

优点

引用计数在引用值为 0 时,也就是在变成垃圾的那一刻就会被回收,所以它可以立即回收垃圾,而标记清除算法需要每隔一段时间进行一次(类似轮询的过程),在运行过程中,Javascript主线程就必须要暂停当前的任务,转而去执行一段时间的 GC,另外,标记清除需要遍历内存中的变量来决定如何垃圾回收,而引用计数则只需要在引用时计数就可以了

缺点

引用计数存在着一个棘手的问题——循环引用,假如一个对象 A.b = B ,而另一个对象 B.a = A 那么实际情况是这两个对象永远都不会被垃圾回收,因为它们的引用计数一直都是1,永远不会为0,也就永远不可能被垃圾回收,除非程序员手动的通过 A = null 或 B = null 这种方式来告诉垃圾回收器当前变量不存在引用了,它才会被回收。

其次,引用计数需要维护每一个进入执行环境的变量的状态,也就是需要维护一个计数器,这样就额外增加了内存开销和性能开销。


标记清除

标记清除通过给变量一个额外的标记来表示它当前的状态,当变量进入执行环境时,就将这个变量标记为 “进入环境”;当变量离开环境时,则将其标记为“离开环境”,表示变量可以被垃圾收集器回收了,这是一个比较好理解的过程。


假设变量进入执行环境时代表它是一个有用的变量,给它标记为 1,反之,则标记为 0 ,那么整个标记清除的大致过程如下:

  1. 垃圾收集器在运行之前,会先假设内存中所有变量都是垃圾,给它们打上标记 0
  2. 从各个顶层作用域向内层作用域开始遍历,把不是垃圾的变量标记为 1
  3. 清理掉所有标记为 0 的变量,回收它们的内存空间
  4. 回到第 1 步,继续下一轮垃圾回收

优点

标记清除的优点就是实现起来比较简单,要判断一个变量是否可以被垃圾回收,只需要判断它是否处于执行环境中,这种标记通过 0 或 1 就能表示;其次,也不用像引用计数那样,额外的去维护这些变量各自的状态,这使得垃圾回收的效率得到提高


缺点

标记清除算法有一个很大的缺点,就是在垃圾回收以后,原来这些垃圾变量占用的位置空了出来,这样就会造成内存空间的不连续,简单来说,就是再次为别的变量分配内存时,这些稀疏内存空间太小则装不下新的变量,太大又会造成浪费,这就是内存碎片

\

目前绝大部分主流浏览器使用的垃圾回收机制为标记清除


同步异步

Javascript 是单线程的,所有代码都在主线程上执行

假如有一个函数消耗了特别长的时间才执行完(耗时任务),那么这个函数后面所有的代码都得等着它执行完了才能被执行,也就是说它会阻塞到主线程上面所有代码的执行,就像你看电影看到一半,突然网络变慢,卡住不动了,你只能等着......如果在浏览器上发生,那就是页面加载到一半就卡住了、元素没有样式或者直接白屏。

为了解决这个问题,浏览器将那些会阻塞主线程代码执行的耗时任务单独拎出来处理,它们被称为 异步任务

首先我们需要搞懂什么是任务,下面是 MDN 的解释:

A task is any JavaScript code which is scheduled to be run by the standard mechanisms such as initially starting to run a program, an event callback being run, or an interval or timeout being fired. These all get scheduled on the task queue

Tasks get added to the task queue when:

  • A new JavaScript program or subprogram is executed (such as from a console, or by running the code in a <script> element directly.
  • An event fires, adding the event's callback function to the task queue.
  • A timeout or interval created with setTimeout() or setInterval() is reached, causing the corresponding callback to be added to the task queue

The event loop driving your code handles these tasks one after another, in the order in which they were enqueued. Only tasks which were already in the task queue when the event loop pass began will be executed during the current iteration. The rest will have to wait until the following iteration.

我的理解是:

任务,首先是任意的可以被执行的 Javascript 代码;其次,任务指的是那些需要异步执行的操作,比如 onclick 事件监听函数、setTimeout 定时器函数,说白了,任务就是那些不能够确定合适才能执行完毕的代码


最新的规范中,将异步任务分为了 微任务(microtask)宏任务(macrotask)

  • 异步微任务包括了:process.nextTickPromisequeueMicrotaskMutationObserver
  • 异步宏任务包括了:setTimeoutsetIntervalsetImmediaterequestAnimationFrame

它们的执行顺序是不一样的,浏览器在执行完主线程上的同步代码以后,才会执行异步宏任务,但是在此之前,浏览器还会检查是否有异步微任务,如果有那么先执行完所有异步微任务,然后再执行异步宏任务

举个例子:

console.log('script start');

setTimeout(function () {
  console.log('setTimeout');
}, 0);

Promise.resolve()
  .then(function () {
    console.log('promise 1');
  })
  .then(function () {
    console.log('promise 2');
  });

console.log('script end');

我们来分析一下上面代码的执行过程:

  1. 首先执行第一行 console.log ,它是同步代码
  2. 遇到 setTimeout 放入任务队列
  3. 遇到 Promise 放入任务队列
  4. 执行最后一行 console.log 代码
  5. 检查任务队列,发现存在微任务,于是执行 Promise
  6. 检查任务队列,发现存在宏任务,于是执行 setTimeout
  7. 程序执行完毕

所以,你现在应该能答出上面代码的打印结果了吧(本人实测 FireFoxChromeEgde上打印的都是这个结果)

事件循环

Web API

JavaScript 单线程指的是浏览器中负责解释和执行 JavaScript 代码的只有一个线程,即JS引擎线程(主线程) ,单线程就意味着同一时刻 Javascript 只能干一件事情,而不能同时干另外一件事情,假如你遇到了一个需要等待 5s 的 AJAX 请求,那么在请求结果返回之前的 5s 内,Javascript 将不能做别的任何事情,也就是说页面将会卡住而且无响应,直到 AJAX 请求结束

那么,如何避免上面的现象呢?

浏览器将那些可能会阻塞页面响应的代码单独提出来进行处理,它们属于 Web API 的范畴,而不属于 Javascript 引擎,所以就不会妨碍 Javascript 代码的执行,下面这些都属于 web API:

  • 事件触发线程(DOM Events
  • 定时触发器线程(setTimeOutsetInterval
  • 异步http请求线程(XMLHttpRequestFetch APIPromise
  • GUI渲染进程

Javascript 的全局执行上下文是 Javascript 引擎的一部分,而 Web API 不是,它会创建一个单独的执行上下文,而且不在 Javascript 引擎中。常见的异步任务都属于 Web API

Task Queue

Task Queue 指的是任务队列,它用来存放回调函数,也可以叫它 Callback Queue。通常,Javascript 主线程中,所有的代码一行一行按顺序执行,如果遇到异步任务,会将其放入 Web API 的上下文中执行,然后继续执行后面的代码。

我知道上面的这段话一时难以理解,所以我们还是通过代码来演示:

function netWork(callback){
  const doWorks = ()=> {
		console.log('get the network source')
		callback()
	}
  
  // 异步任务
	setTimeOut(doWorks,2000)
}

function end(){
	console.log('Finished!')
}

// 模拟一个网络请求操作
netWork(end)

上面代码的意图是,netWork去执行网络请求的耗时操作,endnetWork执行完毕之后调用,意在模拟一个请求网络资源的操作。我们可以分析一下它的执行过程:

  1. newWork 被推入 Javascript 主线程的执行栈中执行,然后是 setTimeOut入栈,但由于 setTimeout 属于 Web API ,所以它会被单独处理,
  2. 2秒后 setTimeOut被触发执行,执行完毕后,setTimeout 的回调函数——doWorks被放入任务队列中
  3. 任务队列接受到了callback(),事件循环会在适当的时机将其推入主线程执行栈中执行

Javascript处理异步任务的机制就是这样:

当遇到异步任务,就放入 WebAPI的执行上下文中执行,然后继续执行后面的代码;WebAPI 会等异步任务有了结果,再其绑定的回调函数放入任务队列中;任务队列再将回调函数按顺序推入主线程执行栈中执行

注意,任务队列并不是一收到回调函数就立即放入主线程中执行,而是会先检查主线程上是否有别的任务在执行,如果有就等待主线程处理完了再推入回调函数。所谓事件循环就是循环地去检查主线程是否空闲,一旦空闲就将回调函数放入其中执行。

如果你没看懂,那么下面是参考资料,这样你就可以直接从源头处开始,自己研究: