ts-node 下錯誤堆棧問題排查小記

ts-node 下錯誤堆棧問題排查小記

來自專欄 Node.js19 人贊了文章

背景

此前 egg 需要支持 ts,所以我們在 egg-bin 中集成了 ts-node ( 詳見 當 Egg 遇到 TypeScript,收穫茶葉蛋一枚 ),從而能夠讓開發者直接跑用 ts 寫的 egg 應用,當然也包括單元測試。

但是實際在跑單測的時候卻發現,power-assert( power-assert 是個很酷的模塊,也集成在了 egg-bin 中 ) 卻在 ts-node 下失效了,查閱了一下文檔發現要引入 espower-typescript 才能讓 power-assert 在 ts-node 下生效,引入後發現 power-assert 正常了,但是卻又有了另一個問題:

可以看到,當單測出錯的時候,錯誤堆棧中的出錯的行數應該是 5,但是實際上卻成了 30 ,列數也是一樣不對的,可是 ts-node 有內置 source-map-support ,應該是會自動糾正錯誤堆棧的行數才對的,為啥還會導致堆棧錯誤?

關於 source-map-support ,可以看一下這篇 Node.js 中 source map 使用問題總結

強迫症表示這可不行啊,這必須得解決,於是開始了對源碼的折騰...

分析

espower-typescript ?

由於一旦引入espower-typescript之後就導致堆棧錯誤,移除又正常,再加上堆棧錯誤的原因一般都是 source-map 哪裡出問題了,所以首先覺得應該是espower-typescript里的 source map 處理的問題。看了一下源碼,發現裡面引入了個espower-source的模塊來處理 source-map 。所以我看了一下espower-source的源碼。

// espower-source/index.jsmodule.exports = function espowerSource (originalCode, filepath, options) { ... var espowerOptions = mergeEspowerOptions(options, filepath); // 分析出 originalCode 中的 source map,即 ts => js 的 source map var inMap = handleIncomingSourceMap(originalCode, espowerOptions); ... // 將 originalCode 加上 power-assert 的封裝 var instrumented = instrument(originalCode, filepath, espowerOptions); // 獲取 power-assert 封裝後的 source map // 即 js => power-assert + js 的 source map var outMap = convert.fromJSON(instrumented.map.toString()); if (inMap) { // 合併兩個 source map 並且返回 var reMap = reconnectSourceMap(inMap, outMap); return instrumented.code +
+ reMap.toComment() +
; } else { return instrumented.code +
+ outMap.toComment() +
; }};

源碼不複雜,可以看到 espower-source 中會先分析 compile 後的代碼,然後從代碼中提取出 sourcemap( 比如 ts 編譯成 js 後的 inlineSourceMap ),這個 sourcemap 是從 ts 到 js 的 sourcemap,然後再將編譯後的代碼做 power-assert 的封裝( 要實現 power-assert 的那種展示效果,是需要對代碼做額外包裝的 ),同時會生成一個新的 sourcemap ,這個就是從 js 到 封裝後的 js 的 sourcemap。然後將兩個 source map 合併成一個新的 sourcemap 並且返回。

這咋看之下,邏輯沒問題呀,按道理這個新的 sourcemap 應該是可以映射出封裝後的 js 到 ts 的位置的。緊接著我將 instrumented.code 加了行號之後列印了出來。

可以看到,前面截圖中出錯的行號正是這個封裝後的 js 代碼堆棧行號,也就是 sourcemap 是沒有映射到 ts 上的。

那是不是合併生成的 sourcemap 是有問題的?抱著這個疑問我又看了一下用來合併 sourcemap 的模塊 multi-stage-sourcemap 的代碼邏輯,也沒看出來問題,那隻能直接自己手動使用 source-map 庫來算來一下這個位置,看一下對不對了。

於是在 espowerSource 的源碼中手動加上了以下這段代碼

const SourceMapConsumer = require(source-map).SourceMapConsumer;// 傳入合併後的 sourcemap: reMap.sourcemapconst consumer = new SourceMapConsumer(reMap.sourcemap);const newPosition = consumer.originalPositionFor({ line: 30, column: 15});console.info(>>>, newPosition);

想通過使用 source-map 模塊的 Consumer 來根據新的 sourcemap ,以及傳入上面報錯截圖中的行數及列數,看下能否算出來正確的 ts 中的行數及列數。結果如下:

嗯...結果是對的,鍋貌似不在 espower-typescript 呀?

source-map-support ?

那既然鍋不是 espower-typescript 的,難道是 source-map-support 的?畢竟實際上做 sourcemap 映射的,是我們引入的 source-map-support 的模塊。

