基於 babel 手寫 ts type checker

else發表於2021-09-09

前言

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/,如需轉載,請註明出處,否則將追究法律責任。

相關文章