Skip to content

Language Tools

什麼是 Language Tools?

Language Tools 為 .vue 單檔案元件(SFCs)提供 IDE 支援。它們啟用以下功能:

  • 語法高亮
  • 自動補全
  • 型別檢查
  • 轉到定義
  • 錯誤診斷

在 Vue.js 生態系統中,vuejs/language-tools 提供此功能,它基於 Volar.js 建置。在本章中,我們將使用 Volar.js 為 chibivue 實作最小化的語言工具。

為什麼需要 Language Tools?

TypeScript 的語言服務只能理解 .ts.tsx 檔案。然而 .vue 檔案包含多種語言混合在一起:

vue
<template>
  <div>{{ message }}</div>  <!-- HTML + 運算式 -->
</template>

<script setup lang="ts">
const message = ref('Hello')  // TypeScript
</script>

<style scoped>
div { color: red; }  /* CSS */
</style>

Language Tools 的作用是將這種複合檔案轉換為 TypeScript 語言服務可以理解的格式。透過這種轉換,在 .vue 檔案中也可以使用 TypeScript 的所有功能(型別檢查,自動補全,重構等)。

架構概述

語言工具由三個主要套件組成:

txt
@extensions/
├── chibivue-language-core/     # 核心語言處理
│   ├── parseSfc.ts             # SFC 解析器
│   ├── virtualCode.ts          # 虛擬程式碼產生
│   ├── languagePlugin.ts       # Volar.js 外掛
│   └── types.ts                # 型別定義
├── chibivue-language-server/   # LSP 伺服器
│   └── server.ts               # 語言伺服器協議伺服器
└── vscode-chibivue/            # VSCode 擴充功能
    ├── extension.ts            # 擴充功能入口點
    ├── syntaxes/               # TextMate 語法
    └── language-configuration.json

資料流

當你在編輯器中編輯 .vue 檔案時,資料按以下流程處理:

txt
┌─────────────────────────────────────────────────────────────────────────────┐
│                              VSCode                                         │
│  ┌─────────────┐                                                            │
│  │  App.vue    │  使用者編輯 .vue 檔案                                        │
│  └──────┬──────┘                                                            │
│         │                                                                   │
│         ▼                                                                   │
│  ┌─────────────────────┐                                                    │
│  │  vscode-chibivue    │  VSCode 擴充功能檢測檔案變化                       │
│  │  (Language Client)  │                                                    │
│  └──────────┬──────────┘                                                    │
└─────────────┼───────────────────────────────────────────────────────────────┘
              │ LSP (Language Server Protocol)

┌─────────────────────────────────────────────────────────────────────────────┐
│  chibivue-language-server                                                   │
│  ┌─────────────────────┐                                                    │
│  │  Language Server    │  接收 LSP 請求                                     │
│  └──────────┬──────────┘                                                    │
│             │                                                               │
│             ▼                                                               │
│  ┌─────────────────────┐    ┌─────────────────────┐                         │
│  │  chibivue-language  │───▶│  Virtual Code       │                         │
│  │  -core (Plugin)     │    │  (.vue → .ts 轉換)  │                         │
│  └─────────────────────┘    └──────────┬──────────┘                         │
│                                        │                                    │
│                                        ▼                                    │
│                             ┌─────────────────────┐                         │
│                             │  TypeScript         │                         │
│                             │  Language Service   │  型別檢查、補全等       │
│                             └──────────┬──────────┘                         │
│                                        │                                    │
│                                        ▼                                    │
│                             ┌─────────────────────┐                         │
│                             │  Code Mappings      │  將結果映射回原位置     │
│                             └─────────────────────┘                         │
└─────────────────────────────────────────────────────────────────────────────┘

核心概念

虛擬程式碼 (Virtual Code)

Language Tools 的核心概念是虛擬程式碼。透過將 .vue 檔案轉換為 TypeScript,可以利用 TypeScript 語言服務的所有功能。

轉換示例

vue
<!-- 原始 .vue 檔案 -->
<template>
  <div>{{ message }}</div>
</template>

<script setup lang="ts">
import { ref } from 'chibivue'

const message = ref('Hello')
</script>

這會被轉換為以下虛擬 TypeScript:

ts
// 虛擬 TypeScript 程式碼
import { ref } from 'chibivue'

const message = ref('Hello')

// 用於型別檢查模板運算式的程式碼
// 實際上不會執行,但允許 TypeScript 驗證運算式型別
declare const __VLS_template: () => void;
(() => {
  // 對應模板中的 {{ message }}
  // TypeScript 驗證 message 是否存在且型別正確
  const __VLS_expr0 = (message);
})();

透過這種轉換:

  • 可以驗證 message 的型別是 Ref<string>
  • 如果 message 未定義,會報告錯誤
  • 懸停在 message 上時會顯示型別資訊

程式碼映射

程式碼映射將虛擬程式碼中的位置連結回原始 .vue 檔案的位置。

txt
原始 .vue 檔案                        虛擬 TypeScript
─────────────────────────────────────────────────────────────
<script setup lang="ts">
import { ref } from 'chibivue'  ←──→  import { ref } from 'chibivue'

const message = ref('Hello')    ←──→  const message = ref('Hello')
</script>                             ↑

<template>                            │
  <div>{{ message }}</div>      ←─────┼──→  const __VLS_expr0 = (message);
</template>                           │

                                映射將位置連結在一起

有了映射:

  • 虛擬程式碼中的錯誤 → 在原始 .vue 檔案的正確位置顯示
  • 執行「轉到定義」→ 將虛擬程式碼的位置轉換為原始檔案的位置

實作

型別定義

首先,定義表示 SFC 結構的型別。

ts
// types.ts

/**
 * 表示 SFC 中的每個區塊(template、script、style)
 */
export interface SfcBlock {
  /** 區塊型別("template"、"script"、"style" 等) */
  type: string;

  /** 區塊內容(不包括標籤的內部內容) */
  content: string;

  /** 位置資訊(用於錯誤顯示和映射) */
  loc: {
    start: { line: number; column: number; offset: number };
    end: { line: number; column: number; offset: number };
  };

  /** 區塊屬性(例如:lang="ts"、scoped) */
  attrs: Record<string, string | true>;

  /** 語言指定(attrs.lang 的快捷方式) */
  lang?: string;
}

/**
 * 表示解析後的整個 SFC
 */
export interface SfcDescriptor {
  /** <template> 區塊 */
  template: SfcBlock | null;

  /** <script>(不帶 setup)區塊 */
  script: SfcBlock | null;

  /** <script setup> 區塊 */
  scriptSetup: SfcBlock | null;

  /** <style> 區塊(可以有多個) */
  styles: SfcBlock[];

  /** 自訂區塊(例如:<docs>) */
  customBlocks: SfcBlock[];
}

SFC 解析器

解析 .vue 檔案以產生 SfcDescriptor

TIP

在實際實作中,可以使用 chibivue 的 @chibivue/compiler-sfc 套件中的 parse 函式。這裡為了教育目的展示一個簡化的解析器。

ts
// parseSfc.ts
import type { SfcBlock, SfcDescriptor } from './types';

/**
 * 解析 .vue 檔案內容並回傳 SfcDescriptor
 *
 * @param content - .vue 檔案的內容
 * @param fileName - 檔案名(用於錯誤訊息)
 */
export function parseSfc(content: string, fileName: string): SfcDescriptor {
  const descriptor: SfcDescriptor = {
    template: null,
    script: null,
    scriptSetup: null,
    styles: [],
    customBlocks: [],
  };

  // 匹配頂級區塊的正規運算式
  // 檢測 <tagName attrs>content</tagName> 格式
  const blockRegex = /<(\w+)([^>]*)>([\s\S]*?)<\/\1>/g;
  let match: RegExpExecArray | null;

  while ((match = blockRegex.exec(content)) !== null) {
    const [fullMatch, tagName, attrsString, blockContent] = match;

    // 計算區塊的起始位置
    const startOffset = match.index + `<${tagName}${attrsString}>`.length;
    const startPos = offsetToPosition(content, startOffset);

    // 計算區塊的結束位置
    const endOffset = startOffset + blockContent.length;
    const endPos = offsetToPosition(content, endOffset);

    // 解析屬性(例如:'lang="ts" scoped' → { lang: "ts", scoped: true })
    const attrs = parseAttrs(attrsString);

    const block: SfcBlock = {
      type: tagName,
      content: blockContent,
      loc: {
        start: { ...startPos, offset: startOffset },
        end: { ...endPos, offset: endOffset },
      },
      attrs,
      lang: typeof attrs.lang === 'string' ? attrs.lang : undefined,
    };

    // 按區塊型別分類
    switch (tagName) {
      case 'template':
        descriptor.template = block;
        break;
      case 'script':
        // 根據是否有 setup 屬性分類
        if ('setup' in attrs) {
          descriptor.scriptSetup = block;
        } else {
          descriptor.script = block;
        }
        break;
      case 'style':
        descriptor.styles.push(block);
        break;
      default:
        descriptor.customBlocks.push(block);
    }
  }

  return descriptor;
}

