二、使用 TypeScript 创建一个 Markdown 编辑器

在网上处理内容时,很难不遭遇降价。 Markdown 是一种使用简单文本创建内容的简化方法,可以很容易地转换为简单的 HTML。 在本章中,我们将研究如何创建一个解析器,将标记格式的子集转换为 HTML 内容。 我们将自动将相关标签转换为头三层、水平规则和段落。

在本章结束时,我们将学习如何创建一个简单的 Bootstrap 网页,如何引用从 TypeScript 生成的 JavaScript,以及如何连接到一个简单的事件处理程序。 我们还将介绍如何使用简单的设计模式创建类,以及如何设计具有单一职责的类,这些技术很好地服务于我们作为专业开发人员。

本章将涵盖以下主题:

  • 创建覆盖引导样式的引导页
  • 选择我们在降价时要使用的标签
  • 定义需求
  • 将我们的 markdown 标签类型映射到 HTML 标签类型
  • 将转换后的 markdown 存储在自定义类中
  • 使用访问者模式来更新我们的文档
  • 使用责任链模式应用标记
  • 把它连接回 HTML

技术要求

本章代码可从https://github.com/PacktPublishing/Advanced-TypeScript-3-Programming-Projects/tree/master/Chapter02下载。

了解项目概况

现在我们已经掌握的一些概念,我们将盖在这本书的其余部分,我们将开始把它们付诸实践,解析一个非常简单的减价格式创建一个项目在用户输入一个文本区域和显示生成的 web 页面在它旁边。 与完全 markdown 解析器不同,我们将专注于格式化前三种标题类型、水平规则和段落。 该标记仅限于用换行符将行拆分并查看行首。 然后,它确定是否存在特定的标记,如果没有,则假定当前行是一个段落。 我们选择这个实现的原因是,它是一个可以立即执行的简单任务。 虽然它很简单,但它提供了足够的深度,表明我们将要处理的主题需要我们真正考虑如何构建应用。

用户界面(UI),使用 Bootstrap,我们将看看如何连接到一个更改事件处理程序,以及如何从当前网页获取和更新 HTML 内容。 这是我们的项目完成后的样子:

现在我们有了概览,可以开始创建 HTML 项目了。

从一个简单的 HTML 项目开始

这个项目是一个简单的 HTML 和 TypeScript 文件的组合。 创建一个存放 HTML 和 TypeScript 文件的目录。 我们的 JavaScript 将驻留在该目录下的脚本文件夹中。 使用以下tsconfig.json文件:

{
  "compilerOptions": {
    "target": "ES2015", 
    "module": "commonjs", 
    "sourceMap": true, 
    "outDir": "./script", 
    "strict": true, 
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "esModuleInterop": true, 
    "experimentalDecorators": true,
  }
}

编写一个简单的标记解析器

当我在考虑本章要处理的项目时,我脑海中有一个明确的目标。 当我们编写这段代码时,我们将尝试一些东西,比如模式和良好的面向对象(OO)实践,比如类具有单一职责。 如果我们能从一开始就应用这些技术,我们很快就会养成使用它们的习惯,这将转化为有用的开发技能。

作为专业的开发人员,在我们编写任何代码之前,我们应该收集我们将要使用的需求,并确保我们没有对我们的应用将做什么做任何假设。 我们可能认为我们知道我们想要我们的应用,但如果我们列出我们的要求的,我们会确保我们了解一切旨在提供,我们会想出一个方便的特性惹火了我们完整的清单。

所以,以下是我的清单:

  • 我们将创建一个应用来解析 markdown
  • 用户将在文本区域中输入
  • 每当文本区域发生变化时,我们将再次解析整个文档
  • 我们将根据用户按输入键的位置来分解文档
  • 开头的字符将决定这一行是否被降价
  • 输入#后跟空格将被 H1 标题替换
  • 在空格后输入##将被 H2 标题替换
  • 在空格后输入###将被 H3 标题取代
  • 输入-被一条水平规则替换
  • 如果该行不是以 markdown 开头,该行将被视为段落
  • 生成的 HTML 将显示在一个标签中
  • 如果标记文本区域中的内容为空,则标签将包含空段落
  • 布局将在 Bootstrap 中完成,内容将拉伸到 100%的高度

有了这些要求,我们就知道我们要交付什么了,所以我们从创建 UI 开始。

构建我们的 Bootstrap UI

第 1 章高级 TypeScript 特性中,我们学习了使用 Bootstrap 创建 UI 的基础知识。 我们将采取相同的基本页面,并调整它,以适应我们的需要与一些小的调整。 我们的出发点是这个页面,它通过设置容器使用container-fluid来扩展整个屏幕的宽度,并通过在两侧设置col-lg-6将界面分成两个相等的部分:

<div class="container-fluid">
  <div class="row">
    <div class="col-lg-6">
    </div>
    <div class="col-lg-6">
    </div>
  </div>
</div>

当我们将文本区域和标签组件添加到表单中时,我们发现在这一行中呈现它们并不能自动扩展它们以填充屏幕的高度。 我们需要做一些调整。 首先,我们需要手动设置htmlbody标签的样式来填充可用空间。 为此,我们在头文件中添加以下内容:

<style>
  html, body { 
    height: 100%;
  }
</style>

有了这些,我们就可以利用 Bootstrap 4 中的一个新特性,它将h-100应用到这些类中,以填补 100%的空间。 我们还将利用这个机会添加文本区域和标签,以及给它们提供 id,我们可以从 TypeScript 代码中查找:

<div class="container-fluid h-100">
  <div class="row h-100">
    <div class="col-lg-6">
      <textarea class="form-control h-100" id="markdown"></textarea>
    </div>
    <div class="col-lg-6 h-100">
      <label class="h-100" id="markdown-output"></label>
    </div>
  </div>
</div>

在完成页面之前,我们将开始编写可以在应用中使用的 TypeScript 代码。 添加一个名为MarkdownParser.ts的文件来保存我们的 TypeScript 代码,并添加以下代码:

class HtmlHandler {
    public TextChangeHandler(id : string, output : string) : void {
        let markdown = <HTMLTextAreaElement>document.getElementById(id);
        let markdownOutput = <HTMLLabelElement>document.getElementById(output);
        if (markdown !== null) {
            markdown.onkeyup = (e) => {
                if (markdown.value) {
                    markdownOutput.innerHTML = markdown.value;
                }
                else 
                   markdownOutput.innerHTML = "<p></p>";
            }
        }
    }
}

我们创建了这个类,以便根据它们的 id 获得文本区域和标签。 一旦我们有了这些,我们将挂钩到文本区域,键上事件,并将 keypress 值写回标签。 注意,即使我们现在不在网页中,TypeScript 还是隐式地让我们访问标准的网页行为。 这允许我们根据前面输入的 id 检索文本区域和标签,并将它们转换为适当的类型。 这样,我们就可以执行订阅事件或访问元素的innerHTML等操作。

For the sake of simplicity, we are going to use the MarkdownParser.ts file for all of our TypeScript in this chapter. Normally, we would separate the classes into their own files, but this single-file structure should be simpler to review as we progress through the code. In future chapters, we will be moving away from a single file because those projects are much more complex.

一旦我们有了这些接口元素,我们就连接到 keyup 事件。 当事件被触发时,我们会查看文本区域中是否有文本,并使用内容(如果有)或空段落(如果没有)设置标签的 HTML。 我们编写这段代码的原因是我们想要使用它来确保我们正确地链接我们生成的 JavaScript 和网页。

We use the keyup event—rather than the keydown or keypress events—because the key is not added into the text area until the keypress event is completed.

我们现在可以重新访问我们的网页,添加缺失的位,这样当我们的文本区域改变时,我们可以更新我们的标签。 就在</body>标签之前,添加以下代码来引用 TypeScript 生成的 JavaScript 文件,以便创建一个HtmlHandler类的实例,并将markdownmarkdown-output元素挂在一起:

<script src="script/MarkdownParser.js">
</script>
<script>
  new HtmlHandler().TextChangeHandler("markdown", "markdown-output");
</script>

作为一个快速回顾,这是 HTML 文件在这一点上的样子:

<!doctype html>
<html lang="en">
 <head>
 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
 <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
 <style>
 html, body { 
 height: 100%; 
 }
 </style>
 <title>Advanced TypeScript - Chapter 2</title>
 </head>
 <body>
 <div class="container-fluid h-100">
 <div class="row h-100">
 <div class="col-lg-6">
 <textarea class="form-control h-100" id="markdown"></textarea>
 </div>
 <div class="col-lg-6 h-100">
 <label class="h-100" id="markdown-output"></label>
 </div>
 </div>
 </div>
 <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
 <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
 <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy" crossorigin="anonymous"></script>

 <script src="script/MarkdownParser.js">
 </script>
 <script>
 new HtmlHandler().TextChangeHandler("markdown", "markdown-output");
 </script>
 </body>
</html>

如果我们在此时运行应用,在文本区域中输入将自动更新标签。 下面的截图显示了我们的应用在运行时的样子:

现在我们知道我们可以自动更新我们的网页,我们不需要对它做更多的更改。 我们将要编写的所有代码都将在 TypeScript 文件中完成。 回到我们的需求列表,我们已经做了足够的工作来满足最后三个需求。

将我们的 markdown 标签类型映射到 HTML 标签类型

在我们的需求中,我们列出了解析器将要处理的标记的主列表。 为了识别这些标签,我们将添加一个枚举,由我们提供给用户的标签组成:

enum TagType {
    Paragraph,
    Header1,
    Header2,
    Header3,
    HorizontalRule
}

从我们的需求中,我们还知道我们需要在这些标签和它们等价的开始和结束 HTML 标签之间进行转换。 我们要做的是将tagType映射到一个等价的 HTML 标签。 为此,我们将创建一个类,它只负责为我们处理这个映射。 下面的代码显示了这一点:

class TagTypeToHtml {
    private readonly tagType : Map<TagType, string> = new Map<TagType, string>();
    constructor() {
        this.tagType.set(TagType.Header1, "h1");
        this.tagType.set(TagType.Header2, "h2");
        this.tagType.set(TagType.Header3, "h3");
        this.tagType.set(TagType.Paragraph, "p");
        this.tagType.set(TagType.HorizontalRule, "hr")
    }
}

At first, the use of readonly on a type can appear confusing. What this keyword means is that, after the class has been instantiated, tagType cannot be recreated elsewhere in the class. This means that we can set up our mappings in the constructor safe, knowing that we are not going to call this.tagType = new Map<TagType, string>(); later on.

我们还需要从这个类中检索开始和结束标记的方法。 我们将创建一个方法来获取tagType的开始标签,如下所示:

public OpeningTag(tagType : TagType) : string {
    let tag = this.tagType.get(tagType);
    if (tag !== null) {
        return `<${tag}>`;
    }
    return `<p>`;
}

这个方法非常简单。 它开始尝试从地图获得tagType。 对于我们目前拥有的代码,映射中总会有一个条目,但是我们可以在将来扩展枚举,而忘记将标记添加到标记列表中。 这就是我们检查标签是否存在的原因; 如果是,则返回<>中包含的标签。 如果标签不存在,则返回一个段落标签作为默认值。

现在,让我们看一下ClosingTag:

public ClosingTag(tagType : TagType) : string {
    let tag = this.tagType.get(tagType);
    if (tag !== null) {
        return `</${tag}>`;
    }
    return `</p>`;
}

看看这两种方法,我们可以看出它们几乎是相同的。 当我们考虑创建 HTML 标签的问题时,我们意识到开始标签和结束标签之间的唯一区别是结束标签中有一个/。 记住这一点,我们可以更改代码,使用一个 helper 方法,该方法接受标签以<</开头:

private GetTag(tagType : TagType, openingTagPattern : string) : string {
    let tag = this.tagType.get(tagType);
    if (tag !== null) {
        return `${openingTagPattern}${tag}>`;
    }
    return `${openingTagPattern}p>`;
}

剩下要做的就是添加方法来检索开始和结束标记:

public OpeningTag(tagType : TagType) : string {
    return this.GetTag(tagType, `<`);
}

public ClosingTag(tagType : TagType) : string {
    return this.GetTag(tagType, `</`);
}

把这些放到一起,我们的TagTypeToHtml类代码现在看起来像这样:

