基於 babel 手寫 ts type checker

前言

typescript 給 javascript 擴展了類型的語法和語義,讓咱們能夠給變量、函數等定義類型,而後編譯期間檢查,這樣可以提早發現類型不匹配的錯誤,還可以在開發時提示可用的屬性方法。javascript

並且,typescript 並不像當年的 coffeescript 同樣改變了語法,它是 javascript 的一個超集,只作了類型的擴展。前端

這些優勢使得 typescript 迅速的火了起來。如今前端面試若是你不會 typescript,那麼可能很難拿到 offer。java

市面上關於 typescript 的教程文不少了,可是沒有一篇去從編譯原理的角度分析它的實現的。本文不會講 typescript 的基礎,而是會實現一個 typescript type checker,幫你理解類型檢查究竟作了什麼。理解了類型檢查的實現思路,再去學 typescript,或許就沒那麼難了。node

image.png

思路分析

typescript compiler 與 babel

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:

image.png

實現類型檢查

咱們須要檢查的是這個賦值語句 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)
複製代碼

效果以下:

image.png

這個錯誤堆棧也太醜了,咱們把它去掉,設置 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:

image.png

它有類型參數部分(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 時求出的最終類型。

有了最終類型以後,就和直接傳入具體類型的函數調用的類型檢查同樣了。(上面咱們實現過)

執行一下,效果以下:

image.png

完整代碼以下(有些長,能夠先跳過日後看):

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 這些順帶也能更深刻的掌握。)

相關文章
相關標籤/搜索