学习Varlet如何给组件库写一个VSCode插件

1,455 阅读8分钟

前言

在日常的开发流程中,例如开发一个二次封装的组件库,快速使用这些组件依赖我们查看文档,开发一款配套的组件库VSCode插件是非常有必要。

本文从0到1搭建一个组件库的vscode插件,具体内容如下:

  1. 项目搭建
  2. 具体功能实现
  3. 发布

1. 项目搭建

1.1 安装开发包

官方提供npm包,可以快速生成项目和发布。

  1. 项目生成包

npm install -g yo generator-code

1.2 生成项目

运行yo -> 选择Code -> 输入项目配置即可,或者直接yo code

开发vscode,使用 yarn会更丝滑

image.png

安装完成后,使用code-Insiders打开,需要安装下vscode-insider版本

image.png

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 worlddemo,此时直接按下F5 F5 F5,重要的事情说三遍。会打开一个新的vscode,如下图右边所示。

image.png

进入后打开Ctrl + Shift + P 输入 Hello World,

image.png

image.png

此时在src/extension.ts下打断点 -> 重启下调试 -> Extension Development Host下输入命令 -> 进行源代码调试。

image.png

image.png

2. 具体功能实现

需要开发一个vscode组件库插件,需要理清哪些功能。通过学习varlet组件库的vscode插件,整体可拆分为四个部分:

image.png

  • 命令功能
    • 主要注册一些vscode的命令,可以直接通过Ctrl + Shift + P输入命令直接调用(需要在package.json通过commands进行注册)
  • 自动补全功能
    • 输入部分字符,可以联想名称
  • 悬浮功能
    • 组件悬浮上,可以进行提示
  • 状态栏功能
    • 定义组件状态,可以通过设置进行关闭

先从入口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.jsoncon中设置右键和命令

"contributes": {
    "commands": [
      {
        "command": "proiview.open-documentation",
        "title": "Proiview: Open Proiview Documentation"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "proiview.open-documentation",
          "group": "navigation"
        }
      ]
    },

2.2 自动补全功能

自动补全分为两个部分:

  1. 一个是输入组件名称,进行联想补全
  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 组件补全

整体流程如下:

image.png

具体实现:

  // 定义组件提供者
  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 属性补全

image.png

具体实现:

 // 定义属性提供者
  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 悬浮功能

组件悬浮上,可以进行文档说明提示,具体流程如下如下:

image.png

具体实现:

// 获取组件的表格数据
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账号

  1. 创建azure地址,登录完点击以下链接,或者再次访问 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文件

安装如下:

image.png

3.7 查看插件发布情况

访问 插件管理

总结

至此,一个完整的vscode插件开发流程就完结,其中vscode提供的api未作过多详细解释,具体参考官方文档,具体完整代码gitee仓库代码,vscode插件体验请搜索proiview-vscode-extension

文中如有错误,请指正O^O!