前言
在日常的开发流程中,例如开发一个二次封装的组件库,快速使用这些组件依赖我们查看文档,开发一款配套的组件库VSCode插件是非常有必要。
本文从0到1搭建一个组件库的vscode插件,具体内容如下:
- 项目搭建
- 具体功能实现
- 发布
1. 项目搭建
1.1 安装开发包
官方提供npm包,可以快速生成项目和发布。
- 项目生成包
npm install -g yo generator-code
1.2 生成项目
运行yo
-> 选择Code
-> 输入项目配置即可,或者直接yo code
。
开发vscode,使用 yarn会更丝滑
安装完成后,使用code-Insiders
打开,需要安装下vscode-insider
版本
1.3 项目说明
主要逻辑在src/extension.ts
文件中:
import * as vscode from 'vscode';
/**
* 一旦你的插件激活,vscode会立刻调用下述方法
* 只会在你的插件激活时执行一次
*/
export function activate(context: vscode.ExtensionContext) {
// 注册一个命令,当该命令被执行时,弹出提示框
let disposable = vscode.commands.registerCommand('hello-world-vscode.helloWorld', () => {
vscode.window.showInformationMessage('Hello World from hello-world-vscode!');
});
// 监听上面注册的命令
context.subscriptions.push(disposable);
}
export function deactivate() {}
命令注册,在package.json
中:
{
"name": "hello-world-vscode", // 插件名
"displayName": "hello-world-vscode", // 显示在应用市场的名字
"description": "", // 插件描述
"version": "0.0.1", // 插件的版本号
"engines": {
// 最低支持的vscode版本
"vscode": "^1.64.0"
},
"categories": [
"Other"
],
// 激活事件组,用来定义插件在什么时候被激活
"activationEvents": [
"onCommand:hello-world-vscode.helloWorld"
],
// 插件的主入口文件
"main": "./dist/extension.js",
// 贡献点这个比较重要,基本所有配置都在这里,保存通过哪些命令触发插件、插件配置信息等等
// 详情可参考官方文档:https://code.visualstudio.com/api/references/contribution-points
"contributes": {
"commands": [
{
"command": "hello-world-vscode.helloWorld",
"title": "Hello World"
}
]
},
}
总的来说,所有的功能都是在activate
这个函数中实现,配置状态栏和命令是在package.json
中配置完成。
1.4 如何调试
打开项目后,会默认一个Hello world
demo,此时直接按下F5
F5
F5
,重要的事情说三遍。会打开一个新的vscode,如下图右边所示。
进入后打开Ctrl + Shift + P
输入 Hello World
,
此时在src/extension.ts
下打断点 -> 重启下调试 -> Extension Development Host下输入命令 -> 进行源代码调试。
2. 具体功能实现
需要开发一个vscode组件库插件,需要理清哪些功能。通过学习varlet组件库的vscode插件,整体可拆分为四个部分:
- 命令功能
- 主要注册一些vscode的命令,可以直接通过
Ctrl + Shift + P
输入命令直接调用(需要在package.json通过commands进行注册)
- 主要注册一些vscode的命令,可以直接通过
- 自动补全功能
- 输入部分字符,可以联想名称
- 悬浮功能
- 组件悬浮上,可以进行提示
- 状态栏功能
- 定义组件状态,可以通过设置进行关闭
先从入口extension.ts文件说明:
import { type ExtensionContext } from 'vscode';
import { registerHover } from './hover'
import { registerCommands } from './commands';
import { registerCompletions } from './completions';
import { registerStatusBarItems, dynamicRegister } from './statusBarItems';
export function activate(context: ExtensionContext) {
registerCommands(context);
registerCompletions(context);
registerHover(context);
registerStatusBarItems(context);
dynamicRegister(context)
}
分别对每个进行介绍。
2.1 注册命令功能
2.1.1 光标移动命令
function moveCursor(characterDelta: number) {
const active = window.activeTextEditor!.selection.active!
const position = active.translate({ characterDelta })
window.activeTextEditor!.selection = new Selection(position, position)
}
获取当前活动的文本编辑器的光标位置,根据字符偏移量计算新位置,设置新的光标位置。后面会介绍具体作用。
2.1.2 打开外部文档命令
function openDocumentation() {
env.openExternal(Uri.parse(url))
}
调用vscodeenv
自带能力,打开外部链接
2.1.3 统一注册
export function registerCommands(context: ExtensionContext) {
context.subscriptions.push(
commands.registerCommand('proiview.move-cursor', moveCursor),
commands.registerCommand('proiview.open-documentation', () => {
openDocumentation()
}),
);
}
在package.json
的con
中设置右键和命令
"contributes": {
"commands": [
{
"command": "proiview.open-documentation",
"title": "Proiview: Open Proiview Documentation"
}
],
"menus": {
"editor/context": [
{
"command": "proiview.open-documentation",
"group": "navigation"
}
]
},
2.2 自动补全功能
自动补全分为两个部分:
- 一个是输入组件名称,进行联想补全
- 一个是在组件上输入属性或事件进行补全
补全必须要满足对应情况,比如特定的文件、或者属性补全必须在组件上。
前提: 定义一个组件默认补全描述,用于组件默认填充
// 定义组件描述符的接口,用于描述每个组件的路径和属性等信息
export interface ComponentDescriptor {
path: string // 组件路径
attrs?: string[] // 可选属性数组
characterDelta?: number // 可选的字符偏移量,用于调整光标位置
closeSelf?: boolean // 是否自闭合标志
}
// 定义组件映射,将组件名称映射到对应的组件描述符
export const componentsMap: Record<string, ComponentDescriptor> = {
button: {
path: '/button', // 组件路径
attrs: ['type="primary"'], // 组件属性
},
}
定义一个组件所有描述json文件,用于属性或实践查找
"contributions": {
"html": {
"tags": [
{
"name": "pro-table",
"attributes": [
{
"name": "v-model",
"description": "双向绑定",
"default": "/",
"value": {
"type": "string",
"kind": "expression"
}
}],
"extraAttrs": [
{
"name": "**columns配置**",
"description": "",
"default": "",
"value": {
"type": "",
"kind": "expression"
}
}
],
"events": [
{
"name": "select",
"description": "选择选项时触发"
}]
"slots": [
{
"name": "actions",
"description": "选项列表"
}]
]
}
}
2.2.1 组件补全
整体流程如下:
具体实现:
// 定义组件提供者
const componentsProvider: CompletionItemProvider = {
// 提供自动补全项
provideCompletionItems(document: TextDocument, position: Position) {
// 如果需要禁用提供函数,则返回 null
if (shouldDisableProvide(document, position)) {
return null
}
// 创建一个空的自动补全项数组
const completionItems: CompletionItem[] = []
// 遍历组件映射表,生成自动补全项
Object.keys(componentsMap).forEach((key) => {
const name = `var-${key}`
completionItems.push(
new CompletionItem(name, CompletionItemKind.Field),
new CompletionItem(bigCamelize(name), CompletionItemKind.Field)
)
})
// 返回自动补全项数组
return completionItems
},
// 解析自动补全项
resolveCompletionItem(item: CompletionItem) {
// 获取组件名称并去掉前缀
const name = kebabCase(item.label as string).slice(4)
const descriptor: ComponentDescriptor = componentsMap[name]
// 生成属性文本
const attrText = descriptor.attrs ? ' ' + descriptor.attrs.join(' ') : ''
// 根据是否自闭合生成标签后缀
const tagSuffix = descriptor.closeSelf ? '' : `</${item.label}>`
const characterDelta = -tagSuffix.length + (descriptor.characterDelta ?? 0)
// 设置插入文本
item.insertText = `<${item.label}${attrText}`
item.insertText += descriptor.closeSelf ? '/>' : `>${tagSuffix}`
// 设置命令,用于移动光标
item.command = {
title: 'proiview.move-cursor',
command: 'proiview.move-cursor',
arguments: [characterDelta],
}
// 返回解析后的自动补全项
return item
},
}
2.2.2 属性补全
具体实现:
// 定义属性提供者
const attrProvider: CompletionItemProvider = {
// 提供自动补全项
provideCompletionItems(document: TextDocument, position: Position) {
// 获取从文档开头到当前光标位置的文本
const text = document.getText(new Range(new Position(0, 0), new Position(position.line, position.character)))
// 获取光标位置的偏移量
const offset = document.offsetAt(position)
// 获取从光标位置到文档末尾的文本
const lastText = document.getText().substring(offset)
// 获取光标后的第一个字符
const nextCharacter = lastText.charAt(0)
// 如果光标后的字符不是空格、换行、斜杠或右尖括号,则返回 null
if (nextCharacter !== ' ' && nextCharacter !== '\n' && nextCharacter !== '/' && nextCharacter !== '>') {
return null
}
// 如果文本中没有匹配属性正则表达式的内容,则返回 null
if (!Array.from(text.matchAll(ATTR_RE)).length) {
return null
}
let name: string
let matchedValue: string
let startIndex = 0
// 遍历匹配的属性,获取属性名称和匹配值
// eslint-disable-next-line no-restricted-syntax
for (const matched of text.matchAll(ATTR_RE)) {
name = kebabCase(matched[1] ?? matched[2])
matchedValue = matched[0]
startIndex = matched.index!
}
// 获取当前文本长度
const currentIndex = text.length
// 计算匹配值的结束索引
const endIndex = startIndex! + matchedValue!.length
// 如果当前索引超出匹配值范围,则返回 null
if (currentIndex > endIndex || currentIndex < startIndex!) {
return null
}
// 获取所有 WebTypes 标签
const tags = getWebTypesTags()
// 查找与属性名称匹配的标签
const tag = tags.find((tag) => tag.name === name)
// 如果没有找到匹配的标签,则返回 null
if (!tag) {
return null
}
// 拆分匹配值为单词数组,获取最后一个单词
const words = matchedValue!.split(' ')
const lastWord = words[words.length - 1]
const hasAt = lastWord.startsWith('@')
const hasColon = lastWord.startsWith(':')
// 生成事件自动补全项数组
const events = tag.events.map((event) => {
const item = new CompletionItem(
{
label: `@${event.name}`,
description: event.description,
},
CompletionItemKind.Event
)
item.filterText = event.name
item.documentation = new MarkdownString(`\
**Event**: ${event.name}
**Description**: ${event.description}`)
item.insertText = hasAt ? event.name : `@${event.name}`
return item
})
// 生成属性自动补全项数组
const props = tag.attributes.map((attr) => {
const item = new CompletionItem(
{
label: attr.name,
description: attr.description,
},
CompletionItemKind.Value
)
item.sortText = '0'
item.documentation = new MarkdownString(`\
**Prop**: ${attr.name}
**Description**: ${attr.description}
**Type**: ${attr.value.type}
**Default**: ${attr.default}`)
item.insertText = attr.name
return item
})
// 返回属性和事件自动补全项数组
return [...(hasAt ? [] : props), ...(hasColon ? [] : events)]
},
// 解析自动补全项
resolveCompletionItem(item: CompletionItem) {
// 如果标签不是字符串,则设置命令和插入文本
if (!isString(item.label)) {
item.command = {
title: 'proiview.move-cursor',
command: 'proiview.move-cursor',
arguments: [-1],
}
item.insertText = `${item.insertText}=""`
}
// 返回解析后的自动补全项
return item
},
}
2.2.3 注册
// 注册自动补全功能
export function registerCompletions(context: ExtensionContext) {
// 注册组件自动补全提供者
context.subscriptions.push(languages.registerCompletionItemProvider(LANGUAGE_IDS, componentsProvider))
// 注册属性自动补全提供者,触发字符为空格、@和:
context.subscriptions.push(languages.registerCompletionItemProvider(LANGUAGE_IDS, attrProvider, ' ', '@', ':'))
}
具体效果
输入组件名称关键字时出现语法提示,选中后进行快速补全。
在标签的属性输入范围按下空格或属性关键字,显示属性补全提示和属性信息。
在标签的属性输入@,显示事件信息
2.3 悬浮功能
组件悬浮上,可以进行文档说明提示,具体流程如下如下:
具体实现:
// 获取组件的表格数据
export function getComponentTableData(component: string) {
// 生成组件名称
const name = `pro-${component}`
// 将组件名称转换为大驼峰命名法
const bigCamelName = bigCamelize(name)
// 获取所有 WebTypes 标签
const tags = getWebTypesTags()
const tag = tags.find((tag) => tag.name === name)
// 生成文档链接, 使用vitepress搭建,对应组件文档
const documentation = `${t('documentation')}pro_iview${componentsMap[component].path}.html`
// 生成链接 Markdown 字符串
const link = `\
[PROIVIEW: ${bigCamelName} -> ${t('linkText')}](${documentation})`
if (!tag) {
return {
link,
props: [],
extraProps: [],
events: [],
slots: [],
}
}
const props = tag.attributes.map((attr) => ({
name: attr.name,
description: attr.description,
defaultValue: attr.default,
type: attr.value.type || '' as string
}))
const extraProps = tag.extraAttrs.map((attr) => ({
name: attr.name,
description: attr.description,
defaultValue: attr.default,
type: attr.value.type || '' as string
}))
const events = tag.events.map((event) => ({
name: event.name,
description: event.description,
params: event.params
}))
const slots =
tag.slots?.map((slot) => ({
name: slot.name,
description: slot.description,
})) ?? []
return {
link,
props,
extraProps,
events,
slots,
}
}
// 获取组件的表格模板
export function getComponentTableTemplate(component: string) {
const { link, props, extraProps, events, slots } = getComponentTableData(component)
// 生成属性表格
const propsTable = props.length
? props.reduce(
(propsTable, prop) => {
const type = (prop.type as string).replace(/\|/g, ' \\| ');
propsTable += `| ${prop.name} | ${prop.description} | ${type} | ${prop.defaultValue} | \n`
return propsTable
},
`\
| ${t('prop')} | ${t('description')} | ${t('type')} | ${t('default')} |
| :--- | :--- | :--- | :--- |
`
)
: ''
// 生成额外属性表格
const extraPropsTable = extraProps.length
? extraProps.reduce(
(propsTable, prop) => {
const type = (prop.type as string).replace(/\|/g, ' \\| ');
propsTable += `| ${prop.name} | ${prop.description} | ${type} | ${prop.defaultValue} | \n`
return propsTable
},
`\
| ${t('extraProp')} | ${t('description')} | ${t('type')} | ${t('default')} |
| :--- | :--- | :--- | :--- |
`
)
: ''
// 生成事件表格
const eventsTable = events.length
? events.reduce(
(eventsTable, event) => {
eventsTable += `| ${event.name} | ${event.description} | ${event.params} \n`
return eventsTable
},
`\
| ${t('event')} | ${t('description')} | ${t('params')} |
| :--- | :--- | :--- |
`
)
: ''
// 生成插槽表格
const slotsTable = slots.length
? slots.reduce(
(slotsTable, slot) => {
slotsTable += `| ${slot.name} | ${slot.description} | \n`
return slotsTable
},
`\
| ${t('slot')} | ${t('description')} |
| :--- | :--- |
`
)
: ''
return {
link,
propsTable,
extraPropsTable,
eventsTable,
slotsTable,
}
}
// 注册悬停提示功能
export function registerHover(context: ExtensionContext) {
// 提供悬停提示函数
function provideHover(document: TextDocument, position: Position) {
// 获取当前行的文本
const line = document.lineAt(position)
// 查找链接组件
const linkComponents = line.text.match(TAG_LINK_RE) ?? []
// 查找大驼峰命名组件并转换为短横线命名
const bigCamelizeComponents = line.text.match(TAG_BIG_CAMELIZE_RE) ?? []
const components = uniq([...linkComponents, ...bigCamelizeComponents.map(kebabCase)]) as string[]
if (!components.length) {
return
}
const hoverContents = components
.filter((component: string) => componentsMap[component])
.map(getComponentTableTemplate)
.reduce((hoverContents, item) => {
const linkMarkdown = new MarkdownString(item.link)
linkMarkdown.isTrusted = true
// 将链接和表格内容添加到悬停提示内容中
hoverContents.push(
linkMarkdown,
new MarkdownString(item.propsTable),
new MarkdownString(item.extraPropsTable),
new MarkdownString(item.eventsTable),
new MarkdownString(item.slotsTable)
)
return hoverContents
}, [] as MarkdownString[])
// 返回悬停提示内容
return new Hover(hoverContents)
}
context.subscriptions.push(
languages.registerHoverProvider(LANGUAGE_IDS, {
provideHover,
})
)
}
具体效果
鼠标移动到组件名会显示组件的文档地址,可以点击进行跳转。
可查看额外的配置属性:
2.4 状态栏功能
定义组件状态,可以通过设置进行关闭。
具体实现:
import { StatusBarAlignment, window, workspace, type ExtensionContext, type StatusBarItem } from 'vscode';
let statusBarItems: StatusBarItem[] = []; // 存储状态栏项的引用
// 注册状态栏项的函数
export function registerStatusBarItems(context: ExtensionContext) {
// 获取用户设置
const config = workspace.getConfiguration('proiview');
const showStatusBarItem = config.get<boolean>('showStatusBarItem', true);
console.log('showStatusBarItem', showStatusBarItem)
// 移除现有状态栏项
statusBarItems.forEach(item => {
item.hide();
item.dispose();
});
statusBarItems = [];
const statusCommandList = [
{
name: 'Proiview文档',
priority: 0,
command: 'proiview.open-documentation',
tooltip: '打开Proiview文档',
},
]
if (showStatusBarItem) {
statusCommandList.forEach((item) => {
// 为每个状态栏项创建一个新的状态栏项对象
const statusBarItem = window.createStatusBarItem(StatusBarAlignment.Right, item.priority)
// 设置状态栏项的命令
statusBarItem.command = item.command
// 设置状态栏项的文本
statusBarItem.text = item.name
// 设置状态栏项的提示信息
statusBarItem.tooltip = item.tooltip
// 显示状态栏项
statusBarItem.show()
statusBarItems.push(statusBarItem);
context.subscriptions.push(statusBarItem);
});
}
}
// 监听配置更改事件
export function dynamicRegister(context: ExtensionContext) {
workspace.onDidChangeConfiguration((event) => {
console.log('event', event, event.affectsConfiguration('proiview.showStatusBarItem'))
if (event.affectsConfiguration('proiview.showStatusBarItem')) {
registerStatusBarItems(context);
}
});
}
具体效果
添加右键或者状态栏跳转文档,状态栏可通过设置进行关闭
3. 发布
3.1 创建azure账号
2. 进去后,创建组织:(组织命名随意,与项目无关)
3.2 获取 Personal Access Token
此处获取的token,是通过本地通过vsce login xxx
登录需要的,需要保存好。
点击上图位置后,出现下图:
点击 New Token
按上图,输入内容(PS:Name
输入自己记得住的就 ok,最好统一同一个)
创建好后复制保存好这个 Personal Access Token(PS:一定要记得保存下来)
3.3 创建发布者
访问创建发布者页面publisher,发布者是发布插件的作者,可以发布多个插件。这个作者名称是vsce login publisherName
,注意登录时必须保持一致。
3.4 vsce login
需要安装npm i vsce -g
,通过下面步骤登录:
# 命令行执行,<publisher name>换成上一步“创建发布者”中的 Name 即可
vsce login <publisher name>
然后再输入“获取 Personal Access Token” 得到的 Personal Access Toke,输入到命令行中:
3.5 package.json 中添加 publisher 字段
输入在 “vsce login” 使用的 Name 即可
3.6 使用命令发布
如果是第一次发布插件,要修改 README.md 文件 才行。
执行命令:
# 命令行执行,执行后一路按 y 就 ok
vsce publish
或者本地打包安装包,命令如下:
vsce package —yarn
本地会生成.vsix
文件
安装如下:
3.7 查看插件发布情况
访问 插件管理
总结
至此,一个完整的vscode插件开发流程就完结,其中vscode提供的api未作过多详细解释,具体参考官方文档,具体完整代码gitee仓库代码,vscode插件体验请搜索proiview-vscode-extension
。
文中如有错误,请指正O^O!