我所知道的JavaScript——关于提升(Hoisting)的头脑风暴

578 阅读13分钟

前言

回望2023年,和付出不成正比的回报、接踵而至的琐碎业务、滑天下之大稽的晋升制度。这些因素都在提醒着我,是时候前往下一站了。

君子不器

对我来说,2024年的关键词是“求变”。

现在,5月份忙碌的工作已经告一段落,除了例行地刷力扣之外,

image.png 终于有足够的时间开始准备和自身岗位相关的面试了。接下来便是回顾知识、沉淀知识的过程,此篇文章主要针对的是js相关的面试题

“知其然,知其所以然”一直都是我写技术文章的主旨,希望你在阅读完本文后,遇到类似的题目,不再是艰难地回忆,而是如同电信号流过脉络一般自然地脱口而出~

提升(Hoisting)

"Talk is cheap,show me the code."

让我们直接看一道经典的题目,并以它为起点,一起来试着发掘到底关联了多少知识点?

例题

基础题(一)

请给出下方代码输出的结果

console.log(a);

var a = 823;

答案

undefined

变量提升相关的题目常常是以一种反直觉的方式去写代码,让人看着就难受,并且心里会想,正经人谁这样写代码啊?

在最早我还没有多少js相关知识储备的时候,我看到这个题目的第一反应是程序会报错😓。

在js里,假如使用var关键字去声明一个变量,那么这个变量的声明会被提升至当前作用域的顶部,而对此变量的赋值操作会留在原地。因此基础题(一)的代码其实可以看成这样:

var a;

console.log(a);

a = 823;

换句话说,对于Javascript来讲,它并没有将var a = 823当作是我们所倾向认为的1行指令,而是将它拆分为了2行:

var aa = 823

  • var a是声明语句,是在编译阶段处理的。

  • a = 823是赋值语句,是在执行阶段处理的。

而编译阶段又在执行阶段之前,这也就意味着var a = 823中的var a被率先拎出去了,而a = 823留在了原地

这里提到了编译阶段执行阶段这两个名词,我们不妨可以暂时脱离一下主题,聊聊它们两位。

JavaScript是一门什么样的语言,它和Java的区别是什么?

虽然我是计算机科班的(浙江省前五的大学🥺),但我几乎没有在大学生涯里选到过哪门课是讲JavaScript的。大一的时候学的C,大二的时候学了C#Java以及python。这些语言都是有系列课程的,从输出Hello World到最终的课程设计。坦白来说,JavaScript就像是一位感情很好的网恋对象,但是从没线下见过面。

那这里就从“编译”的角度来和大家聊一聊JavaScript

JavaScript是一门编译语言,不过它与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。——《你不知道的JavaScript》

我们先来聊聊什么是编译

通俗地来说,编译就是是将人能看懂的代码转换成机器能看懂的代码。 从某种程度上来说就像咱们用的React,写的时候是我们能看懂的JSX,但是到了运行代码的时候,则要先通过Babel,将其转译成JavaScript,让浏览器能够看懂。

当然,其中奥妙仅仅靠我自己的俗语几句显然没有说服力。下方内容以《编译原理》(龙书)为参考教材,结合我自己的理解,和大家接着唠唠编译

扩展内容(感兴趣可以看,或者看完文章的后半部分内容再回过头来看。)

编译器

对代码的编译工作实际上是由编译器负责完成的,那么,什么是编译器呢?

简单地说,编译器是一个程序,它读入用某种语言(源语言)编写的程序并将其翻译成一个与之等价的以另一种语言(目标语言)编写的程序。作为这个翻译过程的一个重要组成部分,编译器能够向用户报告被编译的源程序中出现的错误。——《编译原理》

image.png

编译

编译由两部分组成【分析(analysis)】和【综合(synthesis)】

  • 分析:将源程序分解成多个组成要素,并在这些要素之上加上语法结构。然后,它使用这个结构来创建该源程序的一个中间表示(并且此部分还会收集有关源程序的信息,并把信息存放在一个符号表中。)。
  • 综合:根据中间表示符号表中的信息来构造用户期待的目标程序。

这里我再贴一张编译器的步骤图,方便大家了解编译的全过程

image.png

