不是Typescript用不起,而是JSDoc更有性價比?
1. TS不香了?

2023年,幾條關(guān)于 Typescript 的新聞打破了沉寂,讓沒什么新活好整的前端開發(fā)圈子又熱鬧了一番。
先是 GitHub 的報(bào)告稱:“TypeScript 取代 Java 成為第三受歡迎語言”。
在其當(dāng)年度 Octoverse 開源狀態(tài)報(bào)告中,在最流行的編程語言方面,TypeScript 越來越受歡迎,首次取代 Java 成為 GitHub 上 OSS 項(xiàng)目中第三大最受歡迎的語言,其用戶群增長了 37%。
而 Stack Overflow 發(fā)布的 2023 年開發(fā)者調(diào)查報(bào)告也顯示,JavaScript 連續(xù) 11 年成為最流行編程語言,使用占比達(dá) 63.61%,TypeScript 則排名第五,使用占比 38.87%。
更大的爭議則來自于:2023年9月,Ruby on Rails 作者 DHH 宣布移除其團(tuán)隊(duì)開源項(xiàng)目 Turbo 8 中的 TypeScript 代碼
他認(rèn)為,TypeScript 對他來說只是阻礙。不僅因?yàn)樗枰@式的編譯步驟,還因?yàn)樗妙愋途幊涛廴玖舜a,很影響開發(fā)體驗(yàn)。
無獨(dú)有偶,不久前,知名前端 UI 框架 Svelte 也宣布從 TypeScript 切換到 JavaScript。負(fù)責(zé) Svelte 編譯器的開發(fā)者說,改用 JSDoc 后,代碼不需要編譯構(gòu)建即可進(jìn)行調(diào)試 —— 簡化了編譯器的開發(fā)工作。
Svelte 不是第一個放棄 TypeScript 的前端框架。早在 2020 年,Deno 就遷移了一部分內(nèi)部 TypeScript 代碼到 JavaScript,以減少構(gòu)建時間。
如此一來,今年短期內(nèi)已經(jīng)有幾個項(xiàng)目從 TypeScript 切換到 JavaScript 了,這個狀況就很令人迷惑。難道從 TypeScript 切回 JavaScript 已經(jīng)成了當(dāng)下的新潮流?這難道不是在開歷史的倒車嗎?
TypeScript
由微軟發(fā)布于 2012 年的 TypeScript,其定位是 JavaScript 的一個超集,它的能力是以 TC39 制定的 ECMAScript 規(guī)范為基準(zhǔn)(即 JavaScript )。業(yè)內(nèi)開始用 TypeScript 是因?yàn)?TypeScript 提供了類型檢查,彌補(bǔ)了 JavaScript 只有邏輯沒有類型的問題,
對于大型項(xiàng)目、多人協(xié)作和需要高可靠性的項(xiàng)目來說,使用 TypeScript 是很好的選擇;靜態(tài)類型檢查的好處,主要包括:
- 類型安全
- 代碼智能感知
- 重構(gòu)支持
而 TS 帶來的主要問題則有:
- 某些庫的核心代碼量很小,但類型體操帶來了數(shù)倍的學(xué)習(xí)、開發(fā)和維護(hù)成本
- TypeScript 編譯速度緩慢,而 esbuild 等實(shí)現(xiàn)目前還不支持裝飾器等特性
- 編譯體積會因?yàn)楦鞣N重復(fù)冗余的定義和工具方法而變大
相比于 Svelte 的開發(fā)者因?yàn)椴粎捚錈┒鴹売?TS 的事件本身,其改用的 JSDoc 對于很多開發(fā)者來說,卻是一位熟悉的陌生人。
2. JSDoc:看我?guī)追窒駨那埃?/span>
早在 1999 年由 Netscape/Mozilla 發(fā)布的 Rhino -- 一個 Java 編寫的 JS 引擎中,已經(jīng)出現(xiàn)了類似 Javadoc 語法的 JSDoc 雛形
Michael Mathews 在 2001 年正式啟動了 JSDoc 項(xiàng)目,2007 年發(fā)布了 1.0 版本。直到 2011 年,重構(gòu)后的 JSDoc 3.0 已經(jīng)可以運(yùn)行在 Node.js 上
JSDoc 語法舉例
定義對象類型:
/**
* @typedef {object} Rgb
* @property {number} red
* @property {number} green
* @property {number} blue
*/
/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };
定義函數(shù)類型:
/**
* @callback Add
* @param {number} x
* @param {number} y
* @returns {number}
*/
const add = (x, y) => x + y;
定義枚舉:
/**
* Enumerate values type
* @enum {number}
*/
const Status = {
on: 1,
off: 0,
};
定義類:
class Computer {
/**
* @readonly Readonly property
* @type {string}
*/
CPU;
/**
* @private Private property
*/
_clock = 3.999;
/**
* @param {string} cpu
* @param {number} clock
*/
constructor(cpu, clock) {
this.CPU = cpu;
this._clock = clock;
}
}
在實(shí)踐中,多用于配合 jsdoc2md 等工具,自動生成庫的 API 文檔等。
隨著前后端分離的開發(fā)范式開始流行,前端業(yè)務(wù)邏輯也日益復(fù)雜,雖然不用為每個應(yīng)用生成對外的 API 文檔,但類型安全變得愈發(fā)重要,開發(fā)者們也開始嘗試在業(yè)務(wù)項(xiàng)目中使用 jsdoc。但不久后誕生的 Typescript 很快就接管了這一進(jìn)程。
但前面提到的 TS 的固有問題也困擾著開發(fā)者們,直到今年幾起標(biāo)志性事件的發(fā)生,將大家的目光拉回 JSDoc,人們驚訝地發(fā)現(xiàn):JSDoc 并沒有停留在舊時光中。
吾謂大弟但有武略耳,至于今者,學(xué)識英博,非復(fù)吳下阿蒙
除了 JSDoc 本身能力的不斷豐富,2018 年發(fā)布的 TypeScript 2.9 版本無疑是最令人驚喜的一劑助力;該版本全面支持了將 JSDoc 的類型聲明定義成 TS 風(fēng)格,更是支持了在 JSDoc 注釋的類型聲明中動態(tài)引入并解析 TS 類型的能力。
比如上文中的一些類型定義,如果用這種新語法,寫出來可以是這樣的:
定義對象類型:
/**
* @typedef {{ brand: string; color: Rgb }} Car
*/
/** @type {Rgb} */
const color = { red: 255, green: 255, blue: 255 };
定義函數(shù)類型:
/**
* @typedef {(x: number, y: number) => number} TsAdd
*/
/** @type {TsAdd} */
const add = (x, y) => x + y;
TS 中的聯(lián)合類型等也可以直接用:
/**
* Union type with pipe operator
* @typedef {Date | string | number} MixDate
*/
/**
* @param {MixDate} date
* @returns {void}
*/
function showDate(date) {
// date is Date
if (date instanceof Date) date;
// date is string
else if (typeof date === 'string') date;
// date is number
else date;
}
范型也沒問題:
/**
* @template T
* @param {T} data
* @returns {Promise<T>}
* @example signature:
* function toPromise<T>(data: T): Promise<T>
*/
function toPromise(data) {
return Promise.resolve(data);
}
/**
* Restrict template by types
* @template {string|number|symbol} T
* @template Y
* @param {T} key
* @param {Y} value
* @returns {{ [K in T]: Y }}
* @example signature:
* function toObject<T extends string | number | symbol, Y>(key: T, value: Y): { [K in T]: Y; }
*/
function toObject(key, value) {
return { [key]: value };
}
類型守衛(wèi):
/**
* @param {any} value
* @return {value is YOUR_TYPE}
*/
function isYourType(value) {
let isType;
/**
* Do some kind of logical testing here
* - Always return a boolean
*/
return isType;
}
至于動態(tài)引入 TS 定義也很簡單,不管項(xiàng)目本身是否支持 TS,我們都可以放心大膽地先定義好類型定義的 .d.ts 文件,如:
// color.d.ts
export interface Rgb {
red: number;
green: number;
blue: number;
}
export interface Rgba extends Rgb {
alpha: number;
}
export type Color = Rgb | Rbga | string;
然后在 JSDoc 中:
// color.js
/** @type {import('<PATH_TO_D_TS>/color').Color} */
const color = { red: 255, green: 255, blue: 255, alpha: 0.1 };
當(dāng)然,對于內(nèi)建了基于 JSDoc 的類型檢查工具的 IDE,比如以代表性的 VSCode 來說,其加持能使類型安全錦上添花;與 JSDoc 類型(即便不用TS語法也可以)對應(yīng)的 TS 類型會被自動推斷出來并顯示、配置了 //@ts-check后可以像 TS 項(xiàng)目一樣實(shí)時顯示類型錯誤等。這些都很好想象,在此就不展開了。
JSDoc 和 TS 能力的打通,意味著前者書寫方式的簡化和現(xiàn)代化,成為了通往 TS 的便捷橋梁;也讓后者有機(jī)會零成本就能下沉到業(yè)內(nèi)大部分既有的純 JS 項(xiàng)目中,這路是褲衩一下子就走寬了。
3. 用例:Protobuf+TS 的漸進(jìn)式平替
既然我們找到了一種讓普通 JS 項(xiàng)目也能快速觸及類型檢查的途徑,那也不妨想一想對于在那些短期內(nèi)甚至永遠(yuǎn)不會重構(gòu)為 TS 的項(xiàng)目,能夠復(fù)刻哪些 TS 帶來的好處呢?
對于大部分現(xiàn)代的前后端分離項(xiàng)目來說,一個主要的痛點(diǎn)就是核心的業(yè)務(wù)知識在前后端項(xiàng)目之間是割裂的。前后端開發(fā)者根據(jù) PRD 或 UI,各自理解業(yè)務(wù)邏輯,然后總結(jié)出各自項(xiàng)目中的實(shí)體、枚舉、數(shù)據(jù)派生邏輯等;這些也被成為領(lǐng)域知識或元數(shù)據(jù),其割裂在前端項(xiàng)目中反映為一系列問題:
- API 數(shù)據(jù)接口的入?yún)ⅰ㈨憫?yīng)類型模糊不清
- 表單項(xiàng)的很多默認(rèn)值需要硬編碼、多點(diǎn)維護(hù)
- 前后端對于同一概念的變量或動作命名各異
- mock 需要手寫,并常與最后實(shí)際數(shù)據(jù)結(jié)構(gòu)不符
- TDD缺乏依據(jù),代碼難以重構(gòu)
- VSCode 中缺乏智能感知和提示
對于以上問題,比較理想的解決方法是前端團(tuán)隊(duì)兼顧 Node.js 中間層 BFF 的開發(fā),這樣無論是組織還是技術(shù)都能最大程度通用。
- 但從業(yè)內(nèi)近年的諸多實(shí)踐來看,這無疑是很難實(shí)現(xiàn)的:即便前端團(tuán)隊(duì)有能力和意愿,這樣的 BFF 模式也難以為繼,此中既有 Node.js 技術(shù)棧面臨復(fù)雜業(yè)務(wù)不抗打的問題,更多的也有既有后端團(tuán)隊(duì)的天然抗拒問題。
- 一種比較成功的、前后端接受度都較好的解決方案,是谷歌推出的 ProtoBuf。
在通常的情況下,ProtoBuf(Protocol Buffers)的設(shè)計(jì)思想是先定義 .proto 文件,然后使用編譯器生成對應(yīng)的代碼(例如 Java 類和 d.ts 類型定義)。這種方式確保了不同語言之間數(shù)據(jù)結(jié)構(gòu)的一致性,并提供了跨語言的數(shù)據(jù)序列化和反序列化能力
- 但是這無疑要求前后端團(tuán)隊(duì)同時改變其開發(fā)方式,如果不是從零起步的項(xiàng)目,推廣起來還是有一點(diǎn)難度
因此,結(jié)合 JSDoc 的能力,我們可以設(shè)計(jì)一種退而求其次、雖不中亦不遠(yuǎn)矣的改造方案 -- 在要求后端團(tuán)隊(duì)寫出相對比較規(guī)整的實(shí)體定義等的前提下,編寫提取轉(zhuǎn)換腳本,定期或手動生成對應(yīng)的 JSDoc 類型定義,從而實(shí)現(xiàn)前后端業(yè)務(wù)邏輯的準(zhǔn)確同步。
比如,以一個Java的BFF項(xiàng)目為例,可以做如下轉(zhuǎn)換
枚舉:
public enum Color {
RED("#FF0000"), GREEN("#00FF00"), BLUE("#0000FF");
private String hexCode;
Color(String hexCode) {
this.hexCode = hexCode;
}
public String getHexCode() {
return hexCode;
}
}
public enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
轉(zhuǎn)換為:
/**
* @readonly
* @enum {String}
*/
export const Color = {
RED: '#FF0000',
GREEN: '#00FF00',
BLUE: '#0000FF',
}
/**
* @readonly
* @enum {Number}
*/
export const Day = {
MONDAY: 0,
TUESDAY: 1,
WEDNESDAY: 2,
THURSDAY: 3,
FRIDAY: 4,
SATURDAY: 5,
}
POJO:
public class MyPojo {
private Integer id;
private String name;
public Integer getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
轉(zhuǎn)換為:
/**
* @typedef {Object} MyPojo
* @property {Integer} [id]
* @property {String} [name]
*/
在轉(zhuǎn)換的方法上,理論上如果能基于 AST 等手段當(dāng)然更好,但如本例中的 Java 似乎沒有特別成熟的轉(zhuǎn)換工具,java-parser 等庫文檔資料又過少。
而基于正則的轉(zhuǎn)換雖然與后端具體寫法耦合較大,但也算簡單靈活。這里給出一個示例 demo 項(xiàng)目供參考:https://github.com/tonylua/java-to-type
