vlambda博客
学习文章列表

我用 TypeScript 改造了 Terser,学到了什么?

前端从进阶到入院
我是 ssh,只想用最简单的方式把原理讲明白。wx:sshsunlight,分享前端的前沿趋势和一些有趣的事情。
64篇原创内容
Official Account

https://banyudu.com/

引子

最近突然想做一些技术方面的学习。

这种『突然』实际上是经常出现的,像突然想学学琴、突然想健身,突然学门新语言,总会有一段时间突然想要学习一些东西。

然而设想了很多很多,大多无疾而终。

于是我陷入了奇怪的循环之中。

究其原因,我猜测大概还是门槛太高的缘故。给自己设置的目标过高,能挤出的时间又太少,当发现目标越来越难以实现的时候,也就不会真的相信自己能完成目标了。

方案

现在我想到的解决方案就是尽可能地将难度降低,制订一个『小目标』,待其完成之后,再制订下一个『小目标』。

这个方案的关键,就是『小目标』应该如何制订、怎么监督自己有没有完成『小目标』、以及如何保持自己的兴趣。

在控制风险方面,我认为主要就是两个点:

  1. 尽量控制未知的风险。比如开始一个新的项目的时候,尽量选择自己比较熟悉的技术栈。
  2. 当需要使用新的技术栈的时候,应做一个尽可能简单的项目,或重写已经存在的项目。总结起来就是,不要使用全新的技术涉足全新的领域。

反例

为了警醒自己,我这里也列一个常见的反例。

自从开始做一些前端方面的工作,总是被 UglifyJS、Terser 等工具的低性能所困扰。

粗略地分析下,UglifyJS、Terser 等工具所做的任务,类似于编译器,涉及到复杂的计算,应可认为是 CPU 密集型任务。Node.js 众所周知不太适合 CPU 密集型的任务,所以我想如果用 C++、Rust、Go 之类的重写它,应该能得到不错的性能提升。Rust 看起来比较好,用它来重写一个吧。

所以这里有一个『小目标』:使用 Rust 重写 terser。

评估下它是否符合要求:

不是全新的领域 不是全新的技术 对自己有帮助 可以看到,仅有『对自己有帮助』一点,可维持兴趣。但是它对我来说是个全新的领域,也是个全新的技术,风险不可控。这种目标不够小,往往会无疾而终。

反例的正确拆解

如上面说到的反例,其实我对它也是有兴趣的。那有没有办法把它拆解成合理的『小目标』。

我的主要原则还是那点:『不要使用全新的技术涉足全新的领域』。

所以可将这个目标拆分成两个或更多个『小目标』。

  1. Fork Terser 项目,并将其代码改成 Typescript。这个过程中使用熟悉的技术(TS、JS)来掌握一个全新的领域(JS 代码的压缩优化),风险可控。

  2. 使用 Rust 语言,将上述的 Typesciprt 项目再重写一遍。这次是在熟悉的领域中,使用新的技术(Rust),风险可控。

修改源代码文件后缀

这是开始大规模改造 terser 的第一天,主要做了如下几件事:

  1. 添加 typescript 相关配置
  2. 修改主要的源码文件(不包含 test),从 js 改成 ts

Typescript 相关配置

Terser 项目是使用的 rollup 做的打包构建,所以设置 TS 也主要是对 rollup 的一些处理。另外原来 terser 上有 eslint 配置,也需要处理。

添加相关依赖项

首先是基本的 typescript 支持,及相关库。

npm i -D typescript tslib rollup-plugin-typescript2

这里使用的是rollup-plugin-typescript2而不是@rollup/plugin-typescript,因为后者使用的时候会报一些难以解决的问题。

然后是 eslint+typescript 相关的一些依赖:

npm i -D @typescript-eslint/eslint-plugin @typescript-eslint/parser

添加 Typescript 基本配置文件

Typescript 有一个基本的配置文件tsconfig.json,可以使用工具tsc自动生成。

在项目的顶层目录,执行 tsc init即可添加一个默认的 tsconfig.json。

在默认生成的基础上,我修改了一些默认配置。最后的配置文件如下:

{
  "compilerOptions": {
    "incremental"true,                   /* Enable incremental compilation */
    "target""es5",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
    "module""es2015",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
    "lib": ["DOM"],                             /* Specify library files to be included in the compilation. */
    "allowJs"true,                       /* Allow javascript files to be compiled. */
    "checkJs"true,
    "sourceMap"true,                     /* Generates corresponding '.map' file. */
    "rootDir""./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    "removeComments"true,                /* Do not emit comments to output. */

    /* Additional Checks */
    "noUnusedLocals"true,                /* Report errors on unused locals. */
    "noUnusedParameters"true,            /* Report errors on unused parameters. */
    "noImplicitReturns"true,             /* Report error when not all code paths in function return a value. */
    "noFallthroughCasesInSwitch"true,    /* Report errors for fallthrough cases in switch statement. */

    /* Module Resolution Options */
    "esModuleInterop"true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    /* Advanced Options */
    "forceConsistentCasingInFileNames"true  /* Disallow inconsistently-cased references to the same file. */
  }
}

修改主要的源码文件

首先要确定下修改的范围,我这里修改的时候只修改源码,不改 test 目录中的文件。

之所以不改 test 目录,是想保持测试用例与主仓库完全相同。

因为 terser 的测试用例比较全,如果这些测试都能通过,那么基本可说明改造之后还是可信的。当然这有一个基础条件,就是测试用例未发生变化。

修改节奏

完善的 typescript 很难一步到位地实现,因为这一是需要对业务有足够多的了解,二是改造的成本较高,牵连面甚广,较难控制改造的规模。

所以我这里控制了 TS 修改的节奏,主要分成以下两大阶段:

  1. 第一阶段,关闭严格类型检查,仅把所有待改造的 js 文件修改成.ts 后缀,并修复过程中遇到的错误
  2. 第二阶段,逐渐打开严格类型检查,并修复过程中遇到的问题

在第一阶段中,修改主要是以文件为单位进行,因为这个阶段跨文件的问题不会太多。

在第二阶段中,则是以规则为单位进行了。如果打开某个规则之后,出现的问题过多,也可以在规则内,按照文件为单位进行修改,修改完一部分之后,将规则原则以免编译不通过。

今天我的主要修改内容即为第一阶段的内容。将现有的文件(测试用例除外)修改成 ts 后缀。

修改内容

在将 terser 从 js 改造成 ts 的过程中,我主要做了如下几个方面的事情:

  1. 修改文件引用路径
  2. 修改文件后缀
  3. 修正改成 TS 之后遇到的错误

其中修改文件引用路径,是指的将 import / require 语句中的.js后缀去掉。

比如原来有 import A from './a.js'这样的文件引用,因为后面涉及到将a.js修改成a.ts,所以继续这么引用会出现错误。

为避免后面出现因为这类写法导致的问题,可将其先改成同时兼容.ts.js的写法,即不加文件后缀。

主要的报错及相应处理方法

TS7030: Not all code paths return a value

出现这种问题,一般是因为函数的末尾没有返回值,但是其中的一些if/else区块中有。

为修复这种问题,可以直接定位到函数的末尾,加一个 return undefined; 即可,既可以返回值,消除这个报错,又不影响程序原有的逻辑。

如将

