初步理解 JavaScript 底层原理
本文纲领:
- JS解析引擎
- JS执行过程
- 引入:JS单线程
- JS异步运行机制
- 任务队列 与 事件循环
- JS — 定时器
JS解析引擎
简单说,JS 解析引擎就是:
能够“读懂”JavaScript代码,并准确地给出代码运行结果的一段程序
了解过编译原理的人都清楚,对于静态语言(如C、Java),处理上述过程的称为
编译器
,相应地,对于JS这种动态语言则叫解释器
。简单概括编译器和解释器的区别就是:
- 编译器是
在执行程序前
,将整个源代码编译为目标代码(如机器码、字节码)之后,计算机直接再执行此目标代码即可 - 解释器是
在执行程序时
,一条一条地将源代码解释成机器语言来让计算机执行,直接解析并将代码的运行结果输出
- 编译器是
JS语言是由浏览器的解析引擎解释的,不同的浏览器的解析引擎也不同,例如:
1. Chrome : webkit/blink : V8 2. FireFox : Gecko : SpiderMonkey 3. Safari : webkit : JavaScriptCore 4. IE : Trident : Chakra
- 当然,JS不一定非要在浏览器中运行,只要有引擎即可,最典型的比如
NodeJs
,采用了谷歌的V8引擎,使JS完全脱离浏览器运行
- 当然,JS不一定非要在浏览器中运行,只要有引擎即可,最典型的比如
其实,现在很难去界定JS引擎到底是解释器还是编译器,比如Ghrome V8,它为了提高JS的运行性能,在运行前就会先把JS编译成本地的机器码,然后再去执行机器码,这样会快很多;不过,也不需要过分强调JS引擎到底是什么,只需要了解他做了什么事情就OK
- 拓展了解 - JIT:基于方法的JavaScript编译 — JIT 以及 JavaScriptCore解析
JS解析引擎与ECMAScript是什么关系?
- 我们写的JS代码是一段程序,而JS引擎也同样是一段程序(如V8就是用C/C++编写的),如何让程序去读懂程序呢?这就需要定义规则,这里的ECMAScript就定义了一套标准的规则,而标准的JS引擎就会根据规则去实现
- 除了标准之外,当然也有不按标准来的,如IE的JS引擎,也就是为什么JS会有兼容性问题
- 简单说,两者关系为:ECMAScript定义了语言的标准,JS引擎根据它来实现。
JS解析引擎与浏览器又是什么关系?
- 概括说,JS引擎大多存于浏览器的内核中,是浏览器的组成成分之一
- 浏览器当然还有其他的事情,比如解析页面,渲染页面,Cookie管理,历史记录等等
JS执行过程
JS执行过程可分为两步:(1)语言检查;(2)运行
语法检查
- 语法检查可分为:
词法分析
和语法分析
- 词法分析:
- 将字符组成的字符串分解成有意义的代码块,这些代码块被称为
词法单元
- 例如:会将
var a = 1;
分解成:var
,a
,=
,1
,;
- 深入了解词法分析可读此文:JS词法分析
- 将字符组成的字符串分解成有意义的代码块,这些代码块被称为
- 语法分析:
- 会将词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树
- 例如:上面的的词法单元流
var
a
=
2
;
会被转为下方所示的AST
- 深入语法分析和抽象树可读此文:JavaScript 语法解析、AST、V8、JIT
运行阶段
- 运行阶段可分为:
预编译
和执行
- 预编译:将生成的
AST
复制到当前执行的上下文中,对当前AST
的变量声明、函数声明、函数形参进行属性填充 - 执行:逐行读取并运行代码
引入:JS单线程
- 深入理解浏览器进程与JS线程及其运行机制,请参考:
从浏览器多进程到JS单线程,JS运行机制 - 区分 进程与线程 :
- 一个形象的比喻:
进程
是一个独立的工厂,每个工厂都有它独立的资源,工厂之间相互独立线程
是工厂里的工人,工厂内有一个或多个工人 — 工人之间共享工厂的空间等资源 - 来一套比较专业的描述:
进程
是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位,系统会给进程分配 内存)线程
是 CPU 调度的最小单位(线程是建立在进程的基础上的一次运行单位,一个进程可以有多个线程) - 不同进程之间也可以通信,不过代价很大
- 现在通常说的 “单线程” 与 “多线程” 都指的是在一个进程内部的 “单” 和 “多”
所以讨论前提还得属于一个进程才行。
- 一个形象的比喻:
- 浏览器是多进程的:
- 简单理解:浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存),
每打开一个Tab页,就创建了一个独立的浏览器进程
(至少最近的浏览器版本是这样的)- 了解浏览器是多进程后,再看看它到底包含哪些进程(此处仅简明列举主要进程):
(1)Browser进程:浏览器的主进程(负责协调,主控),此进程只有一个
(2)第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建
(3)GPU进程:最多一个,用于3D绘制等
(4)浏览器渲染进程(浏览器内核,Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响
- 了解浏览器是多进程后,再看看它到底包含哪些进程(此处仅简明列举主要进程):
- 注意: 在这里浏览器应该也有自己的优化机制,有时候打开多个 tab 页后,可以在 Chrome 任务管理器中看到,有些进程被合并了。
所以每一个 Tab 标签对应一个进程并不一定是绝对的
- 简单理解:浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存),
- JS语言的一大特点就是:单线程;也就是说,同一时间只能做一件事情,那么,为什么它不能有多个线程呢,这样也好提交效率啊…
- 在
Java
和C#
中的异步均是通过多线程实现的,没有循环队列一说,直接在子线程中完成相关的操作
- 在
- JS的用途决定了JS必须是单线程的:作为浏览器脚本语言,JS的主要用途是与用户互动,以及操作DOM
- 例如,假设有多个线程,一个线程在某DOM节点上添加内容,另一线程要删除这个节点,这时浏览器应该以哪个线程为准?
- 所以,为了避免复杂性,从一诞生,JS就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
- 为了利用多核CPU的计算能力,HTML5提供了 Web Worker 标准,允许JS脚本创建多线程,但是子线程完全受主线程的控制,而且不能操作DOM。所以,这个新的标准并没有改变JS单线程的本质
JS异步运行机制
- 同步与异步如图:
- 同步:
- 若在函数返回结果时,调用者能够拿到预期的结果(即函数计算的结果),那么这个函数就是同步的,例如:
//在函数返回时,获得了预期值,即2的平方根 Math.sqrt(2); //在函数返回时,获得了预期的效果,即在控制台上打印了'hello' console.log('hello');
- 若函数是同步的,即使调用函数执行任务比较耗时,也会一直等到执行结束。如:
function wait(){ var time = (new Date()).getTime(); //获取当前的unix时间戳 while((new Date()).getTime() - time > 5000){} console.log('5秒过去了'); }; wait(); console.log('慢死了');
- 上面代码中,函数wait()是一个耗时程序,持续5秒,在它执行的这漫长的5秒中,下面的console.log()函数只能等待,这就是同步。
- 若在函数返回结果时,调用者能够拿到预期的结果(即函数计算的结果),那么这个函数就是同步的,例如:
- 异步:
- 如果在函数返回的时候,调用者还不能得到预期结果,而是将来通过一定的手段得到(例如回调函数),这就是异步。例如ajax操作。
- 如果函数是异步的,发出调用之后,马上返回,但是不会马上返回预期结果。调用者不必主动等待,当被调用者得到结果之后会通过回调函数主动通知调用者,例如:
//读取文件 fs.readFile('Hello.text', 'utf-8', function(err, data){ console.log(data); }); //网络请求 var xhr = new XMLHttpRequest(); xhr.onreadystatechange = xxx; //添加回调函数 xhr.open('GET',url); xhr.send();
- 上述中的读取文件函数
readFile()
和网络请求的发起函数send()
都将执行耗时操作,虽然函数会立刻返回,但是不能立刻得到预期的结果,因为耗时操作交给其他线程执行,暂时不能获取预期结果。 - 而上述过程中,通过回调函数
function(err, data){ console.log(data) }
和onreadystatechange
,在耗时操作执行完成后会把相应的结果信息传递给回调函数,通知执行JS代码的线程进行回调
- 上述中的读取文件函数
任务队列 与 事件循环
- 浏览器
- OK,现在,我们知道了:(1)JS是单线程的;(2)异步的概念;
- 现在问题来了,既然JS是单线程的,怎么还会异步呢,谁去执行异步的那些耗时操作呢?
- 首先,我们得清楚,JavaScript仅仅是一门语言 ,我们讨论单线程以及多线程都得结合具体的运行环境。既然JS通常是在浏览器中运行的,那么我们从浏览器角度思考一下:
- 目前主流浏览器为:Chrome,Safari,FireFox,Opera(或许还应有IE)。浏览器的内核是多线程的。
- 对于JS的宿主环境—浏览器,浏览器的内核是多线程的;
- 在内核控制下各线程相互配合以保持同步,浏览器通常由以下 常驻线程 组成:
注意:渲染引擎线程 和 JS引擎线程 是不能同时进行的,渲染引擎线程在执行任务时, JS引擎线程会被挂着,因为JS可以操作DOM,与正在渲染中的DOM可能发生矛盾(1)渲染引擎线程: 负责页面的渲染 (2)JS引擎线程: 负责JS的解析和执行 (3)定时触发器线程: 处理定时事件,比如setTimeout, setInterval (4)事件触发线程: 处理DOM事件 (5)异步http请求线程: 处理http请求
- 既然异步操作是靠浏览器中多个线程的合作完成的,那么
异步的回调函数
又是怎样执行的呢 ? 这还得从任务队列
和事件循环
说起
- 任务队列(Task Queue):
- JS是单线程的,但单线程就意味着:所有任务需要排队,前一个任务结束,才会执行后一个任务。即使前一个任务耗时很长,后一个任务也必须一直等着。
- 耗时的情况通常不是因为计算量过大使得CPU忙不过来,而是因为I/O设备很慢,比如Ajax操作从网络中读取数据,只能等到结果出来才能执行下一步
- JS语言的设计者意识到,这时主线程完全可以不管I/O设备,挂起处于等待中的任务,先运行排在后面的任务。等到I/O设备返回了结果,再回过头,把挂起的任务继续执行下去。
- 于是,所有的任务都分成两种,即
同步任务
与异步任务
:- 同步任务:是在主线程上排队执行的任务,前一个任务执行结束,才能执行后一个
- 异步任务:是不进入主线程,而进入任务队列的任务,只有当任务队列告知主线程,某个任务可以执行了,该任务才会进入主线程执行
- 具体来说,异步执行的运行机制如下:
注:同步执行也是如此,因为它可以被视为 没有异步任务的异步执行1、所有同步任务都在主线程上执行,形成一个执行栈(execution context stack). 2、除主线程外,还有任务队列,只要异步任务有了运行结果,就在任务队列中放置一个事件 3、执行栈中所有同步任务完成时,系统就会读取任务队列,看看里面有哪些事件 这些事件又分别对应哪些异步任务,于是该任务结束等待,进入到执行栈执行 4、主线程不断重复上面的第3步
- 下面就是主线程和任务队列的示意图:
- 只要主线程空了,就去读取 “任务队列”,这就是 JS 的运行机制:一个主线程 + 一个任务队列,这个过程会不断重复。
- 注:任务队列里面的事件,除了I/O设备的事件之外,还包括
用户产生的事件
(比如鼠标点击、页面滚动…),只要指定过回调函数
,这些事件发生时,就会进入任务队列,等待主线程读取。
- 任务队列是一个先进先出的数据结构,只要“执行栈”一清空,就会读取任务队列上的事件,但是由于存在后文存提到的
“定时器”功能
,主线程首先要检查一下执行时间,某些事件,只有到了规定时间,才能返回主线程。
- 回调函数(callback):
- 所谓的回调函数,就是被主线程挂起来的代码
- 前文提到:
只要异步任务有了运行结果,就在任务队列中放置一个事件
,这个事件,就是 注册异步任务时添加的回调函数 - 异步任务必须执行回调函数,当主线程执行的异步任务,就是执行对应的回调函数。
- 事件循环(Event Loop):
- 实际上,主线程只会做两件事情,就是从任务队列里面:读取任务、执行任务,反复如此,这种机制就叫做
事件循环机制
,一次 读取 + 执行 就叫一次 循环 - 事件循环用代码表示大概是这样的:
while (true) { var message = queue.get(); execute(message); }
- 为了更好地理解 Event Loop ,请看下图:
- 上图中,主线程运行的时候,产生
堆(heap)
和栈(stack)
,栈中的代码调用各种外部API,它们在任务队列中加入各种事件(click,load,done),只要栈中的代码执行完毕,主线程就会去读取“任务队列”,依次执行那些事件,所对应的回调函数。 - 执行栈中的代码(同步任务),总是在读取任务队列(异步任务)之前执行,如下例:
var req = new XMLHttpRequest(); req.open('GET', url); req.onload = function (){}; req.onerror = function (){}; req.send();
- 上面的代码中,
req.send()
方法是一个异步任务,是用过Ajax操作向服务器发送数据,这意味着只有当前脚本的所有代码都执行完,系统才会去读取任务列表,所以,它等价于:var req = new XMLHttpRequest(); req.open('GET', url); req.send(); req.onload = function (){}; req.onerror = function (){};
- 也就是说,指定回调函数部分(
onload
和onerror
),在send()
方法的前面或者后面都无关紧要,因为它们就在执行栈中,系统会执行完它们之后才去读取任务队列
- 上图中,主线程运行的时候,产生
- 实际上,主线程只会做两件事情,就是从任务队列里面:读取任务、执行任务,反复如此,这种机制就叫做
定时器
- 除了放置异步任务事件,任务队列还可以放置 定时事件,即指定某些代码在指定事件后执行,这就是定时器功能,即定时执行的代码
- 定时器功能主要由
setTimeout()
和setInterval()
这两个函数完成,其内部机制完全一样,区别在于:前者指定的代码是一次性执行,后者则为反复执行 - JS定时器参考博文:
本作品采用《CC 协议》,转载必须注明作者和本文链接