在浏览器中编译和运行 TypeScript


本着 前端不好搞的就给后端 的办法,如果要在浏览器上编译和运行 TypeScript,最简单的方式就是起一个服务,在服务端运行 TypeScript 代码的解析和编译,然后返回结果给到浏览器来运行。

但既然都是这个标题了,我们还是纯粹一些,就是要探索一下纯浏览器运行的方法,纯浏览器运行中遇到的问题,你用服务端来处理,基本也是会遇到。详情请继续往下看。

简单的实现

TypeScript 有提供 Compiler API 可以使用, 如果只是要编译和运行 TypeScript 代码,实现也是比较简单的,我们将 TypeScript 编译器作为一个模块引入到我们的前端项目中,然后调用 TypeScript 提供的 Compiler API 来编译代码,然后使用 eval 来执行即可。

下边是一个实现运行的大概代码例子:

import * as ts from "typescript"; // 这个模块非常大,考虑页面初始化性能的可以用动态加载

const result = ts.transpilemodule(code, {
  compilerOptions: {
    module: ts.ModuleKind.UMD,
  },
});

console.log(result);
eval(result.outputText);

code 可以是你预设的一段代码,或者做一个简单的界面,提供一个 <textarea> 来输入代码,提供一个 <button> 来点击运行。

这样简单的实现可以运行 TypeScript,但实际应用会有很多问题:

  • 有多个代码模块需要编译执行,且他们之间有依赖关系
  • 无法了解代码编译过程中的问题,代码异常很难排查
  • 代码编辑过程没有想过的补全和提示

为了解决这些问题,我们需要先了解 TypeScript Compiler 的一些概念,明白它是如何协同多个模块来完成我们日常的 TypeScript 编译工作的。

TypeScript Compiler 的概念

TypeScript Compiler 官方相关文档资料,我能翻到这些内容:

Compiler Notes 的仓库 Readme 中有个视频链接,有兴趣可以详细看看

从这些资料中简单整理一下 TypeScript Compiler 相关的概念,以便我们可以继续解决前边提到的问题。

首先呈现的是 Compiler Notes 仓库里提供的架构层级图:

TypeScript Compiler Layer

简单解释一下图中各个层级的内容:

  1. 最上层的是对外提供可用的接口,可以是在 Node 中通过 API 调用,也可以是构建中最常用到的 tsc 命令行工具,还有一种场景是提供服务给到 IDE 用于代码检查(通过 Language Service 实现)
  2. 中间的 TypeScript Program 是一个代理入口,将要处理的内容分发给到不同的子系统,我们后边会用到的 ts.createProgram API 即是创建一个这样的入口。
  3. 然后再往下分别是实现文件内容转语法树,类型检查,文件输出的各个模块
  4. 最底层则是上层依赖的,更加具体的一些模块实现,例如 Scanner 会将文本转为语法 tokens,Binder 会创建和对应代码声明关联的标记,Transformer 可以在输出前进一步修改语法树

另外的是 TypeScript Compiler 处理编译时的线性架构图:

TypeScript Compiler Linear Architecture

这里简单过一遍流程,更加详细的内容(包括一些可视化工具的使用)可以在 TypeScript Compiler Note 里的 intro 文档中查看。

  1. 从一个文件创建代码处理入口,输入有文件信息(路径等)以及文件的文本内容
  2. 从输入的文件中解析出依赖的文件,形成依赖图
  3. 从文件文本内容中构建语法树
  4. 从语法数结构中创建对应的代码声明关联的标记
  5. 使用 3 和 4 的语法树和关联标记,检查类型指派
  6. 修改语法树内容来匹配构建的配置
  7. 输出构建后的文件内容,包括 js 代码和 .d.ts 类型文件

大致了解了 TypeScript Compiler 的实现架构和编译流程,我们就可以着手解决前边提及的一系列问题了。

支持多文件

仔细的读者可能已经发现了前边的文档链接中有一个 TypeScript VFS,VFS 是一个基于 Map 实现的虚拟文件系统,VFS 官方文档也说明了,如果你需要在浏览器中或者非真实文件系统中运行 TypeScript,你便需要 VFS。

这样的 VFS 是为了编译处理时将多个文件模块关联起来,他们除了代码内容外,还有一个重要标识就是文件路径,也就是我们写代码时 import from ... 用到的文件标识。

VFS 没有很具体的文档,我们参考官方 Repo 的 README.md 来摸索着使用。

文件系统

基于 VFS,我们可以创建一个 Map 来简单模拟一个文件系统,key 是路径,value 是文件内容。

由于 TS 本身需要蛮多的类型库作为基础,所以创建 Map 之后,需要内置一些库文件,手动搞的话比较麻烦,因为需要的是文件内容本身,如果用 Vite 的话应该可以用 ?raw 来引入,然后放到 Map 中,也可以使用 VFS 提供的 createDefaultMapFromCDN 从 CDN 拉取默认需要的库文件来创建基础 Map

import ts from "typescript";
import { createSystem } from "@typescript/vfs";

// 需要的不止这一个,还有很多其他的,根据文档或者异常一个个添加
import lib2015 from "typescript/lib/lib.es2015.d.ts?raw";

const fsMap = new Map<string, string>();
fsMap.set("lib.es2015.d.ts", lib2015);

// 这是你自己的代码
fsMap.set("index.ts", 'console.log("hello world");');

// 使用 CDN 这个方法更加简单
// const fsMap = await createDefaultMapFromCDN(
//   { target: ts.ScriptTarget.ES5 },
//   ts.version,
//   true,
//   ts
// );

// 使用 VFS API 创建一个虚拟文件系统
const system = createSystem(fsMap);

编译服务

基于上述创建的虚拟文件系统,可以进一步搭建编译相关的服务,VFS 提供了 createVirtualTypeScriptEnvironment 以及 createVirtualCompilerHost 可以使用,区别大概就是 TypeScriptEnvironment 可以提供代码编辑过程中的周边能力,大致就是前边架构图那里的 Language Service 的能力,而 CompilerHost 更偏向于一次性编译构建的,可以简单地拿到编译结果。

如果是考虑实现一个代码编辑器的功能,选择使用 createVirtualTypeScriptEnvironment,如果是构建工具,可以使用 createVirtualCompilerHost,这里我们考虑使用第一个,简单的例子如下:

const env = createVirtualTypeScriptEnvironment(
  system,
  ["index.ts"], // 项目中的文件都应该列进去,后边才能处理编译
  ts,
  compilerOptions
);

// 创建和更新文件可以这样
env.sys.createFile("test.ts", "// ...");
env.sys.updateFile("index.ts", 'console.log("hello TS")');

// 可以通过 getEmitOutput 获取编译后的文件内容
// env.languageService 提供了很多 API,涉及编辑和编译的整个过程,使用时可以直接通过 `d.ts` 来查看说明
const re = env.languageService.getEmitOutput("index.ts");

运行代码

上述创建的编译服务已经可以将 TS 代码转为 JS 代码了,现在应该已经不推荐通过 TS 编译器将多个代码文件打包成一个文件了,我们前边编译出来的 JS 代码依旧是模块单独的模块,我们需要将他们放一起使其可以正确执行。

比较简单的方法是直接引入一个 require.js,然后将代码编译为 AMD 模块,全部文件代码合并成一个代码字符串,补充多一个入口处理就好,类似 requirejs(['index'], function() {})

上边的办法是简单,但不是比较合理的方式,AMD 规范也是比较旧的了,我们可以考虑编译的版本提升一下,然后利用 ES Module 来实现代码的运行。关于 ES Module 实现运行代码的方法有一点复杂度,这里就不再展开了,我们还是把重点放在 TS 编译处理本身。

编译异常和问题定位

通过 env.languageService 的 API,我们可以获取到编译过程中的各种信息,下边我们用 getSyntacticDiagnostics 举个例子,这个是获取词法诊断信息:

// 假设 VFS 里有这样一个文件
const fsMap = {
  "index.ts": "console.;";
};

// ... 参考上述例子,创建了 env
const re = env.languageService.getSyntacticDiagnostics("index.ts");
console.log(re);

// re 的数据结构大致如下
interface DiagnosticWithLocation {
  category: DiagnosticCategory; // 诊断类型,异常/警告/提示/消息
  code: number; // 编码标识
  file: SourceFile; // 源码文件的更多内容
  start: number; // 位置表示
  length: number; // 相关内容的长度
}

借由 languageService 给出的诊断信息,我们便知道编译过程中的问题,包括词法错误,语法错误,类型问题等等,类似的 API 还有:

  • getSemanticDiagnostics
  • getSuggestionDiagnostics
  • getCompilerOptionsDiagnostics

关于 API 详细的用法可以直接查看 typescript.d.ts 类型声明的源文件。

关于 Diagnostic 中的 code 含义,可以查看源码的这个表: diagnosticMessages.json

代码提示

如同获取代码诊断信息一样,我们通过 env.languageService 来获取代码提示信息,相关的 API 有三个:

  • getCompletionsAtPosition
  • getCompletionEntryDetails
  • getCompletionEntrySymbol

假设我们在 index.ts 文件写了个代码 console,需要 languageService 给出可用的代码补全提示,可以这样操作:

const completion = env.languageService.getCompletionsAtPosition(
  "index.ts",
  38,
  {}
);
console.log(completion);

打印的结果中会有 entries 数组,是代码提示的各个内容主题,因为有可能是多个,所以是数组,如果需要更多额外详细信息,可以使用另外两个 API。这些 API 确实没有太多的文档和资料,我们可以通过 .d.ts 文件查看定义和注释。

引入第三方依赖

看了前边前置 TS 库文件以及虚拟文件系统的内容后,你需要引入第三方依赖的包就很容易了,就是在虚拟文件系统内创建对应的文件即可。

可以在 fsMap 初始化时,浏览器使用 fetch 将依赖内容拉取后,将对应的路径作为 key 把内容放到 fsMap 里,也可以在创建了 env 之后,通过 env.sys.createFile() 等结果把依赖包的文件内容更新进去。

这里就不再贴代码例子了。

AST 的可视化

回顾一下前边提到的 TypeScript Compiler 实现架构,Language Service 已经包括了入口的所有能力,也就是我们可以通过 Language Service 来访问 TS Program,然后获取其底层信息。

TS 官网的 Playground 也是这么实现的,其右侧的相关信息,包括编译后的 JS 代码,类型文件 d.ts,编译错误,编译日志,以及可以开启的 AST 都是通过 Language Service 来获取的。

想要更加深入了解 TypeScript Compiler 可以查看 TypeScript Deep Dive

AST 的信息我们通过 Program 可以获取:

// program 包含了你前边引入的所有文件编译处理的信息
const program = env.languageService.getProgram();

// program 中获取源码文件信息
const sourceFile = program.getSourceFile("index.ts");

// 源码文件信息里边有很多内容,具体 AST 可以进一步从 statements 获取
const { statements } = sourceFile;

Monaco Editor

结合上述这么多内容,我们就可以实现一个 TypeScript 在线编辑和运行的功能了,然而进阶的话,其实还有很多琐碎的事项需要处理,举一些例子:

  • 可配置的 compiler
  • 代码提示的 UI
  • 代码定义的跳转
  • 使用 Worker 来运行 Language Service 提升性能

等等,很多都是代码编辑器或者 IDE 的范畴了。那么,其实 VSCode 有一个可以在 Web 运行的基础版本,即 Monaco Editor

它已经实现了非常多的特性,包括继承了 HTML/CSS/JS/TS 等编辑和处理的功能(我们也可以参考他的源码看他如何使用 TS 的 Language Service),所以如果你有需要的话可以直接使用 Monaco。

我们前边讲述那么多,也可以当做是一个 Monaco 的入门,如果需要进一步深入或者改造时,这个文章也许就派上用场了。