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. 编译器 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 文件的类型。比如:

csharp 复制代码
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 文件的类型声明文本即可。比如对于文件:

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

我们需要生成这样的快照:

typescript 复制代码
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)时,将变量定位到准确的某一行,这时候我们只需要将快照调整一下格式。

typescript 复制代码
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 文件
javascript 复制代码
// xx.ts
import s from "./app.module.less";
  1. typescript service 调用 getScriptSnapshot 钩子获取类型声明
arduino 复制代码
// typescript service invoke
languageServiceHost.getScriptSnapshot("app.module.less");
  1. 劫持 getScriptSnapshot
scss 复制代码
// typescript-plugin-css-modules
languageServiceHost.getScriptSnapshot = (fileName) => {
  if (isCSS(fileName)) {
    return getDtsSnapshot(fileName);
  }
  return info.languageServiceHost.getScriptSnapshot(fileName);
};
  1. 在 getDtsSnapshot 函数中编译 app.module.less
arduino 复制代码
read file string -> less.render -> postcss.process
  1. 编译完成后,我们会得到下面这样的字符串,这样只要针对每一行使用正则匹配即可获取到所有导出的类名,这时候只要进行简单的字符串拼接,即可生成对应的 d.ts 了
css 复制代码
.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

json 复制代码
{
  "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... 上分析出具体的映射关系。

css 复制代码
([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,如下:

scss 复制代码
([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:

php 复制代码
// 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行内即可:

typescript 复制代码
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悟空
相关推荐
啧不应该啊4 分钟前
vue配置axios
前端·javascript·vue.js
__fuys__8 分钟前
【HTML样式】加载动画专题 每周更新
前端·javascript·html
Want59511 分钟前
HTML粉色烟花秀
前端·css·html
让开,我要吃人了16 分钟前
HarmonyOS鸿蒙开发实战(5.0)自定义全局弹窗实践
前端·华为·移动开发·harmonyos·鸿蒙·鸿蒙系统·鸿蒙开发
一条晒干的咸魚34 分钟前
响应式CSS 媒体查询——WEB开发系列39
前端·css·html·css3·响应式设计·媒体查询
凌晨五点的星1 小时前
网络安全-webshell绕过,hash碰撞,webshell绕过原理
开发语言·前端·javascript
天心天地生1 小时前
【bugfix】-洽谈回填的图片消息无法显示
开发语言·前端·javascript
啧不应该啊1 小时前
element plus 按需导入vue
前端·javascript·vue.js
Gungnirss1 小时前
vue中提示Parsing error: No Babel config file detected
前端·vue.js·ubuntu
梅秃头2 小时前
vue2+elementUI实现handleSelectionChange批量删除-前后端
前端·javascript·elementui