写点什么

网站开发进阶 (四十九) 由 JS 报“未结束的字符串常量”引发的思考

  • 2022 年 5 月 09 日
  • 本文字数:7200 字

    阅读完需:约 24 分钟

网站开发进阶(四十九)由JS报“未结束的字符串常量”引发的思考

一、报错

在做公司项目开发过程中,后期生产环境上报 JS 出现“未结束的字符串常量”错,如下:



后期经过不断调试,发现是由于 Js 引擎在解析带有换行字符串时引起的异常。解析后的 js 代码类似于


If (“12345abc” == “12345abc”){...
复制代码


由以上异常,下面主要讲解JavaScript引擎的工作原理。

二、什么是 JavaScript 解析引擎

javascript 解析引擎(简称javascript引擎),是一个程序,是浏览器引擎的一部分。每个浏览器的 javascript 解析引擎都不一样(因为每个浏览器编写 Javascript 解析引擎的语言(C 或者 C++)以及解析原理都不相同)。标准的 Javascript 解析引擎会按照 ECMAScript文档来实现。虽然每个浏览器的 Javascript 解析引擎不同,但Javascript的语言性质决定了Javascript关键的渲染原理仍然是动态执行 Javascript 字符串。只是词法分析、语法分析、变量赋值、字符串拼接的实现方式有所不同。


简单地说,JavaScript 解析引擎就是能够“读懂”JavaScript代码,并准确地给出代码运行结果的一段程序。比方说,当你写了 var a = 1 + 1; 这样一段代码,JavaScript引擎做的事情就是看懂(解析)你这段代码,并且将 a 的值变为 2。


学过编译原理的人都知道,对于静态语言来说(如 Java、C++、C),处理上述这些事情的叫编译器Compiler),相应地对于 JavaScript 这样的动态语言则叫解释器Interpreter)。这两者的区别用一句话来概括就是:编译器是将源代码编译为另外一种代码(比如机器码,或者字节码),而解释器是直接解析并将代码运行结果输出。比方说,firebug console 就是一个 JavaScript 解释器


但是,现在很难去界定说,JavaScript 引擎它到底算是个解释器还是个编译器,因为,比如像 V8(Chrome 的 JS 引擎),它其实为了提高 JS 的运行性能,在运行之前会先将JS编译为本地的机器码(native machine code),然后再去执行机器码(这样速度就快很多),相信大家对 JITJust In Time Compilation)一定不陌生吧。


个人认为,不需要过分去强调 JavaScript 解析引擎到底是什么,了解它究竟做了什么事情就可以了。对于编译器或者解释器究竟是如何看懂代码的,翻出大学编译课的教材就可以了。


这里还要强调的就是,JavaScript 引擎本身也是程序,由代码编写而成。比如 V8 就是用C/C++写的。

三、JavaScript 解析引擎到底是干什么的

JavaScript 解析引擎就是根据ECMAScript定义的语言标准来动态执行JavaScript字符串。虽然之前说现在很多浏览器不全是按照标准来的,解释机制也不尽相同,但动态解析 JS 的过程还是分成两个阶段:语法检查阶段和运行阶段


语法检查包括词法分析语法分析运行阶段又包括预解析运行阶段(像 V8 引擎会将JavaScript字符串编译成二进制代码,此过程应该归到语法检查过程中)。

3.1 JavaScript 解析过程

JavaScript解析过程中,如遇错误就直接跳出当前代码块,直接执行下一个 script 代码段。所以在同一个 script 内的代码段有错误的话就不会执行下去,但是不会影响下一个 script 内的代码段。

3.2 第一阶段:语法检查

语法检查也是JavaScript解析器的工作之一,包括 词法分析语法分析,过程大致如下:

3.2.1 词法分析

词法分析:JavaScript 解释器先把JavaScript代码(字符串)的字符流按照ECMAScript标准转换为记号流。例如:把字符流:


<span style="font-size:18px;">a = (b - c);</span>  
复制代码


转换为记号流:


NAME "a"  EQUALS  OPEN_PARENTHESIS  NAME "b"  MINUSNAME "c"CLOSE_PARENTHESISSEMICOLON
复制代码
3.2.2 语法分析

语法分析:JavaScript 语法分析器在经过词法分析后,将记号流按照ECMAScript标准把词法分析所产生的记号生成语法树。


通俗地说就是把从程序中收集的信息存储到数据结构中,每取一个词法记号,就送入语法分析器进行分析。


语法分析不做的事:去掉注释,自动生成文档,提供错误位置(可以通过记录行号来提供)


当语法检查正确无误之后,就可以进入运行阶段了。

3.3 第二阶段:运行阶段

3.3.1 预解析

第一步:创建执行上下文。JavaScript 引擎将语法检查正确后生成的语法树复制到当前执行上下文中。


第二步:属性填充。JavaScript 引擎会对语法树当中的变量声明、函数声明以及函数的形参进行属性填充。


预解析”从语法检查阶段复制过来的信息如下:


  • 内部变量表varDeclsvarDecls保存的用var进行显式声明的局部变量。

  • 内嵌函数表funDecls:在“预解析”阶段,发现有函数定义的时候,除了记录函数的声明外,还会创建一个原型链对象(prototype)。

3.4 执行上下文(execution context)

(一)预解析阶段创建的执行上下文包括:变量对象作用域链this


  • 变量对象(Variable Object):由var declarationfunction declaration(变量声明、函数声明)、arguments(参数)构成。变量对象是以单例形式存在。

  • 作用域链(Scope Chain):variable object + all parent scopes(变量对象以及所有父级作用域)构成。

  • this值:(this Value):content objectthis值在进入上下文阶段就确定了。一旦进入执行代码阶段,this值就不会变了。


(二)“预解析”阶段创建执行上下文之后,还会对变量对象/活动对象(VO/AO)的一些属性填充数值。


注:函数申明提升优先级高于变量声明提升。


  • 函数的形参:执行上下文的变量对象的一个属性,其属性名就是形参的名字,其值就是实参的值;对于没有传递的参数,其值为undefined

  • 函数声明:执行上下文的变量对象的一个属性,属性名和值都是函数对象创建出来的;如果变量对象已经包含了相同名字的属性,则会替换它的值。

  • 变量声明:执行上下文的变量对象的一个属性,其属性名即为变量名,其值为undefined;如果变量名和已经声明的函数名或者函数的参数名相同,则不会影响已经存在的函数声明的属性。


变量对象/活动对象(VO/AO)填充的顺序也是按照以上顺序:函数的形参->函数声明->变量声明;


在变量对象/活动对象(VO/AO)中权重高低也按照函数的形参->函数声明->变量声明顺序来。如下代码:


var a=1;  function b(a) {       alert(a);  }  var b;  alert(b); // function b(a) { alert(a); }  b();  //undefined  
复制代码


以上代码在进入执行上下文时,按照函数的形参->函数声明->变量声明顺序来填充,并且优先权永远都是函数的形参>函数声明>变量声明,所以只要alert(a)中的 a 是函数中的形参,就永远不会被函数和变量声明覆盖。就算没有赋值也是默认填充的undefined值。


第二部分:执行代码经过“预解析”创建执行上下文之后,就进入执行代码阶段,VO/AO就会重新赋予真实的值,“预解析”阶段赋予的undefined值会被覆盖。


此阶段才是程序真正进入执行阶段,Javascript 引擎会一行一行的读取并运行代码。此时那些变量都会重新赋值。


假如变量是定义在函数内的,而函数从头到尾都没被激活(调用)的话,则变量值永远都是undefined值。


进入了执行代码阶段,在“预解析”阶段所创建的任何东西可能都会改变,不仅仅是 VO/AO,this 和作用域链也会因为某些语句而改变,后面会讲到。


了解完Javascript的解析过程,最后我们再来了解下firebug控制台对Javascript的报错提示。


