背景
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 插件开发有几篇说明文档。
- 编译器 api :https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API
- service api -:https://github.com/Microsoft/TypeScript/wiki/Using-the-Language-Service-API
- 开发环境搭建 -: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 文本。流程大概如下:
- 引入一个 css modules 文件
// xx.ts
import s from "./app.module.less";
- typescript service 调用 getScriptSnapshot 钩子获取类型声明
// typescript service invoke
languageServiceHost.getScriptSnapshot("app.module.less");
- 劫持 getScriptSnapshot
// typescript-plugin-css-modules
languageServiceHost.getScriptSnapshot = (fileName) => {
if (isCSS(fileName)) {
return getDtsSnapshot(fileName);
}
return info.languageServiceHost.getScriptSnapshot(fileName);
};
- 在 getDtsSnapshot 函数中编译 app.module.less
read file string -> less.render -> postcss.process
- 编译完成后,我们会得到下面这样的字符串,这样只要针对每一行使用正则匹配即可获取到所有导出的类名,这时候只要进行简单的字符串拼接,即可生成对应的 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悟空
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。