背景
本文是Vue 3面向对象插件开发系列5的延伸,对其中的关键概念将不再赘述。Vue 3引入了众多语法糖。在vue3中有很多语法糖,比如defineProps
这一语法糖扮演了重要角色,通过编译宏的巧妙应用,让我们能够在无需引入冗杂依赖的前提下,轻松地定义组件的属性。这一特性不仅赋予了代码以更加清晰的结构,还保留了编辑器的友好提示,使开发体验更上一层楼。既然编译宏如此令人叹为观止,想象一下,如果我们能够亲手打造自己的编译宏,是不是会成就感满满、激动人心?
前言
本文的代码并不是最佳实践,只是插件开发的最小实现,可编程性太强,抛砖引玉吧!
编译宏:定制开发
编译宏是一种在代码编译期执行的转换或操作,将高级语法、模式或标记转化为低级等效代码,从而提升开发效率和代码清晰度。Vue 3中的defineProps
其以声明式方式定义组件 props,摆脱了繁琐的类型声明,既增加了代码可读性,又减少了潜在错误。
自行开发编译宏的意义在于,根据项目需求和团队风格,创造适应的高级抽象。此举不仅避免了重复劳动,更提升了开发效率,同时在团队合作中保持了一致的代码规范和风格。
在实现定制化编译宏时,有两个关键条件需要满足:一是在 Vue 编译器层面进行语法转换,二是在编辑器(如 VSCode)中提供智能提示,使开发过程更加顺畅和高效。
首先,在 Vue 编译器层面,编译宏的实现需要借助 Vue3 提供的编译工具(也就是Vue3面向对象插件开发系列的插件)。这些工具能够在代码编译阶段,将高级语法转化为低级代码,实现定制化的功能。通过这一层面的操作,我们可以在代码中使用自定义的编译宏,从而达到代码转换的目的。
其次,为了提供更智能的开发体验,我们需要在编辑器中嵌入自定义的编译宏提示。通过开发 VS Code 插件,我们可以实现代码补全、错误提示等功能,让开发者能够更快速地使用和调试定制化编译宏。这样的智能提示将极大地提高开发效率。
宏语法
在 Vue 中,结构性的指令通常是固定且内建的,这为我们开发类似于 v-for
这样的自定义结构指令带来了极大的挑战。为了实现这样的高度定制化功能,我们需要借助自定义插件来编译这些非传统的语法。同时,我们还可以结合 VS Code 编辑器的插件来提供更流畅的开发体验。
编译宏与自定义语法
自定义宏语法代表了高级抽象的一种,通过这种方式,我们可以在代码中引入自定义的语法结构,以更好地表达特定的逻辑和模式。然而,由于 Vue 的结构性指令存在限制,开发类似于 v-for
的自定义结构指令变得异常艰难。解决这一难题的途径之一是采用编译宏。编译宏可以在代码编译阶段进行转换操作,将自定义语法转化为编译器可识别的结构。例如,在下面的示例中,我们可以编写一个编译宏,将类似于 v-request
的语法转换为合法的 Vue 指令。
html
<div v-requset="(data, loading, reject, error) in $requset(http)">
{{ 444 }}{{ loading }} {{ data }}
<div v-if="loading">loading...</div>
<div v-if="data">loaded data</div>
</div>
这种方法不仅赋予开发者更自由的语法表达能力,还在一定程度上绕过了 Vue 编译器的限制,实现了高度的定制化效果。
在本文中不会讲如何从编译层面实现(感兴趣的可以关注Vue3面向对象插件开发系列)
编辑器插件的智能支持
然而,仅仅在编译器中支持自定义语法还不足以提供理想的开发体验。为了进一步优化开发流程,我们可以开发 VS Code 编辑器插件,为自定义宏语法提供智能支持。通过智能提示、代码补全和错误检测,开发者可以更轻松地使用和调试自定义语法,提高开发效率。
编写vscode插件:定制悬停提示与自动完成
在本章节中,我们将重点介绍如何通过编写 VS Code 插件,为我们的自定义宏语法提供悬停提示(Hover)和自动完成(Auto Completion)功能。这两个功能将显著提升开发者在使用自定义语法时的体验,让开发过程更加高效和舒适。
客户端
代码跟上一期基本一致重复部分就不赘述了,着重说说如何使用请求转发来实现自定义悬停提示和代码自动完成功能
ts
export class VgLanguageClient implements vscode.Disposable {
// 语言客户端实例
private client: lsp.LanguageClient | null = null;
// 用于管理资源释放
private readonly disposables: vscode.Disposable[] = [];
// 存储虚拟文档的内容
private readonly virtualDocumentContents = new Map<string, string>();
// 语言服务的名称
private readonly name = 'vg Language Service';
// 语言客户端选项
private readonly clientOptions: lsp.LanguageClientOptions;
constructor(private readonly context: vscode.ExtensionContext) {
// 注册虚拟文档提供者
vscode.workspace.registerTextDocumentContentProvider('vg-embedded-content', {
provideTextDocumentContent: uri => {
return this.virtualDocumentContents.get(uri.toString());
}
});
// 配置语言客户端选项
this.clientOptions = {
// 哪些文件类型要处理
documentSelector: [
{ scheme: 'file', language: 'html' },
{ scheme: 'file', language: 'typescript' },
],
middleware: {
// 悬停
provideHover: async (
document: vscode.TextDocument, position: vscode.Position,
token: vscode.CancellationToken, next: lsp.ProvideHoverSignature) => {
if (!(await this.isInVgProject(document)) ||
!isInsideInlineTemplateRegion(document, position)) {
return;
}
// 使用请求转发方式获取悬停提示
const vgResultsPromise = next(document, position, token);
// Include results for inline HTML via virtual document and native html providers.
if (document.languageId === 'typescript') {
// 使用语言服务的方式获取悬停提示
const vdocUri = this.createVirtualHtmlDoc(document);
const htmlProviderResultsPromise = vscode.commands.executeCommand<vscode.Hover[]>(
'vscode.executeHoverProvider', vdocUri, position);
const [vgResults, htmlProviderResults] =
await Promise.all([vgResultsPromise, htmlProviderResultsPromise]);
return vgResults ?? htmlProviderResults?.[0];
}
return;
},
// 自动完成中间件
provideCompletionItem: async (
document: vscode.TextDocument, position: vscode.Position,
context: vscode.CompletionContext, token: vscode.CancellationToken,
next: lsp.ProvideCompletionItemsSignature) => {
// 确保在template中
if (!(await this.isInVgProject(document)) ||
!isInsideInlineTemplateRegion(document, position)) {
return;
}
// 使用请求转发方式获取悬停提示
const vgResultsPromise = next(document, position, context, token);
if (document.languageId === 'typescript') {
// 使用语言服务的方式获取悬停提示
const vdocUri = this.createVirtualHtmlDoc(document);
const htmlProviderCompletionsPromise =
vscode.commands.executeCommand<vscode.CompletionList>(
'vscode.executeCompletionItemProvider', vdocUri, position,
context.triggerCharacter);
// const htmlProviderCompletions = await htmlProviderCompletionsPromise;
const [vgResults, htmlProviderResults] =
await Promise.all([vgResultsPromise, htmlProviderCompletionsPromise]);
return vgResults ?? htmlProviderResults?.items ?? [];
// return htmlProviderCompletions?.items ?? []
}
return vgResultsPromise;
},
}
}
}
private createVirtualHtmlDoc(document: vscode.TextDocument): vscode.Uri {
// ...
}
// 判断是否在项目中
private async isInVgProject(doc: vscode.TextDocument): Promise<boolean> {
// 需要更具体的逻辑判断
return true
}
async stop(): Promise<void> {
// ...
}
async start(): Promise<void> {
// ...
}
dispose() {
// ...
}
}
let client: VgLanguageClient;
// 插件激活函数
export function activate(context: ExtensionContext) {
client = new VgLanguageClient(context);
client.start().then();
}
// 插件停用函数
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}
实现悬停提示与自动完成
1. 悬停提示(Hover)功能
悬停提示是一种重要的代码辅助功能,它可以在鼠标悬停在代码上时提供有关该代码的信息,帮助开发者更好地理解代码含义。在我们的自定义宏语法中,通过借助请求转发,我们可以轻松地为内联模板区域提供自定义的悬停提示。
以下是关键代码片段,展示了如何在 VgLanguageClient
类中实现悬停提示功能:
ts
// 悬停提示中间件
provideHover: async (document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, next: lsp.ProvideHoverSignature) => {
if (!(await this.isInVgProject(document)) || !isInsideInlineTemplateRegion(document, position)) {
return;
}
// 使用请求转发方式获取悬停提示
const vgResultsPromise = next(document, position, token);
// 使用语言服务的方式获取悬停提示
const vdocUri = this.createVirtualHtmlDoc(document);
const htmlProviderResultsPromise = vscode.commands.executeCommand<vscode.Hover[]>(
'vscode.executeHoverProvider', vdocUri, position);
const [vgResults, htmlProviderResults] = await Promise.all([vgResultsPromise, htmlProviderResultsPromise]);
return vgResults ?? htmlProviderResults?.[0];
}
next(document, position, token)
会将请求转发到我们的服务端
2. 自动完成(Auto Completion)功能
自动完成是另一项极具效益的功能,它可以在开发者输入代码时自动弹出建议,从而减少代码输入的工作量。在自定义宏语法中,我们同样可以通过请求转发方式,为内联模板区域提供自定义的自动完成建议。
以下是关键代码片段,展示了如何在 VgLanguageClient
类中实现自动完成功能:
ts
// 自动完成中间件
provideCompletionItem: async (document: vscode.TextDocument, position: vscode.Position, context: vscode.CompletionContext, token: vscode.CancellationToken, next: lsp.ProvideCompletionItemsSignature) => {
if (!(await this.isInVgProject(document)) || !isInsideInlineTemplateRegion(document, position)) {
return;
}
// 使用请求转发方式获取自动完成建议
const vgResultsPromise = next(document, position, context, token);
// 使用语言服务的方式获取自动完成建议
const vdocUri = this.createVirtualHtmlDoc(document);
const htmlProviderCompletionsPromise = vscode.commands.executeCommand<vscode.CompletionList>(
'vscode.executeCompletionItemProvider', vdocUri, position, context.triggerCharacter);
const [vgResults, htmlProviderResults] = await Promise.all([vgResultsPromise, htmlProviderCompletionsPromise]);
return vgResults ?? htmlProviderResults?.items ?? [];
}
next(document, position, context, token)
会将请求转发到我们的服务端
服务端
代码跟上一期基本一致重复部分就不赘述了,本节将聚焦在服务端如何通过请求转发来实现自定义悬停提示和代码自动完成功能
ts
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
// 获取HTML语言服务
const htmlLanguageService = getLanguageService();
export class Service {
private readonly connection: lsp.Connection;
constructor() {
// 创建连接
this.connection = lsp.createConnection(lsp.ProposedFeatures.all);
this.addProtocolHandlers(this.connection);
documents.listen(this.connection);
}
private addProtocolHandlers(conn: lsp.Connection) {
conn.onInitialize(p => this.onInitialize(p));
conn.onHover(p => this.onHover(p));
// 这个处理函数提供了初始补全项列表
conn.onCompletion(p => this.onCompletion(p));
}
private onInitialize(_: lsp.InitializeParams): lsp.InitializeResult {
const result: lsp.InitializeResult = {
capabilities: {
// 增量式文本文档同步
textDocumentSync: lsp.TextDocumentSyncKind.Incremental,
// 告诉客户端该服务器支持代码完成。
completionProvider: {
resolveProvider: true,
/**
* 是指代码补全触发字符(trigger characters)的列表。
* 这些字符指的是在你在编辑器中输入代码时,
* 如果输入了其中一个字符,编辑器会发起代码补全请求
*/
triggerCharacters: ['<', '.', '*', '[', '(', '$', '|']
},
// 开启悬停支持
hoverProvider: true,
}
};
return result
}
// 悬停处理函数 在client中间件provideHover执行next会触发
private onHover(params: lsp.TextDocumentPositionParams): lsp.Hover|null {
// 暂时省略 后面有具体过程
return null;
}
// 补全处理函数 在client中间件provideCompletionItem执行next会触发
private onCompletion(params: lsp.CompletionParams): lsp.CompletionList | null {
// 暂时省略 后面有具体过程
return null;
}
listen(): void {
this.connection.listen();
}
}
function main() {
const server = new Service()
server.listen()
}
main()
onHover
悬停处理函数 在client中间件provideHover执行next会触发onCompletion
补全处理函数 在client中间件provideCompletionItem执行next会触发
悬停提示与代码自动完成
1. 悬停提示(Hover)功能的实现
悬停提示是一项强大的功能,它能够在代码中的特定位置为开发者提供有关代码的信息,从而增强代码理解和阅读。在服务端,我们需要在 onHover
函数中实现具体的悬停提示逻辑,然后通过请求转发的方式将处理结果返回给客户端。
以下是 onHover
函数的示例代码:
ts
private onHover(params: lsp.TextDocumentPositionParams): lsp.Hover | null {
const document = documents.get(params.textDocument.uri);
if (!document) {
return null;
}
const text = document.getText()
const sf = ts.createSourceFile(`${params.textDocument.uri}`, text, ts.ScriptTarget.Latest, true);
const virtualHtmlDocContents = getHTMLVirtualContent(sf);
const virtualHtmlDoc =
TextDocument.create(params.textDocument.uri.toString(), 'html', 0, virtualHtmlDocContents);
const position = params.position;
const offset = document.offsetAt(position);
const htmlDocument = htmlLanguageService.parseHTMLDocument(virtualHtmlDoc);
const node = htmlDocument.findNodeAt(offset)
let tokenStartIndex = offset
let tokenEndIndex = offset
// 边界字符
const chars = [_BNG, _CAR, _DQO, _EQS, _FSL, _LAN, _LFD, _MIN, _NWL, _WSP, _TAB, _SQO, _RAN, '}', '{', '(', ')', ',']
while(!chars.includes(text[tokenStartIndex])) {
tokenStartIndex --
}
if (chars.includes(text[tokenStartIndex])) {
tokenStartIndex ++
}
while(!chars.includes(text[tokenEndIndex])) {
tokenEndIndex ++
}
// 获取当前悬停的token 比如 loading
const tokenText = text.slice(tokenStartIndex, tokenEndIndex)
if (!tokenText) {
return null
}
let parent: any = node
// 收集模板上下文
let templateContext: string[] = []
while(parent) {
context = Object.entries(parent.attributes ?? {})
.filter(([key, _]) => key.startsWith('v-for') || key.startsWith('v-requset'))
.map(([_, value]) => (value as string).split(' in')[0]?.trim().replace(/\(|\)|"/g, '').split(',').map(val => val.trimStart()))
.reduce((previousValue, currentValue) => previousValue.concat(currentValue), context)
parent = parent.parent
}
// 将虚拟HTML文档编译为AST
const rootNode = vueTemplateParse(virtualHtmlDoc.getText());
// 用于判断token是否是在js表达式区域中,比如`{{}}`之中
let isVar = false
this.forEachChild(rootNode, (node: any) => {
if (node.type === 5) {
const content = node.content;
if (!(content.loc.start.offset <= startIndex && endIndex <= content.loc.end.offset)) {
return
}
isVar = true
return false
}
const props = node.props;
if (!props || !props.length) {
return
}
for (const prop of props) {
if (prop.type !== 7) {
return;
}
const content = prop.exp;
if (!(content.loc.start.offset <= startIndex && endIndex <= content.loc.end.offset)) {
return;
}
isVar = true;
return false;
}
})
if (isVar && context.includes(tokenText)) {
// (variable) item: number
// (method) OcrSearchComponent.submit(): any
function variable(token: string, typeString: string) {
return {
kind: lsp.MarkupKind.Markdown,
value: [
'```typescript',
`(variable) ${token}: ${typeString}`,
'```'
].join('\n')
};
}
function method(token: string, typeString: string) {
return {
kind: lsp.MarkupKind.Markdown,
value: [
'```typescript',
`(method) ${token}(): ${typeString}`,
'```'
].join('\n')
};
}
const hoverMap: {[key: string]: lsp.Hover} = {
data: {
contents: variable('data', 'User[]')
},
loading: {
contents: variable('loading', 'boolean')
},
reject: {
contents: method('reject', 'void')
},
error: {
contents: variable('error', 'object')
}
}
return hoverMap[tokenText]
}
return null;
}
首先,我们获取到当前悬停位置的文档,并根据其内容生成ts源文件对象。利用这个源文件对象,我们可以进一步生成虚拟 HTML 文档的内容,这个虚拟 HTML 文档实际上就是就是@Component
中template
对应的值。
接下来,我们利用 virtualHtmlDoc
解析出虚拟 HTML 文档的 AST,以及在其中定位到当前悬停位置的 HTML 节点。通过这个节点,我们可以进一步获取到悬停位置所在的标签、属性等具体信息。
在定位到节点后,我们需要进一步处理一系列操作,包括从悬停位置向上搜索,获取到当前悬停位置的标识符、属性等,甚至还需要判断是否在 Vue 的模板表达式中。
2. 代码自动完成(Auto Completion)功能的实现
代码自动完成是另一项重要的功能,它可以根据开发者输入的上下文为其提供代码建议。在服务端,我们需要在 onCompletion
函数中实现自动完成逻辑,然后通过请求转发的方式将建议列表返回给客户端。
以下是 onCompletion
函数的示例代码:
ts
private onCompletion(params: lsp.CompletionParams): lsp.CompletionList | null {
const document = documents.get(params.textDocument.uri);
if (!document) {
return null;
}
const text = document.getText()
const sf = ts.createSourceFile(`${params.textDocument.uri}`, text, ts.ScriptTarget.Latest, true);
const virtualHtmlDocContents = getHTMLVirtualContent(sf);
const virtualHtmlDoc =
TextDocument.create(params.textDocument.uri.toString(), 'html', 0, virtualHtmlDocContents);
const position = params.position;
const offset = document.offsetAt(position);
const htmlDocument = htmlLanguageService.parseHTMLDocument(virtualHtmlDoc);
const node = htmlDocument.findNodeAt(offset)
// 创建 node 扫描器
const scanner = htmlLanguageService.createScanner(text, node.start);
// 如果当前编辑节点是属性值 那么需要知道属性名来进行准确的提示,
// 实际上需要收集的数据很多 这个只是基础演示
let attributeName = '';
let token = scanner.scan();
while (token !== TokenType.EOS && scanner.getTokenEnd() <= offset + 1) {
const getTokenEnd = scanner.getTokenEnd() - 1
const tokenText = scanner.getTokenText()
if (token === TokenType.AttributeName) {
attributeName = tokenText
}
if (token === TokenType.AttributeName && getTokenEnd === offset - 1) {
break
}
if (token === TokenType.AttributeValue && getTokenEnd === offset) {
break
}
token = scanner.scan();
}
// 当前编辑的是属性值
if (token === TokenType.AttributeValue) {
console.log(attributeName);
// 目前只实现v-requset的提示
if(attributeName !== 'v-requset') {
return null;
}
return [
{
label: 'http.getuserlist',
kind: lsp.CompletionItemKind.Snippet,
data: 1,
detail: 'getuserlist details',
// 实际上需要动态解析 不然插入会有错位的情况
insertText: '(data, loading, reject, error) in $requset(http.getuserlist)'
},
{
label: 'http.getUser',
kind: lsp.CompletionItemKind.Snippet,
data: 2,
detail: 'getUser details',
insertText: '(data, loading, reject, error) in $requset(http.getUser)'
},
{
label: 'http.createUser',
kind: lsp.CompletionItemKind.Snippet,
data: 2,
detail: 'createUser details',
insertText: '(data, loading, reject, error) in $requset(http.createUser)'
}
];
}
// 当前编辑的是属性名
if (token === TokenType.AttributeName) {
// 目前只实现v-requset的提示
if(!attributeName.startsWith('H')) {
return null;
}
return [
{
label: 'http.getuserlist',
kind: lsp.CompletionItemKind.Snippet,
data: 1,
detail: 'getuserlist details',
// 实际上需要动态解析 不然插入会有错位的情况
insertText: 'v-requset="(data, loading, reject, error) in $requset(http.getuserlist)"'
},
{
label: 'http.getUser',
kind: lsp.CompletionItemKind.Snippet,
data: 2,
detail: 'getUser details',
insertText: 'v-requset="(data, loading, reject, error) in $requset(http.getUser)"'
},
{
label: 'http.createUser',
kind: lsp.CompletionItemKind.Snippet,
data: 2,
detail: 'createUser details',
insertText: 'v-requset="(data, loading, reject, error) in $requset(http.createUser)"'
}
];
}
return null;
}
首先,我们获取到当前悬停位置的文档,并根据其内容生成ts源文件对象。利用这个源文件对象,我们可以进一步生成虚拟 HTML 文档的内容,这个虚拟 HTML 文档实际上就是就是@Component
中template
对应的值。
接着,我们利用 virtualHtmlDoc
解析出虚拟 HTML 文档的 AST,并定位到当前编辑位置的 HTML 节点。在此基础上,我们创建了 scanner
扫描器,用于在当前编辑节点中扫描属性名或属性值
根据扫描器的扫描结果,我们可以判断当前编辑的是属性名还是属性值。根据不同的上下文,我们返回不同的建议列表,为开发者提供智能的代码建议。
通过以上逻辑,我们根据编辑位置的属性名或属性值上下文,为开发者提供了智能的代码建议。这将大大提高开发效率,让开发者在使用自定义宏语法时能够得到即时的支持与指导。
自定义插件效果演示
悬停效果
自动完成效果
总结与展望
本文深入探讨了如何通过自定义 Vue 编译宏和配合自定义 VSCode 插件实现自定义悬停提示和代码自动完成的功能。我们首先介绍了 Vue 3 中的编译宏以及其在代码结构和开发体验中的积极影响。随后,我们重点聚焦于如何开发自定义 VSCode 插件,以便为开发者提供更加智能和高效的开发环境。
在服务端的实现中,我们详细介绍了 onHover
函数和 onCompletion
函数的内部逻辑,通过生成虚拟 HTML 文档和解析 HTML AST,为开发者提供准确的悬停提示和代码自动建议。借助请求转发,我们实现了服务端和客户端之间的有效通信,让插件的功能更加丰富多样。
通过本文的学习,读者不仅可以深刻理解如何利用自定义编译宏扩展 Vue 的功能,还可以掌握如何借助 VSCode 插件开发,为开发者构建更加智能和高效的开发环境。这对于开发者在实际项目中,尤其是在使用自定义宏语法时,提升开发效率和体验具有重要意义。
感兴趣的读者可以在本文的代码示例基础上,进一步探索和优化,实现更多个性化的功能。本文所涉及的示例代码和详细讲解,可在以下源码位置找到:
希望本文能够为您的开发之路提供一些新的思路和实践经验,使您能够在使用自定义编译宏以及自定义 VSCode 插件方面更加游刃有余。