Node.js項目TypeScript改造指南
- 2019 年 12 月 24 日
- 筆記
前言
如果你有一個 Node.js 項目,並想使用 TypeScript 進行改造,那本文對你或許會有幫助。
TypeScript 越來越火,本文不講為什麼要使用 TypeScript,也不講基本概念。本文講的是如何將一個舊的 Node.js 項目使用 TypeScript 進行改造,包括目錄結構調整、TypeScript-ESLint 配置、tsconfig 配置、調試、常見錯誤處理等。
由於篇幅有限,Node.js 項目能集成的技術也是五花八門,未覆蓋到的場景還請見諒。
步驟一、調整目錄結構
Node.js 程式,由於對新語法的支援比較快(如async/await從v7.6.0開始支援),大部分場景是不需要用到 babel、webapck 等編譯工具的,因此也很少有編譯文件的dist目錄,而 TypeScript 是需要編譯的,所以重點是要獨立出一個源碼目錄
和編譯目標目錄
,推薦的目錄結構如下,另外,根據不同技術棧還有一堆其他的配置文件如 prettier、travis 等等這裡就省略了。
|-- 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配置,請勿修改
步驟二、TypeScript安裝與配置
目錄結構調整後,在你的項目根目錄執行:
(1)npm i typescript -D
,安裝 typescript,保存到 dev 依賴
(2)node ./node_modules/.bin/tsc --init
,初始化 TypeScript 項目,生成一個 tsconfig.json 配置文件
備註:如果第1步選擇全局安裝,那第2步中可以直接使用tsc --init
。
執行初始化命令後會生成一份默認配置文件,更詳細的配置及說明可以自行查閱官方文檔,這裡根據前面的項目結構貼出一份基本的推薦配置
,部分配置下文會解釋。
{ "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/**" ], }
步驟三、源碼文件調整
將所有.js文件改為.ts文件
這一步比較簡單,可以根據自身項目情況,藉助 gulp 等工具將所有文件後綴改成ts並提取到src目錄。
模板文件提取
由於 TypeScript 在編譯時只能處理 ts、tsx、js、jsx 這幾類文件,因此項目中如果用到了一些模板如 json、html 等文件,這些是不需要編譯的,可以提取到 templates 目錄。
packaeg.json中添加scripts
前面我們將 typescript 包安裝到項目依賴後,避免每次執行編譯時都需要輸入node ./node_modules/.bin/tsc
(全局安裝忽略,不建議這麼做,其他同學可能已經全局安裝了,但可能會與你項目所依賴的 typescript 版本不一致),在 package.json 中添加以下腳本。後續就可以直接通過npm run build
或者npm run watch
來編譯了。
{ "scripts":{ "build":"tsc", "watch":"tsc --watch" } }
步驟四、TypeScript程式碼規範
假設你用的 IDE 是 VSCode,TypeScript 與 VSCode 都是微軟親兒子,用 TypeScript 你就老老實實用 VSCode 吧,上述步驟以後,ts 文件中會出現大量飄紅警告。類似這樣:

報錯
先不要著急去解決錯誤,因為還需要對 TypeScript 添加 ESLint 配置,避免改多遍,先把 ESLint 配置好,當然,你如果喜歡 Pretitter,可以把它加上,本文就不介紹如何集成 Pretitter 了。
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[1],安裝依賴。
如果你項目已經在使用 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
1.由於 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 插件、將項目移出工作區再重新加回來。
步驟五、解決報錯
這個步驟內容有點多,可以細品一下。注意,下述解決報錯有些地方用了「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'; }
步驟六、調試配置
經過以上步驟,你的項目就能 run 起來了,雖然有很多警告和 any,但好歹已經算是走過來了,接下來就是解決調試問題。
方法一、調試生成後的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
方法二、直接調試ts文件
使用 ts-node進 行調試,VSCode 參考配置如下,詳見ts-node[2]
{ "configurations": [{ "type": "node", "request": "launch", "name": "debug", "runtimeArgs": [ "-r", "ts-node/register" ], "args": [ "${workspaceFolder}/src/cli.ts", "xx" ] }] }

VSCode調試ts
步驟七、類型加強、消除any
接下來要做的就是補充 Interface、Type,逐步將程式碼中的被業界噴得體無完膚的 any 幹掉,但不要妄想去掉所有 any ,js 語言說到底還是動態語言,TypeScript 雖然是其超集往靜態語言靠,但要做到 Java 這種純靜態語言程度還是有一段距離的。
到這就算結束了,文中只涉及到了工具類的 Node.js 項目改造,場景有限,並不能代表所有 Node.js 項目,希望能對大家有所幫助。
References
[1]
standard: https://standardjs.com/readme-zhcn.html [2]
ts-node: https://github.com/TypeStrong/ts-node#visual-studio-code