class TagTypeToHtml {
    private readonly tagType : Map<TagType, string> = new Map<TagType, string>();
    constructor() {
        this.tagType.set(TagType.Header1, "h1");
        this.tagType.set(TagType.Header2, "h2");
        this.tagType.set(TagType.Header3, "h3");
        this.tagType.set(TagType.Paragraph, "p");
        this.tagType.set(TagType.HorizontalRule, "hr")
    }

    public OpeningTag(tagType : TagType) : string {
        return this.GetTag(tagType, `<`);
    }

    public ClosingTag(tagType : TagType) : string {
        return this.GetTag(tagType, `</`);
    }

    private GetTag(tagType : TagType, openingTagPattern : string) : string {
        let tag = this.tagType.get(tagType);
        if (tag !== null) {
            return `${openingTagPattern}${tag}>`;
        }
        return `${openingTagPattern}p>`;
    }
}

The single responsibility of our TagTypeToHtml class is mapping tagType to an HTML tag. Something that we are going to keep coming back to throughout this chapter is that we want classes to have a single responsibility. In OO theory, this is known as one of the principles of SOLID (short for Single Responsibility PrincipleOpen/Closed PrincipleLiskov Substitution PrincipleInterface Segregation PrincipleDependency Inversion Principle) design. The acronym refers to a set of complementary development techniques to create more robust code. This handy acronym serves to guide us on how to structure classes and the most important part, in my opinion, is the Single Responsibility Principle, which states that a class should do one thing and one thing only. While I would certainly recommend reading about this topic (and we will touch on other aspects of it as we progress), in my opinion, the most important part of SOLID design is that classes are responsible for one thing and one thing only; everything else flows out of that principle. Classes that only do one thing are generally much easier to test and they are a lot easier to understand. That does not mean that they should only have one method. They can have many methods, as long as they are all related to the purpose of the class. We will cover this topic again and again throughout the book because it is so important.

使用 markdown 文档表示转换后的 markdown

在解析内容时,我们需要一种机制来实际存储解析过程中创建的文本。 我们可以只使用一个全局字符串并直接更新它,但如果我们决定以后异步添加它,就会出现问题。 不使用字符串的主要原因再次归结为单一责任原则。 如果我们使用一个简单的字符串,那么我们添加到文本中的每段代码最终都必须以正确的方式写入字符串,这意味着它们将把读取标记和写入 HTML 输出混在一起。 当我们这样讨论时,很明显我们需要一种单独的方法来写出 HTML 内容。

对于我们来说,这意味着我们希望代码能够接受大量的字符串来组成内容(这些字符串可能包括我们的 HTML 标签,所以我们不希望只接受单个字符串)。 我们还需要在完成文档构建后获取文档的方法。 我们将从定义一个接口开始,它将充当消费代码将实现的契约。 特别有趣的是,我们将允许我们的代码在我们的Add方法中接受任意数量的项,所以我们将在这里使用一个 REST 参数:

interface IMarkdownDocument {
    Add(...content : string[]) : void;
    Get() : string;
}

有了这个接口,我们可以像下面这样创建MarkdownDocument类:

class MarkdownDocument implements IMarkdownDocument {
    private content : string = "";
    Add(...content: string[]): void {
        content.forEach(element => {
            this.content += element;
        });
    } 
    Get(): string {
        return this.content;
    }
}

这个类非常简单。 对于传递给我们的Add方法的每一段内容,我们将其添加到名为content的成员变量中。 由于 this 被声明为 private,所以我们的Get方法返回相同的变量。 这就是为什么我喜欢有单一职责的类——在本例中,它们只是更新内容; 与做许多不同事情的复杂类相比,它们往往更清晰、更容易理解。 最主要的是,我们可以做任何我们喜欢的事情来保持我们的内容在内部更新,因为我们已经隐藏了我们如何从消费代码维护文档。

当我们要一次解析一行文档时,我们将使用一个类来表示正在处理的当前行:

class ParseElement {
    CurrentLine : string = "";
}

我们的课很简单。 同样,我们决定不使用简单的字符串来传递我们的代码库,因为这个类明确了我们的意图——我们想要解析当前行。 如果我们只是使用字符串来表示直线,那么当我们想要使用直线时,很容易传递错误的东西。