其实firebug的控制台也算是 JavaScript 解释器,而且他们会提示我们哪行出现了错误或者错误发生在哪个时期,语法检查阶段错误,还是运行期错误。


如下:


alert(var);// SyntaxError: syntax error 语法分析阶段错误 :语法错误  var=1; // SyntaxError: missing variable name 语法分析阶段错误 :var是保留字符,导致变量名丢失  a=b=v // ReferenceError: v is not defined 运行期错误: v 是未定义的JavaScript错误信息)  
复制代码


有如此详细的错误提示,是不是就很快就知道代码中到底是哪里错了呢!

四、作用域链(Scope Chain)

作用域链是处理标识符时进行变量查询的变量对象列表,每个执行上下文都有自己的变量对象:对于全局上下文而言,其变量对象就是全局对象本身;对于函数而言,其变量对象就是活动对象。

4.1 作用域链以及执行上下文的关系

Javascript中只有函数能规定作用域,全局执行上下文中的 Scope 是全局上下文中的属性,也是最外层的作用域链。


函数的属性Scope是在“预解析”的时候就已经存在的了,它包含了所有上层变量对象,并一直保存在函数中。就算函数永远都没被激活(调用),Scope也都还是存在函数对象上。


创建执行上下文的 Scope 属性和进入执行上下文的过程如下:


Scope = AO + [[Scope]] //预解析时的 Scope 属性 Scope = [AO].concat([[Scope]]); //执行阶段,将AO添加到作用域链的最前端
复制代码

4.2 执行上下文定义的 Scope 属性变化过程

执行上下文中的AO是函数的活动对象,而Scope则是该函数属性作用域。当前函数的AO永远是在最前面的,保存在堆栈上,而每当函数激活的时候,这些AO都会压栈到该堆栈上,查询变量是先从栈顶开始查找,也就是说作用域链的栈顶永远是当前正在执行的代码所在环境的VO/AO(当函数调用结束后,则会从栈顶移除)。


通俗点讲就是:JavaScript 解释器通过作用域链将不同执行位置上的变量对象串连成列表,并借助这个列表帮助 JavaScript 解释器检索变量的值。作用域链相当于一个索引表,并通过编号来存储它们的嵌套关系。当 JavaScript 解释器检索变量的值,会按着这个索引编号进行快速查找,直到找到全局对象为止,如果没有找到值,则传递一个特殊的undefined值。


是不是又想到了一条JavaScript高效准则:为什么说在该函数内定义的变量,能减少函数嵌套,能提高JavaScript的效率?因为函数定义的变量,此变量永远在栈顶,这样子查询变量的时间变短了。

4.3 作用域的特性

保证有序的访问所有变量和函数;作用域链感觉就是一个VO链表,当访问一个变量时,先在链表的第一个VO上查找,如果没有找到则继续在第二个VO上查找,直到搜索结束,也就是搜索到全局执行环境的VO中。这也就形成了作用域链的概念。


