65.9K
CodeProject 正在变化。 阅读更多。
Home

AngleSharp 和 JavaScript

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.93/5 (20投票s)

2014年11月7日

CPOL

30分钟阅读

viewsIcon

67646

downloadIcon

652

将现有的 JavaScript 引擎连接到 AngleSharp 以执行 DOM 操作并提供一个可工作的无头浏览器。

AngleSharp Parser Logo

目录

  1. 引言
  2. 背景
  3. AngleSharp 的进展
    1. HTML 和 CSS 状态
    2. 配置
    3. DOM API
    4. 扩展点
  4. 连接 Jint
    1. 理解 Jint
    2. 自动化包装器
  5. 使用代码
  6. 运行 JavaScript
  7. 关注点
  8. 历史

引言

一年多以前,我宣布了 AngleSharp 项目,这是一个相当宏大的项目。该项目试图实现 W3C 和 WHATWG 提供的有关 HTML/CSS 的规范。其核心是一个先进的 HTML(5) 解析器和一个功能强大的 CSS(3) 解析器,后者包含了 L4 模块,如 CSS4 选择器。

自发布以来,该项目取得了相当大的成功。AngleSharp 已经以可移植类库(PCL)的形式发布。这使得它可以在 .NET 4.5+、WindowsPhone 8+ 和 Windows 应用商店应用程序上使用。总的来说,使用轻量级便携解决方案的趋势仍在上升,这也解释了对该项目的持续需求和疑问。

尽管 AngleSharp 最初是作为解析 HTML 的工具,但它的功能远不止于此。AngleSharp 提供了几个扩展点,不仅可以用来解析 HTML 以创建 W3C 指定的 DOM,它还了解脚本和样式。AngleSharp 本身不包含开箱即用的脚本引擎,但它提供了一个了解 CSS 的默认样式引擎。部分原因是 HTML 和 CSS 之间的紧密联系。例如,querySelector()querySelectorAll() API 方法使用一个字符串作为参数,这个字符串由 CSS 引擎解释。

本文将讨论一些当前的扩展点。我们还将看看即将到来的扩展点——只是为了说明 AngleSharp 的路线图和愿景。最后,我们将通过展示如何将一个现有的 JavaScript 引擎连接到 AngleSharp 来激发整个扩展点主题的兴趣。我们会看到整个过程或多或少是直接的,不需要太多代码,并且能产生有趣的应用。

背景

JavaScript 不是唯一可以用来编写 Web 应用程序的语言。事实上,微软从一开始就提供了 VBScript 作为替代品。其他浏览器供应商也提供或曾经提供过替代方案。尽管如此,JavaScript 仍然是在客户端计算机上使 Web 动态化的首选。

这种主导地位有几个原因。最重要的原因是兼容性。每个浏览器都提供了 JavaScript 实现。尽管在表面(API)和实现上存在一些差异,但所有实现至少都与 ECMAScript 3 兼容。我们以此为共同的代码基础,为各种设备上的浏览器提供动力,这些设备从计算机到智能手机、平板电脑和电视机不等。

正是这种主导地位,使得一旦开发者进入 Web 领域,JavaScript 就变得有趣起来。有人可能更喜欢静态类型,但有时类型转换会增加一些不必要的噪音。有人可能更喜欢传统的类和作用域,但脚本的特性有时更直接、更切中要害。任何可以用 JavaScript 编写的应用程序,最终都会用 JavaScript 编写。

因此,仅仅在 C# 中拥有一个 HTML 解析器对于某些任务来说可能是一个很好的工具基础,但它总是排除了大量只有 JavaScript 形式的代码。我们不想排除这一部分。如果已经有一个很好的脚本能以我们想要的方式操纵网页,为什么我们还要重写它呢?

AngleSharp 有一个用于包含脚本引擎的扩展点。这可以用于注册自定义脚本语言(实验性地,或作为一个很好的替代方案),或包含官方语言(如 VBScript 或 ECMAScript 实现)。对我们来说幸运的是,已经有一些用 .NET 编写的很棒的脚本语言。

  • ScriptCS(及其他),使 C# 本身可脚本化
  • IPython(及其他 Python 实现)
  • NeoLua(及其他 Lua 实现)
  • Jurassic(将 JavaScript 编译为 MSIL)
  • JavaScript.NET(基于 V8,一个包装器)
  • Jint(JavaScript 解释器,以 PCL 形式提供)

当然还有许多其他语言。在本文中,我们将使用 Jint 来解释与 ECMAScript (5) 兼容的代码并应用 DOM 操作。为什么选择 Jint?它的性能当然不如 Jurassic(编译型)或 JavaScript.NET(V8 无可匹敌,但包装器会带来一些性能损失),但是,它严格遵循规范,可通过 NuGet 获取(不像 JavaScript.NET),并且与 PCL 兼容。这使得我们本文讨论的解决方案也可以在 WindowsPhone 或 Windows 应用商店等平台上使用。

AngleSharp 的进展

