JavaScript 版俄羅斯方塊——轉(zhuǎn)換為 TypeScript

2018-06-08 13:55 更新

寫 JavaScript 版俄羅斯方塊的目的是為試驗(yàn)了技術(shù)和框架。最初的版本 通過 Gulp + Webpack + Babel,搭建了一個(gè) ES6 的前端構(gòu)建環(huán)境;之后的一個(gè)版本 通過重構(gòu)技術(shù)對模型部分進(jìn)行較全面的重構(gòu),同時(shí)引入了 私有成員寫法,也在重構(gòu)的過程中發(fā)現(xiàn),用 TypeScript 來寫腳本是個(gè)比較好的選擇。

下面就開始把 主要工作分支 working 切換為 TypeScript 腳本。


傳送門


引入 TypeScript 環(huán)境

安裝 TypeScript

如果沒有 安裝 TypeScript,首先肯定是要安裝的。TypeScript 我也不是第一次用,這次主要是用新發(fā)布的 2.0 版本嘗試一下新特性。

用 NPM 安裝 TypeScript,這在 Visual Studio Code 中會(huì)用到,最新版是 2.0.3,所以安裝的時(shí)候不用加版本標(biāo)簽了。

npm install typescript

配置 Visual Studio Code

之前有人問 tsc 編譯器 2.0.3 與 VScode 代碼語言服務(wù) 1.8.10 版本不匹配 怎么解決,這里我已經(jīng)回答過一次如何配置 VSCode 的語言服務(wù),這里再簡單的描述一下。

根據(jù) VSCode 官方文檔,需要配置 "typescript.tsdk" 參數(shù),可以在全局 settings.json 中配置,也可以僅為 VSCode 項(xiàng)目配置(.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 語言服務(wù)和庫的位置了,所以完整的位置是

C:/Users/james/AppData/Roaming/npm/node_modules/typescript/lib

為項(xiàng)目引入 TypeScript

之前已經(jīng)提到,前端項(xiàng)目的源碼是放在 src 目錄下,所以從控制臺(tái)進(jìn)入 src 項(xiàng)目。如果 VSCode 安裝了 Start any shell 插件,可以直接在 VSCode 中打開,我個(gè)人比較喜歡用 Git Bash。

在 src 目錄下使用 tsc -init 命令,tsc(TypeScript CLI)會(huì)創(chuàng)建 tsconfig.json 配置文件?;旧喜挥酶?,但是需要我們加入 "outFile" 選項(xiàng)指定輸出目錄:

{
    "compilerOptions": {
        "target": "es5",
        "noImplicitAny": false,
        "sourceMap": true,
        "removeComments": true,
        "outFile": "../js/tetris.js"
    },
    "include": [
        "scripts/**/*"
    ]
}

配置好之后直接在 src 目錄下就可以通過命令 tsc 編譯 ts 腳本。不過這里還是準(zhǔn)備用 gulp 來統(tǒng)一構(gòu)建,所以配置一下 npm 項(xiàng)目(package.json)。

因?yàn)椴恍枰幾g ES6 的 JavaScript,webpack 和 babel 暫時(shí)不需要了,所以一并 uninstall 掉。保持開發(fā)環(huán)境和源碼干凈是個(gè)好習(xí)慣。

npm install gulp-typescript
npm uninstall babel-core babel-loader babel-preset-es2015 webpack

隨后修改 gulpfile.js,刪除 webpack 任務(wù),添加 typescript 任務(wù)

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 還是花了些時(shí)間試驗(yàn)。sourcemap 是參照 less 任務(wù)的配置進(jìn)行了,試驗(yàn)過程中發(fā)現(xiàn)路徑配置略有不同,根據(jù)試驗(yàn)結(jié)果修正即可。

到此環(huán)境基本上就搭好了

JavaScript → TypeScript

雖然說 TypeScript 是 JavaScript 的超級,理論上來說只需要把 .js 更名 為 .ts 就能完成 JavaScript 到 TypeScript 的轉(zhuǎn)換。用 git mv x.js x.ts 把文件名一個(gè)個(gè)改完之后,發(fā)現(xiàn)并不是想像的這么簡單,編譯結(jié)果有一大堆錯(cuò)誤提示。

GIT 不熟,所以不知道如何批量重命名,只好用 git mv 一個(gè)人重命名了,希望 GIT 高手能指點(diǎn)一二

當(dāng)時(shí)也沒去細(xì)想,直接就把代碼改成了以前習(xí)慣的 ts 文件結(jié)構(gòu),用命名空間把代碼都包了一層?,F(xiàn)在想來,有可能是因?yàn)?"target": "es5" 這個(gè)選項(xiàng)的原因,畢竟之前的 JS 源碼中用了 ES6 的模塊語法,而 TypeScript 雖然可以把 ES6 模塊語法轉(zhuǎn)換成 AMD 或者 System 等模塊語法,卻需要配置。

另外,TypeScript 所有類的數(shù)據(jù)成員(字段,F(xiàn)ield)需要提前申明。這也是造成編譯不能通過的原因之一。

仍然以最小的 Point 為例,看看改造結(jié)果

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 中有詳細(xì)的教程。

TypeScript 提供了 private 關(guān)鍵字,但最終轉(zhuǎn)換出來的 JavaScript 中,所有 private 屬性仍然可以被外部訪問,也就是說,TypeScript 的 private、protected 等修飾詞僅用于它自己的語法檢查。從減少項(xiàng)目代碼本身的的 BUG 這一目的來說,已經(jīng)夠了。但如果是寫類庫,考慮到不少用戶的 Hacking 天賦,還是有些欠缺。

本項(xiàng)目不用考慮 Hacking 的問題,所以代碼轉(zhuǎn)換的過程中,所有 Symbol 實(shí)現(xiàn)的私有化都換成了 private。

TypeScript GitHub Issue 中有人提到希望轉(zhuǎn)換的代碼中用 Symbol 來實(shí)現(xiàn)真正的私有化,但經(jīng)過一群人的 激烈討論(全英文,有興趣自己去看吧),被否決了。也許以后 TypeScript 會(huì)認(rèn)真考慮這個(gè)問題,但至少現(xiàn)在沒實(shí)現(xiàn)。

引入模塊

定義在同一個(gè)命名空間中東西,哪怕是分文件寫的,都不需要 import。但是如果是沒有 export 的東西,就只能在同一個(gè)命名空間塊中使用。

這里的 importexport 并不是 ES6 模塊的語言特性,而是 TypeScript 的語言特性,在這一點(diǎn)上,TypeScript 和 ES6 在語法上很容易混淆,比如 export class 是 TS 語法,也是 ES6 語法,tsc 會(huì)根據(jù)使用場景不同來區(qū)分,但是 export default class 就是 ES6 語法了,TS 需要配置支持。

import Point = model.Point 這種寫法是 TS 的語法,主要用于簡化帶命名空間的名稱,這個(gè)和 ES6 的語法差別還是比較大的,不容易搞混。

不過由此可見一斑,TypeScript 前途漫漫啊。

TypeScript 帶來的好處

在 ES6 剛發(fā)布前后那段時(shí)間,TypeScript 帶來的好處之一就是可以使用 ES6 的類語法來簡化類定義和繼承。不過隨著 ES6 和 Babel 等工具的廣泛使用,這已經(jīng)不再是 TypeScript 的優(yōu)勢。

不過從 TypeScript 2.0 的發(fā)布說明中,可以感覺到 TypeScript 抓住了重點(diǎn)——靜態(tài)化 JavaScript。對于動(dòng)態(tài)語言最大的問題就是,錯(cuò)誤要在運(yùn)行中去遇見。而靜態(tài)語言在編譯過程就能檢查出來幾乎所有的語法錯(cuò)誤和部分可能的邏輯錯(cuò)誤。

即使這個(gè)小小的試驗(yàn)性的俄羅斯方塊程序,在改寫為 TypeScript 的過程中,也發(fā)現(xiàn)了一些問題

自注釋代碼

我比較推崇寫自注釋代碼——我并不是說不應(yīng)該寫注釋,而是說,代碼變量和方法本身就應(yīng)該起到一定的注釋作用。很多所謂的注釋,其實(shí)就是把英文的方法和變量名稱翻譯成中文而,這樣的注釋,其實(shí)沒啥作用。

JavaScript 中的自注釋只能通過名稱來實(shí)現(xiàn),而 TypeScript 中還可以提供類型、重載等信息。比如 Point 構(gòu)造函數(shù),在 JavaScript 中

constructor(x, y) {
    if (typeof x === "object") {
        x = x.x; y = x.y;
    }
    // ...
}

光從構(gòu)造函數(shù)的申明上來看,完全不會(huì)知道可以傳入一個(gè)帶 xy 屬性的對象來代碼分別傳入 x,y。但是 TypeScript 的函數(shù)申明就很明白

constructor(point: IPoint);
constructor(x: number, y: number);
constructor(x: any, y?: number) {
    // 這里是實(shí)現(xiàn)
}

使用類型的問題

當(dāng)初定義 Point 類的時(shí)候,就是希望能把它用在項(xiàng)目中,便于以后的重構(gòu)。然后,改寫為 TS 的過程中卻出現(xiàn)了好幾個(gè)類型不匹配的錯(cuò)誤,都是因?yàn)橹苯邮褂昧俗址繉ο?{ x: v1, y: v2 } 這種形式來代替 Point 對象。

忘記了返回值

Block 類的 moveLeft()、moveRight()、moveDown() 等方法在設(shè)計(jì)的時(shí)候是計(jì)劃返回 this 以便于鏈?zhǔn)秸{(diào)用的。不過很不幸,JavaScript 不檢查返回值,所以 moveDown 忘了返回。

但是 TypeScript 中如果對方法申明了返回值類型,就會(huì)檢查回返值,所以這個(gè)錯(cuò)誤一下子就被發(fā)現(xiàn)了。

空值檢查

雖然由于后面提到的坑,最終沒有使用 TypeScript 的嚴(yán)格空檢查模式。但是這個(gè)模式仍然幫助我檢查出來幾個(gè)可能產(chǎn)生空引用錯(cuò)誤的地方。真心希望 TypeScript 能更快的完善,以便可以更廣泛的使用這些嚴(yán)格模式來幫助檢查錯(cuò)誤。

檢查未使用的變量和參數(shù)

TypeScript 2.0 的這兩個(gè)選項(xiàng)可以檢查未使用的局部變量和參數(shù),這對于凈化代碼是很有幫助的。不過因?yàn)閰?shù)定義有時(shí)候是涉及到接口約定,并不是說沒有在程序中用到就一定沒用,所以最終我取消了對未使用參數(shù)的檢查。

TypeScript 的坑

代碼轉(zhuǎn)換過程中還是遇到不少坑的

嚴(yán)格空檢查模式下不能正確識(shí)別 Array.prototype.filter 結(jié)果類型

嚴(yán)格空檢查模式是 TypeScript 2.0 的新特性,這個(gè)模式下 null 是一個(gè)獨(dú)立的數(shù)據(jù)類型,而不是所有對象類型都可以有 null 值。

在 fasten 操作和刪除行操作的時(shí)候,都會(huì)用到 filter() 來過濾出有效的 BlockPoint 對象,比如

this._puzzle.fastened = this._matrix.reduce((all, row) => {
    return all.concat(row.filter(t => t));
}, []);

這里 this._matrix 是一個(gè) BlockPoint | null 的二維數(shù)組,而 Puzzle::fastened 被定義為 BlockPoint 的一維數(shù)組,它們的元素類型之間,就是一個(gè) null 類型的區(qū)別,很顯然,通過 row.filter(t => t) 得到的結(jié)果已經(jīng)不可能包含 null 了,所以結(jié)果類型應(yīng)該是 Array<BlockPoint> 而不是 Array<BlockPoint | null>。然而 TypeScript 2.0 仍然推斷為 Array<BlockPoint | null>。在 GitHub Issue 上已經(jīng)有很多人提出這個(gè)問題,估計(jì)會(huì)在 2.1 中解決。

本項(xiàng)目中,實(shí)在不想為這個(gè)個(gè)事情去寫循環(huán)處理,所以只好去掉了 "strictNullChecks": true 參數(shù)配置,不使用嚴(yán)格空檢查模式。

沒有自動(dòng)依賴檢查

項(xiàng)目代碼編譯過了之后,運(yùn)行時(shí)會(huì)出現(xiàn)一些類型引用的錯(cuò)誤,比如某個(gè)類的基類需要先于它定義之類的。很顯然,TypeScript 并沒有很好的去分析依賴關(guān)系。官方解決方案是手工加入 /// <reference path="..." /> 來申明依賴。所以源碼中會(huì)發(fā)現(xiàn)不少這樣的文件頭。


傳送門

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)