然後又瀏覽了一下 source-map 的源碼,發現 source-map-support 是通過 hook 掉 Error.prepareStackTrace 方法來實現在每次出錯的時候,能夠拿到錯誤堆棧,並且根據出錯代碼的 sourcemap 做行數及列數的矯正,於是根據這個代碼找到了 source-map-support 中的 mapSourcePosition方法,就是用於錯誤行數及列數矯正的。

function mapSourcePosition(position) { var sourceMap = sourceMapCache[position.source]; if (!sourceMap) { ... } if (sourceMap && sourceMap.map) { var originalPosition = sourceMap.map.originalPositionFor(position); if (originalPosition.source !== null) { originalPosition.source = supportRelativeURL( sourceMap.url, originalPosition.source); return originalPosition; } } return position;}

根據上面的測試,我們知道 originalPositionFor 方法是用來計算原始位置的,然後我將計算出來的 originalPosition 列印了一下,發現映射出來的 source、line、column 的值全是 null,為啥會是 null ?那隻能說明,這裡拿到的 sourcemap 是錯誤的。於是我就將在 source-map-support 中拿到的 sourcemap,跟 espower-typescript 中最後返回的 sourcemap 做了對比,發現.... 完!全!不!一!樣!但是這個 sourcemap 卻跟 js => ts 的那個 sourcemap 一毛一樣。

也就是說,在 source-map-support 中拿到的 sourcemap 其實是 ts 生成的 sourcemap,而不是 espower-typescript 生成的那串,難怪會導致行數算不出來,都不是同個 sourcemap。

ts-node !

因為 source-map-support 是 ts-node 引入的,既然 source-map-support 里拿到的是錯誤的 sourcemap,那肯定就是 ts-node 導致的了,於是又去看 ts-node 的源碼,然後就發現了導致該問題的代碼。

var memoryCache = { contents: Object.create(null), versions: Object.create(null), outputs: Object.create(null)};...sourceMapSupport.install({ environment: node, retrieveFile: function (path) { return memoryCache.outputs[path]; }});

可以看到,在 ts-node 中緩存了編譯後的代碼,並且在 source-map-support 的 retrieveFile 方法中返回緩存值。而 source-map-supportretrieveFile 是用來接收包含 sourcemap 信息的代碼文件的。因為 ts-node 在 source-map-support 獲取 sourcemap 的時候穩定返回了緩存值,所以就導致 espower-typescript 中生成的 sourcemap 沒有生效。

解決方案

既然知道了原因,要解決就很簡單了,直接複寫 source-map-supportretrieveFile 方法,返回正確的緩存值:

const sourceMapSupport = require(source-map-support);const cacheMap = {};const extensions = [.ts, .tsx];sourceMapSupport.install({ environment: node, retrieveFile: function (path) { // 根據路徑找緩存的編譯後的代碼 return cacheMap[path]; }});extensions.forEach(ext => { const originalExtension = require.extensions[ext]; require.extensions[ext] = (module, filePath) => { const originalCompile = module._compile; module._compile = function(code, filePath) { // 緩存編譯後的代碼 cacheMap[filePath] = code; return originalCompile.call(this, code, filePath); }; return originalExtension(module, filePath); };})

經過驗證,在引入 espower-typescript 之後再引入上面的代碼,就可以解決這個問題了。

最後

最後這麼來看,其實也不是 ts-node 的鍋,因為 ts-node 的特殊性( 不會生成包含 sourceMap 的 js ),所以必須得在 source-map-supportretrieveFile 方法返回緩存在內存中的 js 代碼,否則 source-map-support 自己去讀 ts 文件的話也是拿不到 sourcemap ,一樣會導致堆棧行數錯誤。

主要原因還是在於多個模塊都是基於修改 module._compile 來實現,大家都生成了 sourcemap,但是沒有考慮如何能被 source-map-support 正確消費而已。

當查出這個原因之後,發現導致這個的原因並不複雜,只是從出現問題,到解決問題這個過程還是比較折騰的( 也有可能是我學藝不精,繞了個圈子[攤手] ),各種看源碼....正所謂一言不合就看源碼。

寫這篇文章,也是方便之後,如果有其他類似的通過修改 module._compile 來實現的模塊出現堆棧問題的時候,提供一種這樣的解決思路。


本文首發於: ts-node 錯誤堆棧問題排查小記 · Issue #13 · whxaxes/blog


推薦閱讀:

Vue + TypeScript踩坑(初始化項目)
襁褓中的 deno (番外):現狀與展望
微軟發起TSDoc項目試圖規範文檔格式
TypeScript 3.0 元組類型的用法和一些奇技淫巧

TAG:eggjs | Nodejs | TypeScript |