关于前端:探索类型系统的底层-自己实现一个-TypeScript

4次阅读

共计 11206 个字符,预计需要花费 29 分钟才能阅读完成。

这篇文章蕴含两个局部:

A 局部:类型零碎编译器概述(包含 TypeScript)

  • 语法 vs 语义
  • 什么是 AST?
  • 编译器的类型
  • 语言编译器是做什么的?
  • 语言编译器是如何工作的?
  • 类型零碎编译器职责
  • 高级类型查看器的性能

B 局部:构建咱们本人的类型零碎编译器

  • 解析器
  • 查看器
  • 运行咱们的编译器
  • 咱们脱漏了什么?

A 局部:类型零碎编译器概述

语法 vs 语义

语法和语义之间的区别对于晚期的运行很重要。

语法 – Syntax

语法通常是指 JavaScript 本机代码。实质上是询问给定的 JavaScript 代码在运行时是否正确。

例如,上面的语法是正确的:

var foo: number = "not a number";

语义 – Semantics

这是特定于 类型零碎 的代码。实质上是询问附加到代码中的 给定类型 是否正确。

例如,下面的代码在语法上是正确的,但在语义上是谬误的(将变量定义为一个数字类型,然而值是一个字符串)。

接下来是 JavaScript 生态系统中的 AST 和编译器。

什么是 AST?

在进一步探讨之前,咱们须要疾速理解一下 JavaScript 编译器中的一个重要机制 AST。

对于 AST 具体介绍请看这篇文章。

AST 的意思是 形象语法树 ,它是一个示意程序代码的 节点 树。Node 是最小单元,基本上是一个具备 typelocation 属性的 POJO(即一般 JavaScript 对象)。所有节点都有这两个属性,但依据 类型,它们也能够具备其余各种属性。

在 AST 格局中,代码非常容易操作,因而能够执行增加、删除甚至替换等操作。

例如上面这段代码:

function add(number) {return number + 1;}

将解析成以下 AST:

编译器类型

在 JavaScript 生态系统中有两种次要的编译器类型:

1. 原生编译器(Native compiler)

原生编译器将代码转换为可由服务器或计算机运行的代码格局(即机器代码)。相似于 Java 生态系统中的编译器 – 将代码转换为字节码,而后将字节码转换为本机代码。

2. 语言编译器

语言编译器扮演着不同的角色。TypeScript 和 Flow 的编译器在将代码输入到 JavaScript 时都算作语言编译器。

语言编译器与原生编译器的次要区别在于,前者的编译目标是 tooling-sake(例如优化代码性能或增加附加性能),而不是为了生成机器代码。

语言编译器是做什么的?

在类型零碎编译器中,总结的两个最根本的外围职责是:

1. 执行类型查看

引入 类型(通常是通过显式注解或隐式推理),以及查看一种类型是否匹配另一种类型的办法,例如 stringnumber

2. 运行语言服务器

对于一个在开发环境中工作的类型零碎(type system)来说,最好能在 IDE 中运行任何类型查看,并为用户提供即时反馈。

语言服务器将类型零碎连贯到 IDE,它们能够在后盾运行编译器,并在用户保留文件时从新运行。风行的语言,如 TypeScript 和 Flow 都蕴含一个语言服务器。

3. 代码转换

许多类型零碎蕴含原生 JavaScript 不反对的代码(例如不反对类型注解),因而它们必须将不受反对的 JavaScript 转换为受反对的 JavaScript 代码。

对于代码转换更具体的介绍,能够参考原作者的这两篇文章 Web Bundler 和 Source Maps。

语言编译器是如何工作的?

对于大多数编译器来说,在某种模式上有三个独特的阶段。

1. 将源代码解析为 AST

  • 词法剖析 -> 将代码字符串转换为令牌流(即数组)
  • 语法分析 -> 将令牌流转换为 AST 示意模式

解析器查看给定代码的语法。类型零碎必须有本人的解析器,通常蕴含数千行代码。

Babel 解析器 中的 2200+ 行代码,仅用于解决 statement 语句(请参阅此处)。

Hegel 解析器将 typeAnnotation 属性设置为具备类型注解的代码(能够在这里看到)。