活学活用,亲自走一遍编译的过程

看完上面关于编译方面各种专有名词的定义描述,我想大家对编译应该能够具备一个大概的认知了。但是,了解概念并不是我们的最终目的,我们的最终目的是理解概念。那么接下来,我将通过一个具体的代码例子,帮助大家更好地理解概念,达成我们的最终目的。

假设我们有这样一份文件:test.js,文件里有这么一行代码

var a = 823

当我们在终端使用

node test.js

编译这个文件时,会发生什么呢?

首先,var a = 823就是输入编译器的字符流,编译器在接收到字符流后,要做的第一件事情就是词法分析,于是这行代码就被拆成了下面这几部分(也就是所谓的有意义的词法单元):

  • var
  • a
  • =
  • 823

拿到这些词法单元后,紧接着编译器就开始做第二件事情,语法分析。将这些生成的词法单元创建成一个树形结构的中间表示。

Javascript中,则是以抽象语法树(Abstract Syntax Tree,AST)来作为表示方法(语法树抽象语法树有哪些不同之处,咱们下次有机会再聊)。

“树形的中间表示...”

“AST...”

这些个名词在上文中一直被提到,它到底是个什么样的数据结构

好问题,于是我让esprima这个第三方库来帮帮忙,给大家直观地展示这个数据结构。

var esprima = require('esprima');
var util = require('util');

var code = 'var a = 823;';
var ast = esprima.parseScript(code);
// 在这个例子中,{ depth: null } 选项告诉 inspect 方法
// 不要限制嵌套对象的深度,这样就可以打印出所有的内容。
// { colors: true } 选项则会使输出内容带有颜色,便于阅读。
console.log(util.inspect(ast, { depth: null, colors: true }));

为什么要使用util.inspect这个方法?

因为如果我们使用Node.js去运行test.js这个文件时,假设我们直接使用这样的代码去打印

console.log(ast);

则输出的结果是这样的

image.png

这是因为Node.js默认的console.log功能会将大型对象和数组简化显示,以避免输出过多内容。因此输出中包含[Array],而不是实际的内容。

为了解决这个问题,我们需要使用Node.js 的 util 模块中的 inspect 方法来打印完整的对象或数组。

执行之后,会在终端打印这样的信息,可以看到此前[Array]中的内容了

image.png 我们再做一些删减,将其变为我们熟悉的js对象。

{
  type: "Program",
  body: [
    {
      type: "VariableDeclaration",
      declarations: [
        {
          type: "VariableDeclarator",
          id: { type: "Identifier", name: "a" },
          init: { type: "Literal", value: 823, raw: "823" },
        },
      ],
      kind: "var",
    },
  ],
  sourceType: "script",
};

我们也可以用图片的方式展示

image.png

接下来,构建完AST后,编译器会进行语义分析,其中包含了类型检查等工作。

  • 如果有问题,则会停止编译,并报告错误
  • 如果没问题,就会进入代码生成环节了,将AST转换成可执行的机器码。

至此,我们亲自走了一遍编译的全流程,也一睹了AST的风采。

现在如果有人问我们什么是编译,以及编译的流程,我们就算不说是口若悬河,也应该能是口若悬溪😄

基础题(二)

聊到编译一时兴起,扯得有些远了。回过头来咱们继续做几道题,毕竟“实践是检验真理的唯一标准”嘛。

请给出下方代码输出的结果

a = 823;

var a;

console.log( a );

答案

823

这当然很简单,在知道js编译的规则之后,基础题(二)也可以转换成下方的代码

var a;

a = 823;

console.log( a );

那么再来看个变式

基础题(三)

请给出下方代码输出的结果

var a = 823;

var a;

console.log( a );

答案

823

这道题也肯定难不住你,我们继续利用已知的机制,可以将代码转换成下方的样子

var a;

var a;

a = 823;

console.log( a );

欸,好像有点变化。注意看,这里的代码重复使用var关键字声明了a(2次),那么你会不会也好奇这样一件事:

针对于相同变量的重复声明JavaScript会怎么做?

作用域

