【長文詳解】TypeScript、Babel、webpack以及IDE對TS的類型檢查

只要接觸過ts的前端同學都能回答出ts是js超集,它具備靜態類型分析,能夠根據類型在靜態程式碼的解析過程中對ts程式碼進行類型檢查,從而在保證類型的一致性。那,現在讓你對你的webpack項目(其實任意類型的項目都同理)加入ts,你知道怎麼做嗎?帶著這個問題,我們由淺入深,逐步介紹TypeScriptBabel以及我們日常使用IDE進行ts文件類型檢查的關係,讓你今後面對基於ts的工程能夠做到遊刃有餘。

TypeScript基本認識

原則1:主流的瀏覽器的主流版本只認識js程式碼

原則2:ts的程式碼一定會經過編譯為js程式碼,才能運行在主流瀏覽器上

要編譯ts程式碼,至少具備以下幾個要素:

  1. ts源程式碼
  2. ts編譯器
  3. ts編譯器所需要的配置(默認配置也是配置)

編譯TS的方式

目前主流的ts編譯方案有2種,分別是官方tsc編譯、babel+ts插件編譯。

官方tsc編譯器

對於ts官方模式來說,ts編譯器就是tsc(安裝typescript就可以獲得),而編譯器所需的配置就是tsconfig.json配置文件形式或其他形式。ts源程式碼經過tsc的編譯(Compile),就可以生成js程式碼,在tsc編譯的過程中,需要編譯配置來確定一些編譯過程中要處理的內容。

010-ts-compile-flow

我們首先準備一個ts-demo,該demo中有如下的結構:

ts-demo
 |- packages.json
 |- tsconfig.json
 |- src
    |- index.ts

安裝typescript

yarn add -D typescript

package.json

{
  "name": "ts-demo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build-ts": "tsc"
  },
  "author": "",
  "license": "MIT",
  "devDependencies": {
    "typescript": "^4.7.4"
  }
}

tsconfig.js(對於這個簡單的tsconfig,我不再贅述其配置的含義。)

{
  "compilerOptions": {
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist"
  }
}

index.ts

interface User {
    name: string;
    age: number;
}
const userToString = (user: User) => `${user.name}@${user.age}`;
export {userToString, User};

此時,我們只需要運行yarn build-ts就可以將我們的index.ts編譯為index.js:

commonjs模組化方式產物:

"use strict";
exports.__esModule = true;
exports.userToString = void 0;
var userToString = function (user) { return "".concat(user.name, "@").concat(user.age); };
exports.userToString = userToString;

可以看到,原本index.ts編譯為index.js的產物,使用了commonjs模組化方案(tsconfig裡面配置模組化方案是”commonjs”,編譯後的程式碼可以看到”exports”的身影);倘若我們將模組化方案改為ESM(ES模組化)的es:"module": "es6",編譯後的產物依然是index.js,只不過內容採用了es6中的模組方案。

es6模組化方式產物:

var userToString = function (user) {
  return "".concat(user.name, "@").concat(user.age);
};
export {userToString};

說了這麼多,只是想要告訴各位同學,ts無論有多麼龐大的語法體系,多麼強大的類型檢查,最終的產物都是js

此外,ts中的模組化,不能和js中的模組化混為一談。js中的模組化方案很多(es6、commonjs、umd等等),所以ts本身在編譯過程中,需要指定一種js的模組化表達,才能編譯為對應的程式碼。也就是說,在ts中的import/export,不能認為和es6的import/export是一樣的,他們是完全不同的兩個體系!只是語法上類似而已。

babel+ts插件

如前文所述

ts源程式碼經過tsc的編譯(Compile),就可以生成js程式碼,在tsc編譯的過程中,需要編譯配置來確定一些編譯過程中要處理的內容。

那麼是不是說,編譯器這塊是不是有其他的代替呢?ts源碼經過某種其他的編譯器編譯後,生成目標js程式碼。答案是肯定的:babel。

我們準備一個ts-babel-demo:

ts-babel-demo
 |- packages.json
 |- .babelrc
 |- src
    |- index.ts

依賴添加:

 yarn add -D @babel/core @babel/cli
 yarn add -D @babel/preset-env @babel/preset-typescript
 yarn add -D @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread

package.json:

{
  "name": "ts-babel-demo",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "scripts": {
    "build": "babel src -d dist -x '.ts, .tsx'"
  },
  "devDependencies": {
    "@babel/cli": "^7.18.10",
    "@babel/core": "^7.18.10",
    "@babel/plugin-proposal-class-properties": "^7.18.6",
    "@babel/plugin-proposal-object-rest-spread": "^7.18.9",
    "@babel/preset-env": "^7.18.10",
    "@babel/preset-typescript": "^7.18.6"
  }
}

.babelrc

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ],
  "plugins": [
    "@babel/plugin-proposal-object-rest-spread",
    "@babel/plugin-proposal-class-properties"
  ]
}

index.ts和ts-demo保持一致。

完成基礎的項目搭建以後,我們執行yarn build

~/Projects/web-projects/ts-babel-demo > yarn build
yarn run v1.22.17
$ babel src -d dist -x '.ts, .tsx'
Successfully compiled 1 file with Babel (599ms).
Done in 4.05s.

可以看到項目dist目錄下出現了編譯好的js程式碼:

"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.userToString = void 0;

var userToString = function userToString(user) {
  return "".concat(user.name, "@").concat(user.age);
};

exports.userToString = userToString;

可以看到和使用tsc編譯為commonjs效果是一樣。

回顧這個項目,其實按照我們之前的思路來梳理:

  1. ts源文件(src/index.ts)
  2. ts的編譯器(babel)
  3. 編譯配置(.babelrc)

020-babel-compile-flow

了解babel機制

如果對於babel不太熟悉,可能對上述的一堆依賴感到恐懼:

 yarn add -D @babel/core @babel/cli
 yarn add -D @babel/preset-env @babel/preset-typescript
 yarn add -D @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread

這裡如果讀者有時間,我推薦這篇深入了解babel的文章:一口(很長的)氣了解 babel – 知乎 (zhihu.com)。當然,如果這口氣憋不住(哈哈),我做一個簡單摘抄:

babel 總共分為三個階段:解析,轉換,生成。

babel 本身不具有任何轉化功能,它把轉化的功能都分解到一個個 plugin 裡面。因此當我們不配置任何插件時,經過 babel 的程式碼和輸入是相同的。

插件總共分為兩種:

  • 當我們添加 語法插件 之後,在解析這一步就使得 babel 能夠解析更多的語法。(順帶一提,babel 內部使用的解析類庫叫做 babylon,並非 babel 自行開發)

舉個簡單的例子,當我們定義或者調用方法時,最後一個參數之後是不允許增加逗號的,如 callFoo(param1, param2,) 就是非法的。如果源碼是這種寫法,經過 babel 之後就會提示語法錯誤。

但最近的 JS 提案中已經允許了這種新的寫法(讓程式碼 diff 更加清晰)。為了避免 babel 報錯,就需要增加語法插件 babel-plugin-syntax-trailing-function-commas

  • 當我們添加 轉譯插件 之後,在轉換這一步把源碼轉換並輸出。這也是我們使用 babel 最本質的需求。

比起語法插件,轉譯插件其實更好理解,比如箭頭函數 (a) => a 就會轉化為 function (a) {return a}。完成這個工作的插件叫做 babel-plugin-transform-es2015-arrow-functions

同一類語法可能同時存在語法插件版本和轉譯插件版本。如果我們使用了轉譯插件,就不用再使用語法插件了。

簡單來講,使用babel就像如下流程:

源程式碼 =babel=> 目標程式碼

如果沒有使用任何插件,源程式碼和目標程式碼就沒有任何差異。當我們引入各種插件的時候,就像如下流程一樣:

源程式碼
|
進入babel
|
babel插件1處理程式碼:移除某些符號
|
babel插件2處理程式碼:將形如() => {}的箭頭函數,轉換成function xxx() {}
|
目標程式碼

因為babel的插件處理的力度很細,我們程式碼的語法、語義內容規範有很多,如果我們要處理這些語法,可能需要配置一大堆的插件,所以babel提出,將一堆插件組合成一個preset(預置插件包),這樣,我們只需要引入一個插件組合包,就能處理程式碼的各種語法、語義。

所以,回到我們上述的那些@babel開頭的npm包,再回首可能不會那麼迷茫:

@babel/core
@babel/preset-env
@babel/preset-typescript
@babel/preset-react
@babel/plugin-proposal-class-properties
@babel/plugin-proposal-object-rest-spread
  • @babel/core毋庸置疑,babel的核心模組,實現了上述的流程運轉以及程式碼語法、語義分析的功能;

  • @babel/cli則是我們可以在命令行使用babel命令;

  • plugin開頭的就是插件,這裡我們引入了兩個:@babel/plugin-proposal-class-properties允許類具有屬性)和@babel/plugin-proposal-object-rest-spread對象展開);

  • preset開頭的就是預置組件包合集,其中@babel/preset-env表示使用了可以根據實際的瀏覽器運行環境,會選擇相關的轉義插件包,通過配置得知目標環境的特點只做必要的轉換。如果不寫任何配置項,env 等價於 latest,也等價於 es2015 + es2016 + es2017 三個相加(不包含 stage-x 中的插件);@babel/preset-typescript會處理所有ts的程式碼的語法和語義規則,並轉換為js程式碼。

關於babel編譯ts,並不是所有的語法都支援,這裡有一篇文章專門介紹了其中注意點:《TypeScript 和 Babel:美麗的結合》。

webpack項目級TS使用

前面的內容,我們已經介紹了將ts編譯為js的兩種方式(tsc、babel),但僅僅是簡單將一個index.ts編譯為index.js。實際上,對於項目級別的ts項目,還有很多需要了解的。接下來基於一個webpack項目來逐步介紹如何基於前文的兩種方式來使用ts。

對於webpack來說,至少需要讀者了解到webpack的基本機制:概念 | webpack 中文文檔 (docschina.org)

簡單來講,webpack運行從指定的entry文件開始,從頂層開始分析依賴的內容,依賴的內容可以是任何的內容(只要是import的或require了的),而loader可以專門來處理各種類型的文件。

webpack 只能理解 JavaScript 和 JSON 文件,這是 webpack 開箱可用的自帶能力。loader 讓 webpack 能夠去處理其他類型的文件,並將它們轉換為有效 模組,以供應用程式使用,以及被添加到依賴圖中

030-webpack-base-flow

所以,當一個webpack項目是基於TS進行的時候,我們一定會有一個loader來處理ts(甚至是tsx)。當然,我們還是通過demo搭建來演示講解。

ts-loader

mkdir webpack-ts-loader-demo && cd webpack-ts-loader-demo
yarn init
yarn add -D webpack webpack-cli
yarn add -D ts-loader

package.json

{
  "name": "webpack-ts-loader-demo",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack --config webpack.config.js"
  },
  "devDependencies": {
    "ts-loader": "^9.3.1",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0"
  }
}

webpack.config.js

const {resolve} = require('path');
module.exports = {
  entry: './src/index.ts',
  output: {
    path: resolve(__dirname, './dist'),
    filename: "index.js"
  },
  module: {
    rules: [
      {
        test: /\.ts/,
        loader: "ts-loader"
      }
    ]
  }
};

src/index.ts

interface User {
    name: string;
    age: number;
}
const userToString = (user: User) => `${user.name}@${user.age}`;
export {userToString, User};

表面上,只需要上述三個文件,就可以編譯ts文件,但是嘗試運行yarn build會報錯:

Module build failed (from ./node_modules/ts-loader/index.js):
Error: Could not load TypeScript. Try installing with `yarn add typescript` or `npm install typescript`. If TypeScript is installed globally, try using `yarn link typescript` or `npm link typescript`.

通過報錯很容易理解,我們沒有安裝typescript。為什麼?因為ts-loader本身處理ts文件的時候,本質上還是調用的tsc,而tsc是typescript模組提供的。因此,我們只需要yarn add -D typescript即可(其實只需要開發依賴即可),但是緊接著又會有另外一個報錯:

ERROR in ./src/index.t
Module build failed (from ./node_modules/ts-loader/index.js):
Error: error while parsing tsconfig.json

報錯提醒我們,解析tsconfig的出錯,不難理解,我們還沒有配置tsconfig.json,因為tsc需要!所以,在我們項目中,加上tsconfig.json即可:

tsconfig.json

{
  "compilerOptions": {
    "module": "commonjs",
    "rootDir": "./src",
    "outDir": "./dist"
  }
}

配置完成以後,我們再次編譯,發現可以編譯成功,並且在dist目錄下會有對應的js程式碼。

然而,事情到這裡就結束了嗎?一個中大型的項目,必然有模組的引入,假如現在我們添加了個utils.ts

export const hello = () => {
  return 'hello';
}

修改index.ts的程式碼,引入該hello方法,並使用:

import {hello} from "./utils"; // 引入utils
interface User {
    name: string;
    age: number;
}
const userToString = (user: User) => `${user.name}@${user.age}${hello()}`;
export {userToString, User};

再次運行yarn build,讀者會發現還是會報錯,但這一次的錯誤略有點出乎意料:

Module not found: Error: Can't resolve './utils' in '/Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src'
resolve './utils' in '/Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src'

核心報錯在於,webpack似乎無法找到utils這個模組。為什麼呢?因為webpack默認是處理js程式碼的,如果你的程式碼中編寫了import xxx from 'xxx',在沒有明確指明這個模組的後綴的時候,webpack只會認為這個模組是以下幾種:

  1. 無後綴文件
  2. js文件
  3. json文件
  4. wasm文件

所以,你會看到具體一點的報錯:

resolve './utils' in '/Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src'
  using description file: /Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/package.json (relative path: ./src)
    Field 'browser' doesn't contain a valid alias configuration
    using description file: /Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/package.json (relative path: ./src/utils)
      no extension
        Field 'browser' doesn't contain a valid alias configuration
        /Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src/utils doesn't exist
      .js
        Field 'browser' doesn't contain a valid alias configuration
        /Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src/utils.js doesn't exist
      .json
        Field 'browser' doesn't contain a valid alias configuration
        /Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src/utils.json doesn't exist
      .wasm
        Field 'browser' doesn't contain a valid alias configuration
        /Users/w4ngzhen/Projects/web-projects/webpack-ts-loader-demo/src/utils.wasm doesn't exist
      as directory

要想讓webpack知道我們引入的utils是ts程式碼,方式為在webpack配置中,指明webpack默認處理的文件後綴:

const {resolve} = require('path');
module.exports = {
  // ... ...
  resolve: {
    // webpack 默認只處理js、jsx等js程式碼
    // 為了防止在import其他ts程式碼的時候,出現
    // " Can't resolve 'xxx' "的錯誤,需要特別配置
    extensions: ['.js', '.jsx', '.ts', '.tsx']
  },
  // ... ...
};

完成配置以後,我們就能夠正確編譯具備模組導入的ts程式碼了。

綜合來看,在基於ts-loader的webpack項目的解析流程處理如下。

040-webpack-ts-loader-flow

回顧一下webpack,它默認處理模組化js程式碼,比如index.js引用了utils.js(模組引用方式可以是commonjs,也可以是esModule形式),那麼webpack從入口的index.js出發,來處理依賴,並打包為一個js(暫不考慮js拆分)。

對於wepack+ts-loader的ts項目體系主要是通過ts-loader內部調用typescript提供的tsc,將ts程式碼編譯為js程式碼(編譯後的js程式碼依然是js模組化的形式),所以這個過程是需要tsconfig參與;等到tsc將整個所有的ts程式碼均編譯為js程式碼以後,再整體交給webpack進行依賴分析並打包(也就進入webpack的默認處理流程)。

細心的讀者會發現這個過程有一個問題:由於先經過tsc編譯後的js,又再被webpack默認的js處理機制進行分析並編譯打包,這個過程一方面經過了兩次編譯(ts->標準模組化js->webpack模組體系js),那麼如果ts項目特別大,模組特別多的時候,這個兩次編譯的過程會特別漫長!

babel-loader

前面我們簡單介紹了如何使用babel對一份ts進行編譯,那麼在webpack中,如何使用babel呢?有的同學可能會想到這樣操作步驟:我先用babel對ts進行編譯為js,然後再利用webpack對js進行打包,這樣的做法是可以的,但細想不就和上面的ts-loader一樣的情況了嗎?

只要開發過基於webpack的現代化前端項目的同學,或多或少都看到過babel-loader的身影,他是個什麼東西呢?先說結論吧,babel-loader是webpack和babel(由@babel/core和一堆預置集preset、插件plugins組合)的橋樑。

050-webpack-babel-loader-flow

根據這個圖,同學可能覺得這不是和ts-loader的架構很像嗎?webpack啟動,遇到入口ts,匹配到babel-loader,babel-loader交給babel處理,處理完畢,回到webpack打包。但是使用babel進行ts處理,比起ts-loader更加高效。而關於這塊的說明,我更加推薦讀者閱讀這篇文章 TypeScript 和 Babel:美麗的結合 – 知乎 (zhihu.com),簡單來講:

警告!有一個震驚的消息,你可能想坐下來好好聽下。

Babel 如何處理 TypeScript 程式碼?它刪除它

是的,它刪除了所有 TypeScript,將其轉換為「常規的」 JavaScript,並繼續以它自己的方式愉快處理。

這聽起來很荒謬,但這種方法有兩個很大的優勢。

第一個優勢:️⚡️閃電般快速⚡️。

大多數 Typescript 開發人員在開發/監視模式下經歷過編譯時間長的問題。你正在編寫程式碼,保存一個文件,然後…它來了…再然後…最後,你看到了你的變更。哎呀,錯了一個字,修復,保存,然後…啊。它只是慢得令人煩惱並打消你的勢頭。

很難去指責 TypeScript 編譯器,它在做很多工作。它在掃描那些包括 node_modules 在內的類型定義文件(*.d.ts),並確保你的程式碼正確使用。這就是為什麼許多人將 Typescript 類型檢查分到一個單獨的進程。然而,Babel + TypeScript 組合仍然提供更快的編譯,這要歸功於 Babel 的高級快取和單文件發射架構。

讓我們來搭建一個項目來複習這一過程吧:

mkdir webpack-babel-loader-demo && cd webpack-babel-loader-demo
yarn init
yarn add -D webpack webpack-cli
yarn add -D babel-loader
yarn add -D @babel/core
yarn add -D @babel/preset-env @babel/preset-typescript
yarn add -D @babel/plugin-proposal-class-properties @babel/plugin-proposal-object-rest-spread

package.json

{
  "name": "webpack-babel-loader-demo",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "webpack --config webpack.config.js" 
  },
  "devDependencies": {
    "@babel/core": "^7.18.13",
    "@babel/plugin-proposal-class-properties": "^7.18.6",
    "@babel/plugin-proposal-object-rest-spread": "^7.18.9",
    "@babel/preset-env": "^7.18.10",
    "@babel/preset-typescript": "^7.18.6",
    "babel-loader": "^8.2.5",
    "webpack": "^5.74.0",
    "webpack-cli": "^4.10.0"
  }
}

webpack.config.js

const {resolve} = require('path');
module.exports = {
  entry: './src/index.ts',
  output: {
    path: resolve(__dirname, './dist'),
    filename: "index.js"
  },
  resolve: {
    // webpack 默認只處理js、jsx等js程式碼
    // 為了防止在import其他ts程式碼的時候,出現
    // " Can't resolve 'xxx' "的錯誤,需要特別配置
    extensions: ['.js', '.jsx', '.ts', '.tsx']
  },
  module: {
    rules: [
      {
        test: /\.ts/,
        loader: "babel-loader"
      }
    ]
  }
};

src/index.ts

import {hello} from "./utils";
interface User {
    name: string;
    age: number;
}
const userToString = (user: User) => `${user.name}@${user.age}${hello()}`;
export {userToString, User};

src/utils.ts

export const hello = () => {
  return 'hello';
}

完成上述package.json、webpack.config.js、src源程式碼三個部分,我們可以開始運行yarn build,但實際上會報錯:

ERROR in ./src/index.ts
Module build failed (from ./node_modules/babel-loader/lib/index.js):
SyntaxError: /Users/w4ngzhen/Projects/web-projects/webpack-babel-loader-demo/src/index.ts: Unexpected reserved word 'interface'. (1:0)

> 1 | interface User {
    | ^
  2 |     name: string;
  3 |     age: number;
  4 | }
    at instantiate (/Users/w4ngzhen/Projects/web-projects/webpack-babel-loader-demo/node_modules/@babel/parser/lib/index.js:72:32)

出現了語法的錯誤,報錯的主要原因在於沒有把整個babel處理ts的鏈路打通。目前的鏈路是:webpack找到入口ts文件,匹配上babel-loader,babel-loader交給@babel/core,@babel/core處理ts。由於我們沒有給@babel/core配置plugin、preset,所以導致了babel還是以默認的js角度來處理ts程式碼,所以有語法報錯。此時,我們需要添加.babelrc文件來指明讓babel載入處理ts程式碼的插件:

.babelrc

{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-typescript"
  ],
  "plugins": [
    "@babel/plugin-proposal-object-rest-spread",
    "@babel/plugin-proposal-class-properties"
  ]
}

完成配置以後,我們再次運行yarn build,編譯通過,但是在dist下的index.js卻是空白的!

問題:babel-loader編譯後,輸出js內容空白

如果按照上述的配置以後,我們能夠成功編譯但是卻發現,輸出的js程式碼是空白的!原因在於:我們編寫的js程式碼,是按照類庫的模式進行編寫(在indexjs中只有導出一些函數卻沒有實際的使用),且webpack打包的時候,沒有指定js程式碼的編譯為什麼樣子的庫。

假如我們在index中編寫一段具有副作用的程式碼:

import {hello} from "./utils";
interface User {
    name: string;
    age: number;
}
const userToString = (user: User) => `${user.name}@${user.age}${hello()}`;

// 具備副作用:在id=app的元素上添加監聽
document
    .querySelector('#app')
    .addEventListener('click', () => {})

export {userToString, User};

此時我們使用生產模式(mode: ‘production’)來編譯,會發現dist/index.js的內容如下:

(() => {
  "use strict";
  document.querySelector("#app").addEventListener("click", (function () {
  }));
})();

會發現只有副作用程式碼,但是userToString相關的程式碼完全被剔除了!這時候,可能有讀者會說,我導出的程式碼有可能別人會使用,你憑什麼要幫我剔除?其實,因為webpack默認是生成項目使用的js,也就是做打包操作,他的目的是生成當前項目需要的js。在我們這個示例中,在沒有寫副作用之前,webpack認為打包是沒有意義的,因為只有導出方法,卻沒有使用。那麼,如果讓webpack知道,我們需要做一個類庫呢?在webpack中配置library欄位即可:

const {resolve} = require('path');
module.exports = {
  entry: './src/index.ts',
  mode: 'production',
  output: {
    // ... ...
    library: { 
      // 配置library欄位的相關配置,這裡我們配置為commonjs2
      // 至於這塊配置的意義,讀者需要自行學習~
      type: 'commonjs2',
    },
  },
  // ... ...
};

tsc與babel編譯的差異

現在我們先編寫一個簡單錯誤程式碼

interface User {
    name: string;
    age: number;
}
// user.myName並沒有在User介面中提供
const userToString = (user: User) => `${user.myName}@${user.age}`;
export {userToString, User};

在這個示例中,我們試圖訪問在User類型中不存在的myName欄位。

ts-loader

前面我們提到了ts-loader內部調用的是tsc作為編譯器,我們嘗試運行基於ts-loader的webpack配置進行打包該模組,會發現報錯:

... ...
      TS2551: Property 'myName' does not exist on type 'User'. Did you mean 'name'?
ts-loader-default_e3b0c44298fc1c14

webpack 5.74.0 compiled with 1 error in 2665 ms
error Command failed with exit code 1.
info Visit //yarnpkg.com/en/docs/cli/run for documentation about this command.

可以看得出來,tsc幫助我們提示了類型錯誤的地方,user這個類型並沒有對應的myName欄位。

babel-loader

我們切換一下到babel-loader對該ts文件進行編譯,居然發現編譯可以直接成功!並且,我們檢查編譯好的js程式碼,會發現這部分:

// dist/index.js
(() => {
  "use strict";
  // ... ...
  var r = function (e) {
    // 注意這個地方:依然在使用myName
    return "".concat(e.myName, "@").concat(e.age);
  };
  module.exports = o;
})();

編譯好的js程式碼就在直接使用myName欄位。為什麼類型檢查失效了?還記得我們前面提到的babel怎麼處理ts的?

Babel 如何處理 TypeScript 程式碼?它刪除它

是的,它刪除了所有 TypeScript,將其轉換為「常規的」 JavaScript,並繼續以它自己的方式愉快處理。

是的,babel並沒有進行類型檢查,而是將各種類型移除掉以達到快速完成編譯的目的。那麼問題來了,我們如何讓babel進行類型判斷呢?實際上,我們沒有辦法讓babel進行類型判斷,必須要藉助另外的工具進行。那為什麼我們的IDE卻能夠現實ts程式碼的錯誤呢?因為IDE幫助我們進行了類型判斷。

主流IDE對TypeScript的類型檢查

不知道有沒有細心的讀者在使用IDEA的時候,發現一個ts項目的IDEA右下角展示了typescript:

060-idea-ts-service

VSCode也能看到類似:

070-vscode-ts-service

在同一台電腦上,甚至發現IDEA和VSCode的typescript版本都還不一樣(4.7.4和4.7.3)。這是怎麼一回事呢?實際上,IDE檢測到你所在的項目是一個ts項目的時候(或包含ts文件),就會自動的啟動一個ts的檢測服務,專門用於所在項目的ts類型檢測。這個ts類型檢測服務,是通過每個IDE默認情況下自帶的typescript中的tsc進行類型檢測。

但是,我們可以全局安裝(npm -g)或者是為每個項目單獨安裝typescript,然後就可以讓IDE選擇啟動獨立安裝的typescript。比如,我們在本項目中,安裝一個特定版本的ts(版本4.7.2):

yarn add -D [email protected]

在IDEA中,設置 – Languages & Frameworks – TypeScript中,就可以選擇IDEA啟動的4.7.2版本的TypeScript為我們項目提供類型檢查(注意看選項中有一個Bundled的TS,版本是4.7.4,就是默認的):

080-idea-select-ts

IDE之所以能夠在對應的程式碼位置展示程式碼的類型錯誤,流程如下:

090-ide-ts-service-flow

但是,ts類型檢查也要有一定的依據。譬如,有些類型定義的文件從哪裡查找,是否允許較新的語法等,這些配置依然是由tsconfig.json來提供的,但若未提供,則IDE會使用一份默認的配置。如果要進行類型檢測的自定義配置,則需要提供tsconfig.json。

還記得我們前面的ts-loader嗎?在程式碼編譯期,ts-loader調用tsc,tsc讀取項目目錄下的tsconfig.json配置。而咱們編寫程式碼的時候,又讓IDE的ts讀取該tsconfig.json配置文件進行類型檢查。

對於ts-loader項目體系來說,ts程式碼編譯和ts的類型檢測如下:

100-ts-loader-and-ide

然而,對於babel-loader項目體系就不像ts-loader那樣了:

110-babel-loader-and-ide

在babel-loader體系中,程式碼的編譯只取決於babel部分的處理,根類型沒有根本的關係,而類型檢查使用到的tsconfig和tsc則只作用在類型檢查的部分,根ts程式碼編譯沒有任何關係。