TypeScript 的解析器领有 8900+ 行代码(这里是它开始遍历树的中央)。它蕴含了一个残缺的 JavaScript 超集,所有这些都须要解析器来了解。

2. 在 AST 上转换节点

  • 操作 AST 节点

这里将执行利用于 AST 的任何转换。

3. 生成源代码

  • 将 AST 转换为 JavaScript 源代码字符串

类型零碎必须将任何非 js 兼容的 AST 映射回原生 JavaScript。

类型零碎如何解决这种状况呢?

类型零碎编译器(compiler)职责

除了上述步骤之外,类型零碎编译器通常还会在 解析 之后包含一个或两个额定步骤,其中包含特定于类型的工作。

顺便说一下,TypeScript 的编译器实际上有 5 个阶段,它们是:

  1. 语言服务预处理器 – Language server pre-processor
  2. 解析器 – Parser
  3. 联合器 – Binder
  4. 查看器 – Checker
  5. 发射器 – Emitter

正如下面看到的,语言服务器蕴含一个预处理器,它触发类型编译器只在已更改的文件上运行。这会监听任意的 import 语句,来确定还有哪些内容可能产生了更改,并且须要在下次从新运行时携带这些内容。

此外,编译器只能重新处理 AST 构造中已更改的分支。对于更多 lazy compilation,请参阅下文。

类型零碎编译器有两个常见的职责:

1. 推导 – Inferring

对于没有注解的代码须要进行推断。对于这点,这里举荐一篇对于何时应用类型注解和何时让引擎应用推断的文章。

应用预约义的算法,引擎将计算给定变量或者函数的类型。

TypeScript 在其 Binding 阶段(两次语义传递中的第一次)中应用 最佳公共类型 算法。它思考每个候选类型并抉择与所有其余候选类型兼容的类型。上下文类型在这里起作用,也会做为最佳通用类型的候选类型。在这里的 TypeScript 标准中有更多的帮忙。

let zoo: Animal[] = [new Rhino(), new Elephant(), new Snake()];

TypeScript 实际上引入了 Symbols(interface)的概念,这些命名申明将 AST 中的申明节点与其余申明进行连贯,从而造成雷同的实体。它们是 TypeScript 语义零碎的根本形成。

2. 查看 – Checking

当初类型推断曾经实现,类型曾经调配,引擎能够运行它的类型查看。他们查看给定代码的 semantics。这些类型的查看有很多种,从类型谬误匹配到类型不存在。

对于 TypeScript 来说,这是 Checker (第二个语义传递),它有 20000+ 行代码。

我感觉这给出了一个十分弱小的 idea,即在如此多的不同场景中查看如此多的不同类型是如许的简单和艰难。

类型查看器不依赖于调用代码,即如果一个文件中的任何代码被执行(例如,在运行时)。类型查看器将解决给定文件中的每一行,并运行适当的查看。

高级类型查看器性能

因为这些概念的复杂性,咱们明天不深入探讨以下几个概念:

懒编译 – Lazy compilation

古代编译的一个独特特色是 提早加载。他们不会从新计算或从新编译文件或 AST 分支,除非相对须要。

TypeScript 预处理程序能够应用缓存在内存中的前一次运行的 AST 代码。这将大大提高性能,因为它只须要关注程序或节点树的一小部分已更改的内容。

TypeScript 应用不可变的只读数据结构,这些数据结构存储在它所称的 look aside tables 中。这样很容易晓得什么曾经扭转,什么没有扭转。

稳健性

在编译时,有些操作编译器不确定是平安的,必须期待运行时。每个编译器都必须做出艰难的抉择,以确定哪些内容将被蕴含,哪些不会被蕴含。TypeScript 有一些被称为 不健全 的区域(即须要运行时类型查看)。

咱们不会在编译器中探讨上述个性,因为它们减少了额定的复杂性,对于咱们的小 POC 来说不值得。

当初令人兴奋的是,咱们本人也要实现一个编译器。

B 局部:构建咱们本人的类型零碎编译器

咱们将构建一个编译器,它能够对三个不同的场景运行类型查看,并为每个场景抛出特定的信息。

咱们将其限度在三个场景中的起因是,咱们能够关注每一个场景中的具体机制,并心愿到最初可能对如何引入更简单的类型查看有一个更好的构思。

咱们将在编译器中应用函数申明和表达式(调用该函数)。

这些场景包含:

1. 字符串与数字的类型匹配问题

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

2. 应用未定义的未知类型

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

3. 应用代码中未定义的属性名

interface Person {name: string;}
fn({nam: "craig"}); // throw with "nam" vs "name"
function fn(a: Person) {}

实现咱们的编译器,须要两局部:解析器 查看器

解析器 – Parser

后面提到,咱们明天不会关注解析器。咱们将遵循 Hegel 的解析办法,假如一个 typeAnnotation 对象曾经附加到所有带注解的 AST 节点中。我曾经硬编码了 AST 对象。

场景 1 将应用以下解析器:

字符串与数字的类型匹配问题

function parser(code) {// fn("craig-string");
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn"
      },
      arguments: [
        {
          type: "StringLiteral", // Parser "Inference" for type.
          value: "craig-string"
        }
      ]
    }
  };

  // function fn(a: number) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn"
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        // 参数标识
        typeAnnotation: {
          // our only type annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 数字类型
            type: "NumberTypeAnnotation"
          }
        }
      }
    ],
    body: {
      type: "BlockStatement",
      body: [] // "body" === block/line of code. Ours is empty}
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [expressionAst, declarationAst]
    }
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

能够看到场景 1 中,第一行 fn("craig-string") 语句的 AST 对应 expressionAst,第二行申明函数的 AST 对应 declarationAst。最初返回一个 programmast,它是一个蕴含两个 AST 块的程序。

在 AST 中,您能够看到参数标识符 a 上的 typeAnnotation,与它在代码中的地位相匹配。

场景 2 将应用以下解析器:

应用未定义的未知类型

function parser(code) {// fn("craig-string");
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn"
      },
      arguments: [
        {
          type: "StringLiteral", // Parser "Inference" for type.
          value: "craig-string"
        }
      ]
    }
  };

  // function fn(a: made_up_type) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn"
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        typeAnnotation: {
          // our only type annotation
          type: "TypeAnnotation",
          typeAnnotation: {
            // 参数类型不同于场景 1
            type: "made_up_type" // BREAKS
          }
        }
      }
    ],
    body: {
      type: "BlockStatement",
      body: [] // "body" === block/line of code. Ours is empty}
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [expressionAst, declarationAst]
    }
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

场景 2 的解析器的表达式、申明和程序 AST 块十分相似于场景 1。然而,区别在于 params 外部的 typeAnnotationmade_up_type,而不是场景 1 中的 NumberTypeAnnotation

typeAnnotation: {type: "made_up_type" // BREAKS}

场景 3 应用以下解析器:

应用代码中未定义的属性名

function parser(code) {
  // interface Person {
  //   name: string;
  // }
  const interfaceAst = {
    type: "InterfaceDeclaration",
    id: {
      type: "Identifier",
      name: "Person",
    },
    body: {
      type: "ObjectTypeAnnotation",
      properties: [
        {
          type: "ObjectTypeProperty",
          key: {
            type: "Identifier",
            name: "name",
          },
          kind: "init",
          method: false,
          value: {type: "StringTypeAnnotation",},
        },
      ],
    },
  };

  // fn({nam: "craig"});
  const expressionAst = {
    type: "ExpressionStatement",
    expression: {
      type: "CallExpression",
      callee: {
        type: "Identifier",
        name: "fn",
      },
      arguments: [
        {
          type: "ObjectExpression",
          properties: [
            {
              type: "ObjectProperty",
              method: false,
              key: {
                type: "Identifier",
                name: "nam",
              },
              value: {
                type: "StringLiteral",
                value: "craig",
              },
            },
          ],
        },
      ],
    },
  };

  // function fn(a: Person) {}
  const declarationAst = {
    type: "FunctionDeclaration",
    id: {
      type: "Identifier",
      name: "fn",
    },
    params: [
      {
        type: "Identifier",
        name: "a",
        // 
        typeAnnotation: {
          type: "TypeAnnotation",
          typeAnnotation: {
            type: "GenericTypeAnnotation",
            id: {
              type: "Identifier",
              name: "Person",
            },
          },
        },
      },
    ],
    body: {
      type: "BlockStatement",
      body: [], // Empty function},
  };

  const programAst = {
    type: "File",
    program: {
      type: "Program",
      body: [interfaceAst, expressionAst, declarationAst],
    },
  };
  // normal AST except with typeAnnotations on
  return programAst;
}

除了表达式、申明和程序 AST 块之外,还有一个 interfaceAst 块,它负责保留 InterfaceDeclaration AST。

declarationAst 块的 typeAnnotation 节点上有一个 GenericType,因为它承受一个对象标识符,即 Person。在这个场景中,programAst 将返回这三个对象的数组。

解析器的相似性

从下面能够得悉,这三种有共同点,3 个场景中保留所有的类型注解的次要区域是 declaration

查看器

当初来看编译器的类型查看局部。

它须要遍历所有程序主体的 AST 对象,并依据节点类型进行适当的类型查看。咱们将把所有谬误增加到一个数组中,并返回给调用者以便打印。

在咱们进一步探讨之前,对于每种类型,咱们将应用的根本逻辑是:

  • 函数申明:查看参数的类型是否无效,而后查看函数体中的每个语句。
  • 表达式:找到被调用的函数申明,获取申明上的参数类型,而后获取函数调用表达式传入的参数类型,并进行比拟。

代码

以下代码中蕴含 typeChecks 对象(和 errors 数组),它将用于表达式检查和根本的注解(annotation)查看。

const errors = [];

// 注解类型
const ANNOTATED_TYPES = {
  NumberTypeAnnotation: "number",
  GenericTypeAnnotation: true
};

// 类型查看的逻辑
const typeChecks = {
  // 比拟形参和实参的类型
  expression: (declarationFullType, callerFullArg) => {switch (declarationFullType.typeAnnotation.type) {
      // 注解为 number 类型
      case "NumberTypeAnnotation":
        // 如果调用时传入的是数字,返回 true
        return callerFullArg.type === "NumericLiteral";
      // 注解为通用类型
      case "GenericTypeAnnotation": // non-native
        // 如果是对象,查看对象的属性
        if (callerFullArg.type === "ObjectExpression") {
          // 获取接口节点
          const interfaceNode = ast.program.body.find(node => node.type === "InterfaceDeclaration");
          const properties = interfaceNode.body.properties;

          // 遍历查看调用时的每个属性
          properties.map((prop, index) => {
            const name = prop.key.name;
            const associatedName = callerFullArg.properties[index].key.name;
            // 没有匹配,将错误信息存入 errors
            if (name !== associatedName) {
              errors.push(`Property "${associatedName}" does not exist on interface "${interfaceNode.id.name}". Did you mean Property "${name}"?`
              );
            }
          });
        }
        return true; // as already logged
    }
  },
  annotationCheck: arg => {return !!ANNOTATED_TYPES[arg];
  }
};

让咱们来看一下代码,咱们的 expression 有两种类型的查看:

  • 对于 NumberTypeAnnotation; 调用时类型应为 AnumericTeral(即,如果注解为数字,则调用时类型应为数字)。场景 1 将在此处失败,但未记录任何错误信息。
  • 对于 GenericTypeAnnotation; 如果是一个对象,咱们将在 AST 中查找 InterfaceDeclaration 节点,而后查看该接口上调用者的每个属性。之后将所有错误信息都会被存到 errors 数组中,场景 3 将在这里失败并失去这个谬误。

咱们的解决仅限于这个文件中,大多数类型查看器都有 作用域 的概念,因而它们可能确定申明在运行时的精确地位。咱们的工作更简略,因为它只是一个 POC

以下代码蕴含程序体中每个节点类型的解决。这就是下面调用类型查看逻辑的中央。

// Process program
ast.program.body.map(stnmt => {switch (stnmt.type) {
    case "FunctionDeclaration":
      stnmt.params.map(arg => {
        // Does arg has a type annotation?
        if (arg.typeAnnotation) {
          const argType = arg.typeAnnotation.typeAnnotation.type;
          // Is type annotation valid
          const isValid = typeChecks.annotationCheck(argType);
          if (!isValid) {
            errors.push(`Type "${argType}" for argument "${arg.name}" does not exist`
            );
          }
        }
      });

      // Process function "block" code here
      stnmt.body.body.map(line => {// Ours has none});

      return;
    case "ExpressionStatement":
      const functionCalled = stnmt.expression.callee.name;
      const declationForName = ast.program.body.find(
        node =>
          node.type === "FunctionDeclaration" &&
          node.id.name === functionCalled
      );

      // Get declaration
      if (!declationForName) {errors.push(`Function "${functionCalled}" does not exist`);
        return;
      }

      // Array of arg-to-type. e.g. 0 = NumberTypeAnnotation
      const argTypeMap = declationForName.params.map(param => {if (param.typeAnnotation) {return param.typeAnnotation;}
      });

      // Check exp caller "arg type" with declaration "arg type"
      stnmt.expression.arguments.map((arg, index) => {const declarationType = argTypeMap[index].typeAnnotation.type;
        const callerType = arg.type;
        const callerValue = arg.value;

        // Declaration annotation more important here
        const isValid = typeChecks.expression(argTypeMap[index], // declaration details
          arg // caller details
        );

        if (!isValid) {const annotatedType = ANNOTATED_TYPES[declarationType];
          // Show values to user, more explanatory than types
          errors.push(`Type "${callerValue}" is incompatible with "${annotatedType}"`
          );
        }
      });

      return;
  }
});

让咱们再次遍历代码,按类型对其进行合成。

FunctionDeclaration (即 function hello(){})

首先解决 arguments/params。如果找到类型注解,就查看给定参数的类型 argType 是否存在。如果不进行错误处理,场景 2 会在这里报谬误。

之后处理函数体,然而咱们晓得没有函数体须要解决,所以我把它留空了。

stnmt.body.body.map(line => {// Ours has none});

ExpressionStatement (即 hello())

首先检查程序中函数的申明。这就是作用域将利用于理论类型查看器的中央。如果找不到申明,就将错误信息增加到 errors 数组中。

接下来,咱们针对调用时传入的参数类型(实参类型)查看每个已定义的参数类型。如果发现类型不匹配,则向 errors 数组中增加一个谬误。场景 1 和场景 2 在这里都会报错。

运行咱们的编译器

源码寄存在这里,该文件一次性解决所有三个 AST 节点对象并记录谬误。

运行它时,我失去以下信息:

总而言之:

场景 1:

fn("craig-string"); // throw with string vs number
function fn(a: number) {}

咱们定义参数为 number 的类型,而后用字符串调用它。

场景 2:

fn("craig-string"); // throw with string vs ?
function fn(a: made_up_type) {} // throw with bad type

咱们在函数参数上定义了一个不存在的类型,而后调用咱们的函数,所以咱们失去了两个谬误(一个是定义的谬误类型,另一个是类型不匹配的谬误)。

场景 3:

interface Person {name: string;}
fn({nam: "craig"}); // throw with "nam" vs "name"
function fn(a: Person) {}

咱们定义了一个接口,然而应用了一个名为 nam 的属性,这个属性不在对象上,谬误提醒咱们是否要应用 name

咱们脱漏了什么?

如前所述,类型编译器还有许多其余局部,咱们在编译器中省略了这些局部。其中包含:

  • 解析器:咱们是手动编写的 AST 代码,它们实际上是在类型的编译器上解析生成。
  • 预处理 / 语言编译器: 一个真正的编译器具备插入 IDE 并在适当的时候从新运行的机制。
  • 懒编译:没有对于更改或内存应用的信息。
  • 转换:咱们跳过了编译器的最初一部分,也就是生成本机 JavaScript 代码的中央。
  • 作用域:因为咱们的 POC 是一个繁多的文件,它不须要了解 作用域 的概念,然而真正的编译器必须始终晓得上下文。

十分感谢您的浏览和观看,我从这项钻研中理解了大量对于类型零碎的常识,心愿对您有所帮忙。以上残缺代码您能够在这里找到。(给原作者 start)

备注:

原作者在源码中应用的 Node 模块形式为 ESM(ES Module),在将源码克隆到本地后,如果运行不胜利,须要批改 start 指令,增加启动参数 --experimental-modules

"start": "node --experimental-modules src/index.mjs",

原文:https://indepth.dev/under-the…

正文完
 0