Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
使用Typescript改造Terser

TS-Terser项目概要

想学习一下Terser这个项目,但是看起来它还是比较复杂的。

因为之前阅读中大型开源项目的效果一直不太好,没有清晰的目标和效果反馈,所以我想不如给自己订个小目标,将Terser用Typescript改造一遍,过程中能学习到多少是多少。

后面会在这个系列中持续更新TS-Terser项目的进展。

也欢迎大家关注ts-terser,Github地址是:https://github.com/banyudu/tsterser。

质量保证

Terser 中有大量的测试代码,我计划在改造过程中尽可能地不去修改test相关的代码,除非是一些文件路径方面的,不影响测试流程的改动。

初期检查了下,从我开始Fork的时候算,terser有259个测试用例。

虽然最开始运行不起来,但是降了下mocha的版本就正常了。

就以这个为质量保证手段吧。

与主仓库的同步

在改造过程中,有可能主仓库会有更新。

我会尽量尝试及时合并主仓库的代码更新。

版本发布

因为对项目不够熟悉,不清楚改造用时,暂无明确的版本发布计划。

可以先按固定的时间周期发布版本。

修改源代码文件后缀

这是开始大规模改造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": {
    /* Basic Options */
    "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,                       /* Report errors in .js files. */
    // "jsx": "preserve",                     /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
    // "declaration": true,                   /* Generates corresponding '.d.ts' file. */
    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
    "sourceMap": true,                     /* Generates corresponding '.map' file. */
    // "outFile": "./",                       /* Concatenate and emit output to single file. */
    // "outDir": "./",                        /* Redirect output structure to the directory. */
    "rootDir": "./",                       /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
    // "composite": true,                     /* Enable project compilation */
    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
    "removeComments": true,                /* Do not emit comments to output. */
    // "noEmit": true,                        /* Do not emit outputs. */
    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */

    /* Strict Type-Checking Options */
    // "strict": true,                           /* Enable all strict type-checking options. */
    // "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
    // "strictNullChecks": true,              /* Enable strict null checks. */
    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
    // "strictPropertyInitialization": true,  /* Enable strict checking of property initialization in classes. */
    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */

    /* 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 */
    // "moduleResolution": "node",            /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
    // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
    // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
    // "typeRoots": [],                       /* List of folders to include type definitions from. */
    // "types": [],                           /* Type declaration files to be included in compilation. */
    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
    "esModuleInterop": true,                  /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
    // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */

    /* Source Map Options */
    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */

    /* Experimental Options */
    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */

    /* 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.3, 10);

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

parseInt(String(12.3), 10);

改造结果

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

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

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

不是所有的new都能instanceof

问题

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

class A {}
class B {}

var a = new A()

a instanceof A
// => true

a 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 中找到了一段说明。

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

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 中有提到:

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+行代码),还需要进行拆分。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment