手寫一個 ts-node 來深入理解它的原理

當我們用 Typesript 來寫 Node.js 的代碼,寫完代碼之後要用 tsc 作編譯,之後再用 Node.js 來跑,這樣比較麻煩,所以我們會用 ts-node 來直接跑 ts 代碼,省去了編譯階段。

有沒有覺得很神奇,ts-node 怎麼做到的直接跑 ts 代碼的?

其實原理並不難,今天我們來實現一個 ts-node 吧。

相關基礎

實現 ts-node 需要 3 方面的基礎知識:

我們先學下這些基礎

require hook

Node.js 當 require 一個 js 模塊的時候,內部會分別調用 Module.load、 Module._extensions['.js'],Module._compile 這三個方法,然後纔是執行。

同理,ts 模塊、json 模塊等也是一樣的流程,那麼我們只需要修改 Module._extensions[擴展名] 的方法,就能達到 hook 的目的:

require.extensions['.ts'] = function(module, filename) {
    // 修改代碼
    module._compile(修改後的代碼, filename);
}

比如上面我們註冊了 ts 的處理函數,這樣當處理 ts 模塊時就會調用這個方法,所以我們在這裏面做編譯就可以了,這就是 ts-node 能夠直接執行 ts 的原理。

repl 模塊

Node.js 提供了 repl 模塊可以創建 Read、Evaluate、Print、Loop 的命令行交互環境,就是那種一問一答的方式。ts-node 也支持 repl 的模式,可以直接寫 ts 代碼然後執行,原理就是基於 repl 模塊做的擴展。

repl 的 api 是這樣的:通過 start 方法來創建一個 repl 的交互,可以指定提示符 prompt,可以自己實現 eval 的處理邏輯:

const repl = require('repl');

const r = repl.start({ 
    prompt: '- . - > ', 
    eval: myEval 
});

function myEval(cmd, context, filename, callback) {
    // 對輸入的命令做處理
    callback(null, 處理後的內容);
}

repl 的執行時有一個上下文的,在這裏就是 r.context,我們在這個上下文裏執行代碼要使用 vm 模塊:

const vm = require('vm');

const res = vm.runInContext(要執行的代碼, r.context);

這兩個模塊結合,就可以實現一問一答的命令行交互,而且 ts 的編譯也可以放在 eval 的時候做,這樣就實現了直接執行 ts 代碼。

ts compiler api

ts 的編譯我們主要是使用 tsc 的命令行工具,但其實它同樣也提供了編譯的 api,叫做 ts compiler api。我們做工具的時候就需要直接調用 compiler api 來做編譯。

轉換 ts 代碼爲 js 代碼的 api 是這個:

const { outputText } = ts.transpileModule(ts代碼, {
    compilerOptions: {
        strict: false,
        sourceMap: false,
        // 其他編譯選項
    }
});

當然,ts 也提供了類型檢查的 api,因爲參數比較多,我們後面一篇文章再做展開,這裏只瞭解 transpileModule 的 api 就夠了。

瞭解了 require hook、repl 和 vm、ts compiler api 這三方面的知識之後,ts-node 的實現原理就呼之欲出了,接下來我們就來實現一下。

實現 ts-node

直接執行的模式

我們可以使用 ts-node + 某個 ts 文件,來直接執行這個 ts 文件,它的原理就是修改了 require hook,也就是 Module._extensions['.ts'] 來實現的。

在 require hook 裏面做 ts 的編譯,然後後面直接執行編譯後的 js,這樣就能達到直接執行 ts 文件的效果。

所以我們重寫 Module._extensions['.ts'] 方法,在裏面讀取文件內容,然後調用 ts.transpileModule 來把 ts 轉成 js,之後調用 Module._compile 來處理編譯後的 js。

這樣,我們就可以直接執行 ts 模塊了,具體的模塊路徑是通過命令行參數執行的,可以用 process.argv 來取。

const path = require('path');
const ts = require('typescript');
const fs = require('fs');

const filePath = process.argv[2];

require.extensions['.ts'] = function(module, filename) {
    const fileFullPath = path.resolve(__dirname, filename);
    const content = fs.readFileSync(fileFullPath, 'utf-8');

    const { outputText } = ts.transpileModule(content, {
        compilerOptions: require('./tsconfig.json')
    });

    module._compile(outputText, filename);
}

require(filePath);

我們準備一個這樣的 ts 文件 test.ts:

const a = 1;
const b = 2;

function add(a: number, b: number): number {
    return a + b;
}

console.log(add(a, b));

然後用這個工具 hook.js 來跑:

可以看到,成功的執行了 ts,這就是 ts-node 的原理。

當然,細節的邏輯還有很多,但是最主要的原理就是 require hook + ts compiler api。

repl 模式

ts-node 支持啓動一個 repl 的環境,交互式的輸入 ts 代碼然後執行,它的原理就是基於 Node.js 提供的 repl 模塊做的擴展,在自定義的 eval 函數里面做了 ts 的編譯,然後使用 vm.runInContext 的 api 在 repl 的上下文中執行 js 代碼。

我們也啓動一個 repl 的環境,設置提示符和自定義的 eval 實現。

const repl = require('repl');

const r = repl.start({ 
    prompt: '- . - > ', 
    eval: myEval 
});

function myEval(cmd, context, filename, callback) {

}

eval 的實現就是編譯 ts 代碼爲 js,然後用 vm.runInContext 來執行編譯後的 js 代碼,執行的 context 指定爲 repl 的 context:

function myEval(cmd, context, filename, callback) {
    const { outputText } = ts.transpileModule(cmd, {
        compilerOptions: {
            strict: false,
            sourceMap: false
        }
    });
    const res = vm.runInContext(outputText, r.context);
    callback(null, res);
}

同時,我們還可以對 repl 的 context 做一些擴展,比如注入一個 who 的環境變量:

Object.defineProperty(r.context, 'who'{
  configurable: false,
  enumerable: true,
  value: '神說要有光'
});

我們來測試下效果:

可以看到,執行後啓動了一個 repl 環境,提示符修改成了 -.- >,可以直接執行 ts 代碼,還可以訪問全局變量 who。

這就是 ts-node 的 repl 模式的大概原理:repl + vm + ts compiler api。

全部代碼如下:

const repl = require('repl');
const ts = require('typescript');
const vm = require('vm');

const r = repl.start({ 
    prompt: '- . - > ', 
    eval: myEval 
});

Object.defineProperty(r.context, 'who'{
  configurable: false,
  enumerable: true,
  value: '神說要有光'
});

function myEval(cmd, context, filename, callback) {
    const { outputText } = ts.transpileModule(cmd, {
        compilerOptions: {
            strict: false,
            sourceMap: false
        }
    });
    const res = vm.runInContext(outputText, r.context);
    callback(null, res);
}

總結

ts-node 可以直接執行 ts 代碼,不需要手動編譯,爲了深入理解它,我們我們實現了一個簡易 ts-node,支持了直接執行和 repl 模式。

直接執行的原理是通過 require hook,也就是 Module._extensions[ext] 裏通過 ts compiler api 對代碼做轉換,之後再執行,這樣的效果就是可以直接執行 ts 代碼。

repl 的原理是基於 Node.js 的 repl 模塊做的擴展,可以定製提示符、上下文、eval 邏輯等,我們在 eval 裏用 ts compiler api 做了編譯,然後通過 vm.runInContext 在 repl 的 context 中執行編譯後的 js。這樣的效果就是可以在 repl 裏直接執行 ts 代碼。

當然,完整的 ts-node 還有很多細節,但是大概的原理我們已經懂了,而且還學到了 require hook、repl 和 vm 模塊、 ts compiler api 等知識。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/FlzXxqNNcMQqEleVkggtqA