使用访客更新降价文档

第 1 章高级 TypeScript 特性中,我们简要介绍了模式。 简单地说,软件开发过程中的模式是特定问题的通用解决方案。 这仅仅意味着我们使用模式的名称向其他人传达我们正在使用特定的和完善的代码示例解决问题。 例如,如果我们告诉另一个开发人员我们正在使用中介模式解决问题,只要其他开发人员知道模式,他们就会非常清楚我们将如何构造代码。

当我在规划这段代码时,我很早就做出了一个有意识的决定,我们将在代码中使用一种叫做访问者模式的东西。 在我们看我们要创建的代码之前,我们要看看这个模式是什么以及为什么我们要使用它。

理解访问者模式

访客模式就是所谓的行为模式。 行为模式这个术语只是一组模式的分类,这些模式与类和对象通信的方式有关。 访问者模式提供给我们的是将算法与算法所处理的对象分离的能力。 这听起来比实际情况复杂得多。

我们使用访问者模式背后的动机之一是,我们想要使用常见的ParseElement类并对其应用不同的操作,这取决于底层的降价是什么,这最终导致我们构建MarkdownDocument类。 这里的想法是,如果用户输入的内容是我们将在 HTML 中表示为段落的内容,那么我们想要为使用的内容添加不同的标记,例如,当内容表示水平规则时。 访问者模式的约定是我们有两个接口,IVisitorIVisitable。 在最基本的情况下,这些接口看起来是这样的:

interface IVisitor {
    Visit(......);
}
interface IVisitable {
    Accept(IVisitor, .....);
}

这些接口背后的思想是,对象将是可访问的,因此当它需要执行相关操作时,它接受访问者,以便能够访问对象。

将访问者模式应用到我们的代码中

现在我们知道了访问者模式是什么,让我们看看如何将其应用到代码中:

  1. 首先,我们将创建如下的IVisitorIVisitable接口:
interface IVisitor {
    Visit(token : ParseElement, markdownDocument : IMarkdownDocument) : void;
}
interface IVisitable {
    Accept(visitor : IVisitor, token : ParseElement, markdownDocument : IMarkdownDocument) : void;
}
  1. 当我们的代码到达调用Visit的点时,我们将使用TagTypeToHtml类添加相关的开始 HTML 标签、文本行,然后将匹配的结束 HTML 标签添加到MarkdownDocument。 由于这对我们的每个标签类型都是常见的,我们可以实现一个基类来封装这种行为,如下所示:
abstract class VisitorBase implements IVisitor {
    constructor (private readonly tagType : TagType, private readonly TagTypeToHtml : TagTypeToHtml) {}
    Visit(token: ParseElement, markdownDocument: IMarkdownDocument): void {
        markdownDocument.Add(this.TagTypeToHtml.OpeningTag(this.tagType), token.CurrentLine, 
            this.TagTypeToHtml.ClosingTag(this.tagType));
    }
}
  1. 接下来,我们需要添加具体的访问器实现。 这就像创建以下类一样简单:
class Header1Visitor extends VisitorBase {
    constructor() {
        super(TagType.Header1, new TagTypeToHtml());
    }
}
class Header2Visitor extends VisitorBase {
    constructor() {
        super(TagType.Header2, new TagTypeToHtml());
    }
}
class Header3Visitor extends VisitorBase {
    constructor() {
        super(TagType.Header3, new TagTypeToHtml());
    }
}
class ParagraphVisitor extends VisitorBase {
    constructor() {
        super(TagType.Paragraph, new TagTypeToHtml());
    }
}
class HorizontalRuleVisitor extends VisitorBase {
    constructor() {
        super(TagType.HorizontalRule, new TagTypeToHtml());
    }
}

乍一看,这段代码可能有点过分,但它是有目的的。 例如,如果我们取Header1Visitor,我们有一个类,它只负责取当前行并将其添加到用 H1 标签包装的 markdown 文档中。 我们可以在代码中添加一些类,这些类负责检查行是否以#开头,然后在添加 H1 标记和当前行之前从开始处删除#。 然而,这使得代码更难测试,更容易出错,尤其是当我们想要改变行为时。 此外,我们添加的标记越多,代码就会变得越脆弱。

访问者模式代码的另一端是IVisitable实现。 对于我们目前的代码,我们知道我们想在调用Accept时访问相关的访问者。 这对我们的代码意味着我们可以有一个单独的可访问类来实现IVisitable接口。 如下代码所示:

class Visitable implements IVisitable {
    Accept(visitor: IVisitor, token: ParseElement, markdownDocument: IMarkdownDocument): void {
        visitor.Visit(token, markdownDocument);
    }
}

For this example, we have put the simplest visitor pattern implementation in place that we could. There are many variants of the visitor pattern, so we have gone with an implementation that respects the design philosophy of the pattern without slavishly sticking to it. That's the beauty of patterns—while they give us a guide as to how to do something, we should not feel that we have to blindly follow a particular implementation if modifying it slightly differently suits our needs.

通过使用责任链模式决定应用哪些标记

现在我们已经有了将简单的一行转换为 HTML 编码行的方法,我们需要一种方法来决定应该应用哪些标记。 从一开始,我就知道我们将应用另一种模式,这种模式非常适合问这样的问题:“我应该处理这个标签吗?” “如果没有,我会转发这个,这样其他东西就可以决定是否应该处理这个标签。

我们将使用另一种行为模式来处理这个问题——责任链模式。 这种模式允许我们通过创建一个接受链中下一个类的类,以及一个处理请求的方法,将一系列类链接在一起。 根据请求处理程序的内部逻辑,它可以将处理传递给链中的下一个类。

如果我们从基类开始,我们可以看到这个模式给了我们什么,以及我们将如何使用它:

abstract class Handler<T> {
    protected next : Handler<T> | null = null;
    public SetNext(next : Handler<T>) : void {
        this.next = next;
    }
    public HandleRequest(request : T) : void {
        if (!this.CanHandle(request)) {
            if (this.next !== null) {
                this.next.HandleRequest(request);
            }
            return;
        }
    }
    protected abstract CanHandle(request : T) : boolean;
}

我们链中的下一个类使用SetNext设置。 HandleRequest通过调用抽象CanHandle方法来查看当前类是否可以处理请求。 如果它不能处理请求,如果this.next不是null(注意这里使用的 union 类型),我们将请求转发到下一个类。 这将一直重复,直到我们可以处理请求或this.nextnull

现在我们可以添加Handler类的具体实现。 首先,我们将添加构造函数和成员变量,如下所示:

class ParseChainHandler extends Handler<ParseElement> {
    private readonly visitable : IVisitable = new Visitable();
    constructor(private readonly document : IMarkdownDocument, 
        private readonly tagType : string, 
        private readonly visitor : IVisitor) {
        super();
    }
}

构造函数接受 markdown 文档的实例; 表示我们的tagTypestring,例如#; ,如果我们得到匹配的标签,相关的访问者将访问该类。 在我们看到CanHandle的代码之前,我们需要稍微绕道一下,引入一个类来帮助我们解析当前行,并查看标签是否出现在开始处。

我们将创建一个纯粹用于解析字符串的类,并查看它是否以相关的 markdown 标签开始。 Parse方法的特别之处在于我们返回了一个叫做的元组。 我们可以将 tuple 看作是一个固定大小的数组,在数组的不同位置可以有不同的类型。 在本例中,我们将返回一个boolean类型和一个string类型。 boolean类型表示是否找到标签,string类型返回开头没有标签的文本; 例如,如果string# Hello,标签是#,我们想返回Hello。 检查标记的代码非常简单; 它只是查看文本是否以标记开始。 如果是,我们将元组的boolean部分设置为true,并使用substr来获取文本的其余部分。 考虑以下代码:

class LineParser {
    public Parse(value : string, tag : string) : [boolean, string] {
        let output : [boolean, string] = [false, ""];
        output[1] = value;
        if (value === "") {
            return output;
        }
        let split = value.startsWith(`${tag}`);
        if (split) {
            output[0] = true;
            output[1] = value.substr(tag.length);
        }
        return output;
    }
}

现在我们有了LineParser类,我们可以在CanHandle方法中应用它,如下所示:

protected CanHandle(request: ParseElement): boolean {
    let split = new LineParser().Parse(request.CurrentLine, this.tagType);
    if (split[0]){
        request.CurrentLine = split[1];
        this.visitable.Accept(this.visitor, request, this.document);
    }
    return split[0];
}

在这里,我们使用解析器构建一个元组,其中第一个参数表示标记是否存在,第二个参数包含没有标记的文本(如果标记存在)。 如果在字符串中存在 markdown 标记,则在Visitable实现中调用Accept方法。

Strictly speaking, we could have directly called this.visitor.Visit(request, this.document);, however, that provides us with more knowledge about how to perform the visit into this class than I would like. By using the Accept approach, if we make our visitors more complex, we avoid having to revisit this method as well.

这是我们的ParseChainHandler现在的样子:

class ParseChainHandler extends Handler<ParseElement> {
    private readonly visitable : IVisitable = new Visitable();
    protected CanHandle(request: ParseElement): boolean {
        let split = new LineParser().Parse(request.CurrentLine, this.tagType);
        if (split[0]){
            request.CurrentLine = split[1];
            this.visitable.Accept(this.visitor, request, this.document);
        }
        return split[0];
    }
    constructor(private readonly document : IMarkdownDocument, 
        private readonly tagType : string, 
        private readonly visitor : IVisitor) {
        super();
    }
}

我们有个特殊的案子需要处理。 我们知道段落没有与之相关联的标签——如果链的其余部分没有匹配,默认情况下,它是一个段落。 这意味着我们需要一个稍微不同的处理程序来处理段落,如下所示:

class ParagraphHandler extends Handler<ParseElement> {
    private readonly visitable : IVisitable = new Visitable();
    private readonly visitor : IVisitor = new ParagraphVisitor()
    protected CanHandle(request: ParseElement): boolean {
        this.visitable.Accept(this.visitor, request, this.document);
        return true;
    }
    constructor(private readonly document : IMarkdownDocument) {
        super();
    }
}

有了这个基础设施,我们现在可以为适当的标记创建具体的处理程序了,如下所示:

class Header1ChainHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "# ", new Header1Visitor());
    }
}

class Header2ChainHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "## ", new Header2Visitor());
    }
}

class Header3ChainHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "### ", new Header3Visitor());
    }
}

class HorizontalRuleHandler extends ParseChainHandler {
    constructor(document : IMarkdownDocument) {
        super(document, "---", new HorizontalRuleVisitor());
    }
}

我们现在有了一条从标签(例如,---)到适当访问者的路由。 现在,我们已经将责任链模式与访问者模式联系起来。 我们还有最后一件事要做:建立链条。 为了做到这一点,让我们使用一个单独的类来构建我们的链:

class ChainOfResponsibilityFactory {
    Build(document : IMarkdownDocument) : ParseChainHandler {
        let header1 : Header1ChainHandler = new Header1ChainHandler(document);
        let header2 : Header2ChainHandler = new Header2ChainHandler(document);
        let header3 : Header3ChainHandler = new Header3ChainHandler(document);
        let horizontalRule : HorizontalRuleHandler = new HorizontalRuleHandler(document);
        let paragraph : ParagraphHandler = new ParagraphHandler(document);

        header1.SetNext(header2);
        header2.SetNext(header3);
        header3.SetNext(horizontalRule);
        horizontalRule.SetNext(paragraph);

        return header1;
    }
}

这个看起来简单的方法为我们完成了很多。 前几个语句为我们初始化责任链处理程序; 首先是标题,然后是水平规则,最后是段落处理程序。 记住,这只是我们在这里需要做的一部分,然后我们遍历标题和水平规则,并设置链中的下一项。 头 1 将把对头 2 的调用转发给头 2,头 2 转发给头 3,以此类推。 我们没有在段落处理程序之后进一步设置任何链接项的原因是,这是我们希望处理的最后一种情况。 如果用户没有输入header1header2header3horizontalRule,那么我们将把它作为一个段落。

把它们放在一起

我们将要编写的最后一个类用于接收用户输入的文本,并将其分成单独的行,并创建我们的ParseElement、责任链处理程序和MarkdownDocument实例。 然后将每一行转发到Header1ChainHandler,开始对该行的处理。 最后,我们从文档中获取文本并返回它,以便在标签中显示它:

class Markdown {
    public ToHtml(text : string) : string {
        let document : IMarkdownDocument = new MarkdownDocument();
        let header1 : Header1ChainHandler = new ChainOfResponsibilityFactory().Build(document);
        let lines : string[] = text.split(`\n`);
        for (let index = 0; index < lines.length; index++) {
            let parseElement : ParseElement = new ParseElement();
            parseElement.CurrentLine = lines[index];
            header1.HandleRequest(parseElement);
        }
        return document.Get();
    }
}

现在我们可以生成 HTML 内容了,还有一个更改要做。 我们将重新访问HtmlHandler方法并更改它,以便它调用我们的ToHtmlmarkdown 方法。 与此同时,我们将解决原始实现中的一个问题,即在按下一个键之前刷新页面会丢失内容。 为了处理这个问题,我们将添加一个window.onload事件处理程序:

class HtmlHandler {
 private markdownChange : Markdown = new Markdown;
    public TextChangeHandler(id : string, output : string) : void {
        let markdown = <HTMLTextAreaElement>document.getElementById(id);
        let markdownOutput = <HTMLLabelElement>document.getElementById(output);

        if (markdown !== null) {
            markdown.onkeyup = (e) => {
                this.RenderHtmlContent(markdown, markdownOutput);
            }
            window.onload = (e) => {
                this.RenderHtmlContent(markdown, markdownOutput);
            }
        }
    }

    private RenderHtmlContent(markdown: HTMLTextAreaElement, markdownOutput: HTMLLabelElement) {
        if (markdown.value) {
            markdownOutput.innerHTML = this.markdownChange.ToHtml(markdown.value);
        }
        else
            markdownOutput.innerHTML = "<p></p>";
    }
}

现在,当我们运行应用时,即使在刷新页面时,它也会显示呈现的 HTML 内容。 我们已经成功地创建了一个简单的 markdown 编辑器,它满足了我们在需求收集阶段列出的要点。

需求收集阶段是多么重要,我再怎么强调都不为过。 通常,糟糕的需求导致我们不得不对应用的行为做出假设。 这些假设可能导致交付用户不想要的应用。 如果你发现自己做了一个假设,回头问问你的用户他们到底想要什么。 当我们在这里构建代码时,我们回顾了我们的需求,以确保我们构建的正是我们想要构建的。

A final point about requirements—they change. It is common for requirements to evolve or get removed while we are writing an application. When they do change, we make sure that the requirements are updated, that we are making no assumptions, and that we check the work that has already been produced to make sure that it matches the updated requirements. This is what we do because we are professionals.

总结

在本章中,我们构建了一个应用,它可以响应用户在文本区域中输入的内容,并使用转换后的文本更新标签。 这个文本的转换由类处理,每个类都有一个单独的职责。 我们之所以集中在生产类,只做一件事是学习,从一开始,如何使用业界最佳实践让我们的代码更干净、更不容易受到错误,因为一个精心设计的类,只做一件事不太可能比类问题做很多不同的事情。

我们介绍了访问者和责任链模式,以便了解如何将文本处理划分为决定一行是否包含标记和添加适当的 html 编码文本。 我们开始引入模式是因为模式出现在许多不同的软件开发问题中。 他们不仅为如何解决问题提供了清晰的细节; 它们还提供了一种清晰的语言,因此,如果有人说一段代码需要一个特定的模式,其他开发人员就不会对该代码需要做什么产生歧义。

在下一章中,我们将介绍我们的第一个使用 React.js 的应用,用于构建个人联系人管理器。

问题

  1. 应用目前只对用户使用键盘更改内容作出反应。 用户可以使用上下文菜单将文本粘贴进去。 增强HtmlHandler方法,以处理用户粘贴文本。
  2. 我们将 H1 添加到 H3 支持。 HTML 也支持 H4, H5 和 H6。 添加对这些标记的支持。
  3. CanHandle代码中,我们称为Visitable代码。 更改基类Handler,以便它调用Accept方法。

进一步的阅读

关于使用设计模式的更多信息,我推荐 Vilic Vane 的书TypeScript 设计模式(https://www.packtpub.com/application-development/typescript-design-patterns),由 pack 出版。