关于React-Redux的connect, selector, action, reducer, App几个执行顺序

//action
function add(task) {
    return {type: ADD_TASK, task};
}

//reducer
function func1(state = [], action) {
    if (action.type == ADD_TASK) {
        return [...state, action.task];
    }
}

function reducer(...args) {
    return func1(...args) || func2(...args) || args[0];
}

//App
class App extends Component {
render() {
        const {taskList} = this.props;
    }
}

// Which props do we want to inject, given the global state
function selector(state = []) {
    return {
        taskList: state
    }
}

export const store = createStore(reducer);
// 包装 component ,注入 dispatch 和 state 到其默认的 connect(select)(App) 中;
export default connect(selector)(App);

初始化顺序:

  1. createStore
  2. reducer(state,action)
  3. connect(selector)App
  4. selector(state)
  5. App.render(props:{dispatch,…selector#Return})

事件触发顺序:

  1. dispatch(action)
  2. action
  3. reducer(state,action)
  4. selector(state)
  5. App.render(props:{dispatch,…selector#Return})

Chrome V8引擎介绍

 随着Web相关技术的发展,JavaScript所要承担的工作也越来越多,早就超越了“表单验证”的范畴,这就更需要快速的解析和执行JavaScript脚本。V8引擎就是为解决这一问题而生,在node中也是采用该引擎来解析JavaScript。V8是如何使得JavaScript性能有大幅提升的呢?通过对一些书籍和文章的学习,梳理了V8的相关内容,本文将带你认识 V8。

  1.渲染引擎及网页渲染

  浏览器自从上世纪80年代后期90年代初期诞生以来,已经得到了长足的发展,其功能也越来越丰富,包括网络、资源管理、网页浏览、多页面管理、插件和扩展、书签管理、历史记录管理、设置管理、下载管理、账户和同步、安全机制、隐私管理、外观主题、开发者工具等。在这些功能中,为用户提供网页浏览服务无疑是最重要的功能,下面将对相关内容进行介绍。

  1.1.渲染引擎

  渲染引擎:能够将HTML/CSS/JavaScript文本及相应的资源文件转换成图像结果。渲染引擎的主要作用是将资源文件转化为用户可见的结果。在浏览器的发展过程中,不同的厂商开发了不同的渲染引擎,如Tridend(IE)、Gecko(FF)、WebKit(Safari,Chrome,Andriod浏览器)等。WebKit是由苹果2005年发起的一个开源项目,引起了众多公司的重视,几年间被很多公司所采用,在移动端更占据了垄断地位。更有甚者,开发出了基于WebKit的支持HTML5的web操作系统(如:Chrome OS、Web OS)。

  下面是WebKit的大致结构:

  上图中实线框内模块是所有移植的共有部分,虚线框内不同的厂商可以自己实现。下面进行介绍:

  • 操作系统:是管理和控制计算机硬件与软件资源的计算机程序,是直接运行在“裸机”上的最基本的系统软件,任何其他软件都必须在操作系统的支持下才能运行。WebKit也是在操作系统上工作的。
  • 第三方库,为了WebKit提供支持,如图形库、网络库、视频库等。
  • WebCore 是各个浏览器使用的共享部分,包括HTML解析器、CSS解析器、DOM和SVG等。JavaScriptCore是WebKit的默认引擎,在谷歌系列产品中被替换为V8引擎。WebKit Ports是WebKit中的非共享部分,由于平台差异、第三方库和需求的不同等原因,不同的移植导致了WebKit不同版本行为不一致,它是不同浏览器性能和功能差异的关键部分。
  • WebKit嵌入式编程接口,供浏览器调用,与移植密切相关,不同的移植有不同的接口规范。
  • 测试用例,包括布局测试用例和性能测试用例,用来验证渲染结果的正确性。

  1.2.网页渲染流程

  上面介绍了渲染引擎的各个模块,那么一张网页,要经历怎样的过程,才能抵达用户面前?

  首先是网页内容,输入到HTML解析器,HTML解析器解析,然后构建DOM树,在这期间如果遇到JavaScript代码则交给JavaScript引擎处理;如果来自CSS解析器的样式信息,构建一个内部绘图模型。该模型由布局模块计算模型内部各个元素的位置和大小信息,最后由绘图模块完成从该模型到图像的绘制。在网页渲染的过程中,大致可分为下面3个阶段。

  1.2.1.从输入URL到生成DOM树

  1. 地址栏输入URL,WebKit调用资源加载器加载相应资源;
  2. 加载器依赖网络模块建立连接,发送请求并接收答复;
  3. WebKit接收各种网页或者资源数据,其中某些资源可能同步或异步获取;
  4. 网页交给HTML解析器转变为词语;
  5. 解释器根据词语构建节点,形成DOM树;
  6. 如果节点是JavaScript代码,调用JavaScript引擎解释并执行;
  7. JavaScript代码可能会修改DOM树结构;
  8. 如果节点依赖其他资源,如图片\css、视频等,调用资源加载器加载它们,但这些是异步加载的,不会阻碍当前DOM树继续创建;如果是JavaScript资源URL(没有标记异步方式),则需要停止当前DOM树创建,直到JavaScript加载并被JavaScript引擎执行后才继续DOM树的创建。

  1.2.2.从DOM树到构建WebKit绘图上下文

  1. CSS文件被CSS解释器解释成内部表示;
  2. CSS解释器完成工作后,在DOM树上附加样式信息,生成RenderObject树;
  3. RenderObject节点在创建的同时,WebKit会根据网页层次结构构建RenderLayer树,同时构建一个虚拟绘图上下文。

  1.2.3.绘图上下文到最终图像呈现

  1. 绘图上下文是一个与平台无关的抽象类,它将每个绘图操作桥接到不同的具体实现类,也就是绘图具体实现类;
  2. 绘图实现类也可能有简单的实现,也可能有复杂的实现,软件渲染、硬件渲染、合成渲染等;
  3. 绘图实现类将2D图形库或者3D图形库绘制结果保存,交给浏览器界面进行展示。

  上述是一个完整的渲染过程,现代网页很多都是动态的,随着网页与用户的交互,浏览器需要不断的重复渲染过程。

  1.3.JavaScript引擎

  JavaScript本质上是一种解释型语言,与编译型语言不同的是它需要一遍执行一边解析,而编译型语言在执行时已经完成编译,可直接执行,有更快的执行速度(如上图所示)。JavaScript代码是在浏览器端解析和执行的,如果需要时间太长,会影响用户体验。那么提高JavaScript的解析速度就是当务之急。JavaScript引擎和渲染引擎的关系如下图所示:

  JavaScript语言是解释型语言,为了提高性能,引入了Java虚拟机和C++编译器中的众多技术。现在JavaScript引擎的执行过程大致是:

  源代码-→抽象语法树-→字节码-→JIT-→本地代码(V8引擎没有中间字节码)。一段代码的抽象语法树示例如下:

function demo(name) {
    console.log(name);
}

  抽象语法树如下:
  V8更加直接的将抽象语法树通过JIT技术转换成本地代码,放弃了在字节码阶段可以进行的一些性能优化,但保证了执行速度。在V8生成本地代码后,也会通过Profiler采集一些信息,来优化本地代码。虽然,少了生成字节码这一阶段的性能优化,但极大减少了转换时间。

  但是在2017年4月底,v8 的 5.9 版本发布了,新增了一个 Ignition 字节码解释器,将默认启动,从此之后将与JSCore有大致相同的流程。做出这一改变的原因为:(主要动机)减轻机器码占用的内存空间,即牺牲时间换空间;提高代码的启动速度;对 v8 的代码进行重构,降低 v8 的代码复杂度(V8 Ignition:JS 引擎与字节码的不解之缘 – CNode技术社区)。

  JavaScript的性能和C相比还有不小的距离,可预见的未来估计也只能接近它,而不是与它相比,这从语言类型上已经决定。下面将对V8引擎进行更为细致的介绍。

  2.V8引擎

  V8引擎是一个JavaScript引擎实现,最初由一些语言方面专家设计,后被谷歌收购,随后谷歌对其进行了开源。V8使用C++开发,,在运行JavaScript之前,相比其它的JavaScript的引擎转换成字节码或解释执行,V8将其编译成原生机器码(IA-32, x86-64, ARM, or MIPS CPUs),并且使用了如内联缓存(inline caching)等方法来提高性能。有了这些功能,JavaScript程序在V8引擎下的运行速度媲美二进制程序。V8支持众多操作系统,如windows、linux、android等,也支持其他硬件架构,如IA32,X64,ARM等,具有很好的可移植和跨平台特性。
  V8项目代码结构如下:

  2.1.数据表示

  JavaScript是一种无类型语言,在编译时并不能准确知道变量的类型,只可以在运行时确定,这就不像c++或者java等静态类型语言,在编译时候就可以确切知道变量的类型。然而,在运行时计算和决定类型,会严重影响语言性能,这也就是JavaScript运行效率比C++或者JAVA低很多的原因之一。

  在C++中,源代码需要经过编译才能执行,在生成本地代码的过程中,变量的地址和类型已经确定,运行本地代码时利用数组和位移就可以存取变量和方法的地址,不需要再进行额外的查找,几个机器指令即可完成,节省了确定类型和地址的时间。由于JavaScript是无类型语言,那就不能像c++那样在执行时已经知道变量的类型和地址,需要临时确定。JavaScript 和C++有以下几个区别:

  • 编译确定位置,C++编译阶段确定位置偏移信息,在执行时直接存取,JavaScript在执行阶段确定,而且执行期间可以修改对象属性;
  • 偏移信息共享,C++有类型定义,执行时不能动态改变,可共享偏移信息,JavaScript每个对象都是自描述,属性和位置偏移信息都包含在自身的结构中;
  • 偏移信息查找,C++查找偏移地址很简单,在编译代码阶段,对使用的某类型成员变量直接设置偏移位置,JavaScript中使用一个对象,需要通过属性名匹配才能找到相应的值,需要更多的操作。

  在代码执行过程中,变量的存取是非常普遍和频繁的,通过偏移量来存取,使用少数两个汇编指令就能完成,如果通过属性名匹配则需要更多的汇编指令,也需要更多的内存空间。示例如下:

  在JavaScript中,除boolean,number,string,null,undefined这个五个简单变量外,其他的数据都是对象,V8使用一种特殊的方式来表示它们,进而优化JavaScript的内部表示问题。

  在V8中,数据的内部表示由数据的实际内容和数据的句柄构成。数据的实际内容是变长的,类型也是不同的;句柄固定大小,包含指向数据的指针。这种设计可以方便V8进行垃圾回收和移动数据内容,如果直接使用指针的话就会出问题或者需要更大的开销,使用句柄的话,只需修改句柄中的指针即可,使用者使用的还是句柄,指针改动是对使用者透明的。

  除少数数据(如整型数据)由handle本身存储外,其他内容限于句柄大小和变长等原因,都存储在堆中。整数直接从value中取值,然后使用一个指针指向它,可以减少内存的占用并提高访问速度。一个句柄对象的大小是4字节(32位设备)或者8字节(64位设备),而在JavaScriptCore中,使用的8个字节表示句柄。在堆中存放的对象都是4字节对齐的,所以它们指针的后两位是不需要的,V8用这两位表示数据的类型,00为整数,01为其他。

  JavaScript对象在V8中的实现包含三个部分:隐藏类指针,这是v8为JavaScript对象创建的隐藏类;属性值表指针,指向该对象包含的属性值;元素表指针,指向该对象包含的属性。

  2.2.工作过程

  前面有过介绍,V8引擎在执行JavaScript的过程中,主要有两个阶段:编译和运行,与C++的执行前完全编译不同的是,JavaScript需要在用户使用时完成编译和执行。在V8中,JavaScript相关代码并非一下完成编译的,而是在某些代码需要执行时,才会进行编译,这就提高了响应时间,减少了时间开销。在V8引擎中,源代码先被解析器转变为抽象语法树(AST),然后使用JIT编译器的全代码生成器从AST直接生成本地可执行代码。这个过程不同于JAVA先生成字节码或中间表示,减少了AST到字节码的转换时间,提高了代码的执行速度。但由于缺少了转换为字节码这一中间过程,也就减少了优化代码的机会。

  V8引擎编译本地代码时使用的主要类如下所示:

  • Script:表示JavaScript代码,即包含源代码,又包含编译之后生成的本地代码,即是编译入口,又是运行入口;
  • Compiler:编译器类,辅组Script类来编译生成代码,调用解释器(Parser)来生成AST和全代码生成器,将AST转变为本地代码;
  • AstNode:抽象语法树节点类,是其他所有节点的基类,包含非常多的子类,后面会针对不同的子类生成不同的本地代码;
  • AstVisitor:抽象语法树的访问者类,主要用来遍历异构的抽象语法树;
  • FullCodeGenerator:AstVisitor类的子类,通过遍历AST来为JavaScript生成本地可执行代码。

  JavaScript代码编译的过程大致为:Script类调用Compiler类的Compile函数为其生成本地代码。Compile函数先使用Parser类生成AST,再使用FullCodeGenerator类来生成本地代码。本地代码与具体的硬件平台密切相关,FullCodeGenerator使用多个后端来生成与平台相匹配的本地汇编代码。由于FullCodeGenerator通过遍历AST来为每个节点生成相应的汇编代码,缺失了全局视图,节点之间的优化也就无从谈起。

  在执行编译之前,V8会构建众多全局对象并加载一些内置的库(如math库),来构建一个运行环境。而且在JavaScript源代码中,并非所有的函数都被编译生成本地代码,而是延迟编译,在调用时才会编译。

  由于V8缺少了生成中间代码这一环节,缺少了必要的优化,为了提升性能,V8会在生成本地代码后,使用数据分析器(profiler)采集一些信息,然后根据这些数据将本地代码进行优化,生成更高效的本地代码,这是一个逐步改进的过程。同时,当发现优化后代码的性能还不如未优化的代码,V8将退回原来的代码,也就是优化回滚。下面介绍一下运行阶段,该阶段使用的主要类如下所示:

  • Script:表示JavaScript代码,即包含源代码,又包含编译之后生成的本地代码,即是编译入口,又是运行入口;
  • Execution:运行代码的辅组类,包含一些重要函数,如Call函数,它辅组进入和执行Script代码;
  • JSFunction:需要执行的JavaScript函数表示类;
  • Runtime:运行这些本地代码的辅组类,主要提供运行时所需的辅组函数,如:属性访问、类型转换、编译、算术、位操作、比较、正则表达式等;
  • Heap:运行本地代码需要使用的内存堆类;
  • MarkCompactCollector:垃圾回收机制的主要实现类,用来标记、清除和整理等基本的垃圾回收过程;
  • SweeperThread:负责垃圾回收的线程。

  先根据需要编译和生成这些本地代码,也就是使用编译阶段那些类和操作。在V8中,函数是一个基本单位,当某个JavaScript函数被调用时,V8会查找该函数是否已经生成本地代码,如果已经生成,则直接调用该函数。否则,V8引擎会生成属于该函数的本地代码。这就节约了时间,减少了处理那些使用不到的代码的时间。其次,执行编译后的代码为JavaScript构建JS对象,这需要Runtime类来辅组创建对象,并需要从Heap类分配内存。再次,借助Runtime类中的辅组函数来完成一些功能,如属性访问等。最后,将不用的空间进行标记清除和垃圾回收。

  2.3.优化回滚

  因为V8是基于AST直接生成本地代码,没有经过中间表示层的优化,所以本地代码尚未经过很好的优化。于是,在2010年,V8引入了新的编译器-Crankshaft,它主要针对热点函数进行优化,基于JavaScript源代码开始分析而非本地代码,同时构建Hydroger图并基于此来进行优化分析。

  Crankshaft编译器为了性能考虑,通常会做出比较乐观和大胆的预测—代码稳定且变量类型不变,所以可以生成高效的本地代码。但是,鉴于JavaScript的一个弱类型的语言,变量类型也可能在执行的过程中进行改变,鉴于这种情况,V8会将该编译器做的想当然的优化进行回滚,称为优化回滚。

  示例如下:

var counter = 0;
function test(x, y) {
    counter++;
    if (counter < 1000000) {
        // do something
        return 'jeri';
    }
    var unknown = new Date();
    console.log(unknown);
}

  该函数被调用多次之后,V8引擎可能会触发Crankshaft编译器对其进行优化,而优化代码认为示例代码的类型信息都已经被确定。但,由于尚未真正执行到new Date()这个地方,并未获取unknown这个变量的类型,V8只得将该部分代码进行回滚。优化回滚是一个很耗时的操作,在写代码过程中,尽量不要触发优化该操作。

  在最近发布的 V8 5.9 版本中,新增了一个 Ignition 字节码解释器,TurboFan 和 Ignition 结合起来共同完成JavaScript的编译。这个版本中消除 Cranshaft 这个旧的编译器,并让新的 Turbofan 直接从字节码来优化代码,并当需要进行反优化的时候直接反优化到字节码,而不需要再考虑 JS 源代码。

  2.4.隐藏类与内嵌缓存

  2.4.1.隐藏类

  在执行C++代码时,仅凭几个指令即可根据偏移信息获取变量信息,而JavaScript里需要通过字符串匹配来查找属性值的,这就需要更多的操作才能访问到变量信息,而代码量变量存取是十分频繁的,这也就制约了JavaScript的性能。V8借用了类和偏移位置的思想,将本来通过属性名匹配来访问属性值的方法进行了改进,使用类似C++编译器的偏移位置机制来实现,这就是隐藏类。

  隐藏类将对象划分成不同的组,对于组内对象拥有相同的属性名和属性值的情况,将这些组的属性名和对应的偏移位置保存在一个隐藏类中,组内所有对象共享该信息。同时,也可以识别属性不同的对象。示例如下:
  使用Point构造了两个对象p和q,这两个对象具有相同的属性名,V8将它们归为同一个组,也就是隐藏类,这些属性在隐藏类中有相同的偏移值,p和q共享这一信息,进行属性访问时,只需根据隐藏类的偏移值即可。由于JavaScript是动态类型语言,在执行时可以更改变量的类型,如果上述代码执行之后,执行q.z=2,那么p和q将不再被认为是一个组,q将是一个新的隐藏类。

  2.4.2.内嵌缓存

  正常访问对象属性的过程是:首先获取隐藏类的地址,然后根据属性名查找偏移值,然后计算该属性的地址。虽然相比以往在整个执行环境中查找减小了很大的工作量,但依然比较耗时。能不能将之前查询的结果缓存起来,供再次访问呢?当然是可行的,这就是内嵌缓存。

  内嵌缓存的大致思路就是将初次查找的隐藏类和偏移值保存起来,当下次查找的时候,先比较当前对象是否是之前的隐藏类,如果是的话,直接使用之前的缓存结果,减少再次查找表的时间。当然,如果一个对象有多个属性,那么缓存失误的概率就会提高,因为某个属性的类型变化之后,对象的隐藏类也会变化,就与之前的缓存不一致,需要重新使用以前的方式查找哈希表。

  2.5.内存管理

  Node中通过JavaScript使用内存时就会发现只能使用部分内存(64位系统下约为1.4 GB,32位系统下约为0.7 GB),其深层原因是 V8 垃圾回收机制的限制所致(如果可使用内存太大,V8在进行垃圾回收时需耗费更多的资源和时间,严重影响JS的执行效率)。下面对内存管理进行介绍。

  内存的管理组要由分配和回收两个部分构成。V8的内存划分如下:

  • Zone:管理小块内存。其先自己申请一块内存,然后管理和分配一些小内存,当一块小内存被分配之后,不能被Zone回收,只能一次性回收Zone分配的所有小内存。当一个过程需要很多内存,Zone将需要分配大量的内存,却又不能及时回收,会导致内存不足情况。
  • 堆:管理JavaScript使用的数据、生成的代码、哈希表等。为方便实现垃圾回收,堆被分为三个部分:

    年轻分代:为新创建的对象分配内存空间,经常需要进行垃圾回收。为方便年轻分代中的内容回收,可再将年轻分代分为两半,一半用来分配,另一半在回收时负责将之前还需要保留的对象复制过来。
    年老分代:根据需要将年老的对象、指针、代码等数据保存起来,较少地进行垃圾回收。
    大对象:为那些需要使用较多内存对象分配内存,当然同样可能包含数据和代码等分配的内存,一个页面只分配一个对象。

  垃圾回收

  V8 使用了分代和大数据的内存分配,在回收内存时使用精简整理的算法标记未引用的对象,然后消除没有标记的对象,最后整理和压缩那些还未保存的对象,即可完成垃圾回收。

  在V8中,使用较多的是年轻分代和年老分代。年轻分代中的对象垃圾回收主要通过Scavenge算法进行垃圾回收。在Scavenge的具体实现中,主要采用了Cheney算法:通过复制的方式实现的垃圾回收算法。它将堆内存分为两个 semispace,一个处于使用中(From空间),另一个处于闲置状态(To空间)。当分配对象时,先是在From空间中进行分配。当开始进行垃圾回收时,会检查From空间中的存活对象,这些存活对象将被复制到To空间中,而非存活对象占用的空间将会被释放。完成复制后,From空间和To空间的角色发生对换。在垃圾回收的过程中,就是通过将存活对象在两个 semispace 空间之间进行复制。年轻分代中的对象有机会晋升为年老分代,条件主要有两个:一个是对象是否经历过Scavenge回收,一个是To空间的内存占用比超过限制。

  对于年老分代中的对象,由于存活对象占较大比重,再采用上面的方式会有两个问题:一个是存活对象较多,复制存活对象的效率将会很低;另一个问题依然是浪费一半空间的问题。为此,V8在年老分代中主要采用了Mark-Sweep(标记清除)标记清除和Mark-Compact(标记整理)相结合的方式进行垃圾回收。

  2.6.快照

  在V8引擎启动时,需要构建JavaScript运行环境,需要加载很多内置对象,同时也需要建立内置的函数,如Array,String,Math等。为了使V8更加整洁,加载对象和建立函数等任务都是使用JavaScript文件来实现的,V8引擎负责提供机制来支持,就是在编译和执行JavaScript前先加载这些文件。

  V8引擎需要编译和执行这些内置的JavaScript代码,同时使用堆等来保存执行过程中创建的对象、代码等,这些都需要时间。为此,V8引入了快照机制。将这些内置的对象和函数加载之后的内存保存并序列化。序列化之后的结果很容易反序列化,经过快照机制的启动时间可以缩减几毫秒。快照机制也可以将一些开发者认为需要的JavaScript文件序列化,以减少处理时间。不过快照机制的加载的代码不能被CrankShaft这样的编译器优化,可能会存在性能问题。

  3.V8 VS JavaScriptCore

  JavaScriptCore引擎是WebKit中默认的JavaScript引擎,也是苹果开源的一个项目,应用较为广泛。最初,性能不是很好,从2008年开始了一系列的优化,重新实现了编译器和字节码解释器,使得引擎的性能有较大的提升。随后内嵌缓存、基于正则表达式的JIT、简单的JIT及字节码解释器等技术引入进来,JavaScriptCore引擎也在不断的迭代和发展。

  V8引擎自诞生之日起就以性能优化作为目标,引入了众多新技术,极大了带动了整个业界JavaScript引擎性能的快速发展。总的来说,V8引擎较为激进,青睐可以提高性能的新技术,而JavaScriptCore引擎较为稳健,渐进式的改变着自己的性能。总的来说JavaScript引擎工作流程(包含v8和JavaScriptCore)如下所示:
  JavaScriptCore 的大致流程为:源代码-→抽象语法树-→字节码-→JIT-→本地代码。JavaScriptCore与V8有一些不同之处,其中最大的不同就是新增了字节码的中间表示,并加入了多层JIT编译器(如:简单JIT编译器、DFG JIT编译器、LLVM等)优化性能,不停的对本地代码进行优化。(在 V8 的 5.9 版本中,新增了一个 Ignition 字节码解释器,TurboFan 和 Ignition 结合起来共同完成JavaScript的编译,此后 V8 将与 JavaScriptCore 有大致相同的流程,Node 8.0中 V8 版本为 5.8)

  还有就是在数据表示方面,V8在不同的机器上使用与机器位数相匹配的数据表示,而在JavaScriptCore中句柄都是使用64位表示,其可以表示更大范围的数字,所以即使在32位机器上,浮点类型同样可以保存在句柄中,不再需要访问堆中的数据,当也会占用更多的空间。

  4.功能扩展

  JavaScript引擎的主要功能是解析和执行JavaScript代码,往往不能满足使用者多样化的需要,那么就可以增加扩展以提升它的能力。V8引擎有两种扩展机制:绑定和扩展。

  4.1.绑定机制

  使用IDL文件或接口文件生成绑定文件,将这些文件同V8引擎一起编译。WebKit中使用IDL来定义JavaScript,但又与IDL有所不同,有一些改变。定义一个新的接口的步骤大致如下:

  • 1.定义新的接口文件,可以在JavaScript代码进行调用,如mymodule.MyObj.myAttr;
module mymodule {
    interface [
            InterfaceName = MyObject
    ] MyObj { 
        readonly attribute long myAttr;
        DOMString myMethod (DOMString myArg);
    };
}
  • 2.按照引擎定义的标准接口为基础实现接口类,生成JavaScript引擎所需的绑定文件。WebKit提供了工具帮助生成所需的绑定类,根据引擎不同和引擎开发语言的不同而有所差异。V8引擎会为上述示例代码生成 v8MyObj.h (MyObj类具体的实现代码)和 V8MyObj.cpp (桥接代码,辅组注册桥接的函数到V8引擎)两个绑定文件。

  JavaScript引擎绑定机制需要将扩展代码和JavaScript引擎一块编译和打包,不能根据需要在引擎启动后再动态注入这些本地代码。在实际WEB开发中,开发者都是基于现有浏览器的,根本不可能介入到JavaScript引擎的编译中,绑定机制有很大的局限性,但其非常高效,适用于对性能要求较高的场景。

  4.2. Extension机制

  通过V8的基类Extension进行能力扩展,无需和V8引擎一起编译,可以动态为引擎增加功能特性,具有很大的灵活性。

  Extension机制的大致思路就是,V8提供一个基类Extension和一个全局注册函数,要想扩展JavaScript能力,需要经过以下步骤:

class MYExtension : public v8::Extension {
    public:
        MYExtension() : v8::Extension("v8/My", "native function my();") {}
        virtual v8::Handle GetNativeFunction (
        v8::Handle name) {
            // 可以根据name来返回不同的函数
            return v8::FunctionTemplate::New(MYExtention::MY);
        }
        static v8::Handle MY(const v8::Arguments& args) {
            // Do sth here
            return v8::Undefined();
        }
};
MYExtension extension;
RegisterExtension(&extension);
  • 1.基于Extension基类构建一个它的子类,并实现它的虚函数—GetNativeFunction,根据参数name来决定返回实函数;
  • 2.创建一个该子类的对象,并通过注册函数将该对象注册到V8引擎,当JavaScript调用’my’函数时就可被调用到。

  Extension机制是调用V8的接口注入新函数,动态扩展非常方便,但没有绑定机制高效,适用于对性能要求不高的场景。

  总结

  在过去几年,JavaScript在很多领域得到了广泛的应用,然而限于JavaScript语言本身的不足,执行效率不高。Google也推出了一些JavaScript网络应用,如Gmail、Google Maps及Google Docs office等。这些应用的性能不仅受到服务器、网络、渲染引擎以及其他诸多因素的影响,同时也受到JavaScript本身执行速度的影响。然而既有的JavaScript引擎无法满足新的需求,而性能不佳一直是网络应用开发者最关心的。Google就开始了V8引擎的研究,将一系列新技术引入JavaScript引擎中,大大提高了JavaScript的执行效率。相信随着V8引擎的不断发展,JavaScript也会有更广泛的应用场景,前端工程师也会有更好的未来!
那么结合上面对于V8引擎的介绍,我们在编程中应注意:

  • 类型。对于函数,JavaScript是一种动态类型语言,JavaScriptCore和V8都使用隐藏类和内嵌缓存来提高性能,为了保证缓存命中率,一个函数应该使用较少的数据类型;对于数组,应尽量存放相同类型的数据,这样就可以通过偏移位置来访问。
  • 数据表示。简单类型数据(如整型)直接保存在句柄中,可以减少寻址时间和内存占用,如果可以使用整数表示的,尽量不要用浮点类型。
  • 内存。虽然JavaScript语言会自己进行垃圾回收,但我们也应尽量做到及时回收不用的内存,对不再使用的对象设置为null或使用delete方法来删除(使用delete方法删除会触发隐藏类新建,需要更多的额外操作)。
  • 优化回滚。在执行多次之后,不要出现修改对象类型的语句,尽量不要触发优化回滚,否则会大幅度降低代码的性能。
  • 新机制。使用JavaScript引擎或者渲染引擎提供的新机制和新接口提高性能。

  参考资料

转自: Chrome V8引擎介绍

深入理解Safari浏览器引擎WebKit中的JavaScriptCore

  唐笛  美团技术团队 

背景

动态化作为移动客户端技术的一个重要分支,一直是业界积极探索的方向。目前业界流行的动态化方案,如Facebook的React Native,阿里巴巴的Weex都采用了前端系的DSL方案,而它们在iOS系统上能够顺利的运行,都离不开一个背后的功臣:JavaScriptCore(以下简称JSCore),它建立起了Objective-C(以下简称OC)和JavaScript(以下简称JS)两门语言之间沟通的桥梁。无论是这些流行的动态化方案,还是WebView Hybrid方案,亦或是之前广泛流行的JSPatch,JSCore都在其中发挥了举足轻重的作用。作为一名iOS开发工程师,了解JSCore已经逐渐成为了必备技能之一。

从浏览器谈起

在iOS 7之后,JSCore作为一个系统级Framework被苹果提供给开发者。JSCore作为苹果的浏览器引擎WebKit中重要组成部分,这个JS引擎已经存在多年。如果想去追本溯源,探究JSCore的奥秘,那么就应该从JS这门语言的诞生,以及它最重要的宿主-Safari浏览器开始谈起。

JavaScript历史简介

JavaScript诞生于1995年,它的设计者是Netscape的Brendan Eich,而此时的Netscape正是浏览器市场的霸主。

而二十多年前,当时人们在浏览网页的体验极差,因为那会儿的浏览器几乎只有页面的展示能力,没有和用户的交互逻辑处理能力。所以即使一个必填输入框传空,也需要经过服务端验证,等到返回结果之后才给出响应,再加上当时的网速很慢,可能半分钟过去了,返回的结果是告诉你某个必填字段未填。所以Brendan花了十天写出了JavaScript,由浏览器解释执行,从此之后浏览器也有了一些基本的交互处理能力,以及表单数据验证能力。

而Brendan可能没有想到,在二十多年后的今天。JS这门解释执行的动态脚本语言,不光成为前端届的“正统”,还入侵了后端开发领域,在编程语言排行榜上进入前三甲,仅次于Python和Java。而如何解释执行JS,则是各家引擎的核心技术。目前市面上比较常见的JS引擎有Google的V8(它被运用在Android操作系统以及Google的Chrome上),以及我们今天的主角JSCore(它被运用在iOS操作系统以及Safari上)。

WebKit

我们每天都会接触浏览器,使用浏览器进行工作、娱乐。让浏览器能够正常工作最核心的部分就是浏览器的内核,每个浏览器都有自己的内核,Safari的内核就是WebKit。WebKit诞生于1998年,并于2005年由Apple公司开源,Google的Blink也是在WebKit的分支上进行开发的。

WebKit由多个重要模块组成,通过下图我们可以对WebKit有个整体的了解:

简单点讲,WebKit就是一个页面渲染以及逻辑处理引擎,前端工程师把HTML、JavaScript、CSS这“三驾马车”作为输入,经过WebKit的处理,就输出成了我们能看到以及操作的Web页面。从上图我们可以看出来,WebKit由图中框住的四个部分组成。而其中最主要的就是WebCore和JSCore(或者是其它JS引擎),这两部分我们会分成两个小章节详细讲述。除此之外,WebKit Embedding API是负责浏览器UI与WebKit进行交互的部分,而WebKit Ports则是让Webkit更加方便的移植到各个操作系统、平台上,提供的一些调用Native Library的接口,比如在渲染层面,在iOS系统中,Safari是交给CoreGraphics处理,而在Android系统中,Webkit则是交给Skia。

WebCore

在上面的WebKit组成图中,我们可以发现只有WebCore是红色的。这是因为时至今日,WebKit已经有很多的分支以及各大厂家也进行了很多优化改造,唯独WebCore这个部分是所有WebKit共享的。WebCore是WebKit中代码最多的部分,也是整个WebKit中最核心的渲染引擎。那首先我们来看看整个WebKit的渲染流程:

首先浏览器通过URL定位到了一堆由HTML、CSS、JS组成的资源文件,通过加载器(这个加载器的实现也很复杂,在此不多赘述)把资源文件给WebCore。之后HTML Parser会把HTML解析成DOM树,CSS Parser会把CSS解析成CSSOM树。最后把这两棵树合并,生成最终需要的渲染树,再经过布局,与具体WebKit Ports的渲染接口,把渲染树渲染输出到屏幕上,成为了最终呈现在用户面前的Web页面。

JSCore

 

概述

终于讲到我们这期的主角——JSCore。JSCore是WebKit默认内嵌的JS引擎,之所以说是默认内嵌,是因为很多基于WebKit分支开发的浏览器引擎都开发了自家的JS引擎,其中最出名的就是Chrome的V8。这些JS引擎的使命都相同,那就是解释执行JS脚本。而从上面的渲染流程图我们可以看到,JS和DOM树之间存在着互相关联,这是因为浏览器中的JS脚本最主要的功能就是操作DOM树,并与之交互。同样的,我们也通过一张图看下它的工作流程:

可以看到,相比静态编译语言生成语法树之后,还需要进行链接,装载生成可执行文件等操作,解释型语言在流程上要简化很多。这张流程图右边画框的部分就是JSCore的组成部分:Lexer、Parser、LLInt以及JIT的部分(之所以JIT的部分是用橙色标注,是因为并不是所有的JSCore中都有JIT部分)。接下来我们就搭配整个工作流程介绍每一部分,它主要分为以下三个部分:词法分析、语法分析以及解释执行。

PS:严格的讲,语言本身并不存在编译型或者是解释型,因为语言只是一些抽象的定义与约束,并不要求具体的实现,执行方式。这里讲JS是一门“解释型语言”只是JS一般是被JS引擎动态解释执行,而并不是语言本身的属性。

词法分析:Lexer

词法分析很好理解,就是把一段我们写的源代码分解成Token序列的过程,这一过程也叫分词。在JSCore,词法分析是由Lexer来完成(有的编译器或者解释器把分词叫做Scanner)。

这是一句很简单的C语言表达式:

sum = 3 + 2

将其标记化之后可以得到下表的内容:

这就是词法分析之后的结果,但是词法分析并不会关注每个Token之间的关系,是否匹配,仅仅是把它们区分开来,等待语法分析来把这些Token“串起来”。词法分析函数一般是由语法分析器(Parser)来进行调用的。在JSCore中,词法分析器Lexer的代码主要集中在parser/Lexer.h、Lexer.cpp中。

语法分析:Parser

跟人类语言一样,我们讲话的时候其实是按照约定俗成,交流习惯按照一定的语法讲出一个又一个词语。那类比到计算机语言,计算机要理解一门计算机语言,也要理解一个语句的语法。例如以下一段JS语句:

var sum = 2 + 3;
var a = sum + 5;

Parser会把Lexer分析之后生成的token序列进行语法分析,并生成对应的一棵抽象语法树(AST)。这个树长什么样呢?在这里推荐一个网站:esprima Parser,输入JS语句可以立马生成我们所需的AST。例如,以上语句就被生成这样的一棵树:

之后,ByteCodeGenerator会根据AST来生成JSCore的字节码,完成整个语法解析步骤。

解释执行:LLInt和JIT

JS源代码经过了词法分析和语法分析这两个步骤,转成了字节码,其实就是经过任何一门程序语言必经的步骤–编译。但是不同于我们编译运行OC代码,JS编译结束之后,并不会生成存放在内存或者硬盘之中的目标代码或可执行文件。生成的指令字节码,会被立即被JSCore这台虚拟机进行逐行解释执行。

运行指令字节码(ByteCode)是JS引擎中很核心的部分,各家JS引擎的优化也主要集中于此。JSByteCode的解释执行是一套很复杂的系统,特别是加入了OSR和多级JIT技术之后,整个解释执行变的越来越高效,并且让整个ByteCode的执行在低延时之间和高吞吐之间有个很好的平衡:由低延时的LLInt来解释执行ByteCode,当遇到多次重复调用或者是递归,循环等条件会通过OSR切换成JIT进行解释执行(根据具体触发条件会进入不同的JIT进行动态解释)来加快速度。由于这部分内容较为复杂,而且不是本文重点,故只做简单介绍,不做深入的讨论。

JSCore值得注意的Feature

除了以上部分,JSCore还有几个值得注意的Feature。

基于寄存器的指令集结构

JSCore采用的是基于寄存器的指令集结构,相比于基于栈的指令集结构(比如有些JVM的实现),因为不需要把操作结果频繁入栈出栈,所以这种架构的指令集执行效率更高。但是由于这样的架构也造成内存开销更大的问题,除此之外,还存在移植性弱的问题,因为虚拟机中的虚拟寄存器需要去匹配到真实机器中CPU的寄存器,可能会存在真实CPU寄存器不足的问题。

基于寄存器的指令集结构通常都是三地址或者二地址的指令集,例如:

i = a + b;
//转成三地址指令:
add i,a,b; //把a寄存器中的值和b寄存器中的值相加,存入i寄存器

在三地址的指令集中的运算过程是把a和b分别mov到两个寄存器,然后把这两个寄存器的值求和之后,存入第三个寄存器。这就是三地址指令运算过程。

而基于栈的一般都是零地址指令集,因为它的运算不依托于具体的寄存器,而是使用对操作数栈和具体运算符来完成整个运算。

单线程机制

值得注意的是,整个JS代码是执行在一条线程里的,它并不像我们使用的OC、Java等语言,在自己的执行环境里就能申请多条线程去处理一些耗时任务来防止阻塞主线程。JS代码本身并不存在多线程处理任务的能力。但是为什么JS也存在多线程异步呢?强大的事件驱动机制,是让JS也可以进行多线程处理的关键。

事件驱动机制

之前讲到,JS的诞生就是为了让浏览器也拥有一些交互,逻辑处理能力。而JS与浏览器之间的交互是通过事件来实现的,比如浏览器检测到发生了用户点击,会传递一个点击事件通知JS线程去处理这个事件。

那通过这一特性,我们可以让JS也进行异步编程,简单来讲就是遇到耗时任务时,JS可以把这个任务丢给一个由JS宿主提供的工作线程(WebWorker)去处理。等工作线程处理完之后,会发送一个message让JS线程知道这个任务已经被执行完了,并在JS线程上去执行相应的事件处理程序。(但是需要注意,由于工作线程和JS线程并不在一个运行环境,所以它们并不共享一个作用域,故工作线程也不能操作window和DOM。)

JS线程和工作线程,以及浏览器事件之间的通信机制叫做事件循环(EventLoop),类似于iOS的runloop。它有两个概念,一个是Call Stack,一个是Task Queue。当工作线程完成异步任务之后,会把消息推到Task Queue,消息就是注册时的回调函数。当Call Stack为空的时候,主线程会从Task Queue里取一条消息放入Call Stack来执行,JS主线程会一直重复这个动作直到消息队列为空。

以上这张图大概描述了JSCore的事件驱动机制,整个JS程序其实就是这样跑起来的。这个其实跟空闲状态下的iOS Runloop有点像,当基于Port的Source事件唤醒runloop之后,会去处理当前队列里的所有source事件。JS的事件驱动,跟消息队列其实是“异曲同工”。也正因为工作线程和事件驱动机制的存在,才让JS有了多线程异步能力。

iOS中的JSCore

iOS7之后,苹果对WebKit中的JSCore进行了Objective-C的封装,并提供给所有的iOS开发者。JSCore框架给Swift、OC以及C语言编写的App提供了调用JS程序的能力。同时我们也可以使用JSCore往JS环境中去插入一些自定义对象。

iOS中可以使用JSCore的地方有多处,比如封装在UIWebView中的JSCore,封装在WKWebView中的JSCore,以及系统提供的JSCore。实际上,即使同为JSCore,它们之间也存在很多区别。因为随着JS这门语言的发展,JS的宿主越来越多,有各种各样的浏览器,甚至是常见于服务端的Node.js(基于V8运行)。随时使用场景的不同,以及WebKit团队自身不停的优化,JSCore逐渐分化出不同的版本。除了老版本的JSCore,还有2008年宣布的运行在Safari、WKWebView中的Nitro(SquirrelFish)等等。而在本文中,我们主要介绍iOS系统自带的JSCore Framework。

iOS官方文档对JSCore的介绍很简单,其实主要就是给App提供了调用JS脚本的能力。我们首先通过JSCore Framework的15个开放头文件来“管中窥豹”,如下图所示:

乍一看,概念很多。但是除去一些公共头文件以及一些很细节的概念,其实真正常用的并不多,笔者认为很有必要了解的概念只有4个:JSVM、JSContext、JSValue、JSExport。鉴于讲述这些概念的文章已经有很多,本文尽量从一些不同的角度(比如原理,延伸对比等)去解释这些概念。

JSVirtualMachine

一个JSVirtualMachine(以下简称JSVM)实例代表了一个自包含的JS运行环境,或者是一系列JS运行所需的资源。该类有两个主要的使用用途:一是支持并发的JS调用,二是管理JS和Native之间桥对象的内存。

JSVM是我们要学习的第一个概念。官方介绍JSVM为JavaScript的执行提供底层资源,而从类名直译过来,一个JSVM就代表一个JS虚拟机,我们在上面也提到了虚拟机的概念,那我们先讨论一下什么是虚拟机。首先我们可以看看(可能是)最出名的虚拟机——JVM(Java虚拟机),JVM主要做两个事情:

  1. 首先它要做的是把JavaC编译器生成的ByteCode(ByteCode其实就是JVM的虚拟机器指令)生成每台机器所需要的机器指令,让Java程序可执行(如下图)。

  2. 第二步,JVM负责整个Java程序运行时所需要的内存空间管理、GC以及Java程序与Native(即C,C++)之间的接口等等。

 

从功能上来看,一个高级语言虚拟机主要分为两部分,一个是解释器部分,用来运行高级语言编译生成的ByteCode,还有一部分则是Runtime运行时,用来负责运行时的内存空间开辟、管理等等。实际上,JSCore常常被认为是一个JS语言的优化虚拟机,它做着JVM类似的事情,只是相比静态编译的Java,它还多承担了把JS源代码编译成字节码的工作。

既然JSCore被认为是一个虚拟机,那JSVM又是什么?实际上,JSVM就是一个抽象的JS虚拟机,让开发者可以直接操作。在App中,我们可以运行多个JSVM来执行不同的任务。而且每一个JSContext(下节介绍)都从属于一个JSVM。但是需要注意的是每个JSVM都有自己独立的堆空间,GC也只能处理JSVM内部的对象(在下节会简单讲解JS的GC机制)。所以说,不同的JSVM之间是无法传递值的。

值得注意的还有,在上面的章节中,我们提到的JS单线程机制。这意味着,在一个JSVM中,只有一条线程可以跑JS代码,所以我们无法使用JSVM进行多线程处理JS任务。如果我们需要多线程处理JS任务的场景,就需要同时生成多个JSVM,从而达到多线程处理的目的。

JS的GC机制

JS同样也不需要我们去手动管理内存。JS的内存管理使用的是GC机制(Tracing Garbage Collection)。不同于OC的引用计数,Tracing Garbage Collection是由GCRoot(Context)开始维护的一条引用链,一旦引用链无法触达某对象节点,这个对象就会被回收掉。如下图所示:

JSContext

一个JSContext表示了一次JS的执行环境。我们可以通过创建一个JSContext去调用JS脚本,访问一些JS定义的值和函数,同时也提供了让JS访问Native对象,方法的接口。

JSContext是我们在实际使用JSCore时,经常用到的概念之一。”Context”这个概念我们都或多或少的在其它开发场景中见过,它最常被翻译成“上下文”。那什么是上下文?比如在一篇文章中,我们看到一句话:“他飞快的跑了出去。”但是如果我们不看上下文的话,我们并不知道这句话究竟是什么意思:谁跑了出去?他是谁?他为什么要跑?

写计算机理解的程序语言跟写文章是相似的,我们运行任何一段语句都需要有这样一个“上下文”的存在。比如之前外部变量的引入、全局变量、函数的定义、已经分配的资源等等。有了这些信息,我们才能准确的执行每一句代码。

同理,JSContext就是JS语言的执行环境,所有JS代码的执行必须在一个JSContext之中,在WebView中也是一样,我们可以通过KVC的方式获取当时WebView的JSContext。通过JSContext运行一段JS代码十分简单,如下面这个例子:

    JSContext *context = [[JSContext alloc] init];
    [context evaluateScript:@"var a = 1;var b = 2;"];
    NSInteger sum = [[context evaluateScript:@"a + b"] toInt32];//sum=3

借助evaluateScript API,我们就可以在OC中搭配JSContext执行JS代码。它的返回值是JS中最后生成的一个值,用属于当前JSContext中的JSValue(下一节会有介绍)包裹返回。

我们还可以通过KVC的方式,给JSContext塞进去很多全局对象或者全局函数:

    JSContext *context = [[JSContext alloc] init];
        context[@"globalFunc"] =  ^() {
        NSArray *args = [JSContext currentArguments];
        for (id obj in args) {
            NSLog(@"拿到了参数:%@", obj);
        }
    };
    context[@"globalProp"] = @"全局变量字符串";
   [context evaluateScript:@"globalFunc(globalProp)"];//console输出:“拿到了参数:全局变量字符串”

这是一个很好用而且很重要的特性,有很多著名的借助JSCore的框架如JSPatch,都利用了这个特性去实现一些很巧妙的事情。在这里我们不过多探讨可以利用它做什么,而是去研究它究竟是怎样运作的。在JSContext的API中,有一个值得注意的只读属性 — JSValue类型的globalObject。它返回当前执行JSContext的全局对象,例如在WebKit中,JSContext就会返回当前的Window对象。

而这个全局对象其实也是JSContext最核心的东西,当我们通过KVC方式与JSContext进去取值赋值的时候,实际上都是在跟这个全局对象做交互,几乎所有的东西都在全局对象里,可以说,JSContext只是globalObject的一层壳。对于上述两个例子,本文取了context的globalObject,并转成了OC对象,如下图:

可以看到这个globalObject保存了所有的变量与函数,这更加印证了上文的说法(至于为什么globalObject对应OC对象是NSDictionary类型,我们将在下节中讲述)。所以我们还能得出另外一个结论,JS中所谓的全局变量,全局函数不过是全局对象的属性和函数。

同时值得注意的是,每个JSContext都从属于一个JSVM。我们可以通过JSContext的只读属性virtualMachine获得当前JSContext绑定的JSVM。JSContext和JSVM是多对一的关系,一个JSContext只能绑定一个JSVM,但是一个JSVM可以同时持有多个JSContext。而上文中我们提到,每个JSVM同时只有整个一个线程来执行JS代码,所以综合来看,一次简单的通过JSCore运行JS代码,并在Native层获取返回值的过程大致如下:

JSValue

JSValue实例是一个指向JS值的引用指针。我们可以使用JSValue类,在OC和JS的基础数据类型之间相互转换。同时我们也可以使用这个类,去创建包装了Native自定义类的JS对象,或者是那些由Native方法或者Block提供实现JS方法的JS对象。

在JSContext一节中,我们接触了大量的JSValue类型的变量。在JSContext一节中我们了解到,我们可以很简单的通过KVC操作JS全局对象,也可以直接获得JS代码执行结果的返回值(同时每一个JS中的值都存在于一个执行环境之中,也就是说每个JSValue都存在于一个JSContext之中,这也就是JSValue的作用域),都是因为JSCore帮我们用JSValue在底层自动做了OC和JS的类型转换。

JSCore一共提供了如下10种类型互换:

   Objective-C type  |   JavaScript type
 --------------------+---------------------
         nil         |     undefined
        NSNull       |        null
       NSString      |       string
       NSNumber      |   number, boolean
     NSDictionary    |   Object object
       NSArray       |    Array object
        NSDate       |     Date object
       NSBlock            |   Function object 
          id         |   Wrapper object 
        Class        | Constructor object

同时还提供了对应的互换API(节选):

(JSValue *)valueWithDouble:(double)value inContext:(JSContext *)context;
(JSValue *)valueWithInt32:(int32_t)value inContext:(JSContext *)context;
(NSArray *)toArray;
(NSDictionary *)toDictionary;

在讲类型转换前,我们先了解一下JS这门语言的变量类型。根据ECMAScript(可以理解为JS的标准)的定义:JS中存在两种数据类型的值,一种是基本类型值,它指的是简单的数据段。第二种是引用类型值,指那些可能由多个值构成的对象。基本类型值包括”undefined”,”nul”,”Boolean”,”Number”,”String”(是的,String也是基础类型),除此之外都是引用类型。对于前五种基础类型的互换,应该没有太多要讲的。接下来会重点讲讲引用类型的互换:

NSDictionary Object

在上节中,我们把JSContext的globalObject转换成OC对象,发现是NSDictionary类型。要搞清楚这个转换,首先我们对JS这门语言面向对象的特性进行一个简单的了解。在JS中,对象就是一个引用类型的实例。与我们熟悉的OC、Java不一样,对象并不是一个类的实例,因为在JS中并不存在类的概念。ECMA把对象定义为:无序属性的集合,其属性可以包含基本值、对象或者函数。从这个定义我们可以发现,JS中的对象就是无序的键值对,这和OC中的NSDictionary,Java中的HashMap何其相似。

    var person = { name: "Nicholas",age: 17};//JS中的person对象
    NSDictionary *person = @{@"name":@"Nicholas",@"age":@17};//OC中的person dictionary

在上面的实例代码中,笔者使用了类似的方式创建了JS中的对象(在JS中叫“对象字面量”表示法)与OC中的NSDictionary,相信可以更有助理解这两个转换。

NSBlock Function Object

在上节的例子中,笔者在JSContext赋值了一个”globalFunc”的Block,并可以在JS代码中当成一个函数直接调用。我还可以使用”typeof”关键字来判断globalFunc在JS中的类型:

    NSString *type = [[context evaluateScript:@"typeof globalFunc"] toString];//type的值为"function"

通过这个例子,我们也能发现传入的Block对象在JS中已经被转成了”function”类型。”Function Object”这个概念对于我们写惯传统面向对象语言的开发者来说,可能会比较晦涩。而实际上,JS这门语言,除了基本类型以外,就是引用类型。函数实际上也是一个”Function”类型的对象,每个函数名实则是指向一个函数对象的引用。比如我们可以这样在JS中定义一个函数:

    var sum = function(num1,num2){
        return num1 + num2; 
    }

同时我们还可以这样定义一个函数(不推荐):

    var sum = new Function("num1","num2","return num1 + num2");

按照第二种写法,我们就能很直观的理解到函数也是对象,它的构造函数就是Function,函数名只是指向这个对象的指针。而NSBlock是一个包裹了函数指针的类,JSCore把Function Object转成NSBlock对象,可以说是很合适的。

JSExport

实现JSExport协议可以开放OC类和它们的实例方法,类方法,以及属性给JS调用。

除了上一节提到的几种特殊类型的转换,我们还剩下NSDate类型,与id、class类型的转换需要弄清楚。而NSDate类型无需赘述,所以我们在这一节重点要弄清楚后两者的转换。

而通常情况下,我们如果想在JS环境中使用OC中的类和对象,需要它们实现JSExport协议,来确定暴露给JS环境中的属性和方法。比如我们需要向JS环境中暴露一个Person的类与获取名字的方法:

@protocol PersonProtocol <JSExport>
- (NSString *)fullName;//fullName用来拼接firstName和lastName,并返回全名
@end

@interface JSExportPerson : NSObject <PersonProtocol>

- (NSString *)sayFullName;//sayFullName方法

@property (nonatomiccopyNSString *firstName;
@property (nonatomiccopyNSString *lastName;

@end

然后,我们可以把一个JSExportPerson的一个实例传入JSContext,并且可以直接执行fullName方法:

    JSExportPerson *person = [[JSExportPerson alloc] init];
    context[@"person"] = person;
    person.firstName = @"Di";
    person.lastName =@"Tang";
    [context evaluateScript:@"log(person.fullName())"];//调Native方法,打印出person实例的全名
    [context evaluateScript:@"person.sayFullName())"];//提示TypeError,'person.sayFullName' is undefined

这就是一个很简单的使用JSExport的例子,但请注意,我们只能调用在该对象在JSExport中开放出去的方法,如果并未开放出去,如上例中的”sayFullName”方法,直接调用则会报TypeError错误,因为该方法在JS环境中并未被定义。

讲完JSExport的具体使用方法,我们来看看我们最开始的问题。当一个OC对象传入JS环境之后,会转成一个JSWrapperObject。那问题来了,什么是JSWrapperObject?在JSCore的源码中,我们可以找到一些线索。首先在JSCore的JSValue中,我们可以发现这样一个方法:

@method
@abstract Create a JSValue by converting an Objective-C object.
@discussion The resulting JSValue retains the provided Objective-C object.
@param value The Objective-C object to be converted.
@result The new JSValue.
*/
+ (JSValue *)valueWithObject:(id)value inContext:(JSContext *)context;

这个API可以传入任意一个类型的OC对象,然后返回一个持有该OC对象的JSValue。那这个过程肯定涉及到OC对象到JS对象的互换,所以我们只要分析一下这个方法的源码(基于这个分支进行分析)。由于源码实现过长,我们只需要关注核心代码,在JSContext中有一个”wrapperForObjCObject”方法,而实际上它又是调用了JSWrapperMap的”jsWrapperForObject”方法,这个方法就可以解答所有的疑惑:

//接受一个入参object,并返回一个JSValue
- (JSValue *)jsWrapperForObject:(id)object
{
    //对于每个对象,有专门的jsWrapper
    JSC::JSObject* jsWrapper = m_cachedJSWrappers.get(object);
    if (jsWrapper)
        return [JSValue valueWithJSValueRef:toRef(jsWrapper) inContext:m_context];
    JSValue *wrapper;
    //如果该对象是个类对象,则会直接拿到classInfo的constructor为实际的Value
    if (class_isMetaClass(object_getClass(object)))
        wrapper = [[self classInfoForClass:(Class)objectconstructor];
    else {
        //对于普通的实例对象,由对应的classInfo负责生成相应JSWrappper同时retain对应的OC对象,并设置相应的Prototype
        JSObjCClassInfo* classInfo = [self classInfoForClass:[object class]];
        wrapper = [classInfo wrapperForObject:object];
    }
    JSC::ExecState* exec = toJS([m_context JSGlobalContextRef]);
    //将wrapper的值写入JS环境
    jsWrapper = toJS(exec, valueInternalValue(wrapper)).toObject(exec);
    //缓存object的wrapper对象
    m_cachedJSWrappers.set(object, jsWrapper);
    return wrapper;
}

在我们创建”JSWrapperObject”的对象过程中,我们会通过JSWrapperMap来为每个传入的对象创建对应的JSObjCClassInfo。这是一个非常重要的类,它有这个类对应JS对象的原型(Prototype)与构造函数(Constructor)。然后由JSObjCClassInfo去生成具体OC对象的JSWrapper对象,这个JSWrapper对象中就有一个JS对象所需要的所有信息(即Prototype和Constructor)以及对应OC对象的指针。之后,把这个jsWrapper对象写入JS环境中,即可在JS环境中使用这个对象了。

这也就是”JSWrapperObject”的真面目。而我们上文中提到,如果传入的是类,那么在JS环境中会生成constructor对象,那么这点也很容易从源码中看到,当检测到传入的是类的时候(类本身也是个对象),则会直接返回constructor属性,这也就是”constructor object”的真面目,实际上就是一个构造函数。

那现在还有两个问题,第一个问题是,OC对象有自己的继承关系,那么在JS环境中如何描述这个继承关系?第二个问题是,JSExport的方法和属性,又是如何让JS环境中调用的呢?

我们先看第一个问题,继承关系要如何解决?在JS中,继承是通过原型链来实现,那什么是原型呢?原型对象是一个普通对象,而且就是构造函数的一个实例。所有通过该构造函数生成的对象都共享这一个对象,当查找某个对象的属性值,结果不存在时,这时就会去对象的原型对象继续找寻,是否存在该属性,这样就达到了一个封装的目的。我们通过一个Person原型对象快速了解:

//原型对象是一个普通对象,而且就是Person构造函数的一个实例。所有Person构造函数的实例都共享这一个原型对象。
Person.prototype = {
   name:  'tony stark',
   age48,
   job'Iron Man',
   sayNamefunction() {
     alert(this.name);
   }
}

而原型链就是JS中实现继承的关键,它的本质就是重写构造函数的原型对象,链接另一个构造函数的原型对象。这样查找某个对象的属性,会沿着这条原型链一直查找下去,从而达到继承的目的。我们通过一个例子快速了解一下:

    function mammal (){}
     mammal.prototype.commonness = function(){
           alert('哺乳动物都用肺呼吸');
     }; 

    function Person() {}
    Person.prototype = new mammal();//原型链的生成,Person的实例也可以访问commonness属性了
    Person.prototype.name = 'tony stark';
    Person.prototype.age  = 48;
    Person.prototype.job  = 'Iron Man';
    Person.prototype.sayName = function() {
          alert(this.name);
    }

    var person1 = new Person();
    person1.commonness(); // 弹出'哺乳动物都用肺呼吸'
    person1.sayName(); // 'tony stark'

而我们在生成对象的classinfo的时候(具体代码见”allocateConstructorAndPrototypeWithSuperClassInfo”),还会生成父类的classInfo。对每个实现过JSExport的OC类,JSContext里都会提供一个prototype。比如NSObject类,在JS里面就会有对应的Object Prototype。对于其它的OC类,会创建对应的Prototype,这个prototype的内部属性[Prototype]会指向为这个OC类的父类创建的Prototype。这个JS原型链就能反应出对应OC类的继承关系,在上例中,Person.prototype被赋值为一个mammal的实例对象,即原型的链接过程。

讲完第一个问题,我们再来看看第二个问题。那JSExport是如何暴露OC方法到JS环境的呢?这个问题的答案同样出现在我们生成对象的classInfo的时候:

        Protocol *exportProtocol = getJSExportProtocol();
        forEachProtocolImplementingProtocol(m_class, exportProtocol, ^(Protocol *protocol){
            copyPrototypeProperties(m_context, m_class, protocol, prototype);
            copyMethodsToObject(m_context, m_class, protocol, NO, constructor);
        });

对于每个声明在JSExport里的属性和方法,classInfo会在prototype和constructor里面存入对应的property和method。之后我们就可以通过具体的methodName和PropertyName生成的setter和getter方法,来获取实际的SEL。最后就可以让JSExport中的方法和属性得到正确的访问。所以简单点讲,JSExport就是负责把这些方法打个标,以methodName为key,SEL为value,存入一个map(prototype和constructor本质上就是一个Map)中去,之后就可以通过methodName拿到对应的SEL进行调用。这也就解释了上例中,我们调用一个没有在JSExport中开放的方法会显示undefined,因为生成的对象里根本没有这个key。

总结

JSCore给iOS App提供了JS可以解释执行的运行环境与资源。对于我们实际开发而言,最主要的就是JSContext和JSValue这两个类。JSContext提供互相调用的接口,JSValue为这个互相调用提供数据类型的桥接转换。让JS可以执行Native方法,并让Native回调JS,反之亦然。

利用JSCore,我们可以做很多有想象空间的事。所有基于JSCore的Hybrid开发基本就是靠上图的原理来实现互相调用,区别只是具体的实现方式和用途不大相同。大道至简,只要正确理解这个基本流程,其它的所有方案不过是一些变通,都可以很快掌握。

一些引申阅读

JSPatch的对象和方法没有实现JSExport协议,JS是如何调OC方法的?

JS调OC并不是通过JSExport。通过JSExport实现的方式有诸多问题,我们需要先写好Native的类,并实现JSExport协议,这个本身就不能满足“Patch”的需求。

所以JSPatch另辟蹊径,使用了OC的Runtime消息转发机制做这个事情,如下面这一个简单的JSPatch调用代码:

require('UIView'
var view = UIView.alloc().init() 
  1. require在全局作用域里生成UIView变量,来表示这个对象是一个OCClass。

  2. 通过正则把.alloc()改成._c(‘alloc’),来进行方法收口,最终会调用_methodFunc()把类名、对象、MethodName通过在Context早已定义好的Native方法,传给OC环境。

  3. 最终调用OC的CallSelector方法,底层通过从JS环境拿到的类名、方法名、对象之后,通过NSInvocation实现动态调用。

JSPatch的通信并没有通过JSExport协议,而是借助JSCore的Context与JSCore的类型转换和OC的消息转发机制来完成动态调用,实现思路真的很巧妙。

桥方法的实现是怎么通过JSCore交互的?

市面上常见的桥方法调用有两种:

  1. 通过UIWebView的delegate方法:shouldStartLoadWithRequest来处理桥接JS请求。JSRequest会带上methodName,通过WebViewBridge类调用该method。执行完之后,会使用WebView来执行JS的回调方法,当然实际上也是调用的WebView中的JSContext来执行JS,完成整个调用回调流程。

  2. 通过UIWebView的delegate方法:在webViewDidFinishLoadwebViewDidFinishLoad里通过KVC的方式获取UIWebView的JSContext,然后通过这个JSContext设置已经准备好的桥方法供JS环境调用。

参考资料

  1. 《JavaScript高级程序设计》

  2. Webkit Architecture

  3. 虚拟机随谈(一)

  4. 深入剖析 WebKit

  5. JSCore-Wiki

  6. iOS中的JSCore

 

作者简介

唐笛,美团点评高级工程师。2017年加入原美团,目前作为外卖iOS团队主力开发,主要负责移动端基础设施建设,动态化等方向相关推进工作,致力于提升移动端研发效率与研发质量。

———-  END  ———-

转自: 深入理解JavaScriptCore