JavaScript 版俄罗斯方块——转换为 TypeScript
写 JavaScript 版俄罗斯方块的目的是为试验了技术和框架。最初的版本 通过 Gulp + Webpack + Babel,搭建了一个 ES6 的前端构建环境;之后的一个版本 通过重构技术对模型部分进行较全面的重构,同时引入了 私有成员写法,也在重构的过程中发现,用 TypeScript 来写脚本是个比较好的选择。
下面就开始把 主要工作分支 working 切换为 TypeScript 脚本。
传送门
引入 TypeScript 环境
安装 TypeScript
如果没有 安装 TypeScript,首先肯定是要安装的。TypeScript 我也不是第一次用,这次主要是用新发布的 2.0 版本尝试一下新特性。
用 NPM 安装 TypeScript,这在 Visual Studio Code 中会用到,最新版是 2.0.3,所以安装的时候不用加版本标签了。
npm install typescript
配置 Visual Studio Code
之前有人问 tsc 编译器 2.0.3 与 VScode 代码语言服务 1.8.10 版本不匹配 怎么解决,这里我已经回答过一次如何配置 VSCode 的语言服务,这里再简单的描述一下。
根据 VSCode 官方文档,需要配置 "typescript.tsdk"
参数,可以在全局 settings.json
中配置,也可以仅为 VSCode 项目配置(.vscode/settings.json
)。
首先是找到 TypeScript 安装的位置,用 npm list -g typescript
命令:
$ npm list -g typescript
C:\Users\james\AppData\Roaming\npm
+-- typescript@2.0.3
`-- typings@1.3.3
`-- typings-core@1.4.1
`-- typescript@1.8.7
npm 的位置是 C:/Users/james/AppData/Roaming/npm
,后面拼上 node_modules/typescript/lib
就是 TypeScript 语言服务和库的位置了,所以完整的位置是
C:/Users/james/AppData/Roaming/npm/node_modules/typescript/lib
为项目引入 TypeScript
之前已经提到,前端项目的源码是放在 src 目录下,所以从控制台进入 src 项目。如果 VSCode 安装了 Start any shell 插件,可以直接在 VSCode 中打开,我个人比较喜欢用 Git Bash。
在 src 目录下使用 tsc -init
命令,tsc(TypeScript CLI)会创建 tsconfig.json
配置文件。基本上不用改,但是需要我们加入 "outFile"
选项指定输出目录:
{
"compilerOptions": {
"target": "es5",
"noImplicitAny": false,
"sourceMap": true,
"removeComments": true,
"outFile": "../js/tetris.js"
},
"include": [
"scripts/**/*"
]
}
配置好之后直接在 src 目录下就可以通过命令 tsc
编译 ts 脚本。不过这里还是准备用 gulp 来统一构建,所以配置一下 npm 项目(package.json)。
因为不需要编译 ES6 的 JavaScript,webpack 和 babel 暂时不需要了,所以一并 uninstall 掉。保持开发环境和源码干净是个好习惯。
npm install gulp-typescript
npm uninstall babel-core babel-loader babel-preset-es2015 webpack
随后修改 gulpfile.js,删除 webpack 任务,添加 typescript 任务
gulp.task("typescript", callback => {
const ts = require("gulp-typescript");
const tsProj = ts.createProject("tsconfig.json");
const result = tsProj.src()
.pipe(sourcemaps.init())
.pipe(tsProj());
return result.js
.pipe(sourcemaps.write("../js", {
sourceRoot: "../src/scripts"
}))
.pipe(gulp.dest("../js"));
});
配置 gulp-typescript 和 sourcemap 还是花了些时间试验。sourcemap 是参照 less 任务的配置进行了,试验过程中发现路径配置略有不同,根据试验结果修正即可。
到此环境基本上就搭好了
JavaScript → TypeScript
虽然说 TypeScript 是 JavaScript 的超级,理论上来说只需要把 .js 更名 为 .ts 就能完成 JavaScript 到 TypeScript 的转换。用 git mv x.js x.ts
把文件名一个个改完之后,发现并不是想像的这么简单,编译结果有一大堆错误提示。
GIT 不熟,所以不知道如何批量重命名,只好用
git mv
一个人重命名了,希望 GIT 高手能指点一二
当时也没去细想,直接就把代码改成了以前习惯的 ts 文件结构,用命名空间把代码都包了一层。现在想来,有可能是因为 "target": "es5"
这个选项的原因,毕竟之前的 JS 源码中用了 ES6 的模块语法,而 TypeScript 虽然可以把 ES6 模块语法转换成 AMD 或者 System 等模块语法,却需要配置。
另外,TypeScript 所有类的数据成员(字段,Field)需要提前申明。这也是造成编译不能通过的原因之一。
仍然以最小的 Point 为例,看看改造结果
namespace tetris.model {
export interface IPoint {
x: number;
y: number;
}
export class Point {
private _x: number;
private _y: number;
constructor(point: IPoint);
constructor(x: number, y: number);
constructor(x: any, y?: number) {
if (y === void 0) {
this._x = x.x;
this._y = x.y;
} else {
this._x = x;
this._y = y;
}
}
get x(): number {
return this._x;
}
get y(): number {
return this._y;
}
set(x: number = this._x, y: number = this._y) {
this._x = x;
this._y = y;
}
move(offsetX: number = 0, offsetY: number = 0): Point {
return new Point(this.x + offsetX, this.y + offsetY);
}
}
}
这段代码用到了命名空间、接口、类、私有属性、重载(overload) 等语言特性,仅于篇幅,就不详述了,TypeScript Documentation 中有详细的教程。
TypeScript 提供了 private
关键字,但最终转换出来的 JavaScript 中,所有 private 属性仍然可以被外部访问,也就是说,TypeScript 的 private
、protected
等修饰词仅用于它自己的语法检查。从减少项目代码本身的的 BUG 这一目的来说,已经够了。但如果是写类库,考虑到不少用户的 Hacking 天赋,还是有些欠缺。
本项目不用考虑 Hacking 的问题,所以代码转换的过程中,所有 Symbol 实现的私有化都换成了 private
。
TypeScript GitHub Issue 中有人提到希望转换的代码中用 Symbol 来实现真正的私有化,但经过一群人的 激烈讨论(全英文,有兴趣自己去看吧),被否决了。也许以后 TypeScript 会认真考虑这个问题,但至少现在没实现。
引入模块
定义在同一个命名空间中东西,哪怕是分文件写的,都不需要 import。但是如果是没有 export 的东西,就只能在同一个命名空间块中使用。
这里的 import
和 export
并不是 ES6 模块的语言特性,而是 TypeScript 的语言特性,在这一点上,TypeScript 和 ES6 在语法上很容易混淆,比如 export class
是 TS 语法,也是 ES6 语法,tsc 会根据使用场景不同来区分,但是 export default class
就是 ES6 语法了,TS 需要配置支持。
import Point = model.Point
这种写法是 TS 的语法,主要用于简化带命名空间的名称,这个和 ES6 的语法差别还是比较大的,不容易搞混。
不过由此可见一斑,TypeScript 前途漫漫啊。
TypeScript 带来的好处
在 ES6 刚发布前后那段时间,TypeScript 带来的好处之一就是可以使用 ES6 的类语法来简化类定义和继承。不过随着 ES6 和 Babel 等工具的广泛使用,这已经不再是 TypeScript 的优势。
不过从 TypeScript 2.0 的发布说明中,可以感觉到 TypeScript 抓住了重点——静态化 JavaScript。对于动态语言最大的问题就是,错误要在运行中去遇见。而静态语言在编译过程就能检查出来几乎所有的语法错误和部分可能的逻辑错误。
即使这个小小的试验性的俄罗斯方块程序,在改写为 TypeScript 的过程中,也发现了一些问题
自注释代码
我比较推崇写自注释代码——我并不是说不应该写注释,而是说,代码变量和方法本身就应该起到一定的注释作用。很多所谓的注释,其实就是把英文的方法和变量名称翻译成中文而,这样的注释,其实没啥作用。
JavaScript 中的自注释只能通过名称来实现,而 TypeScript 中还可以提供类型、重载等信息。比如 Point 构造函数,在 JavaScript 中
constructor(x, y) {
if (typeof x === "object") {
x = x.x; y = x.y;
}
// ...
}
光从构造函数的申明上来看,完全不会知道可以传入一个带 x
和 y
属性的对象来代码分别传入 x
,y
。但是 TypeScript 的函数申明就很明白
constructor(point: IPoint);
constructor(x: number, y: number);
constructor(x: any, y?: number) {
// 这里是实现
}
使用类型的问题
当初定义 Point
类的时候,就是希望能把它用在项目中,便于以后的重构。然后,改写为 TS 的过程中却出现了好几个类型不匹配的错误,都是因为直接使用了字符量对象 { x: v1, y: v2 }
这种形式来代替 Point
对象。
忘记了返回值
Block
类的 moveLeft()
、moveRight()
、moveDown()
等方法在设计的时候是计划返回 this
以便于链式调用的。不过很不幸,JavaScript 不检查返回值,所以 moveDown
忘了返回。
但是 TypeScript 中如果对方法申明了返回值类型,就会检查回返值,所以这个错误一下子就被发现了。
空值检查
虽然由于后面提到的坑,最终没有使用 TypeScript 的严格空检查模式。但是这个模式仍然帮助我检查出来几个可能产生空引用错误的地方。真心希望 TypeScript 能更快的完善,以便可以更广泛的使用这些严格模式来帮助检查错误。
检查未使用的变量和参数
TypeScript 2.0 的这两个选项可以检查未使用的局部变量和参数,这对于净化代码是很有帮助的。不过因为参数定义有时候是涉及到接口约定,并不是说没有在程序中用到就一定没用,所以最终我取消了对未使用参数的检查。
TypeScript 的坑
代码转换过程中还是遇到不少坑的
严格空检查模式下不能正确识别 Array.prototype.filter 结果类型
严格空检查模式是 TypeScript 2.0 的新特性,这个模式下
null
是一个独立的数据类型,而不是所有对象类型都可以有null
值。
在 fasten 操作和删除行操作的时候,都会用到 filter()
来过滤出有效的 BlockPoint
对象,比如
this._puzzle.fastened = this._matrix.reduce((all, row) => {
return all.concat(row.filter(t => t));
}, []);
这里 this._matrix
是一个 BlockPoint | null
的二维数组,而 Puzzle::fastened
被定义为 BlockPoint
的一维数组,它们的元素类型之间,就是一个 null
类型的区别,很显然,通过 row.filter(t => t)
得到的结果已经不可能包含 null
了,所以结果类型应该是 Array<BlockPoint>
而不是 Array<BlockPoint | null>
。然而 TypeScript 2.0 仍然推断为 Array<BlockPoint | null>
。在 GitHub Issue 上已经有很多人提出这个问题,估计会在 2.1 中解决。
本项目中,实在不想为这个个事情去写循环处理,所以只好去掉了 "strictNullChecks": true
参数配置,不使用严格空检查模式。
没有自动依赖检查
项目代码编译过了之后,运行时会出现一些类型引用的错误,比如某个类的基类需要先于它定义之类的。很显然,TypeScript 并没有很好的去分析依赖关系。官方解决方案是手工加入 /// <reference path="..." />
来申明依赖。所以源码中会发现不少这样的文件头。
更多建议: