typescript 給 javascript 擴展了類型的語法和語義,讓咱們能夠給變量、函數等定義類型,而後編譯期間檢查,這樣可以提早發現類型不匹配的錯誤,還可以在開發時提示可用的屬性方法。javascript
並且,typescript 並不像當年的 coffeescript 同樣改變了語法,它是 javascript 的一個超集,只作了類型的擴展。前端
這些優勢使得 typescript 迅速的火了起來。如今前端面試若是你不會 typescript,那麼可能很難拿到 offer。java
市面上關於 typescript 的教程文不少了,可是沒有一篇去從編譯原理的角度分析它的實現的。本文不會講 typescript 的基礎,而是會實現一個 typescript type checker,幫你理解類型檢查究竟作了什麼。理解了類型檢查的實現思路,再去學 typescript,或許就沒那麼難了。node
typescript compiler 是一個 轉譯器,負責把 typescript 的語法轉成 es201五、es五、es3 的目標 javascript,而且過程當中會作類型檢查。面試
babel 也是一個轉譯器,能夠把 es next、typescript、flow 等語法轉成目標環境支持的 js。typescript
babel 也能夠編譯 typescript? 對的,babel 7 之後就能夠編譯 typescript 代碼,這仍是 typescript 團隊和 babel 團隊合做一年的成果。api
咱們知道,babel 編譯流程分爲 3 個步驟:parse、transform、generate。babel
parse 階段負責編譯源碼成 AST,transform 階段對 AST 進行增刪改,generate 階段打印 AST 成目標代碼並生成 sorucemap。markdown
babel 能夠編譯 typescript 代碼只是可以 parse,並不會作類型檢查,咱們徹底能夠基於 babel parse 出的 AST 來實現一下類型檢查。app
咱們常常用 tsc 來作類型檢查,有沒有想過,類型檢查具體作了什麼?
類型表明了變量存儲的內容,也就是規定了這塊內容佔據多大的內存空間,能夠對它作什麼操做。好比 number 和 boolean 就會分配不一樣字節數的內存,Date 和 String 能夠調用的方法也不一樣。這就是類型的做用。它表明了一種可能性,你能夠在這塊內存放多少內容,可能對它進行什麼操做。
動態類型是指類型是在運行時肯定的,而靜態類型是指編譯期間就知道了變量的類型信息,有了類型信息天然就知道了對它而言什麼操做是合法的,什麼操做是不合法的,什麼變量可以賦值給他。
靜態類型會在代碼中保留類型信息,這個類型信息多是顯式聲明的,也多是自動推導出來的。想作一個大的項目,沒有靜態類型來約束和提早檢查代碼的話,太容易出 bug 了,會很難維護。這也是隨着前端項目逐漸變得複雜,出現了 typescript 以及 typescript 愈來愈火的緣由。
咱們知道了什麼是類型,爲何要作靜態的類型檢查,那麼怎麼檢查呢?
檢查類型就是檢查變量的內容,而理解代碼的話須要把代碼 parse 成 AST,因此類型檢查也就變成了對 AST 結構的檢查。
好比一個變量聲明爲了 number,那麼給它賦值的是一個 string 就是有類型錯誤。
再複雜一點,若是類型有泛型,也就是有類型參數,那麼須要傳入具體的參數來肯定類型,肯定了類型以後再去和實際的 AST 對比。
typescript 還支持高級類型,也就是類型能夠作各類運算,這種就須要傳入類型參數求出具體的類型再去和 AST 對比。
咱們來寫代碼實現一下:
好比這樣一段代碼,聲明的值是一個 string,可是賦值爲了 number,明顯是有類型錯誤的,咱們怎麼檢查出它的錯誤的。
let name: string;
name = 111;
複製代碼
首先咱們使用 babel 把這段代碼 parse 成 AST:
const parser = require('@babel/parser');
const sourceCode = ` let name: string; name = 111; `;
const ast = parser.parse(sourceCode, {
plugins: ['typescript']
});
複製代碼
使用 babel parser 來 parse,啓用 typescript 語法插件。
可使用 astexplerer.net 來查看它的 AST:
咱們須要檢查的是這個賦值語句 AssignmentExpression,左右兩邊的類型是否匹配。
右邊是一個數字字面量 NumericLiteral,很容易拿到類型,而左邊則是一個引用,要從做用域中拿到它聲明的類型,以後才能作類型對比。
babel 提供了 scope 的 api 能夠用於查找做用域中的類型聲明(binding),而且還能夠經過 getTypeAnnotation 得到聲明時的類型
AssignmentExpression(path, state) {
const leftBinding = path.scope.getBinding(path.get('left'));
const leftType = leftBinding.path.get('id').getTypeAnnotation();// 左邊的值聲明的類型
}
複製代碼
這個返回的類型是 TSTypeAnnotation 的一個對象,咱們須要作下處理,轉爲類型字符串
封裝一個方法,傳入類型對象,返回 number、string 等類型字符串
function resolveType(targetType) {
const tsTypeAnnotationMap = {
'TSStringKeyword': 'string'
}
switch (targetType.type) {
case 'TSTypeAnnotation':
return tsTypeAnnotationMap[targetType.typeAnnotation.type];
case 'NumberTypeAnnotation':
return 'number';
}
}
複製代碼
這樣咱們拿到了左右兩邊的類型,接下來就簡單了,對比下就知道了類型是否匹配:
AssignmentExpression(path, state) {
const rightType = resolveType(path.get('right').getTypeAnnotation());
const leftBinding = path.scope.getBinding(path.get('left'));
const leftType = resolveType(leftBinding.path.get('id').getTypeAnnotation());
if (leftType !== rightType ) {
// error: 類型不匹配
}
}
複製代碼
報錯信息怎麼打印呢?可使用 @babel/code-frame,它支持打印某一片斷的高亮代碼。
path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error)
複製代碼
效果以下:
這個錯誤堆棧也太醜了,咱們把它去掉,設置 Error.stackTraceLimit 爲 0 就好了
Error.stackTraceLimit = 0;
path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
複製代碼
可是這裏改了以後還要改回來,也就是:
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
console.log(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
Error.stackTraceLimit = tmp;
複製代碼
再來跑一下:
好看多了!
還有一個問題,如今是遇到類型錯誤就報錯,但咱們但願是在遇到類型錯誤時收集起來,最後統一報錯。
怎麼實現呢?錯誤放在哪?
babel 插件中能夠拿到 file 對象,有 set 和 get 方法用來存取一些全局的信息。能夠在插件調用先後,也就是 pre 和 post 階段拿到 file 對象(這些在掘金小冊《babel 插件通關祕籍》中會細講)。
因此咱們能夠這樣作:
pre(file) {
file.set('errors', []);
},
visitor: {
AssignmentExpression(path, state) {
const errors = state.file.get('errors');
const rightType = resolveType(path.get('right').getTypeAnnotation());
const leftBinding = path.scope.getBinding(path.get('left'));
const leftType = resolveType(leftBinding.path.get('id').getTypeAnnotation());
if (leftType !== rightType ) {
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
errors.push(path.get('right').buildCodeFrameError(`${rightType} can not assign to ${leftType}`, Error));
Error.stackTraceLimit = tmp;
}
}
},
post(file) {
console.log(file.get('errors'));
}
複製代碼
這樣就能夠作到過程當中收集錯誤,最後統一打印:
這樣,咱們就實現了簡單的賦值語句的類型檢查。
賦值語句的檢查比較簡單,咱們來進階一下,實現函數調用參數的類型檢查
function add(a: number, b: number): number{
return a + b;
}
add(1, '2');
複製代碼
這裏咱們要檢查的就是函數調用語句 CallExpression 的參數和它聲明的是否一致。
CallExpression 有 callee 和 arguments 兩部分,咱們須要根據 callee 從做用域中查找函數聲明,而後再把 arguments 的類型和函數聲明語句的 params 的類型進行逐一對比,這樣就實現了函數調用參數的類型檢查。
pre(file) {
file.set('errors', []);
},
visitor: {
CallExpression(path, state) {
const errors = state.file.get('errors');
// 調用參數的類型
const argumentsTypes = path.get('arguments').map(item => {
return resolveType(item.getTypeAnnotation());
});
const calleeName = path.get('callee').toString();
// 根據 callee 查找函數聲明
const functionDeclarePath = path.scope.getBinding(calleeName).path;
// 拿到聲明時參數的類型
const declareParamsTypes = functionDeclarePath.get('params').map(item => {
return resolveType(item.getTypeAnnotation());
})
argumentsTypes.forEach((item, index) => {
if (item !== declareParamsTypes[index]) {
// 類型不一致,報錯
}
});
}
},
post(file) {
console.log(file.get('errors'));
}
複製代碼
運行一下,效果以下:
咱們實現了函數調用參數的類型檢查!實際上思路仍是挺清晰的,檢查別的 AST 也是相似的思路。
泛型是什麼,其實就是類型參數,使得類型能夠根據傳入的參數動態肯定,類型定義更加靈活。
好比這樣一段代碼:
function add<T>(a: T, b: T) {
return a + b;
}
add<number>(1, '2');
複製代碼
怎麼作類型檢查呢?
這仍是函數調用語句的類型檢查,咱們上面實現過了,區別不過是多了個參數,那麼咱們取出類型參數來傳過去就好了。
CallExpression(path, state) {
const realTypes = path.node.typeParameters.params.map(item => {// 先拿到類型參數的值,也就是真實類型
return resolveType(item);
});
const argumentsTypes = path.get('arguments').map(item => {
return resolveType(item.getTypeAnnotation());
});
const calleeName = path.get('callee').toString();
const functionDeclarePath = path.scope.getBinding(calleeName).path;
const realTypeMap = {};
functionDeclarePath.node.typeParameters.params.map((item, index) => {
realTypeMap[item.name] = realTypes[index];
});
const declareParamsTypes = functionDeclarePath.get('params').map(item => {
return resolveType(item.getTypeAnnotation(), realTypeMap);
})// 把類型參數的值賦值給函數聲明語句的泛型參數
argumentsTypes.forEach((item, index) => { // 作類型檢查的時候取具體的類型來對比
if (item !== declareParamsTypes[index]) {
// 報錯,類型不一致
}
});
}
複製代碼
多了一步肯定泛型參數的具體類型的過程。
執行看下效果:
咱們成功支持了帶泛型的函數調用語句的類型檢查!
typescript 支持高級類型,也就是支持對類型參數作各類運算而後返回最終類型
type Res<Param> = Param extends 1 ? number : string;
function add<T>(a: T, b: T) {
return a + b;
}
add<Res<1>>(1, '2');
複製代碼
好比這段代碼中,Res 就是一個高級類型,對傳入的類型參數 Param 進行處理以後返回新類型。
這個函數調用語句的類型檢查,比泛型參數傳具體的類型又複雜了一些,須要先求出具體的類型,而後再傳入參數,以後再去對比參數的類型。
那麼這個 Res 的高級類型怎麼求值呢?
咱們來看一下這個 Res 類型的 AST:
它有類型參數部分(typeParameters),和具體的類型計算邏輯部分(typeAnnotation),右邊的 Param extends 1 ? number : string;
是一個 condition 語句,有 Params 和 1 分別對應 checkType、extendsType,number 和 string 則分別對應 trueType、falseType。
咱們只須要對傳入的 Param 判斷下是不是 1,就能夠求出具體的類型是 trueType 仍是 falseType。
具體類型傳參的邏輯和上面同樣,就不贅述了,咱們看一下根據類型參數來值的邏輯:
function typeEval(node, params) {
let checkType;
if(node.checkType.type === 'TSTypeReference') {
checkType = params[node.checkType.typeName.name];// 若是參數是泛型,則從傳入的參數取值
} else {
checkType = resolveType(node.checkType); // 不然直接取字面量參數
}
const extendsType = resolveType(node.extendsType);
if (checkType === extendsType || checkType instanceof extendsType) { // 若是 extends 邏輯成立
return resolveType(node.trueType);
} else {
return resolveType(node.falseType);
}
}
複製代碼
這樣,咱們就能夠求出這個 Res 的高級類型當傳入 Params 爲 1 時求出的最終類型。
有了最終類型以後,就和直接傳入具體類型的函數調用的類型檢查同樣了。(上面咱們實現過)
執行一下,效果以下:
完整代碼以下(有些長,能夠先跳過日後看):
const { declare } = require('@babel/helper-plugin-utils');
function typeEval(node, params) {
let checkType;
if(node.checkType.type === 'TSTypeReference') {
checkType = params[node.checkType.typeName.name];
} else {
checkType = resolveType(node.checkType);
}
const extendsType = resolveType(node.extendsType);
if (checkType === extendsType || checkType instanceof extendsType) {
return resolveType(node.trueType);
} else {
return resolveType(node.falseType);
}
}
function resolveType(targetType, referenceTypesMap = {}, scope) {
const tsTypeAnnotationMap = {
TSStringKeyword: 'string',
TSNumberKeyword: 'number'
}
switch (targetType.type) {
case 'TSTypeAnnotation':
if (targetType.typeAnnotation.type === 'TSTypeReference') {
return referenceTypesMap[targetType.typeAnnotation.typeName.name]
}
return tsTypeAnnotationMap[targetType.typeAnnotation.type];
case 'NumberTypeAnnotation':
return 'number';
case 'StringTypeAnnotation':
return 'string';
case 'TSNumberKeyword':
return 'number';
case 'TSTypeReference':
const typeAlias = scope.getData(targetType.typeName.name);
const paramTypes = targetType.typeParameters.params.map(item => {
return resolveType(item);
});
const params = typeAlias.paramNames.reduce((obj, name, index) => {
obj[name] = paramTypes[index];
return obj;
},{});
return typeEval(typeAlias.body, params);
case 'TSLiteralType':
return targetType.literal.value;
}
}
function noStackTraceWrapper(cb) {
const tmp = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
cb && cb(Error);
Error.stackTraceLimit = tmp;
}
const noFuncAssignLint = declare((api, options, dirname) => {
api.assertVersion(7);
return {
pre(file) {
file.set('errors', []);
},
visitor: {
TSTypeAliasDeclaration(path) {
path.scope.setData(path.get('id').toString(), {
paramNames: path.node.typeParameters.params.map(item => {
return item.name;
}),
body: path.getTypeAnnotation()
});
path.scope.setData(path.get('params'))
},
CallExpression(path, state) {
const errors = state.file.get('errors');
const realTypes = path.node.typeParameters.params.map(item => {
return resolveType(item, {}, path.scope);
});
const argumentsTypes = path.get('arguments').map(item => {
return resolveType(item.getTypeAnnotation());
});
const calleeName = path.get('callee').toString();
const functionDeclarePath = path.scope.getBinding(calleeName).path;
const realTypeMap = {};
functionDeclarePath.node.typeParameters.params.map((item, index) => {
realTypeMap[item.name] = realTypes[index];
});
const declareParamsTypes = functionDeclarePath.get('params').map(item => {
return resolveType(item.getTypeAnnotation(), realTypeMap);
})
argumentsTypes.forEach((item, index) => {
if (item !== declareParamsTypes[index]) {
noStackTraceWrapper(Error => {
errors.push(path.get('arguments.' + index ).buildCodeFrameError(`${item} can not assign to ${declareParamsTypes[index]}`,Error));
});
}
});
}
},
post(file) {
console.log(file.get('errors'));
}
}
});
module.exports = noFuncAssignLint;
複製代碼
就這樣,咱們實現了 typescript 高級類型!
類型表明了變量的內容和能對它進行的操做,靜態類型讓檢查能夠在編譯期間作,隨着前端項目愈來愈重,愈來愈須要 typescript 這類靜態類型語言。
類型檢查就是作 AST 的對比,判斷聲明的和實際的是否一致:
實現一個完整的 typescript type cheker 仍是很複雜的,否則 typescript checker 部分的代碼也不至於好幾萬行了。可是思路其實沒有那麼難,按照咱們文中的思路來,是能夠實現一個完整的 type checker 的。
(關於 babel 插件和 api 的部分,若是看不懂,能夠在我即將上線的小冊《babel 插件通關祕籍》中來詳細瞭解。掌握了 babel,也就掌握了靜態分析的能力,linter、type checker 這些順帶也能更深刻的掌握。)