前言
回望2023年,和付出不成正比的回报、接踵而至的琐碎业务、滑天下之大稽的晋升制度。这些因素都在提醒着我,是时候前往下一站了。
君子不器
对我来说,2024年的关键词是“求变”。
现在,5月份忙碌的工作已经告一段落,除了例行地刷力扣之外,
终于有足够的时间开始准备和自身岗位相关的面试了。接下来便是回顾知识、沉淀知识的过程,此篇文章主要针对的是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 a
和 a = 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,让浏览器能够看懂。
当然,其中奥妙仅仅靠我自己的俗语几句显然没有说服力。下方内容以《编译原理》(龙书)为参考教材,结合我自己的理解,和大家接着唠唠编译
扩展内容(感兴趣可以看,或者看完文章的后半部分内容再回过头来看。)
编译器
对代码的编译工作实际上是由编译器负责完成的,那么,什么是编译器呢?
简单地说,编译器是一个程序,它读入用某种语言(源语言)编写的程序并将其翻译成一个与之等价的以另一种语言(目标语言)编写的程序。作为这个翻译过程的一个重要组成部分,编译器能够向用户报告被编译的源程序中出现的错误。——《编译原理》
编译
编译由两部分组成【分析(analysis)】和【综合(synthesis)】
- 分析:将源程序分解成多个组成要素,并在这些要素之上加上语法结构。然后,它使用这个结构来创建该源程序的一个中间表示(并且此部分还会收集有关源程序的信息,并把信息存放在一个符号表中。)。
- 综合:根据中间表示和符号表中的信息来构造用户期待的目标程序。
这里我再贴一张编译器的步骤图,方便大家了解编译的全过程
活学活用,亲自走一遍编译的过程
看完上面关于编译方面各种专有名词的定义描述,我想大家对编译应该能够具备一个大概的认知了。但是,了解概念并不是我们的最终目的,我们的最终目的是理解概念。那么接下来,我将通过一个具体的代码例子,帮助大家更好地理解概念,达成我们的最终目的。
假设我们有这样一份文件: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);
则输出的结果是这样的
这是因为
Node.js
默认的console.log
功能会将大型对象和数组简化显示,以避免输出过多内容。因此输出中包含[Array]
,而不是实际的内容。
为了解决这个问题,我们需要使用Node.js
的 util
模块中的 inspect
方法来打印完整的对象或数组。
执行之后,会在终端打印这样的信息,可以看到此前[Array]
中的内容了
我们再做一些删减,将其变为我们熟悉的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",
};
我们也可以用图片的方式展示
接下来,构建完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
的立场上去试着处理程序。
我们需要三位演员去模拟程序被处理的过程。被处理的程序即是基础题(三)的代码。
最先出手的自然是【编译器】,它遇到var a
时,并不会立刻就声明这个变量,而是会先询问【作用域】,是否已经有一个名称为a
的变量存在于同一个作用域的集合中。
- 如果已经有了,那么【编译器】会忽略该声明,继续进行剩下的编译工作。
- 反之,则会要求【作用域】在当前作用域的集合中声明一个名称为
a
的新变量。
既然如此,上文中的相同变量的重复声明这一问题也就迎刃而解了。第二行的var a
代码将会被编译器忽略。
完成编译工作后,【编译器】还不能休息,紧接着就要为【引擎】生成运行时所需的代码了,而这些代码的作用就是处理a = 823
的赋值操作。
【引擎】在运行代码的时候也并不是直接就完成赋值操作,它也要过问【作用域】,来确认当前作用域的集合中是否存在一个名称为a
的变量。
- 如果不存在,【引擎】会继续查找该变量,往当前作用域的外层作用域查找,直到查找到全局作用域为止(这里又引出了新的知识点,嵌套作用域)。
- 如果存在,【引擎】就会使用这个变量。
假如【引擎】最终找到了名称为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
为什么可以生效,我们并没有在代码里事先定义过,那么原型链
的概念其实可以衍生出来。
而从嵌套作用域
入手的话,其实也能聊到闭包
这个初见时令人头疼的概念。
这些都是面试中非常热门的考题,这些考题我也当然不会略过,那么就让我们下期再见~