/**
 * 從偏移量(字元位置)計算行號和列號
 */
function offsetToPosition(
  content: string,
  offset: number
): { line: number; column: number } {
  const lines = content.slice(0, offset).split('\n');
  return {
    line: lines.length,
    column: lines[lines.length - 1].length + 1,
  };
}

/**
 * 解析屬性字串為物件
 * 示例:' lang="ts" scoped' → { lang: "ts", scoped: true }
 */
function parseAttrs(attrsString: string): Record<string, string | true> {
  const attrs: Record<string, string | true> = {};
  const attrRegex = /(\w+)(?:="([^"]*)"|='([^']*)')?/g;
  let attrMatch: RegExpExecArray | null;

  while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
    const [, name, doubleQuoted, singleQuoted] = attrMatch;
    attrs[name] = doubleQuoted ?? singleQuoted ?? true;
  }

  return attrs;
}

虛擬程式碼產生

實作 Volar.js 的 VirtualCode 介面。這是 Language Tools 的核心。

ts
// virtualCode.ts
import type {
  CodeMapping,
  VirtualCode,
} from '@volar/language-core';
import type * as ts from 'typescript';
import { parseSfc } from './parseSfc';
import type { SfcDescriptor } from './types';

/**
 * 程式碼段:產生程式碼的一部分及其映射資訊
 */
type CodeSegment = [
  code: string,                           // 要產生的程式碼
  sourceOffsetStart?: number,             // 源檔案中的起始位置
  sourceOffsetEnd?: number,               // 源檔案中的結束位置
  features?: { verification?: boolean },  // 映射功能設定
];

/**
 * 將 .vue 檔案轉換為虛擬 TypeScript 程式碼的類
 */
export class ChibivueVirtualCode implements VirtualCode {
  id = 'root';
  languageId = 'vue';
  snapshot: ts.IScriptSnapshot;
  mappings: CodeMapping[] = [];
  embeddedCodes: VirtualCode[] = [];

  private fileName: string;
  private sfc: SfcDescriptor;

  constructor(fileName: string, snapshot: ts.IScriptSnapshot) {
    this.fileName = fileName;
    this.snapshot = snapshot;
    const content = snapshot.getText(0, snapshot.getLength());
    this.sfc = parseSfc(content, fileName);
    this.generateVirtualCode(content);
  }

  /**
   * 檔案更新時呼叫
   */
  update(snapshot: ts.IScriptSnapshot): void {
    this.snapshot = snapshot;
    const content = snapshot.getText(0, snapshot.getLength());
    this.sfc = parseSfc(content, this.fileName);
    this.generateVirtualCode(content);
  }

  /**
   * 產生虛擬程式碼的主處理
   */
  private generateVirtualCode(sourceContent: string): void {
    const segments: CodeSegment[] = [];

    // 1. 從 script/scriptSetup 產生程式碼
    this.generateScriptCode(segments);

    // 2. 產生模板型別檢查程式碼
    this.generateTemplateCode(segments);

    // 3. 從段建置最終程式碼和映射
    const { code, mappings } = this.buildCode(segments, sourceContent);

    // 4. 註冊為嵌入程式碼(TypeScript)
    this.embeddedCodes = [
      {
        id: 'ts',
        languageId: 'typescript',
        snapshot: createScriptSnapshot(code),
        mappings,
        embeddedCodes: [],
      },
    ];
  }

