我用 TypeScript 改造了 Terser,学到了什么?
https://banyudu.com/
引子
最近突然想做一些技术方面的学习。
这种『突然』实际上是经常出现的,像突然想学学琴、突然想健身,突然学门新语言,总会有一段时间突然想要学习一些东西。
然而设想了很多很多,大多无疾而终。
于是我陷入了奇怪的循环之中。
究其原因,我猜测大概还是门槛太高的缘故。给自己设置的目标过高,能挤出的时间又太少,当发现目标越来越难以实现的时候,也就不会真的相信自己能完成目标了。
方案
现在我想到的解决方案就是尽可能地将难度降低,制订一个『小目标』,待其完成之后,再制订下一个『小目标』。
这个方案的关键,就是『小目标』应该如何制订、怎么监督自己有没有完成『小目标』、以及如何保持自己的兴趣。
在控制风险方面,我认为主要就是两个点:
-
尽量控制未知的风险。比如开始一个新的项目的时候,尽量选择自己比较熟悉的技术栈。 -
当需要使用新的技术栈的时候,应做一个尽可能简单的项目,或重写已经存在的项目。总结起来就是,不要使用全新的技术涉足全新的领域。
反例
为了警醒自己,我这里也列一个常见的反例。
自从开始做一些前端方面的工作,总是被 UglifyJS、Terser 等工具的低性能所困扰。
粗略地分析下,UglifyJS、Terser 等工具所做的任务,类似于编译器,涉及到复杂的计算,应可认为是 CPU 密集型任务。Node.js 众所周知不太适合 CPU 密集型的任务,所以我想如果用 C++、Rust、Go 之类的重写它,应该能得到不错的性能提升。Rust 看起来比较好,用它来重写一个吧。
所以这里有一个『小目标』:使用 Rust 重写 terser。
评估下它是否符合要求:
不是全新的领域 不是全新的技术 对自己有帮助 可以看到,仅有『对自己有帮助』一点,可维持兴趣。但是它对我来说是个全新的领域,也是个全新的技术,风险不可控。这种目标不够小,往往会无疾而终。
反例的正确拆解
如上面说到的反例,其实我对它也是有兴趣的。那有没有办法把它拆解成合理的『小目标』。
我的主要原则还是那点:『不要使用全新的技术涉足全新的领域』。
所以可将这个目标拆分成两个或更多个『小目标』。
-
Fork Terser 项目,并将其代码改成 Typescript。这个过程中使用熟悉的技术(TS、JS)来掌握一个全新的领域(JS 代码的压缩优化),风险可控。
-
使用 Rust 语言,将上述的 Typesciprt 项目再重写一遍。这次是在熟悉的领域中,使用新的技术(Rust),风险可控。
修改源代码文件后缀
这是开始大规模改造 terser 的第一天,主要做了如下几件事:
-
添加 typescript 相关配置 -
修改主要的源码文件(不包含 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 修改的节奏,主要分成以下两大阶段:
-
第一阶段,关闭严格类型检查,仅把所有待改造的 js 文件修改成.ts 后缀,并修复过程中遇到的错误 -
第二阶段,逐渐打开严格类型检查,并修复过程中遇到的问题
在第一阶段中,修改主要是以文件为单位进行,因为这个阶段跨文件的问题不会太多。
在第二阶段中,则是以规则为单位进行了。如果打开某个规则之后,出现的问题过多,也可以在规则内,按照文件为单位进行修改,修改完一部分之后,将规则原则以免编译不通过。
今天我的主要修改内容即为第一阶段的内容。将现有的文件(测试用例除外)修改成 ts 后缀。
修改内容
在将 terser 从 js 改造成 ts 的过程中,我主要做了如下几个方面的事情:
-
修改文件引用路径 -
修改文件后缀 -
修正改成 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.js
、parse.js
、scope.js
。
修改这些文件的时候,遇到过几次 test 不通过的情况,又推倒重新来了。
后面修改的时候,可以先改一些 JS/TS 兼容的部分,之后先改回文件名,保存一部分成果。
typescriptterser
programming
发布于: 2020-05-23 作者: 鱼肚最后更新: 2020-11-25
不是所有的 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[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 asuper(...)
call, subclassingError
,Array
, and others may no longer work as expected. This is due to the fact that constructor functions forError
,Array
, and the like use ECMAScript 6'snew.target
to adjust the prototype chain; however, there is no way to ensure a value fornew.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 中,存在着大量的根据子类的类型,决定父类中函数的具体行为的语句。这就导致了如下的问题:
-
若是使用 instanceOf,能正常推导类型,但是会带来大量的循环依赖问题。且将来转到 Rust/C++等静态语言时,会比较难处理。 -
若是不使用 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 陪你一起度过!
参考资料
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