寫 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,首先肯定是要安裝的。TypeScript 我也不是第一次用,這次主要是用新發(fā)布的 2.0 版本嘗試一下新特性。
用 NPM 安裝 TypeScript,這在 Visual Studio Code 中會(huì)用到,最新版是 2.0.3,所以安裝的時(shí)候不用加版本標(biāo)簽了。
npm install typescript
之前有人問 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
之前已經(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)境基本上就搭好了
雖然說 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è)命名空間塊中使用。
這里的 import
和 export
并不是 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 前途漫漫啊。
在 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è)帶 x
和 y
屬性的對象來代碼分別傳入 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ò)誤。
TypeScript 2.0 的這兩個(gè)選項(xiàng)可以檢查未使用的局部變量和參數(shù),這對于凈化代碼是很有幫助的。不過因?yàn)閰?shù)定義有時(shí)候是涉及到接口約定,并不是說沒有在程序中用到就一定沒用,所以最終我取消了對未使用參數(shù)的檢查。
代碼轉(zhuǎn)換過程中還是遇到不少坑的
嚴(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)格空檢查模式。
項(xiàng)目代碼編譯過了之后,運(yùn)行時(shí)會(huì)出現(xiàn)一些類型引用的錯(cuò)誤,比如某個(gè)類的基類需要先于它定義之類的。很顯然,TypeScript 并沒有很好的去分析依賴關(guān)系。官方解決方案是手工加入 /// <reference path="..." />
來申明依賴。所以源碼中會(huì)發(fā)現(xiàn)不少這樣的文件頭。
更多建議: