从内到外的 JavaScript!
所有术语...
从更高层次
按设计
实现细节
好多知识啊!
这篇文章取自我的博客,因此请务必查看以获取更多最新内容。
我在这个博客上写的大部分内容都属于所谓的“初学者友好型”。我一直努力践行这一理念。因为正是这些“初学者”最渴望学习和尝试新事物。他们才刚刚踏上旅程,渴望尽可能多地了解新事物。当然,这个过程需要时间,而且由于 Web 开发和编程领域的快速变化,这个过程往往非常艰难。
但是,在这篇文章中,我们将讨论一些真正适合初学者的东西。这些东西非常稳定,而且不会经常变化!我说的是 JavaScript!但不是它的特性、语法之类的——不。我讲的是JS 的底层工作原理以及一些相关的基本术语。让我们开始吧!
所有术语...
如果你曾在维基百科等页面上阅读过 JS 的相关内容,那么从一开始,你就会被大量术语轰炸,例如高级、解释型、即时编译、动态类型、基于原型等等。有些术语不言自明,经验丰富的程序员肯定也熟悉,但有些则不然。虽然你无需全部了解这些术语也能写出好代码,但这些知识肯定能帮助你更好地理解这门语言以及整个编程过程。因此,彻底了解 JS 意味着学习这些术语的含义……
从更高层次
JS 初学者并不真正关心他们的代码是如何工作的……或者至少他们不必关心!这是因为 JS 是一种高级语言。这意味着所有细节,例如数据如何存储在内存 (RAM) 中,或者 CPU 如何执行提供的指令,都对最终程序员隐藏。因此,“高级”指的是该语言提供的抽象或简化级别。
机器码
从最底层开始,是机器码。众所周知,它只是一组以特定方式排列的 0 和 1,因此它们的不同组对机器有意义。有些可能表示特定的指令,有些则表示数据,诸如此类……
(摘自nayuki.io - x86 汇编的精彩介绍)
集会
再上一级是汇编语言——最低级的编程语言,仅次于机器码。与机器码相比,汇编代码具有人类可读的形式。这样,汇编语言是你能达到的最低级(同时保持理智,不必一直查看机器码参考)。尽管如此,即使具有它的“可读性” ,使用ADD或MOV等指令编写实际的汇编代码也是一项非常艰巨的任务。这还没有考虑到需要为想要运行的不同处理器架构(例如桌面上的x86-64和移动设备上的ARM)编写不同的汇编代码!更不用说不同的操作系统了!这绝对与我们在 JS 中习惯的完全不同,不是吗?无论如何,由于汇编语言仍然只是一种抽象,为了运行,它需要被编译,或者应该说被汇编成机器码的形式,使用一个叫做assembler的实用程序。有趣的是,许多汇编程序甚至不是用纯汇编语言编写的 - 有趣,对吧?
高级
除了汇编语言,我们终于看到了许多人都非常熟悉的语言——最著名的是C和C++。在这里,我们可以编写与 JS 更相似的代码。然而,我们仍然可以使用各种“低级”(与 JS 相比)工具,并且使用它们——我们仍然需要自己管理(分配/释放)内存。代码随后被一个称为编译器的程序转换(也称为编译)为机器码(间接地,中间经过汇编步骤)。注意汇编程序和编译器之间的区别——由于编译器位于更高级别的抽象和机器码之间,它能够做更多的事情!这就是为什么 C 代码具有“可移植性”,因为它只需编写一次即可编译到许多平台和架构上!
非常高的水平
既然 C++ 已经被认为是一门高级语言,你知道还有什么更高级的语言吗?没错——JavaScript。JS是一种在其引擎内部运行的语言,最流行的例子就是V8——用 C++ 编写的!这就是为什么 JS 通常被认为是一种解释型语言(并非 100% 正确,但稍后会详细介绍)。这意味着你编写的 JS 代码不会像 C++ 那样先编译后运行,而是由一个称为解释器的程序实时运行。
如你所见,JS 确实是一种非常高级的语言。这有很多好处,其中最主要的一点是程序员不必考虑那些一旦“深入”就会变得显而易见的细节。如此高抽象层次的唯一缺点是性能损失。虽然 JS 速度非常快,而且还在不断改进,但众所周知,一段 C++ 代码(前提是编写正确)的性能可以轻松超越其对应的 JS 代码。尽管如此,更高的抽象层次可以提高开发人员的生产力和整体的开发舒适度。这是一种妥协,也是为什么不同的编程语言适合不同的任务的众多原因之一。
当然,这只是对幕后情况的简单介绍,所以请谨慎看待。为了让您提前体验一下这种简单介绍的精妙之处,我们将继续探索最高级别的抽象——以 JS 为中心!
按设计
摄影:José Alejandro Cuffia / Unsplash
正如我在之前的一些文章中提到的,所有 JS 实现(基本上只是像V8和SpiderMonkey这样的不同引擎)都必须遵循单一的ECMAScript 规范,以维护语言的完整性。许多与 JS 相关的概念都源于该规范……
动态类型和弱类型
本规范涵盖了许多与 JS 设计和工作原理相关的术语。由此我们得知,JS 是一种动态弱类型语言。这意味着 JS 变量的类型会被隐式解析,并且可以在运行时更改(动态类型),并且它们之间的区分并不严格(弱类型)。因此,像 TypeScript 这样更高级的抽象才得以存在,并且我们拥有两个相等运算符——通常的 ( )==
和严格的 ( ===
)。动态类型在解释型语言中非常流行,而其对立面——静态类型——则在编译型语言中很流行。
多范式
与 JS 相关的另一个术语是它是一种多范式语言。这是因为 JS 具有允许您按自己想要的方式编写代码的特性。这意味着您的代码可以从声明式和函数式,到命令式和面向对象……甚至可以混合这两种范式!总之,编程范式如此多样且复杂,值得专门写一篇文章来阐述。
原型继承
那么,JS 是如何获得“多范式”称号的呢?嗯,肯定有一个因素与 JS 的另一个至关重要的概念有关——原型继承。现在你很可能已经知道 JS 中的一切都是对象。你可能也知道面向对象编程和基于类的继承术语的含义。你必须知道,虽然原型继承看起来与基于类的继承类似,但实际上它们有很大不同。在基于原型的语言中,对象的行为是通过将一个对象用作另一个对象的原型来重用的。在这样的原型链中,当给定对象没有指定的属性时,会在其原型中寻找它,并且该过程一直持续到在任何底层原型中找到它或找不到它为止。
const arr = [];
const arrPrototype = Object.getPrototypeOf(arr);
arr.push(1) // .push() originates in arrPrototype
如果你想知道在 ES6 中基于原型的继承是否已经被基于类的继承所取代(引入了类),那么 - 并没有。ES6类只是基于原型继承概念的一种精心设计的语法糖。
实现细节
我们已经讨论了很多有趣的东西,但我们仍然只是触及皮毛!我刚才提到的所有内容都在 ECMAScript 规范中定义。但是,有趣的是——很多东西,比如事件循环,甚至垃圾收集器,都没有定义!ECMAScript 只关注 JS 本身,而将其实现细节留给其他人(主要是浏览器厂商)去思考!这就是为什么所有 JS 引擎——即使它们遵循相同的规范——都可能以不同的方式管理内存,是否使用 JIT 编译等等。那么,这一切意味着什么呢?
JIT 编译
我们先来谈谈JIT。就像我说的,将 JS 视为一种解释型语言是不对的。虽然多年来一直如此,但最近发生了变化,这种假设已经过时。许多流行的 JS 引擎为了加快 JS 执行速度,引入了一项称为即时编译的功能。它是如何工作的?嗯,简而言之,JS 代码不是被解释,而是在执行过程中被直接编译为机器码(至少在 V8 的情况下)。这个过程需要稍微多一点的时间,但输出速度要快得多。为了在合理的时间内实现这一目的,V8 实际上有2 个编译器(不包括与WebAssembly 相关的东西)——一个是通用编译器,能够非常快速地编译任何 JS,但结果还不错,而另一个稍慢一些,它适用于经常使用且需要非常非常快的代码。当然,JS 的动态类型特性对这些编译器来说并不容易。这就是为什么第二种方法在类型不变的情况下效果最佳,让你的代码运行得更快!
但是,如果 JIT 这么快,为什么一开始没有用于 JS 呢?我们不得而知,但我认为正确的猜测是 JS 不需要那么大的性能提升,而且标准解释器更容易实现。不过,在过去,JS 代码通常只有几行,JIT 编译的开销甚至可能会降低一些速度!现在,浏览器(以及许多其他地方)中使用的 JS 代码数量显著增长,JIT 编译无疑是朝着正确方向迈出的一步!
事件循环
摄影:Tine Ivanič / Unsplash
你可能在某处听说过或读到过,JS 运行在一个神秘的事件循环中,而你之前真的没时间去关注它。所以,现在是时候学习一些关于它的新知识了!但首先,我们需要了解一些背景知识……
调用栈和堆
在 JS 代码的执行过程中,会分配两个内存区域——调用堆栈和堆。第一个性能非常高,因此可以连续执行所提供的函数。每个函数调用都会在调用堆栈中创建一个所谓的“框架”,其中包含其局部变量和的副本。您可以通过Chrome 调试器this
查看它的实际运行,就像我们在上一篇文章中所做的那样。就像在任何类似堆栈的数据结构中一样,调用堆栈的框架会被推送或弹出堆栈,具体取决于要执行的新函数或终止。无论您喜欢与否,如果您曾经编写过抛出“超出最大调用堆栈大小”错误的代码(通常是由于某种形式的无限循环),那么您可能已经了解调用堆栈了。
那么堆呢?就像现实生活中的真实堆一样,JS 堆是存储本地作用域之外的对象的地方。它也比调用堆栈慢得多。这就是为什么访问本地变量和访问上层作用域的变量时可能会看到性能差异的原因。堆也是存储未访问或使用的对象(也称为垃圾)的地方。这就是垃圾收集器发挥作用的地方。JS 运行时的这部分会在必要时激活,清理堆并释放内存。
单线程
现在我们知道了调用栈和堆是什么,是时候讨论事件循环本身了!你可能知道 JS 是一种单线程语言。同样,这并非实际规范中定义的内容,而仅仅是实现细节。从历史上看,所有 JS 实现都是单线程的,事实也是如此。如果你了解浏览器的Web Workers或 Node.js子进程之类的东西——它们实际上并没有使 JS 本身变成多线程的!这两个特性确实提供了多线程功能,但它们都不是 JS 本身的一部分,而是分别属于 Web API 和 Node.js 运行时。
好了,事件循环是如何工作的呢?其实非常简单!JS 从来不会真正等待函数的返回值,而是监听传入的事件。这样一来,一旦 JS 检测到新触发的事件(例如用户的点击),它就会调用指定的回调函数。然后,JS 只需等待同步代码执行完毕,所有这些操作就会在一个永无止境、非阻塞的循环——事件循环中重复进行!没错——这说得有点过于简单了,但这就是基础!
同步优先
关于事件循环需要注意的是,同步代码和异步代码的处理方式并不相同。相反,JS 会先执行同步代码,然后再检查任务队列中是否有需要执行的异步操作。以下代码就是一个示例:
setTimeout(() => console.log("Second"), 0);
console.log("First");
/* Console:
> "First"
> "Second"
*/
如果您执行上面的代码片段,您应该注意到,即使setTimeout
是第一个并且它的超时时间是0
,它仍然会在同步代码之后执行。
如果你使用过异步代码,你很可能知道什么是Promise。这里需要注意的一个小细节是,Promise 本身就是一个独立的事物,因此它们有一个特殊的队列——微任务队列。这里唯一需要记住的重要事实是,这个微任务队列的优先级高于普通的任务队列。因此,如果队列中有任何 Promise 正在等待执行,它将在任何其他异步操作之前运行,例如setTimeout
:
setTimeout(() => console.log("Third"), 0);
Promise.resolve().then(() => console.log("Second"));
console.log("First");
/* Console:
> "First"
> "Second"
> "Third"
*/
好多知识啊!
正如你所见,即使是基础知识也可能……不那么基础。不过,理解所有这些应该不成问题!即使如此,你也不必全部了解才能写出优秀的 JS 代码!我认为只有事件循环部分是强制性的。不过,你知道的,越多越好!
那么,你觉得这篇文章怎么样?你想看一些更深入的主题吗?请在下方的评论和反馈区告诉我。如果你喜欢,可以分享它,并在 Twitter、我的 Facebook 页面或我的个人博客上关注我。祝你拥有美好的一天!
文章来源:https://dev.to/areknawo/javascript-from-the-inside-out-353k