一.需求シーン
VS Code は JS/TS の定義へのジャンプ、補完ヒントなどの機能を正しくサポートできますが、Node Module Resolution および TypeScript Module Resolution ルールに準拠したモジュール参照のみに限定されます。例えば:
// 標準 import
import {fn1, fn2} from './myModule.js';
// node modules 引用
var x = require("./moduleB");
// TypeScript
// 後綴名を省略可能
import {fn1, fn2} from './myModule';
// baseUrl をサポート
import { localize } from 'vs/nls';
他のカスタム import ルールの場合、これらの機能はすべて使用できなくなります(ジャンプ不可、ヒントなし、Lint 検証なし……)。例えば:
-
Webpack Resolve:
import Utility from 'Utilities/utility'; -
[React's Haste](/articles/react 背後のツール化体系/#articleHeader3):
var ReactCurrentOwner = require('ReactCurrentOwner');
import ルールが特殊なため、これらの重要な機能が使用できないことは、類似の構築ツール上に構築されたプロジェクトにとって痛点であり、プラグインを通じて解決することを期待します
二.実装思路
VS Code プラグインメカニズムに立脚し、特殊な import ルールをサポートしたい場合、2 つの方式があります:
前者は一般的な Definition 拡張方式で、具体的事例は Show Definitions of a Symbol を参照;後者は JS/TS のみに適用されますが、機能がより強力です(Definition に限定されない)
三.registerDefinitionProvider
registerDefinitionProvider を通じて独自の DefinitionProvider を実装するのは最も一般的な Go to Definition 拡張方式ですが、2 つの問題が存在します:
-
意味サポートの欠如:現在の Document およびジャンプ動作が発生した行列表位置のみを取得でき、コード意味に関連する情報は一切提供されません。例えば
import文なのか属性アクセスなのか、require関数呼び出しか文字列リテラルなのか、これらの重要な情報はすべて公開されていません -
Definition 衝突が発生しやすい:Go to Definition 動作が発生する時、すべてのプラグイン(VS Code 内蔵)が登録した関連 DefinitionProvider がすべてトリガーされて実行され、DefinitionProvider 間はお互いの存在を知らないため、自然に複数の Provider が同じまたは類似の Definition を提供する衝突状況が発生します
意味サポートの欠如
意味サポートの欠如は硬い欠陥です。例えば頻繁に入口でこのようなことを行う必要があります:
getToken() {
const position = Editor.getEditorCursorStart();
const token = this.scanReverse(position) + this.scanForward(position);
if (this.isEmpty(token)) {
return null;
}
return token;
}
(WesleyLuk90/node-import-resolver-code から引用)
より大きな問題は*構文木に触れられないため、多くのことが「できない」*ことです。例えば:
// 非 ES Module 標準の Webpack Resolve
import myModule from 'non-es-module-compatible-path-to/my-awesome-module';
// doSomething 定義にジャンプしようとする
myModule.doSomething();
依存ファイル中の定義にジャンプしたい場合、この 2 点を実現する必要があります:
-
myModuleが依存モジュールであることを「理解」し、myModuleが指すファイルを見つける -
そのファイル内容の意味を「理解」し、
doSomething定義が行列位置にあるか找出す
つまり、現在のファイルおよび依存ファイル内容に対して意味分析を行う必要があり、VS Code プラグインメカニズムはこの種の能力を開放していません
確かに、プラグイン自身が(Babel などのツールを通じて)意味分析を実装することでこの種のシーンに対応できますが、より多くの問題が発見されます:
-
myModule.を入力すると補完ヒントが欠如 -
myModule.doAnotherThing(を入力するとパラメータヒントが欠如 -
myModule.undefinedFunction()を入力すると Lint エラー報告が欠如 -
……
この一整套の元々存在した機能はすべて再び実装し直す必要があり、投入は無底洞のようになり、私たちはある誤区に陥ったようです:上層から下層の問題を修復しようとし、最後に地面全体を敷き詰めなければ解決できないことに気づきます(ほぼ下層全体を再実装する必要あり)
Definition 衝突
同じ/類似 Definition の問題は主にユーザープラグインと内蔵プラグイン機能の衝突に現れ、プラグイン API を通じて内蔵 Provider の Definition 結果を取得できないため、衝突は避けられません
実装から見ると、すべての DefinitionProvider が提供する Definition 結果は merge されます:
// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinition.ts
function getDefinitions<T>(): Promise<DefinitionLink[]> {
const provider = registry.ordered(model);
// get results
const promises = provider.map((provider): Promise<DefinitionLink | DefinitionLink[] | null | undefined> => {
return Promise.resolve(provide(provider, model, position)).then(undefined, err => {
onUnexpectedExternalError(err);
return null;
});
});
return Promise.all(promises)
.then(flatten)
.then(coalesce);
}
VS Code は重複定義の状況を考慮し、内部で重複除去を行いましたが、完全に同一の定義(つまり uri と range(startLine, startColumn, endLine, endColumn) がすべて完全に同一)のみを対象としています:
the design is to be cooperative and we only de-dupe items that are exactly the same.
対応ソースコード:
// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/goToDefinition/goToDefinitionCommands.ts
export class DefinitionAction extends EditorAction {
public run(): TPromise<void> {
const definitionPromise = this._getDeclarationsAtPosition().then(references => {
const result: DefinitionLink[] = [];
for (let i = 0; i < references.length; i++) {
let reference = references[i];
let { uri, range } = reference;
result.push({ uri, range });
}
if (result.length === 0) {
// 无 definition 結果、定義が見つからないと提示
if (this._configuration.showMessage) {
const info = model.getWordAtPosition(pos);
MessageController.get(editor).showMessage(this._getNoResultFoundMessage(info), pos);
}
} else if (result.length === 1 && idxOfCurrent !== -1) {
// 只有 1 条結果、直接ジャンプ
let [current] = result;
this._openReference(editor, editorService, current, false);
} else {
// 多条結果、重複除去して表示
this._onResult(editorService, editor, new ReferencesModel(result));
}
});
}
}
重複除去ロジック:
// ref: https://github.com/Microsoft/vscode/blob/master/src/vs/editor/contrib/referenceSearch/referencesModel.ts
export class ReferencesModel implements IDisposable {
constructor(references: Location[]) {
this._disposables = [];
// 按字典序对文件路径排序,同一文件内按 range 起始位置排序
references.sort(ReferencesModel._compareReferences);
let current: FileReferences;
for (let ref of references) {
// 按文件分组
if (!current || current.uri.toString() !== ref.uri.toString()) {
// new group
current = new FileReferences(this, ref.uri);
this.groups.push(current);
}
// 去重,滤掉完全相同的 range
if (current.children.length === 0
|| !Range.equalsRange(ref.range, current.children[current.children.length - 1].range)) {
let oneRef = new OneReference(current, ref.range);
this._disposables.push(oneRef.onRefChanged((e) => this._onDidChangeReferenceRange.fire(e)));
this.references.push(oneRef);
current.children.push(oneRef);
}
}
}
}
最後に、もう 1 つ重要な表示ロジックがあります:
private _openReference(): TPromise<ICodeEditor> {
return editorService.openCodeEditor({
resource: reference.uri,
options: {
// 选中 range 起始位置(光标移动到该位置)
selection: Range.collapseToStart(reference.range),
revealIfOpened: true,
revealInCenterIfOutsideViewport: true
}
}, editor, sideBySide);
}
これにより、非常に発生しやすい「重複」定義問題が引き起こされます。表示時、これら 3 つの range は完全に同じように見えます:
Range(0, 0, 28, 12)
Position(0, 0)
// Position(0, 0) 会被转换成
Range(0, 0, 0, 0)
起点が同一行の相近位置にある range も区別が困難です(例えば Range(0, 0, _, _) と Range(0, 1, _, _))、表示上はすべて重複定義のように見えます
類似の「重複」(本質的には DefinitionProvider 間の衝突)を解決するには、2 つの思路があります:
-
内蔵 Provider の動作を推測し、内蔵プラグインが処理できる case を積極的に避ける
-
VS Code が特性サポートを提供する、例えば
registerDefinitionProviderにオプションenabelWhenNoReferencesを提供(他の人が誰も結果を出さない時にのみ私のものを使用)
前者は比較的脆弱で、推測に頼ることはすべての状況をカバーするのが難しく、さらに内蔵プラグインが更新されると、これらの推測は適用されなくなる可能性があります。後者は現在(2018/12/16)サポートを提供しておらず、将来も提供されない可能性があります。詳細は Duplicated definition references found when Go to Definition を参照
四.TypescriptServerPlugins
TypeScript server plugins are loaded for all JavaScript and TypeScript files when the user is using VS Code's version of TypeScript.
簡潔に言えば、プラグイン内蔵の指定した TypeScript Language Service Plugin を通じて、VS Code の JS/TS 処理能力を拡張します
TypeScript Language Service Plugin
TypeScript Language Service Plugins ("plugins") are for changing the editing experience only.
編集体験のみを強化でき、TS コア動作(例えばタイプチェック動作を変更)や新特性の追加(例えば新構文を提供)はできません
具体的には、編集体験関連の事柄は以下を含みます:
-
Lint エラー報告を提供
-
補完ヒントリストを処理し、いくつかのものをフィルタリングする、例えば
window.eval -
Go to definition を異なる参照位置に指させる
-
文字列リテラル形式のカスタムテンプレート言語にエラー報告および補完ヒントを提供する、例えば Microsoft/typescript-lit-html-plugin
できないことは以下を含みます:
-
TypeScript に新しいカスタム構文を追加する
-
コンパイラが JavaScript に転译する動作を変更する
-
タイプシステムをカスタマイズし、
tscコマンドの検証動作を変更しようとする
したがって、編集体験を強化したいだけなら、TypeScript Language Service Plugin は非常に良い選択です
示例
VS Code のデフォルト動作は後綴名なしのものを優先して .ts にジャンプします(ソースファイルが JS か TS かに関わらず)。.js ファイル内のモジュール参照がすべて .js ファイルを指すようにしたい場合、簡単な Service Plugin を通じて実現できます:
function init(modules: { typescript: typeof ts_module }) {
const ts = modules.typescript;
function create(info: ts.server.PluginCreateInfo) {
const resolveModuleNames = info.languageServiceHost.resolveModuleNames;
// 篡改 resolveModuleNames,以扩展自定义行为
info.languageServiceHost.resolveModuleNames = function(moduleNames: string[], containingFile: string) {
const isJsFile = containingFile.endsWith('.js');
let resolvedNames = moduleNames;
if (isJsFile) {
const dir = path.dirname(containingFile);
resolvedNames = moduleNames.map(moduleName => {
// 仅针对无后缀名的相对路径引用
const needsToResolve = /^\./.test(moduleName) && !/\.\w+$/.test(moduleName);
if (needsToResolve) {
const targetFile = path.resolve(dir, moduleName + '.js');
if (ts.sys.fileExists(targetFile)) {
// 添上.js 后缀名,要求跳转到.js 文件
return moduleName + '.js';
}
}
return moduleName;
});
return resolveModuleNames.call(info.languageServiceHost, resolvedNames, containingFile);
}
return info.languageService;
}
}
return { create };
}
その中で、moduleNames は構文分析完了後に収集された import モジュール名です。つまり、TypeScript Language Service Plugin には意味サポートがあります
P.S. より多くの類似示例は以下を参照:
-
HelloWorld:補完ヒント中のいくつかの項目をフィルタリングする、例えば
caller
contributes.typescriptServerPlugins
Service Plugin が完成したら、次に VS Code プラグインを通じてそれを導入し、VS Code の編集体験を強化できるようにします
2 つのことを行うだけでよく、まず Service Plugin を npm 依存としてインストールします:
{
"dependencies": {
"my-typescript-server-plugin": "*"
}
}
次に contributes.typescriptServerPlugins 拡張点を通じて導入します:
"contributes": {
"typescriptServerPlugins": [
{
"name": "my-typescript-server-plugin"
}
]
}
最後に、VS Code 内蔵の typescript-language-features プラグインがすべてのプラグインの typescriptServerPlugins をすべて収集し、TypeScriptServiceClient に登録します:
// 收集
export function getContributedTypeScriptServerPlugins(): TypeScriptServerPlugin[] {
const plugins: TypeScriptServerPlugin[] = [];
for (const extension of vscode.extensions.all) {
const pack = extension.packageJSON;
if (pack.contributes && pack.contributes.typescriptServerPlugins && Array.isArray(pack.contributes.typescriptServerPlugins)) {
for (const plugin of pack.contributes.typescriptServerPlugins) {
plugins.push({
name: plugin.name,
path: extension.extensionPath,
languages: Array.isArray(plugin.languages) ? plugin.languages : [],
});
}
}
}
return plugins;
}
// 注册
this.client = this._register(new TypeScriptServiceClient(
workspaceState,
version => this.versionStatus.onDidChangeTypeScriptVersion(version),
plugins,
logDirectoryProvider,
allModeIds)
);
したがって、contributes.typescriptServerPlugins 拡張点は TypeScript Language Service Plugin と VS Code 内の TypeScriptServiceClient を接続する橋です
五.まとめ
JS/TS に対して、VS Code はさらに強力な拡張方式を提供しており、TypescriptServerPlugin と呼ばれます
一般的な registerDefinitionProvider と比較して、TypescriptServerPlugin は構文木に触れることができ、これは極めて大きな優位性であり、定義へのジャンプ、Lint 検査、補完ヒントなどの意味関連のシーンで特に適用されます
もちろん、TypescriptServerPlugin も完璧ではなく、制限は以下の通りです:
-
JS/TS の拡張のみに使用され、JSX/TSX などをサポートしますが、他の言語はサポートしません
-
編集体験の拡張のみをサポートし、言語特性を変更できません
-
意味サポートは依然として TypeScript Language Service API に制限され、Hook を残していない場所には涉足できません
コメントはまだありません