基於 babel 手寫 ts type checker
前言
typescript 給 javascript 擴充套件了型別的語法和語義,讓我們可以給變數、函式等定義型別,然後編譯期間檢查,這樣能夠提前發現型別不匹配的錯誤,還能夠在開發時提示可用的屬性方法。
而且,typescript 並不像當年的 coffeescript 一樣改變了語法,它是 javascript 的一個超集,只做了型別的擴充套件。
這些優點使得 typescript 迅速的火了起來。現在如果你不會 typescript,那麼可能很難拿到 offer。
市面上關於 typescript 的教程文很多了,但是沒有一篇去從編譯原理的角度分析它的實現的。本文不會講 typescript 的基礎,而是會實現一個 typescript type checker,幫你理解型別檢查究竟做了什麼。理解了型別檢查的實現思路,再去學 typescript,或許就沒那麼難了。
思路分析
typescript compiler 與 babel
typescript compiler 是一個 轉譯器,負責把 typescript 的語法轉成 es2015、es5、es3 的目標 javascript,並且過程中會做型別檢查。
babel 也是一個轉譯器,可以把 es next、typescript、flow 等語法轉成目標環境支援的 js。
babel 也可以編譯 typescript? 對的,babel 7 以後就可以編譯 typescript 程式碼,這還是 typescript 團隊和 babel 團隊合作一年的成果。
我們知道,babel 編譯流程分為 3 個步驟:parse、transform、generate。
parse 階段負責編譯原始碼成 AST,transform 階段對 AST 進行增刪改,generate 階段列印 AST 成目的碼並生成 sorucemap。
babel 可以編譯 typescript 程式碼只是能夠 parse,並不會做型別檢查,我們完全可以基於 babel parse 出的 AST 來實現一下型別檢查。
型別檢查要做什麼
我們經常用 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 語法外掛。
可以使用 來檢視它的 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 addT>(a: T, b: T) {
return a + b;
}
addnumber>(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 ResParam> = Param extends 1 ? number : string;
function addT>(a: T, b: T) {
return a + b;
}
addRes1>>(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 的對比,判斷宣告的和實際的是否一致:
- 簡單型別就直接對比,相當於 if else
- 帶泛型的要先把型別引數傳遞過去才能確定型別,之後對比,相當於函式呼叫包裹 if else
- 帶高階型別的泛型的型別檢查,多了一個對型別求值的過程,相當於多級函式呼叫之後再判斷 if else
實現一個完整的 typescript type cheker 還是很複雜的,不然 typescript checker 部分的程式碼也不至於好幾萬行了。但是思路其實沒有那麼難,按照我們文中的思路來,是可以實現一個完整的 type checker 的。
(關於 babel 外掛和 api 的部分,如果看不懂,可以在我即將上線的小冊《babel 外掛通關秘籍》中來詳細瞭解。掌握了 babel,也就掌握了靜態分析的能力,linter、type checker 這些順帶也能更深入的掌握。)
作者:zxg_神說要有光
連結:
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2318/viewspace-2807215/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- 基於netty手寫RPC框架NettyRPC框架
- ts中的type 和 interface 區別
- Property 'context' does not exist on type 'NodeRequire'.ts(2339)ContextUI
- 基於promise /A+規範手寫promisePromise
- Babel 手記Babel
- java 英文單詞拼寫糾正框架(Word Checker)Java框架
- opencv python 基於KNN的手寫體識別OpenCVPythonKNN
- opencv python 基於SVM的手寫體識別OpenCVPython
- 關於babel-polyfill和babel-runtimeBabel
- babel基礎配置Babel
- vue Cannot find module @/xxx/xxx.ts or its corresponding typeVue
- 基於 Babel 的 npm 包的最小化設定BabelNPM
- TS基礎應用 & Hook中的TSHook
- 自己寫一個Babel外掛Babel
- 基於ts的node專案引入報錯歸納
- Babel基礎知識整理Babel
- 基於滴滴雲 GPU 實現簡單 MINIST 手寫識別GPU
- java 從零開始手寫 RPC (01) 基於 websocket 實現JavaRPCWeb
- 編寫自己的Babel外掛(一)Babel
- 從零手寫實現 nginx-03-nginx 基於 Netty 實現NginxNetty
- TS版LangChain實戰:基於文件的增強檢索(RAG)LangChain
- 從零學腳手架(四)---babelBabel
- (譯)理解Rust的 borrow checkerRust
- 深度學習例項之基於mnist的手寫數字識別深度學習
- 使用java語言基於SMTP協議手寫郵件客戶端Java協議客戶端
- 從零到一帶你手寫基於Redis的分散式鎖框架Redis分散式框架
- 基於webpack4.x專案實戰3 - 手寫一個cliWeb
- 帶你手寫基於 Spring 的可插拔式 RPC 框架(一)介紹SpringRPC框架
- 基於APIView&ModelSerializer寫介面APIView
- 從零開始配置webpack(基於webpack 4 和 babel 7版本)WebBabel
- 編寫一個簡單的babel外掛Babel
- 基於Apache Zookeeper手寫實現動態配置中心(純程式碼實踐)Apache
- ts 終於搞懂TS中的泛型啦! | typescript 入門指南 04泛型TypeScript
- 佳能ts3380連線手機如何列印 佳能TS3380使用教程S3
- 基於nodejs編寫小爬蟲NodeJS爬蟲
- 你還在手寫TS型別程式碼嗎型別
- 用 vue 寫小程式,基於 mpvue 框架重寫 weuiVue框架UI
- 從零開始編寫一個babel外掛Babel