vscode 如何支持 css-module 文件跳转到类名?

698 阅读7分钟

背景

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. 编译器 apigithub.com/Microsoft/T…
  2. service api -:github.com/Microsoft/T…
  3. 开发环境搭建 -:github.com/microsoft/T…

其中,文档中提到了一个关键的钩子 - 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");

3. 劫持 getScriptSnapshot

// typescript-plugin-css-modules
languageServiceHost.getScriptSnapshot = (fileName) => {
  if (isCSS(fileName)) {
    return getDtsSnapshot(fileName);
  }
  return info.languageServiceHost.getScriptSnapshot(fileName);
};

4. 在 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"
}

我们暂时不需要明白复杂的编码规则,可以在 www.murzwin.com/base64vlq.h… 上分析出具体的映射关系。

([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 github.com/mrmckeb/typ… goToDefinition doesn't work properly github.com/mrmckeb/typ…

确定了是插件内部的问题后,我们从源码层面分析,打印出了上述的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悟空

qrcode_for_gh_57e44d589585_344.jpg