var color="blue";  function changecolor(){       var anothercolor="red";       function swapcolors(){          var tempcolor=anothercolor;           anothercolor=color;           color=tempcolor; // Todo something       }       swapcolors();  }  changecolor();//这里不能访问tempcolor和anothercolor;但是可以访问color;  alert("Color is now "+color);  
复制代码

五、原型链查询

在介绍“预解析”阶段时,我们有提到当创建函数时,同时也会创建原型链对象。原型链对象在作用域链中没有找到变量时,那么就会通过原型链来查找。


function Foo() {       function bar() {           alert(x);       }       bar();  }  Object.prototype.x = 10;  Foo(); // 10  
复制代码


上例中在作用域链中遍历查询,到了全局对象了,该对象继承自 Object.prototype,因此,最终变量“x”的值就变成了 10。不过,在原型链上定义变量对象有些浏览器不支持,譬如 IE6,而且这样增加了变量对象的查询时间。所以变量声明尽量在调用函数 AO 里,即在用到该变量的函数内声明变量对象。


作用域是在“预解析”时就已经决定的,所以作用域被叫做静态作用域,而在执行阶段的则被叫做动态链,因为在执行阶段会改变作用域链中填充的值。代码执行阶段对“预解析”的改变


创建了函数就有一个闭包,而变量是在函数的执行上下文保存起来的静态作用域链上查询的,而当前函数内创建的的变量会在函数结束后就被销毁。而闭包就能在函数结束之后还能让这些变量一直保存在作用域链上。

六、自由变量

自由变量是指在函数中使用的,但既不是函数参数也不是函数局部变量的变量。

七、闭包

理论角度:所有函数都是闭包。因为它们都在创建的时候就将上层上下文的数据保存起来了。哪怕是简单的全局变量也是如此,因为函数中访问全局变量就相当于是在访问自由变量。应用角度:当在代码中引用了自由变量,即使创建它的上下文已经销毁,此变量还能访问。


ECMAScript标准中,同一个上下文创建的闭包(理论上的闭包)是共用一个作用域的,也就是说在闭包中对其中变量修改会影响到其他闭包对其变量的读取。


所谓创建额外的闭包就是创建函数,不管是匿名函数、函数表达式、函数声明(除了构造函数),只要能创建作用域链就行,与函数类型无关,然而创建额外的函数不是唯一的方法。遍历最外层代码:


for (i = 0; i < len; i++) {...}
复制代码


使用函数创建闭包:


方法一:使用函数闭包获取索引值


(function(i){    lists[i].onmouseover = function () {        alert(i);     };})(i);
复制代码


直接将匿名函数赋值给事件,创建额外的函数来创建多个作用域。


方法二:使用函数闭包获取索引值


lists[i].onmouseover = (function (x) {     return function (){         alert(i);     };})(i);
复制代码


利用return在闭包中返回,而闭包中返回的语句会将控制流返回给调用上下文,也就是返回几个就有几个执行上下文,相应的作用域链也有相同的个数。


使用try { ... } catch (ex) { ... }改变作用域链:try-catch改变作用域链的原理跟with一样,try 部分包含需要运行的代码,而 catch 部分包含错误发生时运行的代码。如下:


var array = null;      var x=10;      try {           document.write(array[0]);      } catch(x) {          x =20;           document.writeln("catch内的x值"+x); //20      }      document.writeln("catch外的x值"+x); //10  
复制代码


try-catch语句在代码调试和异常处理中非常有用,因此不建议完全避免。

八、JavaScript 解析引擎与 ECMAScript 是什么关系

JavaScript 引擎是一段程序,我们平时写的JavaScript代码也是程序,如何让程序去读懂程序呢?这就需要定义规则。比如,之前提到的var a = 1 + 1;,它表示:


  • 左边var代表了这是申明(declaration),它申明了 a 这个变量;

  • 右边的+表示要将 1 和 1 做加法;

  • 中间的等号表示了这是个赋值语句;

  • 最后的分号表示这句语句结束了;


上述这些就是规则,有了它就等于有了衡量的标准,JavaScript 引擎就可以根据这个标准去解析JavaScript代码。那么这里的ECMAScript就是定义了这些规则。其中ECMAScript 262这份文档,就是对JavaScript这门语言定义了一整套完整的标准。其中包括:


  • var,if,else,break,continue等是JavaScript的关键词;

  • abstract,int,long等是JavaScript保留词;

  • 怎么样算是数字、怎么样算是字符串等等;

  • 定义了操作符(+,-,>,<等);

  • 定义了JavaScript语法;

  • 定义了对表达式,语句等标准的处理算法,比如遇到==该如何处理;

  • ⋯⋯

九、JS 兼容性问题的原因

标准的 JavaScript 引擎就会根据这套文档去实现,注意这里强调了标准,因为也有不按照标准来实现的,比如 IE JS 引擎。这也是为什么JavaScript会有兼容性的问题。至于为什么 IE JS 引擎不按照标准来实现,就要说到浏览器大战了,这里就不赘述了,自行Google之。


所以,简单的说,ECMAScript定义了语言标准,JavaScript 引擎根据它来实现,这就是两者的关系。

十、JavaScript 解析引擎与浏览器是什么关系

简单地说,JavaScript 引擎是浏览器的组成部分之一。因为浏览器还要做很多别的事情,比如解析页面、渲染页面、Cookie 管理、历史记录等等。那么,既然是组成部分,因此一般情况下 JavaScript 引擎都是浏览器开发商自行开发的。比如:IE9 ChakraFirefox TraceMonkeyChrome V8 等等。


从而也看出,不同浏览器都采用了不同的 JavaScript 引擎。因此,我们只能说要深入了解哪个 JavaScript 引擎

十一、深入了解其内部原理的途径有哪些

搞清楚了前面三个问题,那这个问题就好回答了。主要途径有如下几种(依次由浅入深):

11.1 阅读 JavaScript 引擎工作原理相关的书

这种方式最方便,不过我个人了解到的这样的书几乎没有,但是 Dmitry A.Soshnikov 博客上的文章真的是非常的赞,建议直接看英文,实在英文看起来吃力的,可以看译本。

11.2 阅读 ECMAScript 的标准文档

这种方式相对直接,原汁原味,因为引擎就是根据标准来实现的。目前来说,可以看第五版和第三版,不过要看懂也是不容易的。

11.3 阅读 JS 引擎源代码

这种方式最直接,当然也最难了。因为还牵涉到了如何实现词法分析器语法分析器等等更加底层的东西了,而且并非所有的引擎代码都是开源的。


以上几种方式中第一种都很难看明白怎么办?其实第一种方式中的文章,作者已经将文档中内容提炼出来,用通俗易懂的方式阐述出来了。如果,看起来还觉得吃力,那说明还缺少两块的东西:


  • JavaScript本身还理解的不够深入


如果你刚刚接触JavaScript,或者说以前甚至都没有接触过。那一下子就想要去理解内部工作原理,的确是很吃力的。首先应该多看看书,多实践实践,从知识和实践的方式来了解JavaScript语言特性。这种情况下,你只需要了解现象。比方说(function(){})() 这样可以直接调用该匿名函数、用闭包可以解决循环中延迟操作的变量值获取问题等等。要了解这些,都是需要多汲取和实践的。实践这里就不多说了,而知识汲取方面可以多看看书和博客。这个层面的书就相对比较多了,《Professional JavaScript for Web Developers》就是本很好的书(中文版请自行寻找)。


  • 缺乏相应的领域知识


JavaScript也达到一定深度了,但是,还是看不大明白,或者没法很深入到内部去一探究竟。那就意味着缺少对应的领域知识。这里明显的就是编译原理相关的知识。不过,其实对这块了解个大概基本看起来就没问题了。要再继续深入,那需要对编译原理了解的很深入,比如说词法分析采用什么算法,一般怎么处理。会有什么问题,如何解决,AST 生成算法一般有哪几种等等。那要看编译原理方面的书,也有基本经典的书,比如《Compilers: Principles, Techniques, and Tools》这本也是传说中的龙书,还有非常著名的《SICP》和《PLAI》。不过其实根据个人经验,对于 Dmitry 的文章,要看懂它,只要你对JavaScript有一定深度的了解,同时你大学计算机的课程都能大致掌握了(尤其是操作系统),也就是说基础不错,理解起来应该没问题。因为这些文章基本没有涉及底层编译相关的,只是在解释文档的内容,并且其中很多东西都是相通的,比如:context的切换与CPU的进程切换、函数相关的的局部变量的栈存储、函数退出的操作等等都是一致的。



发布于: 刚刚阅读数: 4
用户头像

No Silver Bullet 2021.07.09 加入

岂曰无衣 与子同袍

评论

发布
暂无评论
网站开发进阶(四十九)由JS报“未结束的字符串常量”引发的思考_作用域_No Silver Bullet_InfoQ写作社区