好用到飞起!VSCode插件DevUIHelper设计开发全攻略(三)

avatar
前端解决方案集 @华为

DevUI是一支兼具设计视角和工程视角的团队,服务于华为云DevCloud平台和华为内部数个中后台系统,服务于设计师和前端工程师。
官方网站:devui.design
Ng组件库:ng-devui(欢迎Star)
官方交流:添加DevUI小助手(devui-official)
DevUIHelper插件:DevUIHelper-LSP(欢迎Star)

引言

嗨,我们是DevUIHelper 的开发团队。今天,让我们聊一下我们插件开发中应用的一些技术方案。这些经验也许对您的插件开发有帮助,让我们开始吧!

综述

由于我们的插件运行在 VSCode 上,我们使用了一些 VSCode 提供的能力,例如 LSP 协议,补全、悬停提示接口等。同时,插件的功能由多个独立功能的模块进行完成。接下来我们根据模块分别进行介绍。

LSP 协议

VSCode 对代码补全插件大体提供了本地与LSP两种方案。本地的插件补全可以直接应用 VSCode 供的一些能力,但 LSP 协议为跨编辑器使用提供了可能,考虑到我们的插件未来可能不仅仅运行在VSCode平台上,我们最终选择了LSP 协议。

LSP 协议的愿景,多个 IDE 使用同一套补全提示系统。

createConnection API

LSP 协议是基于 客户端-服务器 模式的,所以使用 LSP 协议的第一步,便是创造一个客户端与服务器的链接,这时,你需要在服务器端输入这样一段代码:

import {createConnection,} from 'vscode-languageserver';
private connection = createConnection(ProposedFeatures.all);
connection.listen()

这样你就创建了一个默认规则的 LSP 连接。但是仅有服务器显然是不行的,建议通过微软官方提供的 LSP 种子项目开始进行创作。同时,VSCode 还提供了大量不同场景下的种子项目
下面的介绍将基于DevUIHelper-LSP 项目,建议配合代码食用。顺便求一波 star~。

DConnection

由于直接使用vscode 提供的API 会导致代码非常分散不易阅读, DConnection 对于 VSCode 提供的连接进行的一次封装,这样,你可以方便的对所有的功能函数进行管理:

export class DConnection{
    private connection = createConnection(ProposedFeatures.all);
    ...

    constructor(host:Host,logger:Logger){
        ...
        this.addProtocalHandlers();
    }

    addProtocalHandlers(){
        this.connection.onInitialize(e=>this.onInitialze(e));
        this.connection.onInitialized(()=>this.onInitialized());
        this.connection.onDidChangeConfiguration(e=>this.onDidChangeConfiguration(e));
        this.connection.onHover(e=>this.onHover(e));
        this.connection.onCompletion(e=>this.onCompletion(e));
        this.connection.onDidOpenTextDocument(e=>this.validateTextDocument(e.textDocument.uri))
        this.host.documents.onDidChangeContent(change=>this.validateTextDocument(change.document.uri));
    }   
    ...
}

其余API的应用我们将在对应包中进行讲解

功能模块

DevUIHelper 的功能主要是由多个不同的功能模块实现的,以下是这些包的依赖关系,接下来我们将自底向上进行讲解

Providers

黄色的部分代表了许多 Providers 包 他们位于server/src 目录下。
其中 CompletionProvider 通过 Dconnection.onCompletion唤醒, 应用了 onCompletion 接口, 提供了补全的能力,

onCompletion(_textDocumentPosition: TextDocumentPositionParams){
    ...
    return this.host.completionProvider.provideCompletionItes(_textDocumentPosition,FileType.HTML);
}

HoverProvider 通过 Dconnection.onHover 唤醒, 应用了 onHover 接口,提供了悬停提示的能力,

async onHover(_textDocumentPosition:HoverParams){
    ...
    return this.host.hoverProvider.provideHoverInfoForHTML(_textDocumentPosition);
}

Diagnosis 通过 通过 Dconnection.validateTextDocument 唤醒,应用了sendDiagnostics 接口提供错误提醒。

async validateTextDocument(uri: string) {
    ...
    let diagnostics: Diagnostic[] = this.host.diagnoser.diagnose(textDocument); 
    this.connection.sendDiagnostics({ uri: uri, diagnostics });
}

解析器

由于 VSCode 的 onCompletion/onHover API 仅仅告诉了我们一个坐标, 为了完成补全、悬停、以及报警的任务,我们需要明白光标所在的位置意味着什么。

export interface Position {
    /**
     * 光标所在行
     */
    line: number;
    /**
     * 光标相对于行首位的位移
     */
    character: number;
}

VSCode 的坐标API,仅提供了行数与位移。

Parser

首先,我们需要对输入的文档进行解析,这一部分能力由 yq-Parser 提供:

export class YQ_Parser{
    ...
    parseTextDocument(textDocument:TextDocument,parseOption:ParseOption):ParseResult{
    const uri = textDocument.uri;

    // 进行词法解析
    const tokenizer = new Tokenizer(textDocument); 
    const tokens = tokenizer.Tokenize();

    // 建立语法树
    const treebuilder =new TreeBuilder(tokens);
    return treebuilder.build();
    }
}

在分析过后,我们需要找到光标所在位置的语法树节点,这个能力由 Hunter 提供。 例如:「光标 {line:10 character:5} 悬停在了 d-button 节点上」

export class Hunter {
    ...
    searchTerminalAST(offset: number, uri: string): SearchResult {

    // 找到分析生成的语法树   
    let _snapShot = host.snapshotMap.get(uri);
    if (!_snapShot) { throw Error(`this uri does not have a snapShot: ${uri}`); }
    const { root, textDocument, HTMLAstToHTMLInfoNode } = _snapShot;
    if (!root) {
        throw Error(`Snap shot does not have this file : ${uri}, please parse it befor use it!`);
    }

    // 进行深度搜索
    let _result = this.searchParser.DFS(offset, root);

    //调整Node位置
    return _result ? _result : { ast: undefined, type: SearchResultType.Null };
    }

之后,我们需要明白字符串 d-button 意味着什么,这部分能力由 SourceLoader 提供,通过加载资源树文件,我们了解了每一个AST节点对应的字符串的含义。例如 「d-button 意味着 这是一个 DevUI 组件库的按钮标签」这样,我们就可以把这些信息提供给使用者。

export class Architect {

    // 初始化
    private readonly componentRootNode = new RootNode();
    private readonly directiveRootNode = new RootNode();
    constructor() { }

    // 加载语法树的资源文件
    build(info: Array<any>,comName:SupportComponentName): RootNode[] {
        ...
    }

    // 生成补全和悬停信息
    buildCompletionItemsAndHoverInfo() {
    this.componentRootNode.buildCompletionItemsAndHoverInfo();
    this.directiveRootNode.buildCompletionItemsAndHoverInfo();
    }

我们需要一个对于文件变化的监视器来保证插件语法树的分析结果一直是最新的,我们使用了VSCode 本身提供的 document 接口,这个接口的调用也十分简单:

public documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
    ...
    // 当文件出现变化的时候进行的操作
    this.documents.onDidChangeContent(change => {
        ...
    });

DataStructor

我们希望我们的语法书更新是伴随着最小的资源消耗的,这一点借鉴了回流重绘的思想。网页在回流重绘的过程中会通过只更新变化的部分(通常是变化节点树之后的部分)尽可能的减少资源消耗。 在插件的工作中,响应速度直接影响了用户体验,因此我们希望局部更新语法树。为此我们设计了一个比较特殊的语法树结构,在这种结构中大量的应用了链表的思想,因此我们制作了一个小型的数据结构模块对此进行支持。

export interface LinkList<T>{
    /**
     * 头结点
     */
    head:HeadNode;

    /**
     * 长度
     */
    length:number;

    /**
     * 尾节点
     */
    end:LinkNode<T>|undefined;

    /**
     * 插入节点
     */
    insertNode(newElement:T,node?:Node):void;

    /**
     * 插入链表
     */
    insetLinkList(list:LinkList<T>,node?:Node):void;

    /**
     * 获取元素
     */
    getElement(cb?:()=>any,param?:T):T|undefined;

    /**
     * 依据下标获取元素
     * @param num 
     */
    get(num:number):Node|undefined;

    /**
     * 转化为数组
     */
    toArray():T[];
}

我们希望通过这种语法树结构更好的进行局部更新。

Cursor

在插件制作的早期我们借鉴了许多 Angular Parser 部分的思想,指针思想便是其中之一,我们希望通过指针进行语法树分析,储存错误和语法树节点出现的位置。但是作为一个组件库的提示插件,我们并不需要框架级别的强大能力,因此我们制作了一个简单版的指针模块,现在,他为 Parser 和 @表达式 的解析提供支撑。

MarkUpBuilder

DevUIHelper 插件使用了 MarkDown 模式的文本进行提示,但是在 LSP 中,vscode 暂时没有提强大的markDown 语法编辑器,因此,我们期望使用这个工具模块文档分段添加 文本 内容,并且使得代码更加语义化。

export class MarkUpBuilder{
    private markUpContent:MarkupContent;
    constructor(content?:string){
        this.markUpContent=  {kind:MarkupKind.Markdown,value:content?content:""};
    }

    getMarkUpContent():MarkupContent{
        return this.markUpContent;
    }

    addContent(content:string){
        this.markUpContent.value+=content;
        this.markUpContent.value+='\n\n';
        return this;
    }

    addCodeBlock(type:string,content:string[]){
        content = content.filter(e=>e!="");
        this.markUpContent.value+= 
             [
                '```'+type,
                 ...content,
                '```'
            ].join('\n');
        return this;
    }

    setSpecialContent(type:string,content:string){
        this.markUpContent.value='```'+type+'\n'+content+'\n```';
        return this;
    }
}

结语

截止这篇文章发稿之前,DevUIHelper 已经获得了 211 次独立下载量了,在插件刚起步的时候,我们发现关于 VSCode 插件的中文教程与讨论比较少, 我们希望通过文章来与更多喜爱插件的开发者进行交流。

如果想要更进一步的了解 VSCode 插件,建议参照 VSCode 插件 官方文档, 此外,中文社区也有许多非常优秀的入门教程,例如小茗同学的 VSCode 插件教程 、JTag 特工的 快餐式VSCode 插件教程 等。这些教程非常全面的介绍了 VSCode 的 API。

最后,祝大家使用愉快~

加入我们

我们是DevUI团队,欢迎来这里和我们一起打造优雅高效的人机设计/研发体系。招聘邮箱:muyang2@huawei.com

作者: 动次打次咚咚咚

责编: DevUI团队

往期文章推荐

《好用到飞起!VSCode插件DevUIHelper设计开发全攻略(二)》

《Web界面深色模式和主题化开发》

《手把手教你搭建一个灰度发布环境》