  /**
   * 從 script/scriptSetup 區塊產生 TypeScript 程式碼
   */
  private generateScriptCode(segments: CodeSegment[]): void {
    const { script, scriptSetup } = this.sfc;

    if (scriptSetup) {
      // 原樣輸出 <script setup> 內容
      // 加入映射資訊(連結到源檔案位置)
      segments.push([
        scriptSetup.content,
        scriptSetup.loc.start.offset,
        scriptSetup.loc.end.offset,
        { verification: true },
      ]);
      segments.push(['\n']);
    } else if (script) {
      // 原樣輸出 <script> 內容
      segments.push([
        script.content,
        script.loc.start.offset,
        script.loc.end.offset,
        { verification: true },
      ]);
      segments.push(['\n']);
    }
  }

  /**
   * 產生用於型別檢查模板運算式的程式碼
   */
  private generateTemplateCode(segments: CodeSegment[]): void {
    const { template } = this.sfc;
    if (!template) return;

    // 加入模板型別檢查程式碼
    segments.push(['\n// Template type-checking\n']);
    segments.push(['declare const __VLS_template: () => void;\n']);

    // 檢測 mustache 運算式 {{ expr }}
    const mustacheRegex = /\{\{\s*([\s\S]*?)\s*\}\}/g;
    let match: RegExpExecArray | null;
    let exprIndex = 0;

    while ((match = mustacheRegex.exec(template.content)) !== null) {
      const expr = match[1];
      // 計算運算式在源檔案中的位置
      const exprStartInTemplate = match.index + match[0].indexOf(expr);
      const sourceStart = template.loc.start.offset + exprStartInTemplate;
      const sourceEnd = sourceStart + expr.length;

      // 產生驗證運算式的程式碼
      // (() => { const __VLS_expr0 = (message); })();
      segments.push([`(() => {\n  const __VLS_expr${exprIndex} = (`]);
      segments.push([
        expr,
        sourceStart,
        sourceEnd,
        { verification: true },
      ]);
      segments.push([');\n})();\n']);

      exprIndex++;
    }
  }

  /**
   * 從段建置最終程式碼和映射
   */
  private buildCode(
    segments: CodeSegment[],
    sourceContent: string
  ): { code: string; mappings: CodeMapping[] } {
    let code = '';
    const mappings: CodeMapping[] = [];

    for (const segment of segments) {
      const [text, sourceStart, sourceEnd, features] = segment;

      if (sourceStart !== undefined && sourceEnd !== undefined) {
        // 存在映射資訊時記錄
        mappings.push({
          sourceOffsets: [sourceStart],
          generatedOffsets: [code.length],
          lengths: [sourceEnd - sourceStart],
          data: {
            verification: features?.verification ?? false,
            completion: true,
            semantic: true,
            navigation: true,
            structure: true,
            format: false,
          },
        });
      }

      code += text;
    }

    return { code, mappings };
  }
}

/**
 * 建立 TypeScript 腳本快照
 */
function createScriptSnapshot(content: string): ts.IScriptSnapshot {
  return {
    getText: (start, end) => content.slice(start, end),
    getLength: () => content.length,
    getChangeRange: () => undefined,
  };
}

語言外掛

實作告訴 Volar.js 如何處理 .vue 檔案的外掛。

ts
// languagePlugin.ts
import type { LanguagePlugin } from '@volar/language-core';
import { ChibivueVirtualCode } from './virtualCode';

/**
 * 為 Volar.js 建立語言外掛
 *
 * 此外掛負責:
 * 1. 識別 .vue 檔案
 * 2. 從 .vue 檔案產生虛擬程式碼
 * 3. 向 TypeScript 語言服務提供虛擬程式碼
 */
export function createChibivueLanguagePlugin(): LanguagePlugin<
  string,
  ChibivueVirtualCode
