Node.js項目TypeScript改造指南

做者:陳曉強javascript

前言

若是你有一個 Node.js 項目,並想使用 TypeScript 進行改造,那本文對你或許會有幫助。TypeScript 愈來愈火,本文不講爲何要使用 TypeScript,也不講基本概念。本文講的是如何將一箇舊的 Node.js 項目使用 TypeScript 進行改造,包括目錄結構調整、TypeScript-ESLint 配置、tsconfig 配置、調試、常見錯誤處理等。因爲篇幅有限,Node.js 項目能集成的技術也是五花八門,未覆蓋到的場景還請見諒。html

步驟1、調整目錄結構

Node.js 程序,因爲對新語法的支持比較快(如async/await從v7.6.0開始支持),大部分場景是不須要用到 babel、webapck 等編譯工具的,所以也不多有編譯文件的dist目錄,而 TypeScript 是須要編譯的,因此重點是要獨立出一個源碼目錄編譯目標目錄,推薦的目錄結構以下,另外,根據不一樣技術棧還有一堆其餘的配置文件如 prettier、travis 等等這裏就省略了。vue

|-- assets            # 存放項目的圖片、視頻等資源文件
|-- bin               # CLI命令入口,require('../dist/cli'),注意文件頭加上#!/usr/bin/env node
|-- dist              # 項目使用ts開發,dist爲編譯後文件目錄,注意package.json中main字段要指向dist目錄
|-- docs              # 存放項目相關文檔
|-- scripts           # 對應package.json中scripts字段須要執行的腳本文件
|-- src               # 源碼目錄,注意此目錄只放ts文件,其餘文件如json、模板等文件放templates目錄
    |-- sub           # 子目錄
    |-- cli.ts        # cli入口文件
    |-- index.ts      # api入口文件
|-- templates         # 存放json、模板等文件
|-- tests             # 測試文件目錄
|-- typings           # 存放ts聲明文件,主要用於補充第三方包沒有ts聲明的狀況
|-- .eslintignore     # eslint忽略規則配置
|-- .eslintrc.js      # eslint規則配置
|-- .gitignore        # git忽略規則
|-- package.json      # 
|-- README.md         # 項目說明
|-- tsconfig.json     # typescript配置,請勿修改
複製代碼

步驟2、TypeScript安裝與配置

目錄結構調整後,在你的項目根目錄執行java

  1. npm i typescript -D,安裝 typescript,保存到 dev 依賴
  2. node ./node_modules/.bin/tsc --init,初始化 TypeScript 項目,生成一個 tsconfig.json 配置文件

若是第1步選擇全局安裝,那第2步中能夠直接使用tsc --initnode

執行初始化命令後會生成一份默認配置文件,更詳細的配置及說明能夠自行查閱官方文檔,這裏根據前面的項目結構貼出一份基本的推薦配置,部分配置下文會解釋。react

{
  "compilerOptions": {
    // "incremental": true,                   /* 增量編譯 提升編譯速度*/
    "target": "ES2019",                       /* 編譯目標ES版本*/
    "module": "commonjs",                     /* 編譯目標模塊系統*/
    // "lib": [],                             /* 編譯過程當中須要引入的庫文件列表*/
    "declaration": true,                      /* 編譯時建立聲明文件 */
    "outDir": "dist",                         /* ts編譯輸出目錄 */
    "rootDir": "src",                         /* ts編譯根目錄. */
    // "importHelpers": true,                 /* 從tslib導入輔助工具函數(如__importDefault)*/
    "strict": true,                           /* 嚴格模式開關 等價於noImplicitAny、strictNullChecks、strictFunctionTypes、strictBindCallApply等設置true */
    "noUnusedLocals": true,                   /* 未使用局部變量報錯*/
    "noUnusedParameters": true,               /* 未使用參數報錯*/
    "noImplicitReturns": true,                /* 有代碼路徑沒有返回值時報錯*/
    "noFallthroughCasesInSwitch": true,       /* 不容許switch的case語句貫穿*/
    "moduleResolution": "node",               /* 模塊解析策略 */
    "typeRoots": [                            /* 要包含的類型聲明文件路徑列表*/
      "./typings",
      "./node_modules/@types"
      ],                      
    "allowSyntheticDefaultImports": false,    /* 容許從沒有設置默認導出的模塊中默認導入,僅用於提示,不影響編譯結果*/
    "esModuleInterop": false                  /* 容許編譯生成文件時,在代碼中注入工具類(__importDefault、__importStar)對ESM與commonjs混用狀況作兼容處理*/

  },
  "include": [                                /* 須要編譯的文件 */
    "src/**/*.ts",
    "typings/**/*.ts"
  ],
  "exclude": [                                /* 編譯須要排除的文件 */
    "node_modules/**"
  ],
}
複製代碼

步驟3、源碼文件調整

將全部.js文件改成.ts文件

這一步比較簡單,能夠根據自身項目狀況,藉助 gulp 等工具將全部文件後綴改爲ts並提取到src目錄。git

模板文件提取

因爲 TypeScript 在編譯時只能處理 ts、tsx、js、jsx 這幾類文件,所以項目中若是用到了一些模板如 json、html 等文件,這些是不須要編譯的,能夠提取到 templates 目錄。github

package.json中添加scripts

前面咱們將 typescript 包安裝到項目依賴後,避免每次執行編譯時都須要輸入node ./node_modules/.bin/tsc(全局安裝忽略,不建議這麼作,其餘同窗可能已經全局安裝了,但可能會與你項目所依賴的 typescript 版本不一致),在 package.json 中添加如下腳本。後續就能夠直接經過npm run build或者npm run watch來編譯了。web

{
  "scripts":{
    "build":"tsc",
    "watch":"tsc --watch"
  }
}
複製代碼

步驟4、TypeScript代碼規範

假設你用的 IDE 是 VSCode,TypeScript 與 VSCode 都是微軟親兒子,用 TypeScript 你就老老實實用 VSCode 吧,上述步驟之後,ts 文件中會出現大量飄紅警告。相似這樣: typescript

報錯
先不要着急去解決錯誤,由於還須要對 TypeScript 添加 ESLint 配置,避免改多遍,先把 ESLint 配置好,固然,你若是喜歡 Prettier,能夠把它加上,本文就不介紹如何集成 Prettier 了。

TypeScript-ESLint

早期的 TypeScript 項目通常使用 TSLint ,但2019年初 TypeScript 官方決定全面採用 ESLint,所以 TypeScript 的規範,直接使用 ESLint 就好,首先安裝依賴:
npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin -D 接着在根目錄下新建.eslintrc.js文件,最簡單的配置以下

module.exports = {
  'parser':'@typescript-eslint/parser',  //ESLint的解析器換成 @typescript-eslint/parser 用於解析ts文件
  'extends': ['plugin:@typescript-eslint/recommended'], // 讓ESLint繼承 @typescript-eslint/recommended 定義的規則
  'env': {'node': true}
}
複製代碼

因爲 @typescript-eslint/recommended 的規則並不完善,所以還須要補充ESLint的規則,如禁止使用多個空格(no-multi-spaces)等。可使用standard,安裝依賴。

若是你項目已經在使用 ESLint,並有本身的規範,則不用再安裝依賴,直接調整 .eslintrc.js 配置便可

npm i eslint-config-standard eslint-plugin-import eslint-plugin-node eslint-plugin-promise eslint-plugin-standard -D
以上幾個包,eslint-config-standard 是規則集,後面幾個都是它的依賴。接來下調整. eslintrc.js 配置:

module.exports = {
  'parser':'@typescript-eslint/parser', 
  'extends': ['standard','plugin:@typescript-eslint/recommended'], //extends這裏加上standard規範
  'env': {'node': true}
}
複製代碼

VSCode中集成ESLint配置

爲了開發方便咱們能夠在 VSCode 中集成 ESLint 的配置,一是用於實時提示,二是能夠在保存時自動 fix。

vscode-demo

  1. 安裝 VSCode 的 ESLint 插件
  2. 修改 ESLint 插件配置:設置 => 擴展 => ESLint => 打鉤(Auto Fix On Save) => 在 settings.json 中編輯,如圖:
    VSCode配置ESLint
  3. 因爲 ESLint 默認只校驗 .js 文件,所以須要在 settings.json 中添加 ESLint 相關配置:
{
    "eslint.enable": true,  //是否開啓vscode的eslint
    "eslint.autoFixOnSave": true, //是否在保存的時候自動fix 
    "eslint.options": {    //指定vscode的eslint所處理的文件的後綴
        "extensions": [
            ".js",
            // ".vue",
            ".ts",
            ".tsx"
        ]
    },
    "eslint.validate": [     //肯定校驗準則
        "javascript",
        "javascriptreact",
        // {
        // "language": "html",
        // "autoFix": true
        // },
        // {
        // "language": "vue",
        // "autoFix": true
        // },
        {
            "language": "typescript",
            "autoFix": true
        },
        {
            "language": "typescriptreact",
            "autoFix": true
        }
    ]
}
複製代碼
  1. 若遇到 VSCode 沒法提示,可嘗試重啓下 ESLint 插件、將項目移出工做區再從新加回來。

步驟5、解決報錯

這個步驟內容有點多,能夠細品一下。注意,下述解決報錯有些地方用了「any大法」(不推薦),這是爲了能讓項目儘快 run 起來,畢竟是舊項目改造,不可能一步到位。

找不到模塊

Node.js 項目是 commonjs 規範,使用 require 導出一個模塊:const path = require('path');首先看到的是 require 處的錯誤:

Cannot find name 'require'. Do you need to install type definitions for node? Try `npm i @types/node`.ts(2580)
複製代碼

此時你可能會想到改爲 TypeScript 的 import 寫法:import * as path from 'path',接着你會看到在 path 處的錯誤:

找不到模塊「path」。ts(2307)
複製代碼

這兩個是同一個問題,path 模塊和 require 都是 Node.js 的東西,須要安裝 Node.js 的聲明文件,npm i @types/node -D

TypeScript的import問題

安裝完 Node 的聲明文件後,以前的寫法:const path = require('path')在 require 處仍然會報錯,不過此次不是 TypeScript 報錯,而是 ESLint 報錯:

Require statement not part of import statement .eslint(@typescript-eslint/no-var-requires)
複製代碼

意思是不推薦這種導入寫法,由於這種 commonjs 寫法導出來的對象是 any,沒有類型支持。這也是爲啥前面說不用着急改,先作好 ESLint 配置。

接着咱們將模塊導入改爲 TypeScript 的 import,這裏共有4種寫法,分別講一下須要注意的問題。

import * as mod from 'mod'

針對 commonjs 模塊,使用此寫法,咱們來看看編譯先後的區別,注意咱們改造的是 Node.js 項目,所以咱們 tsconfig 中配置"module": "commonjs"
test.ts 文件:

import * as path from 'path'
console.log(path);
複製代碼

編譯後的 test.js 文件:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const path = require("path");
console.log(path);
複製代碼

能夠看到,TypeScript 對編譯後給模塊加上了__esModule:true,標識這是一個 ES6 模塊,若是你在 tsconfig 中配置"esModuleInterop":true,編譯後的 test.js 文件以下:

"use strict";
var __importStar = (this && this.__importStar) || function (mod) {
    if (mod && mod.__esModule) return mod;
    var result = {};
    if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k];
    result["default"] = mod;
    return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
const path = __importStar(require("path"));
console.log(path);
複製代碼

能夠看到針對 import * 寫法,在編譯成 commonjs 後包裹了一個__importStar工具函數,其做用是:若是導入模塊 __esModule 屬性爲 true,則直接返回 module.exports。不然返回module.exports.defalut = module.exports(消除了循環引用)。
若是你不想在編譯後的每一個文件中都注入這麼一段工具函數,能夠配置"importHelpers":true,編譯後的 test.js 文件以下:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const tslib_1 = require("tslib");
const path = tslib_1.__importStar(require("path"));
console.log(path);
複製代碼

細心的同窗可能會發現,"esModuleInterop":true這個配置添加的__importStar在以上場景除了增長 require 複雜度,沒什麼其餘做用。那是否能夠去掉這個配置呢,咱們接着往下看。

若是你用 import 導入的項目內的其餘源文件,因爲原先 commonjs 寫法,會提示你文件「/path/to/project/src/mod.ts」不是模塊。ts(2306),此時,須要將被導入的模塊修改成 ES6 的 export 寫法

import { fun } from 'mod'

修改 test.ts 文件,依然是配置了:"esModuleInterop":true

import { resolve } from 'path'
console.log(resolve)
複製代碼

編譯後的 test.js 文件

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = require("path");
console.log(path_1.resolve);
複製代碼

能夠看出導出單個屬性時,並不會添加工具類,但會將單個屬性導出修改成整個模塊導出,並將原來的函數調用表達式修改成成員函數調用表達式。

import mod from 'mod'

這個語法是導出默認值,要特別注意。

照例修改 test.ts 文件,配置"esModuleInterop":true,爲了方便展現,配置"importHelpers":false

import path from 'path'
console.log(path)
複製代碼

編譯後的 test.js 文件:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
console.log(path_1.default);

複製代碼

能夠看到針對 import mod 這種寫法,在編譯成 commonjs 後包裹了一個__importDefault工具函數,其做用是:若是導入模塊__esModule爲 true,則直接返回module.exports。 不然返回{default:module.exports}。這個是針對沒有默認導出的模塊的一種兼容,fs 模塊是 commonjs,並無__esModule屬性,使用modules.exports導出。上述代碼中的path_1實際是{default:module.exports},所以path_1.default指向的是原 path 模塊,能夠看出轉換是正常的。

但這種方式是有個陷阱,舉個例子,若是有第三方模塊,其文件是用 babel 或者也是 ts 轉換過的,那其模塊代碼頗有可能包含了 __esModule 屬性,但同時沒有exports.default導出,此時就會出現 mod.default 指向的是undefined更要命的是,IDE和編譯器沒有任何報錯。若是這個最基本的類型檢查都解決不了,那我要 TypeScript 何用?

所幸,tsconfig 提供了一個配置allowSyntheticDefaultImports,意思是容許從沒有設置默認導出的模塊中默認導入,須要注意的是,這個屬性並不會對代碼的生成有任何影響,僅僅是給出提示。另外,在配置"module": "commonjs"時,其值是和esModuleInterop同步的,也就是說咱們前面設置了"esModuleInterop":true,至關於同時設置了"allowSyntheticDefaultImports":true。這個容許也就是不會提示。

手動修改"allowSyntheticDefaultImports":false後,會發現 ts 文件中import path from 'path'處出現提示模塊「"path"」沒有默認導出。ts(1192),經過這個提示,咱們將其修改成import * as path from path,能夠有效避免上述陷阱

import mod = require('mod');

這種寫法有點奇怪,乍一看,一半的 ES6 模塊寫法和一半的 commonjs 寫法。其實這是針對早期的聲明文件,使用了export = mod語法進行導出。所以若是碰上這種聲明文件,就使用此種寫法。拿第三方包 moment 舉例:
你原來的寫法是const moment = require('moment'); moment();
當你改爲import * as moment from 'moment'時,moment();語句處會提示:

This expression is not callable.
  Type 'typeof moment' has no call signatures.ts(2349)
gulp-task.ts(15, 1): Type originates at this import. A namespace-style import cannot be called or constructed, and will cause a failure at runtime. Consider using a default import or import require here instead.
複製代碼

提示你使用default導入或import require寫法,當你改爲default導入時:import moment from'moment'; moment(); ,則在導入語句處會提示:

Module '"/path/to/project/src/moment"' can only be default-imported using the 'esModuleInterop' flagts(1259)
moment.d.ts(736, 1): This module is declared with using 'export =', and can only be used with a default import when using the 'esModuleInterop' flag.
複製代碼

改爲import moment = require('moment'),則沒有任何報錯,對應的類型檢測也都正常。

新的 ts 聲明文件寫法(declare module 'mod'),如前面所說的path模塊,也支持此種 Import assignment 寫法,但建議仍是不要這樣寫了。

import小結

看完後再來回顧前面的問題:是否能夠去掉這個配置"esModuleInterop":true
我的認爲在 Node.js 場景是能夠去掉的我並不想看到那兩個多餘的工具函數。 但考慮到一些導入 ES6 模塊的場景,可能須要保留,這裏就再也不討論了,須要注意的是手動配置"allowSyntheticDefaultImports":false避免陷阱
解決了 import 問題,其實問題就解決一大半了,確保了你編譯後的文件引入的模塊不會出現 undefined。

找不到聲明文件

部分第三方包,其包內沒有 ts 聲明文件,此時報錯以下:

沒法找到模塊「mod」的聲明文件。「/path/to/project/src/index.js」隱式擁有 "any" 類型。
  Try `npm install @types/mod` if it exists or add a new declaration (.d.ts) file containing `declare module 'mod';`ts(7016)
複製代碼

根據提示安裝對應包便可,注意添加 -D 保存到 dev 依賴,注意安裝對應版本。好比你安裝了 gulp@3 的版本,就不要安裝 gulp@4 的 @types/gulp

極少狀況,第三方包內既沒有聲明文件,對應的@types/mod包也沒有,此時爲了解決報錯,只能本身給第三方包添加聲明文件了。咱們將聲明文件補充到typings文件夾中,以包名做爲子目錄名,最簡單的寫法以下,這樣 IDE 和 TypeScript 編譯便不會報錯了。

declare module 'mod'
複製代碼

至於爲何須要放在 typings 目錄,而且以包名做爲子包目錄,由於不這樣寫,ts-node(下文會提到)識別不了,暫且按照 ts-node 的規範來吧。

Class構造函數this.xx初始化報錯

在 Class 的構造函數中對 this 屬性進行初始化是常見作法,但在 ts 中,你得先定義。全部 this 屬性,都要先聲明,相似這樣:

class Person {
  name: string;
  constructor (name:string) {
    this.name = name;
  }
}
複製代碼

固然,若是你代碼比較多,改造太耗時間,那就用'any大法'吧,每個屬性直接用 any 就完事了。

對象屬性賦值報錯

動態對象是 js 的特點,我先定義個對象,無論啥時候我均可以直接往裏面加屬性,這種報錯,最快的改造辦法就是給對象聲明 any 類型。再次聲明,正確的姿式是聲明 Interface 或者 Type,而不是 any,此處用 any 只是爲了快速改造舊項目讓其能先 run 起來。

let obj:any = {};
obj.name = 'string'
複製代碼

參數「arg」隱式具備「any」類型

const init = (opt: any) => {
  console.log(opt)
}
複製代碼

除了參數隱式 any 外,此處還會有警告Missing return type on function.eslint(@typescript-eslint/explicit-function-return-type),意思是方法須要有返回值,只是警告,不影響項目運行,先忽略,後續再完善。

未使用的函數參數

const result = code.replace(/version=(1)/g, function (_a: string, b: number): string {
  return `version=${++b}`
})
複製代碼

有些回調函數參數多是用不上的,將參數名字改爲_或者_開頭

函數中使用this

根據寫法不一樣,大概會有如下4種報錯:

  1. 類型「NodeModule」上不存在屬性「name」。ts(2339)
  2. 類型「typeof globalThis」上不存在屬性「name」。ts(2339)
  3. "this" 隱式具備類型 "any",由於它沒有類型註釋。ts(2683)
  4. The containing arrow function captures the global value of 'this'.ts(7041)

處理方式是將 this 做爲函數參數,並做爲第一個參數,編譯後會自動去掉第一個 this 參數。

export default function (this:any,one:'string') {
  this.name = 'haha';
}
複製代碼

步驟6、調試配置

通過以上步驟,你的項目就能 run 起來了,雖然有不少警告和 any,但好歹已經算是走過來了,接下來就是解決調試問題。

方法1、調試生成後的dist文件

VSCode 參考配置(/path/to/project/.vscode/launch.json)以下

{
  "configurations": [{
    "type": "node",
    "request": "launch",
    "name": "debug",
    "program": "/path/to/wxa-cli/dist/cli.js",
    "args": [
        "xx"
    ]
  }]
}
複製代碼

VSCode調試js

方法2、直接調試ts文件

使用 ts-node進 行調試,VSCode 參考配置以下,詳見ts-node

{
  "configurations": [{
    "type": "node",
    "request": "launch",
    "name": "debug",
    "runtimeArgs": [
      "-r",
      "ts-node/register"
    ],
    "args": [
      "${workspaceFolder}/src/cli.ts",
      "xx"
    ]
  }]
}
複製代碼

VSCode調試ts

步驟7、類型增強、消除any

接下來要作的就是補充 Interface、Type,逐步將代碼中的被業界噴得體無完膚的 any 幹掉,但不要妄想去掉全部 any ,js 語言說到底仍是動態語言,TypeScript 雖然是其超集往靜態語言靠,但要作到 Java 這種純靜態語言程度仍是有一段距離的。

到這就算結束了,文中只涉及到了工具類的 Node.js 項目改造,場景有限,並不能表明全部 Node.js 項目,但願能對你們有所幫助。


若是你以爲這篇內容對你有價值,請點贊,並關注咱們的官網和咱們的微信公衆號(WecTeam),每週都有優質文章推送:

WecTeam
相關文章
相關標籤/搜索