作者:兰燕平
背景
在进行项目开发的时候,可能会遇到"想找某个色值"的场景,因为颜色值一般是数字类型,没有语义,不好全局搜索。所以就希望有一个工具,能够快速展示工作目录下的所有颜色,方便取值。
由于是在编写代码阶段,所以这个工具最方便的其实是编辑器的插件。我使用VSCode插件,所以先来试试开发一个VSCode插件。
在正式开发之前,先来给这个插件取个好名字,color, see, 一想到这两个单词,我脑海里直接蹦出了"给你点颜色看看"的中文式表达"give you color to see",看起来Color to See 是个好名字。
需求分析
在开发之前,先来想想我们的"产品"长什么样子,要实现什么功能。
插件要实现的目标是能够快速找到某个色值,所以我们要将颜色可视化,色值是最终我们希望能拿到的,所以需要将项目中所有的颜色收集起来,统一展示(包含可视化、色值)。
具体功能清单如下:
-
收集工作区下的所有颜色
- 文件更新(删除、新建、文本更新)了能够获取到最新的颜色
-
可视化所有颜色块
- 以网格状的形式展示颜色色块
-
色值展示和获取
- 点击颜色展示对应的色值,支持Hex和RGB格式
- 点击色值一键复制
UI设计如下:
功能实现
VSCode WebView
WebView
可以用来创建自定义的视图,可以看成是VSCode内部的 Iframe
容器,可以渲染任何HTML, 通过消息传递实现通信,所以具备非常强大的页面渲染和交互能力。
VSCode内部提供了三个API可以创建WebView
:
window.createWebviewPanel
创建一个编辑器面板。即时的,关闭编辑器就销毁了,主要用于那些不需要持久化状态(在关闭后不需要恢复)的场景,示例代码如下:
TS
const panel = vscode.window.createWebviewPanel(
'webviewId', // Webview 的标识符
'My Webview', // 面板标题
vscode.ViewColumn.One, // 面板显示在哪个编辑器列中
{ enableScripts: true } // 额外的 Webview 选项
);
panel.webview.html = "<html><body><h1>Hello, Webview!</h1></body></html>";
window.registerWebviewPanelSerializer
定义如何序列化和反序列化 WebView 面板的状态,这样即使在 VSCode 重启后,这个 WebView 面板的状态也可以被恢复。所以这种WebView,对于那些需要保留用户状态或信息的面板来说非常有用。
TS
class MyWebviewPanelSerializer implements vscode.WebviewPanelSerializer {
async deserializeWebviewPanel(webviewPanel: vscode.WebviewPanel, state: any) {
// 重新设置 Webview 的内容等
webviewPanel.webview.html = "<html><body><h1>Restored Webview</h1></body></html>";
}
}
context.subscriptions.push(vscode.window.registerWebviewPanelSerializer('webviewId', new MyWebviewPanelSerializer()));
window.registerWebviewViewProvider
创建一个编辑器面板。持久的视图,常驻在侧边栏(Sidebar)和面板(Panel)上
TS
class MyWebviewProvider implements vscode.WebviewViewProvider {
resolveWebviewView(webviewView: vscode.WebviewView) {
webviewView.webview.html = "<html><body><h1>Hello, Sidebar Webview!</h1></body></html>";
}
}
const provider = new MyWebviewProvider();
context.subscriptions.push(vscode.window.registerWebviewViewProvider('myWebview', provider));
考虑我们插件的使用场景一般是没有UI稿的后台开发,使用次数不会很频繁,所以插件的视图不需要持久,所以可以选择用createWebviewPanel
创建一个编辑器面板来承载界面。
项目初始化
通过官方文档给的脚手架命令,开始初始化我们的插件项目。目录结构如下:
MARKDOWN
├─ .eslintrc.json
├─ .gitignore
├─ .vscode
│ ├─ extensions.json
│ ├─ launch.json
│ ├─ settings.json
│ └─ tasks.json
├─ .vscode-test.mjs
├─ .vscodeignore
├─ .yarnrc
├─ CHANGELOG.md
├─ README.md
├─ package.json
├─ src
│ ├─ extension.ts
│ ├─ test
│ │ └─ extension.test.ts
├─ tsconfig.json
├─ vsc-extension-quickstart.md
├─ webpack.config.js
├─ yarn-error.log
└─ yarn.lock
在package.json
文件中,可以看到main
配置是dist
文件夹下的extension.js
文件,这是我们项目的入口文件,是Webpack把src/extentions.ts
作为入口文件编译过来的
看package.json
的script
配置,发现可以运行yarn watch
命令进行热更新,也就是实时把改动的代码编译成可执行的JS文件。
运行完yarn watch
后,通过按"F5
"或者"运行->启动调试",运行我们的插件,这时候VSCode会打开一个新的窗口,快捷键ctrl + shift + p输入hello world
最后,点击执行这个命令,可以看到右下角弹出了欢迎标语。如下图所示:
如果修改了代码,可以F5刷新debug窗口,也可以在debug窗口上使用快捷键command + R刷新,和在浏览器刷新网页一样。
以上就是 VSCode 插件开发起步的过程。
接入WebView
新增colorToSee
命令
在package.json
文件的commands
属性上新增extension.colorToSee
,这个命令是使用我们插件的起点,所以务必写个好title
,为了让插件国际化,我的title
设置为"ColorToSee: Show colors of the working directory in a webview panel"
JSON
"commands": [
{
"command": "extension.colorToSee",
"title": "ColorToSee: Show colors of the working directory in a webview panel"
}
],
这个命令的执行回调函数在extension.ts
文件上,重点代码如下:
TS
vscode.commands.registerCommand(COMMAND_NAME, () => {
registerWebviewViewProvider(context);
})
在这串代码中,我们把具体的实现逻辑封装在registerWebviewViewProvider
方法上,接下来我们可以只关注这个方法的实现。
创建WebView来显示自定义UI
使用WebView API createWebviewPanel
来创建一个自定义的HTML页面,基本的代码块如下所示:
TS
const panel = vscode.window.createWebviewPanel(
CatCodiconsPanel.viewType,
"Cat Codicons",
column || vscode.ViewColumn.One
);
panel.webview.html = _getHtmlForWebview(panel.webview, extensionUri);
function _getHtmlForWebview(webview: vscode.Webview) {
// Use a nonce to only allow specific scripts to be run
const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Use a content security policy to only allow loading images from https or from our extension directory, and only allow scripts that have a specific nonce. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your View</title>
</head>
<body>
<h1>Hello from Your View!</h1>
</body>
</html>`;
}
function getNonce() {
let text = '';
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
在registerWebviewViewProvider
方法中,我们将基于上述代码做些改造,大致的框架如下:
TS
const registerWebviewViewProvider = (context: vscode.ExtensionContext) => {
const provider = new ViewProvider(context.extensionUri, config);
const panel = vscode.window.createWebviewPanel(
ViewProvider.viewType, // Webview 的标识符
PANEL_TITLE, // 面板标题
vscode.ViewColumn.One, // 面板显示在哪个编辑器列中
{
enableScripts: true
} // 额外的 Webview 选项
);
provider.resolveWebviewView(panel as unknown as vscode.WebviewView);
context.subscriptions.push(panel);
};
在这段代码中,我们把页面信息维护在ViewProvider
这个类上。在这个类上,我们要实现页面的渲染和更新。
页面渲染
页面的渲染机制可以借用React 框架的 render(state) 思想,这个思想基于函数式编程的原则,其核心是将UI视为状态的函数,UI的每一次更新可以看作是一个状态转换的结果。
本插件的页面构成比较简单,为了方便,可以直接使用原生JS和HTML开发。页面的模块主要分为以下4个部分:
- 刷新按钮
- 颜色网格
- 颜色值展示
- toast弹窗提醒(消息提醒)
整个数据驱动式的页面渲染如下所示:
HTML
<body>
${generateMainDiv(this.colorInfos)}
</body>
定义状态
首先,我们定义colorInfos
状态,这个变量存储了当前工作区所有颜色信息,包括该颜色的所在文件的位置和色值,TS定义如下所示:
TS
export type ColorItem = {
/** 颜色值起始位置 */
start: number;
/** 颜色值结束位置 */
end: number;
/** 颜色值 */
color: string;
/** 颜色值所在的文件路径 */
file: string;
};
渲染函数
"颜色网格"是我们整个页面主要的功能模块,所以我们重点介绍这个功能的渲染函数,根据colorInfos
,一个能根据这个状态生成HTML的函数可以简化成这样:
TS
function generateMainDiv(colors) {
return colors.map(info => `<div style="color: ${info.color}" data-colorItem="${encodeURIComponent(
JSON.stringify(item)
)}">${info.color}</div>`).join('');
}
其中自定义属性data-colorItem
存储了整个颜色块的所有信息。
页面挂载
把html
元素赋值给WebView
的html
属性,就可以实现页面的挂载。
TS
this._view.webview.html = this._getHtmlForWebview(this._view.webview);
function _getHtmlForWebview(webview: vscode.Webview) {
// Use a nonce to only allow specific scripts to be run
const nonce = getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Use a content security policy to only allow loading images from https or from our extension directory, and only allow scripts that have a specific nonce. -->
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your View</title>
</head>
<body>
${generateMainDiv(this.colorInfos)}
</body>
</html>`;
}
如何构造状态colorInfos
?
上面描述的数据驱动式框架比较简单,还没有涉及到本项目的难点实现,如果构造colorInfos
数据,是本项目的难点之一,本节主要讲这块内容。
颜色格式分析
在项目开发中,比较常见的颜色格式分为RGB, Hex, HSL和颜色关键字(Named Colors)这几种。下面我们来分析这几种颜色在CSS中的语法。
从MDN官网发现rgb()
的写法分为绝对和相对格式,我们平时熟悉的写法是这样的:rgb(x, y, z)
,其中x、y、z是从0到255的整数,属于绝对格式的一种。
为了让项目能尽快先落地,我们此处只分析"绝对格式"的场景。绝对格式的写法如下:
TS
rgb(R G B[ / A])
其中R
G
B
的值可以为0-255的整数,或者0%-100%的百分比;A的值为0到1(或者0%-100%),表示颜色的透明度信息。如果有A值时,需要使用 /
。
我们经常见到的一种写法是R
G
B
通过逗号隔开,这种写法其实是一种过时的写法,但是考虑到大部分人都在用,匹配CSS RGB颜色的时候也应该考虑到。rgba()
语法也是一种过时的写法,同样本次也会考虑这种颜色格式的匹配。
CSS hsl()
的语法和rgb()
是一致的,这里不在赘述,需要请移至MDN官网查看。
16进制Hex的色值写法分为以下几种:
TS
#RGB // The three-value syntax
#RGBA // The four-value syntax
#RRGGBB // The six-value syntax
#RRGGBBAA // The eight-value syntax
其中R
G
B
的值为0到ff。
在CSS中,一些常用颜色可以使用预定义的关键字来表示,例:red
、blue
、green
等,这些就是颜色关键词。完整的标准关键词可以在MDN官网找到。
颜色匹配
了解了颜色在项目中的多种写法,下面考虑如何匹配代码中的颜色。常见的方案分为两种:
- 基于文本模式的正则匹配
- 基于代码结构的抽象语法树(AST)分析
前者实现比较简单且通用性大,因此本插件选择通过正则语法,针对不同的颜色格式,定制不同的匹配策略。
编写一个基本的正则表达式来匹配颜色代码其实不难,但是如果想提高要求,确保写出来的正则表达式既准确又具有良好的防御性,能够考虑到各种边缘情况以及性能优化,这并不容易。
为了实现这一点,此处我借鉴了另外一个插件Color Highlight的实现。这个插件是我目前在使用的比较好用的插件,在开发自己的插件之前,我从没想过它是如何实现的,但是我熟悉它的功能:在编写代码的时候,可以高亮显示当前当前编辑器中的颜色格式。要实现的功能和我的有相似之处,所以了解这个插件的实现应该对于完成自己的插件很有帮助。
事实确实是这样的,Color Highlight插件给出了一系列针对不同颜色格式的匹配策略,如下图所示。
基于它的实现,下面分析了针对不同颜色格式的正则表达式解析。
RGB和HSL
TS
const colorRegex = /((rgb|hsl)a?(\s*[\d]*.?[\d]+%?\s*(?<commaOrSpace>\s|,)\s*[\d]*.?[\d]+%?\s*\k<commaOrSpace>\s*[\d]*.?[\d]+%?(\s*(\k<commaOrSpace>|/)\s*[\d]*.?[\d]+%?)?\s*))/gi;
整个正则表达式是一个全局不区分大小写的匹配(由结尾的 gi
标志指定),用于在文本中查找所有匹配的颜色值。主要分为两部分:格式名称(值)
,其中格式名称分为三种情况,如下图所示,分别为rgb
,hsl
,rgba
, hsla
。后半部分主要是匹配它们的不同书写方式,包含数字值、逗号或空格分割。具体解析如下:
((rgb|hsl)a?
: 这部分使用捕获组来匹配rgb
或hsl
字符串,后面跟着一个可选的a
字符,这样可以匹配rgb
、rgba
、hsl
和hsla
。
(
:匹配左括号**\s*[\d]*.?[\d]+%?\s***
: 用来匹配颜色值的第一个参数,(如红色值 R、色相值 H ),首先是匹配零个或多个空格符,然后是一个数字(可能是整数或小数),该数字可能后跟一个百分号%
。
(?<commaOrSpace>\s|,)
: 定义了一个命名捕获组commaOrSpace
,用来匹配空格或逗号。这是颜色值参数之间的分隔符。**\s*[\d]*.?[\d]+%?\s***
: 用来匹配颜色值的第二个参数,(如红色值 R、色相值 H ),首先是匹配零个或多个空格符,然后是一个数字(可能是整数或小数),该数字可能后跟一个百分号%
。
\k<commaOrSpace>
: 对前面定义的commaOrSpace
捕获组的引用,确保分隔符的一致性。\s*[\d]*.?[\d]+%?
: 再次匹配一个数字(整数或小数),可能后跟一个百分号%
。这部分用于匹配颜色值的第三个参数。
**\s*(\k<commaOrSpace>|/)\s*[\d]*.?[\d]+%?)?\s*
: 这是一个可选的捕获组,用于匹配颜色透明度值(alpha 值)。它首先匹配空格,然后是前面定义的分隔符(commaOrSpace
)或斜杠/
,后面跟着空格和数字(整数或小数),数字后面可能有一个百分号%
**。
)
:最后匹配右括号)
。
颜色关键词
收集MDN官网给出的颜色关键字,写一个简单的正则,下面是列举了部分色值的正则匹配:
TS
(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)
16进制Hex
TS
const colorHex =
/.?((?:#|\b0x)([a-f0-9]{6}([a-f0-9]{2})?|[a-f0-9]{3}([a-f0-9]{1})?))\b/gi;
这个正则表达式用来匹配 CSS 中的十六进制颜色值,同时也匹配十六进制颜色值的简写形式,并且考虑到可能的透明度值(RGBA格式)。下面逐部分解释这个正则表达式:
.?
: 匹配任意单个字符,但不捕获它。((?:#|\b0x)
: 这是一个非捕获组(由?:
开头),用于匹配#
或0x
。这里的\b
是一个单词边界,确保0x
前面是一个单词边界,避免中间匹配到不正确的字符串。
-
([a-f0-9]{6}([a-f0-9]{2})?|[a-f0-9]{3}([a-f0-9]{1})?)
: 这部分有两大选项,分别通过|
分隔:[a-f0-9]{6}([a-f0-9]{2})?
: 匹配6个十六进制字符,后面可选地跟随2个十六进制字符(通常用于表示透明度)。[a-f0-9]{3}([a-f0-9]{1})?
: 匹配3个十六进制字符,后面可选地跟随1个十六进制字符。这是十六进制颜色的简写形式
-
\b
: 单词边界,确保匹配的字符串在一个单词的边界结束,防止部分匹配到更长的字符串中。
颜色获取
了解了不同颜色格式的匹配规则后,颜色的获取思路其实很简单:遍历每个文件,获取每个文件的文本字符串,通过对应的正则匹配策略去匹配。基本的代码如下所示:
策略维护
从产品的使用场景看来,本插件所关注的颜色格式比较简单:一些基本的rgb, hex, hsl,单词的颜色可以不考虑,因为用不上,能用上我都可以直接匹配了,因此我们只关注没有语义、且常用的颜色格式。
最终,针对本插件,可以总结出下面三种策略:
findColorFunctionsInText
: 查找rgb(a), hsl(a)基本颜色findHexRGBA
:查找hex颜色
TS
this.strategies = [findColorFunctionsInText,findHexRGBA];
文件遍历
TS
for (const file of files) {
try {
const document = await vscode.workspace.openTextDocument(file);
const instance = await this.findOrCreateInstance(document);
colorsInfos.push(await instance.getColorInfo());
} catch {
continue;
}
}
颜色获取getColorInfo
TS
getColorInfo(document = this.document) {
const text = this.document.getText();
const version = this.document.version.toString();
const file = this.document.uri.fsPath; // file path
const result = await Promise.all(this.strategies.map((fn) => fn(text)));
return resolveResult(result); // 颜色解析,根据colorInfos的数据格式进行数据解析
}
页面更新
颜色的更新场景主要有以下几种情况:
- 编辑已有文件的颜色
- 新增有颜色的文件
- 删除了有颜色的文件
针对这三种情况,在产品设计上,可以设置一个"刷新按钮"按钮,点击该按钮,拉取最新的颜色信息。本章节主要从消息通信和更新机制两个角度讨论本插件的实现。
消息通信
在页面上点击按钮,然后执行对应的事件,这点的实现涉及到VSCode插件中Extension
和 Webview
的通信,主要是通过postMessage
和onDidReceiveMessage
实现消息的发送与接收。
在Webview
发送消息
Extension
里通过vscode.postMessage
发送消息:
TS
// 刷新
refreshBtn.addEventListener('click', () => {
// 如果已经在Loading, 无需发送message
if (refreshBtn.classList.contains('btn--loading')) {
return;
}
refreshBtn.classList.add('btn--loading');
vscode.postMessage({ command: 'refresh' });
});
在Extension
接收消息
TS
webviewView.webview.onDidReceiveMessage((message) => {
switch (message.command) {
case 'refresh':
const prom = () => this.doUpdateWebView()
prom().finally(() => {
webviewView.webview.postMessage({
command: 'refreshEnd'
});
});
break;
}
});
更新机制
在Extension
接受到需要更新的消息后,需要执行对应的更新机制,这个实现主要封装在doUpdateWebView
方法中。更新机制主要实现的就是更新colorInfos
,并重新执行渲染函数以更新UI。
为了让插件的第一个版本快速上线,文件新增、删除场景的更新我们可以重新扫描一下所有文件;文件编辑的场景容易定位具体文档,所以可以按需更新。
下面给出了本项目的更新实现,其中整个了页面初始化的渲染,因为这个场景的逻辑和文件新增、删除重复。
TS
private async doUpdateWebView() {
try {
if (
this.type === 'init' ||
this.type === 'add' ||
this.type === 'delete'
) {
await this.initDataView();
return Promise.resolve();
}
// 颜色变更: text change
// 收集变更的document,局部更新颜色视图
for (let index = 0; index < this.instanceMap.length; index++) {
const instance = this.instanceMap[index];
// 如果页面更改了
if (instance.changed) {
const colorDocumentItem = await instance.getColorInfo();
this.colorMapArray[index] = colorDocumentItem;
// 恢复
instance.changed = false;
}
}
// 更新颜色信息
this.colorInfos = updateColorInfosByMap(this.colorMapArray);
// 更新视图
this._view.webview.html = this._getHtmlForWebview(this._view.webview);
return Promise.resolve();
} catch {
return Promise.reject();
}
}
代码示意图如下所示:
插件配置
并不是项目中所有文件都有颜色值,所以为了让插件能够有效地找到有用的色值,我们需要给插件增加文件类型配置项,用来定义哪些文件应该在文件扫描过程中包含或排除。
include
和 exclude
这两个配置项在很多前端开发相关的工具和配置文件中非常常见,如 webpack.config
文件、tsconfig
文件、babel.config
文件。为了降低插件使用者的学习曲线,本插件也选择使用 include
和 exclude
来指定扫描的文件类型。
下面是本插件默认的配置属性。
JSON
"color-to-see.findFilesRules": {
"default": {
"include": [
"**/*.js",
"**/*.jsx",
"**/*.tsx",
"**/*.css",
"**/*.less",
"**/*.sass",
"**/*.html",
"**/*.vue"
],
"exclude": [
"**/node_modules/**",
"**/dist/**",
".git"
]
},
扫描的文件类型主要关注实际开发中包含色值定义的文件类型,比如样式文件、HTML文件或者JS文件。排除不需要扫描或处理的文件夹,如node_modules
,dist
,.git
文件等。include
和exclude
的值使用 Glob Pattern 语法来指定哪些文件被包括或排除。
在Extension中,这套配置的使用原理如下:
TS
config = vscode.workspace.getConfiguration(EXTENSION_NAME);
const findFilesUsingConfig = async (config: Config) => {
const { include, exclude } = config.findFilesRules;
const includePattern = `{${include.join(',')}}`;
const excludePattern = `{${exclude.join(',')}}`;
try {
const files = await vscode.workspace.findFiles(
includePattern,
excludePattern
);
return files;
} catch {
return [];
}
};
成品
Demo Snapshop
- 有颜色
- 无颜色
插件市场链接
👉👉👉:Color to See - Visual Studio Marketplace
局限性分析和优化点
尽管插件的功能大致已经实现了,但是本插件还是有一些局限性,以及后续可以优化的地方
文件扫描效率
局限性
插件需要扫描整个工作区的所有文件来查找颜色值,尽管增加了文件类型限制少扫描一些文件,但是剩下的文件扫描还是会消耗大量的计算资源。
优化
- 编译打包:项目代码打包到一个文件(如app.js)中以减少颜色扫描的文件数量的方法
- 缓存+增量更新:通过缓存机制保留未更改的文件,而只对更改的文件进行扫描,而不是整个工作区。
用户体验
局限性
颜色展示必须等待文件全部扫描完,长时间的扫描和等待会影响用户体验
优化
实现异步扫描机制,一旦扫描到新的颜色值,就立即在UI中展示,而不是等待所有扫描完成。
颜色值跳转功能配置化
局限性
颜色值跳转功能的实用性其实比较低,而且在本插件中,如果一个颜色在多个地方出现,我只会记录第一个位置。因此,本插件的使用过程中,用户应该更加关注于颜色值本身,而不是其在代码中的具体位置。
除此之外,颜色跳转到具体某个文件后,当前视图的位置没有滚动到对应的高度。
优化
增加一个配置项让用户可以自行决定是否启用颜色值跳转功能。默认关闭。
总结
本文基于一个场景问题,介绍了VSCode插件的开发过程,总的来说,这个项目算是一次很有意思的独立开发体验。在这个过程中,可以总结出以下几个比较重要的点:
- 善于发现问题,并尝试解决它
- 积累经验,关注细节,说不定何时能派上用场
- Always have fun
最后
关注公众号「Goodme前端团队」,获取更多干货实践,欢迎交流分享。