动态

详情 返回 返回

vscode 如何支持 css-module 文件跳轉到類名? - 动态 详情

背景

css module 是目前主流的 css 模塊化的解決方案。使用 css module 之後,我們可以將 css 類當作模塊變量引入到我們的 typescript (下述使用 "ts" 代指)文件中來作為樣式的引用。過去,由於 ts 無法識別 css module 中導出的變量,我們使用 css 模塊變量需要到 css 文件中找到對應的類名,再寫到 ts 文件中使用,容易出錯且影響了開發效率和體驗。

為此,社區有解決方案:typescript-plugin-css-modules (下述使用"插件"代指),使 IDE(vscode)可以正確識別出的 css module 文件中 css class 的類型,開發體驗已經有了非常大的提升。下圖為插件的演示:

插件提供了一個實驗性功能 - goToDefinition,目標是能支持 vscode 跳轉到定義 class 類名的樣式文件的位置。但是在實際使用上,始終無法跳轉到正確的類名位置:

為了進一步提高開發體驗,我們嘗試實現這個功能。

開始

開始分析之前,首先了解一下 typescript 插件的原理,官方對於 typescript 插件開發有幾篇説明文檔。

  1. 編譯器 api :https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API
  2. service api -:https://github.com/Microsoft/TypeScript/wiki/Using-the-Language-Service-API
  3. 開發環境搭建 -:https://github.com/microsoft/TypeScript/wiki/Writing-a-Language-Service-Plugin

其中,文檔中提到了一個關鍵的鈎子 - getScriptSnapshot。這是關於 ScriptSnapshot 的官方描述:

表示給定時間點語言服務的輸入文件文本的狀態。ScriptSnapshot 主要用於實現高效的增量解析。 ScriptSnapshot 旨在回答兩個問題:
1.當前文本是什麼?
2.給定之前的快照,變化範圍是多少?

我們只需要理解第一點 - 當前文本是什麼,其實可以通俗一點解釋為目標文件對應的 d.ts 文本,其中 d.ts 文本描述了目標文件的類型聲明。

舉個例子,我們有一個 foo.ts 文件,那麼使用 tsc 編譯後,可以產生一個 foo.js 和foo.d.ts 文件,那麼 foo.d.ts 就是 foo.js 的快照(ScriptSnapshot),對於快照和 d.ts 文件兩者的區別可以簡單地理解成,在插件運行過程中,快照會保存在內存,而 tsc 運行過程中,foo.d.ts 會保存到磁盤。

插件只要在這個鈎子函數裏面,返回 d.ts 文本,那麼 vscode 就能正確識別出 css module 文件的類型。比如:

languageServiceHost.getScriptSnapshot = () => {
  if (isCSS(fileName)) {
    // 返回 d.ts 文本快照
    return `declare let _classes: {
      'container': string;
      'content': string;  
    }
    export default _classes;`;
  }

  return info.languageServiceHost.getScriptSnapshot(fileName);
};

目標

知道原理後,我們知道插件只要在 typescript 調用 getScriptSnapshot 鈎子的時候,返回能描述 css module 文件的類型聲明文本即可。比如對於文件:

// foo.less
.container {
  height: 100%;
  width: 100%;
  min-width: 1440px;
  min-height: 580px;
  .content {
    width: 100%;
    height: calc(~'100% - 48px');
  }
}

我們需要生成這樣的快照:

declare let _classes: {
  container: string;
  content: string;
};
export default _classes;
export let container: string;
export let content: string;

那麼 vscode 就能 foo.less 正確識別文件中導出了 container 和 content 這兩個變量了。

現在,vscode 已經知道了文件中導出的變量了,思路打開,現在我們希望在 goToDefinition(cmd + click)時,將變量定位到準確的某一行,這時候我們只需要將快照調整一下格式。

declare let _classes: {
  container: string;




  content: string;
};
export default _classes;

有什麼不同呢?我們將快照中的 container 聲明位置的代碼行數調整成和 foo.less 對應的 container 類名的代碼行數一致(都在第2行),content 同理(都在第7行)。那麼,我們在使用 goToDefinition(cmd + click)時,vscode 即可跳轉到跟聲明相同的行數,從而實現類名跳轉的準確定位

分析

瞭解到我們需要實現的快照目標之後,我們再瞭解一下插件目前是怎麼做的。

首先,插件需要將 css modules 編譯成具有類名的 d.ts,這意味着需要先安裝幾種預編譯器,包括 less,sass,stylus,postcss。然後在 typescript service 調用 getScriptSnapshot 這個鈎子時,將 css modules 文件編譯成 d.ts 文本。流程大概如下:

  1. 引入一個 css modules 文件

// xx.ts
import s from "./app.module.less";
  1. typescript service 調用 getScriptSnapshot 鈎子獲取類型聲明
// typescript service invoke
languageServiceHost.getScriptSnapshot("app.module.less");
  1. 劫持 getScriptSnapshot
// typescript-plugin-css-modules
languageServiceHost.getScriptSnapshot = (fileName) => {
  if (isCSS(fileName)) {
    return getDtsSnapshot(fileName);
  }
  return info.languageServiceHost.getScriptSnapshot(fileName);
};
  1. 在 getDtsSnapshot 函數中編譯 app.module.less
read file string -> less.render -> postcss.process
  1. 編譯完成後,我們會得到下面這樣的字符串,這樣只要針對每一行使用正則匹配即可獲取到所有導出的類名,這時候只要進行簡單的字符串拼接,即可生成對應的 d.ts 了

.container {
  height: 100%;
  width: 100%;
  min-width: 1440px;
  min-height: 580px;
}
.container .content {
  width: 100%;
  height: calc(~'100% - 48px');
}
:export {
  container: container;
  content: content;
}

至此,其實我們已經可以實現第一個目標了,但對於實現 goToDefinition,最後生成 d.ts 的時候,還需要知道一個信息 - 編譯後的類名對應源文件中的哪一行。比如説,怎麼知道編譯後的 content 類對應的是 app.module.less 文件的哪一行?

SourceMap

sourceMap 記錄源碼和編譯後代碼的位置映射關係,我們可以根據 sourceMap 從編譯後代碼找到源碼對應位置。舉個例子:下面是一段 less 編譯成 css 的 SourceMap

{
  "version": 3,
  "sources": [
    "/Users/qyzz/Desktop/workspace/mf-district-web/client/modules/main/__test.module.less"
  ],
  "names": [],
  "mappings": "AAAA;EACI,WAAA;EACA,YAAA;EACA,iBAAA;EACA,iBAAA;;AAJJ,UAMI;EACI,WAAA;EACA,QAAQ,iBAAR;;AARR,UAWI;EACI,YAAA;;AAZR,UAeI;EACI,YAAA;;AAhBR,UAmBI;EACI,YAAA;;AApBR,UAuBI;EACI,YAAA;;AAxBR,UA2BI;EACI,YAAA;;AA5BR,UA+BI;EACI,YAAA;;AAhCR,UAmCI;EACI,YAAA;;AApCR,UAuCI;EACI,YAAA"
}

我們暫時不需要明白複雜的編碼規則,可以在 https://www.murzwin.com/base64vlq.html 上分析出具體的映射關係。

([from_position](source_index)=>[to_position])
([0,0](#0)=>[0,0])
([1,4](#0)=>[1,2]) | ([1,4](#0)=>[1,13])
([2,4](#0)=>[2,2]) | ([2,4](#0)=>[2,14])
([3,4](#0)=>[3,2]) | ([3,4](#0)=>[3,19])
([4,4](#0)=>[4,2]) | ([4,4](#0)=>[4,19])
([0,0](#0)=>[6,0]) | ([6,4](#0)=>[6,10])
([7,8](#0)=>[7,2]) | ([7,8](#0)=>[7,13])
([8,8](#0)=>[8,2]) | ([8,16](#0)=>[8,10]) | ([8,8](#0)=>[8,27])

幸運的是,less 、sass、postcss 都支持在編譯後生成 sourceMap。那麼插件可以根據 sourceMap 的映射關係,找到源代碼的位置。以 less 編譯為例:

less 編譯 app.module.less 產生 SourceMap1,postcss 再次編譯產生 SourceMap2,再利用 SourceMap2 找到類名的源碼位置後,生成 d.ts 文件。

設計是沒問題的,但是在上述背景中發現其實在 goToDefinition 並沒有對上源碼的位置。這個問題在github上也有相關的issue。

Go to definition" does not work right
https://github.com/mrmckeb/typescript-plugin-css-modules/issues/34
goToDefinition doesn't work properly
https://github.com/mrmckeb/typescript-plugin-css-modules/issues/247

確定了是插件內部的問題後,我們從源碼層面分析,打印出了上述的SourceMap2,如下:


([from_position](source_index)=>[to_position])
([0,0](#0)=>[0,0])
([1,4](#0)=>[1,2]) | ([1,4](#0)=>[1,13])
([2,4](#0)=>[2,2]) | ([2,4](#0)=>[2,14])
([3,4](#0)=>[3,2]) | ([3,4](#0)=>[3,19])
([4,4](#0)=>[4,2]) | ([4,4](#0)=>[4,19])
([5,0](#1)=>[5,0])
([0,0](#0)=>[6,0])
([7,8](#0)=>[7,2]) | ([7,8](#0)=>[7,13])
([8,8](#0)=>[8,2]) | ([8,8](#0)=>[8,27])
// ... 省略

以 content 這個類為例:在編譯後的文件中,content 這個類處於第7行,對應的 sourceMap 為 (0,0=>[6,0]),其中 sourceMap 行數從0開始。我們發現,content 被指向了第0行,而不是預期的第6行。因此我們可以定位出問題在 sourceMap 上。

觀察源碼,我們發現在 postcss.process 進行編譯時,沿用了 less 編譯的 sourceMap:


// less 編譯
const { transformedCss, sourceMap } = less.render(rawCss);
// postcss 編譯
const processedCss = processor.process(transformedCss, {
      from: fileName,
      map: {
        inline: false,
        prev: sourceMap,
      },
    });

實際上,由於 postcss 和 less 生成 sourceMap 的方式和處理邏輯不同,postcss基於 less 生成的 sourceMap 來進一步生成新的 sourceMap 是有可能會導致在多次編譯過程中代碼位置對不上號的。

因此,我們稍微修改一下流程, postcss 不沿用 less 生成的 sourceMap,而是分別利用兩個sourceMap來找到css類的源碼位置。

知道css類的源碼行的位置後,我們只需要將類名聲明插入到對應的d.ts行內即可:


declare let _classes: {
  container: string;




  content: string;
};
export default _classes;

至此,我們就可以讓 vscode 正確地跳轉到 css module 類的源碼的位置了。效果演示:

總結

typescript-plugin-css-modules 使用了 less/sass/postcss 預編譯 css modules 文件後,使用正則找出可導出的類名,並依此生成 d.ts 快照(ScriptSnapshot),從而讓 vscode 能識別出 css modules 文件的類型。為了實現 goToDefinition 的功能,插件使用 sourceMap 查詢源碼位置,保證了導出變量類型和源碼之間的行位置一致,但 sourceMap 傳遞過程中可能會導致位置錯亂,可以考慮使用多次查詢 sourceMap 位置的方式來規避位置錯亂的問題。

更多好文盡在同名公眾號:好奇de悟空

Add a new 评论

Some HTML is okay.