在前面的内容,我们知道了变量的声明是在编译阶段完成的,而编译阶段又在执行阶段之前,因此会出现提升的现象。现在我们遇到了相同变量的重复声明这一问题,要想解决这个问题,我们就得先了解JavaScript到底是如何管理这些声明的变量的。

  • 这些变量被存放在何处?
  • 这些变量又是如何被找到的?

就像学校有学校的规章制度去管理学生的学习生活一样,JavaScript中也有这样一套“规章制度”,用来存储这些变量,并且之后可以方便地找到这些变量,这套“规章制度”被称为作用域

模拟

换位思考要求我们站在对方的立场去体验和思考问题,从而为增进理解奠定基础。

我想理解知识点也是如此,此刻,我们也不妨站在JavaScript的立场上去试着处理程序。

我们需要三位演员去模拟程序被处理的过程。被处理的程序即是基础题(三)的代码。 微信图片_20240602225812.jpg

最先出手的自然是【编译器】,它遇到var a时,并不会立刻就声明这个变量,而是会先询问【作用域】,是否已经有一个名称为a的变量存在于同一个作用域的集合中。

  • 如果已经有了,那么【编译器】会忽略该声明,继续进行剩下的编译工作。
  • 反之,则会要求【作用域】在当前作用域的集合中声明一个名称为a的新变量。

既然如此,上文中的相同变量的重复声明这一问题也就迎刃而解了。第二行的var a代码将会被编译器忽略。

完成编译工作后,【编译器】还不能休息,紧接着就要为【引擎】生成运行时所需的代码了,而这些代码的作用就是处理a = 823的赋值操作。

【引擎】在运行代码的时候也并不是直接就完成赋值操作,它也要过问【作用域】,来确认当前作用域的集合中是否存在一个名称为a的变量。

  • 如果不存在,【引擎】会继续查找该变量,往当前作用域的外层作用域查找,直到查找到全局作用域为止(这里又引出了新的知识点,嵌套作用域)。

image.png

  • 如果存在,【引擎】就会使用这个变量。

假如【引擎】最终找到了名称为a的变量,就会把823赋值给它。反之,则会抛出异常。

根据基础题(三)的代码,它们仨很顺利地完成了对a变量的声明和赋值操作。不过别急着杀青,还剩下最后一行代码没有执行呢。

当执行到console.log(a)时,【引擎】需要查询到变量a,不过此时与先前执行a = 823这样的赋值语句不同,对于console.log(a)这行代码来说,它需要的是变量a的值,这样它才能够打印出来。于是我们可以将【引擎】查询变量a的行为划分成2种情况

  • 赋值操作的目标是谁
  • 谁是赋值操作的源头

而这两种情况又可以用2种术语来定义

  • LHS引用:以a = 823为例,这里对a引用时,我们仅仅只是想要为= 823这个赋值操作找到它的目标,即a,而并不关心a当前的值是多少。
  • RHS引用:以console.log(a)为例,这里对a引用时,并没有任何对a的赋值操作,我们在此时的目的是想要拿到a当前的值,这样才能把值传递给console.log()

阶段性小结

我们以一道基础的【变量提升】题目为起点,一步步向外辐射,于是我们领略了有关编译的知识点,亲自踏上了一段编译器编译代码的旅途。而在之后的进阶练习中,我们又对JavaScript究竟是如何管理变量产生了好奇,在好奇心的驱使下结识了作用域这位朋友,并通过模拟的方式学习了作用域是如何生效的,在推演的过程中,又了解了变量查询的2种引用类型。

最后,我将再用几个小练习题作为本次文档的收尾,答案将会写在评论区,欢迎大家尝试一下~

小试牛刀

基础题(四)

请说出下面这段代码,到底发生了几次LHS引用?发生了几次RHS引用?

function test(a){
    var b = a;
    console.log(a + b);
    var c = a + b;
    return c;
}

var d = test( 823 );

后记

提升(Hoisting) 仅仅只是我们学习JavaScript的冰山一角。考虑到篇幅的限制,也没有再做更深入的讨论。

比如console.log.log为什么可以生效,我们并没有在代码里事先定义过,那么原型链的概念其实可以衍生出来。

而从嵌套作用域入手的话,其实也能聊到闭包这个初见时令人头疼的概念。

这些都是面试中非常热门的考题,这些考题我也当然不会略过,那么就让我们下期再见~