> {
  return {
    /**
     * 從檔案擴充名判斷語言 ID
     * 對於 .vue 檔案回傳 "vue"
     */
    getLanguageId(scriptId: string): string | undefined {
      if (scriptId.endsWith('.vue')) {
        return 'vue';
      }
      return undefined;
    },

    /**
     * 建立新的虛擬程式碼
     * 首次打開檔案時呼叫
     */
    createVirtualCode(scriptId, languageId, snapshot) {
      if (languageId === 'vue') {
        return new ChibivueVirtualCode(scriptId, snapshot);
      }
      return undefined;
    },

    /**
     * 更新現有的虛擬程式碼
     * 編輯檔案時呼叫
     */
    updateVirtualCode(_scriptId, virtualCode, snapshot) {
      virtualCode.update(snapshot);
      return virtualCode;
    },

    /**
     * TypeScript 特定設定
     */
    typescript: {
      /**
       * 使 TypeScript 識別 .vue 檔案的設定
       *
       * - extension: 目標檔案擴充名
       * - isMixedContent: 表示包含多種語言
       * - scriptKind: TypeScript 的 ScriptKind
       *   - 7 = Deferred(延遲評估,使用虛擬程式碼)
       */
      extraFileExtensions: [
        { extension: 'vue', isMixedContent: true, scriptKind: 7 },
      ],

      /**
       * 從虛擬程式碼取得要傳遞給 TypeScript 的腳本
       *
       * @returns
       *   - code: 嵌入的 TypeScript 程式碼
       *   - extension: ".ts"(作為 TypeScript 處理)
       *   - scriptKind: 3 = TS(普通 TypeScript)
       */
      getServiceScript(rootVirtualCode) {
        for (const code of rootVirtualCode.embeddedCodes) {
          if (code.id === 'ts') {
            return {
              code,
              extension: '.ts',
              scriptKind: 3, // ts.ScriptKind.TS
            };
          }
        }
        return undefined;
      },
    },
  };
}

語言伺服器

LSP(語言伺服器協議)伺服器連接編輯器和語言功能。

ts
// server.ts
import {
  createConnection,
  createServer,
  createSimpleProjectProviderFactory,
  loadTsdkByPath,
} from '@volar/language-server/node';
import { create as createTypeScriptServices } from 'volar-service-typescript';
import { createChibivueLanguagePlugin } from '@chibivue/language-core';

/**
 * 關於 LSP(語言伺服器協議)
 *
 * LSP 是將編輯器與語言功能分離的協議。
 *
 * ┌──────────┐                        ┌──────────────────┐
 * │  VSCode  │ ◄───── LSP 通訊 ─────► │  Language Server │
 * │  Neovim  │    (JSON-RPC over      │  (此檔案)      │
 * │  Emacs   │     stdio/IPC)         │                  │
 * └──────────┘                        └──────────────────┘
 *
 * 主要 LSP 請求:
 * - textDocument/completion: 取得自動補全候選
 * - textDocument/hover: 取得懸停資訊
 * - textDocument/definition: 轉到定義
 * - textDocument/references: 尋找引用
 * - textDocument/rename: 重新命名符號
 * - textDocument/diagnostics: 錯誤診斷
 */

// 建立 LSP 連接(透過 stdin/stdout 或 IPC 通訊)
const connection = createConnection();

// 建立 Volar 語言伺服器
const server = createServer(connection);

// 開始監聽連接
connection.listen();

/**
 * 初始化請求的處理程式
 * 使用者端(編輯器)連接時呼叫
 */
connection.onInitialize((params) => {
  // 取得 TypeScript SDK 路徑(從使用者端傳遞)
  const tsdk = params.initializationOptions?.typescript?.tsdk;

  // 載入 TypeScript 模組
  const ts = tsdk
    ? loadTsdkByPath(tsdk, params.locale)
    : require('typescript');

  // 建立 chibivue 語言外掛
  const chibivuePlugin = createChibivueLanguagePlugin();

  // 初始化伺服器並註冊功能
  return server.initialize(
    params,
    // 專案管理設定(tsconfig.json 檢測等)
    createSimpleProjectProviderFactory(),
    {
      /**
       * 回傳語言外掛
       * 負責從 .vue 檔案產生虛擬程式碼
       */
      getLanguagePlugins() {
        return [chibivuePlugin];
      },

      /**
       * 回傳服務外掛
       * 提供 TypeScript 語言功能(補全、診斷等)
       */
      getServicePlugins() {
        return [...createTypeScriptServices(ts)];
      },
    }
  );
});

/**
 * 初始化完成的處理程式
 */
connection.onInitialized(() => {
  // 根據需要進行額外設定
});

VSCode 擴充功能