最初的 AngleSharp 文章讨论了 DOM 生成背后的原理。主要关注了词法分析和解析过程。除了进行了一些优化和增加了 HTML5 HTMLTemplateElement 接口外,没有发生任何重大变化。后者已按照 HTML 5.1 规范的要求包含在解析器中。

主要的变化发生在 API 和 CSS 引擎中。API 的更改方式使其对用户更加灵活并遵循 .NET 编码标准,而 CSS 引擎则从头开始重写。它现在更灵活,更容易扩展,也更快。

我们将首先讨论解析器的变化,然后介绍 AngleSharp 中实现的配置模型。最后,我们将详细介绍当前提供的 DOM API 和可用的扩展点。

HTML 和 CSS 状态

正如已经解释过的,HTML 解析器部分几乎没有变化。尽管如此,一些提交已经被推送,因此我们可以预期一些变化。主要是创建了更多的 HTML DOM 元素,实现了 API,并提供了一些性能改进。

然而,一个相当重要的变化是对外暴露的 API。普通用户现在只处理接口定义,而不是处理 DOM 元素的实现。这就像 W3C 提议的那样,使得对底层实现的更改变得容易得多。这里我们有了一种不太可能被破坏的契约。具体的实现细节也被隐藏了。需要注意的是,这种封装非常重要,因为它允许在不破坏用户代码的情况下重构实现。它还允许暴露一个以完全不同方式实现的 API。

下图展示了当前 HTML DOM 树的一部分。当前版本的 AngleSharp 实现了最新的 DOM (4)。因此,EventTarget 接口位于顶部。此外,Attr 接口不是一个 Node。最后,该图使用了 AngleSharp 中因 .NET 约定而可用的前缀表示法。

AngleSharp HTML DOM Tree Snippet

AngleSharp 的一个功能示例是提交 HTML 表单。任何尝试用 C# 包装现有网站的人都了解这个问题。没有 API,需要提交表单以接收数据。撇开数据提取问题不谈,我们需要检查表单的所有字段(文本、隐藏等),可能还需要在途中填写一些字段。大多数时候,人们无法让这样一个过程足够好地在生产环境中使用。现在这已成为过去!

HTMLFormElement 包含 Submit() 方法,该方法会将表单数据发送到指定的操作 URL。该方法使用当前的方法(POST、GET 等)并按照官方规范创建请求。这使得以下场景得以实现:获取登录页面的内容,通过提交包含用户名和密码的表单执行登录,提取特定子页面的数据,然后注销。

对于所描述的过程,一个非常重要的部分是 cookie。用户的会话必须以(会话)cookie 的形式保存。此外,我们需要一个单元来接收和发送 HTTP 请求。幸运的是,所有这三个部分,

  1. 表单提交
  2. 发送/接收 Cookie
  3. 发送请求

都包含在 AngleSharp 中。尽管最后两点仅由默认实现提供,并且任何用户都可以用更好的实现来升级它们。

一个表单提交可以像下面这样简单(取自单元测试)

var url = "http://anglesharp.azurewebsites.net/PostUrlencodeNormal";
var config = new Configuration { AllowRequests = true };
var html = DocumentBuilder.Html(new Uri(url), config);
var form = html.Forms[0] as IHtmlFormElement;
var name = form.Elements["Name"] as IHtmlInputElement;
var number = form.Elements["Number"] as IHtmlInputElement;
var active = form.Elements["IsActive"] as IHtmlInputElement;
name.Value = "Test";
number.Value = "1";
active.IsChecked = true;
form.Submit();

在最近的版本中,AngleSharp 的 DOM 模型已更新至 DOM4。只有少数标记为过时的方法和属性仍被包含。仅存的是当前网站大量使用的 API 构件。树遍历也通过包含诸如 NodeIteratorTreeWalkerRange 类等辅助工具进行了更新。这些数据结构使得通过过滤重要节点来遍历 DOM 树成为可能。

通常,Document 实例用于创建这类数据结构的实例。其思想是,可以从 Document 调用包含所有必需数据的构造函数,而无需 API 用户知道特定类构造函数的具体签名。这也表明了 Document 与例如 TreeWalker 之间的密切关系。为了完成这个例子,具体的方法被称为 CreateTreeWalker()

AngleSharp 还包含遵循官方树语言的方法。虽然父元素或子元素的定义是微不足道的,但祖先和后代的特化并非如此。一个包含性祖先是元素的任何祖先,包括该元素本身。因此,一个包含性后代也是元素与其后代集合的并集。元素的根是顶层父元素,即没有父元素的父元素。

DOM Vertical View

不仅在垂直方向上有这样的定义,水平方向上也有。在这里,我们感兴趣的是树的宽度。从单个元素的角度来看,我们将因此关注其兄弟节点。兄弟节点是当前元素父节点的所有子节点,不包括当前元素本身。我们可以区分前置兄弟节点和后置兄弟节点。前置兄弟节点在树顺序中位于当前元素之前,而后置兄弟节点则在当前元素之后。简而言之:前置兄弟节点的索引小于当前元素的索引。后置兄弟节点的索引大于当前元素的索引。索引是父节点子节点数组中的位置。

DOM Horizontal View

有两种特殊的兄弟节点:前一个(元素)是索引最接近且小于当前元素索引的兄弟节点。类似地,我们有下一个(元素),它是索引最接近且大于当前元素索引的兄弟节点。对于当前元素的索引 i,我们有 i-1 作为前一个元素,i+1 作为下一个元素。可能没有前一个或下一个元素。

最后,关于 Range 结构的一点说明。下图(取自 W3C 官网的官方规范)说明了几个用途

DOM Range Examples

在图中,我们看到了四个不同的范围。范围的起始点被称为 s#。结束点用 e# 表示,其中 #Range 实例的编号。对于范围 2,起始点在 body 元素内。它紧跟在 h1 元素之后,紧靠在段落标签之前。因此,它的位置在文档 body 的 h1p 子节点之间。

边界点的容器不是文本节点时,其偏移量为:

  • 0,如果它在第一个子节点之前,
  • 1,如果在第一个和第二个子节点之间,以及
  • n,如果在第 n 个和第 (n+1) 个子节点之间。

因此,对于范围 2 的起点,容器是 body,偏移量是 1。边界点的容器是文本节点时,其偏移量的获取方式类似。这里我们使用(16位)字符的数量作为位置信息。例如,范围 1 中标记为 s1 的边界点,其容器是一个文本节点(包含“Title”的那个),偏移量为 2,因为它在第二个和第三个字符之间。

我们应该注意到,范围 3 和 4 的边界点在文本表示中对应相同的位置。Range 类的一个重要特性是,范围的边界点可以明确地表示文档树中的每一个位置。

所有迭代器都是实时集合,即它们会根据 DOM 树的变化而改变其表示。原因很简单:它们没有固定的内容,而是在请求时从 Document 指定的底层 DOM 中获取其内容。因此,与 Document 的关系是任何实时结构的最低要求。

那么 CSS 呢?CSS API 现在也几乎完成了。最新的 CSS 版本是 CSS3,它直接建立在 CSS 2.1 之上。然而,CSS3 也为所谓的模块做好了准备。现在所有东西都在自己的模块里。AngleSharp 实现了选择器模块 4。还有其他模块也以其当前状态、较旧状态或根本没有实现。总的来说,有太多的模块无法在核心中实现,或者根本无法实现。即使是当前的浏览器也没有实现所有模块。有些模块太特殊,有些模块没有进入生产就绪状态,有些可能已经过时,从未被广泛使用过。

最重要的是声明树。在处理 CSS 时,我们最终会处理一个由 CSS 规则组成的样式表。这些规则可以嵌套。例如,一个 CSS媒体规则可以嵌套其他规则,如其他媒体规则、普通样式规则或其他文档特定规则。另一方面,样式规则可能是 CSS 中最重要的货币。它不能嵌套其他规则,由一个元素选择器和一组声明组成。

通过 ICssStyleDeclaration 接口可以访问这组声明。每个声明都由一个 ICssProperty 接口表示。一个属性总是分为多个部分。我们有属性的名称和它的值。历史上,曾有一种通过 ICssValue 接口访问这些值的方法。然而,AngleSharp 很可能会放弃对该接口的支持。它非常有限,没有被积极实现,并且很快将被 W3C 移除。

AngleSharp CSSOM Tree Snippet

