1

写 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

clipboard.png

为项目引入 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 的 privateprotected 等修饰词仅用于它自己的语法检查。从减少项目代码本身的的 BUG 这一目的来说,已经够了。但如果是写类库,考虑到不少用户的 Hacking 天赋,还是有些欠缺。

本项目不用考虑 Hacking 的问题,所以代码转换的过程中,所有 Symbol 实现的私有化都换成了 private

TypeScript GitHub Issue 中有人提到希望转换的代码中用 Symbol 来实现真正的私有化,但经过一群人的 激烈讨论(全英文,有兴趣自己去看吧),被否决了。也许以后 TypeScript 会认真考虑这个问题,但至少现在没实现。

引入模块

定义在同一个命名空间中东西,哪怕是分文件写的,都不需要 import。但是如果是没有 export 的东西,就只能在同一个命名空间块中使用。

这里的 importexport 并不是 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;
    }
    // ...
}

光从构造函数的申明上来看,完全不会知道可以传入一个带 xy 属性的对象来代码分别传入 xy。但是 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="..." /> 来申明依赖。所以源码中会发现不少这样的文件头。


传送门


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!