function(output{
  var p = output.parent();
  if (p instanceof AST_PropAccess && p.expression === this) {
    var value = this.getValue();
    if (value.startsWith("-")) {
      return true;
    }
  }
}

修改为:

function(output{
  var p = output.parent();
  if (p instanceof AST_PropAccess && p.expression === this) {
    var value = this.getValue();
    if (value.startsWith("-")) {
      return true;
    }
  }
  return undefined// 此处新加一行
}

TS6133: 'xxx' is declared but its value is never read.

这里的 xxx 可以是任意变量名。

一般出现这种问题,多是函数的某个参数未被使用导致的。

可以给函数参数重命名,加个下划线前缀即可。

如将

function(self, output{
  output.print("debugger");
  output.semicolon();
});

修改为:

function(_self, output// self 改成了 _self
  output.print("debugger");
  output.semicolon();
});

TS2554: Expected 2 arguments, but got 1.

此处的 2 和 1 都只是一个示例数字。

这个错误是指在某处函数调用处,传的参数比此函数定义的参数要少。

无伤的改法就是将此函数的参数定义中,超出给定参数长度的,设置为可选参数。

如将

function parse($TEXT, options{
  // 省略具体代码
}

改成

function parse($TEXT, options?{
  // options后加了个?号
  // 省略具体代码
}

TS2339: Property 'xxx' does not exist on type YYY

在未打开严格类型检查时,会出现此类错误的,一般是变量定义之后添加元素的情况。

修改的话,需要将此变量的完整类型给出,或直接设置为 any。

如将

var YYY = {};
YYY.xxx = 1;

改成

var YYY: any = {};
YYY.xxx = 1;

var YYY: { xxx?: number } = {};
YYY.xxx = 1;

TS2345: Argument of type 'number' is not assignable to parameter of type 'string'.

这是类型不匹配导致的错误。常见于 parseInt 等函数,当然所有函数都有可能出现此问题。

以 parseInt 举例,其参数类型定义如下:

declare function parseInt(s: string, radix?: number): number;

这里的第一个参数是 string,而不是 number。

所以像

parseInt(12.310);

这样的用法都是错的,要改成

parseInt(String(12.3), 10);

改造结果

现在还剩下最难搞定的几个大文件,compress/index.jsparse.jsscope.js

修改这些文件的时候,遇到过几次 test 不通过的情况,又推倒重新来了。

后面修改的时候,可以先改一些 JS/TS 兼容的部分,之后先改回文件名,保存一部分成果。

typescriptterser

programming

发布于: 2020-05-23 作者: 鱼肚最后更新: 2020-11-25

不是所有的 new 都能 instanceof

问题

JS 里面有个 instanceof 方法,可以用来判断一个对象是否是某个类的实例。

class A {}
class B {}

var a = new A()

instanceof A
// => true

instanceof B
// => false

但是在 Typescript 之中,这个并不是一直成立的。

假设有这样一段 JS 代码:

class MyError extends Error {
    constructor () {
        super()
        this.someAttr = 'hello'
    }
}

const err = new MyError()

console.log('err instanceof MyError: ', (err instanceof MyError))
// err instanceof MyError:  true

但是在将其转换成 TS 之后:

class MyError extends Error {
    filename: string
    constructor () {
        super()
        this.filename = 'hello'
    }
}

const err = new MyError()

console.log('err instanceof MyError: ', (err instanceof MyError))

使用ts-node(with typescript >= 2)运行上述代码,却会得到下面的结果:

err instanceof MyError: false

更神奇的是:如果加上以下的一段 tsconfig.json

{
  "compilerOptions": {
    "target""es6"
  }
}

ts-node运行的结果就又变成// err instanceof MyError: true

解决方案

一般搜索之后,在 Typescript 的Breaking Changes[1] 中找到了一段说明。

里面有提到相应的解决方案:

class MyError extends Error {
    filename: string
    constructor () {
        super()
        this.filename = 'hello'
        Object.setPrototypeOf(this, MyError.prototype)
    }
}

const err = new MyError()

console.log('err instanceof MyError: ', (err instanceof MyError))
// err instanceof MyError:  true

或者像上面提到的,将tsconfig.json中的 target 改成es6,也可解决这个问题。

相关原理

刚刚发的Breaking Changes[2] 中有提到:

As part of substituting the value of this with the value returned by a super(...) call, subclassing Error, Array, and others may no longer work as expected. This is due to the fact that constructor functions for Error, Array, and the like use ECMAScript 6's new.target to adjust the prototype chain; however, there is no way to ensure a value for new.target when invoking a constructor in ECMAScript 5.

Terser 项目中与 Typescript 主要的不兼容点及改进方向

Terser 源自 Uglifyjs,其开发时间较早,从现在的 ES6、Typescript 的视角看过去,存在不少的问题,代码结构存在一些可优化点。

这篇文章中,我主要讲一下在我眼中 Terser 项目中的问题,及相应的改进方向。

问题

类定义松散,不利于添加类型

terser 中的类定义是这样的过程:

  • 先定义一个基本的类,只包含骨架和简单方法
  • 在各个分散的文件中,通过修改 prototype 的方式,为相应的类添加方法

按这种方式,类在一开始定义的时候,不包含相应的属性和方法,而在 import 某个文件之后,通过修改 prototype,又变成了拥有此属性和方法。

在 Typescript 中,如果直接声明此类具备相应的方法,则会报类型错误。而如果声明这些方法是可选方法,则在使用的时候需要做各种非空判断,比较麻烦。

改进方向

为了解决这个问题,我在为 terser 添加类型之前,先对 terser 进行了一次大的重构。从原来的同类方法定义在一个文件中,改成同一个类的方法定义在一个文件中。

代码不符合 es6 规范

terser 的代码仓库中大量充斥着 es5 的代码,如很多对 prototype 的操作,这样的代码在 Typescript 中都会比较难以处理,带来大量的类型问题。

改进方向

将相应的 es5 代码先转换成可正常工作的 es6 代码。

大量的工厂方法

terser 中有大量的工厂方法,如 DEFNODE用来创建类,def_optimize用来在各个类中添加optimize方法。

DEFNODE中基于用传参的方式,动态地定义各种函数、方法,在 Typescript 中基本无法处理。

这些工厂方法增大了添加类型的难度。

改进方向

将相应的工厂方法删除,换成正常的定义。

大量的 instanceOf

instanceOf 在 typescript 中是一种 type gurad,可以用来判断类型。但是它有一个缺点,就是必须将相应的 class 引入。

在 terser 中,存在着大量的根据子类的类型,决定父类中函数的具体行为的语句。这就导致了如下的问题:

  1. 若是使用 instanceOf,能正常推导类型,但是会带来大量的循环依赖问题。且将来转到 Rust/C++等静态语言时,会比较难处理。
  2. 若是不使用 instanceOf,换成别的方式实现。则没有类型推导,不方便将类型从 any 换成具体类型,否则会报一些类型不匹配的问题。

改进方向 1

首先将 instanceOf 换成了一个通用的实例方法 isAst,解决循环依赖的问题。

如原来node instanceOf AST_Class 现在可以换成node?.isAst?.('AST_Class'),这样就没必要引入 AST_Class 类型,也就不会有循环依赖了。

改进方向 2

换成 isAst 可以解决循环依赖问题,但是没办法解决类型推导问题。原来 instanceOf 能起到 type guard 的作用,现在的 isAst 方法没有了。

要解决这个问题,可以为 isAst 方法添加类型推导能力,即

isAst<T extends AST_Node> (type: string): this is T

但是这样还是会需要引入AST_Node,一个替换方案是将所有的 AST 类的定义,抽取成接口:

// types.ts
interface IAST_Node {}
interface IAST_Class extends IAST_Node {}

// ast/class.ts
import { IAST_Class } from '../types'
class AST_Class implements IAST_Class {
  xxx () {}
}

// ast/node.ts
import { IAST_Node, IAST_Class } from '../types'
class AST_Node implements IAST_Node {
  foo () {
    if (this.isAst<IAST_Class>('AST_Class')) {
      this.xxx()
    }
  }
}

如上代码所示,首先要在一个types.ts中定义所有的 ast 相关类的定义,然后需要在基类中引入子类的类型,再通过手动的 type guard 推导类型。

虽然能用,这样使用起来就比较繁琐了。

改进方向 3

第 3 种改进方向,就是将基类中对 instanceOf 的调用,切换成子类的方法覆盖。

如原来有

class AST_Node {
  foo () {
    if (this instanceof AST_Class) {
      // code for AST_Class
    }
    // code for AST_Node
  }
}
class AST_Class extends AST_Node {}

变成

class AST_Node {
  foo () {
    // code for AST_Node
  }
}
class AST_Class extends AST_Node {
  foo () {
    // code for AST_Class
  }
}

但是这种方法改起来比较困难,一方面很多判断条件在抽取成方法时不好描述,另一方面容易引入 bug。

总结

为了使 tsterser 开发更加顺利,在为 terser 添加类型之前,首先要对其进行一次重构,具体来说,主要是将类定义从工厂方法中抽取出来,将动态添加的方法写成静态定义。

另外因为全部抽取之后,所有类都在一起,lib/ast.ts 文件变得过大(10000+行代码),还需要进行拆分。

谢谢支持

欢迎长按图片加 ssh 为好友,我会第一时间和你分享前端行业趋势,学习途径等等。2021 陪你一起度过!

参考资料

[1]

Breaking Changes: https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work

[2]

Breaking Changes: https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work