因此,上面以片段形式展示的树将在未来的版本中发生变化。在 JavaScript(也可能在 C#)中,普通用户会通过字符串来设置值。在 C# 中,也将有可能直接设置值,这才是真正的核心。不会有任何笨拙的抽象,它非常有限,在实践中难以使用。

配置

通常,解析 HTML 文档的过程涉及构建 HtmlParser 类的实例。然而,对于某些用户来说,这个过程可能令人困惑且过于间接。因此,存在一种更短的方式。我们有 DocumentBuilder 来缩短大多数解析过程。DocumentBuilder 提供了一些静态方法,可以直接从不同来源(stringStreamUri 实例)创建 IDocumentICssStyleSheet 文档。

问题在于任何过程可能需要的配置多种多样。例如,应该使用哪种(如果有的话)请求器(以及针对哪种协议,如 http 或 https)?我们是提供一个脚本引擎,还是脚本功能根本就没启用?可以设置的配置选项有很多。目前的优先级如下

  1. 已提供配置 - 使用它。
  2. 已将自定义配置设置为默认配置 - 使用它。
  3. 考虑默认配置。

配置是一个实现 IConfiguration 的对象。根据具体需求,可以从头创建一个类来实现 IConfiguration,或者基于 Configuration,它包含了 IConfiguration 接口的默认实现。大多数情况下,仅实例化 Configuration 类并修改其内容就足够了,而无需更改实现。

可以通过调用 Configuration 类的 SetDefault() 方法来设置默认配置。默认情况下,同一个类的实例就是默认配置。这个实例实际上是不可变的,因为我们无法从外部获取该实例。

让我们看看 IConfiguration 提供了什么。

public interface IConfiguration
{
    Boolean IsScripting { get; set; }

    Boolean IsStyling { get; set; }

    Boolean IsEmbedded { get; set; }

    CultureInfo Culture { get; set; }

    IEnumerable<IScriptEngine> ScriptEngines { get; }

    IEnumerable<IStyleEngine> StyleEngines { get; }

    IEnumerable<IService> Services { get; }

    IEnumerable<IRequester> Requesters { get; }

    void ReportError(ParseErrorEventArgs e);
}

我们看到配置接口根据我们的需要设置了上下文。我们可以指定样式和脚本是否应被视为活动状态。这会影响,例如,<noscript> 标签的处理方式。此外,像从 Culture 属性中提取的语言这样的设置,将在为文档选择正确编码时被考虑。

最值得注意的是,我们有两个属性 ServicesRequesters,它们提供了扩展背后的大部分魔力。Services 包含用于非常特殊目的的任意服务,例如检索和存储 cookie,而 Requesters 则包含所有注册的流请求器。可能没有(只对解析单个提供的文档感兴趣)或有多个请求器。然后会考虑第一个支持当前请求协议(方案)的请求器。

当然,一个主要的使用场景可能是从支持 HTTP 协议的网络源检索文档(及其子文档)。因此,提供了一个默认的请求器(用于 http 和 https 协议)。激活默认请求器非常简单。

var config = new Configuration().WithDefaultRequester();

WithDefaultRequester() 方法是作为一个扩展方法实现的,它需要一个 Configuration 或其特化类的实例。它返回提供的实例以支持链式调用。我们被允许编写如下表达式:

var config = new Configuration()
	.WithDefaultRequester()
	.WithCss()
	.WithScripting();

前一个表达式创建了一个新的 AngleSharp 配置实例,它带有默认的 HTTP / HTTPS 请求器,注册并激活了 CSS 样式引擎,并且激活了脚本功能。注意:该行代码中没有添加脚本引擎。尽管我们激活了脚本,但我们没有提供一个(JavaScript)脚本引擎,因此将无法运行任何脚本。然而,<noscript> 标签将被视为包含一块原始文本,即在 DOM 生成中被忽略。

DOM API

关于 AngleSharp 项目的一个批评是它使用了官方的 W3C DOM API。然而,在我看来,从原始 API 开始,并在其之上添加语法糖要好得多。原因有很多:

  • 连接脚本引擎会容易得多,这允许运行已经访问原始 API 的脚本。
  • 官方 API 有大量的文档存在。
  • 标准驱动着网络,我们应该首先完全符合标准,然后再添加我们自己的东西。
  • 可以在其之上创建扩展(参见 jQuery),提供更好的 API。

目前,基础(官方)API 已接近完成。一些方法的实现仍然滞后,但将在未来几个月内完成。大多数改进和快捷方式将通过使用扩展方法来实现。这不会与现有 API 冲突,会做出正确的指示,并确保一切都建立在官方 API 之上(这应该是共同的基础——只是为了确保有效性)。

API 已转向 DOM L4。只有少数来自早期 DOM 规范的遗留部分仍然被包含。这些遗留部分大多数来自在各种网页上的大量使用。如果一个 API 方法或属性似乎仍在使用中,但在 DOM L4 中被排除了,我们(很可能)仍然会将其包含在 AngleSharp 中。

扩展点

AngleSharp 的生命力在于其可扩展性。扩展之所以有用,是因为 AngleSharp 知道如何集成它们。它们在特定的场景中被调用。让我们考虑本文想要探讨的情况。在这里,我们感兴趣的是集成一个脚本引擎。

需要做什么?当发现某种类型的 <script> 标签时,我们希望 AngleSharp 寻找一个与给定脚本语言匹配的脚本引擎。脚本语言通常以属性的形式设置。如果没有脚本语言的属性,则自动采用 JavaScript。在这种情况下,我们有“text/javascript”。如果注册了具有给定类型的引擎,我们需要运行一个方法来评估该脚本。

这就是事情变得棘手的地方。脚本既可以内联提供(只是一个字符串),也可以以外部资源的形式提供。如果后者为真,那么 AngleSharp 将自动执行请求(如果可能的话)。在这种情况下,源以响应的形式呈现,响应中包含内容流、头部信息以及关于响应的附加信息,如 URL。

下面定义了一个脚本引擎。

public interface IScriptEngine
{
    String Type { get; }

    void Evaluate(String source, ScriptOptions options);

    void Evaluate(IResponse response, ScriptOptions options);
}

还有其他遵循这种模式的例子。以下代码片段展示了注册样式引擎的接口。尽管 CSS3 已经包含在 AngleSharp 中,但我们可能会考虑用我们自己的实现或另一个引擎(比如从 Mozilla Firefox 移植过来的引擎)来替换它。我们也可以考虑为其他格式注册一个样式引擎,比如 XML、LESS 或 SASS。后两者可以被预处理并使用原始的 CSS3 引擎进行转换。

public interface IStyleEngine
{
    String Type { get; }

    IStyleSheet Parse(String source, StyleOptions options);

    IStyleSheet Parse(IResponse response, StyleOptions options);
}

拥有这些接口使得 AngleSharp 相当灵活。它也赋予了 AngleSharp 暴露开箱即用功能之外的能力。有时这是由于项目的重点,有时是由于平台的限制。不管原因是什么,易于扩展肯定是一个加分项。在我们继续之前,应该注意,AngleSharp 仍处于测试阶段,这就是为什么 API(包括扩展接口)仍在变化中。有关最新版本的信息,请查阅托管在 GitHub 上的官方仓库。

另一个例子是 ICookieService 接口。这个接口也使用了 IService 接口,它标记了通用服务。服务只是 AngleSharp 用来声明各种扩展的方式,这些扩展对于任何功能来说都不是直接至关重要的,但可能带来新的连接、可能性或收集信息。ICookieService 的目的是为 cookie 提供一个公共的存储。此外,关于 cookie 的信息,例如 cookie 是否是 HTTP-only,也必须被读取和评估。

public interface ICookieService : IService
{
    String this[String origin] { get; set; }
}

我们看到该服务为给定的源字符串定义了一个 getter 和一个 setter 属性。Cookie 总是针对特定的“域”(称为源)来考虑。这包括 URL 的完整主机和协议部分。当需要从响应中读取 cookie、在 DOM 中更改 cookie 或发送请求需要 cookie 时,会使用 cookie 服务。

连接 Jint

使用 IScriptEngine 接口将 JavaScript 引擎连接到 AngleSharp 可以非常容易,也可以非常具有挑战性。主要问题在于 .NET 对象与引擎期望的形式之间的连接。通常,我们需要编写包装器,这些包装器遵循由指定引擎规定的基本结构。

此外,我们可能想要重命名一些 .NET 类和方法(包括属性)的名称。事件、构造函数和索引器也可能需要我们额外的关注。然而,有了反射的帮助,我们可以编写这样一个包装器生成器而无需花费太多时间。

接下来,我们将首先简要了解 Jint。在理解了 Jint 的主要目的和内部工作原理之后,我们将继续自动将传入的对象包装成可脚本化对象。传出的对象也需要被解包,然而,正如我们将看到的,这部分是微不足道的。

理解 Jint

Jint 是 ECMAScript 5 规范的一个 .NET 实现。因此,它包含(几乎)所有官方指定的内容。然而,这并不意味着所有东西都像在浏览器中预期的那样工作。浏览器中 JS API 的大部分来自 DOM / W3C。例如用于从 JavaScript 发出请求的 XmlHttpRequester

JavaScript 专注于键值对,其中键是字符串。Jint 中的值表示为 JsValue 实例。然而,我们可以更通用地注册函数来检索特定值。函数不应与 JavaScript 函数混淆。那是一种值。为什么我们要使用函数而不是值?这就是 C# 中属性的工作方式。属性只不过是一个像变量一样调用的函数。我们希望在 JavaScript 中暴露同样的行为,否则我们每次接触父对象时都必须读出每个值。

一旦我们接触父对象就读出每个值不仅是一个性能问题。它还意味着我们需要更新父对象。总的来说,这不是我们想要的。我们希望父对象看起来是“活的”,而不需要强制手动更新所有的(JavaScript)属性。因此,获得与 C# 中相同的行为确实是可取的,并且可以减少在通过包装器暴露 API 时可能出现的一些意外副作用。

连接 JavaScript 引擎时,我们需要做的第一件事是创建一个实现 IScriptEngine 接口的类。最后,我们需要在我们提供给 AngleSharp 的配置中注册我们的引擎实例。

为新的脚本引擎设置正确的类型非常重要。该类型对应于所提供脚本的 MIME 类型。目前尚不清楚 Type 属性是否会保留在接口中,或者是否会被一个方法所取代,该方法会检查所提供的类型是否与脚本引擎所涵盖的类型相匹配。后者通常更难实现,但更灵活。它可以匹配多个(或任何,或没有)类型。

让我们看看在这种情况下实现是什么样的。

public class JavaScriptEngine : IScriptEngine
{
    readonly Engine _engine;
    readonly LexicalEnvironment _variable;

    public JavaScriptEngine()
    {
        _engine = new Engine();
        _engine.SetValue("console", new ConsoleInstance(_engine));
        _variable = LexicalEnvironment.NewObjectEnvironment(_engine, _engine.Global, null, false);
    }

    public String Type
    {
        get { return "text/javascript"; }
    }

    public void Evaluate(String source, ScriptOptions options)
    {
        var context = new DomNodeInstance(_engine, options.Context ?? new AnalysisWindow(options.Document));
        var env = LexicalEnvironment.NewObjectEnvironment(_engine, context, _engine.ExecutionContext.LexicalEnvironment, true);
        _engine.EnterExecutionContext(env, _variable, context);
        _engine.Execute(source);
        _engine.LeaveExecutionContext();
    }

    public void Evaluate(IResponse response, ScriptOptions options)
    {
        var reader = new StreamReader(response.Content, options.Encoding ?? Encoding.UTF8, true);
        var content = reader.ReadToEnd();
        reader.Close();
        Evaluate(content, options);
    }
}

创建引擎需要我们提供在所有流行实现中(任何浏览器、node 等)都能找到的常见东西。一个例子是 console 对象。在这个示例中,我们只提供了最重要的方法,比如 log(),用于将任意数量的对象输出到控制台。

还有什么要说的呢?首先,两个 Evaluate() 方法并没有太大区别。那个以 Stream 为输入的方法只是将整个 Stream 读入一个 String。通常我们会倾向于使用 Stream,但是 Jint 只接受 String 作为源。

然而,重要的是 Jint 支持执行上下文。这允许我们为提供的评估放置一个自定义的执行上下文。脚本完成后,我们将离开该执行上下文。承载执行上下文的对象是脚本选项提供的 Window 对象。

在 JavaScript 中,上下文就是一切。但上下文是一个非常相对的概念。事实上,虽然上下文及其父级决定了变量解析,但它也决定了相关的 this 指针。全局上下文是无法离开的一层。无论我们做什么,我们最终都会回到全局上下文中。

我们在 DOM 操作中的全局上下文实际上是双重的。一方面,我们有 JavaScript API,其中包含诸如 MathJSON 和原始类型对象(StringNumberObject 等)的对象。另一方面,我们也有一个映射 IWindow 对象的 DOM 层。因此,命名解析将直接解析基于 IWindow 的属性。然而,这个代表 IWindow ".NET" 对象的 "JavaScript" 对象也可以被扩展。因此,如果没有找到可用的属性,它也将作为 this,并用属性进行扩展。

JavaScript Execution Context

其他上下文可以放在这之上。Jint 允许手动放置执行上下文,这使得包装像 IWindow 实例这样的对象成为可能。没有它,我们无法区分我们的全局对象和全局 JavaScript API。

另一件至关重要(并且 Jint 做得非常完美)的事情是 JavaScript 原型模式的表示。每个对象都有一个原型。任何对象的实例化都会产生一个带有原型的对象。乍一听可能觉得奇怪,但这幅详细的图会帮助理解。

JavaScript Prototype Constructor

在 ES5 中,我们有两个属性。一个叫做 prototype,另一个叫做 __proto__。如果我们有一个经典的构造函数,比如 function Foo() { /* ... */ },我们显然得到一个 Function 类型的东西。因此,__proto__ 属性映射到 Function 对象。然而,一旦我们创建了 Foo 的一个实例,我们就得到了一个 Object。因此,至少在开始时,Fooprototype 映射到 Object。所以,一个构造函数的 prototype 变成了由该构造函数创建的实例的 __proto__

我们可以继续这个链条,但此时唯一需要注意的是 Jint 做得都对。我们的部分只包括从相应的对象派生,例如 ObjectInstance。通过这样做,我们设置了结果实例的 __proto__ 属性。就是这样!

自动化包装器

正如我们所见,我们实际需要做的一件事是创建包装器或从我们的包装器中解包对象。为了动态生成包装器对象,开发了一个简单的机制。然而,比仅仅将(DOM)对象包装成(JS)Jint 对象更难的是包装函数(委托)。

这里我们需要应用一些反射魔法。此外,我们需要考虑 Jint 提供的 FunctionInstance 类的一些属性。下面的代码展示了两件事:

  1. 将一个函数实例包装成任意委托
  2. 将一个函数实例包装成一个 DomEventHandler

如果具体目标未知,有一个快捷方式。通过调用 ToDelegate 方法,我们总是能得到正确的选择。

static class DomDelegates
{
    public static Delegate ToDelegate(this Type type, FunctionInstance function)
    {
        if (type == typeof(EventListener))
            return ToListener(function);

        var method = typeof(DomDelegates).GetMethod("ToCallback").MakeGenericMethod(type);
        return method.Invoke(null, new Object[] { function }) as Delegate;
    }

    public static DomEventHandler ToListener(this FunctionInstance function)
    {
        return (obj, ev) =>
        {
            var engine = function.Engine;
            function.Call(obj.ToJsValue(engine), new[] { ev.ToJsValue(engine) });
        };
    }

    public static T ToCallback<T>(this FunctionInstance function)
    {
        var type = typeof(T);
        var methodInfo = type.GetMethod("Invoke");
        var convert = typeof(Extensions).GetMethod("ToJsValue");
        var mps = methodInfo.GetParameters();
        var parameters = new ParameterExpression[mps.Length];

        for (var i = 0; i < mps.Length; i++)
            parameters[i] = Expression.Parameter(mps[i].ParameterType, mps[i].Name);

        var obj = Expression.Constant(function);
        var engine = Expression.Property(obj, "Engine");
        var call = Expression.Call(obj, "Call", new Type[0], new Expression[]
        {
            Expression.Call(convert, parameters[0], engine),
            Expression.NewArrayInit(typeof(JsValue), parameters.Skip(1).Select(m => Expression.Call(convert, m, engine)).ToArray())
        });

        return Expression.Lambda<T>(call, parameters).Compile();
    }
}

前两部分相当明显,但第三个方法是需要应用实际工作的地方。在这里,我们基本上是构建一个类型为 T 的委托,它包装了传入的 FunctionInstance 实例。该方法解决的问题是通过传递所需的现有参数来包装参数。然而,这种方法的问题来自 JavaScript 的动态性。我们必须接受省略参数的可能性。问题是:被省略的参数是可选的吗?如果我们发现参数太多怎么办?我们应该丢弃多余的,还是因为有一个 params 参数?如果是后者,我们实际上需要构建一个数组。

还有更多的问题,但最重要的已由上面展示的实现解决了。最后,我们编译一个 Expression 实例,这是一个相当繁重的过程,但不幸的是,对于所示的过程是必需的。人们可以(也应该)缓冲结果以加速后续使用,但这对于我们的小例子来说不是必需的。

那么让我们来看看我们的标准(DOM)对象包装器。下面的类基本上就是连接 AngleSharp 和 Jint 所需的全部内容。当然,还有比表面上更多的东西,但大多数其他的类和方法处理的是特殊情况,比如上面我们包装委托的情况。类似的情况还包括索引器、事件等等。

sealed class DomNodeInstance : ObjectInstance
{
    readonly Object _value;

    public DomNodeInstance(Engine engine, Object value)
        : base(engine)
    {
        _value = value;
        SetMembers(value.GetType());
    }

    void SetMembers(Type type)
    {
        if (type.GetCustomAttribute<DomNameAttribute>() == null)
        {
            foreach (var contract in type.GetInterfaces())
                SetMembers(contract);
        }
        else
        {
            SetProperties(type.GetProperties());
            SetMethods(type.GetMethods());
        }
    }

    void SetProperties(PropertyInfo[] properties)
    {
        foreach (var property in properties)
        {
            var names = property.GetCustomAttributes<DomNameAttribute>();

            foreach (var name in names.Select(m => m.OfficialName))
            {
                FastSetProperty(name, new PropertyDescriptor(
                    new DomFunctionInstance(this, property.GetMethod),
                    new DomFunctionInstance(this, property.SetMethod), false, false));
            }
        }
    }

    void SetMethods(MethodInfo[] methods)
    {
        foreach (var method in methods)
        {
            var names = method.GetCustomAttributes<DomNameAttribute>();

            foreach (var name in names.Select(m => m.OfficialName))
                FastAddProperty(name, new DomFunctionInstance(this, method), false, false, false);
        }
    }

    public Object Value
    {
        get { return _value; }
    }
}

好吧,代码量相当大,但重要的一步是使用当前值的类型信息调用 SetMembers() 方法,即 value.GetType()SetMembers() 方法本身负责获取正确的(DOM)接口,或者在找到 DOM 接口时触发属性和方法的填充。

最后,我们开始添加接口中包含的方法。我们选择存储在 DomNameAttribute 属性中的值作为名称。类似地,我们也包含接口中列出的所有属性,也使用正确的名称。

DomFunctionInstance 看起来怎么样?关键是为 Call() 方法提供一个新的实现。在这里我们实际上执行了一些魔法。

主要问题是将参数解包成通常的结构。我们已经简要讨论过这个问题。现在我们想看看这些参数是如何被处理的。BuildArgs() 方法负责这个魔法。

sealed class DomFunctionInstance : FunctionInstance
{
    readonly MethodInfo _method;
    readonly DomNodeInstance _host;

    public DomFunctionInstance(DomNodeInstance host, MethodInfo method)
        : base(host.Engine, GetParameters(method), null, false)
    {
        _host = host;
        _method = method;
    }

    public override JsValue Call(JsValue thisObject, JsValue[] arguments)
    {
        if (_method != null && thisObject.Type == Types.Object)
        {
            var node = thisObject.AsObject() as DomNodeInstance;

            if (node != null)
                return _method.Invoke(node.Value, BuildArgs(arguments)).ToJsValue(Engine);
        }

        return JsValue.Undefined;
    }

    Object[] BuildArgs(JsValue[] arguments)
    {
        var parameters = _method.GetParameters();
        var max = parameters.Length;
        var args = new Object[max];

        if (max > 0 && parameters[max - 1].GetCustomAttribute<ParamArrayAttribute>() != null)
            max--;

        var n = Math.Min(arguments.Length, max);

        for (int i = 0; i < n; i++)
            args[i] = arguments[i].FromJsValue().As(parameters[i].ParameterType);

        for (int i = n; i < max; i++)
            args[i] = parameters[i].IsOptional ? parameters[i].DefaultValue : parameters[i].ParameterType.GetDefaultValue();

        if (max != parameters.Length)
        {
            var array = Array.CreateInstance(parameters[max].ParameterType.GetElementType(), Math.Max(0, arguments.Length - max));

            for (int i = max; i < arguments.Length; i++)
                array.SetValue(arguments[i].FromJsValue(), i - max);

            args[max] = array;
        }

        return args;
    }

    static String[] GetParameters(MethodInfo method)
    {
        if (method == null)
            return new String[0];

        return method.GetParameters().Select(m => m.Name).ToArray();
    }
}

我们在这里做什么?我们提供一些检查以确保传入的参数是合理的。然后我们构造一个名为 args 的数组,它基本上是需要处理的参数集。最后,在包含了多种可能性,如可选类型、可变参数输入之后,我们以正确的格式返回转换和打包后的参数,即一个对象数组。

使用代码

我以一个示例 WPF JS DOM REPL(一个 WPF JavaScript 文档对象模型读取-求值-打印-循环)应用程序的形式提取了脚本项目。因此,这基本上是一个几乎所有浏览器都有的 JavaScript 控制台。提供的源代码是冻结的,不会更新。

下图展示了示例应用程序的样子。控制台是一个第三方控件,在 WPF 中提供了基本的控制台功能。它可能有一些怪癖,但足以说明 REPL 的方面。

该示例也包含在 GitHub 仓库提供的示例中。因此,当前源代码(来自这个小项目,以及 AngleSharp 和相关项目)可以在项目页面上找到,该页面托管在 GitHub 上。项目地址为 github.com/FlorianRappl/AngleSharp

如果您发现示例中有极端错误,请考虑在此处发帖。如果在应用程序中发现小错误,或 AngleSharp 本身有任何错误,请优先选择 GitHub 上的 issues 页面。同样,如果您不确定在哪里发布与项目相关的任何内容,请考虑 GitHub 上的 issues 页面。

所以简而言之:与文章相关(拼写错误、示例中的错误、赞扬 [希望有!] 等)的评论应该在这里发布,所有其他主题都在 GitHub 上讨论。我个人绝不会忽略它们,但是,如果讨论是集中的并且有重点的,那么对每个人来说都会更简单、更容易跟进。

运行 JavaScript

通过所有描述的工作,解析(和评估)包含 JavaScript 的网页也成为可能。我特别喜欢的一个示例是关注以下 HTML 代码

<!doctype html>
<html>
<head><title>Event sample</title></head>
<body>
<script>
console.log('Before setting the handler!');

document.addEventListener('load', function() {
    console.log('Document loaded!');
});

document.addEventListener('hello', function() {
    console.log('hello world from JavaScript!');
});

console.log('After setting the handler!');
</script>
</body>

HTML 本身很简单,但它包含一段内联的 JavaScript 代码,该代码会写入控制台(无论那是什么)并安装一些事件监听器。

第一个监听器等待常见的 load 事件,而第二个是针对特殊 hello 事件的自定义事件监听器。那么 AngleSharp 与这段 HTML 代码有什么关系呢?

public static void EventScriptingExample()
{
    //We require a custom configuration
    var config = new Configuration();

    //Including a script engine
    config.Register(new JavaScriptEngine());

    //And enabling scripting + styling (should be enabled anyway)
    config.IsScripting = true;
    config.IsStyling = true;

    //This is our sample source, we will trigger the load event
    var source = @"/* Code from above */";
    var document = DocumentBuilder.Html(source, config);

    //Register Hello event listener from C# (we also have one in JS)
    document.AddEventListener("hello", (s, ev) =>
    {
        Console.WriteLine("hello world from C#!");
    });

    var e = document.CreateEvent("event");
    e.Init("hello", false, false);
    document.Dispatch(e);
}

好的,所以我们创建了一个新的 Configuration。然后我们注册了我们之前描述的 JavaScriptEngine 并启用了脚本。最后,我们使用 DocumentBuilder 来解析给定的源(代码中未显示,只需使用上面的片段)并使用提供的配置。

最后,我们还在 C# 中为自定义的 hello 事件添加了一个事件监听器。如果我们不为它分派事件参数,自定义事件就不会被调用。因此,最后三行是专门为它服务的。

这个过程的结果是什么样的?

橙色矩形显示两个处理程序都是在文档加载之前添加的。中间没有任何输出。然后所有其他处理程序都触发了,顺序不确定,但是有一个约束,即通过 JavaScript 附加的处理程序总是会在通过 C# 附加的处理程序之前触发。原因很简单:通过 JavaScript 添加的那个是在之前添加的(在解析时,而 C# 的那个是之后添加的)。

绿色框再次说明两个处理程序都已触发。我们只需要分派自定义事件。其余的都按预期工作。

兴趣点

AngleSharp 不是唯一完全用 C# 编写的 HTML 解析器。事实上,AngleSharp 面临着相当激烈的竞争。选择其他解析器来完成工作有其原因,但总的来说,AngleSharp 对这项工作可能绰绰有余。如果不确定,那就选择 AngleSharp。如果我们想创建一个无头浏览器,那么 AngleSharp 无疑是完成这项工作的工具。

有人可能会争辩说,存在控制无头浏览器的其他选项。这当然是真的,但这些工具大多建立在用其他语言编写的库之上,这些库不会编译成 .NET 代码。因此,这些库只是包装器,可能在性能或敏捷性方面存在缺点。健壮性也可能是一个问题。

历史

  • v1.0.0 | 初始发布 | 2014年11月7日
  • v1.1.0 | 添加了演示截图 | 2014年11月8日
  • v1.1.1 | 修正了拼写错误 | 2014年11月10日
  • v1.1.2 | 更新了目录列表 | 2014年11月11日
  • v1.2.0 | 添加了“运行 JavaScript” | 2014年11月17日
© . All rights reserved.