實作將 VSCode 連接到語言伺服器的擴充功能。

ts
// extension.ts
import * as path from 'path';
import * as vscode from 'vscode';
import {
  LanguageClient,
  LanguageClientOptions,
  ServerOptions,
  TransportKind,
} from 'vscode-languageclient/node';

let client: LanguageClient | undefined;

/**
 * 擴充功能啟動
 * 打開 .vue 檔案時自動呼叫
 */
export async function activate(context: vscode.ExtensionContext) {
  // 解析語言伺服器路徑
  const serverPath = context.asAbsolutePath(
    path.join('dist', 'server.js')
  );

  // 伺服器啟動選項
  const serverOptions: ServerOptions = {
    run: {
      module: serverPath,
      transport: TransportKind.ipc, // 透過 IPC 通訊
    },
    debug: {
      module: serverPath,
      transport: TransportKind.ipc,
      options: { execArgv: ['--nolazy', '--inspect=6009'] },
    },
  };

  // 使用者端選項
  const clientOptions: LanguageClientOptions = {
    // 處理哪些檔案
    documentSelector: [{ scheme: 'file', language: 'vue' }],

    // 初始化時傳遞給伺服器的選項
    initializationOptions: {
      typescript: {
        // 使用 VSCode 內建的 TypeScript SDK
        tsdk: path.join(
          vscode.env.appRoot,
          'extensions/node_modules/typescript/lib'
        ),
      },
    },
  };

  // 建立 Language Client
  client = new LanguageClient(
    'chibivue',                    // 使用者端 ID
    'Chibivue Language Server',   // 顯示名稱
    serverOptions,
    clientOptions
  );

  // 啟動語言伺服器
  await client.start();

  // 擴充功能停用時清理
  context.subscriptions.push({
    dispose: () => client?.stop(),
  });
}

/**
 * 擴充功能停用
 */
export function deactivate(): Thenable<void> | undefined {
  return client?.stop();
}

語法高亮(TextMate 語法)

語法高亮使用 TextMate 語法定義。這使用 VSCode 的內建功能,不涉及語言伺服器。

json
// syntaxes/vue.tmLanguage.json
{
  "name": "Vue",
  "scopeName": "source.vue",
  "patterns": [
    { "include": "#template" },
    { "include": "#script" },
    { "include": "#style" }
  ],
  "repository": {
    "template": {
      "begin": "(<)(template)",
      "end": "(</)(template)(>)",
      "patterns": [{ "include": "text.html.basic" }]
    },
    "script": {
      "begin": "(<)(script)",
      "end": "(</)(script)(>)",
      "patterns": [{ "include": "source.ts" }]
    },
    "style": {
      "begin": "(<)(style)",
      "end": "(</)(style)(>)",
      "patterns": [{ "include": "source.css" }]
    }
  }
}

支援的功能

功能狀態描述
語法高亮已支援透過 TextMate 語法進行顏色編碼
自動補全已支援變數,函式,屬性補全
型別檢查已支援透過 TypeScript 檢測型別錯誤
轉到定義已支援跳轉到變數/函式定義
錯誤診斷已支援顯示語法和型別錯誤
重新命名符號已支援批量重新命名變數等
懸停資訊已支援顯示游標位置的型別資訊

總結

Language Tools 透過將 .vue 檔案轉換為虛擬 TypeScript 程式碼,使 SFC 中可以使用 TypeScript 的所有功能。

主要元件:

  1. SFC 解析器 - 將 .vue 檔案分解為 template,script 和 style 區塊
  2. 虛擬程式碼產生 - 將 SFC 轉換為帶有程式碼映射的 TypeScript
  3. 語言外掛 - 實作 Volar.js 介面以提供虛擬程式碼
  4. 語言伺服器 - 透過 LSP 與編輯器通訊
  5. VSCode 擴充功能 - 將 VSCode 連接到語言伺服器

此實作是用於教育目的的最小實作。生產環境使用的 vuejs/language-tools 加入了許多高階功能:

  • 模板指令(v-ifv-for 等)的型別檢查
  • 元件 props 型別驗證
  • <style scoped> 選擇器補全
  • <template> 中的 HTML 補全
  • 巨集支援(definePropsdefineEmits

基於 MIT 許可證發布。