舊專案 TypeScript 改造問題與解決方案記

黃Java發表於2018-07-24

概述

由於本次改造的專案為一個通過NPM進行釋出的基礎服務包,因此本次採用TypeScript進行改造的目標是移除Babel全家桶,減小包體積,同時增加強型別約束從而避免今後開發時可能的問題。

本次改造使用的是TypeScript v2.9.2,採用Webpack v4.16.0進行打包編譯。開發工具使用的是VSCode,使用中文語言包。預期目標是直接將TypeScript程式碼通過loader直接編譯為ES5的程式碼。

本文中涉及的問題有部分是TypeScript配置和使用的問題,也有部分是VSCode本身配置相關問題。

改造問題記錄與分析

VSCode相關

“無法找到相關模組”報錯

在專案中,如果我們使用了webpack.alias,可能會提示找不到模組。

具體錯誤如下:

終端編譯報錯:TS2307: Cannot find module '_utils/index'.
編輯器報錯:[ts]找不到模組“_utils/index”。
複製程式碼

這是由於編輯器無法讀取對應的別名資訊導致的。

此時我們需要檢查對應的模組是否存在。如果確認模組存在,且終端編譯編譯時不報錯,而只是編輯器報錯,則是因為編輯器無法讀取webpack配置,我們需要增加另外的配置。

解決方法:除了配置webpack.alias,還需要配置相對應的tsconfig.json,具體配置如下所示:

"compilerOptions": {
    "baseUrl": ".",
    "paths": {
        "_util/*": [
            "src/core/utils/*"
        ]
    }
}
複製程式碼

注:如果配置了tsconfig.json以後還是報錯的話,需要重啟下VSCode,猜測是由於VSCode只在專案載入時讀取相關配置資訊。在JavaScript專案中的jsconfig.json同理。

TypeScript相關

物件屬性賦值報錯

在JavaScript中,我們經常會宣告一個空物件,然後再給這個屬性進行賦值。但是這個操作放在TypeScript中是會發生報錯的:

let a = {};

a.b = 1;
// 終端編譯報錯:TS2339: Property 'b' does not exist on type '{}'.
// 編輯器報錯:[ts] 型別“{}”上不存在屬性“b”。
複製程式碼

這是因為TypeScript不允許增加沒有宣告的屬性。

因此,我們有兩個辦法來解決這個報錯:

  1. 在物件中增加屬性定義(推薦)。具體方式為:let a = {b: void 0};。這個方法能夠從根本上解決當前問題,也能夠避免物件被隨意賦值的問題。

  2. 在物件中新增型別定義(推薦)。具體方式為如下:

    interface obj {
        [propName: string]: any
    };
    let a: obj = {};
    
    a.a = 1;
    複製程式碼

    這樣也能夠避免報錯問題,並且不引入全物件any情況。

  3. a物件增加any屬性(應急)。具體方式為:let a: any = {};。這個方法能夠讓TypeScript型別檢查時忽略這個物件,從而編譯通過不報錯。這個方法適用於大量舊程式碼改造的情況。

Window物件屬性賦值報錯

與上一個情況類似,我們給一個物件中賦值一個不存在的屬性,會出現編輯器和編譯報錯:

window.a = 1;
// 終端編譯報錯:TS2339: Property 'a' does not exist on type 'Window'.
// 編輯器報錯:[ts] 型別“Window”上不存在屬性“a”。
複製程式碼

這也是因為TypeScript不允許增加沒有宣告的屬性導致的。

由於我們沒有辦法宣告windows屬性的值(或者說很困難),因此我們需要通過下面這一種方式來解決:

  1. 我們在windows使用時增加一個型別轉換,即(window as any).a = 1;。這樣就能夠保證編輯器和編譯時不會出錯。不過該方法只建議用於舊專案改造,我們還是要儘量避免在window物件上面增加屬性,應該通過一個全域性的資料管理器來進行資料存取。

ES2015 Object新增的原型鏈上的方法報錯

在專案中,使用到了一些Object原型鏈上面的一些ES2015新增的方法,如Object.assignObject.values等,此時編譯會失敗,同時VSCode會提示報錯:

終端編譯報錯:TS2339: Property 'assign' does not exist on type 'ObjectConstructor'.
編輯器報錯:[ts] 型別“ObjectConstructor”上不存在屬性“assign”。
複製程式碼

這是由於我們在tsconfig.json中指定的target是ES5,而TypeScript並沒有相關的polyfill,因此我們無法使用ES2015中新增的方法。

通過以上分析,我們可以使用如下方法解決:

  1. 可以使用lodash工具集中的相關方法,安裝時需要安裝lodash.assign@types/lodash.assign。並且lodash.assign是一個CMD規範的包,需要通過import _assign = require('lodash.assing');方式引入。

  2. 我們可以使用rest寫法,例如let a = {...b};,也能夠達到一級淺拷貝的效果,具體效果如下:

    image.png

ES2015新增的資料結構Map初始化報錯

將ES2015的程式碼改造成為TypeScript程式碼時,如果你使用了ES2015新增的Map型別,那在編輯器還是終端編譯中編譯時都會報錯:

終端編譯報錯:TS2693: 'Map' only refers to a type, but is being used as a value here.
編輯器報錯報錯:[ts] “Map”僅表示型別,但在此處卻作為值使用。
複製程式碼

這是由於TypeScript並沒有提供相關的資料型別,也沒有對應的polyfill。

因此,我們解決這個問題的思路有三種:

  1. tsconfig.json配置中的target屬性改為es6,即輸出符合ES2015規範的程式碼。因為ES2015存在全域性的Promise物件,因此編譯和編輯器都不會報錯。該方法優點為配置簡單,無需改動程式碼,缺點為需要高階瀏覽器的支援或者Babel全家桶的支援。
  2. 捨棄Map型別,改用Object進行替代。這種改造比較費時費力,適用於工作量較小和不願意引入其他檔案的場景。
  3. 自行實現或者安裝一個Map包。這種方法改造成本較小,缺點就是會引入額外的程式碼或者包,並且程式碼效率無法保證。例如ts-maptypescript-map,這兩個包的查詢效率都是o(n),低於原生型別的Map。因此推薦自己使用Object實現一個簡單的Map,具體實現方式可以去網上找相關的Map原理分析與實踐(大致原理為使用多個Object,儲存不同型別元素時使用不同容器,避免型別轉換問題)。

ES2015新增的Promise使用報錯

將ES2015的程式碼改造成為TypeScript程式碼時,如果你使用了ES2015的新增的Promise型別,那在編輯器還是終端編譯編譯時都會報錯:

終端編譯報錯: TS2693: 'Promise' only refers to a type, but is being used as a value here.
編輯器報錯:[ts] “Promise”僅表示型別,但在此處卻作為值使用。
複製程式碼

這是由於TypeScript並沒有提供Promise資料型別,也沒有對應的polyfill。

因此,我們解決這個問題的思路仍然有三種:

  1. tsconfig.json配置檔案配置中的target屬性改為es6,即輸出符合ES2015規範的程式碼。因為ES2015存在全域性的Promise物件,因此編譯和編輯器都不會報錯。該方法優點為配置簡單,無需改動程式碼,缺點為需要高階瀏覽器的支援或者Babel全家桶的支援。

  2. 引入一個Promise庫,如bluebird等比較知名的Promise庫。在安裝bluebird時需要同時安裝@types/bluebird宣告檔案。缺點就是引入的Promise庫較大,而且如果你的庫作為一個基礎庫時,可能會與其他的呼叫方的Promise庫產生衝突。

  3. tsconfig.json配置檔案中增加lib。此方法的原理是讓TypeScript編譯時引用外部的Promise物件,因此在編譯時不會報錯。此方式優點是不會引入任何其他程式碼,但是缺點是一定要保證在引用此庫的前提下,一定存在Promise物件。具體配置如下:

    "compilerOptions": {
    	"lib": ["es2015.promise"]
    }
    複製程式碼

SetTimeout使用報錯

將ES2015程式碼改造成TypeScript程式碼時,如果使用了setTimeout和setInterval函式時,可能會出現無法找到該函式的報錯:

終端編譯報錯:TS2304: Cannot find name 'setTimeout'.
編輯器報錯:[ts] 找不到名稱“setTimeout”。
複製程式碼

這是由於編輯器和編譯時不知道當前程式碼執行環境導致的。

因此,我們解決這個問題的思路有兩種:

  1. tsconfig.json配置檔案中增加lib。讓TypeScript能夠知道當前的程式碼容器。具體示例如下:

    "compilerOptions": {
    	"lib": ["dom"]
    }
    複製程式碼
  2. 安裝@types/node。該方法適用於node環境下或者採用webpack打包時可以引入node程式碼。該方法直接通過npm install @types/node即可安裝完成,解決報錯問題。

模組引用和匯出報錯

在ES2015的程式碼中,我們可以通過@babel/plugin-proposal-export-default-from外掛來直接匯出引入的檔案,具體示例如下:

export Session from './session'; // 報錯
export * from '_models/read-item'; // 不報錯
複製程式碼

而在TypeScript中,這種寫法是會報錯的:

終端編譯報錯:TS1128: Declaration or statement expected.
編輯器報錯:[ts] 應為宣告或語句。
複製程式碼

這是由於兩者的模組語法不一樣導致的。

因此,我們解決這個問題只需要用下面這一種方法:

  1. 將上面的export from的語法稍加調整來適配TypeScript語法。具體改造如下:

    export {default as Session} from '_models/session'; //調整後不報錯
    export * from '_models/read-item';// 之前不報錯不需要調整
    複製程式碼

泛型定義

我們在專案中經常會遇到這種情況,我們需要保證傳入的屬性型別的同時,還需要保證其與某個函式的引數一致,如:

interface props {
    value: number | string, 
    onChange: (v: string | number) => void // 引數型別值需要與value一致
}
複製程式碼

為了解決這個問題,我們需要用到泛型定義:

interface Props<T extends string | number> {
    value: T,
    onChange: (v: T) => void
}
複製程式碼

此時,當value的型別確定時,引數的型別也就變得和value一樣確定了。

模組引用

當我們使用TypeScript時,經常會出現引用其他模組甚至是JavaScript其他包的情況。在TypeScript中,有多重不同的匯出方式,不同的匯出方式也對應著不同的引用方式。

目前我在專案改造中,遇到的模組有這麼幾種方式:

  1. CMD規範。
  2. ES2015 Module規範。

而對於這幾種模組,我們也有不同的匯入方式:

import _assign = require('lodash.assign'); //CMD規範
import constant from './constant'; // ES2015 Module規範
複製程式碼

如果你引入的檔案是一個非TypeScript而是JavaScript檔案時,你可能還需要增加宣告檔案。我們可以通過如下方法來新增宣告檔案:

  1. 增加@types檔案。這個方式針對於一些比較出名的類庫可以使用此方法。

  2. 在.d.ts檔案中增加宣告,這個宣告全域性有效。具體方式如下:

    declare module 'promiz';
    複製程式碼

    對於JSON檔案,你也需要採用這種宣告方式,具體方式如下:

    declare module "*.json" {
        const value: any;
        export const version: string;
        export default value;
    }
    複製程式碼

通過以上方法,我們就可以應對不同模組的規範和不同型別的檔案。

TypeScript區域性替換

在進行重構改造的時候,我們在最開始可能只能逐個模組進行替換。我們需要新的TypeScript檔案和舊的JavaScript檔案能夠和平共存進行編譯執行。

針對這種需求,我們只需要在webpack編譯的loader中增加相關ts檔案的配置,並且在extension中增加.ts字尾的支援。相關配置如下:

{
    module: {
        rules: [
            {
                test: /ts$/,
                use: [{
                    loader: 'ts-loader',
                    options: {
                        silent: process.env.env === 'production' ? true : false
                    }
                }]
            }
        ]
    },
    extensions: ['.ts', '.js']
}
複製程式碼

然後,我們只需要在JavaScript中檔案引入時,帶上.ts字尾即可,如下例所示:

// 本人之前使用的是CMD規範,因此引入ES2015模組需要訪問default
var EventEmitter = require('eventemitter3');
var Session = require('./session.ts').default;
複製程式碼

這樣,我們就可以逐步的進行模組替換和改造,而不需要進行大規模的檔案替換和改名。

總結

在做專案TypeScript改造的過程中,遇到了不少大大小小的坑。很多問題在網上都沒有解決方案或者沒有說明白具體的解決步驟,因此希望通過這一篇文章來幫助大家在進行TypeScript遷移時避免在我踩過的坑上再浪費時間。

相關文章