WebKit 的JavaScript 引擎简介
————基于
基于WebKit-r29753腾讯研究院无线中心/无线浏览器组周晓波(xiaobozhou )
1.概述
1.1.浏览器
浏览器是用于展示远程信息并提供有限修改能力的客户端程序。
事实上,世界上第一个浏览器是一个远程格式化编辑器,其修改权限是很大的。而目前浏览提提供的修改能力很弱,对修改的权限控制、对修改内容的处理等更多的集中在服务器端。因此,可以说现在的浏览器主要任务是更高效的、更标准的处理和显示远程信息。浏览器的这些主要工作都是由内核完成的。
浏览器对远程信息的显示并不是随意的,因为远程信息(通常是网页)是一种格式化的信息,即这些信息不仅包括内容,而且包括结构和显示样式。浏览器需要根据信息的格式化指令(如html 的标签)来对信息的结构进行理解,理解出来的每一部分称为一个元素。所谓结构化就是信息各部分之间的层级关系,类似书本的章节。然后,浏览器根据样式指令(如style 标签或属性等)来决定(或建议,因为不一定每个指定
的样式都能满足)某一元素的样式。再后,浏览器根据当前视窗的大小,以及元素之间的关系,以各个元素的样式(如大小)为约束条件,来输出每个元素的绝对位置等信息。最后,调用平台相关的接口来把每个元素在屏幕上画出来。如果用户利用JavaScript 改变了某个部分,浏览器就重复最后三步操作。
上述过程中,第一步是解析标记语言,其结果是形成DOM 树,第二步称为渲染(Render),其结果是产生Render 树,第三步称为布局(Layout ),其结果是决定远程信息的最终样子。这三步是浏览器内核的核心功能。在每次加载一个页面时都会执行;而JavaScript 扮演的角就是在不再次加载的情况下推动上述三步的执行,这是通过对DOM 树的修改来实现的。图1展示了一个页面解析的基本过程。这里涉及到几个中间实体:页面源文件、
DOM 图1:网页解析基本过程。
树、Render 树和JavaScript 语法树。这里的页面源文件是广义的,不是简单的一个远程文件。一方面随着动态页面的流行,几乎所有的页面源文件都是在服务器端根据用户的需要生成的,另外,ajax 技术会直接修改DOM 树在页面源文件中没有任何迹象。这里页面源文件就是指所有这些从服务器获取的信息。这三个椭圆圈住的内容就是本文所要讨论的主题:Render 树随DOM 树的变化(包括结构的变化,和节点属性的变化)而变化,JavaScript 修改DOM 树来推动这个变化。
1.2.脚本语言
Script 这个单词是指电影剧本,是用来指示演员行动的指令;这正如脚本语言:本身不做实际的事情只是指挥解释器如何做事情。
可以这样理解脚本语言:它是某个程序(即脚本解释器)的命令行参数;但是这个参数是如此的复杂以至于必须拥有自己的语法。这种理解方式可以说明这样几个问题:1),在操作系统的角度来看,只有一个程序在执行,就是脚本解释器,2),脚本语言通过给解释器发指令来实现某个功能,不同于C 语言直接给操作系统发指令来实现功能,3)脚本语言有语法结构。
如图2所示,一般二进制程序,以操作系统为平台,直接运行于操作系统提供的的进程上下文中,程序
的改动和发布需要针对多种操作系统。而脚本语言依赖于解释器,只要保证解释器一致(同一个,或者执行相同的标准),就能保证运行。这样的好处是利用解释器隔离的平台相关性,为代码发布降低了难度。这非常适合Web 程序的开发场景。从另一个角度也可以说,脚本解释器是一个极具扩展性的软件,它通过输入不同的脚本来改变其运行;归根结底是一种虚拟化处理。
从前面的叙述可以知道,脚本语言的扩展性来
源于两个方面:一是利用脚本语言的语法组合现有
的功能,例如定义新的函数、类和数据结构等;但
是这种其实只增强了易用性。另一个方面是扩展解
释器,提供新的功能,例如Python 调用C 语言编写
的模块。扩展解释器,可以是利用其他语言(主要是C/C++)提供新的模块,也可以是把解释器嵌入到其他软件中,浏览器是后一种情形。浏览器在没有JavaScript 之前就有了,它只是利用JavaScript 来增加动态效果,因此它内嵌JavaScript 解释器。站在浏览器的角度,它是利用JavaScript 来动态的调用某些操作,来增加其扩展性。
事实上有很多著名的软件都具有这种结构——内嵌脚本解释器来提供扩展性。如Emacs 利用Lisp 脚本来扩展编辑功能,NS (Network Simulator )利用Tcl/OTcl 来初始化网络拓扑和网络事件,VIM 利也定义了自己的脚本语言,iptables/Netfilter 也算是利用简单脚本来控制内核对数据包的处理,等等。
那么作为JavaScript ,它是如何嵌入浏览器的呢?或者说其功能是如何被扩展的呢?从前面的叙述可以看到,虚拟化是为了屏蔽异质的平台,为上层提供统一的接口,但是如果这个虚拟化本身是多种多样的,没有一个统一的标准,那将没有任何屏蔽效果,反而引入一场灾难;幸好在浏览器技术中有DOM 标准和ECMAScript
标准。
图2:二进制程序和脚本程序的运行方式比较
2.DOM
DOM描述了一个数据结构。
文章开头提到,浏览器的第一步就是要理解信息的结构。理解的过程其实并不复杂(不考虑容错),因为浏览器所支持的标记语言(如html)规定了信息的结构必须是树状的,那么通过栈就可以解析出这种结构。问题在于如何存储解析出来的结构,并且应该提供什么样的操作接口给其他模块,如JavaScript解释器。如果这个接口不能标准化,那么JavaScript 解释器就很难一致。DOM就是这个对这个标准化接口的描述。
DOM标准包含3个级别,实现的接口逐级趋向完备。另外,它分为core、html、views、events、style、traversal+range、L+S和Validation几个部分。
core和html部分定义了很多的类型,包含了所有标签,及其继承关系。图3是webkit 对DOM标准的实现中Input标签对应的类的继承关系。和DOM标准中所描述的继承关系有差别,这很容易理解:DOM只定义外界关心的接口,而WebKit实现时则要考虑实现细节和代码架构;而且从图中可以看到WebKit的这部分类结构把DOM标准的Events部分也实现了。
有了这些类结构,就可以表达文档的元素了,例如Input元素(即由Input标签包含的内容)用HTMLInputElement类的实例来表达。
接下来,元素之间的层级关系如何表现,例如form元素有两个input子元素。DOM通过定义操作接口,如插入/删除一个节点,插入/删除一个子节点,查节点等,来实现元素间层级关系的构建;但是,DOM并没有规定这些元素应该如何存储,是N叉树、数组还是其他,也就是说DOM不关心元素的组织方式,只关心接口。
DOM也规定了StyleSheet/CSS的类结构,即解析出的CSS规则用什么来类的实例表达。至于这些实例放在哪里,则并不关心。StyleSheet/CSS是对DOM树中元素的样式的描述,
它只是一条条的规则,元素在渲染时从中到
属于自己的那些规则就行了。图4显示了
WebKit中由DOM树结合CSS生成Render树
的过程。
需要注意的一点是CSS的存储不像DOM
树那样有很强的结构,它分散放在多个地方,
例如可以在StyleElement对象中;但是DOM
的根节点Document对象的
m_styleSheetCandidateNodes变量指出了哪些
DOM节点存储了CSS规则,然后在
Document::recalcStyleSelector函数中遍历这些
节点中的CSS规则,合成一个
RefPtr<StyleSheetList>m_styleSheets,这样整
个文档所有的CSS规则都在Document对象中
js原型和原型链的理解了。之后,Render过程为每个节点到其CSS 图3:Input标签的继承关系,WebKit实现V.S.DOM标准
规则,并产生Render树。
图4:由DOM树、CSS规则形成Render树
3.JavaScript解释器
前面介绍的其实是JavaScript解释器所面临的环境,即它被内嵌到什么中去了,也即是它可利用的资源有哪些。下面来简要介绍JavaScript解释器本身。
3.1.解释器眼中的JavaScript对象
JavaScript本身是一种通用的脚本语言,其基本能力是构建语法树,根据语法树的结构执行相关函数。这里函数是指解释器提供的API,而不是JavaScript函数,事实上JavaScript 解释器只认识对象(C++对象,即解释器内部的数据类型,不是JavaScript对象),甚至把JavaScript的类型也实现为对象。JavaScript语言本身语法只具有很弱的面向对象能力,需要使用JavaScript解释器提供的一种称为原型链的方式,实现对象和继承。
图5展示了JavaScript解释器中的JSObject对象的结构,每一个JavaScript对象都对应着一个JSObject对象。其中prop_指向一个map结构,存储的是<name,value>对,即该对象的属性表,而proto_则指向另一个JSObject对象。proto_指向的就是所谓原型(Prototype)。
JavaScript中的表达式即告诉解释器,在XXX对应的JSObject对象属性表中yyy,如果不到
则到proto_指向的JSObject的属性表中。很显然,通过proto_指针最终到的属性,是所有对象共有的属性,而prop_指向的则是该对象独有的属性。这与C++的虚函数表指针正好相反。
通过这种方式,不仅可以实现对象共享属性(对象共享属性其实就是类的属性)还可以实现继承,即把prop_指向想继承的那个类的一个实例,就可以访问它的属性了,也就是继承自它了。
JavaScript 解释器的类都是继承自
JSObject ,都具有图5的结构。有了这个结
构,所有具有 的表达式的执行过
程就清楚了:通过两个字符串XXX 和yyy
出对应的指针,如果yyy 是一个变量则返
回其值,如果是函数则执行这个函数。
当然,在实现中属性表的结构比这个复
杂,尤其是初始化阶段。每个继承自
JSObject 的类需要一个静态变量ClassInfo
s_info 来提供一些初始化信息以及类型信息。JavaScript 解释器区分的对待s_info 提供的属性和解释器本身具有的属性:在解释如 的表达式时,如果发现yyy 是一般的JavaScript 属性则直接查属性表,如果是由s_info 提供的属性则有callRuntimeMethod 函数作为统一入口去查s_info (参看图6)。但是从总体上看,可以认为就是提供一个属性表。
3.2.JavaScript 语法树
对于控制流和算术表达式的执行通过语法树来实现。对x =y +z ;解释器创建五个对象,并组织成一个树:
每个对象都实现了evaluate 函数,通过调用根节点的evaluate ,
可以递归调用所有节点的evaluate ,从而完成该表达式的求值。这只
是一个简单的例子,对于大量的JavaScript 源代码,其语法树是很复
杂的。
综上所述,可以认为JavaScript 解释器的解释过程是:首先,通过扫描JavaScript 源代码,构建语法树;然后,由于函数的调用(如<script>标签里的顶层函数调用,onload 等事件调用)触发语法树的求值,在求值过程中会执行一些由JavaScript 解释器提供的API (图5中prop_所指向的表中有API 的入口地址)。
那么向prop_指向的表中添加更多的API 也就扩展了JavaScript 解释器。这正是宿纳(host )JavaScript 所要做的事情。
4.宿纳JavaScript
宿纳(host )这个词很形象:即招待,这里就是如何招待JavaScript 解释器,也就是为它提供执行环境。WebKit 是通过绑定(binding )机制实现JavaScript 和DOM 的互操作的,即用DOM 来招待JavaScript 解释器。
binding 包括三个方面:一是JavaScript 操作DOM ,即让JavaScript 能访问DOM 接口,二是DOM 中触发JavaScript 语句的执行,如onclick ,三是JavaScript 执行环境,即在创建语法树之前如何初始化。这一节的叙述基于WebKit-r29753。
4.1.JavaScript 操作DOM 图5:JavaScript
对象在解释器中的表现形式